signalk-underkeel 0.2.0 → 0.3.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 (62) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +27 -5
  3. package/lib/api/routes.js +35 -7
  4. package/lib/api-wiring.js +4 -3
  5. package/lib/box-sizing.js +94 -0
  6. package/lib/box-status.js +61 -0
  7. package/lib/build-process.js +174 -0
  8. package/lib/config-schema.js +20 -10
  9. package/lib/config.js +2 -0
  10. package/lib/engine/chart-box.js +83 -0
  11. package/lib/engine/fan.js +32 -22
  12. package/lib/engine/lookahead.js +3 -2
  13. package/lib/engine/schedule.js +32 -29
  14. package/lib/engine/ukc-reach.js +2 -2
  15. package/lib/engine/ukc-tile-cache.js +216 -0
  16. package/lib/engine/ukc-tile-geometry.js +70 -115
  17. package/lib/engine/verdict.js +5 -2
  18. package/lib/foundation/area-cover.js +212 -0
  19. package/lib/foundation/child-process.js +82 -0
  20. package/lib/foundation/geometry.js +61 -250
  21. package/lib/foundation/rect-distance.js +227 -0
  22. package/lib/index-loader.js +21 -4
  23. package/lib/index.js +1 -0
  24. package/lib/ingest/cell-cache.js +352 -0
  25. package/lib/ingest/cell-source.js +23 -0
  26. package/lib/ingest/enc-read-process.js +69 -0
  27. package/lib/ingest/enc-wasm.js +38 -6
  28. package/lib/livechart/csb-thin.js +39 -0
  29. package/lib/livechart-service.js +14 -2
  30. package/lib/monitor.js +17 -11
  31. package/lib/query-service.js +26 -12
  32. package/lib/route-service.js +3 -1
  33. package/lib/setup-checks.js +61 -3
  34. package/lib/spatial/build-box.js +6 -3
  35. package/lib/spatial/build-items.js +70 -7
  36. package/lib/spatial/build-progress.js +41 -0
  37. package/lib/spatial/build-reuse.js +58 -7
  38. package/lib/spatial/build-survey.js +112 -0
  39. package/lib/spatial/build-worker.js +152 -135
  40. package/lib/spatial/chart-index.js +35 -18
  41. package/lib/spatial/hazard-columns.js +213 -0
  42. package/lib/spatial/hazard-file.js +104 -0
  43. package/lib/spatial/hazards.js +170 -58
  44. package/lib/spatial/index-parts.js +25 -0
  45. package/lib/spatial/parsed-cells.js +101 -0
  46. package/lib/spatial/polygon-index.js +49 -0
  47. package/lib/spatial/pyramid-build.js +16 -46
  48. package/lib/spatial/pyramid-regions.js +2 -2
  49. package/lib/spatial/pyramid.js +48 -50
  50. package/lib/spatial/region-jobs.js +46 -0
  51. package/lib/spatial/survey-shadow.js +44 -12
  52. package/lib/spatial/tile-reuse.js +345 -0
  53. package/lib/status-report.js +13 -3
  54. package/lib/tick-publisher.js +60 -17
  55. package/lib/wfs-service.js +115 -152
  56. package/lib/wfs-wiring.js +5 -1
  57. package/lib/wfs-worker.js +11 -5
  58. package/package.json +1 -1
  59. package/public/panel.js +9 -0
  60. package/public/runtime.js +2 -3
  61. package/public/ukc-shared.js +37 -1
  62. package/public/widget.js +9 -9
