@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
package/public/polar.mjs
ADDED
|
@@ -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
|
+
}
|