Skip to content

Technical documentationInternal

For the team and anyone taking the product over. Not linked from the site and not indexed.

Technical documentation pages

Bioreactor

Bioreactor (registry slug bioreactor-simulator) simulates a stirred, aerated microbial culture in batch, fed-batch or chemostat mode from a versioned preset, finds when dissolved oxygen runs out and searches a rescue cascade within vessel limits the user enters. This page describes the engine in src/lib/hosts/ and src/lib/fit/ode.ts, the app around it, its verification against an independent SciPy oracle, and its known gaps.

Purpose, users and status

The app answers one question: when does a culture take up more oxygen than the vessel can transfer, and what change of agitation, gas flow, inlet oxygen or feed would prevent it within the user's limits. The design brief (docs/design/brief-bioreactor-simulator.md §1 and §2, design intent) names three audiences: bioprocess students and lecturers, upstream scientists at biotechs and CDMOs, and synthetic-biology teams scaling up from shake flasks. It also serves as a demonstration piece for the consultancy.

Status facts from content/apps/bioreactor-simulator.ts:

Field Value
status preview
version 0.4.0 (updated 2026-10-20); the changelog's 0.3.0 (19 October) and 0.4.0 (20 October) entries were backfilled on 24 October 2026 (Stage H BR1.1, the CPO's ruling 7). Each verification set names the folder it is published in (version): the four model sets 0.2.0, the measured-comparison sets 0.3.0, 0.4.0 and 0.5.0
dataPolicy compute browser, storage browser-only, endpoints: [] (no external host)
flags workers: true, webgl: false, shareAlikeData: false
Objects owns, reads and writes run and vessel; object-page tabs for both
Cross-links H28 (pump sheet to Lab calculators as a feed recipe) is planned: Bioreactor's side is built (Stage H BR5), and its key shows only once the row leaves planned (H28_LIVE in the workspace)

The model is an unstructured, single-substrate textbook model. The registry's limits list opens with the standing line "Teaching and estimation model, not a validated process model" (STANDING.teaching in content/apps/bioreactor-simulator.standing.ts), and the exported method paragraph ends with the same wording. The scientific audits (docs/science/2026-10-scientific-audit.md, docs/science/2026-10-scientific-audit-implementation.md) conclude that it should remain an exploratory teaching tool until its parameter sets have been compared with independent process data and Q-19 (an independent bioprocess reviewer) closes. Nothing in the code or fixtures compares it with measured fermentation data.

Addresses