package/CHANGELOG.md CHANGED
@@ -2,6 +2,95 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.3.0] - 2026-10-28
6
+
7
+ ### Changed
8
+
9
+ - Index builds run in a child process (Node `fork`, `MALLOC_ARENA_MAX=2`) instead of a worker
10
+ thread, and S-57 cells are read with GDAL in a short-lived reader process of their own, so
11
+ the memory a build used is returned when it ends. After the whole 20-cell Waddenzee build the
12
+ server held 1.07–1.42 GB; it now holds about 100 MB (x86). A build that crashes or passes its
13
+ heap limit fails with the reason and keeps the index in use. Region threads default to 1 on
14
+ machines with up to 4 GB of RAM.
15
+ - Named hazards are saved in `hazards.bin` (CRC-checked columns, depths exact in centimetres)
16
+ instead of `hazards.json`: 11 MB instead of 89 MB for the Waddenzee set. Loading the saved
17
+ index takes 0.31–0.35 s instead of 1.5–1.7 s, blocks the event loop for 5–7 ms instead of
18
+ 1.1–1.3 s, and leaves 97–101 MB resident instead of 282–292 MB. A damaged file rebuilds the
19
+ index; an index saved with `hazards.json` is rebuilt once.
20
+ - Index builds test soundings against survey zones through a zone grid, and coverage through
21
+ the edge grids, with results identical to before: the 10 km Waddenzee box built in 31.6 s
22
+ instead of 57.2 s, the whole set in 147 s instead of 216 s (x86, one thread).
23
+ - The look-ahead's chart walk measures footprint-to-tile distances with exact pre-checks and
24
+ allocates less: ticks on the Waddenzee index take 13–18 ms instead of 22–28 ms (median, x86),
25
+ with every result byte-identical.
26
+ - UKC overlay tiles are kept encoded (4 MiB) beside their geometry (24 MiB, about 41 % of its
27
+ former size, source edges stored as float32 rounded outward), and gzipped off the event
28
+ loop. A 40-tile viewport redrawn unchanged takes about 0 ms instead of 1.4–2.1 s (x86).
29
+ - While the chart box is rebuilding, or the vessel is outside it, the no-coverage reason says
30
+ so ("outside the chart box, the box is rebuilding around the vessel (last build took 5
31
+ min)"); the state stays `warn`. `GET /status` `chartBox` reports the rebuild's phase,
32
+ progress and estimate, and the panel shows it. The box grows with speed and the measured
33
+ build time up to `boxMaxRadiusKm` (by default from the build heap: 10 km on 1 GB, about 19 km on a
34
+ 4 GB Raspberry Pi 4) and says when no size keeps up. Route legs left unverified past the box say why.
35
+ - `navigation.underKeel.state` and `.envelopeRev` are sent when they change and at least every
36
+ 5 s, no longer every second: at the dock a history logger stored two points a second. Over
37
+ 240 stationary ticks the plugin sent 626 values in 242 deltas, now 242 in 74. A display should
38
+ read a `state` not received for 15 s as unknown; the status widget does, and only a fresh
39
+ `state` keeps it current, and the chart runtime withdraws the envelope 15 s after the last
40
+ `envelopeRev` (it was 10 s). Notifications are unchanged.
41
+ - The plugin status no longer carries the seconds to shoal (`GET /status` still has them), and a
42
+ status that differs only in its numbers is set at most every 30 s: through the warn zone of a
43
+ 240 s approach the status was set 67 times, now 5.
44
+ - Below 1 kn, or without a fresh SOG, the living chart's CSB export log (`soundings.ndjson`)
45
+ keeps one sounding per cell per minute. A 10 Hz sounder on a moored boat rewrote the
46
+ 40 000-line file every 33 minutes. Every accepted sounding still updates its cell, so
47
+ discrepancies are found as before. `GET /status` counts the left-out soundings as
48
+ `livechart.csbThinned`.
49
+
50
+ - An index build keeps each S-57 cell's read layers and extent in `cell-cache` in the plugin
51
+ data directory (at most 512 MB) and reads with GDAL only the cells it holds no valid entry
52
+ for: changed cells, and cells whose entry is missing or was evicted. The
53
+ 10 km Waddenzee chart box rebuilt in 12–15 s instead of 30–34 s, with no 300–370 MB reader
54
+ process; the whole 20-cell set in 78–90 s instead of 141 s on one thread (x86). A torn or
55
+ damaged entry is read from the cell again. The built index is byte for byte the same.
56
+ - A chart box refresh copies from the saved box index each tile down to z15 that lies inside
57
+ both boxes, or in both boxes' build-area band outside them, and is evaluated from the same
58
+ cell inputs in both builds; `tiles.json` beside the index holds
59
+ the digests. On the 10 km Waddenzee box (x86, warm cell cache) a 5 km move copied 66 % of
60
+ the nodes and built in 6.9–7.2 s instead of 11.6–11.7 s, an 8 km move 54 % in 6.4–6.9 s
61
+ instead of 10.4–11.6 s. The index is byte for byte the one a fresh build of the new box
62
+ makes; a changed cell's tiles, another leaf zoom or plugin version, or a missing or foreign
63
+ `tiles.json` are built afresh. `cells.json` is written in the field order it is read back.
64
+
65
+ ### Added
66
+
67
+ - A hardware line in the setup check: CPUs, RAM and free RAM, with advice on 1 GB machines
68
+ (keep the index box, consider leaf zoom 19), on little free RAM, and on large ones (box 0
69
+ avoids rebuilds for a small chart set). It changes no setting.
70
+ - Descriptions on **Index leaf zoom**, **Rebuild index at start**, **Look-ahead distance** and
71
+ **Minimum horizon** saying what each costs in CPU or memory.
72
+ - README: what to check before installing on Victron Venus OS, whose Node.js version the
73
+ repository does not record.
74
+
75
+ ### Fixed
76
+
77
+ - A larger least position buffer could lower the look-ahead's P: the buffer also set how far
78
+ apart the footprints were scored, so changing it moved the samples even where the footprints
79
+ kept the same buffer, and one set of samples could read a shoal up to 1e-4 higher (safety
80
+ invariant 2). Each footprint now sweeps the hull since the step before, at hull positions that
81
+ touch with no buffer, so no footprint time or shape depends on the buffer or the position's
82
+ uncertainty. The step stays 3 s at any speed; a fast boat scores fewer, longer footprints.
83
+ Beyond the warn window a footprint sweeps at most three hull lengths.
84
+
85
+ ## [0.2.1] - 2026-10-27
86
+
87
+ ### Fixed
88
+
89
+ - The tests pass in the Signal K plugin registry, which runs them with a read-only home
90
+ directory. There npm could not write its cache, so the published-package test's
91
+ `npm pack --dry-run` failed and the plugin lost its "tested" mark. The test now gives npm a
92
+ cache under the temp directory. The plugin itself is unchanged.
93
+
5
94
  ## [0.2.0] - 2026-10-27
6
95
 
7
96
  ### Added
package/README.md CHANGED
@@ -89,6 +89,16 @@ Restart the server, then enable **Under-Keel Awareness** under _Server → Plugi
89
89
  Signal K admin UI. Its one runtime dependency is gdal3.js (about 40 MB), which reads the S-57
90
90
  cells.
