@meri-imperiumi/signalk-passage-briefing 0.12.2 → 0.12.4
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 +15 -1
- package/package.json +4 -4
- package/plugin/bulletin-engine.js +64 -15
- package/plugin/hazard-alerts.js +120 -6
- package/plugin/hazard-source.js +34 -20
- package/plugin/hazard-state.js +73 -0
- package/plugin/index.js +132 -29
- package/public/components/passage-outlook.js +9 -0
- package/tests/bulletin-engine.test.js +102 -0
- package/tests/fixtures/bulletins/fqps01-nffn-2026-10-16.txt +62 -0
- package/tests/hazard-alerts.test.js +222 -4
- package/tests/hazard-state.test.js +60 -0
- package/tests/plugin-degradation.test.js +291 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.12.4] - 2026-10-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- The tropical-cyclone notification flapped on/off 27 times in ~45 min (logbook 2026-10-16, TC ONE-26): every ticker republish sent a fresh value (re-alarming boards), a GDACS dip outlasted the 2-minute clear grace and the return re-raised, and the exposure envelope tightened whenever a compile switched the reference. Four layers of anti-flap: notifications publish through a three-tier gate (raise = first publish or state change, visual+sound; amendment = same state, new GDACS figures, visual only so sounders stay quiet; identical = silence); once raised, a cyclone notification latches — it stays warnable while the storm exists in the GDACS envelope at any alert level (the wide green filter feeds a new `retainLatched` carry-over) and clears only after an hour out of the matrix; the hazard filter admits via either envelope (1000 nm from the vessel or 500 nm off the route) instead of letting the closer reference tighten the band; and acknowledgements are level- and distance-aware, persisted on disk (`hazards-state.json`, 7-day prune) so restarts stay quiet — only a strictly higher level (orange → red) or a storm closed to half the acked distance re-alarms. GDACS refreshes now amend the notification text in place rather than clearing and re-raising.
|
|
9
|
+
- A `weather_source: "weather-api"` configuration on a server without the Weather API provider crashed `start()` outright — taking the warnings console, the bulletins and the hazard escalation down with it. The misconfiguration now degrades to Open-Meteo for the session and logs the error, so the rest of the plugin keeps working.
|
|
10
|
+
- Graceful-degradation regression suite (`tests/plugin-degradation.test.js`): the plugin boots and serves empty states on a bare server — position available, but no Weather API provider (auto falls back to Open-Meteo and briefs), no active route (here mode), no energy predictor (empty `energyHourly`/`energyEvents` ride the payload), no polar management (`/api/polar` serves null, webapp falls back), no Resources API (`/api/routes` serves 501), link never online (refresh 503, briefing `cached: false`), and the hazard escalation stays quiet over empty caches.
|
|
11
|
+
- A TC Wati storm warning (FQPS01 NFFN 2026-10-16 fixture) parsed geometry-less and was dropped whole by the track filter — the one bulletin the console must never miss. Four parsing gaps fixed: the GMDSS section anchor matched any line starting with "FORECAST" (the warning's own "FORECAST POSITION NEAR…" line split the warning section in half, leaving gale headers and cancel notices floating unanchored), so the bare `FORECAST`/`WARNINGS`/`SYNOPSIS` anchors now require heading-only lines; NFFN spells the hemispheres out in cyclone positions ("NEAR 12.1 SOUTH 163.0 WEST", line-wrapped anywhere), which defeated both the NEAR extraction and the coordinate parser — the paired lat+lon form is now contracted to the letter form in boilerplate stripping, leaving prose ("5 SOUTH OF T1") untouched; storm-declaration detection runs per paragraph inside the block, so a coalesced block leading with the "PART 1 : WARNING STORM WARNING 012" header still types as its storm's point (kept on any track) instead of merging into a geometry-less discard; and quadrant wind-radii lines ("WITHIN 95 NAUTICAL MILES IN SE QUADRANT") coalesce onto the storm's point anchor rather than the nearest area, so the full wind field rides with the warning. The result: a vessel far outside the warning areas still reads the complete storm warning, a vessel inside the gale polygon reads it whole (header, area, cancel notice), and "WITHIN 300 NAUTICAL MILES OF TROPICAL CYCLONE WATI CENTER" resolves through the collected feature instead of stopping at "OF TROPICAL".
|
|
12
|
+
- The `S OF`/`N OF` latitude abbreviations matched mid-word — "BETWEEN OF 16S" read as "…BETWEEn OF 16S" and clamped the band to 16S — so NFFN's stray-"OF" latitude-band phrasing ("IN THE AREA BETWEEN OF 16S AND 22S AND BETWEEN 156W AND 169W") widened to a pole-to-pole band. The abbreviations now require a word boundary, and the BETWEEN latitude pair parses as the band it states (16S–22S).
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- The energy predictor and polar management moved from the Signal K metadata's `requires` to `recommends`: the plugin consumes them purely through Signal K paths and the resources API, and the degradation suite proves it briefs, serves and escalates without either (built-in fallback polar, empty energy outlook). `signalk-internet` stays required — the online window drives every fetch gate.
|
|
16
|
+
|
|
17
|
+
## [0.12.3] - 2026-10-16
|
|
18
|
+
|
|
5
19
|
## [0.12.2] - 2026-10-16
|
|
6
20
|
|
|
7
21
|
### Changed
|
|
@@ -9,7 +23,7 @@
|
|
|
9
23
|
|
|
10
24
|
### Fixed
|
|
11
25
|
- The webapp console logged three requests that could never succeed (work doc #40/#30 session report): the "Next" compile pill and the sea-legs day checkpoints called `/api/api/status` and `/api/api/sea-legs` — a stray `api/` segment doubled into the plugin base ever since those two UI pieces shipped, 404ing on every load and silently hiding both features. The URLs now match the registered routes. The critical-hazard banner polled the Signal K REST notifications subtree (`vessels/self/notifications/navigation/briefing/hazards`), which 404s whenever no notification was ever raised on the server; the escalation's active list now also serves from the plugin's own `GET /api/hazards/active` (same data the delta stream carries, empty list when nominal) and the banner polls that.
|
|
12
|
-
- The route briefing had no manual refresh affordance on a fresh cache: the "Update now" button only existed in the no-cached strip and the outdated banner, so the crew couldn't regenerate an up-to-date-looking briefing (e.g. to pick up the throttled payload shape or a fresher forecast) — while conditions-here always carried its card-head button. The tactical view's "Next 24 Hours" card head now carries the same "Update now" in the same place, wired to the route refresh and disabled offline like its here-mode twin.
|
|
26
|
+
- The route briefing had no manual refresh affordance on a fresh cache: the "Update now" button only existed in the no-cached strip and the outdated banner, so the crew couldn't regenerate an up-to-date-looking briefing (e.g. to pick up the throttled payload shape or a fresher forecast) — while conditions-here always carried its card-head button. The tactical view's "Next 24 Hours" card head now carries the same "Update now" in the same place, wired to the route refresh and disabled offline like its here-mode twin. The root also imports the components it instantiates — its connectedCallback runs during customElements.define, so the first renderRoute created an un-upgraded tactical-dashboard whose methods did not exist yet, aborting the boot with `tactical?.setOnline is not a function` on pages that loaded only the root module first.
|
|
13
27
|
|
|
14
28
|
## [0.12.1] - 2026-10-16
|
|
15
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meri-imperiumi/signalk-passage-briefing",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "Offshore passage daily briefing webapp for Signal K",
|
|
5
5
|
"main": "plugin/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"doc/here-brief.png"
|
|
32
32
|
],
|
|
33
33
|
"requires": [
|
|
34
|
-
"@meri-imperiumi/signalk-
|
|
35
|
-
"@meri-imperiumi/signalk-internet",
|
|
36
|
-
"signalk-polar-management"
|
|
34
|
+
"@meri-imperiumi/signalk-internet"
|
|
37
35
|
],
|
|
38
36
|
"recommends": [
|
|
37
|
+
"@meri-imperiumi/signalk-energy-predictor",
|
|
38
|
+
"signalk-polar-management",
|
|
39
39
|
"signalk-watch-schedule",
|
|
40
40
|
"@meri-imperiumi/signalk-ships-time"
|
|
41
41
|
]
|
|
@@ -71,7 +71,7 @@ const RETAINED_SUBJECTS = "ABE";
|
|
|
71
71
|
* leading-dot NWS style (`.WARNINGS.`, `.SYNOPSIS AND FORECAST.`)
|
|
72
72
|
* is tolerated alongside the GMDSS `PART 1` headings. */
|
|
73
73
|
const SECTION_ANCHORS =
|
|
74
|
-
|
|
74
|
+
/^(?:\.?\s*PARTS?\s+\d+\b.*(?:WARNING|SYNOPSIS|FORECAST)\b|\.?\s*(?:WARNINGS?|SYNOPSIS(?:\s+AND\s+FORECAST)?|FORECAST)\s*\.?\s*$)/i;
|
|
75
75
|
|
|
76
76
|
/**
|
|
77
77
|
* Strips transmission routing boilerplate: NAVTEX `ZCZC`/`NNNN`
|
|
@@ -134,6 +134,16 @@ function stripBoilerplate(text) {
|
|
|
134
134
|
// all-caps bulletins, and an unparsed latitude bound widens the
|
|
135
135
|
// area to pole-to-pole
|
|
136
136
|
.replace(/\bO(?=\d)/g, "0")
|
|
137
|
+
// NFFN spells the hemispheres out in cyclone positions ("NEAR
|
|
138
|
+
// 12.1 SOUTH 163.0 WEST", line-wrapped anywhere): contracted to
|
|
139
|
+
// the letter form the coordinate and NEAR parsers expect. Only
|
|
140
|
+
// the paired lat+lon form is rewritten, so prose like "5 SOUTH
|
|
141
|
+
// OF T1" is left alone.
|
|
142
|
+
.replace(
|
|
143
|
+
/\b(\d+(?:\.\d+)?)\s+(NORTH|SOUTH)\s+(\d+(?:\.\d+)?)\s+(EAST|WEST)\b/gi,
|
|
144
|
+
(_text, lat, latWord, lon, lonWord) =>
|
|
145
|
+
`${lat}${latWord[0].toUpperCase()} ${lon}${lonWord[0].toUpperCase()}`,
|
|
146
|
+
)
|
|
137
147
|
.replace(/\n{3,}/g, "\n\n")
|
|
138
148
|
.trim()
|
|
139
149
|
);
|
|
@@ -267,9 +277,14 @@ function isWarningSection(sectionBlocks) {
|
|
|
267
277
|
*/
|
|
268
278
|
function coalesceWarningBlocks(sectionBlocks, features = new Map()) {
|
|
269
279
|
const anchored = [];
|
|
280
|
+
const pointAnchors = [];
|
|
270
281
|
sectionBlocks.forEach((block, i) => {
|
|
271
|
-
|
|
282
|
+
const geometry = extractGeometry(block, features);
|
|
283
|
+
if (geometry != null) {
|
|
272
284
|
anchored.push(i);
|
|
285
|
+
if (geometry.type === "point") {
|
|
286
|
+
pointAnchors.push(i);
|
|
287
|
+
}
|
|
273
288
|
}
|
|
274
289
|
});
|
|
275
290
|
if (anchored.length === 0) {
|
|
@@ -280,6 +295,10 @@ function coalesceWarningBlocks(sectionBlocks, features = new Map()) {
|
|
|
280
295
|
// nearest anchor, ties to the earlier one — a trailer describes the
|
|
281
296
|
// area above it. Coordinate-bearing blocks that place to null stay
|
|
282
297
|
// unowned: they emit as their own blocks, never merged.
|
|
298
|
+
// Quadrant wind-radii lines ("WITHIN 95 NAUTICAL MILES IN SE
|
|
299
|
+
// QUADRANT") describe the storm, not the nearest area: they prefer
|
|
300
|
+
// a declared storm's point anchor so they keep or drop with the
|
|
301
|
+
// warning they belong to.
|
|
283
302
|
const owner = new Map();
|
|
284
303
|
for (const i of anchored) {
|
|
285
304
|
owner.set(i, i);
|
|
@@ -288,7 +307,11 @@ function coalesceWarningBlocks(sectionBlocks, features = new Map()) {
|
|
|
288
307
|
if (owner.has(i) || parseCoordinatePoints(sectionBlocks[i]).length > 0) {
|
|
289
308
|
continue;
|
|
290
309
|
}
|
|
291
|
-
const
|
|
310
|
+
const candidates =
|
|
311
|
+
/QUADRANT/i.test(sectionBlocks[i]) && pointAnchors.length > 0
|
|
312
|
+
? pointAnchors
|
|
313
|
+
: anchored;
|
|
314
|
+
const target = candidates.reduce((best, candidate) =>
|
|
292
315
|
Math.abs(candidate - i) < Math.abs(best - i) ? candidate : best,
|
|
293
316
|
);
|
|
294
317
|
owner.set(i, target);
|
|
@@ -431,16 +454,29 @@ function parseCardinalBounds(text) {
|
|
|
431
454
|
// full word is matched first so the abbreviation cannot swallow it
|
|
432
455
|
// (an unmatched N OF left the band pole-to-pole and a North Pacific
|
|
433
456
|
// synopsis intersected every water on its longitude band).
|
|
434
|
-
const southOf = text.match(
|
|
457
|
+
const southOf = text.match(/\b(?:SOUTH|S)\s+OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
|
|
435
458
|
if (southOf) {
|
|
436
459
|
maxLat = hemisphereDegrees(southOf[1], southOf[2].toUpperCase());
|
|
437
460
|
found = true;
|
|
438
461
|
}
|
|
439
|
-
const northOf = text.match(
|
|
462
|
+
const northOf = text.match(/\b(?:NORTH|N)\s+OF\s+(\d+(?:\.\d+)?)\s*([NS])/i);
|
|
440
463
|
if (northOf) {
|
|
441
464
|
minLat = hemisphereDegrees(northOf[1], northOf[2].toUpperCase());
|
|
442
465
|
found = true;
|
|
443
466
|
}
|
|
467
|
+
// NFFN states the latitude band as a pair — with the occasional
|
|
468
|
+
// stray "OF" ("IN THE AREA BETWEEN OF 16S AND 22S"). Longitude
|
|
469
|
+
// BETWEEN pairs (EW) are matched separately below.
|
|
470
|
+
const latBand = text.match(
|
|
471
|
+
/BETWEEN\s+(?:OF\s+)?(\d+(?:\.\d+)?)\s*([NS])\s+AND\s+(\d+(?:\.\d+)?)\s*([NS])/i,
|
|
472
|
+
);
|
|
473
|
+
if (latBand) {
|
|
474
|
+
const a = hemisphereDegrees(latBand[1], latBand[2].toUpperCase());
|
|
475
|
+
const b = hemisphereDegrees(latBand[3], latBand[4].toUpperCase());
|
|
476
|
+
minLat = Math.max(minLat, Math.min(a, b));
|
|
477
|
+
maxLat = Math.min(maxLat, Math.max(a, b));
|
|
478
|
+
found = true;
|
|
479
|
+
}
|
|
444
480
|
// The equator is a real bound in NWS area headers ("NORTH PACIFIC
|
|
445
481
|
// EQUATOR TO 30N BETWEEN 140W AND 180W"), not a poleward default —
|
|
446
482
|
// without this the area line parses to a pole-to-pole box that
|
|
@@ -606,14 +642,23 @@ function resolveNamedEndpoints(text, features) {
|
|
|
606
642
|
* block's first coordinate pair. Null when the block declares a
|
|
607
643
|
* system but carries no position.
|
|
608
644
|
*
|
|
645
|
+
* The anchored-per-paragraph test (not the whole-block test) keeps
|
|
646
|
+
* coalesced warning blocks working: they lead with the "PART 1 :
|
|
647
|
+
* WARNING STORM WARNING 012" header, and a whole-block anchored test
|
|
648
|
+
* would type the merged block geometry-less — dropping the whole
|
|
649
|
+
* storm warning for any vessel outside the forecast line.
|
|
650
|
+
*
|
|
609
651
|
* @param {string} text - Block text
|
|
610
652
|
* @returns {number[]|null} [lon, lat]
|
|
611
653
|
*/
|
|
612
654
|
function stormDeclarationPoint(text) {
|
|
613
|
-
|
|
655
|
+
const declaring = String(text ?? "")
|
|
656
|
+
.split(/\n\n/)
|
|
657
|
+
.find((paragraph) => STORM_DECLARATION.test(paragraph.trim()));
|
|
658
|
+
if (!declaring) {
|
|
614
659
|
return null;
|
|
615
660
|
}
|
|
616
|
-
const near =
|
|
661
|
+
const near = declaring.match(
|
|
617
662
|
/NEAR\s+(\d+(?:\.\d+)?)\s*([NS])\s+(\d+(?:\.\d+)?)\s*([EW])/i,
|
|
618
663
|
);
|
|
619
664
|
if (near) {
|
|
@@ -622,7 +667,7 @@ function stormDeclarationPoint(text) {
|
|
|
622
667
|
hemisphereDegrees(near[1], near[2].toUpperCase()),
|
|
623
668
|
];
|
|
624
669
|
}
|
|
625
|
-
const points = parseCoordinatePoints(
|
|
670
|
+
const points = parseCoordinatePoints(declaring);
|
|
626
671
|
return points.length > 0 ? points[0] : null;
|
|
627
672
|
}
|
|
628
673
|
|
|
@@ -643,7 +688,10 @@ function stormReferenceGeometry(text, features, bufferNm) {
|
|
|
643
688
|
return null;
|
|
644
689
|
}
|
|
645
690
|
const match = text.match(
|
|
646
|
-
new RegExp(
|
|
691
|
+
new RegExp(
|
|
692
|
+
`\\b(?:OF|AROUND)\\s+(?:(?:SEVERE\\s+)?TROPICAL\\s+(?:CYCLONE|STORM|DEPRESSION|HURRICANE|TYPHOON)\\s+)?(${FEATURE_REFERENCE})\\b`,
|
|
693
|
+
"i",
|
|
694
|
+
),
|
|
647
695
|
);
|
|
648
696
|
if (!match) {
|
|
649
697
|
return null;
|
|
@@ -887,12 +935,13 @@ function composeFeatureBounds(text, features) {
|
|
|
887
935
|
*/
|
|
888
936
|
function extractGeometry(blockText, features = new Map()) {
|
|
889
937
|
// A storm declaration block is its own geometry: the analysed
|
|
890
|
-
// position of the named system, typed "point"
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
938
|
+
// position of the named system, typed "point". Declarations are
|
|
939
|
+
// found per paragraph, so a coalesced block leading with the
|
|
940
|
+
// "PART 1 : WARNING STORM WARNING 012" header still types as its
|
|
941
|
+
// storm's point.
|
|
942
|
+
const stormPoint = stormDeclarationPoint(blockText);
|
|
943
|
+
if (stormPoint) {
|
|
944
|
+
return { type: "point", coordinates: stormPoint };
|
|
896
945
|
}
|
|
897
946
|
const bandMatch = blockText.match(
|
|
898
947
|
/WITHIN\s+(\d+(?:\.\d+)?)\s*(?:NM|NAUTICAL\s+MILES?)\b/i,
|
package/plugin/hazard-alerts.js
CHANGED
|
@@ -39,6 +39,10 @@ const STATE = { emergency: "emergency", error: "error" };
|
|
|
39
39
|
*/
|
|
40
40
|
const MOORED_STATES = new Set(["moored", "anchored"]);
|
|
41
41
|
|
|
42
|
+
/** GDACS alert-level ranks (duplicated from hazard-source.js; this
|
|
43
|
+
* module stays dependency-free). */
|
|
44
|
+
const ALERT_RANK = { green: 0, orange: 1, red: 2 };
|
|
45
|
+
|
|
42
46
|
/**
|
|
43
47
|
* Classifies one refresh's warning stream into notifications.
|
|
44
48
|
*
|
|
@@ -70,15 +74,23 @@ function escalateHazards({
|
|
|
70
74
|
typeof navigationState === "string" && MOORED_STATES.has(navigationState);
|
|
71
75
|
|
|
72
76
|
// Tropical cyclone: GDACS TC at Red or Orange — the compile filters
|
|
73
|
-
// established impact (near vessel or route)
|
|
77
|
+
// established impact (near vessel or route). The notification is
|
|
78
|
+
// marked `latch` (held through source dips, work doc #30) and
|
|
79
|
+
// carries the alert level so an acknowledgement can be level-aware:
|
|
80
|
+
// orange ack silences orange, a later red re-alarms.
|
|
74
81
|
for (const event of gdacsEvents ?? []) {
|
|
75
82
|
if (
|
|
76
83
|
event?.type === "TC" &&
|
|
77
|
-
(event.alertLevel === "red" ||
|
|
84
|
+
(event.alertLevel === "red" ||
|
|
85
|
+
event.alertLevel === "orange" ||
|
|
86
|
+
event.latched === true)
|
|
78
87
|
) {
|
|
79
88
|
notifications.push({
|
|
80
89
|
id: `gdacs-${event.id}`,
|
|
81
90
|
eventId: event.id,
|
|
91
|
+
level: event.alertLevel ?? null,
|
|
92
|
+
distanceNm: event.distanceNm ?? null,
|
|
93
|
+
latch: true,
|
|
82
94
|
state: STATE.emergency,
|
|
83
95
|
message: `Tropical cyclone: ${event.title ?? event.type} — ${event.distanceNm ?? "?"} nm from the vessel`,
|
|
84
96
|
});
|
|
@@ -141,11 +153,99 @@ function escalateHazards({
|
|
|
141
153
|
return notifications;
|
|
142
154
|
}
|
|
143
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Warnability carry-over for latched tropical-cyclone notifications
|
|
158
|
+
* (work doc #30 anti-flap): once a cyclone notification is raised, it
|
|
159
|
+
* stays warnable while the cyclone still exists in the GDACS envelope
|
|
160
|
+
* — even when its alert level dips below the fresh-escalation floor.
|
|
161
|
+
* GDACS recomputes with every model run and borderline systems flap
|
|
162
|
+
* orange/green; the vessel should not re-alarm on each flap. Fresh
|
|
163
|
+
* escalations still need the configured minimum level; ids held
|
|
164
|
+
* active by the hysteresis ride at any level the feed carries.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} params
|
|
167
|
+
* @param {Array<object>} [params.events] - GDACS events filtered at
|
|
168
|
+
* the wide (green) level, distances computed
|
|
169
|
+
* @param {Map<string, object>} [params.previous] - Previously active
|
|
170
|
+
* notifications by id (the hysteresis state)
|
|
171
|
+
* @param {string} [params.minLevel="orange"] - Fresh-escalation floor
|
|
172
|
+
* @returns {Array<object>} Events to escalate this cycle
|
|
173
|
+
*/
|
|
174
|
+
function retainLatched({ events, previous, minLevel = "orange" }) {
|
|
175
|
+
const minRank = ALERT_RANK[String(minLevel).toLowerCase()] ?? 1;
|
|
176
|
+
const latched = new Set(
|
|
177
|
+
[...(previous ?? new Map())]
|
|
178
|
+
.filter(([, notification]) => notification?.latch === true)
|
|
179
|
+
.map(([id]) => id),
|
|
180
|
+
);
|
|
181
|
+
const kept = [];
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
for (const event of events ?? []) {
|
|
184
|
+
const id = `gdacs-${event?.id}`;
|
|
185
|
+
if (seen.has(id)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
seen.add(id);
|
|
189
|
+
const rank = ALERT_RANK[String(event?.alertLevel ?? "").toLowerCase()];
|
|
190
|
+
if (rank >= minRank) {
|
|
191
|
+
kept.push(event);
|
|
192
|
+
} else if (latched.has(id)) {
|
|
193
|
+
kept.push({ ...event, latched: true });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return kept;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether one escalation may raise (or re-raise) its notification,
|
|
201
|
+
* given the recorded acknowledgement (work doc #30 anti-flap): an ack
|
|
202
|
+
* silences the same alert level and below — a strictly higher level
|
|
203
|
+
* (orange acked, red now) escalates through and re-alarms. So does a
|
|
204
|
+
* cyclone that has closed to half the distance it was at ack time:
|
|
205
|
+
* the situation materially changed even at the same level.
|
|
206
|
+
* Notifications without a level (bulletin direct-hits, CAP) suppress
|
|
207
|
+
* on any ack, as before.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} params
|
|
210
|
+
* @param {object} params.notification - The current escalation
|
|
211
|
+
* @param {{level: string|null, distanceNm: number|null,
|
|
212
|
+
* at: string}|undefined} params.ack - The recorded acknowledgement,
|
|
213
|
+
* if any
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
function shouldRaise({ notification, ack }) {
|
|
217
|
+
if (!ack) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
const level = String(notification?.level ?? "").toLowerCase();
|
|
221
|
+
if (!(level in ALERT_RANK)) {
|
|
222
|
+
return false; // Level-less notification: any ack suppresses
|
|
223
|
+
}
|
|
224
|
+
const ackRank = ALERT_RANK[String(ack?.level ?? "").toLowerCase()];
|
|
225
|
+
if (ALERT_RANK[level] > (ackRank ?? -1)) {
|
|
226
|
+
return true; // Escalation: orange acked, red now
|
|
227
|
+
}
|
|
228
|
+
const ackDistance = Number(ack?.distanceNm);
|
|
229
|
+
const distance = Number(notification?.distanceNm);
|
|
230
|
+
if (
|
|
231
|
+
Number.isFinite(ackDistance) &&
|
|
232
|
+
ackDistance > 0 &&
|
|
233
|
+
Number.isFinite(distance) &&
|
|
234
|
+
distance <= ackDistance / 2
|
|
235
|
+
) {
|
|
236
|
+
return true; // Closed to half the acked distance: materially closer
|
|
237
|
+
}
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
144
241
|
/**
|
|
145
242
|
* Suppression hysteresis (work doc #30): a notification that dips out
|
|
146
243
|
* of the matrix for one refresh cycle does not flap the alarm — it
|
|
147
244
|
* clears only after `missesToClear` consecutive cycles without
|
|
148
|
-
* qualifying. Pure function over the previous active set.
|
|
245
|
+
* qualifying. Pure function over the previous active set. Latched
|
|
246
|
+
* notifications (tropical cyclones) clear only after
|
|
247
|
+
* `latchMissesToClear` — GDACS dips are minutes long, and a clear
|
|
248
|
+
* followed by a re-raise re-alarms the crew for the same storm.
|
|
149
249
|
*
|
|
150
250
|
* @param {object} params
|
|
151
251
|
* @param {Map<string, object>} params.previous - Previously active
|
|
@@ -154,10 +254,18 @@ function escalateHazards({
|
|
|
154
254
|
* @param {Map<string, number>} [params.missCounts] - Per-id
|
|
155
255
|
* consecutive miss counters (mutated)
|
|
156
256
|
* @param {number} [params.missesToClear=2]
|
|
257
|
+
* @param {number} [params.latchMissesToClear=60] - Cycles a latched
|
|
258
|
+
* notification survives before clearing
|
|
157
259
|
* @returns {{active: Map<string, object>, cleared: string[]}} The new
|
|
158
260
|
* active set and the ids that cleared this cycle
|
|
159
261
|
*/
|
|
160
|
-
function applyHysteresis({
|
|
262
|
+
function applyHysteresis({
|
|
263
|
+
previous,
|
|
264
|
+
current,
|
|
265
|
+
missCounts,
|
|
266
|
+
missesToClear = 2,
|
|
267
|
+
latchMissesToClear = 60,
|
|
268
|
+
}) {
|
|
161
269
|
const misses = missCounts ?? new Map();
|
|
162
270
|
const active = new Map();
|
|
163
271
|
const cleared = [];
|
|
@@ -167,14 +275,17 @@ function applyHysteresis({ previous, current, missCounts, missesToClear = 2 }) {
|
|
|
167
275
|
active.set(notification.id, notification);
|
|
168
276
|
}
|
|
169
277
|
// Previously active ids that dipped out: carry them through the
|
|
170
|
-
// miss grace period (borderline geometry must not flap the alarm)
|
|
278
|
+
// miss grace period (borderline geometry must not flap the alarm);
|
|
279
|
+
// latched notifications earn the long grace
|
|
171
280
|
for (const [id, notification] of previous ?? []) {
|
|
172
281
|
if (currentIds.has(id)) {
|
|
173
282
|
continue;
|
|
174
283
|
}
|
|
284
|
+
const threshold =
|
|
285
|
+
notification?.latch === true ? latchMissesToClear : missesToClear;
|
|
175
286
|
const count = (misses.get(id) ?? 0) + 1;
|
|
176
287
|
misses.set(id, count);
|
|
177
|
-
if (count >=
|
|
288
|
+
if (count >= threshold) {
|
|
178
289
|
cleared.push(id);
|
|
179
290
|
misses.delete(id);
|
|
180
291
|
} else {
|
|
@@ -191,6 +302,9 @@ function applyHysteresis({ previous, current, missCounts, missesToClear = 2 }) {
|
|
|
191
302
|
module.exports = {
|
|
192
303
|
STATE,
|
|
193
304
|
MOORED_STATES,
|
|
305
|
+
ALERT_RANK,
|
|
194
306
|
escalateHazards,
|
|
307
|
+
retainLatched,
|
|
308
|
+
shouldRaise,
|
|
195
309
|
applyHysteresis,
|
|
196
310
|
};
|
package/plugin/hazard-source.js
CHANGED
|
@@ -361,39 +361,53 @@ function filterHazards({
|
|
|
361
361
|
if (rank == null || rank < minRank) {
|
|
362
362
|
continue;
|
|
363
363
|
}
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
364
|
+
// Either envelope admits: the vessel's ahead-radius or the route
|
|
365
|
+
// corridor. The old best-reference-wins rule let the closer
|
|
366
|
+
// reference tighten the envelope — a compile switching the
|
|
367
|
+
// exposure reference (here mode admits at 1000 nm from the
|
|
368
|
+
// vessel, route mode at 500 nm off the nearest sample) tipped a
|
|
369
|
+
// stationary cyclone out of the matrix and back, flapping the
|
|
370
|
+
// notification without the storm moving an inch.
|
|
371
|
+
const vesselRef =
|
|
372
|
+
vessel && Number.isFinite(vessel.lat) && Number.isFinite(vessel.lon)
|
|
373
|
+
? {
|
|
374
|
+
distanceNm: distanceNm(
|
|
375
|
+
vessel.lat,
|
|
376
|
+
vessel.lon,
|
|
377
|
+
event.lat,
|
|
378
|
+
event.lon,
|
|
379
|
+
),
|
|
380
|
+
bearingDeg: bearingDeg(
|
|
381
|
+
vessel.lat,
|
|
382
|
+
vessel.lon,
|
|
383
|
+
event.lat,
|
|
384
|
+
event.lon,
|
|
385
|
+
),
|
|
386
|
+
}
|
|
387
|
+
: null;
|
|
388
|
+
let routeRef = null;
|
|
374
389
|
for (const point of route) {
|
|
375
390
|
const d = distanceNm(point.lat, point.lon, event.lat, event.lon);
|
|
376
|
-
if (
|
|
377
|
-
|
|
391
|
+
if (routeRef === null || d < routeRef.distanceNm) {
|
|
392
|
+
routeRef = {
|
|
378
393
|
distanceNm: d,
|
|
379
394
|
bearingDeg: bearingDeg(point.lat, point.lon, event.lat, event.lon),
|
|
380
|
-
from: "route",
|
|
381
395
|
};
|
|
382
396
|
}
|
|
383
397
|
}
|
|
384
|
-
if (!best) {
|
|
385
|
-
continue;
|
|
386
|
-
}
|
|
387
398
|
const within =
|
|
388
|
-
(
|
|
389
|
-
(
|
|
399
|
+
(vesselRef !== null && vesselRef.distanceNm <= aheadRadiusNm) ||
|
|
400
|
+
(routeRef !== null && routeRef.distanceNm <= offRouteRadiusNm);
|
|
390
401
|
if (!within) {
|
|
391
402
|
continue;
|
|
392
403
|
}
|
|
404
|
+
// Report vessel-centric distance and bearing when the vessel is
|
|
405
|
+
// known — the crew thinks in distance from the boat
|
|
406
|
+
const ref = vesselRef ?? routeRef;
|
|
393
407
|
surviving.push({
|
|
394
408
|
...event,
|
|
395
|
-
distanceNm: Math.round(
|
|
396
|
-
bearingDeg: Math.round(
|
|
409
|
+
distanceNm: Math.round(ref.distanceNm * 10) / 10,
|
|
410
|
+
bearingDeg: Math.round(ref.bearingDeg),
|
|
397
411
|
});
|
|
398
412
|
}
|
|
399
413
|
return surviving.sort((a, b) =>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk acknowledgement state for critical hazard notifications
|
|
3
|
+
* (work doc #30 anti-flap): the ack must survive plugin and server
|
|
4
|
+
* restarts, or every restart re-alarms the crew for a cyclone they
|
|
5
|
+
* already acknowledged. Acks are keyed by notification id and record
|
|
6
|
+
* the alert level they silenced, so a later escalation to a strictly
|
|
7
|
+
* higher level (orange → red) re-alarms while same-level refreshes
|
|
8
|
+
* stay quiet.
|
|
9
|
+
*
|
|
10
|
+
* Entries age out after a week: a cyclone acknowledged today should
|
|
11
|
+
* still be silent next restart, but ack memory must not grow
|
|
12
|
+
* unbounded, and a week-old hazard id is stale by any measure.
|
|
13
|
+
*
|
|
14
|
+
* @file hazard-state.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const { readFileSync, renameSync, writeFileSync } = require("node:fs");
|
|
18
|
+
const { join } = require("node:path");
|
|
19
|
+
|
|
20
|
+
const ACK_FILE = "hazards-state.json";
|
|
21
|
+
const ACK_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Loads the acknowledgement map, pruning stale entries.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} dataDir - Plugin data directory
|
|
27
|
+
* @returns {Map<string, {level: string|null, at: string}>} Ack by
|
|
28
|
+
* notification id; empty when no state exists yet or the file is
|
|
29
|
+
* corrupt (a crashed read must not take the escalation down —
|
|
30
|
+
* re-alarms are annoying, a dead plugin is worse)
|
|
31
|
+
*/
|
|
32
|
+
function loadHazardAcks(dataDir) {
|
|
33
|
+
const acks = new Map();
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(readFileSync(join(dataDir, ACK_FILE), "utf8"));
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [id, record] of Object.entries(raw?.acked ?? {})) {
|
|
38
|
+
const at = new Date(record?.at).getTime();
|
|
39
|
+
if (Number.isFinite(at) && now - at < ACK_TTL_MS) {
|
|
40
|
+
acks.set(id, record);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// No state yet (first boot) or unreadable file: start empty
|
|
45
|
+
}
|
|
46
|
+
return acks;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Saves the acknowledgement map, pruning stale entries and writing
|
|
51
|
+
* atomically (tmp file + rename, so a crash mid-write cannot leave a
|
|
52
|
+
* half-read JSON behind).
|
|
53
|
+
*
|
|
54
|
+
* @param {string} dataDir - Plugin data directory
|
|
55
|
+
* @param {Map<string, {level: string|null, at: string}>} acks
|
|
56
|
+
* @returns {void}
|
|
57
|
+
*/
|
|
58
|
+
function saveHazardAcks(dataDir, acks) {
|
|
59
|
+
const acked = {};
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
for (const [id, record] of acks ?? []) {
|
|
62
|
+
const at = new Date(record?.at).getTime();
|
|
63
|
+
if (Number.isFinite(at) && now - at < ACK_TTL_MS) {
|
|
64
|
+
acked[id] = record;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const path = join(dataDir, ACK_FILE);
|
|
68
|
+
const tmp = `${path}.tmp`;
|
|
69
|
+
writeFileSync(tmp, `${JSON.stringify({ acked }, null, 2)}\n`);
|
|
70
|
+
renameSync(tmp, path);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { loadHazardAcks, saveHazardAcks, ACK_FILE, ACK_TTL_MS };
|