Skip to content

Commit 049bdb1

Browse files
MiguelPuntoEsclaude
andcommitted
feat: time-differenced ionosphere (ROT) and per-arc TEC detrending
computeIonoRate: bias-free sequential or standardized-baseline differences (TECU/min) and second undivided differences, never crossing arc boundaries; detrendIonoArcs anchors arcs at their first observation. Ground-truth tested against a known dTEC/dt. Suggested by Hans van der Marel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 29719ed commit 049bdb1

4 files changed

Lines changed: 248 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 1.6.0
4+
5+
### New features
6+
7+
- **Time-differenced ionosphere (rate of TEC)**`computeIonoRate(result, intervalSec?, order?)`: sequential ΔSTEC/Δt in TECU/min with all biases cancelled; a standardized baseline (e.g. 60 s, all pairs) gives a sample-rate-independent picture; `order: 2` returns the second undivided difference (gradients removed, scintillation/noise amplified). Differences never cross arc boundaries, so cycle slips do not pollute the series. `detrendIonoArcs(result)` removes the per-arc bias by anchoring each arc at its first observation, showing pure TEC variation. `IonoSeries` gains `arcStarts`. Suggested by Hans van der Marel.
8+
39
## 1.5.1
410

511
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gnss-js",
3-
"version": "1.5.1",
3+
"version": "1.6.0",
44
"description": "Comprehensive GNSS library for JavaScript — time scales, coordinates, RINEX parsing, RTCM3 decoding, orbit computation, and signal analysis",
55
"type": "module",
66
"sideEffects": false,

src/analysis/ionosphere.ts

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export interface IonoSeries {
5858
tecuPerNs: number;
5959
/** Time series of slant TEC values (TECU, DCB-biased). */
6060
points: IonoPoint[];
61+
/**
62+
* Indices into `points` where a new continuous arc begins (gap,
63+
* cycle slip, or geometry-free jump). Differencing and per-arc
64+
* detrending must not cross these boundaries.
65+
*/
66+
arcStarts: number[];
6167
}
6268

