j-ocean

Not an operational forecast system.

Data model

Every type here crosses a boundary between rings. Types internal to one module are not listed: they are that module's business and may change without anybody being told.

Configuration

Loaded by one module, validated against a schema before any computation, and digested into every run manifest. Every number the requirements call declared lives here, and no component in the tree holds a literal for one.

Field Meaning
schemaVersion The shape of this file. A version this code does not know is a startup failure.
run.defaultSeed Sixteen hex characters. The recorded case: the run a reader gets who does not ask for another.
run.recordedCaseLabel What the surface calls that run.
clock.epoch The instant step zero is valid for.
clock.timestepSeconds The stated timestep.
clock.stabilityCriterionCfl The stated stability criterion the kernel must satisfy.
grid.nx, grid.ny The grid, configurable and starting at 100 x 100. There is no declared cell size: a five-degree box is not square in kilometres at Gulf Stream latitudes, so dx and dy are computed from the box and the grid and reported as computed figures.
horizons.leadHours Strictly increasing. Every one is rendered; no panel is drawn for anything else.
budget.frameBudgetMs The frame budget, a number rather than a judgement made at review time.
presentation.referenceViewportWidthPx The window width at which all six visible at once is a promise. The schema refuses a value too narrow for the declared horizons; a browser test measures the rendered geometry at it.
presentation.minimumPanelWidthPx Below this a panel stops being legible, so the row stops shrinking panels and its own container scrolls. The page never does.
presentation.panelGapPx, presentation.pageGutterPx The rest of the row's arithmetic, so that no width is a literal in the stylesheet.
presentation.anomalyLimitMetres The half-range the panels draw interface-depth anomalies against.
presentation.attributionHatchThreshold The weight above which a cell is hatched rather than merely tinted — the second channel that makes the layer readable without colour.
presentation.footprint.colocationToleranceDegrees Two profiles this close are the same place as far as the drawing is concerned, and are offset so both are visible.
presentation.footprint.needleOffsetPx, elevationHeightPx The offset applied to co-located needles, and the height of the enlarged panel's depth elevation.
forecast.validityWindowHours How far past its issue instant a forecast claims to be valid. A panel beyond it says so and draws nothing.
forecast.quaysideOffsetHours The departure brief's instant: the analysis there, held constant and never refreshed.
forecast.issueTimeControl The range and step of the one issue-time control. The schema refuses a default issue time it cannot reach.
presentation.footprint.levelTickLimit Above this many levels a needle draws its ticks only while hovered. An Argo profile carries five hundred; drawn always they are a solid bar.
domains.list[] Extents in degrees, and a declared character of eventful or bland.

The configuration digest is a SHA-256 over a canonical serialisation: object keys in code-unit order, no insignificant whitespace, arrays in their own order. Reformatting the file does not change it; changing any value always does. It is computed in pure TypeScript so that the browser and Node agree, which they must, because a manifest exported from one is imported by the other.

RunManifest

Everything needed to rebuild a run, and none of its state.

Field Meaning
formatVersion The manifest shape. A version this code cannot read is refused, naming both.
generatorVersion The RNG family and parameters. A mismatch is refused: the same seed would not mean the same numbers.
kernelId The kernel that ran. A mismatch is refused: the same seed would not mean the same fields.
rootSeed Sixteen hex characters.
derivedSeeds Every named stream the run asked for, with the seed derived for it.
clock The epoch and timestep the run used.
configDigest The digest above. A mismatch is refused, naming both digests.
steps How far the run had got.
codeVersion The build's own commit, injected at build time. "Byte-identical" is a promise about the same code, so the manifest says which code. A difference is a warning beside the run, not a refusal.
domainId The domain the run was made in. A manifest naming one this build lacks is refused, naming both.
issueInstantMs The instant the shore forecast was issued. A reader's choice rather than a property of the integration, so a manifest that did not record it could not rebuild the fields it describes.
recordedCase False once a reader has asked for a new run.
counterfactual The reader's edits, in order. Empty for the recorded case; the slot was present from beat 001, so a manifest exported before the counterfactuals existed and one after have the same shape.

Replay is re-computation, not the restoration of a snapshot. That is why the manifest holds no state: a snapshot compared with itself would prove nothing, whereas a rebuilt run compared byte for byte proves the property the whole system rests on.