Address What it is Source
/bioreactor Product page content/apps/bioreactor-simulator.ts (page, entry)
/bioreactor/app The workspace, never indexed src/apps/bioreactor-simulator/definition.ts
/bioreactor/e-coli-fed-batch, /bioreactor/s-cerevisiae-fed-batch, /bioreactor/pichia-aox1, /bioreactor/chemostat Server-rendered preset pages with the prebuilt run src/apps/bioreactor-simulator/tasks.marketing.tsx
/bioreactor/kla-van-t-riet, /bioreactor/otr-our, /bioreactor/scale-up Calculator pages, one client island each src/apps/bioreactor-simulator/islands/
/docs/bioreactor Documentation, including the fixtures table content/apps/bioreactor-simulator.docs.ts, verification-table.marketing.tsx
/verification/bioreactor-simulator/<version>/* Published copies of the oracle fixtures: 0.2.0 the model's (scenarios, closed forms, transfer, scale-up, step), 0.3.0, 0.4.0 and 0.5.0 the measured-run comparison's (0.5.0 the completion set, 25 cases with complete inputs), 0.6.0 kLa scaling from your value or readings (18 settings, NumPy float64) and 0.7.0 the feed laws and pump sheet (12 feeds and 4 design rates, Python decimal) (Stage H BR3.1, BR3.2), 0.12.0 the feed hand-off (14 schedules, 15 programmes, 57 substrate matches on the shipped identity table, Python fractions with RDKit-checked keys; Stage H BR5) (a 0.1.0 directory of model 1 is also kept) public/verification/bioreactor-simulator/
/bioreactor/monod-kinetics-fed-batch-simulator, /bioreactor/kla-oxygen-transfer-rate-calculator, /bioreactor/haldane-substrate-inhibition-chemostat-kinetics, /bioreactor/exponential-fed-batch-glucose-feed-rate-profile Public reference routes that open the workspace with seeded parameters (the kLa route opens with d: 'klasrc_correlation') content/seo/presets/discovery.ts, content/seo/presets/expansion-batch2.ts; Public reference workflows
/o/mine/run/<uuid>, /o/mine/vessel/<uuid> Object pages for a kept run or vessel mineObjectHref in content/apps/routes.ts

The segment bioreactor comes from APP_SEGMENTS in content/apps/routes.ts. The registry lists seven tasks (four presets, three calculators); the custom preset has no page. The workspace state lives in the #s= fragment (see UI structure and state).

File map

App glue: src/apps/bioreactor-simulator/

File Role
definition.ts AppDefinition: Main, objectTabs, an empty rail (the views are tabs), shortcuts 1 to 5, palette commands (go to a view, lecturer mode, open each preset), the share schema and link. Kept free of the model and preset data.
entry.tsx Hands the definition and the registry entry to the shell; displayName drybench-entry:bioreactor-simulator is the size-check marker.
workspace.tsx BioreactorMain: loads views/bioreactor-workspace.tsx with next/dynamic (ssr: false) inside ToolBoundary, with a skeleton.
store.ts Module-level snapshot (publishBioreactor, readBioreactor, useBioreactor) so shell-rendered commands and the share link can read and drive the workspace.
views/bioreactor-workspace.tsx The workspace (about 1,160 lines): state, the five views, keep and vessel actions, arrivals, exports, recorded results, lecturer mode.
views/use-simulation.ts React hook around SimClient: latest-input-wins, slow-run ladder, stop and retry. It keeps an error's stage (errorStage): a run- or compare-stage error leaves the previous run on screen, a rescue-stage one the current run. Re-exports settledRun.
views/settled-run.ts settledRun(sim): the run on screen is the run for these inputs (not pending, refused, stale after a run- or compare-stage error, or stopped short; a rescue-stage error or stop leaves it settled). One predicate for the recorded capture and H28's key (the CTO's H28 ruling 2).
views/lever.tsx Slider with a visible number field; drags update a draft, the address is written on gesture end.
views/setup-view.tsx Every parameter, grouped, with its source pill, range, labels and reset; the vessel panel.
views/run-card.tsx Chart card: the headline tiles on the readout strip, the flags notice line (FlagsNotice, flags-line.ts), the playback key row (the legend's row with two runs), chart, time cursor, data table, stopped states. It holds the cursor (snapped to stored points; Escape clears it; a new run keeps its time or clamps it to the new end, clampedAt), the slots the playback chunk fills (the key slot, held by a blank key until the chunk arrives; the overlay slot inside the figure, aria-hidden, inert, hidden in print) and renders that chunk (islands/motion.tsx) once a run has painted.
views/pump-view.tsx Pump sheet (fed-batch), single rate (chemostat), nothing (batch). The CSO's limits line (LIMITS feed) heads the fed-batch sheet; the total's line holds the H28 key's slot. No Print key: the Export menu's "Print the bench sheet" carries the pump sheet. Withheld behind the lecturer gate while results are hidden. Loaded on demand (drybench-island:bioreactor-pump-view, row bioreactor-pump-view-chunk), retryable on a failed download (retryingControl, ControlLoading).
views/prepare-feed.tsx H28's key "Prepare feed in" the calculators and its one muted line (the preparation line, or the reason it is disabled), its state from prepareFeedState, the recipe from feedRecipe on click with the witness hash; sends through the workspace's sendFeed (keeps the run first, then objects.sendTo with the recipe beside mine:run/…). Mounted only while the cross-link is live (drybench-island:bioreactor-prepare-feed, row bioreactor-prepare-feed-chunk).
views/research-context-editor.tsx Free-text research context and sample rows (never model inputs). Loaded on demand on every ready scenario (drybench-island:bioreactor-research-context, row bioreactor-research-context-chunk); its fallback is the editor's closed frame, so the page does not move, and its value stays in the parent's state.
views/bioreactor-start.tsx The start a bare /bioreactor/app opens: a preset picker, "Run the E. coli example" and "Compare measured runs" (copy content/apps/bioreactor-start.ts); it starts no worker. No preview stage beside it (aside={null}; the CPO's ruling of 24 October 2026). Its chunk's marker is drybench-island:bioreactor-start, budgeted as bioreactor-start-chunk.
views/measured-review.tsx, measured-growth.ts, evidence-export.ts Wave 8 B1: the measured review island, its model and the evidence writer (see Wave 8 B1).
views/measured-comparison.tsx, measured-comparison.ts, measured-comparison-capture.ts, measured-comparison-settings.ts Contract 11A: the island, the engine (strict reader, verification, projection), the capture and exports (loaded on use), and the settings snapshot's readers.
views/alignment-moves.ts, views/alignment-slide.ts The 11A alignment act (BR11.4): the pure act model (each moved panel's shift, the redrawn panels, the words and the announcement), and the slide, the lazy chunk bioreactor-analysis.
views/comparison-session.ts, views/reading-groups.ts Two measured runs' unnamed working session (BR4.3) and the readings table's groups by source (screen 1).
objects.ts RunData and VesselData codecs, runDataFrom/stateFromRun, applyVessel, vesselKlaNumbers, objectInbound (runs, vessels and, since Stage H BR6, constructs by their own mine: ref, private; no recipe rule until Stage D adds it with objects.reads, since a rule exists exactly when the registry reads the type and reading recipe would offer the app in Send to before H27 passes end to end). RunData.recipes (AppliedRecipe, at most one per parameter) and readAppliedRecipes: read strictly, an entry whose change no longer holds its value dropped, omitted by the writer when empty.
views/recipe-notice.tsx H27's lazy island (drybench-island:bioreactor-recipe-notice, row bioreactor-recipe-notice-chunk): verifies an arrival, reviews it with matchSubstrate, portals the notice into its Setup group's slot; RecipeApplied draws an applied row's three lines.
feed-recipe.ts B3 (Stage H BR5), H28: feedRecipe builds the stock recipe with its feed block from the settled run's feed, or the reason its programme is incomplete (see the feed hand-off).
object-tab.tsx, object-tabs.ts Object-page tabs for run (recomputed in the worker) and vessel.
workspace-adapter.ts Workspace task schemas and the recorded-result capture (captureBioreactorWorkspaceResult).
research-context.ts ScenarioResearchContext shape and its strict reader.
exports.ts Export menu items (scenario JSON, CSV, pump CSV, figure, print, method paragraph).
provenance.ts provenanceFor, provenanceFlags, SOURCE_IDS, shortHash (FNV-1a).
report.ts Method block, method paragraph and the A4 bench sheet.
statement.ts The one reader of the shared statements.simulation text.
subject.ts Workspace heading and default task name.
tasks.marketing.tsx, task-islands.tsx, islands/* Preset and calculator pages and their islands (preset-run.tsx, preset-compute.ts, kla.tsx, otr-our.tsx, scale-up.tsx); the kLa page's model and figure (kla-model.ts, kla-vessel-geometry.ts, kla-chart-geometry.ts, kla-geometry.ts, kla-figure.tsx, the chunk kla-live.tsx; see the kLa figure); the scale-up page's model (scale-up-model.ts) and figure (vessel-geometry.ts, scale-up-geometry.ts, scale-up-figure.tsx, and the chunk scale-up-live.tsx; see the scale-up figure).
islands/motion.tsx The visual layer's playback (BR11.1; see Run playback): the lazy chunk bioreactor-motion (row bioreactor-motion-chunk, marker drybench-island:bioreactor-motion).
islands/vessel.tsx, views/vessel-frame.tsx The vessel figure (BR11.2; see The vessel figure): the frame the workspace renders and the lazy chunk bioreactor-vessel (row bioreactor-vessel-chunk, marker drybench-island:bioreactor-vessel).
verification-table.marketing.tsx Fixtures table on the docs page: scenarios.json and preset-runs.json read from disk when the page is built through the server-only readFixture (src/lib/fixtures/read.ts; MNT-26), not imported; the fixtures manifest is still a small JSON import.
page-ids.ts No extra static pages under /bioreactor/app/<id>.

Panels and pure builders: src/components/tools/bioreactor-simulator/

File Role
address.ts bioreactorSchema (URL schema, with cv and COMPARE_VIEWS), SHARE_KEYS, REFUSED_KEYS, horizon bounds 1 to 240 h. The definition chunk reaches it.
link-describe.ts The workspace's reading of the address for the Compare selector: compareViewFromState, shareStateOf (cv only as beside with view=compare) and describeLink, the share dialog's plain-words rows. Only the workspace imports it and publishes describeLink on its store; the definition's share.describe is a getter that reads it there, so neither the code nor its words (content/apps/bioreactor-simulator.compare-selector.ts) ride in the definition chunk (the coordinator's ruling of 24 October 2026).
source-details.tsx B1's and 11A's quiet disclosure style and "Source details", which renders its hashes in a print-only sibling while closed.
sim-protocol.ts Worker message types, readSimRequest, deadlines (SIM_DEADLINE_MS 5,000, SIM_RETRY_DEADLINE_MS 10,000), progress interval 100 ms.
sim.worker.ts Worker entry; refuses any request shape other than the protocol's. Keeps the marker string drybench-worker:bioreactor-simulator.
sim-job.ts runJob: resolve preset, decode changes, refuse, run, compare, rescue; plain function so Node tests run it.
sim-client.ts SimClient: one job in flight, one waiting request, per-stage deadlines, worker replacement.
run-model.ts RunView, RescueView, computeRun, computeRescue, cutAt; SERIES_KEYS.
preset-runs.ts buildPresetRuns for public/data/bioreactor-simulator/preset-runs.json.
page-run.ts ecoliBlock, singleBlock: what a preset page draws, built identically on the server and in the island.
flags.ts Numbered flag notes across one or two runs, sharedOutside hatch.
summary.ts Headline tiles, rescue steps in words, chart markers.
exports.ts CSV_SERIES, runTimeseries, seriesRows, flagsCell, pumpRows.
sources.ts Source pill and citation words per preset value.
format.ts Display precision per quantity.
stored-points.ts Reading a run at its stored output points only (SCI-270): storedIndexAt (the latest point at or before a time, the later of two at one time), readIndex (the cursor's point: then the last of the consecutive points whose times show the same), exactIndex (a point at exactly a time, within 1e-9 h), tableRows and storedRows (the data table's rows on the first run's stored points).
reveal.ts The Lecturer reveal's sentence in the CSO's four forms, its intervals and flag numbers from withheldIntervals (summary.ts), the lookup the tiles' withhold predicate reads.
playback.ts The visual layer's pure playback model (BR11.1, BR11.5): PLAYBACK_MS 2,000; the clock and frames on stored points; the closed list of acts that play (PLAYBACK_ACTS); the cursor's reading rule (readIndex: the last of the consecutive points whose times show the same); the event stops from stored fields, merged within 0.05 h; Next step under reduced motion; the visibility gate on the DO panel's plot (actDecision). No UI and no copy.
vessel-tap.ts The vessel figure's tap (BR11.2, vesselTap, one per page): where the playhead or cursor stands, written by the playback chunk and read by the vessel chunk; the workspace never imports it.
vessel.ts The vessel figure's pure model (BR11.2): the set values and the rescue step in force at a stored point (inForce), the reading at a point (vesselReading, withheld as readRun withholds), the motion's phase from run time (phaseAt), and what changed in forward time (changed). No UI and no copy.
ladder.ts SLOW_MS 300, STILL_MS 2,000.
run-chart.tsx, run-table.tsx, run-parts.tsx SVG chart (server-safe) and its legend (RunLegend), data table, tiles on the readout strip, notes, rescue list.
chart-kit.tsx, chart-plot.tsx, axis-title.ts The chart kit (UI-22): the measured drawing unit, gridlines and ticks every Bioreactor chart uses; KitPlot, the XY plot of B1 and 11A, in its own module. KitPlot wraps its x-axis title inside the SVG at word boundaries (titleLines; never inside "a = b", between a number and the next word or before a parenthesised unit; REL-651), adds a 14 px line below the plot per extra title line, and keeps room for a reserveLabel's lines; its x tick labels sit in a [data-x-ticks] group.

Engine: src/lib/hosts/ and src/lib/fit/ode.ts

hosts here means host organisms, not network hosts. Every module is pure TypeScript with relative imports, so the worker, the prebuild and the Node tests share it.

File Role Version constant
model.ts State vector, right-hand side, analytic Jacobian, segments, watched triggers, simulate, checkSpec, runFlags MODEL_VERSION 2.0.0, MODEL_ID hosts-model-2
kinetics.ts Rates per regime and their derivatives; derive, demandAt, ksFactor, maximumFeasibleYield (in GROWTH_MODEL_METHOD)
transfer.ts Benson–Krause solubility, gas flow, exhaust-gas root, Hughmark gassed power, Van 't Riet kLa TRANSFER_VERSION 2.0.0
ranges.ts Fitted ranges of the transfer methods (no imports)
feed.ts Feed laws as exponential pieces, exact volumes, pump sheet, pumpProgram FEED_VERSION 1.0.0
feed-handoff.ts The feed hand-off's arithmetic: the schedule with units (mass flow, mL/min, mL, g) FEED_HANDOFF_VERSION 1.0.0
substrate-identity.ts The CSO's exact-identity rule: the match, the conversions to g/L and the refusals, over the shipped table substrate-identity@1.json (write-once, hash pinned) bioreactor-substrate-identity@1
pump-program-keys.ts The keys a feed recipe's programme carries and their units; a leaf, re-exported by feed.ts
setup.ts Preset plus changes to RunSpec; kLa by source with range and Unverified flags; cross-field refusals SETUP_VERSION 2.2.0
grammar.ts The d/cmp link grammar
rescue.ts Oxygen rescue cascade RESCUE_VERSION 1.0.0
scaleup.ts Three scale-up rules SCALEUP_VERSION 1.0.0
constants.ts Pinned molar masses, substrate C-mol data, degree of reduction
flag-codes.ts Flag vocabulary, rule versions, RESPIRATORY_FRACTION 0.95
headline.ts Headline outputs and the tenfold test's display units
methods.ts MethodRef records for provenance (no imports)
preset-specs.ts The seven published runs as oracle inputs
presets/ types.ts, params.ts (parameter table), one file per preset version, index.ts, manifest.ts, citations.ts, additional-defaults.ts
golden/ Golden runs and their manifest
*.test.ts, *.test-util.ts Unit tests; quasi-steady.test-util.ts (the cross-check closure), agreement.test-util.ts, golden.test-util.ts, snapshot.test-util.ts
src/lib/fit/ode.ts Modified Rosenbrock solver ODE_VERSION 1.1.0
src/lib/fit/dormand-prince.test-util.ts Test-only second integrator

Content

File Role
content/apps/bioreactor-simulator.ts Registry entry content and product-page copy: tasks, sources, sourceMeta, excluded sources, oracles, verificationSets.
content/apps/bioreactor-simulator.entry.ts withIdentity("bioreactor-simulator", entry).
content/tools/bioreactor-simulator.limits.ts SOURCES and LIMITS (the one copy), built from LIMIT_LINES, which gives each line an id and scope; LIMIT_META is the registry's index-aligned limitMeta (ids bioreactor-simulator.limit.<id>, limitId). Since 24 October 2026 it also holds the batch fit's three lines in the CSO's words (fit-conditional, fit-inputs, fit-profile, scope feature:batch-fit), ahead of the fit itself, as the registry is the one record of limits (the CTO's ruling on REL-535).
content/apps/bioreactor-simulator.verification.ts Fixture date, oracle versions, FIXTURE_COUNTS, TOLERANCES, verification sentences.
content/apps/bioreactor-simulator.methods.ts METHODS (method records joined with verification state) and EQUATIONS.
content/apps/bioreactor-simulator.measured-methods.ts MEASURED_METHODS: the records of the measured review (growth-window-rate@1.0.0, Assay's method) and of the measured-run comparison (measured-comparison-time-alignment@1.0.0), read only by the methods sheet so the workspace chunk does not carry them.
content/apps/bioreactor-simulator.methods-sheet.ts The methods and checks dialog's groups (see Methods and checks dialog).
content/apps/bioreactor-simulator.workspace.ts, .words.ts, .pages.ts, .standing.ts, .research.ts, .docs.ts All workspace, chart, flag, page, standing and research copy.
content/apps/bioreactor-simulator.visual-layer.ts, .visual-frame.ts, .vessel-figure.ts The visual layer's copy (BR11), one module per surface (rulings §1.18(i): the bundler keeps every export of a module a chunk uses, and a module's imports wherever it is imported): .visual-layer.ts, imported only by the playback chunk, holds playbackCopy (run playback, the cursor readout and the event labels, the CPO's ruling of 24 October 2026) and predictionMark; .visual-frame.ts the few words the workspace's own chunk renders, lecturerCopy (the reveal's sentences, the CSO's words), inFlags, clampedByEdit and NBSP; .vessel-figure.ts the vessel's words (vesselCopy, BR11.2). All outside the definition, entry and address closures and never in workspaceCopy (REL-535); the workspace's static imports reach only the frame (the fence test). Every value joined to a unit is joined by a no-break space.
content/apps/bioreactor-simulator.stored-points.ts The data table's note on its row times (the CSO's words, SCI-270).
content/apps/bioreactor-simulator.preset-words.ts PRESET_WORDS, the presets' names, in a module of its own so definition.ts imports them without the rest of .words.ts, which re-exports them (the CTO's ruling of 24 October 2026 on REL-535).
content/apps/bioreactor-simulator.range-flag.ts, .flags-line.ts The Van 't Riet range flag's lane label, sentence and line segment, read only by flags.ts (kept out of .words.ts, which rode in the definition chunk until PRESET_WORDS moved out; the CTO's ruling 1 of 24 October 2026); the flags line's words, read only by the run card.

Pipeline, published data and browser tests

Path Role
pipeline/bioreactor-simulator/reference.py Independent SciPy/NumPy oracle (build-time only).
pipeline/bioreactor-simulator/reference-measured-comparison.py, reference-measured-comparison-2.py Python fractions oracles of the measured-run comparison (11A): verification 0.3.0 and 0.4.0.
pipeline/bioreactor-simulator/reference-transport-20261011.py The supplementary transport cases (feed DO, reference gas flow), run on Python 3.9.6, SciPy 1.13.1 and NumPy 2.0.2, outside every manifest; test-only.
pipeline/bioreactor-simulator/exact_time_reference.py The exact-time E. coli reference, drafted and never run (a diagnostic, gates nothing; the CSO's memo of 24 October 2026, ruling 1).
pipeline/bioreactor-simulator/write-legacy-measured-comparison-settings.mts Wrote the frozen legacy settings fixture src/apps/bioreactor-simulator/fixtures/measured-comparison-settings-legacy-v1.json once.
pipeline/bioreactor-simulator/requirements.txt scipy==1.17.0, numpy==2.3.5.
pipeline/bioreactor-simulator/export-presets.mts Writes fixtures/preset-specs.json (inputs of the published runs).
pipeline/bioreactor-simulator/write-golden.mts Writes golden runs once; --freeze publishes.
pipeline/bioreactor-simulator/record-presets.mts Records preset file hashes in presets/manifest.ts; --freeze publishes.
pipeline/bioreactor-simulator/fixtures/ scenarios.json, analytic.json, transfer.json, scaleup.json, step.json, preset-specs.json, usgs-dotables-2026-10-06.csv, manifest.json; measured-comparison-0.3.0.json and measured-comparison-0.4.0.json (11A); transport-20261011.json (4 cases, read by transport-oracle-20261011.test.ts, in no manifest).
scripts/prebuild/bioreactor-simulator-preset-runs.mjs Writes public/data/bioreactor-simulator/preset-runs.json on every build.
scripts/size-budgets/bioreactor-simulator.json Chunk budgets.
e2e/bioreactor-simulator/workspace.spec.ts, tasks.spec.ts, measured-review.spec.ts, measured-comparison.spec.ts; e2e/objects/work-draft-bioreactor.spec.ts Browser tests.
e2e/perf/bioreactor-worker.spec.ts Worker timing gate.

Architecture and data flow

  1. The workspace reads ex (preset key) and d (changes) from the fragment, builds values with valuesOf(preset, changes) and derives an input { ex, d, horizon }. It never sends a parameter set to the worker.
  2. SimClient.submit posts { type: "run", jobId, input, compare, rescue } to sim.worker.ts. The worker's runJob resolves the preset with resolveEx, decodes d, runs setupRefusals and checkSpec, then computeRun (and the comparison), posts run, then computeRescue if asked and DO dips below the setpoint, then done.
  3. computeRun calls toRunSpec (setup) and simulate (model), then runFlags with the setup's kLa range flags, and returns a serialisable RunView (series, events, flags, balances, feed pieces, segments, step count, tolerances).
  4. The workspace builds notes, tiles, the chart and exports from the RunView. Simulation trajectories are never stored in a link or editable object record; a kept run stores simulation inputs and is recomputed when reopened. Wave 8 B1 run v2 also keeps the reviewed observational record and its empirical receipts, described below.

The preset pages skip the worker on first load: preset-runs.json is computed at prebuild and rendered on the server, read from disk through readFixture when the pages are built (tasks.marketing.tsx; MNT-26), so the file is in no bundle and not in the type program. The island loads preset-compute.ts (and so the worker) only on the kLa field's first focus, press or change, and redraws through the same page-run.ts builders.

UI structure and state

Views

VIEW_IDS in content/apps/bioreactor-simulator.workspace.ts are run, setup, rescue, compare, pump, shown as one tab row (Run, Setup, Rescue, Compare, Pump sheet) and reachable with keys 1 to 5 and palette commands. There is no inspector; the "Tools & sections" rail holds only the preset list.

View Contents
Run Chart card (headline tiles, the flags notice line, chart with DO and setpoint, X, S, plus F and V in fed-batch or F in chemostat; collapsed data table), carbon and electron closure line, product line when α or β is non-zero, simulation statement. Levers beside the chart (under it at narrow widths): kLa if the source is value, otherwise rpm and vvm; inlet O₂; feed start and μset or F (fed-batch); D (chemostat); horizon.
Setup Horizon field and every in-play parameter (inPlay in params.ts), grouped into organism, start, mode, feed, chemostat, oxygen, vessel, kLa table and limits, each with its source pill, range, labels and a "Yours" marker with reset; the Ks factor note on ks; the vessel panel.
Rescue The vessel's limits, the cascade's steps as text with kLa and limit notes, the safety line, the rescued run drawn solid against the unrescued run dashed.
Compare Scenario comparison is empty until cmp is set; then two runs share a chart with shared or per-run notes. The separate on-demand measured-data review accepts an Assay growth-review file and displays original response beside the completed simulation on independent scales.
Pump sheet pumpRows for fed-batch, numbers right-aligned with one decimal count per column (columnDecimals: d = min(3, max(1, 3 − D)) from the column's smallest nonzero value; formatPumpValue keeps an exact zero as "0" and shows "<0.001" for a nonzero value that rounds to zero; the CSO's ruling of 24 October 2026; the printed sheet still uses significant figures), the single in/out rate D·V0 for chemostat (its line calls the sheet its whole pump programme, and once H28 is live adds that a feed is prepared from a fed-batch run), a sentence for batch. The fed-batch sheet opens with the CSO's limits line, and the printed bench sheet's pump section carries the same line (report.ts, pumpLimit).

Headline tiles (runTiles in summary.ts) are: time oxygen runs out, lowest DO, biomass at the horizon, peak OUR, and product at the end when α or β is non-zero. A tile whose time falls in an outside-model interval carries the note number; RunTiles can replace its value with "Outside model" (withheldLabel), except the oxygen-out tile. RunTiles draws the suite's readout strip (ReadoutContents from layout/readout-strip.tsx in a ui-readouts list), each card keeping data-tile; in the workspace it leads the run card, above the chart (instrument DS §4.7).

State in the address

bioreactorSchema in address.ts (shared mechanics in Storage and state):

Key Meaning Check
ex Preset key <id>@<n>, the subject; changing it pushes history HANDOFF_CHECKS.ex
view One of the five views; run is left out oneOf(VIEW_IDS)
d Changes from the preset in the link grammar changesCheck (whole value refused on any bad entry)
cmp Comparison run: preset or changes in the same grammar, relative to the same ex compareCheck
lect Lecturer mode, 1 flag
h Horizon in whole hours, 1 to 240; left out when equal to the preset's integer
mine Arrival of the user's own run, vessel, construct or recipe (mine:run/…, mine:vessel/…, mine:construct/…, mine:recipe/…); never in a share link HANDOFF_CHECKS.mine
recipe H27: a Calc recipe's z: payload beside its mine:recipe/… ref, by the hand-off record only; fragment-only, so a query's is refused; the arrival takes it and mine out of the address HANDOFF_CHECKS.recipe

SHARE_KEYS are ex, view, d, cmp, lect, h. REFUSED_KEYS (obj, handoff, walk, via, example, step, ts) may not appear in the schema. A bare visit with no ex, d or cmp opens the start view and writes nothing (see Explicit first run). A link that carries d or cmp without ex, or whose ex is malformed or unknown, opens the default preset's current version (e-coli-fed-batch@1) and writes it into the address (history replace). A withdrawn version opens its replacement and the replacement's key is written the same way; a superseded version opens as named, with a notice.

The link grammar (grammar.ts): entries <id>_<value> joined by ~, in PARAM_IDS order, each id once, entries equal to the preset value omitted; ids match ^[a-z][a-z0-9]{0,15}$; numbers use canonicalNumber (at most six significant figures, e without +); enums are words from the parameter's list; nullable parameters take none. A malformed, unknown, repeated or out-of-range entry refuses the whole value with a reason naming the entry. setup.test.ts and address.test.ts check that a link with every parameter changed in both d and cmp stays within 2,000 characters.

Edits and the draft

A lever drag writes a draft (setDraft) and runs on every change through the worker; the address is written once on pointer up, key up or blur (commitValue). Setup fields commit directly. The run on screen during a drag may be for an earlier value; once the gesture ends, results for older keys are discarded.

Simulation lifecycle

useSimulation keys every outcome by JSON.stringify([input, compare]):

  • One job in flight; a newer request replaces any waiting one (SimClient).
  • 300 ms with no result for the latest key: the previous run dims and the card is busy. At 2 s the status line reports progress in simulated hours once (the worker posts progress at most every 100 ms).
  • Each stage (run including the comparison; then the rescue) has a 5 s deadline. A missed deadline terminates the worker, shows "Not simulated" with the reason and offers one retry at 10 s. The solver's step cap (40,000 steps) stops a run with the time reached.
  • The rescue is requested only when no drag is in progress.
  • Refusals (setupRefusals codes and checkSpec codes) show in a warning banner; the run is not computed and nothing is clamped.

Lecturer mode, keep, vessels, research context

  • Lecturer mode (lect=1) renders no result, and imports nothing of the visual layer, until a prediction (hours, or "never") is entered for when DO falls below the setpoint. The reveal is an explicit act: the sentence (reveal.ts, the CSO's words) gives the prediction as the student entered it ("11 h"; "it never does"; "30 h, after the 24 h horizon") and when DO first falls below the setpoint, claiming DO only where the run is inside the model: a withheld interval before the crossing, or across the run when it never falls below, is named with its flag. The flags at the crossing follow as badges. The prediction's mark, a dashed diagnostic line on the DO panel with its label, sits on the playback chunk's persistent layer from the numeric hours, never on the figure, so no export carries it; none for "never" or a time past the horizon. Exports are disabled while hidden.
  • "Keep" (in the ⋯ menu) creates a mine:run/<uuid> object with RunData (preset key, changes as a list, comparison, horizon, lecturer flag, model version, optional research context) and links it made-from to a construct or vessel in play.
  • A vessel (mine:vessel/<uuid>) holds the geometry, limits and kLa source as link parameters (VESSEL_IDS). applyVessel turns it into changes from the current preset, so the run's link still reproduces it. Only a kLa the user entered (not the preset's, never a correlation's or a simulated value) is written as a kla Measurement with conditions.rpm.
  • Research context (objective, assumptions, references, up to 50 sample rows) is stored with the scenario, shown in exports, and never fed to the model.

Run playback (BR11.1)

The visual layer's playback on the run chart (Run, Rescue, Compare › Scenarios; the instrument DS §3.6.1; docs/design/bioreactor-stage/run-playback.md, rescue-compare-playback.md, reduced-motion.md; the rulings in docs/progress/bioreactor-stage-briefs/br11-rulings.md). The chunk islands/motion.tsx holds it; playback.ts is its pure model.

  • A mask over a finished run. The worker has computed the whole run before anything moves. A sweep clones the static chart once into the figure's overlay slot (ids renamed with their references; roles, ARIA, titles and data attributes stripped; the cursor removed), clips its run groups and draws a playhead per panel, from the plot top to the plot bottom and, in the first panel, 2 px below the stamp boxes. One requestAnimationFrame loop moves the clip and the playheads to the main run's latest stored point at or before a 2,000 ms clock linear in run time, writing the clone's attributes only. Axes, lanes, hatches, the setpoint, stamps, end markers, flags and labels are at full opacity from frame one; each stamp or end marker is traced once (the shared trace, the app's accent at 14 % in its own fill, 700 ms) as the clip reaches it, on the clone only. Nothing is written to the static SVG, so the export, Copy, the bench sheet and print read the static figure. At 2,000 ms the clone goes and the chart is the static chart.
  • Acts. A sweep starts only on an act: a preset picked (the select, the rail, the start view's "Open this preset"), a comparison chosen, the Lecturer reveal, or Replay. The workspace leaves a token (ActToken: the view it was made in and the result key of the run it names), consumed once by that view's card when the run has arrived; a reload, a shared link, a restore or a lever change carries none, so they open at the end state. The gate is the DO panel's plot: half of it in view at the act, a sweep; otherwise the end state at once with Replay present, and the sweep once, later in the visit, the first time the plot is half in view; nothing scrolls. A cursor input, a new run key (a setting changed), a theme change or reduced motion turning on ends a sweep.
  • The key row. One quiet small key above the chart, at the figure's right edge, in the legend's row with two runs: "Skip to end" while sweeping, "Replay run" otherwise (described by the help line); under reduced motion or save-data "Back to the start", described by its help, the system's disabled key at 0 h.
  • The cursor readout. Opens on a cursor input (the arrow keys, Page, Home, End, a pointer press-drag on the chart with pointer capture and touch-action: pan-y, the run-time field, previous and next event). A hairline; the values line, its height reserved by a hidden copy of its longest form for the run in the same grid cell; on two-run views a heading and one line per run, each at its own reading point (a run whose point shows a different time names it; a run that has ended says so); a stop's event labels after the time (the CPO's words, joined as the interval words join); ", inside flag {n}" for any flag; inside an outside-model interval biomass, substrate and DO show the tiles' withheld word, and volume and the feed rate too when the feed piece in force was designed inside that interval (the CSO's condition (b)); substrate through formatConcentration. Then the controls row (Play or, under reduced motion or save-data, Next step; the run-time field with its "h" well; Previous event and Next event; Speed in hours of the run per second, 3, 6 or 12, not rendered under reduced motion), the field's box and the keys on one bottom edge; then the last line, help or noteStep. Enter or Space in the chart group reads the values line out through the one live region. Play and scrubbing move the layer's own cursor lines and readout, rendering only when the stored index changes, and commit the card's cursor when they end. A setting changed during Play stops it with the cursor kept on the new run (stoppedByEdit); a shorter new run clamps the cursor to its end (clampedByEdit, said once with the run's announcement); the values line reads stripBusy while the new run is slow. Escape clears the cursor and closes the readout.
  • Event stops come from stored fields only (runEvents): the feed's start, DO first below the setpoint, oxygen out, each flag's start and end (not an end at the end of the run), each rescue step, the feed's stop and the pump limit, and the end of the run (a stop with no label). Events less than 0.05 h apart are one stop, on the reading point of the earliest. Next step moves to the first stored point at or after the time the readout shows plus the keyboard step, or to the next stop if that comes first; it is disabled at the end.
  • Reduced motion or save-data (prefersReducedMotion({ saveData: true }), subscribed): nothing plays; the end state; Replay, Skip and Speed are not rendered.

The vessel figure (BR11.2)

A schematic of the person's own setup at the stored point the playhead or cursor stands on (screen 8; the instrument DS §4.8; docs/design/bioreactor-stage/vessel-figure.md; the CDO's Rescue placement memo docs/board/memos/2026-10-25-cdo-bioreactor-vessel-rescue-placement.md). It draws nothing the model does not track.

  • Frame and chunk. The workspace renders the frame at first paint (views/vessel-frame.tsx: the kicker, the time line's cell with a hidden copy of its longest wording for the run on the view, the drawing's slot at its fixed size, 260 × 312 or 112 × 140 compact, the compact <dl> with its terms and, for the two values that can wrap, a hidden copy of the longest over the run (DO, and the gas flow at reference conditions), the caption and Hide), and the lazy chunk islands/vessel.tsx (bioreactor-vessel, marker drybench-island:bioreactor-vessel) fills those slots through portals, so its arrival moves nothing. The frame's words are vesselFrameCopy in bioreactor-simulator.visual-frame.ts (the workspace's chunk; the DO and reference-flow value forms live there for the reserves, and vesselCopy takes them from it); the drawing's are vesselCopy in bioreactor-simulator.vessel-figure.ts, which only the chunk imports. data-export="exclude" and data-print="hide": never in an export, Copy, the bench sheet or print.
  • Place. One instance. Run: first in the settings column, above "The main settings" and its sliders at every width; the figure is full size at and above the view's container breakpoint (@[52rem]) and compact below it. Rescue, when it draws two runs: the Run view's grid, the chart column holding the card and then the statement, the 17 rem column the vessel only, its top on the card's; below the breakpoint the same slot in the card. A Rescue panel drawing one run, Compare, and Lecturer mode before the reveal have none. useWideContainer measures the container before paint, the one mechanism for both views. Hide and Show are one per-device choice (suite:bioreactor-simulator:vessel, through the shell's useLocalValue and writeLocal; the run mounts after hydration, so the first render reads it); hiding keeps the grid, and the chunk is not imported while hidden.
  • Caption. The explanatory text beneath the animation is collapsed by default under “About this animation”, using a native keyboard-accessible disclosure. Expanding it shows both the schematic description and the modelling limitations.
  • What it reads. The playback publishes where it stands to the page's one tap (vesselTap in vessel-tap.ts, which only the two chunks import): each stored point of a sweep, the readout's point under Play, scrubbing or a cursor, nothing (the end of the run) with neither. The vessel re-renders only when that point changes. vessel.ts is the pure model: volume, the feed rate and DO at the point, withheld exactly as the readout withholds them (readRun); the feed on if and only if F > 0; the set agitation, gas flow and inlet O₂ in force, updated by each rescue step at or before the point's time, the step named from the same lookup (inForce). On Rescue it draws the with-rescue run only.
  • The drawing. The ungassed liquid level and the tank's diameter to one scale fixed for the run (the larger of Vmax and the run's largest volume fills the height unless the width would not fit), Vmax dotted, the impeller to scale, the ring sparger and eight bubbles of fixed count and size below the run's lowest level, the pump filled when the feed is on, a chemostat's broth outlet at the level, the DO probe, and the DO meter on the DO panel's own domain and ticks (panelDomain, exported by run-chart.tsx) with the setpoint 3 px past the bar. Inside an outside-model interval the liquid and the meter take the hatch and DO reads the tiles' withheld word. The impeller and the bubbles move at one constant rate, a pure function of run time (48 blade and 16 bubble periods per run, phaseAt), so paused, reduced-motion and end-of-playback frames at one time are identical. In forward playback (a sweep or Play) a changed set value or feed state takes the shared trace on its label (at 375 its <dl> row) and its line one 2 px pulse, 500 ms expo-out; under reduced motion or save-data neither runs.
  • Tests. vessel.test.ts (the step in force at each step's own stored time, the feed on exactly when F > 0, DO withheld as the readout withholds it, the rest phase at 0 h and the horizon, the trace only in forward time, the tap) and e2e/bioreactor-simulator/vessel.spec.ts (one instance and its place per width, the cursor's point, the steps at 10.6, 12.6, 14.4 and 19.0 h on Rescue, the box unchanged by the cursor, Hide keeping the chart's box, none and no chunk on Compare, one instance across Run, Rescue and Run).

Kept edits. Edits to a named task are kept as its draft beside the record, never written into it by the timer or by leaving (D-0068 rule 8; Storage and state). When a run opens by mine (a resumed task, an object page or an address alone), keptRun finds the live task that owns the run (readOpenWork) and reads its draft (readOpenWorkDraft); the draft is shown only when readRunData accepts it and its preset resolves, and putRun sets the address, the research context and appliedDraft in one update, with the saved run still named as kept (a refused draft is offered as unshown). The owning task becomes the research context's workId, so an address alone shows the offer. reloadOpenRun serves onDraftResolved: Discard puts the saved run back, its Undo the draft, each only while the same run is open in the same scope. A saved run put on screen is held in savedShown until the address holds its inputs, its research context and measured review are set and the payload is ready, then savedOnScreen is bumped: the payload is rebuilt from the address (runDataFrom), so a record with an older growth model's version, no research context or a horizon equal to the preset's would otherwise read as an edit and write a draft on opening. Bioreactor has no autosave of saved runs (Keep and vessels are explicit), so nothing else writes a task's record.

Wave 8 B1: reviewed growth evidence

Wave 8 B1, on main since 11 October 2026 (merged on the CEO's instruction with checks deferred; docs/progress/wave8-measured-evidence.md), adds views/measured-review.tsx, measured-growth.ts and evidence-export.ts. Run and Compare provide an explicit entry that transfers keyboard focus to the lazy review heading. The review marker is drybench-island:bioreactor-measured-review (row bioreactor-measured-review-chunk, 40 KB; measured 7.5 to 9.4 KB); the writer marker is drybench-export:bioreactor-evidence, a 4.3 KB chunk-family that counts Turbopack's copies once, Assay's task-page reach included (REL-21; the Wave 8 20 KB ceiling was never measured). The simulation worker is unchanged. The data arrive by file download and upload; Send to is Stage H BR4.1. Its methods-sheet group and LIMITS line are listed under Methods and checks dialog.

src/lib/handoff/payloads/growth-review.ts reads the file schema drybench.growth-review v1. It bounds UTF-8 input at 2 MB before parsing, reuses the 2,000-reading growth limit, refuses unsupported nested fields and non-JSON values, and validates hash/ref syntax. The file retains each original time, response, unit/label, row ID, source row, role, exclusion, decision history, origin markers and source-reported empirical receipt. The receiving boundary clones the payload and checks the canonical source input hash. A checksum establishes payload consistency, not authenticity or experimental origin. Unknown origin stays undeclared; synthetic origin declared either in the source data or imported context cannot be downgraded.

Arrival context is separate from the immutable imported witness: response unit and measurement basis, time-zero convention, instrument, geometry, valid range, calibration source/uncertainty, process-event context and supplied sample IDs. These are documentary additions; no conversion, calibration or time alignment is applied. Original response and simulated biomass retain independent time and response scales. An explicit new window uses unchanged growth-window-rate@1.0.0 and its existing Python/SciPy oracle; repeats are not averaged. The result is the slope of ln(blank-corrected response), with response-doubling time only for a positive slope. Biological interpretation requires an appropriate response basis, valid range and window without unaccounted dilution. The actual retained blank count/value accompanies an estimate; nonpositive refusals identify the affected source rows separately from exclusion decisions. No process parameter is estimated or applied.

objects.ts keeps legacy run v1 readable and adds a strict v2 envelope containing MeasuredGrowthReview. Unsupported v2 data is never retried as v1. Reopening goes to Compare, recomputes the simulation and checks any cached current empirical receipt by exact kernel recomputation before display/export. The imported receipt remains historical and source-reported. Local window drafts belong to the imported payload identity, so replacing another revision with the same sourceId resets the displayed window. Pending imports, calculations and downloads check their captured review, operation generation and workspace epoch before committing effects.

Bench recording uses bioreactor-simulator.recorded-growth-review v1, distinct from recorded-simulation. It captures the complete observations and historical/current receipt separately from the exact completed simulation, with independent hashes of observations, simulation inputs and output. The combined preparation time and original simulation completion time are both retained. Requested comparison input/output presence must match exactly. Since comparison is requested only in Compare, an immutable result input records the scenario actually executed; editable inputs may retain a comparison selection for later. Shared bounded-summary reporting recognises the new receipt without reading raw scientific output as a fallback. The generic workspace bundle preserves this v2 input data. Invalid subsequent imports preserve the current review. Measured data never enter a public URL fragment.

measured-growth.test.ts covers transport, strict refusal, origin retention, v2 restore, source/current-receipt integrity and exact capture/projection. e2e/bioreactor-simulator/measured-review.spec.ts drives the export/import, keyboard, replacement and saved-reopen journeys; it passes on the desktop, phone and desktop-dark projects (REL-26, fixed 13 October 2026). B1 does not approve B2 or B4.

B2: the batch μmax estimate (engine and worker; no surface yet)

D-0131 (24 October 2026) took B2 as a new engine on the CSO's condition. src/lib/hosts/fit/batch-fit.ts (bioreactor-batch-fit@1.1.0, a method version under D-0139 that changes admission only; method record BATCH_FIT_METHOD in methods.ts) estimates one parameter, model 2.0.0's uptake-form μmax, in the person's bounds (0 < lower < upper ≤ 5 per hour), conditional on every other value as entered, from measured biomass and substrate as mass concentrations in a constant-volume batch with constant aeration. admit.ts converts rows once to hours and g/L, refuses optical density alone (od-only-refused), fewer than three retained times per channel, distinct times closer than 1e-9 h (observation-time-policy), a channel with no kept time-zero row (initial-state-missing) and then a non-physical initial state (initial-state-not-physical: X0 not finite or ≤ 0, S0 not finite or < 0, the bounds checkSpec applies to a run; REL-681), keeps excluded rows with their reasons and takes the initial state from the time-zero rows: each channel's value is the unweighted arithmetic mean of its kept rows at exactly t = 0 in g/L, added one at a time in row order from 0 and divided by k (the CSO's rule of 24 October 2026; at k = 1 it is 1.0.0's value bit for bit); excluded rows at t = 0 keep the person's reason and are not counted. An admitted case carries initialStateBasis per channel (the value unrounded, k, the ids used, the minimum and maximum when k ≥ 2, the method; no SD, SE, CV or interval) and, beside it, decimals per channel: the most fraction digits among the kept t = 0 readings as written (the shortest round-trip string, exponent forms included), plus three for a reading in mg/L, over exactly the rows the mean takes (the CTO's memo of 25 October 2026 on the mean's decimals). The words print by the CSO's precision rule of the same day (formatPlaces in format.ts): a single reading at exactly d places, a mean of k ≥ 2 at d to d + 1 places half away from zero (a trailing zero in the extra place dropped), the minimum and maximum at exactly d; an exact zero or −0 as 0, and a non-zero value that would print as zero gains places until its first non-zero digit shows. It travels on the initial-state-not-physical detail and on receipt v2 at fixed.initialState.decimals; it is a property of the input, so the 0.13.0 fixture does not carry it. Known limit: the import payload carries each value as a number, so a sheet's "0.10" arrives as 0.1 and counts one decimal; a trailing zero is lost before admission. 1.0.0 refused more than one kept time-zero row per channel and checked the initial state only in the worker (the interim guard, deleted with 1.1.0). Predictions come from simulate with times, at the schedule's shipped tolerances; the search, verdict and flags follow the 0.9.0 oracle step for step (engines.md; oracles-and-verification.md). "Resolved" means numerically resolved only; an estimate is applicable only when resolved and carrying neither weak-profile nor near-competing-minimum. Exhausting the schedule, a failed grid point or refinement, Brent's method reporting failure (optimiser-not-converged) and an unstable sensitivity are not-resolved results with no estimate; the caller's deadline stops the fit with nothing recorded. No interval in this version. No model bump: RunSpec gained an optional atol beside rtol, used only by the fit's levels.

The worker side sits beside the simulation worker: fit-protocol.ts (the canonical request drybench.bioreactor-batch-fit-request v1, its strict reader, the messages, the receipt type drybench.bioreactor-batch-fit-receipt v2 (v1 was 1.0.0's; FIT_RECEIPT_PAIRS and receiptPairKnown accept v1 with @1.0.0 and v2 with @1.1.0 only; a v1 receipt is never migrated, and Apply is disabled on it with olderMethod), and the recorded-result label bioreactor-simulator.recorded-batch-fit v1), fit-job.ts (re-reads the request, checks its versions against the engine's, recomputes inputHash with contentHashLight and refuses a mismatch, checks the fixed values with checkSpec, runs the fit with progress at most every SIM_PROGRESS_MS as exactly {type, jobId, inputHash, k}, k the evaluations so far (no n: the search is adaptive; the last k is the receipt's count; the CTO's T10-5), and builds the receipt with the initial-state basis (its t = 0 count is admission's, never recounted) and receiptHash over the body without execution and, for a resolved, applicable estimate only, the model-against-readings curve: one run at the estimate with the observation times in its grid (trajectoryAt), equal to the receipt's predictions at those times), fit-client.ts (a fresh worker per Estimate, nothing queued, the worker terminated once on every end, messages of another job or hash dropped, one absolute deadline from the click with late answers discarded) and fit.worker.ts (wiring and the marker). A refused message is {type, jobId, inputHash, code, located, detail}: detail is admission's, carried by the engine's refused outcome (AdmissionDetail in admit.ts: the failing channels in the order biomass, substrate, and its counts: kept rows for missing-channel, distinct times in the objective for too-few-times, kept rows at t = 0 for initial-state-missing, with channels k = 0 and several always empty from 1.1.0; initial for initial-state-not-physical), null for codes admission does not give. admit is admitDetailed without the detail, so the oracle's exact comparison is unchanged; too-few-times counts both channels before it returns, with the same decision. The client reads the detail strictly (readRefusalDetail: the same code, channels in order, counts non-negative integers; otherwise undecodable). The words take the detail, never a number (batchFitCopy.notEstimated, the CSO's lines of 24 October; src/apps/bioreactor-simulator/batch-fit-refusal.ts picks the line by code; initialStateSeveral served 1.0.0 only and was retired with it; initialStateNotPhysical, the CSO's words of 25 October, prints detail.initial with −0 as 0; t0SourceMean is the k ≥ 2 source line), and batch-fit-request.ts's receiptStaleness compares a receipt's request with the current one without their versions ("inputs-changed") and the estimator separately ("older-method"; inputs first), and refusal-copy.test.ts pins every numeral and channel name to admission's detail. The client imports the protocol's types and its detail reader only and the protocol imports the engine's types only, so the panel island carries no numerical code (fence.test.ts). Tests: src/lib/hosts/fit/batch-fit-oracle.test.ts, batch-fit-contract.test.ts, batch-fit-oracle-0-13-0.test.ts (1.1.0 against verification 0.13.0: item 1 on every case, items 3 to 5 and 7 on the new fitted cases; a case the generator flags flatObjective, an exactly flat objective, is compared with every bound-contact removed from both reason lists, because where a bounded minimiser stops on a constant function is not a property of the data; the CSO's ruling of 25 October 2026; the exemption is keyed on the flag, never on a case id; SCI-286 is the summary line such a receipt must not show), fence.test.ts, and fit-job.test.ts, fit-client.test.ts here, and src/apps/bioreactor-simulator/refusal-copy.test.ts. The receipt surface, Apply (a new scenario that cites its receipt), the size row, the deadline value, the perf spec and the limits lines come with BR7.3. Its non-visual parts are in place: src/apps/bioreactor-simulator/batch-fit-request.ts builds the canonical request from the scenario (fixedOf: the setup's kinetics without μmax, the aeration and the volume, through toRunSpec) and two measured channels, refuses a fed-batch or chemostat scenario before any worker starts, gives the stale key (requestKey, canonicalJsonLight of the request; μmax itself is not in it), the value Apply writes (appliedMu, six significant figures) and estimateSource, the derived provenance check (estimated only when the receipt's hash holds, it is resolved and applicable and the scenario's μmax is the applied value; the CSO's qualifier when another held value changed since; the person's own otherwise). The words are drafted in content/apps/bioreactor-simulator.batch-fit.ts (batchFitCopy) from the CPO's keys and the CSO's statements; no component reads them yet. B2's input is a drybench.batch-observations v1 payload (Storage and state); batch-fit-channels.ts adapts a read payload to the request's two channels (fitChannels: the source's label, ref and revision, the payload's own content hash as each channel's sourceInputHash, the units and time unit, the rows as read; the scales stay panel inputs) and decides substrate-mismatch (the substrate channel's quantity against the scenario's substrate) before any worker starts. Written as payloads, the 19 two-channel cases of 0.9.0 admit through the reader, the adapter and buildFitRequest to the oracle's canonical rows (batch-fit-channels.test.ts). The writer is a Bioreactor import of one two-channel table (the CPO's writer memo; no Assay export): src/apps/bioreactor-simulator/batch-observations-import.ts, words in content/apps/bioreactor-simulator.batch-observations-import.ts (batchObservationsImportCopy, held for the CPO's review where the memo did not word a line). readObservationsTable reads pasted or file text through src/lib/table/delimited.ts (delimiter, RFC 4180 fields, the comma rule): fixed column names matched once, case-insensitively, time, biomass and substrate required and sample, excluded (exactly yes or no, in any case; blank is no) and reason optional. A blank biomass or substrate cell is not measured, never zero; each table line gives at most one reading per channel, with id r{line} and its line as sourceRow, and an exclusion applies to both of the line's readings. Every refusal is listed with its line and the copy key that words it, before anything is written, and refusalLines adds "Nothing was imported." once after the list (the CPO's ruling on the import's lines, 24 October 2026); nothing is returned while any refusal remains: an unknown, missing or repeated column, a value under no column name, a time or cell that is not a number (a censored "<0.05" included), a negative time, an exclusion mark other than yes or no, an excluded line without a reason or a kept line with one, a sample or reason over 200 or 500 characters, an empty channel (excluded readings count towards the floor of one per channel; the fit's minimum stays in admission), and more than BATCH_OBSERVATIONS_MAX_ROWS readings across both channels. Units are never read from the header. declarationsComplete holds the estimate until the origin, time zero, time unit and both channel units are declared; none has a default. writeBatchObservations writes source.ref: null, the file's name or "Pasted readings" as its label and the SHA-256 of the text as read as source.inputHash, takes the substrate the caller names (the panel refuses one that is not the scenario's) and returns the strict reader's verdict on what it wrote. Written as tables, the same 19 cases of 0.9.0 admit to the oracle's canonical rows (batch-observations-import.test.ts). The control is views/batch-observations-import.tsx (BatchObservationsImport, island marker drybench-island:bioreactor-batch-observations-import): a file chooser and a paste box, the refusals listed (the first 20, then a count), the five declarations with nothing preselected, and "Use these readings", unavailable with its reason until the table reads and every declaration is made. It hands its caller { read, payloadHash } (the strict reader's accepted payload and its contentHashLight), which the batch fit's panel passes to fitChannels; it starts no worker and puts nothing in a URL. It is mounted nowhere yet: the batch fit's panel mounts it on intent inside "Measured data beside this run" in B2's user-facing commit, which gives it its size row (8,662 bytes gzip standalone on 24 October 2026, measured with a temporary mount; the row is born at the mounted measurement plus 15 %).

Contract 11A: measured-run comparison overlay

The six memos of 19 October 2026 in the CSO's, CPO's and CTO's voices (docs/board/memos/2026-10-19-{cso,cpo,cto}-measured-run-overlay{,-review}.md) are the builder's own notes, written by the building session; they are not seat rulings (audit of 20 October 2026, measured-overlay M1). The seats' rulings on this feature are the Wave 8 memos of 11 October, the CSO's strategic audit of 16 October and the CSO, CPO and CTO memos of 20 October 2026 (docs/board/memos/2026-10-20-{cso,cpo,cto}-audit-fixes.md); the CSO, CPO and CTO reviews that D-0096 requires of the built feature remain due.

Contract 11A (docs/design/product-expansion/11-bioreactor-comparison.md) adds a dedicated lazy island views/measured-comparison.tsx (drybench-island:bioreactor-measured-comparison), backed by measured-comparison.ts and measured-comparison-capture.ts:

  • Entry without worker start. The Bioreactor start view (BioreactorStart) has "Compare two measured runs", and Compare has the chip "Two measured runs" in its selector (screen 1, BR4.2). Opening the comparison view renders two empty source slots (slot-a, slot-b) without selecting a preset or starting SimClient.
  • Exact time-anchor formula and oracle. Time alignment uses method measured-comparison-time-alignment@1.0.0: elapsedHours = (sourceTime - anchor) / divisor (divisor = 1 for h, 60 for min), verified against the write-once Python fractions.Fraction oracle (pipeline/bioreactor-simulator/reference-measured-comparison.py and pipeline/bioreactor-simulator/fixtures/measured-comparison-0.3.0.json, published under public/verification/bioreactor-simulator/0.3.0/). Alignment requires a non-empty sharedEventLabel and explicit numeric anchors + bases for both slots; before alignment, each slot plots on its own entered-time axis.
  • Separate response axes and complete row preservation. Each dataset plots untouched raw sample responses on its own vertical axis (hollow circles for excluded samples; blanks, exclusions and historical receipts preserved in the complete row table). No blank subtraction, dilution correction, calibration, response normalisation, shared response axis or simulation residual is computed.
  • Persistence and recovery. Saved comparisons use schema bioreactor-simulator.measured-comparison v1 ({ v: 1, kind: "measured-comparison", ... }), discriminated from scenario RunData via isMeasuredComparisonData / readBioreactorWorkData. Recorded results use bioreactor-simulator.recorded-measured-comparison v1. Workspace recovery remaps slot.imported.source.analysisRef and simulationReference.referenceRunRef in editable-references.ts while preserving slot.payloadHash verification.
  • The settings snapshot has its own schema (the CTO's ruling of 20 October 2026, item 7). A recorded comparison captures three inputs (the two growth-review sources and the settings) and, with a verified simulation reference, the reference's own input. The settings are written under bioreactor-simulator.measured-comparison-settings v1, { v: 1, kind: "measured-comparison-settings", sharedEventLabel, slotA, slotB, simulationReference, research? }, where each slot holds its identity hashes, context and alignment, and simulationReference is null, { status: "verified", … ids, hashes, alignment, series } or { status: "unavailable", code, claimedInputHash, claimedResultHash }. measured-comparison-settings.ts holds its closed reader (readMeasuredComparisonSettings) and readLegacyMeasuredComparisonSettings, which reads exactly the shape written under the work id until 20 October 2026 and is never used to write. Snapshots saved before then are never rewritten; src/apps/bioreactor-simulator/fixtures/measured-comparison-settings-legacy-v1.json (engine-written, frozen) holds two of them. source-impact.ts reads the settings id with the closed reader and the work id with readMeasuredComparison or the legacy reader; the shape sniff it had is gone. result-projection-core.ts shows the shared event label for either id. The result schema stays v1.
  • A failed simulation reference never refuses the pair (the CSO's ruling of 20 October 2026, section 5). The reference is checked by its strict reader (validateRecordedSimulationSnapshot), then by its exact input and result hashes (simulationReferenceStatus). If either fails, verifyMeasuredComparison still verifies the two measured sources and reports simulationReference: { status: "unavailable", code, claimedInputHash, claimedResultHash }. The record keeps the reference as stored, so a saved comparison reopens with it marked unavailable. projectMeasuredComparison(value, verification) then draws nothing from it, leaves it out of the elapsed-time union, records simulationReferenceUnavailable and adds the warning qualification simulation-reference-unavailable in place of the simulated-output one, worded "Simulation reference not shown: {reason}. …" with the refusal sentence of the failing code. The receipt JSON and the settings snapshot keep only the code and the claimed hashes, and the reference's input is not recorded. The projection takes the verification as a second argument for this reason: a reference whose hashes fail passes the synchronous reader. A verified reference whose times give no finite elapsed hours is dropped the same way, with unsupported-simulation-reference-output, rather than drawn at 0 h (SCI-240). A slot verifies only by slotPayloadHash (the payload with the analysis ref left out); the whole-payload hash the verifier also accepted until 24 October 2026, which no writer produced, is refused (REL-533). Bioreactor verification 0.4.0 (reference-measured-comparison-2.py, 3 cases with their inputs) checks it; see Oracles and verification.
  • Step 2's source summary. Each slot card shows the source label, the captured date, the saved Assay revision or "Working draft", origin, time unit and response axis (the two hashes are in its "Source details", below), the reading counts (total, sample, blank, excluded), the historical window receipt's status, its slope when the source estimated one, and its method pin in the CSO's approved words, "Source method: growth-window-rate@1.0.0, as recorded; not rerun here." (growth.result.version; the CSO's memo of 24 October 2026, ruling 6). sourceSummary(growth) computes the counts and pins once; the projection carries them per slot (readingCounts, historicalMethod, capturedAt), so the receipt has them, and the rows CSV adds source_captured_at, source_analysis_ref, source_revision, readings_total, readings_sample, readings_blank, readings_excluded, historical_slope_per_entered_time_unit, historical_method_id and historical_method_version (36 columns). When the panels are aligned, each carries its coverage within the union (coverageNote) and the event anchor (anchorMarker), so non-overlapping coverage is labelled. Numbers use one format, formatComparisonNumber (five significant figures), in the view and on the object tab.
  • What shows instead of the charts (the CPO's ruling of 20 October 2026, section 4; screen 1 of 24 October 2026). No source: Source datasets alone, each slot its heading and a FileDrop; there is no empty-state box. One source: oneSlotNotice naming the missing slot, one line above both columns. Two sources whose candidate fails validation or verification: a warning Banner titled "Comparison not shown" with the refusal sentence and "Both sources are kept as imported.", announced once, with "Remove simulation reference" when one is attached. While both are being checked, after 300 ms: "Checking both sources before showing the comparison." Two loaded sources never show the empty state.
  • Screen 1: results first, inside Compare (BR4.2 and BR4.3; docs/design/bioreactor-stage/compare-selector.md, the CDO's change 1 confirmed by the CSO). Under Compare › Two measured runs the island renders, top to bottom: the refusal banner or the checking line when there is one; "Responses on separate axes" (the alignment status line, the separate-axes line, the qualifications as a plain group with a left rule, the method disclosure, the stacked panels divided by rules, the point legend); "All readings, with aligned times", one table at full height that scrolls only sideways, rows grouped by source (views/reading-groups.ts: each group row names slot, source, response label and entered-time unit, so cells hold bare numbers; the reader refuses a row entered in another unit; in read-only renderings an all-empty Sample ID is said once on the group row) with the group label sticky at the left edge; "Source datasets" (the import line and Swap, the alignment block only once both slots are filled, the two slots as columns divided by one rule at lg, each with Replace and Clear slot, its source group with the slope and method pin together, "Source details", its alignment fields and its context disclosure; the simulation-reference disclosure; the share notice); then "Comparison context", the research-context editor with the comparison's words passed in as words (so the editor never reads the comparison's copy). The in-panel export keys, the persistence note, the subtitle and the import Banner are gone: the last import is announced in the status bar, and a refusal stays visible as a line in Source datasets. The receipt and rows CSV are the Export menu's only items in this mode (the workspace registers no scenario items while cv=runs), each unavailable with "Import both measured source slots before saving or exporting a comparison." Before this, both islands passed the reason as disabled, which ExportItem does not have (disabledReason), so the menu showed them as live; fixed in both.
  • Source details (the CSO's conditions a to f). Each slot's "Scientific-input witness hash" and "Payload content hash", and a simulation reference's input and output hashes ("Not provided" when one is null), sit in a closed "Source details" (components/tools/bioreactor-simulator/source-details.tsx), whole, with their prefix, selectable and wrapping. Refusal sentences, the same-source qualification and the "Simulation reference not shown" reason stay outside it. While it is closed its contents render in a sibling hidden on screen and shown in print, so the strings are in the DOM and on paper without relying on <details>; B1's witness hash uses the same component. measured-comparison.spec.ts checks both, in the DOM while closed and under print media.
  • Loading and the shell. The workspace passes its own WorkCaptureControls and its on-demand research-context editor to the island as props, and the island imports both as types only (the CTO's ruling of 20 October 2026, item 1; scripts/bioreactor-chunk-imports.test.mjs), so the note editor keeps one loading context and the editor's chunk one import site. The island's loading line is measuredEntryCopy.comparisonLoading (content/apps/bioreactor-simulator.measured-entry.ts), which the workspace imports directly, so it never loads the measured review's copy and the definition chunk, which reaches workspaceCopy, does not carry it. While the comparison is on screen the workspace publishes shareable: false and the definition's share.available hides Copy link, since the preset's link would not carry the comparison; the view says so with shareDisabledNotice. The mode is in the address (view=compare&cv=runs; cv never enters a shared link), and the unsaved imports, alignment fields, simulation reference and research context are kept in the unnamed working draft (views/comparison-session.ts, schema bioreactor-simulator.measured-comparison-session v1): written at each import or clear and 1,200 ms after other edits, cleared on a successful save and when both slots are cleared, and restored only when no saved comparison or comparison task is open, each slot re-read by the import reader, with "Restored after reloading; not yet saved." in the status bar and "The imports were not kept on this device; import them again." only when a kept draft is refused (BR4.3; audit m7 closed).

The alignment slide (BR11.4)

Screen 9 (docs/design/bioreactor-stage/alignment-storyboard.md, signed off on 24 October 2026) shows what an alignment act does to the panels. Its parts:

  • Commit on blur or Enter. Every alignment field (the shared event, each slot's mode, anchor and basis, the reference's mode, anchor and basis) holds its typed text locally (CommitInput) and commits on blur or Enter, never per keystroke (the CTO's screen 9 memo). A nonfinite anchor is refused on commit, not while typing. The mode selects commit on change.
  • The act. A commit records what the panels show (pending.from) and finishes any running slide. While the new candidate is verified (under 300 ms) the panels keep showing that projection, so nothing unmounts or moves between the commit and the aligned frame; a refusal or a visible "Checking" ends the act and the panels then draw at once. The projection that answers the commit consumes it during the same render: alignmentAct(before, after) (views/alignment-moves.ts) returns each moved panel's shift in hours and the panels redrawn on a changed combined time range without moving. A panel's drawn times are (t − anchor) / divisor, with anchor 0 while the comparison is not aligned (an unaligned panel draws its entered times), so a shift is (anchor after − anchor before) / divisor, the same for every reading of the panel; the unit test checks it against every reading's own times in both projections. It returns null for changed sources, for an unaligned result (unaligning draws at once) and for a commit that moves no reading. An import, a swap, a cleared slot, a restore and a reopened comparison never make an act.
  • The slide (views/alignment-slide.ts, the chunk bioreactor-analysis, loaded on the first focus or pointer in the alignment fields; with no chunk the aligned state shows at once). React draws the final state: the axis, gridlines, title, coverage note and zero line are final at 0 ms. Each moved panel's marks (SlideGroup) are handed dx = x(shift) − x(0) on the new scale, so each reading starts at its previous time (its entered time over the divisor on a first declaration) and translates by it to 0 over 300 ms (MS.move), expo-out, as a Web Animation with fill: "none" and the id drybench-island:bioreactor-analysis; the moving panel's x tick labels ([data-x-ticks] text) are held at opacity 0 for the same 300 ms. A reference's path moves the same way, by its own anchor. The readings sit in a group clipped to the plot plus 6 px, so a reading that starts outside the final domain enters from the edge; the clip is drawn at rest too. A panel slides only if its plot is at least half in view at the commit, its travel is at least 12 CSS px, the tab is visible and neither reduced motion (the OS, the Settings switch) nor save-data is on; otherwise its final state shows at once. finishSlides ends every running slide on each commit, on beforeprint and when the motion setting changes; it writes nothing, so print and any later export read the final figure. Responses never move.
  • The words. Under each panel's coverage note, its moved line (alignmentCopy.moved.earlier, later or none, with shift as "30 min (0.5 h)" for a minutes source and "0.5 h" for an hours source; state, so present on load, restore and reduced motion), then the transient redrawn line (alignmentCopy.redrawn) on a panel whose readings did not move on an act that changed the combined time range; it shows only while that range holds, goes at the next act, an import, a swap, a clear or a restore, and is never saved, exported, linked or restored. alignmentCopy.alignNote follows the separate-axes note while aligned; each slot's alignment box shows the pending refusal or alignmentCopy.slotAligned. The status and the coverage notes say "combined time range". The words are in content/apps/bioreactor-simulator.alignment.ts, which only the island reaches (one content module per lazy surface, the BR11 rulings §1.18(i)).
  • Nothing moves vertically (S9-3). The status line, the alignment note, each panel's coverage-note, moved-line and redrawn-line slots and each slot's alignment box are grid cells holding hidden copies (visibility: hidden, aria-hidden) of the words they may hold (Reserved; adjudication 14: layout, never a px height); unaligned, a panel reserves its words as they would read aligned (unalignedReserve) and its axis title's aligned line count (KitPlot's reserveLabel); aligned, the qualifications list keeps a hidden copy of "Time alignment is not established", so it keeps its unaligned height as trailing space.
  • One announcement per act, from the island and never from the slide: the status sentence, each moved line, then any redrawn line (actAnnouncement), through the host's announcer; nothing on a keystroke, import, load, restore, swap or a commit that moves no reading.
  • Tests. views/alignment-moves.test.ts (the shifts against every reading's times, the act, the words, the reserves); scripts/bioreactor-chunk-imports.test.mjs (the chunk's closure holds no serialiser, export, print path, announcer, copy or engine, and no attribute write or timer; the island loads it by import() only and imports no figure export or print path; only the island reaches the words); e2e/bioreactor-simulator/alignment-slide.spec.ts (the act's frames, panel and form positions, beforeprint, the announcement, an anchor change, reduced motion and an out-of-view panel against the slid DOM, the 375 title).

The process model

Sources: src/lib/hosts/model.ts, kinetics.ts, transfer.ts, constants.ts. Units: time h, concentrations g/L, dissolved oxygen mg/L, volume L, flow L/h, specific rates per g dry weight per h.

State variables

The state vector has ten components (IX in model.ts):

Index Symbol Meaning Unit
0 X Biomass (dry weight) g/L
1 S Substrate g/L
2 P Product g/L
3 V Liquid volume L
4 C Dissolved oxygen mg/L
5 CO2 Cumulative CO₂ produced mol
6 O2 Cumulative O₂ taken up mol
7 SFED Cumulative substrate fed g
8 COUT Cumulative carbon washed out g C
9 EOUT Cumulative electrons washed out mol e⁻

Components 5 to 9 are accumulators used only for the balance closures. Initial values: X0, S0, V0 from the parameters; P0 = 0; C0 = saturation with the inlet gas, K·y_in with K = C*air(T, p)/0.2095 (not the exhaust-gas C*).

Kinetics

Per gram of biomass, with S and C each taken as max(0, ·) (kinetics.ts):

fS   = S / (Ks + S + S²/Ki)            Monod; Haldane–Andrews when Ki is set
fO   = C / (Ko + C)                    multiplied with fS (a model choice)
qS,max = μmax/Yxs + ms
qS   = qS,max · fS · fO                uptake, g/g/h
B    = Yxs · (qS − ms)                 budget for biomass and product
Regime G (qS ≥ ms and B ≥ β):  μ = (B − β)/(1 + α),  qP = α·μ + β
Regime P (qS ≥ ms and B < β):  μ = 0,                qP = B
Regime M (qS < ms):            μ = 0,                qP = 0

With α = β = 0 this is μ = (μmax + Yxs·ms)·fS·fO − Yxs·ms where positive, which equals qS = μ/Yxs + ms while cells grow. Monod is applied to uptake, not to growth: the code and content/tools/bioreactor-simulator.limits.ts state that a literature growth-fitted Ks is not interchangeable with this uptake Ks. In the Monod, product-free, full-oxygen limit:

S_crit = Ks·Yxs·ms/μmax
μ      = μmax·(S − S_crit)/(Ks + S)   above S_crit
S50    = Ks·(1 + 2·Yxs·ms/μmax)       (ksFactor: S50/Ks)

Death and endogenous decay are not modelled: below qS = ms nothing grows and nothing decays. The product is counted as biomass for substrate yield, carbon fraction and degree of reduction (standing line STANDING.product).

Elemental balances for O₂ and CO₂

With MSc the substrate mass per C-mol, MXc = 12.011/biomassCarbon the biomass mass per C-mol, γS and γX the degrees of reduction (ammonium as nitrogen source, γ = 4 + h − 2o − 3n):

qCO2 = qS/MSc − (μ + qP)/MXc                    mol/g/h (carbon balance)
qO2  = (γS·qS/MSc − γX·(μ + qP)/MXc) / 4        mol/g/h (electron balance)
OUR  = qO2·X·1000      CER = qCO2·X·1000        mmol/L/h
RQ   = CER/OUR (NaN where OUR = 0)

Substrate data (SUBSTRATES in constants.ts): glucose per C-mol CH₂O, 30.026 g/C-mol, γ 4; methanol CH₄O, 32.042 g/C-mol, γ 6. Molar masses are pinned literals (C 12.011, H 1.008, O 15.999, O₂ 31.998, CO₂ 44.009, CIAAW 2021 conventional values); constants.test.ts recomputes them from src/lib/units/atomic-weights.ts and fails on any difference, so a change to the shared table cannot move a preset run silently.

checkSpec refuses a yield above maximumFeasibleYield = min(MXc/MSc, γS·MXc/(γX·MSc)), which would imply negative O₂ uptake or CO₂ production (audit finding B10). Its method record, yield-feasibility (version as the growth model's, unit-tested), is YIELD_FEASIBILITY_METHOD in content/apps/bioreactor-simulator.yield-method.ts, read only by the methods sheet's run group, with the limit "The yield check refuses only what the carbon and electron balances forbid; a feasible yield is not thereby a likely one." (the CSO's addition A4).

Balances (the ODEs)

F is the feed rate (L/h), Sf the feed substrate (g/L), Fout = F in a chemostat (F = D·V) and 0 otherwise, Cfeed the feed's dissolved oxygen (parameter feeddo, default 0 mg/L), M = 31.998 g/mol:

dX/dt    = μ·X − (F/V)·X
dS/dt    = −qS·X + (F/V)·(Sf − S)
dP/dt    = qP·X − (F/V)·P
dV/dt    = F − Fout
dC/dt    = kLa·(C* − C) − qO2·X·M·1000 + (F/V)·(Cfeed − C)
dCO2/dt  = qCO2·X·V
dO2/dt   = qO2·X·V
dSFED/dt = F·Sf
dCOUT/dt = Fout·(S·cs + (X + P)·cx)                     cs = 12.011/MSc, cx = biomassCarbon
dEOUT/dt = Fout·((S/MSc)·γS + ((X + P)/MXc)·γX)

The run reports carbon and electron closure at the horizon as |in − (broth + out)|/in, carbon including CO₂ and washout, electrons including 4·O2 taken up and washout. The preset runs close to about 1e-7 or better (preset-runs.json). The preset pages say "carbon closes within 10⁻ⁿ and electrons within 10⁻ⁿ of what went in, as a fraction of it", n the largest whole number with the residual at most 10⁻ⁿ and at most 9 (the CSO's wording, ruling A6 of 24 October 2026).

Oxygen: solubility, gas flow and the exhaust gas

transfer.ts:

  • Air saturation by Benson and Krause as documented by USGS OWQ Technical Memorandum 2011.03: DO°(T) = exp(−139.34411 + 1.575701e5/T − 6.642308e7/T² + 1.2438e10/T³ − 8.621949e11/T⁴) mg/L at 1 atm (T in K), times the pressure factor (P − u)(1 − θP)/((1 − u)(1 − θ)) with u = exp(11.8571 − 3840.7/T − 216961/T²) and θ = 0.000975 − 1.426e-5·t + 6.436e-8·t² (t in °C, P in atm). Valid 0 to 40 °C and 0.5 to 1.1 atm. Salinity and hydrostatic head are not counted.
  • Enriched gas scales by y_O2/0.2095 (ideal-gas Henry scaling).
  • Gas flow n = p·(vvm·V/1000)·60/(R·T) mol/h with p in Pa and R = 8.314462618. vvm is per current liquid volume, so the gas flow rises as a fed-batch fills. With flowbasis = reference, actualGasVvm converts a reference vvm at (flowtemp, flowp) to actual conditions, preserving moles.
  • C* is taken from the exhaust gas, the gas phase well mixed and at steady state: C* = K·y_out. y_out is the root in [0, 1] of a·y² − b·y + c = 0 with a = kLa·V·K/(1000·M), β = kLa·V·max(0, C)/(1000·M), b = n + β + CER_mol + a, c = n·y_in + β (dry basis, no inlet CO₂). exhaustO2 returns the root and its derivatives for the Jacobian and throws RangeError if no physical root exists. y_out may exceed y_in when supersaturated liquid strips oxygen (audit finding B11).
  • OTR = kLa·(C* − C)/M in mmol/L/h.

The quasi-steady closure kLa·(C* − C) = OUR exists only in quasi-steady.test-util.ts as a cross-check; the worker refuses any request that names an oxygen model.

Feeding strategies

feed.ts represents every feed as pieces F(t) = F·e^{μ(t − from)} on [from, to):

Mode Law Parameters Notes
Batch none
Fed-batch constant f (L/h) from tf Refused if above fmax
Fed-batch exponential muset from tf; F0 designed from the state at tf Runs at fmax once reached
Fed-batch (rescue) cap from capAt, rate designed from the state there, growing at μcap Only the rescue sets it
Chemostat dilution d (1/h), sf F = D·V in and out; the engine also supports a start time and D steps, which the UI does not expose

The exponential design rate (exponentialF0):

F0 = [((1 + α)·μset + β)/Yxs + ms] · X0·V0 / (Sf − S0)

with X0, S0, V0 the state at the feed start. The feed stops at tend or when V reaches vmax; the volume stop is found exactly by inverting the piece integral (log1p). Fed volumes use closed-form integrals (expm1). No linear, DO-stat, pulse or perfusion law exists.

The feed hand-off (B3)

Stage H BR5 (the CSO's A3, the CTO's day-one memo §4, the CPO's H27 and H28) carries a fed-batch run's feed to Calc as a medium to make up, and a Calc recipe back into Setup. Bioreactor's side is built; Calc's is Stage D's (from 27 October), so the cross-links stay planned.

  • The schedule (feed-handoff.ts, handoffSchedule): the pump sheet with the feed's concentration Sf: each row's rate in L/h and mL/min (×1000/60), its mass flow Sf × rate in g/h, and the volume and substrate mass fed from the start in L, mL and g; the totals are the last row's. Planned, never delivered.
  • The recipe (feed-recipe.ts, feedRecipe): a stock recipe named for the run, its one component the substrate at Sf g/L, inputs empty, and a feed block (Storage and state) carrying the run's resolved feed as program keys with units (RunView.feed.resolved keeps it), the law, the substrate as a solution, a witness { model, preset, inputHash } (the preset as its versioned key), the stop (tEnd, Vmax, or runEnd at the run's horizon when the feed has no stop inside the run), the total fed in L (closed form, unrounded, a cross-check the receiver recomputes), the codes of the flags whose intervals overlap the feed's window, and FEED_VERSION and FEED_HANDOFF_VERSION. It is built from the settled run's view (FeedRun), never re-resolved from the inputs. An incomplete programme is refused with its reason (FEED_INCOMPLETE: start, an exponential feed with no starting rate; capNotReached; capNoDesign; noFeed), never sent in part. PUMP_PROGRAM_KEYS is the eleven fed-batch keys pumpProgram writes (no d, v or density since 24 October 2026).
  • The identity table (src/lib/hosts/substrate-identity@1.json, write-once; the CSO's bioreactor-substrate-identity@1 as ruled in the CSO's substrate-identity memos of 24 October 2026, the second governing, and laid out by the CTO's the same day): seven rows { substrate, form, key, cid }: glucose matches by four InChIKeys (D-glucopyranose with the anomer unspecified, alpha, beta, and the open chain, CID 107526), two monohydrates (CIDs 22814120 and 66370) are recognised hydrate forms, methanol by one key; refused keys never ship. A changed row is @2. SUBSTRATE_IDENTITY_HASH pins its contentHash as a literal, which the oracle test recomputes from the file and compares with the fixture's tableHash.
  • The match (matchSubstrate, substrate-identity.ts; built for H27): identity from compoundRef only, read with parseRef (never makeRef, case folding or trimming); an inchikey: ref's key, or for a kept mine:compound/… the ref the caller's resolver gives (the store's get), counted only when that parses as inchikey:; compared byte for byte. The value from amount only: any mass-concentration unit, %w/v at 10 g/L per percent, a molarity times the model's molar mass (substrateMolarMass), a monohydrate's mass concentration times MONOHYDRATE_FACTOR (180.156/198.171 from constants.ts). The target follows the recipe's role: a feed block writes Sf in a fed-batch run only; without one the person's choice of S0 or Sf. The first refusal, in the CSO's order: identity-not-ruled, feed-needs-fed-batch-run, target-not-declared (until the chooser exists), ref-not-canonical (any compoundRef that does not parse or names no compound), no-component-matches, several-components-match (a hydrate counts), unit-not-convertible, concentration-not-positive, concentration-out-of-range (S0 0 to 1000, Sf above 0 to 2000 g/L). record-not-verified comes before, from the hand-off layer.
  • The key (H28; docs/design/bioreactor-stage/h28-prepare-feed.md): on the pump sheet's total line, fed-batch runs only, judged by the run on screen (run.feed), never by the inputs. prepareFeedState: no key while the cross-link is planned, in Lecturer mode, or without a feed; enabled on settledRun with a ready scenario and a complete programme; else disabled with one reason (waiting, refused after a setup refusal, error after a run- or compare-stage error, or the programme's reason). Sending keeps the run if it is not kept, so mine:run/… travels beside the recipe.
  • The review (H27; docs/design/bioreactor-stage/h27-notice.md, the CTO's H27 notice memo, the CPO's refusals memo): a recipe arrives by the hand-off record as a mine:recipe/… ref with its payload (mine and recipe in the fragment). The objectInbound rule and recipe in the registry's objects.reads come together with Calc's sender (Stage D's), so Send to does not offer the app yet (the CPO's red line). The workspace holds one pending arrival in memory (a second replaces it), takes recipe and mine out of the address and opens Setup. The lazy island verifies it: the payload decoded, the store record a recipe at that ref, the SHA-256 of the canonical arrived JSON equal to the record's, then readStockRecipePayload (readRecipePayload for the two kinds, without the other calculators' readers) and the record's rev; any failure is record-not-verified, whose notice shows nothing of the recipe. The match runs against the run's declared substrate and current mode, so a mode change re-checks it. The notice sits at the head of a Setup group (SetupView's slots): The feed for a feed recipe, The mode for feed-needs-fed-batch-run, The start for a recipe without a block and for record-not-verified, The organism for identity-not-ruled; the arrival scrolls it into view (nearest) and focuses it, a later move is announced without taking focus. It shows the match and change (old now, new if set), the components not modelled, and the registry's two limits lines (feed-handoff, substrate-identity). Set writes the value as an ordinary change and an AppliedRecipe entry with the identity's provenance; the row then reads "Yours, from a recipe", the source line and the preset's value. A hand edit or Reset drops the entry. Keep and Save work both link the run made-from each applied recipe, the construct and the vessel (linkMadeFrom; REL-680); a recorded result names each recipe as an input by ref and rev (bioreactor-simulator.applied-recipe v1). The share dialog lists one row per applied recipe after the changed parameters ("recipe: … is not in this link; Sf goes as a plain changed value"); nothing of the recipe enters the link.
  • Checked against reference-feed-handoff.py (Python fractions, verification 0.12.0) over A2's published pump sheets (0.7.0); see Oracles and verification. Method feed-handoff 1.0.0; the pump group's limits add "A complex medium is context; the model simulates one declared substrate; delivery is not verified."

Time events, regimes and watched triggers

simulate splits the horizon into segments at fixed cuts (aeration steps, chemostat start and steps, feed start, cap, tEnd) and feed-piece boundaries (pump limit, volume stop). Each segment has one feed piece or dilution rate and one aeration. After each time event output points are added at 1, 2, 3 and 5 time constants (1/kLa) so DO transients are drawn.

Within a segment the max(0, ·) switches between regimes G, P and M are located as events by the solver, so no step straddles a switch. Five functions are watched and their sign changes located:

Watch Value Becomes
oxygen kLa·K·y_out(C=0)/M − qO2,demand·X·1000 (mmol/L/h), demand at fO = 1 outside-model-oxygen, events.oxygenOut
respiratory qS/qS,max − 0.95 outside-model-respiratory (Crabtree-positive presets)
overflow qS − qS,crit (−1 without one) outside-model-acetate (presets with qS,crit)
doFloor C − 0.01·C*air events.doBelowOnePercent
setpoint 100·C/C*air − setpoint events.belowSetpoint (drives the rescue and lecturer mode)

Excursion rule (excursions): excursions separated by gaps shorter than MIN_EXCURSION (0.05 h, 3 minutes) are merged, then excursions shorter than 0.05 h are dropped. "Oxygen runs out" is the start of the first oxygen-supply excursion.

Input checks

checkSpec (worker side) refuses: temperature outside 0 to 40 °C; headspace pressure below 0.5 atm or above 3 atm; a constant feed above the pump limit; non-positive or non-finite X0, V0, horizon, μmax, Ks, Yxs, Ko, kLa, vvm, Sf, F or μset, D; negative or non-finite ms, α, β, S0, feed DO; yO2 outside (0, 1]; biomass carbon outside (0, 1]; non-positive biomass γ; an infeasible yield. Pressure from 1.1 to 3 atm runs, flagged extrapolated:benson-krause. setupRefusals adds: constant feed above the pump, rpm, vvm or O₂ above the vessel limits, and a kLa table with fewer than two readings. Per-parameter bounds are in PARAMS (presets/params.ts) and enforced by the grammar and the fields.

kLa sources

klaAt in setup.ts gives kLa (1/h) at an agitation, gas flow and liquid volume, with range flags. kLa is taken where the aeration is set (the start, or a rescue step) and held as the volume changes.

klasrc Rule
value The entered kla exactly at the setup's own rpm, vvm and V0, with no flag. Elsewhere it is scaled by Van 't Riet's exponents: kLa = kla·[(N/N0)³·(r/r0)·(V0/V)]^b·(Q/Q0)^c, r the Hughmark gassed-power ratio, Q the actual gas flow; the power number, density and D⁵ cancel. Range flags of the correlation are attached, with unverified:van-t-riet-range whatever the verdict (setup 2.2.0).
correlation kLa = 3600·a·(Pg/V)^b·vs^c, P0 = nimp·Np·ρ·N³·D⁵ (ρ 1,000 kg/m³, RUSHTON_NP = 5), Pg = P0·min(1, Pg/P0), vs = Q/(πT²/4).
table Up to six readings kr1..kr6/kk1..kk6, interpolated log-log, extrapolated from the nearest two beyond them with extrapolated:kla-table. A vvm different from the setup's scales the reading by (r/r0)^b·(Q/Q0)^c.

Hughmark's gassed-power ratio (Yawalkar et al. 2002 eq. 2): Pg/P0 = 0.1·(Q/(N·V))^−0.25·(N²D⁴/(g·w·V^(2/3)))^−0.2, w = D/5, g = 9.81, capped at 1.

Constant Value Status
Van 't Riet, coalescing a 0.026, b 0.4, c 0.5 (SI, kLa in s⁻¹); volume up to 2.6 m³ Read in Yawalkar et al. 2002, Table 2 (read: true)
Van 't Riet, non-coalescing a 0.002, b 0.7, c 0.2; volume up to 4.4 m³ Not read against Van 't Riet 1979 (read: false); "Unverified" under D-0025
Van 't Riet range 500 to 10,000 W/m³, volume from 0.002 m³ Compared with an OCR scan only; labelled "approximate"; since setup 2.2.0 (24 October 2026) every verdict it decides, inside or outside, carries unverified:van-t-riet-range (D-0043; SCI-242)
Hughmark range T 0.21 to 3.33 m, D/T 0.33 to 0.576, H/T 0.75 to 1.87, vs ≤ 0.053 m/s, Pg/P0 0.31 to 0.8 Yawalkar et al. 2002, Table 3
Rushton Np 5 Nominal default for baffled turbulent geometry (Re > 10⁴); Reynolds number is not computed. KLA_CORRELATION_METHOD cites Kaiser et al. 2017 (read in full 24 October 2026; Builder C, 999411ca): §3.3.1 and Table 3 compile 3.6 to 5.9 for fully turbulent Rushton turbines, and §3.3.1 with Fig. 6 measures 4.17 ± 0.14 at Re above 10⁴; across that range kLa moves by −12 to +7 percent (the CSO's words of 24 October 2026). The source credit cites the same paper, and the Np limit line says Re above 10⁴
Several impellers power summed Nominal; stated as needing calibration

All five presets use klasrc = value with one impeller and the coalescing medium, so no default preset run depends on Np, the non-coalescing constants or the impeller sum (D-0025's condition).

Parameters per preset

Presets live in src/lib/hosts/presets/<id>@<n>.ts, one immutable file per version, hashed in presets/manifest.ts. All five are at version 1, state current, recorded 2026-10-06. withAdditionalDefaults adds four parameters that postdate the files without editing them: flowbasis actual, flowtemp 0 °C, flowp 1 atm, feeddo 0 mg/L.

Organism parameters

Parameter (id) E. coli fed-batch, chemostat, custom S. cerevisiae fed-batch Pichia AOX1
μmax (mumax), 1/h 0.61, cited, Haverkorn van Rijsewijk et al. 2011 Table 1 (BW25113); range 0.54 to 1.23 0.28, cited, van Hoek et al. 1998 (critical D, DS28911); range 0.28 to 0.30 0.146, cited, Zhang et al. 2000 as quoted by Cos et al. 2006 Table 2 (Haldane); range 0.087 to 0.146
Ks (ks), g/L 3.3e-5, cited, Kovárová et al. 1996 (ML30), quoted by Kovárová-Kovar and Egli 1998 0.034, illustrative (reproduces 110 µM residual glucose at D 0.10); tenfold span 0.0034 to 0.34 1.5, cited (Zhang via Cos)
Ki (ki), g/L none (Monod) none 8.86, cited (Zhang via Cos)
Yxs (yxs), g/g 0.422, converted, Nanchen et al. 2006 Table 1 (76 g/mol ÷ 180.156) 0.5, cited, van Hoek et al. 2000 via Vos et al. 2016 0.411, fit by this app to Tomàs-Gamisans et al. 2018 Table 1 (unweighted line qS = μ/Y + ms)
ms (ms), g/g/h 0.0667, converted, Nanchen et al. 2006 (0.37 mmol/g/h) 0.00703, converted, Vos et al. 2016 (0.039 mmol/g/h) 0.0027, fit; range 0 to 0.021; label "not resolved"
Ko (ko), mg/L 0.00387, converted, Stolper et al. 2010 (121 nM, K-12) 0.01, illustrative 0.01, illustrative
Substrate glucose glucose methanol
Biomass carbon (carbon), g/g 0.4738, cited, Folsom and Carlson 2015 Table 2 (MG1655) 0.4738, same source, label "not species-specific" 0.4228, cited, Tomàs-Gamisans et al. 2018 Table 2
Biomass γ (gamma) 4.17, converted from CH1.725N0.23O0.4325 4.17, label "not species-specific" 3.999, converted from CH1.812N0.204O0.601
α, β 0, 0 (product off, a model choice) 0, 0 0, 0

Derived values at the defaults (computed with kinetics.ts): qS,max 1.512 (E. coli), 0.567 (yeast), 0.358 (Pichia) g/g/h; Ks factor ksFactor 1.092, 1.025, 1.015; maximum feasible yield 0.810, 0.810, 0.887 g/g; C*air at the preset temperature and 1 atm 6.73, 7.56, 8.26 mg/L.

Citation records (authors, title, journal, DOI, PMID or PMCID, what was read and when, all 2026-10-06) are in presets/citations.ts. Some were read as abstract or as a quoted table only; each record's read.what says which.

Operating settings (all kind setting)

Setting E. coli S. cerevisiae Pichia Chemostat Custom
Mode, horizon fed-batch, 24 h fed-batch, 24 h fed-batch, 24 h chemostat, 24 h fed-batch, 24 h
X0, S0, V0 0.1 g/L, 4 g/L, 7 L 1.5, 0.02, 7 10, 0, 7 3.793, 2.55e-5 (derived steady state), 7 as E. coli
Feed exponential from 5 h, Sf 500, μset 0.2, Fmax 0.3 L/h, Vmax 10 L exponential from 0 h, Sf 500, μset 0.1, Fmax 0.2, Vmax 10 exponential from 0 h, Sf 790, μset 0.04, Fmax 0.1, Vmax 10 D 0.25 1/h, Sf 10 as E. coli
T, p, inlet O₂ 37 °C, 1 atm, 20.95 % 30 °C 25 °C 37 °C 37 °C
Aeration 600 rpm, 1 vvm, kLa 400 1/h 600 rpm, kLa 400 800 rpm, kLa 700 600 rpm, kLa 400 as E. coli
Vessel tank 0.22 m, impeller 0.088 m, 1 impeller, coalescing (all presets)
Limits, setpoint rpmmax 900, vvmmax 2, o2max 40 %, DO setpoint 30 % (all presets)

Biology rules (preset data)

Preset crabtreePositive qSCrit Kinetics temperature
E. coli, chemostat false 0.8 g/g/h, Vemuri et al. 2006 (MG1655) 37 °C
S. cerevisiae true none 30 °C
Pichia false none 25 °C
Custom false none 37 °C

A run at a temperature other than the preset's kinetics temperature is flagged extrapolated:temperature over the whole horizon; no temperature correction is applied to any rate.

Source kinds and what is "Unverified"

ValueSource kinds (presets/types.ts) are cited, converted, fit, illustrative, setting, derived, model and unverified. sourceLine in sources.ts renders illustrative and unverified in the warning tone with STANDING.illustrative or "Unverified: not yet checked against {source}." (D-0025, extended suite-wide by D-0043).

  • No shipped preset value uses kind unverified. presets.test.ts enforces the tenfold test on the three illustrative values (changing each tenfold either way must not move biomass at the horizon, lowest DO, peak OUR, the oxygen-out time or the rescue steps by more than their displayed precision, headline.ts).
  • The remaining unread engineering constants are the non-coalescing Van 't Riet coefficients and the Van 't Riet range (see kLa sources). klaAt sets KlaResult.unverified only when the non-coalescing exponents moved the kLa. Setup 2.1.0 turns it into the flag unverified:van-t-riet (rule non-coalescing-source@1) over every affected run interval, so it shows on a workspace run's flags, the rescue steps (rescueText), recorded qualifications, provenance, the reports and every export, and in the kLa calculator's medium label (item 7 of known gaps). A kLa entered at unchanged settings is not flagged. Van 't Riet's range carries only extrapolated:van-t-riet today, although the copy calls it Unverified (Stage H BR3.7). Setup 2.2.0 (24 October 2026) adds unverified:van-t-riet-range (rule van-t-riet-range-source@1) wherever the range decides the extrapolated verdict, so the copy that calls the range "marked Unverified" (limits.ts, pages.ts) is now true of the runs; the kLa calculator's verdict carries it through KlaEstimate.unverifiedRange since BR11.3 (REL-661, calculators.kla.unverified); the scale-up page's table and figure carry the same sentence since BR11.3 (REL-669, calculators.scaleup.unverified).
  • D-0025's exception ends at the pre-live pass; the limits list says each such value "is checked, or removed, before this app leaves preview".

Numerical integrator

src/lib/fit/ode.ts implements the modified Rosenbrock (W-) triple of Shampine and Reichelt 1997, §3.1 (the method of MATLAB's ode23s), from the paper only. With W = I − h·d·J, d = 1/(2 + √2), e32 = 6 + √2, T = ∂F/∂t:

F0 = F(tn, yn);                    k1 = W⁻¹(F0 + h·d·T)
F1 = F(tn + h/2, yn + h/2·k1);     k2 = W⁻¹(F1 − k1) + k1
yn+1 = yn + h·k2;                  F2 = F(tn+1, yn+1)
k3 = W⁻¹[F2 − e32(k2 − F1) − 2(k1 − F0) + h·d·T]
error ≈ h/6·(k1 − 2k2 + k3)
Aspect Implementation
Order Advances with the second-order, L-stable formula (no local extrapolation)
Jacobian Analytic from model.ts (dynamicJacobian, checked against central differences in model.test.ts); ∂F/∂t from dfdt (non-zero only on an exponential feed piece). Formed once per accepted step; W is LU-factorised with partial pivoting for every attempt.
Error norm `max_i
Step control factor 0.8·e^(−1/3) clamped to [0.2, 5]; after more than two rejections in a row at most 0.5; a non-finite error gives 0.25; first step by Hairer–Nørsett–Wanner's rule with p = 2
Bounds hMax = MAX_STEP 0.1 h; step cap DEFAULT_STEP_CAP 40,000 (accepted plus rejected, shared across segments); OdeError reasons step-cap, step-underflow (h < 1e-14·max(1,
Tolerances DEFAULT_RTOL 1e-7; atolFor: per state the smaller of FIXED_ATOL = [1e-8, 1e-9, 1e-8, 1e-8, 1e-7, 1e-10, 1e-10, 1e-8, 1e-10, 1e-10] and a ceiling from the run: X and P 1e-6·X0, S 1e-3·Ks, V 1e-9·V0, C min(1e-3·Ko, 1e-6·C*air), accumulators 1e-9 of the initial carbon, electrons or mass
Dense output Continuous extension y(n+s) = yn + h[s(1 − s)/(1 − 2d)·k1 + s(s − 2d)/(1 − 2d)·k2]; outputs are taken from it
Regime switches A step ending in another regime is bisected on the extension (up to 60 iterations, to 1e-12·max(1,
Watched functions Sampled at s = 0, 0.5, 1 and at the vertex of the parabola through them when it lies inside the step; each sign change bisected to 1e-9 h (at most 80 iterations); watching never changes the step sequence

rk4Fixed (classical RK4) is kept only for the regression test showing that a 10-second fixed step diverges at kLa 1,000 1/h. Dormand–Prince lives in dormand-prince.test-util.ts as a test-only second route. DEFAULT_RTOL and MAX_STEP carry comments saying the shipped values were pending CSO confirmation.

Rescue cascade

rescue.ts. When belowSetpoint is not null, the cascade tries, in order: agitation (grid 10 rpm, up to rpmmax), gas flow (0.05 vvm, up to vvmmax), inlet O₂ (1 percentage point, up to o2max), then, in fed-batch only, a feed cap (μ in steps of 0.005 1/h below the current feed growth rate, or below the current μ for a constant feed).

  • Each lever acts at the first below-setpoint time of the run with the earlier levers applied.
  • firstHolding tries the limit first; if even the limit does not hold DO at or above the setpoint to the horizon, the lever goes to its limit (atLimit) and the next lever takes over. Otherwise it bisects the grid for the smallest holding value, assuming DO rises monotonically with each lever.
  • An rpm or vvm step takes kLa from klaAt at the volume at that time (interpolated from the run) and carries that aeration's range flags from then on. An O₂ step changes only yO2. A feed cap steps the rate to exponentialF0 at the cap time and grows at the cap rate, still limited by fmax.
  • Results: needed, holds, steps, the rescued RunSpec and RunResult, and the number of simulations (runs).

For e-coli-fed-batch@1 (preset-runs.json): DO falls below 30 % at 10.59 h and oxygen runs out at 12.32 h. The rescue is 900 rpm at 10.59 h (kLa 637 1/h, at limit), 2 vvm at 12.56 h (kLa 842 1/h, at limit), 40 % O₂ at 14.43 h (at limit), then a feed cap at μ 0.105 1/h from 19.00 h, which holds; ten simulations. The standing safety line (cooling capacity, vessel pressure and gas-safety limits ignored; oxygen enrichment near flammable vapours is a fire and explosion hazard) is shown beside every rescue.

Scale-up and the calculators

Scale-up exists only on the /bioreactor/scale-up page (scaleup.ts, islands/scale-up.tsx), not in the workspace. Geometric similarity: diameters scale by the cube root of the volume ratio, at the same vvm, coalescing medium, one Rushton impeller, Np 5.

constant ungassed P/V:  N2 = N1·(D1/D2)^(2/3)
constant tip speed:     N2 = N1·D1/D2              (tip speed = π·D·N/60)
constant kLa:           N2 such that kLa_corr(large, N2) = kLa_corr(small, N1),
                        by bisection on [1, 10,000] rpm (KLA_RPM_BRACKET); null if
                        not bracketed, with noKla "lo" (even 1 rpm gives the large
                        vessel more) or "hi" (even 10,000 rpm gives it less)

When constant kLa has no result, the table's row reads one of the CSO's two sentences (REL-667), chosen by noKla and formatted from KLA_RPM_BRACKET (islands/scale-up-model.ts, noKlaText): "No agitation from 1 to 10,000 rpm gives the small vessel's kLa here: even at 1 rpm the correlation gives the large vessel more." or "…: even at 10,000 rpm the correlation gives the large vessel less.". Inside the page's field bounds only the first occurs (a larger vessel at 10,000 rpm always out-transfers the small one); scaleup.test.ts checks both. Below sm the table's rows are DataTable's <dl> cards (REL-657), both forms in the server HTML. The page's defaults, the scale-up input and the table's cell strings live in one module, islands/scale-up-model.ts (SCALE_UP_DEFAULTS, scaleUpOf, rowStrings), which the island reads.

Each rule reports rpm, tip speed and the full KlaEstimate (P0, Pg/P0, Pg, Pg/V, vs, kLa, range flags, and unverifiedRange: true, the marker that Van 't Riet's unread range decided the verdict; the kLa calculator's verdict shows it since BR11.3 (REL-661); the scale-up page's table and figure since BR11.3 (REL-669)). The page states that cooling, mixing time and shear are ignored and that every kLa is the correlation's, flagged outside its fitted range, with no prediction interval; the registry's limit bioreactor-simulator.limit.scale-up, named by the scale-up group of the methods sheet, adds that constant P/V holds ungassed power, so gassed P/V differs between scales (the CSO's approved words, docs/board/memos/2026-10-24-cso-bioreactor-a4-copy.md). The gas-flow basis is not applied here (vvm is taken as actual).

The scale-up figure (BR11.3)

Below the table the page draws the two vessels to one scale (screen 11; docs/design/bioreactor-stage/scale-up-page.md, the BR11 rulings §1.2 and §2). Four modules, one source each:

  • islands/vessel-geometry.ts: one tank drawn from T, D and the ungassed liquid height H = 4V/(πT²) at a scale in px per metre, walls at 1.25 H, the impeller a third of the way up (both schematic, as the caption says), a 1/2/2.5/5 × 10ⁿ m scale bar, and character wrapping for 11 px IBM Plex Mono labels (0.6 em advance, so no text is measured). Shared with the kLa figure.
  • islands/scale-up-geometry.ts: the two layout variants, wide (a 1,046-unit viewBox, the panel's width from 1280 px; shown from lg) and narrow (286 units, the panel at 375 px; below lg, at most 360 px wide). One scale per variant, the largest at which the large vessel fits; the small vessel is drawn at it. When the small vessel's walls would draw under 12 px it also appears in an inset at its own scale, whole, with its rpm, volume and a bar in the main bar's form, while the true-scale vessel stays on the baseline. Every string is the table's (rowStrings); the rule lines read "{rule} {rpm} rpm", with "; kLa extrapolated" wherever that row's kLa cell is flagged, or "{rule}:" and the table's no-result sentence.
  • islands/scale-up-figure.tsx: the renderer, server-safe (no hooks); its words are scaleUpFigureWords in content/apps/bioreactor-simulator.scale-up-figure.ts, a module of its own because a chunk that imports a module carries every export the app uses from it. Both variants are in the markup and CSS picks one. Colours are the chart tokens by var(), so a stored Dark choice paints the first frame dark. Number-bearing labels, the rule lines, their leader and the scale bars sit in data-fig-num groups; the inset in data-fig-inset; the large vessel in data-fig-large. Under the drawing one grid cell holds the narrow variant's rule lines, the caption (with "The small vessel is in an inset at its own scale." in the inset state) and the note, beside a hidden copy of their longest wording, so a shorter state leaves its difference as trailing space under the note.
  • islands/scale-up-live.tsx: the chunk bioreactor-scale-up (marker drybench-island:bioreactor-scale-up). The server page renders the figure at SCALE_UP_DEFAULTS and passes it to the island as children; the island loads the chunk when the browser is idle or a field takes focus, and from then on the chunk draws the figure from the committed fields. The table follows every keystroke; the figure follows a commit (blur or Enter). Until then, or while the chunk has not arrived, the figure's wrapper carries data-stale and its numbers are hidden, so it never shows a number the table does not.

On a committed change of the scale factor the chunk runs the CDO's step, then grow: React draws the end state at once (scale, baseline, small vessel, scale bar position, inset box), then one Web Animations transform with no fill grows the large vessel's group from a copy of the small vessel's outline over 500 ms (D.slow), expo-out, with non-scaling strokes; every data-fig-num group, and the inset with its caption sentence when the inset is new, is held at opacity 0 on its container for the same 500 ms, so all numbers arrive in one frame. It runs only when the drawing is at least half in view at the commit, the tab is visible and neither reduced motion nor save-data is on; a second commit mid-grow starts from the shape drawn; Print finishes a grow first. Any other field's commit redraws at once. Tests: islands/scale-up-geometry.test.ts (strings, H, aspect ratios, bar length, inset rule and clearances, totality over the field bounds), islands/scale-up-fence.test.ts (the island reaches the chunk only dynamically) and e2e/bioreactor-simulator/scale-up-figure.spec.ts (no-JavaScript figure, chunk takeover without change or layout shift, the grow's frames, reduced-motion identity, the inset at arrival, REL-667's line, a stale figure, Dark first paint, the 375 cards).

The kLa figure (BR11.3)

Below the kLa answer the page draws the visitor's vessel, the equation with its numbers, and kLa against gassed power per volume (screen 12; docs/design/bioreactor-stage/kla-page.md, the BR11 rulings §1.3–1.5 and §2). Modules: islands/kla-model.ts (KLA_DEFAULTS, klaOf, the answer's strings, vMaxText, answerReason), islands/kla-vessel-geometry.ts (the tank from T, D and H = 4V/(πT²) fitted to a fixed box and trimmed to the drawing, each quantity's label beside it and every KlaOutside code beside its quantity in the warning ink: T for Hughmark's tank range, D/T, H/T and V, vs, Pg/V and Pg/P0), islands/kla-chart-geometry.ts (log axes; the x domain is 100 to 31,623 W/m³ at every width, widened only to hold the person's point, the CSO's ruling of 24 October; the line is a·(Pg/V)^b·vs^c·3600 at the visitor's vs with the medium's constants, solid inside Van 't Riet's range and dashed outside; at lg the line's label and each dashed part's "extrapolated" are placed in the plot at searched places 4 px clear of the range's edges, the gridlines, the line, the point and each other, with the surface halo, and if a dashed part has no clear place both go to the legend; below lg the legend carries them, the CDO's and CSO's rulings: the line's row with a 16 px sample of the plotted stroke, then "Extrapolated: the correlation outside Van 't Riet's approximate range of Pg/V" with the plotted dashed line as its swatch, then the point's row), islands/kla-geometry.ts (klaFigureStrings, the two variants and the reserve) and islands/kla-figure.tsx (the server-safe renderer; words klaFigureWords in content/apps/bioreactor-simulator.kla-figure.ts, a module of its own like the scale-up figure's; the island's kla-model.ts imports only the pages' words, so the figure's words stay out of the island).

The page renders the figure at the defaults on the server; the island loads the chunk bioreactor-kla (islands/kla-live.tsx, marker drybench-island:bioreactor-kla) on idle or focus, which then draws it from the same fields as the answer on every parsed keystroke: the point and the numbers step in the frame the answer changes, never travelling and never drawn in; the point is an open circle when inRange and a dashed ring in the warning ink otherwise, with the legend switching in the same frame. A commit (blur, Enter, a select change) traces the changed term's label block once with the suite's shared trace (trace, 700 ms from TRACE_MS, the app's accent): Pg/V for agitation, impellers or impeller diameter, vs for gas flow, the H label for volume; nothing under reduced motion or save-data. Before the chunk arrives a change hides the server figure's numbers (data-stale). The figure's box is reserved by a hidden copy of its tallest state in the same grid cell (empty SVGs of the tallest viewBoxes, the longest equation, legend and caption), so a shorter state leaves trailing space. The verdict carries D-0130's label (REL-661) and non-coalescing broth its own beside the answer (REL-668). Tests: islands/kla-figure.test.ts, e2e/bioreactor-simulator/kla-figure.spec.ts.

The kLa calculator (islands/kla.tsx) calls correlationKla with RUSHTON_NP; the OTR/OUR calculator (islands/otr-our.tsx) computes C*, OTR = kLa(C* − C)/31.998 and OTRmax = kLa·C*/31.998 against OUR = qO2·X, and refuses temperature and pressure outside Benson and Krause's range.

