@meri-imperiumi/signalk-passage-briefing 0.4.0 → 0.5.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +32 -95
  3. package/SPEC.md +117 -0
  4. package/package.json +4 -1
  5. package/plugin/bulletin-engine.js +63 -7
  6. package/plugin/celestial-ephemeris.js +561 -0
  7. package/plugin/celestial-source.js +98 -20
  8. package/plugin/energy-source.js +167 -0
  9. package/plugin/fetch-engine.js +3 -0
  10. package/plugin/hazard-source.js +410 -0
  11. package/plugin/index.js +301 -3
  12. package/plugin/maritime-zones-source.js +366 -0
  13. package/plugin/notes-publisher.js +88 -1
  14. package/plugin/satellite-source.js +295 -0
  15. package/plugin/spool-watcher.js +87 -0
  16. package/plugin/weather-source.js +3 -0
  17. package/public/components/conditions-here.js +111 -2
  18. package/public/components/departure-control.js +170 -0
  19. package/public/components/horizon-sparkline.js +28 -7
  20. package/public/components/models.mjs +414 -31
  21. package/public/components/passage-outlook.js +179 -4
  22. package/public/components/passage-timeline.js +3 -1
  23. package/public/components/sk-api.js +19 -0
  24. package/public/components/strategic-outlook.js +55 -3
  25. package/public/components/tactical-dashboard.js +63 -2
  26. package/public/lines-of-interest.js +227 -0
  27. package/public/route-sim.mjs +143 -0
  28. package/public/sereno-physics.mjs +189 -5
  29. package/tests/bulletin-engine.test.js +314 -0
  30. package/tests/celestial-ephemeris.test.js +215 -0
  31. package/tests/celestial-source.test.js +57 -7
  32. package/tests/fetch-engine.test.js +1 -0
  33. package/tests/fixtures/bulletins/fqau23-ammc-western.txt +91 -0
  34. package/tests/fixtures/bulletins/fqps01-nffn.txt +33 -0
  35. package/tests/fixtures/bulletins/fqps43-nzkl-subtropic.txt +35 -0
  36. package/tests/fixtures/bulletins/fznt01-kwbc-hsf-at1.txt +139 -0
  37. package/tests/fixtures/bulletins/fzpn01-kwbc-hsf-ep1.txt +178 -0
  38. package/tests/fixtures/bulletins/fzpn03-knhc-hsf-ep2.txt +85 -0
  39. package/tests/fixtures/bulletins/fzpn40-phfo-hsf-np.txt +74 -0
  40. package/tests/fixtures/bulletins/fzps40-phfo-hsf-sp.txt +77 -0
  41. package/tests/fixtures/bulletins/hsfat2-knhc-2026-10-03.txt +55 -0
  42. package/tests/fixtures/bulletins/wtpz23-knhc-tcmep3.txt +157 -0
  43. package/tests/fixtures/energy-forecast-hourly.json +156 -0
  44. package/tests/fixtures/gdacs-rss-sample.xml +391 -0
  45. package/tests/hazard-source.test.js +239 -0
  46. package/tests/lines-of-interest.test.js +160 -0
  47. package/tests/maritime-zones-source.test.js +249 -0
  48. package/tests/notes-publisher.test.js +16 -0
  49. package/tests/openmeteo-mock.js +1 -0
  50. package/tests/plugin.test.js +261 -2
  51. package/tests/route-sim.test.js +153 -4
  52. package/tests/satellite-source.test.js +149 -0
  53. package/tests/sereno-physics.test.js +165 -0
  54. package/tests/webapp.test.js +442 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.0] - 2026-10-03
