signalk-underkeel 0.2.1 → 0.4.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 (66) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +50 -14
  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-charts.js +34 -0
  9. package/lib/config-schema.js +23 -15
  10. package/lib/config.js +13 -0
  11. package/lib/engine/chart-box.js +83 -0
  12. package/lib/engine/fan.js +32 -22
  13. package/lib/engine/lookahead.js +3 -2
  14. package/lib/engine/schedule.js +32 -29
  15. package/lib/engine/ukc-reach.js +2 -2
  16. package/lib/engine/ukc-tile-cache.js +216 -0
  17. package/lib/engine/ukc-tile-geometry.js +70 -115
  18. package/lib/engine/verdict.js +5 -2
  19. package/lib/foundation/area-cover.js +212 -0
  20. package/lib/foundation/child-process.js +82 -0
  21. package/lib/foundation/geometry.js +61 -250
  22. package/lib/foundation/rect-distance.js +227 -0
  23. package/lib/index-loader.js +49 -22
  24. package/lib/index-source.js +65 -0
  25. package/lib/index.js +24 -2
  26. package/lib/ingest/cell-cache.js +352 -0
  27. package/lib/ingest/cell-source.js +23 -0
  28. package/lib/ingest/enc-read-process.js +69 -0
  29. package/lib/ingest/enc-wasm.js +38 -6
  30. package/lib/livechart/csb-thin.js +39 -0
  31. package/lib/livechart-service.js +14 -2
  32. package/lib/monitor.js +17 -11
  33. package/lib/query-service.js +26 -12
  34. package/lib/route-service.js +3 -1
  35. package/lib/setup-checks.js +69 -5
  36. package/lib/shared-enc.js +214 -0
  37. package/lib/spatial/build-box.js +6 -3
  38. package/lib/spatial/build-items.js +70 -7
  39. package/lib/spatial/build-progress.js +41 -0
  40. package/lib/spatial/build-reuse.js +58 -7
  41. package/lib/spatial/build-source.js +26 -0
  42. package/lib/spatial/build-survey.js +112 -0
  43. package/lib/spatial/build-worker.js +156 -135
  44. package/lib/spatial/chart-index.js +48 -21
  45. package/lib/spatial/hazard-columns.js +213 -0
  46. package/lib/spatial/hazard-file.js +104 -0
  47. package/lib/spatial/hazards.js +170 -58
  48. package/lib/spatial/index-parts.js +25 -0
  49. package/lib/spatial/parsed-cells.js +101 -0
  50. package/lib/spatial/polygon-index.js +49 -0
  51. package/lib/spatial/pyramid-build.js +16 -46
  52. package/lib/spatial/pyramid-regions.js +2 -2
  53. package/lib/spatial/pyramid.js +48 -50
  54. package/lib/spatial/region-jobs.js +46 -0
  55. package/lib/spatial/survey-shadow.js +44 -12
  56. package/lib/spatial/tile-reuse.js +345 -0
  57. package/lib/status-report.js +13 -3
  58. package/lib/tick-publisher.js +60 -17
  59. package/lib/wfs-service.js +129 -152
  60. package/lib/wfs-wiring.js +50 -1
  61. package/lib/wfs-worker.js +11 -5
  62. package/package.json +1 -1
  63. package/public/panel.js +9 -0
  64. package/public/runtime.js +2 -3
  65. package/public/ukc-shared.js +37 -1
  66. package/public/widget.js +9 -9
package/CHANGELOG.md CHANGED
@@ -2,6 +2,114 @@
2
2
 
3
3
  All notable changes to signalk-underkeel are documented here.
4
4
 