91
91
 
92
+ **Victron Venus OS (Cerbo GX).** `package.json` asks for Node.js 22 or newer (`engines`), and CI
93
+ tests Node 22 and 24 only. Venus OS runs Signal K on the Node.js it ships, and this repository
94
+ does not record which version that is: check with `node --version` over SSH on the device before
95
+ installing. On an older Node, npm prints an `EBADENGINE` warning and installs anyway unless
96
+ `engine-strict` is set, and the plugin runs untested.
97
+
98
+ On a 1 GB machine such as a Raspberry Pi 3 or a Cerbo GX, keep the index box (the default
99
+ 10 km) rather than indexing every cell, and consider **Index leaf zoom** 19. The setup check
100
+ shows the RAM and CPUs it finds and says the same; it never changes a setting.
101
+
92
102
  ## Prepare the charts
93
103
 
94
104
  The plugin reads unencrypted S-57 ENC cells as they are delivered: each `.000` base cell with
@@ -114,18 +124,27 @@ The index covers a box around the vessel, **Index box around the vessel (km)** (
114
124
  each way), and is rebuilt off the event loop as the vessel moves, so a large exchange set such
115
125
  as NOAA's 698 East and Gulf coast cells is never indexed whole: only the cells reaching the box
116
126
  are read (21 of them around Miami, 22 s at leaf zoom 20 on x86). Outside the box the plugin
117
- reports no coverage, never clear water; a radius under 2 km is raised to 2. Until the first position arrives the engine is `off`
127
+ reports no coverage, never clear water, and the reason says the vessel is outside the chart box
128
+ and whether it is being rebuilt; the plugin status and the details panel show the rebuild's
129
+ phase, start and the last build's duration. At speed the box grows past its radius, up to
130
+ **Largest index box at speed** (0 sizes it from the RAM), so the vessel cannot reach the edge
131
+ before the next box is built (docs/HOW-IT-WORKS.md §12). A radius under 2 km is raised to 2.
132
+ Until the first position arrives the engine is `off`
118
133
  with the reason `waiting for the vessel position to build the chart box`. Set the box to 0 to
119
134
  index every cell.
120
135
 
121
- At the first start the plugin builds a chart index from the cells in a worker thread and saves
136
+ At the first start the plugin builds a chart index from the cells in a child process and saves
122
137
  it under the plugin data directory, `~/.signalk/plugin-config-data/signalk-underkeel/index`
123
138
  unless **Chart index directory** says otherwise. Until the index is ready the engine is `off`
124
139
  with the reason `building chart index`. The 12 NOAA cells around Miami (6.8 MB of `.000` and
125
140
  updates) build in 19 s at leaf zoom 18 on x86. Later starts load the saved index, 0.1 s for
126
141
  those cells, and rebuild it when a cell was added or removed, or a cell's `.000` or update files
127
142
  changed; `GET /status` names the cells that caused the rebuild. **Rebuild index at start**
128
- forces a rebuild on every start. A cell folder may be a symbolic link.
143
+ forces a rebuild on every start. A cell folder may be a symbolic link. What the build read
144
+ from each cell is kept, compressed, in `cell-cache` in the plugin data directory (at most 512 MB), so a
145
+ chart box refresh reads again only the cells whose files changed: the 10 km Waddenzee box
146
+ rebuilds in 12–15 s instead of 30–34 s on x86. Deleting the folder is safe; the cells are then
147
+ read again.
129
148
 
130
149
  Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
131
150
  longer read: point **ENC cells directory** at the original `.000` cells instead. Such a folder
@@ -140,7 +159,8 @@ There are two places to set the plugin up, and both write the same configuration
140
159
  level and warnings, in the same panel with a link back to the details. Drag the keel, the margin
141
160
  line and the sounder on a side view of the hull, or type the numbers. The page shows the draft
142
161
  Signal K already states, the vertical datum of your charts with the water-level source that fits
143
- it, and a setup check. **Save and restart** needs a Signal K admin login.
162
+ it, and a setup check, which also lists the machine's RAM and CPUs with advice on the index
163
+ settings. **Save and restart** needs a Signal K admin login.
144
164
  - The plugin's page in the admin UI. It asks for the same essentials first; **Show expert
145
165
  settings** opens the rest, and an optional feature's fields appear once it is enabled. Admin
146
166
  UIs older than Signal K 2.32 show every field at once.
@@ -257,7 +277,9 @@ screenshots, including the plugin layers in the chart list and the setup steps i
257
277
  | `notifications.grounding` | only from the optional grounding detector, when the boat has gone aground |
258
278
 
259
279
  Any Signal K app can show these; the status line on the plugin's admin page reads, for
260
- example, `Monitoring: unknown — position stale`.
280
+ example, `Monitoring: unknown — position stale`. Each value is sent when it changes; `state` and
281
+ `envelopeRev` at least every 5 s, the others every 10 s. A display that shows them should read a
282
+ `state` older than 15 s as unknown, as the plugin's own widget does.
261
283
 
262
284
  ## Optional features
263
285
 