6
+
7
+ ### Added
8
+ - 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. SPEC §2.1/§3.1 updated.
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - GDACS hazard event feed (work doc #22): earthquakes, tropical cyclones, floods and volcanic activity from the EU JRC's `gdacs.org/xml/rss.xml`, fetched during the online window and cached in `weather/hazards.json` with id-based dedup (repolls update in place). Events filter route-relative — alert level at or above the configured minimum (default Orange), within 500 nm of the route corridor or 1000 nm of the vessel, and no older than 72 h — and ride the briefing payload as `hazardEvents`, render in the unified timeline as `hazard` events with the alert level setting the severity, and publish as georeferenced `resources/notes` chart notes (category `hazard-event`, provenance `GDACS`) with scoped expiry that leaves the METAREA notes and other clients' notes untouched. Zero XML dependencies: a targeted extractor reads the stable RSS shape; malformed items skip without costing the feed.
13
+ - Slatting & roll-dampening penalty in the Sereno comfort index (work doc #14): a residual swell (combined Hs ≥ 0.6 m) with too little wind to keep the sails loaded — below 7 kn upwind, below 12 kn downwind — now scores as the wash machine it is: the vertical acceleration carries a 2.5× discomfort multiplier before banding and the hour is forced to at least Rough. The hourly rows tag `slatting: true`, and the tactical dashboard's sparkline renders those Rough blocks with a diagonal hatch (alternating `--comfort-rough` over the card background) so the skipper sees the discomfort is light-air slatting, not heavy weather. SPEC §5.2 amended.
14
+ - Real-world bulletin fixtures (work doc #4 follow-up): ten received-as-is High Seas texts — NHC Atlantic T1/T2, OPC East Pacific EP1, NHC EP2, Honolulu North and South Pacific (NP/SP), Fiji NAVAREA XIV, MetService NZ Subtropic, Australian BoM Western X and an NHC hurricane advisory — live under `tests/fixtures/bulletins/` with a per-family regression suite asserting issue metadata, issuer and geography extraction.
15
+ - The bulletin engine now parses the NHC/OPC/PHFO High Seas families: compact coordinate pairs (`13N67W`, no separator) normalize into the spaced form; a hemisphere-less second number that is not the dateline shorthand is rejected as prose ("07N TO 31N" is two latitudes, not 7N 31E); issue lines from NHC (`0430 UTC SAT OCT 3 2026`), PHFO (`1130 UTC TUE FEB 17 2026`), Fiji (`OCT 022000 UTC`, mixed case `Oct` from the WMO bulletin sets), MetService New Zealand (`Wellington issued at 021856UTC`) and the Australian BoM (`commencing 2300 UTC 2 October 2026`) all parse to real timestamps; `.FORECASTER <NAME>.` lines identify the issuer when no `ISSUED BY` exists, and a leading article is trimmed ("Issued by the Australian Government Bureau of Meteorology").
16
+ - NWS's renamed Gulf is normalized back to the crew's name: "GULF OF AMERICA" reads "GULF OF MEXICO" everywhere the cleaned bulletin text is shown or matched.
17
+ - Lines of interest (work doc #1): the briefing detects where the planned passage crosses the traditional ceremonial lines — the equator (Shellback ceremony), the tropics, the polar circles (Blue Nose / Red Nose), the prime meridian and the antimeridian (Domain of the Golden Dragon, with the calendar skip/repeat note) — reading the simulated track, so each crossing's position, distance from start and ETA come from the boat's own schedule rather than the forecast window. The crossings ride the unified passage timeline as `line` events (⌀ glyph, the general form the timeline was built for) instead of a separate strategic block; `passageSummary.linesOfInterest` carries the data, `lines_of_interest_enabled` (default on) turns the feature off.
18
+ - Celestial ephemeris engine (work doc #3 Phase 2): the sky is computed, not fetched. A new `plugin/celestial-ephemeris.js` module built on `astronomy-engine` (MIT, zero transitive dependencies — the dependency decision is made) derives planetary conjunctions and oppositions during nautical night, and meteor shower peaks (static annual calendar) gated to dark, clear, visible nights: moon set or a crescent, radiant able to clear 20° from the vessel's latitude, forecast cloud cover under 30 %. Bright station passes (ISS, Tiangong) are propagated locally from CelesTrak two-line elements with `satellite.js` (MIT — the SGP4 propagator call resolved) in `plugin/satellite-source.js`, reported when the pass peaks over 15° in nautical night under a clear enough sky. Every event passes the full tactical visibility gate the work document specifies — nautical night, altitude over 15° to clear the marine boundary-layer haze, cloud cover, moonlight — and the Phase-1 aurora alert is upgraded onto the same gate (nautical night replaces the sunset-based check; moonlit or overcast nights no longer raise aurora banners). Briefing payloads gain `celestialNights` — per-night twilight times (civil, nautical, astronomical) and moon phase/illumination — and `TimeStepForecast` gains `surface.cloudCover` (Open-Meteo `cloud_cover`; the Weather API provider publishes none and maps null, which leaves the cloud gate open).
19
+ - The timeline's night stamps show the actual moon phase instead of a fixed crescent: `mergeTimeline` picks one of the eight moon glyphs (🌑🌒🌓🌔🌕🌖🌗🌘) for each night-flagged event from the payload's `celestialNights`, falling back to ☾ for payloads that predate the field. The strategic ETA table's night-arrival markers (P10/P50/P90) get the same treatment.
20
+ - **METAREA notes now carry provenance: who published, when.** Every note the bulletin pipeline serves in `resources/notes` is stamped in its `properties` with `publishedBy` — the issuing authority parsed from the bulletin header ("ISSUED BY FIJI METEOROLOGICAL SERVICE …" → `FIJI METEOROLOGICAL SERVICE`), falling back to `signalk-passage-briefing` when the bulletin names no service (structured UKHO warnings, some NWS products) — and `publishedAt`, the bulletin's issue time. Chart plotters can show the line "Published on 10-03 12:00Z by FIJI METEOROLOGICAL SERVICE" straight from the resource; the DR webapp's note detail surface does.
21
+
5
22
  ## [0.4.0] - 2026-10-03
6
23
 
7
24
  ### Added
package/README.md CHANGED
@@ -1,121 +1,58 @@
1
1
  # signalk-passage-briefing
2
2
 
3
- Offshore passage daily briefing webapp for Signal K: a plugin that plans
4
- and reviews passages for a cruising sailing vessel.
3
+ Offshore passage daily briefing webapp for Signal K: a plugin that plans and reviews passages for a cruising sailing vessel.
5
4
 
6
5
  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.
7
6
 
8
- Part of the Lille Ø offshore suite, alongside
9
- [@meri-imperiumi/signalk-energy-predictor](https://github.com/meri-imperiumi/signalk-energy-predictator)
10
- and [@meri-imperiumi/signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook).
7
+ 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).
11
8
 
12
9
  ## Data sources
13
10
 
14
11
  ### Signal K
15
12
 
16
- - `navigation.course.activeRoute` — the route being sailed; wins over
17
- the last briefed route when the cron/oneshot fetch window opens
18
- - `navigation.position` — vessel position for the conditions-here
19
- empty state and position-only bulletin filtering
20
- - `network.internet.state` — connectivity gating (weather, bulletins
21
- and backfill only fetch while `online` or `metered`)
22
- - `navigation.state`, `electrical.batteries.house.capacity.stateOfCharge`
23
- — state machine inputs (moored/anchored/sailing, publication windows)
24
- - `polars.activePolar`, `polars.performanceFactor` — the canonical
25
- polar resource consumed for boat speed (falls back to a bundled
26
- default table)
13
+ - `navigation.course.activeRoute` — the route being sailed; wins over the last briefed route when the cron/oneshot fetch window opens
14
+ - `navigation.position` — vessel position for the conditions-here empty state and position-only bulletin filtering
15
+ - `network.internet.state` — connectivity gating (weather, bulletins and backfill only fetch while `online` or `metered`)
16
+ - `navigation.state`, `electrical.batteries.house.capacity.stateOfCharge` — state machine inputs (moored/anchored/sailing, publication windows)
17
+ - `polars.activePolar`, `polars.performanceFactor` — the canonical polar resource consumed for boat speed (falls back to a bundled default table)
27
18
  - Resources API — route geometries and polar tables
28
- - History API (on board) — wind/attempt snapshots for the sail-event
29
- backfill
30
- - Published tile paths — `navigation.briefing.generatedAt` / `.route` /
31
- `.hasNew` / `.comfort` drive the plotter-extension tile
32
- (`.acknowledgedAt` is writable to clear the NEW badge)
33
- - [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook)
34
- store — crewed sail events (reefs, sail changes) that train the
35
- preference matrix
36
- - [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time)
37
- — `environment.time.timezoneOffset` / `.timezoneRegion`, the
38
- vessel's published timezone: briefing stamps render in ship's time
39
- when available (the offset rides on every stamp), falling back to
40
- UTC `Z` when nothing is published
41
- - [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule)
42
- — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and
43
- `watch.schedule`: the running watch rotation. While a watch is
44
- running, planned sail changes anchor to the previous watch handover
45
- (both teams awake) instead of sunrise/sunset; boundaries are
46
- extrapolated across the forecast horizon from the rotation cycle
47
- - [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration)
48
- — sail inventory used to filter free-text noise out of log entries
49
- - Weather API (`app.weatherApi`) — waypoint forecasts from the
50
- registered provider, the preferred source (`weather_source: auto`)
51
- whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus)
52
- serves it from its decoded ECMWF run, so briefing numbers match what
53
- the router planned with. That source carries combined sea only:
54
- swell/wind-sea partitions and the upper-air fields behind the
55
- convective warnings degrade to absent rather than being invented
19
+ - History API (on board) — wind/attempt snapshots for the sail-event backfill
20
+ - Published tile paths — `navigation.briefing.generatedAt` / `.route` / `.hasNew` / `.comfort` drive the plotter-extension tile (`.acknowledgedAt` is writable to clear the NEW badge)
21
+ - [signalk-logbook](https://github.com/meri-imperiumi/signalk-logbook) store — crewed sail events (reefs, sail changes) that train the preference matrix
22
+ - [signalk-ships-time](https://github.com/meri-imperiumi/signalk-ships-time) — `environment.time.timezoneOffset` / `.timezoneRegion`, the vessel's published timezone: briefing stamps render in ship's time when available (the offset rides on every stamp), falling back to UTC `Z` when nothing is published
23
+ - [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule) — `watch.state.onWatch`, `watch.state.startedAt`, `watch.system` and `watch.schedule`: the running watch rotation. While a watch is running, planned sail changes anchor to the previous watch handover (both teams awake) instead of sunrise/sunset; boundaries are extrapolated across the forecast horizon from the rotation cycle
24
+ - [@signalk/sailsconfiguration](https://www.npmjs.com/package/@signalk/sailsconfiguration) — sail inventory used to filter free-text noise out of log entries
25
+ - Weather API (`app.weatherApi`) — waypoint forecasts from the registered provider, the preferred source (`weather_source: auto`) whenever one answers; [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus) serves it from its decoded ECMWF run, so briefing numbers match what the router planned with. That source carries combined sea only: swell/wind-sea partitions and the upper-air fields behind the convective warnings degrade to absent rather than being invented
56
26
 
57
27
  ### External
58
28
 
59
- The Open-Meteo entries below are the fallback weather source: used
60
- when no Weather API provider answers (or `weather_source` is
61
- `open-meteo`), and the only source of the partition and upper-air
62
- fields the Weather API providers don't publish.
63
-
64
- - [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface
65
- wind, gusts, MSL pressure, CAPE and the pressure-layer fields behind
66
- the K-index (best-match model)
67
- - [NOAA SWPC planetary K-index forecast](https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json)
68
- — geomagnetic activity behind the aurora advisories
69
- - [JPL SBDB query API](https://ssd-api.jpl.nasa.gov/doc/sbdb_query.html)
70
- — comet brightness parameters (M1/K1) behind the Sky Notes comets
71
- - [NOAA TGFTP radiofax tree](https://tgftp.nws.noaa.gov/fax/) and the
72
- [BoM difacs charts](http://www.bom.gov.au/difacs/) — synoptic
73
- surface-analysis charts per METAREA zone (work doc #11), per the
74
- schedule in
75
- [otherfax.txt](https://tgftp.nws.noaa.gov/fax/otherfax.txt)
76
- - [Open-Meteo Marine API](https://open-meteo.com/en/docs) —
77
- NOAA GFS-Wave 0.25° combined sea/wind sea/swell partitions, and
78
- Météo-France SMOC surface currents
79
- - NOAA TGFTP (`tgftp.nws.noaa.gov`) — raw METAREA bulletin text for
80
- the resolved GMDSS zone's station (e.g. `FQPS01 NFFN` for XIV)
81
- - WMO GMDSS portal (`weather.gmdss.org`) — per-zone bulletin pages,
82
- fallback when the TGFTP station is not configured
83
- - api.weather.gov product API — US High Seas Forecast texts (default
84
- `bulletin_urls`: HSF NP/EP1/EP2); extra sources can be added via the
85
- `bulletin_urls` configuration (plain text or api.weather.gov
86
- product URLs; UKHO MSI JSON works too)
87
-
88
- All external fetches are online-gated and cached to the plugin data
89
- directory, so the last payloads survive the offline hours.
29
+ The Open-Meteo entries below are the fallback weather source: used when no Weather API provider answers (or `weather_source` is `open-meteo`), and the only source of the partition and upper-air fields the Weather API providers don't publish.
30
+
31
+ - [Open-Meteo Forecast API](https://open-meteo.com/en/docs) — surface wind, gusts, MSL pressure, CAPE and the pressure-layer fields behind the K-index (best-match model)
32
+ - [NOAA SWPC planetary K-index forecast](https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json) — geomagnetic activity behind the aurora advisories
33
+ - [JPL SBDB query API](https://ssd-api.jpl.nasa.gov/doc/sbdb_query.html) — comet brightness parameters (M1/K1) behind the Sky Notes comets
34
+ - [CelesTrak](https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle) — two-line elements for the crewed stations (ISS, Tiangong); bright satellite passes are propagated locally from the fetched TLEs with SGP4, gated to nautical night, clear sky and a 15° haze line
35
+ - [NOAA TGFTP radiofax tree](https://tgftp.nws.noaa.gov/fax/) and the [BoM difacs charts](http://www.bom.gov.au/difacs/) — synoptic surface-analysis charts per METAREA zone (work doc #11), per the schedule in [otherfax.txt](https://tgftp.nws.noaa.gov/fax/otherfax.txt)
36
+ - [UKHO Admiralty MSI API](https://msi.admiralty.co.uk/) — structured NAVAREA navigational warnings for the resolved zone; the third rung of the GMDSS bulletin fetch ladder (TGFTP raw text, WMO GMDSS portal, then UKHO MSI JSON) and also accepted verbatim as a `bulletin_urls` source
37
+ - [GDACS RSS](https://www.gdacs.org/xml/rss.xml) — EU JRC global disaster alerts (earthquakes, tropical cyclones, floods, volcanic activity), filtered route-relative by alert level and corridor distance: they ride the unified passage timeline and publish as georeferenced chart notes (work doc #22)
38
+ - [Open-Meteo Marine API](https://open-meteo.com/en/docs) — NOAA GFS-Wave 0.25° combined sea/wind sea/swell partitions, and Météo-France SMOC surface currents
39
+ - NOAA TGFTP (`tgftp.nws.noaa.gov`) — raw METAREA bulletin text for the resolved GMDSS zone's station (e.g. `FQPS01 NFFN` for XIV)
40
+ - WMO GMDSS portal (`weather.gmdss.org`) — per-zone bulletin pages, fallback when the TGFTP station is not configured
41
+ - api.weather.gov product API — US High Seas Forecast texts (default `bulletin_urls`: HSF NP/EP1/EP2); extra sources can be added via the `bulletin_urls` configuration (plain text or api.weather.gov product URLs; UKHO MSI JSON works too)
42
+
43
+ All external fetches are online-gated and cached to the plugin data directory, so the last payloads survive the offline hours.
90
44
 
91
45
  ## Pairing with Weather Router Plus
92
46
 
93
- [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus)
94
- plans the passage: isochrone routing against the vessel's polar on the ECMWF open-data run it keeps decoded on disk, with map overlays, tides and currents. Activate the route it publishes to the Resources API, and this plugin briefs it — `navigation.course.activeRoute` wins over the last briefed route at the next fetch window, and with `weather_source: auto` (the default) the briefing reads its forecasts from the router's Weather API provider. One forecast on board, planner and briefing in agreement: the router for planning and visualization, the briefing for the underway daily routine (comfort, sail changes, bulletins, energy).
47
+ [signalk-weather-router-plus](https://github.com/motamman/signalk-weather-router-plus) plans the passage: isochrone routing against the vessel's polar on the ECMWF open-data run it keeps decoded on disk, with map overlays, tides and currents. Activate the route it publishes to the Resources API, and this plugin briefs it — `navigation.course.activeRoute` wins over the last briefed route at the next fetch window, and with `weather_source: auto` (the default) the briefing reads its forecasts from the router's Weather API provider. One forecast on board, planner and briefing in agreement: the router for planning and visualization, the briefing for the underway daily routine (comfort, sail changes, bulletins, energy).
95
48
 
96
49
  ## Pairing with Watch Schedule
97
50
 
98
- [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule)
99
- runs the crew's watch rotation and publishes it under `watch.*`. When a
100
- watch is running, this plugin reads the schedule at app load and moves
101
- planned sail changes (reefs, canvas work) to the *previous* watch
102
- handover — the moment both teams are awake on deck — rather than
103
- sunrise/sunset; tacks and gybes stay at their tactical times, since
104
- course work can't wait for a handover. The watch boundary wins over
105
- sunlight. Without a running watch, canvas work anchors to the next
106
- sunrise/sunset. Webapp-side only: no configuration, and the briefing
107
- works unchanged when the plugin is absent.
51
+ [signalk-watch-schedule](https://github.com/hoeken/signalk-watch-schedule) runs the crew's watch rotation and publishes it under `watch.*`. When a watch is running, this plugin reads the schedule at app load and moves planned sail changes (reefs, canvas work) to the *previous* watch handover — the moment both teams are awake on deck — rather than sunrise/sunset; tacks and gybes stay at their tactical times, since course work can't wait for a handover. The watch boundary wins over sunlight. Without a running watch, canvas work anchors to the next sunrise/sunset. Webapp-side only: no configuration, and the briefing works unchanged when the plugin is absent.
108
52
 
109
53
  ## Acknowledgments
110
54
 
111
- The comfort model and the whole idea of "what will each departure
112
- actually feel like" come from SV Sabado's
113
- [passage-weather](https://github.com/sailing12388/passage-weather)
114
- ensemble departure planner by Ray Hendricks. The Sereno comfort scale
115
- (Champagne, Easy, Coffee, Rough, Sick), the encounter-period motion
116
- math, and the ISO 2631-1 comfort bands are adapted from it for a
117
- monohull (heel and waterline-pitch resonance instead of catamaran
118
- beam-roll and bridgedeck slam).
55
+ The comfort model and the whole idea of "what will each departure actually feel like" come from SV Sabado's [passage-weather](https://github.com/sailing12388/passage-weather) ensemble departure planner by Ray Hendricks. The Sereno comfort scale (Champagne, Easy, Coffee, Rough, Sick), the encounter-period motion math, and the ISO 2631-1 comfort bands are adapted from it for a monohull (heel and waterline-pitch resonance instead of catamaran beam-roll and bridgedeck slam).
119
56
 
120
57
  ## License
121
58
 
package/SPEC.md CHANGED
@@ -77,6 +77,52 @@ signalk-passage-outlook/
77
77
  "type": "number",
78
78
  "title": "Pitching Acceleration Multiplier Constant",
79
79
  "default": 0.40
80
+ },
81
+ "lines_of_interest_enabled": {
82
+ "type": "boolean",
83
+ "title": "Lines of Interest (ceremonial crossings)",
84
+ "default": true
85
+ },
86
+ "hazard_events_enabled": {
87
+ "type": "boolean",
88
+ "title": "GDACS Hazard Events",
89
+ "default": true
90
+ },
91
+ "hazard_min_alert_level": {
92
+ "type": "string",
93
+ "enum": ["green", "orange", "red"],
94
+ "title": "Minimum GDACS Alert Level",
95
+ "default": "orange"
96
+ },
97
+ "hazard_radius_offroute_nm": {
98
+ "type": "number",
99
+ "title": "Hazard Off-route Radius (nm)",
100
+ "default": 500
101
+ },
102
+ "hazard_radius_ahead_nm": {
103
+ "type": "number",
104
+ "title": "Hazard Vessel Radius (nm)",
105
+ "default": 1000
106
+ },
107
+ "hazard_max_age_hours": {
108
+ "type": "number",
109
+ "title": "Hazard Event Aging (hours)",
110
+ "default": 72
111
+ },
112
+ "departure_daylight_auto": {
113
+ "type": "boolean",
114
+ "title": "Anchor Departure to Daylight (auto)",
115
+ "default": true
116
+ },
117
+ "departure_prep_hours": {
118
+ "type": "number",
119
+ "title": "Departure Prep Delay (hours)",
120
+ "default": 1.5
121
+ },
122
+ "departure_dawn_altitude_deg": {
123
+ "type": "number",
124
+ "title": "First Light Sun Altitude (degrees)",
125
+ "default": -6
80
126
  }
81
127
  }
82
128
  }
@@ -144,6 +190,63 @@ interface UnifiedWeatherPayload {
144
190
  issuedAt: string;
145
191
  bulletinText: string;
146
192
  };
193
+ spaceEvents?: {
194
+ kind: string; // 'aurora' | 'comet' | 'conjunction' | 'opposition' | 'meteor'
195
+ timestamp: string; // ISO timestamp
196
+ tactical: boolean; // 24h dashboard banner vs strategic sky note
197
+ description: string;
198
+ }[];
199
+ celestialNights?: {
200
+ timestamp: string; // ISO timestamp (night anchor)
201
+ moonPhaseDeg: number; // 0 = new, 90 = first quarter, 180 = full, 270 = third quarter
202
+ moonIllumination: number; // fraction 0-1
203
+ civilDusk: string | null; // ISO timestamps, null beyond polar day/night
204
+ nauticalDusk: string | null;
205
+ astronomicalDusk: string | null;
206
+ astronomicalDawn: string | null;
207
+ nauticalDawn: string | null;
208
+ civilDawn: string | null;
209
+ }[];
210
+ hazardEvents?: {
211
+ id: string; // GDACS event id
212
+ type: string; // 'EQ' | 'TC' | 'FL' | 'VO' | 'DR' | 'WF'
213
+ alertLevel: string; // 'green' | 'orange' | 'red'
214
+ title: string;
215
+ description: string;
216
+ timestamp: string; // ISO, newer of pubDate/datemodified
217
+ lat: number;
218
+ lon: number;
219
+ distanceNm: number; // from the admitting reference (vessel or route)
220
+ bearingDeg: number; // degrees true from the same reference
221
+ link: string | null; // GDACS event page
222
+ }[];
223
+ zoneTransitions?: {
224
+ kind: 'enter' | 'leave'; // boundary crossing direction
225
+ territory: { name: string; iso_ter: string };
226
+ lat: number; // crossing position
227
+ lon: number;
228
+ distanceFromStartNm: number;
229
+ connectivity?: 'ocean'; // leave events: metered-ocean rules beyond
230
+ }[];
231
+ zonesHere?: { // here mode: waters the vessel sits in
232
+ layer: string; // 'internal' | 'archipelagic' | '12nm'
233
+ name: string;
234
+ iso_ter: string;
235
+ territory: string;
236
+ }[];
237
+ zoneDisclaimer?: string; // Marine Regions attribution + no-navigation notice
238
+ energyHourly?: {
239
+ timestamp: string; // ISO hour stamp
240
+ solarWh: number; // total ideal generation (solar+wind+hydro+alternator)
241
+ loadWh: number; // house consumption
242
+ netWh: number; // solarWh - loadWh
243
+ soc: number | null; // ideal SoC 0-1 at hour end
244
+ }[]; // signalk-energy-predictor forecast, 48 h
245
+ departure?: {
246
+ assumed: boolean; // auto daylight anchor vs crew override
247
+ time: string; // ISO — the schedule's anchor instant
248
+ reason: string; // 'underway' | 'next_dawn' | 'daylight_prep' | 'no_dawn' | 'manual'
249
+ };
147
250
  }
148
251
 
149
252
  interface TimeStepForecast {
@@ -152,6 +255,7 @@ interface TimeStepForecast {
152
255
  tws: number; // knots
153
256
  twd: number; // degrees true
154
257
  mslp: number; // hPa
258
+ cloudCover: number | null; // percent (0-100); null when the source carries none
155
259
  };
156
260
  marine: {
157
261
  hsCombined: number; // meters
@@ -351,6 +455,19 @@ $$a_z = a_{z,\text{base}} \cdot \mu_{\text{heel}} \cdot \mu_{\text{pitch}}$$
351
455
  * $0.630 \le a_z < 1.250\text{ m/s}^2 \implies \mathbf{Rough}$
352
456
  * $a_z \ge 1.250\text{ m/s}^2 \implies \mathbf{Sick}$
353
457
 
458
+ #### Slatting & Roll-Dampening Penalty (work doc #14)
459
+
460
+ On a monohull, aerodynamic pressure in the sails dampens roll. When
461
+ that pressure is lost but a residual swell remains, the snap-roll and
462
+ boom shock-loading can be worse than a gale — yet raw $a_z$ scores it
463
+ calm, because the sea state itself is modest. After the base rating:
464
+
465
+ 1. **Glassy-calm gate:** if $H_{s,\text{combined}} < 0.6\text{ m}$ there is not enough wave energy to roll the hull — the penalty is skipped entirely.
466
+ 2. **Dampening-loss thresholds:** otherwise the sails have lost their dampening pressure when
467
+ * upwind / reaching ($|TWA| < 90°$): $TWS < 7.0\text{ kt}$
468
+ * downwind / running ($|TWA| \ge 90°$): $TWS < 12.0\text{ kt}$ — the boat sails away from its wind, dropping apparent wind below flow-attachment.
469
+ 3. **Washing-machine penalty:** when the regime is detected, the vertical acceleration is multiplied by $\mu_{\text{slat}} = 2.5$ before ISO banding, and the hour's tier is forced to at least **Rough**. The hourly row carries a `slatting` tag; the tactical dashboard renders slatting-triggered Rough blocks with a diagonal hatch (alternating `--comfort-rough` over the card background) so the skipper sees the discomfort is light-air slatting, not heavy weather — alter course for a better wave angle, drop the main and motor, or lock the boom down.
470
+
354
471
 
355
472
 
356
473
  ### 5.3 Hazard Proximity Ray-Casting Algorithm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-passage-briefing",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Offshore passage daily briefing webapp for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
@@ -44,6 +44,9 @@
44
44
  "node": ">=22.5.0"
45
45
  },
46
46
  "dependencies": {
47
+ "@openwaters/maritime-zones": "^0.2.1",
48
+ "astronomy-engine": "^2.1.19",
49
+ "satellite.js": "^7.1.0",
47
50
  "yaml": "^2.9.0"
48
51
  },
49
52
  "devDependencies": {
@@ -54,6 +54,10 @@ const SECTION_ANCHORS =
54
54
  * lines. The NAVTEX message id line (`ZCZC GA05 011200Z AUG 26`) is
55
55
  * kept — the subject filter reads it first.
56
56
  *
57
+ * NWS's renamed geography is normalized back to the name the crew
58
+ * uses: "GULF OF AMERICA" reads "GULF OF MEXICO" everywhere the
59
+ * cleaned text is shown or matched.
60
+ *
57
61
  * @param {string} text - Raw bulletin text
58
62
  * @returns {string} Cleaned text
59
63
  */
@@ -71,6 +75,7 @@ function stripBoilerplate(text) {
71
75
  return true;
72
76
  })
73
77
  .join("\n")
78
+ .replace(/\bGULF OF AMERICA\b/gi, "GULF OF MEXICO")
74
79
  .replace(/\n{3,}/g, "\n\n")
75
80
  .trim();
76
81
  }
@@ -175,14 +180,24 @@ function hemisphereDegrees(value, hemisphere) {
175
180
  * are missing are prose numbers ("280600 UTC", "20 TO 30 KNOTS")
176
181
  * and rejected.
177
182
  *
183
+ * Also accepts the compact NHC Atlantic form (`13N67W`, no separator
184
+ * between the latitude and longitude parts) by normalizing it to the
185
+ * spaced form first — the loose pattern cannot match it because
186
+ * there is no separator to consume.
187
+ *
178
188
  * @param {string} text
179
189
  * @returns {number[][]} [[lon, lat], …] (possibly empty)
180
190
  */
181
191
  function parseCoordinatePoints(text) {
192
+ // Compact NHC pairs: digits + NS + digits + EW glued together
193
+ const compact = text.replace(
194
+ /\b(\d{1,3})([NS])(\d{1,3})([EW])\b/g,
195
+ "$1$2 $3$4",
196
+ );
182
197
  const pair = /(\d+(?:\.\d+)?|EQT)\s*([NS])?[,\s]+(\d+(?:\.\d+)?)\s*([EW])?/gi;
183
198
  const points = [];
184
199
  let match;
185
- while ((match = pair.exec(text)) !== null) {
200
+ while ((match = pair.exec(compact)) !== null) {
186
201
  const equator = match[1].toUpperCase() === "EQT";
187
202
  const latHemi = match[2]?.toUpperCase();
188
203
  const lonHemi = match[4]?.toUpperCase();
@@ -192,10 +207,17 @@ function parseCoordinatePoints(text) {
192
207
  pair.lastIndex = match.index + 1;
193
208
  continue;
194
209
  }
210
+ const lonValue = hemisphereDegrees(match[3], lonHemi ?? "E");
211
+ if (!equator && !lonHemi && lonValue !== 180) {
212
+ // A hemisphere-less longitude that is not the dateline
213
+ // shorthand is prose's second number ("07N TO 31N" is two
214
+ // latitudes, not 7N 31E) — reject it the same way
215
+ pair.lastIndex = match.index + 1;
216
+ continue;
217
+ }
195
218
  const lat = equator ? 0 : hemisphereDegrees(match[1], latHemi ?? "N");
196
- const lon = hemisphereDegrees(match[3], lonHemi ?? "E");
197
- if (Number.isFinite(lat) && Number.isFinite(lon)) {
198
- points.push([lon, lat]);
219
+ if (Number.isFinite(lat) && Number.isFinite(lonValue)) {
220
+ points.push([lonValue, lat]);
199
221
  }
200
222
  }
201
223
  return points;
@@ -737,6 +759,38 @@ function intersectsTrack(geometry, track) {
737
759
  return true;
738
760
  }
739
761
 
762
+ /**
763
+ * Extracts the issuing authority from a bulletin's text (work doc #12
764
+ * provenance): GMDSS bulletins name their service in the header block,
765
+ * e.g. "ISSUED BY FIJI METEOROLOGICAL SERVICE SEP 270800 UTC." The
766
+ * name is captured verbatim, minus the trailing issue-time fragment
767
+ * and punctuation. Null when the bulletin does not name an issuer
768
+ * (structured UKHO warnings, some NWS products) — consumers then fall
769
+ * back to the publishing plugin.
770
+ *
771
+ * @param {string} text - Raw or cleaned bulletin text
772
+ * @returns {string|null} Issuer name, e.g. "FIJI METEOROLOGICAL SERVICE"
773
+ */
774
+ function extractIssuer(text) {
775
+ const match = text.match(/ISSUED\s+BY\s+(.+)/i);
776
+ if (!match) {
777
+ // NHC High Seas style: ".FORECASTER DELGADO. NATIONAL HURRICANE
778
+ // CENTER." — the forecaster's surname stands in for the issuer
779
+ const forecaster = text.match(/\bFORECASTER\s+([A-Z][A-Z .'-]+)/i);
780
+ return forecaster ? forecaster[1].replace(/[.\s]+$/, "") : null;
781
+ }
782
+ // Strip the trailing issue-time fragment in its observed shapes
783
+ // ("SEP 270800 UTC", "270800 UTC", "270800Z"), then any trailing
784
+ // punctuation or padding the station style leaves behind. The
785
+ // leading \s+ anchors the fragment to a word start, so a name that
786
+ // merely ends in 3–4 letters can't be mistaken for the month token.
787
+ const name = match[1]
788
+ .replace(/\s+(?:[A-Z]{3,4}\.?\s+)?\d{4,10}Z?(?:\s+UTC)?\.?\s*$/i, "")
789
+ .replace(/^[Tt][Hh][Ee]\s+/, "")
790
+ .replace(/[.\s]+$/, "");
791
+ return name || null;
792
+ }
793
+
740
794
  /**
741
795
  * Runs the full pipeline over one raw bulletin (work doc #4 §2–4):
742
796
  * boilerplate stripping, NAVTEX subject filter, GMDSS segmentation,
@@ -751,9 +805,9 @@ function intersectsTrack(geometry, track) {
751
805
  * keep (default {@link RETAINED_SUBJECTS})
752
806
  * @param {string} [params.issuedAt] - Issue time override (spool
753
807
  * file parsing result); extracted from the text otherwise
754
- * @returns {object|null} `{header, issuedAt, bulletinText, source,
755
- * blocks: [{text, subject, geometryType, source}]}` — null when
756
- * the whole message is discarded by the subject filter
808
+ * @returns {object|null} `{header, issuedAt, issuer, bulletinText,
809
+ * source, blocks: [{text, subject, geometryType, source}]}` — null
810
+ * when the whole message is discarded by the subject filter
757
811
  */
758
812
  function filterBulletin({
759
813
  rawText,
@@ -792,6 +846,7 @@ function filterBulletin({
792
846
  issuedAt ??
793
847
  spoolWatcher.extractIssuedAt(cleaned) ??
794
848
  new Date(0).toISOString(),
849
+ issuer: extractIssuer(cleaned),
795
850
  bulletinText: rawText,
796
851
  source,
797
852
  blocks,
@@ -979,6 +1034,7 @@ module.exports = {
979
1034
  polygonIntersectsTrack,
980
1035
  bboxIntersectsTrack,
981
1036
  intersectsTrack,
1037
+ extractIssuer,
982
1038
  filterBulletin,
983
1039
  fetchRemoteBulletin,
984
1040
  resolveBulletinSource,