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
- The workspace reads
ex(preset key) andd(changes) from the fragment, buildsvalueswithvaluesOf(preset, changes)and derives an input{ ex, d, horizon }. It never sends a parameter set to the worker. SimClient.submitposts{ type: "run", jobId, input, compare, rescue }tosim.worker.ts. The worker'srunJobresolves the preset withresolveEx, decodesd, runssetupRefusalsandcheckSpec, thencomputeRun(and the comparison), postsrun, thencomputeRescueif asked and DO dips below the setpoint, thendone.computeRuncallstoRunSpec(setup) andsimulate(model), thenrunFlagswith the setup's kLa range flags, and returns a serialisableRunView(series, events, flags, balances, feed pieces, segments, step count, tolerances).- 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 (
setupRefusalscodes andcheckSpeccodes) 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 withRunData(preset key, changes as a list, comparison, horizon, lecturer flag, model version, optional research context) and links itmade-fromto a construct or vessel in play. - A vessel (
mine:vessel/<uuid>) holds the geometry, limits and kLa source as link parameters (VESSEL_IDS).applyVesselturns 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 aklaMeasurement withconditions.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
requestAnimationFrameloop 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 sharedtrace, 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 throughformatConcentration. 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,helpornoteStep. 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 readsstripBusywhile 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 chunkislands/vessel.tsx(bioreactor-vessel, markerdrybench-island:bioreactor-vessel) fills those slots through portals, so its arrival moves nothing. The frame's words arevesselFrameCopyinbioreactor-simulator.visual-frame.ts(the workspace's chunk; the DO and reference-flow value forms live there for the reserves, andvesselCopytakes them from it); the drawing's arevesselCopyinbioreactor-simulator.vessel-figure.ts, which only the chunk imports.data-export="exclude"anddata-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.useWideContainermeasures 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'suseLocalValueandwriteLocal; 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 (
vesselTapinvessel-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.tsis 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 byrun-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) ande2e/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 startingSimClient. - Exact time-anchor formula and oracle. Time alignment uses method
measured-comparison-time-alignment@1.0.0:elapsedHours = (sourceTime - anchor) / divisor(divisor = 1forh,60formin), verified against the write-once Pythonfractions.Fractionoracle (pipeline/bioreactor-simulator/reference-measured-comparison.pyandpipeline/bioreactor-simulator/fixtures/measured-comparison-0.3.0.json, published underpublic/verification/bioreactor-simulator/0.3.0/). Alignment requires a non-emptysharedEventLabeland 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-comparisonv1 ({ v: 1, kind: "measured-comparison", ... }), discriminated from scenarioRunDataviaisMeasuredComparisonData/readBioreactorWorkData. Recorded results usebioreactor-simulator.recorded-measured-comparisonv1. Workspace recovery remapsslot.imported.source.analysisRefandsimulationReference.referenceRunRefineditable-references.tswhile preservingslot.payloadHashverification. - 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-settingsv1,{ v: 1, kind: "measured-comparison-settings", sharedEventLabel, slotA, slotB, simulationReference, research? }, where each slot holds its identity hashes, context and alignment, andsimulationReferenceisnull,{ status: "verified", … ids, hashes, alignment, series }or{ status: "unavailable", code, claimedInputHash, claimedResultHash }.measured-comparison-settings.tsholds its closed reader (readMeasuredComparisonSettings) andreadLegacyMeasuredComparisonSettings, 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.tsreads the settings id with the closed reader and the work id withreadMeasuredComparisonor the legacy reader; the shape sniff it had is gone.result-projection-core.tsshows 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,verifyMeasuredComparisonstill verifies the two measured sources and reportssimulationReference: { 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, recordssimulationReferenceUnavailableand adds the warning qualificationsimulation-reference-unavailablein 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, withunsupported-simulation-reference-output, rather than drawn at 0 h (SCI-240). A slot verifies only byslotPayloadHash(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 addssource_captured_at,source_analysis_ref,source_revision,readings_total,readings_sample,readings_blank,readings_excluded,historical_slope_per_entered_time_unit,historical_method_idandhistorical_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:oneSlotNoticenaming the missing slot, one line above both columns. Two sources whose candidate fails validation or verification: a warningBannertitled "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 atlg, 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 aswords(so the editor never reads the comparison's copy). The in-panel export keys, the persistence note, the subtitle and the importBannerare 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 whilecv=runs), each unavailable with "Import both measured source slots before saving or exporting a comparison." Before this, both islands passed the reason asdisabled, whichExportItemdoes 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.tschecks both, in the DOM while closed and under print media. - Loading and the shell. The workspace passes its own
WorkCaptureControlsand 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 ismeasuredEntryCopy.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 reachesworkspaceCopy, does not carry it. While the comparison is on screen the workspace publishesshareable: falseand the definition'sshare.availablehides Copy link, since the preset's link would not carry the comparison; the view says so withshareDisabledNotice. The mode is in the address (view=compare&cv=runs;cvnever 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, schemabioreactor-simulator.measured-comparison-sessionv1): 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 chunkbioreactor-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 handeddx = 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 withfill: "none"and the iddrybench-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.finishSlidesends every running slide on each commit, onbeforeprintand 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,laterornone, withshiftas "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.alignNotefollows the separate-axes note while aligned; each slot's alignment box shows the pending refusal oralignmentCopy.slotAligned. The status and the coverage notes say "combined time range". The words are incontent/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'sreserveLabel); 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 byimport()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 − θ))withu = 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 andR = 8.314462618.vvmis per current liquid volume, so the gas flow rises as a fed-batch fills. Withflowbasis = reference,actualGasVvmconverts 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_outis the root in [0, 1] ofa·y² − b·y + c = 0witha = 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₂).exhaustO2returns the root and its derivatives for the Jacobian and throwsRangeErrorif no physical root exists.y_outmay exceedy_inwhen supersaturated liquid strips oxygen (audit finding B11). OTR = kLa·(C* − C)/Min 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): astockrecipe named for the run, its one component the substrate at Sf g/L,inputsempty, and afeedblock (Storage and state) carrying the run's resolved feed as program keys with units (RunView.feed.resolvedkeeps it), the law, the substrate as a solution, a witness{ model, preset, inputHash }(the preset as its versioned key), the stop (tEnd,Vmax, orrunEndat 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, andFEED_VERSIONandFEED_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_KEYSis the eleven fed-batch keyspumpProgramwrites (nod,vordensitysince 24 October 2026). - The identity table (
src/lib/hosts/substrate-identity@1.json, write-once; the CSO'sbioreactor-substrate-identity@1as 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 recognisedhydrateforms, methanol by one key; refused keys never ship. A changed row is@2.SUBSTRATE_IDENTITY_HASHpins itscontentHashas a literal, which the oracle test recomputes from the file and compares with the fixture'stableHash. - The match (
matchSubstrate,substrate-identity.ts; built for H27): identity fromcompoundRefonly, read withparseRef(nevermakeRef, case folding or trimming); aninchikey:ref's key, or for a keptmine:compound/…the ref the caller's resolver gives (the store'sget), counted only when that parses asinchikey:; compared byte for byte. The value fromamountonly: any mass-concentration unit,%w/vat 10 g/L per percent, a molarity times the model's molar mass (substrateMolarMass), a monohydrate's mass concentration timesMONOHYDRATE_FACTOR(180.156/198.171 fromconstants.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(anycompoundRefthat 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-verifiedcomes 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 onsettledRunwith a ready scenario and a complete programme; else disabled with one reason (waiting,refusedafter a setup refusal,errorafter a run- or compare-stage error, or the programme's reason). Sending keeps the run if it is not kept, somine: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 amine:recipe/…ref with its payload (mineandrecipein the fragment). TheobjectInboundrule andrecipein the registry'sobjects.readscome 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), takesrecipeandmineout 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, thenreadStockRecipePayload(readRecipePayloadfor the two kinds, without the other calculators' readers) and the record'srev; any failure isrecord-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 forfeed-needs-fed-batch-run, The start for a recipe without a block and forrecord-not-verified, The organism foridentity-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 anAppliedRecipeentry 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 runmade-fromeach 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-recipev1). 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(Pythonfractions, verification 0.12.0) over A2's published pump sheets (0.7.0); see Oracles and verification. Methodfeed-handoff1.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.tsenforces 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).
klaAtsetsKlaResult.unverifiedonly when the non-coalescing exponents moved the kLa. Setup 2.1.0 turns it into the flagunverified:van-t-riet(rulenon-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 onlyextrapolated:van-t-riettoday, although the copy calls it Unverified (Stage H BR3.7). Setup 2.2.0 (24 October 2026) addsunverified:van-t-riet-range(rulevan-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 throughKlaEstimate.unverifiedRangesince 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.
firstHoldingtries 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
klaAtat the volume at that time (interpolated from the run) and carries that aeration's range flags from then on. An O₂ step changes onlyyO2. A feed cap steps the rate toexponentialF0at the cap time and grows at the cap rate, still limited byfmax. - Results:
needed,holds,steps, the rescuedRunSpecandRunResult, 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 fromlg) andnarrow(286 units, the panel at 375 px; belowlg, 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 arescaleUpFigureWordsincontent/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 byvar(), so a stored Dark choice paints the first frame dark. Number-bearing labels, the rule lines, their leader and the scale bars sit indata-fig-numgroups; the inset indata-fig-inset; the large vessel indata-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 chunkbioreactor-scale-up(markerdrybench-island:bioreactor-scale-up). The server page renders the figure atSCALE_UP_DEFAULTSand passes it to the island aschildren; 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 carriesdata-staleand 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 |
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,expectOnlyTriggerine2e/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 inertaria-hiddenclone, 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 KBchunk-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.mtsrefuses a changed published file. States arecurrent,superseded(opens on the current model with a notice) andwithdrawn(opens its replacement with a notice).resolveEximplements 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.tschecks 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:
- Fixed 11 October: model header states the implemented feed-oxygen transport term and consistent quasi-steady equation.
- Fixed 11 October: exported method paragraph includes
(Fin/V)(Cfeed − C). - Fixed 11 October: Python reference documentation names model 2 and the feed-oxygen balance.
- Fixed 11 October:
aeration_atpreservesfeedDOandflow. 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. - Fixed 11 October: setup comments correctly state that non-coalescing coefficients remain source-unverified.
- Fixed 11 October:
KlaResult.unverifieddocuments the actual non-coalescing-coefficient trigger. - Fixed 11 October: setup 2.1.0 propagates
unverified:van-t-rietover 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. - 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 inmodel.tsandpresets/types.tsnow 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) addsunverified:van-t-riet-rangeto 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. - 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.
- Fixed 11 October: registry states actual gates (closed forms 1e-3 relative; constant power/tip-speed 1e-9; constant-kLa speed 1e-6).
- The exhaust-gas fixtures are checked through
exhaustAtRates(rates given), not through the quadraticexhaustO2the model uses; the quadratic is exercised only through the scenario comparisons. - Dead or unwired exports:
measuredKla(with itsKlaReadingtype) intransfer.tsandimpliedOxygenandgrowthThresholdinkinetics.tswere deleted on 24 October 2026 (Stage H BR1.5, the CTO's §6).pumpProgramstays for B3 (H28 not built).exportCsvinstore.ts(a no-op) goes once the workspace view stops calling it. - Fixed 24 October 2026:
definition.tscites its budget row (bioreactor-definition-chunk) instead of a number. hughmarkFlaginsetup.tsreports only the first failing Hughmark condition (else ifchain), whilecorrelationKlalists all of them inoutside.HANDOFF_CHECKS.ex(src/lib/handoff/params.ts:99) allows versions up to three digits and a leading digit in the id;parseExinpresets/index.tsandreadRunDataallow four digits and require a leading letter. Harmless while every version is 1.golden.test-util.tshard-codes themodel-2-prefix and theFLAG_REVISIONSsuffix table (-setup-2.2.0); the next model version needs both changed.- The design brief's fragment keys (
preset,z:JSONd,mode,ts,recipe), itsoffgas.tsandbuild-preset-runs.mts, and its stated chemostat closed formS* = 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. - 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). - 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
- Read each source and add a
Citationtopresets/citations.tswith DOI, pages and what was read on which date. - Write
src/lib/hosts/presets/<id>@1.tswith every id inPARAM_IDS, asourcefor each value (cited, converted with the conversion, fit with the method, illustrative with a reason, setting, derived or model) and a reportedrangefor kinetic, yield and composition values. Illustrative values must pass the tenfold test inpresets.test.ts. - Set
biology(crabtreePositive,qSCritwith source and strain,kineticsTemperature). A neworganismvalue needs theOrganismunion incontent/apps/bioreactor-simulator.words.ts,PresetVersion.organismintypes.tsand flag sentences for each outside-model code (flags.test.tschecks coverage). - Add the id to
PRESET_IDSandPRESET_VERSIONSinpresets/index.ts, toPRESET_WORDS(content/apps/bioreactor-simulator.preset-words.ts), and, if it gets a page, totasksincontent/apps/bioreactor-simulator.tsandtasks.marketing.tsx. - Add the published run to
publishedRunsinpreset-specs.tsand a three-change variant toTHREE_CHANGESingolden.test-util.ts. - Run
node pipeline/bioreactor-simulator/record-presets.mts,export-presets.mts,reference.py all(pinned environment),write-golden.mts, and the prebuild forpreset-runs.json; updateFIXTURE_COUNTS. At the merge, runrecord-presets.mts --freezeandwrite-golden.mts --freeze. - A new substrate needs
SubstrateIdandSUBSTRATESinconstants.ts, thesubstrateenum inparams.ts, the same entry inreference.py, andconstants.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
- Add the term to the right-hand side in
systemFor, its partial derivatives indynamicJacobianand, if time-dependent,dfdt. Add any new state toIX,N_STATES,FIXED_ATOLand theatolForceilings. Extend the balances insimulateif carbon or electrons move. - Restate it independently in
reference.py(do not import engine code) and inquasi-steady.test-util.tsif it touches oxygen. - Run the Jacobian checks in
model.test.ts; add right-hand-side cases tostep.json; add scenarios that exercise the term with non-default values. - Bump
MODEL_VERSIONandGROWTH_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. - 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. - 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):
- Add the code and its rule to
flag-codes.ts(never rename or reuse a code), its words tocontent/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. - Find the golden records whose discrete results change; if any default preset run's flags change, stop: that is the CSO's question.
- For each changed record add a
FLAG_REVISIONSentry (base,suffix,added), mark the base entrysupersededby hand ingolden/manifest.json(state,on,reasononly), runwrite-golden.mtsand, before the merge,write-golden.mts --freeze. - 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.