Every refusal happens before anything is constructed, so a refused load leaves no run behind. In order: the schema (strict, so a manifest carrying a field, a score or an observation is rejected for carrying a key that does not belong), the format version (before the shape, so a manifest from another version is told what it is), the configuration digest, and the domain. The code version is the one difference that warns rather than refuses.

The committed schemas/run-manifest.schema.json is generated from the same definition by pnpm manifest-schema, and a test regenerates and compares it, so the portable copy cannot drift from the code.

Ports

RandomStream

name, seed, and nextU32 / nextFloat / nextGaussian. Constructed, never looked up: stream(name) twice returns two streams that both start at the beginning.

SimulationClock and ClockControl

SimulationClock is what every consumer sees — epochMs, timestepSeconds, step, instantMs(), instantIso() — and it has no method that moves it. ClockControl, which carries advance, is held by the run and handed to nobody. That "no consumer can advance the clock" is a fact about the types, not a convention to be remembered.

ModelState and StepContext

ModelState is a GridSpec and a record of named Float64Array fields: typed arrays, never arrays of objects. StepContext is the step index, the instant, the timestep and one stream — everything a kernel is permitted to know about, and nothing else.

TruthSource

sample(query) for a variable at a position, depth and instant; coverage() for the box and window it covers; and nativeResolutionDegrees, recorded from the artefact rather than inferred, because scoring refuses to make claims below it and a refusal cannot rest on a number the code guessed.

The committed field container

One file per field artefact — a domain's truth record, a domain's climatology. Deliberately small enough to read with DataView, TextDecoder and a typed array, because an artefact format that needs a parser is one a reader cannot check by hand.

bytes 0..7      magic, ASCII "JOCEAN01"
bytes 8..11     uint32 little-endian: the header length in bytes
bytes 12..      the header, UTF-8 JSON, exactly that many bytes
                zero padding to the next multiple of 4
then            the payload: each variable at the byte offset the header declares

The header carries:

Field Meaning
kind truth or climatology
domain Which declared domain this is
nativeResolutionDegrees The source's own resolution, recorded from the artefact and never inferred
coordinates timeMs (absent for a climatology), depthMetres, latDegrees, lonDegrees
variables[] Per variable: dims, shape, dtype, scaleFactor, addOffset, fillValue, byteOffset, byteLength
provenance Source, service, every raw input with its digest and URL, and the window

The payload is int16 with a declared scale and offset, which is exactly how the source product stores these fields. Widening to float32 would double the artefact in order to invent precision the source does not have. Land carries the declared fill value, and the reader turns it into NaN rather than into a plausible number.

What the truth record's provenance says about itself

Two things are recorded because they would otherwise be assumed:

The observation record

Argo profiles, as JSON rather than as the binary container: profiles are ragged, different floats report different numbers of levels, and a ragged binary layout is a parser.

{
  "format": "j-ocean/observations",
  "domain": "gulf-stream-front",
  "provenance": { "source": "...", "flagNote": "...", "levelsInRaw": 9058, "temperatureFlagCountsInRaw": { "1": 5082, "4": 3976 } },
  "profiles": [
    {
      "platform": "1901584", "cycle": 54,
      "instant": "2013-09-02T...Z",       // as reported; a float drifts through its cycle
      "latDegrees": 36.1, "lonDegrees": -72.4,
      "levels": [ { "pressureDbar": 4.9, "pressureFlag": 1, "temperatureDegC": 26.3, "temperatureFlag": 1 } ]
    }
  ]
}

A quality flag is carried through at every level and is never used to drop one. A flagged observation is drawn as flagged, not omitted — a reader who cannot see the flagged ones cannot see what the analysis chose to ignore. The provenance carries a flag histogram computed from the raw NetCDF arrays before conversion, so a test can count the same thing from the committed record and compare: two independent computations of one fact.

The model

ModelState

Three prognostic fields, all Float64Array on the declared grid: thickness at cell centres, velocityU on the eastern face, velocityV on the northern face. That is an Arakawa C-grid, and the arrangement is the point — it puts the pressure gradient and the divergence on adjacent points rather than averaged ones, which is what stops a two-grid-interval checkerboard from being invisible to the scheme.