package/lib/api/routes.js CHANGED
@@ -144,13 +144,7 @@ function registerRoutes(router, api, log = console.error) {
144
144
  res.setHeader("Cache-Control", "no-cache");
145
145
  if (reply.body instanceof Uint8Array) {
146
146
  res.setHeader("Vary", "Accept-Encoding");
147
- // Signal K's compression() skips this content type; it leaves an encoded body alone.
148
- const gzip = reply.body.length >= GZIP_MIN_BYTES && acceptsGzip(req.headers?.["accept-encoding"]);
149
- if (gzip) {
150
- res.setHeader("Content-Encoding", "gzip");
151
- }
152
- const body = gzip ? (0, zlib_1.gzipSync)(reply.body) : Buffer.from(reply.body);
153
- res.status(reply.status).type(reply.contentType).send(body);
147
+ sendTile(res, reply, reply.body, acceptsGzip(req.headers?.["accept-encoding"]));
154
148
  }
155
149
  else {
156
150
  res.status(reply.status).json(publicBody(reply.status, reply.body));
@@ -191,6 +185,40 @@ function registerSettings(router, api, log) {
191
185
  .catch((err) => fail(res, err, log));
192
186
  });
193
187
  }
188
+ /**
189
+ * Sends a tile body, gzipped when the client takes it and the body is worth it.
190
+ * Signal K's compression() skips the tile content type and leaves an encoded body alone.
191
+ */
192
+ function sendTile(res, reply, raw, gzip) {
193
+ const sendBody = (body, encoded) => {
194
+ if (encoded) {
195
+ res.setHeader("Content-Encoding", "gzip");
196
+ }
197
+ res
198
+ .status(reply.status)
199
+ .type(reply.contentType)
200
+ .send(Buffer.from(body.buffer, body.byteOffset, body.byteLength));
201
+ };
202
+ if (raw.length < GZIP_MIN_BYTES || !gzip) {
203
+ sendBody(raw, false);
204
+ }
205
+ else if (reply.memo?.gzip) {
206
+ sendBody(reply.memo.gzip, true);
207
+ }
208
+ else {
209
+ // Off the event loop: a tile of some kilobytes takes a millisecond or more on a Pi.
210
+ (0, zlib_1.gzip)(raw, (err, packed) => {
211
+ if (err) {
212
+ sendBody(raw, false);
213
+ return;
214
+ }
215
+ if (reply.memo) {
216
+ reply.memo.gzip = packed;
217
+ }
218
+ sendBody(packed, true);
219
+ });
220
+ }
221
+ }
194
222
  /** Smaller bodies gain less from gzip than its header costs; compression() uses the same. */
195
223
  const GZIP_MIN_BYTES = 1024;
196
224
  /** Whether an Accept-Encoding header accepts gzip, by name or `*`, with a q above 0. */
package/lib/api-wiring.js CHANGED
@@ -4,7 +4,7 @@ exports.apiFor = apiFor;
4
4
  const ack_service_1 = require("./ack-service");
5
5
  const manifest_1 = require("./api/manifest");
6
6
  const ukc_tile_changes_1 = require("./api/ukc-tile-changes");
7
- const ukc_tile_geometry_1 = require("./engine/ukc-tile-geometry");
7
+ const ukc_tile_cache_1 = require("./engine/ukc-tile-cache");
8
8
  const config_1 = require("./config");
9
9
  const water_level_1 = require("./model/water-level");
10
10
  const query_service_1 = require("./query-service");
@@ -19,7 +19,7 @@ function livechartReply(s, body) {
19
19
  }