5
+ ## [0.4.0] - 2026-11-02
6
+
7
+ ### Added
8
+
9
+ - **ENC from Charts Provider** reads the ENC cells signalk-charts-provider-simple (2.16.0 or
10
+ later) shares in its ENC store: one chart set's or converted chart's cells by name, or all
11
+ of them with `*`, in place of **ENC cells directory**. The plugin restarts itself and
12
+ rebuilds the index when the charts provider writes new editions or updates of those cells,
13
+ and the admin form names the collections shared at the moment.
14
+ - When the Charts Provider collection in use has no cells, the reason says why, as
15
+ signalk-charts-provider-simple tells it: a chart set not downloaded yet or downloading, its
16
+ cells being written, or no such collection.
17
+ - **Ask Charts Provider for the ENC around the vessel** (expert, off by default): while all
18
+ of the charts provider's shared ENC is read, each new index box asks it to download the free
19
+ ENC cells of that area, when the charts provider allows such downloads. The cells arrive in
20
+ a chart set of their own and the index is rebuilt with them. An answer that may change
21
+ without the box changing is asked again later.
22
+
23
+ ### Changed
24
+
25
+ - The index records the ENC source it was built from: the cells directory, or the Charts
26
+ Provider collection by its id. A cells directory that is gone or empty keeps the saved index
27
+ only when that index was built from it, as an unmounted drive leaves it; switching to a
28
+ source with no cells yet no longer keeps using the previous source's index, and the reason
29
+ says why. Clearing the ENC source leaves no index in use rather than the previous source's,
30
+ again with the reason. An index saved by an earlier version, which records no source, is
31
+ kept as before.
32
+
33
+ ## [0.3.0] - 2026-10-28
34
+
35
+ ### Changed
36
+
37
+ - Index builds run in a child process (Node `fork`, `MALLOC_ARENA_MAX=2`) instead of a worker
38
+ thread, and S-57 cells are read with GDAL in a short-lived reader process of their own, so
39
+ the memory a build used is returned when it ends. After the whole 20-cell Waddenzee build the
40
+ server held 1.07–1.42 GB; it now holds about 100 MB (x86). A build that crashes or passes its
41
+ heap limit fails with the reason and keeps the index in use. Region threads default to 1 on
42
+ machines with up to 4 GB of RAM.
43
+ - Named hazards are saved in `hazards.bin` (CRC-checked columns, depths exact in centimetres)
44
+ instead of `hazards.json`: 11 MB instead of 89 MB for the Waddenzee set. Loading the saved
45
+ index takes 0.31–0.35 s instead of 1.5–1.7 s, blocks the event loop for 5–7 ms instead of
46
+ 1.1–1.3 s, and leaves 97–101 MB resident instead of 282–292 MB. A damaged file rebuilds the
47
+ index; an index saved with `hazards.json` is rebuilt once.
48
+ - Index builds test soundings against survey zones through a zone grid, and coverage through
49
+ the edge grids, with results identical to before: the 10 km Waddenzee box built in 31.6 s
50
+ instead of 57.2 s, the whole set in 147 s instead of 216 s (x86, one thread).
51
+ - The look-ahead's chart walk measures footprint-to-tile distances with exact pre-checks and
52
+ allocates less: ticks on the Waddenzee index take 13–18 ms instead of 22–28 ms (median, x86),
53
+ with every result byte-identical.
54
+ - UKC overlay tiles are kept encoded (4 MiB) beside their geometry (24 MiB, about 41 % of its
55
+ former size, source edges stored as float32 rounded outward), and gzipped off the event
56
+ loop. A 40-tile viewport redrawn unchanged takes about 0 ms instead of 1.4–2.1 s (x86).
57
+ - While the chart box is rebuilding, or the vessel is outside it, the no-coverage reason says
58
+ so ("outside the chart box, the box is rebuilding around the vessel (last build took 5
59
+ min)"); the state stays `warn`. `GET /status` `chartBox` reports the rebuild's phase,
60
+ progress and estimate, and the panel shows it. The box grows with speed and the measured
61
+ build time up to `boxMaxRadiusKm` (by default from the build heap: 10 km on 1 GB, about 19 km on a
62
+ 4 GB Raspberry Pi 4) and says when no size keeps up. Route legs left unverified past the box say why.
63
+ - `navigation.underKeel.state` and `.envelopeRev` are sent when they change and at least every
64
+ 5 s, no longer every second: at the dock a history logger stored two points a second. Over
65
+ 240 stationary ticks the plugin sent 626 values in 242 deltas, now 242 in 74. A display should
66
+ read a `state` not received for 15 s as unknown; the status widget does, and only a fresh
67
+ `state` keeps it current, and the chart runtime withdraws the envelope 15 s after the last
68
+ `envelopeRev` (it was 10 s). Notifications are unchanged.
69
+ - The plugin status no longer carries the seconds to shoal (`GET /status` still has them), and a
70
+ status that differs only in its numbers is set at most every 30 s: through the warn zone of a
71
+ 240 s approach the status was set 67 times, now 5.
72
+ - Below 1 kn, or without a fresh SOG, the living chart's CSB export log (`soundings.ndjson`)
73
+ keeps one sounding per cell per minute. A 10 Hz sounder on a moored boat rewrote the
74
+ 40 000-line file every 33 minutes. Every accepted sounding still updates its cell, so
75
+ discrepancies are found as before. `GET /status` counts the left-out soundings as
76
+ `livechart.csbThinned`.
77
+
78
+ - An index build keeps each S-57 cell's read layers and extent in `cell-cache` in the plugin
79
+ data directory (at most 512 MB) and reads with GDAL only the cells it holds no valid entry
80
+ for: changed cells, and cells whose entry is missing or was evicted. The
81
+ 10 km Waddenzee chart box rebuilt in 12–15 s instead of 30–34 s, with no 300–370 MB reader
82
+ process; the whole 20-cell set in 78–90 s instead of 141 s on one thread (x86). A torn or
83
+ damaged entry is read from the cell again. The built index is byte for byte the same.
84
+ - A chart box refresh copies from the saved box index each tile down to z15 that lies inside
85
+ both boxes, or in both boxes' build-area band outside them, and is evaluated from the same
86
+ cell inputs in both builds; `tiles.json` beside the index holds
87
+ the digests. On the 10 km Waddenzee box (x86, warm cell cache) a 5 km move copied 66 % of
88
+ 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
89
+ instead of 10.4–11.6 s. The index is byte for byte the one a fresh build of the new box
90
+ makes; a changed cell's tiles, another leaf zoom or plugin version, or a missing or foreign
91
+ `tiles.json` are built afresh. `cells.json` is written in the field order it is read back.
92
+
93
+ ### Added
94
+
95
+ - A hardware line in the setup check: CPUs, RAM and free RAM, with advice on 1 GB machines
96
+ (keep the index box, consider leaf zoom 19), on little free RAM, and on large ones (box 0
97
+ avoids rebuilds for a small chart set). It changes no setting.
98
+ - Descriptions on **Index leaf zoom**, **Rebuild index at start**, **Look-ahead distance** and
99
+ **Minimum horizon** saying what each costs in CPU or memory.
100
+ - README: what to check before installing on Victron Venus OS, whose Node.js version the
101
+ repository does not record.
102
+
103
+ ### Fixed
104
+
105
+ - A larger least position buffer could lower the look-ahead's P: the buffer also set how far
106
+ apart the footprints were scored, so changing it moved the samples even where the footprints
107
+ kept the same buffer, and one set of samples could read a shoal up to 1e-4 higher (safety
108
+ invariant 2). Each footprint now sweeps the hull since the step before, at hull positions that
109
+ touch with no buffer, so no footprint time or shape depends on the buffer or the position's
110
+ uncertainty. The step stays 3 s at any speed; a fast boat scores fewer, longer footprints.
111
+ Beyond the warn window a footprint sweeps at most three hull lengths.
112
+
5
113
  ## [0.2.1] - 2026-10-27
