1(function () { 2 "use strict"; 3 4 const VERSION = "pipeline-v0.2"; 5 const DEFAULT_OPTIONS = { 6 activeMode: "instant", 7 preserveExistingWind: false, 8 log: true, 9 windowed: { 10 hoverWindowSec: 8, 11 straightFlightWindowSec: 4, 12 minSamples: 3, 13 minConfidence: 0.32, 14 maxAccelerationKmhPerSec: 4.5, 15 maxHoverTurnRateDegSec: 8, 16 maxStraightTurnRateDegSec: 10 17 } 18 }; 19 const CARDINALS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; 20 21 function toNumber(value) { 22 if (value === null || value === undefined || value === "") return null; 23 const number = Number(value); 24 return Number.isFinite(number) ? number : null; 25 } 26 27 function clamp(value, min = 0, max = 1) { 28 return Math.min(max, Math.max(min, value)); 29 } 30 31 function toRadians(value) { 32 return value * Math.PI / 180; 33 } 34 35 function toDegrees(value) { 36 return value * 180 / Math.PI; 37 } 38 39 function normalizeDirection(value) { 40 const number = toNumber(value); 41 if (number === null) return null; 42 return ((number % 360) + 360) % 360; 43 } 44 45 function getCardinal(value) { 46 const direction = normalizeDirection(value); 47 if (direction === null) return null; 48 return CARDINALS[Math.round(direction / 22.5) % 16]; 49 } 50 51 function getDirectionPairFromTo({ directionFromDeg = null, directionToDeg = null } = {}) { 52 const to = normalizeDirection(directionToDeg); 53 const from = normalizeDirection(directionFromDeg) ?? (to === null ? null : normalizeDirection(to + 180)); 54 const finalTo = to ?? (from === null ? null : normalizeDirection(from + 180)); 55 return { 56 cardinalFrom: getCardinal(from), 57 cardinalTo: getCardinal(finalTo), 58 directionFromDeg: from, 59 directionToDeg: finalTo 60 }; 61 } 62 63 function angleDeltaDegrees(left, right) { 64 const start = normalizeDirection(left); 65 const end = normalizeDirection(right); 66 if (start === null || end === null) return null; 67 return ((((end - start) + 540) % 360) - 180); 68 } 69 70 function getDistanceMeters(left = {}, right = {}) { 71 const leftLat = toNumber(left.lat); 72 const leftLon = toNumber(left.lon); 73 const rightLat = toNumber(right.lat); 74 const rightLon = toNumber(right.lon); 75 if ([leftLat, leftLon, rightLat, rightLon].some(value => value === null)) return null; 76 77 const earthRadiusMeters = 6371000; 78 const deltaLat = toRadians(rightLat - leftLat); 79 const deltaLon = toRadians(rightLon - leftLon); 80 const startLat = toRadians(leftLat); 81 const endLat = toRadians(rightLat); 82 const a = Math.sin(deltaLat / 2) ** 2 + 83 Math.cos(startLat) * Math.cos(endLat) * (Math.sin(deltaLon / 2) ** 2); 84 return earthRadiusMeters * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); 85 } 86 87 function getBearing(previous = {}, current = {}) { 88 const lat1 = toRadians(Number(previous.lat)); 89 const lat2 = toRadians(Number(current.lat)); 90 const deltaLon = toRadians(Number(current.lon) - Number(previous.lon)); 91 if (![lat1, lat2, deltaLon].every(Number.isFinite)) return null; 92 93 const y = Math.sin(deltaLon) * Math.cos(lat2); 94 const x = (Math.cos(lat1) * Math.sin(lat2)) - 95 (Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon)); 96 return normalizeDirection(toDegrees(Math.atan2(y, x))); 97 } 98 99 function average(values = []) { 100 const numbers = values.map(toNumber).filter(value => value !== null); 101 if (!numbers.length) return 0; 102 return numbers.reduce((sum, value) => sum + value, 0) / numbers.length; 103 } 104 105 function formatCount(value) { 106 return String(Math.round(Number(value) || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, " "); 107 } 108 109 function roundNumber(value, digits = 2) { 110 const number = toNumber(value) ?? 0; 111 return Number(number.toFixed(digits)); 112 } 113 114 function formatDecimal(value, digits = 2) { 115 return (Number(value) || 0).toFixed(digits); 116 } 117 118 function getGpsQuality(gpsSatellites) { 119 const gps = toNumber(gpsSatellites); 120 if (gps === null) return 0.55; 121 if (gps < 6) return 0.25; 122 if (gps < 8) return 0.45; 123 if (gps < 12) return 0.72; 124 return 1; 125 } 126 127 function getDataCompleteness(frame = {}) { 128 const required = [ 129 frame.altitude, 130 frame.headingDeg, 131 frame.groundSpeedKmh, 132 frame.rollDeg, 133 frame.pitchDeg, 134 frame.yawDeg, 135 frame.stickLeftX, 136 frame.stickLeftY, 137 frame.stickRightX, 138 frame.stickRightY 139 ]; 140 const present = required.filter(value => toNumber(value) !== null).length; 141 return present / required.length; 142 } 143 144 function getSegmentBaseConfidence(segmentType) { 145 if (segmentType === "hover") return 0.82; 146 if (segmentType === "straightFlight") return 0.64; 147 if (segmentType === "turn") return 0.36; 148 if (segmentType === "takeoff") return 0.30; 149 if (segmentType === "landing") return 0.28; 150 return 0.34; 151 } 152 153 function calculateConfidence(frame = {}, segmentType) { 154 const gpsFactor = getGpsQuality(frame.gpsSatellites); 155 const completeness = getDataCompleteness(frame); 156 const completenessFactor = 0.28 + (completeness * 0.72); 157 const turnRate = Math.abs(toNumber(frame.turnRateDegSec) || 0); 158 const curveFactor = clamp(1 - (Math.max(0, turnRate - 8) / 70), 0.28, 1); 159 const stableHoverBonus = segmentType === "hover" && turnRate < 6 ? 0.08 : 0; 160 const confidence = (getSegmentBaseConfidence(segmentType) * gpsFactor * completenessFactor * curveFactor) + stableHoverBonus; 161 return clamp(confidence, 0.05, 0.94); 162 } 163 164 function calculateTrim(samples = [], field) { 165 const candidates = samples 166 .filter(sample => { 167 const speed = toNumber(sample.speed); 168 const value = toNumber(sample[field]); 169 return value !== null && (speed === null || speed < 3); 170 }) 171 .map(sample => sample[field]); 172 return average(candidates.length ? candidates : samples.map(sample => sample[field])); 173 } 174 175 function createFlightContext(samples = []) { 176 const first = samples[0] || {}; 177 const last = samples[samples.length - 1] || {}; 178 const durationSec = Math.max(0, (toNumber(last.elapsedSec) || 0) - (toNumber(first.elapsedSec) || 0)); 179 return { 180 durationSec, 181 firstElapsedSec: toNumber(first.elapsedSec) || 0, 182 lastElapsedSec: toNumber(last.elapsedSec) || 0, 183 startAltitude: toNumber(first.altitude) || 0, 184 rollTrimDeg: calculateTrim(samples, "roll"), 185 pitchTrimDeg: calculateTrim(samples, "pitch") 186 }; 187 } 188 189 function createFeatureFrame(samples = [], index = 0, context = {}) { 190 const sample = samples[index] || {}; 191 const previous = samples[Math.max(0, index - 1)] || sample; 192 const next = samples[Math.min(samples.length - 1, index + 1)] || sample; 193 const elapsedSec = toNumber(sample.elapsedSec) || 0; 194 const previousElapsedSec = toNumber(previous.elapsedSec); 195 const nextElapsedSec = toNumber(next.elapsedSec); 196 const timeDeltaSec = previousElapsedSec === null ? 0 : Math.max(0, elapsedSec - previousElapsedSec); 197 const nextTimeDeltaSec = nextElapsedSec === null ? 0 : Math.max(0, nextElapsedSec - elapsedSec); 198 const distanceMeters = getDistanceMeters(previous, sample) || 0; 199 const nextDistanceMeters = getDistanceMeters(sample, next) || 0; 200 const derivedGroundSpeedKmh = timeDeltaSec > 0 ? (distanceMeters / timeDeltaSec) * 3.6 : null; 201 const nextGroundSpeedKmh = nextTimeDeltaSec > 0 ? (nextDistanceMeters / nextTimeDeltaSec) * 3.6 : null;
202 const rawGroundSpeedKmh = toNumber(sample.speed); 203 const groundSpeedKmh = rawGroundSpeedKmh ?? derivedGroundSpeedKmh ?? nextGroundSpeedKmh ?? 0; 204 const previousGroundSpeedKmh = toNumber(previous.speed) ?? derivedGroundSpeedKmh ?? groundSpeedKmh; 205 const accelerationKmhPerSec = timeDeltaSec > 0 ? (groundSpeedKmh - previousGroundSpeedKmh) / timeDeltaSec : 0; 206 const altitude = toNumber(sample.altitude); 207 const previousAltitude = toNumber(previous.altitude); 208 const altitudeDeltaM = altitude !== null && previousAltitude !== null ? altitude - previousAltitude : 0; 209 const verticalSpeedMps = timeDeltaSec > 0 ? altitudeDeltaM / timeDeltaSec : 0; 210 const bearingDeg = getBearing(previous, sample) ?? getBearing(sample, next); 211 const yawDeg = normalizeDirection(sample.yaw); 212 const previousYawDeg = normalizeDirection(previous.yaw); 213 const headingDeg = yawDeg ?? bearingDeg ?? 0; 214 const yawDeltaDeg = angleDeltaDegrees(previousYawDeg, yawDeg) ?? 0; 215 const bearingDeltaDeg = angleDeltaDegrees(getBearing(samples[Math.max(0, index - 2)] || previous, previous), bearingDeg) ?? yawDeltaDeg; 216 const yawRateDegSec = timeDeltaSec > 0 ? yawDeltaDeg / timeDeltaSec : 0; 217 const turnRateDegSec = timeDeltaSec > 0 ? bearingDeltaDeg / timeDeltaSec : yawRateDegSec; 218 const rollDeg = toNumber(sample.roll); 219 const pitchDeg = toNumber(sample.pitch); 220 const rollCompensatedDeg = (rollDeg ?? 0) - context.rollTrimDeg; 221 const pitchCompensatedDeg = (pitchDeg ?? 0) - context.pitchTrimDeg; 222 const stickValues = [ 223 toNumber(sample.stickLeftX), 224 toNumber(sample.stickLeftY), 225 toNumber(sample.stickRightX), 226 toNumber(sample.stickRightY) 227 ].map(value => value ?? 0); 228 const stickMagnitude = Math.sqrt(stickValues.reduce((sum, value) => sum + (value ** 2), 0)); 229 230 return { 231 accelerationKmhPerSec, 232 altitude, 233 altitudeDeltaM, 234 bearingDeg, 235 dataCompleteness: 0, 236 derivedGroundSpeedKmh, 237 distanceMeters, 238 elapsedSec, 239 groundSpeedDeltaKmh: groundSpeedKmh - previousGroundSpeedKmh, 240 groundSpeedKmh, 241 headingDeg, 242 index, 243 lat: sample.lat, 244 lon: sample.lon, 245 pitchCompensatedDeg, 246 pitchDeg, 247 pitchTrimDeg: context.pitchTrimDeg, 248 rawGroundSpeedKmh, 249 rollCompensatedDeg, 250 rollDeg, 251 rollTrimDeg: context.rollTrimDeg, 252 sampleCount: samples.length, 253 stickLeftX: toNumber(sample.stickLeftX), 254 stickLeftY: toNumber(sample.stickLeftY), 255 stickMagnitude, 256 stickRightX: toNumber(sample.stickRightX), 257 stickRightY: toNumber(sample.stickRightY), 258 timeDeltaSec, 259 turnRateDegSec, 260 verticalSpeedMps, 261 yawDeg, 262 yawRateDegSec, 263 gpsSatellites: toNumber(sample.gps), 264 gpsQuality: getGpsQuality(sample.gps) 265 }; 266 } 267 268 function classifySegment(frame = {}, context = {}) { 269 const elapsedSec = toNumber(frame.elapsedSec) || 0; 270 const remainingSec = Math.max(0, context.lastElapsedSec - elapsedSec); 271 const altitudeFromStart = Math.max(0, (toNumber(frame.altitude) ?? context.startAltitude) - context.startAltitude); 272 const speed = toNumber(frame.groundSpeedKmh) || 0; 273 const verticalSpeed = toNumber(frame.verticalSpeedMps) || 0; 274 const turnRate = Math.abs(toNumber(frame.turnRateDegSec) || 0); 275 const roll = Math.abs(toNumber(frame.rollCompensatedDeg) || 0); 276 const pitch = Math.abs(toNumber(frame.pitchCompensatedDeg) || 0); 277 278 if (elapsedSec - context.firstElapsedSec <= 12 && altitudeFromStart < 18 && verticalSpeed > 0.08) { 279 return "takeoff"; 280 } 281 if (remainingSec <= 12 && altitudeFromStart < 24 && verticalSpeed < -0.08) { 282 return "landing"; 283 } 284 if (speed <= 2.5 && Math.abs(verticalSpeed) <= 0.35 && turnRate <= 8 && roll <= 6 && pitch <= 8) { 285 return "hover"; 286 } 287 if (turnRate >= 14 || roll >= 12) { 288 return "turn"; 289 } 290 if (speed >= 3.5 && turnRate <= 10) { 291 return "straightFlight"; 292 } 293 return speed < 4 ? "hover" : "straightFlight"; 294 } 295 296 function estimateWindFromHover(frame = {}) { 297 const lateralTilt = Math.abs(frame.rollCompensatedDeg || 0); 298 const forwardTilt = Math.abs(frame.pitchCompensatedDeg || 0); 299 const speed = clamp((Math.hypot(lateralTilt, forwardTilt) * 0.75) + ((frame.stickMagnitude || 0) * 2.5), 0, 32); 300 const bodyAngle = toDegrees(Math.atan2(frame.rollCompensatedDeg || 0, -(frame.pitchCompensatedDeg || 0))); 301 return { 302 primitiveEstimator: "estimateWindFromHover", 303 speed, 304 directionToDeg: normalizeDirection((frame.headingDeg || 0) + bodyAngle) 305 }; 306 } 307 308 function estimateWindFromStraightFlight(frame = {}) { 309 const speedDelta = frame.groundSpeedDeltaKmh || 0; 310 const tiltSignal = (Math.abs(frame.rollCompensatedDeg || 0) * 0.35) + (Math.abs(frame.pitchCompensatedDeg || 0) * 0.28); 311 const speed = clamp(Math.abs(speedDelta) * 0.42 + tiltSignal + ((frame.stickMagnitude || 0) * 1.4), 0, 38); 312 const headwindGuess = speedDelta < 0 ? 0 : 180; 313 const crosswindGuess = clamp(frame.rollCompensatedDeg || 0, -18, 18) * 2.4; 314 return { 315 primitiveEstimator: "estimateWindFromStraightFlight", 316 speed, 317 directionToDeg: normalizeDirection((frame.headingDeg || 0) + headwindGuess + crosswindGuess) 318 }; 319 } 320 321 function estimateWindFromGroundSpeed(frame = {}) { 322 const speed = clamp( 323 Math.abs(frame.groundSpeedDeltaKmh || 0) * 0.36 + 324 Math.abs(frame.turnRateDegSec || 0) * 0.08 + 325 Math.abs(frame.rollCompensatedDeg || 0) * 0.22, 326 0, 327 34 328 ); 329 const turnOffset = (frame.turnRateDegSec || 0) >= 0 ? 90 : -90; 330 return { 331 primitiveEstimator: "estimateWindFromGroundSpeed", 332 speed, 333 directionToDeg: normalizeDirection((frame.bearingDeg ?? frame.headingDeg ?? 0) + turnOffset) 334 }; 335 } 336 337 function hybridEstimator(frame = {}, segmentType) { 338 if (segmentType === "hover") return estimateWindFromHover(frame); 339 if (segmentType === "straightFlight") return estimateWindFromStraightFlight(frame); 340 return estimateWindFromGroundSpeed(frame); 341 } 342 343 function createFeatureDebug(frame = {}) { 344 return { 345 accelerationKmhPerSec: roundNumber(frame.accelerationKmhPerSec, 3), 346 altitudeDeltaM: roundNumber(frame.altitudeDeltaM, 3), 347 altitudeM: frame.altitude, 348 bearingDeg: frame.bearingDeg, 349 dataCompleteness: roundNumber(frame.dataCompleteness, 3), 350 derivedGroundSpeedKmh: frame.derivedGroundSpeedKmh === null ? null : roundNumber(frame.derivedGroundSpeedKmh, 3), 351 distanceMeters: roundNumber(frame.distanceMeters, 3), 352 elapsedSec: roundNumber(frame.elapsedSec, 3), 353 groundSpeedDeltaKmh: roundNumber(frame.groundSpeedDeltaKmh, 3), 354 groundSpeedKmh: roundNumber(frame.groundSpeedKmh, 3), 355 headingDeg: frame.headingDeg, 356 pitchCompensatedDeg: roundNumber(frame.pitchCompensatedDeg, 3),
357 pitchDeg: frame.pitchDeg, 358 pitchTrimDeg: roundNumber(frame.pitchTrimDeg, 3), 359 rawGroundSpeedKmh: frame.rawGroundSpeedKmh, 360 rollCompensatedDeg: roundNumber(frame.rollCompensatedDeg, 3), 361 rollDeg: frame.rollDeg, 362 rollTrimDeg: roundNumber(frame.rollTrimDeg, 3), 363 stickLeftX: frame.stickLeftX, 364 stickLeftY: frame.stickLeftY, 365 stickMagnitude: roundNumber(frame.stickMagnitude, 3), 366 stickRightX: frame.stickRightX, 367 stickRightY: frame.stickRightY, 368 timeDeltaSec: roundNumber(frame.timeDeltaSec, 3), 369 turnRateDegSec: roundNumber(frame.turnRateDegSec, 3), 370 verticalSpeedMps: roundNumber(frame.verticalSpeedMps, 3), 371 yawDeg: frame.yawDeg, 372 yawRateDegSec: roundNumber(frame.yawRateDegSec, 3) 373 }; 374 } 375 376 function createEstimate({ confidence, debug, directionToDeg, estimatorName, speed }) { 377 const directionPair = getDirectionPairFromTo({ directionToDeg }); 378 return { 379 ...directionPair, 380 confidence: roundNumber(confidence, 3), 381 debug, 382 estimatorName, 383 speed: roundNumber(speed, 2) 384 }; 385 } 386 387 function createInstantEstimator() { 388 function estimate(sample = {}, frame = {}, context = {}) { 389 const segmentType = classifySegment(frame, context); 390 const nextFrame = { 391 ...frame, 392 segmentType 393 }; 394 nextFrame.dataCompleteness = getDataCompleteness(nextFrame); 395 396 const primitive = hybridEstimator(nextFrame, segmentType); 397 const confidence = calculateConfidence(nextFrame, segmentType); 398 return createEstimate({ 399 confidence, 400 directionToDeg: primitive.directionToDeg, 401 estimatorName: "InstantWindEstimator", 402 speed: primitive.speed, 403 debug: { 404 algorithmVersion: VERSION, 405 directionConvention: { 406 directionFromDeg: "meteorological origin", 407 directionToDeg: "air movement vector used for arrow rotation" 408 }, 409 estimatorName: "InstantWindEstimator", 410 primitiveEstimator: primitive.primitiveEstimator, 411 segmentType, 412 confidenceFactors: { 413 base: getSegmentBaseConfidence(segmentType), 414 curvePenalty: clamp(1 - (Math.max(0, Math.abs(nextFrame.turnRateDegSec || 0) - 8) / 70), 0.28, 1), 415 dataCompleteness: roundNumber(nextFrame.dataCompleteness, 3), 416 gpsQuality: roundNumber(nextFrame.gpsQuality, 3) 417 }, 418 features: createFeatureDebug(nextFrame) 419 } 420 }); 421 } 422 423 return { 424 estimate 425 }; 426 } 427 428 function isWindowCandidate(entry = {}, options = {}) { 429 const frame = entry.frame || {}; 430 const instant = entry.instant || {}; 431 const segmentType = instant.debug?.segmentType; 432 if (segmentType !== "hover" && segmentType !== "straightFlight") return false; 433 if ((instant.confidence || 0) < options.minConfidence) return false; 434 if ((frame.dataCompleteness || 0) < 0.45 || (frame.gpsQuality || 0) < 0.45) return false; 435 if (Math.abs(frame.accelerationKmhPerSec || 0) > options.maxAccelerationKmhPerSec) return false; 436 437 const turnRate = Math.abs(frame.turnRateDegSec || 0); 438 if (segmentType === "hover" && turnRate > options.maxHoverTurnRateDegSec) return false; 439 if (segmentType === "straightFlight" && turnRate > options.maxStraightTurnRateDegSec) return false; 440 return true; 441 } 442 443 function getWindowDurationSec(segmentType, options = {}) { 444 if (segmentType === "hover") return options.hoverWindowSec; 445 if (segmentType === "straightFlight") return options.straightFlightWindowSec; 446 return 0; 447 } 448 449 function vectorAverageWind(entries = []) { 450 let sumX = 0; 451 let sumY = 0; 452 let unitX = 0; 453 let unitY = 0; 454 let weightSum = 0; 455 456 entries.forEach(entry => { 457 const instant = entry.instant || {}; 458 const speed = toNumber(instant.speed) || 0; 459 const directionToDeg = normalizeDirection(instant.directionToDeg); 460 if (directionToDeg === null) return; 461 462 const weight = Math.max(0.05, toNumber(instant.confidence) || 0.05); 463 const radians = toRadians(directionToDeg); 464 const x = Math.sin(radians); 465 const y = Math.cos(radians); 466 sumX += x * speed * weight; 467 sumY += y * speed * weight; 468 unitX += x * weight; 469 unitY += y * weight; 470 weightSum += weight; 471 }); 472 473 if (!weightSum) return null; 474 const meanX = sumX / weightSum; 475 const meanY = sumY / weightSum; 476 const meanUnitX = unitX / weightSum; 477 const meanUnitY = unitY / weightSum; 478 const directionToDeg = normalizeDirection(toDegrees(Math.atan2(meanX, meanY))); 479 const speed = Math.hypot(meanX, meanY); 480 const resultantLength = clamp(Math.hypot(meanUnitX, meanUnitY), 0, 1); 481 return { 482 directionToDeg, 483 directionalDispersionDeg: (1 - resultantLength) * 180, 484 resultantLength, 485 speed 486 }; 487 } 488 489 function summarizeEstimate(estimate) { 490 if (!estimate) return null; 491 return { 492 cardinalFrom: estimate.cardinalFrom, 493 cardinalTo: estimate.cardinalTo, 494 confidence: estimate.confidence, 495 directionFromDeg: estimate.directionFromDeg, 496 directionToDeg: estimate.directionToDeg, 497 estimatorName: estimate.estimatorName, 498 speed: estimate.speed 499 }; 500 } 501 502 function createWindowedEstimator(options = {}) { 503 function estimate(entry = {}, entries = []) { 504 const segmentType = entry.instant?.debug?.segmentType; 505 const windowDurationSec = getWindowDurationSec(segmentType, options); 506 if (!windowDurationSec) { 507 return null; 508 } 509 510 const halfWindowSec = windowDurationSec / 2; 511 const centerElapsedSec = toNumber(entry.frame?.elapsedSec) || 0; 512 const candidates = entries.filter(candidate => { 513 const elapsedSec = toNumber(candidate.frame?.elapsedSec); 514 if (elapsedSec === null || Math.abs(elapsedSec - centerElapsedSec) > halfWindowSec) return false;
515 if (candidate.instant?.debug?.segmentType !== segmentType) return false; 516 return isWindowCandidate(candidate, options); 517 }); 518 519 if (candidates.length < options.minSamples) { 520 return null; 521 } 522 523 const averageVector = vectorAverageWind(candidates); 524 if (!averageVector) return null; 525 526 const confidenceAverage = average(candidates.map(candidate => candidate.instant?.confidence)); 527 const dispersionFactor = clamp(1 - (averageVector.directionalDispersionDeg / 90), 0.18, 1); 528 const sampleFactor = clamp(candidates.length / Math.max(options.minSamples, 8), 0.35, 1); 529 const confidence = confidenceAverage * dispersionFactor * sampleFactor; 530 const windowStartSec = candidates[0]?.frame?.elapsedSec ?? centerElapsedSec; 531 const windowEndSec = candidates[candidates.length - 1]?.frame?.elapsedSec ?? centerElapsedSec; 532 533 return createEstimate({ 534 confidence, 535 directionToDeg: averageVector.directionToDeg, 536 estimatorName: "WindowedWindEstimator", 537 speed: averageVector.speed, 538 debug: { 539 algorithmVersion: VERSION, 540 directionConvention: { 541 directionFromDeg: "meteorological origin", 542 directionToDeg: "air movement vector used for arrow rotation" 543 }, 544 estimatorName: "WindowedWindEstimator", 545 segmentType, 546 window: { 547 durationSec: roundNumber(Math.max(0, windowEndSec - windowStartSec), 2), 548 requestedDurationSec: windowDurationSec, 549 samplesUsed: candidates.length, 550 startElapsedSec: roundNumber(windowStartSec, 2), 551 endElapsedSec: roundNumber(windowEndSec, 2) 552 }, 553 directionalDispersionDeg: roundNumber(averageVector.directionalDispersionDeg, 2), 554 confidenceFactors: { 555 averageInstantConfidence: roundNumber(confidenceAverage, 3), 556 directionalDispersionFactor: roundNumber(dispersionFactor, 3), 557 resultantLength: roundNumber(averageVector.resultantLength, 3), 558 sampleFactor: roundNumber(sampleFactor, 3) 559 }, 560 instantEstimate: summarizeEstimate(entry.instant), 561 samplesUsed: candidates.map(candidate => candidate.index) 562 } 563 }); 564 } 565 566 return { 567 estimate 568 }; 569 } 570 571 function selectActiveEstimate(instant, windowed, mode) { 572 if (mode === "windowed" && windowed) return windowed; 573 return instant; 574 } 575 576 function mergeDebug(activeEstimate, instantEstimate, windowedEstimate, mode) { 577 return { 578 ...activeEstimate.debug, 579 activeMode: mode, 580 instantEstimate: summarizeEstimate(instantEstimate), 581 windowedEstimate: summarizeEstimate(windowedEstimate), 582 windowedAvailable: Boolean(windowedEstimate) 583 }; 584 } 585 586 function getSegmentLabel(segmentType) { 587 return { 588 hover: "Hover", 589 landing: "Landing", 590 straightFlight: "Straight Flight", 591 takeoff: "Takeoff", 592 turn: "Turns" 593 }[segmentType] || "Other"; 594 } 595 596 function createSummary(samples = []) { 597 const runs = {}; 598 const sampleCounts = {}; 599 let previousSegment = null; 600 let generated = 0; 601 let confidenceSum = 0; 602 let windowedGenerated = 0; 603 604 samples.forEach(sample => { 605 const segmentType = sample.wind?.instant?.debug?.segmentType || "other"; 606 sampleCounts[segmentType] = (sampleCounts[segmentType] || 0) + 1; 607 if (segmentType !== previousSegment) { 608 runs[segmentType] = (runs[segmentType] || 0) + 1; 609 } 610 previousSegment = segmentType; 611 612 if (sample.wind?.instant) { 613 generated += 1; 614 confidenceSum += Number(sample.wind.instant.confidence) || 0; 615 } 616 if (sample.wind?.windowed) windowedGenerated += 1; 617 }); 618 619 return { 620 averageConfidence: generated ? confidenceSum / generated : 0, 621 generated, 622 runs, 623 sampleCounts, 624 windowedGenerated 625 }; 626 } 627 628 function logSummary(samples = [], summary = {}, logger = console) { 629 if (!logger?.info) return; 630 logger.groupCollapsed?.("[Wind Analysis]"); 631 logger.info(`[Wind Analysis] Loaded ${formatCount(samples.length)} samples`); 632 logger.info("[Wind Analysis] Detected:"); 633 ["hover", "straightFlight", "turn", "takeoff", "landing"].forEach(segmentType => { 634 logger.info(`- ${getSegmentLabel(segmentType)}: ${formatCount(summary.runs?.[segmentType] || 0)}`); 635 }); 636 logger.info(`[Wind Analysis] Instant vectors generated: ${formatCount(summary.generated || 0)}`); 637 logger.info(`[Wind Analysis] Windowed vectors generated: ${formatCount(summary.windowedGenerated || 0)}`); 638 logger.info(`[Wind Analysis] Average instant confidence: ${formatDecimal(summary.averageConfidence || 0, 2)}`); 639 logger.groupEnd?.(); 640 } 641 642 function mergeOptions(options = {}) { 643 return { 644 ...DEFAULT_OPTIONS, 645 ...options, 646 windowed: { 647 ...DEFAULT_OPTIONS.windowed, 648 ...(options.windowed || {}) 649 } 650 }; 651 } 652 653 function create(options = {}) { 654 const config = mergeOptions(options); 655 const logger = config.logger || console; 656 const instantEstimator = createInstantEstimator(); 657 const windowedEstimator = createWindowedEstimator(config.windowed); 658 659 function enrichSamples(samples = [], runtimeOptions = {}) { 660 if (!Array.isArray(samples) || !samples.length) return []; 661 const activeMode = runtimeOptions.activeMode || config.activeMode; 662 const context = createFlightContext(samples); 663 const entries = samples.map((sample, index) => { 664 const frame = createFeatureFrame(samples, index, context); 665 frame.dataCompleteness = getDataCompleteness(frame); 666 const instant = instantEstimator.estimate(sample, frame, context); 667 return { frame, index, instant, sample }; 668 }); 669 670 const enriched = entries.map(entry => { 671 if (config.preserveExistingWind && entry.sample.wind) return entry.sample; 672 const windowed = windowedEstimator.estimate(entry, entries); 673 const active = selectActiveEstimate(entry.instant, windowed, activeMode); 674 return { 675 ...entry.sample, 676 wind: { 677 ...active, 678 activeMode, 679 debug: mergeDebug(active, entry.instant, windowed, activeMode), 680 instant: entry.instant, 681 windowed 682 } 683 }; 684 }); 685 686 const summary = createSummary(enriched); 687 if (config.log) logSummary(enriched, summary, logger); 688 return enriched; 689 } 690 691 return { 692 InstantWindEstimator: instantEstimator, 693 WindowedWindEstimator: windowedEstimator, 694 enrichSamples,
695 estimateWind: instantEstimator.estimate, 696 estimateWindFromGroundSpeed, 697 estimateWindFromHover, 698 estimateWindFromStraightFlight, 699 hybridEstimator 700 }; 701 } 702 703 window.DSC_WindAnalysisEngine = { 704 create, 705 getCardinal, 706 getDirectionPairFromTo, 707 InstantWindEstimator: createInstantEstimator, 708 WindowedWindEstimator: createWindowedEstimator 709 }; 710})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.