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.
@@ -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 = { windSpeedThresholds: [20, 30], pressureDropThreshold: 4 };
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
@@ -20,7 +20,8 @@ const EVENT_TYPES = [
20
20
  'weather_threshold',
21
21
  'manual_correction',
22
22
  'propulsion_change',
23
- 'stopover'
23
+ 'stopover',
24
+ 'heading_change'
24
25
  ];
25
26
 
26
27
  // The other event types are produced by the plugin itself from Signal K data
@@ -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 };