Skip to content

Bring soundings in line with chart standards - #180

Merged
bkeepers merged 11 commits into
mainfrom
soundings-chart-conformance
Aug 19, 2026
Merged

Bring soundings in line with chart standards#180
bkeepers merged 11 commits into
mainfrom
soundings-chart-conformance

Conversation

@bkeepers

Copy link
Copy Markdown
Member

Soundings are the numbers a mariner actually reads a chart by, and ours diverged from the paper-chart standards in ways that ranged from cosmetic to unsafe. An audit against S-4, S-52, and the S-57 encoding guide turned up eleven divergences. This fixes seven of them, measures the rest, and leaves behind a harness that can tell whether the field is good.

The two that mattered most for safety:

Shoal depths were being thrown away. A 1.0 m floor deleted exactly the least depths that S-4 B-410b ranks above everything else. The floor is now 0.2 m, picked from the measured point where real shoals stop and waterline noise starts, not from a guess.

Soundings sat where they weren't. Each one was placed on a lattice node rather than on the pixel its depth came from — in a sampled New York view, a quarter of them landed on dry land, displaced by up to 400 m. The winning pixel's position now rides up the pyramid with the depth, so every sounding is where its depth is. S-57 UOC §5.3 is blunt about this: there is no "sounding, out of position" in an ENC.

Channels had no deep line. Taking only the shoalest pixel per cell meant a dredged channel could never show its axis — B-410b wants maximum depths too, and B-403.1a asks for "the full range of depth." Rather than special-casing channels, soundings are now inserted wherever the charted field interpolates wrong by more than a threshold, deep side included. The Ambrose Channel gets its 26-31 m line and axis infill out of a rule that knows nothing about channels.

Also fixed: decimetre precision now runs to 21 m and half-metres to 31 m per B-412 (it cut off at 6 m); fathoms print as fathoms-and-feet below 11 fathoms the way charts do; and the numbers are set in sloping type, since upright is the posture B-412.4 reserves for unreliable soundings — we had every sounding claiming to be untrustworthy.

Typography

The decimetre digit is a real subscript glyph rather than a scaled-down copy of the numeral. MapLibre's format with vertical-align aligns em-boxes, not baselines, so the shrunken digit floated visibly high — the type designer already solved this, and the glyph carries the correct drop and sidebearings.

Getting there meant hosting the fonts: the MapLibre demo stack ships three of the ten subscript digits, and a missing glyph doesn't error, it silently doesn't draw. The full stack now lives at tiles.openwaters.io/fonts from the new tile-fonts repo.

Measurement

S-4 B-410a states an acceptance test outright: no source depth should be shoaler than a mariner would get by interpolating from the charted soundings and contours. pipelines/perf/soundings.py implements it as a standing gate, and it's what scoped the rest of this work — including talking me out of a redesign, since the violation rate turns out to be flat across zoom, so thinning was never the cause.

It also surfaced something not in the original audit: regions built from coarse sources starve as you zoom in. One stem freezes at 9,438 soundings past z10, so by z14 they sit 224 mm apart at chart scale. Correct per the current design, and exactly backwards from what a mariner wants — the weakest data presents as the emptiest chart precisely when someone zooms in for detail. Filed as a follow-up rather than fixed here.

Style structure

The style module was one 850-line file. It's now one module per layer family, with appearance in flavor.ts and index.ts reduced to composition. The public API is unchanged and every expression moved verbatim.

One behavioural improvement rode along: each layer now declares which of its properties depend on the mariner settings, and applyState walks those declarations. The hand-maintained list it replaces would silently skip any property nobody remembered to add to it.

Out of scope

Datum correction (finding 1) and CATZOC/M_QUAL quality attribution (finding 2) are tracked in their own workstreams. Drying heights (finding 7) are blocked on the datum work — printing a drying height off an MSL-referenced source would be worse than printing nothing.

Findings, phases, and the full measurements are in docs/plans/2026-08-17-soundings-chart-conformance.md.

Remaining

  • Rebuild soundings — the position, floor, precision, and repair changes need one to take effect
  • Verify the rebuilt field against the B-410a gate on the shoal-rich and deep fixtures
  • Decide what to do about coarse-source starvation at high zoom
  • Drying heights, once the datum workstream lands

Closes openwatersio/seamap#73.

