@meri-imperiumi/signalk-dead-reckoning 0.13.0 → 0.13.2
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/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/plugin/db.js +27 -4
- package/plugin/engine.js +19 -2
- package/plugin/index.js +45 -4
- package/tests/db.test.js +46 -0
- package/tests/engine.test.js +35 -0
- package/tests/plugin.test.js +29 -2
- package/tests/replay-passage.test.js +64 -0
- package/tools/replay-passage.js +67 -19
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.2] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The shadow boat no longer sails away while anchored.** `engine.tick()` integrated its displacement unconditionally — the `underway` flag from `navigation.state` only gated the uncertainty cone's clock, never the position integration. A month on the hook therefore painted a fictional eastward passage across the chart: the resolved current vector (tier 3 Weather API keeps reporting a set/drift at the anchor position) was integrated every tick as ground displacement, and the paddlewheel on a stopped hull reads the water streaming past it as STW, integrated along the swinging bow. The anchor holds the ground position, so a moored/anchored tick now freezes the shadow boat's position and the logs entirely — water-track vector, current vector, `logNm`/`tripLogNm`/`logNmSinceOrigin` all hold — while the wall-clock `elapsedSinceOriginS` keeps counting the real stay and the engine stays warm for an instant OVERRIDE handoff. Sailing off the hook resumes integration from the held position with no snap needed.
|
|
14
|
+
- **Resource guards against degenerate stored config, and lighter per-tick database work** (found auditing a boat install whose server event loop stalled 10–74 s at a time — the DR plugin measured innocent, but its config surface allowed amplifying exactly such a stall): a hand-edited stored config bypasses the admin UI's schema validation, so `start()` now clamps `tickIntervalMs` to ≥ 10 ms (a 0/negative/NaN value would degenerate `setInterval` into a 1 ms storm multiplying the whole tick path) and `saveIntervalMs` to ≥ 1 s, and the ground-track ring capacity — sized as hours ÷ tick — is hard-capped at 1 M samples (~50 MB; unreachable at the default 1 s tick). The schema itself gains `minimum`/`maximum` for both intervals. The per-tick deviation-rate query no longer recompiles its SQL every second (statement memoized per connection), and the database opens with WAL's recommended `synchronous = NORMAL` — commit fsyncs on SD-card boat hardware drop from one per write to checkpoint time only, for state that is all reconstructable.
|
|
15
|
+
|
|
16
|
+
## [0.13.1] - 2026-10-05
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- The passage replay tool renders and fetches the short way across the antimeridian (`tools/replay-passage.js`): a track crossing 180° used to smear across the whole 360° span in the SVG track plot — the naive longitude min/max bounds turned 179E → 179W into a world-wide box — and the SCUD current grid fetch computed its 0–360 request box from the same raw bounds, returning nothing useful. Longitudes are now unwrapped against the first fix before bounds are computed, and the SCUD box is built by the new exported `trackBox()` helper in the server's 0–360 frame
|
|
20
|
+
|
|
10
21
|
## [0.13.0] - 2026-10-05
|
|
11
22
|
|
|
12
23
|
### Fixed
|
package/package.json
CHANGED
package/plugin/db.js
CHANGED
|
@@ -198,6 +198,14 @@ function openDatabase(dbPath) {
|
|
|
198
198
|
mkdirSync(dirname(dbPath), { recursive: true });
|
|
199
199
|
const db = new DatabaseSync(dbPath);
|
|
200
200
|
db.exec("PRAGMA journal_mode = WAL");
|
|
201
|
+
// WAL's recommended durability level: commits sync the WAL to the OS
|
|
202
|
+
// but do not fsync per commit — a power loss may lose the last
|
|
203
|
+
// transactions but never corrupts the database. Everything this store
|
|
204
|
+
// holds is reconstructable (re-derivable from fixes, or a running
|
|
205
|
+
// average that self-corrects), so trading the per-commit fsync —
|
|
206
|
+
// sustained minute after minute on SD-card boat hardware — for that
|
|
207
|
+
// small window is the right default. FULL would fsync every commit.
|
|
208
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
201
209
|
db.exec("PRAGMA foreign_keys = ON");
|
|
202
210
|
for (const stmt of SCHEMA_DDL) {
|
|
203
211
|
db.exec(stmt);
|
|
@@ -557,6 +565,17 @@ function attachObservationsToFix(db, fixId, ids) {
|
|
|
557
565
|
for (const id of ids?.cplIds ?? []) cpl.run(fixId, id);
|
|
558
566
|
}
|
|
559
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Per-connection cache of prepared statements (WeakMap, so a closed db
|
|
570
|
+
* is collected with its statements). Statement compilation is not free,
|
|
571
|
+
* and `getDeviationRateStats` runs on the 1 Hz tick path — re-preparing
|
|
572
|
+
* the same SQL every second would pay the compile over and over for the
|
|
573
|
+
* life of the process.
|
|
574
|
+
*
|
|
575
|
+
* @type {WeakMap<import("node:sqlite").DatabaseSync, Record<string, import("node:sqlite").StatementSync>>}
|
|
576
|
+
*/
|
|
577
|
+
const preparedStatements = new WeakMap();
|
|
578
|
+
|
|
560
579
|
/**
|
|
561
580
|
* Reads recent `dr_corrections` rows for the uncertainty polygon's
|
|
562
581
|
* empirical deviation-rate (SPEC §8). Returns rows newest-first so the
|
|
@@ -571,8 +590,12 @@ function attachObservationsToFix(db, fixId, ids) {
|
|
|
571
590
|
* @returns {Array<{deviation_nm: number, dr_elapsed_seconds: number}>}
|
|
572
591
|
*/
|
|
573
592
|
function getDeviationRateStats(db, q = {}) {
|
|
574
|
-
|
|
575
|
-
|
|
593
|
+
let stmts = preparedStatements.get(db);
|
|
594
|
+
if (!stmts) {
|
|
595
|
+
stmts = {};
|
|
596
|
+
preparedStatements.set(db, stmts);
|
|
597
|
+
}
|
|
598
|
+
stmts.deviationRate ??= db.prepare(
|
|
576
599
|
`SELECT deviation_nm, dr_elapsed_seconds
|
|
577
600
|
FROM dr_corrections
|
|
578
601
|
WHERE (? IS NULL OR sail_state = ?)
|
|
@@ -580,12 +603,12 @@ function getDeviationRateStats(db, q = {}) {
|
|
|
580
603
|
ORDER BY correction_id DESC
|
|
581
604
|
LIMIT ?`,
|
|
582
605
|
);
|
|
583
|
-
return
|
|
606
|
+
return stmts.deviationRate.all(
|
|
584
607
|
q.sail_state ?? null,
|
|
585
608
|
q.sail_state ?? null,
|
|
586
609
|
q.sea_state ?? null,
|
|
587
610
|
q.sea_state ?? null,
|
|
588
|
-
limit,
|
|
611
|
+
q.limit ?? 50,
|
|
589
612
|
);
|
|
590
613
|
}
|
|
591
614
|
|
package/plugin/engine.js
CHANGED
|
@@ -129,13 +129,30 @@ class DeadReckoningEngine {
|
|
|
129
129
|
* @param {number} inputs.speedLoss - learned speed-loss fraction [0,1]
|
|
130
130
|
* @param {{setTrue: number, drift: number}} [inputs.current] - resolved current (deg true, kn)
|
|
131
131
|
* @param {boolean} [inputs.underway=true] - whether the vessel is under
|
|
132
|
-
* way this tick (from `navigation.state`);
|
|
133
|
-
* the
|
|
132
|
+
* way this tick (from `navigation.state`); false (moored/anchored)
|
|
133
|
+
* freezes the shadow boat's position — the anchor holds the ground
|
|
134
|
+
* position, so neither the water-track vector nor the resolved
|
|
135
|
+
* current vector may integrate — and gates the under-way clock the
|
|
136
|
+
* uncertainty cone grows on. The wall-clock `elapsedSinceOriginS`
|
|
137
|
+
* still advances: time since the last fix really did elapse.
|
|
134
138
|
* @param {number} [inputs.dtS=1] - tick interval in seconds
|
|
135
139
|
* @returns {{latitude: number, longitude: number}|null} new position, or null if no origin
|
|
136
140
|
*/
|
|
137
141
|
tick(inputs, dtS = 1) {
|
|
138
142
|
if (!this.origin) return null;
|
|
143
|
+
// §7.1–2: moored/anchored is a different regime. The anchor holds the
|
|
144
|
+
// ground position, so the shadow boat must not sail: neither the
|
|
145
|
+
// water-track vector (a paddlewheel on a stopped hull reads the water
|
|
146
|
+
// streaming past it) nor the resolved current vector (a GRIB tier
|
|
147
|
+
// would otherwise walk the shadow boat across the chart at drift kn
|
|
148
|
+
// for the whole stay — a month on the hook painted a fictional
|
|
149
|
+
// passage east) may integrate. The engine stays warm — origin, logs
|
|
150
|
+
// and clocks carry — so an OVERRIDE handoff stays instant; only the
|
|
151
|
+
// displacement is frozen, exactly like the real boat.
|
|
152
|
+
if (inputs.underway === false) {
|
|
153
|
+
this.elapsedSinceOriginS += dtS;
|
|
154
|
+
return this.origin;
|
|
155
|
+
}
|
|
139
156
|
const stw = toNumber(inputs.stwKn);
|
|
140
157
|
const hdg = toNumber(inputs.headingTrueDeg);
|
|
141
158
|
if (stw == null || hdg == null) return this.origin;
|
package/plugin/index.js
CHANGED
|
@@ -740,11 +740,19 @@ module.exports = (app) => {
|
|
|
740
740
|
tickIntervalMs: {
|
|
741
741
|
type: "integer",
|
|
742
742
|
title: "DR Integration Interval (ms)",
|
|
743
|
+
// Floor guards the whole pipeline: the ground-track ring is
|
|
744
|
+
// sized as hours/tickInterval, so a small tick multiplies the
|
|
745
|
+
// ring's memory, the tick-path work and the flush cadence by
|
|
746
|
+
// the same factor (1 ms would allocate a ~130 M-sample ring).
|
|
747
|
+
// 500 ms (2 Hz) is the useful ceiling for a shadow boat.
|
|
748
|
+
minimum: 500,
|
|
749
|
+
maximum: 60000,
|
|
743
750
|
default: DEFAULT_CONFIG.tickIntervalMs,
|
|
744
751
|
},
|
|
745
752
|
saveIntervalMs: {
|
|
746
753
|
type: "integer",
|
|
747
754
|
title: "State Save Interval (ms)",
|
|
755
|
+
minimum: 5000,
|
|
748
756
|
default: DEFAULT_CONFIG.saveIntervalMs,
|
|
749
757
|
},
|
|
750
758
|
"publish.everyTicks": {
|
|
@@ -836,6 +844,31 @@ module.exports = (app) => {
|
|
|
836
844
|
// logbook write-through).
|
|
837
845
|
const opts = deflattenConfig(options);
|
|
838
846
|
config = { ...DEFAULT_CONFIG, ...opts };
|
|
847
|
+
// Defensive clamps: the admin UI enforces the schema minimums, but
|
|
848
|
+
// hand-edited or legacy stored configs skip validation, and a
|
|
849
|
+
// 0/negative/NaN tick would degenerate setInterval to a 1 ms storm
|
|
850
|
+
// that also multiplies the tick path and the flush cadence. The
|
|
851
|
+
// floors are deliberately permissive (10 ms — the test harness
|
|
852
|
+
// drives ticks at 20–100 ms); the UI schema is where the human
|
|
853
|
+
//-facing floor (500 ms) lives.
|
|
854
|
+
config.tickIntervalMs = Math.min(
|
|
855
|
+
60000,
|
|
856
|
+
Math.max(
|
|
857
|
+
10,
|
|
858
|
+
Math.round(
|
|
859
|
+
Number(config.tickIntervalMs) || DEFAULT_CONFIG.tickIntervalMs,
|
|
860
|
+
),
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
config.saveIntervalMs = Math.min(
|
|
864
|
+
24 * 3600 * 1000,
|
|
865
|
+
Math.max(
|
|
866
|
+
1000,
|
|
867
|
+
Math.round(
|
|
868
|
+
Number(config.saveIntervalMs) || DEFAULT_CONFIG.saveIntervalMs,
|
|
869
|
+
),
|
|
870
|
+
),
|
|
871
|
+
);
|
|
839
872
|
// Allow partial divergence overrides without losing the defaults.
|
|
840
873
|
config.divergence = {
|
|
841
874
|
...DEFAULT_CONFIG.divergence,
|
|
@@ -891,10 +924,18 @@ module.exports = (app) => {
|
|
|
891
924
|
tripLogNm: Number.isFinite(savedTripNm) ? savedTripNm : 0,
|
|
892
925
|
});
|
|
893
926
|
groundTrack = new deps.GroundTrack({
|
|
894
|
-
capacity: Math.
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
927
|
+
capacity: Math.min(
|
|
928
|
+
// Memory guard: the ring is sized as hours/tickInterval, so a
|
|
929
|
+
// fast stored tick would scale it without bound (1 ms → a
|
|
930
|
+
// ~130 M-sample ring). 1 M samples ≈ 50 MB is the hard
|
|
931
|
+
// ceiling; at the default 1 s tick it is never reached
|
|
932
|
+
// (168 h, the schema max, is 604,800).
|
|
933
|
+
1_000_000,
|
|
934
|
+
Math.max(
|
|
935
|
+
60,
|
|
936
|
+
Math.round(
|
|
937
|
+
(config.groundTrackHours * 3600 * 1000) / config.tickIntervalMs,
|
|
938
|
+
),
|
|
898
939
|
),
|
|
899
940
|
),
|
|
900
941
|
});
|
package/tests/db.test.js
CHANGED
|
@@ -378,6 +378,52 @@ test("getDeviationRateStats returns recent rows filtered by sail/sea state", asy
|
|
|
378
378
|
await rm(dir, { recursive: true, force: true });
|
|
379
379
|
});
|
|
380
380
|
|
|
381
|
+
test("getDeviationRateStats reuses its prepared statement per connection", async () => {
|
|
382
|
+
// The 1 Hz tick path calls this with the same SQL every second — the
|
|
383
|
+
// statement is memoized per database (WeakMap). Repeated calls with
|
|
384
|
+
// different bindings must stay correct, and a second connection must
|
|
385
|
+
// get its own statement, not share the first one's.
|
|
386
|
+
const dirs = await Promise.all([
|
|
387
|
+
mkdtemp(join(tmpdir(), "dr-stmt-a-")),
|
|
388
|
+
mkdtemp(join(tmpdir(), "dr-stmt-b-")),
|
|
389
|
+
]);
|
|
390
|
+
const dbs = dirs.map((dir) => openDatabase(join(dir, "t.sqlite")));
|
|
391
|
+
for (const db of dbs) {
|
|
392
|
+
const fid = recordFix(db, {
|
|
393
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
394
|
+
source_type: "gps",
|
|
395
|
+
latitude: 60,
|
|
396
|
+
longitude: 24,
|
|
397
|
+
resets_dr_origin: true,
|
|
398
|
+
});
|
|
399
|
+
recordCorrection(db, {
|
|
400
|
+
fix_id: fid,
|
|
401
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
402
|
+
dr_lat: 60.01,
|
|
403
|
+
dr_lon: 24.01,
|
|
404
|
+
fix_lat: 60,
|
|
405
|
+
fix_lon: 24,
|
|
406
|
+
deviation_nm: 0.4,
|
|
407
|
+
deviation_bearing: 200,
|
|
408
|
+
dr_elapsed_seconds: 1800,
|
|
409
|
+
sail_state: "sailing",
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
// Interleaved calls across both connections, changing bindings each time.
|
|
413
|
+
const a1 = getDeviationRateStats(dbs[0], { limit: 10 });
|
|
414
|
+
const b1 = getDeviationRateStats(dbs[1], { limit: 10 });
|
|
415
|
+
const a2 = getDeviationRateStats(dbs[0], { limit: 1 });
|
|
416
|
+
const b2 = getDeviationRateStats(dbs[1], { sail_state: "motoring" });
|
|
417
|
+
assert.strictEqual(a1.length, 1);
|
|
418
|
+
assert.strictEqual(b1.length, 1);
|
|
419
|
+
assert.strictEqual(a2.length, 1);
|
|
420
|
+
assert.strictEqual(b2.length, 0);
|
|
421
|
+
for (const [db, dir] of dbs.map((db, i) => [db, dirs[i]])) {
|
|
422
|
+
db.close();
|
|
423
|
+
await rm(dir, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
381
427
|
// --- Observation & fix CRUD (work doc #13 stage D) -------------------------
|
|
382
428
|
|
|
383
429
|
const {
|
package/tests/engine.test.js
CHANGED
|
@@ -39,6 +39,41 @@ test("under-way clock only advances while under way", () => {
|
|
|
39
39
|
assert.strictEqual(e.underwaySinceOriginS, 0);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test("anchored tick holds the shadow boat on the anchor", () => {
|
|
43
|
+
const e = new DeadReckoningEngine();
|
|
44
|
+
e.snapToFix({ latitude: -18.65, longitude: 174.0 });
|
|
45
|
+
// A month on the hook: sensors on a stopped hull still read — the
|
|
46
|
+
// paddlewheel sees the current streaming past, the resolver hands back
|
|
47
|
+
// a GRIB current — but the anchor holds the ground position, so none
|
|
48
|
+
// of it may move the shadow boat or grow the logs.
|
|
49
|
+
const before = { ...e };
|
|
50
|
+
for (let day = 0; day < 30; day++) {
|
|
51
|
+
const pos = e.tick(
|
|
52
|
+
{
|
|
53
|
+
stwKn: 0.8,
|
|
54
|
+
headingTrueDeg: 90,
|
|
55
|
+
leewayDeg: 5,
|
|
56
|
+
current: { setTrue: 90, drift: 0.4 },
|
|
57
|
+
underway: false,
|
|
58
|
+
},
|
|
59
|
+
24 * 3600,
|
|
60
|
+
);
|
|
61
|
+
assert.deepStrictEqual(pos, before.origin);
|
|
62
|
+
}
|
|
63
|
+
assert.deepStrictEqual(e.origin, before.origin);
|
|
64
|
+
assert.strictEqual(e.logNm, before.logNm);
|
|
65
|
+
assert.strictEqual(e.tripLogNm, before.tripLogNm);
|
|
66
|
+
assert.strictEqual(e.logNmSinceOrigin, before.logNmSinceOrigin);
|
|
67
|
+
// The under-way clock — the cone's growth axis — stays frozen, while
|
|
68
|
+
// the wall-clock "since fix" keeps counting the real stay.
|
|
69
|
+
assert.strictEqual(e.underwaySinceOriginS, 0);
|
|
70
|
+
assert.strictEqual(e.elapsedSinceOriginS, 30 * 24 * 3600);
|
|
71
|
+
// The engine stays warm: sailing off the hook resumes integration
|
|
72
|
+
// from the held position with no snap needed.
|
|
73
|
+
const pos = e.tick({ stwKn: 5, headingTrueDeg: 0, underway: true }, 3600);
|
|
74
|
+
assert.ok(Math.abs(pos.latitude - (before.origin.latitude + 5 / 60)) < 1e-4);
|
|
75
|
+
});
|
|
76
|
+
|
|
42
77
|
test("tick advances north along a meridian at the given STW", () => {
|
|
43
78
|
const e = new DeadReckoningEngine();
|
|
44
79
|
e.snapToFix({ latitude: 60, longitude: 24 });
|
package/tests/plugin.test.js
CHANGED
|
@@ -44,14 +44,14 @@ test.after(async () => {
|
|
|
44
44
|
*
|
|
45
45
|
* @returns {{app: FakeSignalKApp, plugin: object, router: FakeRouter}}
|
|
46
46
|
*/
|
|
47
|
-
function makeStarted() {
|
|
47
|
+
function makeStarted(options) {
|
|
48
48
|
const app = new FakeSignalKApp();
|
|
49
49
|
// Fresh data dir per call: the speed-plausibility gate and restart
|
|
50
50
|
// persistence make cross-test DB leakage (a stale restored DR origin)
|
|
51
51
|
// observable, so each test owns its database.
|
|
52
52
|
app.dataPath = mkdtempSync(join(tmpdir(), "dr-plugin-"));
|
|
53
53
|
const plugin = makePlugin(app);
|
|
54
|
-
plugin.start({});
|
|
54
|
+
plugin.start(options ?? {});
|
|
55
55
|
const router = new FakeRouter();
|
|
56
56
|
plugin.registerWithRouter(router);
|
|
57
57
|
return { app, plugin, router };
|
|
@@ -2843,6 +2843,33 @@ test("app.get config endpoint serves the plugin config with a hash", () => {
|
|
|
2843
2843
|
plugin.stop();
|
|
2844
2844
|
});
|
|
2845
2845
|
|
|
2846
|
+
test("insane intervals from stored config are clamped, ring capacity bounded", () => {
|
|
2847
|
+
// The admin UI enforces the schema minimums, but a hand-edited stored
|
|
2848
|
+
// config skips validation — a 1 ms tick would degenerate setInterval
|
|
2849
|
+
// into a 1 ms storm, and the ground-track ring (sized as hours ÷ tick)
|
|
2850
|
+
// would grow without bound. The floors are deliberately permissive
|
|
2851
|
+
// (the test harness itself drives ticks at 20–100 ms); the clamp only
|
|
2852
|
+
// stops the degenerate cases.
|
|
2853
|
+
const { app, plugin } = makeStarted({
|
|
2854
|
+
tickIntervalMs: 1,
|
|
2855
|
+
saveIntervalMs: 100,
|
|
2856
|
+
});
|
|
2857
|
+
const cfg = app.appRoutes.find((r) =>
|
|
2858
|
+
r.path.includes("/signalk-dead-reckoning/configuration"),
|
|
2859
|
+
);
|
|
2860
|
+
const res = {
|
|
2861
|
+
set() {},
|
|
2862
|
+
json(obj) {
|
|
2863
|
+
this._body = obj;
|
|
2864
|
+
},
|
|
2865
|
+
status() {},
|
|
2866
|
+
};
|
|
2867
|
+
cfg.handler({}, res);
|
|
2868
|
+
assert.strictEqual(res._body.config.tickIntervalMs, 10);
|
|
2869
|
+
assert.strictEqual(res._body.config.saveIntervalMs, 1000);
|
|
2870
|
+
plugin.stop();
|
|
2871
|
+
});
|
|
2872
|
+
|
|
2846
2873
|
test("schema exposes the positionFormat option with DMS default", () => {
|
|
2847
2874
|
const app = new FakeSignalKApp();
|
|
2848
2875
|
const plugin = makePlugin(app);
|
|
@@ -22,6 +22,8 @@ const {
|
|
|
22
22
|
runReplay,
|
|
23
23
|
summarize,
|
|
24
24
|
currentAt,
|
|
25
|
+
toHtmlReport,
|
|
26
|
+
trackBox,
|
|
25
27
|
PATH_SPECS,
|
|
26
28
|
} = require("../tools/replay-passage.js");
|
|
27
29
|
const {
|
|
@@ -316,3 +318,65 @@ test("leeway synthetic: the learning variant absorbs a 4° course offset", () =>
|
|
|
316
318
|
`learn div ${last.learn.divNm}`,
|
|
317
319
|
);
|
|
318
320
|
});
|
|
321
|
+
|
|
322
|
+
test("report plot: a track crossing the antimeridian renders contiguous", () => {
|
|
323
|
+
// A due-east track from 179°E crosses 180° and reads as -179° in the
|
|
324
|
+
// next fix (GPS wraps to [-180, 180]). The SVG plot must unwrap those
|
|
325
|
+
// longitudes so the polyline runs left-to-right across the seam —
|
|
326
|
+
// the naive min/max bounds used to smear the track across the whole
|
|
327
|
+
// 360° span.
|
|
328
|
+
const samples = [];
|
|
329
|
+
for (let i = 0; i < 20; i++) {
|
|
330
|
+
const lon = 179 + i * 0.2; // crosses 180 at i = 6
|
|
331
|
+
const wrapped = lon > 180 ? lon - 360 : lon;
|
|
332
|
+
samples.push({
|
|
333
|
+
tMs: Date.parse("2026-08-14T00:00:00Z") + i * DT_S * 1000,
|
|
334
|
+
gps: { latitude: -14, longitude: wrapped },
|
|
335
|
+
cold: {
|
|
336
|
+
position: { latitude: -14.001, longitude: wrapped },
|
|
337
|
+
divNm: 0.1 * i,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const summary = summarize(samples);
|
|
342
|
+
const html = toHtmlReport({
|
|
343
|
+
meta: {
|
|
344
|
+
vessel: "test",
|
|
345
|
+
from: "2026-08-14",
|
|
346
|
+
to: "2026-08-15",
|
|
347
|
+
historyUrl: "x",
|
|
348
|
+
},
|
|
349
|
+
samples,
|
|
350
|
+
summary,
|
|
351
|
+
hourly: [],
|
|
352
|
+
});
|
|
353
|
+
const polylines = html.match(/<polyline points="([^"]+)"/g);
|
|
354
|
+
assert.ok(polylines.length >= 1, "the report has a track polyline");
|
|
355
|
+
const gpsPts = polylines[0]
|
|
356
|
+
.match(/points="([^"]+)"/)[1]
|
|
357
|
+
.split(" ")
|
|
358
|
+
.map((p) => p.split(",").map(Number));
|
|
359
|
+
// x advances monotonically across the seam instead of jumping back
|
|
360
|
+
let monotonic = true;
|
|
361
|
+
for (let i = 1; i < gpsPts.length; i++) {
|
|
362
|
+
if (gpsPts[i][0] < gpsPts[i - 1][0] - 0.5) monotonic = false;
|
|
363
|
+
}
|
|
364
|
+
assert.ok(monotonic, "GPS polyline x is monotonic across the seam");
|
|
365
|
+
assert.ok(gpsPts[gpsPts.length - 1][0] > 800, "track spans the plot width");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("trackBox: a seam-crossing track produces a contiguous SCUD box", () => {
|
|
369
|
+
// A track from 179E to 179W (wrapped at the seam by the GPS) must
|
|
370
|
+
// produce a box around 359-361 in the server's 0-360 frame — not the
|
|
371
|
+
// naive 179..359 span the raw min/max would request.
|
|
372
|
+
const track = [
|
|
373
|
+
{ latitude: -14, longitude: 179.0 },
|
|
374
|
+
{ latitude: -14.1, longitude: 179.5 },
|
|
375
|
+
{ latitude: -14.2, longitude: -179.0 }, // wrapped at the seam
|
|
376
|
+
];
|
|
377
|
+
const box = trackBox(track);
|
|
378
|
+
assert.ok(Math.abs(box.lonMin - 539) < 1e-9, `lonMin ${box.lonMin}`);
|
|
379
|
+
assert.ok(Math.abs(box.lonMax - 541) < 1e-9, `lonMax ${box.lonMax}`);
|
|
380
|
+
assert.ok(Math.abs(box.latMin - -14.2) < 1e-9);
|
|
381
|
+
assert.ok(Math.abs(box.latMax - -14) < 1e-9);
|
|
382
|
+
});
|
package/tools/replay-passage.js
CHANGED
|
@@ -288,6 +288,42 @@ const SCUD_ERDDAP =
|
|
|
288
288
|
/** Margin (deg) around the track bounds for the SCUD fetch box. */
|
|
289
289
|
const SCUD_BOX_MARGIN_DEG = 0.5;
|
|
290
290
|
|
|
291
|
+
/**
|
|
292
|
+
* Bounds of a GPS track as SCUD fetch-box coordinates: latitude in
|
|
293
|
+
* degrees, longitude in the server's 0–360 east frame. Longitudes are
|
|
294
|
+
* unwrapped against the first fix so a track crossing the antimeridian
|
|
295
|
+
* stays contiguous (179E → 181, not −179): the naive min/max would
|
|
296
|
+
* request a box spanning the whole Pacific.
|
|
297
|
+
*
|
|
298
|
+
* @param {{latitude: number, longitude: number}[]} track
|
|
299
|
+
* @returns {{latMin: number, latMax: number, lonMin: number, lonMax: number}}
|
|
300
|
+
*/
|
|
301
|
+
function trackBox(track) {
|
|
302
|
+
if (!track.length) {
|
|
303
|
+
return { latMin: 0, latMax: 0, lonMin: 360, lonMax: 360 };
|
|
304
|
+
}
|
|
305
|
+
const unwrap = (lon, ref) => lon - 360 * Math.round((lon - ref) / 360);
|
|
306
|
+
let latMin = Infinity;
|
|
307
|
+
let latMax = -Infinity;
|
|
308
|
+
let lonMinU = track[0].longitude;
|
|
309
|
+
let lonMaxU = lonMinU;
|
|
310
|
+
let prev = lonMinU;
|
|
311
|
+
for (const p of track) {
|
|
312
|
+
latMin = Math.min(latMin, p.latitude);
|
|
313
|
+
latMax = Math.max(latMax, p.latitude);
|
|
314
|
+
const u = unwrap(p.longitude, prev);
|
|
315
|
+
lonMinU = Math.min(lonMinU, u);
|
|
316
|
+
lonMaxU = Math.max(lonMaxU, u);
|
|
317
|
+
prev = u;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
latMin,
|
|
321
|
+
latMax,
|
|
322
|
+
lonMin: lonMinU + 360,
|
|
323
|
+
lonMax: lonMaxU + 360,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
291
327
|
/**
|
|
292
328
|
* Fetches a SCUD current grid over a lat/lon box and time range.
|
|
293
329
|
*
|
|
@@ -860,22 +896,34 @@ function toHtmlReport(report) {
|
|
|
860
896
|
if (s.gps) pts.push(s.gps);
|
|
861
897
|
for (const k of keys) if (s[k]?.position) pts.push(s[k].position);
|
|
862
898
|
}
|
|
863
|
-
let
|
|
864
|
-
let
|
|
865
|
-
let lonMinV = Infinity;
|
|
866
|
-
let lonMaxV = -Infinity;
|
|
899
|
+
let latMin = Infinity;
|
|
900
|
+
let latMax = -Infinity;
|
|
867
901
|
for (const p of pts) {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
902
|
+
latMin = Math.min(latMin, p.latitude);
|
|
903
|
+
latMax = Math.max(latMax, p.latitude);
|
|
904
|
+
}
|
|
905
|
+
// Unwrap longitudes against the first point so a track crossing the
|
|
906
|
+
// antimeridian stays contiguous (179 -> 181 instead of 179 -> -179,
|
|
907
|
+
// which the naive min/max would smear across the whole 360° span)
|
|
908
|
+
const unwrap = (lon, ref) => lon - 360 * Math.round((lon - ref) / 360);
|
|
909
|
+
const lon0 = pts.length ? pts[0].longitude : 0;
|
|
910
|
+
let lonMin = Infinity;
|
|
911
|
+
let lonMax = -Infinity;
|
|
912
|
+
let prev = lon0;
|
|
913
|
+
for (const p of pts) {
|
|
914
|
+
const u = unwrap(p.longitude, prev);
|
|
915
|
+
lonMin = Math.min(lonMin, u);
|
|
916
|
+
lonMax = Math.max(lonMax, u);
|
|
917
|
+
prev = u;
|
|
918
|
+
}
|
|
919
|
+
if (!pts.length) {
|
|
920
|
+
lonMin = 0;
|
|
921
|
+
lonMax = 0;
|
|
922
|
+
latMin = 0;
|
|
923
|
+
latMax = 0;
|
|
872
924
|
}
|
|
873
|
-
const latMin = latMinV;
|
|
874
|
-
const latMax = latMaxV;
|
|
875
|
-
const lonMin = lonMinV;
|
|
876
|
-
const lonMax = lonMaxV;
|
|
877
925
|
const latMid = ((latMin + latMax) / 2) * (Math.PI / 180);
|
|
878
|
-
const xOf = (lon) => (lon - lonMin) * Math.cos(latMid);
|
|
926
|
+
const xOf = (lon) => (unwrap(lon, lonMin) - lonMin) * Math.cos(latMid);
|
|
879
927
|
const yOf = (lat) => latMax - lat;
|
|
880
928
|
const spanX = Math.max(1e-9, xOf(lonMax));
|
|
881
929
|
const spanY = Math.max(1e-9, yOf(latMin));
|
|
@@ -1050,18 +1098,17 @@ async function main(argv) {
|
|
|
1050
1098
|
let scudGrid = null;
|
|
1051
1099
|
if (opts.scud) {
|
|
1052
1100
|
const track = rows.map((r) => r.position).filter(Boolean);
|
|
1053
|
-
const
|
|
1054
|
-
const lons = track.map((p) => p.longitude);
|
|
1101
|
+
const { latMin, latMax, lonMin, lonMax } = trackBox(track);
|
|
1055
1102
|
const scudFrom = new Date(Date.parse(opts.from) - 86400000).toISOString();
|
|
1056
1103
|
const scudTo = new Date(Date.parse(opts.to) + 86400000).toISOString();
|
|
1057
1104
|
try {
|
|
1058
1105
|
scudGrid = await fetchScudGrid({
|
|
1059
1106
|
from: scudFrom,
|
|
1060
1107
|
to: scudTo,
|
|
1061
|
-
latMin:
|
|
1062
|
-
latMax:
|
|
1063
|
-
lonMin:
|
|
1064
|
-
lonMax:
|
|
1108
|
+
latMin: latMin - SCUD_BOX_MARGIN_DEG,
|
|
1109
|
+
latMax: latMax + SCUD_BOX_MARGIN_DEG,
|
|
1110
|
+
lonMin: lonMin - SCUD_BOX_MARGIN_DEG,
|
|
1111
|
+
lonMax: lonMax + SCUD_BOX_MARGIN_DEG,
|
|
1065
1112
|
});
|
|
1066
1113
|
process.stderr.write(
|
|
1067
1114
|
`SCUD grid: ${scudGrid.timesMs.length} days (${scudGrid.timesMs
|
|
@@ -1145,6 +1192,7 @@ module.exports = {
|
|
|
1145
1192
|
forwardFill,
|
|
1146
1193
|
fetchHistory,
|
|
1147
1194
|
fetchScudGrid,
|
|
1195
|
+
trackBox,
|
|
1148
1196
|
currentAt,
|
|
1149
1197
|
runReplay,
|
|
1150
1198
|
hourlyBuckets,
|