@mearman/doppler 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/browser/entry.d.ts +105 -0
- package/dist/browser/entry.js +276 -0
- package/dist/browser/mic-capture.worklet.d.ts +1 -0
- package/dist/browser/mic-capture.worklet.js +35 -0
- package/dist/index-TOQF-n8K.d.ts +1577 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/mic-capture-protocol-CfYodiRN.js +4 -0
- package/dist/node/entry.d.ts +128 -0
- package/dist/node/entry.js +478 -0
- package/dist/src-C4UucgwP.js +2807 -0
- package/dist/time-DWqWWgPL.js +4 -0
- package/dist-cli/doppler.mjs +3085 -0
- package/package.json +103 -2
|
@@ -0,0 +1,3085 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { closeSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { gunzipSync } from "node:zlib";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
// src/physics/atmosphere.ts
|
|
7
|
+
/** Adiabatic index of dry air. */
|
|
8
|
+
const GAMMA_DRY_AIR = 1.4;
|
|
9
|
+
/** Specific gas constant for dry air in J/(kg*K). */
|
|
10
|
+
const R_DRY_AIR = 287.058;
|
|
11
|
+
/** Absolute zero offset in Celsius. */
|
|
12
|
+
const KELVIN_OFFSET = 273.15;
|
|
13
|
+
/** Lowest air temperature in kelvin that `speedOfSoundAtTempMps` evaluates, below any temperature found in the troposphere (about -93 degrees Celsius), so that an implausible input cannot drive the speed of sound towards zero. */
|
|
14
|
+
const MIN_AIR_TEMPERATURE_KELVIN = 180;
|
|
15
|
+
/**
|
|
16
|
+
* Computes the local phase speed of sound in meters per second at a given temperature in Celsius.
|
|
17
|
+
* @param tempCelsius - Ambient air temperature in degrees Celsius.
|
|
18
|
+
* @returns Speed of sound in m/s.
|
|
19
|
+
*/
|
|
20
|
+
function speedOfSoundAtTempMps(tempCelsius) {
|
|
21
|
+
const tempKelvin = Math.max(MIN_AIR_TEMPERATURE_KELVIN, tempCelsius + KELVIN_OFFSET);
|
|
22
|
+
return Math.sqrt(GAMMA_DRY_AIR * R_DRY_AIR * tempKelvin);
|
|
23
|
+
}
|
|
24
|
+
// src/physics/geo.ts
|
|
25
|
+
/** WGS84 equatorial radius in meters. */
|
|
26
|
+
const WGS84_A = 6378137;
|
|
27
|
+
/** WGS84 first eccentricity squared. */
|
|
28
|
+
const WGS84_E2 = .00669437999014;
|
|
29
|
+
/** Latitude of either pole in degrees, the bound of the valid latitude range. */
|
|
30
|
+
const POLE_LATITUDE_DEG = 90;
|
|
31
|
+
/** Degrees to radians conversion factor. */
|
|
32
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
33
|
+
/** Radians to degrees conversion factor. */
|
|
34
|
+
const RAD_TO_DEG = 180 / Math.PI;
|
|
35
|
+
/**
|
|
36
|
+
* Mean length of one degree of latitude in metres on the WGS84 ellipsoid, the constant term of the cosine series `MEAN - COS2 * cos(2 * lat) + COS4 * cos(4 * lat)`.
|
|
37
|
+
* Source: "Geographic coordinate system", section "Length of a degree" (https://en.wikipedia.org/wiki/Geographic_coordinate_system#Length_of_a_degree).
|
|
38
|
+
*/
|
|
39
|
+
const METERS_PER_DEG_LAT_MEAN = 111132.92;
|
|
40
|
+
/** Amplitude of the second-harmonic term of the metres-per-degree-of-latitude series, in metres per degree. */
|
|
41
|
+
const METERS_PER_DEG_LAT_COS2 = 559.82;
|
|
42
|
+
/** Amplitude of the fourth-harmonic term of the metres-per-degree-of-latitude series, in metres per degree. */
|
|
43
|
+
const METERS_PER_DEG_LAT_COS4 = 1.175;
|
|
44
|
+
/** Harmonic order of the second latitude term. */
|
|
45
|
+
const LAT_FOURTH_HARMONIC = 4;
|
|
46
|
+
/**
|
|
47
|
+
* Fundamental amplitude of the length of one degree of longitude in metres on the WGS84 ellipsoid, as a cosine series in the latitude: `LON_COS1 * cos(lat) - LON_COS3 * cos(3 * lat)`.
|
|
48
|
+
* Source: "Geographic coordinate system", section "Length of a degree" (https://en.wikipedia.org/wiki/Geographic_coordinate_system#Length_of_a_degree).
|
|
49
|
+
*/
|
|
50
|
+
const METERS_PER_DEG_LON_COS1 = 111412.84;
|
|
51
|
+
/** Amplitude of the third-harmonic term of the metres-per-degree-of-longitude series, in metres per degree. */
|
|
52
|
+
const METERS_PER_DEG_LON_COS3 = 93.5;
|
|
53
|
+
/** Harmonic order of the longitude series correction term. */
|
|
54
|
+
const LON_THIRD_HARMONIC = 3;
|
|
55
|
+
/** Floor on the metres per degree of longitude in metres per degree, so that an offset at a pole divides by a small positive length instead of zero. */
|
|
56
|
+
const MIN_METERS_PER_DEG_LON = .001;
|
|
57
|
+
/**
|
|
58
|
+
* Converts a geodetic coordinate (latitude, longitude, altitude) to Earth-Centered, Earth-Fixed (ECEF) Cartesian coordinates in meters.
|
|
59
|
+
* @param point - Geodetic coordinate on or above WGS84.
|
|
60
|
+
* @returns ECEF vector in meters.
|
|
61
|
+
*/
|
|
62
|
+
function geoToEcef(point) {
|
|
63
|
+
const latRad = point.lat * DEG_TO_RAD;
|
|
64
|
+
const lonRad = point.lon * DEG_TO_RAD;
|
|
65
|
+
const sinLat = Math.sin(latRad);
|
|
66
|
+
const cosLat = Math.cos(latRad);
|
|
67
|
+
const sinLon = Math.sin(lonRad);
|
|
68
|
+
const cosLon = Math.cos(lonRad);
|
|
69
|
+
const primeVerticalRadius = WGS84_A / Math.sqrt(1 - WGS84_E2 * sinLat * sinLat);
|
|
70
|
+
return {
|
|
71
|
+
x: (primeVerticalRadius + point.altMeters) * cosLat * cosLon,
|
|
72
|
+
y: (primeVerticalRadius + point.altMeters) * cosLat * sinLon,
|
|
73
|
+
z: (primeVerticalRadius * .99330562000986 + point.altMeters) * sinLat
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Converts a global displacement between two geodetic points into local East-North-Up (ENU) meters relative to the origin point.
|
|
78
|
+
* @param origin - Reference observer coordinate.
|
|
79
|
+
* @param target - Target aircraft or ground coordinate.
|
|
80
|
+
* @returns Local tangent-plane ENU vector in meters (x = East, y = North, z = Up).
|
|
81
|
+
*/
|
|
82
|
+
function geoToLocalEnu(origin, target) {
|
|
83
|
+
const originEcef = geoToEcef(origin);
|
|
84
|
+
const targetEcef = geoToEcef(target);
|
|
85
|
+
const dx = targetEcef.x - originEcef.x;
|
|
86
|
+
const dy = targetEcef.y - originEcef.y;
|
|
87
|
+
const dz = targetEcef.z - originEcef.z;
|
|
88
|
+
const latRad = origin.lat * DEG_TO_RAD;
|
|
89
|
+
const lonRad = origin.lon * DEG_TO_RAD;
|
|
90
|
+
const sinLat = Math.sin(latRad);
|
|
91
|
+
const cosLat = Math.cos(latRad);
|
|
92
|
+
const sinLon = Math.sin(lonRad);
|
|
93
|
+
const cosLon = Math.cos(lonRad);
|
|
94
|
+
return {
|
|
95
|
+
x: -sinLon * dx + cosLon * dy,
|
|
96
|
+
y: -sinLat * cosLon * dx - sinLat * sinLon * dy + cosLat * dz,
|
|
97
|
+
z: cosLat * cosLon * dx + cosLat * sinLon * dy + sinLat * dz
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Projects a local East-North-Up (ENU) offset in meters from a reference geodetic origin back to geodetic coordinates.
|
|
102
|
+
* @param origin - Reference coordinate.
|
|
103
|
+
* @param enuOffset - Offset in local meters (x = East, y = North, z = Up).
|
|
104
|
+
* @returns Resulting geodetic coordinate.
|
|
105
|
+
*/
|
|
106
|
+
function localEnuToGeo(origin, enuOffset) {
|
|
107
|
+
const latRad = origin.lat * DEG_TO_RAD;
|
|
108
|
+
const metersPerDegLat = METERS_PER_DEG_LAT_MEAN - METERS_PER_DEG_LAT_COS2 * Math.cos(2 * latRad) + METERS_PER_DEG_LAT_COS4 * Math.cos(LAT_FOURTH_HARMONIC * latRad);
|
|
109
|
+
const metersPerDegLon = Math.max(MIN_METERS_PER_DEG_LON, METERS_PER_DEG_LON_COS1 * Math.cos(latRad) - METERS_PER_DEG_LON_COS3 * Math.cos(LON_THIRD_HARMONIC * latRad));
|
|
110
|
+
return {
|
|
111
|
+
lat: Math.max(-90, Math.min(POLE_LATITUDE_DEG, origin.lat + enuOffset.y / metersPerDegLat)),
|
|
112
|
+
lon: ((origin.lon + enuOffset.x / metersPerDegLon + 180) % 360 + 360) % 360 - 180,
|
|
113
|
+
altMeters: origin.altMeters + enuOffset.z
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Highest resolution tier that uses the coarse cell size. */
|
|
117
|
+
const COARSE_RESOLUTION_MAX = 4;
|
|
118
|
+
/** Highest resolution tier that uses the medium cell size; tiers above it use the fine cell size. */
|
|
119
|
+
const MEDIUM_RESOLUTION_MAX = 6;
|
|
120
|
+
/** Cell side in degrees for the coarse tier (about 20 km). */
|
|
121
|
+
const COARSE_CELL_STEP_DEG = .2;
|
|
122
|
+
/** Cell side in degrees for the medium tier (about 3.5 km). */
|
|
123
|
+
const MEDIUM_CELL_STEP_DEG = .032;
|
|
124
|
+
/** Cell side in degrees for the fine tier (about 500 m). */
|
|
125
|
+
const FINE_CELL_STEP_DEG = .005;
|
|
126
|
+
/** Fraction of a cell side from a cell's lower edge to its centre. */
|
|
127
|
+
const CELL_CENTRE_FRACTION = .5;
|
|
128
|
+
/** Squared distance below which a terrain sample counts as coincident with the query point, in the squared units of the weighting distance in `interpolateTerrainElevationMeters`. */
|
|
129
|
+
const COINCIDENT_SAMPLE_DISTANCE_SQ = 1e-12;
|
|
130
|
+
/**
|
|
131
|
+
* Looks up the side of a spatial cell for a resolution tier.
|
|
132
|
+
* @param resolution - Grid resolution tier.
|
|
133
|
+
* @returns Cell side in degrees.
|
|
134
|
+
*/
|
|
135
|
+
function cellStepDeg(resolution) {
|
|
136
|
+
if (resolution <= COARSE_RESOLUTION_MAX) return COARSE_CELL_STEP_DEG;
|
|
137
|
+
return resolution <= MEDIUM_RESOLUTION_MAX ? MEDIUM_CELL_STEP_DEG : FINE_CELL_STEP_DEG;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Computes the 3D Euclidean slant distance in meters between two geodetic coordinates.
|
|
141
|
+
* @param a - First 3D point.
|
|
142
|
+
* @param b - Second 3D point.
|
|
143
|
+
* @returns Slant distance in meters.
|
|
144
|
+
*/
|
|
145
|
+
function slantDistanceMeters(a, b) {
|
|
146
|
+
const ea = geoToEcef(a);
|
|
147
|
+
const eb = geoToEcef(b);
|
|
148
|
+
return Math.hypot(ea.x - eb.x, ea.y - eb.y, ea.z - eb.z);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Encodes a latitude/longitude coordinate into a deterministic hierarchical spatial cell identifier at a given resolution step.
|
|
152
|
+
* @param lat - Latitude in degrees.
|
|
153
|
+
* @param lon - Longitude in degrees.
|
|
154
|
+
* @param resolution - Grid resolution tier (4 = ~20km, 6 = ~3.5km, 8 = ~500m).
|
|
155
|
+
* @returns Deterministic cell string identifier.
|
|
156
|
+
*/
|
|
157
|
+
function encodeSpatialCell(lat, lon, resolution = 6) {
|
|
158
|
+
const stepDeg = cellStepDeg(resolution);
|
|
159
|
+
const latIdx = Math.floor((lat + POLE_LATITUDE_DEG) / stepDeg);
|
|
160
|
+
const lonIdx = Math.floor((lon + 180) / stepDeg);
|
|
161
|
+
return `h3r${String(resolution)}_${String(latIdx)}_${String(lonIdx)}`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Decodes a spatial cell identifier back to its center latitude and longitude in degrees.
|
|
165
|
+
* @param cellId - Spatial cell identifier produced by `encodeSpatialCell`.
|
|
166
|
+
* @returns Center latitude and longitude in degrees.
|
|
167
|
+
*/
|
|
168
|
+
function decodeSpatialCellCenter(cellId) {
|
|
169
|
+
const match = /^h3r(\d+)_(-?\d+)_(-?\d+)$/.exec(cellId);
|
|
170
|
+
if (match === null) throw new Error(`Invalid spatial cell identifier: ${cellId}`);
|
|
171
|
+
const resolution = Number(match[1]);
|
|
172
|
+
const latIdx = Number(match[2]);
|
|
173
|
+
const lonIdx = Number(match[3]);
|
|
174
|
+
const stepDeg = cellStepDeg(resolution);
|
|
175
|
+
return {
|
|
176
|
+
lat: -90 + (latIdx + CELL_CENTRE_FRACTION) * stepDeg,
|
|
177
|
+
lon: -180 + (lonIdx + CELL_CENTRE_FRACTION) * stepDeg
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Evaluates terrain elevation at a target coordinate via inverse-distance-weighted interpolation over a DEM grid.
|
|
182
|
+
* @param lat - Target latitude in degrees.
|
|
183
|
+
* @param lon - Target longitude in degrees.
|
|
184
|
+
* @param demGrid - Sparse or regular grid of elevation samples.
|
|
185
|
+
* @returns Interpolated terrain elevation in meters above sea level.
|
|
186
|
+
*/
|
|
187
|
+
function interpolateTerrainElevationMeters(lat, lon, demGrid) {
|
|
188
|
+
if (demGrid.length === 0) return 0;
|
|
189
|
+
let weightedSum = 0;
|
|
190
|
+
let weightTotal = 0;
|
|
191
|
+
for (const sample of demGrid) {
|
|
192
|
+
const dLat = (lat - sample.lat) * RAD_TO_DEG;
|
|
193
|
+
const dLon = (lon - sample.lon) * RAD_TO_DEG;
|
|
194
|
+
const distSq = dLat * dLat + dLon * dLon;
|
|
195
|
+
if (distSq < COINCIDENT_SAMPLE_DISTANCE_SQ) return sample.altMeters;
|
|
196
|
+
const weight = 1 / distSq;
|
|
197
|
+
weightedSum += sample.altMeters * weight;
|
|
198
|
+
weightTotal += weight;
|
|
199
|
+
}
|
|
200
|
+
return weightTotal > 0 ? weightedSum / weightTotal : 0;
|
|
201
|
+
}
|
|
202
|
+
// src/physics/retarded-time.ts
|
|
203
|
+
/** Smallest margin `c^2 - v^2` in m^2/s^2 accepted by `solveRetardedDelaySec`; a source this close to the speed of sound makes the quadratic's leading coefficient vanish, so it is treated as outside the subsonic domain. */
|
|
204
|
+
const MIN_SUBSONIC_MARGIN_SQ = .001;
|
|
205
|
+
/** Shortest segment duration in seconds used as the divisor of a segment's velocity, so a zero-length segment cannot divide by zero. */
|
|
206
|
+
const MIN_SEGMENT_DURATION_SEC$1 = .001;
|
|
207
|
+
/** Squared speed in m^2/s^2 below which a segment counts as stationary, so the closest-approach offset is zero instead of a division by a near-zero speed. */
|
|
208
|
+
const MIN_SEGMENT_SPEED_SQ = 1e-6;
|
|
209
|
+
/** Slant distance in metres below which the radial velocity is taken as zero, because the line of sight is undefined at the source position. */
|
|
210
|
+
const MIN_RADIAL_DIRECTION_DISTANCE_METERS = .001;
|
|
211
|
+
/**
|
|
212
|
+
* Solves the exact subsonic Liénard-Wiechert retarded-time propagation delay `Δt = t_obs - t_emit` in closed form for a uniform linear trajectory segment.
|
|
213
|
+
*
|
|
214
|
+
* Given an aircraft position `r(t_emit) = r(t_obs) - v * Δt`, the acoustic propagation condition
|
|
215
|
+
* `c^2 * Δt^2 = || r(t_obs) - x_obs - v * Δt ||^2` is a quadratic equation in `Δt`:
|
|
216
|
+
* `(c^2 - v^2) * Δt^2 + 2 * (Δr · v) * Δt - ||Δr||^2 = 0`
|
|
217
|
+
* @param deltaPosEnu - Displacement vector `r(t_obs) - x_obs` in local ENU meters at reception time `t_obs`.
|
|
218
|
+
* @param velocityEnu - Aircraft velocity vector in local ENU meters per second.
|
|
219
|
+
* @param soundSpeedMps - Local speed of sound in meters per second.
|
|
220
|
+
* @returns Exact retarded propagation delay `Δt >= 0` in seconds.
|
|
221
|
+
*/
|
|
222
|
+
function solveRetardedDelaySec(deltaPosEnu, velocityEnu, soundSpeedMps) {
|
|
223
|
+
const vSq = velocityEnu.x * velocityEnu.x + velocityEnu.y * velocityEnu.y + velocityEnu.z * velocityEnu.z;
|
|
224
|
+
const cSq = soundSpeedMps * soundSpeedMps;
|
|
225
|
+
const cSqMinusVSq = cSq - vSq;
|
|
226
|
+
if (cSqMinusVSq <= MIN_SUBSONIC_MARGIN_SQ) throw new Error(`Supersonic or sonic source speed (v^2=${String(vSq)}, c^2=${String(cSq)}) is outside subsonic domain`);
|
|
227
|
+
const rSq = deltaPosEnu.x * deltaPosEnu.x + deltaPosEnu.y * deltaPosEnu.y + deltaPosEnu.z * deltaPosEnu.z;
|
|
228
|
+
const vDotR = velocityEnu.x * deltaPosEnu.x + velocityEnu.y * deltaPosEnu.y + velocityEnu.z * deltaPosEnu.z;
|
|
229
|
+
const discriminant = cSqMinusVSq * rSq + vDotR * vDotR;
|
|
230
|
+
return (-vDotR + Math.sqrt(Math.max(0, discriminant))) / cSqMinusVSq;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Computes the analytical Closest Point of Approach (CPA) emission time, delayed reception time, and 3D slant distance for an observer relative to a linear flight segment.
|
|
234
|
+
* @param observer - Candidate ground observer 3D coordinate.
|
|
235
|
+
* @param segment - Linear flight segment.
|
|
236
|
+
* @param soundSpeedMps - Effective speed of sound in m/s.
|
|
237
|
+
* @returns Emission CPA timestamp, delayed observer CPA timestamp, and minimum 3D slant distance in meters.
|
|
238
|
+
*/
|
|
239
|
+
function computeSegmentCpa(observer, segment, soundSpeedMps = 343) {
|
|
240
|
+
const duration = Math.max(MIN_SEGMENT_DURATION_SEC$1, segment.endEpochSec - segment.startEpochSec);
|
|
241
|
+
const startEnu = geoToLocalEnu(observer, segment.startGeo);
|
|
242
|
+
const endEnu = geoToLocalEnu(observer, segment.endGeo);
|
|
243
|
+
const vx = (endEnu.x - startEnu.x) / duration;
|
|
244
|
+
const vy = (endEnu.y - startEnu.y) / duration;
|
|
245
|
+
const vz = (endEnu.z - startEnu.z) / duration;
|
|
246
|
+
const vSq = vx * vx + vy * vy + vz * vz;
|
|
247
|
+
const unclampedOffsetSec = vSq > MIN_SEGMENT_SPEED_SQ ? -(startEnu.x * vx + startEnu.y * vy + startEnu.z * vz) / vSq : 0;
|
|
248
|
+
const cpaOffsetSec = Math.max(0, Math.min(duration, unclampedOffsetSec));
|
|
249
|
+
const cpaPosX = startEnu.x + vx * cpaOffsetSec;
|
|
250
|
+
const cpaPosY = startEnu.y + vy * cpaOffsetSec;
|
|
251
|
+
const cpaPosZ = startEnu.z + vz * cpaOffsetSec;
|
|
252
|
+
const cpaSlantMeters = Math.hypot(cpaPosX, cpaPosY, cpaPosZ);
|
|
253
|
+
const cpaEmitEpochSec = segment.startEpochSec + cpaOffsetSec;
|
|
254
|
+
return {
|
|
255
|
+
cpaEmitEpochSec,
|
|
256
|
+
cpaDelayedEpochSec: cpaEmitEpochSec + cpaSlantMeters / soundSpeedMps,
|
|
257
|
+
cpaSlantMeters
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Synthesizes the exact retarded-time Doppler curve observed on the ground for a linear flight segment at specified reception timestamps.
|
|
262
|
+
* @param observer - Ground observer 3D coordinate.
|
|
263
|
+
* @param segment - Linear flight segment.
|
|
264
|
+
* @param options - Base frequency, observation timestamps, and optional sound speed.
|
|
265
|
+
* @returns Evaluated retarded acoustic trajectory at each reception timestamp.
|
|
266
|
+
*/
|
|
267
|
+
function synthesizeRetardedDopplerCurve(observer, segment, options) {
|
|
268
|
+
const { baseFreqHz, obsEpochTimesSec, soundSpeedMps = 343 } = options;
|
|
269
|
+
const duration = Math.max(MIN_SEGMENT_DURATION_SEC$1, segment.endEpochSec - segment.startEpochSec);
|
|
270
|
+
const startEnu = geoToLocalEnu(observer, segment.startGeo);
|
|
271
|
+
const endEnu = geoToLocalEnu(observer, segment.endGeo);
|
|
272
|
+
const velocityEnu = {
|
|
273
|
+
x: (endEnu.x - startEnu.x) / duration,
|
|
274
|
+
y: (endEnu.y - startEnu.y) / duration,
|
|
275
|
+
z: (endEnu.z - startEnu.z) / duration
|
|
276
|
+
};
|
|
277
|
+
return obsEpochTimesSec.map((obsTimeSec) => {
|
|
278
|
+
const dtFromStart = obsTimeSec - segment.startEpochSec;
|
|
279
|
+
const delaySec = solveRetardedDelaySec({
|
|
280
|
+
x: startEnu.x + velocityEnu.x * dtFromStart,
|
|
281
|
+
y: startEnu.y + velocityEnu.y * dtFromStart,
|
|
282
|
+
z: startEnu.z + velocityEnu.z * dtFromStart
|
|
283
|
+
}, velocityEnu, soundSpeedMps);
|
|
284
|
+
const emitTimeSec = obsTimeSec - delaySec;
|
|
285
|
+
const emitDtFromStart = emitTimeSec - segment.startEpochSec;
|
|
286
|
+
const emitPosEnu = {
|
|
287
|
+
x: startEnu.x + velocityEnu.x * emitDtFromStart,
|
|
288
|
+
y: startEnu.y + velocityEnu.y * emitDtFromStart,
|
|
289
|
+
z: startEnu.z + velocityEnu.z * emitDtFromStart
|
|
290
|
+
};
|
|
291
|
+
const slantDistanceMeters = Math.hypot(emitPosEnu.x, emitPosEnu.y, emitPosEnu.z);
|
|
292
|
+
const radialVelocityMps = slantDistanceMeters > MIN_RADIAL_DIRECTION_DISTANCE_METERS ? -(emitPosEnu.x * velocityEnu.x + emitPosEnu.y * velocityEnu.y + emitPosEnu.z * velocityEnu.z) / slantDistanceMeters : 0;
|
|
293
|
+
return {
|
|
294
|
+
obsTimeSec,
|
|
295
|
+
emitTimeSec,
|
|
296
|
+
delaySec,
|
|
297
|
+
slantDistanceMeters,
|
|
298
|
+
radialVelocityMps,
|
|
299
|
+
observedFreqHz: baseFreqHz * (soundSpeedMps / (soundSpeedMps - radialVelocityMps))
|
|
300
|
+
};
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
// src/dsp/constants.ts
|
|
304
|
+
/**
|
|
305
|
+
* Sample rate in Hz of the decimated audio that every analysis stage consumes. Its Nyquist limit of 1000 Hz sits above the top of the analysis band while keeping each FFT short.
|
|
306
|
+
*/
|
|
307
|
+
const ANALYSIS_SAMPLE_RATE_HZ = 2e3;
|
|
308
|
+
/**
|
|
309
|
+
* Smallest FFT length in samples used for Electric Network Frequency (ENF) detection. At the analysis rate it resolves bins of about 0.49 Hz, fine enough to separate 50 Hz from 60 Hz with room for the neighbouring bins.
|
|
310
|
+
*/
|
|
311
|
+
const ENF_MIN_FFT_SIZE = 4096;
|
|
312
|
+
/**
|
|
313
|
+
* Power in dB substituted for a spectrum bin that does not exist, far below any recorded level so that it never wins a maximum.
|
|
314
|
+
*/
|
|
315
|
+
const MISSING_BIN_POWER_DB = -120;
|
|
316
|
+
/**
|
|
317
|
+
* Noise floor in dB reported when a search band holds no background bins to take a median of.
|
|
318
|
+
*/
|
|
319
|
+
const NO_BACKGROUND_NOISE_FLOOR_DB = -100;
|
|
320
|
+
// src/dsp/fft.ts
|
|
321
|
+
/**
|
|
322
|
+
* One full turn in radians (a complete circle, 2 pi): the phase span of the twiddle factors of a transform and of the Hann window.
|
|
323
|
+
*/
|
|
324
|
+
const FULL_TURN_RADIANS = 2 * Math.PI;
|
|
325
|
+
/**
|
|
326
|
+
* Hann window coefficient scale: the window is `0.5 * (1 - cos(2 pi i / (N - 1)))`, a raised cosine that tapers both ends of the frame to zero.
|
|
327
|
+
*/
|
|
328
|
+
const HANN_SCALE = .5;
|
|
329
|
+
/**
|
|
330
|
+
* Smallest squared magnitude passed to the logarithm, so that an all-zero bin reports -200 dB instead of negative infinity. Squared magnitude is a power, so the floor is 10 * log10(1e-20) = -200 dB.
|
|
331
|
+
*/
|
|
332
|
+
const POWER_FLOOR = 1e-20;
|
|
333
|
+
/**
|
|
334
|
+
* Decibels per decade of a power ratio: `10 * log10(power)`.
|
|
335
|
+
*/
|
|
336
|
+
const DB_PER_POWER_DECADE = 10;
|
|
337
|
+
/**
|
|
338
|
+
* Computes a Radix-2 Cooley-Tukey Complex Fast Fourier Transform without modifying its inputs: the samples are gathered into freshly allocated arrays in bit-reversed order and the butterflies run on those arrays.
|
|
339
|
+
* @param real - Real components array of power-of-two length N.
|
|
340
|
+
* @param imag - Imaginary components array of the same length N.
|
|
341
|
+
* @returns The forward transform (negative-exponent convention, unnormalised) as new arrays.
|
|
342
|
+
* @throws When N is not a positive power of two or the two arrays differ in length.
|
|
343
|
+
*/
|
|
344
|
+
function fft(real, imag) {
|
|
345
|
+
const n = real.length;
|
|
346
|
+
if (n === 0 || (n & n - 1) !== 0 || imag.length !== n) throw new Error(`FFT length must be a positive power of two with matching real/imag lengths, got ${String(n)}`);
|
|
347
|
+
const outReal = new Float64Array(n);
|
|
348
|
+
const outImag = new Float64Array(n);
|
|
349
|
+
let j = 0;
|
|
350
|
+
for (let i = 0; i < n; i += 1) {
|
|
351
|
+
outReal[i] = real[j] ?? 0;
|
|
352
|
+
outImag[i] = imag[j] ?? 0;
|
|
353
|
+
let bit = n >> 1;
|
|
354
|
+
while (bit >= 1 && (j & bit) !== 0) {
|
|
355
|
+
j ^= bit;
|
|
356
|
+
bit >>= 1;
|
|
357
|
+
}
|
|
358
|
+
j |= bit;
|
|
359
|
+
}
|
|
360
|
+
for (let len = 2; len <= n; len <<= 1) {
|
|
361
|
+
const halfLen = len >> 1;
|
|
362
|
+
const angleStep = -FULL_TURN_RADIANS / len;
|
|
363
|
+
const wStepR = Math.cos(angleStep);
|
|
364
|
+
const wStepI = Math.sin(angleStep);
|
|
365
|
+
for (let i = 0; i < n; i += len) {
|
|
366
|
+
let wR = 1;
|
|
367
|
+
let wI = 0;
|
|
368
|
+
for (let k = 0; k < halfLen; k += 1) {
|
|
369
|
+
const evenIdx = i + k;
|
|
370
|
+
const oddIdx = evenIdx + halfLen;
|
|
371
|
+
const oddR = outReal[oddIdx] ?? 0;
|
|
372
|
+
const oddI = outImag[oddIdx] ?? 0;
|
|
373
|
+
const tR = wR * oddR - wI * oddI;
|
|
374
|
+
const tI = wR * oddI + wI * oddR;
|
|
375
|
+
const evenR = outReal[evenIdx] ?? 0;
|
|
376
|
+
const evenI = outImag[evenIdx] ?? 0;
|
|
377
|
+
outReal[oddIdx] = evenR - tR;
|
|
378
|
+
outImag[oddIdx] = evenI - tI;
|
|
379
|
+
outReal[evenIdx] = evenR + tR;
|
|
380
|
+
outImag[evenIdx] = evenI + tI;
|
|
381
|
+
const nextWR = wR * wStepR - wI * wStepI;
|
|
382
|
+
wI = wR * wStepI + wI * wStepR;
|
|
383
|
+
wR = nextWR;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
real: outReal,
|
|
389
|
+
imag: outImag
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Computes the one-sided Hann-windowed power spectrum in decibels for a real-valued frame.
|
|
394
|
+
* @param samples - Input time-domain samples.
|
|
395
|
+
* @param fftSize - Power-of-two FFT size (zero-padded if `samples.length < fftSize`).
|
|
396
|
+
* @returns One-sided power spectrum in dB (`length = fftSize / 2 + 1`).
|
|
397
|
+
*/
|
|
398
|
+
function computeHannPowerSpectrumDb(samples, fftSize) {
|
|
399
|
+
const windowed = new Float64Array(fftSize);
|
|
400
|
+
const winLen = Math.min(samples.length, fftSize);
|
|
401
|
+
for (let i = 0; i < winLen; i += 1) {
|
|
402
|
+
const hann = HANN_SCALE * (1 - Math.cos(FULL_TURN_RADIANS * i / Math.max(1, winLen - 1)));
|
|
403
|
+
windowed[i] = (samples[i] ?? 0) * hann;
|
|
404
|
+
}
|
|
405
|
+
const { real, imag } = fft(windowed, new Float64Array(fftSize));
|
|
406
|
+
const numBins = (fftSize >> 1) + 1;
|
|
407
|
+
const powerDb = new Float64Array(numBins);
|
|
408
|
+
const norm = 1 / Math.max(1, winLen);
|
|
409
|
+
for (let bin = 0; bin < numBins; bin += 1) {
|
|
410
|
+
const r = (real[bin] ?? 0) * norm;
|
|
411
|
+
const im = (imag[bin] ?? 0) * norm;
|
|
412
|
+
const magSq = Math.max(POWER_FLOOR, r * r + im * im);
|
|
413
|
+
powerDb[bin] = DB_PER_POWER_DECADE * Math.log10(magSq);
|
|
414
|
+
}
|
|
415
|
+
return powerDb;
|
|
416
|
+
}
|
|
417
|
+
// src/dsp/side-channels.ts
|
|
418
|
+
/**
|
|
419
|
+
* Detects passive 50.00 Hz vs 60.00 Hz Electrical Network Frequency (ENF) mains hum in an audio buffer to split the global search space by power-grid hemisphere.
|
|
420
|
+
* @param samples - Audio samples.
|
|
421
|
+
* @param sampleRateHz - Sample rate in Hz.
|
|
422
|
+
* @returns Detected ENF nominal grid (50 Hz, 60 Hz, or unknown), SNR, and geographic region hint.
|
|
423
|
+
*/
|
|
424
|
+
function detectEnfSignature(samples, sampleRateHz) {
|
|
425
|
+
let fftSize = ENF_MIN_FFT_SIZE;
|
|
426
|
+
while (fftSize * 2 <= samples.length && fftSize < 16384) fftSize <<= 1;
|
|
427
|
+
if (samples.length < 512) return {
|
|
428
|
+
detected: false,
|
|
429
|
+
nominalHz: "unknown",
|
|
430
|
+
measuredFreqHz: 0,
|
|
431
|
+
snrDb: 0,
|
|
432
|
+
regionHint: "Insufficient audio length for ENF analysis"
|
|
433
|
+
};
|
|
434
|
+
const spectrumDb = computeHannPowerSpectrumDb(samples, fftSize);
|
|
435
|
+
const binStepHz = sampleRateHz / fftSize;
|
|
436
|
+
const bin50 = Math.round(50 / binStepHz);
|
|
437
|
+
const bin60 = Math.round(60 / binStepHz);
|
|
438
|
+
const power50Db = Math.max(spectrumDb[bin50 - 1] ?? -120, spectrumDb[bin50] ?? -120, spectrumDb[bin50 + 1] ?? -120);
|
|
439
|
+
const power60Db = Math.max(spectrumDb[bin60 - 1] ?? -120, spectrumDb[bin60] ?? -120, spectrumDb[bin60 + 1] ?? -120);
|
|
440
|
+
const bgValues = [];
|
|
441
|
+
const startBin = Math.max(1, Math.floor(42 / binStepHz));
|
|
442
|
+
const endBin = Math.min((fftSize >> 1) - 1, Math.ceil(68 / binStepHz));
|
|
443
|
+
for (let b = startBin; b <= endBin; b += 1) {
|
|
444
|
+
const hz = b * binStepHz;
|
|
445
|
+
if (Math.abs(hz - 50) > 1.8 && Math.abs(hz - 60) > 1.8) {
|
|
446
|
+
const val = spectrumDb[b];
|
|
447
|
+
if (val !== void 0) bgValues.push(val);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
bgValues.sort((a, b) => a - b);
|
|
451
|
+
const noiseFloorDb = bgValues.length > 0 ? bgValues[bgValues.length >> 1] ?? -100 : NO_BACKGROUND_NOISE_FLOOR_DB;
|
|
452
|
+
const snr50 = power50Db - noiseFloorDb;
|
|
453
|
+
const snr60 = power60Db - noiseFloorDb;
|
|
454
|
+
if (snr50 >= 6 && snr50 > snr60 + 2) return {
|
|
455
|
+
detected: true,
|
|
456
|
+
nominalHz: "50",
|
|
457
|
+
measuredFreqHz: 50,
|
|
458
|
+
snrDb: snr50,
|
|
459
|
+
regionHint: "50 Hz Grid (Europe, UK, Asia, Africa, Australia, E. Japan)"
|
|
460
|
+
};
|
|
461
|
+
if (snr60 >= 6 && snr60 > snr50 + 2) return {
|
|
462
|
+
detected: true,
|
|
463
|
+
nominalHz: "60",
|
|
464
|
+
measuredFreqHz: 60,
|
|
465
|
+
snrDb: snr60,
|
|
466
|
+
regionHint: "60 Hz Grid (North America, South Korea, Taiwan, W. Japan)"
|
|
467
|
+
};
|
|
468
|
+
return {
|
|
469
|
+
detected: false,
|
|
470
|
+
nominalHz: "unknown",
|
|
471
|
+
measuredFreqHz: 0,
|
|
472
|
+
snrDb: Math.max(0, snr50, snr60),
|
|
473
|
+
regionHint: "No dominant 50/60 Hz mains hum detected"
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
// src/dsp/channels.ts
|
|
477
|
+
/**
|
|
478
|
+
* Averages equal-length audio channels into one mono signal.
|
|
479
|
+
* @param channels - One sample array per channel; at least one, all of the same length.
|
|
480
|
+
* @returns The mono signal. A single channel is returned as a copy, never the caller's array.
|
|
481
|
+
*/
|
|
482
|
+
function downmixToMono(channels) {
|
|
483
|
+
const [first] = channels;
|
|
484
|
+
if (first === void 0) throw new Error("downmixToMono needs at least one channel");
|
|
485
|
+
const mono = new Float32Array(first.length);
|
|
486
|
+
for (const channel of channels) {
|
|
487
|
+
if (channel.length !== first.length) throw new Error("downmixToMono needs channels of equal length");
|
|
488
|
+
for (let index = 0; index < channel.length; index += 1) mono[index] = (mono[index] ?? 0) + (channel[index] ?? 0);
|
|
489
|
+
}
|
|
490
|
+
const count = channels.length;
|
|
491
|
+
if (count > 1) for (let index = 0; index < mono.length; index += 1) mono[index] = (mono[index] ?? 0) / count;
|
|
492
|
+
return mono;
|
|
493
|
+
}
|
|
494
|
+
// src/dsp/wav.ts
|
|
495
|
+
/** WAVE format tags (RIFF `fmt ` chunk) this decoder understands. */
|
|
496
|
+
const FORMAT_PCM = 1;
|
|
497
|
+
const FORMAT_FLOAT = 3;
|
|
498
|
+
/** Extensible format: the real tag is the first two bytes of the sub-format GUID. */
|
|
499
|
+
const FORMAT_EXTENSIBLE = 65534;
|
|
500
|
+
/** Offset of the sub-format tag inside an extensible `fmt ` chunk body (after the 16-byte base, size, valid bits and channel mask). */
|
|
501
|
+
const EXTENSIBLE_SUBFORMAT_OFFSET = 24;
|
|
502
|
+
/** Smallest `fmt ` chunk body: tag, channels, rate, byte rate, block align, bits. */
|
|
503
|
+
const MIN_FMT_BODY_BYTES = 16;
|
|
504
|
+
/** Offset of the 16-bit channel count inside a `fmt ` chunk body, after the 16-bit tag. */
|
|
505
|
+
const FMT_CHANNELS_OFFSET = 2;
|
|
506
|
+
/** Offset of the 32-bit sample rate inside a `fmt ` chunk body. */
|
|
507
|
+
const FMT_SAMPLE_RATE_OFFSET = 4;
|
|
508
|
+
/** Offset of the 16-bit bits per sample inside a `fmt ` chunk body. */
|
|
509
|
+
const FMT_BITS_PER_SAMPLE_OFFSET = 14;
|
|
510
|
+
/** Bytes of a four-character code: a chunk id or the RIFF form type. */
|
|
511
|
+
const FOUR_CC_BYTES = 4;
|
|
512
|
+
/** Bytes of a chunk header: four-character id and a 32-bit size. */
|
|
513
|
+
const CHUNK_HEADER_BYTES = 8;
|
|
514
|
+
/** Bytes of the RIFF preamble: `RIFF`, size and `WAVE`. */
|
|
515
|
+
const RIFF_PREAMBLE_BYTES = 12;
|
|
516
|
+
/** Bits in a byte. */
|
|
517
|
+
const BITS_PER_BYTE = 8;
|
|
518
|
+
/** Bit depth of unsigned 8-bit PCM. */
|
|
519
|
+
const PCM_BITS_UINT8 = 8;
|
|
520
|
+
/** Bit depth of signed 16-bit PCM. */
|
|
521
|
+
const PCM_BITS_INT16 = 16;
|
|
522
|
+
/** Bit depth of signed 24-bit PCM. */
|
|
523
|
+
const PCM_BITS_INT24 = 24;
|
|
524
|
+
/** Bit depth of signed 32-bit PCM. */
|
|
525
|
+
const PCM_BITS_INT32 = 32;
|
|
526
|
+
/** Bit depth of single-precision float samples. */
|
|
527
|
+
const FLOAT_BITS_SINGLE = 32;
|
|
528
|
+
/** Bit depth of double-precision float samples. */
|
|
529
|
+
const FLOAT_BITS_DOUBLE = 64;
|
|
530
|
+
/** Magnitude of the most negative signed integer of a bit depth, which maps to -1.0. */
|
|
531
|
+
const signedFullScale = (bits) => 2 ** (bits - 1);
|
|
532
|
+
/** Unsigned 8-bit PCM value that is silence, and the divisor that maps its offset from silence to `[-1, 1)`. */
|
|
533
|
+
const UINT8_MIDPOINT = signedFullScale(PCM_BITS_UINT8);
|
|
534
|
+
/** Divisor that maps a signed 16-bit sample to `[-1, 1)`. */
|
|
535
|
+
const INT16_FULL_SCALE = signedFullScale(PCM_BITS_INT16);
|
|
536
|
+
/** Divisor that maps a signed 24-bit sample to `[-1, 1)`. */
|
|
537
|
+
const INT24_FULL_SCALE = signedFullScale(PCM_BITS_INT24);
|
|
538
|
+
/** Divisor that maps a signed 32-bit sample to `[-1, 1)`. */
|
|
539
|
+
const INT32_FULL_SCALE = signedFullScale(PCM_BITS_INT32);
|
|
540
|
+
function fourCc(view, offset) {
|
|
541
|
+
return String.fromCharCode(...Array.from({ length: FOUR_CC_BYTES }, (_, index) => view.getUint8(offset + index)));
|
|
542
|
+
}
|
|
543
|
+
function readFormat(view, bodyOffset, bodySize) {
|
|
544
|
+
if (bodySize < MIN_FMT_BODY_BYTES) throw new Error(`WAV fmt chunk is ${String(bodySize)} bytes, shorter than the ${String(MIN_FMT_BODY_BYTES)} required`);
|
|
545
|
+
let tag = view.getUint16(bodyOffset, true);
|
|
546
|
+
if (tag === FORMAT_EXTENSIBLE) {
|
|
547
|
+
if (bodySize < 26) throw new Error("WAV extensible fmt chunk is too short to name its sub-format");
|
|
548
|
+
tag = view.getUint16(bodyOffset + EXTENSIBLE_SUBFORMAT_OFFSET, true);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
tag,
|
|
552
|
+
channels: view.getUint16(bodyOffset + FMT_CHANNELS_OFFSET, true),
|
|
553
|
+
sampleRateHz: view.getUint32(bodyOffset + FMT_SAMPLE_RATE_OFFSET, true),
|
|
554
|
+
bitsPerSample: view.getUint16(bodyOffset + FMT_BITS_PER_SAMPLE_OFFSET, true)
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Reads one sample as a float in `[-1, 1]`.
|
|
559
|
+
* @param view - The file bytes.
|
|
560
|
+
* @param position - Byte offset of the sample.
|
|
561
|
+
* @param format - The decoded `fmt ` chunk.
|
|
562
|
+
* @returns The sample value.
|
|
563
|
+
* @throws When the tag and bit depth are not a supported combination.
|
|
564
|
+
*/
|
|
565
|
+
function readSample(view, position, format) {
|
|
566
|
+
const { tag, bitsPerSample } = format;
|
|
567
|
+
if (tag === FORMAT_FLOAT) {
|
|
568
|
+
if (bitsPerSample === FLOAT_BITS_SINGLE) return view.getFloat32(position, true);
|
|
569
|
+
if (bitsPerSample === FLOAT_BITS_DOUBLE) return view.getFloat64(position, true);
|
|
570
|
+
} else if (tag === FORMAT_PCM) switch (bitsPerSample) {
|
|
571
|
+
case PCM_BITS_UINT8: return (view.getUint8(position) - UINT8_MIDPOINT) / UINT8_MIDPOINT;
|
|
572
|
+
case PCM_BITS_INT16: return view.getInt16(position, true) / INT16_FULL_SCALE;
|
|
573
|
+
case PCM_BITS_INT24: return (view.getInt8(position + 2) << 16 | view.getUint8(position + 1) << BITS_PER_BYTE | view.getUint8(position)) / INT24_FULL_SCALE;
|
|
574
|
+
case PCM_BITS_INT32: return view.getInt32(position, true) / INT32_FULL_SCALE;
|
|
575
|
+
}
|
|
576
|
+
throw new Error(`Unsupported WAV sample format: tag ${String(tag)}, ${String(bitsPerSample)} bits`);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Decodes a RIFF WAVE byte buffer into mono samples and its sample rate. Chunks are located by walking the chunk list, so metadata chunks (`JUNK`, `bext`, `LIST`) before `fmt ` or `data` are skipped, and an odd-sized chunk's pad byte is honoured. Multi-channel audio is averaged to mono.
|
|
580
|
+
* @param wavBytes - Raw `.wav` file bytes.
|
|
581
|
+
* @returns Decoded mono samples in `[-1, 1]` and the sample rate in Hz.
|
|
582
|
+
* @throws When the buffer is not RIFF/WAVE, has no `fmt ` or `data` chunk, declares no channels, or uses an unsupported tag or bit depth (8, 16, 24 and 32-bit integer PCM and 32 and 64-bit float are supported).
|
|
583
|
+
*/
|
|
584
|
+
function decodeWavBytes(wavBytes) {
|
|
585
|
+
if (wavBytes.byteLength < RIFF_PREAMBLE_BYTES) throw new Error(`WAV buffer is shorter than the ${String(RIFF_PREAMBLE_BYTES)}-byte RIFF preamble`);
|
|
586
|
+
const view = new DataView(wavBytes.buffer, wavBytes.byteOffset, wavBytes.byteLength);
|
|
587
|
+
const riff = fourCc(view, 0);
|
|
588
|
+
if (riff !== "RIFF") throw new Error(`Expected RIFF header, got ${riff}`);
|
|
589
|
+
const form = fourCc(view, CHUNK_HEADER_BYTES);
|
|
590
|
+
if (form !== "WAVE") throw new Error(`Expected WAVE form, got ${form}`);
|
|
591
|
+
let format;
|
|
592
|
+
let dataOffset;
|
|
593
|
+
let dataLength = 0;
|
|
594
|
+
let cursor = RIFF_PREAMBLE_BYTES;
|
|
595
|
+
while (cursor + CHUNK_HEADER_BYTES <= wavBytes.byteLength) {
|
|
596
|
+
const chunkId = fourCc(view, cursor);
|
|
597
|
+
const chunkSize = view.getUint32(cursor + FOUR_CC_BYTES, true);
|
|
598
|
+
const bodyOffset = cursor + CHUNK_HEADER_BYTES;
|
|
599
|
+
if (chunkId === "fmt ") format = readFormat(view, bodyOffset, Math.min(chunkSize, wavBytes.byteLength - bodyOffset));
|
|
600
|
+
else if (chunkId === "data") {
|
|
601
|
+
dataOffset = bodyOffset;
|
|
602
|
+
dataLength = Math.min(chunkSize, wavBytes.byteLength - bodyOffset);
|
|
603
|
+
}
|
|
604
|
+
if (format !== void 0 && dataOffset !== void 0) break;
|
|
605
|
+
cursor = bodyOffset + chunkSize + chunkSize % 2;
|
|
606
|
+
}
|
|
607
|
+
if (format === void 0) throw new Error("WAV file has no fmt chunk");
|
|
608
|
+
if (dataOffset === void 0) throw new Error("WAV file has no data chunk");
|
|
609
|
+
if (format.channels < 1) throw new Error("WAV file declares no channels");
|
|
610
|
+
const bytesPerSample = format.bitsPerSample / BITS_PER_BYTE;
|
|
611
|
+
const frameStride = format.channels * bytesPerSample;
|
|
612
|
+
const numFrames = Math.floor(dataLength / frameStride);
|
|
613
|
+
const channels = Array.from({ length: format.channels }, () => new Float32Array(numFrames));
|
|
614
|
+
for (let frame = 0; frame < numFrames; frame += 1) for (let channel = 0; channel < format.channels; channel += 1) {
|
|
615
|
+
const target = channels[channel];
|
|
616
|
+
if (target !== void 0) target[frame] = readSample(view, dataOffset + frame * frameStride + channel * bytesPerSample, format);
|
|
617
|
+
}
|
|
618
|
+
return {
|
|
619
|
+
samples: downmixToMono(channels),
|
|
620
|
+
sampleRateHz: format.sampleRateHz
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
// src/solver/constellation.ts
|
|
624
|
+
/** Width in m/s of a groundspeed bucket in a constellation key. A design choice: a wider bucket tolerates more speed measurement error and admits more false pair matches. */
|
|
625
|
+
const SPEED_BUCKET_MPS = 3;
|
|
626
|
+
/** Width in seconds of a closest-approach time-offset bucket in a constellation key. A design choice with the same trade-off as the speed bucket. */
|
|
627
|
+
const DELTA_BUCKET_SEC = 5;
|
|
628
|
+
/** Largest closest-approach slant distance in metres at which a segment is paired within a cell: the acoustic range of an aircraft. */
|
|
629
|
+
const MAX_PAIR_SLANT_METERS = 1e4;
|
|
630
|
+
/** Shortest closest-approach time offset in seconds between two flyovers that forms a pair. */
|
|
631
|
+
const MIN_PAIR_DELTA_SEC = 10;
|
|
632
|
+
/** Longest closest-approach time offset in seconds between two flyovers that forms a pair (40 minutes). */
|
|
633
|
+
const MAX_PAIR_DELTA_SEC = 2400;
|
|
634
|
+
/** Seconds in a UTC day. */
|
|
635
|
+
const SECONDS_PER_DAY = 86400;
|
|
636
|
+
/** Seconds in an hour. */
|
|
637
|
+
const SECONDS_PER_HOUR = 3600;
|
|
638
|
+
/** Hours in a UTC day, the default end of an hour window. */
|
|
639
|
+
const HOURS_PER_DAY$1 = 24;
|
|
640
|
+
/** Westernmost longitude in degrees of the region classed as 60 Hz mains (the Americas). */
|
|
641
|
+
const AMERICAS_MIN_LON_DEG = -170;
|
|
642
|
+
/** Easternmost longitude in degrees of the region classed as 60 Hz mains (the Americas). */
|
|
643
|
+
const AMERICAS_MAX_LON_DEG = -30;
|
|
644
|
+
/**
|
|
645
|
+
* Quantises two groundspeeds and a time offset into the integer buckets of a constellation key.
|
|
646
|
+
* @param speedAMps - Groundspeed of first flyover in m/s.
|
|
647
|
+
* @param speedBMps - Groundspeed of second flyover in m/s.
|
|
648
|
+
* @param deltaSec - Time offset `t_CPA_B - t_CPA_A` in seconds.
|
|
649
|
+
* @returns The speed A, speed B and time-offset bucket indices.
|
|
650
|
+
*/
|
|
651
|
+
function quantiseConstellation(speedAMps, speedBMps, deltaSec) {
|
|
652
|
+
return [
|
|
653
|
+
Math.round(speedAMps / SPEED_BUCKET_MPS),
|
|
654
|
+
Math.round(speedBMps / SPEED_BUCKET_MPS),
|
|
655
|
+
Math.round(deltaSec / DELTA_BUCKET_SEC)
|
|
656
|
+
];
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Computes a quantized Shazam-style constellation hash key from two aircraft groundspeeds and their delayed CPA time offset.
|
|
660
|
+
* @param speedAMps - Groundspeed of first flyover in m/s.
|
|
661
|
+
* @param speedBMps - Groundspeed of second flyover in m/s.
|
|
662
|
+
* @param deltaSec - Time offset `t_CPA_B - t_CPA_A` in seconds.
|
|
663
|
+
* @returns Quantized constellation hash key string.
|
|
664
|
+
*/
|
|
665
|
+
function formatConstellationKey(speedAMps, speedBMps, deltaSec) {
|
|
666
|
+
const [qSpeedA, qSpeedB, qDelta] = quantiseConstellation(speedAMps, speedBMps, deltaSec);
|
|
667
|
+
return `v${String(qSpeedA)}_v${String(qSpeedB)}_dt${String(qDelta)}`;
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Generates neighbouring constellation hash keys within measurement tolerance (`QUERY_BUCKET_SPAN` buckets either side on each of the speed and time axes) to prevent boundary quantization misses.
|
|
671
|
+
* @param speedAMps - Inferred speed of first flyover in m/s.
|
|
672
|
+
* @param speedBMps - Inferred speed of second flyover in m/s.
|
|
673
|
+
* @param deltaSec - Measured CPA offset in seconds.
|
|
674
|
+
* @returns Set of candidate constellation hash keys.
|
|
675
|
+
*/
|
|
676
|
+
function expandConstellationQueryKeys(speedAMps, speedBMps, deltaSec) {
|
|
677
|
+
const [baseA, baseB, baseDt] = quantiseConstellation(speedAMps, speedBMps, deltaSec);
|
|
678
|
+
const keys = /* @__PURE__ */ new Set();
|
|
679
|
+
for (let da = -2; da <= 2; da += 1) for (let db = -2; db <= 2; db += 1) for (let ddt = -2; ddt <= 2; ddt += 1) keys.add(`v${String(baseA + da)}_v${String(baseB + db)}_dt${String(baseDt + ddt)}`);
|
|
680
|
+
return keys;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Builds the Shazam-style Constellation Index from a collection of 4D linear flight segments.
|
|
684
|
+
* Pairs any two non-jet segments passing within acoustic range (`MAX_PAIR_SLANT_METERS`) of the same cell, with closest-approach times between `MIN_PAIR_DELTA_SEC` and `MAX_PAIR_DELTA_SEC` apart, on the same date.
|
|
685
|
+
* @param segments - Linear flight segments.
|
|
686
|
+
* @param options - DEM elevation grid samples and speed of sound.
|
|
687
|
+
* @returns Array of indexed constellation pair entries.
|
|
688
|
+
*/
|
|
689
|
+
function buildConstellationIndex(segments, options = {}) {
|
|
690
|
+
const { demGrid = [], soundSpeedMps = 343 } = options;
|
|
691
|
+
const nonJet = segments.filter((s) => s.category !== "jet");
|
|
692
|
+
const byCellAndDate = /* @__PURE__ */ new Map();
|
|
693
|
+
for (const seg of nonJet) {
|
|
694
|
+
const groupKey = `${seg.dateIso}|${seg.h3Cell}`;
|
|
695
|
+
const existing = byCellAndDate.get(groupKey);
|
|
696
|
+
if (existing !== void 0) existing.push(seg);
|
|
697
|
+
else byCellAndDate.set(groupKey, [seg]);
|
|
698
|
+
}
|
|
699
|
+
const entries = [];
|
|
700
|
+
for (const [, group] of byCellAndDate) {
|
|
701
|
+
if (group.length < 2) continue;
|
|
702
|
+
const firstSeg = group[0];
|
|
703
|
+
if (firstSeg === void 0) continue;
|
|
704
|
+
const center = decodeSpatialCellCenter(firstSeg.h3Cell);
|
|
705
|
+
const groundAlt = interpolateTerrainElevationMeters(center.lat, center.lon, demGrid);
|
|
706
|
+
const cellObserver = {
|
|
707
|
+
lat: center.lat,
|
|
708
|
+
lon: center.lon,
|
|
709
|
+
altMeters: groundAlt
|
|
710
|
+
};
|
|
711
|
+
const cpas = group.map((seg) => ({
|
|
712
|
+
seg,
|
|
713
|
+
cpa: computeSegmentCpa(cellObserver, seg, soundSpeedMps)
|
|
714
|
+
})).filter((item) => item.cpa.cpaSlantMeters <= MAX_PAIR_SLANT_METERS).sort((a, b) => a.cpa.cpaDelayedEpochSec - b.cpa.cpaDelayedEpochSec);
|
|
715
|
+
for (let i = 0; i < cpas.length; i += 1) for (let j = i + 1; j < cpas.length; j += 1) {
|
|
716
|
+
const a = cpas[i];
|
|
717
|
+
const b = cpas[j];
|
|
718
|
+
if (a === void 0 || b === void 0 || a.seg.flightId === b.seg.flightId) continue;
|
|
719
|
+
const deltaSec = b.cpa.cpaDelayedEpochSec - a.cpa.cpaDelayedEpochSec;
|
|
720
|
+
if (deltaSec < MIN_PAIR_DELTA_SEC || deltaSec > MAX_PAIR_DELTA_SEC) continue;
|
|
721
|
+
const hashKey = formatConstellationKey(a.seg.groundspeedMps, b.seg.groundspeedMps, deltaSec);
|
|
722
|
+
entries.push({
|
|
723
|
+
hashKey,
|
|
724
|
+
dateIso: a.seg.dateIso,
|
|
725
|
+
h3Cell: a.seg.h3Cell,
|
|
726
|
+
centerLat: center.lat,
|
|
727
|
+
centerLon: center.lon,
|
|
728
|
+
segmentIdA: a.seg.id,
|
|
729
|
+
segmentIdB: b.seg.id,
|
|
730
|
+
speedAMps: a.seg.groundspeedMps,
|
|
731
|
+
speedBMps: b.seg.groundspeedMps,
|
|
732
|
+
cpaDelayedEpochSecA: a.cpa.cpaDelayedEpochSec,
|
|
733
|
+
cpaDelayedEpochSecB: b.cpa.cpaDelayedEpochSec,
|
|
734
|
+
deltaSec
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return entries;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Checks whether a geographic coordinate and UTC epoch timestamp satisfy all active multi-axis user and side-channel constraints (Space, Date, Time of Day, and 50/60 Hz ENF power grid).
|
|
742
|
+
* @param target - Candidate coordinate, date, and epoch timestamp.
|
|
743
|
+
* @param constraints - Active multi-axis search constraints (any undefined field is treated as unconstrained `*`).
|
|
744
|
+
* @returns `true` if the candidate satisfies all specified constraints.
|
|
745
|
+
*/
|
|
746
|
+
function matchesSearchConstraints(target, constraints) {
|
|
747
|
+
const { lat, lon, dateIso, epochSec } = target;
|
|
748
|
+
if (constraints.minLat !== void 0 && lat < constraints.minLat) return false;
|
|
749
|
+
if (constraints.maxLat !== void 0 && lat > constraints.maxLat) return false;
|
|
750
|
+
if (constraints.minLon !== void 0 && lon < constraints.minLon) return false;
|
|
751
|
+
if (constraints.maxLon !== void 0 && lon > constraints.maxLon) return false;
|
|
752
|
+
if (constraints.dateFromIso !== void 0 && dateIso < constraints.dateFromIso) return false;
|
|
753
|
+
if (constraints.dateToIso !== void 0 && dateIso > constraints.dateToIso) return false;
|
|
754
|
+
if (constraints.utcHourStart !== void 0 || constraints.utcHourEnd !== void 0) {
|
|
755
|
+
const utcHour = (epochSec % SECONDS_PER_DAY + SECONDS_PER_DAY) % SECONDS_PER_DAY / SECONDS_PER_HOUR;
|
|
756
|
+
const startH = constraints.utcHourStart ?? 0;
|
|
757
|
+
const endH = constraints.utcHourEnd ?? HOURS_PER_DAY$1;
|
|
758
|
+
if (startH <= endH) {
|
|
759
|
+
if (utcHour < startH || utcHour > endH) return false;
|
|
760
|
+
} else if (utcHour < startH && utcHour > endH) return false;
|
|
761
|
+
}
|
|
762
|
+
if (constraints.enfNominalHz !== void 0) {
|
|
763
|
+
const isAmericasLon = lon >= AMERICAS_MIN_LON_DEG && lon <= AMERICAS_MAX_LON_DEG;
|
|
764
|
+
if (constraints.enfNominalHz === "60" && !isAmericasLon) return false;
|
|
765
|
+
if (constraints.enfNominalHz === "50" && isAmericasLon) return false;
|
|
766
|
+
}
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
// src/bundled.ts
|
|
770
|
+
/**
|
|
771
|
+
* Reference ground truth observer location used for offline demonstration and regression testing.
|
|
772
|
+
* Oxford, UK (51.7520° N, 1.2577° W, elevation 64 m).
|
|
773
|
+
*/
|
|
774
|
+
const BUNDLED_GROUND_TRUTH = {
|
|
775
|
+
lat: 51.752,
|
|
776
|
+
lon: -1.2577,
|
|
777
|
+
altMeters: 64
|
|
778
|
+
};
|
|
779
|
+
/**
|
|
780
|
+
* Realistic piecewise-linear flight segments crossing the bundled reference region.
|
|
781
|
+
*/
|
|
782
|
+
const BUNDLED_SEGMENTS = [
|
|
783
|
+
{
|
|
784
|
+
id: "flyover_alpha",
|
|
785
|
+
flightId: "G-BOAC",
|
|
786
|
+
icao24: "4009a1",
|
|
787
|
+
typeCode: "C172",
|
|
788
|
+
category: "piston",
|
|
789
|
+
dateIso: "2026-09-08",
|
|
790
|
+
startEpochSec: 17889e5,
|
|
791
|
+
endEpochSec: 1788900180,
|
|
792
|
+
startGeo: localEnuToGeo(BUNDLED_GROUND_TRUTH, {
|
|
793
|
+
x: -6e3,
|
|
794
|
+
y: -1200,
|
|
795
|
+
z: 950
|
|
796
|
+
}),
|
|
797
|
+
endGeo: localEnuToGeo(BUNDLED_GROUND_TRUTH, {
|
|
798
|
+
x: 6e3,
|
|
799
|
+
y: -800,
|
|
800
|
+
z: 980
|
|
801
|
+
}),
|
|
802
|
+
groundspeedMps: 66.8,
|
|
803
|
+
h3Cell: encodeSpatialCell(BUNDLED_GROUND_TRUTH.lat, BUNDLED_GROUND_TRUTH.lon, 6)
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
id: "flyover_bravo",
|
|
807
|
+
flightId: "G-KEST",
|
|
808
|
+
icao24: "4011ef",
|
|
809
|
+
typeCode: "PA28",
|
|
810
|
+
category: "piston",
|
|
811
|
+
dateIso: "2026-09-08",
|
|
812
|
+
startEpochSec: 1788900920,
|
|
813
|
+
endEpochSec: 1788901100,
|
|
814
|
+
startGeo: localEnuToGeo(BUNDLED_GROUND_TRUTH, {
|
|
815
|
+
x: 1400,
|
|
816
|
+
y: -7e3,
|
|
817
|
+
z: 1200
|
|
818
|
+
}),
|
|
819
|
+
endGeo: localEnuToGeo(BUNDLED_GROUND_TRUTH, {
|
|
820
|
+
x: 800,
|
|
821
|
+
y: 7e3,
|
|
822
|
+
z: 1250
|
|
823
|
+
}),
|
|
824
|
+
groundspeedMps: 77.9,
|
|
825
|
+
h3Cell: encodeSpatialCell(BUNDLED_GROUND_TRUTH.lat, BUNDLED_GROUND_TRUTH.lon, 6)
|
|
826
|
+
},
|
|
827
|
+
{
|
|
828
|
+
id: "flyover_charlie",
|
|
829
|
+
flightId: "G-ROTN",
|
|
830
|
+
icao24: "4022bc",
|
|
831
|
+
typeCode: "DA42",
|
|
832
|
+
category: "turboprop",
|
|
833
|
+
dateIso: "2026-09-08",
|
|
834
|
+
startEpochSec: 1788901850,
|
|
835
|
+
endEpochSec: 1788902030,
|
|
836
|
+
startGeo: localEnuToGeo(BUNDLED_GROUND_TRUTH, {
|
|
837
|
+
x: -5e3,
|
|
838
|
+
y: 5e3,
|
|
839
|
+
z: 1400
|
|
840
|
+
}),
|
|
841
|
+
endGeo: localEnuToGeo(BUNDLED_GROUND_TRUTH, {
|
|
842
|
+
x: 5e3,
|
|
843
|
+
y: -5e3,
|
|
844
|
+
z: 1350
|
|
845
|
+
}),
|
|
846
|
+
groundspeedMps: 78.5,
|
|
847
|
+
h3Cell: encodeSpatialCell(BUNDLED_GROUND_TRUTH.lat, BUNDLED_GROUND_TRUTH.lon, 6)
|
|
848
|
+
}
|
|
849
|
+
];
|
|
850
|
+
/**
|
|
851
|
+
* Regional DEM grid surrounding the reference location.
|
|
852
|
+
*/
|
|
853
|
+
const BUNDLED_DEM_GRID = [
|
|
854
|
+
{
|
|
855
|
+
lat: 51.7,
|
|
856
|
+
lon: -1.3,
|
|
857
|
+
altMeters: 60
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
lat: 51.7,
|
|
861
|
+
lon: -1.2,
|
|
862
|
+
altMeters: 62
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
lat: 51.8,
|
|
866
|
+
lon: -1.3,
|
|
867
|
+
altMeters: 75
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
lat: 51.8,
|
|
871
|
+
lon: -1.2,
|
|
872
|
+
altMeters: 68
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
lat: 51.752,
|
|
876
|
+
lon: -1.2577,
|
|
877
|
+
altMeters: 64
|
|
878
|
+
}
|
|
879
|
+
];
|
|
880
|
+
/**
|
|
881
|
+
* Complete bundled DopplerDataset object available synchronously without network requests.
|
|
882
|
+
*/
|
|
883
|
+
const BUNDLED_DATASET = {
|
|
884
|
+
version: "1.0.0-bundled",
|
|
885
|
+
generatedAtIso: "2026-09-08T00:00:00Z",
|
|
886
|
+
segments: BUNDLED_SEGMENTS,
|
|
887
|
+
constellations: buildConstellationIndex(BUNDLED_SEGMENTS, { demGrid: BUNDLED_DEM_GRID }),
|
|
888
|
+
elevationGrid: BUNDLED_DEM_GRID
|
|
889
|
+
};
|
|
890
|
+
// src/dsp/decimate.ts
|
|
891
|
+
/**
|
|
892
|
+
* Width of a Blackman window's transition band as a multiple of the sampling rate divided by the tap count: `transition = 5.5 * rate / taps`.
|
|
893
|
+
*/
|
|
894
|
+
const BLACKMAN_TRANSITION_FACTOR = 5.5;
|
|
895
|
+
/**
|
|
896
|
+
* Share of the output sample rate kept as passband. The filter is flat to this fraction of the output rate and must be fully attenuated from the output rate less this fraction, the lowest frequency that folds back into the passband.
|
|
897
|
+
*/
|
|
898
|
+
const PASSBAND_FRACTION = .4;
|
|
899
|
+
/**
|
|
900
|
+
* Constant term of the Blackman window.
|
|
901
|
+
*/
|
|
902
|
+
const BLACKMAN_A0 = .42;
|
|
903
|
+
/**
|
|
904
|
+
* Amplitude of the fundamental cosine of the Blackman window.
|
|
905
|
+
*/
|
|
906
|
+
const BLACKMAN_A1 = .5;
|
|
907
|
+
/**
|
|
908
|
+
* Amplitude of the second-harmonic cosine of the Blackman window.
|
|
909
|
+
*/
|
|
910
|
+
const BLACKMAN_A2 = .08;
|
|
911
|
+
/**
|
|
912
|
+
* Smallest absolute kernel sum that is still normalised by; a sum below it means the kernel carries no usable gain, so it is left as designed rather than amplified by a near-zero divisor.
|
|
913
|
+
*/
|
|
914
|
+
const MIN_KERNEL_SUM = 1e-12;
|
|
915
|
+
/**
|
|
916
|
+
* Designs a normalized Blackman-windowed sinc low-pass FIR filter kernel.
|
|
917
|
+
* @param cutoffHz - Low-pass cutoff frequency in Hz.
|
|
918
|
+
* @param sampleRateHz - Input sample rate in Hz.
|
|
919
|
+
* @param numTaps - Odd number of FIR filter taps.
|
|
920
|
+
* @returns Normalized FIR filter coefficients.
|
|
921
|
+
*/
|
|
922
|
+
function designLowPassFirKernel(cutoffHz, sampleRateHz, numTaps = 97) {
|
|
923
|
+
const taps = numTaps % 2 === 1 ? numTaps : numTaps + 1;
|
|
924
|
+
const kernel = new Float64Array(taps);
|
|
925
|
+
const center = (taps - 1) / 2;
|
|
926
|
+
const normalizedCutoff = cutoffHz / sampleRateHz;
|
|
927
|
+
let sum = 0;
|
|
928
|
+
for (let i = 0; i < taps; i += 1) {
|
|
929
|
+
const n = i - center;
|
|
930
|
+
const sinc = n === 0 ? 2 * normalizedCutoff : Math.sin(2 * Math.PI * normalizedCutoff * n) / (Math.PI * n);
|
|
931
|
+
const windowPhase = 2 * Math.PI * i / (taps - 1);
|
|
932
|
+
const coeff = sinc * (BLACKMAN_A0 - BLACKMAN_A1 * Math.cos(windowPhase) + BLACKMAN_A2 * Math.cos(2 * windowPhase));
|
|
933
|
+
kernel[i] = coeff;
|
|
934
|
+
sum += coeff;
|
|
935
|
+
}
|
|
936
|
+
if (Math.abs(sum) > MIN_KERNEL_SUM) for (let i = 0; i < taps; i += 1) kernel[i] = (kernel[i] ?? 0) / sum;
|
|
937
|
+
return kernel;
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Decimates a high-sample-rate audio buffer (e.g. 48,000 Hz) down to a target sub-kilohertz/low-kilohertz rate (e.g. 2,000 Hz) after applying an anti-aliasing FIR low-pass filter.
|
|
941
|
+
* @param input - Input mono audio samples.
|
|
942
|
+
* @param inputRateHz - Original sample rate in Hz (e.g. 48000).
|
|
943
|
+
* @param targetRateHz - Decimated sample rate in Hz (default `ANALYSIS_SAMPLE_RATE_HZ`).
|
|
944
|
+
* @returns Decimated mono audio samples and actual output sample rate.
|
|
945
|
+
*/
|
|
946
|
+
function decimateAudioStream(input, inputRateHz, targetRateHz = ANALYSIS_SAMPLE_RATE_HZ) {
|
|
947
|
+
if (!(inputRateHz > 0) || !(targetRateHz > 0)) throw new Error(`Sample rates must be positive, got input ${String(inputRateHz)} Hz and target ${String(targetRateHz)} Hz`);
|
|
948
|
+
if (inputRateHz <= targetRateHz) return {
|
|
949
|
+
samples: new Float32Array(input),
|
|
950
|
+
sampleRateHz: inputRateHz
|
|
951
|
+
};
|
|
952
|
+
const ratio = Math.max(1, Math.round(inputRateHz / targetRateHz));
|
|
953
|
+
const actualRateHz = inputRateHz / ratio;
|
|
954
|
+
const cutoffHz = PASSBAND_FRACTION * actualRateHz;
|
|
955
|
+
const transitionHz = actualRateHz - 2 * cutoffHz;
|
|
956
|
+
const kernel = designLowPassFirKernel(cutoffHz, inputRateHz, Math.ceil(BLACKMAN_TRANSITION_FACTOR * inputRateHz / transitionHz));
|
|
957
|
+
const halfKernel = kernel.length - 1 >> 1;
|
|
958
|
+
const outLength = Math.floor(input.length / ratio);
|
|
959
|
+
const output = new Float32Array(outLength);
|
|
960
|
+
for (let outIdx = 0; outIdx < outLength; outIdx += 1) {
|
|
961
|
+
const centerInIdx = outIdx * ratio;
|
|
962
|
+
let acc = 0;
|
|
963
|
+
for (let k = 0; k < kernel.length; k += 1) {
|
|
964
|
+
const sampleIdx = centerInIdx + k - halfKernel;
|
|
965
|
+
if (sampleIdx >= 0 && sampleIdx < input.length) acc += (input[sampleIdx] ?? 0) * (kernel[k] ?? 0);
|
|
966
|
+
}
|
|
967
|
+
output[outIdx] = acc;
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
samples: output,
|
|
971
|
+
sampleRateHz: actualRateHz
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
// src/physics/algebra.ts
|
|
975
|
+
/** Default one-sigma frequency measurement uncertainty of an asymptote in Hz; a design default that callers with a known spectrogram resolution override. */
|
|
976
|
+
const DEFAULT_FREQ_MEASUREMENT_STD_HZ = .15;
|
|
977
|
+
/** Default one-sigma relative uncertainty of the measured inflection slope (dimensionless, 5 per cent). */
|
|
978
|
+
const DEFAULT_SLOPE_REL_STD = .05;
|
|
979
|
+
/** Floor on the inferred groundspeed in m/s used as the divisor of the relative speed uncertainty, so a near-zero speed cannot divide by zero. */
|
|
980
|
+
const MIN_INFERRED_SPEED_MPS = .001;
|
|
981
|
+
/**
|
|
982
|
+
* Separates aircraft groundspeed `v`, unshifted engine fundamental `f0`, and 3D Closest Point of Approach (CPA) slant distance `d_slant` in O(1) closed-form algebra directly from a Doppler curve's asymptotes and inflection slope.
|
|
983
|
+
*
|
|
984
|
+
* Because `f+ / f- = (1 + v/c) / (1 - v/c)`, the unknown engine base pitch `f0` cancels out identically:
|
|
985
|
+
* - `v = c * (f+ - f-) / (f+ + f-)`
|
|
986
|
+
* - `f0 = 2 * f+ * f- / (f+ + f-)` (the harmonic mean)
|
|
987
|
+
* - `d_slant = - (f0 * v^2) / (c * df/dt|_CPA)`
|
|
988
|
+
* @param measurement - Measured approaching/receding frequencies and inflection slope.
|
|
989
|
+
* @returns Closed-form kinematic parameters and propagated standard deviations.
|
|
990
|
+
*/
|
|
991
|
+
function extractAsymptoticKinematics(measurement) {
|
|
992
|
+
const { asymptoticHighHz, asymptoticLowHz, inflectionSlopeHzPerSec } = measurement;
|
|
993
|
+
const soundSpeedMps = measurement.soundSpeedMps ?? 343;
|
|
994
|
+
const freqStd = measurement.freqMeasurementStdHz ?? DEFAULT_FREQ_MEASUREMENT_STD_HZ;
|
|
995
|
+
const slopeRelStd = measurement.slopeMeasurementRelStd ?? DEFAULT_SLOPE_REL_STD;
|
|
996
|
+
if (asymptoticHighHz <= asymptoticLowHz || asymptoticLowHz <= 0) throw new Error(`Approaching frequency (${String(asymptoticHighHz)} Hz) must exceed positive receding frequency (${String(asymptoticLowHz)} Hz)`);
|
|
997
|
+
if (inflectionSlopeHzPerSec >= 0) throw new Error(`Doppler inflection slope at CPA must be negative, got ${String(inflectionSlopeHzPerSec)} Hz/s`);
|
|
998
|
+
const sumFreq = asymptoticHighHz + asymptoticLowHz;
|
|
999
|
+
const inferredSpeedMps = soundSpeedMps * ((asymptoticHighHz - asymptoticLowHz) / sumFreq);
|
|
1000
|
+
const baseFreqHz = 2 * asymptoticHighHz * asymptoticLowHz / sumFreq;
|
|
1001
|
+
const inferredSlantMeters = -(baseFreqHz * inferredSpeedMps * inferredSpeedMps) / (soundSpeedMps * inflectionSlopeHzPerSec);
|
|
1002
|
+
const dvDfHigh = 2 * soundSpeedMps * asymptoticLowHz / (sumFreq * sumFreq);
|
|
1003
|
+
const dvDfLow = -(2 * soundSpeedMps * asymptoticHighHz) / (sumFreq * sumFreq);
|
|
1004
|
+
const speedStdMps = Math.hypot(dvDfHigh * freqStd, dvDfLow * freqStd);
|
|
1005
|
+
const relSpeedStd = speedStdMps / Math.max(MIN_INFERRED_SPEED_MPS, inferredSpeedMps);
|
|
1006
|
+
return {
|
|
1007
|
+
asymptoticHighHz,
|
|
1008
|
+
asymptoticLowHz,
|
|
1009
|
+
baseFreqHz,
|
|
1010
|
+
inflectionSlopeHzPerSec,
|
|
1011
|
+
inferredSpeedMps,
|
|
1012
|
+
inferredSlantMeters,
|
|
1013
|
+
speedStdMps,
|
|
1014
|
+
slantStdMeters: inferredSlantMeters * Math.hypot(2 * relSpeedStd, slopeRelStd)
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// src/dsp/ridge-tracker.ts
|
|
1018
|
+
/**
|
|
1019
|
+
* Default minimum per-frame peak signal-to-noise ratio in dB for a frame to join a ridge.
|
|
1020
|
+
*/
|
|
1021
|
+
const DEFAULT_MIN_SNR_DB = 6;
|
|
1022
|
+
/**
|
|
1023
|
+
* Default minimum time in seconds between the first and last point of a ridge for it to count as a flyover.
|
|
1024
|
+
*/
|
|
1025
|
+
const DEFAULT_MIN_DURATION_SEC = 6;
|
|
1026
|
+
/**
|
|
1027
|
+
* Default minimum drop in Hz from the approaching to the receding asymptote for a ridge to count as a Doppler S-curve rather than a steady tone.
|
|
1028
|
+
*/
|
|
1029
|
+
const DEFAULT_MIN_FREQ_DROP_HZ = 3.5;
|
|
1030
|
+
/**
|
|
1031
|
+
* Extra signal-to-noise ratio in dB demanded of a frame flagged as noise-polluted, so that only strong peaks survive elevated noise.
|
|
1032
|
+
*/
|
|
1033
|
+
const NOISE_POLLUTED_SNR_PENALTY_DB = 4;
|
|
1034
|
+
/**
|
|
1035
|
+
* Fewest candidate points, and fewest points in a ridge, from which a Doppler S-curve can be fitted.
|
|
1036
|
+
*/
|
|
1037
|
+
const MIN_RIDGE_POINTS = 5;
|
|
1038
|
+
/**
|
|
1039
|
+
* Fewest consecutive frequency-continuous points that form a ridge worth keeping.
|
|
1040
|
+
*/
|
|
1041
|
+
const MIN_CLUSTER_POINTS = 8;
|
|
1042
|
+
/**
|
|
1043
|
+
* Longest gap in seconds between consecutive points of one ridge.
|
|
1044
|
+
*/
|
|
1045
|
+
const MAX_TRACK_GAP_SEC = 1.5;
|
|
1046
|
+
/**
|
|
1047
|
+
* Smallest frequency jump in Hz allowed between consecutive ridge points however short the gap between them.
|
|
1048
|
+
*/
|
|
1049
|
+
const MIN_TRACK_FREQ_JUMP_HZ = 10;
|
|
1050
|
+
/**
|
|
1051
|
+
* Greatest frequency slew rate in Hz per second allowed between consecutive ridge points.
|
|
1052
|
+
*/
|
|
1053
|
+
const MAX_TRACK_SLEW_HZ_PER_SEC = 15;
|
|
1054
|
+
/**
|
|
1055
|
+
* Fraction of a ridge's points at each end whose median frequency estimates the approaching and receding asymptotes.
|
|
1056
|
+
*/
|
|
1057
|
+
const EDGE_FRACTION = .22;
|
|
1058
|
+
/**
|
|
1059
|
+
* Fewest points at each end of a ridge that are used to estimate an asymptote.
|
|
1060
|
+
*/
|
|
1061
|
+
const MIN_EDGE_POINTS = 2;
|
|
1062
|
+
/**
|
|
1063
|
+
* Number of ridge points that earn one point of half-span on each side of the closest point of approach when measuring the inflection slope.
|
|
1064
|
+
*/
|
|
1065
|
+
const RIDGE_POINTS_PER_SLOPE_SPAN_POINT = 6;
|
|
1066
|
+
/**
|
|
1067
|
+
* Greatest half-span in points on each side of the closest point of approach when measuring the inflection slope.
|
|
1068
|
+
*/
|
|
1069
|
+
const MAX_SLOPE_SPAN_POINTS = 3;
|
|
1070
|
+
/**
|
|
1071
|
+
* Shortest time in seconds across which the inflection slope is measured, so that a ridge sampled very densely does not divide by a near-zero interval.
|
|
1072
|
+
*/
|
|
1073
|
+
const MIN_SLOPE_WINDOW_SEC = .25;
|
|
1074
|
+
/**
|
|
1075
|
+
* Shortest time-scale in seconds of the closest-approach geometry that is assumed when extrapolating the asymptotes.
|
|
1076
|
+
*/
|
|
1077
|
+
const MIN_TAU_SEC = 1.5;
|
|
1078
|
+
/**
|
|
1079
|
+
* Smallest value of `cosThetaMax`, the cosine of the angle between the aircraft's course and its line of sight at the ridge ends, that the asymptotic extrapolation divides by; it bounds the correction a short ridge can apply.
|
|
1080
|
+
*/
|
|
1081
|
+
const MIN_COS_THETA_MAX = .5;
|
|
1082
|
+
/**
|
|
1083
|
+
* Lowest frequency in Hz that the extrapolated receding asymptote may take.
|
|
1084
|
+
*/
|
|
1085
|
+
const MIN_ASYMPTOTIC_LOW_HZ = 10;
|
|
1086
|
+
/**
|
|
1087
|
+
* Extracts Doppler S-curve observations from a spectrogram, down-weighting noise-polluted frames and computing closed-form kinematics for each detected flyover.
|
|
1088
|
+
* @param spectrogram - Computed spectrogram with noise-pollution flags.
|
|
1089
|
+
* @param options - Tracker thresholds and episode context.
|
|
1090
|
+
* @returns Array of detected aircraft Doppler observations.
|
|
1091
|
+
*/
|
|
1092
|
+
function extractDopplerObservations(spectrogram, options) {
|
|
1093
|
+
const minSnrDb = options.minSnrDb ?? DEFAULT_MIN_SNR_DB;
|
|
1094
|
+
const minDurationSec = options.minDurationSec ?? DEFAULT_MIN_DURATION_SEC;
|
|
1095
|
+
const minFreqDropHz = options.minFreqDropHz ?? DEFAULT_MIN_FREQ_DROP_HZ;
|
|
1096
|
+
const soundSpeedMps = options.soundSpeedMps ?? 343;
|
|
1097
|
+
const candidatePoints = [];
|
|
1098
|
+
for (const frame of spectrogram.frames) {
|
|
1099
|
+
const effectiveThreshold = frame.noisePolluted ? minSnrDb + NOISE_POLLUTED_SNR_PENALTY_DB : minSnrDb;
|
|
1100
|
+
if (frame.peakSnrDb >= effectiveThreshold) candidatePoints.push({
|
|
1101
|
+
timeSec: frame.timeSec,
|
|
1102
|
+
freqHz: frame.peakFreqHz,
|
|
1103
|
+
snrDb: frame.peakSnrDb
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
if (candidatePoints.length < MIN_RIDGE_POINTS) return [];
|
|
1107
|
+
const clusters = [];
|
|
1108
|
+
let currentCluster = [];
|
|
1109
|
+
for (const pt of candidatePoints) {
|
|
1110
|
+
if (currentCluster.length === 0) {
|
|
1111
|
+
currentCluster.push(pt);
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const prev = currentCluster[currentCluster.length - 1];
|
|
1115
|
+
if (prev === void 0) continue;
|
|
1116
|
+
const dt = pt.timeSec - prev.timeSec;
|
|
1117
|
+
const df = Math.abs(pt.freqHz - prev.freqHz);
|
|
1118
|
+
if (dt <= MAX_TRACK_GAP_SEC && df <= Math.max(MIN_TRACK_FREQ_JUMP_HZ, MAX_TRACK_SLEW_HZ_PER_SEC * dt)) currentCluster.push(pt);
|
|
1119
|
+
else {
|
|
1120
|
+
if (currentCluster.length >= MIN_CLUSTER_POINTS) clusters.push(currentCluster);
|
|
1121
|
+
currentCluster = [pt];
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
if (currentCluster.length >= MIN_CLUSTER_POINTS) clusters.push(currentCluster);
|
|
1125
|
+
const observations = [];
|
|
1126
|
+
for (let idx = 0; idx < clusters.length; idx += 1) {
|
|
1127
|
+
const cluster = clusters[idx];
|
|
1128
|
+
if (cluster === void 0 || cluster.length < MIN_RIDGE_POINTS) continue;
|
|
1129
|
+
const firstPt = cluster[0];
|
|
1130
|
+
const lastPt = cluster[cluster.length - 1];
|
|
1131
|
+
if (firstPt === void 0 || lastPt === void 0) continue;
|
|
1132
|
+
const durationSec = lastPt.timeSec - firstPt.timeSec;
|
|
1133
|
+
if (durationSec < minDurationSec) continue;
|
|
1134
|
+
const edgeCount = Math.max(MIN_EDGE_POINTS, Math.floor(cluster.length * EDGE_FRACTION));
|
|
1135
|
+
const headFreqs = cluster.slice(0, edgeCount).map((p) => p.freqHz).sort((a, b) => a - b);
|
|
1136
|
+
const tailFreqs = cluster.slice(cluster.length - edgeCount).map((p) => p.freqHz).sort((a, b) => a - b);
|
|
1137
|
+
const fHigh = headFreqs[headFreqs.length >> 1] ?? firstPt.freqHz;
|
|
1138
|
+
const fLow = tailFreqs[tailFreqs.length >> 1] ?? lastPt.freqHz;
|
|
1139
|
+
if (fHigh - fLow < minFreqDropHz) continue;
|
|
1140
|
+
const targetBaseFreq = 2 * fHigh * fLow / (fHigh + fLow);
|
|
1141
|
+
let cpaIdx = cluster.length >> 1;
|
|
1142
|
+
let minFreqDiff = Infinity;
|
|
1143
|
+
for (let i = 1; i < cluster.length - 1; i += 1) {
|
|
1144
|
+
const pt = cluster[i];
|
|
1145
|
+
if (pt !== void 0) {
|
|
1146
|
+
const diff = Math.abs(pt.freqHz - targetBaseFreq);
|
|
1147
|
+
if (diff < minFreqDiff) {
|
|
1148
|
+
minFreqDiff = diff;
|
|
1149
|
+
cpaIdx = i;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const cpaPoint = cluster[cpaIdx] ?? firstPt;
|
|
1154
|
+
const span = Math.max(1, Math.min(MAX_SLOPE_SPAN_POINTS, Math.floor(cluster.length / RIDGE_POINTS_PER_SLOPE_SPAN_POINT)));
|
|
1155
|
+
const leftPt = cluster[Math.max(0, cpaIdx - span)] ?? firstPt;
|
|
1156
|
+
const rightPt = cluster[Math.min(cluster.length - 1, cpaIdx + span)] ?? lastPt;
|
|
1157
|
+
const dtSlope = Math.max(MIN_SLOPE_WINDOW_SEC, rightPt.timeSec - leftPt.timeSec);
|
|
1158
|
+
const rawSlope = (rightPt.freqHz - leftPt.freqHz) / dtSlope;
|
|
1159
|
+
const inflectionSlopeHzPerSec = rawSlope < -.02 ? rawSlope : -(fHigh - fLow) / durationSec;
|
|
1160
|
+
const halfDur = Math.max(1, durationSec / 2);
|
|
1161
|
+
const tauEst = Math.max(MIN_TAU_SEC, -(targetBaseFreq * ((fHigh - fLow) / (fHigh + fLow))) / inflectionSlopeHzPerSec);
|
|
1162
|
+
const cosThetaMax = halfDur / Math.hypot(halfDur, tauEst);
|
|
1163
|
+
const halfSpread = (fHigh - fLow) / 2 / Math.max(MIN_COS_THETA_MAX, cosThetaMax);
|
|
1164
|
+
const midFreq = (fHigh + fLow) / 2;
|
|
1165
|
+
const kinematics = extractAsymptoticKinematics({
|
|
1166
|
+
asymptoticHighHz: midFreq + halfSpread,
|
|
1167
|
+
asymptoticLowHz: Math.max(MIN_ASYMPTOTIC_LOW_HZ, midFreq - halfSpread),
|
|
1168
|
+
inflectionSlopeHzPerSec,
|
|
1169
|
+
soundSpeedMps
|
|
1170
|
+
});
|
|
1171
|
+
const snrValues = cluster.map((p) => p.snrDb).sort((a, b) => a - b);
|
|
1172
|
+
const medianSnrDb = snrValues[snrValues.length >> 1] ?? minSnrDb;
|
|
1173
|
+
observations.push({
|
|
1174
|
+
id: `${options.episodeId}_obs${String(idx)}`,
|
|
1175
|
+
episodeId: options.episodeId,
|
|
1176
|
+
cpaTimeSec: cpaPoint.timeSec,
|
|
1177
|
+
kinematics,
|
|
1178
|
+
snrDb: medianSnrDb,
|
|
1179
|
+
harmonicCount: 1,
|
|
1180
|
+
ridgePoints: cluster
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
return observations;
|
|
1184
|
+
}
|
|
1185
|
+
// src/dsp/spectrogram.ts
|
|
1186
|
+
/**
|
|
1187
|
+
* Default margin in dB by which a frame's median noise floor may exceed the reference floor before the frame is flagged as noise-polluted.
|
|
1188
|
+
*/
|
|
1189
|
+
const NOISE_POLLUTION_MARGIN_DB = 12;
|
|
1190
|
+
/**
|
|
1191
|
+
* Default reference noise floor in dB against which a single isolated frame is judged for noise pollution when the caller supplies no baseline.
|
|
1192
|
+
*/
|
|
1193
|
+
const DEFAULT_BASELINE_NOISE_DB = -80;
|
|
1194
|
+
/**
|
|
1195
|
+
* Overall median noise floor in dB reported for a spectrogram that holds no frames.
|
|
1196
|
+
*/
|
|
1197
|
+
const EMPTY_SPECTROGRAM_NOISE_DB = -90;
|
|
1198
|
+
/**
|
|
1199
|
+
* Smallest absolute second difference in dB across a peak for which parabolic interpolation is attempted; a flatter peak keeps its integer bin.
|
|
1200
|
+
*/
|
|
1201
|
+
const MIN_PARABOLA_CURVATURE_DB = 1e-9;
|
|
1202
|
+
/**
|
|
1203
|
+
* Largest sub-bin offset in bins that parabolic interpolation may apply to a peak: a true maximum lies within half a bin of the sampled one.
|
|
1204
|
+
*/
|
|
1205
|
+
const MAX_SUB_BIN_OFFSET = .5;
|
|
1206
|
+
/**
|
|
1207
|
+
* Computes the median value of a numeric slice without mutating the source buffer.
|
|
1208
|
+
*/
|
|
1209
|
+
function medianOfSlice(values, start, end) {
|
|
1210
|
+
const slice = [];
|
|
1211
|
+
for (let i = start; i <= end; i += 1) {
|
|
1212
|
+
const val = values[i];
|
|
1213
|
+
if (val !== void 0) slice.push(val);
|
|
1214
|
+
}
|
|
1215
|
+
if (slice.length === 0) return MISSING_BIN_POWER_DB;
|
|
1216
|
+
slice.sort((a, b) => a - b);
|
|
1217
|
+
return slice[slice.length >> 1] ?? -120;
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Computes an isolated single STFT spectrogram frame over a single time window.
|
|
1221
|
+
* @param windowSlice - Time-domain audio slice (length must match fftSize).
|
|
1222
|
+
* @param options - Single frame computation options.
|
|
1223
|
+
* @returns Computed STFT frame, peak tracking values, and frequency bin parameters.
|
|
1224
|
+
*/
|
|
1225
|
+
function computeSingleSpectrogramFrame(windowSlice, options) {
|
|
1226
|
+
const sampleRateHz = options.sampleRateHz;
|
|
1227
|
+
const fftSize = options.fftSize ?? 2048;
|
|
1228
|
+
const minFreqHz = options.minFreqHz ?? 65;
|
|
1229
|
+
const maxFreqHz = options.maxFreqHz ?? 500;
|
|
1230
|
+
const timeSec = options.timeSec ?? 0;
|
|
1231
|
+
const pollutionMarginDb = options.noisePollutionThresholdDb ?? NOISE_POLLUTION_MARGIN_DB;
|
|
1232
|
+
const baselineNoiseDb = options.baselineNoiseDb ?? DEFAULT_BASELINE_NOISE_DB;
|
|
1233
|
+
const binStepHz = sampleRateHz / fftSize;
|
|
1234
|
+
const minBin = Math.max(1, Math.floor(minFreqHz / binStepHz));
|
|
1235
|
+
const maxBin = Math.min((fftSize >> 1) - 1, Math.ceil(maxFreqHz / binStepHz));
|
|
1236
|
+
const fullSpectrumDb = computeHannPowerSpectrumDb(windowSlice, fftSize);
|
|
1237
|
+
const medianNoiseFloorDb = medianOfSlice(fullSpectrumDb, minBin, maxBin);
|
|
1238
|
+
let bestBin = minBin;
|
|
1239
|
+
let bestPowerDb = -Infinity;
|
|
1240
|
+
for (let b = minBin; b <= maxBin; b += 1) {
|
|
1241
|
+
const p = fullSpectrumDb[b] ?? -Infinity;
|
|
1242
|
+
if (p > bestPowerDb) {
|
|
1243
|
+
bestPowerDb = p;
|
|
1244
|
+
bestBin = b;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
const leftDb = fullSpectrumDb[bestBin - 1] ?? bestPowerDb;
|
|
1248
|
+
const rightDb = fullSpectrumDb[bestBin + 1] ?? bestPowerDb;
|
|
1249
|
+
const denom = leftDb - 2 * bestPowerDb + rightDb;
|
|
1250
|
+
const deltaBin = Math.abs(denom) > MIN_PARABOLA_CURVATURE_DB ? Math.max(-.5, Math.min(MAX_SUB_BIN_OFFSET, (leftDb - rightDb) / 2 / denom)) : 0;
|
|
1251
|
+
const peakFreqHz = (bestBin + deltaBin) * binStepHz;
|
|
1252
|
+
const peakSnrDb = bestPowerDb - medianNoiseFloorDb;
|
|
1253
|
+
return {
|
|
1254
|
+
timeSec,
|
|
1255
|
+
powerDb: fullSpectrumDb.slice(minBin, maxBin + 1),
|
|
1256
|
+
medianNoiseFloorDb,
|
|
1257
|
+
peakFreqHz,
|
|
1258
|
+
peakPowerDb: bestPowerDb,
|
|
1259
|
+
peakSnrDb,
|
|
1260
|
+
noisePolluted: medianNoiseFloorDb > baselineNoiseDb + pollutionMarginDb,
|
|
1261
|
+
minBin,
|
|
1262
|
+
maxBin,
|
|
1263
|
+
binStepHz
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Computes a high-resolution spectrogram focused on the aircraft acoustic Doppler band (65 to 500 Hz by default), with parabolic sub-bin peak interpolation and continuous noise-pollution gating.
|
|
1268
|
+
* @param samples - Decimated mono audio samples (typically at 2,000 Hz).
|
|
1269
|
+
* @param options - Spectrogram configuration parameters.
|
|
1270
|
+
* @returns Spectrogram frames, sub-bin peak tracks, and quiet intervals where silence filtering is valid.
|
|
1271
|
+
*/
|
|
1272
|
+
function computeDopplerSpectrogram(samples, options) {
|
|
1273
|
+
const sampleRateHz = options.sampleRateHz;
|
|
1274
|
+
const startTimeSec = options.startTimeSec ?? 0;
|
|
1275
|
+
const fftSize = options.fftSize ?? 2048;
|
|
1276
|
+
const hopSamples = options.hopSamples ?? 500;
|
|
1277
|
+
const minFreqHz = options.minFreqHz ?? 65;
|
|
1278
|
+
const maxFreqHz = options.maxFreqHz ?? 500;
|
|
1279
|
+
const pollutionMarginDb = options.noisePollutionThresholdDb ?? NOISE_POLLUTION_MARGIN_DB;
|
|
1280
|
+
if (!(sampleRateHz > 0)) throw new Error(`Sample rate must be positive, got ${String(sampleRateHz)} Hz`);
|
|
1281
|
+
if (!Number.isInteger(hopSamples) || hopSamples < 1) throw new Error(`hopSamples must be a positive integer, got ${String(hopSamples)}`);
|
|
1282
|
+
const binStepHz = sampleRateHz / fftSize;
|
|
1283
|
+
const minBin = Math.max(1, Math.floor(minFreqHz / binStepHz));
|
|
1284
|
+
const maxBin = Math.min((fftSize >> 1) - 1, Math.ceil(maxFreqHz / binStepHz));
|
|
1285
|
+
const rawFrames = [];
|
|
1286
|
+
for (let offset = 0; offset + fftSize <= samples.length; offset += hopSamples) {
|
|
1287
|
+
const frame = computeSingleSpectrogramFrame(samples.subarray(offset, offset + fftSize), {
|
|
1288
|
+
sampleRateHz,
|
|
1289
|
+
fftSize,
|
|
1290
|
+
minFreqHz,
|
|
1291
|
+
maxFreqHz,
|
|
1292
|
+
timeSec: startTimeSec + (offset + fftSize / 2) / sampleRateHz
|
|
1293
|
+
});
|
|
1294
|
+
rawFrames.push(frame);
|
|
1295
|
+
}
|
|
1296
|
+
const floorList = rawFrames.map((f) => f.medianNoiseFloorDb).sort((a, b) => a - b);
|
|
1297
|
+
const overallMedianNoiseDb = floorList.length > 0 ? floorList[floorList.length >> 1] ?? EMPTY_SPECTROGRAM_NOISE_DB : EMPTY_SPECTROGRAM_NOISE_DB;
|
|
1298
|
+
const frameDurationSec = hopSamples / sampleRateHz;
|
|
1299
|
+
const frames = rawFrames.map((f) => ({
|
|
1300
|
+
timeSec: f.timeSec,
|
|
1301
|
+
powerDb: f.powerDb,
|
|
1302
|
+
medianNoiseFloorDb: f.medianNoiseFloorDb,
|
|
1303
|
+
peakFreqHz: f.peakFreqHz,
|
|
1304
|
+
peakPowerDb: f.peakPowerDb,
|
|
1305
|
+
peakSnrDb: f.peakSnrDb,
|
|
1306
|
+
noisePolluted: f.medianNoiseFloorDb > overallMedianNoiseDb + pollutionMarginDb
|
|
1307
|
+
}));
|
|
1308
|
+
const quietIntervals = [];
|
|
1309
|
+
let currentStart = null;
|
|
1310
|
+
let noiseAccum = [];
|
|
1311
|
+
for (const frame of frames) if (!frame.noisePolluted) {
|
|
1312
|
+
currentStart ??= frame.timeSec - frameDurationSec / 2;
|
|
1313
|
+
noiseAccum.push(frame.medianNoiseFloorDb);
|
|
1314
|
+
} else if (currentStart !== null) {
|
|
1315
|
+
const sortedNoise = [...noiseAccum].sort((a, b) => a - b);
|
|
1316
|
+
quietIntervals.push({
|
|
1317
|
+
startSec: currentStart,
|
|
1318
|
+
endSec: frame.timeSec - frameDurationSec / 2,
|
|
1319
|
+
medianNoiseFloorDb: sortedNoise[sortedNoise.length >> 1] ?? overallMedianNoiseDb
|
|
1320
|
+
});
|
|
1321
|
+
currentStart = null;
|
|
1322
|
+
noiseAccum = [];
|
|
1323
|
+
}
|
|
1324
|
+
if (currentStart !== null && frames.length > 0) {
|
|
1325
|
+
const lastFrame = frames[frames.length - 1];
|
|
1326
|
+
const sortedNoise = [...noiseAccum].sort((a, b) => a - b);
|
|
1327
|
+
if (lastFrame !== void 0) quietIntervals.push({
|
|
1328
|
+
startSec: currentStart,
|
|
1329
|
+
endSec: lastFrame.timeSec + frameDurationSec / 2,
|
|
1330
|
+
medianNoiseFloorDb: sortedNoise[sortedNoise.length >> 1] ?? overallMedianNoiseDb
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
return {
|
|
1334
|
+
sampleRateHz,
|
|
1335
|
+
fftSize,
|
|
1336
|
+
minFreqHz,
|
|
1337
|
+
maxFreqHz,
|
|
1338
|
+
minBin,
|
|
1339
|
+
maxBin,
|
|
1340
|
+
binStepHz,
|
|
1341
|
+
frames,
|
|
1342
|
+
quietIntervals,
|
|
1343
|
+
overallMedianNoiseDb
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
// src/define.ts
|
|
1347
|
+
/**
|
|
1348
|
+
* Reports whether a schema carries a callable `is` property, which narrows it to its guarded type without a type assertion.
|
|
1349
|
+
* @param candidate - The schema to inspect.
|
|
1350
|
+
* @returns True when `candidate` has an own `is` function.
|
|
1351
|
+
*/
|
|
1352
|
+
function isGuarded(candidate) {
|
|
1353
|
+
return Object.hasOwn(candidate, "is") && "is" in candidate && typeof candidate.is === "function";
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Attaches a type guard to a Zod schema (the schema-attached guard pattern: validation, inference and narrowing share one definition, so the three cannot drift). The guard is defined on the schema object itself, because a Zod 4 schema keeps its methods on its prototype and its parse state on `_zod`: a spread copy loses the methods, so it could no longer be parsed or nested in another schema.
|
|
1357
|
+
* @param schema - The schema to augment.
|
|
1358
|
+
* @returns The same schema object, now also carrying `is`.
|
|
1359
|
+
* @throws When the schema is non-extensible, so the guard cannot be attached, or when the property was not installed.
|
|
1360
|
+
*/
|
|
1361
|
+
function define(schema) {
|
|
1362
|
+
const augmented = Object.defineProperty(schema, "is", {
|
|
1363
|
+
enumerable: true,
|
|
1364
|
+
value: (value) => schema.safeParse(value).success
|
|
1365
|
+
});
|
|
1366
|
+
if (!isGuarded(augmented)) throw new TypeError("define: the type guard was not installed on the schema");
|
|
1367
|
+
return augmented;
|
|
1368
|
+
}
|
|
1369
|
+
// src/schemas.ts
|
|
1370
|
+
/**
|
|
1371
|
+
* Northern limit of geodetic latitude in degrees: the North Pole.
|
|
1372
|
+
*/
|
|
1373
|
+
const MAX_LATITUDE_DEGREES = 90;
|
|
1374
|
+
/**
|
|
1375
|
+
* Southern limit of geodetic latitude in degrees: the South Pole, symmetric with the northern limit.
|
|
1376
|
+
*/
|
|
1377
|
+
const MIN_LATITUDE_DEGREES = -90;
|
|
1378
|
+
/**
|
|
1379
|
+
* Eastern limit of geodetic longitude in degrees: the antimeridian, half a full turn of 360 degrees from the prime meridian.
|
|
1380
|
+
*/
|
|
1381
|
+
const MAX_LONGITUDE_DEGREES = 180;
|
|
1382
|
+
/**
|
|
1383
|
+
* Western limit of geodetic longitude in degrees, symmetric with the eastern limit.
|
|
1384
|
+
*/
|
|
1385
|
+
const MIN_LONGITUDE_DEGREES = -180;
|
|
1386
|
+
/**
|
|
1387
|
+
* Lowest accepted altitude above the WGS84 ellipsoid in metres: a design margin below the deepest exposed land depression (the Dead Sea shore, about 430 m below sea level) so that real terrain-level observers are never rejected.
|
|
1388
|
+
*/
|
|
1389
|
+
const MIN_ALTITUDE_METERS = -500;
|
|
1390
|
+
/**
|
|
1391
|
+
* Highest accepted altitude above the WGS84 ellipsoid in metres: a design ceiling of 60 km, well above any aircraft service ceiling and so a safe bound that still rejects corrupt altitude values.
|
|
1392
|
+
*/
|
|
1393
|
+
const MAX_ALTITUDE_METERS = 6e4;
|
|
1394
|
+
/**
|
|
1395
|
+
* Hours in a UTC day: the upper bound of a time-of-day hour value, where 24 denotes the end of the day.
|
|
1396
|
+
*/
|
|
1397
|
+
const HOURS_PER_DAY = 24;
|
|
1398
|
+
/**
|
|
1399
|
+
* Geodetic 3D coordinate on or above the WGS84 reference ellipsoid.
|
|
1400
|
+
*/
|
|
1401
|
+
const GeoPoint3DSchema = define(z.object({
|
|
1402
|
+
lat: z.number().min(MIN_LATITUDE_DEGREES).max(MAX_LATITUDE_DEGREES),
|
|
1403
|
+
lon: z.number().min(MIN_LONGITUDE_DEGREES).max(MAX_LONGITUDE_DEGREES),
|
|
1404
|
+
altMeters: z.number().min(MIN_ALTITUDE_METERS).max(MAX_ALTITUDE_METERS)
|
|
1405
|
+
}));
|
|
1406
|
+
define(z.object({
|
|
1407
|
+
x: z.number(),
|
|
1408
|
+
y: z.number(),
|
|
1409
|
+
z: z.number()
|
|
1410
|
+
}));
|
|
1411
|
+
/**
|
|
1412
|
+
* Aircraft propulsion category used to filter acoustic candidates.
|
|
1413
|
+
*/
|
|
1414
|
+
const AircraftCategorySchema = define(z.enum([
|
|
1415
|
+
"piston",
|
|
1416
|
+
"turboprop",
|
|
1417
|
+
"helicopter",
|
|
1418
|
+
"jet",
|
|
1419
|
+
"unknown"
|
|
1420
|
+
]));
|
|
1421
|
+
define(z.object({
|
|
1422
|
+
epochSec: z.number(),
|
|
1423
|
+
lat: z.number().min(MIN_LATITUDE_DEGREES).max(MAX_LATITUDE_DEGREES),
|
|
1424
|
+
lon: z.number().min(MIN_LONGITUDE_DEGREES).max(MAX_LONGITUDE_DEGREES),
|
|
1425
|
+
altMeters: z.number()
|
|
1426
|
+
}));
|
|
1427
|
+
/**
|
|
1428
|
+
* Piecewise-linear 4D aircraft trajectory segment produced by Ramer-Douglas-Peucker compression.
|
|
1429
|
+
*/
|
|
1430
|
+
const LinearFlightSegmentSchema = define(z.object({
|
|
1431
|
+
id: z.string().min(1),
|
|
1432
|
+
flightId: z.string().min(1),
|
|
1433
|
+
icao24: z.string().min(1),
|
|
1434
|
+
typeCode: z.string(),
|
|
1435
|
+
category: AircraftCategorySchema,
|
|
1436
|
+
dateIso: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
1437
|
+
startEpochSec: z.number(),
|
|
1438
|
+
endEpochSec: z.number(),
|
|
1439
|
+
startGeo: GeoPoint3DSchema,
|
|
1440
|
+
endGeo: GeoPoint3DSchema,
|
|
1441
|
+
groundspeedMps: z.number().nonnegative(),
|
|
1442
|
+
h3Cell: z.string().min(1)
|
|
1443
|
+
}));
|
|
1444
|
+
/**
|
|
1445
|
+
* Single time-frequency point along an extracted spectrogram Doppler ridge.
|
|
1446
|
+
*/
|
|
1447
|
+
const DopplerRidgePointSchema = define(z.object({
|
|
1448
|
+
timeSec: z.number(),
|
|
1449
|
+
freqHz: z.number().positive(),
|
|
1450
|
+
snrDb: z.number()
|
|
1451
|
+
}));
|
|
1452
|
+
/**
|
|
1453
|
+
* Closed-form kinematic parameters extracted algebraically from a single Doppler S-curve.
|
|
1454
|
+
*/
|
|
1455
|
+
const AsymptoticKinematicsSchema = define(z.object({
|
|
1456
|
+
asymptoticHighHz: z.number().positive(),
|
|
1457
|
+
asymptoticLowHz: z.number().positive(),
|
|
1458
|
+
baseFreqHz: z.number().positive(),
|
|
1459
|
+
inflectionSlopeHzPerSec: z.number().negative(),
|
|
1460
|
+
inferredSpeedMps: z.number().positive(),
|
|
1461
|
+
inferredSlantMeters: z.number().positive(),
|
|
1462
|
+
speedStdMps: z.number().nonnegative(),
|
|
1463
|
+
slantStdMeters: z.number().nonnegative()
|
|
1464
|
+
}));
|
|
1465
|
+
/**
|
|
1466
|
+
* Complete acoustic observation of an aircraft flyover extracted from an audio stream or file.
|
|
1467
|
+
*/
|
|
1468
|
+
const DopplerObservationSchema = define(z.object({
|
|
1469
|
+
id: z.string().min(1),
|
|
1470
|
+
episodeId: z.string().min(1),
|
|
1471
|
+
cpaTimeSec: z.number(),
|
|
1472
|
+
kinematics: AsymptoticKinematicsSchema,
|
|
1473
|
+
snrDb: z.number(),
|
|
1474
|
+
harmonicCount: z.number().int().min(1),
|
|
1475
|
+
ridgePoints: z.array(DopplerRidgePointSchema).readonly()
|
|
1476
|
+
}));
|
|
1477
|
+
/**
|
|
1478
|
+
* Quantized Shazam-style constellation hash entry linking two flyovers at a shared H3 cell.
|
|
1479
|
+
*/
|
|
1480
|
+
const ConstellationEntrySchema = define(z.object({
|
|
1481
|
+
hashKey: z.string().min(1),
|
|
1482
|
+
dateIso: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
1483
|
+
h3Cell: z.string().min(1),
|
|
1484
|
+
centerLat: z.number().min(MIN_LATITUDE_DEGREES).max(MAX_LATITUDE_DEGREES),
|
|
1485
|
+
centerLon: z.number().min(MIN_LONGITUDE_DEGREES).max(MAX_LONGITUDE_DEGREES),
|
|
1486
|
+
segmentIdA: z.string().min(1),
|
|
1487
|
+
segmentIdB: z.string().min(1),
|
|
1488
|
+
speedAMps: z.number().positive(),
|
|
1489
|
+
speedBMps: z.number().positive(),
|
|
1490
|
+
cpaDelayedEpochSecA: z.number(),
|
|
1491
|
+
cpaDelayedEpochSecB: z.number(),
|
|
1492
|
+
deltaSec: z.number().positive()
|
|
1493
|
+
}));
|
|
1494
|
+
/**
|
|
1495
|
+
* Time interval within a listening episode where ambient noise was below the silence-gating threshold.
|
|
1496
|
+
*/
|
|
1497
|
+
const QuietIntervalSchema = define(z.object({
|
|
1498
|
+
startSec: z.number(),
|
|
1499
|
+
endSec: z.number(),
|
|
1500
|
+
medianNoiseFloorDb: z.number()
|
|
1501
|
+
}));
|
|
1502
|
+
/**
|
|
1503
|
+
* Continuous listening window within a multi-episode pause/resume geolocation session.
|
|
1504
|
+
*/
|
|
1505
|
+
const ListeningEpisodeSchema = define(z.object({
|
|
1506
|
+
id: z.string().min(1),
|
|
1507
|
+
startedAtSec: z.number(),
|
|
1508
|
+
endedAtSec: z.number(),
|
|
1509
|
+
status: z.enum([
|
|
1510
|
+
"active",
|
|
1511
|
+
"paused",
|
|
1512
|
+
"completed"
|
|
1513
|
+
]),
|
|
1514
|
+
currentNoiseFloorDb: z.number(),
|
|
1515
|
+
noisePolluted: z.boolean(),
|
|
1516
|
+
quietIntervals: z.array(QuietIntervalSchema).readonly()
|
|
1517
|
+
}));
|
|
1518
|
+
/**
|
|
1519
|
+
* Passive Electrical Network Frequency (ENF) side-channel signature (50 Hz vs 60 Hz power grid).
|
|
1520
|
+
*/
|
|
1521
|
+
const EnfSignatureSchema = define(z.object({
|
|
1522
|
+
detected: z.boolean(),
|
|
1523
|
+
nominalHz: z.enum([
|
|
1524
|
+
"50",
|
|
1525
|
+
"60",
|
|
1526
|
+
"unknown"
|
|
1527
|
+
]),
|
|
1528
|
+
measuredFreqHz: z.number().nonnegative(),
|
|
1529
|
+
snrDb: z.number(),
|
|
1530
|
+
regionHint: z.string()
|
|
1531
|
+
}));
|
|
1532
|
+
/**
|
|
1533
|
+
* Optional user or side-channel constraints over Space, Date, Time of Day, and Power Grid.
|
|
1534
|
+
*/
|
|
1535
|
+
const SearchConstraintsSchema = define(z.object({
|
|
1536
|
+
minLat: z.number().min(MIN_LATITUDE_DEGREES).max(MAX_LATITUDE_DEGREES).optional(),
|
|
1537
|
+
maxLat: z.number().min(MIN_LATITUDE_DEGREES).max(MAX_LATITUDE_DEGREES).optional(),
|
|
1538
|
+
minLon: z.number().min(MIN_LONGITUDE_DEGREES).max(MAX_LONGITUDE_DEGREES).optional(),
|
|
1539
|
+
maxLon: z.number().min(MIN_LONGITUDE_DEGREES).max(MAX_LONGITUDE_DEGREES).optional(),
|
|
1540
|
+
dateFromIso: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
1541
|
+
dateToIso: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
|
1542
|
+
utcHourStart: z.number().min(0).max(HOURS_PER_DAY).optional(),
|
|
1543
|
+
utcHourEnd: z.number().min(0).max(HOURS_PER_DAY).optional(),
|
|
1544
|
+
enfNominalHz: z.enum(["50", "60"]).optional()
|
|
1545
|
+
}));
|
|
1546
|
+
/**
|
|
1547
|
+
* A flyover observation attributed to a flight segment under a location hypothesis. Matches are keyed by observation id, never by position, because an observation that fits no segment is absent from a hypothesis and would shift every later index.
|
|
1548
|
+
*/
|
|
1549
|
+
const HypothesisMatchSchema = define(z.object({
|
|
1550
|
+
observationId: z.string().min(1),
|
|
1551
|
+
segmentId: z.string().min(1)
|
|
1552
|
+
}));
|
|
1553
|
+
/**
|
|
1554
|
+
* Candidate observer location hypothesis with cumulative Bayesian log-posterior and 95% confidence ellipse.
|
|
1555
|
+
*/
|
|
1556
|
+
const LocationHypothesisSchema = define(z.object({
|
|
1557
|
+
id: z.string().min(1),
|
|
1558
|
+
h3Cell: z.string().min(1),
|
|
1559
|
+
dateIso: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
1560
|
+
lat: z.number().min(MIN_LATITUDE_DEGREES).max(MAX_LATITUDE_DEGREES),
|
|
1561
|
+
lon: z.number().min(MIN_LONGITUDE_DEGREES).max(MAX_LONGITUDE_DEGREES),
|
|
1562
|
+
altMeters: z.number(),
|
|
1563
|
+
estimatedStartEpochSec: z.number(),
|
|
1564
|
+
logPosterior: z.number(),
|
|
1565
|
+
probability: z.number().min(0).max(1),
|
|
1566
|
+
rmsFreqResidualHz: z.number().nonnegative(),
|
|
1567
|
+
fisherInfo: z.tuple([
|
|
1568
|
+
z.number(),
|
|
1569
|
+
z.number(),
|
|
1570
|
+
z.number(),
|
|
1571
|
+
z.number()
|
|
1572
|
+
]).readonly(),
|
|
1573
|
+
ellipseSemiMajorMeters: z.number().positive(),
|
|
1574
|
+
ellipseSemiMinorMeters: z.number().positive(),
|
|
1575
|
+
ellipseBearingDeg: z.number(),
|
|
1576
|
+
matches: z.array(HypothesisMatchSchema).readonly()
|
|
1577
|
+
}));
|
|
1578
|
+
/**
|
|
1579
|
+
* Persistent, serializable geolocation session supporting continuous and episodic pause/resume refinement.
|
|
1580
|
+
*/
|
|
1581
|
+
const LocationSessionSchema = define(z.object({
|
|
1582
|
+
id: z.string().min(1),
|
|
1583
|
+
createdAtEpochSec: z.number(),
|
|
1584
|
+
updatedAtEpochSec: z.number(),
|
|
1585
|
+
ambientTempCelsius: z.number(),
|
|
1586
|
+
constraints: SearchConstraintsSchema,
|
|
1587
|
+
enfSignature: EnfSignatureSchema,
|
|
1588
|
+
episodes: z.array(ListeningEpisodeSchema).readonly(),
|
|
1589
|
+
observations: z.array(DopplerObservationSchema).readonly(),
|
|
1590
|
+
hypotheses: z.array(LocationHypothesisSchema).readonly()
|
|
1591
|
+
}));
|
|
1592
|
+
/**
|
|
1593
|
+
* Portable dataset bundle containing 4D-RDP trajectory segments, constellation hash index, and elevation tiles.
|
|
1594
|
+
*/
|
|
1595
|
+
const DopplerDatasetSchema = define(z.object({
|
|
1596
|
+
version: z.string().min(1),
|
|
1597
|
+
generatedAtIso: z.string().min(1),
|
|
1598
|
+
segments: z.array(LinearFlightSegmentSchema).readonly(),
|
|
1599
|
+
constellations: z.array(ConstellationEntrySchema).readonly(),
|
|
1600
|
+
elevationGrid: z.array(GeoPoint3DSchema).readonly()
|
|
1601
|
+
}));
|
|
1602
|
+
// src/physics/rdp.ts
|
|
1603
|
+
/** Shortest time span in seconds over which a range's end points define a constant-speed segment; a shorter span is treated as a single instant, so the interpolation fraction is zero instead of a division by a near-zero span. */
|
|
1604
|
+
const MIN_INTERPOLATION_SPAN_SEC = 1e-6;
|
|
1605
|
+
/** Shortest simplified segment in seconds; pings arrive at 1 Hz, so a shorter span between kept pings only arises from duplicate or near-duplicate timestamps, whose groundspeed would divide by a near-zero span and is dropped. */
|
|
1606
|
+
const MIN_SEGMENT_DURATION_SEC = .1;
|
|
1607
|
+
/**
|
|
1608
|
+
* Selects the indices of key vertices using 4D Ramer-Douglas-Peucker simplification over `(X, Y, Z, t)`: the end points are kept, and a range is split at its worst-fitting ping while that ping lies further than `toleranceMeters` from the straight, constant-speed segment joining the range's ends.
|
|
1609
|
+
* The ranges are processed from an explicit stack rather than by recursion, because a trace of tens of thousands of pings can split unevenly enough to exhaust the call stack, and each ping's ECEF position is computed once.
|
|
1610
|
+
* @param pings - Chronologically ordered pings.
|
|
1611
|
+
* @param toleranceMeters - Largest allowed 3D deviation of a dropped ping.
|
|
1612
|
+
* @returns Ascending indices of the kept pings, always including the first and last.
|
|
1613
|
+
*/
|
|
1614
|
+
function rdpIndices(pings, toleranceMeters) {
|
|
1615
|
+
const count = pings.length;
|
|
1616
|
+
const ecef = pings.map((ping) => geoToEcef(ping));
|
|
1617
|
+
const keep = new Uint8Array(count);
|
|
1618
|
+
keep[0] = 1;
|
|
1619
|
+
keep[count - 1] = 1;
|
|
1620
|
+
const ranges = [[0, count - 1]];
|
|
1621
|
+
for (let range = ranges.pop(); range !== void 0; range = ranges.pop()) {
|
|
1622
|
+
const [startIdx, endIdx] = range;
|
|
1623
|
+
const first = pings[startIdx];
|
|
1624
|
+
const last = pings[endIdx];
|
|
1625
|
+
const firstEcef = ecef[startIdx];
|
|
1626
|
+
const lastEcef = ecef[endIdx];
|
|
1627
|
+
if (endIdx <= startIdx + 1 || first === void 0 || last === void 0 || firstEcef === void 0 || lastEcef === void 0) continue;
|
|
1628
|
+
const totalDt = last.epochSec - first.epochSec;
|
|
1629
|
+
let maxError = -1;
|
|
1630
|
+
let splitIdx = startIdx;
|
|
1631
|
+
for (let i = startIdx + 1; i < endIdx; i += 1) {
|
|
1632
|
+
const ping = pings[i];
|
|
1633
|
+
const position = ecef[i];
|
|
1634
|
+
if (ping === void 0 || position === void 0) continue;
|
|
1635
|
+
const fraction = totalDt > MIN_INTERPOLATION_SPAN_SEC ? Math.max(0, Math.min(1, (ping.epochSec - first.epochSec) / totalDt)) : 0;
|
|
1636
|
+
const error = Math.hypot(position.x - (firstEcef.x + (lastEcef.x - firstEcef.x) * fraction), position.y - (firstEcef.y + (lastEcef.y - firstEcef.y) * fraction), position.z - (firstEcef.z + (lastEcef.z - firstEcef.z) * fraction));
|
|
1637
|
+
if (error > maxError) {
|
|
1638
|
+
maxError = error;
|
|
1639
|
+
splitIdx = i;
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
if (maxError > toleranceMeters && splitIdx > startIdx && splitIdx < endIdx) {
|
|
1643
|
+
keep[splitIdx] = 1;
|
|
1644
|
+
ranges.push([startIdx, splitIdx], [splitIdx, endIdx]);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
return Array.from(keep.entries(), ([index, kept]) => kept === 1 ? index : -1).filter((index) => index !== -1);
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Compresses a sequence of raw 1 Hz ADS-B pings into piecewise-linear 4D segments (`LinearFlightSegment`) with bounded spatiotemporal error.
|
|
1651
|
+
* @param pings - Chronologically ordered raw ADS-B pings.
|
|
1652
|
+
* @param meta - Flight and aircraft metadata.
|
|
1653
|
+
* @param toleranceMeters - Maximum allowed 3D interpolation error in meters (default 5 m).
|
|
1654
|
+
* @returns Array of compressed linear trajectory segments.
|
|
1655
|
+
*/
|
|
1656
|
+
function simplifyTrajectory4D(pings, meta, toleranceMeters = 5) {
|
|
1657
|
+
if (pings.length < 2) return [];
|
|
1658
|
+
const sorted = [...pings].sort((a, b) => a.epochSec - b.epochSec);
|
|
1659
|
+
const keyIndices = rdpIndices(sorted, toleranceMeters);
|
|
1660
|
+
const segments = [];
|
|
1661
|
+
for (let k = 0; k < keyIndices.length - 1; k += 1) {
|
|
1662
|
+
const idxA = keyIndices[k];
|
|
1663
|
+
const idxB = keyIndices[k + 1];
|
|
1664
|
+
if (idxA === void 0 || idxB === void 0) continue;
|
|
1665
|
+
const startPing = sorted[idxA];
|
|
1666
|
+
const endPing = sorted[idxB];
|
|
1667
|
+
if (startPing === void 0 || endPing === void 0) continue;
|
|
1668
|
+
const dt = endPing.epochSec - startPing.epochSec;
|
|
1669
|
+
if (dt <= MIN_SEGMENT_DURATION_SEC) continue;
|
|
1670
|
+
const groundspeedMps = slantDistanceMeters(startPing, endPing) / dt;
|
|
1671
|
+
const midLat = (startPing.lat + endPing.lat) / 2;
|
|
1672
|
+
const midLon = (startPing.lon + endPing.lon) / 2;
|
|
1673
|
+
segments.push({
|
|
1674
|
+
id: `${meta.flightId}_${String(Math.floor(startPing.epochSec))}`,
|
|
1675
|
+
flightId: meta.flightId,
|
|
1676
|
+
icao24: meta.icao24,
|
|
1677
|
+
typeCode: meta.typeCode,
|
|
1678
|
+
category: meta.category,
|
|
1679
|
+
dateIso: meta.dateIso,
|
|
1680
|
+
startEpochSec: startPing.epochSec,
|
|
1681
|
+
endEpochSec: endPing.epochSec,
|
|
1682
|
+
startGeo: {
|
|
1683
|
+
lat: startPing.lat,
|
|
1684
|
+
lon: startPing.lon,
|
|
1685
|
+
altMeters: startPing.altMeters
|
|
1686
|
+
},
|
|
1687
|
+
endGeo: {
|
|
1688
|
+
lat: endPing.lat,
|
|
1689
|
+
lon: endPing.lon,
|
|
1690
|
+
altMeters: endPing.altMeters
|
|
1691
|
+
},
|
|
1692
|
+
groundspeedMps,
|
|
1693
|
+
h3Cell: encodeSpatialCell(midLat, midLon, 6)
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
return segments;
|
|
1697
|
+
}
|
|
1698
|
+
// src/solver/gauss-newton.ts
|
|
1699
|
+
/**
|
|
1700
|
+
* Residual size, in standard deviations, at which the score stops growing: a measurement further out than this is an outlier whatever its distance, so one gross mismatch cannot outweigh the rest of the evidence. Five sigma keeps a gross mismatch (ten sigma) costlier than a modest one (three sigma), which a three-sigma cap would score alike.
|
|
1701
|
+
*/
|
|
1702
|
+
const OUTLIER_SIGMAS = 5;
|
|
1703
|
+
/**
|
|
1704
|
+
* Standard deviation, in m/s, of the along-track wind. The Doppler fit measures speed through the air and ADS-B reports speed over the ground, so the two differ by the wind component along the track; a 5 m/s (about 10 knot) spread covers ordinary conditions.
|
|
1705
|
+
*/
|
|
1706
|
+
const WIND_SIGMA_MPS = 5;
|
|
1707
|
+
/**
|
|
1708
|
+
* Standard deviation, in seconds, of the modelled-minus-observed difference between the closest-approach times of two flyovers in one episode. A design choice: it sets how strongly timing weighs against slant, speed and frequency evidence.
|
|
1709
|
+
*/
|
|
1710
|
+
const CPA_OFFSET_SIGMA_SEC = .8;
|
|
1711
|
+
/**
|
|
1712
|
+
* Standard deviation, in Hz, of a ridge-point frequency residual. A design choice: it sets how strongly the ridge shape weighs against slant, speed and timing evidence.
|
|
1713
|
+
*/
|
|
1714
|
+
const RIDGE_FREQ_SIGMA_HZ = .35;
|
|
1715
|
+
/**
|
|
1716
|
+
* Largest per-flyover fundamental-frequency trim, in Hz, that the residual evaluation absorbs before scoring the ridge. It allows a small engine speed (RPM) trim per aircraft, and its bound stops a wrong trajectory from being fitted by shifting its whole ridge.
|
|
1717
|
+
*/
|
|
1718
|
+
const MAX_F0_TRIM_HZ = 2.5;
|
|
1719
|
+
/**
|
|
1720
|
+
* Standard deviation, in metres, of the weak isotropic prior added to the Fisher information so that a single-flyover ribbon, which constrains one direction only, stays invertible.
|
|
1721
|
+
*/
|
|
1722
|
+
const PRIOR_SIGMA_METERS = 2500;
|
|
1723
|
+
/**
|
|
1724
|
+
* Smallest Fisher eigenvalue, in 1/m^2, the confidence ellipse accepts, so that a degenerate matrix cannot divide by zero.
|
|
1725
|
+
*/
|
|
1726
|
+
const MIN_FISHER_EIGENVALUE = 1e-9;
|
|
1727
|
+
/**
|
|
1728
|
+
* Chi-square quantile with two degrees of freedom at probability 0.95 (`-2 * ln(0.05)`, rounded to five decimals): the squared radius of the 95% confidence ellipse in standard deviations.
|
|
1729
|
+
*/
|
|
1730
|
+
const CHI_SQUARE_2DOF_95 = 5.99146;
|
|
1731
|
+
/**
|
|
1732
|
+
* Largest semi-axis in metres that a confidence ellipse reports; a larger uncertainty is reported as this bound.
|
|
1733
|
+
*/
|
|
1734
|
+
const MAX_ELLIPSE_SEMI_AXIS_METERS = 15e3;
|
|
1735
|
+
/**
|
|
1736
|
+
* Smallest semi-major axis in metres that a confidence ellipse reports, so a very sharp fit is not reported as an exact position.
|
|
1737
|
+
*/
|
|
1738
|
+
const MIN_ELLIPSE_SEMI_MAJOR_METERS = 2;
|
|
1739
|
+
/**
|
|
1740
|
+
* Smallest semi-minor axis in metres that a confidence ellipse reports, so a very sharp fit is not reported as an exact position.
|
|
1741
|
+
*/
|
|
1742
|
+
const MIN_ELLIPSE_SEMI_MINOR_METERS = 1.5;
|
|
1743
|
+
/**
|
|
1744
|
+
* Angle in degrees between the East axis (where the mathematical angle starts) and North (where the compass bearing starts), so that `bearing = NORTH_FROM_EAST_DEG - angle`.
|
|
1745
|
+
*/
|
|
1746
|
+
const NORTH_FROM_EAST_DEG = 90;
|
|
1747
|
+
/**
|
|
1748
|
+
* Number of Levenberg-Marquardt iterations. Each iteration costs three residual evaluations over every pair, so it bounds the work spent on one candidate; a design choice that trades convergence for work.
|
|
1749
|
+
*/
|
|
1750
|
+
const MAX_REFINEMENT_ITERATIONS = 8;
|
|
1751
|
+
/**
|
|
1752
|
+
* Initial Levenberg-Marquardt damping, as a fraction of the diagonal of the normal equations. A small value starts close to a pure Gauss-Newton step.
|
|
1753
|
+
*/
|
|
1754
|
+
const INITIAL_DAMPING = .01;
|
|
1755
|
+
/**
|
|
1756
|
+
* Factor by which damping shrinks after an accepted step, moving towards a pure Gauss-Newton step.
|
|
1757
|
+
*/
|
|
1758
|
+
const DAMPING_DECREASE_FACTOR = .3;
|
|
1759
|
+
/**
|
|
1760
|
+
* Factor by which damping grows after a rejected step, moving towards a short gradient-descent step.
|
|
1761
|
+
*/
|
|
1762
|
+
const DAMPING_INCREASE_FACTOR = 4;
|
|
1763
|
+
/**
|
|
1764
|
+
* Finite-difference step in metres used for the numerical Jacobian: a design choice that trades truncation error (too large a step) against floating-point noise in the residual differences (too small).
|
|
1765
|
+
*/
|
|
1766
|
+
const FINITE_DIFFERENCE_STEP_METERS = 2;
|
|
1767
|
+
/**
|
|
1768
|
+
* Floor on a diagonal entry of the normal equations, in the units of the Jacobian squared, so that an unconstrained direction still receives damping.
|
|
1769
|
+
*/
|
|
1770
|
+
const MIN_NORMAL_DIAGONAL = 1e-4;
|
|
1771
|
+
/**
|
|
1772
|
+
* Determinant magnitude below which the damped normal equations are treated as singular and the refinement stops.
|
|
1773
|
+
*/
|
|
1774
|
+
const SINGULAR_DETERMINANT = 1e-12;
|
|
1775
|
+
/**
|
|
1776
|
+
* Longest single refinement step in metres, which keeps a poorly conditioned step from leaving the neighbourhood where the linearisation holds.
|
|
1777
|
+
*/
|
|
1778
|
+
const MAX_STEP_METERS = 1500;
|
|
1779
|
+
/**
|
|
1780
|
+
* Accepted step length in metres below which the refinement has converged.
|
|
1781
|
+
*/
|
|
1782
|
+
const CONVERGED_STEP_METERS = .25;
|
|
1783
|
+
/**
|
|
1784
|
+
* Log-likelihood cost of leaving an observation unmatched. An observation that no segment explains contributes every residual term it would have had (one slant residual, one speed residual and one frequency residual per ridge point) at the outlier gate, so a hypothesis cannot outscore a rival by ignoring evidence: a poor fit costs at most this much, and an omission costs exactly this much.
|
|
1785
|
+
* @param observation - The observation a hypothesis does not explain.
|
|
1786
|
+
* @returns A non-negative amount to subtract from the hypothesis's log-posterior.
|
|
1787
|
+
*/
|
|
1788
|
+
function unmatchedObservationLogPenalty(observation) {
|
|
1789
|
+
return (2 + observation.ridgePoints.length) * OUTLIER_SIGMAS ** 2 / 2;
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* Evaluates the frequency + CPA timing residual vector and log-likelihood at a local East-North offset `(eastMeters, northMeters)` from `referenceOrigin`.
|
|
1793
|
+
*/
|
|
1794
|
+
function evaluateResidualsAtEnuOffset(referenceOrigin, offsetEnu, pairs, context) {
|
|
1795
|
+
const { eastMeters, northMeters } = offsetEnu;
|
|
1796
|
+
const { demGrid, soundSpeedMps } = context;
|
|
1797
|
+
const shiftedGeo = localEnuToGeo(referenceOrigin, {
|
|
1798
|
+
x: eastMeters,
|
|
1799
|
+
y: northMeters,
|
|
1800
|
+
z: 0
|
|
1801
|
+
});
|
|
1802
|
+
const groundAlt = interpolateTerrainElevationMeters(shiftedGeo.lat, shiftedGeo.lon, demGrid);
|
|
1803
|
+
const point = {
|
|
1804
|
+
lat: shiftedGeo.lat,
|
|
1805
|
+
lon: shiftedGeo.lon,
|
|
1806
|
+
altMeters: groundAlt
|
|
1807
|
+
};
|
|
1808
|
+
const firstPair = pairs[0];
|
|
1809
|
+
if (firstPair === void 0) return {
|
|
1810
|
+
point,
|
|
1811
|
+
estimatedStartEpochSec: 0,
|
|
1812
|
+
residuals: [],
|
|
1813
|
+
rmsFreqResidualHz: 0
|
|
1814
|
+
};
|
|
1815
|
+
const episodeReference = /* @__PURE__ */ new Map();
|
|
1816
|
+
for (const pair of pairs) if (!episodeReference.has(pair.observation.episodeId)) episodeReference.set(pair.observation.episodeId, pair);
|
|
1817
|
+
const sameClockPairs = pairs.filter((pair) => pair.observation.episodeId === firstPair.observation.episodeId);
|
|
1818
|
+
let sumStartEpoch = 0;
|
|
1819
|
+
for (const pair of sameClockPairs) {
|
|
1820
|
+
const cpa = computeSegmentCpa(point, pair.segment, soundSpeedMps);
|
|
1821
|
+
sumStartEpoch += cpa.cpaDelayedEpochSec - pair.observation.cpaTimeSec;
|
|
1822
|
+
}
|
|
1823
|
+
const estimatedStartEpochSec = sumStartEpoch / sameClockPairs.length;
|
|
1824
|
+
const residuals = [];
|
|
1825
|
+
let freqSqSum = 0;
|
|
1826
|
+
let freqCount = 0;
|
|
1827
|
+
for (const pair of pairs) {
|
|
1828
|
+
const { observation, segment } = pair;
|
|
1829
|
+
const cpa = computeSegmentCpa(point, segment, soundSpeedMps);
|
|
1830
|
+
const slantDiff = cpa.cpaSlantMeters - observation.kinematics.inferredSlantMeters;
|
|
1831
|
+
const slantSigma = Math.max(40, observation.kinematics.slantStdMeters);
|
|
1832
|
+
residuals.push(slantDiff / slantSigma);
|
|
1833
|
+
const speedSigma = Math.hypot(WIND_SIGMA_MPS, observation.kinematics.speedStdMps);
|
|
1834
|
+
residuals.push((segment.groundspeedMps - observation.kinematics.inferredSpeedMps) / speedSigma);
|
|
1835
|
+
const reference = episodeReference.get(observation.episodeId);
|
|
1836
|
+
if (reference !== void 0 && reference !== pair) {
|
|
1837
|
+
const firstCpa = computeSegmentCpa(point, reference.segment, soundSpeedMps);
|
|
1838
|
+
const timeDiff = cpa.cpaDelayedEpochSec - firstCpa.cpaDelayedEpochSec - (observation.cpaTimeSec - reference.observation.cpaTimeSec);
|
|
1839
|
+
residuals.push(timeDiff / CPA_OFFSET_SIGMA_SEC);
|
|
1840
|
+
}
|
|
1841
|
+
const sampleEpochs = observation.ridgePoints.map((pt) => cpa.cpaDelayedEpochSec + (pt.timeSec - observation.cpaTimeSec));
|
|
1842
|
+
const modeledCurve = synthesizeRetardedDopplerCurve(point, segment, {
|
|
1843
|
+
baseFreqHz: observation.kinematics.baseFreqHz,
|
|
1844
|
+
obsEpochTimesSec: sampleEpochs,
|
|
1845
|
+
soundSpeedMps
|
|
1846
|
+
});
|
|
1847
|
+
let meanFreqOffset = 0;
|
|
1848
|
+
const nPts = Math.min(observation.ridgePoints.length, modeledCurve.length);
|
|
1849
|
+
for (let k = 0; k < nPts; k += 1) {
|
|
1850
|
+
const obsPt = observation.ridgePoints[k];
|
|
1851
|
+
const modPt = modeledCurve[k];
|
|
1852
|
+
if (obsPt !== void 0 && modPt !== void 0) meanFreqOffset += obsPt.freqHz - modPt.observedFreqHz;
|
|
1853
|
+
}
|
|
1854
|
+
meanFreqOffset = nPts > 0 ? meanFreqOffset / nPts : 0;
|
|
1855
|
+
const clampedF0Trim = Math.max(-2.5, Math.min(MAX_F0_TRIM_HZ, meanFreqOffset));
|
|
1856
|
+
for (let k = 0; k < nPts; k += 1) {
|
|
1857
|
+
const obsPt = observation.ridgePoints[k];
|
|
1858
|
+
const modPt = modeledCurve[k];
|
|
1859
|
+
if (obsPt !== void 0 && modPt !== void 0) {
|
|
1860
|
+
const freqErr = obsPt.freqHz - (modPt.observedFreqHz + clampedF0Trim);
|
|
1861
|
+
freqSqSum += freqErr * freqErr;
|
|
1862
|
+
freqCount += 1;
|
|
1863
|
+
residuals.push(freqErr / RIDGE_FREQ_SIGMA_HZ);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return {
|
|
1868
|
+
point,
|
|
1869
|
+
estimatedStartEpochSec,
|
|
1870
|
+
residuals,
|
|
1871
|
+
rmsFreqResidualHz: freqCount > 0 ? Math.sqrt(freqSqSum / freqCount) : 0
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Converts a 2x2 Fisher Information matrix `[I_xx, I_xy, I_yx, I_yy]` in inverse-square meters into a 95% confidence ellipse (`chi2_2(0.95) = 5.991`).
|
|
1876
|
+
* @param fisher - 2x2 symmetric Fisher Information matrix entries `[I_xx, I_xy, I_yx, I_yy]`.
|
|
1877
|
+
* @returns Semi-major axis (m), semi-minor axis (m), and compass bearing of semi-major axis (degrees).
|
|
1878
|
+
*/
|
|
1879
|
+
function fisherToConfidenceEllipse95(fisher) {
|
|
1880
|
+
const priorReg = 1 / (PRIOR_SIGMA_METERS * PRIOR_SIGMA_METERS);
|
|
1881
|
+
const ixx = fisher[0] + priorReg;
|
|
1882
|
+
const ixy = (fisher[1] + fisher[2]) / 2;
|
|
1883
|
+
const iyy = fisher[3] + priorReg;
|
|
1884
|
+
const trace = ixx + iyy;
|
|
1885
|
+
const diff = ixx - iyy;
|
|
1886
|
+
const disc = Math.hypot(diff, 2 * ixy);
|
|
1887
|
+
const lambdaMax = Math.max(MIN_FISHER_EIGENVALUE, (trace + disc) / 2);
|
|
1888
|
+
const lambdaMin = Math.max(MIN_FISHER_EIGENVALUE, (trace - disc) / 2);
|
|
1889
|
+
const chi2Scale = Math.sqrt(CHI_SQUARE_2DOF_95);
|
|
1890
|
+
const ellipseSemiMajorMeters = Math.min(MAX_ELLIPSE_SEMI_AXIS_METERS, Math.max(MIN_ELLIPSE_SEMI_MAJOR_METERS, chi2Scale / Math.sqrt(lambdaMin)));
|
|
1891
|
+
const ellipseSemiMinorMeters = Math.min(MAX_ELLIPSE_SEMI_AXIS_METERS, Math.max(MIN_ELLIPSE_SEMI_MINOR_METERS, chi2Scale / Math.sqrt(lambdaMax)));
|
|
1892
|
+
const angleRad = Math.atan2(2 * ixy, ixx - iyy) / 2 + Math.PI / 2;
|
|
1893
|
+
return {
|
|
1894
|
+
ellipseSemiMajorMeters,
|
|
1895
|
+
ellipseSemiMinorMeters,
|
|
1896
|
+
ellipseBearingDeg: ((NORTH_FROM_EAST_DEG - angleRad * 180 / Math.PI) % 360 + 360) % 360
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Refines a candidate observer coordinate `(lat, lon)` without spatial grids using damped Levenberg-Marquardt nonlinear least squares and computes the 2x2 Fisher Information matrix and 95% confidence ellipse.
|
|
1901
|
+
* @param seedGeo - Coarse initial coordinate (e.g. H3 cell center or cylinder intersection).
|
|
1902
|
+
* @param pairs - Matched observation-to-segment pairs (1 or more flyovers).
|
|
1903
|
+
* @param options - Terrain elevation grid and sound speed.
|
|
1904
|
+
* @returns Optimized coordinate, log-likelihood, Fisher Information matrix, and 95% error ellipse.
|
|
1905
|
+
*/
|
|
1906
|
+
function refineObserverCoordinateGaussNewton(seedGeo, pairs, options = {}) {
|
|
1907
|
+
const { demGrid = [], soundSpeedMps = 343 } = options;
|
|
1908
|
+
const context = {
|
|
1909
|
+
demGrid,
|
|
1910
|
+
soundSpeedMps
|
|
1911
|
+
};
|
|
1912
|
+
let eastMeters = 0;
|
|
1913
|
+
let northMeters = 0;
|
|
1914
|
+
let damping = INITIAL_DAMPING;
|
|
1915
|
+
let currentEval = evaluateResidualsAtEnuOffset(seedGeo, {
|
|
1916
|
+
eastMeters,
|
|
1917
|
+
northMeters
|
|
1918
|
+
}, pairs, context);
|
|
1919
|
+
let currentCost = currentEval.residuals.reduce((acc, r) => acc + r * r, 0);
|
|
1920
|
+
let jtj00 = 0;
|
|
1921
|
+
let jtj01 = 0;
|
|
1922
|
+
let jtj11 = 0;
|
|
1923
|
+
for (let iter = 0; iter < MAX_REFINEMENT_ITERATIONS; iter += 1) {
|
|
1924
|
+
const evalPlusE = evaluateResidualsAtEnuOffset(seedGeo, {
|
|
1925
|
+
eastMeters: eastMeters + FINITE_DIFFERENCE_STEP_METERS,
|
|
1926
|
+
northMeters
|
|
1927
|
+
}, pairs, context);
|
|
1928
|
+
const evalPlusN = evaluateResidualsAtEnuOffset(seedGeo, {
|
|
1929
|
+
eastMeters,
|
|
1930
|
+
northMeters: northMeters + FINITE_DIFFERENCE_STEP_METERS
|
|
1931
|
+
}, pairs, context);
|
|
1932
|
+
jtj00 = 0;
|
|
1933
|
+
jtj01 = 0;
|
|
1934
|
+
jtj11 = 0;
|
|
1935
|
+
let jtr0 = 0;
|
|
1936
|
+
let jtr1 = 0;
|
|
1937
|
+
const nRes = currentEval.residuals.length;
|
|
1938
|
+
for (let k = 0; k < nRes; k += 1) {
|
|
1939
|
+
const r0 = currentEval.residuals[k] ?? 0;
|
|
1940
|
+
const rE = evalPlusE.residuals[k] ?? r0;
|
|
1941
|
+
const rN = evalPlusN.residuals[k] ?? r0;
|
|
1942
|
+
const jE = (rE - r0) / FINITE_DIFFERENCE_STEP_METERS;
|
|
1943
|
+
const jN = (rN - r0) / FINITE_DIFFERENCE_STEP_METERS;
|
|
1944
|
+
jtj00 += jE * jE;
|
|
1945
|
+
jtj01 += jE * jN;
|
|
1946
|
+
jtj11 += jN * jN;
|
|
1947
|
+
jtr0 += jE * r0;
|
|
1948
|
+
jtr1 += jN * r0;
|
|
1949
|
+
}
|
|
1950
|
+
const a00 = jtj00 + damping * Math.max(MIN_NORMAL_DIAGONAL, jtj00);
|
|
1951
|
+
const a11 = jtj11 + damping * Math.max(MIN_NORMAL_DIAGONAL, jtj11);
|
|
1952
|
+
const det = a00 * a11 - jtj01 * jtj01;
|
|
1953
|
+
if (Math.abs(det) < SINGULAR_DETERMINANT) break;
|
|
1954
|
+
const rawStepE = -(a11 * jtr0 - jtj01 * jtr1) / det;
|
|
1955
|
+
const rawStepN = -(-jtj01 * jtr0 + a00 * jtr1) / det;
|
|
1956
|
+
const stepNorm = Math.hypot(rawStepE, rawStepN);
|
|
1957
|
+
const scale = stepNorm > MAX_STEP_METERS ? MAX_STEP_METERS / stepNorm : 1;
|
|
1958
|
+
const stepE = rawStepE * scale;
|
|
1959
|
+
const stepN = rawStepN * scale;
|
|
1960
|
+
const candidateEval = evaluateResidualsAtEnuOffset(seedGeo, {
|
|
1961
|
+
eastMeters: eastMeters + stepE,
|
|
1962
|
+
northMeters: northMeters + stepN
|
|
1963
|
+
}, pairs, context);
|
|
1964
|
+
const candidateCost = candidateEval.residuals.reduce((acc, r) => acc + r * r, 0);
|
|
1965
|
+
if (candidateCost < currentCost) {
|
|
1966
|
+
eastMeters += stepE;
|
|
1967
|
+
northMeters += stepN;
|
|
1968
|
+
currentEval = candidateEval;
|
|
1969
|
+
currentCost = candidateCost;
|
|
1970
|
+
damping *= DAMPING_DECREASE_FACTOR;
|
|
1971
|
+
if (Math.hypot(stepE, stepN) < CONVERGED_STEP_METERS) break;
|
|
1972
|
+
} else damping *= DAMPING_INCREASE_FACTOR;
|
|
1973
|
+
}
|
|
1974
|
+
const fisherInfo = [
|
|
1975
|
+
jtj00,
|
|
1976
|
+
jtj01,
|
|
1977
|
+
jtj01,
|
|
1978
|
+
jtj11
|
|
1979
|
+
];
|
|
1980
|
+
const ellipse = fisherToConfidenceEllipse95(fisherInfo);
|
|
1981
|
+
const logLikelihood = -(currentEval.residuals.reduce((acc, r) => acc + Math.min(r * r, OUTLIER_SIGMAS ** 2), 0) / 2);
|
|
1982
|
+
return {
|
|
1983
|
+
lat: currentEval.point.lat,
|
|
1984
|
+
lon: currentEval.point.lon,
|
|
1985
|
+
altMeters: currentEval.point.altMeters,
|
|
1986
|
+
estimatedStartEpochSec: currentEval.estimatedStartEpochSec,
|
|
1987
|
+
rmsFreqResidualHz: currentEval.rmsFreqResidualHz,
|
|
1988
|
+
logLikelihood,
|
|
1989
|
+
fisherInfo,
|
|
1990
|
+
ellipseSemiMajorMeters: ellipse.ellipseSemiMajorMeters,
|
|
1991
|
+
ellipseSemiMinorMeters: ellipse.ellipseSemiMinorMeters,
|
|
1992
|
+
ellipseBearingDeg: ellipse.ellipseBearingDeg
|
|
1993
|
+
};
|
|
1994
|
+
}
|
|
1995
|
+
// src/solver/session.ts
|
|
1996
|
+
/** Hypotheses kept after ranking, which bounds the work each further flyover costs. */
|
|
1997
|
+
const MAX_HYPOTHESES = 15;
|
|
1998
|
+
/** Milliseconds in a second, the divisor from `Date.now()` to epoch seconds. */
|
|
1999
|
+
const MS_PER_SEC = 1e3;
|
|
2000
|
+
/** Radix of the random part of a session id: digits and lowercase letters. */
|
|
2001
|
+
const SESSION_ID_RADIX = 36;
|
|
2002
|
+
/** Characters `Math.random().toString(36)` places before the fraction digits (the leading `0.`), skipped when taking the random part. */
|
|
2003
|
+
const SESSION_ID_PREFIX_LENGTH = 2;
|
|
2004
|
+
/** Air temperature in degrees Celsius assumed for the speed of sound when the caller gives none; a design default for a mild day. */
|
|
2005
|
+
const DEFAULT_AMBIENT_TEMP_CELSIUS = 20;
|
|
2006
|
+
/** Noise floor in decibels reported for an episode before its first noise update, below any floor measured in practice so that it never reads as pollution. */
|
|
2007
|
+
const INITIAL_NOISE_FLOOR_DB = -80;
|
|
2008
|
+
/** Number of standard deviations of the measured speed uncertainty allowed between a measured Doppler speed and a segment's ground speed, when that exceeds the along-track wind allowance. */
|
|
2009
|
+
const SPEED_TOLERANCE_SIGMAS = 2.5;
|
|
2010
|
+
/** Largest difference in metres between a segment's closest-approach slant distance at a cell centre and the measured slant distance for the cell to seed a hypothesis. It trades missed seeds against refinement work, since each admitted seed costs a Gauss-Newton run. */
|
|
2011
|
+
const SEED_SLANT_GATE_METERS = 3500;
|
|
2012
|
+
/** Largest difference in seconds between the arrival-time gap a candidate segment implies and the measured gap on one clock for the segment to explain a further flyover. */
|
|
2013
|
+
const TIMING_GAP_GATE_SEC = 30;
|
|
2014
|
+
/** Largest difference in metres between a candidate segment's closest-approach slant distance at a hypothesis and the measured slant distance for the segment to explain a further flyover. */
|
|
2015
|
+
const MATCH_SLANT_GATE_METERS = 4e3;
|
|
2016
|
+
/**
|
|
2017
|
+
* Reads the system clock as whole seconds since the Unix epoch.
|
|
2018
|
+
* @returns Epoch seconds, rounded down.
|
|
2019
|
+
*/
|
|
2020
|
+
function nowEpochSec() {
|
|
2021
|
+
return Math.floor(Date.now() / MS_PER_SEC);
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* Continuous & Episodic Bayesian Refinement Engine.
|
|
2025
|
+
*
|
|
2026
|
+
* Supports leaving the microphone running continuously, or pausing and resuming across hours, days,
|
|
2027
|
+
* or device reboots. Ingests observations sequentially and computes an accumulating log-posterior
|
|
2028
|
+
* surface and Fisher Information covariance matrix across surviving candidate hypotheses.
|
|
2029
|
+
*/
|
|
2030
|
+
var DopplerSessionEngine = class DopplerSessionEngine {
|
|
2031
|
+
session;
|
|
2032
|
+
dataset;
|
|
2033
|
+
segmentsById;
|
|
2034
|
+
constructor(options) {
|
|
2035
|
+
this.dataset = options.dataset;
|
|
2036
|
+
const segMap = /* @__PURE__ */ new Map();
|
|
2037
|
+
for (const seg of options.dataset.segments) segMap.set(seg.id, seg);
|
|
2038
|
+
this.segmentsById = segMap;
|
|
2039
|
+
if (options.initialSession !== void 0) this.session = { ...options.initialSession };
|
|
2040
|
+
else {
|
|
2041
|
+
const now = nowEpochSec();
|
|
2042
|
+
this.session = {
|
|
2043
|
+
id: `sess_${String(now)}_${Math.random().toString(SESSION_ID_RADIX).slice(SESSION_ID_PREFIX_LENGTH, 8)}`,
|
|
2044
|
+
createdAtEpochSec: now,
|
|
2045
|
+
updatedAtEpochSec: now,
|
|
2046
|
+
ambientTempCelsius: options.ambientTempCelsius ?? DEFAULT_AMBIENT_TEMP_CELSIUS,
|
|
2047
|
+
constraints: options.constraints ?? {},
|
|
2048
|
+
enfSignature: {
|
|
2049
|
+
detected: false,
|
|
2050
|
+
nominalHz: "unknown",
|
|
2051
|
+
measuredFreqHz: 0,
|
|
2052
|
+
snrDb: 0,
|
|
2053
|
+
regionHint: "No ENF analysis performed yet"
|
|
2054
|
+
},
|
|
2055
|
+
episodes: [],
|
|
2056
|
+
observations: [],
|
|
2057
|
+
hypotheses: []
|
|
2058
|
+
};
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Returns an immutable snapshot of the current session state.
|
|
2063
|
+
*/
|
|
2064
|
+
getSession() {
|
|
2065
|
+
return this.session;
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Updates multi-axis user constraints (bounding box, date range, hour window, ENF grid) and prunes incompatible hypotheses.
|
|
2069
|
+
*/
|
|
2070
|
+
updateConstraints(constraints) {
|
|
2071
|
+
const updatedHypotheses = this.session.hypotheses.filter((h) => matchesSearchConstraints({
|
|
2072
|
+
lat: h.lat,
|
|
2073
|
+
lon: h.lon,
|
|
2074
|
+
dateIso: h.dateIso,
|
|
2075
|
+
epochSec: h.estimatedStartEpochSec
|
|
2076
|
+
}, constraints));
|
|
2077
|
+
this.session = {
|
|
2078
|
+
...this.session,
|
|
2079
|
+
updatedAtEpochSec: nowEpochSec(),
|
|
2080
|
+
constraints: {
|
|
2081
|
+
...this.session.constraints,
|
|
2082
|
+
...constraints
|
|
2083
|
+
},
|
|
2084
|
+
hypotheses: updatedHypotheses
|
|
2085
|
+
};
|
|
2086
|
+
return this.session;
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Starts a new listening episode (e.g. user clicks "Start Listening" or device boots up).
|
|
2090
|
+
*/
|
|
2091
|
+
startEpisode(startedAtEpochSec) {
|
|
2092
|
+
const newEpisode = {
|
|
2093
|
+
id: `ep_${String(startedAtEpochSec)}_${String(this.session.episodes.length + 1)}`,
|
|
2094
|
+
startedAtSec: startedAtEpochSec,
|
|
2095
|
+
endedAtSec: startedAtEpochSec,
|
|
2096
|
+
status: "active",
|
|
2097
|
+
currentNoiseFloorDb: INITIAL_NOISE_FLOOR_DB,
|
|
2098
|
+
noisePolluted: false,
|
|
2099
|
+
quietIntervals: []
|
|
2100
|
+
};
|
|
2101
|
+
this.session = {
|
|
2102
|
+
...this.session,
|
|
2103
|
+
updatedAtEpochSec: nowEpochSec(),
|
|
2104
|
+
episodes: [...this.session.episodes, newEpisode]
|
|
2105
|
+
};
|
|
2106
|
+
return newEpisode;
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Updates episode telemetry with real-time in-band noise floor and pollution gating.
|
|
2110
|
+
*/
|
|
2111
|
+
updateEpisodeNoise(episodeId, currentNoiseFloorDb, noisePolluted, currentEpochSec) {
|
|
2112
|
+
const episodes = this.session.episodes.map((ep) => {
|
|
2113
|
+
if (ep.id !== episodeId || ep.status !== "active") return ep;
|
|
2114
|
+
return {
|
|
2115
|
+
...ep,
|
|
2116
|
+
endedAtSec: currentEpochSec,
|
|
2117
|
+
currentNoiseFloorDb,
|
|
2118
|
+
noisePolluted
|
|
2119
|
+
};
|
|
2120
|
+
});
|
|
2121
|
+
this.session = {
|
|
2122
|
+
...this.session,
|
|
2123
|
+
updatedAtEpochSec: nowEpochSec(),
|
|
2124
|
+
episodes
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Pauses an active listening episode (e.g. user pauses, device enters sleep, or ambient noise exceeds threshold).
|
|
2129
|
+
*/
|
|
2130
|
+
pauseEpisode(episodeId, pausedAtEpochSec) {
|
|
2131
|
+
const episodes = this.session.episodes.map((ep) => {
|
|
2132
|
+
if (ep.id !== episodeId) return ep;
|
|
2133
|
+
return {
|
|
2134
|
+
...ep,
|
|
2135
|
+
endedAtSec: pausedAtEpochSec,
|
|
2136
|
+
status: "paused"
|
|
2137
|
+
};
|
|
2138
|
+
});
|
|
2139
|
+
this.session = {
|
|
2140
|
+
...this.session,
|
|
2141
|
+
updatedAtEpochSec: nowEpochSec(),
|
|
2142
|
+
episodes
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Resumes a paused episode or starts a follow-up active episode within the session.
|
|
2147
|
+
*/
|
|
2148
|
+
resumeEpisode(episodeId, resumedAtEpochSec) {
|
|
2149
|
+
const existing = this.session.episodes.find((ep) => ep.id === episodeId);
|
|
2150
|
+
if (existing?.status === "paused") {
|
|
2151
|
+
const episodes = this.session.episodes.map((ep) => {
|
|
2152
|
+
if (ep.id !== episodeId) return ep;
|
|
2153
|
+
return {
|
|
2154
|
+
...ep,
|
|
2155
|
+
status: "active",
|
|
2156
|
+
endedAtSec: resumedAtEpochSec
|
|
2157
|
+
};
|
|
2158
|
+
});
|
|
2159
|
+
this.session = {
|
|
2160
|
+
...this.session,
|
|
2161
|
+
updatedAtEpochSec: nowEpochSec(),
|
|
2162
|
+
episodes
|
|
2163
|
+
};
|
|
2164
|
+
return {
|
|
2165
|
+
...existing,
|
|
2166
|
+
status: "active",
|
|
2167
|
+
endedAtSec: resumedAtEpochSec
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
return this.startEpisode(resumedAtEpochSec);
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Ingests an extracted DopplerObservation and performs sequential Bayesian refinement.
|
|
2174
|
+
*
|
|
2175
|
+
* - First flyover: creates candidate hypotheses along matching flight track cylinders.
|
|
2176
|
+
* - Second flyover: queries constellation hash index or pairs with existing observation to collapse candidates to curve intersections.
|
|
2177
|
+
* - Third+ flyovers: refits each surviving hypothesis on every flyover it explains, so the Fisher Information matrix is that of the joint fit (shrinking O(1/sqrt(N))) and no flyover is counted twice.
|
|
2178
|
+
*
|
|
2179
|
+
* An observation whose id was ingested before replaces it, and the hypotheses are rebuilt from the whole list: a live listener re-reports a growing flyover under one id, and the latest report must not stack on the earlier ones.
|
|
2180
|
+
*/
|
|
2181
|
+
ingestObservation(obs) {
|
|
2182
|
+
const index = this.session.observations.findIndex((existing) => existing.id === obs.id);
|
|
2183
|
+
if (index === -1) return this.applyObservation(obs);
|
|
2184
|
+
const observations = this.session.observations.map((existing, position) => position === index ? obs : existing);
|
|
2185
|
+
this.session = {
|
|
2186
|
+
...this.session,
|
|
2187
|
+
observations: [],
|
|
2188
|
+
hypotheses: []
|
|
2189
|
+
};
|
|
2190
|
+
for (const observation of observations) this.applyObservation(observation);
|
|
2191
|
+
return this.session;
|
|
2192
|
+
}
|
|
2193
|
+
applyObservation(obs) {
|
|
2194
|
+
const soundSpeedMps = speedOfSoundAtTempMps(this.session.ambientTempCelsius);
|
|
2195
|
+
const existingObs = this.session.observations;
|
|
2196
|
+
const allObs = [...existingObs, obs];
|
|
2197
|
+
let newHypotheses = [];
|
|
2198
|
+
if (existingObs.length === 0) newHypotheses = this.spawnInitialHypotheses(obs, soundSpeedMps, allObs);
|
|
2199
|
+
else if (this.session.hypotheses.length === 0) {
|
|
2200
|
+
const prevObs = existingObs.findLast((candidate) => candidate.episodeId === obs.episodeId);
|
|
2201
|
+
newHypotheses = prevObs === void 0 ? [] : this.matchConstellations({
|
|
2202
|
+
obsA: prevObs,
|
|
2203
|
+
obsB: obs,
|
|
2204
|
+
allObs
|
|
2205
|
+
}, soundSpeedMps);
|
|
2206
|
+
if (newHypotheses.length === 0) newHypotheses = this.spawnInitialHypotheses(obs, soundSpeedMps, allObs);
|
|
2207
|
+
} else newHypotheses = this.refineExistingHypotheses(obs, soundSpeedMps);
|
|
2208
|
+
newHypotheses = newHypotheses.filter((h) => matchesSearchConstraints({
|
|
2209
|
+
lat: h.lat,
|
|
2210
|
+
lon: h.lon,
|
|
2211
|
+
dateIso: h.dateIso,
|
|
2212
|
+
epochSec: h.estimatedStartEpochSec
|
|
2213
|
+
}, this.session.constraints));
|
|
2214
|
+
if (newHypotheses.length > 0) {
|
|
2215
|
+
const maxLog = Math.max(...newHypotheses.map((h) => h.logPosterior));
|
|
2216
|
+
const expSum = newHypotheses.reduce((acc, h) => acc + Math.exp(h.logPosterior - maxLog), 0);
|
|
2217
|
+
newHypotheses = newHypotheses.map((h) => ({
|
|
2218
|
+
...h,
|
|
2219
|
+
probability: expSum > 0 ? Math.exp(h.logPosterior - maxLog) / expSum : 1 / newHypotheses.length
|
|
2220
|
+
}));
|
|
2221
|
+
newHypotheses.sort((a, b) => b.logPosterior - a.logPosterior);
|
|
2222
|
+
}
|
|
2223
|
+
this.session = {
|
|
2224
|
+
...this.session,
|
|
2225
|
+
updatedAtEpochSec: nowEpochSec(),
|
|
2226
|
+
observations: allObs,
|
|
2227
|
+
hypotheses: newHypotheses
|
|
2228
|
+
};
|
|
2229
|
+
return this.session;
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Log-posterior of a hypothesis: the joint fit of the flyovers it explains, less the cost of every flyover it leaves unexplained.
|
|
2233
|
+
*/
|
|
2234
|
+
static scoreHypothesis(fitLogLikelihood, allObs, matches) {
|
|
2235
|
+
return fitLogLikelihood - allObs.filter((obs) => !matches.some((match) => match.observationId === obs.id)).reduce((total, obs) => total + unmatchedObservationLogPenalty(obs), 0);
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Allowed speed difference between a measured Doppler speed and a segment's ground speed, which differ by the along-track wind.
|
|
2239
|
+
*/
|
|
2240
|
+
static speedTolerance(kinematics) {
|
|
2241
|
+
return Math.max(15, kinematics.speedStdMps * SPEED_TOLERANCE_SIGMAS);
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Generates initial candidate hypotheses along flight tracks matching a single flyover.
|
|
2245
|
+
*/
|
|
2246
|
+
spawnInitialHypotheses(obs, soundSpeedMps, allObs) {
|
|
2247
|
+
const candidates = [];
|
|
2248
|
+
const targetSpeed = obs.kinematics.inferredSpeedMps;
|
|
2249
|
+
const speedTolerance = DopplerSessionEngine.speedTolerance(obs.kinematics);
|
|
2250
|
+
for (const seg of this.dataset.segments) {
|
|
2251
|
+
if (seg.category === "jet") continue;
|
|
2252
|
+
if (Math.abs(seg.groundspeedMps - targetSpeed) > speedTolerance) continue;
|
|
2253
|
+
const center = decodeSpatialCellCenter(seg.h3Cell);
|
|
2254
|
+
const groundAlt = interpolateTerrainElevationMeters(center.lat, center.lon, this.dataset.elevationGrid);
|
|
2255
|
+
const seed = {
|
|
2256
|
+
lat: center.lat,
|
|
2257
|
+
lon: center.lon,
|
|
2258
|
+
altMeters: groundAlt
|
|
2259
|
+
};
|
|
2260
|
+
const cpa = computeSegmentCpa(seed, seg, soundSpeedMps);
|
|
2261
|
+
if (Math.abs(cpa.cpaSlantMeters - obs.kinematics.inferredSlantMeters) > SEED_SLANT_GATE_METERS) continue;
|
|
2262
|
+
const refined = refineObserverCoordinateGaussNewton(seed, [{
|
|
2263
|
+
observation: obs,
|
|
2264
|
+
segment: seg
|
|
2265
|
+
}], {
|
|
2266
|
+
demGrid: this.dataset.elevationGrid,
|
|
2267
|
+
soundSpeedMps
|
|
2268
|
+
});
|
|
2269
|
+
const matches = [{
|
|
2270
|
+
observationId: obs.id,
|
|
2271
|
+
segmentId: seg.id
|
|
2272
|
+
}];
|
|
2273
|
+
candidates.push({
|
|
2274
|
+
id: `hyp_${seg.id}_${String(candidates.length + 1)}`,
|
|
2275
|
+
h3Cell: seg.h3Cell,
|
|
2276
|
+
dateIso: seg.dateIso,
|
|
2277
|
+
lat: refined.lat,
|
|
2278
|
+
lon: refined.lon,
|
|
2279
|
+
altMeters: refined.altMeters,
|
|
2280
|
+
estimatedStartEpochSec: refined.estimatedStartEpochSec,
|
|
2281
|
+
logPosterior: DopplerSessionEngine.scoreHypothesis(refined.logLikelihood, allObs, matches),
|
|
2282
|
+
probability: 0,
|
|
2283
|
+
rmsFreqResidualHz: refined.rmsFreqResidualHz,
|
|
2284
|
+
fisherInfo: refined.fisherInfo,
|
|
2285
|
+
ellipseSemiMajorMeters: refined.ellipseSemiMajorMeters,
|
|
2286
|
+
ellipseSemiMinorMeters: refined.ellipseSemiMinorMeters,
|
|
2287
|
+
ellipseBearingDeg: refined.ellipseBearingDeg,
|
|
2288
|
+
matches
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
return DopplerSessionEngine.keepBest(candidates);
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Keeps the best-scoring hypotheses. Ranking comes before the cut: dataset order says nothing about fit, so cutting first can discard the true track.
|
|
2295
|
+
*/
|
|
2296
|
+
static keepBest(candidates) {
|
|
2297
|
+
return [...candidates].sort((a, b) => b.logPosterior - a.logPosterior).slice(0, MAX_HYPOTHESES);
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Matches candidate constellations from the Shazam-style hash table.
|
|
2301
|
+
*/
|
|
2302
|
+
matchConstellations(flyovers, soundSpeedMps) {
|
|
2303
|
+
const { obsA, obsB, allObs } = flyovers;
|
|
2304
|
+
const keys = expandConstellationQueryKeys(obsA.kinematics.inferredSpeedMps, obsB.kinematics.inferredSpeedMps, obsB.cpaTimeSec - obsA.cpaTimeSec);
|
|
2305
|
+
const matchedEntries = this.dataset.constellations.filter((c) => keys.has(c.hashKey));
|
|
2306
|
+
const candidates = [];
|
|
2307
|
+
for (const entry of matchedEntries) {
|
|
2308
|
+
const segA = this.segmentsById.get(entry.segmentIdA);
|
|
2309
|
+
const segB = this.segmentsById.get(entry.segmentIdB);
|
|
2310
|
+
if (segA === void 0 || segB === void 0) continue;
|
|
2311
|
+
const groundAlt = interpolateTerrainElevationMeters(entry.centerLat, entry.centerLon, this.dataset.elevationGrid);
|
|
2312
|
+
const refined = refineObserverCoordinateGaussNewton({
|
|
2313
|
+
lat: entry.centerLat,
|
|
2314
|
+
lon: entry.centerLon,
|
|
2315
|
+
altMeters: groundAlt
|
|
2316
|
+
}, [{
|
|
2317
|
+
observation: obsA,
|
|
2318
|
+
segment: segA
|
|
2319
|
+
}, {
|
|
2320
|
+
observation: obsB,
|
|
2321
|
+
segment: segB
|
|
2322
|
+
}], {
|
|
2323
|
+
demGrid: this.dataset.elevationGrid,
|
|
2324
|
+
soundSpeedMps
|
|
2325
|
+
});
|
|
2326
|
+
const matches = [{
|
|
2327
|
+
observationId: obsA.id,
|
|
2328
|
+
segmentId: segA.id
|
|
2329
|
+
}, {
|
|
2330
|
+
observationId: obsB.id,
|
|
2331
|
+
segmentId: segB.id
|
|
2332
|
+
}];
|
|
2333
|
+
candidates.push({
|
|
2334
|
+
id: `hyp_${entry.segmentIdA}_${entry.segmentIdB}`,
|
|
2335
|
+
h3Cell: entry.h3Cell,
|
|
2336
|
+
dateIso: entry.dateIso,
|
|
2337
|
+
lat: refined.lat,
|
|
2338
|
+
lon: refined.lon,
|
|
2339
|
+
altMeters: refined.altMeters,
|
|
2340
|
+
estimatedStartEpochSec: refined.estimatedStartEpochSec,
|
|
2341
|
+
logPosterior: DopplerSessionEngine.scoreHypothesis(refined.logLikelihood, allObs, matches),
|
|
2342
|
+
probability: 0,
|
|
2343
|
+
rmsFreqResidualHz: refined.rmsFreqResidualHz,
|
|
2344
|
+
fisherInfo: refined.fisherInfo,
|
|
2345
|
+
ellipseSemiMajorMeters: refined.ellipseSemiMajorMeters,
|
|
2346
|
+
ellipseSemiMinorMeters: refined.ellipseSemiMinorMeters,
|
|
2347
|
+
ellipseBearingDeg: refined.ellipseBearingDeg,
|
|
2348
|
+
matches
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
return DopplerSessionEngine.keepBest(candidates);
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Sequentially refines existing candidate hypotheses by refitting each on every flyover it explains, the new one included.
|
|
2355
|
+
*/
|
|
2356
|
+
refineExistingHypotheses(newObs, soundSpeedMps) {
|
|
2357
|
+
const refinedList = [];
|
|
2358
|
+
const allObs = [...this.session.observations, newObs];
|
|
2359
|
+
const observationsById = new Map(allObs.map((obs) => [obs.id, obs]));
|
|
2360
|
+
for (const hyp of this.session.hypotheses) {
|
|
2361
|
+
const hypCoord = {
|
|
2362
|
+
lat: hyp.lat,
|
|
2363
|
+
lon: hyp.lon,
|
|
2364
|
+
altMeters: hyp.altMeters
|
|
2365
|
+
};
|
|
2366
|
+
let bestSeg = null;
|
|
2367
|
+
let minSlantDiff = Infinity;
|
|
2368
|
+
const firstMatch = hyp.matches[0];
|
|
2369
|
+
const firstSeg = firstMatch === void 0 ? void 0 : this.segmentsById.get(firstMatch.segmentId);
|
|
2370
|
+
const firstObs = firstMatch === void 0 ? void 0 : observationsById.get(firstMatch.observationId);
|
|
2371
|
+
const firstCpa = firstSeg === void 0 ? null : computeSegmentCpa(hypCoord, firstSeg, soundSpeedMps);
|
|
2372
|
+
const timingReference = firstCpa !== null && firstObs?.episodeId === newObs.episodeId ? {
|
|
2373
|
+
firstCpa,
|
|
2374
|
+
firstObs
|
|
2375
|
+
} : null;
|
|
2376
|
+
const speedTolerance = DopplerSessionEngine.speedTolerance(newObs.kinematics);
|
|
2377
|
+
for (const seg of this.dataset.segments) {
|
|
2378
|
+
if (seg.dateIso !== hyp.dateIso || seg.category === "jet") continue;
|
|
2379
|
+
if (hyp.matches.some((match) => match.segmentId === seg.id)) continue;
|
|
2380
|
+
if (Math.abs(seg.groundspeedMps - newObs.kinematics.inferredSpeedMps) > speedTolerance) continue;
|
|
2381
|
+
const cpa = computeSegmentCpa(hypCoord, seg, soundSpeedMps);
|
|
2382
|
+
if (timingReference !== null) {
|
|
2383
|
+
const expectedDeltaSec = newObs.cpaTimeSec - timingReference.firstObs.cpaTimeSec;
|
|
2384
|
+
const actualDeltaSec = cpa.cpaDelayedEpochSec - timingReference.firstCpa.cpaDelayedEpochSec;
|
|
2385
|
+
if (Math.abs(actualDeltaSec - expectedDeltaSec) > TIMING_GAP_GATE_SEC) continue;
|
|
2386
|
+
}
|
|
2387
|
+
const slantDiff = Math.abs(cpa.cpaSlantMeters - newObs.kinematics.inferredSlantMeters);
|
|
2388
|
+
if (slantDiff < minSlantDiff && slantDiff < MATCH_SLANT_GATE_METERS) {
|
|
2389
|
+
minSlantDiff = slantDiff;
|
|
2390
|
+
bestSeg = seg;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
if (bestSeg === null) {
|
|
2394
|
+
refinedList.push({
|
|
2395
|
+
...hyp,
|
|
2396
|
+
logPosterior: hyp.logPosterior - unmatchedObservationLogPenalty(newObs)
|
|
2397
|
+
});
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
const matches = [...hyp.matches, {
|
|
2401
|
+
observationId: newObs.id,
|
|
2402
|
+
segmentId: bestSeg.id
|
|
2403
|
+
}];
|
|
2404
|
+
const refined = refineObserverCoordinateGaussNewton(hypCoord, matches.flatMap((match) => {
|
|
2405
|
+
const observation = observationsById.get(match.observationId);
|
|
2406
|
+
const segment = this.segmentsById.get(match.segmentId);
|
|
2407
|
+
return observation !== void 0 && segment !== void 0 ? [{
|
|
2408
|
+
observation,
|
|
2409
|
+
segment
|
|
2410
|
+
}] : [];
|
|
2411
|
+
}), {
|
|
2412
|
+
demGrid: this.dataset.elevationGrid,
|
|
2413
|
+
soundSpeedMps
|
|
2414
|
+
});
|
|
2415
|
+
refinedList.push({
|
|
2416
|
+
...hyp,
|
|
2417
|
+
lat: refined.lat,
|
|
2418
|
+
lon: refined.lon,
|
|
2419
|
+
altMeters: refined.altMeters,
|
|
2420
|
+
estimatedStartEpochSec: refined.estimatedStartEpochSec,
|
|
2421
|
+
logPosterior: DopplerSessionEngine.scoreHypothesis(refined.logLikelihood, allObs, matches),
|
|
2422
|
+
rmsFreqResidualHz: refined.rmsFreqResidualHz,
|
|
2423
|
+
fisherInfo: refined.fisherInfo,
|
|
2424
|
+
ellipseSemiMajorMeters: refined.ellipseSemiMajorMeters,
|
|
2425
|
+
ellipseSemiMinorMeters: refined.ellipseSemiMinorMeters,
|
|
2426
|
+
ellipseBearingDeg: refined.ellipseBearingDeg,
|
|
2427
|
+
matches
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
return refinedList;
|
|
2431
|
+
}
|
|
2432
|
+
};
|
|
2433
|
+
// src/time.ts
|
|
2434
|
+
/** Milliseconds in a second, for converting between epoch seconds (the unit of every timestamp in the schemas) and the millisecond clocks of `Date` and `performance`. */
|
|
2435
|
+
const MS_PER_SECOND = 1e3;
|
|
2436
|
+
// src/node/stream-adsb.ts
|
|
2437
|
+
/**
|
|
2438
|
+
* One trace sample as `adsb.lol` and `tar1090` write it: seconds since the trace's `timestamp`, latitude, longitude and barometric altitude in feet, which is the string `ground` on the ground and null when unreported. Later fields (ground speed, track, flags) are ignored.
|
|
2439
|
+
*/
|
|
2440
|
+
const RawTracePoint = z.tuple([
|
|
2441
|
+
z.number(),
|
|
2442
|
+
z.number(),
|
|
2443
|
+
z.number(),
|
|
2444
|
+
z.union([
|
|
2445
|
+
z.number(),
|
|
2446
|
+
z.literal("ground"),
|
|
2447
|
+
z.null()
|
|
2448
|
+
])
|
|
2449
|
+
]).rest(z.unknown());
|
|
2450
|
+
/**
|
|
2451
|
+
* Raw trace format from `adsb.lol` or `tar1090` JSON exports.
|
|
2452
|
+
*/
|
|
2453
|
+
const RawAdsbTraceFileSchema = define(z.object({
|
|
2454
|
+
icao: z.string().min(1),
|
|
2455
|
+
r: z.string().optional(),
|
|
2456
|
+
t: z.string().optional(),
|
|
2457
|
+
desc: z.string().optional(),
|
|
2458
|
+
/** Epoch seconds that each point's offset is counted from. */
|
|
2459
|
+
timestamp: z.number().optional(),
|
|
2460
|
+
trace: z.array(RawTracePoint)
|
|
2461
|
+
}));
|
|
2462
|
+
/** Altitude above which a ping is cruise traffic that the microphone is not expected to hear: 20,000 ft. */
|
|
2463
|
+
const MAX_AUDIBLE_ALTITUDE_METERS = 6096;
|
|
2464
|
+
/**
|
|
2465
|
+
* Ground speed, in m/s (about 195 knots), above which a segment of an aircraft whose type is not classified is dropped. Light propeller aircraft and helicopters seldom exceed it (about 2 percent of their segments in a sample of real traces), whereas most unclassified types are airliners that fly faster than this below 20,000 ft (about 56 percent of theirs) and have no narrowband propeller tone to match.
|
|
2466
|
+
*/
|
|
2467
|
+
const MAX_UNCLASSIFIED_SPEED_MPS = 100;
|
|
2468
|
+
/** Characters of the ISO 8601 calendar date (`YYYY-MM-DD`) at the start of a `Date.toISOString()` string. */
|
|
2469
|
+
const ISO_DATE_LENGTH = 10;
|
|
2470
|
+
/** Metres in a foot. */
|
|
2471
|
+
const METERS_PER_FOOT = .3048;
|
|
2472
|
+
/**
|
|
2473
|
+
* Longest gap between pings that is still bridged by a straight line. Across a longer gap the aircraft can have turned, landed or left coverage, and at light-aircraft speeds (about 70 m/s) a two-minute gap is already about 8 km, comparable to the acoustic range, so the run is split instead.
|
|
2474
|
+
*/
|
|
2475
|
+
const MAX_PING_GAP_SEC = 120;
|
|
2476
|
+
function inBox(lat, lon, box) {
|
|
2477
|
+
return lat >= box.minLat && lat <= box.maxLat && lon >= box.minLon && lon <= box.maxLon;
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Classifies an ICAO aircraft type code into a propulsion category.
|
|
2481
|
+
* @param typeCode - 4-character ICAO aircraft type designator (e.g. `C172`, `B738`, `DA42`, `EC35`).
|
|
2482
|
+
* @returns Propulsion category.
|
|
2483
|
+
*/
|
|
2484
|
+
function classifyAircraftCategory(typeCode) {
|
|
2485
|
+
if (typeCode === void 0 || typeCode === "") return "unknown";
|
|
2486
|
+
const code = typeCode.toUpperCase().trim();
|
|
2487
|
+
if ([
|
|
2488
|
+
"R22",
|
|
2489
|
+
"R44",
|
|
2490
|
+
"R66",
|
|
2491
|
+
"EC20",
|
|
2492
|
+
"EC30",
|
|
2493
|
+
"EC35",
|
|
2494
|
+
"EC45",
|
|
2495
|
+
"B06",
|
|
2496
|
+
"B206",
|
|
2497
|
+
"B407",
|
|
2498
|
+
"AS50",
|
|
2499
|
+
"A109",
|
|
2500
|
+
"S76",
|
|
2501
|
+
"H125",
|
|
2502
|
+
"H135",
|
|
2503
|
+
"H145",
|
|
2504
|
+
"G2CA"
|
|
2505
|
+
].includes(code)) return "helicopter";
|
|
2506
|
+
if ([
|
|
2507
|
+
"C150",
|
|
2508
|
+
"C152",
|
|
2509
|
+
"C172",
|
|
2510
|
+
"C182",
|
|
2511
|
+
"C206",
|
|
2512
|
+
"C210",
|
|
2513
|
+
"PA28",
|
|
2514
|
+
"PA32",
|
|
2515
|
+
"PA34",
|
|
2516
|
+
"PA44",
|
|
2517
|
+
"SR20",
|
|
2518
|
+
"SR22",
|
|
2519
|
+
"DA20",
|
|
2520
|
+
"DA40",
|
|
2521
|
+
"DA42",
|
|
2522
|
+
"P28A",
|
|
2523
|
+
"AA5",
|
|
2524
|
+
"BE36",
|
|
2525
|
+
"BE58",
|
|
2526
|
+
"M20T",
|
|
2527
|
+
"RV7",
|
|
2528
|
+
"RV8",
|
|
2529
|
+
"RV10"
|
|
2530
|
+
].includes(code)) return "piston";
|
|
2531
|
+
if ([
|
|
2532
|
+
"B350",
|
|
2533
|
+
"BE20",
|
|
2534
|
+
"PC12",
|
|
2535
|
+
"TBM7",
|
|
2536
|
+
"TBM8",
|
|
2537
|
+
"TBM9",
|
|
2538
|
+
"DH8D",
|
|
2539
|
+
"AT72",
|
|
2540
|
+
"AT76",
|
|
2541
|
+
"AT45",
|
|
2542
|
+
"C208",
|
|
2543
|
+
"SW4",
|
|
2544
|
+
"D228"
|
|
2545
|
+
].includes(code)) return "turboprop";
|
|
2546
|
+
if ([
|
|
2547
|
+
"A319",
|
|
2548
|
+
"A320",
|
|
2549
|
+
"A321",
|
|
2550
|
+
"A332",
|
|
2551
|
+
"A333",
|
|
2552
|
+
"A359",
|
|
2553
|
+
"B737",
|
|
2554
|
+
"B738",
|
|
2555
|
+
"B739",
|
|
2556
|
+
"B772",
|
|
2557
|
+
"B77W",
|
|
2558
|
+
"B788",
|
|
2559
|
+
"B789",
|
|
2560
|
+
"E190",
|
|
2561
|
+
"E195",
|
|
2562
|
+
"CRJ7",
|
|
2563
|
+
"CRJ9",
|
|
2564
|
+
"C56X",
|
|
2565
|
+
"C680",
|
|
2566
|
+
"GL5T",
|
|
2567
|
+
"GLEX",
|
|
2568
|
+
"FA7X",
|
|
2569
|
+
"CL30"
|
|
2570
|
+
].includes(code)) return "jet";
|
|
2571
|
+
return "unknown";
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Converts a raw `adsb.lol` / `tar1090` trace object into compressed 4D piecewise-linear segments.
|
|
2575
|
+
* Jet categories are discarded. A ping on the ground, with no altitude, or above 20,000 ft ends the current run, and so does a gap of more than two minutes, so a segment never spans a landing, a climb to cruise or a loss of coverage.
|
|
2576
|
+
* @param trace - Raw ADS-B trace record.
|
|
2577
|
+
* @param dateIso - ISO calendar date (`YYYY-MM-DD`).
|
|
2578
|
+
* @param baseEpochSec - Epoch seconds that the trace's point offsets are counted from.
|
|
2579
|
+
* @param options - Region filter and compression tolerance.
|
|
2580
|
+
* @returns Array of linear flight segments.
|
|
2581
|
+
*/
|
|
2582
|
+
function processRawAdsbTrace(trace, dateIso, baseEpochSec, options = {}) {
|
|
2583
|
+
const typeCode = trace.t ?? "unknown";
|
|
2584
|
+
const category = classifyAircraftCategory(typeCode);
|
|
2585
|
+
if (category === "jet") return [];
|
|
2586
|
+
const runs = [];
|
|
2587
|
+
let run = [];
|
|
2588
|
+
const endRun = () => {
|
|
2589
|
+
if (run.length > 0) {
|
|
2590
|
+
runs.push(run);
|
|
2591
|
+
run = [];
|
|
2592
|
+
}
|
|
2593
|
+
};
|
|
2594
|
+
for (const [offsetSec, lat, lon, altFeet] of trace.trace) {
|
|
2595
|
+
const altMeters = typeof altFeet === "number" ? altFeet * METERS_PER_FOOT : void 0;
|
|
2596
|
+
const { bbox } = options;
|
|
2597
|
+
if (altMeters === void 0 || altMeters > MAX_AUDIBLE_ALTITUDE_METERS || bbox !== void 0 && !inBox(lat, lon, bbox)) {
|
|
2598
|
+
endRun();
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
const epochSec = baseEpochSec + offsetSec;
|
|
2602
|
+
const previous = run[run.length - 1];
|
|
2603
|
+
if (previous !== void 0 && epochSec - previous.epochSec > MAX_PING_GAP_SEC) endRun();
|
|
2604
|
+
run.push({
|
|
2605
|
+
epochSec,
|
|
2606
|
+
lat,
|
|
2607
|
+
lon,
|
|
2608
|
+
altMeters
|
|
2609
|
+
});
|
|
2610
|
+
}
|
|
2611
|
+
endRun();
|
|
2612
|
+
return runs.flatMap((pings) => simplifyTrajectory4D(pings, {
|
|
2613
|
+
flightId: trace.r ?? trace.icao,
|
|
2614
|
+
icao24: trace.icao,
|
|
2615
|
+
typeCode,
|
|
2616
|
+
category,
|
|
2617
|
+
dateIso
|
|
2618
|
+
}, options.toleranceMeters ?? 20)).filter((segment) => category !== "unknown" || segment.groundspeedMps <= MAX_UNCLASSIFIED_SPEED_MPS);
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Converts parsed ADS-B JSON into segments: ready-made segments (an array, or an object with `segments`), a raw trace, or an array of raw traces.
|
|
2622
|
+
* @param parsed - The parsed JSON value.
|
|
2623
|
+
* @param options - Date for raw traces without a `timestamp`.
|
|
2624
|
+
* @returns The segments it holds.
|
|
2625
|
+
* @throws When the value matches none of the accepted shapes, or a raw trace has neither a `timestamp` nor a date option.
|
|
2626
|
+
*/
|
|
2627
|
+
function segmentsFromAdsbJson(parsed, options) {
|
|
2628
|
+
if (Array.isArray(parsed)) return parsed.flatMap((element) => segmentsFromAdsbJson(element, options));
|
|
2629
|
+
if (typeof parsed === "object" && parsed !== null && "segments" in parsed) return z.array(LinearFlightSegmentSchema).parse(parsed.segments);
|
|
2630
|
+
if (LinearFlightSegmentSchema.is(parsed)) return [parsed];
|
|
2631
|
+
const trace = RawAdsbTraceFileSchema.parse(parsed);
|
|
2632
|
+
if (trace.timestamp !== void 0) return processRawAdsbTrace(trace, (/* @__PURE__ */ new Date(trace.timestamp * MS_PER_SECOND)).toISOString().slice(0, ISO_DATE_LENGTH), trace.timestamp, options);
|
|
2633
|
+
if (options.dateIso === void 0) throw new Error(`trace ${trace.icao} has no timestamp, so its date is unknown; pass the date`);
|
|
2634
|
+
return processRawAdsbTrace(trace, options.dateIso, Date.parse(`${options.dateIso}T00:00:00Z`) / MS_PER_SECOND, options);
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
2637
|
+
* Reads flight segments from ADS-B JSON on disk: a file or every `.json` file of a directory (not recursive). Each file may hold ready-made segments (an array, or an object with `segments`), a raw `adsb.lol` / `tar1090` trace, or an array of raw traces.
|
|
2638
|
+
* @param inputPath - A JSON file or a directory of them.
|
|
2639
|
+
* @param options - Date for raw traces without a `timestamp`.
|
|
2640
|
+
* @returns Segments from every file, in file-name order.
|
|
2641
|
+
* @throws When a file is not valid JSON or matches none of the accepted shapes, naming the file.
|
|
2642
|
+
*/
|
|
2643
|
+
function readAdsbSegments(inputPath, options = {}) {
|
|
2644
|
+
return (statSync(inputPath).isDirectory() ? readdirSync(inputPath).filter((name) => name.endsWith(".json")).sort().map((name) => join(inputPath, name)) : [inputPath]).flatMap((file) => {
|
|
2645
|
+
try {
|
|
2646
|
+
return segmentsFromAdsbJson(JSON.parse(readFileSync(file, "utf8")), options);
|
|
2647
|
+
} catch (error) {
|
|
2648
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2649
|
+
}
|
|
2650
|
+
});
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Loads and parses a DopplerDataset JSON file from disk.
|
|
2654
|
+
* @param filePath - Path to `.json` dataset file.
|
|
2655
|
+
* @returns Parsed `DopplerDataset`.
|
|
2656
|
+
*/
|
|
2657
|
+
function loadDatasetFile(filePath) {
|
|
2658
|
+
const raw = readFileSync(filePath, "utf8");
|
|
2659
|
+
const parsed = JSON.parse(raw);
|
|
2660
|
+
return DopplerDatasetSchema.parse(parsed);
|
|
2661
|
+
}
|
|
2662
|
+
/** Items serialised per write, so no single string approaches the engine's string length limit however large the dataset. */
|
|
2663
|
+
const WRITE_BATCH_ITEMS = 5e3;
|
|
2664
|
+
function writeJsonArray(fd, items) {
|
|
2665
|
+
writeSync(fd, "[");
|
|
2666
|
+
for (let start = 0; start < items.length; start += WRITE_BATCH_ITEMS) {
|
|
2667
|
+
const batch = items.slice(start, start + WRITE_BATCH_ITEMS).map((item) => JSON.stringify(item)).join(",");
|
|
2668
|
+
writeSync(fd, start === 0 ? batch : `,${batch}`);
|
|
2669
|
+
}
|
|
2670
|
+
writeSync(fd, "]");
|
|
2671
|
+
}
|
|
2672
|
+
/**
|
|
2673
|
+
* Serializes and saves a DopplerDataset to disk with built constellation index. The file is compact JSON written in batches, because a day of real traffic is hundreds of thousands of segments and one `JSON.stringify` of that overflows the string length limit.
|
|
2674
|
+
* @param filePath - Output path.
|
|
2675
|
+
* @param segments - Array of linear flight segments.
|
|
2676
|
+
* @param demGrid - Optional DEM elevation grid points.
|
|
2677
|
+
* @returns The saved `DopplerDataset`.
|
|
2678
|
+
*/
|
|
2679
|
+
function saveDatasetBundle(filePath, segments, demGrid = []) {
|
|
2680
|
+
const constellations = buildConstellationIndex(segments, { demGrid });
|
|
2681
|
+
const dataset = {
|
|
2682
|
+
version: "1.0.0",
|
|
2683
|
+
generatedAtIso: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2684
|
+
segments,
|
|
2685
|
+
constellations,
|
|
2686
|
+
elevationGrid: demGrid
|
|
2687
|
+
};
|
|
2688
|
+
const fd = openSync(filePath, "w");
|
|
2689
|
+
try {
|
|
2690
|
+
writeSync(fd, `{"version":${JSON.stringify(dataset.version)},"generatedAtIso":${JSON.stringify(dataset.generatedAtIso)},"segments":`);
|
|
2691
|
+
writeJsonArray(fd, segments);
|
|
2692
|
+
writeSync(fd, ",\"constellations\":");
|
|
2693
|
+
writeJsonArray(fd, constellations);
|
|
2694
|
+
writeSync(fd, ",\"elevationGrid\":");
|
|
2695
|
+
writeJsonArray(fd, demGrid);
|
|
2696
|
+
writeSync(fd, "}");
|
|
2697
|
+
} finally {
|
|
2698
|
+
closeSync(fd);
|
|
2699
|
+
}
|
|
2700
|
+
return dataset;
|
|
2701
|
+
}
|
|
2702
|
+
// src/node/tar-stream.ts
|
|
2703
|
+
/** Bytes in a tar block: headers and the padding of entry data are multiples of it. */
|
|
2704
|
+
const TAR_BLOCK_BYTES = 512;
|
|
2705
|
+
/** Offsets and lengths of the header fields this reader needs (POSIX ustar and GNU tar agree on them). */
|
|
2706
|
+
const NAME_FIELD = {
|
|
2707
|
+
offset: 0,
|
|
2708
|
+
length: 100
|
|
2709
|
+
};
|
|
2710
|
+
const SIZE_FIELD = {
|
|
2711
|
+
offset: 124,
|
|
2712
|
+
length: 12
|
|
2713
|
+
};
|
|
2714
|
+
const TYPE_FIELD_OFFSET = 156;
|
|
2715
|
+
const PREFIX_FIELD = {
|
|
2716
|
+
offset: 345,
|
|
2717
|
+
length: 155
|
|
2718
|
+
};
|
|
2719
|
+
/** Tar writes sizes as octal text. */
|
|
2720
|
+
const OCTAL_RADIX = 8;
|
|
2721
|
+
/** Words for the parser state in a truncation error. */
|
|
2722
|
+
const STATE_LABELS = {
|
|
2723
|
+
header: "header",
|
|
2724
|
+
longName: "long name",
|
|
2725
|
+
data: "entry",
|
|
2726
|
+
skip: "entry"
|
|
2727
|
+
};
|
|
2728
|
+
/**
|
|
2729
|
+
* A push-driven tar parser: it is fed chunks as they arrive and holds only the bytes of the current header or entry plus the unconsumed remainder of the current chunk.
|
|
2730
|
+
* It is a synchronous state machine, so the only suspension point of a reader is the `for await` over the source.
|
|
2731
|
+
*/
|
|
2732
|
+
var TarParser = class {
|
|
2733
|
+
wanted;
|
|
2734
|
+
state = { kind: "header" };
|
|
2735
|
+
/** Bytes still needed to finish the current state. */
|
|
2736
|
+
need = TAR_BLOCK_BYTES;
|
|
2737
|
+
/** Bytes the current state asked for in total, for the truncation error. */
|
|
2738
|
+
total = TAR_BLOCK_BYTES;
|
|
2739
|
+
/** Collected bytes of the current header, long name or file; skipped bytes are not kept. */
|
|
2740
|
+
collected = [];
|
|
2741
|
+
longName;
|
|
2742
|
+
ended = false;
|
|
2743
|
+
constructor(wanted) {
|
|
2744
|
+
this.wanted = wanted;
|
|
2745
|
+
}
|
|
2746
|
+
/** True once an end-of-archive block has been read: later bytes are ignored. */
|
|
2747
|
+
get isEnded() {
|
|
2748
|
+
return this.ended;
|
|
2749
|
+
}
|
|
2750
|
+
begin(state, length) {
|
|
2751
|
+
this.state = state;
|
|
2752
|
+
this.need = length;
|
|
2753
|
+
this.total = length;
|
|
2754
|
+
this.collected = [];
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Feeds one chunk of the stream.
|
|
2758
|
+
* @param chunk - The next bytes of the tar stream.
|
|
2759
|
+
* @returns The files completed by these bytes.
|
|
2760
|
+
* @throws When a header has a size that is not an octal number.
|
|
2761
|
+
*/
|
|
2762
|
+
*push(chunk) {
|
|
2763
|
+
let rest = chunk;
|
|
2764
|
+
while (!this.ended) {
|
|
2765
|
+
if (this.need === 0) {
|
|
2766
|
+
yield* this.complete();
|
|
2767
|
+
continue;
|
|
2768
|
+
}
|
|
2769
|
+
if (rest.length === 0) return;
|
|
2770
|
+
const taken = rest.subarray(0, this.need);
|
|
2771
|
+
if (this.state.kind !== "skip") this.collected.push(taken);
|
|
2772
|
+
this.need -= taken.length;
|
|
2773
|
+
rest = rest.subarray(taken.length);
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Checks that the stream ended on an entry boundary.
|
|
2778
|
+
* @throws When the stream ended inside a header, a long name or an entry.
|
|
2779
|
+
*/
|
|
2780
|
+
finish() {
|
|
2781
|
+
const onBoundary = this.state.kind === "header" && this.need === this.total;
|
|
2782
|
+
if (!this.ended && !onBoundary) throw new Error(`Tar stream ended ${String(this.need)} bytes short of a ${String(this.total)}-byte ${STATE_LABELS[this.state.kind]}`);
|
|
2783
|
+
}
|
|
2784
|
+
*complete() {
|
|
2785
|
+
const state = this.state;
|
|
2786
|
+
const bytes = Buffer.concat(this.collected);
|
|
2787
|
+
switch (state.kind) {
|
|
2788
|
+
case "header":
|
|
2789
|
+
this.readHeader(bytes);
|
|
2790
|
+
break;
|
|
2791
|
+
case "longName":
|
|
2792
|
+
this.longName = field(bytes, {
|
|
2793
|
+
offset: 0,
|
|
2794
|
+
length: bytes.length
|
|
2795
|
+
});
|
|
2796
|
+
this.begin({ kind: "skip" }, state.padding);
|
|
2797
|
+
break;
|
|
2798
|
+
case "data":
|
|
2799
|
+
this.begin({
|
|
2800
|
+
kind: "skip",
|
|
2801
|
+
file: {
|
|
2802
|
+
name: state.name,
|
|
2803
|
+
data: bytes
|
|
2804
|
+
}
|
|
2805
|
+
}, state.padding);
|
|
2806
|
+
break;
|
|
2807
|
+
case "skip":
|
|
2808
|
+
this.begin({ kind: "header" }, TAR_BLOCK_BYTES);
|
|
2809
|
+
if (state.file !== void 0) yield state.file;
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
readHeader(header) {
|
|
2813
|
+
if (header.every((byte) => byte === 0)) {
|
|
2814
|
+
this.ended = true;
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
const size = Number.parseInt(field(header, SIZE_FIELD).trim() || "0", OCTAL_RADIX);
|
|
2818
|
+
if (!Number.isFinite(size)) throw new Error("Tar header has a size that is not an octal number");
|
|
2819
|
+
const padding = (TAR_BLOCK_BYTES - size % TAR_BLOCK_BYTES) % TAR_BLOCK_BYTES;
|
|
2820
|
+
const type = String.fromCharCode(header[TYPE_FIELD_OFFSET] ?? 0);
|
|
2821
|
+
if (type === "L") {
|
|
2822
|
+
this.begin({
|
|
2823
|
+
kind: "longName",
|
|
2824
|
+
padding
|
|
2825
|
+
}, size);
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
const prefix = field(header, PREFIX_FIELD);
|
|
2829
|
+
const name = this.longName ?? (prefix === "" ? field(header, NAME_FIELD) : `${prefix}/${field(header, NAME_FIELD)}`);
|
|
2830
|
+
this.longName = void 0;
|
|
2831
|
+
if ((type === "0" || type === "\0") && this.wanted(name)) this.begin({
|
|
2832
|
+
kind: "data",
|
|
2833
|
+
name,
|
|
2834
|
+
padding
|
|
2835
|
+
}, size);
|
|
2836
|
+
else this.begin({ kind: "skip" }, size + padding);
|
|
2837
|
+
}
|
|
2838
|
+
};
|
|
2839
|
+
function field(header, spec) {
|
|
2840
|
+
const raw = header.subarray(spec.offset, spec.offset + spec.length);
|
|
2841
|
+
const end = raw.indexOf(0);
|
|
2842
|
+
return raw.subarray(0, end === -1 ? raw.length : end).toString("utf8");
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Reads the regular files of a tar stream one at a time. Entries for which `wanted` is false are skipped without being buffered, which matters for archives that mix small files with very large ones.
|
|
2846
|
+
* Entry data is held in memory, so one entry must fit in memory. GNU long names (`L`) are honoured; other extension headers (`K`, pax `x` and `g`) are skipped, and directories, links and devices are ignored.
|
|
2847
|
+
* @param source - The tar bytes, for example a file or standard input stream.
|
|
2848
|
+
* @param wanted - Chooses by name which entries are read.
|
|
2849
|
+
* @returns A generator of each wanted regular file.
|
|
2850
|
+
* @throws When the stream ends inside a header or an entry.
|
|
2851
|
+
*/
|
|
2852
|
+
async function* readTarFiles(source, wanted) {
|
|
2853
|
+
const parser = new TarParser(wanted);
|
|
2854
|
+
for await (const chunk of source) {
|
|
2855
|
+
yield* parser.push(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
2856
|
+
if (parser.isEnded) return;
|
|
2857
|
+
}
|
|
2858
|
+
parser.finish();
|
|
2859
|
+
}
|
|
2860
|
+
// src/node/adsb-archive.ts
|
|
2861
|
+
/** First two bytes of a gzip stream. `readsb` history files carry a `.json` name but are usually gzip-compressed. */
|
|
2862
|
+
const GZIP_MAGIC = [31, 139];
|
|
2863
|
+
/** Matches the per-aircraft trace files of an `adsb.lol` `globe_history` archive, for example `./traces/3a/trace_full_3a1b2c.json`. */
|
|
2864
|
+
const TRACE_FILE = /(^|\/)trace_full_[0-9a-f~]+\.json$/;
|
|
2865
|
+
/**
|
|
2866
|
+
* Reads flight segments from a tar stream of `adsb.lol` `globe_history` traces, one aircraft at a time so memory holds a single trace and the segments kept.
|
|
2867
|
+
* A day is published as `.tar.aa` and `.tar.ab` parts that concatenate to one tar, so the two parts can be piped in one after the other.
|
|
2868
|
+
* @param source - The tar bytes.
|
|
2869
|
+
* @param options - Region filter and compression tolerance.
|
|
2870
|
+
* @returns Segments from every trace file in the archive.
|
|
2871
|
+
* @throws When a trace file is not valid JSON of the trace shape, naming the file.
|
|
2872
|
+
*/
|
|
2873
|
+
async function readAdsbArchiveSegments(source, options = {}) {
|
|
2874
|
+
const segments = [];
|
|
2875
|
+
for await (const file of readTarFiles(source, (name) => TRACE_FILE.test(name))) {
|
|
2876
|
+
const text = (GZIP_MAGIC.every((byte, index) => file.data[index] === byte) ? gunzipSync(file.data) : file.data).toString("utf8");
|
|
2877
|
+
let found;
|
|
2878
|
+
try {
|
|
2879
|
+
found = segmentsFromAdsbJson(JSON.parse(text), options);
|
|
2880
|
+
} catch (error) {
|
|
2881
|
+
throw new Error(`${file.name}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
2882
|
+
}
|
|
2883
|
+
segments.push(...found);
|
|
2884
|
+
}
|
|
2885
|
+
return segments;
|
|
2886
|
+
}
|
|
2887
|
+
// src/node/wav-file.ts
|
|
2888
|
+
/**
|
|
2889
|
+
* Reads and decodes a WAV audio file from disk into mono `Float32Array` samples and sample rate.
|
|
2890
|
+
* @param filePath - Path to `.wav` file.
|
|
2891
|
+
* @returns Decoded mono audio samples and sample rate in Hz.
|
|
2892
|
+
*/
|
|
2893
|
+
function readWavFile(filePath) {
|
|
2894
|
+
return decodeWavBytes(new Uint8Array(readFileSync(filePath)));
|
|
2895
|
+
}
|
|
2896
|
+
// cli/doppler.ts
|
|
2897
|
+
const PackageManifest = z.object({ version: z.string() });
|
|
2898
|
+
/** The package version, read from the package.json that sits one directory above both `cli/` and `dist-cli/`. */
|
|
2899
|
+
function getCliVersion() {
|
|
2900
|
+
return PackageManifest.parse(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))).version;
|
|
2901
|
+
}
|
|
2902
|
+
/**
|
|
2903
|
+
* Parses `minLat,minLon,maxLat,maxLon`.
|
|
2904
|
+
* @param text - The option value.
|
|
2905
|
+
* @returns The box.
|
|
2906
|
+
* @throws When the value is not four numbers with the minimums below the maximums.
|
|
2907
|
+
*/
|
|
2908
|
+
function parseBoundingBox(text) {
|
|
2909
|
+
const parts = (text ?? "").split(",").map(Number);
|
|
2910
|
+
const [minLat, minLon, maxLat, maxLon] = parts;
|
|
2911
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part)) || minLat === void 0 || minLon === void 0 || maxLat === void 0 || maxLon === void 0 || minLat >= maxLat || minLon >= maxLon) throw new Error(`--bbox needs minLat,minLon,maxLat,maxLon with each minimum below its maximum, got ${text ?? "nothing"}`);
|
|
2912
|
+
return {
|
|
2913
|
+
minLat,
|
|
2914
|
+
minLon,
|
|
2915
|
+
maxLat,
|
|
2916
|
+
maxLon
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
function formatCpaTime(cpaTimeSec) {
|
|
2920
|
+
if (cpaTimeSec > 1e9) return (/* @__PURE__ */ new Date(cpaTimeSec * 1e3)).toLocaleTimeString();
|
|
2921
|
+
const mins = Math.floor(cpaTimeSec / 60);
|
|
2922
|
+
const secs = (cpaTimeSec % 60).toFixed(1);
|
|
2923
|
+
return mins > 0 ? `${String(mins)}m ${secs}s` : `${secs}s`;
|
|
2924
|
+
}
|
|
2925
|
+
function printHelp() {
|
|
2926
|
+
console.log(`
|
|
2927
|
+
doppler - Offline and real-time aircraft Doppler audio geolocation
|
|
2928
|
+
|
|
2929
|
+
Usage:
|
|
2930
|
+
doppler locate <audio.wav> [options]
|
|
2931
|
+
doppler session <session.json> --add-audio <audio.wav>
|
|
2932
|
+
doppler ingest --input <adsb.json|directory|-> --out <dataset.json> [--date <YYYY-MM-DD>] [--bbox <minLat,minLon,maxLat,maxLon>]
|
|
2933
|
+
|
|
2934
|
+
Options:
|
|
2935
|
+
--dataset <path> Path to a custom DopplerDataset .json file (defaults to bundled dataset)
|
|
2936
|
+
--date <YYYY-MM-DD> For ingest: date of raw traces that carry no timestamp
|
|
2937
|
+
--bbox <a,b,c,d> For ingest: keep segments touching minLat,minLon,maxLat,maxLon
|
|
2938
|
+
(--input - reads an adsb.lol globe_history tar from stdin)
|
|
2939
|
+
--temp <celsius> Ambient air temperature in Celsius (default 20.0)
|
|
2940
|
+
--json Output raw JSON results
|
|
2941
|
+
--version, -v Show version
|
|
2942
|
+
--help, -h Show help
|
|
2943
|
+
`);
|
|
2944
|
+
}
|
|
2945
|
+
async function run() {
|
|
2946
|
+
const args = process.argv.slice(2);
|
|
2947
|
+
if (args.length === 0 || args.includes("-h") || args.includes("--help")) {
|
|
2948
|
+
printHelp();
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
if (args.includes("-v") || args.includes("--version")) {
|
|
2952
|
+
console.log(`doppler v${getCliVersion()}`);
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
const command = args[0];
|
|
2956
|
+
if (command === "locate") {
|
|
2957
|
+
const audioPath = args[1];
|
|
2958
|
+
if (audioPath === void 0 || audioPath.startsWith("--")) {
|
|
2959
|
+
console.error("Error: specify an audio file path: doppler locate <file.wav>");
|
|
2960
|
+
process.exit(1);
|
|
2961
|
+
}
|
|
2962
|
+
const datasetIdx = args.indexOf("--dataset");
|
|
2963
|
+
const datasetPath = datasetIdx !== -1 ? args[datasetIdx + 1] : void 0;
|
|
2964
|
+
const tempIdx = args.indexOf("--temp");
|
|
2965
|
+
const ambientTemp = tempIdx !== -1 ? Number(args[tempIdx + 1]) : 20;
|
|
2966
|
+
if (!Number.isFinite(ambientTemp)) {
|
|
2967
|
+
console.error(`Error: --temp needs a number of degrees Celsius, got ${args[tempIdx + 1] ?? "nothing"}`);
|
|
2968
|
+
process.exit(1);
|
|
2969
|
+
}
|
|
2970
|
+
const isJson = args.includes("--json");
|
|
2971
|
+
const dataset = datasetPath !== void 0 ? loadDatasetFile(datasetPath) : BUNDLED_DATASET;
|
|
2972
|
+
const { samples, sampleRateHz } = readWavFile(audioPath);
|
|
2973
|
+
const decimated = decimateAudioStream(samples, sampleRateHz, 2e3);
|
|
2974
|
+
const enf = detectEnfSignature(decimated.samples, decimated.sampleRateHz);
|
|
2975
|
+
const observations = extractDopplerObservations(computeDopplerSpectrogram(decimated.samples, {
|
|
2976
|
+
sampleRateHz: decimated.sampleRateHz,
|
|
2977
|
+
startTimeSec: 0,
|
|
2978
|
+
fftSize: 2048,
|
|
2979
|
+
hopSamples: 500
|
|
2980
|
+
}), {
|
|
2981
|
+
episodeId: "cli_file",
|
|
2982
|
+
minSnrDb: 5
|
|
2983
|
+
});
|
|
2984
|
+
const engine = new DopplerSessionEngine({
|
|
2985
|
+
dataset,
|
|
2986
|
+
ambientTempCelsius: ambientTemp,
|
|
2987
|
+
constraints: enf.detected ? { enfNominalHz: enf.nominalHz === "50" ? "50" : "60" } : {}
|
|
2988
|
+
});
|
|
2989
|
+
engine.startEpisode(0);
|
|
2990
|
+
for (const obs of observations) engine.ingestObservation(obs);
|
|
2991
|
+
const session = engine.getSession();
|
|
2992
|
+
const topHyp = session.hypotheses[0];
|
|
2993
|
+
if (isJson) {
|
|
2994
|
+
console.log(JSON.stringify(session, null, 2));
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
console.log("\n--- Doppler Geolocation Results ---");
|
|
2998
|
+
console.log(`Audio File: ${audioPath} (${(samples.length / sampleRateHz).toFixed(1)}s @ ${String(sampleRateHz)}Hz)`);
|
|
2999
|
+
console.log(`ENF Power Grid: ${enf.detected ? `${enf.nominalHz} Hz (${enf.regionHint})` : "Not detected"}`);
|
|
3000
|
+
console.log(`Flyovers Found: ${String(observations.length)}`);
|
|
3001
|
+
for (let i = 0; i < observations.length; i += 1) {
|
|
3002
|
+
const o = observations[i];
|
|
3003
|
+
if (o !== void 0) {
|
|
3004
|
+
const timeLabel = formatCpaTime(o.cpaTimeSec);
|
|
3005
|
+
console.log(` [#${String(i + 1)}] CPA: ${timeLabel}, Base: ${o.kinematics.baseFreqHz.toFixed(1)}Hz, Speed: ${o.kinematics.inferredSpeedMps.toFixed(1)}m/s, Slant: ${o.kinematics.inferredSlantMeters.toFixed(0)}m, SNR: ${o.snrDb.toFixed(1)}dB`);
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
if (topHyp !== void 0) {
|
|
3009
|
+
console.log("\nTop Hypothesis:");
|
|
3010
|
+
console.log(` Coordinates: ${topHyp.lat.toFixed(5)}° N, ${topHyp.lon.toFixed(5)}° E (alt: ${topHyp.altMeters.toFixed(0)}m)`);
|
|
3011
|
+
console.log(` H3 Cell: ${topHyp.h3Cell}`);
|
|
3012
|
+
console.log(` Date / Time: ${topHyp.dateIso} ~${(/* @__PURE__ */ new Date(topHyp.estimatedStartEpochSec * 1e3)).toISOString()}`);
|
|
3013
|
+
console.log(` Probability: ${(topHyp.probability * 100).toFixed(1)}% (Log-Posterior: ${topHyp.logPosterior.toFixed(2)})`);
|
|
3014
|
+
console.log(` 95% Ellipse: ${topHyp.ellipseSemiMajorMeters.toFixed(0)}m × ${topHyp.ellipseSemiMinorMeters.toFixed(0)}m (bearing ${topHyp.ellipseBearingDeg.toFixed(0)}°)`);
|
|
3015
|
+
console.log(` Matched Segs: ${topHyp.matches.map((match) => match.segmentId).join(", ")}`);
|
|
3016
|
+
} else console.log("\nNo matching flight trajectory found in dataset.");
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (command === "session") {
|
|
3020
|
+
const sessionPath = args[1];
|
|
3021
|
+
const addIdx = args.indexOf("--add-audio");
|
|
3022
|
+
const audioPath = addIdx !== -1 ? args[addIdx + 1] : void 0;
|
|
3023
|
+
if (sessionPath === void 0 || audioPath === void 0) {
|
|
3024
|
+
console.error("Usage: doppler session <session.json> --add-audio <file.wav>");
|
|
3025
|
+
process.exit(1);
|
|
3026
|
+
}
|
|
3027
|
+
const sessionRaw = readFileSync(sessionPath, "utf8");
|
|
3028
|
+
const initialSession = LocationSessionSchema.parse(JSON.parse(sessionRaw));
|
|
3029
|
+
const { samples, sampleRateHz } = readWavFile(audioPath);
|
|
3030
|
+
const decimated = decimateAudioStream(samples, sampleRateHz, 2e3);
|
|
3031
|
+
const observations = extractDopplerObservations(computeDopplerSpectrogram(decimated.samples, {
|
|
3032
|
+
sampleRateHz: decimated.sampleRateHz,
|
|
3033
|
+
startTimeSec: 0,
|
|
3034
|
+
fftSize: 2048,
|
|
3035
|
+
hopSamples: 500
|
|
3036
|
+
}), {
|
|
3037
|
+
episodeId: `episode_${String(initialSession.episodes.length + 1)}`,
|
|
3038
|
+
minSnrDb: 5
|
|
3039
|
+
});
|
|
3040
|
+
const engine = new DopplerSessionEngine({
|
|
3041
|
+
initialSession,
|
|
3042
|
+
dataset: BUNDLED_DATASET
|
|
3043
|
+
});
|
|
3044
|
+
const episode = engine.startEpisode(Math.floor(Date.now() / 1e3));
|
|
3045
|
+
for (const obs of observations) engine.ingestObservation(obs);
|
|
3046
|
+
engine.pauseEpisode(episode.id, Math.floor(Date.now() / 1e3));
|
|
3047
|
+
const updated = engine.getSession();
|
|
3048
|
+
writeFileSync(sessionPath, JSON.stringify(updated, null, 2), "utf8");
|
|
3049
|
+
const top = updated.hypotheses[0];
|
|
3050
|
+
console.log(`\nUpdated session ${sessionPath}:`);
|
|
3051
|
+
console.log(` Total Observations: ${String(updated.observations.length)}`);
|
|
3052
|
+
if (top !== void 0) {
|
|
3053
|
+
console.log(` Current Estimate: ${top.lat.toFixed(5)}° N, ${top.lon.toFixed(5)}° E`);
|
|
3054
|
+
console.log(` 95% Confidence: ${top.ellipseSemiMajorMeters.toFixed(0)}m × ${top.ellipseSemiMinorMeters.toFixed(0)}m`);
|
|
3055
|
+
}
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
if (command === "ingest") {
|
|
3059
|
+
const inIdx = args.indexOf("--input");
|
|
3060
|
+
const outIdx = args.indexOf("--out");
|
|
3061
|
+
const inputPath = inIdx !== -1 ? args[inIdx + 1] : void 0;
|
|
3062
|
+
const outputPath = outIdx !== -1 ? args[outIdx + 1] : void 0;
|
|
3063
|
+
if (inputPath === void 0 || outputPath === void 0) {
|
|
3064
|
+
console.error("Usage: doppler ingest --input <adsb.json|directory|-> --out <dataset.json> [--date <YYYY-MM-DD>] [--bbox <minLat,minLon,maxLat,maxLon>]");
|
|
3065
|
+
process.exit(1);
|
|
3066
|
+
}
|
|
3067
|
+
const dateIdx = args.indexOf("--date");
|
|
3068
|
+
const dateIso = dateIdx !== -1 ? args[dateIdx + 1] : void 0;
|
|
3069
|
+
const bboxIdx = args.indexOf("--bbox");
|
|
3070
|
+
const bbox = bboxIdx !== -1 ? parseBoundingBox(args[bboxIdx + 1]) : void 0;
|
|
3071
|
+
const options = {
|
|
3072
|
+
...bbox === void 0 ? {} : { bbox },
|
|
3073
|
+
...dateIso === void 0 ? {} : { dateIso }
|
|
3074
|
+
};
|
|
3075
|
+
const segments = inputPath === "-" ? await readAdsbArchiveSegments(process.stdin, options) : readAdsbSegments(inputPath, options);
|
|
3076
|
+
saveDatasetBundle(outputPath, segments);
|
|
3077
|
+
console.log(`Saved dataset bundle with ${String(segments.length)} segments to ${outputPath}`);
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
console.error(`Unknown command: ${command ?? "none"}`);
|
|
3081
|
+
printHelp();
|
|
3082
|
+
process.exit(1);
|
|
3083
|
+
}
|
|
3084
|
+
await run();
|
|
3085
|
+
export {};
|