@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.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Polar performance lookup for the simulation (SPEC §5.1).
3
+ *
4
+ * Consumes the vessel's canonical `polars` resource table — the same
5
+ * contract signalk-polar-management publishes and signalk-polar-tools
6
+ * (dead reckoning, energy predictor) consume:
7
+ *
8
+ * { kind: 'polarTable',
9
+ * axes: { tws: [m/s], twa: [rad] },
10
+ * values: { boatSpeedMatrix: [twsIdx][twaIdx] },
11
+ * symmetry: { portStarboardSymmetric: true } }
12
+ *
13
+ * Interpolation semantics match the other consumers exactly:
14
+ *
15
+ * - bilinear over the TWS × TWA grid, port/starboard symmetric via
16
+ * |TWA| when the flag is set;
17
+ * - TWA below the table's closest-winded angle scales linearly toward
18
+ * zero at head-to-wind; above the last column clamps (dead run);
19
+ * - TWS below the first row scales linearly toward zero at calm; above
20
+ * the last row clamps (hull speed does not grow with more wind).
21
+ *
22
+ * The worker receives the table (resolved from `polars.activePolar`
23
+ * by the main thread or the plugin's `/api/polar` route) together with
24
+ * the published `polars.performanceFactor`. A missing or malformed
25
+ * table falls back to the built-in default below, so a fresh install
26
+ * still routes sensibly.
27
+ *
28
+ * The simulation runs in knots while the table is SI; conversion
29
+ * happens here, at the boundary.
30
+ *
31
+ * @module polar
32
+ */
33
+
34
+ /**
35
+ * Knots per m/s.
36
+ */
37
+ const MS_TO_KN = 1.943844;
38
+
39
+ const DEG = Math.PI / 180;
40
+
41
+ const KN_TO_MS = 0.514444;
42
+
43
+ /**
44
+ * Default fallback polar in the canonical format: a conservative
45
+ * cruising monohull (Lille Ø, 9.4 m waterline). Authored in knots and
46
+ * converted to the table's SI units so the source stays readable.
47
+ */
48
+ const FALLBACK_TWS_KN = [4, 6, 8, 10, 12, 15, 20, 25];
49
+ const FALLBACK_TWA_DEG = [0, 20, 30, 40, 60, 90, 120, 150, 180];
50
+ const FALLBACK_STW_KN = [
51
+ // 0 20 30 40 60 90 120 150 180
52
+ [0, 0.0, 1.5, 2.2, 2.8, 3.0, 2.8, 2.0, 0.0], // 4 kn
53
+ [0, 2.0, 3.0, 3.8, 4.4, 4.6, 4.4, 3.6, 1.5], // 6 kn
54
+ [0, 3.0, 4.2, 5.0, 5.6, 5.8, 5.6, 4.8, 2.5], // 8 kn
55
+ [0, 3.8, 5.0, 5.8, 6.4, 6.6, 6.4, 5.6, 3.2], // 10 kn
56
+ [0, 4.4, 5.6, 6.4, 7.0, 7.2, 7.0, 6.2, 3.8], // 12 kn
57
+ [0, 5.0, 6.2, 7.0, 7.6, 7.8, 7.6, 6.8, 4.4], // 15 kn
58
+ [0, 5.6, 6.8, 7.6, 8.2, 8.4, 8.2, 7.4, 5.0], // 20 kn
59
+ [0, 6.0, 7.0, 7.8, 8.4, 8.5, 8.3, 7.5, 5.2], // 25 kn
60
+ ];
61
+
62
+ /**
63
+ * Built-in default polar table (canonical shape, SI units).
64
+ */
65
+ export const DEFAULT_POLAR_TABLE = {
66
+ kind: "polarTable",
67
+ axes: {
68
+ tws: FALLBACK_TWS_KN.map((kn) => kn * KN_TO_MS),
69
+ twa: FALLBACK_TWA_DEG.map((deg) => deg * DEG),
70
+ },
71
+ values: {
72
+ boatSpeedMatrix: FALLBACK_STW_KN.map((row) =>
73
+ row.map((kn) => kn * KN_TO_MS),
74
+ ),
75
+ },
76
+ symmetry: { portStarboardSymmetric: true },
77
+ };
78
+
79
+ /**
80
+ * Extracts the resource id from a `polars.activePolar` value. Accepts
81
+ * the plain delta form (`{href: "/resources/polars/<id>"}`), the
82
+ * `getSelfPath`-wrapped form (`{value: {href: ...}}`), and full API
83
+ * URLs — the same contract as signalk-polar-management consumers.
84
+ *
85
+ * @param {unknown} value - Signal K value of `polars.activePolar`
86
+ * @returns {string|null} Resource id, or null when unset/malformed
87
+ */
88
+ export function parseActivePolarId(value) {
89
+ if (!value || typeof value !== "object") {
90
+ return null;
91
+ }
92
+ const v =
93
+ value.value && typeof value.value === "object" ? value.value : value;
94
+ const href = typeof v.href === "string" ? v.href : null;
95
+ if (!href) {
96
+ return null;
97
+ }
98
+ const match = href.match(/\/resources\/polars\/([^/?#]+)/);
99
+ return match ? decodeURIComponent(match[1]) : null;
100
+ }
101
+
102
+ /**
103
+ * Reads the `polars.performanceFactor` delta value, clamped to
104
+ * [0, 1] (1 when unset/invalid — polar-management publishes 1 as its
105
+ * default).
106
+ *
107
+ * @param {unknown} value
108
+ * @returns {number}
109
+ */
110
+ export function parsePerformanceFactor(value) {
111
+ const v =
112
+ value && typeof value === "object" && typeof value.value === "number"
113
+ ? value.value
114
+ : typeof value === "number"
115
+ ? value
116
+ : null;
117
+ if (v == null || !Number.isFinite(v)) {
118
+ return 1;
119
+ }
120
+ return Math.min(1, Math.max(0, v));
121
+ }
122
+
123
+ /**
124
+ * Validates the shape of a canonical polar table enough for
125
+ * interpolation: ascending non-empty axes and a rectangular matrix
126
+ * matching the axes' dimensions.
127
+ *
128
+ * @param {unknown} table - Candidate polar resource
129
+ * @returns {boolean}
130
+ */
131
+ export function isInterpolatableTable(table) {
132
+ if (!table || typeof table !== "object") {
133
+ return false;
134
+ }
135
+ const { axes, values } = table;
136
+ if (!Array.isArray(axes?.tws) || !Array.isArray(axes?.twa)) {
137
+ return false;
138
+ }
139
+ if (axes.tws.length < 1 || axes.twa.length < 1) {
140
+ return false;
141
+ }
142
+ if (!Array.isArray(values?.boatSpeedMatrix)) {
143
+ return false;
144
+ }
145
+ if (values.boatSpeedMatrix.length !== axes.tws.length) {
146
+ return false;
147
+ }
148
+ for (let i = 0; i < axes.tws.length; i++) {
149
+ const row = values.boatSpeedMatrix[i];
150
+ if (!Array.isArray(row) || row.length !== axes.twa.length) {
151
+ return false;
152
+ }
153
+ for (let j = 0; j < axes.twa.length; j++) {
154
+ if (typeof row[j] !== "number" || !Number.isFinite(row[j])) {
155
+ return false;
156
+ }
157
+ }
158
+ if (i > 0 && !(axes.tws[i] > axes.tws[i - 1])) {
159
+ return false;
160
+ }
161
+ }
162
+ for (let j = 1; j < axes.twa.length; j++) {
163
+ if (!(axes.twa[j] > axes.twa[j - 1])) {
164
+ return false;
165
+ }
166
+ }
167
+ return true;
168
+ }
169
+
170
+ /**
171
+ * First index whose axis value is >= target.
172
+ *
173
+ * @param {number[]} axis - Ascending axis values
174
+ * @param {number} target
175
+ * @returns {number} Index in [0, axis.length]
176
+ */
177
+ function upperIndex(axis, target) {
178
+ let lo = 0;
179
+ let hi = axis.length;
180
+ while (lo < hi) {
181
+ const mid = (lo + hi) >> 1;
182
+ if (axis[mid] < target) {
183
+ lo = mid + 1;
184
+ } else {
185
+ hi = mid;
186
+ }
187
+ }
188
+ return lo;
189
+ }
190
+
191
+ /**
192
+ * Plain bilinear interpolation inside the grid bounds.
193
+ *
194
+ * @param {object} table - Validated canonical table
195
+ * @param {number} twsMs - Clamped TWS (m/s)
196
+ * @param {number} twaRad - Clamped TWA (rad, ≥ 0 with symmetry)
197
+ * @returns {number} Boat speed (m/s)
198
+ */
199
+ function bilinear(table, twsMs, twaRad) {
200
+ const { tws, twa } = table.axes;
201
+ const matrix = table.values.boatSpeedMatrix;
202
+ const i = upperIndex(tws, twsMs);
203
+ const j = upperIndex(twa, twaRad);
204
+ const i0 = Math.max(0, i - 1);
205
+ const j0 = Math.max(0, j - 1);
206
+ const i1 = Math.min(tws.length - 1, i0 + 1);
207
+ const j1 = Math.min(twa.length - 1, j0 + 1);
208
+
209
+ const tw = tws[i1] > tws[i0] ? (twsMs - tws[i0]) / (tws[i1] - tws[i0]) : 0;
210
+ const ta = twa[j1] > twa[j0] ? (twaRad - twa[j0]) / (twa[j1] - twa[j0]) : 0;
211
+
212
+ const s00 = matrix[i0][j0];
213
+ const s01 = matrix[i0][j1];
214
+ const s10 = matrix[i1][j0];
215
+ const s11 = matrix[i1][j1];
216
+ return (
217
+ s00 * (1 - tw) * (1 - ta) +
218
+ s01 * (1 - tw) * ta +
219
+ s10 * tw * (1 - ta) +
220
+ s11 * tw * ta
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Boat speed through water from a polar table at given conditions,
226
+ * in knots. Out-of-grid and unit handling per the module docs; a null
227
+ * table falls back to {@link DEFAULT_POLAR_TABLE}, a malformed table
228
+ * yields 0 (the boat then drifts rather than sailing on fiction).
229
+ *
230
+ * @param {object|null} table - Canonical polar resource (or null)
231
+ * @param {number} twsKnots - True wind speed (kn)
232
+ * @param {number} twaRad - True wind angle (rad, signed ok)
233
+ * @param {object} [options]
234
+ * @param {number} [options.performanceFactor=1] - Derating
235
+ * multiplier from `polars.performanceFactor`, clamped to [0, 1]
236
+ * @returns {number} Speed through water (kn)
237
+ */
238
+ export function polarSpeedKnots(
239
+ table,
240
+ twsKnots,
241
+ twaRad,
242
+ { performanceFactor = 1 } = {},
243
+ ) {
244
+ const active = table ?? DEFAULT_POLAR_TABLE;
245
+ if (!isInterpolatableTable(active)) {
246
+ return 0;
247
+ }
248
+ if (!Number.isFinite(twsKnots) || !Number.isFinite(twaRad) || twsKnots <= 0) {
249
+ return 0;
250
+ }
251
+ const pf = Number.isFinite(performanceFactor)
252
+ ? Math.min(1, Math.max(0, performanceFactor))
253
+ : 1;
254
+
255
+ const symmetric = active.symmetry?.portStarboardSymmetric !== false;
256
+ let twa = symmetric ? Math.abs(twaRad) : twaRad;
257
+
258
+ // TWS axis: below the table scales toward zero at calm, above clamps
259
+ let twsScale = 1;
260
+ let twsMs = twsKnots / MS_TO_KN;
261
+ const twsAxis = active.axes.tws;
262
+ if (twsMs < twsAxis[0]) {
263
+ twsScale = twsMs / twsAxis[0];
264
+ twsMs = twsAxis[0];
265
+ } else if (twsMs > twsAxis[twsAxis.length - 1]) {
266
+ twsMs = twsAxis[twsAxis.length - 1];
267
+ }
268
+
269
+ // TWA axis: pinch below the closest-winded angle, clamp on the run
270
+ let twaScale = 1;
271
+ const twaAxis = active.axes.twa;
272
+ if (twa < 0) {
273
+ twa = 0;
274
+ }
275
+ if (twa < twaAxis[0]) {
276
+ twaScale = twaAxis[0] > 0 ? twa / twaAxis[0] : 0;
277
+ twa = twaAxis[0];
278
+ } else if (twa > twaAxis[twaAxis.length - 1]) {
279
+ twa = twaAxis[twaAxis.length - 1];
280
+ }
281
+
282
+ const speedMs = bilinear(active, twsMs, twa);
283
+ return Math.max(0, speedMs * twsScale * twaScale * pf * MS_TO_KN);
284
+ }
285
+
286
+ /**
287
+ * Great-circle initial bearing from one position to another.
288
+ *
289
+ * @param {number} lat1 - Degrees
290
+ * @param {number} lon1 - Degrees
291
+ * @param {number} lat2 - Degrees
292
+ * @param {number} lon2 - Degrees
293
+ * @returns {number} Bearing (radians, true)
294
+ */
295
+ export function bearingRad(lat1, lon1, lat2, lon2) {
296
+ const φ1 = lat1 * DEG;
297
+ const φ2 = lat2 * DEG;
298
+ const dλ = (lon2 - lon1) * DEG;
299
+ const y = Math.sin(dλ) * Math.cos(φ2);
300
+ const x =
301
+ Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(dλ);
302
+ return Math.atan2(y, x);
303
+ }