perf/soundings.py holds four absolute measurements of one stem's sounding
field, so a selection change is scoped and judged by evidence rather than
taste:

  interpolation  S-4 B-410a's "final test of depth selection" — no source
                 pixel may be shoaler than interpolation from the charted
                 soundings and contours, evaluated per display zoom
  rings          every closed isobath enclosing a shoal must carry the
                 least depth inside it (S-4 B-410b)
  density        nearest-neighbour spacing in mm at chart scale, against
                 the practice figures (critical/supporting/fill)
  floor          what each candidate SOUND_MIN_DEPTH_M admits and drops

Measuring per zoom is the point: the shoalest pixel of every cell is
emitted at the finest level, so violations only appear once the pyramid
thins the field.

The fixtures gain iberian-abyssal (GEBCO-only, 1358-3937 m, no land): every
other site is Gulf marsh or Delmarva lagoon, so shoal-water measurements had
no control where selection should trivially pass. Coarse macrotiles build at
a lower stem zoom (SITE_Z) so the crop covers comparable ground.
Position: every sounding sits on the pixel it was measured at. "There is
no 'sounding, out of position' in an ENC" (S-57 UOC 5.3); the jittered
lattice placement put a quarter of a sampled New York view's soundings on
land, displaced by up to a cell (~400 m). The shoalest-cell grid carries
the winning pixel's coordinates through every pyramid level.

Precision: S-4 B-412's bands replace the 6 m cut — decimetres to 21 m,
half metres to 31 m, whole metres beyond — and every unit conversion
floors through one epsilon (floor(0.3 * 10) evaluates to 2, so 0.3 m
charted as 0.2 m; the same error made 3 fathoms 4 feet compute as 3
feet). Fathoms stay whole in the tiles: a chart prints fathoms-and-feet
below 11 fathoms, and a fathom is exactly six feet, so the viewer derives
both digits from depth_ft rather than shipping a mixed-radix number.

Floor: 0.2 m, from the measured waterline-noise knee (admitted cells grow
smoothly from 1.0 down to 0.1 m, then jump 2-5x at 0.1 -> 0.0 across the
marsh fixtures). B-410b puts least depths at the top of what must survive
selection, so the floor is as low as the data supports.

Least depths: the least depth inside every closed isobath is emitted as a
prime sounding — a closed contour with no sounding on it is a charted
shoal a mariner interpolates across at the ring's own level (measured on
the terrebonne fixture: 4 of 4 shoal rings bare, understating by up to
1.30 m; B-410a violations with contours 86% -> 35% once charted).
Detection runs on one decimated read bounded to SHOAL_DETECT_PX and peaks
are pinned by one small full-res read each: the windows have no
overviews, so per-component full-res reads ground the whole 4.33 GB file
repeatedly and were OOM-killed at >10 GB; this pass peaks at 0.84 GB on
the same window.

A prime sounding is never zoom-capped, and displays exactly where its
enclosing isobath displays: its minzoom is the ring's display zoom off
CONTOUR_TIERS. B-410b's "must always be shown" is a claim about the
charted feature — once generalization drops the ring at a scale, the
chart makes no claim there for the sounding to complete. Promoting primes
past their rings put decimetre least depths over the generalized
coastline of coarse views; on the rebuilt New York stem the 2-5 m ring
class (2,603 of 4,127 primes) moves from z8 to z11 and inland Long
Island renders clean, with 4 of 105 rendered soundings on the land code
at native resolution.

prime rides to the tiles as a property (tippecanoe -y is an allowlist;
an attribute absent from VECTOR_ATTRS is silently dropped however
faithfully the pipeline wrote it) so the style can rank it for label
collisions.
Sloping numerals (S-4 B-412.1) via a dedicated sounding face, Noto Sans
Italic — upright is the posture B-412.4 reserves for soundings of lower
reliability, and every label was set in it. Glyphs come from
tiles.openwaters.io/fonts (openwatersio/tile-fonts): the MapLibre demo
stack ships three of the ten subscript digits, and a missing glyph is not
an error, it simply does not draw, so a decimetre digit would vanish off
the chart.

The sub-unit digit is set smaller and dropped below the baseline
(B-412.1), never shown when zero, with no separator — a point would
misread 3 fathoms 4 feet as 3.4 fathoms. Metres carry decimetres;
fathoms derive fathoms-and-feet from depth_ft, six to the fathom.

Label size is a flat 18 px em (~13 px digit height, S-52 5.2.1(2)'s
~3.5 mm): "text size should never be decreased when zooming out"
(S-52 3.1.5), so the zoom ramp is gone and low-zoom clutter is thinned
by per-feature minzoom and collision, never by shrinking the type.

