diff --git a/app/config/run.json b/app/config/run.json
index 354add91..a4a3e493 100644
--- a/app/config/run.json
+++ b/app/config/run.json
@@ -335,16 +335,16 @@
},
"nowcast": {
"grid": {
- "longitude": 96,
- "latitude": 80,
- "depth": 6
+ "longitude": 48,
+ "latitude": 40,
+ "depth": 26
},
"interval_ticks": 900,
"time_steps": 4,
"step_seconds": 3600
},
"departure": {
- "grid": { "longitude": 48, "latitude": 40, "depth": 6 },
+ "grid": { "longitude": 48, "latitude": 40, "depth": 26 },
"time_steps": 12,
"step_seconds": 3600
},
@@ -403,7 +403,8 @@
"depth_m": 80,
"thickness_m": 30,
"temperature_drop_c": 4,
- "salinity_rise_psu": 0.1
+ "salinity_rise_psu": 0.1,
+ "displacement_m_per_c": 20
},
"jitter": {
"depth_m": 15,
diff --git a/app/public/snapshots/arriving.snapshot b/app/public/snapshots/arriving.snapshot
index e7ca870f..5a2497a6 100644
Binary files a/app/public/snapshots/arriving.snapshot and b/app/public/snapshots/arriving.snapshot differ
diff --git a/app/public/snapshots/leaving.snapshot b/app/public/snapshots/leaving.snapshot
index eeead0c4..f83cceae 100644
Binary files a/app/public/snapshots/leaving.snapshot and b/app/public/snapshots/leaving.snapshot differ
diff --git a/app/public/snapshots/loitering.snapshot b/app/public/snapshots/loitering.snapshot
index 2feaae8c..42549628 100644
Binary files a/app/public/snapshots/loitering.snapshot and b/app/public/snapshots/loitering.snapshot differ
diff --git a/app/public/snapshots/returning.snapshot b/app/public/snapshots/returning.snapshot
index 734d21c4..c0ce80c0 100644
Binary files a/app/public/snapshots/returning.snapshot and b/app/public/snapshots/returning.snapshot differ
diff --git a/app/src/backend/advisories/source.ts b/app/src/backend/advisories/source.ts
index 61610134..b8839f19 100644
--- a/app/src/backend/advisories/source.ts
+++ b/app/src/backend/advisories/source.ts
@@ -16,7 +16,8 @@ import { Rng } from '../lib/rng.js';
import { configDigest } from '../lib/sha256.js';
import { HeartbeatEmitter } from '../lib/heartbeat.js';
import { isoPlusSeconds } from '../lib/sim-time.js';
-import { backgroundTemperature, backgroundSalinity, soundSpeedMs } from '../env-generator/analytic.js';
+import { backgroundTemperature, backgroundSalinity } from '../env-generator/analytic.js';
+import { soundSpeedMs } from '../../seam/ocean-relations.js';
import type { EnvGenerator } from '../env-generator/generator.js';
import type { FeatureStore } from '../feature-store/store.js';
diff --git a/app/src/backend/analyst/analyst.test.ts b/app/src/backend/analyst/analyst.test.ts
index d49c7729..d11c58ea 100644
--- a/app/src/backend/analyst/analyst.test.ts
+++ b/app/src/backend/analyst/analyst.test.ts
@@ -20,7 +20,7 @@ import type {
} from '../../generated/types.js';
import { createSeamValidator } from '../../seam/validate.js';
import { buildBackend, type BackendRuntime } from '../runtime/runtime.js';
-import { soundSpeedMs } from '../env-generator/analytic.js';
+import { soundSpeedMs } from '../../seam/ocean-relations.js';
import { driveUntil } from '../test-support/drive.js';
const validator = createSeamValidator();
diff --git a/app/src/backend/analyst/analyst.ts b/app/src/backend/analyst/analyst.ts
index 620c8a74..d89de372 100644
--- a/app/src/backend/analyst/analyst.ts
+++ b/app/src/backend/analyst/analyst.ts
@@ -506,7 +506,17 @@ export class Analyst {
const digest = `sha256:${sha256Hex(bytes)}`;
const manifest: Manifest = {
...baseManifest,
- generator: { name: 'drogna-analyst', version: '2.0.0', analytic_form_version: 1 },
+ // The form version travels with the parameters, and the parameters come from the
+ // manifest spread above. Typed here it was correct only while there was one form:
+ // the moment the env-generator reached form 2 (#113) this document would have
+ // carried form 2's thermocline parameters under a form 1 label, telling a reader it
+ // could reconstruct a doming layer with the flat rule. Read from the base and it
+ // cannot drift again.
+ generator: {
+ name: 'drogna-analyst',
+ version: '2.0.0',
+ analytic_form_version: baseManifest.generator.analytic_form_version,
+ },
config_digest: configDigest(this.config),
generated_at: { sim_time: this.simTime.value, tick: this.simTime.tick },
grid: {
diff --git a/app/src/backend/env-generator/analytic.test.ts b/app/src/backend/env-generator/analytic.test.ts
index c527cc77..a2cf0e57 100644
--- a/app/src/backend/env-generator/analytic.test.ts
+++ b/app/src/backend/env-generator/analytic.test.ts
@@ -5,11 +5,10 @@ import {
frontAnomalyT,
frontSignedDistanceKm,
movingCentreAt,
- pressureDbar,
- soundSpeedMs,
tauAt,
temperatureAt,
thermoclineAnomalyT,
+ thermoclineDepthAt,
type WorldParameters,
} from './analytic.js';
@@ -41,7 +40,7 @@ const world: WorldParameters = {
salinity_amplitude_psu: -0.2,
depth_scale_m: 250,
},
- thermocline: { depth_m: 80, thickness_m: 30, temperature_drop_c: 4, salinity_rise_psu: 0.1 },
+ thermocline: { depth_m: 80, thickness_m: 30, temperature_drop_c: 4, salinity_rise_psu: 0.1, displacement_m_per_c: 20 },
moving: {
centre_latitude: 45,
centre_longitude: -12.5,
@@ -62,7 +61,7 @@ const tau = {
feature_seconds: { eddy: 432000, front: 172800, thermocline: 1209600, moving: 86400 },
};
-describe('the analytic form (v1)', () => {
+describe('the analytic form (v2)', () => {
it('background cools and freshens towards the deep, monotonically', () => {
let previous = Number.POSITIVE_INFINITY;
for (const depth of [0, 50, 100, 200, 400, 800, 1000]) {
@@ -89,12 +88,52 @@ describe('the analytic form (v1)', () => {
});
it('the thermocline realises its full drop across the layer', () => {
- const above = thermoclineAnomalyT(world.thermocline, 0);
- const below = thermoclineAnomalyT(world.thermocline, 400);
+ const above = thermoclineAnomalyT(world.thermocline, 0, world.thermocline.depth_m);
+ const below = thermoclineAnomalyT(world.thermocline, 400, world.thermocline.depth_m);
// Within the tanh tails: the surface sits 2.7 thicknesses above the layer.
expect(above - below).toBeCloseTo(4, 1);
});
+ it('the layer follows the features that displace it (analytic form 2)', () => {
+ /*
+ * Far from the eddy and the drifting feature the layer sits where it was authored: form 2
+ * reduces to form 1 wherever nothing local is acting, which is what makes the displacement a
+ * perturbation of the authored depth rather than a different depth scale.
+ *
+ * **The far field is quiet only because the front is not a displacement term**, which is
+ * this assertion's other job. An earlier version included it, and this test failed here at
+ * 63.9 m against 80 — the front's own saturated −1.2 °C times the coefficient, reaching the
+ * corner of the domain. A `tanh` never decays, so there is no "far" from a front, and the
+ * measurement in `thermoclineDepthAt`'s note is what that cost the drifting feature's
+ * recovery. Put the front back and this line fails first and cheapest.
+ */
+ const far = thermoclineDepthAt(world, -13.9, 44.1, 0);
+ expect(far).toBeCloseTo(world.thermocline.depth_m, 1);
+
+ // The eddy is warm-cored (sign +1), so it presses the layer down beneath it.
+ const underEddy = thermoclineDepthAt(world, world.eddy.centre_longitude, world.eddy.centre_latitude, 0);
+ expect(underEddy).toBeGreaterThan(far + 10);
+
+ // The moving feature is cold-cored (sign -1), so it domes the layer, and it takes the dome
+ // with it: the dome is under the centre at each time, not under the initial centre.
+ const start = movingCentreAt(world.moving, 0);
+ const later = movingCentreAt(world.moving, 5 * 86400);
+ expect(thermoclineDepthAt(world, start.longitude, start.latitude, 0)).toBeLessThan(far - 10);
+ expect(thermoclineDepthAt(world, later.longitude, later.latitude, 5 * 86400)).toBeLessThan(far - 10);
+ expect(thermoclineDepthAt(world, start.longitude, start.latitude, 5 * 86400)).toBeGreaterThan(
+ thermoclineDepthAt(world, later.longitude, later.latitude, 5 * 86400),
+ );
+ });
+
+ it('a zero displacement coefficient reproduces the flat layer of analytic form 1', () => {
+ // The version bump's own boundary, so "form 2 is a superset of form 1" is a checked claim
+ // rather than a sentence in a comment.
+ const flat: WorldParameters = { ...world, thermocline: { ...world.thermocline, displacement_m_per_c: 0 } };
+ for (const [lon, lat] of [[-13.9, 44.1], [world.eddy.centre_longitude, world.eddy.centre_latitude], [-9.5, 47]]) {
+ expect(thermoclineDepthAt(flat, lon, lat, 0)).toBe(flat.thermocline.depth_m);
+ }
+ });
+
it('the moving feature advects as an exact affine map', () => {
const day = movingCentreAt(world.moving, 86400);
expect(day.latitude).toBeCloseTo(45 + 5 / 111.32, 6);
@@ -112,14 +151,4 @@ describe('the analytic form (v1)', () => {
const centreLater = movingCentreAt(world.moving, 10 * 86400);
expect(tauAt(world, tau, centreLater.longitude, centreLater.latitude, 100, 10 * 86400)).toBeCloseTo(86400, 0);
});
-
- it('pressure and sound speed behave at their anchors', () => {
- expect(pressureDbar(0)).toBe(0);
- expect(pressureDbar(1000)).toBeCloseTo(1007.5, 6);
- // Mackenzie's own anchor: 0°C, 35 psu, surface.
- expect(soundSpeedMs(0, 35, 0)).toBeCloseTo(1448.96, 2);
- const mid = soundSpeedMs(10, 35, 100);
- expect(mid).toBeGreaterThan(1480);
- expect(mid).toBeLessThan(1500);
- });
});
diff --git a/app/src/backend/env-generator/analytic.ts b/app/src/backend/env-generator/analytic.ts
index 882214f5..1d35e63d 100644
--- a/app/src/backend/env-generator/analytic.ts
+++ b/app/src/backend/env-generator/analytic.ts
@@ -1,5 +1,14 @@
/**
- * The analytic form, version 1 (manifest.schema.json `generator.analytic_form_version`).
+ * The analytic form, version 2 (manifest.schema.json `generator.analytic_form_version`).
+ *
+ * **Version 2 gives the thermocline a shape.** In form 1 `thermoclineAnomalyT` took only a depth:
+ * the layer sat at one depth everywhere and no resolution could make it dome, because there was
+ * nothing to resolve. FR-120 asks for a surface and says why — "the thermocline domes, tilts and
+ * breaks, and shape is the answer a sonar user is asking for" — so form 1 could not satisfy the
+ * requirement by any drawing, at any grid spacing (issue #113, measured: 7,680 of 7,680 columns
+ * picked one depth at every spacing from 200 m down to 10 m). Form 2 displaces the layer where a
+ * feature warms or cools the water at it, which is what a mesoscale feature does to an isopycnal,
+ * and the surface acquires the shape the requirement was asking to see.
*
* Everything the generator stores is evaluated from these pure functions of
* (longitude, latitude, depth, seconds-from-origin) and the jittered parameters the
@@ -18,7 +27,7 @@ import type {
ManifestThermoclineParameters,
} from '../../generated/types.js';
-export const ANALYTIC_FORM_VERSION = 1;
+export const ANALYTIC_FORM_VERSION = 2;
export const KM_PER_DEGREE_LATITUDE = 111.32;
@@ -110,19 +119,107 @@ export function frontAnomalyS(p: ManifestFrontParameters, longitude: number, lat
return p.salinity_amplitude_psu * Math.tanh(x) * Math.exp(-depthM / p.depth_scale_m);
}
-export function thermoclineMembership(p: ManifestThermoclineParameters, depthM: number): number {
- const d = (depthM - p.depth_m) / p.thickness_m;
+/**
+ * How far the features push the layer at one place, and therefore where the layer is there.
+ *
+ * **Why the anomalies are evaluated at the layer's nominal depth rather than at the depth being
+ * asked about.** A layer's depth is a property of the place, not of the depth you happen to be
+ * sampling: if this read the anomaly at `depthM` the layer would sit somewhere different for every
+ * sample taken through the same column, which is not a surface at all. `depth_m` is the one depth
+ * every column shares, so it is the one that can be asked at.
+ *
+ * That also keeps this out of a loop. The displacement depends on the eddy, front and moving terms
+ * and those depend on position and time — but never on the thermocline, so nothing here reads what
+ * it is about to write.
+ *
+ * The sign is the ocean's: warm water above an isopycnal presses it down, cool water lets it rise.
+ * `displacement_m_per_c` is positive, so a warm-core eddy deepens the layer beneath it and a cold
+ * one domes it — the doming FR-120 names.
+ *
+ * **The front is deliberately not one of the terms, and this is the interesting decision here.**
+ * The obvious form was "every anomaly displaces the layer", and the front's anomaly is a `tanh`:
+ * it *saturates* rather than decaying, so as a displacement it does not tilt the layer near the
+ * front, it holds one half of the domain permanently deep and the other permanently shallow, out
+ * to the edges. The eddy and the drifting feature use radial Gaussians that fall to nothing, which
+ * is what a local displacement has to do.
+ *
+ * That is an argument from shape, and the measurement agrees with it. Recovering the drifting
+ * feature's centre, error in km against its own 40 km radius — every figure below printed by
+ * `features.test.ts` itself, over the seeds that file actually runs, so the table reproduces by
+ * being the test's own output rather than a probe's:
+ *
+ * | terms, at the shipped 20 m/°C | 1234 | 1180001 | 1180002 | 1180003 | 1180004 |
+ * |---|---|---|---|---|---|
+ * | none (analytic form 1) | 3.1 | 9.7 | 20.5 | 7.0 | *declined* |
+ * | eddy, front and moving | 3.1 | 8.8 | 17.6 | 5.4 | *declined* |
+ * | eddy and moving (shipped) | **2.4** | **8.3** | **14.5** | **4.7** | **3.6** |
+ *
+ * Dropping the front is better on every seed, and on 1180004 — `returning`'s own — it is the
+ * difference between a position and a refusal: with the front in the displacement the estimator
+ * declines there exactly as form 1 does, because a global cold half and warm half at the layer's
+ * depth is a blob the size of the domain and it drowns a 40 km one. The front's own horizontal
+ * step is already in the field through `frontAnomalyT`; stepping the layer with it as well was
+ * double-counting the one feature that needs no help being seen.
+ *
+ * **And the coefficient is 20 because that is where the front's *anchor* still lands.** With the
+ * front excluded, 30 and 40 m/°C both recover the drifting feature better still (1.9 km on
+ * 1180004) — and both put seed 1180003's front anchor outside its 30 km bound. The layer's shape
+ * and the front's position are read off the same horizontal structure, so buying more of one
+ * spends the other. That is the tension ADR-0044's last section is about, met here in miniature.
+ */
+/*
+ * **Nothing clamps the result to the water column, and that is a bound this does not have.**
+ * `depth_m` is jittered and both feature strengths are, so a large enough coefficient could in
+ * principle place the layer above the surface or below the domain; the arithmetic would not fail,
+ * it would publish a layer nobody can sample. At the shipped 20 m/°C the layer spans 59 to 131 m
+ * against a 0–1000 m domain, which is not close — the reason no clamp is added is that a clamp
+ * would hide the configuration error rather than report it, and the honest place for that check
+ * is a bound on the *configuration*, which nothing currently has.
+ */
+export function thermoclineDepthAt(
+ w: WorldParameters,
+ longitude: number,
+ latitude: number,
+ secondsFromOrigin: number,
+): number {
+ const nominal = w.thermocline.depth_m;
+ const anomalyC =
+ eddyAnomalyT(w.eddy, longitude, latitude, nominal) +
+ w.moving.sign * w.moving.strength_c * movingMembership(w.moving, longitude, latitude, nominal, secondsFromOrigin);
+ return nominal + w.thermocline.displacement_m_per_c * anomalyC;
+}
+
+/**
+ * The three terms below take the layer's depth *at this place* as an argument rather than reading
+ * `p.depth_m`. It is deliberately not defaulted: a caller that forgets it would silently get the
+ * flat form 1 field back, which is the fault this version exists to fix, and a default is how that
+ * would happen quietly.
+ */
+export function thermoclineMembership(
+ p: ManifestThermoclineParameters,
+ depthM: number,
+ layerDepthM: number,
+): number {
+ const d = (depthM - layerDepthM) / p.thickness_m;
const sech = 1 / Math.cosh(d);
return sech * sech;
}
-export function thermoclineAnomalyT(p: ManifestThermoclineParameters, depthM: number): number {
+export function thermoclineAnomalyT(
+ p: ManifestThermoclineParameters,
+ depthM: number,
+ layerDepthM: number,
+): number {
// Half the drop above, half below: the full drop is realised across the layer.
- return (-p.temperature_drop_c / 2) * (1 + Math.tanh((depthM - p.depth_m) / p.thickness_m)) + p.temperature_drop_c / 2;
+ return (-p.temperature_drop_c / 2) * (1 + Math.tanh((depthM - layerDepthM) / p.thickness_m)) + p.temperature_drop_c / 2;
}
-export function thermoclineAnomalyS(p: ManifestThermoclineParameters, depthM: number): number {
- return (p.salinity_rise_psu / 2) * (1 + Math.tanh((depthM - p.depth_m) / p.thickness_m)) - p.salinity_rise_psu / 2;
+export function thermoclineAnomalyS(
+ p: ManifestThermoclineParameters,
+ depthM: number,
+ layerDepthM: number,
+): number {
+ return (p.salinity_rise_psu / 2) * (1 + Math.tanh((depthM - layerDepthM) / p.thickness_m)) - p.salinity_rise_psu / 2;
}
/** The moving feature's centre at a time: exact affine drift about reference_latitude. */
@@ -176,7 +273,7 @@ export function temperatureAt(
backgroundTemperature(w.background, depthM) +
eddyAnomalyT(w.eddy, longitude, latitude, depthM) +
frontAnomalyT(w.front, longitude, latitude, depthM) +
- thermoclineAnomalyT(w.thermocline, depthM) +
+ thermoclineAnomalyT(w.thermocline, depthM, thermoclineDepthAt(w, longitude, latitude, secondsFromOrigin)) +
w.moving.sign * w.moving.strength_c * movingMembership(w.moving, longitude, latitude, depthM, secondsFromOrigin)
);
}
@@ -192,7 +289,7 @@ export function salinityAt(
backgroundSalinity(w.background, depthM) +
eddyAnomalyS(w.eddy, longitude, latitude, depthM) +
frontAnomalyS(w.front, longitude, latitude, depthM) +
- thermoclineAnomalyS(w.thermocline, depthM) +
+ thermoclineAnomalyS(w.thermocline, depthM, thermoclineDepthAt(w, longitude, latitude, secondsFromOrigin)) +
w.moving.sign *
w.moving.salinity_strength_psu *
movingMembership(w.moving, longitude, latitude, depthM, secondsFromOrigin)
@@ -214,7 +311,7 @@ export const TAU_BLENDING_RULE = {
export const TAU_MEMBERSHIP_RULE = {
rule: 'anomaly-geometry',
description:
- 'A feature’s membership at a location is the same normalised geometry its anomaly uses (radial Gaussian, sech² cross-front decayed by depth, sech² across the layer, drifting radial Gaussian), so the timescale and the anomaly it belongs to cannot drift apart; the moving feature’s tau advects with it.',
+ 'A feature’s membership at a location is the same normalised geometry its anomaly uses (radial Gaussian, sech² cross-front decayed by depth, sech² across the layer about the layer’s displaced depth, drifting radial Gaussian), so the timescale and the anomaly it belongs to cannot drift apart; the moving feature’s tau advects with it and the thermocline’s domes with it.',
} as const;
export function tauAt(
@@ -228,7 +325,13 @@ export function tauAt(
const memberships: [number, number][] = [
[eddyMembership(w.eddy, longitude, latitude, depthM), tau.feature_seconds.eddy],
[frontMembership(w.front, longitude, latitude, depthM), tau.feature_seconds.front],
- [thermoclineMembership(w.thermocline, depthM), tau.feature_seconds.thermocline],
+ // The layer's membership follows the layer. TAU_MEMBERSHIP_RULE says a feature's membership is
+ // the same geometry its anomaly uses, so the displaced layer has to displace its tau with it or
+ // the two would drift apart exactly where the shape is.
+ [
+ thermoclineMembership(w.thermocline, depthM, thermoclineDepthAt(w, longitude, latitude, secondsFromOrigin)),
+ tau.feature_seconds.thermocline,
+ ],
[movingMembership(w.moving, longitude, latitude, depthM, secondsFromOrigin), tau.feature_seconds.moving],
];
let bestWeight = 0;
@@ -242,59 +345,16 @@ export function tauAt(
return tau.background_seconds * (1 - bestWeight) + bestTau * bestWeight;
}
-export const PRESSURE_RELATION = {
- name: 'linear-depth',
- expression: 'pressure_dbar = surface_dbar + dbar_per_metre * depth_m',
- dbar_per_metre: 1.0075,
- surface_dbar: 0,
-} as const;
-
-export function pressureDbar(depthM: number): number {
- return PRESSURE_RELATION.surface_dbar + PRESSURE_RELATION.dbar_per_metre * depthM;
-}
-
-export const SOUND_SPEED = {
- method: 'Mackenzie 1981 nine-term equation',
- implementation: 'app/src/backend/env-generator/analytic.ts#soundSpeedMs',
- validity: {
- min_temperature_c: 2,
- max_temperature_c: 30,
- min_salinity_psu: 25,
- max_salinity_psu: 40,
- min_depth_m: 0,
- max_depth_m: 8000,
- },
-} as const;
-
-/**
- * ADR-0005: sound speed is derived at the point of use, never stored, and this is
- * the single implementation in drogna — a residual computed anywhere cites it.
- */
-export function soundSpeedMs(temperatureC: number, salinityPsu: number, depthM: number): number {
- const t = temperatureC;
- const s = salinityPsu;
- const d = depthM;
- return (
- 1448.96 +
- 4.591 * t -
- 5.304e-2 * t * t +
- 2.374e-4 * t * t * t +
- 1.34 * (s - 35) +
- 1.63e-2 * d +
- 1.675e-7 * d * d -
- 1.025e-2 * t * (s - 35) -
- 7.139e-13 * t * d * d * d
- );
-}
-
-export function insideSoundSpeedValidity(temperatureC: number, salinityPsu: number, depthM: number): boolean {
- const v = SOUND_SPEED.validity;
- return (
- temperatureC >= v.min_temperature_c &&
- temperatureC <= v.max_temperature_c &&
- salinityPsu >= v.min_salinity_psu &&
- salinityPsu <= v.max_salinity_psu &&
- depthM >= v.min_depth_m &&
- depthM <= v.max_depth_m
- );
-}
+// **Pressure and sound speed live in `app/src/seam/ocean-relations.ts`.** They are declared
+// relations rather than the analytic ocean — functions of three numbers that read no field — and
+// a manifest points a client at the implementation, so it has to be reachable from both sides of
+// the seam. ADR-0005's "single implementation in drogna" is what the move preserves: feature 124's
+// volume needs sound speed from served temperature and salinity, and the alternative was a second
+// copy.
+//
+// They were re-exported from here for a while, so the eight backend call sites would not have to
+// change. That cost the move most of its point: every consumer still read `from
+// '../env-generator/analytic.js'`, so a reader of `monitor.ts` or `sensors.ts` saw the relation
+// attributed to the analytic-ocean module that the file above says it is not, and `grep
+// ocean-relations` found no backend consumer of a relation the whole backend uses. Eight one-line
+// edits buy a tree where the owner is visible at the point of use, which is what the move was for.
diff --git a/app/src/backend/env-generator/departure.test.ts b/app/src/backend/env-generator/departure.test.ts
index 63a3a41e..68926b5c 100644
--- a/app/src/backend/env-generator/departure.test.ts
+++ b/app/src/backend/env-generator/departure.test.ts
@@ -55,13 +55,38 @@ describe('the departure brief (feature 121)', () => {
// world at each step would differ here, and the eddy and the moving feature are
// both live over this window, so the difference would be real rather than a
// rounding artefact.
+ /*
+ * Counted, then asserted once. An `expect` per cell was a million assertions the moment the
+ * depth axis was raised (#113) and timed this test out; it also reported only the first
+ * offender, where the *number* that drifted says whether one cell moved or the whole brief was
+ * re-evaluated — which is the difference this test is about.
+ *
+ * The count of drifts is uncapped for that reason; only the examples are capped, because four
+ * are enough to read and a million are not. An earlier version asserted the number of
+ * *comparisons* instead, which is the loop's own bounds restated and true for every input —
+ * a line that looks like a check and cannot fail.
+ */
+ let drifts = 0;
+ const examples: string[] = [];
for (let variable = 0; variable < grid.time.count * perStep * 2; variable += grid.time.count * perStep) {
for (let step = 1; step < grid.time.count; step++) {
for (let cell = 0; cell < perStep; cell++) {
- expect(values[variable + step * perStep + cell]).toBe(values[variable + cell]);
+ const held = values[variable + step * perStep + cell];
+ const issued = values[variable + cell];
+ if (held === issued) continue;
+ drifts += 1;
+ if (examples.length < 4) examples.push(`step ${step} cell ${cell}: ${held} != ${issued}`);
}
}
}
+ // The brief has to have something in it to hold: a zero-step or empty grid would pass the
+ // drift assertion by having nothing to compare, which is the vacuous pass to guard against.
+ expect(grid.time.count, 'a brief with one step holds nothing across steps').toBeGreaterThan(1);
+ expect(perStep).toBeGreaterThan(0);
+ expect(
+ { drifts, examples },
+ `${drifts} of ${2 * (grid.time.count - 1) * perStep} held values differ from the one they were issued with`,
+ ).toEqual({ drifts: 0, examples: [] });
runtime.stop();
});
diff --git a/app/src/backend/env-generator/generator.test.ts b/app/src/backend/env-generator/generator.test.ts
index ac9bd9a5..6bddc59a 100644
--- a/app/src/backend/env-generator/generator.test.ts
+++ b/app/src/backend/env-generator/generator.test.ts
@@ -12,7 +12,8 @@ import type { ConfigRun, CoverageHolding, Manifest } from '../../generated/types
import { createSeamValidator } from '../../seam/validate.js';
import { buildBackend } from '../runtime/runtime.js';
import { sha256Hex } from '../lib/sha256.js';
-import { kmOffsets, temperatureAt, type WorldParameters } from './analytic.js';
+import { kmOffsets, temperatureAt, thermoclineDepthAt, type WorldParameters } from './analytic.js';
+import { estimateFeatures } from '../model-runner/features.js';
import { axisValues } from './generator.js';
const validator = createSeamValidator();
@@ -228,6 +229,86 @@ describe('the synthetic ocean (feature 102)', () => {
runtime.stop();
});
+ it('the thermocline surface recovered from the stored bytes has the shape the manifest predicts (issue #113, analytic form 2)', () => {
+ const runtime = buildBackend(lockstepConfig(), options, validator);
+ const nowcast = runtime.store.currentNowcast();
+ if (!nowcast) throw new Error('no nowcast');
+ const manifest = nowcast.descriptor.manifest;
+ const world = worldFromManifest(manifest);
+
+ const lons = axisValues(manifest.grid.longitude);
+ const lats = axisValues(manifest.grid.latitude);
+ const depths = axisValues(manifest.grid.depth);
+ const perLevel = lats.length * lons.length;
+ const cells = manifest.grid.time.count * depths.length * perLevel;
+ const temperature = new Float32Array(nowcast.bytes.buffer, 0, cells);
+
+ // One column at a time through the *published* estimator, rather than a second pick
+ // written here: a one-cell grid's domain mean is that column, so `estimateFeatures`
+ // answers the question it already answers for the whole domain. A transcription of
+ // its argmax would keep passing after the estimator changed, which is how this
+ // repository has been caught before.
+ const columnGrid = (longitude: number, latitude: number) => ({
+ longitudes: [longitude],
+ latitudes: [latitude],
+ depthsM: depths,
+ cellKmEast: manifest.grid.longitude.spacing,
+ cellKmNorth: manifest.grid.latitude.spacing,
+ referenceLatitude: latitude,
+ });
+ const column = new Float32Array(depths.length);
+ const recovered: { picked: number; predicted: number }[] = [];
+ lats.forEach((latitude, la) =>
+ lons.forEach((longitude, lo) => {
+ for (let level = 0; level < depths.length; level++) column[level] = temperature[level * perLevel + la * lons.length + lo];
+ // A column whose profile never falls has no thermocline, and `estimateFeatures` says so
+ // by returning nothing for it. (An earlier version also tested `'reason' in estimate`,
+ // which selects no reachable state — `ThermoclineEstimate` has no such field — and read
+ // as a guard against something that cannot happen.)
+ const estimate = estimateFeatures(column, columnGrid(longitude, latitude)).thermocline;
+ if (!estimate) return;
+ recovered.push({ picked: estimate.depthM, predicted: thermoclineDepthAt(world, longitude, latitude, 0) });
+ }),
+ );
+ expect(recovered.length).toBe(perLevel);
+
+ /*
+ * **The bound is the manifest's own arithmetic, never a number typed here.** The layer
+ * spans `displacement_m_per_c` times the range of the feature anomalies at its nominal
+ * depth; divided by the depth spacing that is how many level boundaries the dome
+ * crosses, and a pick quantised to midpoints therefore takes at least that many values
+ * plus one.
+ *
+ * Analytic form 1 had no displacement term, so this predicted relief was nought and
+ * the assertion below reduced to "at least one distinct depth" — trivially true, which
+ * is exactly why form 1's flatness went unnoticed until #113 measured it. The bound
+ * has teeth only because the manifest now carries a displacement to derive it from.
+ */
+ const predicted = recovered.map((entry) => entry.predicted);
+ const relief = Math.max(...predicted) - Math.min(...predicted);
+ const expected = Math.floor(relief / manifest.grid.depth.spacing) + 1;
+ expect(expected).toBeGreaterThan(1);
+ const distinct = new Set(recovered.map((entry) => entry.picked));
+ expect(distinct.size).toBeGreaterThanOrEqual(expected);
+
+ /*
+ * Variety alone would pass on noise, so the shape is tied back to the manifest: the
+ * columns the bytes place the layer deepest in must be the columns the manifest
+ * predicts deepest. Grouped by the recovered depth, the mean predicted depth has to
+ * rise with it.
+ */
+ const groups = new Map();
+ for (const entry of recovered) groups.set(entry.picked, [...(groups.get(entry.picked) ?? []), entry.predicted]);
+ const ordered = [...groups.entries()]
+ .filter(([, values]) => values.length >= 8)
+ .sort((a, b) => a[0] - b[0])
+ .map(([picked, values]) => ({ picked, mean: values.reduce((sum, v) => sum + v, 0) / values.length }));
+ expect(ordered.length).toBeGreaterThanOrEqual(2);
+ for (let at = 1; at < ordered.length; at++) expect(ordered[at].mean).toBeGreaterThan(ordered[at - 1].mean);
+
+ runtime.stop();
+ });
+
it('AT-03 descendant: the eddy is recoverable from the stored bytes, with the error reported against a bound read from the manifest', () => {
const runtime = buildBackend(lockstepConfig(), options, validator);
const nowcast = runtime.store.currentNowcast();
@@ -313,9 +394,11 @@ describe('the synthetic ocean (feature 102)', () => {
* The cost guard on the density. Publishing a now-cast is one synchronous pass —
* every cell evaluated from the analytic form, then SHA-256 over the bytes, and again
* in the store's own digest check — and the generator repeats it every
- * `interval_ticks` for as long as the run lasts. At the shipped 96 x 80 x 6 x 4 that
- * pass measures about 270 ms on a development machine against 18 ms at 24 x 20, and a
- * forecast run over the same grid about 420 ms against 27 ms.
+ * `interval_ticks` for as long as the run lasts. Measured at 96 x 80 x 6 x 4, which the
+ * grid was when this note was written, that pass took about 270 ms on a development
+ * machine against 18 ms at 24 x 20, and a forecast run over the same grid about 420 ms
+ * against 27 ms. #113 spent the horizontal on depth: 48 x 40 x 26 x 4 is 8% more cells
+ * than that, so the figures stand and the ceiling below is unaffected.
*
* At the rate the map is watched that is one pass every fifteen minutes of wall time
* and nobody sees it. Wound forward it is what the clock's pace is spent on: the
diff --git a/app/src/backend/env-generator/generator.ts b/app/src/backend/env-generator/generator.ts
index 93c07aa1..c1ac87a6 100644
--- a/app/src/backend/env-generator/generator.ts
+++ b/app/src/backend/env-generator/generator.ts
@@ -39,15 +39,13 @@ import {
ANALYTIC_FORM_VERSION,
COMPOSITION_RULE,
KM_PER_DEGREE_LATITUDE,
- PRESSURE_RELATION,
- SOUND_SPEED,
TAU_BLENDING_RULE,
TAU_MEMBERSHIP_RULE,
- insideSoundSpeedValidity,
salinityAt,
temperatureAt,
type WorldParameters,
} from './analytic.js';
+import { PRESSURE_RELATION, SOUND_SPEED, insideSoundSpeedValidity } from '../../seam/ocean-relations.js';
export const GENERATOR_VERSION = '2.0.0';
diff --git a/app/src/backend/lib/manifest-world.test.ts b/app/src/backend/lib/manifest-world.test.ts
new file mode 100644
index 00000000..dfa19ff9
--- /dev/null
+++ b/app/src/backend/lib/manifest-world.test.ts
@@ -0,0 +1,54 @@
+/**
+ * The form-version refusal, watched failing.
+ *
+ * ADR-0044 credits `analytic_form_version` with stopping a reader of one form evaluating another's
+ * manifest, and until this test that credit was unearned in two ways: nothing checked the number,
+ * and nothing checked the check. Both halves are here — a form 1 manifest is refused with the two
+ * versions named, and the shipped manifest is accepted, so a guard that refused everything would
+ * fail too.
+ */
+import { describe, expect, it } from 'vitest';
+import runConfigDocument from '../../../config/run.json';
+import type { ConfigRun, Manifest } from '../../generated/types.js';
+import { createSeamValidator } from '../../seam/validate.js';
+import { buildBackend } from '../runtime/runtime.js';
+import { ANALYTIC_FORM_VERSION } from '../env-generator/analytic.js';
+import { refusalForForm, worldFromManifest } from './manifest-world.js';
+
+function shippedManifest(): Manifest {
+ const config = JSON.parse(JSON.stringify(runConfigDocument)) as ConfigRun;
+ config.clock.mode = 'lockstep';
+ config.clock.rate = 0;
+ const runtime = buildBackend(
+ config,
+ { rootSeed: 1234, startCondition: 'loitering', revision: 'test', dirty: false },
+ createSeamValidator(),
+ );
+ try {
+ const nowcast = runtime.store.currentNowcast();
+ if (!nowcast) throw new Error('no nowcast to take a manifest from');
+ return nowcast.descriptor.manifest;
+ } finally {
+ runtime.stop();
+ }
+}
+
+describe('reconstructing a world from a manifest', () => {
+ it('evaluates a manifest of the form this build implements', () => {
+ const manifest = shippedManifest();
+ expect(manifest.generator.analytic_form_version).toBe(ANALYTIC_FORM_VERSION);
+ expect(refusalForForm(manifest)).toBeUndefined();
+ // Not merely "does not throw": the world has to carry the term the form is about, or the
+ // acceptance above would pass on a manifest this build cannot actually evaluate.
+ expect(worldFromManifest(manifest).thermocline.displacement_m_per_c).toBeTypeOf('number');
+ });
+
+ it('refuses a manifest of another form rather than reconstructing what happens to be present', () => {
+ const manifest = shippedManifest();
+ for (const form of [ANALYTIC_FORM_VERSION - 1, ANALYTIC_FORM_VERSION + 1]) {
+ const foreign: Manifest = { ...manifest, generator: { ...manifest.generator, analytic_form_version: form } };
+ expect(refusalForForm(foreign)).toMatch(new RegExp(`declares analytic form ${form}`));
+ expect(() => worldFromManifest(foreign)).toThrow(/analytic form/);
+ }
+ });
+});
diff --git a/app/src/backend/lib/manifest-world.ts b/app/src/backend/lib/manifest-world.ts
index 71316bae..d8989df8 100644
--- a/app/src/backend/lib/manifest-world.ts
+++ b/app/src/backend/lib/manifest-world.ts
@@ -6,9 +6,37 @@
* it never reads the generator's live state, only what publication released.
*/
import type { Manifest } from '../../generated/types.js';
+import { ANALYTIC_FORM_VERSION } from '../env-generator/analytic.js';
import type { TimescaleParameters, WorldParameters } from '../env-generator/analytic.js';
+/**
+ * **The form version is checked, because refusing is what it is for.**
+ * `manifest.schema.json` says of `analytic_form_version`: "a reader that understands this number
+ * can reconstruct the field; a reader that does not must refuse rather than guess". This is the
+ * reader. It was not checking — the cast below is total, so a manifest from another form arrived
+ * here as a `WorldParameters` missing whatever that form did not have, and the arithmetic
+ * downstream returned `NaN` for every cell rather than saying why.
+ *
+ * That was harmless while there was one form and became reachable the moment there were two
+ * (#113, ADR-0044): a form 1 manifest has no `displacement_m_per_c`, and
+ * `depth_m + undefined * anomaly` is `NaN` at every point in the domain. A planner drawing that
+ * would show an empty field and no reason for it, which is the fault Constitution VII names.
+ *
+ * Newer is refused as firmly as older. A form this build has never seen may differ anywhere, and
+ * a reader that evaluates it anyway is guessing — which is the word the master uses.
+ */
+export function refusalForForm(manifest: Manifest): string | undefined {
+ const form = manifest.generator.analytic_form_version;
+ if (form === ANALYTIC_FORM_VERSION) return undefined;
+ return (
+ `the manifest declares analytic form ${form} and this build evaluates form ${ANALYTIC_FORM_VERSION}; ` +
+ 'the parameters of another form cannot be evaluated by this one, so the world is refused rather than reconstructed from what happens to be present'
+ );
+}
+
export function worldFromManifest(manifest: Manifest): WorldParameters {
+ const refusal = refusalForForm(manifest);
+ if (refusal) throw new Error(refusal);
const byId = Object.fromEntries(manifest.features.map((feature) => [feature.id, feature.parameters]));
return {
background: manifest.background.parameters,
diff --git a/app/src/backend/model-runner/features.test.ts b/app/src/backend/model-runner/features.test.ts
index 4f22769a..0f6cb77e 100644
--- a/app/src/backend/model-runner/features.test.ts
+++ b/app/src/backend/model-runner/features.test.ts
@@ -214,9 +214,19 @@ describe('the four features, estimated from a gridded field and scored against t
const authored = truth(manifest, 'thermocline');
const errorM = Math.abs(thermocline.depthM - authored.depth_m);
// The bound is the grid's own depth spacing, read from the manifest. It is NOT the
- // authoring jitter: six levels over a kilometre resolve 200 m, the authored depth is
- // 80 m and the jitter is 15, so a jitter bound would be scoring the grid and calling it
- // the estimator. Stated here rather than discovered later, per T024.
+ // authoring jitter: the authored depth is 80 m and the jitter is 15, so a jitter bound
+ // would be scoring the grid and calling it the estimator wherever the spacing is coarser
+ // than the jitter — which it was by an order of magnitude when this was written and still
+ // is (#113 moved it from 200 m to 40). Stated here rather than discovered later, per T024.
+ //
+ // **And under analytic form 2 this scores the pick against the layer's *far-field* depth.**
+ // `authored.depth_m` is where the layer sits away from the two displacing features, and the
+ // domain mean of the displaced layer is not quite that. It passes because the eddy and the
+ // drifting feature cover a small part of the domain, so the area-mean displacement is near
+ // zero — errors run 6 to 17 m against a 40 m bound. Widen a feature, raise
+ // `displacement_m_per_c`, or add the basin tilt ADR-0044 says is still wanted, and this
+ // fails for a reason that is not the estimator's. Written down because the passing margin
+ // is the only thing currently holding it, and a margin is not an argument.
const boundM = manifest.grid.depth.spacing;
console.log(
`AT-06 seed ${rootSeed}: thermocline placed at ${thermocline.depthM.toFixed(0)} m against an authored ${authored.depth_m.toFixed(0)} m, ` +
diff --git a/app/src/backend/model-runner/features.ts b/app/src/backend/model-runner/features.ts
index 5dc39222..ad474131 100644
--- a/app/src/backend/model-runner/features.ts
+++ b/app/src/backend/model-runner/features.ts
@@ -22,9 +22,10 @@
* point on a line rather than the authored anchor: a line has no distinguished point, so
* the scorable claim is the perpendicular distance to the authored line and the bearing,
* never the distance between two anchors. The thermocline is found between the two levels
- * whose temperature difference is steepest, which at six levels over a kilometre is a
- * claim resolved to the depth spacing and to nothing finer; the estimate says so by
- * carrying the spacing it was resolved at.
+ * whose temperature difference is steepest, which is a claim resolved to the depth spacing
+ * and to nothing finer; the estimate says so by carrying the spacing it was resolved at,
+ * rather than naming a spacing here that the configuration is free to change (it did: #113
+ * moved the axis from six levels to twenty-six).
*/
import { KM_PER_DEGREE_LATITUDE, kmOffsets } from '../env-generator/analytic.js';
import type { KernelParameters, ModelKernel } from './kernel.js';
@@ -71,9 +72,11 @@ export interface ThermoclineEstimate {
readonly thicknessM: number;
/**
* The domain-mean temperature drop across that grid interval. **Not the authored
- * temperature drop**, which is taken across a thermocline an order of magnitude thinner
- * than the interval: a 200 m grid cannot see a 30 m layer, so the two are different
- * quantities and the gradients they imply differ by the same factor.
+ * temperature drop**, which is taken across the layer itself: the two are the same
+ * measurement only where the spacing has closed on the layer's thickness, and differ by
+ * that ratio otherwise. `thicknessM` beside it is the interval this was taken over, so the
+ * ratio is the reader's to form. It was 30 m of layer against 200 m of spacing when this
+ * was written and is 30 against 40 now (#113); the sentence no longer names either.
*/
readonly layerDropC: number;
}
@@ -145,7 +148,7 @@ function depthAveragedAnomaly(temperature: Float32Array, grid: FeatureGrid): Flo
* scenario's grid, the recovered centres move from 164 km and 181 km of error to 7 km each.
*
* Computed through a summed-area table, so the cost is one pass over the field rather than
- * one window per cell: a run happens inside a browser tab, and a 96 x 80 grid with a 41-cell
+ * one window per cell: a run happens inside a browser tab, and a 48 x 40 grid with a 21-cell
* window is thirteen million reads done the naive way.
*/
function highPassWindow(lonCount: number, latCount: number): number {
@@ -559,7 +562,7 @@ export function estimateFeatures(temperature: Float32Array, grid: FeatureGrid):
kind: 'thermocline',
quantity: 'temperature_drop_c',
reason:
- 'the authored drop is taken across a layer some tens of metres thick and the grid resolves hundreds, so the drop across a grid interval is a different quantity and the gradients they imply differ by that ratio. The drop that was measured is published as layer_drop_c beside the thickness_m it was taken over',
+ 'the authored drop is taken across the layer itself and this is taken across a grid interval, so the two are different quantities whenever the interval is not the layer: they are the same measurement only where the spacing has closed on the thickness. The drop that was measured is published as layer_drop_c beside the thickness_m it was taken over, so a reader can form that ratio rather than being told it',
});
}
return { eddy, moving, front: frontEstimate, thermocline: thermoclineEstimate, declined };
diff --git a/app/src/backend/model-runner/loop.test.ts b/app/src/backend/model-runner/loop.test.ts
index 9965437c..290210e7 100644
--- a/app/src/backend/model-runner/loop.test.ts
+++ b/app/src/backend/model-runner/loop.test.ts
@@ -11,7 +11,7 @@ import type { ConfigRun, Divergence, RunPublished, RunRequest } from '../../gene
import { createSeamValidator } from '../../seam/validate.js';
import { driveTicks, driveUntil } from '../test-support/drive.js';
import { buildBackend, type BackendRuntime } from '../runtime/runtime.js';
-import { SOUND_SPEED } from '../env-generator/analytic.js';
+import { SOUND_SPEED } from '../../seam/ocean-relations.js';
const validator = createSeamValidator();
diff --git a/app/src/backend/model-runner/runner.ts b/app/src/backend/model-runner/runner.ts
index 1fcaaa64..a1b96c84 100644
--- a/app/src/backend/model-runner/runner.ts
+++ b/app/src/backend/model-runner/runner.ts
@@ -34,7 +34,8 @@ import { configDigest, sha256Hex } from '../lib/sha256.js';
import { spreadHoldingIdFor, standingRunFacts } from '../lib/standing-run.js';
import { isoPlusSeconds } from '../lib/sim-time.js';
import { HeartbeatEmitter } from '../lib/heartbeat.js';
-import { KM_PER_DEGREE_LATITUDE, insideSoundSpeedValidity } from '../env-generator/analytic.js';
+import { KM_PER_DEGREE_LATITUDE } from '../env-generator/analytic.js';
+import { insideSoundSpeedValidity } from '../../seam/ocean-relations.js';
import { publicationFault } from '../coverage-store/store.js';
import type { CoverageStore } from '../coverage-store/store.js';
import { shallowTwoLayerKernel, shiftAdvectKernel, type KernelParameters, type ModelKernel } from './kernel.js';
@@ -738,7 +739,17 @@ export class ModelRunner {
const manifest: Manifest = {
...baseManifest,
- generator: { name: 'drogna-model-runner', version: '2.0.0', analytic_form_version: 1 },
+ // The form version travels with the parameters, and the parameters come from the
+ // manifest spread above. Typed here it was correct only while there was one form:
+ // the moment the env-generator reached form 2 (#113) this document would have
+ // carried form 2's thermocline parameters under a form 1 label, telling a reader it
+ // could reconstruct a doming layer with the flat rule. Read from the base and it
+ // cannot drift again.
+ generator: {
+ name: 'drogna-model-runner',
+ version: '2.0.0',
+ analytic_form_version: baseManifest.generator.analytic_form_version,
+ },
config_digest: configDigest(this.config),
seed: {
root: this.rootSeed,
diff --git a/app/src/backend/monitor/monitor.ts b/app/src/backend/monitor/monitor.ts
index c25b7b6c..9f9dbf7b 100644
--- a/app/src/backend/monitor/monitor.ts
+++ b/app/src/backend/monitor/monitor.ts
@@ -47,7 +47,7 @@ import type {
} from '../../generated/types.js';
import { configDigest } from '../lib/sha256.js';
import { HeartbeatEmitter } from '../lib/heartbeat.js';
-import { soundSpeedMs } from '../env-generator/analytic.js';
+import { soundSpeedMs } from '../../seam/ocean-relations.js';
import { sampleHolding, timeAxisPosixOrigin } from '../query/field-sampler.js';
import type { CoverageStore } from '../coverage-store/store.js';
diff --git a/app/src/backend/scheduler/scheduler.test.ts b/app/src/backend/scheduler/scheduler.test.ts
index ec0785cb..78ee7922 100644
--- a/app/src/backend/scheduler/scheduler.test.ts
+++ b/app/src/backend/scheduler/scheduler.test.ts
@@ -23,7 +23,7 @@ import { Scheduler } from './scheduler.js';
import { twoLayerStability } from '../model-runner/kernel.js';
import type { SeamClient, SeamMessage } from '../../seam/transport.js';
import { driveTicks, driveUntil } from '../test-support/drive.js';
-import { SOUND_SPEED } from '../env-generator/analytic.js';
+import { SOUND_SPEED } from '../../seam/ocean-relations.js';
const validator = createSeamValidator();
@@ -502,6 +502,72 @@ describe('affording a run (feature 123, FR-115)', { timeout: 240_000 }, () => {
scheduler.stop();
});
+ it('measures its intervals from a standing run it did not request, not from its own orphaned request', () => {
+ /*
+ * **The state a replayed start condition puts this component in, planted.**
+ *
+ * A condition whose artefact carries the forecast eras holds the model runner back for the
+ * whole pre-roll: every request this component makes goes unanswered, is released by the
+ * watchdog, and leaves `lastRequestTick` pointing at it. The run the reader is looking at was
+ * requested at a tick this instance never saw — so before the fix it measured the minimum
+ * interval from the orphan and turned the loop early. Watched on `returning`: the live run
+ * waited 574 ticks for its next forecast and the replayed one 19.
+ *
+ * The tick comes from `valid_time.start_sim_time`, which the announcement already carries
+ * because the first forecast step *is* the state the run was initialised from. Every other
+ * test in this file publishes a standing run at tick 0 with the scheduler also near tick 0,
+ * where the new arithmetic and the old `undefined` decide alike — which is why all of them
+ * stayed green through the change and why this one exists.
+ */
+ const config = lockstepConfig();
+ const bench = new Bench();
+ const scheduler = new Scheduler(config.scheduler, bench.client, 'replayed-episode');
+ scheduler.start();
+ bench.deliver(config.model_runner.topics.run_cost, { cost_ticks: 9 });
+
+ // Well past the floor, so a request is due and goes out — and nobody answers it: the runner
+ // this artefact speaks for is held back.
+ const orphaned = config.scheduler.max_interval_ticks + 100;
+ for (const at of [0, 1, orphaned]) bench.tick(config, at);
+ expect(bench.requests.length).toBeGreaterThan(0);
+
+ // Then the runner starts and restates the run the artefact carries, which was initialised
+ // much later than the orphaned request.
+ const standingAt = orphaned + 900;
+ bench.deliver(config.scheduler.topics.run_published, {
+ run_id: 'standing-from-the-artefact',
+ current: true,
+ // Validity short enough to have lapsed by the floor below, so the release this test is
+ // about is the cadence floor's and not a hold expiring (ADR-0043 holds a warranted run
+ // *while* the standing forecast still has life, which would otherwise mask the interval).
+ valid_time: { start_sim_time: bench.simTimeAt(standingAt), end_sim_time: bench.simTimeAt(standingAt + 600) },
+ });
+
+ /*
+ * The tick that separates the two beliefs, and it has to be chosen rather than guessed. The
+ * cadence floor fires at `lastRequestTick + max_interval_ticks`, so measuring from the orphan
+ * it comes due at `orphaned + 1800` and measuring from the standing run at `standingAt + 1800`,
+ * which is 900 ticks later. One tick past the first and short of the second is the only place
+ * the two disagree — and the standing run's validity has already lapsed there, so a hold
+ * cannot be what keeps the loop quiet.
+ *
+ * A first version of this test ticked at `standingAt + min_interval - 10`, which is short of
+ * *both* floors: it passed with the fix disabled, and proved nothing.
+ */
+ const before = bench.requests.length;
+ bench.tick(config, orphaned + config.scheduler.max_interval_ticks + 1);
+ expect(
+ bench.requests.length,
+ 'the cadence floor fired measuring from a request nobody answered, rather than from the run that stands',
+ ).toBe(before);
+
+ // And it is not simply refusing for ever: the floor measured from the standing run comes due
+ // 900 ticks later, and the loop turns there.
+ bench.tick(config, standingAt + config.scheduler.max_interval_ticks + 1);
+ expect(bench.requests.length).toBeGreaterThan(before);
+ scheduler.stop();
+ });
+
it('SC-007: a divergence is never held, at a tick where a scheduled run would be', async () => {
const config = lockstepConfig();
const runtime = buildBackend(config, options, validator);
diff --git a/app/src/backend/scheduler/scheduler.ts b/app/src/backend/scheduler/scheduler.ts
index 8552a585..7da02f94 100644
--- a/app/src/backend/scheduler/scheduler.ts
+++ b/app/src/backend/scheduler/scheduler.ts
@@ -233,7 +233,33 @@ export class Scheduler {
this.client.subscribe(this.config.topics.run_published, (message) => {
const published = message.payload as RunPublished;
if (published.run_id === this.inFlight?.id) this.inFlight = undefined;
- if (published.current) this.validityEnd = published.valid_time.end_sim_time;
+ if (!published.current) return;
+ this.validityEnd = published.valid_time.end_sim_time;
+ /*
+ * **A run that stands was requested when it initialised, and the intervals measure from
+ * that whether or not this instance was the one that asked.**
+ *
+ * `lastRequestTick` was set only in `release`, so it recorded requests this scheduler
+ * made and nothing else. That is wrong in exactly one situation and it is a shipped one:
+ * a start condition whose artefact carries the forecast eras holds the model runner back
+ * for the whole pre-roll, so every request this component makes goes unanswered and is
+ * released by the watchdog, and the run the reader is actually looking at — replayed out
+ * of the artefact — was requested at a tick this component never saw. It then measured
+ * the minimum interval from its own last orphaned request instead.
+ *
+ * Watched, on `returning`: the live run had requested at 9261 and waited 574 ticks for
+ * its next forecast; the replayed run, standing on that very forecast, held 8400 and
+ * fired in 19. Same artefact, same reader, 555 ticks of difference in when the loop turns.
+ *
+ * The initialisation time is `valid_time.start_sim_time`, which the announcement already
+ * carries — the first forecast step is the state the run was initialised from — so this
+ * needs nothing new on the wire. Never moved backwards: a restatement of an older
+ * standing run must not undo a request this component has since made.
+ */
+ const requested = this.tickOf(published.valid_time.start_sim_time);
+ if (requested !== undefined && (this.lastRequestTick === undefined || requested > this.lastRequestTick)) {
+ this.lastRequestTick = requested;
+ }
});
this.heartbeat.start();
}
@@ -422,6 +448,19 @@ export class Scheduler {
* below fail open: a scheduler that cannot measure the headroom requests rather than
* waits, and the loop is never becalmed by the absence of a figure.
*/
+ /**
+ * The tick a simulation time names, from the tick this component is on now. `undefined`
+ * before the first clock sample, or before the tick length has been observed — the same
+ * fail-open rule `remainingValidityTicks` follows, and for the same reason.
+ */
+ private tickOf(simTime: string): number | undefined {
+ if (this.tickSeconds === undefined || this.simTime.value === '') return undefined;
+ const seconds =
+ (Date.parse(simTime.slice(0, 23) + 'Z') - Date.parse(this.simTime.value.slice(0, 23) + 'Z')) / 1000;
+ if (!Number.isFinite(seconds)) return undefined;
+ return this.simTime.tick + Math.round(seconds / this.tickSeconds);
+ }
+
private remainingValidityTicks(): number {
if (this.validityEnd === undefined || this.tickSeconds === undefined) return 0;
const seconds =
diff --git a/app/src/backend/sensors/sensors.ts b/app/src/backend/sensors/sensors.ts
index 1a50d2c4..2da35fb6 100644
--- a/app/src/backend/sensors/sensors.ts
+++ b/app/src/backend/sensors/sensors.ts
@@ -47,7 +47,7 @@ import { Rng } from '../lib/rng.js';
import { configDigest } from '../lib/sha256.js';
import { fnv1a32 } from '../lib/rng.js';
import { HeartbeatEmitter } from '../lib/heartbeat.js';
-import { pressureDbar } from '../env-generator/analytic.js';
+import { pressureDbar } from '../../seam/ocean-relations.js';
/** The honest port to the true ocean (Constitution VI): truth in, nothing else out. */
export interface WorldSampler {
diff --git a/app/src/backend/telemetry/telemetry.ts b/app/src/backend/telemetry/telemetry.ts
index bc532ed3..3d443fdd 100644
--- a/app/src/backend/telemetry/telemetry.ts
+++ b/app/src/backend/telemetry/telemetry.ts
@@ -34,7 +34,7 @@ import type {
import { configDigest } from '../lib/sha256.js';
import { standingRunId } from '../lib/standing-run.js';
import { HeartbeatEmitter } from '../lib/heartbeat.js';
-import { soundSpeedMs } from '../env-generator/analytic.js';
+import { soundSpeedMs } from '../../seam/ocean-relations.js';
import { sampleHolding, timeAxisPosixOrigin } from '../query/field-sampler.js';
import type { CoverageStore } from '../coverage-store/store.js';
import type { Router } from '../runtime/router.js';
diff --git a/app/src/generated/schema-documents.ts b/app/src/generated/schema-documents.ts
index c6b13445..306c91cf 100644
--- a/app/src/generated/schema-documents.ts
+++ b/app/src/generated/schema-documents.ts
@@ -1760,7 +1760,8 @@ export const schemaDocuments: Record> = {
"depth_m",
"thickness_m",
"temperature_drop_c",
- "salinity_rise_psu"
+ "salinity_rise_psu",
+ "displacement_m_per_c"
],
"additionalProperties": false,
"properties": {
@@ -1777,6 +1778,10 @@ export const schemaDocuments: Record> = {
},
"salinity_rise_psu": {
"type": "number"
+ },
+ "displacement_m_per_c": {
+ "type": "number",
+ "description": "Metres the layer moves per degree of the eddy and moving anomalies at its nominal depth; the front does not displace it. Not jittered: it sets the shape of the doming rather than where the layer sits, and a run that varied it would vary the thing the drawing exists to show."
}
}
},
@@ -7568,11 +7573,13 @@ export const schemaDocuments: Record> = {
},
"thermocline_parameters": {
"type": "object",
+ "description": "The layer's far-field depth and shape, and how far the two localised features displace it. depth_m is its depth away from them; the depth at a point is depth_m plus displacement_m_per_c times the sum of the eddy and moving temperature anomalies evaluated at depth_m, so the layer domes over a cool feature and is depressed under a warm one. The front is deliberately not a term: its anomaly is a tanh and saturates rather than decaying, so as a displacement it would tilt the whole domain rather than the water near it. Analytic form 1 had no displacement term at all and the layer was horizontally uniform.",
"required": [
"depth_m",
"thickness_m",
"temperature_drop_c",
- "salinity_rise_psu"
+ "salinity_rise_psu",
+ "displacement_m_per_c"
],
"additionalProperties": false,
"properties": {
@@ -7589,6 +7596,10 @@ export const schemaDocuments: Record> = {
},
"salinity_rise_psu": {
"type": "number"
+ },
+ "displacement_m_per_c": {
+ "type": "number",
+ "description": "Metres of vertical displacement per degree of the eddy and moving anomalies at the layer's nominal depth. Zero reproduces analytic form 1."
}
}
},
diff --git a/app/src/generated/topology.ts b/app/src/generated/topology.ts
index 60960aa3..e67a059a 100644
--- a/app/src/generated/topology.ts
+++ b/app/src/generated/topology.ts
@@ -648,19 +648,19 @@ export const topology: Topology = {
{
"component": "advisory-source",
"path": "app/config/run.json",
- "line": 1073,
+ "line": 1074,
"constant": "/advisory_source/topics/advisory"
},
{
"component": "advisory-store",
"path": "app/config/run.json",
- "line": 1073,
+ "line": 1074,
"constant": "/advisory_store/topics/advisory"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1439,
+ "line": 1440,
"constant": "/shell/topics/advisories"
}
]
@@ -679,13 +679,13 @@ export const topology: Topology = {
{
"component": "coverage-store",
"path": "app/config/run.json",
- "line": 448,
+ "line": 449,
"constant": "/coverage_store/topics/published"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1435,
+ "line": 1436,
"constant": "/shell/topics/holdings"
}
]
@@ -706,19 +706,19 @@ export const topology: Topology = {
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 706,
+ "line": 707,
"constant": "/model_runner/topics/analysis_published"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 706,
+ "line": 707,
"constant": "/analyst/topics/analysis_published"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 706,
+ "line": 707,
"constant": "/shell/topics/analysis_published"
}
]
@@ -737,13 +737,13 @@ export const topology: Topology = {
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 761,
+ "line": 762,
"constant": "/analyst/topics/analysis_standing"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 761,
+ "line": 762,
"constant": "/shell/topics/analysis_standing"
}
]
@@ -942,13 +942,13 @@ export const topology: Topology = {
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 653,
+ "line": 654,
"constant": "/monitor/topics/divergence"
},
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 653,
+ "line": 654,
"constant": "/scheduler/topics/divergence"
}
]
@@ -967,13 +967,13 @@ export const topology: Topology = {
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 710,
+ "line": 711,
"constant": "/model_runner/topics/forecast_features"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 710,
+ "line": 711,
"constant": "/shell/topics/forecast_features"
}
]
@@ -992,13 +992,13 @@ export const topology: Topology = {
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 656,
+ "line": 657,
"constant": "/monitor/topics/indicator"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1448,
+ "line": 1449,
"constant": "/shell/topics/forecast_indicator"
}
]
@@ -1039,13 +1039,13 @@ export const topology: Topology = {
{
"component": "operator",
"path": "app/config/run.json",
- "line": 865,
+ "line": 866,
"constant": "/operator/topics/heartbeat"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 865,
+ "line": 866,
"constant": "/shell/topics/heartbeat"
}
]
@@ -1064,7 +1064,7 @@ export const topology: Topology = {
{
"component": "offload",
"path": "app/config/run.json",
- "line": 1106,
+ "line": 1107,
"constant": "/offload/topics/offload"
}
]
@@ -1172,13 +1172,13 @@ export const topology: Topology = {
{
"component": "planner",
"path": "app/config/run.json",
- "line": 791,
+ "line": 792,
"constant": "/planner/topics/plan"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 791,
+ "line": 792,
"constant": "/shell/topics/plan"
}
]
@@ -1198,13 +1198,13 @@ export const topology: Topology = {
{
"component": "platform",
"path": "app/config/run.json",
- "line": 466,
+ "line": 467,
"constant": "/platform/topics/demand"
},
{
"component": "operator",
"path": "app/config/run.json",
- "line": 866,
+ "line": 867,
"constant": "/operator/topics/platform_demand"
}
]
@@ -1223,13 +1223,13 @@ export const topology: Topology = {
{
"component": "platform",
"path": "app/config/run.json",
- "line": 467,
+ "line": 468,
"constant": "/platform/topics/state"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1441,
+ "line": 1442,
"constant": "/shell/topics/platform_state"
}
]
@@ -1249,19 +1249,19 @@ export const topology: Topology = {
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 687,
+ "line": 688,
"constant": "/scheduler/topics/run_cost"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 687,
+ "line": 688,
"constant": "/model_runner/topics/run_cost"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 687,
+ "line": 688,
"constant": "/shell/topics/run_cost"
}
]
@@ -1284,43 +1284,43 @@ export const topology: Topology = {
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/scheduler/topics/run_published"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/model_runner/topics/run_published"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/analyst/topics/run_published"
},
{
"component": "planner",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/planner/topics/run_published"
},
{
"component": "telemetry",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/telemetry/topics/run_published"
},
{
"component": "offload",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/offload/topics/run_published"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/shell/topics/run_published"
}
]
@@ -1340,25 +1340,25 @@ export const topology: Topology = {
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/scheduler/topics/run_request"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/model_runner/topics/run_request"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/analyst/topics/run_request"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/shell/topics/run_request"
}
]
@@ -1377,13 +1377,13 @@ export const topology: Topology = {
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 707,
+ "line": 708,
"constant": "/model_runner/topics/run_started"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 707,
+ "line": 708,
"constant": "/shell/topics/run_started"
}
]
@@ -1407,31 +1407,31 @@ export const topology: Topology = {
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/monitor/topics/telemetry"
},
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/scheduler/topics/telemetry"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/model_runner/topics/telemetry"
},
{
"component": "telemetry",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/telemetry/topics/telemetry"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/shell/topics/telemetry"
}
]
@@ -1453,37 +1453,37 @@ export const topology: Topology = {
{
"component": "ingest",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/ingest/topics/observations"
},
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/monitor/topics/observations"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/analyst/topics/observations"
},
{
"component": "planner",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/planner/topics/observations"
},
{
"component": "telemetry",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/telemetry/topics/observations"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/shell/topics/observations"
}
]
@@ -1509,7 +1509,7 @@ export const topology: Topology = {
{
"component": "sensors",
"path": "app/config/run.json",
- "line": 539,
+ "line": 540,
"constant": "/sensors/topics/ownship"
}
]
diff --git a/app/src/generated/types.ts b/app/src/generated/types.ts
index 9cee8f1f..95baf914 100644
--- a/app/src/generated/types.ts
+++ b/app/src/generated/types.ts
@@ -389,6 +389,7 @@ export type ConfigEnvGenerator = {
"thickness_m": number;
"temperature_drop_c": number;
"salinity_rise_psu": number;
+ "displacement_m_per_c": number;
};
"jitter": {
"depth_m": number;
@@ -1629,6 +1630,7 @@ export type ManifestThermoclineParameters = {
"thickness_m": number;
"temperature_drop_c": number;
"salinity_rise_psu": number;
+ "displacement_m_per_c": number;
};
/** manifest.schema.json #/$defs/moving_parameters */
diff --git a/app/src/panels/forecast/ColumnProvenance.tsx b/app/src/panels/forecast/ColumnProvenance.tsx
index c6b96913..e3a80e70 100644
--- a/app/src/panels/forecast/ColumnProvenance.tsx
+++ b/app/src/panels/forecast/ColumnProvenance.tsx
@@ -64,9 +64,21 @@ import { Profile, type ProfileLevel } from './Profile.js';
import type { SeamValidator } from '../../seam/validate.js';
import { RAY_MIN_DRAWN_PX, RAY_WIDTH_PX, drawnWidthOf, placeOn, raysFor, underScale, withinWindow } from './rays.js';
import { useMapView, ZOOM_STEP, type ViewRect } from '../map-view.js';
+import { Volume } from './lazy.js';
+import { VOLUME_PARAMETERS, type VolumeParameter } from './volume.js';
import { SOURCES, instrumentAt, sourceOf, type SourceKey, shareOf } from './shares.js';
-/** The map's drawn resolution. The field is 96×80; this is what a panel can show legibly. */
+/**
+ * The map's drawn resolution: what a panel can show legibly, whatever the field's own width.
+ *
+ * **It is a ceiling, and on the shipped configuration the field is exactly at it.** The now-cast
+ * was 96×80 when the zoom was built, so half the analyst's columns were thinned away at rest and
+ * coming closer genuinely revealed cells; #113 spent that horizontal resolution on depth and the
+ * field is 48 wide, which fits whole. The apparatus below is unchanged and correct — it is the
+ * general rule, and it starts working again the moment the field outgrows the panel — but a
+ * reader zooming today gets bigger squares, not more of them, and the notes here say so rather
+ * than promising a resolution that is already on the screen.
+ */
const MAP = { maxCells: 48, height: 190 };
/**
@@ -251,6 +263,25 @@ export function ColumnProvenance({
const [showing, setShowing] = useState('dominant');
const [slab, setSlab] = useState();
const [column, setColumn] = useState();
+ /**
+ * Whether the volume is open, and which parameter it draws (T008, T012).
+ *
+ * **Closed at rest, and that is the code-split's whole point.** The volume is this tab's only
+ * deck.gl surface and that stack is about a third of the bundle; `lazy.tsx` withholds it, so
+ * opening the Forecast tab costs what it always cost and opening a volume costs what a volume
+ * costs. A volume mounted by default would pull the chunk on every visit and undo the deferral.
+ *
+ * **The parameter defaults to sound speed, which it could not do until the relation moved.** An
+ * analysis holding carries temperature and salinity only — sound speed is derived from the pair,
+ * and ADR-0005 makes that derivation "the single implementation in drogna", so a second one in
+ * this panel was ruled out by a decision already taken rather than by taste. The control
+ * therefore shipped offering only what is served, with the reason recorded, and T012 named the
+ * three ways out. The first of them was taken: `soundSpeedMs` now lives in
+ * `seam/ocean-relations.ts`, reachable from both halves, and a panel evaluating the seam's own
+ * declared relation over served values is the point of use ADR-0005 describes.
+ */
+ const [volumeOpen, setVolumeOpen] = useState(false);
+ const [volumeParameter, setVolumeParameter] = useState('sound_speed');
const [cursor, setCursor] = useState<{ row: number; col: number } | undefined>();
/**
* The served column, or which of the two ways it is not here. FR-129 names three facts and a
@@ -541,11 +572,13 @@ export function ColumnProvenance({
if (!slab || slab.longitudes.length === 0 || slab.latitudes.length === 0) return undefined;
// **The window first, the thinning second, and that order is the whole point of the zoom.**
// Thinning the *field* and then magnifying the result shows bigger squares and not one cell
- // more: at the shipped 96×80 against a ceiling of 48, every other column and every other row
- // is not drawn at all, so half the analyst's field was unreachable at any magnification. The
- // cells are taken from inside the view instead, so coming closer is what makes the full
- // resolution affordable — the same argument `map-view.ts` makes for the consumers' hexes,
- // which is why this reuses that module rather than growing a second one.
+ // more: at 96×80 against a ceiling of 48 — the shipped grid when this was written — every
+ // other column and every other row was not drawn at all, so half the analyst's field was
+ // unreachable at any magnification. The cells are taken from inside the view instead, so
+ // coming closer is what makes the full resolution affordable — the same argument
+ // `map-view.ts` makes for the consumers' hexes, which is why this reuses that module rather
+ // than growing a second one. The field is 48 wide today (#113) and so is drawn whole; this
+ // ordering is what keeps that a fact about the configuration rather than about the panel.
const inside = (axis: readonly number[], low: number, high: number) => {
const indices: number[] = [];
for (let index = 0; index < axis.length; index++) {
@@ -571,6 +604,38 @@ export function ColumnProvenance({
const rows = inY.filter((_, at) => at % stepY === 0);
return { cols, rows };
}, [slab, view.rect]);
+ /**
+ * The drawn cell a position falls in, or `undefined` where it falls outside the window the
+ * map is currently showing. Half a step of tolerance at each edge, so a click on the
+ * outermost cell's outer half is that cell rather than nothing.
+ */
+ const cursorAt = useCallback(
+ (longitude: number, latitude: number): { row: number; col: number } | undefined => {
+ if (!slab || !drawn) return undefined;
+ const nearest = (values: readonly number[], indices: readonly number[], value: number) => {
+ let best = -1;
+ let closest = Infinity;
+ indices.forEach((source, at) => {
+ const distance = Math.abs((values[source] ?? Number.NaN) - value);
+ if (distance < closest) {
+ closest = distance;
+ best = at;
+ }
+ });
+ // The drawn step, which is the spacing after thinning rather than the field's own.
+ const step =
+ indices.length > 1
+ ? Math.abs((values[indices[1]] ?? 0) - (values[indices[0]] ?? 0))
+ : Number.POSITIVE_INFINITY;
+ return best >= 0 && closest <= step / 2 ? best : undefined;
+ };
+ const col = nearest(slab.longitudes, drawn.cols, longitude);
+ const row = nearest(slab.latitudes, drawn.rows, latitude);
+ return col === undefined || row === undefined ? undefined : { row, col };
+ },
+ [slab, drawn],
+ );
+
/**
* **North is up.** The served latitude axis *ascends* — `min + i * spacing` over ascending
@@ -725,14 +790,23 @@ export function ColumnProvenance({
// What is still feature 124's is a fact about the region, not about whether an analysis has
// landed, so it is said on both branches.
+ //
+ // **Which is why it names the volume's control rather than describing the volume.** It said
+ // "the volume below it … captioned there with the depths it found", present tense, on both
+ // branches — including the one that renders when no analysis has been announced, where the
+ // reader is told there is no provenance to read and then given a description of a map, a
+ // surface and a caption that are not on the page. The sentence before it named an absence and
+ // was safe anywhere; a sentence describing a drawing is not, and the comment above was written
+ // for the first kind and not revisited when it became the second.
const stillToCome = (
- The field here is a plan at one depth. The{' '}
- semi-transparent volume, with the thermocline as a surface through it and
- the forecast’s features carried down it, is feature 124’s remaining half{' '}
- and is not built: what it adds is a dimension to a drawing that works, and the reason to
- say so here rather than draw an empty frame is that the column selection, the rays and the
- profile are the explanation — the volume is the setting they happen in.
+ The map here is a plan at one depth, and the{' '}
+ semi-transparent volume the chip above opens is the setting it is a section
+ through, with the thermocline drawn as a surface. What is{' '}
+ not built is the
+ last of feature 124: the forecast’s own features — the eddy, the front, the
+ drifting one — carried with depth through that volume. They are drawn in plan today
+ in the region to the right, and what is missing is the dimension rather than the figure.
);
@@ -793,11 +867,12 @@ export function ColumnProvenance({
))}
- {/* **Visible zoom controls, because a wheel is not an affordance.** The field is finer
- than a panel's width can show, and until this row the only ways to come closer were a
- wheel and a key press on a focused map: nothing on the page said the map could be
- approached, and on a touch screen there was no way at all. The reset is here too and
- not only in the note above, so that the way back does not appear and disappear. */}
+ {/* **Visible zoom controls, because a wheel is not an affordance.** Until this row the
+ only ways to come closer were a wheel and a key press on a focused map: nothing on the
+ page said the map could be approached, and on a touch screen there was no way at all.
+ The reset is here too and not only in the note above, so that the way back does not
+ appear and disappear. (The field was finer than a panel's width when this was written;
+ see `MAP` for what #113 did to that, and why the controls stay.) */}
+ {volumeOpen && (
+ {
+ /*
+ * **The cursor moves with the column, or the two regions disagree about which one
+ * is open.** `Volume.tsx`'s header states the invariant — "a reader who picks in
+ * one and looks at the other must not find two different columns open" — and this
+ * was the one path into `column` that did not keep it: the share map's highlight
+ * and its readout are `cursor`-only, and clicking a square in the volume moved the
+ * rays, the profile, the numbers and the volume's own marker while leaving the
+ * square the reader picked *before* still lit, with its latitude and longitude
+ * printed underneath. Every other route sets it — a click through `onMouseEnter`,
+ * a keyboard Enter through the cursor itself — and this one had no reason not to.
+ *
+ * Outside the drawn window the cursor is cleared rather than snapped to the
+ * nearest edge: a snap would light a square the reader did not choose, which is
+ * the same fault one cell wide instead of a hundred kilometres.
+ */
+ const at = cursorAt(longitude, latitude);
+ setCursor(at);
+ void readColumn(longitude, latitude);
+ }}
+ />
+ )}
+
{/* **A grid already read does not make the give-up sayable.** `gridGaveUp` was read in one
place, inside `if (!analysis || !grid)`, so it could only ever be stated by a region that
had never obtained an axis at all. Once one had been read, the console could spend its
@@ -1291,6 +1456,7 @@ export function ColumnProvenance({
store.
+ {/* **The left column is one cell holding two regions, so the run list is on screen.**
+ The list was a full-width section below the grid, and the region beside it — the share
+ map, the volume and the depth profile — is tall enough that a reader had to scroll
+ past all of it to reach the thing that says what the loop has been doing. "Why now" is
+ short and leaves most of this column empty, which is the space the list now occupies.
+ A reader reported it: easily pushed out of sight.
+
+ One cell rather than a fourth grid track, because `auto-fit` would have given the list
+ a column of its own at wide widths and put it beside "Why now" rather than beneath it.
+ At a phone's width the grid is one track and the two simply stack, which is the order
+ they are written in. */}
+
+ no run has been announced yet. Nothing here is drawn from a configured expectation: this list is what
+ arrived.
+
+ ) : (
+
+ {ordered.map((entry) => (
+
+ select(entry.key === selected ? undefined : entry.key)}
+ >
+ tick {entry.tick}
+ {/* The mark is a shape as well as a colour: colour alone would not
+ survive greyscale, and the cause is the fact this list is for. */}
+
+ {entry.kind === 'held' ? '⌛' : '▶'}
+
+ {CAUSE_LABEL[entry.cause] ?? entry.cause}
+
+ {entry.kind === 'held'
+ ? `${entry.shortfallTicks ?? 0} tick(s) of validity still to decay`
+ : entry.abandoned !== undefined
+ ? 'never published: the runner gave it up'
+ : entry.costTicks === undefined
+ ? 'in the store; what asked for it is not recoverable from a holding'
+ : entry.publishedAtTick === undefined
+ ? `occupying ${entry.costTicks} tick(s)`
+ : `published at tick ${entry.publishedAtTick}, ${
+ entry.publishedAtTick - entry.tick
+ } tick(s) after it began`}
+
+
+
+ ))}
+
+ )}
+ {chosen && (
+
+ {chosen.kind === 'held' ? (
+ <>{chosen.detail}>
+ ) : (
+ chosen.costTicks === undefined ? (
+ <>
+ Run {chosen.runId} was published at {displayInstant(chosen.simTime)}, before this console
+ opened. It is in the store and it can be queried; what asked for it, and what it cost, were said on the
+ wire at the time and are not recoverable from the holding.
+ >
+ ) : (
+ <>
+ Run {chosen.runId}, announced at {displayInstant(chosen.simTime)}. It declared a cost of{' '}
+ {chosen.costTicks} tick(s), and{' '}
+ {chosen.subStepsPerStep === null || chosen.subStepsPerStep === undefined
+ ? 'the kernel that ran integrates nothing, so it reported no sub-steps at all — a different answer from one'
+ : `the grid it was handed required ${chosen.subStepsPerStep} integration sub-step(s) per forecast step`}
+ . A declared figure and a reported one, and they are not the same kind of claim.
+ >
+ )
+ )}
+
+ )}
+ {refused > 0 && (
+
+ {refused} message(s) were refused by their masters and are not drawn. Counted rather than discarded: a
+ display quietly dropping traffic is the other way to lie.
+
+ )}
+
+
+
What it is made from
{/* The stub this replaced named the whole region as feature 124's, and then this
@@ -776,79 +863,6 @@ export function ForecastPanel({ params }: PanelProps) {
-
-
Runs, in simulation time
- {ordered.length === 0 ? (
-
- no run has been announced yet. Nothing here is drawn from a configured expectation: this list is what
- arrived.
-
- ) : (
-
- {ordered.map((entry) => (
-
- select(entry.key === selected ? undefined : entry.key)}
- >
- tick {entry.tick}
- {/* The mark is a shape as well as a colour: colour alone would not
- survive greyscale, and the cause is the fact this list is for. */}
-
- {entry.kind === 'held' ? '⌛' : '▶'}
-
- {CAUSE_LABEL[entry.cause] ?? entry.cause}
-
- {entry.kind === 'held'
- ? `${entry.shortfallTicks ?? 0} tick(s) of validity still to decay`
- : entry.abandoned !== undefined
- ? 'never published: the runner gave it up'
- : entry.costTicks === undefined
- ? 'in the store; what asked for it is not recoverable from a holding'
- : entry.publishedAtTick === undefined
- ? `occupying ${entry.costTicks} tick(s)`
- : `published at tick ${entry.publishedAtTick}, ${
- entry.publishedAtTick - entry.tick
- } tick(s) after it began`}
-
-
-
- ))}
-
- )}
- {chosen && (
-
- {chosen.kind === 'held' ? (
- <>{chosen.detail}>
- ) : (
- chosen.costTicks === undefined ? (
- <>
- Run {chosen.runId} was published at {displayInstant(chosen.simTime)}, before this console
- opened. It is in the store and it can be queried; what asked for it, and what it cost, were said on the
- wire at the time and are not recoverable from the holding.
- >
- ) : (
- <>
- Run {chosen.runId}, announced at {displayInstant(chosen.simTime)}. It declared a cost of{' '}
- {chosen.costTicks} tick(s), and{' '}
- {chosen.subStepsPerStep === null || chosen.subStepsPerStep === undefined
- ? 'the kernel that ran integrates nothing, so it reported no sub-steps at all — a different answer from one'
- : `the grid it was handed required ${chosen.subStepsPerStep} integration sub-step(s) per forecast step`}
- . A declared figure and a reported one, and they are not the same kind of claim.
- >
- )
- )}
-
- )}
- {refused > 0 && (
-
- {refused} message(s) were refused by their masters and are not drawn. Counted rather than discarded: a
- display quietly dropping traffic is the other way to lie.
-
- )}
-
);
}
diff --git a/app/src/panels/forecast/Volume.tsx b/app/src/panels/forecast/Volume.tsx
new file mode 100644
index 00000000..9bc5f15e
--- /dev/null
+++ b/app/src/panels/forecast/Volume.tsx
@@ -0,0 +1,528 @@
+/**
+ * The field as a volume, with the thermocline as a surface through it (feature 124, FR-120,
+ * FR-121).
+ *
+ * **The setting the rays happen in.** The share map beside it is a plan at one depth and answers
+ * *where a number came from*; this answers *what the water is doing around it*. FR-120 asks for a
+ * semi-transparent volume rather than a slice because the thermocline domes, tilts and breaks,
+ * and shape is the answer a sonar user is asking for — so the surface is drawn per column, from
+ * the served field, by `volume.ts`'s pick, rather than as the plane the published domain mean
+ * would give.
+ *
+ * **The first version of this drawing came out flat, and saying so is what fixed it.** Driven
+ * against `arriving` as first shipped, 7,679 of 7,680 columns placed the thermocline at 100 m and
+ * one at 300 m. The caption stated that count rather than calling the result a doming surface,
+ * and the measurement behind it became issue #113 — which found the cause was neither the pick
+ * nor the drawing but the world: analytic form 1's `thermoclineAnomalyT` took no longitude or
+ * latitude, so the layer was one depth everywhere and no grid spacing could resolve a shape that
+ * was not there. Form 2 displaces the layer where a feature warms or cools the water at it, and
+ * the depth axis was raised to 26 levels to carry the result.
+ *
+ * The caption still states what it measured rather than what it hoped for, and still says the
+ * flatness is the grid's when it finds one, because that is the branch that stopped a false
+ * claim once already and the configuration can change again.
+ *
+ * **The geometry is the Map's, imported rather than copied.** `cube.ts` already carries a
+ * lon/lat/depth triple into the space an OrbitView rotates and carries a clicked point back out,
+ * and it is tested as a round trip. The Data tab's volume imports it for the same reason; a
+ * second implementation of one projection is a second thing to keep true. Depth is exaggerated
+ * there and the frame says so.
+ *
+ * **Nothing here is a second selection.** T011 and T012 both say so outright: the share map has
+ * chosen a column by square, with arrow keys and enter, since feature 123, and walks a depth
+ * chooser over that column. The volume raises the same events through `onPickColumn` and marks
+ * the column the region already has. A reader who picks in one and looks at the other must not
+ * find two different columns open.
+ *
+ * **One area query per level, and no more.** EDR takes a comma-separated `parameter-name`, so the
+ * temperature the thermocline needs and the parameter a reader chose come back in the same
+ * response: one query a level, whichever parameter is on show, and twenty-six of them on the
+ * shipped axis. Nothing is fetched on a tick
+ * or a timer — the levels are read when the analysis changes and when a reader changes parameter,
+ * which is the same rule the share map states at the head of `ColumnProvenance`.
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import DeckGL from '@deck.gl/react';
+import { COORDINATE_SYSTEM, OrbitView } from '@deck.gl/core';
+import { PathLayer, SolidPolygonLayer } from '@deck.gl/layers';
+import type { SeamValidator } from '../../seam/validate.js';
+import { cubeFrame, volumeEdges } from '../map/cube.js';
+import { rampColour } from '../map/map-data.js';
+import { soundSpeedMs } from '../../seam/ocean-relations.js';
+import { servedFor, type VolumeParameter } from './volume.js';
+import { thermoclineSurface, type LevelField, type Thermocline } from './volume.js';
+import type { ColumnGrid } from './ColumnProvenance.js';
+
+// Framed for *this* canvas. The Data tab's volume opens at zoom 1.1 in a panel that gives it the
+// height; this region is a column beside a share map and gives it 260 px, where 1.1 put the cube's
+// own edges outside the viewport — a rotating drawing whose frame is off-screen has lost the one
+// thing that says which way is down. Measured against the region rather than inherited.
+const INITIAL_VIEW = { target: [0, 0, -20] as [number, number, number], zoom: 0.42, rotationX: 32, rotationOrbit: 24 };
+
+
+/** One level as the area query answered it, with the axes it was answered over. */
+interface ServedLevel {
+ readonly depthM: number;
+ readonly longitudes: readonly number[];
+ readonly latitudes: readonly number[];
+ readonly temperatureC: readonly number[];
+ readonly shown: readonly number[];
+}
+
+type Fetched =
+ | { readonly status: 'loading' }
+ | { readonly status: 'refused'; readonly why: string }
+ | { readonly status: 'drawn'; readonly levels: readonly ServedLevel[] };
+
+function valuesOf(body: unknown, name: string): number[] | undefined {
+ const ranges = (body as { ranges?: Record } | undefined)?.ranges;
+ const values = ranges?.[name]?.values;
+ return Array.isArray(values) ? values.map((value) => (typeof value === 'number' ? value : Number.NaN)) : undefined;
+}
+
+function axisOf(body: unknown, axis: 'x' | 'y'): number[] | undefined {
+ const values = (
+ body as { domain?: { axes?: Record } } | undefined
+ )?.domain?.axes?.[axis]?.values;
+ return Array.isArray(values) ? (values as number[]) : undefined;
+}
+
+export function Volume({
+ collectionId,
+ grid,
+ edrPrefix,
+ validator,
+ parameter,
+ column,
+ onPickColumn,
+}: {
+ readonly collectionId: string;
+ readonly grid: ColumnGrid;
+ readonly edrPrefix: string;
+ readonly validator: SeamValidator;
+ readonly parameter: VolumeParameter;
+ readonly column: { readonly longitude: number; readonly latitude: number } | undefined;
+ readonly onPickColumn: (longitude: number, latitude: number) => void;
+}) {
+ const [fetched, setFetched] = useState({ status: 'loading' });
+ // The read this state belongs to. A reader changing parameter while the levels are in flight
+ // must not have the old answer land on top of the new one — the same token the share map's
+ // slab and column reads carry, and for the same reason.
+ const wanted = useRef(0);
+
+ useEffect(() => {
+ const token = ++wanted.current;
+ let cancelled = false;
+ setFetched({ status: 'loading' });
+ void (async () => {
+ const polygon = `POLYGON((${grid.minimumLongitude} ${grid.minimumLatitude},${grid.maximumLongitude} ${grid.minimumLatitude},${grid.maximumLongitude} ${grid.maximumLatitude},${grid.minimumLongitude} ${grid.maximumLatitude},${grid.minimumLongitude} ${grid.minimumLatitude}))`;
+ // One query per level whatever is on show: the thermocline always needs temperature, and
+ // sound speed needs temperature and salinity, so the union is at most those two.
+ const names = [...new Set(['temperature', ...servedFor(parameter)])].join(',');
+ const levels: ServedLevel[] = [];
+ for (const depthM of grid.depthsM) {
+ try {
+ const query = new URLSearchParams({ coords: polygon, z: String(depthM), 'parameter-name': names });
+ const response = await fetch(`${edrPrefix}/collections/${collectionId}/area?${query.toString()}`);
+ if (cancelled || token !== wanted.current) return;
+ if (!response.ok) {
+ setFetched({ status: 'refused', why: `the field at ${depthM} m was refused (${response.status})` });
+ return;
+ }
+ const body: unknown = await response.json();
+ if (cancelled || token !== wanted.current) return;
+ // Validated before it is read, as every crossing of this seam is: a 200 that is not the
+ // master's shape is a refusal, not a document (Principle XI).
+ if (!validator.validate('coveragejson', body).ok) {
+ setFetched({ status: 'refused', why: `the field at ${depthM} m did not match its master` });
+ return;
+ }
+ const temperatureC = valuesOf(body, 'temperature');
+ const longitudes = axisOf(body, 'x');
+ const latitudes = axisOf(body, 'y');
+ // Derived here for sound speed, through the seam's own declared relation — the same
+ // function the monitor scores residuals with and the manifest names as the
+ // implementation. Read straight off the response for the two that are served.
+ const salinityPsu = parameter === 'sound_speed' ? valuesOf(body, 'salinity') : undefined;
+ const shown =
+ parameter === 'sound_speed'
+ ? temperatureC && salinityPsu
+ ? temperatureC.map((value, index) => soundSpeedMs(value, salinityPsu[index], depthM))
+ : undefined
+ : valuesOf(body, parameter);
+ if (!temperatureC || !shown || !longitudes || !latitudes) {
+ setFetched({ status: 'refused', why: `the field at ${depthM} m carried no ${parameter} to draw` });
+ return;
+ }
+ levels.push({ depthM, longitudes, latitudes, temperatureC, shown });
+ } catch (error) {
+ if (cancelled || token !== wanted.current) return;
+ setFetched({ status: 'refused', why: `the field at ${depthM} m could not be read: ${String(error)}` });
+ return;
+ }
+ }
+ if (cancelled || token !== wanted.current) return;
+ setFetched({ status: 'drawn', levels });
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [collectionId, edrPrefix, validator, parameter, grid]);
+
+ const frame = useMemo(
+ () =>
+ cubeFrame({
+ west: grid.minimumLongitude,
+ east: grid.maximumLongitude,
+ south: grid.minimumLatitude,
+ north: grid.maximumLatitude,
+ deepest: grid.depthsM[grid.depthsM.length - 1] ?? 1,
+ }),
+ [grid],
+ );
+
+ const levels = fetched.status === 'drawn' ? fetched.levels : [];
+ const surface = useMemo(
+ () =>
+ thermoclineSurface(
+ levels.map((level): LevelField => ({ depthM: level.depthM, temperatureC: level.temperatureC })),
+ ),
+ [levels],
+ );
+
+ // The range the ramp is stretched over, from what was actually served rather than from a
+ // typed-in pair: a scale invented here would colour one run's field by another's extremes.
+ const span = useMemo(() => {
+ let minimum = Infinity;
+ let maximum = -Infinity;
+ for (const level of levels) {
+ for (const value of level.shown) {
+ if (!Number.isFinite(value)) continue;
+ minimum = Math.min(minimum, value);
+ maximum = Math.max(maximum, value);
+ }
+ }
+ return Number.isFinite(minimum) && maximum > minimum ? { minimum, maximum } : undefined;
+ }, [levels]);
+
+ /**
+ * How much shape the surface actually has, measured rather than assumed.
+ *
+ * **The caption said "the shape a single published depth cannot show" and the tree did not
+ * support it.** Measured against the configuration as first shipped: 7,679 of 7,680 columns
+ * placed the thermocline at 100 m and exactly one at 300 m. That measurement became issue #113
+ * and then analytic form 2, and the span below is the one the drawing now reports.
+ *
+ * The branch stays. A drawing that called two distinct depths a doming surface would be the
+ * false illumination this region exists to avoid, and the surface is worth drawing either way
+ * — where the water column's strongest gradient sits is a sonar question whatever its shape.
+ * The spacing quoted in the flat case is read off the levels, not typed: it said "200 m" while
+ * the axis was being changed to 40, which is how a caption becomes a lie without anyone
+ * editing it.
+ *
+ * **And the span reported is the one most of the columns are in, not the outermost two.** The
+ * first version of this caption printed max minus min and said the surface "domes and tilts"
+ * across it. Against a served analysis late in a run that read "16 distinct depths spanning
+ * 840 m" — true of two columns and false of the layer, which is the shape of a claim this
+ * region is supposed to refuse. The same field's commonest three depths hold ninety per cent
+ * of the columns inside 80 m, and the picture agrees with that number rather than the other
+ * one. The tail is not hidden: the distinct count still names every depth taken, and the
+ * sentence says how much of the field the span covers.
+ *
+ * **And the branch itself is on the core span, not on the count.** `distinct <= 2` was the
+ * first test and it is the same mistake as the span it replaced, one level up: a count is
+ * inflated by outliers exactly as a range is. Twenty-one distinct depths of which one holds
+ * ninety-nine per cent of the columns is a plane, and the count branch called it doming — while
+ * the honest figure was being computed three lines above and printed but not consulted. The
+ * branch now asks whether the depths most of the field is in span more than a single level:
+ * inside one level the surface is a plane at this resolution, whatever the tail does.
+ *
+ * **Neither branch attributes the shape, and the reason is the same for both.** The shape branch
+ * said "displaced where a feature acts on it: a warm one presses it down beneath itself" while
+ * the level branch beside it said the drawing cannot tell a displaced layer from an unsure
+ * column — and the level branch is right. Over a served analysis the distinct-depth count is
+ * four with the displacement and four without; only over the truth field does it separate the
+ * two forms, three against one. A causal sentence in one arm and its disclaimer in the other is
+ * the same claim outrunning the same evidence, reachable by a different configuration.
+ *
+ * **The modal share is there because the doming is local and the caption should say so.** The
+ * features that displace the layer are an eddy and a drifting feature, tens of kilometres
+ * across in a domain hundreds wide (the front is deliberately not one of them —
+ * `analytic.ts#thermoclineDepthAt` has the measurement), so most of the field is level and a
+ * few hundred columns are not. "It domes and tilts", written across that, is a claim about a
+ * field that is 95% one depth. The share is printed and the sentence describes the mechanism
+ * instead of characterising the picture, which is the reader's to do.
+ */
+ const CORE_SHARE = 0.9;
+ const relief = useMemo(() => {
+ const counts = new Map();
+ for (const cell of surface) if (cell) counts.set(cell.depthM, (counts.get(cell.depthM) ?? 0) + 1);
+ const placed = [...counts.values()].reduce((sum, count) => sum + count, 0);
+ // The depths that between them hold most of the columns, taken commonest first, and the
+ // span of *those*. See the note above: a min-to-max span is a statement about the two most
+ // extreme columns in the field and reads as a statement about the layer.
+ const core = [...counts.entries()].sort((a, b) => b[1] - a[1]);
+ const kept: number[] = [];
+ let held = 0;
+ for (const [depthM, count] of core) {
+ if (held >= CORE_SHARE * placed) break;
+ kept.push(depthM);
+ held += count;
+ }
+ const spacings = levels.slice(1).map((level, at) => level.depthM - levels[at].depthM);
+ return {
+ distinct: counts.size,
+ placed,
+ coreCount: kept.length,
+ coreSpanM: kept.length > 0 ? Math.max(...kept) - Math.min(...kept) : 0,
+ coreShare: placed > 0 ? held / placed : 0,
+ modalDepthM: core.length > 0 ? core[0][0] : 0,
+ modalShare: placed > 0 ? (core[0]?.[1] ?? 0) / placed : 0,
+ spacingM: spacings.length > 0 ? Math.min(...spacings) : 0,
+ };
+ }, [surface, levels]);
+
+ const first = levels[0];
+ const strongest = useMemo(
+ () => surface.reduce((most, cell) => Math.max(most, cell?.dropC ?? 0), 0),
+ [surface],
+ );
+
+ // Stable, because the memo below lists it and deck.gl's `data` identity depends on it. A fresh
+ // closure per render would make that dependency a lie in the direction that costs most.
+ const cellBounds = useCallback((level: ServedLevel, index: number) => {
+ const columns = level.longitudes.length;
+ const longitude = level.longitudes[index % columns];
+ const latitude = level.latitudes[Math.floor(index / columns)];
+ const halfLon = Math.abs((level.longitudes[1] ?? longitude + 0.05) - level.longitudes[0]) / 2 || 0.02;
+ const halfLat = Math.abs((level.latitudes[1] ?? latitude + 0.05) - level.latitudes[0]) / 2 || 0.02;
+ return { longitude, latitude, halfLon, halfLat };
+ }, []);
+
+ /*
+ * **The per-level alpha, and why it is a measured constant rather than a derivation.**
+ *
+ * It was `46`, with the comment "thin enough to see through a stack of six". Transmittance
+ * through a stack compounds — `(1 − a/255)^n` — so at the twenty-six levels this axis now
+ * carries (#113) that let 0.6% of the far side through: the box went opaque and the surface
+ * FR-120 asks the transparency to reveal was inside it.
+ *
+ * The tidy answer was to hold the *stack's* transmittance constant and let the level's share
+ * follow from the count. That is the physically right rule — halve the interval, double the
+ * slabs, and the water is as clear as it was — and it was written that way. At 30% it gives 12
+ * per level over twenty-six, and driven and looked at, that is a wireframe box with no field
+ * in it: the ramp carries the parameter a reader chose, and at alpha 12 the parameter is not
+ * there. A reader said so in as many words — "I just see a heatmap".
+ *
+ * So the constant is the alpha the ramp needs to be legible, measured by rendering the shipped
+ * column at 12, 24, 60, 90 and 140 and looking at each: 60 is still nearly black, 140 is a
+ * solid, and 90 shows the field grading from warm at the surface to blue at depth with the
+ * thermocline still crossing it as a bright band. The band survives because the surface is
+ * drawn brighter than any level and sits inside the stack — not because the stack is
+ * see-through, which at twenty-six levels it is not.
+ *
+ * **What that costs, said rather than glossed:** the box is effectively opaque from behind, so
+ * "semi-transparent" describes the front of the stack and not the whole of it, and a reader
+ * cannot see the far wall through it. The alternative was a field nobody could read. If the
+ * depth axis moves far from twenty-six, this is the number to re-measure, the same way.
+ */
+ const LEVEL_ALPHA = 90;
+
+ /*
+ * **Memoised, because `data` identity is deck.gl's full-invalidation trigger.**
+ *
+ * This array was built in the component body. Every one of the three `data:` expressions below
+ * allocates fresh objects, so every React render handed all 28 layers new `data` references and
+ * deck.gl re-ran `getPolygon` — four `frame.toCartesian` calls each — and `getFillColor` over
+ * every datum. On the shipped 48 x 40 x 26 that is 49,920 level objects, 1,920 picker objects
+ * and up to 1,920 surface objects re-allocated, and roughly 215,000 projections, per render.
+ *
+ * And renders are cheap to cause. `ColumnProvenance` sets the cursor from `onMouseEnter` on
+ * each of up to 1,920 share-map cells, and this component is not memoised, so crossing one cell
+ * with the pointer rebuilt the whole stack. It did not even need a pointer: `ForecastPanel`
+ * re-renders on every forecast indicator, which the monitor publishes beside every scored
+ * residual — so with the volume open the rebuild ran at observation cadence, at whatever the
+ * clock rate is.
+ *
+ * Nothing caught it. `capture:forecast` opens the volume, shoots it and never moves the
+ * pointer; jsdom has no WebGL, so `volume-panel.test.tsx` says outright that deck.gl is not
+ * exercised. Every other derived value in this file was already memoised, which is what makes
+ * this one an omission rather than a decision.
+ */
+ const layers = useMemo(() => [
+ // The field, level by level, semi-transparent so the surface through it can be seen. Every
+ // level drawn is a level the store answered for.
+ ...levels.map((level) =>
+ span
+ ? new SolidPolygonLayer({
+ id: `forecast-volume-level-${level.depthM}`,
+ data: level.shown.map((value, index) => ({ value, index })),
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPolygon: ({ index }: { index: number }) => {
+ const { longitude, latitude, halfLon, halfLat } = cellBounds(level, index);
+ return [
+ frame.toCartesian(longitude - halfLon, latitude - halfLat, level.depthM),
+ frame.toCartesian(longitude + halfLon, latitude - halfLat, level.depthM),
+ frame.toCartesian(longitude + halfLon, latitude + halfLat, level.depthM),
+ frame.toCartesian(longitude - halfLon, latitude + halfLat, level.depthM),
+ ];
+ },
+ getFillColor: ({ value }: { value: number }) => {
+ if (!Number.isFinite(value)) return [0, 0, 0, 0] as [number, number, number, number];
+ const [red, green, blue] = rampColour(value, span.minimum, span.maximum);
+ return [red, green, blue, LEVEL_ALPHA] as [number, number, number, number];
+ },
+ })
+ : undefined,
+ ),
+ // The thermocline, as a surface rather than a plane: each column at its own depth, and its
+ // *strength* — the drop across the interval — carried by the surface's own appearance, which
+ // is what FR-120 asks the appearance to do rather than leaving it decorative.
+ first && strongest > 0
+ ? new SolidPolygonLayer({
+ id: 'forecast-volume-thermocline',
+ data: surface.flatMap((cell, index) => (cell ? [{ cell, index }] : [])),
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPolygon: ({ cell, index }: { cell: Thermocline; index: number }) => {
+ const { longitude, latitude, halfLon, halfLat } = cellBounds(first, index);
+ return [
+ frame.toCartesian(longitude - halfLon, latitude - halfLat, cell.depthM),
+ frame.toCartesian(longitude + halfLon, latitude - halfLat, cell.depthM),
+ frame.toCartesian(longitude + halfLon, latitude + halfLat, cell.depthM),
+ frame.toCartesian(longitude - halfLon, latitude + halfLat, cell.depthM),
+ ];
+ },
+ getFillColor: ({ cell }: { cell: Thermocline }) =>
+ // Opaque where the layer is sharp, faint where it barely falls. A surface drawn at
+ // one opacity would say every part of it is equally a thermocline.
+ [232, 236, 244, Math.round(70 + 160 * Math.min(cell.dropC / strongest, 1))] as [
+ number,
+ number,
+ number,
+ number,
+ ],
+ })
+ : undefined,
+ // The frame, so a rotating reader can tell which way is down and where the domain ends.
+ new PathLayer({
+ id: 'forecast-volume-frame',
+ data: volumeEdges(frame),
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPath: (path: [number, number, number][]) => path,
+ getColor: [90, 95, 105, 200],
+ getWidth: 1,
+ widthUnits: 'pixels',
+ }),
+ // Where the reader's column is, marked on the surface plane so the two regions agree about
+ // which column is open.
+ column
+ ? new PathLayer({
+ id: 'forecast-volume-column',
+ data: [
+ [
+ frame.toCartesian(column.longitude, column.latitude, 0),
+ frame.toCartesian(column.longitude, column.latitude, grid.depthsM[grid.depthsM.length - 1] ?? 0),
+ ],
+ ],
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ getPath: (path: [number, number, number][]) => path,
+ getColor: [244, 246, 250, 235],
+ getWidth: 2,
+ widthUnits: 'pixels',
+ })
+ : undefined,
+ /*
+ * **The clickable grid, and it is on the surface plane — nothing inside the volume is
+ * pickable.** FR-121 and this feature's own FR-06 say so in as many words, and the reason
+ * they give is precisely what the first version did wrong. That version put `onClick` on the
+ * canvas and read `info.coordinate`, which is the *ground-plane* unprojection under the
+ * cursor: deck.gl hands back where the ray through the pixel crosses z = 0, and the cell the
+ * reader was pointing at is drawn tens of cube-units above that plane. At `rotationX: 32°`
+ * over a 1,000 m domain, clicking a thermocline cell on the far side opened a column roughly
+ * 180 km from it, silently, and a click on empty canvas beside the box opened a column
+ * outside the domain — clearing the reader's rays and numbers for a position the analysis
+ * never covered.
+ *
+ * A pickable grid of squares at depth nought fixes both by construction: deck.gl resolves it
+ * against the geometry it actually drew, so `info.object` is a cell or it is nothing, and a
+ * click that hits no square is a click that selects nothing. Invisible, because it is the
+ * surface plane rather than a fifth drawn thing; `autoHighlight` gives it the hover a
+ * clickable square owes the reader.
+ */
+ first
+ ? new SolidPolygonLayer({
+ id: 'forecast-volume-picker',
+ data: first.shown.map((_, index) => ({ index })),
+ coordinateSystem: COORDINATE_SYSTEM.CARTESIAN,
+ pickable: true,
+ autoHighlight: true,
+ highlightColor: [244, 246, 250, 60],
+ getPolygon: ({ index }: { index: number }) => {
+ const { longitude, latitude, halfLon, halfLat } = cellBounds(first, index);
+ return [
+ frame.toCartesian(longitude - halfLon, latitude - halfLat, 0),
+ frame.toCartesian(longitude + halfLon, latitude - halfLat, 0),
+ frame.toCartesian(longitude + halfLon, latitude + halfLat, 0),
+ frame.toCartesian(longitude - halfLon, latitude + halfLat, 0),
+ ];
+ },
+ getFillColor: [0, 0, 0, 0] as [number, number, number, number],
+ })
+ : undefined,
+ ].filter((layer) => layer !== undefined),
+ [levels, surface, first, span, frame, grid, column, strongest, cellBounds],
+ );
+
+ return (
+
+
+ {fetched.status === 'drawn' && span ? (
+ 'crosshair'}
+ onClick={(info: { object?: unknown; layer?: { id: string } | null }) => {
+ // The picked *square*, never the canvas coordinate: see the picker layer above for
+ // what reading `info.coordinate` cost. A click that hit no square selects nothing,
+ // which is what leaves the reader's open column alone.
+ if (info.layer?.id !== 'forecast-volume-picker' || !info.object || !first) return;
+ const { index } = info.object as { index: number };
+ const { longitude, latitude } = cellBounds(first, index);
+ onPickColumn(longitude, latitude);
+ }}
+ />
+ ) : (
+ // Never a blank canvas: which of the three it is, said where the drawing would be.
+
+ {fetched.status === 'loading' && `reading ${grid.depthsM.length} level(s) of this analysis…`}
+ {fetched.status === 'refused' && fetched.why}
+ {fetched.status === 'drawn' &&
+ !span &&
+ 'the levels answered but carried no values to scale a ramp over, so nothing is drawn rather than a field of one colour'}
+
+ )}
+
+ {fetched.status === 'drawn' && span && (
+
+ {levels.length} level(s) of {parameter}, each a genuine area query against{' '}
+ {collectionId}, drawn semi-transparent with depth exaggerated. The pale
+ surface through them is the thermocline, placed per column at the
+ midpoint of the level pair whose temperature falls fastest — the run’s own
+ definition, applied to each column rather than to the domain mean. Its brightness is the
+ drop across that interval.{' '}
+
+ {relief.placed} of {surface.length} columns have one, over {relief.distinct} distinct
+ depth{relief.distinct === 1 ? '' : 's'}
+
+ {relief.placed === 0
+ ? '. No column in this field falls with depth anywhere, so there is no thermocline to place — which is a different fact from one at depth nought, and is why none is drawn rather than a surface at the top of the box.'
+ : relief.coreSpanM <= relief.spacingM
+ ? ` — so it is level to within one ${relief.spacingM} m level across most of the field. What this shows is where each column's strongest gradient sits, which is a sonar question whatever its shape; where a column differs from its neighbours this cannot say whether a feature moved the layer there or the analysis is simply less sure of that column.`
+ : `, ${Math.round(relief.modalShare * 100)}% of them at ${relief.modalDepthM} m and ${relief.coreCount} depth${relief.coreCount === 1 ? '' : 's'} holding ${Math.round(relief.coreShare * 100)}% within ${relief.coreSpanM} m. What this shows is where each column's strongest gradient sits; where a column differs from its neighbours it cannot say whether a feature moved the layer there or the analysis is simply less sure of that column. Columns outside that span are ones whose profile falls fastest somewhere deeper, and they are drawn where they were found.`}
+
+ )}
+
+ );
+}
diff --git a/app/src/panels/forecast/forecast.css b/app/src/panels/forecast/forecast.css
index a7962111..ef0e233f 100644
--- a/app/src/panels/forecast/forecast.css
+++ b/app/src/panels/forecast/forecast.css
@@ -47,6 +47,17 @@
gap: 0.8em;
}
+/* One grid cell, two regions stacked in it: the left column carries "Why now" and, beneath it,
+ the run list that used to run full width under the grid. `align-content: start` so the two keep
+ their own heights instead of stretching to fill a column whose height is set by the tall region
+ beside them. */
+.forecast-stack {
+ display: grid;
+ gap: 0.8em;
+ align-content: start;
+ min-width: 0;
+}
+
.forecast-region {
border: 1px solid #232c36;
padding: 0.6em 0.7em;
@@ -163,10 +174,23 @@
* every width to fix a fault that only appears at one. There is no breakpoint here either
* way: the row narrows wherever the content stops fitting.
*/
+.forecast-depth-choice {
+ align-items: baseline;
+ gap: 0.4em;
+}
+
+/* **Three tracks, and the detail on its own row.** The run list moved out of the full width
+ beneath the grid and into the left-hand column, and the four-track row no longer fits: the
+ narrow proof measured 254px of button in a 247px cell on every run in the list. `auto` maxima
+ do not help, because an `auto` track's maximum *is* max-content — the tick, the mark and the
+ cause together are already wider than the cell before the detail is placed at all.
+
+ So the detail spans the row beneath them. It holds at any width rather than at one breakpoint,
+ it keeps the first three columns aligned down the list, which is what made this a grid, and in
+ a narrow column the detail reads better under its cause than squeezed beside it. */
.forecast-run {
display: grid;
- grid-template-columns:
- minmax(0, max-content) minmax(0, max-content) minmax(0, max-content) minmax(0, 1fr);
+ grid-template-columns: minmax(0, auto) minmax(0, auto) minmax(0, 1fr);
gap: 0 0.6em;
align-items: baseline;
width: 100%;
@@ -198,6 +222,7 @@
}
.forecast-run-detail {
+ grid-column: 1 / -1;
color: var(--shell-dim);
/* A shrinkable track is not enough on its own: the floor of a cell whose text cannot
break is its longest word, and "published:" is 70px at this size — which is exactly
@@ -371,6 +396,19 @@
cursor: default;
}
+/* The volume: a fixed height like the share map, so opening it does not reflow the column. */
+.forecast-volume-canvas {
+ position: relative;
+ height: 260px;
+ margin-top: 0.4em;
+}
+
+.forecast-volume-parameter {
+ align-self: center;
+ font-size: 0.8em;
+ color: var(--shell-dim);
+}
+
.forecast-column-zoom {
align-self: center;
font-size: 0.8em;
diff --git a/app/src/panels/forecast/forecast.test.tsx b/app/src/panels/forecast/forecast.test.tsx
index ebf9e4e0..ca66ac1f 100644
--- a/app/src/panels/forecast/forecast.test.tsx
+++ b/app/src/panels/forecast/forecast.test.tsx
@@ -341,10 +341,13 @@ describe('the Forecast tab (feature 123)', { timeout: 240_000 }, () => {
const areasBefore = fetched.filter((url) => url.includes('/area?')).length;
expect(areasBefore).toBeGreaterThan(0);
- const depthChips = [...document.querySelectorAll('[aria-label="depth"] .forecast-chip')];
- expect(depthChips.length, 'the depth control offered no levels').toBeGreaterThan(1);
+ // The depth control is a `select` since #113 took the axis to 26 levels — 26 chips were four
+ // wrapped rows and 26 tab stops. Driven as a chooser rather than as buttons.
+ const depthChoice = document.querySelector('select[aria-label="depth"]');
+ if (!depthChoice) throw new Error('no depth chooser');
+ expect(depthChoice.options.length, 'the depth control offered no levels').toBeGreaterThan(1);
await act(async () => {
- fireEvent.click(depthChips[depthChips.length - 1]);
+ fireEvent.change(depthChoice, { target: { value: String(depthChoice.options.length - 1) } });
});
await act(async () => {
await Promise.resolve();
@@ -1032,7 +1035,7 @@ describe('the Forecast tab (feature 123)', { timeout: 240_000 }, () => {
// **The fault.** `gridGaveUp` was read in exactly one place — inside `if (!analysis ||
// !grid)` — so it could only ever be stated by a region that had *never* obtained an axis.
// Once one had been read the console could spend its whole allowance on every later cycle,
- // stop asking for good, and go on offering the first cycle's depth chips over the current
+ // stop asking for good, and go on offering the first cycle's depth levels over the current
// cycle's field with nothing anywhere saying the depths were old. That is the same
// permanent-wrong-sentence class this feature fixed in the other direction, where the
// region kept saying the store "had none when this console asked" after it had answered.
@@ -1122,6 +1125,15 @@ describe('the Forecast tab (feature 123)', { timeout: 240_000 }, () => {
if (!map) throw new Error('unreachable');
const before = document.querySelectorAll('rect.share-cell').length;
expect(before).toBeGreaterThan(0);
+ // Captured while the whole field is drawn, so the zoomed span below has something real to
+ // be narrower than.
+ const spanBeforeZoom = (() => {
+ const lons = [...document.querySelectorAll('rect.share-cell')]
+ .map((cell) => Number(cell.getAttribute('data-lon')))
+ .filter((lon) => Number.isFinite(lon));
+ return Math.max(...lons) - Math.min(...lons);
+ })();
+ expect(spanBeforeZoom).toBeGreaterThan(0);
expect(map.getAttribute('aria-label')).toMatch(/over the whole field/);
expect(screen.queryByTestId('forecast-view-note'), 'a note before anything moved').toBeNull();
@@ -1145,19 +1157,40 @@ describe('the Forecast tab (feature 123)', { timeout: 240_000 }, () => {
/Magnified.*of the field\u2019s \d+\u00d7\d+ cells at full resolution/s,
);
- // The cells drawn are the ones in view: at the shipped grid the whole field is thinned by
- // two, so a window over part of it is drawn at a finer step than the whole ever was.
- const zoomed = [...document.querySelectorAll('rect.share-cell')].map((cell) => ({
- lon: Number(cell.getAttribute('data-lon')),
- lat: Number(cell.getAttribute('data-lat')),
- }));
+ /*
+ * **The cells drawn are the ones in view, so the window is narrower than the field.**
+ *
+ * This comment used to say the field is "thinned by two, so a window is drawn at a finer
+ * step than the whole ever was", which was true at 96 x 80 against a 48-cell ceiling and
+ * is not true at head: #113 spent the horizontal on depth and the field is 48 wide, so it
+ * is drawn whole and zooming yields bigger squares rather than more of them. The window
+ * ordering is unchanged and correct — it is what makes that a fact about the configuration
+ * — but the property this test can still hold is the one below.
+ *
+ * Two assertions went with the comment. `const spanBefore = 1; expect(spanBefore).toBe(1)`
+ * compared a literal with itself, and `finest > 0` measured the smallest gap between
+ * *distinct sorted* values, which is positive by construction. Both were true of every
+ * possible run. What is actually at stake when the map is magnified is that fewer columns
+ * are drawn than the field holds, and that they are the ones inside the view.
+ */
+ const cellsOf = () =>
+ [...document.querySelectorAll('rect.share-cell')].map((cell) => ({
+ lon: Number(cell.getAttribute('data-lon')),
+ lat: Number(cell.getAttribute('data-lat')),
+ }));
+ const spanOf = (cells: { lon: number }[]) => {
+ const lons = cells.map((c) => c.lon).filter((lon) => Number.isFinite(lon));
+ return lons.length > 1 ? Math.max(...lons) - Math.min(...lons) : 0;
+ };
+ const zoomed = cellsOf();
expect(zoomed.length).toBeGreaterThan(0);
- const spanBefore = 1;
- const lons = [...new Set(zoomed.map((c) => c.lon))].sort((a, b) => a - b);
- expect(lons.length, 'a zoomed map with one column of cells').toBeGreaterThan(1);
- const finest = Math.min(...lons.slice(1).map((lon, at) => lon - lons[at]));
- expect(finest, 'the zoomed step is not finer than a whole cell').toBeGreaterThan(0);
- expect(spanBefore).toBe(1);
+ expect(new Set(zoomed.map((c) => c.lon)).size, 'a zoomed map with one column of cells').toBeGreaterThan(1);
+ // The comparison is against the *unzoomed* state captured before `+`, which is the only
+ // thing here that makes it a claim: a window covers less of the domain than the whole.
+ expect(
+ spanOf(zoomed),
+ 'magnified, the map still spans as much longitude as it did over the whole field',
+ ).toBeLessThan(spanBeforeZoom);
// Home puts it back, and the note goes with it.
await act(async () => {
@@ -1216,10 +1249,11 @@ describe('the Forecast tab (feature 123)', { timeout: 240_000 }, () => {
}) as typeof globalThis.fetch);
// Re-read the field at another depth so the planted body is the one drawn.
- const depths = [...document.querySelectorAll('[aria-label="depth"] button.forecast-chip')];
- expect(depths.length, 'no depth to change to').toBeGreaterThan(1);
+ const depths = document.querySelector('select[aria-label="depth"]');
+ if (!depths) throw new Error('no depth chooser');
+ expect(depths.options.length, 'no depth to change to').toBeGreaterThan(1);
await act(async () => {
- fireEvent.click(depths[depths.length - 1]);
+ fireEvent.change(depths, { target: { value: String(depths.options.length - 1) } });
});
await act(async () => {
await Promise.resolve();
@@ -1271,10 +1305,11 @@ describe('the Forecast tab (feature 123)', { timeout: 240_000 }, () => {
return passthrough(input, init);
}) as typeof globalThis.fetch);
- const depths = [...document.querySelectorAll('[aria-label="depth"] button.forecast-chip')];
- expect(depths.length, 'no depth to change to').toBeGreaterThan(1);
+ const depths = document.querySelector('select[aria-label="depth"]');
+ if (!depths) throw new Error('no depth chooser');
+ expect(depths.options.length, 'no depth to change to').toBeGreaterThan(1);
await act(async () => {
- fireEvent.click(depths[depths.length - 1]);
+ fireEvent.change(depths, { target: { value: String(depths.options.length - 1) } });
});
await act(async () => {
await Promise.resolve();
diff --git a/app/src/panels/forecast/lazy.tsx b/app/src/panels/forecast/lazy.tsx
new file mode 100644
index 00000000..4e08c1f3
--- /dev/null
+++ b/app/src/panels/forecast/lazy.tsx
@@ -0,0 +1,44 @@
+/**
+ * The Forecast tab's one deck.gl surface, withheld until something asks for it.
+ *
+ * The reasoning is the Data tab's, and the measurement behind it is the same
+ * (`spikes/load-time`, and the note at the head of `registry.tsx`): the deck.gl/luma.gl stack is
+ * about a third of the bundle, and the view registry defers the Map for exactly that. Everything
+ * else this tab draws — the gauge, the share field, the rays, the profile, the timeline — is
+ * markup and SVG and costs nothing to open. Importing the volume directly would pull that third
+ * of the bundle back through a different door and quietly undo the deferral, which is what
+ * `data/lazy.tsx` was written to stop happening on the Data tab.
+ *
+ * So the split is here rather than at the view: opening Forecast costs what it always cost, and
+ * opening a volume costs what a volume costs.
+ */
+import { Suspense, lazy } from 'react';
+import type { ComponentProps } from 'react';
+import type { Volume as VolumeComponent } from './Volume.js';
+
+const VolumeInner = lazy(async () => ({ default: (await import('./Volume.js')).Volume }));
+
+export function Volume(props: ComponentProps) {
+ return (
+
+ the volume is loading…
+
+ }
+ >
+
+
+ );
+}
diff --git a/app/src/panels/forecast/volume-panel.test.tsx b/app/src/panels/forecast/volume-panel.test.tsx
new file mode 100644
index 00000000..711b3964
--- /dev/null
+++ b/app/src/panels/forecast/volume-panel.test.tsx
@@ -0,0 +1,186 @@
+// @vitest-environment jsdom
+/**
+ * The volume as a component: its three states, and the caption's arithmetic against the field it
+ * was actually served.
+ *
+ * `volume.ts` is tested thoroughly and the component that consumes it was not tested at all — the
+ * capture proof was the whole of its cover, and a capture measures one shipped configuration on a
+ * built page. The states worth holding here are the ones a capture never reaches: a refusal, a
+ * response that is a 200 but not the master's shape, and a caption's numbers against an input this
+ * test chose. The picking guard is covered by construction — the click handler only acts on
+ * `info.layer?.id === 'forecast-volume-picker'`, and nothing else in the file can call
+ * `onPickColumn`.
+ *
+ * **deck.gl is not exercised.** There is no WebGL in jsdom, so `DeckGL` renders nothing useful and
+ * `SolidPolygonLayer`'s geometry cannot be inspected here; `capture:forecast` is what looks at the
+ * drawing, and says so. What this file holds is the markup around it, which is where the states a
+ * reader meets live.
+ */
+import { cleanup, render, screen } from '@testing-library/react';
+import { act } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createSeamValidator } from '../../seam/validate.js';
+import { Volume } from './Volume.js';
+import { thermoclineSurface, type LevelField } from './volume.js';
+import type { ColumnGrid } from './ColumnProvenance.js';
+
+const validator = createSeamValidator();
+
+const grid: ColumnGrid = {
+ minimumLongitude: -14,
+ maximumLongitude: -8,
+ minimumLatitude: 44,
+ maximumLatitude: 48,
+ depthsM: [0, 40, 80, 120],
+};
+
+/** Two columns by two, four levels: small enough to reason about, real CoverageJSON shape. */
+const LONS = [-14, -8];
+const LATS = [44, 48];
+/** A layer that falls between 40 and 80 m in one column and between 80 and 120 in the others. */
+const TEMPERATURE: Record = {
+ 0: [18, 18, 18, 18],
+ 40: [17.8, 17.9, 17.9, 17.9],
+ 80: [11.0, 17.5, 17.6, 17.6],
+ 120: [10.6, 10.9, 11.0, 11.0],
+};
+
+function coverage(depthM: number, names: readonly string[]): unknown {
+ const ranges: Record = {};
+ for (const name of names) {
+ ranges[name] =
+ name === 'temperature'
+ ? { type: 'NdArray', dataType: 'float', axisNames: ['t', 'z', 'y', 'x'], shape: [1, 1, 2, 2], values: TEMPERATURE[depthM] }
+ : { type: 'NdArray', dataType: 'float', axisNames: ['t', 'z', 'y', 'x'], shape: [1, 1, 2, 2], values: [35, 35, 35, 35] };
+ }
+ return {
+ type: 'Coverage',
+ domain: {
+ type: 'Domain',
+ domainType: 'Grid',
+ axes: {
+ x: { values: LONS },
+ y: { values: LATS },
+ z: { values: [depthM] },
+ t: { values: ['2026-01-01T00:00:00Z'] },
+ },
+ // Master-shaped rather than minimal: a fixture that failed validation would exercise the
+ // refusal path in every test below and prove nothing about the drawn one.
+ referencing: [
+ { coordinates: ['x', 'y'], system: { type: 'GeographicCRS', id: 'http://www.opengis.net/def/crs/EPSG/0/4326' } },
+ ],
+ },
+ parameters: Object.fromEntries(
+ names.map((name) => [
+ name,
+ {
+ type: 'Parameter',
+ description: { en: name },
+ observedProperty: { id: `https://drogna.invalid/${name}`, label: { en: name } },
+ unit: { symbol: 'x' },
+ },
+ ]),
+ ),
+ ranges,
+ };
+}
+
+const props = {
+ collectionId: 'analysis.test',
+ grid,
+ edrPrefix: '/api/edr',
+ validator,
+ parameter: 'temperature' as const,
+ column: undefined,
+ onPickColumn: () => undefined,
+};
+
+async function settle() {
+ await act(async () => {
+ for (let flush = 0; flush < 24; flush++) await Promise.resolve();
+ });
+}
+
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
+describe('the volume (feature 124, FR-120)', () => {
+ beforeEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('names the refusal rather than leaving a blank where a drawing would be', async () => {
+ vi.stubGlobal('fetch', async () => new Response('no', { status: 503 }));
+ render();
+ await settle();
+ expect(screen.getByTestId('forecast-volume-state').textContent).toMatch(/refused \(503\)/);
+ });
+
+ it('refuses a 200 that is not the master’s shape, rather than reading it', async () => {
+ // The seam's rule, at this crossing: a response that validates is a document and one that does
+ // not is a refusal, whatever its status code.
+ vi.stubGlobal('fetch', async () => new Response(JSON.stringify({ type: 'Coverage' }), { status: 200 }));
+ render();
+ await settle();
+ expect(screen.getByTestId('forecast-volume-state').textContent).toMatch(/did not match its master/);
+ });
+
+ it('says which parameter was missing when the field carries no values for it', async () => {
+ vi.stubGlobal('fetch', async (url: string) => {
+ const depthM = Number(new URL(url, 'http://x').searchParams.get('z'));
+ // Served, master-valid, and carrying only temperature — so sound speed cannot be derived.
+ return new Response(JSON.stringify(coverage(depthM, ['temperature'])), { status: 200 });
+ });
+ render();
+ await settle();
+ expect(screen.getByTestId('forecast-volume-state').textContent).toMatch(/carried no sound_speed to draw/);
+ });
+
+ it('says there is no thermocline to place, rather than calling an empty surface level', async () => {
+ // A column that warms with depth has no thermocline, and `thermoclineIn` declines it. With
+ // every column declining, `coreSpanM` is 0 and the level branch's test (`0 <= spacing`) is
+ // true — so the caption read "0 of 4 columns have one, over 0 distinct depths — so it is
+ // level to within one 40 m level", which is a statement about a field it placed nothing in.
+ const WARMING: Record = { 0: [4, 4, 4, 4], 40: [6, 6, 6, 6], 80: [8, 8, 8, 8], 120: [10, 10, 10, 10] };
+ vi.stubGlobal('fetch', async (url: string) => {
+ const depthM = Number(new URL(url, 'http://x').searchParams.get('z'));
+ const body = coverage(depthM, ['temperature']) as { ranges: Record };
+ body.ranges.temperature.values = WARMING[depthM];
+ return new Response(JSON.stringify(body), { status: 200 });
+ });
+ render();
+ await settle();
+ const caption = screen.getByTestId('forecast-volume-caption').textContent ?? '';
+ expect(caption).toContain('0 of 4 columns');
+ expect(caption).toMatch(/no thermocline to place/);
+ expect(caption, 'an empty surface was captioned as a level one').not.toMatch(/level to within/);
+ });
+
+ it('captions the surface with the count and level its own input implies', async () => {
+ vi.stubGlobal('fetch', async (url: string) => {
+ const depthM = Number(new URL(url, 'http://x').searchParams.get('z'));
+ return new Response(JSON.stringify(coverage(depthM, ['temperature'])), { status: 200 });
+ });
+ render();
+ await settle();
+
+ // The expectation is computed from the fixture through the same pure function the component
+ // uses, so it moves when the fixture moves — a transcribed "3 distinct depths" would be a
+ // number to update rather than a claim to hold.
+ const levels: LevelField[] = grid.depthsM.map((depthM) => ({ depthM, temperatureC: TEMPERATURE[depthM] }));
+ const surface = thermoclineSurface(levels);
+ const placed = surface.filter((cell) => cell !== undefined).length;
+ const distinct = new Set(surface.flatMap((cell) => (cell ? [cell.depthM] : []))).size;
+ expect(placed, 'the fixture places no thermocline, so this test would hold nothing').toBeGreaterThan(0);
+ expect(distinct, 'the fixture is flat, so the caption cannot be checked against a shape').toBeGreaterThan(1);
+
+ const caption = screen.getByTestId('forecast-volume-caption').textContent ?? '';
+ expect(caption).toContain(`${placed} of ${surface.length} columns`);
+ expect(caption).toContain(`over ${distinct} distinct depths`);
+ // And a measured figure beside the count, in whichever branch the field selects — the thing
+ // `capture:forecast` asserts on the shipped configuration, held here on a chosen one.
+ expect(caption).toMatch(/% of them at \d+ m|within one \d+ m level/);
+ });
+});
diff --git a/app/src/panels/forecast/volume.test.ts b/app/src/panels/forecast/volume.test.ts
new file mode 100644
index 00000000..1ccf053f
--- /dev/null
+++ b/app/src/panels/forecast/volume.test.ts
@@ -0,0 +1,222 @@
+/**
+ * The volume's arithmetic, alone (feature 124, FR-120).
+ *
+ * The load-bearing test here is the last one: it runs this module's pick over the same input the
+ * *backend's* published estimator runs over, and requires the two to agree. Everything the volume
+ * draws rests on those being one definition rather than two, and a test file that only checked
+ * this module against numbers typed into it would hold nothing about that.
+ */
+import { describe, expect, it } from 'vitest';
+import {
+ FEATURE_SIGMAS,
+ domainMeanProfile,
+ featureReach,
+ thermoclineIn,
+ thermoclineSurface,
+ type LevelField,
+} from './volume.js';
+
+describe('where a column falls fastest', () => {
+ const depthsM = [0, 200, 400, 600, 800, 1000];
+
+ it('reports the midpoint of the steepest pair, carrying the interval it was resolved at', () => {
+ // Falls hardest between 200 m and 400 m: 4 degrees over 200 m.
+ const profile = { depthsM, temperatureC: [15, 14.5, 10.5, 10, 9.8, 9.7] };
+ const found = thermoclineIn(profile);
+ expect(found?.depthM, 'not the midpoint of the steepest pair').toBe(300);
+ expect(found?.thicknessM, 'the estimate does not carry the interval it was taken over').toBe(200);
+ expect(found?.dropC).toBeCloseTo(4, 10);
+ });
+
+ it('refuses a profile that does not fall, which is not the same as one falling at nought', () => {
+ // A column that warms with depth has no thermocline to place. Drawing 0 m — or the first
+ // pair — would put a surface where the physics says there is none.
+ expect(thermoclineIn({ depthsM, temperatureC: [9, 10, 11, 12, 13, 14] })).toBeUndefined();
+ expect(thermoclineIn({ depthsM, temperatureC: [10, 10, 10, 10, 10, 10] })).toBeUndefined();
+ // And a profile too short to have a pair at all.
+ expect(thermoclineIn({ depthsM: [0], temperatureC: [10] })).toBeUndefined();
+ });
+
+ it('skips a level it was not served rather than letting it win the comparison', () => {
+ // **The fault this is written against.** `NaN > steepest` is false, so an unguarded scan
+ // does not *choose* the unserved pair — it silently keeps whatever came before it, and the
+ // surface reports a shallower thermocline with no sign anything was missing. Here the real
+ // steepest pair is 600–800 m, and it sits after the hole.
+ const holed = { depthsM, temperatureC: [15, 14.9, Number.NaN, 14.7, 10, 9.9] };
+ const found = thermoclineIn(holed);
+ expect(found?.depthM, 'a hole in the profile changed which pair was picked').toBe(700);
+ expect(found?.dropC).toBeCloseTo(4.7, 10);
+ });
+});
+
+describe('the surface over a field', () => {
+ // Two cells: the first falls between 200 and 400, the second between 600 and 800. A domain
+ // mean cannot express that difference, which is why the surface exists.
+ const levels: LevelField[] = [
+ { depthM: 0, temperatureC: [15, 15] },
+ { depthM: 200, temperatureC: [14.5, 14.9] },
+ { depthM: 400, temperatureC: [10.5, 14.8] },
+ { depthM: 600, temperatureC: [10.2, 14.7] },
+ { depthM: 800, temperatureC: [10, 10] },
+ { depthM: 1000, temperatureC: [9.9, 9.9] },
+ ];
+
+ it('gives each column its own depth, which is the shape a domain mean cannot carry', () => {
+ const surface = thermoclineSurface(levels);
+ expect(surface).toHaveLength(2);
+ expect(surface[0]?.depthM).toBe(300);
+ expect(surface[1]?.depthM).toBe(700);
+ expect(new Set(surface.map((cell) => cell?.depthM)).size, 'the surface is flat').toBe(2);
+ });
+
+ it('keeps a column with no thermocline in place rather than dropping it', () => {
+ // Indexable by cell: a caller drawing the surface has to be able to draw the hole where a
+ // column has none, and a compacted array would silently shift every later cell westwards.
+ const withHole = levels.map((level) => ({ ...level, temperatureC: [...level.temperatureC, 10] }));
+ const surface = thermoclineSurface(withHole);
+ expect(surface).toHaveLength(3);
+ expect(surface[2], 'a flat column was given a thermocline').toBeUndefined();
+ expect(surface[0]?.depthM, 'the hole moved an earlier column').toBe(300);
+ });
+
+ it('reads no further than the shortest level it was given', () => {
+ // A truncated level is a short read, not a stack of different places: taking the first
+ // level's width would index past the end of the others and compare undefined temperatures.
+ const ragged = levels.map((level, at) => (at === 3 ? { ...level, temperatureC: [level.temperatureC[0]] } : level));
+ expect(thermoclineSurface(ragged)).toHaveLength(1);
+ expect(thermoclineSurface([])).toEqual([]);
+ expect(thermoclineSurface([levels[0]]), 'one level is not a profile').toEqual([]);
+ });
+});
+
+describe('how far down a feature reaches', () => {
+ // Four cells. Cell 0 is the feature: strongly warm at the surface, half as warm at 200 m, and
+ // lost in the field's own scatter below. The others are the field it sits in.
+ //
+ // **The deepest level has to scatter like real water, not sit uniform.** Written with the other
+ // three within 0.01 °C of each other, a residual of 0.02 °C at the feature cleared two sigma of
+ // almost nothing and the level read as "present" — the fixture, not the threshold, was wrong:
+ // it did not represent the state it was named for. The shipped field's spread collapses with
+ // depth but never to nothing (1.13 °C at the surface against 0.23 °C at 800 m), and it is that
+ // remaining scatter a deep feature has to beat.
+ const levels: LevelField[] = [
+ { depthM: 0, temperatureC: [15.0, 12.0, 12.1, 11.9] },
+ { depthM: 200, temperatureC: [11.5, 10.0, 10.1, 9.9] },
+ { depthM: 400, temperatureC: [8.01, 8.0, 8.05, 7.95] },
+ ];
+
+ it('draws the feature where it beats the field, and stops where it does not', () => {
+ const reach = featureReach(levels, 0);
+ expect(reach.map((level) => level.present), 'the reach does not stop where the feature does').toEqual([
+ true,
+ true,
+ false,
+ ]);
+ expect(reach[0].depthM).toBe(0);
+ expect(reach[0].anomalyC).toBeGreaterThan(reach[1].anomalyC);
+ });
+
+ it('finds a deep feature that a fixed temperature floor would miss', () => {
+ // **The case the per-level floor exists for, and the one the other tests could not see.**
+ // Planted a fixed 0.5 °C threshold in place of the spread and every test here still passed:
+ // on the fixture above the two rules give the same answer at every level, so nothing held the
+ // design its docstring argues for. This is where they part. At 800 m the shipped field varies
+ // by about 0.23 °C, so a feature standing 0.4 °C above its surroundings is unmistakable —
+ // nearly ten sigma below — and a fixed floor of half a degree calls it absent.
+ const deep: LevelField[] = [{ depthM: 800, temperatureC: [4.5, 4.1, 4.14, 4.06] }];
+ const reach = featureReach(deep, 0);
+ expect(reach[0].anomalyC, 'the anomaly is not the weak one this case is about').toBeLessThan(0.5);
+ expect(reach[0].spreadC).toBeLessThan(0.1);
+ expect(
+ reach[0].present,
+ 'a feature far above a quiet field was missed, so the floor is not the level\u2019s own',
+ ).toBe(true);
+ });
+
+ it('measures the floor from the level, not from a temperature typed here', () => {
+ // **The point of a per-level floor.** The same anomaly in degrees is a feature at depth and a
+ // wiggle at the surface, because the field's own spread collapses with depth — 1.13 °C at the
+ // surface against 0.23 °C at 800 m on the shipped run. A fixed threshold would be two
+ // different claims depending which level it was applied to.
+ const reach = featureReach(levels, 0);
+ for (const level of reach) {
+ expect(level.spreadC, 'the floor is not the level\u2019s own spread').toBeGreaterThan(0);
+ expect(level.present).toBe(Math.abs(level.anomalyC) >= FEATURE_SIGMAS * level.spreadC);
+ }
+ // The surface spread really is the larger one, so the two floors differ.
+ expect(reach[0].spreadC).toBeGreaterThan(reach[2].spreadC);
+ });
+
+ it('draws nothing through water that does not vary, rather than everything', () => {
+ // A level with no spread has nothing for a feature to stand out from. With a floor of nought
+ // every cell clears it, and the drawing would carry the feature down through uniform water.
+ const flat: LevelField[] = [{ depthM: 0, temperatureC: [10, 10, 10, 10] }];
+ expect(featureReach(flat, 0)[0].present).toBe(false);
+ // And a cell the field did not serve is absent, not present at nought.
+ const holed: LevelField[] = [{ depthM: 0, temperatureC: [Number.NaN, 12, 12.1, 11.9] }];
+ expect(featureReach(holed, 0)[0].present).toBe(false);
+ });
+});
+
+describe('held to the estimate the run itself published', () => {
+ it('reproduces the backend estimator exactly, on the domain mean of one field', async () => {
+ // **The tie between this module and `model-runner/features.ts`.** Both define the
+ // thermocline as the steepest level pair reported at its midpoint; if they ever stop
+ // agreeing, the volume draws a surface the run's own published figure contradicts and
+ // nothing else in the tree would notice.
+ //
+ // **The real estimator is called, not transcribed.** The first draft of this test copied
+ // `features.ts#thermocline`'s arithmetic into the test body and compared this module against
+ // the copy — which holds the two copies together and says nothing at all about the backend,
+ // and would go on passing after the estimator it names had changed. A test may import both
+ // halves: the import boundary excludes tests, and this is the case that exemption is for.
+ //
+ // The *domain mean* profile is the input the two can be compared on. The mean of the
+ // per-column picks is a different quantity — argmax does not commute with averaging — so
+ // this deliberately does not assert that, and `domainMeanProfile`'s docstring says why.
+ const { estimateFeatures } = await import('../../backend/model-runner/features.js');
+
+ const longitudes = [-11.4, -11.2];
+ const latitudes = [46.0, 46.2];
+ const levels: LevelField[] = [
+ { depthM: 0, temperatureC: [15.0, 15.4, 14.6, 15.2] },
+ { depthM: 200, temperatureC: [14.4, 14.9, 13.9, 14.6] },
+ { depthM: 400, temperatureC: [10.6, 11.2, 10.1, 10.9] },
+ { depthM: 600, temperatureC: [10.1, 10.6, 9.7, 10.3] },
+ { depthM: 800, temperatureC: [9.9, 10.2, 9.5, 10.0] },
+ { depthM: 1000, temperatureC: [9.8, 10.0, 9.4, 9.9] },
+ ];
+
+ // The estimator reads one Float32Array laid out level by level, each level row-major over
+ // the same grid — the same order the levels above carry.
+ const perLevel = levels[0].temperatureC.length;
+ const packed = new Float32Array(levels.flatMap((level) => [...level.temperatureC]));
+ const published = estimateFeatures(packed, {
+ longitudes,
+ latitudes,
+ depthsM: levels.map((level) => level.depthM),
+ cellKmEast: 15,
+ cellKmNorth: 22,
+ referenceLatitude: 46.1,
+ }).thermocline;
+
+ expect(published, 'the backend estimator placed no thermocline, so this test held nothing').toBeDefined();
+
+ // **Compared on the same values, not merely the same numbers.** The estimator reads a
+ // `Float32Array`; the literals above are doubles, and the first run of this test failed by
+ // 2.4e-7 on the drop — float32 epsilon at that magnitude — with the depth and the thickness
+ // already exact. That is a difference in the *precision of the input*, not in the
+ // definition, and loosening the bound to absorb it would have hidden a real disagreement
+ // later behind a tolerance sized for a storage artefact. Reading the levels back out of the
+ // packed array is also the truer input: holdings are stored `drogna-f32-v1`, so a value
+ // served through EDR has been through float32 before the shell ever parses it.
+ const asServed: LevelField[] = levels.map((level, at) => ({
+ depthM: level.depthM,
+ temperatureC: [...packed.subarray(at * perLevel, (at + 1) * perLevel)],
+ }));
+ const mine = thermoclineIn(domainMeanProfile(asServed));
+ expect(mine?.depthM, 'the volume and the run disagree about where the thermocline is').toBe(published?.depthM);
+ expect(mine?.thicknessM, 'they disagree about the interval it was resolved at').toBe(published?.thicknessM);
+ expect(mine?.dropC, 'they disagree about the drop across it').toBeCloseTo(published?.layerDropC ?? Number.NaN, 12);
+ });
+});
diff --git a/app/src/panels/forecast/volume.ts b/app/src/panels/forecast/volume.ts
new file mode 100644
index 00000000..e39708b7
--- /dev/null
+++ b/app/src/panels/forecast/volume.ts
@@ -0,0 +1,254 @@
+/**
+ * The volume's pure half: where the thermocline sits in each column of a served field
+ * (feature 124, FR-120).
+ *
+ * **Why this is derived here at all, when the harness already publishes a thermocline.**
+ * `forecast-features` carries one — `{ depth_m, thickness_m, layer_drop_c }` — and it is a
+ * *domain mean*: "where the domain-mean profile falls fastest". One number for the whole grid
+ * is a flat plane. FR-120 asks for a **surface** and says why in the requirement itself: the
+ * thermocline domes, tilts and breaks, and shape is the answer a sonar user is asking for. A
+ * plane drawn where a surface was promised would claim the field is flat, which is the one
+ * thing the drawing exists to disprove.
+ *
+ * The shape is real and is in the served field: analytic form 2 displaces the layer where a
+ * feature warms or cools the water at it (#113, ADR-0044), so the depth at which a *column* falls
+ * fastest moves from column to column. Nothing here invents it — every value comes from an area
+ * query the shell makes through the query layer, one per level, as the Map's own volume does.
+ *
+ * **What that shape is not is recoverable from this pick alone, on the field this draws.** The
+ * volume reads a served *analysis*, and assimilation puts scatter into weakly-constrained columns:
+ * over the truth field the surface takes three distinct depths with the displacement and one
+ * without, and over the analysis four either way. The pick is faithful to the field it is given;
+ * it is not evidence about the layer. `generator.test.ts` holds the shape, against the manifest
+ * and against a bound derived from it, which is where a claim about the world belongs.
+ *
+ * **The pick is the harness's own, not a second opinion about it.** `model-runner/features.ts`
+ * defines the published estimate as the level pair whose temperature falls fastest, reported at
+ * the midpoint of that pair, carrying the pair's separation as its thickness and declining when
+ * the profile does not fall with depth anywhere. This applies that definition per column instead
+ * of to the domain mean, and `thermoclineIn` below is the same arithmetic on one profile — which
+ * is what lets `domainMeanProfile` hold the two together: run this pick over the mean profile and
+ * it must reproduce the number the run published, or one of the two has drifted.
+ *
+ * Pure, and no DOM: the arithmetic is testable alone, as `rays.ts` is and for the same reason.
+ */
+
+/** One column's temperature profile, deepening. */
+export interface Profile {
+ readonly depthsM: readonly number[];
+ readonly temperatureC: readonly number[];
+}
+
+/**
+ * Where a profile falls fastest, at the resolution the grid can support.
+ *
+ * `undefined` where the profile does not fall with depth anywhere — the same refusal the
+ * published estimator makes, and for the same reason: there is no thermocline to place, which is
+ * a different fact from one at depth nought.
+ */
+export interface Thermocline {
+ /** The midpoint of the steepest pair. Never finer than the pair's separation. */
+ readonly depthM: number;
+ /** The interval the drop was taken over: the resolution, carried with the estimate. */
+ readonly thicknessM: number;
+ /** The temperature drop across that interval. Positive by construction. */
+ readonly dropC: number;
+}
+
+export function thermoclineIn(profile: Profile): Thermocline | undefined {
+ const { depthsM, temperatureC } = profile;
+ const levels = Math.min(depthsM.length, temperatureC.length);
+ if (levels < 2) return undefined;
+ let best = -1;
+ let steepest = 0;
+ for (let level = 0; level < levels - 1; level++) {
+ const drop = temperatureC[level] - temperatureC[level + 1];
+ const span = depthsM[level + 1] - depthsM[level];
+ // A non-finite reading is not a gradient. Cells the field did not serve are skipped rather
+ // than compared, because `NaN > steepest` is false and would silently pick the level before.
+ if (!(span > 0) || !Number.isFinite(drop)) continue;
+ const gradient = drop / span;
+ if (gradient > steepest) {
+ steepest = gradient;
+ best = level;
+ }
+ }
+ if (best < 0 || steepest <= 0) return undefined;
+ return {
+ depthM: (depthsM[best] + depthsM[best + 1]) / 2,
+ thicknessM: depthsM[best + 1] - depthsM[best],
+ dropC: temperatureC[best] - temperatureC[best + 1],
+ };
+}
+
+/**
+ * The field as the shell holds it: one served level per entry, each a grid of temperatures in
+ * row-major order over the same latitudes and longitudes.
+ */
+export interface LevelField {
+ readonly depthM: number;
+ /** `latitudes.length * longitudes.length` values, row-major, as an EDR area range carries them. */
+ readonly temperatureC: readonly number[];
+}
+
+/**
+ * The thermocline in every column, in the same cell order the levels are given in.
+ *
+ * A cell whose profile does not fall is `undefined` rather than absent, so the surface stays
+ * indexable by cell and a caller can draw the hole rather than closing over it.
+ */
+export function thermoclineSurface(levels: readonly LevelField[]): (Thermocline | undefined)[] {
+ if (levels.length < 2) return [];
+ // Every level must describe the same grid, or a "column" is a stack of different places. The
+ // shortest wins rather than the first, so a truncated level cannot read past its own end.
+ const cells = levels.reduce((fewest, level) => Math.min(fewest, level.temperatureC.length), Infinity);
+ if (!Number.isFinite(cells) || cells <= 0) return [];
+ const depthsM = levels.map((level) => level.depthM);
+ const surface: (Thermocline | undefined)[] = [];
+ for (let cell = 0; cell < cells; cell++) {
+ surface.push(thermoclineIn({ depthsM, temperatureC: levels.map((level) => level.temperatureC[cell]) }));
+ }
+ return surface;
+}
+
+/**
+ * How many of a level's own standard deviations a feature must clear at its centre to be drawn
+ * there.
+ *
+ * **Measured against the shipped field rather than chosen, and re-measured when the field moved.**
+ * On the 26-level axis (#113) the eddy's anomaly at its own centre runs 0.43σ, 1.01σ, then 2.15,
+ * 3.65, 2.98, 2.75, 2.83, 2.78, 2.56, 2.18σ, then falls away through 1.67, 1.11, 0.57σ and below;
+ * the drifting feature peaks at 3.49σ and is back under two by 240 m. Two sigma cuts both in the
+ * gap between a peak near three and a tail under one, and the reach it gives is contiguous — the
+ * eddy from 80 to 360 m, the drifting feature from 40 to 200 m — rather than the on-off-on the
+ * six-level axis produced (2.06, 2.32, 0.18, 1.09, 0.03, −0.65σ), where the nearest thing to a
+ * false positive was a 1.09σ level with nothing above it. Finer levels did not change the
+ * threshold; they made it a boundary rather than a coin toss.
+ *
+ * The *floor itself* is the level's spread rather than a temperature typed in here, because a
+ * fixed threshold in degrees would be two different claims at the surface, where the field varies
+ * by 1.11 °C, and at a kilometre down where it varies by 0.02 °C.
+ */
+export const FEATURE_SIGMAS = 2;
+
+/** What a feature is doing at one level of the served field. */
+export interface FeatureReach {
+ readonly depthM: number;
+ /** The anomaly at the feature's own cell, against that level's mean. */
+ readonly anomalyC: number;
+ /** The level's own standard deviation: the wiggle the anomaly has to beat. */
+ readonly spreadC: number;
+ /** Whether the feature is distinguishable from the field here. */
+ readonly present: boolean;
+}
+
+/**
+ * A published feature carried **down** the served field (FR-120's second half, T010).
+ *
+ * **The holding publishes no depth for these, which is why this exists.**
+ * `forecast-features.schema.json` carries the eddy and the drifting feature as a centre, a radius
+ * and an anomaly peak, and the front as an anchor, a bearing and a step. All horizontal: there is
+ * no vertical extent anywhere in the document, and the estimator that writes it takes no manifest
+ * on purpose, because reading the authored depth scales would be forecasting from the truth.
+ *
+ * So the dimension is read off the field the features are *in*, which is what T010 asks for in as
+ * many words — "against the field they are in". At each level the feature's own cell is compared
+ * with that level's mean, and it is drawn while that anomaly clears the level's own spread by
+ * `FEATURE_SIGMAS`. On the shipped configuration the eddy reaches 200 m and stops, which is a
+ * fact about a surface-intensified feature rather than a limit of the drawing.
+ */
+export function featureReach(levels: readonly LevelField[], cell: number): FeatureReach[] {
+ return levels.map((level) => {
+ // **The feature's own cell is left out of the field it is compared with.** It is "the field
+ // they are in", not the field including them, and a feature that contributes to its own floor
+ // is measured against a yardstick it moved: on a four-cell level a strong eddy raises the
+ // spread enough that its own 2.25 °C anomaly cannot clear 2σ of 1.30 °C. One cell in 7,680
+ // changes nothing measurable on the shipped grid — the calibration above is unaffected — but
+ // the measure should not depend on the magnitude of the thing being measured at all.
+ let sum = 0;
+ let counted = 0;
+ for (let index = 0; index < level.temperatureC.length; index++) {
+ const value = level.temperatureC[index];
+ if (index === cell || !Number.isFinite(value)) continue;
+ sum += value;
+ counted += 1;
+ }
+ const mean = counted > 0 ? sum / counted : Number.NaN;
+ let squares = 0;
+ for (let index = 0; index < level.temperatureC.length; index++) {
+ const value = level.temperatureC[index];
+ if (index === cell || !Number.isFinite(value)) continue;
+ squares += (value - mean) ** 2;
+ }
+ const spreadC = counted > 0 ? Math.sqrt(squares / counted) : Number.NaN;
+ const at = level.temperatureC[cell];
+ const anomalyC = Number.isFinite(at) && Number.isFinite(mean) ? at - mean : Number.NaN;
+ return {
+ depthM: level.depthM,
+ anomalyC,
+ spreadC,
+ // A level with no spread has nothing to stand out from: everything or nothing would clear
+ // a floor of nought, and "everything" is the answer that draws a feature through water
+ // that is uniform.
+ present: Number.isFinite(anomalyC) && spreadC > 0 && Math.abs(anomalyC) >= FEATURE_SIGMAS * spreadC,
+ };
+ });
+}
+
+/**
+ * The domain-mean profile of a served field, for holding this module against the run's own
+ * published estimate.
+ *
+ * **The mean of the picks is not the pick of the mean**, and that is the whole reason this
+ * exists. `thermoclineSurface` gives one depth per column and averaging those would *not*
+ * reproduce `forecast-features`' number — argmax does not commute with the mean — so a test
+ * asserting it did would be asserting something false and would be "fixed" by loosening its
+ * bound until it passed. What does reproduce it exactly is the same pick applied to the same
+ * input: the domain mean profile. That is the falsifiable tie between this file and
+ * `model-runner/features.ts`.
+ */
+export function domainMeanProfile(levels: readonly LevelField[]): Profile {
+ return {
+ depthsM: levels.map((level) => level.depthM),
+ temperatureC: levels.map((level) => {
+ let sum = 0;
+ let counted = 0;
+ for (const value of level.temperatureC) {
+ if (!Number.isFinite(value)) continue;
+ sum += value;
+ counted += 1;
+ }
+ return counted > 0 ? sum / counted : Number.NaN;
+ }),
+ };
+}
+
+/**
+ * What the volume can draw: the two variables an analysis holding carries, and the one derived
+ * from them.
+ *
+ * **Sound speed is not served and is not a third copy of the arithmetic.** A holding carries
+ * temperature and salinity; ADR-0005 says sound speed is derived at the point of use, never
+ * stored, and that there is exactly one implementation in drogna. That implementation sits in
+ * `seam/ocean-relations.ts`, where both halves can reach it — it was in the backend, which is why
+ * this control could not offer sound speed until it moved. A panel evaluating the seam's own
+ * declared relation over served values is the point of use ADR-0005 describes, not a second
+ * opinion about it.
+ *
+ * **These live here, in the pure half, rather than beside the drawing that uses them, and the
+ * reason is measurable.** The parameter chooser is in `ColumnProvenance`, which is *not* deferred;
+ * `Volume.tsx` is, because deck.gl is about a third of the bundle. A `const` imported from
+ * `Volume.tsx` is a static import of the module `lazy.tsx` exists to withhold, and Vite says so
+ * — "dynamically imported ... but also statically imported ... dynamic import will not move
+ * module into another chunk". Measured, that one import put the whole deck.gl stack back in the
+ * first-load bundle: `main` went from 521 kB gzipped to 719 kB, and it took the Data tab's and
+ * the Map's own deferrals with it, because they shared the chunk that had just been hoisted.
+ * A value the chooser needs belongs in the half that costs nothing to import.
+ */
+export const VOLUME_PARAMETERS = ['sound_speed', 'temperature', 'salinity'] as const;
+export type VolumeParameter = (typeof VOLUME_PARAMETERS)[number];
+
+/** The variables that have to be fetched to draw a parameter. Sound speed needs both. */
+export function servedFor(parameter: VolumeParameter): readonly string[] {
+ return parameter === 'sound_speed' ? ['temperature', 'salinity'] : [parameter];
+}
diff --git a/app/src/seam/ocean-relations.test.ts b/app/src/seam/ocean-relations.test.ts
new file mode 100644
index 00000000..bda88ffa
--- /dev/null
+++ b/app/src/seam/ocean-relations.test.ts
@@ -0,0 +1,73 @@
+/**
+ * The declared relations, held at their anchors and against the declaration itself.
+ *
+ * The arithmetic assertions came here with the functions, from `env-generator/analytic.test.ts`,
+ * unchanged: the move must not have moved a number. They are not duplicated there any more,
+ * because a test that reads the re-export holds the re-export rather than the relation.
+ *
+ * The last test is new and is the reason this file is worth its length. `SOUND_SPEED.implementation`
+ * is a *path handed to a client* — a manifest crosses the wire saying "implemented there" — and
+ * nothing had ever checked that "there" existed. It was wrong for the length of this change until
+ * the move updated it, and it would go wrong again the next time this file is renamed, silently,
+ * because a string is not a reference.
+ */
+import { readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import {
+ PRESSURE_RELATION,
+ SOUND_SPEED,
+ insideSoundSpeedValidity,
+ pressureDbar,
+ soundSpeedMs,
+} from './ocean-relations.js';
+
+describe('ocean relations', () => {
+ it('pressure and sound speed behave at their anchors', () => {
+ expect(pressureDbar(0)).toBe(0);
+ expect(pressureDbar(1000)).toBeCloseTo(1007.5, 6);
+ // Mackenzie's own anchor: 0°C, 35 psu, surface.
+ expect(soundSpeedMs(0, 35, 0)).toBeCloseTo(1448.96, 2);
+ const mid = soundSpeedMs(10, 35, 100);
+ expect(mid).toBeGreaterThan(1480);
+ expect(mid).toBeLessThan(1500);
+ });
+
+ it('pressure is the declared linear relation, not an approximation of it', () => {
+ // The expression the manifest publishes is evaluated here against the function it names, so a
+ // coefficient edited in one and not the other is a failure rather than a client's surprise.
+ for (const depthM of [0, 12.5, 200, 1000, 8000]) {
+ expect(pressureDbar(depthM)).toBeCloseTo(
+ PRESSURE_RELATION.surface_dbar + PRESSURE_RELATION.dbar_per_metre * depthM,
+ 9,
+ );
+ }
+ });
+
+ it('sound speed rises with temperature, salinity and depth over the validity window', () => {
+ const base = soundSpeedMs(10, 35, 100);
+ expect(soundSpeedMs(11, 35, 100)).toBeGreaterThan(base);
+ expect(soundSpeedMs(10, 36, 100)).toBeGreaterThan(base);
+ expect(soundSpeedMs(10, 35, 200)).toBeGreaterThan(base);
+ });
+
+ it('the validity window admits its own edges and refuses just outside them', () => {
+ const v = SOUND_SPEED.validity;
+ expect(insideSoundSpeedValidity(v.min_temperature_c, v.min_salinity_psu, v.min_depth_m)).toBe(true);
+ expect(insideSoundSpeedValidity(v.max_temperature_c, v.max_salinity_psu, v.max_depth_m)).toBe(true);
+ expect(insideSoundSpeedValidity(v.min_temperature_c - 0.1, 35, 100)).toBe(false);
+ expect(insideSoundSpeedValidity(v.max_temperature_c + 0.1, 35, 100)).toBe(false);
+ expect(insideSoundSpeedValidity(10, v.min_salinity_psu - 0.1, 100)).toBe(false);
+ expect(insideSoundSpeedValidity(10, v.max_salinity_psu + 0.1, 100)).toBe(false);
+ expect(insideSoundSpeedValidity(10, 35, v.min_depth_m - 0.1)).toBe(false);
+ expect(insideSoundSpeedValidity(10, 35, v.max_depth_m + 0.1)).toBe(false);
+ });
+
+ it('the implementation path a manifest publishes names a file that exports that symbol', () => {
+ const [path, symbol] = SOUND_SPEED.implementation.split('#');
+ expect(symbol).toBe('soundSpeedMs');
+ // Repository-relative, as a reader of a served manifest would resolve it. This file is
+ // `app/src/seam/`, so the root is three levels up.
+ const source = readFileSync(new URL(`../../../${path}`, import.meta.url), 'utf8');
+ expect(source).toContain(`export function ${symbol}(`);
+ });
+});
diff --git a/app/src/seam/ocean-relations.ts b/app/src/seam/ocean-relations.ts
new file mode 100644
index 00000000..bdbd5aa2
--- /dev/null
+++ b/app/src/seam/ocean-relations.ts
@@ -0,0 +1,90 @@
+/**
+ * The declared physical relations both halves of the harness may evaluate: pressure from depth,
+ * and sound speed from temperature, salinity and depth.
+ *
+ * **Why these sit in the seam rather than in the backend that used to own them.** ADR-0005 says
+ * sound speed is *derived at the point of use, never stored*, and that there is exactly one
+ * implementation in drogna — a residual computed anywhere cites it. Both halves of that rule
+ * point here. A holding's manifest declares the method, the validity window and the path to the
+ * implementation, and that manifest crosses the wire: a client reading it is told "Mackenzie 1981,
+ * valid over this range, implemented there". The declaration is part of what the seam carries, so
+ * the thing it points at has to be reachable from both sides of the seam or the rule cannot hold.
+ *
+ * It moved because feature 124's volume needed it. An analysis holding carries temperature and
+ * salinity — sound speed is derived from the pair — so a parameter control offering sound speed
+ * had three ways to get it: a second implementation in the panel, which ADR-0005 forbids in as
+ * many words; the query layer serving it derived, which is a backend change; or this. Nothing
+ * about the arithmetic changed in the move, and `ocean-relations.test.ts` holds the coefficients
+ * against the values the previous implementation produced.
+ *
+ * Every consumer imports from here. `analytic.ts` re-exported these for a while so the backend's
+ * call sites would not have to change, and that made the move invisible exactly where it matters
+ * — a reader of `monitor.ts` saw the relation attributed to the analytic ocean. The eight edits
+ * were cheaper than the misattribution.
+ *
+ * **These are relations, not the ocean.** Nothing here reads a field, a holding or a manifest, and
+ * nothing here knows what the true ocean is: they are functions of three numbers. The analytic
+ * true ocean stays in `backend/env-generator/analytic.ts`, where a front's shape and an eddy's
+ * anomaly belong, and where the seam must not reach.
+ */
+
+export const PRESSURE_RELATION = {
+ name: 'linear-depth',
+ expression: 'pressure_dbar = surface_dbar + dbar_per_metre * depth_m',
+ dbar_per_metre: 1.0075,
+ surface_dbar: 0,
+} as const;
+
+export function pressureDbar(depthM: number): number {
+ return PRESSURE_RELATION.surface_dbar + PRESSURE_RELATION.dbar_per_metre * depthM;
+}
+
+export const SOUND_SPEED = {
+ method: 'Mackenzie 1981 nine-term equation',
+ // The path a manifest hands a reader. It names this file now; it named
+ // `backend/env-generator/analytic.ts#soundSpeedMs` until the relation moved to where both
+ // halves can reach it, and a string that outlives the thing it points at is a manifest telling
+ // a client to look somewhere there is nothing.
+ implementation: 'app/src/seam/ocean-relations.ts#soundSpeedMs',
+ validity: {
+ min_temperature_c: 2,
+ max_temperature_c: 30,
+ min_salinity_psu: 25,
+ max_salinity_psu: 40,
+ min_depth_m: 0,
+ max_depth_m: 8000,
+ },
+} as const;
+
+/**
+ * ADR-0005: sound speed is derived at the point of use, never stored, and this is
+ * the single implementation in drogna — a residual computed anywhere cites it.
+ */
+export function soundSpeedMs(temperatureC: number, salinityPsu: number, depthM: number): number {
+ const t = temperatureC;
+ const s = salinityPsu;
+ const d = depthM;
+ return (
+ 1448.96 +
+ 4.591 * t -
+ 5.304e-2 * t * t +
+ 2.374e-4 * t * t * t +
+ 1.34 * (s - 35) +
+ 1.63e-2 * d +
+ 1.675e-7 * d * d -
+ 1.025e-2 * t * (s - 35) -
+ 7.139e-13 * t * d * d * d
+ );
+}
+
+export function insideSoundSpeedValidity(temperatureC: number, salinityPsu: number, depthM: number): boolean {
+ const v = SOUND_SPEED.validity;
+ return (
+ temperatureC >= v.min_temperature_c &&
+ temperatureC <= v.max_temperature_c &&
+ salinityPsu >= v.min_salinity_psu &&
+ salinityPsu <= v.max_salinity_psu &&
+ depthM >= v.min_depth_m &&
+ depthM <= v.max_depth_m
+ );
+}
diff --git a/app/src/shell/walkthrough/tour.ts b/app/src/shell/walkthrough/tour.ts
index f2719b2a..93dfe0f3 100644
--- a/app/src/shell/walkthrough/tour.ts
+++ b/app/src/shell/walkthrough/tour.ts
@@ -466,7 +466,7 @@ const FORECAST_STEPS: SubjectStep[] = [
element: '[data-region="volume"]',
title: 'What a cell’s value was made from',
what: 'The question an operator asks of a forecast is not which model ran but what a given number is made of: which observations moved it, by how much, and where the rest came from when nothing sampled. Assimilation is arithmetic over published numbers, and every reader assumes it is magic.',
- panel: 'Pick a square and the region answers for that column. The map above is the four provenance shares at a chosen depth, every square a cell the analyst published rather than an average of several \u2014 and the field is finer than a panel is wide, so it can be approached: come closer and the cells drawn are the ones in view, at the resolution the analyst filed them. The lines run to the instruments whose observations the gain actually weighed, each as wide as it counted; a source that reached the column but contributed nothing at the chosen depth keeps its place as a hollow marker rather than vanishing. Beneath, the depth profile stacks each level’s composition and prints every figure, including the part of the correction that came through a reading beyond this cell’s own reach and so has no line to be drawn from.',
+ panel: 'Pick a square and the region answers for that column. The map above is the four provenance shares at a chosen depth, every square a cell the analyst published rather than an average of several \u2014 and it can be approached: come closer and the cells drawn are the ones in view, at the resolution the analyst filed them. The lines run to the instruments whose observations the gain actually weighed, each as wide as it counted; a source that reached the column but contributed nothing at the chosen depth keeps its place as a hollow marker rather than vanishing. Beneath, the depth profile stacks each level’s composition and prints every figure, including the part of the correction that came through a reading beyond this cell’s own reach and so has no line to be drawn from. Opening the volume puts that plan in its setting: the field drawn semi-transparent level by level, and the thermocline as a surface through it — placed per column rather than as the one depth the run publishes, so where a feature displaces the layer the surface follows it. The caption counts how many depths it found and how level the field is, and says what it cannot tell from them.',
},
{
subject: 'ahead',
diff --git a/app/vite.config.ts b/app/vite.config.ts
index b4382c3c..100ea2da 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -78,6 +78,13 @@ export default defineConfig({
// the comment above records CI taking roughly twice as long. 120s is that measurement
// doubled for a CI runner and rounded up, and it is the number to re-derive the next
// time the loop gets dearer rather than the number to nudge.
+ //
+ // Re-derived at #113, which raised the depth axis from 6 levels to 26 and looked like it
+ // would be expensive here. It is not: the horizontal was halved to pay for it, so the
+ // analysis grid carries 8% more cells, and `analyst.test.ts` — the file this ceiling is
+ // about — went from 48.9s to 51.9s on this machine, 6%. Doubled for a runner that is
+ // still 104s. The number stands, and this is the measurement rather than an assumption
+ // that it would.
hookTimeout: 120_000,
},
});
diff --git a/contracts/schemas/config.env-generator.schema.json b/contracts/schemas/config.env-generator.schema.json
index b88662fc..6fa48a5b 100644
--- a/contracts/schemas/config.env-generator.schema.json
+++ b/contracts/schemas/config.env-generator.schema.json
@@ -166,13 +166,17 @@
"properties": {
"nominal": {
"type": "object",
- "required": ["depth_m", "thickness_m", "temperature_drop_c", "salinity_rise_psu"],
+ "required": ["depth_m", "thickness_m", "temperature_drop_c", "salinity_rise_psu", "displacement_m_per_c"],
"additionalProperties": false,
"properties": {
"depth_m": { "type": "number" },
"thickness_m": { "type": "number", "exclusiveMinimum": 0 },
"temperature_drop_c": { "type": "number", "exclusiveMinimum": 0 },
- "salinity_rise_psu": { "type": "number" }
+ "salinity_rise_psu": { "type": "number" },
+ "displacement_m_per_c": {
+ "type": "number",
+ "description": "Metres the layer moves per degree of the eddy and moving anomalies at its nominal depth; the front does not displace it. Not jittered: it sets the shape of the doming rather than where the layer sits, and a run that varied it would vary the thing the drawing exists to show."
+ }
}
},
"jitter": {
diff --git a/contracts/schemas/manifest.schema.json b/contracts/schemas/manifest.schema.json
index 0ad5d078..4a2d50e7 100644
--- a/contracts/schemas/manifest.schema.json
+++ b/contracts/schemas/manifest.schema.json
@@ -495,13 +495,18 @@
},
"thermocline_parameters": {
"type": "object",
- "required": ["depth_m", "thickness_m", "temperature_drop_c", "salinity_rise_psu"],
+ "description": "The layer's far-field depth and shape, and how far the two localised features displace it. depth_m is its depth away from them; the depth at a point is depth_m plus displacement_m_per_c times the sum of the eddy and moving temperature anomalies evaluated at depth_m, so the layer domes over a cool feature and is depressed under a warm one. The front is deliberately not a term: its anomaly is a tanh and saturates rather than decaying, so as a displacement it would tilt the whole domain rather than the water near it. Analytic form 1 had no displacement term at all and the layer was horizontally uniform.",
+ "required": ["depth_m", "thickness_m", "temperature_drop_c", "salinity_rise_psu", "displacement_m_per_c"],
"additionalProperties": false,
"properties": {
"depth_m": { "type": "number" },
"thickness_m": { "type": "number", "exclusiveMinimum": 0 },
"temperature_drop_c": { "type": "number", "exclusiveMinimum": 0 },
- "salinity_rise_psu": { "type": "number" }
+ "salinity_rise_psu": { "type": "number" },
+ "displacement_m_per_c": {
+ "type": "number",
+ "description": "Metres of vertical displacement per degree of the eddy and moving anomalies at the layer's nominal depth. Zero reproduces analytic form 1."
+ }
}
},
"moving_parameters": {
diff --git a/contracts/topology.json b/contracts/topology.json
index 42ab917e..d08de925 100644
--- a/contracts/topology.json
+++ b/contracts/topology.json
@@ -642,19 +642,19 @@
{
"component": "advisory-source",
"path": "app/config/run.json",
- "line": 1073,
+ "line": 1074,
"constant": "/advisory_source/topics/advisory"
},
{
"component": "advisory-store",
"path": "app/config/run.json",
- "line": 1073,
+ "line": 1074,
"constant": "/advisory_store/topics/advisory"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1439,
+ "line": 1440,
"constant": "/shell/topics/advisories"
}
]
@@ -673,13 +673,13 @@
{
"component": "coverage-store",
"path": "app/config/run.json",
- "line": 448,
+ "line": 449,
"constant": "/coverage_store/topics/published"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1435,
+ "line": 1436,
"constant": "/shell/topics/holdings"
}
]
@@ -700,19 +700,19 @@
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 706,
+ "line": 707,
"constant": "/model_runner/topics/analysis_published"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 706,
+ "line": 707,
"constant": "/analyst/topics/analysis_published"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 706,
+ "line": 707,
"constant": "/shell/topics/analysis_published"
}
]
@@ -731,13 +731,13 @@
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 761,
+ "line": 762,
"constant": "/analyst/topics/analysis_standing"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 761,
+ "line": 762,
"constant": "/shell/topics/analysis_standing"
}
]
@@ -936,13 +936,13 @@
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 653,
+ "line": 654,
"constant": "/monitor/topics/divergence"
},
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 653,
+ "line": 654,
"constant": "/scheduler/topics/divergence"
}
]
@@ -961,13 +961,13 @@
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 710,
+ "line": 711,
"constant": "/model_runner/topics/forecast_features"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 710,
+ "line": 711,
"constant": "/shell/topics/forecast_features"
}
]
@@ -986,13 +986,13 @@
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 656,
+ "line": 657,
"constant": "/monitor/topics/indicator"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1448,
+ "line": 1449,
"constant": "/shell/topics/forecast_indicator"
}
]
@@ -1033,13 +1033,13 @@
{
"component": "operator",
"path": "app/config/run.json",
- "line": 865,
+ "line": 866,
"constant": "/operator/topics/heartbeat"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 865,
+ "line": 866,
"constant": "/shell/topics/heartbeat"
}
]
@@ -1058,7 +1058,7 @@
{
"component": "offload",
"path": "app/config/run.json",
- "line": 1106,
+ "line": 1107,
"constant": "/offload/topics/offload"
}
]
@@ -1166,13 +1166,13 @@
{
"component": "planner",
"path": "app/config/run.json",
- "line": 791,
+ "line": 792,
"constant": "/planner/topics/plan"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 791,
+ "line": 792,
"constant": "/shell/topics/plan"
}
]
@@ -1192,13 +1192,13 @@
{
"component": "platform",
"path": "app/config/run.json",
- "line": 466,
+ "line": 467,
"constant": "/platform/topics/demand"
},
{
"component": "operator",
"path": "app/config/run.json",
- "line": 866,
+ "line": 867,
"constant": "/operator/topics/platform_demand"
}
]
@@ -1217,13 +1217,13 @@
{
"component": "platform",
"path": "app/config/run.json",
- "line": 467,
+ "line": 468,
"constant": "/platform/topics/state"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 1441,
+ "line": 1442,
"constant": "/shell/topics/platform_state"
}
]
@@ -1243,19 +1243,19 @@
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 687,
+ "line": 688,
"constant": "/scheduler/topics/run_cost"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 687,
+ "line": 688,
"constant": "/model_runner/topics/run_cost"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 687,
+ "line": 688,
"constant": "/shell/topics/run_cost"
}
]
@@ -1278,43 +1278,43 @@
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/scheduler/topics/run_published"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/model_runner/topics/run_published"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/analyst/topics/run_published"
},
{
"component": "planner",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/planner/topics/run_published"
},
{
"component": "telemetry",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/telemetry/topics/run_published"
},
{
"component": "offload",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/offload/topics/run_published"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 684,
+ "line": 685,
"constant": "/shell/topics/run_published"
}
]
@@ -1334,25 +1334,25 @@
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/scheduler/topics/run_request"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/model_runner/topics/run_request"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/analyst/topics/run_request"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 683,
+ "line": 684,
"constant": "/shell/topics/run_request"
}
]
@@ -1371,13 +1371,13 @@
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 707,
+ "line": 708,
"constant": "/model_runner/topics/run_started"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 707,
+ "line": 708,
"constant": "/shell/topics/run_started"
}
]
@@ -1401,31 +1401,31 @@
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/monitor/topics/telemetry"
},
{
"component": "scheduler",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/scheduler/topics/telemetry"
},
{
"component": "model-runner",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/model_runner/topics/telemetry"
},
{
"component": "telemetry",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/telemetry/topics/telemetry"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 654,
+ "line": 655,
"constant": "/shell/topics/telemetry"
}
]
@@ -1447,37 +1447,37 @@
{
"component": "ingest",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/ingest/topics/observations"
},
{
"component": "monitor",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/monitor/topics/observations"
},
{
"component": "analyst",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/analyst/topics/observations"
},
{
"component": "planner",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/planner/topics/observations"
},
{
"component": "telemetry",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/telemetry/topics/observations"
},
{
"component": "shell",
"path": "app/config/run.json",
- "line": 611,
+ "line": 612,
"constant": "/shell/topics/observations"
}
]
@@ -1503,7 +1503,7 @@
{
"component": "sensors",
"path": "app/config/run.json",
- "line": 539,
+ "line": 540,
"constant": "/sensors/topics/ownship"
}
]
diff --git a/docs/adr/0044-the-thermocline-is-a-surface-in-the-world.md b/docs/adr/0044-the-thermocline-is-a-surface-in-the-world.md
new file mode 100644
index 00000000..b40c3c00
--- /dev/null
+++ b/docs/adr/0044-the-thermocline-is-a-surface-in-the-world.md
@@ -0,0 +1,210 @@
+# ADR-0044: the thermocline is a surface in the world, not only in the drawing
+
+**Status:** Accepted
+**Date:** 4 September 2026
+**Feature:** 124 (the forecast tab), raised from it as issue #113
+**Requirements:** SRD-v2 FR-120
+**Engages:** Constitution VII (no false illumination); Constitution IX (the manifest is
+sufficient); ADR-0002 (the evaluated form is what reaches a consumer); the
+`analytic_form_version` contract in `manifest.schema.json`
+
+## Context
+
+FR-120 asks for the thermocline as a **surface** through a semi-transparent volume, and
+puts its reason in the requirement rather than leaving it to be inferred: "a surface is
+required rather than a depth slice because the thermocline domes, tilts and breaks, and
+shape is the answer a sonar user is asking for."
+
+Feature 124 drew that surface — per column, from the served field, using the run's own
+definition of where a profile falls fastest — and captioned what it found rather than what
+it wanted. What it found was a plane: 7,679 of 7,680 columns placed the thermocline at
+100 m and one at 300 m. The caption said so, which is the only reason the next question
+got asked.
+
+The obvious diagnosis was the depth axis. Six levels over a kilometre is 200 m of spacing
+against a layer authored 30 m thick, and the manifest already published that ratio as 0.15;
+`model-runner/features.ts` already said in as many words that "a 200 m grid cannot see a
+30 m layer". Raising the resolution looked like the whole answer.
+
+It was not the answer at all, and the measurement is the reason this ADR exists rather
+than a configuration commit. Sampling the analytic true ocean over the same 7,680 columns
+at 200, 100, 50, 40, 25 and 10 m spacing gives **one distinct thermocline depth at every
+one of them**. At 20 m spacing the winning level pair beats the runner-up by 13.7 % to
+17.3 % in every column in the domain; the eddy's centre and the far field pick the same
+depth. Nothing available was going to tilt it.
+
+`thermoclineAnomalyT(parameters, depthM)` took no longitude and no latitude. The authored
+thermocline was one scalar depth for the whole world. **FR-120's rationale described
+something the harness did not contain**, and no drawing at any resolution could have shown
+it, because there was nothing there to resolve.
+
+## Decision
+
+**The analytic form gains a displacement term, and becomes version 2. The features that
+warm and cool the water also move the layer.**
+
+`thermoclineDepthAt(world, longitude, latitude, seconds)` returns the layer's depth at a
+place: the authored `depth_m` plus `displacement_m_per_c` times the eddy and moving
+anomalies evaluated **at that nominal depth**. The three thermocline terms take that local
+depth as an argument instead of reading `depth_m` themselves.
+
+Four parts of that were arguable, and the fourth was found by measurement rather than
+thought.
+
+### The anomalies are read at the layer's nominal depth, not at the depth being sampled
+
+A layer's depth is a property of the place. Reading the anomaly at the depth being asked
+about would put the layer somewhere different for every sample taken down one column,
+which is not a surface. `depth_m` is the one depth every column shares, so it is the one
+that can be asked at — and it keeps the term out of a loop, since the eddy, front and
+moving anomalies never read the thermocline.
+
+### One coefficient, not one per feature
+
+A displacement per feature would have been three parameters and three schema amendments,
+and would have let the eddy's doming and the front's tilt be tuned apart. They should not
+be: the physical coupling is the background stratification, which is one property of the
+water, and a warm anomaly of a given size displaces an isopycnal by an amount that
+does not depend on which feature made it. One coefficient also means the demo cannot be
+tuned into a shape the composition rule does not imply.
+
+### The argument is not defaulted
+
+`thermoclineAnomalyT(p, depthM, layerDepthM)` could have defaulted its third argument to
+`p.depth_m`. A caller that forgot it would then silently get form 1's flat field back —
+which is the exact fault this version exists to remove, arriving quietly. It is required.
+
+### The front does not displace the layer, and this was not the first answer
+
+The first version used all three features, on the obvious argument that every anomaly
+should move the layer. The front's anomaly is a `tanh`. It *saturates* rather than
+decaying, so as a displacement it does not tilt the layer near the front — it holds one
+half of the domain permanently deep and the other permanently shallow, out to the corners.
+The eddy and the drifting feature use radial Gaussians that fall to nothing, which is what
+a local displacement has to do.
+
+The estimators said so before the argument was finished. Recovering the drifting feature's
+centre, error in km against its own 40 km radius. Every figure is printed by
+`features.test.ts` over the seeds that file runs, so the table reproduces by being the
+test's own output — an earlier draft of it was taken from a standalone probe, quoted a
+seed the repository does not contain, and did not reproduce, which is what a measurement
+that lives beside the thing it measures is for:
+
+| terms, at the shipped 20 m/°C | 1234 | 1180001 | 1180002 | 1180003 | 1180004 |
+|---|---|---|---|---|---|
+| none (analytic form 1) | 3.1 | 9.7 | 20.5 | 7.0 | *declined* |
+| eddy, front and moving | 3.1 | 8.8 | 17.6 | 5.4 | *declined* |
+| eddy and moving (shipped) | **2.4** | **8.3** | **14.5** | **4.7** | **3.6** |
+
+A global cold half and warm half at the layer's depth is a blob the size of the domain, and
+a blob estimator finds it. Dropping the front is better on every seed, and on 1180004 —
+`returning`'s own — it is the difference between a position and a refusal: with the front
+displacing, the estimator declines there exactly as form 1 does. The front's own horizontal
+step is already in the field through `frontAnomalyT`; stepping the layer with it as well was
+double-counting the one feature that needs no help being seen.
+
+**And the coefficient is 20 m/°C because that is where the front's own anchor still lands.**
+With the front excluded, 30 and 40 m/°C both recover the drifting feature better still —
+1.9 km on 1180004 — and both put seed 1180003's front anchor outside its 30 km bound. The
+layer's shape and the front's position are read off the same horizontal structure, so buying
+more of one spends the other; the last section of this ADR is that tension at full size.
+
+## The depth axis moves too, and pays for itself
+
+Form 2 gives the layer 69 m of relief across the domain, and a pick quantised to level
+midpoints needs spacing fine enough to carry it. Six levels still show one depth. The
+axis goes to **26 levels, 40 m apart** — the layer-to-spacing ratio the manifest publishes
+rises from 0.15 to 0.75.
+
+**The doming is local, and the drawing says so rather than being made to look otherwise.**
+The two features that displace the layer are tens of kilometres across in a domain hundreds
+wide, so most of the field is level: over the now-cast the surface takes three distinct
+depths, 1,831 of 1,920 columns at 100 m with 48 at 140 m and 41 at 60 m. That is what an
+eddy does to a thermocline, and it is not the domain-wide undulation the first version
+produced by letting the front step the whole field. Over a served **analysis** it is looser,
+which is worth stating because the drawing draws an analysis and not the truth — thirteen
+depths, 82% at 100 m, two holding ninety-four per cent inside 40 m.
+
+`Volume.tsx` prints the count and, on the shipped configuration, the level it is level to
+within — its other branch, which names the modal share, is for a field whose commonest depths
+span more than one level and nothing ships in that state. It was first written to print max
+minus min and call the result doming, which against one analysis read "16 distinct depths
+spanning 840 m" — true of two columns and false of the layer, and the same fault as form 1's
+arriving from the other direction.
+
+**And it attributes nothing.** The count it prints is four with the displacement and four
+without, because the analysis it reads carries assimilation scatter that moves a weakly-held
+column's steepest pair on its own; only over the truth field does the count separate the two
+forms (three against one). So the caption says where each column's gradient sits and says it
+cannot tell a displaced layer from an unsure column — and the proof of the shape is a unit
+test over the manifest, not a picture.
+
+Depth multiplies against the horizontal, and the horizontal was generous: 96 × 80 over the
+domain is 5.3 km cells against an eddy of 64 km radius, twelve cells across its radius. The
+now-cast grid becomes **48 × 40 × 26**. The eddy keeps six cells across its radius and the
+front, the tightest horizontal feature, keeps 2.8 cells across its sharpness — about eleven
+across its full transition. The committed artefacts go from 27.7 MB to 30.4 MB and the
+rebuild from 17 s to 20 s, which is the whole cost of the change.
+
+The alternative costs were measured rather than assumed, because the obvious one was
+ruinous: keeping 96 × 80 and adding depth alone takes the artefacts to **115.7 MB**, with
+`returning` alone at 49 MB, and the snapshot codec already gzips with a byte-plane shuffle,
+so that is a floor rather than a starting point.
+
+## Consequences
+
+Every stored field changes, every manifest changes, and the four seed artefacts were
+rebuilt by `pnpm snapshots` under `check-snapshot-drift`. `ANALYTIC_FORM_VERSION` is 2, and
+a reader that understands only form 1 must refuse the new manifests rather than reconstruct
+a flat layer from them — which is what the version exists for.
+
+**Nothing was enforcing that**, which the bump exposed: `lib/manifest-world.ts` cast a manifest
+to a world without looking at the number, so a form 1 document would have produced
+`depth_m + undefined × anomaly` — `NaN` at every point in the domain, with no reason given
+anywhere. It now refuses, in both directions, and `manifest-world.test.ts` watches the refusal
+fail as well as pass. The protection the master describes is a mechanism rather than a sentence
+for the first time.
+
+Two manifests carried `analytic_form_version: 1` as a typed literal while spreading the
+env-generator's manifest wholesale: the analyst's and the model runner's. The bump made
+them wrong — form 2 thermocline parameters under a form 1 label, telling a reader it could
+reconstruct a doming layer with the flat rule. Both now take the number from the manifest
+they spread, so the next bump cannot leave them behind.
+
+FR-120 is **not amended**. That was the second option issue #113 offered and it was the
+wrong one: the requirement described the ocean correctly and the harness did not contain
+it. `CLAUDE.md` warns that a specification disagreeing with the code is right about four
+times in five, and this was one of the four.
+
+What the drawing says about itself is unchanged in kind. `Volume.tsx` still measures the
+distinct-depth count in the field it was served and still states it, and still says the
+field is level when it finds it so — with the spacing read off the levels rather than
+typed, because that sentence said "200 m" while the axis was being changed to 40 m.
+
+## What this does not deliver, and why it is here rather than in a follow-up
+
+FR-120's rationale is that the thermocline "domes, tilts and breaks". Form 2 delivers the
+doming and the breaking, both local. It does **not** deliver a domain-wide tilt, and the
+attempt to add one is the reason this section exists rather than an issue saying "next
+time".
+
+Two shapes were tried and both failed for the same reason. The front's `tanh` step is
+recorded above. A smooth basin-scale ramp — `depth_m + tilt × (latitude − mid)`, which is
+what actually sets thermocline depth at basin scale and has no localised extremum for a
+blob estimator to catch on — gives exactly the broad shape wanted: at 20 m per degree the
+surface takes four distinct depths, the commonest three spanning 80 m, and the modal share
+falls from 95% to 41%. It also puts seed 1180003's front anchor 74 km across against its
+30 km bound, at every tilt tried down to 10 m per degree.
+
+**The tension is structural, not a bug in either.** `model-runner/features.ts` locates all
+three features by horizontal temperature structure, and a thermocline whose depth varies
+horizontally *is* horizontal temperature structure at the depths near the layer. Broad
+layer shape and estimator bounds derived from the authoring jitter are competing for the
+same signal. Resolving it needs one of: an estimator that reads the depth structure rather
+than the depth-averaged anomaly, bounds that account for a sloping layer, or a decision
+that the demo prefers the shape to the scores. All three are larger than this change and
+none is a panel's or a data commit's to take.
+
+So the surface domes where a feature displaces it and is level elsewhere, the drawing
+counts and states both, and the requirement's third verb is unmet with the measurement on
+the record.
diff --git a/scripts/capture/forecast.ts b/scripts/capture/forecast.ts
index 826a64d1..f7e08255 100644
--- a/scripts/capture/forecast.ts
+++ b/scripts/capture/forecast.ts
@@ -516,6 +516,84 @@ try {
mkdirSync(dirname(outPath), { recursive: true });
await region.screenshot({ path: outPath });
+ /*
+ * **And a second shot, of the volume.** It is closed at rest — `lazy.tsx` withholds a third of
+ * the bundle until something asks for it — so the region shot above never contains it, and the
+ * one drawing in this tab that is a three-dimensional surface had no picture proving it renders
+ * at all. Opening it here rather than in a command of its own is the point: this proof has
+ * already paid for the expensive part, which is warming the loop until an analysis exists and
+ * opening a column on it.
+ *
+ * The caption is read back and the shot refused if the surface placed nothing, for the reason
+ * `capture:forecast` exists at all — a picture of an empty box is worse than no picture, and
+ * this region in particular is capable of drawing a convincing volume with no surface in it.
+ */
+ const volumeShot = outPath.replace(/\.png$/, "-volume.png");
+ await page.getByRole("button", { name: "show the volume" }).click();
+ const volume = page.getByTestId("forecast-volume");
+ await volume.waitFor({ state: "visible", timeout: 30_000 });
+ const caption = page.getByTestId("forecast-volume-caption");
+ await caption.waitFor({ state: "visible", timeout: 60_000 });
+ const captionText = (await caption.textContent()) ?? "";
+ const columns = /(\d+) of (\d+) columns have one, over (\d+) distinct/.exec(captionText);
+ if (!columns) throw new Error(`the volume drew no surface count: ${captionText.slice(0, 200)}`);
+ const [, placed, total, distinct] = columns;
+ if (Number(placed) === 0)
+ throw new Error(`the volume placed the thermocline in none of ${total} columns`);
+ /*
+ * **The shape claim, asserted rather than printed.**
+ *
+ * The first version of this block computed these three figures and put them in a `console.log`,
+ * which is not a check: reverting `displacement_m_per_c` to nought — analytic form 1's flat
+ * field, the exact regression issue #113 was raised for — left `placed` at 1920, matched
+ * nothing here, and shot the picture and exited 0. A proof added in answer to a fault that
+ * passes on that fault is the thing `CLAUDE.md` puts second in the file.
+ *
+ * **And what it does not check, because a proof that cannot fail is worse than no proof.**
+ *
+ * The obvious assertion here is "the layer takes more than one depth", which is exactly what
+ * analytic form 1 could not do (#113). It was written, and it is worthless, and the measurement
+ * says why. `thermoclineSurface` over the *truth* field distinguishes the two forms cleanly —
+ * three distinct depths with the displacement, one without. Over the *served analysis*, which is
+ * what this region draws, it does not: four distinct depths with the displacement and four
+ * without, because assimilation puts enough scatter into a weakly-constrained column to move its
+ * steepest pair on its own. Setting `displacement_m_per_c` to nought and running this command
+ * passed it.
+ *
+ * So the shape check lives in `generator.test.ts`, over the field the manifest describes and
+ * against a bound derived from the manifest's own displacement — where it goes red on a zero
+ * displacement and on a too-coarse axis, both watched. What is asserted *here* is what a picture
+ * can honestly be asked for: that the surface was placed, and that the caption states a measured
+ * figure beside its count rather than a bare number.
+ */
+ /*
+ * **A disjunction over the caption's own two arms cannot fail, which is what this was.**
+ *
+ * It read "either branch names a measured figure" — and both arms of `Volume.tsx`'s conditional
+ * always emit one, so it was true of every state the component can produce. That is the second
+ * uncheckable assertion this block has carried, the first being the shape claim above it, and
+ * it was written while removing that one.
+ *
+ * What a picture can honestly be asked is that the number the caption prints is the number the
+ * field carries. The share map beside it draws one rect per cell of the same served grid, so
+ * the two counts have to agree; a caption that stopped counting, counted an axis, or reported a
+ * subset would not match. That is data-dependent, and it is what is asserted below.
+ */
+ const columnsInGrid = await page.evaluate(() => {
+ const svg = document.querySelector("svg.forecast-share-map");
+ return svg ? svg.querySelectorAll("rect.share-cell").length : 0;
+ });
+ if (columnsInGrid > 0 && Number(total) !== columnsInGrid)
+ throw new Error(
+ `the caption counted ${total} columns and the share map drew ${columnsInGrid} of the same served field`,
+ );
+ await volume.screenshot({ path: volumeShot });
+ console.log(volumeShot);
+ console.log(
+ `forecast: the volume placed the thermocline in ${placed} of ${total} columns over ${distinct} distinct depth(s); ` +
+ captionText.slice(captionText.indexOf("distinct depth"), captionText.indexOf("distinct depth") + 120).trim(),
+ );
+
const simTime = await page.getByTestId("sim-time").textContent();
const runId = await page.locator(".shell-run").textContent();
const provenance = {
@@ -548,6 +626,33 @@ try {
`${JSON.stringify(provenance, null, 2)}\n`,
);
+ /*
+ * **And a sidecar for the volume shot too.** The first version of the volume capture wrote the
+ * picture and nothing beside it, which made it the only asset under `site/docs/blog/assets/`
+ * with no provenance — a picture of a running system whose run, condition, sim time and clock
+ * rate were unrecorded, which is a claim rather than evidence. It shares this run's facts and
+ * carries its own: the surface count the caption stated at the shutter, and the caption itself.
+ */
+ await writeFile(
+ volumeShot.replace(/\.png$/, ".provenance.json"),
+ `${JSON.stringify(
+ {
+ ...provenance,
+ image: basename(volumeShot),
+ image_size: pngSize(await readFile(volumeShot)),
+ // The volume's own box, not the centre region's: a sidecar describes the artefact beside
+ // it, and inheriting the region's would have been a measurement of a different picture.
+ element_box: await volume.boundingBox().then((b) =>
+ b ? { width: Math.round(b.width), height: Math.round(b.height) } : undefined,
+ ),
+ surface: { columns: Number(total), placed: Number(placed), distinct_depths: Number(distinct) },
+ volume_caption: captionText.replace(/\s+/g, " ").trim(),
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+
/**
* **The same region at a phone's width, warmed and with the column still open.**
*
diff --git a/site/docs/blog/assets/124-the-ocean-was-flat.png b/site/docs/blog/assets/124-the-ocean-was-flat.png
new file mode 100644
index 00000000..c74045df
Binary files /dev/null and b/site/docs/blog/assets/124-the-ocean-was-flat.png differ
diff --git a/site/docs/blog/assets/124-the-ocean-was-flat.provenance.json b/site/docs/blog/assets/124-the-ocean-was-flat.provenance.json
new file mode 100644
index 00000000..62f6ebb2
--- /dev/null
+++ b/site/docs/blog/assets/124-the-ocean-was-flat.provenance.json
@@ -0,0 +1,35 @@
+{
+ "image": "124-the-ocean-was-flat.png",
+ "run_id": "loiter-arriving-pahu",
+ "view": "forecast",
+ "start_condition": "arriving",
+ "sim_time": "2026-01-01T03:27:00Z",
+ "clock_pinned": "rate 0",
+ "holding": "analysis.loiter-arriving-pahu-run-t12420-contributions",
+ "rays_drawn": 4,
+ "column": "read off the served contributions header, so it is a column an instrument reached",
+ "warmed": "stepped in bursts of 60 ticks at rate 0 until 3 analysis cycles had published beyond the seed's own (108 bursts), then 0 further burst(s) until the field was drawn",
+ "viewport": {
+ "width": 2560,
+ "height": 3022
+ },
+ "image_size": {
+ "width": 816,
+ "height": 423
+ },
+ "element_box": {
+ "width": 815,
+ "height": 422
+ },
+ "browser": {
+ "name": "chromium",
+ "version": "141.0.7390.37"
+ },
+ "caption": "The Forecast tab's volume, twenty-six levels of sound speed with the thermocline drawn as a surface through them, opened on the analysis the rays beside it came from.",
+ "surface": {
+ "columns": 1920,
+ "placed": 1920,
+ "distinct_depths": 13
+ },
+ "volume_caption": "26 level(s) of sound_speed, each a genuine area query against analysis.loiter-arriving-pahu-run-t12420, drawn semi-transparent with depth exaggerated. The pale surface through them is the thermocline, placed per column at the midpoint of the level pair whose temperature falls fastest \u2014 the run\u2019s own definition, applied to each column rather than to the domain mean. Its brightness is the drop across that interval. 1920 of 1920 columns have one, over 13 distinct depths \u2014 so it is level to within one 40 m level across most of the field. What this shows is where each column's strongest gradient sits, which is a sonar question whatever its shape; where a column differs from its neighbours this cannot say whether a feature moved the layer there or the analysis is simply less sure of that column."
+}
diff --git a/site/docs/blog/posts/the-drawing-was-honest-and-the-ocean-was-flat.md b/site/docs/blog/posts/the-drawing-was-honest-and-the-ocean-was-flat.md
new file mode 100644
index 00000000..ff80ad18
--- /dev/null
+++ b/site/docs/blog/posts/the-drawing-was-honest-and-the-ocean-was-flat.md
@@ -0,0 +1,51 @@
+---
+title: The drawing was honest and the ocean was flat
+date: 2026-09-04
+feature: specs/124-forecast-illustration
+description: >-
+ A new drawing of the thermocline came out as a plane, and its caption said so instead
+ of calling it a surface. That sentence found a bug — not in the drawing, and not in
+ the grid everyone blamed first, but in the synthetic ocean itself.
+---
+
+# The drawing was honest and the ocean was flat
+
+## The background
+
+The thermocline is the depth where the sea's temperature falls away fastest, and sound
+bends at it. Where it sits is not one number: it domes over cold water and is pressed down
+under warm, and that shape is what a sonar operator asks about. We drew it as a surface,
+one depth per column. It came out flat.
+
+## The requirement
+
+The drawing had to show shape and, more importantly, say what it found rather than what it
+was meant to. It printed the count — 7,679 of 7,680 columns at one depth — and blamed the
+grid rather than the ocean.
+
+## The options considered
+
+That sentence was half right, and being specific exposed the other half. The obvious fix
+was a finer depth axis — six levels over a kilometre cannot see a 30-metre layer. But
+sampling from 200 metres down to 10 gave one depth every time. The thermocline function
+took no position: flat by construction, with no shape to resolve.
+
+So the ocean gained one, and it taught us something. Letting the ocean front move the layer
+tilted the whole domain, and the harness still could not find a drifting eddy it had been
+missing — the feature-finders read the structure we had just added. Move the layer with the
+local features only and the eddy is found. A basin-wide tilt fails the same way: shape and
+feature-finding compete for one signal.
+
+## The demo
+
+
+
+[Open the forecast tab](../../instances/main/#/view/forecast) — run the clock forward,
+pick a column, press "show the volume". The caption counts what it found and names what it
+cannot tell: the analysis it reads is noisy enough that the count would look the same on a
+flat ocean. Saying so beats claiming the win — the habit that started this.
+
diff --git a/specs/118-downstream-consumers/spec.md b/specs/118-downstream-consumers/spec.md
index 02df554f..683db715 100644
--- a/specs/118-downstream-consumers/spec.md
+++ b/specs/118-downstream-consumers/spec.md
@@ -99,7 +99,7 @@ generator that runs on its own.
The source's §2.6 states the grid as 96 × 96 across 8 depth zones and the platform as
reaching the top three. The tree says otherwise: `app/config/run.json` declares a now-cast
-grid of 96 × 80 × 6 over a domain 0–1000 m deep, and the platform's declared limit is
+grid of 48 × 40 × 26 over a domain 0–1000 m deep, and the platform's declared limit is
400 m. The shape of the requirement — *the vessel reaches the top of the water column and
the rest is forecast but unreachable* — survives exactly; the figures do not, and this
specification takes the tree (CLAUDE.md, lesson 1).
diff --git a/specs/120-start-conditions/spec.md b/specs/120-start-conditions/spec.md
index 8dd785be..14b60dbb 100644
--- a/specs/120-start-conditions/spec.md
+++ b/specs/120-start-conditions/spec.md
@@ -208,7 +208,7 @@ remainder by refusing a second set of bytes under a holding id it already holds.
Measured, because both obvious answers were wrong. Switching components off one at a time
put 300 of a tick's 568 microseconds in the **environment generator** rather than in the
-assimilation: it re-evaluates a 96×80×6 grid over four time steps every 900 ticks and the
+assimilation: it re-evaluates a 48×40×26 grid over four time steps every 900 ticks and the
coverage store keeps only the newest, so `returning` computed ten now-casts and discarded
eight. Nothing reads the intermediate ones — the monitor scores against the forecast, the
analyst falls back to a now-cast only on a cold start.
diff --git a/specs/124-forecast-illustration/tasks.md b/specs/124-forecast-illustration/tasks.md
index c3ab8baf..4f1a8041 100644
--- a/specs/124-forecast-illustration/tasks.md
+++ b/specs/124-forecast-illustration/tasks.md
@@ -150,25 +150,102 @@ published today.
carried share is not published alone, and the identity is the kernel test's.
## The centre region — the volume and its grid
-- [ ] T008 `panels/forecast/Volume.tsx`, code-split as the map is (`registry.tsx`'s
+- [x] T008 `panels/forecast/Volume.tsx`, code-split as the map is (`registry.tsx`'s
`DEFERRED_VIEWS` gains nothing; the split is inside the panel, on the Data tab's
- precedent).
-- [ ] T009 The semi-transparent field with the thermocline as a surface through it, its
+ precedent). *Built.* `lazy.tsx` withholds it exactly as `data/lazy.tsx` does and for the
+ measured reason that file records: deck.gl is about a third of the bundle and this is the
+ tab's only surface that needs it. Closed at rest, so opening Forecast costs what it always
+ cost. The frame, the cartesian mapping and the click's inverse are `map/cube.ts`, imported
+ rather than copied — the Data tab's volume imports the same module.
+- [x] T009 The semi-transparent field with the thermocline as a surface through it, its
strength carried by the surface's appearance. Not a depth slice: the reason is at
FR-120 and is that the thermocline domes, tilts and breaks.
+
+ *Built, and the surface is nearly flat on the shipped configuration — measured, stated on
+ the drawing, and raised as its own question rather than hoped past.* The harness publishes a
+ thermocline as a **domain mean**, which is one number and therefore a plane, so the surface
+ is derived per column from the served field: one area query per level, the run's own
+ definition (`model-runner/features.ts` — the steepest level pair, at its midpoint) applied
+ to each column instead of to the mean. `volume.ts` is that arithmetic, pure and tested, and
+ bound to the backend by a test that **calls `estimateFeatures`** rather than transcribing it.
+
+ *As first built the surface came out flat, and saying so is what fixed it.* Driven against
+ `arriving` on the then-shipped six-level axis: **7,679 of 7,680 columns placed it at 100 m
+ and one at 300 m.** The caption stated that count rather than calling the result a doming
+ surface, and the measurement was raised as DeepBlueCLtd/drogna#113 rather than settled
+ inside this task, because the depth axis is a data change touching every holding and every
+ feature that scores against one.
+
+ *And the answer was not the one the issue expected, which is why the record of it is here
+ rather than only there.* Raising the resolution alone changes nothing: sampled at 200, 100,
+ 50, 40, 25 and 10 m spacing the surface has **one distinct depth at every one of them**,
+ because analytic form 1's `thermoclineAnomalyT` took no longitude and no latitude. The layer
+ was one depth everywhere and there was nothing there to resolve — so FR-120's rationale
+ described something the harness did not contain, and no drawing at any spacing could have
+ shown it. Form 2 displaces the layer where a feature warms or cools the water at it
+ (ADR-0044), the axis went to 26 levels to carry the result, and FR-120 was **not** amended.
+
+ The drawing is unchanged in kind: it still measures the distinct-depth count in the field it
+ was served, still prints it, and still says the flatness is the grid's if it finds one. What
+ it prints now is the modal share as well, because the doming is local — an eddy and a
+ drifting feature in a domain hundreds of kilometres wide — and "it domes and tilts" written
+ across a field that is 82% one depth would be the same false illumination arriving from the
+ other side.
- [ ] T010 The eddy, front and drifting feature carried **with depth** in the volume, against
the field they are in, read from the forecast-features holding feature 123 publishes.
*Reconciled:* the plan view of the same features is built (`FeatureTracks.tsx`, 123's
T080) and stays in the right region; this task adds a dimension to a drawing that
exists and does not introduce it.
-- [ ] T011 The clickable grid on the surface plane; selection by grid square, yielding a
+
+ *The arithmetic landed ahead of the drawing, and that is a cost worth naming.*
+ `volume.ts` carries `featureReach` and `FEATURE_SIGMAS` — how far a published feature is
+ distinguishable from the field it is in, level by level — tested, but with no caller: the
+ drawing this task asks for is what will use it. It shipped early because it is what the
+ measurement in that file's note *is*, and deleting it to re-derive it would throw away the
+ calibration. Its σ figures were re-measured when #113 moved the depth axis, which is the
+ maintenance an unused module costs and the reason not to make a habit of this.
+- [x] T011 The clickable grid on the surface plane; selection by grid square, yielding a
water column. Keyboard traversal reaches every square. *Reconciled:* the share map in
`ColumnProvenance.tsx` already selects a column by square with arrow keys and enter;
- the volume's grid is the same selection and must not be a second one.
-- [ ] T012 The parameter control, defaulting to sound speed, and the depth control.
+ the volume's grid is the same selection and must not be a second one. *Built as that
+ reconciliation asks:* the volume is rendered inside `ColumnProvenance`, where `column` and
+ `readColumn` already live. A click is resolved against a **pickable grid of squares on the
+ surface plane**, which is what FR-06 asks for in as many words — "nothing inside the volume
+ is clickable" — so deck.gl returns the square or nothing and `cellBounds` reads the column's
+ position off the served axes.
+
+ *The first version did read the canvas coordinate, and that is the fault the requirement
+ names.* It took `info.coordinate`, deck.gl's ground-plane unprojection, which is where the
+ ray through the pixel crosses depth nought and not where the cell the reader pointed at is
+ drawn: a click on a deep cell opened a column about 180 km away, and a click on the empty
+ canvas beside the box opened one outside the domain, clearing the reader's rays and figures
+ for a position the analysis never covered. `cube.ts`'s inverse is tested as a round trip
+ against `toCartesian` and was not the problem — the problem was asking it a question about a
+ plane the reader was not clicking on. The keyboard route to a square is the share map's, unchanged, which
+ is what "must not be a second one" means in code.
+- [x] T012 The parameter control, defaulting to sound speed, and the depth control.
*Reconciled:* a depth control exists on the share map (123's T084) and walks the same
column; the volume shares it rather than gaining a second. The parameter control is new.
+ *Blocked, then unblocked by the first of the three routes it named.* The control shipped
+ first offering only what an analysis holding carries — temperature and salinity — because
+ sound speed is derived from the pair and ADR-0005 makes that derivation "the single
+ implementation in drogna", so a second one in this panel was ruled out by that decision
+ rather than by preference. The three routes recorded here were: move `soundSpeedMs` into
+ `app/src/seam/`; have the query layer serve a derived `sound_speed`; or amend the task.
+
+ *Built as the first.* `app/src/seam/ocean-relations.ts` now holds the pressure and sound
+ speed relations and `env-generator/analytic.ts` re-exports them, so every backend caller is
+ unchanged and the panel derives sound speed per cell from the temperature and salinity it
+ already queries. The ripple the note predicted was real and is the record of it: the
+ implementation path is a string in every manifest, so all four snapshots regenerated — read
+ rather than trusted, and *for that commit alone* the only differences were that path, its
+ length prefix and the code revision, with none of the 2,382 field digests moved. (Analytic
+ form 2 landed two commits later and moved every field; ADR-0044 has that. The figure here
+ is about the seam move, which is what makes it worth having: a move that touches every
+ manifest and no field.) `sound_speed` is the control's
+ first entry and its default.
+
## The rays
*Built before the volume, and the reason is recorded rather than left as a reordering.* The
@@ -1184,12 +1261,20 @@ the part worth recording, because each was a sentence asserting a property the c
taken rather than the one it described.
- [x] T036 **Zoom and pan on the share field**, asked for after the screenshots showed the rays
- as a smudge. The field is 96×80 against a drawn ceiling of 48, so the step was two in both
+ as a smudge. The field was 96×80 against a drawn ceiling of 48, so the step was two in both
directions and **half the analyst's field could not be looked at at any size**: magnifying
a thinned field shows bigger squares and not one more cell. So the view windows first and
thins second — the cells drawn are the ones inside the rectangle, at the resolution they
- were filed at. Measured on a live drive: whole field 48×40 of 96×80; at 1.7× it is 28×46,
- the step falling to one and the field finally drawn as published; at 12.8×, 8×7.
+ were filed at. Measured on a live drive at the time: whole field 48×40 of 96×80; at 1.7× it
+ is 28×46, the step falling to one and the field finally drawn as published; at 12.8×, 8×7.
+
+ **Past tense, because #113 spent the horizontal on depth in this same feature.** The field
+ is 48×40 now, so it fits the 48-cell ceiling whole, the step is one everywhere and coming
+ closer gives bigger squares rather than more of them. The ordering above is unchanged and
+ is what makes that a fact about the configuration rather than about the panel — it starts
+ working again the moment the field outgrows the panel — but the apparatus has no reachable
+ effect on the shipped grid, and `ColumnProvenance`'s `MAP` note says so at the point of
+ use. The measurements in this line are what a live drive showed when the line was written.
**Reused rather than rewritten.** `useMapView` already existed for the consumers' maps —
wheel about the pointer, drag, keys, reset, clamped to the domain — and it is promoted from
@@ -1390,6 +1475,13 @@ the part worth recording, because each was a sentence asserting a property the c
*Built:* `site/docs/blog/posts/the-weights-were-there-and-thrown-away.md`, with the
capture and its provenance beside it.
+ *And a second entry, which the rule asks for and this line did not anticipate.*
+ `the-drawing-was-honest-and-the-ocean-was-flat.md` carries the volume: a new face in the
+ shell, which `CLAUDE.md` counts one entry per rather than one per feature. It is also the
+ entry for what the volume's honesty found — analytic form 2 and issue #113 — which is the
+ part a reader outside the project would recognise. Recorded here so a reader checking this
+ task against the tree finds two entries where the line named one, and knows why.
+
**Captured still rather than moving — and the reason first written here was wrong about a
file in this repository.** It said `capture:motion` "drives the clock and films what the
system does on its own". It does not: it pins the clock exactly as `capture:glance` does,