6369
export interface IonoResult {
@@ -107,6 +113,7 @@ interface PairState {
107113
export class IonoAccumulator {
108114
private state = new Map<string, Map<string, PairState>>();
109115
private closed = new Map<string, Map<string, IonoPoint[]>>();
116+
private closedArcStarts = new Map<string, Map<string, number[]>>();
110117
private interval: number;
111118
private obsIndices: Map<string, Map<string, { L: number; C: number | null }>>;
112119
private gloChannels: Record<string, number>;
@@ -254,6 +261,17 @@ export class IonoAccumulator {
254261
points = [];
255262
satArcs.set(pairKey, points);
256263
}
264+
let satStarts = this.closedArcStarts.get(prn);
265+
if (!satStarts) {
266+
satStarts = new Map();
267+
this.closedArcStarts.set(prn, satStarts);
268+
}
269+
let starts = satStarts.get(pairKey);
270+
if (!starts) {
271+
starts = [];
272+
satStarts.set(pairKey, starts);
273+
}
274+
starts.push(points.length);
257275
for (let k = 0; k < arc.times.length; k++) {
258276
points.push({
259277
time: arc.times[k]!,
@@ -289,7 +307,7 @@ export class IonoAccumulator {
289307
}
290308
if (!bestKey) continue;
291309
const points = satArcs.get(bestKey)!;
292-
points.sort((a, b) => a.time - b.time);
310+
const arcStarts = this.closedArcStarts.get(prn)?.get(bestKey) ?? [0];
293311
const sys = prn[0]!;
294312
for (const p of points) {
295313
sum += p.stec;
@@ -308,6 +326,7 @@ export class IonoAccumulator {
308326
codes: this.pairCodes.get(`${sys}:${bestKey}`) ?? ['', ''],
309327
tecuPerNs,
310328
points,
329+
arcStarts,
311330
});
312331
}
313332
series.sort((a, b) => a.prn.localeCompare(b.prn));
@@ -319,3 +338,132 @@ export class IonoAccumulator {
319338
};
320339
}
321340
}
341+
342+
/* ================================================================== */
343+
/* Time-differenced ionosphere (rate of TEC) */
344+
/* ================================================================== */
345+
346+
export interface IonoRatePoint {
347+
/** Epoch time (ms) — the later epoch of the differenced pair. */
348+
time: number;
349+
/** ROT in TECU/min (order 1) or undivided ΔΔSTEC in TECU (order 2). */
350+
value: number;
351+
}
352+
353+
export interface IonoRateSeries {
354+
prn: string;
355+
system: string;
356+
points: IonoRatePoint[];
357+
}
358+
359+
/**
360+
* Sequential time differences of the slant TEC series — the biases
361+
* (ambiguities, DCBs) cancel, leaving ionospheric rate of TEC, phase
362+
* noise, and scintillation. Differences never cross arc boundaries,
363+
* so cycle slips do not appear as outliers (arcs split there).
364+
*
365+
* @param intervalSec Standardized differencing baseline in seconds.
366+
* Omit for native-rate sequential differences; set e.g. 60 for a
367+
* sample-rate-independent picture (all pairs ~1 min apart are used).
368+
* @param order 1 (default) = first difference in TECU/min;
369+
* 2 = second undivided difference in TECU (native rate only,
370+
* gradients removed, noise and scintillation amplified).
371+
*/
372+
export function computeIonoRate(
373+
result: IonoResult,
374+
intervalSec?: number,
375+
order: 1 | 2 = 1
376+
): IonoRateSeries[] {
377+
const out: IonoRateSeries[] = [];
378+
for (const s of result.series) {
379+
const pts: IonoRatePoint[] = [];
380+
const bounds = [...s.arcStarts, s.points.length];
381+
for (let a = 0; a < bounds.length - 1; a++) {
382+
const lo = bounds[a]!;
383+
const hi = bounds[a + 1]!;
384+
if (order === 2) {
385+
// Second undivided difference on (approximately) uniform spacing
386+
for (let i = lo + 2; i < hi; i++) {
387+
const dt1 = s.points[i - 1]!.time - s.points[i - 2]!.time;
388+
const dt2 = s.points[i]!.time - s.points[i - 1]!.time;
389+
if (Math.abs(dt1 - dt2) > 0.1 * Math.max(dt1, dt2)) continue;
390+
pts.push({
391+
time: s.points[i]!.time,
392+
value:
393+
s.points[i]!.stec -
394+
2 * s.points[i - 1]!.stec +
395+
s.points[i - 2]!.stec,
396+
});
397+
}
398+
continue;
399+
}
400+
if (intervalSec === undefined) {
401+
for (let i = lo + 1; i < hi; i++) {
402+
const dtMin = (s.points[i]!.time - s.points[i - 1]!.time) / 60_000;
403+
if (dtMin <= 0) continue;
404+
pts.push({
405+
time: s.points[i]!.time,
406+
value: (s.points[i]!.stec - s.points[i - 1]!.stec) / dtMin,
407+
});
408+
}
409+
} else {
410+
// All pairs ~intervalSec apart (two-pointer over the arc)
411+
const targetMs = intervalSec * 1000;
412+
let j = lo;
413+
for (let i = lo; i < hi; i++) {
414+
if (j <= i) j = i + 1;
415+
while (j < hi && s.points[j]!.time - s.points[i]!.time < targetMs)
416+
j++;
417+
if (j >= hi) break;
418+
const dtMs = s.points[j]!.time - s.points[i]!.time;
419+
if (dtMs > 1.5 * targetMs) continue; // gap-ish, not a clean pair
420+
pts.push({
421+
time: s.points[j]!.time,
422+
value: ((s.points[j]!.stec - s.points[i]!.stec) / dtMs) * 60_000,
423+
});
424+
}
425+
}
426+
}
427+
if (pts.length > 0) out.push({ prn: s.prn, system: s.system, points: pts });
428+
}
429+
return out;
430+
}
431+
432+
/**
433+
* Remove the per-arc bias from a slant TEC result — each arc is
434+
* shifted so its first observation reads zero, leaving only the TEC
435+
* *variation* along the arc (Hans van der Marel's suggestion; using
436+
* the first observation keeps the shape untouched at the small risk
437+
* of anchoring on an outlier).
438+
*/
439+
export function detrendIonoArcs(result: IonoResult): IonoResult {
440+
let sum = 0;
441+
let count = 0;
442+
let maxStec = -Infinity;
443+
const series = result.series.map((s) => {
444+
const bounds = [...s.arcStarts, s.points.length];
445+
const points: IonoPoint[] = new Array<IonoPoint>(s.points.length);
446+
for (let a = 0; a < bounds.length - 1; a++) {
447+
const lo = bounds[a]!;
448+
const hi = bounds[a + 1]!;
449+
const anchor = s.points[lo]?.stec ?? 0;
450+
for (let i = lo; i < hi; i++) {
451+
points[i] = {
452+
time: s.points[i]!.time,
453+
stec: s.points[i]!.stec - anchor,
454+
};
455+
}
456+
}
457+
for (const p of points) {
458+
sum += p.stec;
459+
count++;
460+
if (p.stec > maxStec) maxStec = p.stec;
461+
}
462+
return { ...s, points };
463+
});
464+
return {
465+
series,
466+
maxStec: count > 0 ? maxStec : 0,
467+
meanStec: count > 0 ? sum / count : 0,
468+
};
469+
}

test/iono-rate.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Ground-truth tests for the time-differenced ionosphere helpers.
3+
* A synthetic TEC(t) with known derivative pins the ROT computation;
4+
* differencing across arc boundaries and sample-rate dependence are
5+
* the two failure modes checked explicitly.
6+
*/
7+
import { describe, expect, it } from 'vitest';
8+
import type { IonoResult, IonoSeries } from '../src/analysis/ionosphere';
9+
import { computeIonoRate, detrendIonoArcs } from '../src/analysis/ionosphere';
10+
11+
const T0 = Date.UTC(2026, 0, 1);
12+
13+
/** TEC(t) = 20 + 5·sin(2π·t/7200): dTEC/dt peaks at ~0.26 TECU/min. */
14+
const tec = (tMs: number) =>
15+
20 + 5 * Math.sin((2 * Math.PI * (tMs - T0)) / 7_200_000);
16+
const RATE_PEAK = ((5 * 2 * Math.PI) / 7200) * 60; // TECU/min
17+
18+
function series(stepSec: number, n: number, arcStarts = [0]): IonoSeries {
19+
return {
20+
prn: 'G01',
21+
system: 'G',
22+
label: 'L1-L2',
23+
codes: ['C1C', 'C2W'],
24+
tecuPerNs: 2.85,
25+
points: Array.from({ length: n }, (_, i) => {
26+
const t = T0 + i * stepSec * 1000;
27+
return { time: t, stec: tec(t) };
28+
}),
29+
arcStarts,
30+
};
31+
}
32+
33+
const asResult = (s: IonoSeries): IonoResult => ({
34+
series: [s],
35+
maxStec: 25,
36+
meanStec: 20,
37+
});
38+
39+
describe('computeIonoRate', () => {
40+
it('native sequential differences track the true dTEC/dt', () => {
41+
const rates = computeIonoRate(asResult(series(30, 240)));
42+
const values = rates[0]!.points.map((p) => p.value);
43+
const peak = Math.max(...values.map(Math.abs));
44+
expect(peak).toBeGreaterThan(RATE_PEAK * 0.95);
45+
expect(peak).toBeLessThan(RATE_PEAK * 1.05);
46+
});
47+
48+
it('standardized 60 s baseline is sample-rate independent', () => {
49+
const at30s = computeIonoRate(asResult(series(30, 240)), 60);
50+
const at1s = computeIonoRate(asResult(series(1, 7200)), 60);
51+
const peak30 = Math.max(...at30s[0]!.points.map((p) => Math.abs(p.value)));
52+
const peak1 = Math.max(...at1s[0]!.points.map((p) => Math.abs(p.value)));
53+
expect(peak30).toBeCloseTo(peak1, 1);
54+
// ~1-min pairs from 1 s data: many more of them, same magnitude
55+
expect(at1s[0]!.points.length).toBeGreaterThan(
56+
at30s[0]!.points.length * 10
57+
);
58+
});
59+
60+
it('never differences across an arc boundary', () => {
61+
// Two arcs with a 100 TECU step between them (a slip's signature):
62+
// the step must NOT appear in the rate series.
63+
const s = series(30, 100, [0, 50]);
64+
for (let i = 50; i < 100; i++) s.points[i]!.stec += 100;
65+
const rates = computeIonoRate(asResult(s));
66+
expect(rates[0]!.points).toHaveLength(98); // 49 + 49, not 99
67+
const peak = Math.max(...rates[0]!.points.map((p) => Math.abs(p.value)));
68+
expect(peak).toBeLessThan(1); // the 100 TECU step would read ~200/min
69+
});
70+
71+
it('second undivided difference is near zero for smooth TEC', () => {
72+
const d2 = computeIonoRate(asResult(series(30, 240)), undefined, 2);
73+
const peak = Math.max(...d2[0]!.points.map((p) => Math.abs(p.value)));
74+
expect(peak).toBeLessThan(0.01); // curvature of the slow sinusoid only
75+
});
76+
});
77+
78+
describe('detrendIonoArcs', () => {
79+
it('anchors every arc at its first observation', () => {
80+
const s = series(30, 100, [0, 50]);
81+
for (let i = 50; i < 100; i++) s.points[i]!.stec += 7; // arc bias
82+
const out = detrendIonoArcs(asResult(s));
83+
const pts = out.series[0]!.points;
84+
expect(pts[0]!.stec).toBe(0);
85+
expect(pts[50]!.stec).toBe(0);
86+
// Variation within each arc is preserved
87+
expect(pts[10]!.stec).toBeCloseTo(
88+
s.points[10]!.stec - s.points[0]!.stec,
89+
9
90+
);
91+
});
92+
});

0 commit comments

Comments
 (0)