One colour for the whole field, like a paper chart — S-4 sets every
sounding in one style, and hazard is carried by the depth-area tint and
the isobaths. prime (the least depth inside a closed isobath) outranks
the field for label collisions, so a deeper neighbour can never displace
it, but gets no ink of its own: it is a topological fact of the build
window (a shelf whose contour closes beyond the window edge is never
prime, a 0.5 m wrinkle at 29 m is), and painting it black read as a
hazard ranking it is not.

The viewer's click popup floors depths toward shoal like everything else
on the chart; it rounded, so 16.6 m of water read 17 m.
The audit findings, the measurements that scoped each phase, and the
design record — including the approaches that were tried and reverted,
with the numbers that killed them.
S-4 B-410a's "final test of depth selection" — no ground shoaler than a
mariner would infer by interpolating the charted field — becomes a
selector, not just a gate. Per display zoom, coarse to fine, the
displayed field is interpolated against the DEM and repaired where it
understates the water, each insertion at its measured pixel, uncapped
from the zoom that needed it: a sounding's minzoom is the coarsest zoom
that needed it. A regression fixture holds a violation only the finer
zoom's field can see (outside the coarse field's convex hull), so the
per-zoom loop cannot silently degrade to a first-zoom pass.

Deep-side repair is the default. Where the charted field understates the
water by half its true depth (B-403.1a's "full range of depth"), the
ground is charted — which is what makes a dredged channel's deep line
appear with no channel-specific code: the New York window gains 2,552
insertions (+1.5%), 236 of them the 26-31 m line and infill of the
Ambrose corridor through ~10 m ambient, including the Narrows scour.
Deep-side stops at the navigational band — without that bound the flat
abyssal control drew 1,018 insertions bounding 3,000 m slopes; with it
the control draws zero — and respects a screen-px spacing floor
(REPAIR_SPACING_PX, default 8): these points exist for legibility, so
legibility may thin them. Swept 0-16 px on the marsh fixture: at 8 px the
field grows 13.5%, the deep residual falls 5.2% -> ~0, and every shoal
ring carries its least depth (bare rings 1/4 -> 0/4 with contours
charted).

Shoal-side repair exists but is opt-in (REPAIR_SHOAL). The mariner of
B-410a interpolates from soundings AND contours, and with the isobaths
charted — cut from this same DEM — the measured shoal residual at
charted precision is 0.08% before any repair. A soundings-only
interpolant, by contrast, manufactures shoal "violations" in the tent
around every deep insertion and cascades pitch-spaced points zoom after
zoom (measured: every zoom capped out, nearest-neighbour medians of
0.5 px). The shoal side is already guarded by the shoalest-per-cell
lattice, the prime rings, and the band tints.

One pass per zoom, judged against the field as it stood: iterating
re-judges against fresh insertions and seesaws. Deep pins take the
measured pixel closest to the decimated value rather than the extreme —
pinned extremes mint sharp control points whose own tents violate the
opposite tolerance. Both sides keep the decimation pitch from the
existing field: a violation within a pitch of a charted sounding is a
near-duplicate, not a suppressed hazard.
On an incompatible TileJSON schema the viewer removes the map and is
meant to replace it with an explanation; with the replacement commented
out, version skew rendered a blank page with no diagnosis.
The sub-unit digit is the typeface's own subscript glyph (U+2080-2089,
present ten-of-ten in the self-hosted stack) followed by a hair space of
air. The previous construction — a scaled `format` section with
vertical-align — aligned em-boxes rather than baselines, so the digit sat
visibly high and crowded the integer; the type designer's glyph carries
the drop, the size, and the sidebearings. No decimal separator: S-4
B-412.1 separates only when the digits share a baseline, and on the
fathom ladder a point would misread 3 fathoms 4 feet as 3.4 fathoms.

Label size ramps 12 px at z8 to 18 px at z13 — a deliberate deviation
from S-52 3.1.5's "never decrease when zooming out", which assumes
ECDIS's fixed compilation scale; at a z8 overview full-size type shouts
over a whole sea. Chart-scale size is unchanged at 18 px.

The chart sets Medium (soundings in Medium Italic): a halo eats into a
light stroke, so weight buys legibility at small sizes that size alone
cannot, and S-52's stroked sounding glyphs sit nearer Medium than
Regular anyway. Halo softened to half alpha, and the soundings'
collision padding drops to the renderer default — padding is the
render-side density knob (centre spacing is label width plus twice the
padding), and the old 20 px forced ~17 mm "fill" spacing everywhere,
culling soundings the pipeline deliberately carries.