6
114
 
7
115
  ### Fixed
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
@@ -96,7 +106,20 @@ its `.001`, `.002`, … updates beside it. Copy the exchange set, for example it
96
106
  folder, under one directory and set **ENC cells directory** to it; `.000` files are found down
97
107
  to three folders deep, and down to six below a folder holding the exchange set's `CATALOG.031`,
98
108
  so an unzipped Inland ENC download (`<set>/ENC_ROOT/1R/7/1R7WAD07/1R7WAD07.000`) can be pointed
99
- at as it is. The cells are read in place with GDAL compiled to WebAssembly
109
+ at as it is.
110
+
111
+ Cells downloaded with signalk-charts-provider-simple (2.16.0 or later) are read where that
112
+ plugin shares them, in its ENC store: set **ENC from Charts Provider** to one of the chart sets
113
+ or converted charts the admin form lists as shared, or to `*` for all of them. It is used
114
+ instead of **ENC cells directory**. When the charts provider writes new editions or updates of
115
+ those cells, the plugin restarts itself and rebuilds the index from them. When the collection
116
+ has no cells yet, the reason says why, as the charts provider tells it: a chart set not
117
+ downloaded or still downloading, cells being written, or no such collection. With `*` and
118
+ **Ask Charts Provider for the ENC around the vessel** on (an expert setting, off by default),
119
+ each new index box asks the charts provider to download the free ENC cells of that area, when
120
+ its **Download the ENC cells around the vessel** is on too; they arrive in its chart set
121
+ "Around the vessel" and the index is rebuilt with them. The cells are
122
+ read in place with GDAL compiled to WebAssembly
100
123
  (gdal3.js), which the plugin installs with it, and the updates are applied as they are read.