20
20
  function apiFor(app, clock, s) {
21
21
  const tileChanges = new ukc_tile_changes_1.UkcTileChanges();
22
- const tileCache = new ukc_tile_geometry_1.UkcTileCache();
22
+ const tileCache = new ukc_tile_cache_1.UkcTileCache();
23
23
  const queryDeps = () => ({
24
24
  index: s.load?.index,
25
25
  indexReason: s.load?.reason ?? "plugin not started",
@@ -39,7 +39,8 @@ function apiFor(app, clock, s) {
39
39
  waveHeight: () => s.monitor?.nav.waveHeight?.value,
40
40
  getRoute: (id) => app.resourcesApi.getResource("routes", id),
41
41
  now: () => clock.now(),
42
- queue: s.requests
42
+ queue: s.requests,
43
+ chartBox: () => s.wfs?.view().bbox
43
44
  });
44
45
  const withAcks = (fn) => {
45
46
  const m = s.monitor;
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_BOX_KM = exports.SHRINK_BELOW = exports.SPEED_HEADROOM = exports.STEP_KM = void 0;
4
+ exports.autoCapKm = autoCapKm;
5
+ exports.buildMsAt = buildMsAt;
6
+ exports.fits = fits;
7
+ exports.nextSize = nextSize;
8
+ const build_items_1 = require("./spatial/build-items");
9
+ /**
10
+ * The radius of the chart box around the vessel (plan §4.2). A box must let the vessel run,
11
+ * from its centre, the distance it covers while the next box is built (with the refresh
12
+ * margin) plus the look-ahead's reach and the no-coverage band inside the edge; otherwise
13
+ * rebuilds run back to back and a slow machine at speed lets the vessel reach the edge. The
14
+ * box grows for that from the configured radius up to a cap, and shrinks back only once the
15
+ * speed needs much less, so the radius does not change with every gust.
16
+ */
17
+ /** Radii are chosen in steps of this many km. */
18
+ exports.STEP_KM = 0.5;
19
+ /** A new box is sized for this much more than the current speed. */
20
+ exports.SPEED_HEADROOM = 1.25;
21
+ /** A box is kept at its size while the size the speed needs is above this share of it. */
22
+ exports.SHRINK_BELOW = 0.7;
23
+ /** The automatic cap: this radius on a 256 MB build heap, growing with the heap's root. */
24
+ const CAP_AT_256_MB_KM = 10;
25
+ /** The largest box the configuration allows, km. */
26
+ exports.MAX_BOX_KM = 50;
27
+ /**
28
+ * The automatic cap for a build heap of `heapMb`: 10 km at 256 MB (a 1 GB Pi 3 or Cerbo GX),
29
+ * 20 km at 1 GB, 28 km at 2 GB, rounded down to STEP_KM. The heap is a quarter of the RAM the
30
+ * system reports, which on a 4 GB Pi 4 is about 3.8 GiB: 19 km. A build's memory grows with the box's area,
31
+ * and the 10 km box with the ENC pair at leaf zoom 20 builds in under 96 MB.
32
+ */
33
+ function autoCapKm(heapMb) {
34
+ const km = CAP_AT_256_MB_KM * Math.sqrt(heapMb / 256);
35
+ return Math.min(exports.MAX_BOX_KM, Math.floor(km / exports.STEP_KM) * exports.STEP_KM);
36
+ }
37
+ /**
38
+ * The build time of a box of `km`: the measured one, grown with the build area past the
39
+ * radius it was measured at. Tiles are built to BOX_REACH past the box, so the area grows
40
+ * with (radius + reach)². Never shorter than measured, so a smaller box is not assumed to
41
+ * build faster until it has.
42
+ */
43
+ function buildMsAt(km, i) {
44
+ const reach = build_items_1.BOX_REACH / 1000;
45
+ return i.buildMs * Math.max(1, ((km + reach) / (i.builtKm + reach)) ** 2);
46
+ }
47
+ /** Whether a box of `km` holds a refresh's run at `sog` plus the reach between its centre and edge. */
48
+ function fits(km, i, sog = i.sog) {
49
+ return km * 1000 >= (i.margin * sog * buildMsAt(km, i)) / 1000 + i.reachM;
50
+ }
51
+ /** The smallest radius on the STEP_KM grid in [from, to] that fits at `sog`. */
52
+ function smallestFit(from, to, i, sog) {
53
+ for (let km = from; km <= to + 1e-9; km = nextStep(km)) {
54
+ if (fits(km, i, sog)) {
55
+ return km;
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ const nextStep = (km) => (Math.floor(km / exports.STEP_KM + 1e-9) + 1) * exports.STEP_KM;
61
+ /** The radius at which the box's spare reach is greatest, when none fits. */
62
+ function roomiest(i) {
63
+ let best = i.minKm;
64
+ const spare = (km) => km * 1000 - (i.margin * i.sog * buildMsAt(km, i)) / 1000;
65
+ for (let km = i.minKm; km <= i.capKm + 1e-9; km = nextStep(km)) {
66
+ if (spare(km) > spare(best)) {
67
+ best = km;
68
+ }
69
+ }
70
+ return best;
71
+ }
72
+ /**
73
+ * The radius for the next box, given the radius of the box in use. Stopped or without a
74
+ * speed the box in use keeps its size, within the configured radius and the cap.
75
+ */
76
+ function nextSize(i, currentKm) {
77
+ const minKm = Math.min(i.minKm, i.capKm);
78
+ const within = currentKm !== undefined && currentKm >= minKm && currentKm <= i.capKm;
79
+ if (!(i.sog > 0)) {
80
+ return { radiusKm: within ? currentKm : minKm, enough: true };
81
+ }
82
+ const want = smallestFit(minKm, i.capKm, i, i.sog * exports.SPEED_HEADROOM);
83
+ if (want === undefined) {
84
+ const radiusKm = smallestFit(minKm, i.capKm, i, i.sog) ?? roomiest(i);
85
+ const enough = fits(radiusKm, i);
86
+ const neededKm = enough ? undefined : smallestFit(i.capKm, exports.MAX_BOX_KM, i, i.sog);
87
+ return { radiusKm, enough, ...(neededKm === undefined ? {} : { neededKm }) };
88
+ }
89
+ // Kept while it holds the speed and the speed does not need much less.
90
+ if (within && fits(currentKm, i) && want <= currentKm && want > exports.SHRINK_BELOW * currentKm) {
91
+ return { radiusKm: currentKm, enough: true };
92
+ }
93
+ return { radiusKm: want, enough: true };
94
+ }
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rebuildStatus = rebuildStatus;
4
+ exports.boxSummary = boxSummary;
5
+ const box_sizing_1 = require("./box-sizing");
6
+ const chart_box_1 = require("./engine/chart-box");
7
+ function rebuildStatus(r, now) {
8
+ return {
9
+ startedAt: new Date(r.startedAt).toISOString(),
10
+ elapsedMs: now - r.startedAt,
11
+ radiusKm: r.radiusKm,
12
+ ...r.progress,
13
+ ...(r.estimateMs === undefined ? {} : { estimateMs: r.estimateMs })
14
+ };
15
+ }
16
+ function phaseText(r) {
17
+ const of = r.done !== undefined && r.total !== undefined ? ` ${r.done}/${r.total}` : "";
18
+ switch (r.phase) {
19
+ case "download":
20
+ return "downloading WFS depths";
21
+ case "cells":
22
+ return `reading cells${of}`;
23
+ case "roots":
24
+ return `building roots${of}`;
25
+ case "save":
26
+ return "saving the index";
27
+ default:
28
+ return "starting";
29
+ }
30
+ }
31
+ function rebuildText(r, lastMs) {
32
+ const expected = r.estimateMs === undefined
33
+ ? "first build"
34
+ : r.estimateMs === lastMs
35
+ ? `last build took ${(0, chart_box_1.spoken)(r.estimateMs)}`
36
+ : `about ${(0, chart_box_1.spoken)(r.estimateMs)} expected`;
37
+ const ago = (0, chart_box_1.spoken)(r.elapsedMs);
38
+ return `chart box rebuilding (${r.radiusKm} km): ${phaseText(r)}, started ${ago} ago, ${expected}`;
39
+ }
40
+ function tooSmallText(s, i) {
41
+ const kn = ((s.sog * 3600) / 1852).toFixed(1);
42
+ const builds = (0, chart_box_1.spoken)((0, box_sizing_1.buildMsAt)(s.radiusKm, i));
43
+ const need = s.neededKm === undefined
44
+ ? "no box size keeps up"
45
+ : `needs ${s.neededKm} km, limit ${s.capKm} km`;
46
+ return `chart box ${s.radiusKm} km is too small for ${kn} kn with ${builds} builds: ${need}`;
47
+ }
48
+ /** One line on the rebuild in progress and the box size; undefined when neither needs saying. */
49
+ function boxSummary(s) {
50
+ const parts = [];
51
+ if (s.rebuild) {
52
+ parts.push(rebuildText(s.rebuild, s.lastMs));
53
+ }
54
+ if (!s.sizing.enough) {
55
+ parts.push(tooSmallText(s.sizing, s.input));
56
+ }
57
+ if (s.ceilingKm !== undefined) {
58
+ parts.push(`chart box limited to ${s.ceilingKm} km: a larger one ran out of memory`);
59
+ }
60
+ return parts.length > 0 ? parts.join("; ") : undefined;
61
+ }
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.workerRefresher = exports.workerBuilder = void 0;
7
+ exports.workerHeapLimitMb = workerHeapLimitMb;
8
+ exports.runBuildProcess = runBuildProcess;
9
+ exports.inThreadRefresher = inThreadRefresher;
10
+ const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const child_process_1 = require("./foundation/child-process");
13
+ const memory_1 = require("./foundation/memory");
14
+ const wfs_1 = require("./ingest/wfs");
15
+ const wfs_worker_1 = require("./wfs-worker");
16
+ const build_progress_1 = require("./spatial/build-progress");
17
+ const build_worker_1 = require("./spatial/build-worker");
18
+ const block_reader_1 = require("./spatial/block-reader");
19
+ const chart_index_1 = require("./spatial/chart-index");
20
+ /** Settles once every build process aborted so far has exited. */
21
+ let exits = Promise.resolve();
22
+ /**
23
+ * Old-generation heap for a build process: a quarter of the RAM, 256 MB to 2 GB. The ENC pair
24
+ * plus a 10 km WFS box at leaf zoom 20 builds in under 96 MB; without a limit an oversized
25
+ * build grows until the kernel ends the whole Signal K server on a 1 GB Pi 3 or Cerbo GX.
26
+ */
27
+ function workerHeapLimitMb(totalBytes = (0, memory_1.usableMemory)()) {
28
+ return Math.min(2048, Math.max(256, Math.floor(totalBytes / 4 / 2 ** 20)));
29
+ }
30
+ /**
31
+ * Runs the compiled `script` in a child process (`forkLimited`) with `data` and resolves with
32
+ * its staged reply. The child only stages; the caller moves the result into place on the main
33
+ * thread, and never for an aborted run, so a build still exiting cannot replace an index
34
+ * saved after it was aborted. An abort kills the child and its S-57 reader (`killGroup`); its
35
+ * run rejects once the child has exited and its staging is removed, and the next run starts
36
+ * only then. stop() itself returns at once. A child that ends without a reply fails the run.
37
+ * Progress messages the child sends before its reply go to `progress`.
38
+ */
39
+ async function runBuildProcess(script, data, signal, indexDir, heapMb = workerHeapLimitMb(), progress) {
40
+ await exits;
41
+ if (signal.aborted) {
42
+ throw new Error("aborted");
43
+ }
44
+ return new Promise((resolve, reject) => {
45
+ const { child, stderr, outOfMemory } = (0, child_process_1.forkLimited)(script, heapMb, true);
46
+ const { pid } = child;
47
+ let replied = false;
48
+ // Staging the child left half-written: once it has exited nothing else writes there.
49
+ const cleanup = () => {
50
+ if (indexDir !== undefined && pid !== undefined) {
51
+ (0, chart_index_1.removeStaging)(indexDir, (p) => p === pid);
52
+ }
53
+ };
54
+ const closed = new Promise((r) => child.once("close", () => r()));
55
+ const abort = () => {
56
+ (0, child_process_1.killGroup)(child);
57
+ const exit = closed.then(cleanup).catch(() => undefined);
58
+ exits = Promise.all([exits, exit]);
59
+ void exit.then(() => reject(new Error("aborted")));
60
+ };
61
+ signal.addEventListener("abort", abort, { once: true });
62
+ child.on("message", (m) => {
63
+ if ((0, build_progress_1.isProgress)(m)) {
64
+ if (!replied && !signal.aborted) {
65
+ progress?.(m.progress);
66
+ }
67
+ return;
68
+ }
69
+ replied = true;
70
+ // It exits once the reply is sent; killed, it cannot linger holding its memory.
71
+ child.kill("SIGKILL");
72
+ if (signal.aborted) {
73
+ if (m.staging) {
74
+ fs_1.default.rmSync(m.staging, { recursive: true, force: true });
75
+ }
76
+ return;
77
+ }
78
+ signal.removeEventListener("abort", abort);
79
+ if (m.ok && m.staging) {
80
+ resolve(m);
81
+ }
82
+ else {
83
+ reject(new Error(m.error ?? "index build failed"));
84
+ }
85
+ });
86
+ // A child that could not start, or ended without a reply: the index in use stays.
87
+ child.once("error", (err) => {
88
+ if (!signal.aborted && !replied) {
89
+ signal.removeEventListener("abort", abort);
90
+ (0, child_process_1.killGroup)(child);
91
+ reject(err);
92
+ }
93
+ });
94
+ child.once("close", (code, end) => {
95
+ if (signal.aborted || replied) {
96
+ return;
97
+ }
98
+ signal.removeEventListener("abort", abort);
99
+ try {
100
+ cleanup();
101
+ }
102
+ catch {
103
+ // Left for the next start's recovery, which removes staging of exited processes.
104
+ }
105
+ const how = code === null ? `signal ${end ?? "?"}` : `code ${code}`;
106
+ const tail = stderr() === "" ? "" : `: ${stderr()}`;
107
+ reject(new Error(outOfMemory()
108
+ ? `index build stopped at the ${heapMb} MB heap limit${tail}`
109
+ : `index build process exited with ${how} without a result${tail}`));
110
+ });
111
+ // A child that cannot take the job exits, and "close" reports it.
112
+ child.send(data, () => undefined);
113
+ });
114
+ }
115
+ /**
116
+ * Moves a staged index into `indexDir` and adopts the one the build posted, unless the run
117
+ * was aborted meanwhile. Reading the saved index back blocked the event loop for 52–61 ms
118
+ * with the Nauvo pair and a 10 km WFS box at leaf zoom 20, 258–292 ms with 32 cells (x86).
119
+ */
120
+ async function commit(indexDir, m, signal) {
121
+ await (0, block_reader_1.readsSettled)(indexDir);
122
+ if (signal.aborted) {
123
+ fs_1.default.rmSync(m.staging, { recursive: true, force: true });
124
+ throw new Error("aborted");
125
+ }
126
+ (0, chart_index_1.commitChartIndex)(indexDir, m.staging);
127
+ if (m.index) {
128
+ return (0, build_worker_1.adoptIndex)(m.index);
129
+ }
130
+ // A pyramid above HELD_WHOLE_NODES is not posted: the committed file is read block by block,
131
+ // its blocks verified off the event loop.
132
+ const index = await (0, chart_index_1.loadChartIndexAsync)(indexDir);
133
+ if (signal.aborted) {
134
+ throw new Error("aborted");
135
+ }
136
+ return index;
137
+ }
138
+ /** Builds in a child process from the compiled `lib/spatial/build-worker.js`. */
139
+ const workerBuilder = async (job, signal, progress) => {
140
+ const script = path_1.default.join(__dirname, "spatial", "build-worker.js");
141
+ const heapMb = workerHeapLimitMb();
142
+ const data = { ...job, heapMb };
143
+ const m = await runBuildProcess(script, data, signal, job.indexDir, heapMb, progress);
144
+ return await commit(job.indexDir, m, signal);
145
+ };
146
+ exports.workerBuilder = workerBuilder;
147
+ /**
148
+ * The whole refresh in a child process from the compiled `lib/wfs-worker.js`, which fetches
149
+ * with its own global fetch.
150
+ */
151
+ const workerRefresher = async (job, signal, progress) => {
152
+ const script = path_1.default.join(__dirname, "wfs-worker.js");
153
+ const heapMb = workerHeapLimitMb();
154
+ const data = { ...job, build: { ...job.build, heapMb } };
155
+ const { indexDir } = job.build;
156
+ const m = await runBuildProcess(script, data, signal, indexDir, heapMb, progress);
157
+ const features = m.features ?? wfs_worker_1.NO_FEATURES;
158
+ return { index: await commit(job.build.indexDir, m, signal), features };
159
+ };
160
+ exports.workerRefresher = workerRefresher;
161
+ /** The download on this thread with `fetch`, then `build`: for an injected fetch or builder. */
162
+ function inThreadRefresher(fetch, build) {
163
+ return async (job, signal, progress) => {
164
+ const cells = (0, wfs_worker_1.refreshCells)(job);
165
+ if (job.download) {
166
+ progress?.({ phase: "download" });
167
+ }
168
+ const features = job.download
169
+ ? await (0, wfs_1.downloadWfsCell)({ ...job.download, now: new Date(job.download.now), fetch, signal })
170
+ : wfs_worker_1.NO_FEATURES;
171
+ await (0, block_reader_1.readsSettled)(job.build.indexDir);
172
+ return { index: await build((0, wfs_worker_1.refreshBuild)(job, cells), signal, progress), features };
173
+ };
174
+ }
@@ -202,13 +202,14 @@ const LOOKAHEAD = {
202
202
  distance: {
203
203
  type: "number",
204
204
  title: "Look-ahead distance (m)",
205
- description: FOLLOWS_STYLE,
205
+ description: `${FOLLOWS_STYLE} Each second the look-ahead scores footprints out to this distance, at most 400; a longer one costs more CPU per tick, which counts on a Raspberry Pi 3 or Cerbo GX.`,
206
206
  minimum: 0,
207
207
  maximum: lookahead_1.LOOKAHEAD_LIMITS.distance
208
208
  },
209
209
  minHorizon: {
210
210
  type: "number",
211
211
  title: "Minimum horizon (s)",
212
+ description: "The look-ahead reaches at least this far in time at speed. A longer horizon scores more footprints each second, at most 400, so more CPU per tick.",
212
213
  default: 60,
213
214
  minimum: 0,
214
215
  maximum: lookahead_1.LOOKAHEAD_LIMITS.minHorizon
@@ -239,22 +240,31 @@ const LOOKAHEAD = {
239
240
  }
240
241
  }
241
242
  };
243
+ /** A chart box half-width field, km. */
244
+ const BOX_KM = { type: "number", minimum: 0, maximum: 50 };
245
+ const boxKm = (title, def, description) => Object.assign({ title, description, default: def }, BOX_KM);
242
246
  const CHART_INDEX = {
243
247
  indexDir: {
244
248
  type: "string",
245
249
  title: "Chart index directory (optional)",
246
250
  description: "Where the depth index built from the cells is kept between restarts. Default: <plugin data dir>/index"
247
251
  },
248
- rebuildIndex: { type: "boolean", title: "Rebuild index at start", default: false },
249
- leafZoom: { type: "number", title: "Index leaf zoom", default: 20, minimum: 14, maximum: 22 },
250
- boxRadiusKm: {
252
+ rebuildIndex: {
253
+ type: "boolean",
254
+ title: "Rebuild index at start",
255
+ description: "Builds the index again at every start, with the CPU, memory and flash writes of a first build. The index is rebuilt anyway when a cell changes; leave this off.",
256
+ default: false
257
+ },
258
+ leafZoom: {
251
259
  type: "number",
252
- title: "Index box around the vessel (km)",
253
- description: "Half-width of the area the index is built in around the vessel; it is rebuilt as the vessel moves. Outside it the plugin reports no coverage. 0 indexes every cell (slow for large chart sets); a radius under 2 km is raised to 2. With the Traficom WFS on, its radius is used.",
254
- default: 10,
255
- minimum: 0,
256
- maximum: 50
257
- }
260
+ title: "Index leaf zoom",
261
+ description: "Size of the smallest index tile: about 19 m across at 20 and 38 m at 19 at 60° N. Each step down needs fewer tiles, so less memory, disk and build time. A tile keeps the shallowest depth inside it, so a coarser index warns more, never less. Consider 19 on a 1 GB Raspberry Pi 3 or Cerbo GX. Changing it rebuilds the index.",
262
+ default: 20,
263
+ minimum: 14,
264
+ maximum: 22
265
+ },
266
+ boxRadiusKm: boxKm("Index box around the vessel (km)", 10, "Half-width of the area the index is built in around the vessel; it is rebuilt as the vessel moves. Outside it the plugin reports no coverage, and the reason says the vessel is outside the chart box. At speed the box grows past this radius, up to the largest index box, so the vessel cannot reach its edge, less the look-ahead, before the next box is built: a box must hold 1.5 times the distance run during the last build (grown with the build area) plus the look-ahead and a 2.4 km band. It grows at once when too small, and shrinks only at the next rebuild once the speed needs under 70% of it. 0 indexes every cell (slow for large chart sets); a radius under 2 km is raised to 2. With the Traficom WFS on, its radius is used, and the box grows to 25 km at most."),
267
+ boxMaxRadiusKm: boxKm("Largest index box at speed (km)", 0, "The box around the vessel grows up to this half-width at speed. 0 sizes it from the RAM the system reports: 10 km on a 1 GB Raspberry Pi or Cerbo GX, about 19 km on a 4 GB Raspberry Pi 4, about 27 km with 8 GB. The status says when even this box is too small for the speed and build time.")
258
268
  };
259
269
  const EXPERT = {
260
270
  ...CHART_INDEX,
package/lib/config.js CHANGED
@@ -9,6 +9,7 @@ const vessel_1 = require("./model/vessel");
9
9
  const route_request_1 = require("./api/route-request");
10
10
  const wfs_1 = require("./ingest/wfs");
11
11
  const config_presets_1 = require("./config-presets");
12
+ const box_sizing_1 = require("./box-sizing");
12
13
  var config_schema_1 = require("./config-schema");
13
14
  Object.defineProperty(exports, "SCHEMA", { enumerable: true, get: function () { return config_schema_1.SCHEMA; } });
14
15
  exports.DEFAULT_ROUTE_CHECK = { speed: 2.5, xteLimit: 30, probability: 0.01 };
@@ -161,6 +162,7 @@ function parseConfig(raw) {
161
162
  rebuildIndex: r.rebuildIndex === true,
162
163
  leafZoom: Math.round(num(r.leafZoom, 20, 14, 22)),
163
164
  boxRadiusKm: boxRadius(r.boxRadiusKm),
165
+ boxMaxRadiusKm: num(r.boxMaxRadiusKm, 0, 0, box_sizing_1.MAX_BOX_KM),
164
166
  warningStyle: style,
165
167
  vessel: {
166
168
  draft: optionalPositive(v.draft),