@meri-imperiumi/signalk-passage-briefing 0.5.2 → 0.7.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/CHANGELOG.md +24 -1
- package/README.md +10 -0
- package/SPEC.md +25 -0
- package/package.json +1 -1
- package/plugin/bulletin-engine.js +276 -0
- package/plugin/cap-source.js +489 -0
- package/plugin/hazard-alerts.js +196 -0
- package/plugin/history-backfill.js +74 -5
- package/plugin/index.js +492 -54
- package/plugin/notes-publisher.js +103 -0
- package/plugin/sqlite-db.js +27 -0
- package/public/components/backfill-controls.js +3 -0
- package/public/components/comfort-info.js +17 -0
- package/public/components/conditions-here.js +50 -0
- package/public/components/horizon-sparkline.js +4 -4
- package/public/components/models.mjs +115 -9
- package/public/components/passage-outlook.js +160 -5
- package/public/components/passage-timeline.js +12 -0
- package/public/components/sk-api.js +73 -1
- package/public/components/strategic-outlook.js +88 -2
- package/public/components/tactical-dashboard.js +124 -2
- package/public/route-sim.mjs +288 -13
- package/public/sereno-physics.mjs +71 -0
- package/public/tack-gybe.js +10 -4
- package/public/vendor/tz-lookup/LICENSE +116 -0
- package/public/vendor/tz-lookup/tz-lookup.mjs +8 -0
- package/tests/bulletin-engine.test.js +76 -12
- package/tests/cap-source.test.js +272 -0
- package/tests/fixtures/bulletins/fzpn02-kwbc-hsf-epi-hurricane.txt +252 -0
- package/tests/fixtures/bulletins/wtpz23-knhc-tcmep3-r28.txt +82 -0
- package/tests/fixtures/cap/fah-rss-sample.xml +23 -0
- package/tests/fixtures/cap/phebcap-sample.xml +86 -0
- package/tests/fixtures/cap/tsunami-polygon-alert.xml +26 -0
- package/tests/hazard-alerts.test.js +161 -0
- package/tests/history-backfill.test.js +146 -6
- package/tests/plugin.test.js +208 -9
- package/tests/route-sim.test.js +305 -0
- package/tests/sereno-physics.test.js +22 -0
- package/tests/source-status.test.js +1 -1
- package/tests/sqlite-db.test.js +80 -0
- package/tests/tack-gybe.test.js +12 -0
- package/tests/webapp.test.js +191 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-10-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- CAP (Common Alerting Protocol) source (work doc #24): official structured warnings as a second ingestion channel alongside the free-text bulletin pipeline. Feeds: direct CAP documents (the NWS Pacific tsunami center's PHEBCAP.xml is the default) and Filtered Alert Hub aggregations whose RSS items link to per-alert CAP documents (newest followed, bounded). The parser maps CAP's own geometry — polygons with the latitude-first order flipped (the classic CAP integration trap, regression-tested), circles approximated, zero-radius circles as placeable points — onto the bulletin-engine's geometry types and reuses its track-intersection tests. Severity gate (default Severe and above), deterministic `<expires>` expiry, identifier-based dedup with repolls updating in place. Surviving alerts ride the payload as `capAlerts`, render as an "Official Alerts" card in the tactical, strategic and conditions-here views — visually separate from the METAREA text (severity-coloured, expires stamp, instruction text attached), with an explicit "No official alerts in effect for these waters" empty state so silence is distinguishable from a dead feed — and publish as chart notes with the native polygon carried schema-safely in the note properties (the SK Note schema has no feature field) with centroid placement and scoped expiry. Each configured feed is a row in the #23 data source checklist, success and failure alike. Verified against the live PHEBCAP document and the live Filtered Alert Hub unfiltered feed.
|
|
9
|
+
- Critical hazard notifications (work doc #30): an escalation layer over the warning stream — GDACS events, CAP alerts, bulletin blocks — publishes Signal K notifications for the small subset that qualifies, so the right warnings interrupt dinner instead of waiting for the next glance at the plotter. The matrix requires both a hazard condition and an exposure condition: a GDACS TC at Red/Orange or a CAP hurricane/typhoon event escalates to `emergency` (impact near vessel or route, established by the compile-time filters); a CAP tsunami warning escalates to `emergency` while anchored or moored (a roadstead is shallow by definition) and to `error` when a live depth sounding exists (a transducer reading means shallow) — a tsunami observed in deep open water with no sounding stays a passive event; a bulletin gale/storm block escalates to `error` when its native geometry contains the vessel position *now*. Notifications publish at `notifications.navigation.briefing.hazards.<eventId>` via the standard delta path so every Signal K client sees them; two-cycle hysteresis prevents borderline flapping; acknowledged events are not re-raised until they leave the matrix and return; expiry clears deterministically. The webapp renders an interrupting full-width banner with an acknowledge button on both screens and polls the notifications subtree — an ack returns the notification to nominal for every connected client. Depth presence rides the watched-path subscription. The severity tiers are ordered so escalations stay rare enough to always be taken seriously.
|
|
10
|
+
|
|
11
|
+
## [0.6.0] - 2026-10-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Timezone change events along the route (work doc #19): the briefing now says when the clock itself changes — the moment a crew sets watches and re-plans arrival in local time. Offshore, the simulated track detects crossings of the 15° zone meridians (short-arc direction from the step's longitude delta, so the antimeridian reads "180°" either way) and the unified timeline renders them as `time` events (◷ glyph): "Crossing 165°E — solar time 1 h ahead/behind — clock change due", advising but never asserting what the crew does with the clock. Crossings inside territorial waters stay quiet — the local zone governs there — using distance stints folded from the plugin's enter/leave transitions. Territorial transitions (work doc #17) are now timezone-annotated: the IANA zone at the crossing point comes from vendored tz-lookup (CC0, ~72 KB single ES module under `public/vendor/tz-lookup/` — resolved by position, which sidesteps the multi-zone-country problem entirely, and consistent with signalk-ships-time's answer) and its UTC offset at the crossing instant from the platform's `Intl` database (DST handled, no dependency); `mergeTimeline` adds the "time zone UTC+13" line when the crossing's offset differs from the vessel's current zone, or when none is published. Stamps themselves stay in the current ship's zone.
|
|
16
|
+
- No-sails semantics: slatting and survival regimes (work doc #26). On a cruising boat a logged `NO_SAILS` means one of three things the old pipeline could not tell apart — the slatting regime (apparent wind too low to keep flow attached: canvas down so it stops banging, the engine drives), the survival regime (wind very high: nothing set, nobody motors into it) — or a propulsion/non-passage state (motor-sailing nights, anchorage entries). The learning pipeline now enforces that reading. The regimes are defined in the physics module: `SAILS_FILL_MIN_AWS_KNOTS` (7 kt apparent, mirroring the upwind slatting gate in apparent rather than true wind — motoring into light air is exactly when canvas slats) and `SAILS_MAX_AWS_KNOTS` (45 kt apparent — Beaufort 9 territory, deliberately decoupled from the comfort bands whose 33 kt line marks seasickness, not the rig decision; the vessel carries working canvas standing well into the forties), with `noSailsRegime()` answering slatting / survival / null. The learning gate folds a `NO_SAILS` observation into a rig bin only when its wind window justifies it — the window's average TWS below the point-of-sail slatting gates (7 kt upwind, 12 kt downwind, from the comfort model) or its peak gust at the survival floor; a mid-scale bare-poles observation still caches its wind window (idempotency intact) and stays in the logbook record, but never wins the bin, and the backfill counts it in a new `skippedNoSails` the UI reports ("7 bare-poles entries kept out of the matrix"). The simulation only accepts a `NO_SAILS` suggestion when the step's real apparent wind (boat speed and heading included) sits in a regime, so already-poisoned learned cells cannot depower a passage. The drive under an accepted canvas-down follows one shared rule for rows and events: survival — no drive, the water moves the boat, and the timeline names the decision "Storm tactics" rather than claiming a tactic (heave to, drogue, run off — proper storm tactics are their own work document, #27); becalmed with drift mode enabled — drift on the current alone; otherwise the engine pushes at motor speed with honest motor hours and fuel. A plan holding `NO_SAILS` no longer makes phantom polar speed with zero motor hours. The one-time migration on plugin start clears the learned matrix, its wind-history cache and the recorded events (guarded by `PRAGMA user_version`, logbook files untouched) so the next backfill rebuilds every bin under the new rules — the recency rule had let single bare-poles entries erase a bin's real rig, and that loss is unrecoverable from the matrix alone; empty bins degrade safely to "keep the current rig". **Upgrading crews must run the Logbook Backfill once after starting this version** (the webapp card, or `POST /plugins/.../api/backfill`) — until then the learned plan is empty and the simulation keeps whatever rig the plan already had.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Slatting has a name wherever the comfort tier shows: timeline details at sail changes and sparkline titles read "slatting" instead of the escalated "rough" — a 10 kn afternoon in a residual swell was rendering as weather it isn't, sending someone looking for wind that isn't there. The comfort explainer (ℹ️) carries a SLATTING entry with its thresholds ("wind < 7–12 kn by point of sail · swell ≥ 0.6 m").
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Phone timeline layout: the when-gutter (relative hours + date stamp + moon glyph) squeezed the event text to the side on narrow screens. On phone (below the app's 700 px layout breakpoint) each entry now renders on two lines — the date/time gutter on top at full width, glyph and event data beneath with the whole row's width available. Desktop layout unchanged.
|
|
25
|
+
- No more tacks and gybes with canvas down: the maneuver detector's sailing-step test now requires the plan's rig up — a course change under power or carried by the current is not a tack or gybe. This closes a real leak: drift rows carry `motoring: false` and can hold current-driven SOG above the sailing threshold, previously qualifying as sailing steps.
|
|
26
|
+
|
|
5
27
|
## [0.5.2] - 2026-10-04
|
|
6
28
|
|
|
7
29
|
### Fixed
|
|
@@ -26,7 +48,8 @@
|
|
|
26
48
|
## [0.5.0] - 2026-10-03
|
|
27
49
|
|
|
28
50
|
### Added
|
|
29
|
-
-
|
|
51
|
+
- The plan anchors to actual route progress (work doc #28, compile-time trim): the briefing is forward-looking — when the course provider reports the vessel navigating the briefed route (`navigation.course.activeRoute` with `pointIndex`, verified against `@signalk/course-provider`'s published shape, `reverse` honored), the plan starts where the boat is and sails to the point it is heading for, then the remaining points. Sailed legs drop out; ETA, comfort, sail work, lines of interest, landfall and territorial waters all describe the remaining passage, and events in already-sailed water disappear naturally. The payload stamps the trim point (`trimmedFromNm`, the boat's distance along the original plan); no progress (or a briefing for a non-active route) serves the full plan exactly as before.
|
|
52
|
+
- Daylight-anchored departure time (work doc #15): while the boat is moored, the whole forecast schedule anchors to a realistic departure instead of "now" — nobody casts off at 02:40 because the forecast said so. Auto mode: underway sails from now; night at the start position waits for next first light (civil dawn, −6° sun altitude at the route's first waypoint); day adds the prep delay (1.5 h, configurable 0.5–3) unless the sun would set within it, in which case the anchor is the next dawn — never an immediate departure right before dark (the recorded decision). The pure helper (`assumedDepartureTime` in sereno-physics) detects a rising sun crossing with 5-minute steps bounded at 18 h and states what it assumed (`reason`), including a polar `no_dawn` fallback. The tactical and strategic views share a departure control (Auto / Now / First light / +1 h / +2 h / custom) whose manual choice overrides auto until it is put back; the root owns the state, re-checks the anchor every 10 minutes, and re-simulates only when the anchor moved more than 10 minutes so the view doesn't flap around dawn. The tactical chip states the anchor ("First light 10-05 06:00 +13"), the strategic ETA header says "Assumed departure first light …" ("Underway — from now" when sailing), sparkline titles read wall-clock stamps while shifted, and a delayed departure appears in the timeline as a ⚓ event ("Departure at first light" / "Departure after prep"). The served payload carries the same `departure` metadata so external consumers see numbers consistent with the auto assumption. While underway the plan keeps advancing: navigation.state rides the live stream, so a moored → underway flip re-anchors to now immediately and the 10-minute check keeps the schedule fresh. SPEC §2.1/§3.1 updated.
|
|
30
53
|
- Energy forecast consumption (work doc #10): the briefing finally powers its energy views — in the predictor's own terms. signalk-energy-predictor's hourly forecast (`electrical.energy.prediction.forecast.hourly`, the whole 48 h series as one delta value) and its outlook paths (`status`, `net`, `surplus` + window, `timeToEmpty`) are subscribed like any other Signal K path; the hourly series adapts into the frozen `energyHourly` consumer contract at compile time (the contract's `solarWh` carries total ideal generation: solar + wind + hydro + alternator) and rides the briefing payload so the offline hours re-derive from the cache. The 24 h solar-yield strip and the deficit banner work for the first time; the conditions-here view gains an "Energy 24h" line (the passage strip's here-mode sibling at SOG 0). New in the timeline: the predictor's own energy events — **surplus** is forecast curtailment (battery full while yield continues; the event carries the curtailed Wh and its window, "run opportunistic loads"), **deficit** and **critical** come from the outlook status (the event carries the 24 h net and, when the trajectory names one, the depletion time) — no independent re-derivation with invented thresholds.
|
|
31
54
|
- Territorial waters transitions along the route (work doc #17): the briefing now says where the route crosses the boundaries that change the practical picture on board — entering or leaving a country's internal, archipelagic or 12 NM waters (the metered-ocean data boundary, customs and discharge rules). Built on `@openwaters/maritime-zones` (MIT) over the Marine Regions Maritime Boundaries Geodatabase (VLIZ, CC-BY 4.0 — the "not for navigation, no legal value" disclaimer rides the payload and renders wherever zones do). Corridor tiles (about 8 MB for a 900 nm crossing) download during the online window into the plugin data directory and answer offline afterwards; the boundary walk resamples the route at 1 nm so short territorial hops are not missed. The route simulation timestamps each crossing against the passage schedule, the unified timeline renders enter/leave events (leave events carry the "ocean data rules beyond this point" connectivity note), and here mode reports the waters the vessel sits in right now in the conditions view. Missing tiles, older Node (the package needs ≥ 24, the plugin allows 22.5) or a failed download degrade to no zone data — never a failed briefing. — entering or leaving a country's internal, archipelagic or 12 NM waters (the metered-ocean data boundary, customs and discharge rules). Built on `@openwaters/maritime-zones` (MIT) over the Marine Regions Maritime Boundaries Geodatabase (VLIZ, CC-BY 4.0 — the "not for navigation, no legal value" disclaimer rides the payload and renders wherever zones do). Corridor tiles (about 8 MB for a 900 nm crossing) download during the online window into the plugin data directory and answer offline afterwards; the boundary walk resamples the route at 1 nm so short territorial hops are not missed. The route simulation timestamps each crossing against the passage schedule, the unified timeline renders enter/leave events (leave events carry the "ocean data rules beyond this point" connectivity note), and here mode reports the waters the vessel sits in right now in the conditions view. Missing tiles, older Node (the package needs ≥ 24, the plugin allows 22.5) or a failed download degrade to no zone data — never a failed briefing.
|
|
32
55
|
- GDACS hazard events also surface in the conditions-here view: earthquakes, cyclones and floods near the vessel are critical while stationary, so the here view lists them (Red events highlighted) alongside the passage timeline.
|
package/README.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
Offshore passage daily briefing webapp for Signal K: a plugin that plans and reviews passages for a cruising sailing vessel.
|
|
4
4
|
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Simulates the passage hourly from the boat's actual position: speed from the vessel's polar, current added, ETA percentiles for the remaining route
|
|
8
|
+
- Recommends sail changes hour by hour from what the crew actually did, learned from the electronic logbook
|
|
9
|
+
- Rates every hour on the Sereno comfort scale and flags when light air in a residual swell will be worse than a gale
|
|
10
|
+
- Renders one unified timeline: sail work, tacks and gybes, clock changes, territorial waters, official alerts, sky events and hazards in order
|
|
11
|
+
- Fetches bulletins, official structured alerts, global disaster events and synoptic charts for the waters the route actually crosses
|
|
12
|
+
- Keeps working offline: everything fetches in the online window and survives the other 23 hours from the cache
|
|
13
|
+
- Shows a mini summary on the chart plotter and learns from every logbook entry the crew writes
|
|
14
|
+
|
|
5
15
|
The plugin fetches weather along the planned route — from the server's Weather API when a provider answers, Open-Meteo otherwise; fetched online, or via a GRIB/text spool when offline offshore. It runs a step-forward isochrone simulation with a monohull comfort model, learns the crew's sail preferences from the electronic logbook, and serves a two-screen webapp: a 24-hour tactical dashboard and a strategic passage summary. See [SPEC.md](SPEC.md) for the full design.
|
|
6
16
|
|
|
7
17
|
Part of the Lille Ø offshore suite, alongside [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-predictator) and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook).
|
package/SPEC.md
CHANGED
|
@@ -253,6 +253,31 @@ interface UnifiedWeatherPayload {
|
|
|
253
253
|
time: string; // ISO — the schedule's anchor instant
|
|
254
254
|
reason: string; // 'underway' | 'next_dawn' | 'daylight_prep' | 'no_dawn' | 'manual'
|
|
255
255
|
};
|
|
256
|
+
trimmedFromNm?: number; // when the plan trimmed to route progress (work doc #28):
|
|
257
|
+
// the boat's distance along the original plan; the
|
|
258
|
+
// payload's distances count from the boat
|
|
259
|
+
capAlerts?: {
|
|
260
|
+
id: string; // identifier + sent dedup key
|
|
261
|
+
identifier: string; // CAP identifier
|
|
262
|
+
sent: string | null; // ISO issue instant
|
|
263
|
+
msgType: string | null; // 'Alert' | 'Update' | 'Cancel' | ...
|
|
264
|
+
senderName: string | null;
|
|
265
|
+
event: string | null; // e.g. 'Tsunami Warning'
|
|
266
|
+
severity: string | null; // 'extreme' | 'severe' | 'moderate' | 'minor'
|
|
267
|
+
urgency: string | null;
|
|
268
|
+
certainty: string | null;
|
|
269
|
+
effective: string | null;
|
|
270
|
+
expires: string | null; // hard expiry — deterministic drop
|
|
271
|
+
headline: string | null;
|
|
272
|
+
description: string | null;
|
|
273
|
+
instruction: string | null;
|
|
274
|
+
web: string | null;
|
|
275
|
+
areaDesc: string | null;
|
|
276
|
+
geometry: { // native CAP geometry, mapped to the
|
|
277
|
+
type: 'polygon' | 'bbox'; // bulletin-engine types (circle → polygon)
|
|
278
|
+
coordinates: any;
|
|
279
|
+
};
|
|
280
|
+
}[]; // CAP alerts near the vessel or route (work doc #24)
|
|
256
281
|
}
|
|
257
282
|
|
|
258
283
|
interface TimeStepForecast {
|
package/package.json
CHANGED
|
@@ -877,6 +877,236 @@ function extractIssuer(text) {
|
|
|
877
877
|
* source, blocks: [{text, subject, geometryType, source}]}` — null
|
|
878
878
|
* when the whole message is discarded by the subject filter
|
|
879
879
|
*/
|
|
880
|
+
/**
|
|
881
|
+
* Builds a quadrant-arc ring around a storm center (work doc #21):
|
|
882
|
+
* walking bearings 0..360°, each 90° quadrant carries its own radius
|
|
883
|
+
* (NE/SE/SW/NW), the four arcs join into one closed ring. A zero or
|
|
884
|
+
* missing radius collapses that quadrant's arc onto the center — the
|
|
885
|
+
* NHC "0SW" case — without breaking the ring. Points are [lon, lat].
|
|
886
|
+
*
|
|
887
|
+
* @param {object} params
|
|
888
|
+
* @param {number} params.lat - Center latitude
|
|
889
|
+
* @param {number} params.lon - Center longitude
|
|
890
|
+
* @param {{ne: number, se: number, sw: number, nw: number}} params.radiiNm
|
|
891
|
+
* @returns {number[][]|null} Closed ring, null when every radius is
|
|
892
|
+
* zero (no area to draw)
|
|
893
|
+
*/
|
|
894
|
+
function quadrantRing({ lat, lon, radiiNm }) {
|
|
895
|
+
const radii = [
|
|
896
|
+
radiiNm?.ne ?? 0,
|
|
897
|
+
radiiNm?.se ?? 0,
|
|
898
|
+
radiiNm?.sw ?? 0,
|
|
899
|
+
radiiNm?.nw ?? 0,
|
|
900
|
+
];
|
|
901
|
+
if (radii.every((r) => !(r > 0))) {
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
const toRad = Math.PI / 180;
|
|
905
|
+
const ring = [];
|
|
906
|
+
for (let bearing = 0; bearing < 360; bearing += 5) {
|
|
907
|
+
const quadrant = Math.floor(bearing / 90) % 4;
|
|
908
|
+
const radiusNm = radii[quadrant];
|
|
909
|
+
if (!(radiusNm > 0)) {
|
|
910
|
+
ring.push([lon, lat]); // Collapsed quadrant: touch the center
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const distanceRad = radiusNm / 3440.065;
|
|
914
|
+
const bearingRad = bearing * toRad;
|
|
915
|
+
const latRad = lat * toRad;
|
|
916
|
+
const lat2 = Math.asin(
|
|
917
|
+
Math.sin(latRad) * Math.cos(distanceRad) +
|
|
918
|
+
Math.cos(latRad) * Math.sin(distanceRad) * Math.cos(bearingRad),
|
|
919
|
+
);
|
|
920
|
+
const lon2 =
|
|
921
|
+
lon * toRad +
|
|
922
|
+
Math.atan2(
|
|
923
|
+
Math.sin(bearingRad) * Math.sin(distanceRad) * Math.cos(latRad),
|
|
924
|
+
Math.cos(distanceRad) - Math.sin(latRad) * Math.sin(lat2),
|
|
925
|
+
);
|
|
926
|
+
ring.push([
|
|
927
|
+
Math.round((((lon2 / toRad + 540) % 360) - 180) * 100) / 100,
|
|
928
|
+
Math.round((lat2 / toRad) * 100) / 100,
|
|
929
|
+
]);
|
|
930
|
+
}
|
|
931
|
+
ring.push([...ring[0]]);
|
|
932
|
+
return { type: "polygon", coordinates: ring };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Severity label for the advisory family, from the max wind threshold
|
|
937
|
+
* present (work doc #21): 64 KT and above is hurricane force, 48–63
|
|
938
|
+
* storm force, 34–47 gale.
|
|
939
|
+
*
|
|
940
|
+
* @param {number} maxWindKt
|
|
941
|
+
* @returns {string|null}
|
|
942
|
+
*/
|
|
943
|
+
function advisorySeverityLabel(maxWindKt) {
|
|
944
|
+
if (!(maxWindKt > 0)) {
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
if (maxWindKt >= 64) {
|
|
948
|
+
return "HURRICANE FORCE";
|
|
949
|
+
}
|
|
950
|
+
if (maxWindKt >= 48) {
|
|
951
|
+
return "STORM FORCE";
|
|
952
|
+
}
|
|
953
|
+
if (maxWindKt >= 34) {
|
|
954
|
+
return "GALE";
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Parses an NHC tropical-cyclone FORECAST/ADVISORY (the WTPZ/TCM
|
|
961
|
+
* family, work doc #21) into structured storm data with native
|
|
962
|
+
* geometry: present wind radii per threshold as quadrant-arc
|
|
963
|
+
* polygons, sea-height radii as a single ring, and the forecast and
|
|
964
|
+
* outlook positions with their own radii — the plan's forward storm
|
|
965
|
+
* coverage.
|
|
966
|
+
*
|
|
967
|
+
* Radii lines carry the largest expected radius per quadrant:
|
|
968
|
+
* `64 KT....... 40NE 35SE 25SW 40NW.` — zero radii (the "0SW"
|
|
969
|
+
* case) collapse onto the center in the ring. Forecast positions:
|
|
970
|
+
* `FORECAST VALID 03/1200Z 19.5N 112.1W` followed by their own wind
|
|
971
|
+
* and radii lines.
|
|
972
|
+
*
|
|
973
|
+
* @param {string} text - Cleaned bulletin text
|
|
974
|
+
* @returns {object|null} Structured storm, null when the text is not
|
|
975
|
+
* the advisory family
|
|
976
|
+
*/
|
|
977
|
+
function parseAdvisory(text) {
|
|
978
|
+
if (
|
|
979
|
+
typeof text !== "string" ||
|
|
980
|
+
!/FORECAST\/ADVISORY/i.test(text) ||
|
|
981
|
+
!/MAX SUSTAINED WINDS/i.test(text)
|
|
982
|
+
) {
|
|
983
|
+
return null;
|
|
984
|
+
}
|
|
985
|
+
const nameMatch = text.match(
|
|
986
|
+
/\b([A-Z][A-Z .'-]+?)\s+FORECAST\/ADVISORY\s+NUMBER\s+(\d+)/i,
|
|
987
|
+
);
|
|
988
|
+
const centerMatch = text.match(
|
|
989
|
+
/CENTER LOCATED NEAR\s+(\d+(?:\.\d+)?)([NS])\s+(\d+(?:\.\d+)?)([EW])/i,
|
|
990
|
+
);
|
|
991
|
+
if (!centerMatch) {
|
|
992
|
+
return null;
|
|
993
|
+
}
|
|
994
|
+
const center = {
|
|
995
|
+
lat:
|
|
996
|
+
Number.parseFloat(centerMatch[1]) *
|
|
997
|
+
(centerMatch[2].toUpperCase() === "S" ? -1 : 1),
|
|
998
|
+
lon:
|
|
999
|
+
Number.parseFloat(centerMatch[3]) *
|
|
1000
|
+
(centerMatch[4].toUpperCase() === "W" ? -1 : 1),
|
|
1001
|
+
};
|
|
1002
|
+
const movement = text.match(
|
|
1003
|
+
/PRESENT MOVEMENT TOWARD THE ([^.]+?) OR (\d{1,3}) DEGREES AT\s+(\d+) KT/i,
|
|
1004
|
+
);
|
|
1005
|
+
const pressure = text.match(/MINIMUM CENTRAL PRESSURE\s+(\d+) MB/i);
|
|
1006
|
+
const winds = text.match(
|
|
1007
|
+
/MAX SUSTAINED WINDS\s+(\d+) KT(?:\s+WITH GUSTS TO (\d+) KT)?/i,
|
|
1008
|
+
);
|
|
1009
|
+
const maxWindKt = winds ? Number.parseInt(winds[1], 10) : null;
|
|
1010
|
+
const gustKt = winds?.[2] ? Number.parseInt(winds[2], 10) : null;
|
|
1011
|
+
|
|
1012
|
+
// Radius sets: the present fields live before the first VALID line;
|
|
1013
|
+
// each FORECAST/OUTLOOK VALID line starts a position block with its
|
|
1014
|
+
// own fields, centered on that block's position
|
|
1015
|
+
const parseRadii = (segment, centerLat, centerLon) => {
|
|
1016
|
+
const fields = [];
|
|
1017
|
+
const radiiRe =
|
|
1018
|
+
/\b(\d{1,3})\s*(M|FT)?\s*(SEAS|KT)\s*\.{2,}\s*(\d+)\s*NE\s+(\d+)\s*SE\s+(\d+)\s*SW\s+(\d+)\s*NW/gi;
|
|
1019
|
+
for (const radiiMatch of segment.matchAll(radiiRe)) {
|
|
1020
|
+
const threshold = Number.parseInt(radiiMatch[1], 10);
|
|
1021
|
+
const kind = radiiMatch[3].toLowerCase() === "seas" ? "seas" : "wind";
|
|
1022
|
+
const source = radiiMatch[2] ? `${radiiMatch[2].toUpperCase()} ` : "";
|
|
1023
|
+
const label = `${threshold} ${source}${radiiMatch[3].toUpperCase()}`;
|
|
1024
|
+
const radiiNm = {
|
|
1025
|
+
ne: Number.parseInt(radiiMatch[4], 10),
|
|
1026
|
+
se: Number.parseInt(radiiMatch[5], 10),
|
|
1027
|
+
sw: Number.parseInt(radiiMatch[6], 10),
|
|
1028
|
+
nw: Number.parseInt(radiiMatch[7], 10),
|
|
1029
|
+
};
|
|
1030
|
+
fields.push({
|
|
1031
|
+
label,
|
|
1032
|
+
threshold,
|
|
1033
|
+
kind,
|
|
1034
|
+
radiiNm,
|
|
1035
|
+
geometry: quadrantRing({
|
|
1036
|
+
lat: centerLat,
|
|
1037
|
+
lon: centerLon,
|
|
1038
|
+
radiiNm,
|
|
1039
|
+
}),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
return fields;
|
|
1043
|
+
};
|
|
1044
|
+
const parsePositionBlock = (segment) => {
|
|
1045
|
+
const position = segment.match(
|
|
1046
|
+
/\b(\d{1,2})\/(\d{4})Z\s+(\d+(?:\.\d+)?)([NS])\s+(\d+(?:\.\d+)?)([EW])/,
|
|
1047
|
+
);
|
|
1048
|
+
const wind = segment.match(
|
|
1049
|
+
/MAX WIND\s+(\d+) KT(?:\.{2,}|\s+)GUSTS\s+(\d+) KT/i,
|
|
1050
|
+
);
|
|
1051
|
+
const lat =
|
|
1052
|
+
position && Number.isFinite(Number.parseFloat(position[3]))
|
|
1053
|
+
? Number.parseFloat(position[3]) *
|
|
1054
|
+
(position[4].toUpperCase() === "S" ? -1 : 1)
|
|
1055
|
+
: null;
|
|
1056
|
+
const lon =
|
|
1057
|
+
position && Number.isFinite(Number.parseFloat(position[5]))
|
|
1058
|
+
? Number.parseFloat(position[5]) *
|
|
1059
|
+
(position[6].toUpperCase() === "W" ? -1 : 1)
|
|
1060
|
+
: null;
|
|
1061
|
+
return {
|
|
1062
|
+
validText: headerMatch(segment),
|
|
1063
|
+
lat,
|
|
1064
|
+
lon,
|
|
1065
|
+
maxWindKt: wind ? Number.parseInt(wind[1], 10) : null,
|
|
1066
|
+
gustKt: wind ? Number.parseInt(wind[2], 10) : null,
|
|
1067
|
+
fields: parseRadii(segment, lat ?? center.lat, lon ?? center.lon),
|
|
1068
|
+
};
|
|
1069
|
+
};
|
|
1070
|
+
const headerMatch = (segment) =>
|
|
1071
|
+
segment.match(/\b((?:FORECAST|OUTLOOK) VALID [^\n]*)/)?.[1]?.trim() ?? null;
|
|
1072
|
+
|
|
1073
|
+
const parts = text.split(/(?=\b(?:FORECAST|OUTLOOK) VALID )/);
|
|
1074
|
+
const fields = parseRadii(parts[0] ?? "", center.lat, center.lon);
|
|
1075
|
+
const forecastPoints = [];
|
|
1076
|
+
const outlookPoints = [];
|
|
1077
|
+
for (let i = 1; i < parts.length; i++) {
|
|
1078
|
+
const block = parsePositionBlock(parts[i]);
|
|
1079
|
+
if (block.lat == null) {
|
|
1080
|
+
continue; // A VALID line without a position is not a storm point
|
|
1081
|
+
}
|
|
1082
|
+
const target = /OUTLOOK VALID/i.test(parts[i])
|
|
1083
|
+
? outlookPoints
|
|
1084
|
+
: forecastPoints;
|
|
1085
|
+
target.push(block);
|
|
1086
|
+
}
|
|
1087
|
+
const primary =
|
|
1088
|
+
fields.find((field) => field.threshold === 34 && field.kind === "wind") ??
|
|
1089
|
+
fields.find((field) => field.kind === "wind") ??
|
|
1090
|
+
fields[0] ??
|
|
1091
|
+
null;
|
|
1092
|
+
return {
|
|
1093
|
+
stormName: nameMatch ? nameMatch[1].trim() : null,
|
|
1094
|
+
advisoryNumber: nameMatch ? Number.parseInt(nameMatch[2], 10) : null,
|
|
1095
|
+
center,
|
|
1096
|
+
movementText: movement ? movement[1].trim() : null,
|
|
1097
|
+
movementDegrees: movement ? Number.parseInt(movement[2], 10) : null,
|
|
1098
|
+
movementSpeedKt: movement ? Number.parseInt(movement[3], 10) : null,
|
|
1099
|
+
pressureMb: pressure ? Number.parseInt(pressure[1], 10) : null,
|
|
1100
|
+
maxWindKt,
|
|
1101
|
+
gustKt,
|
|
1102
|
+
severityLabel: advisorySeverityLabel(maxWindKt),
|
|
1103
|
+
fields,
|
|
1104
|
+
forecastPoints,
|
|
1105
|
+
outlookPoints,
|
|
1106
|
+
primaryGeometry: primary?.geometry ?? null,
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
880
1110
|
function filterBulletin({
|
|
881
1111
|
rawText,
|
|
882
1112
|
source,
|
|
@@ -889,6 +1119,49 @@ function filterBulletin({
|
|
|
889
1119
|
return null;
|
|
890
1120
|
}
|
|
891
1121
|
const cleaned = stripBoilerplate(rawText);
|
|
1122
|
+
const advisory = parseAdvisory(cleaned);
|
|
1123
|
+
if (advisory) {
|
|
1124
|
+
// Advisory family (work doc #21): one semantic unit — the whole
|
|
1125
|
+
// text is the block. The track filter runs over every field
|
|
1126
|
+
// (present + forecast wind/seas areas): the storm's forward
|
|
1127
|
+
// coverage counts, not just its present position. Severity from
|
|
1128
|
+
// the max wind threshold rides the block for the console.
|
|
1129
|
+
const fieldGeometries = [
|
|
1130
|
+
...advisory.fields,
|
|
1131
|
+
...advisory.forecastPoints.flatMap((point) => point.fields),
|
|
1132
|
+
]
|
|
1133
|
+
.map((field) => field.geometry)
|
|
1134
|
+
.filter(Boolean);
|
|
1135
|
+
const onTrack =
|
|
1136
|
+
fieldGeometries.length === 0 ||
|
|
1137
|
+
fieldGeometries.some((geometry) => intersectsTrack(geometry, track));
|
|
1138
|
+
if (!onTrack) {
|
|
1139
|
+
return null; // Storm coverage misses our waters entirely
|
|
1140
|
+
}
|
|
1141
|
+
const spoolWatcher = require("./spool-watcher.js");
|
|
1142
|
+
return {
|
|
1143
|
+
header: cleaned.split(/\r?\n/, 1)[0]?.trim() ?? "",
|
|
1144
|
+
issuedAt:
|
|
1145
|
+
issuedAt ??
|
|
1146
|
+
spoolWatcher.extractIssuedAt(cleaned) ??
|
|
1147
|
+
new Date(0).toISOString(),
|
|
1148
|
+
issuer: extractIssuer(cleaned),
|
|
1149
|
+
bulletinText: rawText,
|
|
1150
|
+
source,
|
|
1151
|
+
blocks: [
|
|
1152
|
+
{
|
|
1153
|
+
text: cleaned,
|
|
1154
|
+
subject: subject ?? null,
|
|
1155
|
+
geometryType: advisory.primaryGeometry
|
|
1156
|
+
? advisory.primaryGeometry.type
|
|
1157
|
+
: null,
|
|
1158
|
+
geometry: advisory.primaryGeometry,
|
|
1159
|
+
storm: advisory,
|
|
1160
|
+
source,
|
|
1161
|
+
},
|
|
1162
|
+
],
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
892
1165
|
const features = collectFeatures(cleaned);
|
|
893
1166
|
const header = cleaned.split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
894
1167
|
const blocks = segmentBlocks(cleaned)
|
|
@@ -1105,6 +1378,9 @@ function ukhoGeometry(warning) {
|
|
|
1105
1378
|
}
|
|
1106
1379
|
|
|
1107
1380
|
module.exports = {
|
|
1381
|
+
parseAdvisory,
|
|
1382
|
+
advisorySeverityLabel,
|
|
1383
|
+
quadrantRing,
|
|
1108
1384
|
RETAINED_SUBJECTS,
|
|
1109
1385
|
SEVERE_KEYWORDS,
|
|
1110
1386
|
SECTION_ANCHORS,
|