@meri-imperiumi/signalk-passage-briefing 0.2.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/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- package/tests/zone-source.test.js +226 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-endpoint weather & METAREA aggregation (SPEC §3.1).
|
|
3
|
+
*
|
|
4
|
+
* Builds the UnifiedWeatherPayload along a route's waypoints from
|
|
5
|
+
* Open-Meteo, the same source SV Sabado's passage-weather vetted
|
|
6
|
+
* against raw ECMWF/NOAA GRIB:
|
|
7
|
+
*
|
|
8
|
+
* - forecast API: surface wind (kn), gusts, MSL pressure, CAPE, and
|
|
9
|
+
* the pressure-layer fields behind the K-index (computed here from
|
|
10
|
+
* T850/T700/T500 and dewpoints via Magnus);
|
|
11
|
+
* - marine API (`ncep_gfswave025`): combined sea plus wind sea and
|
|
12
|
+
* swell partitions (the SPEC §3.1 marine block needs the split;
|
|
13
|
+
* ECMWF's split fields come back empty per passage-weather's
|
|
14
|
+
* 2026-09 checks);
|
|
15
|
+
* - marine API currents: Météo-France SMOC surface current.
|
|
16
|
+
*
|
|
17
|
+
* The marine and current endpoints degrade gracefully — a failure
|
|
18
|
+
* nulls their fields — while a forecast failure fails the fetch.
|
|
19
|
+
*
|
|
20
|
+
* Everything fetched is cached to the plugin data directory
|
|
21
|
+
* (`weather/latest-<route>.json` plus dated snapshots, newest kept):
|
|
22
|
+
* on passage the boat is online for about an hour per day, so the
|
|
23
|
+
* last payload must survive restarts and serve offline reads.
|
|
24
|
+
*
|
|
25
|
+
* Conventions: wind directions are the direction the wind blows FROM
|
|
26
|
+
* (degrees true, matching Signal K), waves likewise FROM; speeds in
|
|
27
|
+
* knots; pressure hPa; timestamps ISO UTC.
|
|
28
|
+
*
|
|
29
|
+
* @file fetch-engine.js
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
mkdir,
|
|
34
|
+
readFile,
|
|
35
|
+
readdir,
|
|
36
|
+
rename,
|
|
37
|
+
writeFile,
|
|
38
|
+
} = require("node:fs/promises");
|
|
39
|
+
const path = require("node:path");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Earth radius in nautical miles.
|
|
43
|
+
*/
|
|
44
|
+
const EARTH_RADIUS_NM = 3440.065;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default route sampling interval.
|
|
48
|
+
*/
|
|
49
|
+
const DEFAULT_SAMPLE_INTERVAL_NM = 30;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Default forecast horizon.
|
|
53
|
+
*/
|
|
54
|
+
const DEFAULT_FORECAST_DAYS = 7;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Snapshots kept per route in the cache.
|
|
58
|
+
*/
|
|
59
|
+
const KEEP_SNAPSHOTS = 8;
|
|
60
|
+
|
|
61
|
+
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
|
62
|
+
const MARINE_URL = "https://marine-api.open-meteo.com/v1/marine";
|
|
63
|
+
|
|
64
|
+
const FORECAST_HOURLY = [
|
|
65
|
+
"wind_speed_10m",
|
|
66
|
+
"wind_direction_10m",
|
|
67
|
+
"wind_gusts_10m",
|
|
68
|
+
"pressure_msl",
|
|
69
|
+
"cape",
|
|
70
|
+
"temperature_850hPa",
|
|
71
|
+
"temperature_700hPa",
|
|
72
|
+
"temperature_500hPa",
|
|
73
|
+
"relative_humidity_850hPa",
|
|
74
|
+
"relative_humidity_700hPa",
|
|
75
|
+
"wind_speed_850hPa",
|
|
76
|
+
].join(",");
|
|
77
|
+
|
|
78
|
+
const WAVE_HOURLY = [
|
|
79
|
+
"wave_height",
|
|
80
|
+
"wave_direction",
|
|
81
|
+
"wave_peak_period",
|
|
82
|
+
"wind_wave_height",
|
|
83
|
+
"wind_wave_period",
|
|
84
|
+
"wind_wave_direction",
|
|
85
|
+
"swell_wave_height",
|
|
86
|
+
"swell_wave_period",
|
|
87
|
+
"swell_wave_direction",
|
|
88
|
+
].join(",");
|
|
89
|
+
|
|
90
|
+
const CURRENT_HOURLY = [
|
|
91
|
+
"ocean_current_velocity",
|
|
92
|
+
"ocean_current_direction",
|
|
93
|
+
].join(",");
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parses a timestamp that carries no explicit offset as UTC
|
|
97
|
+
* (Open-Meteo returns naive ISO strings under `timezone=GMT`).
|
|
98
|
+
*
|
|
99
|
+
* @param {string} time
|
|
100
|
+
* @returns {Date}
|
|
101
|
+
*/
|
|
102
|
+
function parseUtcTimestamp(time) {
|
|
103
|
+
const hasOffset = /[zZ]$|[+-]\d{2}:?\d{2}$/.test(time);
|
|
104
|
+
return new Date(hasOffset ? time : `${time}Z`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Great-circle distance between two positions.
|
|
109
|
+
*
|
|
110
|
+
* @param {number} lat1 - Degrees
|
|
111
|
+
* @param {number} lon1 - Degrees
|
|
112
|
+
* @param {number} lat2 - Degrees
|
|
113
|
+
* @param {number} lon2 - Degrees
|
|
114
|
+
* @returns {number} Distance in nautical miles
|
|
115
|
+
*/
|
|
116
|
+
function distanceNm(lat1, lon1, lat2, lon2) {
|
|
117
|
+
const toRad = Math.PI / 180;
|
|
118
|
+
const φ1 = lat1 * toRad;
|
|
119
|
+
const φ2 = lat2 * toRad;
|
|
120
|
+
const dφ = (lat2 - lat1) * toRad;
|
|
121
|
+
const dλ = (lon2 - lon1) * toRad;
|
|
122
|
+
const a =
|
|
123
|
+
Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
|
|
124
|
+
return 2 * EARTH_RADIUS_NM * Math.asin(Math.min(1, Math.sqrt(a)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Great-circle interpolation between two positions (spherical linear
|
|
129
|
+
* interpolation), fraction `f` along the great circle.
|
|
130
|
+
*
|
|
131
|
+
* @param {number} lat1 - Degrees
|
|
132
|
+
* @param {number} lon1 - Degrees
|
|
133
|
+
* @param {number} lat2 - Degrees
|
|
134
|
+
* @param {number} lon2 - Degrees
|
|
135
|
+
* @param {number} f - Fraction 0..1
|
|
136
|
+
* @returns {{lat: number, lon: number}}
|
|
137
|
+
*/
|
|
138
|
+
function interpolatePosition(lat1, lon1, lat2, lon2, f) {
|
|
139
|
+
const toRad = Math.PI / 180;
|
|
140
|
+
const toDeg = 180 / Math.PI;
|
|
141
|
+
const φ1 = lat1 * toRad;
|
|
142
|
+
const λ1 = lon1 * toRad;
|
|
143
|
+
const φ2 = lat2 * toRad;
|
|
144
|
+
const λ2 = lon2 * toRad;
|
|
145
|
+
const d =
|
|
146
|
+
2 *
|
|
147
|
+
Math.asin(
|
|
148
|
+
Math.min(
|
|
149
|
+
1,
|
|
150
|
+
Math.sqrt(
|
|
151
|
+
Math.sin((φ2 - φ1) / 2) ** 2 +
|
|
152
|
+
Math.cos(φ1) * Math.cos(φ2) * Math.sin((λ2 - λ1) / 2) ** 2,
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
if (d < 1e-9) {
|
|
157
|
+
return { lat: lat1, lon: lon1 };
|
|
158
|
+
}
|
|
159
|
+
const A = Math.sin((1 - f) * d) / Math.sin(d);
|
|
160
|
+
const B = Math.sin(f * d) / Math.sin(d);
|
|
161
|
+
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
|
|
162
|
+
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
|
|
163
|
+
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
|
|
164
|
+
return {
|
|
165
|
+
lat: Math.atan2(z, Math.sqrt(x * x + y * y)) * toDeg,
|
|
166
|
+
lon: Math.atan2(y, x) * toDeg,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Samples a route geometry into waypoints at most `intervalNm` apart
|
|
172
|
+
* (passage-weather's scheme: the route is divided into
|
|
173
|
+
* `ceil(total/interval)` even steps, so spacing is uniform and both
|
|
174
|
+
* ends are included), recording each waypoint's distance from the
|
|
175
|
+
* start (SPEC §3.1 waypoints shape).
|
|
176
|
+
*
|
|
177
|
+
* @param {number[][]} coordinates - Route GeoJSON coordinates
|
|
178
|
+
* ([[lon, lat], ...], Signal K convention)
|
|
179
|
+
* @param {number} [intervalNm] - Maximum sampling interval
|
|
180
|
+
* @returns {Array<{lat: number, lon: number, distanceFromStartNm: number}>}
|
|
181
|
+
*/
|
|
182
|
+
function sampleRoutePoints(
|
|
183
|
+
coordinates,
|
|
184
|
+
intervalNm = DEFAULT_SAMPLE_INTERVAL_NM,
|
|
185
|
+
) {
|
|
186
|
+
if (!Array.isArray(coordinates) || coordinates.length === 0) {
|
|
187
|
+
return [];
|
|
188
|
+
}
|
|
189
|
+
if (coordinates.length === 1) {
|
|
190
|
+
return [
|
|
191
|
+
{
|
|
192
|
+
lat: coordinates[0][1],
|
|
193
|
+
lon: coordinates[0][0],
|
|
194
|
+
distanceFromStartNm: 0,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
const legs = [];
|
|
199
|
+
let total = 0;
|
|
200
|
+
for (let i = 1; i < coordinates.length; i++) {
|
|
201
|
+
const leg = distanceNm(
|
|
202
|
+
coordinates[i - 1][1],
|
|
203
|
+
coordinates[i - 1][0],
|
|
204
|
+
coordinates[i][1],
|
|
205
|
+
coordinates[i][0],
|
|
206
|
+
);
|
|
207
|
+
legs.push({
|
|
208
|
+
lat1: coordinates[i - 1][1],
|
|
209
|
+
lon1: coordinates[i - 1][0],
|
|
210
|
+
lat2: coordinates[i][1],
|
|
211
|
+
lon2: coordinates[i][0],
|
|
212
|
+
start: total,
|
|
213
|
+
length: leg,
|
|
214
|
+
});
|
|
215
|
+
total += leg;
|
|
216
|
+
}
|
|
217
|
+
const steps = Math.max(1, Math.ceil(total / intervalNm));
|
|
218
|
+
const stepLength = total / steps;
|
|
219
|
+
const waypoints = [];
|
|
220
|
+
for (let i = 0; i <= steps; i++) {
|
|
221
|
+
const d = i * stepLength;
|
|
222
|
+
const pos = positionAt(legs, d);
|
|
223
|
+
waypoints.push({
|
|
224
|
+
lat: pos.lat,
|
|
225
|
+
lon: pos.lon,
|
|
226
|
+
distanceFromStartNm: Math.round(d * 10) / 10,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return waypoints;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Position at distance `d` along a leg list.
|
|
234
|
+
*
|
|
235
|
+
* @param {Array<{lat1: number, lon1: number, lat2: number, lon2: number, start: number, length: number}>} legs
|
|
236
|
+
* @param {number} d - Distance from the route start (nm)
|
|
237
|
+
* @returns {{lat: number, lon: number}}
|
|
238
|
+
*/
|
|
239
|
+
function positionAt(legs, d) {
|
|
240
|
+
for (let i = 0; i < legs.length; i++) {
|
|
241
|
+
const leg = legs[i];
|
|
242
|
+
if (d <= leg.start + leg.length || i === legs.length - 1) {
|
|
243
|
+
const f = leg.length < 1e-9 ? 0 : (d - leg.start) / leg.length;
|
|
244
|
+
return interpolatePosition(leg.lat1, leg.lon1, leg.lat2, leg.lon2, f);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const last = legs[legs.length - 1];
|
|
248
|
+
return { lat: last.lat2, lon: last.lon2 };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Total length of a route geometry.
|
|
253
|
+
*
|
|
254
|
+
* @param {number[][]} coordinates - [[lon, lat], ...]
|
|
255
|
+
* @returns {number} Distance in nautical miles
|
|
256
|
+
*/
|
|
257
|
+
function routeDistanceNm(coordinates) {
|
|
258
|
+
let total = 0;
|
|
259
|
+
for (let i = 1; i < (coordinates?.length ?? 0); i++) {
|
|
260
|
+
total += distanceNm(
|
|
261
|
+
coordinates[i - 1][1],
|
|
262
|
+
coordinates[i - 1][0],
|
|
263
|
+
coordinates[i][1],
|
|
264
|
+
coordinates[i][0],
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return Math.round(total * 10) / 10;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Dew point from temperature and relative humidity (Magnus formula,
|
|
272
|
+
* a=17.62 b=243.12 — accurate enough for the K-index).
|
|
273
|
+
*
|
|
274
|
+
* @param {number} tempC
|
|
275
|
+
* @param {number} rhPercent
|
|
276
|
+
* @returns {number} Dew point (°C)
|
|
277
|
+
*/
|
|
278
|
+
function dewpointC(tempC, rhPercent) {
|
|
279
|
+
const a = 17.62;
|
|
280
|
+
const b = 243.12;
|
|
281
|
+
const gamma =
|
|
282
|
+
Math.log(Math.max(rhPercent, 1) / 100) + (a * tempC) / (b + tempC);
|
|
283
|
+
return (b * gamma) / (a - gamma);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* K-index from pressure-layer temperatures and humidities:
|
|
288
|
+
* K = (T850 − T500) + Td850 − (T700 − Td700). Thunderstorm potential
|
|
289
|
+
* grows above ~28 (SPEC §6.2 convective warnings use 28).
|
|
290
|
+
*
|
|
291
|
+
* @param {object} levels
|
|
292
|
+
* @param {number} levels.t850 - °C
|
|
293
|
+
* @param {number} levels.t700 - °C
|
|
294
|
+
* @param {number} levels.t500 - °C
|
|
295
|
+
* @param {number} levels.rh850 - %
|
|
296
|
+
* @param {number} levels.rh700 - %
|
|
297
|
+
* @returns {number|null} K-index, null on missing inputs
|
|
298
|
+
*/
|
|
299
|
+
function kIndex({ t850, t700, t500, rh850, rh700 }) {
|
|
300
|
+
if (
|
|
301
|
+
[t850, t700, t500, rh850, rh700].some(
|
|
302
|
+
(v) => typeof v !== "number" || !Number.isFinite(v),
|
|
303
|
+
)
|
|
304
|
+
) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
return t850 - t500 + dewpointC(t850, rh850) - (t700 - dewpointC(t700, rh700));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Fetch helper with per-attempt timeout and retry with backoff on
|
|
312
|
+
* 429/5xx/network errors (an uplink that blackholes or rate-limits
|
|
313
|
+
* must not wedge a fetch window).
|
|
314
|
+
*
|
|
315
|
+
* @param {string} url
|
|
316
|
+
* @param {typeof fetch} fetchImpl
|
|
317
|
+
* @param {number} timeoutMs
|
|
318
|
+
* @param {number} [tries=3]
|
|
319
|
+
* @returns {Promise<object>} Parsed JSON
|
|
320
|
+
*/
|
|
321
|
+
async function fetchJson(url, fetchImpl, timeoutMs, tries = 3) {
|
|
322
|
+
let lastError;
|
|
323
|
+
// Non-ok bodies often carry the actionable part (Open-Meteo 400s
|
|
324
|
+
// carry a `reason`); surface a snippet in the error so the plugin
|
|
325
|
+
// status says WHY, not just which URL
|
|
326
|
+
const describe = async (response) => {
|
|
327
|
+
let detail = "";
|
|
328
|
+
try {
|
|
329
|
+
const body = await response.text();
|
|
330
|
+
const parsed = JSON.parse(body);
|
|
331
|
+
detail = parsed.reason ?? parsed.message ?? body;
|
|
332
|
+
} catch {
|
|
333
|
+
detail = "";
|
|
334
|
+
}
|
|
335
|
+
detail = String(detail).slice(0, 200);
|
|
336
|
+
return `${url} returned ${response.status}${detail ? `: ${detail}` : ""}`;
|
|
337
|
+
};
|
|
338
|
+
for (let attempt = 0; attempt < tries; attempt++) {
|
|
339
|
+
const controller = new AbortController();
|
|
340
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
341
|
+
try {
|
|
342
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
343
|
+
if (response.status === 429 || response.status >= 500) {
|
|
344
|
+
lastError = new Error(await describe(response));
|
|
345
|
+
} else if (!response.ok) {
|
|
346
|
+
throw new Error(await describe(response));
|
|
347
|
+
} else {
|
|
348
|
+
return await response.json();
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
lastError = error;
|
|
352
|
+
} finally {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
}
|
|
355
|
+
await new Promise((resolve) => setTimeout(resolve, 2000 * (attempt + 1)));
|
|
356
|
+
}
|
|
357
|
+
throw lastError;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Normalizes an Open-Meteo response covering one location per
|
|
362
|
+
* requested coordinate (a single object for one location, an array
|
|
363
|
+
* in request order for several) into an array aligned with the
|
|
364
|
+
* request.
|
|
365
|
+
*
|
|
366
|
+
* @param {object|object[]} response
|
|
367
|
+
* @param {number} count - Number of requested locations
|
|
368
|
+
* @returns {Array<object|null>}
|
|
369
|
+
*/
|
|
370
|
+
function normalizeLocations(response, count) {
|
|
371
|
+
if (response == null) {
|
|
372
|
+
return Array(count).fill(null);
|
|
373
|
+
}
|
|
374
|
+
const blocks = Array.isArray(response) ? response : [response];
|
|
375
|
+
return Array.from({ length: count }, (_, i) => blocks[i] ?? null);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Aligns one location's hourly arrays into a time → value lookup.
|
|
380
|
+
*
|
|
381
|
+
* @param {object|null} block - Open-Meteo location block
|
|
382
|
+
* @param {string[]} keys - Hourly variable names
|
|
383
|
+
* @returns {Map<string, Object<string, number|null>>}
|
|
384
|
+
*/
|
|
385
|
+
function hourlyLookup(block, keys) {
|
|
386
|
+
const map = new Map();
|
|
387
|
+
if (!block?.hourly?.time) {
|
|
388
|
+
return map;
|
|
389
|
+
}
|
|
390
|
+
const { time } = block.hourly;
|
|
391
|
+
for (let i = 0; i < time.length; i++) {
|
|
392
|
+
const row = {};
|
|
393
|
+
for (const key of keys) {
|
|
394
|
+
const values = block.hourly[key];
|
|
395
|
+
row[key] = values ? (values[i] ?? null) : null;
|
|
396
|
+
}
|
|
397
|
+
map.set(parseUtcTimestamp(time[i]).toISOString(), row);
|
|
398
|
+
}
|
|
399
|
+
return map;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Number-like passthrough (Open-Meteo uses null for missing hours).
|
|
404
|
+
*
|
|
405
|
+
* @param {unknown} value
|
|
406
|
+
* @returns {number|null}
|
|
407
|
+
*/
|
|
408
|
+
function num(value) {
|
|
409
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Builds the SPEC §3.1 TimeStepForecast for one waypoint by merging
|
|
414
|
+
* the aligned forecast, wave and current lookups.
|
|
415
|
+
*
|
|
416
|
+
* @param {Map<string, object>} surfaceMap
|
|
417
|
+
* @param {Map<string, object>} marineMap
|
|
418
|
+
* @param {Map<string, object>} currentMap
|
|
419
|
+
* @returns {Array<object>} TimeStepForecast list
|
|
420
|
+
*/
|
|
421
|
+
function buildTimeSteps(surfaceMap, marineMap, currentMap) {
|
|
422
|
+
const times = new Set([...surfaceMap.keys(), ...marineMap.keys()]);
|
|
423
|
+
const steps = [];
|
|
424
|
+
for (const time of [...times].sort()) {
|
|
425
|
+
const surface = surfaceMap.get(time) ?? {};
|
|
426
|
+
const marine = marineMap.get(time) ?? {};
|
|
427
|
+
const currents = currentMap.get(time) ?? {};
|
|
428
|
+
const gust = num(surface.wind_gusts_10m);
|
|
429
|
+
const kIndexValue = kIndex({
|
|
430
|
+
t850: num(surface.temperature_850hPa),
|
|
431
|
+
t700: num(surface.temperature_700hPa),
|
|
432
|
+
t500: num(surface.temperature_500hPa),
|
|
433
|
+
rh850: num(surface.relative_humidity_850hPa),
|
|
434
|
+
rh700: num(surface.relative_humidity_700hPa),
|
|
435
|
+
});
|
|
436
|
+
// Ocean current direction convention: SMOC reports the direction
|
|
437
|
+
// the water sets TOWARD; velocity arrives in km/h.
|
|
438
|
+
const driftKmh = num(currents.ocean_current_velocity);
|
|
439
|
+
steps.push({
|
|
440
|
+
timestamp: time,
|
|
441
|
+
surface: {
|
|
442
|
+
tws: num(surface.wind_speed_10m),
|
|
443
|
+
twd: num(surface.wind_direction_10m),
|
|
444
|
+
mslp: num(surface.pressure_msl),
|
|
445
|
+
gust,
|
|
446
|
+
},
|
|
447
|
+
marine: {
|
|
448
|
+
hsCombined: num(marine.wave_height),
|
|
449
|
+
tpCombined: num(marine.wave_peak_period),
|
|
450
|
+
dirCombined: num(marine.wave_direction),
|
|
451
|
+
hsSwell: num(marine.swell_wave_height),
|
|
452
|
+
tpSwell: num(marine.swell_wave_period),
|
|
453
|
+
dirSwell: num(marine.swell_wave_direction),
|
|
454
|
+
hsWindSea: num(marine.wind_wave_height),
|
|
455
|
+
tpWindSea: num(marine.wind_wave_period),
|
|
456
|
+
dirWindSea: num(marine.wind_wave_direction),
|
|
457
|
+
},
|
|
458
|
+
upperAir: {
|
|
459
|
+
cape: num(surface.cape),
|
|
460
|
+
kIndex: kIndexValue != null ? Math.round(kIndexValue * 10) / 10 : null,
|
|
461
|
+
rh700: num(surface.relative_humidity_700hPa),
|
|
462
|
+
wind850kts: num(surface.wind_speed_850hPa),
|
|
463
|
+
},
|
|
464
|
+
current: {
|
|
465
|
+
drift:
|
|
466
|
+
driftKmh != null ? Math.round((driftKmh / 1.852) * 100) / 100 : null,
|
|
467
|
+
set: num(currents.ocean_current_direction),
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return steps;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Fetches weather along the route waypoints and assembles the
|
|
476
|
+
* UnifiedWeatherPayload (SPEC §3.1).
|
|
477
|
+
*
|
|
478
|
+
* @param {object} params
|
|
479
|
+
* @param {Array<{lat: number, lon: number, distanceFromStartNm: number}>} params.waypoints
|
|
480
|
+
* From {@link sampleRoutePoints}
|
|
481
|
+
* @param {number} [params.forecastDays] - Forecast horizon (default
|
|
482
|
+
* {@link DEFAULT_FORECAST_DAYS})
|
|
483
|
+
* @param {number} [params.timeoutMs] - Per-attempt timeout
|
|
484
|
+
* @param {typeof fetch} [params.fetchImpl] - Fetch implementation (tests)
|
|
485
|
+
* @returns {Promise<object>} UnifiedWeatherPayload
|
|
486
|
+
*/
|
|
487
|
+
async function fetchWeatherAlongTrack({
|
|
488
|
+
waypoints,
|
|
489
|
+
forecastDays = DEFAULT_FORECAST_DAYS,
|
|
490
|
+
timeoutMs = 30000,
|
|
491
|
+
fetchImpl = fetch,
|
|
492
|
+
}) {
|
|
493
|
+
if (!Array.isArray(waypoints) || waypoints.length === 0) {
|
|
494
|
+
throw new Error("No waypoints to fetch weather for");
|
|
495
|
+
}
|
|
496
|
+
const lats = waypoints.map((w) => w.lat.toFixed(3)).join(",");
|
|
497
|
+
const lons = waypoints.map((w) => w.lon.toFixed(3)).join(",");
|
|
498
|
+
|
|
499
|
+
const forecastUrl =
|
|
500
|
+
`${FORECAST_URL}?latitude=${lats}&longitude=${lons}` +
|
|
501
|
+
`&hourly=${FORECAST_HOURLY}&forecast_days=${forecastDays}&timezone=GMT&wind_speed_unit=kn`;
|
|
502
|
+
const wavesUrl =
|
|
503
|
+
`${MARINE_URL}?latitude=${lats}&longitude=${lons}` +
|
|
504
|
+
`&hourly=${WAVE_HOURLY}&forecast_days=${forecastDays}&timezone=GMT&models=ncep_gfswave025`;
|
|
505
|
+
const currentsUrl =
|
|
506
|
+
`${MARINE_URL}?latitude=${lats}&longitude=${lons}` +
|
|
507
|
+
`&hourly=${CURRENT_HOURLY}&forecast_days=${forecastDays}&timezone=GMT`;
|
|
508
|
+
|
|
509
|
+
// The forecast is the core; sea and current degrade gracefully.
|
|
510
|
+
const forecastResponse = await fetchJson(forecastUrl, fetchImpl, timeoutMs);
|
|
511
|
+
const [wavesResponse, currentsResponse] = await Promise.all([
|
|
512
|
+
fetchJson(wavesUrl, fetchImpl, timeoutMs).catch(() => null),
|
|
513
|
+
fetchJson(currentsUrl, fetchImpl, timeoutMs).catch(() => null),
|
|
514
|
+
]);
|
|
515
|
+
|
|
516
|
+
const forecastBlocks = normalizeLocations(forecastResponse, waypoints.length);
|
|
517
|
+
const waveBlocks = normalizeLocations(wavesResponse, waypoints.length);
|
|
518
|
+
const currentBlocks = normalizeLocations(currentsResponse, waypoints.length);
|
|
519
|
+
|
|
520
|
+
const payloadWaypoints = waypoints.map((waypoint, index) => {
|
|
521
|
+
const surfaceMap = hourlyLookup(forecastBlocks[index], [
|
|
522
|
+
"wind_speed_10m",
|
|
523
|
+
"wind_direction_10m",
|
|
524
|
+
"wind_gusts_10m",
|
|
525
|
+
"pressure_msl",
|
|
526
|
+
"cape",
|
|
527
|
+
"temperature_850hPa",
|
|
528
|
+
"temperature_700hPa",
|
|
529
|
+
"temperature_500hPa",
|
|
530
|
+
"relative_humidity_850hPa",
|
|
531
|
+
"relative_humidity_700hPa",
|
|
532
|
+
"wind_speed_850hPa",
|
|
533
|
+
]);
|
|
534
|
+
const marineMap = hourlyLookup(waveBlocks[index], [
|
|
535
|
+
"wave_height",
|
|
536
|
+
"wave_direction",
|
|
537
|
+
"wave_peak_period",
|
|
538
|
+
"wind_wave_height",
|
|
539
|
+
"wind_wave_period",
|
|
540
|
+
"wind_wave_direction",
|
|
541
|
+
"swell_wave_height",
|
|
542
|
+
"swell_wave_period",
|
|
543
|
+
"swell_wave_direction",
|
|
544
|
+
]);
|
|
545
|
+
const currentMap = hourlyLookup(currentBlocks[index], [
|
|
546
|
+
"ocean_current_velocity",
|
|
547
|
+
"ocean_current_direction",
|
|
548
|
+
]);
|
|
549
|
+
return {
|
|
550
|
+
lat: waypoint.lat,
|
|
551
|
+
lon: waypoint.lon,
|
|
552
|
+
distanceFromStartNm: waypoint.distanceFromStartNm,
|
|
553
|
+
forecasts: buildTimeSteps(surfaceMap, marineMap, currentMap),
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const models = ["forecast:best_match"];
|
|
558
|
+
if (wavesResponse != null) {
|
|
559
|
+
models.push("marine:ncep_gfswave025");
|
|
560
|
+
}
|
|
561
|
+
if (currentsResponse != null) {
|
|
562
|
+
models.push("marine:smoc_currents");
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
metadata: {
|
|
566
|
+
fetchedAt: new Date().toISOString(),
|
|
567
|
+
source: "api",
|
|
568
|
+
models,
|
|
569
|
+
},
|
|
570
|
+
waypoints: payloadWaypoints,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Cache directory for a data dir.
|
|
576
|
+
*
|
|
577
|
+
* @param {string} dataDir
|
|
578
|
+
* @returns {string}
|
|
579
|
+
*/
|
|
580
|
+
function cacheDir(dataDir) {
|
|
581
|
+
return path.join(dataDir, "weather");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Sanitizes a route id for use as a cache file name.
|
|
586
|
+
*
|
|
587
|
+
* @param {string} routeId
|
|
588
|
+
* @returns {string}
|
|
589
|
+
*/
|
|
590
|
+
function routeFileId(routeId) {
|
|
591
|
+
return encodeURIComponent(String(routeId)).replace(/[.']/g, "_");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Persists a briefing payload: `latest-<route>.json` (atomic rename,
|
|
596
|
+
* always the one offline reads hit) plus a dated snapshot so the crew
|
|
597
|
+
* can see how forecasts evolve between fetch windows. Older
|
|
598
|
+
* snapshots beyond {@link KEEP_SNAPSHOTS} are pruned.
|
|
599
|
+
*
|
|
600
|
+
* @param {string} dataDir - Plugin data directory
|
|
601
|
+
* @param {string} routeId
|
|
602
|
+
* @param {object} payload - UnifiedWeatherPayload
|
|
603
|
+
* @returns {Promise<void>}
|
|
604
|
+
*/
|
|
605
|
+
async function savePayload(dataDir, routeId, payload) {
|
|
606
|
+
const dir = cacheDir(dataDir);
|
|
607
|
+
await mkdir(dir, { recursive: true });
|
|
608
|
+
const fileId = routeFileId(routeId);
|
|
609
|
+
const stamp = String(payload?.metadata?.fetchedAt ?? Date.now()).replace(
|
|
610
|
+
/[:.]/g,
|
|
611
|
+
"-",
|
|
612
|
+
);
|
|
613
|
+
await writeFile(
|
|
614
|
+
path.join(dir, `latest-${fileId}.json`),
|
|
615
|
+
JSON.stringify(payload),
|
|
616
|
+
);
|
|
617
|
+
const tmp = path.join(dir, `.tmp-${fileId}-${stamp}.json`);
|
|
618
|
+
await writeFile(tmp, JSON.stringify(payload));
|
|
619
|
+
await rename(tmp, path.join(dir, `${fileId}-${stamp}.json`));
|
|
620
|
+
|
|
621
|
+
const files = (await readdir(dir))
|
|
622
|
+
.filter((f) => f.startsWith(`${fileId}-`) && f.endsWith(".json"))
|
|
623
|
+
.sort();
|
|
624
|
+
for (const old of files.slice(
|
|
625
|
+
0,
|
|
626
|
+
Math.max(0, files.length - KEEP_SNAPSHOTS),
|
|
627
|
+
)) {
|
|
628
|
+
await rm(path.join(dir, old));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Removes a cache file (fs.rm promise helper).
|
|
634
|
+
*
|
|
635
|
+
* @param {string} file
|
|
636
|
+
* @returns {Promise<void>}
|
|
637
|
+
*/
|
|
638
|
+
function rm(file) {
|
|
639
|
+
const { unlink } = require("node:fs/promises");
|
|
640
|
+
return unlink(file).catch(() => {});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Loads the latest cached payload for a route.
|
|
645
|
+
*
|
|
646
|
+
* @param {string} dataDir - Plugin data directory
|
|
647
|
+
* @param {string} routeId
|
|
648
|
+
* @returns {Promise<object|null>} {payload, cachedAt} or null when
|
|
649
|
+
* nothing is cached
|
|
650
|
+
*/
|
|
651
|
+
async function loadPayload(dataDir, routeId) {
|
|
652
|
+
try {
|
|
653
|
+
const file = path.join(
|
|
654
|
+
cacheDir(dataDir),
|
|
655
|
+
`latest-${routeFileId(routeId)}.json`,
|
|
656
|
+
);
|
|
657
|
+
const payload = JSON.parse(await readFile(file, "utf8"));
|
|
658
|
+
return { payload, cachedAt: payload?.metadata?.fetchedAt ?? null };
|
|
659
|
+
} catch (_error) {
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Lists the routes that have a cached briefing.
|
|
666
|
+
*
|
|
667
|
+
* @param {string} dataDir - Plugin data directory
|
|
668
|
+
* @returns {Promise<Array<{routeId: string, cachedAt: string|null}>>}
|
|
669
|
+
*/
|
|
670
|
+
async function listCachedRoutes(dataDir) {
|
|
671
|
+
let files = [];
|
|
672
|
+
try {
|
|
673
|
+
files = await readdir(cacheDir(dataDir));
|
|
674
|
+
} catch (_error) {
|
|
675
|
+
return [];
|
|
676
|
+
}
|
|
677
|
+
return files
|
|
678
|
+
.filter((f) => f.startsWith("latest-") && f.endsWith(".json"))
|
|
679
|
+
.map(async (f) => {
|
|
680
|
+
const routeId = decodeURIComponent(
|
|
681
|
+
f.slice("latest-".length, -".json".length),
|
|
682
|
+
);
|
|
683
|
+
try {
|
|
684
|
+
const payload = JSON.parse(
|
|
685
|
+
await readFile(path.join(cacheDir(dataDir), f), "utf8"),
|
|
686
|
+
);
|
|
687
|
+
return { routeId, cachedAt: payload?.metadata?.fetchedAt ?? null };
|
|
688
|
+
} catch (_error) {
|
|
689
|
+
return { routeId, cachedAt: null };
|
|
690
|
+
}
|
|
691
|
+
})
|
|
692
|
+
.reduce(
|
|
693
|
+
async (acc, promise) => [...(await acc), await promise],
|
|
694
|
+
Promise.resolve([]),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
module.exports = {
|
|
699
|
+
DEFAULT_FORECAST_DAYS,
|
|
700
|
+
DEFAULT_SAMPLE_INTERVAL_NM,
|
|
701
|
+
KEEP_SNAPSHOTS,
|
|
702
|
+
FORECAST_URL,
|
|
703
|
+
MARINE_URL,
|
|
704
|
+
distanceNm,
|
|
705
|
+
interpolatePosition,
|
|
706
|
+
sampleRoutePoints,
|
|
707
|
+
routeDistanceNm,
|
|
708
|
+
dewpointC,
|
|
709
|
+
kIndex,
|
|
710
|
+
parseUtcTimestamp,
|
|
711
|
+
normalizeLocations,
|
|
712
|
+
buildTimeSteps,
|
|
713
|
+
fetchWeatherAlongTrack,
|
|
714
|
+
savePayload,
|
|
715
|
+
loadPayload,
|
|
716
|
+
listCachedRoutes,
|
|
717
|
+
};
|