GridSpec

nx and ny come from configuration; cellSizeXMetres and cellSizeYMetres are computed from the declared domain box and reported as computed. They differ. A five-degree box at 36.5 °N is 4 474 m east–west and 5 529 m north–south, so there is no single declared cell size — declaring one would be declaring something untrue, and the stability limit is set by the smaller of the two.

StabilityAssessment

Field Meaning
gravityWaveSpeedMetresPerSecond sqrt(g'H), 3.162 m/s as declared
linearStabilityBoundarySeconds Where leapfrog on a C-grid becomes unstable: 549.9 s
largestStableTimestepSeconds That boundary times the declared CFL: 275.0 s
declaredTimestepSeconds 240 s, which also divides an hour so the hourly diagnosis is exact
viscousNumber A · 2Δt · (4/dx² + 4/dy²), stable below a half

Initialisation fails with all of these figures if either criterion is missed. It has done so twice.

ModelResults

The published interface. Everything it hands out is a copy, so the integration buffers are not reachable from the harness — a test asserts that writing into a published array leaves the model unchanged. It carries the sea-surface height, the thickness, the two velocity components, the sponge weight and its width in cells (the margin scoring must exclude), the invariants, the outcrop count and the volume those clamps added, and the diagnosed profile at any cell.

DiagnosedProfile

Depth is displayed, never integrated. The profile is a warm upper layer over a cold deep one joined by a tanh transition of declared thickness centred on the layer thickness, evaluated at the declared display levels.

A level's kind does not move. The surface and the deepest declared level are the model's own two layers and are computed; every level between them is derived, always, whatever the interface is doing. A kind that tracked the interface would be a kind a reader could not learn.

The relation inverts, which is what beat 004's observation operator needs — and the inverse reports whether it resolved anything. A tanh saturates: a thermometer at 100 m in an ocean whose interface is at 700 m reads the upper layer's own temperature and constrains the interface hardly at all. An observation that constrains nothing must not be allowed to look as though it did.

Observations

The only currency the analysis accepts, and the only thing src/instruments/ produces.

The type is opaque: it carries a brand keyed by a symbol that src/instruments/observation.ts declares and never exports, so a module that cannot name the key cannot construct one. Gate G-02 fails if that symbol's name appears anywhere else.

Field Meaning
kind surface, profile, or interface-depth
value, lonDeg, latDeg, depthMetres, instantMs What was measured, and where
error Three declared parts, never one opaque figure — see below
flags What each declared check found. A flag is carried; it never drops the observation
external True for Argo: a real measurement the truth record itself assimilated
streamName Which named RNG stream drew this observation's noise
levels For a profile: the depth actually reached, the depth requested, the value, and per-level flags
bound For an unresolved interface: below 150 m, say — a fact where a depth would not be

ObservationError

instrumentNoiseSd is what the device does. representativenessSd is what a point sample of the real ocean cannot know about a 1/12-degree six-hourly record. declaredBias is what a broken instrument adds (and it is added after noise and before the checks, or quality control could never catch it). totalSd is the two random parts in quadrature; a bias is not a variance and is not in it.

The observation operator

A temperature profile in, an interface depth out — the same two-layer relation the model draws a profile from, inverted. Levels are combined by inverse-variance weighting with the depth error propagated from the temperature error:

|dh/dT| = 2L / (|T_deep − T_upper| · (1 − f²))

That expression diverges as a measurement leaves the thermocline, so a level in the body of a layer acquires an enormous depth error and weighs almost nothing. That is the reason the operator was chosen (ADR-0005): the down-weighting happens through the arithmetic rather than through a rule.

Flags

gross-range, climatology-departure (measured in standard deviations of the climatology at that depth — a scale the artefact can answer for), vertical-inversion, argo-flagged, unresolved, and outside-record (the probe reached somewhere the truth record does not cover). Each carries a detail good enough to draw and a usable boolean, so consumers cannot disagree about what a flag means.

The analysis

AnalysisRecord

The analysed field, the attribution, which observations were used and which excluded and why, and enough of the gain to answer a breakdown or an influence query.

Attribution

Three weights per cell — observations, background, climatology — summing to one. It is branded like Observation: the symbol lives in src/analysis/attribution.ts and is exported from nowhere, and gate G-06 fails if its name appears elsewhere, or if anything under src/harness/ contains a function producing an attribution.

The weights are not estimated. Because H selects one cell per observation, H·1 = 1, and

Σ_k (I − K H)_ik + Σ_j K_ij = 1

so the row sum of the gain is the observation weight. A test recomputes it independently and asserts equality; the largest difference is zero.

clampedCells counts the cells whose row sum fell outside [0, 1] and was clamped and renormalised. That happens: optimal interpolation with a Gaussian covariance and clustered observations produces such row sums in the shadows between them. It is published rather than hidden.

CellBreakdown and InfluenceRegion

A row of the gain and a column of it. The breakdown names each contributing observation and its share, summing to the cell's observation weight. The influence region is one observation's weight in every cell, and its radius above a declared threshold.

The radius is a property of the declared correlation length scale, not of the ocean. An observation across a front influences the far side exactly as much as its own, which the flow would not. The surface says so; beat 012's ensemble spread is the flow-dependent answer.

What an observation is worth

Three numbers, and the third is the one that is easy to forget.

The instrument's own noise declared per instrument
Representativeness in the measured quantity declared per instrument
Representativeness in the state variable analysis.interfaceRepresentativenessMetres

The operator propagates a formal interface-depth error under a metre. That is what the instrument could not know, and it is a lower bound on what the observation is worth to a 4.5 km cell, which differs from a point sounding by tens of metres of mesoscale variability. Using the formal error as the whole error tells the analysis to trust a measurement more than it deserves — and it did, until this was declared.

Scores

Figure

A number that says what kind of number it is: { value, kind, unit }. Scoring emits most of the figures the surface draws, which is why the kind travels on the figure rather than being decided where it is rendered — a value that arrived as computed cannot be re-typed on its way to the screen. A test round-trips a whole score through JSON and asserts the kinds survive.

Score

Field Meaning
forecastError, persistenceError, climatologyError Root-mean-square difference of interface-depth anomaly
skillAgainstPersistence, skillAgainstClimatology 1 − forecast/reference; null where the reference is perfect
statement In the SRD's own words. The surface prints it verbatim
meanOffsets What was removed before comparing, published rather than absorbed
provenance Below. A score cannot be constructed without one

Why anomalies. A reduced-gravity model has no absolute reference for its free surface: only departures from a mean carry information, which is why the mean is removed at initialisation. Scoring uses the same convention or it measures an offset neither field claims to determine — and it did, to the tune of a hundred metres out of the hundred and forty first reported.

ScoreProvenance

Reference, region label, margin in cells, cells actually scored, the window's two instants, the metric, the resolution floor from the truth artefact, the truth source's id, and the independence caveat naming any external observation the analysis assimilated in the window.

A score without provenance is an assertion, so the type makes one impossible.

What the scorer refuses

The row

The primary surface, and the only place the six horizons appear together. ADR-0003 records why it is a row and not a slider or a grid.

Field Meaning
Panel.leadHours The declared horizon. One panel per horizon and no others; gate G-05 checks that in a running browser.
Panel.validInstant, Panel.initialisedFrom Absolute instants in the run's clock, not only a lead time, so that no reader has to do arithmetic to find out whether two panels are comparable.
Panel.field The forecast interface depth as an anomaly about its own regional mean — the same convention the scorer uses, so the picture and the number describe the same thing.
Panel.observationWeight The analysis's own weights, read through weightsAt and computed nowhere else.
Panel.observationsDominant Where observations lead both other sources: the hatch, and the second channel of FR-019.
Panel.score The Score of the section above, or null before the row has been scored. Scoring is on demand because it costs a second and the interface does not freeze.
Panel.enlarged A class on an element. Enlargement changes what is shown and never what is computed; the test asserts it by object identity.

The attribution is one field. The recorded case runs a single analysis, at the issue instant, and the row draws it on every panel. Six identical fields side by side would imply six analyses, so the legend states the instant and says that attribution becomes per horizon once the forecast cycles.

Where a field is painted

One module, field-surface.ts, owns every palette and shader. It reports the surface it got — webgl2 where the browser has it, canvas2d where it does not — on the canvas itself, and both paths are exercised by tests: headless Chromium here has WebGL2, so a test refuses it in an init script to make the fallback actually run.

The counterfactuals

An edit is a value, not a mutation: it lives in the manifest, it is applied in order to a fresh run, and reverting is removing it. That is why byte-identical revert is structural.

Edit What it changes Applied
withhold One observation leaves the analysis and stays in the record, drawn struck through. after sampling
profile A measured profile's values, through the same observation operator, with the measurement kept as a ghost. after sampling
bias An instrument's declared bias, added after noise and before the checks. to the configuration
quality-control Whether the declared checks run at all. to the configuration
track The waypoints. The instruments resample truth where the reader put them, through the same instruments and named streams. to the configuration

A track edit is checked against instruments.track.vesselSpeedKnots, and the declared rule is to stretch the instants rather than refuse the edit — a reader dragging a waypoint is asking what if we had gone there, not whether we could have got there by Tuesday. The stretch is stated.

Two things a reader should know about the checks, both measured in beat 010:

The forecast, on two axes

runForecast produces one entry per declared horizon. The declared horizons are measured from the default issue instant, so moving the issue time leaves every panel valid at the same moment and makes each of them a longer forecast of it.

Field Meaning
HorizonForecast.leadHours The declared horizon. It names the panel and fixes the valid instant.
HorizonForecast.leadFromIssueHours What was actually asked of the model: the valid instant less the issue instant. The two differ the moment issue time moves, and conflating them is the confusion the second axis exists to remove.
HorizonForecast.field Absent where the panel is outside validity or before its issue instant.
HorizonForecast.refusal Present exactly when the field is absent, and says which of the two it is.
ForecastResult.observationsAvailable How many observations the analysis was allowed to see: those with instants at or before the issue instant.
ForecastResult.observationsWithheld How many had not happened yet. Until beat 009 this was zero because the analysis was handed all of them, which is the correction that beat records.
DepartureBrief The analysis at the declared quay-side instant, held constant and never refreshed (FR-026). At the quay side of the recorded case nothing has reported, so it is background blended with climatology — a generous baseline, not a straw man.

The observation footprint

What the instruments did, as marks. Built from the run's observations and from nothing else: the footprint does not sample truth and does not compute, and a test asserts that by reading the module's own imports.

Field Meaning
TrackMark.value The surface measurement itself. Its place in the track's own range drives the mark's size and fill, so the encoding survives having its colour removed.
FootprintMark.afterInitialisation Measured after the instant a forecast was initialised from, so it did not inform it. Drawn distinctly, because a mark that did not inform a forecast must not look like one that did.
FootprintMark.insideMargin Inside the sponge margin. The analysis used it; scoring excludes the region, and the hover says so.
FootprintMark.flagged Failed a declared check, or carries an Argo flag. Drawn as flagged, never omitted, and distinguishable by luminance rather than hue.
Needle.deepestMetres The depth actually reached, which is not the depth asked for: an XBT infers its depth from a fall-rate equation. The needle's extent is this and nothing else.
Needle.continuesBelow The probe went past the floor of the displayed volume. The needle says so rather than stopping as though the profile had.
Needle.measuredNothing The profile reports no value at any level. Five delayed-mode Argo profiles in the recorded case are like this; they are drawn as a cross at the surface, not as a needle of zero length.
Needle.assimilated ADR-0007: Argo is drawn either way, and the hover says which.

The elevation is a side elevation, not a scene: it shares the field's horizontal axis and puts depth downward, so latitude is not shown and position is read from the plan view above. A perspective volume would imply a viewpoint and a set of distances the harness does not have.

Every panel also carries a visually hidden list of its marks, one entry per observation. A canvas says nothing to a reader who cannot see it, and nothing to a test either.

The figure kinds

Not a type but a discipline, and the surface enforces it typographically.

Declared
A value from configuration. Dotted underline, blue.
Computed
Produced by the model or the analysis. Bold.
Derived
Diagnosed from computed state, such as the depth levels between the model's own. Dashed underline, italic.
Host time
How long the machinery took. Never simulation time, and never enters a run or a manifest.

A figure does not change kind between states.