Tests pin the invariants and free the knobs: sounding posture must be
an italic face (the weight is flavor tuning), sub-unit strings are
asserted exactly, and prime still outranks the field for collisions.
Layer structure moves out of the 850-line index into per-layer modules —
depth-shading (with the relief ramp), depth-areas (with the isobath
ladders and the safety-contour snap both it and the contours share),
hillshade, contours, soundings, coverage — with appearance staying in
flavor.ts and index.ts reduced to composition: sources(), layers(),
style(), applyState(), readDepth(). The public API is unchanged, and
every expression moved verbatim.

Each layer module also declares WHICH of its properties depend on the
mariner settings (unit/safety/shading/hillshade), and applyState walks
those declarations generically. The old hand-maintained per-layer switch
silently skipped any state-dependent property it didn't know about — a
layer gaining one now declares it in the same file, same diff. The
generic walk emits the identical call set to the switch it replaces.

Relative imports carry .js extensions so the built package resolves
under pure Node ESM, not just bundlers, and package.json ships every
module for the development condition that serves raw TypeScript.
Paint order runs forward (later on top); symbol placement runs in
reverse (the last symbol layer claims space first and wins cross-layer
collisions — verified against MapLibre's placement loop). The soundings-
after-contour-labels position is deliberate under both readings, and is
the kind of fact the next reader would plausibly 'fix' backwards.
Copilot AI lite review requested due to automatic review settings August 19, 2026 14:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates the sounding generation pipeline and MapLibre style to better match nautical chart standards (S-4/S-52/S-57), improving both safety-critical behavior (shoal retention, true-position encoding) and chart typography, while introducing a measurement harness to quantify conformance.

Changes:

  • Reworks sounding generation to place soundings at the source pixel, retain least depths over enclosed shoals (“prime” soundings), and optionally repair the field based on interpolation error thresholds.
  • Refactors the style into per-layer modules and updates sounding typography (subscript glyphs, fathoms-and-feet rendering, uniform sounding ink, italic-only soundings) plus state application plumbing.
  • Adds performance/conformance measurement tooling and fixtures updates, and documents the updated schema and plan.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
style/soundings.ts New soundings symbol-layer module with chart-typography formatting (subscripts, fathoms/feet) and collision priority rules.
style/package.json Adjusts published file set to include the new per-layer TS modules.
style/index.ts Refactors style assembly into composed per-layer modules; updates applyState to use per-layer state declarations; switches glyph host.
style/index.test.ts Expands tests to validate new sounding expressions, collision priority, glyph hosting, and state application behavior.
style/hillshade.ts New hillshade layer module plus state declaration for applyState.
style/flavor.ts New shared flavor/types module and shared label size expression; adds sounding-specific italic font.
style/depth-shading.ts New depth-shading (color-relief) layer module and state declaration.
style/depth-areas.ts New DEPARE fill module; centralizes safety contour snapping shared with contours.
style/coverage.ts New provenance/coverage overlay layer family module.
style/contours.ts New contours layer modules (lines + labels) and state declarations; shares safety snapping with depth areas.
pipelines/soundings_run.py Major overhaul of sounding selection: true-position placement, prime soundings for enclosed shoals, error-driven repair, updated precision rules.
pipelines/perf/soundings.py New conformance measurement harness (B-410a interpolation gate, rings, density, floor measurements) with self-checks.
pipelines/perf/fixtures.py Adds an abyssal-plane control fixture and updates fixture zoom selection logic.
pipelines/contour_run.py Ensures prime attribute is preserved into vector tiles via tippecanoe allowlist/types.
Justfile Wires the new soundings perf self-check into just test-perf.
index.js Updates click readout depth formatting to avoid overstating depth (flooring toward shoaler).
docs/schema.md Updates sounding field documentation to reflect new precision bands and fathoms/feet derivation rules.
docs/plans/2026-08-17-soundings-chart-conformance.md Adds a detailed planning/measurement document capturing findings, phases, and rationale.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pipelines/soundings_run.py Outdated
The constant was assigned twice with two versions of the same comment,
left over from editing the detection path in two passes. Same value both
times, so nothing changes but the reader's confidence about which one
is authoritative.
@bkeepers
bkeepers merged commit 1615e26 into main Aug 19, 2026
2 checks passed
@bkeepers
bkeepers deleted the soundings-chart-conformance branch August 19, 2026 14:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

idea: soundings with subscript decimals

2 participants