signalk-chiplog 2.6.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -1
- package/README.md +63 -49
- package/index.js +110 -27
- package/lib/database.js +54 -1
- package/lib/detection.js +4 -16
- package/lib/event-watcher.js +142 -2
- package/lib/events.js +2 -1
- package/lib/history-api.js +401 -0
- package/lib/history-series.js +138 -0
- package/lib/influx-history.js +27 -102
- package/lib/replay-job.js +48 -26
- package/package.json +4 -1
- package/public/js/animation/boat-model.mjs +534 -171
- package/public/js/animation/camera3d.mjs +5 -2
- package/public/js/i18n.mjs +8 -4
- package/public/js/log-lines.mjs +5 -0
- package/public/vendor/three.min.mjs +172 -172
package/lib/event-watcher.js
CHANGED
|
@@ -4,7 +4,16 @@ const { CRITICAL_STATES } = require('./events');
|
|
|
4
4
|
|
|
5
5
|
const METRES_PER_SECOND_PER_KNOT = 1852 / 3600;
|
|
6
6
|
|
|
7
|
-
const EVENT_DEFAULTS = {
|
|
7
|
+
const EVENT_DEFAULTS = {
|
|
8
|
+
windSpeedThresholds: [20, 30],
|
|
9
|
+
pressureDropThreshold: 4,
|
|
10
|
+
headingChangeEnabled: true,
|
|
11
|
+
headingChangeThreshold: 30,
|
|
12
|
+
headingChangeTolerance: 10,
|
|
13
|
+
headingChangeHoldSeconds: 60,
|
|
14
|
+
headingChangeMinSpeed: 2,
|
|
15
|
+
headingChangeCooldownMinutes: 5
|
|
16
|
+
};
|
|
8
17
|
|
|
9
18
|
const CHECK_INTERVAL_MS = 1000;
|
|
10
19
|
const NOTIFICATION_METADATA_KEYS = new Set([
|
|
@@ -27,6 +36,11 @@ const PRESSURE_WINDOW_MS = 3 * 60 * 60 * 1000;
|
|
|
27
36
|
const PRESSURE_SAMPLE_INTERVAL_MS = 60 * 1000;
|
|
28
37
|
const PRESSURE_MAX_AGE_MS = 15 * 60 * 1000;
|
|
29
38
|
|
|
39
|
+
// Heading, like the same reading elsewhere (track-recorder.js): current
|
|
40
|
+
// within this age, magnetic variation allowed to lag further behind.
|
|
41
|
+
const HEADING_MAX_AGE_MS = 2 * 60 * 1000;
|
|
42
|
+
const VARIATION_MAX_AGE_MS = 15 * 60 * 1000;
|
|
43
|
+
|
|
30
44
|
function iso(ms) {
|
|
31
45
|
return new Date(ms).toISOString();
|
|
32
46
|
}
|
|
@@ -35,6 +49,27 @@ function isFiniteNumber(value) {
|
|
|
35
49
|
return typeof value === 'number' && Number.isFinite(value);
|
|
36
50
|
}
|
|
37
51
|
|
|
52
|
+
function toRadians(degrees) {
|
|
53
|
+
return (degrees * Math.PI) / 180;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeAngle(radians) {
|
|
57
|
+
return ((radians % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function headingDifference(a, b) {
|
|
61
|
+
const diff = Math.abs(a - b) % (2 * Math.PI);
|
|
62
|
+
return diff > Math.PI ? 2 * Math.PI - diff : diff;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The mean of a set of headings, around the compass rather than along a line
|
|
66
|
+
// -- so 350° and 010° average to 000°, not 180°.
|
|
67
|
+
function circularMean(headings) {
|
|
68
|
+
const sumSin = headings.reduce((sum, heading) => sum + Math.sin(heading), 0);
|
|
69
|
+
const sumCos = headings.reduce((sum, heading) => sum + Math.cos(heading), 0);
|
|
70
|
+
return normalizeAngle(Math.atan2(sumSin, sumCos));
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
function collectNotifications(node, path, found, depth = 0) {
|
|
39
74
|
if (!node || typeof node !== 'object' || depth > 12) {
|
|
40
75
|
return;
|
|
@@ -63,11 +98,16 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
|
|
|
63
98
|
const windAbove = new Map();
|
|
64
99
|
const pressureSamples = [];
|
|
65
100
|
let pressureFalling = false;
|
|
101
|
+
// The last confirmed heading, and the run of samples building towards the
|
|
102
|
+
// next one; both null until the vessel is under way with a fresh heading.
|
|
103
|
+
let headingRef = null;
|
|
104
|
+
let headingCandidate = null;
|
|
105
|
+
let lastHeadingChangeAt = undefined;
|
|
66
106
|
|
|
67
107
|
// An anchor alarm goes off between passages, at the last one's destination.
|
|
68
108
|
function entryFor(type, position) {
|
|
69
109
|
const active = activeEntryId(db);
|
|
70
|
-
if (active !== null || type === 'autopilot') {
|
|
110
|
+
if (active !== null || type === 'autopilot' || type === 'heading_change') {
|
|
71
111
|
return active;
|
|
72
112
|
}
|
|
73
113
|
return entryNearArrival(db, position);
|
|
@@ -305,6 +345,105 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
|
|
|
305
345
|
}
|
|
306
346
|
}
|
|
307
347
|
|
|
348
|
+
// Read once, the first time it is needed, from the log itself -- so a
|
|
349
|
+
// restart neither forgets the cooldown nor logs a change too soon after one
|
|
350
|
+
// made just before it stopped.
|
|
351
|
+
function lastHeadingChangeTime() {
|
|
352
|
+
if (lastHeadingChangeAt === undefined) {
|
|
353
|
+
const row = db
|
|
354
|
+
.prepare(
|
|
355
|
+
"SELECT time FROM events WHERE type = 'heading_change' ORDER BY time DESC, id DESC LIMIT 1"
|
|
356
|
+
)
|
|
357
|
+
.get();
|
|
358
|
+
lastHeadingChangeAt = row ? Date.parse(row.time) : null;
|
|
359
|
+
}
|
|
360
|
+
return lastHeadingChangeAt;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function readHeading(now) {
|
|
364
|
+
const headingTrue = readFresh('navigation.headingTrue', now, HEADING_MAX_AGE_MS);
|
|
365
|
+
if (isFiniteNumber(headingTrue)) {
|
|
366
|
+
return headingTrue;
|
|
367
|
+
}
|
|
368
|
+
const headingMagnetic = readFresh('navigation.headingMagnetic', now, HEADING_MAX_AGE_MS);
|
|
369
|
+
const variation = readFresh('navigation.magneticVariation', now, VARIATION_MAX_AGE_MS);
|
|
370
|
+
return isFiniteNumber(headingMagnetic) && isFiniteNumber(variation)
|
|
371
|
+
? normalizeAngle(headingMagnetic + variation)
|
|
372
|
+
: null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// A change counts once it is at least headingChangeThreshold away from the
|
|
376
|
+
// last logged heading and holds there, within headingChangeTolerance, for
|
|
377
|
+
// headingChangeHoldSeconds; the average over that hold is what gets logged
|
|
378
|
+
// (SPEC §4.6). Below headingChangeMinSpeed, course over ground is noise, so
|
|
379
|
+
// a turn is neither started nor kept building.
|
|
380
|
+
function checkHeadingChange(cycle) {
|
|
381
|
+
if (!settings.headingChangeEnabled) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const sog = readFresh('navigation.speedOverGround', cycle.now);
|
|
385
|
+
const minSpeed = settings.headingChangeMinSpeed * METRES_PER_SECOND_PER_KNOT;
|
|
386
|
+
if (!isFiniteNumber(sog) || sog < minSpeed) {
|
|
387
|
+
headingCandidate = null;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const heading = readHeading(cycle.now);
|
|
391
|
+
if (heading === null) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
// The first reading under way only sets where the vessel is heading, not
|
|
395
|
+
// a change from some earlier, unrelated course.
|
|
396
|
+
if (headingRef === null) {
|
|
397
|
+
headingRef = heading;
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const thresholdRad = toRadians(settings.headingChangeThreshold);
|
|
402
|
+
const toleranceRad = toRadians(settings.headingChangeTolerance);
|
|
403
|
+
|
|
404
|
+
if (!headingCandidate) {
|
|
405
|
+
if (headingDifference(heading, headingRef) >= thresholdRad) {
|
|
406
|
+
headingCandidate = { start: cycle.now, headings: [heading] };
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const candidateMean = circularMean(headingCandidate.headings);
|
|
412
|
+
if (headingDifference(heading, candidateMean) > toleranceRad) {
|
|
413
|
+
// Still swinging, or swinging back: start a fresh candidate if it is
|
|
414
|
+
// still far enough from the reference to be a change in the making,
|
|
415
|
+
// else drop it -- back within the reference is not a change at all.
|
|
416
|
+
headingCandidate =
|
|
417
|
+
headingDifference(heading, headingRef) >= thresholdRad
|
|
418
|
+
? { start: cycle.now, headings: [heading] }
|
|
419
|
+
: null;
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
headingCandidate.headings.push(heading);
|
|
424
|
+
if (cycle.now - headingCandidate.start < settings.headingChangeHoldSeconds * 1000) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const confirmed = circularMean(headingCandidate.headings);
|
|
429
|
+
const previousHeading = headingRef;
|
|
430
|
+
headingRef = confirmed;
|
|
431
|
+
headingCandidate = null;
|
|
432
|
+
|
|
433
|
+
const last = lastHeadingChangeTime();
|
|
434
|
+
if (last !== null && cycle.now - last < settings.headingChangeCooldownMinutes * 60 * 1000) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (
|
|
438
|
+
logEvent(cycle, 'heading_change', null, {
|
|
439
|
+
payload: { heading: confirmed, previousHeading },
|
|
440
|
+
snapshot: true
|
|
441
|
+
})
|
|
442
|
+
) {
|
|
443
|
+
lastHeadingChangeAt = cycle.now;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
308
447
|
return {
|
|
309
448
|
check() {
|
|
310
449
|
const now = clock();
|
|
@@ -319,6 +458,7 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
|
|
|
319
458
|
checkAutopilot(cycle);
|
|
320
459
|
checkWind(cycle);
|
|
321
460
|
checkPressure(cycle);
|
|
461
|
+
checkHeadingChange(cycle);
|
|
322
462
|
|
|
323
463
|
for (const entryId of cycle.snapshots) {
|
|
324
464
|
observe(entryId, iso(now));
|
package/lib/events.js
CHANGED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
const { Temporal } = require('@js-temporal/polyfill');
|
|
2
|
+
const { MAX_AGE_MS } = require('./detection');
|
|
3
|
+
const { createSeriesStore, underwayIntervals } = require('./history-series');
|
|
4
|
+
|
|
5
|
+
// Reads historical Signal K data back through the server's own History API
|
|
6
|
+
// (`app.getHistoryApi()`), whichever provider is registered for it --
|
|
7
|
+
// signalk-to-influxdb2, QuestDB, TimescaleDB. Unlike lib/influx-history.js
|
|
8
|
+
// this knows no storage schema at all: it asks for Signal K paths and gets
|
|
9
|
+
// Signal K values back, so it needs no database credentials of its own.
|
|
10
|
+
//
|
|
11
|
+
// It answers the same `{ preload, clear, scanMotion, readSelfPath }` contract
|
|
12
|
+
// and applies the same bounds as the InfluxDB reader -- chunked requests, a
|
|
13
|
+
// pause between them, a timeout with retries -- because the provider usually
|
|
14
|
+
// sits on the very same Raspberry Pi as Signal K itself.
|
|
15
|
+
|
|
16
|
+
// Paths Chiplog reads while replaying a passage. The History API returns
|
|
17
|
+
// Signal K values, so this list deliberately holds paths rather than storage
|
|
18
|
+
// fields.
|
|
19
|
+
const STATIC_PATHS = [
|
|
20
|
+
'navigation.position',
|
|
21
|
+
'navigation.speedOverGround',
|
|
22
|
+
'navigation.courseOverGroundTrue',
|
|
23
|
+
'navigation.headingTrue',
|
|
24
|
+
'navigation.headingMagnetic',
|
|
25
|
+
'navigation.magneticVariation',
|
|
26
|
+
'navigation.speedThroughWater',
|
|
27
|
+
'navigation.log',
|
|
28
|
+
'navigation.state',
|
|
29
|
+
'environment.wind.speedTrue',
|
|
30
|
+
'environment.wind.directionTrue',
|
|
31
|
+
'environment.wind.speedApparent',
|
|
32
|
+
'environment.wind.angleApparent',
|
|
33
|
+
'environment.depth.belowSurface',
|
|
34
|
+
'environment.depth.belowTransducer',
|
|
35
|
+
'environment.outside.pressure',
|
|
36
|
+
'environment.outside.temperature',
|
|
37
|
+
'environment.water.temperature',
|
|
38
|
+
'steering.autopilot.target',
|
|
39
|
+
'steering.autopilot.target.headingTrue',
|
|
40
|
+
'steering.autopilot.target.headingMagnetic',
|
|
41
|
+
'steering.autopilot.target.windAngleApparent',
|
|
42
|
+
'steering.autopilot.target.windAngleTrue',
|
|
43
|
+
'steering.autopilot.state',
|
|
44
|
+
'steering.autopilot.mode',
|
|
45
|
+
'steering.autopilot.engaged'
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// Same bounds as the InfluxDB reader, for the same reason: a fixed-size
|
|
49
|
+
// request keeps each answer small however long the replayed range is, and the
|
|
50
|
+
// pause gives a provider sharing the boat's Pi room to recover between them.
|
|
51
|
+
const CHUNK_MS = 2 * 60 * 60 * 1000;
|
|
52
|
+
const CHUNK_PAUSE_MS = 200;
|
|
53
|
+
const SCAN_CHUNK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
54
|
+
const SCAN_BUCKET_MS = 60 * 1000;
|
|
55
|
+
|
|
56
|
+
const DEFAULT_QUERY_TIMEOUT_SECONDS = 30;
|
|
57
|
+
const QUERY_MAX_RETRIES = 3;
|
|
58
|
+
const QUERY_RETRY_DELAY_MS = 5 * 1000;
|
|
59
|
+
|
|
60
|
+
const STATE_PATH = 'navigation.state';
|
|
61
|
+
const POSITION_PATH = 'navigation.position';
|
|
62
|
+
|
|
63
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
64
|
+
const instant = (ms) => Temporal.Instant.from(iso(ms));
|
|
65
|
+
|
|
66
|
+
function sleep(ms) {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `navigation.position` comes back as a GeoJSON `[longitude, latitude]` pair;
|
|
71
|
+
// accept the object forms a provider might answer with as well.
|
|
72
|
+
function position(value) {
|
|
73
|
+
if (Array.isArray(value) && typeof value[0] === 'number' && typeof value[1] === 'number') {
|
|
74
|
+
return { longitude: value[0], latitude: value[1] };
|
|
75
|
+
}
|
|
76
|
+
if (value && typeof value.latitude === 'number' && typeof value.longitude === 'number') {
|
|
77
|
+
return { longitude: value.longitude, latitude: value.latitude };
|
|
78
|
+
}
|
|
79
|
+
if (value && typeof value.lon === 'number' && typeof value.lat === 'number') {
|
|
80
|
+
return { longitude: value.lon, latitude: value.lat };
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createHistoryApiHistory({
|
|
86
|
+
getHistoryApi,
|
|
87
|
+
selfContext,
|
|
88
|
+
signal,
|
|
89
|
+
queryTimeoutSeconds = DEFAULT_QUERY_TIMEOUT_SECONDS,
|
|
90
|
+
retryDelayMs = QUERY_RETRY_DELAY_MS,
|
|
91
|
+
onRetry = () => {}
|
|
92
|
+
}) {
|
|
93
|
+
if (typeof getHistoryApi !== 'function') {
|
|
94
|
+
throw new Error('This Signal K server does not expose the History API to plugins');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const queryTimeoutMs = queryTimeoutSeconds * 1000;
|
|
98
|
+
const store = createSeriesStore();
|
|
99
|
+
// The engines the history holds, for the `propulsion` branch node the
|
|
100
|
+
// propulsion detector and the observation recorder enumerate.
|
|
101
|
+
const engineIds = new Set();
|
|
102
|
+
// Ranges already asked for paths, so a window and each of its extensions
|
|
103
|
+
// cost one discovery request each rather than one per loaded chunk.
|
|
104
|
+
const discoveredRanges = new Set();
|
|
105
|
+
let apiPromise;
|
|
106
|
+
let verified = null;
|
|
107
|
+
|
|
108
|
+
function checkAborted() {
|
|
109
|
+
if (signal?.aborted) {
|
|
110
|
+
throw new DOMException('Replay cancelled', 'AbortError');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function api() {
|
|
115
|
+
apiPromise ??= getHistoryApi();
|
|
116
|
+
try {
|
|
117
|
+
return await apiPromise;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new Error(`Signal K History API provider is unavailable: ${err.message}`, {
|
|
120
|
+
cause: err
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The History API takes no abort signal and promises no timeout of its own,
|
|
126
|
+
// so a provider that never answers would otherwise hang the whole replay
|
|
127
|
+
// with nothing to show for it, and cancelling would only take effect at the
|
|
128
|
+
// end of the chunk in flight. Racing the call bounds both.
|
|
129
|
+
function bounded(work) {
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
let settled = false;
|
|
132
|
+
const finish = (fn) => (value) => {
|
|
133
|
+
if (settled) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
settled = true;
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
signal?.removeEventListener('abort', onAbort);
|
|
139
|
+
fn(value);
|
|
140
|
+
};
|
|
141
|
+
const done = finish(resolve);
|
|
142
|
+
const fail = finish(reject);
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
const err = new Error(
|
|
145
|
+
`The Signal K history provider did not answer within ${queryTimeoutSeconds}s`
|
|
146
|
+
);
|
|
147
|
+
err.name = 'TimeoutError';
|
|
148
|
+
fail(err);
|
|
149
|
+
}, queryTimeoutMs);
|
|
150
|
+
const onAbort = () => fail(new DOMException('Replay cancelled', 'AbortError'));
|
|
151
|
+
if (signal?.aborted) {
|
|
152
|
+
onAbort();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
156
|
+
work().then(done, fail);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// A provider momentarily busy -- sharing its host with Signal K itself -- is
|
|
161
|
+
// retried rather than failing the whole run, exactly as an InfluxDB query
|
|
162
|
+
// timing out is. Only a timeout is retried: anything else would answer the
|
|
163
|
+
// same way again.
|
|
164
|
+
async function attempt(work) {
|
|
165
|
+
for (let retries = 0; ; retries += 1) {
|
|
166
|
+
try {
|
|
167
|
+
const answer = await bounded(work);
|
|
168
|
+
onRetry(null);
|
|
169
|
+
return answer;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (signal?.aborted || err.name !== 'TimeoutError') {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
if (retries >= QUERY_MAX_RETRIES) {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
onRetry(retries + 1, QUERY_MAX_RETRIES, err.message);
|
|
178
|
+
await sleep(retryDelayMs);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// The contexts the history actually holds, so a context matching none of
|
|
184
|
+
// them -- this server pointed at another boat's history -- is a clear error
|
|
185
|
+
// rather than a replay that runs to completion and reconstructs nothing.
|
|
186
|
+
async function verifyContext(fromMs, toMs) {
|
|
187
|
+
if (!selfContext) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
verified ??= (async () => {
|
|
191
|
+
const found = await attempt(() =>
|
|
192
|
+
api().then((history) => history.getContexts({ from: instant(fromMs), to: instant(toMs) }))
|
|
193
|
+
);
|
|
194
|
+
const contexts = new Set(found ?? []);
|
|
195
|
+
if (contexts.size > 0 && !contexts.has(selfContext)) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`No data for context "${selfContext}" in this history. ` +
|
|
198
|
+
`Found: ${[...contexts].join(', ')}. ` +
|
|
199
|
+
'Set "Vessel context (retrospective analysis)" in the plugin configuration to one of these.'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
})();
|
|
203
|
+
return verified;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function discover(fromMs, toMs) {
|
|
207
|
+
const key = `${fromMs}-${toMs}`;
|
|
208
|
+
if (discoveredRanges.has(key)) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const paths = await attempt(() =>
|
|
212
|
+
api().then((history) => history.getPaths({ from: instant(fromMs), to: instant(toMs) }))
|
|
213
|
+
);
|
|
214
|
+
for (const path of paths ?? []) {
|
|
215
|
+
const match = /^propulsion\.([^.]+)\.(revolutions|state|runTime)$/.exec(path);
|
|
216
|
+
if (match) {
|
|
217
|
+
engineIds.add(match[1]);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
discoveredRanges.add(key);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// `last` unless the caller asks otherwise, like the InfluxDB reader: the
|
|
224
|
+
// value in force at the end of a bucket is the one a replay stepping at that
|
|
225
|
+
// interval could have seen.
|
|
226
|
+
function pathSpecs(paths, aggregates) {
|
|
227
|
+
return paths.map((path) => ({
|
|
228
|
+
path,
|
|
229
|
+
aggregate: aggregates[path] ?? 'last',
|
|
230
|
+
parameter: []
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `resolution` is a number of seconds, so a bucket finer than a second
|
|
235
|
+
// cannot be asked for -- the replay never uses one (SAMPLE_INTERVAL_MS).
|
|
236
|
+
async function values(paths, fromMs, toMs, resolutionMs, aggregates = {}) {
|
|
237
|
+
checkAborted();
|
|
238
|
+
// The InfluxDB 2 provider needs the position in its own request. It also
|
|
239
|
+
// applies every aggregate in a request to every measurement, so mixing a
|
|
240
|
+
// numeric `max` with a string `last` makes it try MAX() on the string.
|
|
241
|
+
// Keep measurements with different aggregates in separate requests.
|
|
242
|
+
const groups = new Map();
|
|
243
|
+
for (const path of paths) {
|
|
244
|
+
const key = path === POSITION_PATH ? POSITION_PATH : (aggregates[path] ?? 'last');
|
|
245
|
+
const group = groups.get(key) ?? [];
|
|
246
|
+
group.push(path);
|
|
247
|
+
groups.set(key, group);
|
|
248
|
+
}
|
|
249
|
+
const results = [];
|
|
250
|
+
for (const group of groups.values()) {
|
|
251
|
+
results.push(
|
|
252
|
+
await attempt(() =>
|
|
253
|
+
api().then((history) =>
|
|
254
|
+
history.getValues({
|
|
255
|
+
context: selfContext,
|
|
256
|
+
from: instant(fromMs),
|
|
257
|
+
to: instant(toMs),
|
|
258
|
+
resolution: Math.max(1, Math.round(resolutionMs / 1000)),
|
|
259
|
+
pathSpecs: pathSpecs(group, aggregates)
|
|
260
|
+
})
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
checkAborted();
|
|
266
|
+
return results;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// A bucketed row is dated at the start of its bucket; shifting it to the end
|
|
270
|
+
// is what stops the replay seeing a reading before it was published, exactly
|
|
271
|
+
// as the InfluxDB reader does.
|
|
272
|
+
function load(result, shift) {
|
|
273
|
+
const descriptors = result?.values ?? [];
|
|
274
|
+
for (const row of result?.data ?? []) {
|
|
275
|
+
const time = Date.parse(row[0]) + shift;
|
|
276
|
+
descriptors.forEach((descriptor, index) => {
|
|
277
|
+
const value = row[index + 1];
|
|
278
|
+
store.add(
|
|
279
|
+
descriptor.path,
|
|
280
|
+
time,
|
|
281
|
+
descriptor.path === POSITION_PATH ? position(value) : value
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function preload(fromMs, toMs, onChunk, { bucketMs } = {}) {
|
|
288
|
+
await verifyContext(fromMs, toMs);
|
|
289
|
+
await discover(fromMs, toMs);
|
|
290
|
+
const paths = [
|
|
291
|
+
...STATIC_PATHS,
|
|
292
|
+
...[...engineIds].flatMap((id) => [
|
|
293
|
+
`propulsion.${id}.revolutions`,
|
|
294
|
+
`propulsion.${id}.state`,
|
|
295
|
+
`propulsion.${id}.runTime`
|
|
296
|
+
])
|
|
297
|
+
];
|
|
298
|
+
const resolutionMs = bucketMs ?? 1000;
|
|
299
|
+
for (let start = fromMs; start < toMs; start += CHUNK_MS) {
|
|
300
|
+
const end = Math.min(start + CHUNK_MS, toMs);
|
|
301
|
+
for (const result of await values(paths, start, end, resolutionMs)) {
|
|
302
|
+
load(result, resolutionMs);
|
|
303
|
+
}
|
|
304
|
+
onChunk?.(end, toMs);
|
|
305
|
+
if (end < toMs) {
|
|
306
|
+
await sleep(CHUNK_PAUSE_MS);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
store.sort();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// The rows of one scan request, as `{ path, time, value }`, dated at the
|
|
313
|
+
// start of their bucket -- `underwayIntervals` allows for the bucket itself.
|
|
314
|
+
function scanRows(result) {
|
|
315
|
+
const descriptors = result?.values ?? [];
|
|
316
|
+
const rows = [];
|
|
317
|
+
for (const row of result?.data ?? []) {
|
|
318
|
+
const time = Date.parse(row[0]);
|
|
319
|
+
descriptors.forEach((descriptor, index) => {
|
|
320
|
+
rows.push({ path: descriptor.path, time, value: row[index + 1] });
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return rows;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// When the vessel may have been moving within [fromMs, toMs]: the highest
|
|
327
|
+
// speed of each minute -- `max`, not a mean, so a minute of motion is never
|
|
328
|
+
// averaged away and a departure lost with it -- and navigation.state,
|
|
329
|
+
// whatever source published it.
|
|
330
|
+
async function scanMotion(fromMs, toMs, { stoppedSpeed }, onChunk) {
|
|
331
|
+
await verifyContext(fromMs, toMs);
|
|
332
|
+
const intervals = [];
|
|
333
|
+
const states = [];
|
|
334
|
+
|
|
335
|
+
// The state already in force when the range opens: without it, a passage
|
|
336
|
+
// begun before `from` is missed entirely.
|
|
337
|
+
const [seed] = await values(
|
|
338
|
+
[STATE_PATH],
|
|
339
|
+
fromMs - MAX_AGE_MS[STATE_PATH],
|
|
340
|
+
fromMs,
|
|
341
|
+
SCAN_BUCKET_MS
|
|
342
|
+
);
|
|
343
|
+
const seeded = scanRows(seed).filter((row) => typeof row.value === 'string');
|
|
344
|
+
if (seeded.length > 0) {
|
|
345
|
+
states.push(seeded[seeded.length - 1]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
for (let start = fromMs; start < toMs; start += SCAN_CHUNK_MS) {
|
|
349
|
+
const end = Math.min(start + SCAN_CHUNK_MS, toMs);
|
|
350
|
+
const results = await values(
|
|
351
|
+
['navigation.speedOverGround', STATE_PATH],
|
|
352
|
+
start,
|
|
353
|
+
end,
|
|
354
|
+
SCAN_BUCKET_MS,
|
|
355
|
+
{ 'navigation.speedOverGround': 'max' }
|
|
356
|
+
);
|
|
357
|
+
for (const result of results) {
|
|
358
|
+
for (const row of scanRows(result)) {
|
|
359
|
+
if (
|
|
360
|
+
row.path === 'navigation.speedOverGround' &&
|
|
361
|
+
typeof row.value === 'number' &&
|
|
362
|
+
row.value >= stoppedSpeed
|
|
363
|
+
) {
|
|
364
|
+
intervals.push({ from: row.time, to: row.time + SCAN_BUCKET_MS });
|
|
365
|
+
}
|
|
366
|
+
if (row.path === STATE_PATH && typeof row.value === 'string') {
|
|
367
|
+
states.push(row);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
onChunk?.(end, toMs);
|
|
372
|
+
if (end < toMs) {
|
|
373
|
+
await sleep(CHUNK_PAUSE_MS);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
intervals.push(...underwayIntervals(states, { fromMs, bucketMs: SCAN_BUCKET_MS }));
|
|
378
|
+
|
|
379
|
+
return intervals.sort((a, b) => a.from - b.from);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Matches `app.getSelfPath(path)` at that historical instant. `propulsion`
|
|
383
|
+
// is the one branch node asked for: the propulsion detector and the
|
|
384
|
+
// observation recorder enumerate the boat's engines through it.
|
|
385
|
+
function readSelfPath(path, atMs) {
|
|
386
|
+
if (path === 'propulsion') {
|
|
387
|
+
return engineIds.size > 0
|
|
388
|
+
? Object.fromEntries([...engineIds].map((id) => [id, {}]))
|
|
389
|
+
: undefined;
|
|
390
|
+
}
|
|
391
|
+
return store.readSelfPath(path, atMs);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function clear() {
|
|
395
|
+
store.clear();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return { preload, clear, scanMotion, readSelfPath };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
module.exports = { createHistoryApiHistory, CHUNK_MS, QUERY_MAX_RETRIES };
|