Outputs, flags and charts

RunView.series keys (SERIES_KEYS): X, S, P, V, DOpct (percent of air saturation at the vessel's T and p), F, mu, OUR, OTR, CER, RQ, kLa, yO2out (dry fraction), Cstar (mg/L). Output times: every outputStep h (0.5 h in the workspace, horizon/200 = 0.12 h on preset pages), every event time (segment starts, switches, crossings) and the time-constant points. Where a time event produces two points at one time, the later segment's is kept.

Flags (flag-codes.ts, runFlags in model.ts) are never stored in a run record or a link:

Code Kind Rule Trigger
outside-model-respiratory outside model respiratory-limit@1 qS ≥ 0.95·qS,max, Crabtree-positive presets
outside-model-acetate outside model overflow@1 qS ≥ qS,crit
outside-model-oxygen outside model oxygen-supply@1 demand at fO = 1 exceeds maximum transfer; every organism
extrapolated:hughmark extrapolated hughmark-range@1 kLa from correlation or scaling outside Hughmark's range (first failing condition only)
extrapolated:van-t-riet extrapolated van-t-riet-range@1 Pg/V or volume outside Van 't Riet's range
extrapolated:kla-table extrapolated kla-table-range@1 rpm outside the readings
extrapolated:benson-krause extrapolated benson-krause-range@1 pressure 1.1 to 3 atm
extrapolated:temperature extrapolated kinetics-temperature@1 temperature differs from the preset's kinetics temperature
unverified:van-t-riet-range unverified van-t-riet-range-source@1 Van 't Riet's range, compared only with a scanned copy of the 1979 paper, decided the extrapolated verdict on this aeration, inside or outside: the correlation source, a table at another gas flow, a value at other settings and every rescue agitation or gas-flow step, over exactly those aeration intervals; not a value at its own settings or a table at its own gas flow (setup 2.2.0; the CSO's rulings of 24 October 2026). Lane label "Unverified: Van 't Riet's range"; the sentence and the line segment are the CSO's (2026-10-24-cso-bioreactor-flags-line.md)
unverified:van-t-riet unverified non-coalescing-source@1 the non-coalescing coefficients, not read against Van 't Riet 1979, moved kLa (D-0025, SCI-06); its sentence says "Unverified: not yet checked against Van 't Riet 1979; calibrate to your broth and vessel before interpreting process predictions." (the CSO's flags-line ruling of 24 October 2026, a D-0043 correction)

FLAG_CODES holds these ten codes; flagKind reads the kind from the prefix. Flags are sorted by onset, then kind (outside-model first), then vocabulary order. flagNotes numbers them across the drawn runs, sharing a number when a code has the same intervals in every run; the same numbers appear on chart lanes, end markers, table rows, tile footnotes, print and the SVG caption. Kind A lanes are hatched, kind B dashed; the plot hatch covers only intervals where every drawn run is outside the model. On the run card the notes show as one notice line under the tiles (UI-28; the CSO's flags-line ruling of 24 October 2026): flagsLine (flags-line.ts) builds "{k} flags · {n} {label}, {interval}[ ({run label})] · … · Why" from the notes in note order, the run label as the legend shows it on a note that covers only some runs, and D-0043's "Unverified: … not yet checked against Van 't Riet 1979" in place of an unverified flag's lane label (words in content/apps/bioreactor-simulator.flags-line.ts); FlagsNotice draws each number as the notes' badge and opens the numbered sentences (FlagNotesList, its id kept for the chart's aria-describedby) under "Where this run leaves the model" in a disclosure. The preset pages, print and every export keep the full sentences.

The chart (run-chart.tsx) is server-safe SVG with stacked panels sharing the time axis, literal theme colours (so exported SVG keeps them), drawn on the chart kit (chart-kit.tsx, UI-22): the viewBox stays 640 units wide, and text, padding, strokes and marks are drawn in u, one CSS pixel at the width measured before the first paint, so chart text is 11 px Plex Mono with tabular figures at every width; charts are at most 55 rem (880 px) wide; every panel has gridlines at its value and time ticks. Lane labels carry a halo in the surface colour so the hatch never crosses them, and RunLegend draws each run's swatch in the slot colour and dash the plot uses. B1's response chart and 11A's panels use KitPlot (chart-plot.tsx) on the same kit, with tick labels in place of their minimum and maximum. Numeric table columns are right-aligned. The chart has a keyboard time cursor (arrow keys; Enter announces values) and a data table (regular grid plus event rows). Both read stored output points only, never an interpolation between two (SCI-270, stored-points.ts): the cursor stands on the latest stored point at or before its time and reads the last of the consecutive points whose times show the same. The table follows the CSO's rule of 24 October 2026 (docs/board/memos/2026-10-24-cso-bioreactor-br11-table-rule.md): a grid row reads the latest stored point at or before its whole hour and shows that point's own time to 0.01 h (on the preset pages' 0.12 h grid: 0, 0.96, 1.92, 3.00, 3.96, 4.92, 6.00 h); an event row reads the point at the event, which the model always stores, and shows the event's time; a stored point appears once, by its event row where both reach it; with two runs the first sets the row's time and the other shows a value only where it stored a point at exactly that time, else "–". The caption says so in the CSO's words. The cursor is a reading aid, never part of the figure: it carries data-export="exclude", so the SVG export, Copy and the bench sheet's chart leave it out, and data-print="hide" with its readout (REL-650). Display precision (format.ts): kLa integer, DO, OUR, OTR and CER one decimal, RQ two, μ three decimals, biomass and volume three significant figures, times a tenth of an hour, substrate in g/L, mg/L or µg/L by size. Files keep full precision.

Integration with Objects, Studies and handoff

General mechanics are in Storage and state; this section lists what Bioreactor adds.

Mechanism Bioreactor's use
Objects Owns run (mine:run/<uuid>, data RunData v1, or v2 with the measured review, objects.ts:56) and vessel (mine:vessel/<uuid>, data VesselData v1). readRunData and readVesselData reject anything malformed. objectInbound opens either privately via mine.
Measurements Only user-entered kLa on a vessel (kind: "kla", unit 1/h, conditions.rpm). Simulated values are never written as Measurements.
Links A kept run is linked made-from to the construct (arrival mine:construct/…) and to the vessel in use.
Workspace tasks src/lib/workspace/work-apps.ts maps the app to object type run. WorkCaptureControls saves the scenario under schema bioreactor-simulator.scenario, whose version follows RunData.v (1, or 2 with a measured review; bioreactorWorkSchema in workspace-adapter.ts); source-impact.ts reads it with readRunData. A measured comparison saves under bioreactor-simulator.measured-comparison v1.
Recorded results captureBioreactorWorkspaceResult writes an input snapshot (evidence manual-assertion, raw, planned) and a result snapshot under bioreactor-simulator.recorded-simulation v1: the full RunView (RQ NaN recorded as null with its indices), comparison, rescue, methods with versions, the preset version as data version, a simulation info qualification and one warning qualification per flag note, reporting status qualified. It refuses if the captured job's key, horizon or comparison does not match the scenario.
Study bundles src/lib/workspace/bundle.ts includes run, vessel and construct objects for this app.
Arrivals The H32 receiving note for via=drug-pipeline-explorer was removed on 24 October 2026 (the CPO's ruling 4: a one-sided stub; H32 is deferred until a CHO view exists): Builder B removed the view's banner and Builder A its copy. The construct arrival (H30, Stage H BR6; objectInbound.construct in objects.ts) records the construct for linking and never chooses a preset: with no preset in the address the start view names the construct ([data-start-construct], words in content/apps/bioreactor-simulator.construct.ts) and the person picks one; nothing is read from its host or promoter. The open run's meta line names it as the material. e2e/bioreactor-simulator/handoffs.spec.ts covers it.
Departures H28 (feed recipe to Lab calculators): the recipe and its feed block are built (feed-recipe.ts, Stage H BR5); the registry's cross-link stays planned until Calc's side passes end to end (Stage D's ground, from 27 October).
Object-page tabs run: compact DO and biomass chart, oxygen-out time, rescue steps, preset and model versions, statement, recomputed in the worker. vessel: its values and a link to use it.

Exports and provenance

Exports (src/apps/bioreactor-simulator/exports.ts) are disabled while lecturer mode hides the result and until a completed job (run, rescue assessment, requested comparison) exists for the current inputs.

Item Output
Scenario JSON { format: "drybench.bioreactor.scenario", version: 1, inputs, methods, limits, statement, provenance, simulation, … }
Time series CSV One row per computed point (strictly increasing times) per run; columns time, X, S, P, V, F, DO %, C*, μ, OUR, OTR, CER, RQ, kLa, exhaust O₂ %, flags (codes in force, ; -joined), data_origin (simulated), product_basis; a run column with two runs; provenance file and README whose first lines are the flag notes
Pump sheet CSV Five columns: time from inoculation, time from feed start, rate to set (mL/h, the law's average over the interval), fed so far (mL, closed form), and qualifications (exports.ts:48)
Figure SVG or PNG with the flag notes in the caption
Print A4 bench sheet: flags under the title, tiles, chart, parameter table with sources, pump table (at the screen's per-column decimals), rescue, method block, verification line, statement. Its render (views/print-sheet.tsx, marker drybench-island:bioreactor-print-sheet, the suite's print report and printNow) loads when Print is first chosen, before the sheet is set (the CTO's ruling 3 of 24 October 2026).
Method paragraph Plain text

provenanceFor builds the suite's provenance block: app and version, canonical link, the method records with an FNV-1a hash of ex|d|h, the registry sources, limits, the simulation statement and the flags as flags.intervals. Method verification states come from content/apps/bioreactor-simulator.methods.ts: model, solver, oxygen, kLa correlation, kLa value (verification 0.6.0, NumPy float64, since 24 October 2026), feed (verification 0.7.0, Python decimal, since 24 October 2026) and scale-up verified (meaning checked against the named oracle), rescue unit-tested. The pump group of the methods sheet names the feed limit, "The model's feed law; pump calibration, line dead volume and feed density at temperature are yours to check." (the CSO's addition A2).

Methods and checks dialog

The methods and checks dialog (D-0085; Shared shell, Registry and content) reads content/apps/bioreactor-simulator.methods-sheet.ts: the simulated run (opened by Run, Setup and Compare, with the method block's EQUATIONS), the rescue cascade and the pump sheet, with the methods the workspace's provenance names; then the measured review beside a run (Wave 8 B1) and two measured runs compared (contract 11A), each with its method record from MEASURED_METHODS and the registry limit scoped to it (the CSO's ruling 8 of 24 October 2026: bioreactor-simulator.limit.measured-review, "An apparent ln(response) slope over your window; not the model's μmax; no OD-to-biomass conversion; no interval.", and bioreactor-simulator.limit.measured-comparison, "Elapsed time from a declared event is not physiological equivalence; responses are not converted or overlaid on one axis; a simulation reference is context only."); the rescue group names the safety limit; then the kLa, OTR and OUR and scale-up calculators, which have task pages of their own. The two measured groups open nothing (opens: []): both views live inside Compare, so the dialog opens at the Compare group and lists them. definition.methods.group() returns the store's view.

Verification

Wider context is in Oracles and verification and Build, test and CI. All 183 tests in src/lib/hosts/*.test.ts, src/lib/fit/ode.test.ts, src/components/tools/bioreactor-simulator/*.test.ts and src/apps/bioreactor-simulator/*.test.ts passed when run with node --test on 10 October 2026 for this page.

Unit tests

File What it checks
src/lib/fit/ode.test.ts LU against LAPACK on row-swapping matrices, singular matrix, one step on a 3×3 system against NumPy (1e-13), stability function value and `
model.test.ts Analytic Jacobian against central differences; quasi-steady elimination; one stiff step against NumPy; right-hand side against the oracle's to 1e-12 relative at 13 states; RK4 divergence at kLa 1,000; atolFor; excursion rule; flag codes and runFlags; event points; checkSpec
science.test.ts Reference gas flow; stripping; feed DO in the balance; yield feasibility; event sampling; quasi-steady time derivative; Setup and docs show ksFactor
setup.test.ts Canonical numbers, grammar round trip, refusals, 2,000-character worst-case link, kLa by value, table and correlation, run specs, cross-field refusals
feed.test.ts Design rate, pieces against closed forms, pump sheet volumes, model volume against feed.ts, pumpProgram keys
feed-handoff-oracle.test.ts Verification 0.12.0 against reference-feed-handoff.py: hashes and manifest, every schedule row, every programme (keys, stop, total fed, refusals), the identity table's pinned hash against the file and the fixture, the snapshot's hash, every substrate match, conversion and refusal
src/lib/handoff/payloads/recipe-feed.test.ts The feed block: carried by the envelope on stock and multi-component recipes, read strictly (units, bounds, the law's keys, the stop, the total, the flag codes), refused in a query
src/apps/bioreactor-simulator/feed-recipe.test.ts H28's recipe from a resolved feed and from a real E. coli run; the stop and the overlapping flags; each incomplete-programme reason; the key's states
src/apps/bioreactor-simulator/views/settled-run.test.ts settledRun in every state of the simulation: pending, refused, both error stages, run-, compare- and rescue-stage stops
src/apps/bioreactor-simulator/applied-recipes.test.ts readAppliedRecipes strict and stale-dropping; the writer omits an empty list; a recorded result's recipe inputs; the share row's place; every substrate has Setup's name
rescue.test.ts Grids; E. coli steps within limits, in order, smallest on grid; skip at limit; no cap in batch or chemostat
presets.test.ts File hashes, every parameter present and valid, citations with DOI and pages, ranges, conversions, matched yield and maintenance, chemostat steady state, expected flags per preset, tenfold test, Pichia maintenance range
constants.test.ts, methods.test.ts Pinned masses against the CIAAW table; method versions against module constants
fixtures.test.ts, golden.test.ts, snapshot.test.ts See below
src/components/tools/bioreactor-simulator/*.test.ts Address schema, exports, flags, page blocks, preset runs file, worker job; playback.test.ts: every frame, reading, stop and step a stored point, the end frame the run's last point, the input deep-frozen and unchanged, the stops from stored fields (flag 1's end at 4.7 h, the feed at 5.0 h, DO below the setpoint at 10.6 h, oxygen out at 12.3 h), Next step stopping at 4.7 h from 4.5 h and stepping 0.5, 1.0, 1.5 h from 0 h, the rescue's four steps stored points of the rescued run, the readout's event labels against the table's names, the gate, and the table's rows (the CSO's row times, the 3.00 h tolerance, one row per point, the two-run rule); reveal.test.ts: the reveal's sentence with no interval, an interval to the end, an interval before the crossing, two intervals, a prediction past the horizon
content/apps/bioreactor-simulator.visual-layer.test.ts The layer's copy is outside the definition, entry and address closures and out of workspaceCopy; no value is broken from its unit
src/components/suite/motion/effects.test.ts The shared trace: an SVG shape's fill or an HTML element's background, the accent at 14 %, TRACE_MS, sine-out, no fill mode, nothing under reduced motion, a hidden tab or (opted in) save-data
src/apps/bioreactor-simulator/*.test.ts Object codecs, vessels, subject words; measured-comparison.test.ts drives the 0.3.0 and 0.4.0 measured-comparison fixtures, the source summary, the settings schema against the frozen legacy fixture, and source impact
scripts/bioreactor-chunk-imports.test.mjs The comparison island has no value import of the capture controls or the research-context editor, and its static closure reaches neither the controls nor the note editor

Independent oracle

pipeline/bioreactor-simulator/reference.py restates the model in Python without sharing code and solves it with scipy.integrate.solve_ivp (Radau, rtol 1e-9, per-state atol a hundredth of the engine's rule, a central-difference Jacobian), piecewise between time events and the state events located by solve_ivp's event finder, with dense output every 0.5 h plus the time-constant points. Triggers are found on a 0.01 h scan refined by Brent's method. Sub-commands scenarios, analytic, transfer, scaleup, step, manifest, all. Fixtures were recorded on 2026-10-07 with Python 3.12.14, SciPy 1.17.0 and NumPy 2.3.5; manifest.json holds each file's SHA-256 and the script's. To regenerate (from docs/science/2026-10-scientific-audit-implementation.md): node pipeline/bioreactor-simulator/export-presets.mts, install requirements.txt in a throwaway environment, python pipeline/bioreactor-simulator/reference.py all. The fixtures are write-once (see Data pipeline).

Fixture Content Test tolerance (fixtures.test.ts)
scenarios.json 33 runs: 26 test scenarios (batch Monod, small Ks, product, Haldane, starvation; fed-batch exponential, small Ks, capped, methanol, constant, overfeed, stop, rescued; chemostat steady, step, washout; oxygen-limited fed-batch at kLa 50, 200, 1,000 each dynamic and quasi-steady; kLa step 20 to 40; two planted excursions) and the 7 published runs Every series within 0.5 % of its peak; growth switches, trigger intervals, oxygen-out and DO-below-1 % times within 1e-3 h; resolved F0, Fcap and stop time; balances within 0.5 %; S and C not below −atol; step count 0.5 to 1.25 times the recorded count
(same) CSO accuracy check (agreement.test-util.ts) Each point within the larger of 1e-5 of its value, a time shift of max(1e-4 h, 1e-5·t) times its slope, and a per-series floor; no shift allowance for DO-derived series within five time constants after a time event
(same) Quasi-steady cross-check Within 2 % of peak where OUR is below transfer capacity; oxygen-out within 0.01 h
analytic.json Chemostat steady states S* = Ks(D + Y·ms)/(μmax − D), X* = D(Sf − S*)/(D/Y + ms), washout D = (μmax·Sf − Ks·Y·ms)/(Ks + Sf) (3 cases); batch Monod implicit solution 1e-3 relative; washout checked 2 % above and below
transfer.json Benson–Krause at 9 conditions with Sander's Henry constant; 3 exhaust-gas cases; 10 kLa points (8 coalescing, 2 non-coalescing; 2 outside range) 1e-9 relative; Sander within 4 %
usgs-dotables-2026-10-06.csv USGS DOTABLES program output (21 temperatures × 6 pressures) 0.005 mg/L
scaleup.json 3 cases 1e-9 relative (1e-6 for constant kLa)
step.json One Rosenbrock step (3×3 and full model state), LU cases, a singular matrix, 13 right-hand-side states 1e-13 and 1e-12 relative

The published copies in public/verification/bioreactor-simulator/0.2.0/ must be byte-identical to the fixtures (checked by fixtures.test.ts), and FIXTURE_COUNTS in content/apps/bioreactor-simulator.verification.ts must match the files.

Golden runs

golden.test-util.ts and write-golden.mts record, once per published engine state, twelve runs: the seven published runs plus one run per preset with three fixed changes (THREE_CHANGES). Each record holds discrete results, event times, every series every 0.1 h at full precision with slopes, segments, tolerances, versions and step count. golden.test.ts replays each current record: discrete results equal, events within 0.01 h, step count 0.5 to 1.25 times, series within 1e-4 of value (DO 0.01 percentage points, OUR/OTR/CER 0.01 mmol/L/h, RQ 0.001) or a time shift of max(1e-3 h, 1e-4·t) times the slope. The golden runs are a regression record, not evidence of correctness. The current set is prefixed model-2- (recorded 2026-10-07); the twelve model-1 files are kept as superseded with the reason.

On 24 October 2026 three records were re-recorded under setup 2.2.0 with the -setup-2.2.0 suffix, for a change to flags only (unverified:van-t-riet-range on lever and rescue intervals; the CSO's and CTO's rulings of that day): model-2-e-coli-fed-batch@1-rescued, model-2-e-coli-fed-batch@1-three-changes and model-2-custom@1-three-changes. The current set is still twelve (FLAG_REVISIONS in golden.test-util.ts; goldenRuns() returns the suffixed ids, baseGoldenRuns() the base ones). The three 2026-10-07 records are superseded in the manifest (state, date and reason only; files untouched) and stay under test: golden.test.ts replays each against today's engine with only the added code masked (holds(record, run, ignore)), checks that the revision's files agree once that code is masked, and that the revision's curves equal the superseded file's within the golden tolerances at every shared output time, so the 2026-10-07 curve anchor holds for all twelve. A record's engine field gives the versions when it was written: the nine untouched records say setup 2.0.0, though setup was 2.1.0 and is now 2.2.0. The new records' series are today's engine output, not byte copies (the CTO found every record's series differ in the last digits from today's engine, all within tolerance).

Preset runs file and snapshots

preset-runs.test.ts recomputes preset-runs.json and compares it exactly, except carbon and electron closures (1e-12 absolute, presetPageTolerance). Since 24 October 2026 (REL-09) buildPresetRuns rounds every closure to an absolute 1e-9 (CLOSURE_STEP, roundClosure) before round6, so a residual near 1e-14 that differed between machines is written as 0 and the file's bytes no longer depend on the machine; a test checks the stored closures sit on that grid and that recomputing gives them exactly. The prebuild prints the file's SHA-256, so a CI log and a session host can be compared. fixtures.test.ts compares presetSpecs() with preset-specs.json, allowing 1e-9 on rescue times and kLa (rescuedSpecTolerance).

Browser tests and gates

  • e2e/bioreactor-simulator/workspace.spec.ts (strictly offline): the E. coli preset opens with oxygen out at 12.3 h and two notes; a kLa change reaches the address; Rescue lists steps and the safety line; Compare round trip within 2,000 characters; lecturer mode (and the pump sheet withheld in it); pump sheet sums, its limits line and the Export menu's print; chart time cursor; reduced motion; CSV columns; kept run reopens; vessel keep and use.
  • e2e/bioreactor-simulator/motion.spec.ts (strictly offline; the visual layer, BR11): each reduced-motion trigger alone in its own context, as the page detects it (reduceMotionBy, expectOnlyTrigger in e2e/support/motion.ts; MNT-240); the cursor on stored points, and neither Print nor the bench sheet's chart carrying it (REL-650); a shared link at the end state; Replay's sweep with the tiles, the chart's name and the static SVG unchanged mid-sweep, an inert aria-hidden clone, focus kept on the key; Skip and a key press ending a sweep; the readout's event labels, one bottom edge for the field and keys, a reserved height; Play and Pause; each reduced-motion trigger (Back to the start, Next step stopping at 4.7 h, no Speed, noteStep, the disabled key at the end); the bench sheet's chart identical between the end of playback and reduced motion; Rescue's stamps traced on the clone only; the Lecturer reveal's mark outside the figure and absent from the bench sheet, and no mark for a prediction past the horizon.
  • e2e/bioreactor-simulator/handoffs.spec.ts (strictly offline): H27, a verified feed recipe offered in The feed, Set with its provenance, a hand edit making it the person's, Keep linking it, a feed recipe refused in a batch run and an unverified payload showing nothing of it; H30, a construct opening the start view named, no preset inferred, the kept run made from it.
  • e2e/objects/work-draft-bioreactor.spec.ts (strictly offline): a resumed task shows its kept draft (Discard returns the saved run, Undo the draft), a resume with no edits writes no draft, and an address alone opens the owning task with the draft shown.
  • e2e/bioreactor-simulator/tasks.spec.ts: preset pages render their run in server HTML; the E. coli page simulates a kLa change; live-region behaviour; the three calculators; sitemap and docs.
  • e2e/perf/bioreactor-worker.spec.ts: first change median at most 2,000 ms, running-worker change at most 1,000 ms, at 4× CPU throttle with the worker's own time scaled; overloaded sets are inconclusive. It runs outside the required CI job (see Build, test and CI).
  • Size budgets (scripts/size-budgets/bioreactor-simulator.json): definition chunk 24.6 KB, workspace chunk 137 KB (interim under D-0048, release 100 KB), worker 30 KB, each task island 40 KB, the measured review 40 KB, the measured comparison 36 KB and its capture and export writers 4.2 KB (bioreactor-measured-comparison-capture-chunk, loaded on Save's record or an export since Stage H BR4; first measured 3,574 bytes); the visual layer's playback 8.6 KB (bioreactor-motion-chunk, born at its first measurement, 7,478 bytes, plus 15 percent; BR11.5 caps it at 12 KB); the Pump view 1.2 KB and the research-context editor 3.1 KB, each set at its first measurement on the integrated build of 20 October 2026 (2e35e3bf7); the evidence writer is a 4.3 KB chunk-family (gzip). On 23 October the workspace chunk read 137.8 KB (REL-42, REL-402) and the preset-run island 40.3 of 40 KB (REL-532); the CTO's ruling of 24 October 2026 raises no ceiling this stage.
  • e2e/bioreactor-simulator/measured-comparison.spec.ts: the comparison opens without a preset; the empty and one-source notices; step 2's fields; unaligned then aligned mixed h/min sources; coverage notes; the refusal banner for a context field holding a control character, with both sources kept; a bad import preserving the slot; receipt and 36-column CSV exports; save.

Versioning and write-once data

  • A preset version file never changes once published; record-presets.mts refuses a changed published file. States are current, superseded (opens on the current model with a notice) and withdrawn (opens its replacement with a notice). resolveEx implements this.
  • Golden files and oracle fixtures are protected by the write-once guard; see Data pipeline.
  • Each engine module carries a version constant that flows into methods.ts, RunData.model, golden records and provenance; methods.test.ts checks they agree.

Known gaps, defects and divergences

Scope gaps (stated in the limits, content/tools/bioreactor-simulator.limits.ts):

  • No heat transfer, pH, dissolved CO₂ or bicarbonate, death or decay, overflow metabolism (acetate, Crabtree) beyond flagging, gas hold-up or DO-probe lag; no perfusion, linear, DO-stat or pulse feed; no parameter fitting, uncertainty propagation or Monte Carlo; no temperature dependence of kinetics; no mammalian (CHO) preset.
  • Product is biomass-equivalent in composition and yield; OUR, CER and RQ are approximate when α or β is non-zero. Product formation exists only in the model (Luedeking–Piret on the substrate budget, CSO ruling M3); a separate titer and overflow panel added on 17 October 2026, with uncited α/β per host, product outside the substrate budget and overflow thresholds that contradicted the presets' cited onsets, was removed the same day (SCI-134).
  • There is no measured-data residual overlay. Measured data are reviewed only through the growth-review import (Wave 8 B1); a CSV overlay with an OD600-to-dry-weight default, clamped extrapolation past the horizon and RMSE/R² scorecards, added on 17 October 2026 and never rendered, was removed. Comparison of measured runs is the separate contract docs/design/product-expansion/11-bioreactor-comparison.md.
  • Presets combine values from different strains and conditions; they have not been compared with process data. Q-19 (independent reviewer) is open.
  • kLa is held between aeration changes while the volume grows.

Defects and divergences found while writing this page:

  1. Fixed 11 October: model header states the implemented feed-oxygen transport term and consistent quasi-steady equation.
  2. Fixed 11 October: exported method paragraph includes (Fin/V)(Cfeed − C).
  3. Fixed 11 October: Python reference documentation names model 2 and the feed-oxygen balance.
  4. Fixed 11 October: aeration_at preserves feedDO and flow. Four additive independent Radau full-run cases cover baseline, nonzero feed DO, reference-condition gas flow and both, including fixed-time transients; existing fixture files and gates remain unchanged.
  5. Fixed 11 October: setup comments correctly state that non-coalescing coefficients remain source-unverified.
  6. Fixed 11 October: KlaResult.unverified documents the actual non-coalescing-coefficient trigger.
  7. Fixed 11 October: setup 2.1.0 propagates unverified:van-t-riet over every affected run interval, to screen flags, recorded qualifications, provenance, reports and exports (including pump CSV). Directly entered/measured kLa at unchanged settings is not spuriously flagged.
  8. Model 2.0.0 changed preset curves (feed-DO dilution, stripping, event sampling) while all presets stayed at version 1; the model-1 golden files were superseded instead (src/lib/hosts/golden/manifest.json). The CSO ratified this on 24 October 2026 (ruling 7) and the rule text in model.ts and presets/types.ts now says so: a preset version tracks values, a model version tracks equations, and every run, export and recorded result names both. A share link names no model version, so a teaching link made before 7 October now gives a slightly different run with no notice (SCI-241). Setup 2.2.0 (24 October 2026) adds unverified:van-t-riet-range to lever-changed and rescued runs of unchanged presets; no preset version moved (a preset version tracks values, never flags: the CSO's ruling of that day). Saved runs stamp the model version, not the setup version, so a reopened run gains the flag silently; it adds a caution and moves no number. Recorded results keep the flag set they were computed with (they name the kLa method at 2.1.0), so a reopened scenario may show one more qualification than its recorded result; that is expected.
  9. Fixed 11 October: rescue times are roots located in preceding cascade runs, now included in the existing golden state-event policy. Independent time checks remain 0.01 h; fixed-clock windows remain strict. New negative controls prove fixed-time and out-of-window mismatches fail. Published golden hashes are unchanged. See dated scientific fix report.
  10. Fixed 11 October: registry states actual gates (closed forms 1e-3 relative; constant power/tip-speed 1e-9; constant-kLa speed 1e-6).
  11. The exhaust-gas fixtures are checked through exhaustAtRates (rates given), not through the quadratic exhaustO2 the model uses; the quadratic is exercised only through the scenario comparisons.
  12. Dead or unwired exports: measuredKla (with its KlaReading type) in transfer.ts and impliedOxygen and growthThreshold in kinetics.ts were deleted on 24 October 2026 (Stage H BR1.5, the CTO's §6). pumpProgram stays for B3 (H28 not built). exportCsv in store.ts (a no-op) goes once the workspace view stops calling it.
  13. Fixed 24 October 2026: definition.ts cites its budget row (bioreactor-definition-chunk) instead of a number.
  14. hughmarkFlag in setup.ts reports only the first failing Hughmark condition (else if chain), while correlationKla lists all of them in outside.
  15. HANDOFF_CHECKS.ex (src/lib/handoff/params.ts:99) allows versions up to three digits and a leading digit in the id; parseEx in presets/index.ts and readRunData allow four digits and require a leading letter. Harmless while every version is 1.
  16. golden.test-util.ts hard-codes the model-2- prefix and the FLAG_REVISIONS suffix table (-setup-2.2.0); the next model version needs both changed.
  17. The design brief's fragment keys (preset, z: JSON d, mode, ts, recipe), its offgas.ts and build-preset-runs.mts, and its stated chemostat closed form S* = Ks·D/(μmax − D) are superseded by later rulings and the code; the brief also predates the decision to show out-of-range kLa as "extrapolated" rather than struck through.
  18. Fixed 24 October 2026 (SCI-270): the preset pages' tables and the workspace cursor showed valueAt's linear interpolation between two output points as if the model had computed it (run-table.tsx, run-card.tsx). Both now read stored points only (stored-points.ts).
  19. Fixed 24 October 2026 (REL-650): the workspace's time cursor ([data-cursor]) reached the SVG export, Copy and the bench sheet's chart, and printed. It is excluded and hidden in print. Legal questions (the public "Unverified" wording, the use of cited parameter values) are held for the General Counsel under D-0010.

Adding a host or a model term safely

A new host organism or preset

  1. Read each source and add a Citation to presets/citations.ts with DOI, pages and what was read on which date.
  2. Write src/lib/hosts/presets/<id>@1.ts with every id in PARAM_IDS, a source for each value (cited, converted with the conversion, fit with the method, illustrative with a reason, setting, derived or model) and a reported range for kinetic, yield and composition values. Illustrative values must pass the tenfold test in presets.test.ts.
  3. Set biology (crabtreePositive, qSCrit with source and strain, kineticsTemperature). A new organism value needs the Organism union in content/apps/bioreactor-simulator.words.ts, PresetVersion.organism in types.ts and flag sentences for each outside-model code (flags.test.ts checks coverage).
  4. Add the id to PRESET_IDS and PRESET_VERSIONS in presets/index.ts, to PRESET_WORDS (content/apps/bioreactor-simulator.preset-words.ts), and, if it gets a page, to tasks in content/apps/bioreactor-simulator.ts and tasks.marketing.tsx.
  5. Add the published run to publishedRuns in preset-specs.ts and a three-change variant to THREE_CHANGES in golden.test-util.ts.
  6. Run node pipeline/bioreactor-simulator/record-presets.mts, export-presets.mts, reference.py all (pinned environment), write-golden.mts, and the prebuild for preset-runs.json; update FIXTURE_COUNTS. At the merge, run record-presets.mts --freeze and write-golden.mts --freeze.
  7. A new substrate needs SubstrateId and SUBSTRATES in constants.ts, the substrate enum in params.ts, the same entry in reference.py, and constants.test.ts.

Never edit a published preset file: publish <id>@2.ts, mark @1 superseded or withdrawn in the manifest by hand with the date and reason.

A new model term

  1. Add the term to the right-hand side in systemFor, its partial derivatives in dynamicJacobian and, if time-dependent, dfdt. Add any new state to IX, N_STATES, FIXED_ATOL and the atolFor ceilings. Extend the balances in simulate if carbon or electrons move.
  2. Restate it independently in reference.py (do not import engine code) and in quasi-steady.test-util.ts if it touches oxygen.
  3. Run the Jacobian checks in model.test.ts; add right-hand-side cases to step.json; add scenarios that exercise the term with non-default values.
  4. Bump MODEL_VERSION and GROWTH_MODEL_METHOD.version. If any preset curve moves, publish new preset versions (or record a decision that departs from that rule), regenerate fixtures, supersede the golden runs with a dated reason, and update the method paragraph, EQUATIONS, the limits and the docs in the same change.
  5. A new trigger needs a flag code (never rename or reuse one), a rule version in FLAG_RULES, words in the content files, an oracle counterpart and the excursion rule.
  6. Keep new parameters out of default preset runs until their sources are read, or label them "Unverified" on every output they move (D-0025, D-0043).

A flag-only change

A new flag code or a changed rule that moves no curve (the CTO's ruling of 24 October 2026; first used for setup 2.2.0):

  1. Add the code and its rule to flag-codes.ts (never rename or reuse a code), its words to content/apps/bioreactor-simulator.words.ts, and bump the version of the engine that emits it (setup, model or rescue) and the method records that name it (methods.ts; methods.test.ts). No preset version moves.
  2. Find the golden records whose discrete results change; if any default preset run's flags change, stop: that is the CSO's question.
  3. For each changed record add a FLAG_REVISIONS entry (base, suffix, added), mark the base entry superseded by hand in golden/manifest.json (state, on, reason only), run write-golden.mts and, before the merge, write-golden.mts --freeze.
  4. Regenerate public/data/bioreactor-simulator/preset-runs.json (the prebuild script) if a published run's flags change, and update the tests that list those flags with the change cited.

Scientific repair verification — 11 October 2026

See dated fixes and evidence (docs/science/2026-10-11-scientific-audit-fixes.md). No model arithmetic, published preset or golden fixture was regenerated for the rescue comparison correction. Non-coalescing coefficients remain visibly unverified; synthetic oracle agreement does not validate biological parameter choices or process predictions. Supplemental transport fixtures record their actual Python/SciPy/NumPy runtime and source hashes separately from the original fixture manifest.

Explicit first run (16 October 2026)

A bare /bioreactor/app opens a shared discovery start with a preset picker and a one-click E. coli example. It does not write a default preset or start the simulation worker. Opening a preset starts the existing computation; explicit preset links, saved runs and vessel/construct arrivals preserve their existing interpretation and recovery checks.

Public reference workflows (16 October 2026)

Two public reference routes seed existing microbial Monod and oxygen-transfer parameters. Neither extends the model to mammalian growth. See Public reference workflows for the shared route, state, source and export contracts.