PageSourceSearch

https://droneskycheck-d0136.web.app/js/fleet/flight-replay/wind-analysis-engine.js?v=__VERSION__

js droneskycheck-d0136.web.app collected 2026-10-03 12:23:45 UTC 27,188 bytes, 710 lines download raw bytes

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.