101
124
  Nothing is converted or written next to the cells. A cell without an `M_COVR` coverage layer
102
125
  is rejected. A cell's usage band comes from the third character of its name, else from its
@@ -114,18 +137,27 @@ The index covers a box around the vessel, **Index box around the vessel (km)** (
114
137
  each way), and is rebuilt off the event loop as the vessel moves, so a large exchange set such
115
138
  as NOAA's 698 East and Gulf coast cells is never indexed whole: only the cells reaching the box
116
139
  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`
140
+ reports no coverage, never clear water, and the reason says the vessel is outside the chart box
141
+ and whether it is being rebuilt; the plugin status and the details panel show the rebuild's
142
+ phase, start and the last build's duration. At speed the box grows past its radius, up to
143
+ **Largest index box at speed** (0 sizes it from the RAM), so the vessel cannot reach the edge
144
+ before the next box is built (docs/HOW-IT-WORKS.md §12). A radius under 2 km is raised to 2.
145
+ Until the first position arrives the engine is `off`
118
146
  with the reason `waiting for the vessel position to build the chart box`. Set the box to 0 to
119
147
  index every cell.
120
148
 
121
- At the first start the plugin builds a chart index from the cells in a worker thread and saves
149
+ At the first start the plugin builds a chart index from the cells in a child process and saves
122
150
  it under the plugin data directory, `~/.signalk/plugin-config-data/signalk-underkeel/index`
123
151
  unless **Chart index directory** says otherwise. Until the index is ready the engine is `off`
124
152
  with the reason `building chart index`. The 12 NOAA cells around Miami (6.8 MB of `.000` and
125
153
  updates) build in 19 s at leaf zoom 18 on x86. Later starts load the saved index, 0.1 s for
126
154
  those cells, and rebuild it when a cell was added or removed, or a cell's `.000` or update files
127
155
  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.
156
+ forces a rebuild on every start. A cell folder may be a symbolic link. What the build read
157
+ from each cell is kept, compressed, in `cell-cache` in the plugin data directory (at most 512 MB), so a
158
+ chart box refresh reads again only the cells whose files changed: the 10 km Waddenzee box
159
+ rebuilds in 12–15 s instead of 30–34 s on x86. Deleting the folder is safe; the cells are then
160
+ read again.
129
161
 
130
162
  Folders of GeoJSON layers exported by `scripts/enc-to-geojson.sh` for earlier versions are no
131
163
  longer read: point **ENC cells directory** at the original `.000` cells instead. Such a folder
@@ -140,20 +172,22 @@ There are two places to set the plugin up, and both write the same configuration
140
172
  level and warnings, in the same panel with a link back to the details. Drag the keel, the margin
141
173
  line and the sounder on a side view of the hull, or type the numbers. The page shows the draft
142
174
  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.
175
+ it, and a setup check, which also lists the machine's RAM and CPUs with advice on the index
176
+ settings. **Save and restart** needs a Signal K admin login.
144
177
  - The plugin's page in the admin UI. It asks for the same essentials first; **Show expert
145
178
  settings** opens the rest, and an optional feature's fields appear once it is enabled. Admin
146
179
  UIs older than Signal K 2.32 show every field at once.
147
180
 
148
181
  ![The setup steps: boat, water level and warnings](docs/images/how-it-works/setup-panel.png)
149
182
 
150
- | Setting | What it does |
151
- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
- | ENC cells directory | where the `.000` cells are |
153
- | Boat | draft (empty uses Signal K `design.draft.maximum`) and safety margin under the keel (0 m); only the keel's lowest point counts, and squat and waves are added on top (below) |
154
- | Water level | source (`none`, `manual`, `signalk-tide`, `fmi`, see below); only the chosen source's fields are shown |
155
- | Warnings | Early, Balanced or Late, in the table below |
156
- | Optional features | living chart (own soundings), Traficom WFS depths, grounding detector; each off until enabled |
183
+ | Setting | What it does |
184
+ | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
185
+ | ENC cells directory | where the `.000` cells are |
186
+ | ENC from Charts Provider | or a chart set or converted chart whose cells signalk-charts-provider-simple shares, `*` for all of them; used instead of the directory |
187
+ | Boat | draft (empty uses Signal K `design.draft.maximum`) and safety margin under the keel (0 m); only the keel's lowest point counts, and squat and waves are added on top (below) |
188
+ | Water level | source (`none`, `manual`, `signalk-tide`, `fmi`, see below); only the chosen source's fields are shown |
189
+ | Warnings | Early, Balanced or Late, in the table below |
190
+ | Optional features | living chart (own soundings), Traficom WFS depths, grounding detector; each off until enabled |
157
191
 
158
192
  | Warning style | Looks ahead | Alert at | Warn at, within | Alarm at, within | Alarm on negative mean UKC |
159
193
  | ------------------ | ----------- | -------- | --------------- | ---------------- | -------------------------- |
@@ -180,7 +214,7 @@ Expert settings, with their defaults:
180
214
 
181
215
  | Group | Settings |
182
216
  | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
183
- | Chart index | index directory, rebuild at start, index leaf zoom (20), box around the vessel (10 km) |
217
+ | Chart index | index directory, rebuild at start, index leaf zoom (20), box around the vessel (10 km), ask Charts Provider for the ENC around the vessel |
184
218
  | Boat | length, beam (fallback: Signal K `design.*`); draft σ (0.05 m); block coefficient and heel effect (none, fin keel or a table); double squat in narrow channels; wave allowance factor (0.4 × Hs) |
185
219
  | Look-ahead | minimum horizon (60 s); position buffer (10 m, 2σ); fan spread (COG 5°, rate of turn 0.1°/s); escape-heading rate of turn (3°/s); and the distance, probabilities and windows of the warning style, each overriding the style when set |
186
220
  | Water level | manual height and σ; FMI refresh (600 s); chart-datum mappings for FMI |
@@ -257,7 +291,9 @@ screenshots, including the plugin layers in the chart list and the setup steps i
257
291
  | `notifications.grounding` | only from the optional grounding detector, when the boat has gone aground |
258
292
 
259
293
  Any Signal K app can show these; the status line on the plugin's admin page reads, for
260
- example, `Monitoring: unknown — position stale`.
294
+ example, `Monitoring: unknown — position stale`. Each value is sent when it changes; `state` and
295
+ `envelopeRev` at least every 5 s, the others every 10 s. A display that shows them should read a
296
+ `state` older than 15 s as unknown, as the plugin's own widget does.
261
297
 
262
298
  ## Optional features
263
299
 
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
+ }