Calc
The blank sheet remains a directly usable calculator. While the selected task has no explicit input keys, a compact Examples row above the form offers the three existing worked cases. Selecting one loads its values through loadExample; visiting the sheet alone loads none.
Saved serial plan to a Bench session
The serial task's contextual Start bench session action uses the optional saved-owner bridge. It requires an exact saved task in a Study; it does not save an editor implicitly or create a result. Review captures its name, destination, saved revision, complete raw plan and qualifications, then creates a separate execution Object. Unknown creation outcomes reuse the original request.
plate-plan-capture.ts is the shared creation boundary for print and execution.
It captures raw serial and plate-sheet inputs/output before formatting.
bench-execution-plan.ts checks saved method pins, input/table/sheet mirrors,
compound identity and consumed bindings, then hashes the retained body. Only
derived concentration and carrier-fraction table comparisons allow the
independent reference's 1e-9 relative tolerance, with zero exact. All other
fields, units, identities and order compare exactly. Hashes always cover the
actual retained bytes; a numerically close oracle body is not claimed to have
the same hash.
The initial supported source subset has explicit saved inputs, current known method pins and no reference-data constants: typed/material and recorded-stock sources covered by the published specimens. Formula/reagent-derived sources are refused until separately referenced. Historical readers, reports and recovery consume the frozen body without importing this creation adapter. Source admission and plan-reader references are in Calc verification 0.10.0.
Creation imports shared pure leaves for recipe-envelope reading, compound reading, serial component construction, stock-binding scalar checks and plate geometry/placement. Existing public modules re-export the same implementations. This keeps unrelated calculator copy, CSV import review and other recipe variants outside the plan-capture download, without introducing a second scientific implementation or hiding it behind another lazy import.
D-0060 (11 October 2026): the method inspector opts into docking once the selected workbench has explicit inputs, with blank starts transiently hidden before hydration. Only a blank/work transition changes that transient state; explicit choices made on this visit are preserved across edits and blank/work transitions. Phones remain hidden until opened.
Calc (registry slug lab-calculators, product name "Drybench Calc", D-0019) is the suite's bench calculator: one bench sheet with twelve calculators for stocks, dilutions, serial plans (with a plate preparation sheet), absorbance, buffers, recipes (with a pH line at temperature), master mixes and everyday preparations (cell seeding, OD600 estimates, centrifuge, percent solutions, antibody dilution), sixteen answer-first task pages (one of them a lab unit converter that exists only as a task page), and the shared units, dilution and preparation modules other apps reuse. This page describes how it is built, every calculation it performs, how its results are stored and checked, and where the code diverges from its design documents.
Purpose, users and status
Calc answers "how much do I weigh, and how do I dilute it?" for students, postdocs, technicians and assay scientists (docs/design/brief-lab-calculators.md §1 and §2, design intent). Its distinguishing features, as implemented:
- Salt forms, hydrates and purity are handled explicitly. A compound arriving from Molecule carries the formula of the whole input and of its parent, and the sheet weighs on the whole input and states the shortfall of weighing the parent.
- "Weigh what you weighed": the solvent volume for a mass actually on the balance.
- Bench flags: transfers below 2 µL and solvent (DMSO) above 0.5 percent in a serial plan, both worded as rules of thumb.
- Every constant is cited with a constants-table id, and every method on the sheet but the 2 µL rule and the OD600 paired calibration is compared with an independent oracle on a published fixture (verification versions 0.1.0 to 0.6.0; A260 joined in 0.4.0, SCI-147, its refusals in 0.5.0, SCI-148, and the A280 concentration and the A260/A280 ratio in 0.6.0, SCI-149).
- The pure calculation modules (
src/lib/units/,src/lib/dilution/,src/lib/preparation/, and the protein part ofsrc/lib/seq/) are shared with Assay, Sequence, Protein, Plasmid and Bioreactor, so the same number appears in every app.
Registry facts (content/apps/lab-calculators.ts): status: "preview", version: "0.2.0", updated: "2026-10-17" (moved for the a260-conventions source read that day, SCI-146), workspaceGroup: "bench", accent indigo, mark grid (content/apps/identity.ts). The short name "Calc" and full name live only in content/app-names.ts. Calc was built in this repository (6 October 2026) and has no legacy mbio.tech path to redirect. The registry marks it notMedicalAdvice: true; its first limit says it is "for research and education only, and not medical advice".
Addresses and pages
APP_SEGMENTS["lab-calculators"] is calculators (content/apps/routes.ts). Route groups are described in Architecture.
| Address | Served by | What it is |
|---|---|---|
/calculators |
src/app/(marketing)/[app]/ |
Product page, copy from content/apps/lab-calculators.ts (entry, page) |
/calculators/app |
src/app/(workspace)/[app]/app/page.tsx |
The bench sheet. State lives after #s=; never indexed. PAGE_IDS is empty (src/apps/lab-calculators/page-ids.ts), so there are no other /calculators/app/<page> pages. |
/calculators/<task> |
src/app/(tasks)/[app]/[task]/page.tsx, content from src/apps/lab-calculators/tasks.marketing.tsx (looked up through src/apps/tasks.ts) |
Sixteen indexable task pages, listed below |
/docs/calculators |
docs route, copy in content/apps/lab-calculators.docs.ts, its Methods and Verification definitions in content/apps/lab-calculators.docs-entries.ts (E6, 17 October 2026: sample normalisation, the plate preparation sheet, the liquid-handler worklists and the bench checklist added, dated 17 October) |
Methods, exports, verification (with the generated fixture table), security overview, limits |
/changelog#lab-calculators |
changelog route | Release notes (content/apps/changelog/) |
/share/calculators/<task> |
src/app/share/ |
Rich preview of a shared link; the subject is the public calculator id (src/lib/sharing/links.ts maps Calc to the task key and CALCULATOR_IDS) |
/verification/lab-calculators/<version>/<file>.json |
public/ |
Published oracle fixtures |
/data/lab-calculators/reagents.<hash>.json |
public/ |
The reagent table; immutable cache rule in the registry's cacheRules |
Task pages (registry tasks, components in tasks.marketing.tsx):
| Task id | Island | Worked example (computed at build by the engine unless stated) |
|---|---|---|
molarity |
molarity, with compound card | 10 mM imatinib mesylate (C30H35N7O4S) in 1 mL |
dilution |
dilution | 10 mM to 50 µM in 200 µL |
serial-dilution |
serial | 3-fold, 8 points, triplicate 50 µL wells from 10 mM in DMSO (sentences hard-coded) |
a280 |
a280 | Human insulin precursor P01308 at A280 = 0.5 (sentences hard-coded) |
stock-from-mass |
molarity in volume mode | 11.37 mg imatinib mesylate to 10 mM |
salt-form |
molarity, purity 98 | Imatinib against imatinib mesylate |
tris-ph-temperature |
buffer | Tris pH 8.0 at 25 °C taken to 4 °C |
reagents |
none (server-rendered table) | The 75-row reagent table read from public/ at build |
master-mix |
none | The manufacturer's 20 µL composition (MAN0025706 B pp. 12–13, the fixture's thermo-96-well-standard case) planned at build; a link opens it in the sheet (task=mastermix&mm=…) |
centrifuge-rcf-rpm, percent-solutions, cell-seeding, antibody-dilution, od600-cell-estimate |
prep-centrifuge, prep-percent, prep-cells, prep-antibody, prep-od600 (the preparation kind's form, seeded through ep) |
Each kind's own example (for OD600 a stated example of a person's own calibration, not a reference factor) |
unit-converter |
unit-converter (UnitConverterPanel; no bench-sheet equivalent) |
1.1 µL to L, asserted at build to be exactly 0.0000011 |
buffer-recipe |
none | Tris base (C4H11NO3) 50 mM in 1 L, pH 8.0 wanted at 4 °C and adjusted at 25 °C, planned at build by planBufferRecipe; a link opens the same recipe with its pH block in the recipe table |
Each island is a separate next/dynamic chunk (src/apps/lab-calculators/task-islands.tsx). IslandId is the calculator ids except recipe, master mix and preparation, plus the six Wave 3 page ids; the five preparation islands share islands/prep-shared.tsx. The master-mix and buffer-recipe pages are server-rendered with no island, so they have no client budget. An island renders on the server, marks itself data-ready once hydrated, seeds its fields from the page's example until the person edits, and links to the bench sheet with its state (islands/shared.tsx, useIslandState, IslandFrame).
File map
| Path | Role |
|---|---|
content/apps/lab-calculators.ts |
Registry content and product-page copy: capabilities, tasks, sources, sourceMeta, excluded sources, dataPolicy, oracles, verificationSets, cacheRules, cross-links, objects, FAQ |
content/apps/lab-calculators.entry.ts |
Full entry with identity (withIdentity), imported by the app chunk |
content/tools/lab-calculators.ts |
detail: the write-up, the six cited sources and the nine limit sentences |
content/apps/lab-calculators.ids.ts |
CALCULATOR_IDS alone, so the address schema and the task islands read the ids without the workspace copy |
content/apps/lab-calculators.workspace.ts |
OPERATION_IDS (and a re-export of CALCULATOR_IDS) and the workspace copy (panels, inspector equations, exports, arrival strip, tabs) |
content/apps/lab-calculators.columns.ts |
The column layouts' words (the UI redesign of 13 October 2026): the stock screen's three cards and recipe summary, the serial planner's three cards and plan checks, the Method panel |
content/apps/lab-calculators.shell.ts, .pages.ts |
The words the shell and the task-page frame need (names, commands, inspector titles; task-page explainers and worked lines), apart from the sheet's copy |
content/apps/lab-calculators.sheet.ts |
The five calculator groups and the "cannot save yet" sentences |
content/apps/lab-calculators.methods.ts |
METHODS (the MethodRef records chips, exports and pages read) and CONSTANTS_TABLES |
content/apps/lab-calculators.verification.ts, .dates.ts |
The badge sentences; FIXTURES_DATE (2026-10-06) and PREPARATION_DATE (2026-10-10), kept apart so the registry entry carries no verification sentences |
content/apps/lab-calculators.static-methods.ts |
Generated by scripts/prebuild/lab-calculators-static-methods.mjs: chip label and title for task islands |
content/apps/lab-calculators.{master-mix,intermediate,preparation,workflow,docs}.ts |
Copy for those features and the docs page |
content/apps/lab-calculators.preparation.{common,summary,cells,centrifuge,percent,antibody,od600}.ts and their .notes.ts |
Preparation copy split by kind: field labels, receipt rows, qualifications and refusal sentences |
content/apps/lab-calculators.{plate-sheet,buffer-recipe,converter,od600-calibration,island}.ts |
Copy for the plate preparation sheet, the recipe table's pH section and buffer-recipe page, the unit converter, the OD600 calibration section and the task-island frame |
src/apps/lab-calculators/definition.ts |
AppDefinition: Main, inspector, shortcuts, commands, search, share schema, object tabs |
src/apps/lab-calculators/entry.tsx, workspace.tsx |
Entry chunk (size-budget marker drybench-entry:lab-calculators); Main with skeleton and ToolBoundary |
src/apps/lab-calculators/views/bench-sheet.tsx |
The bench sheet (1,626 lines): arrivals, compound resolution, results, keep, continuation, Workspace capture, exports, render, recipeFor, sheetRows |
src/apps/lab-calculators/store.ts |
publishBench/readBench/useBench: the sheet's state for shell-rendered parts |
src/apps/lab-calculators/inspector.tsx |
Method inspector |
src/apps/lab-calculators/exports.ts |
Export menu items and provenance block |
src/apps/lab-calculators/objects.ts |
Inbound rules, recipeMethods, recipeInput, compoundInput, recipePayloadFields, newRecipeId |
src/apps/lab-calculators/object-tab.tsx, object-tabs.ts |
Calc tabs on compound, target, sequence, construct and recipe pages |
src/apps/lab-calculators/provenance.ts |
SLUG, methodProvenance, constants-table to source mapping |
src/apps/lab-calculators/workspace-adapter.ts |
CALC_WORK_SCHEMA, calculatorRecipeState, captureCalculatorWorkspaceResult |
src/apps/lab-calculators/workspace-state.ts |
Lossless capture and restore of the resolved compound; boundCalculatorCompound |
src/apps/lab-calculators/serial-handoff.ts |
serialTableFor: the serial plan as a table payload |
src/apps/lab-calculators/tasks.marketing.tsx, task-islands.tsx, islands/*.tsx |
Task pages and their islands (prep-*.tsx and prep-shared.tsx for the preparation kinds, unit-converter.tsx) |
src/apps/lab-calculators/verification-table.marketing.tsx |
Server-only table comparing engine output with each fixture for the docs page |
src/components/tools/lab-calculators/address.ts |
calculatorsSchema (the #s= keys and checks), taskFromState, TASK_KEYS, COMPOUND_KEYS, JSON_INPUT_LIMITS |
src/lib/dilution/normalization.ts, panels/normalization-panel.tsx, normalization-worklist.ts |
Sample normalisation for gel loading and its Tecan worklist |
src/lib/export/liquid-handlers/ (tecan.ts, echo.ts, types.ts), plate-sheet-worklist.ts |
Tecan worklist and Echo pick-list writers; the plate sheet's worklist |
src/components/suite/bench/glove-mode-view.tsx |
The bench checklist |
src/lib/omnibox/scientific-sniffer.ts |
The command palette's solver, accession recogniser and cross-app bench-task intent matcher |
src/components/tools/lab-calculators/address-deep.ts |
DEEP_CHECKS and deepDropped: the full readers of the JSON keys ep, mm, ip, ps, ns, run by the bench sheet once its state is ready |
src/components/tools/lab-calculators/operations.ts, operation-names.ts |
Groups, operations and preparation kinds |
src/components/tools/lab-calculators/model.ts, model/*.ts |
One pure model module per calculator, plus common.ts, display.ts, compound.ts |
src/components/tools/lab-calculators/panels/*.tsx |
One panel per calculator, plus compound card, quantity field, reagent picker, label dialog, shared parts; calculator-layout.tsx (the two-column layout every calculator uses on the sheet and its task page, D-0084), needs.tsx (the qualified line, with no copy of its own so the islands carry none), stock-summary.tsx (the stock's recipe steps under its answer) and method-panel.tsx (the Method and result panel under every calculator's answer), both sheet-only; plate-sheet-section.tsx and plate-sheet-panel.tsx (the Serial tab's plate sheet, loaded on demand) with plate-sheet-rows.ts (its step and source words and its bench-sheet CSV rows), recipe-ph-section.tsx, unit-converter-panel.tsx, master-mix-summary.tsx (the read-only summary Assay reuses) |
src/components/tools/lab-calculators/panels/preparation/ |
kind-panel.tsx and one form per kind (cells-form, od600-form with od600-calibration-section, centrifuge-form, percent-form, antibody-form), fields.tsx, result.tsx |
src/components/tools/lab-calculators/preparation/ |
Per-kind glue for the sheet (kind.ts, the PreparationKindDef with its headline rows, the result rows the answer panel shows as tiles; all.ts, display.ts and one module per kind) |
src/components/tools/lab-calculators/model/preparation-drafts.ts |
switchPreparationKind: per-kind drafts kept in memory for the session |
src/components/tools/lab-calculators/input-rows.ts |
The active calculator's inputs as typed, with units, for the CSV; which answer sections a calculator's CSV carries; each row's method version |
src/components/tools/lab-calculators/{intermediate,master-mix,preparation}-export.ts |
Feature CSV writers |
src/components/tools/lab-calculators/reagents.ts, reagents-manifest.ts |
Reagent table type, loader and search; generated manifest |
src/lib/units/ |
Units, exact scaling, CIAAW 2021 atomic weights, formula parser and weights, quantity entry; unit-converter.ts, temperature.ts; version.ts (UNITS_VERSION, no imports) |
src/lib/dilution/ |
C1V1, stock, DMSO and pipette rules, serial planner, recipe, buffer, master mix, intermediate planner; allowances.ts (the one allowance rule), plate-sheet.ts and plate-sheet-method.ts, buffer-recipe.ts; version.ts (DILUTION_VERSION, no imports) |
src/lib/preparation/ |
Everyday preparation: contract.ts (readers and empty inputs), calculate.ts (dispatch and PREPARATION_METHODS), kinds/ (one input module, one calculation module and one receipt per kind, plus common.ts, calc-common.ts, readers.ts, result.ts, empty.ts), od600-calibration.ts |
src/lib/table/well-ranges.ts, plate-map.ts |
Well-range parsing and the shared proposePlacement the plate sheet uses |
src/lib/seq/protein-mass.ts, absorbance.ts, methods-protein.ts |
ε280, average protein mass, A280 and A260 (shared with Protein and Sequence) |
src/lib/handoff/payloads/{compound,recipe,table,plate-sheet}.ts |
The hand-off payloads Calc reads and writes |
src/lib/workspace/calc-projection.ts, intermediate-projection.ts, everyday-preparation-projection.ts |
Recorded-result projections for Bench, never recomputed |
pipeline/lab-calculators/ |
Reagent curation and build, oracle script reference.py, the Wave 1 and Wave 3 oracle scripts (*_reference*.py) and publish_preparation.py, fixture hash manifest |
e2e/lab-calculators/, e2e/calc-preparation.spec.ts, e2e/intermediate-dilution.spec.ts, e2e/perf/two-second.spec.ts |
Browser tests |
scripts/size-budgets/lab-calculators.json |
Size budgets for the definition chunk, label dialog and islands |
docs/design/calc/tokens.py is a colour-contrast script for suite accent tokens; it has no role in Calc's calculations.
How the app is put together
Shell integration
definition.ts hands the shell:
Main:CalculatorsMain, which loadsBenchSheetwithnext/dynamic(ssr: false) behind a skeleton.inspector:MethodInspector, dynamically imported;inspectorLayout: { defaultState: "docked", startHidden: true }opts into docking for explicit inputs in the selected workbench. Blank starts stay hidden, and explicit choices on this visit survive blank/work transitions (D-0060).rail: []: navigation lives in the sheet's tab row. The sheet registers its own Tools & sections (useRail): reagent table, stock label, and the three examples.- Shortcuts
1to8select the eightCALCULATOR_IDS(the registry'sshortcutslist now names all eight);lopens the stock label (only when the sheet is keepable);ropens the reagent picker. Palette commands mirror these and add the three examples. search: the palette's search opens the reagent picker.share:calculatorsSchema, withcontentsnaming what a link carries.usesUrlState: true.
Because the definition is rendered by the shell outside the sheet's React tree, the sheet publishes a BenchSnapshot (task, operation, keepable, compoundName, constants, actions) through store.ts on every change; commands and the inspector read it with readBench() or useBench(). See Shared shell for the host contract.
Calculators, operations and groups
There are two id lists in content/apps/lab-calculators.workspace.ts:
CALCULATOR_IDS(the addresstaskvalues,content/apps/lab-calculators.ids.ts):molarity,dilution,serial,a280,buffer,recipe,mastermix,preparation.OPERATION_IDS(what the sheet offers): the same minuspreparation, plus its five kindscells,od600,centrifuge,percent,antibody.
content/apps/lab-calculators.sheet.ts groups operations into five line tabs: Solutions and buffers (molarity, recipe, percent, buffer), Dilutions (dilution, serial, antibody), Reactions (mastermix), Absorbance (a280), Cells and centrifuge (cells, od600, centrifuge). A group with more than one calculator shows a "Calculator" select in the view toolbar at the top of the tab panel (D-0084); the page's keys (Save to Bench and ⋯) sit at the right end of the tab row, on its hairline, through ViewTabs actions (the CEO, 14 October 2026; beneath the tabs where the row is narrower than 48 rem). operations.ts maps between the two lists: operationFromState reads task and, for preparation, the kind inside ep; operationTarget returns the task and the preparation kind; groupEntry reopens a group on the calculator last used in it this session, else the preparation kind the address already holds, else the first. Choosing a different preparation kind stashes the visible kind's inputs and recipe id and brings back the next kind's, or starts its empty inputs with a new recipe id (switchPreparationKind, model/preparation-drafts.ts); the drafts live in memory for the session only, and the address carries the visible kind alone.
taskFromState (address.ts) returns the address's task when valid. Otherwise a protein opens a280: pseq, id, or seq with alphabet=protein, even beside a compound (changed 16 October 2026; before, any compound kept the weighing calculator); a seq with no compound and no alphabet also opens a280. Anything else opens molarity. A compound with an unlabelled seq still opens molarity.
The bench sheet
BenchSheet (views/bench-sheet.tsx) holds no scientific state of its own beyond the recipe-table rows; everything else is in the #s= fragment through useUrlState(calculatorsSchema). On each render it:
- Decodes what arrived:
cpd(compound payload),seq(residues) andrecipe(recipe payload), each possiblyz:compressed, withuseDecoded. Arecipeof kind stock, dilution, serial-dilution, master-mix or everyday-preparation is restored into the address once throughcalculatorRecipeState; amulti-componentrecipe seeds the recipe rows. - Looks up a reagent (
rg) in the reagent table, and a storedsequenceobject whose aliases includeuniprot:<id>when only an accession arrived. - Resolves the compound (
resolveCompound, then a Workspace input binding or a saved compound snapshot when either applies). - Computes all eight calculators' results in one
useMemo(results), the serialtablepayload and the recipe to keep (recipeFor). - Publishes the bench snapshot, sets the shell subject, registers exports and announces the visible answer once it has settled for 700 ms (it clears an earlier announcement when the answer changes or is withdrawn).
When a compound arrives and the inbound report has been made, the sheet calls arrive() (motion/effects.ts, the shared shell): the compound's name travels from the arrival strip to the compound card ([data-compound-card]) and the card is tinted once, or only tinted when the card is off screen or the effects are off; the timers are cancelled on unmount. The concentration's unit select keeps its meaning (it re-reads the typed value in the new unit, it does not convert), so the "Converted to" receipt of NumberField's converting units does not appear there.
All eight panels stay mounted; only the selected one is visible (hidden), so unfinished fields survive switching. Each panel is wrapped in EntryValidity: while any field holds text that cannot be read, the answers are replaced with a "fix the highlighted field" line, and the compound card's invalid state is inherited by the three compound calculators.
Layout (the redesign atlas of 13 October 2026, D-0084, frames 040 to 064; before it the UI redesign's mock-ups 05 and 06). The page's one H1 is the subject (the compound, the identified protein or the saved task's name), or with none the selected tab's name ("Solutions and buffers", "Dilutions"), with the chosen calculator as a subheading under it ([data-calculator-operation]), so choosing another calculator in the tab's select changes the subheading and never the title (the CEO's feedback of 14 October 2026; before it the H1 was the calculator's name, or "Calculations for the bench" for a stock or dilution with no compound); its accent bar and identity lines follow; the five group tabs rule across the column; under them the ViewToolbar holds the Calculator select at the left (only where the group has more than one calculator, so Reactions and Absorbance show the keys alone) and the page's keys at the right in R1 order (the split Save to Bench, Record result, then the ⋯ menu with Keep, the stock label and Use a Bench input; on A280, buffer and the recipe table the disabled Save work key with its reason). Every calculator then lays out with CalculatorLayout (panels/calculator-layout.tsx): two columns from a 50rem main container (40/60, inputs/answer), one below, DOM order left then right, the whole layout one region named by the calculator's heading (the inputs card draws the heading; the answer panels are regions inside it), so a selector scoped to the calculator reaches its inputs and its answer alike. Left: the compact compound/reagent picker on the stock, C1V1 and serial views, then the calculator's panel (its name in the serif face, read out but not shown when the H1 or its subheading already says it, R4; the master mix's is "Reaction setup", the frame's own title, so always shown; its one-line lede; its fields in the frame's order, in one column, two from 30rem of the panel, so a phone keeps one) and the panels under it: "Compound on the sheet" for the stock, C1V1 and the serial plan (a disclosure row on a serial plan with no compound yet), "Preparation" with a disclosure row for the plate sheet (serial) or the constrained intermediate planner (C1V1), the editable "Components" rows and "Buffer recipe" row (recipe table), "Components" and "Preparation options" (master mix), "A260 rules of thumb" (A280). Right: the answer panel ("Answer"; "Dilution plan", "Preparation", "Protein result" where the frame names it) with the answers as the atlas's tinted tiles (Answer in panels/parts.tsx, one ReadoutStrip tone="tint" tile each with its copy key, note and method chip; the preparation kinds' headline rows as one strip, the summary line first), the inputs the number rests on and the recipe steps (stock-summary.tsx) for a stock, the serial table with its footnotes, the master mix's tables, and the keys that close the answer (Stock label, Start a serial plan from this stock, Open plan in Assay, Use preparation in Assay); then "Bench flags" (serial) or "Nucleic-acid estimate" (A280); then "Method and result" (panels/method-panel.tsx: the equation sentence in the serif face, one line on units and rounding, the provenance chips; the inspector keeps the constants, verification and versions, D-0060). The sheet lends each visible calculator these panels through slots (CalculatorSlots: compound, actions, method; the serial panel's plateSheet, the molarity panel's summary), so each is in the page once. No region is ever empty: with incomplete or refused inputs the answer region shows the qualified line (Needs, never a dash or a zero), the recipe steps say when they appear, the plate sheet's row says the series does not plan yet, an empty components list is an action placeholder, and the Nucleic-acid estimate asks for the A260 reading. The task islands use the same layout inside their own main container (islands/shared.tsx), so a task page shows its calculator in the same two columns, the unit converter as frame 064 draws it.
Address state
The fragment schema is calculatorsSchema in address.ts. Checks run when an address is read (acceptInbound in src/components/suite/store/url-state.ts); an invalid value is dropped with a reason. Inputs are stored as typed with a unit key beside each quantity, never as results. General mechanics are in Storage and state.
| Keys | Check | Used by |
|---|---|---|
task |
one of CALCULATOR_IDS |
subject; changing it pushes history |
cpd, name, seq, alphabet, id, range, recipe |
the hand-off dictionary's validators (HANDOFF_CHECKS) |
arrivals |
rg |
kebab-case id | reagent |
fw, lw |
positive number (at most two-digit exponent) | typed and vial/CoA formula weight, g/mol |
pur |
positive and at most 100 | purity, percent |
mode |
mass, volume, concentration |
molarity |
c, v, m, c1, v1, cf, vf, top, well, sc, rv, fold, a280, a260, path |
positive number | quantities |
cu, vu, mu, c1u, v1u, cfu, vfu, topu, wellu, scu, rvu |
any unit the unit table knows, or %w/v |
unit beside each quantity |
pts |
integer 2 to 24 | serial points |
rep |
integer 1 to 96 | replicate wells |
dmso, temp, ph |
non-negative number | solvent percent, °C, pH |
sol |
text up to 120 characters | solvent name |
scb |
text up to 200 characters | stated basis of the stock concentration (Repeat setup, a recorded stock) |
cru, c1s |
1 |
markers from a recorded stock: Serial's carrier not recorded (serial in TASK_KEYS); C1's concentration never converted to another dimension (dilution) |
pseq |
letters, * and -, at most 20,000 |
pasted protein sequence |
tag |
none, his6 |
A280 tag |
eps |
cystines, reduced |
A280 convention |
na |
dsDNA, RNA, ssDNA |
A260 |
buf |
kebab-case id | buffer |
ip, mm, ep, ps |
shape only at this step: a JSON object with v: 1 within JSON_INPUT_LIMITS (4,000, 30,000, 15,000 and 4,000 characters) |
JSON inputs for intermediate dilution, master mix, preparation and the plate preparation sheet |
The schema checks the four JSON keys for shape only, so it imports no reader and every task island stays small (CTO Wave 3 memo §2.1). Once its state is ready the bench sheet runs the full readers (deepDropped in address-deep.ts: intermediateConstraintsFromJson, masterMixFromJson, preparationFromJson, plateSheetFromJson), clears each key that fails and reports it with the same "Ignored {key}" words a one-step check gives; every other reader of these keys also goes through the full reader. ps is the one key Wave 3 added; it belongs to serial in TASK_KEYS. The recipe table's pH line travels inside the recipe payload (ph), not in a key of its own.
TASK_KEYS names the keys each calculator owns (so a reset or a task page clears or seeds only its own fields); COMPOUND_KEYS (cpd, name, rg, fw, lw, pur) are shared. DEFAULTS in model/common.ts supplies units (cu mM, vu mL, mu mg, c1u mM, v1u µL, cfu µM, vfu µL, topu µM, wellu µL, scu mM, rvu mL) and values (fold 3, points 8, replicates 3, DMSO 100, path 1 cm, temperature 4 °C, pH 8, convention cystines, nucleic acid dsDNA, mode mass, buffer tris, solvent DMSO).
Display rounding
Engines never round. Display helpers in model/display.ts:
show(value, unit, digits = 4)converts from the base unit withfromBaseand formats withformatSig:digitssignificant figures, rounded half away from zero (roundHalfAway, which nudges byNumber.EPSILON × scaledso that 2.675 rounds to 2.68), en-GB grouping, trailing zeros not shown.bestUnitFor(value, allowed)picks the unit whose value lies in [1, 1000), else the one closest to that range.- Formula weights are rounded on their exact decimal expansion (
roundDecimalString, half away from zero), two places on screen and three in the CSV. - Master-mix volumes show six significant figures in µL, preparation and intermediate quantities eight, both switching to scientific notation for positive values below 0.001 so a small quantity never shows as zero.
- Buffer pK and pH use
toFixed(2)(binary rounding) on screen andtoFixed(3)in the CSV.
The units system
src/lib/units/ is the suite's single unit table and formula-weight engine (UNITS_VERSION 1.0.0). It is pure TypeScript with relative imports, so Node tests and pipeline scripts import it directly. The object store's measurement register (src/components/suite/objects/measurements.ts) builds its unit options from the same table.
The unit table
UNITS in quantity.ts gives each unit a canonical symbol (micro sign U+00B5), a Dimension, an exact power-of-ten exponent exp to the dimension's base unit, accepted aliases and a spoken form. Base units are listed first.
| Dimension | Base | Other units (exponent) | Notable aliases |
|---|---|---|---|
| mass | g | kg (3), mg (−3), µg (−6), ng (−9), pg (−12) | ug, mcg |
| volume | L | mL (−3), µL (−6), nL (−9) | l, litre, liter, ml, ul, uL, µl, nl |
| amount | mol | mmol, µmol, nmol, pmol, fmol | umol |
| molarity | M | mM, µM, nM, pM | mol/L, mmol/L, uM, µmol/L, nmol/L, pmol/L |
| massConcentration | g/L | mg/L (−3), µg/L (−6), kg/L (3) | g/L also reads mg/mL, µg/µL; mg/L also reads µg/mL, ng/µL; µg/L also reads ng/mL; kg/L also reads g/mL |
| molarMass | g/mol | kg/mol (3) | Da, kDa |
| length | cm | mm (−1) | |
| area | cm² | mm² (−2), m² (4) | cm2, mm2, m2 |
| cellCount | cells | ||
| cellDensity | cells/L | cells/mL (3), cells/µL (6) | cells/uL |
| cellSurfaceDensity | cells/cm² | cells/mm² (2) | cells/cm2, cells/mm2 |
| absorbance | AU | mAU (−3) | au, A, mau |
Temperature is deliberately absent: it has an offset, not a scale, and the buffer module converts °C to K itself (kelvin). Percent weight per volume is not a unit in the table; the calculators carry it as the pseudo-unit %w/v and convert with percentWvToGramsPerLitre (× 10, exactly). UNIT_DISPLAY is exported but empty.
Exact decimal scaling
scaleByPow10(value, n) computes value × 10ⁿ on the number's shortest decimal string: it splits Math.abs(value).toString() into digits and exponent, adds n to the exponent and parses "<digits>e<exp>". The result is the double nearest the exact decimal, so 1.1 µL becomes exactly 0.0000011 L and back to 1.1, and a chain of conversions never accumulates binary error (contracts §5.6). convert, toBase and fromBase all use it; convert throws when dimensions differ. scaledDecimalString gives the same value as a plain decimal string. bestUnit (module level) picks the largest unit that keeps the number at or above 1; the calculators use their own bestUnitFor instead.
The molarity arithmetic helpers in convert.ts (molesIn, massOfMoles, molesOfMass, molarityFromMassConcentration, massConcentrationFromMolarity, molarityFromMass) work on base units with ordinary floating-point multiplication and division; only unit conversion is decimal-exact.
Spelling normalisation
normaliseUnitText applies NFKC (which maps both U+00B5 and U+03BC to U+03BC), removes whitespace and replaces U+03BC with U+00B5. unitFor then looks the text up among symbols and aliases; if that fails and the text starts with u, it retries with the micro sign (uM → µM). Lookups are case-sensitive: mM and MM are different, and only the aliases listed in the table (such as ml, ul) are case variants. unit() throws on an unknown symbol, as a programming error.
Typed and pasted quantities
src/lib/units/quantity-entry.ts lets a unit-bearing field accept "10 mM" typed or pasted. parseQuantityPaste returns null for a bare number (the normal number path handles it), refuses text with a unit of another dimension ("10 g" into a concentration) with the incompatible message, and otherwise converts into the closest permitted unit: the typed spelling if the field offers it, else the first offered unit with the same symbol, else the current unit. convertEnteredQuantity reports overflow when a non-zero value converts to zero or a non-finite number. QuantityField (panels/quantity-field.tsx) wires this in and also offers an explicit "convert" disclosure that rewrites the value into another offered unit of the same dimension, with a spoken receipt.
Formula weights
atomic-weights.ts holds the IUPAC CIAAW Atomic Weights 2021 table with CIAAW's 2024 revisions of Gd, Lu and Zr, read on 6 October 2026, as integers in units of 10⁻⁴ g/mol (SCALE = 10_000). Elements CIAAW gives as an interval carry the conventional value and are flagged interval (H 1.008, Li, B, C 12.011, N 14.007, O 15.999, Mg, Si, S 32.06, Cl 35.45, Ar, Br, Tl, Pb); others carry the standard value abridged to five significant figures (Na 22.990, K 39.098, P 30.974). Elements without a standard atomic weight (Tc, Pm, Po onward, apart from Th, Pa, U) are absent and a formula naming one is refused. The constants-table id is ciaaw-2021.
formula-mass.ts:
parseFormulareads element symbols with counts, nested( )and[ ]groups with multipliers, adducts joined by·,.or*with an optional leading multiplier (CaCl2·2H2O,MgSO4.7H2O), and a trailing charge (SO4 2-,Fe+3,NH4+,H2PO4-; U+2212 minus accepted). The charge is reported and does not change the mass. Errors areFormulaErrorwith a position. A count of zero after an element is refused.formulaMasssumsscaled × countas integers, so the weight is exact: imatinib mesylate is exactly 589.715 and displays as 589.72, where a float sum would give 589.7149999…. It returnsscaled,value(scaled / 10⁴), the exact decimaltextand the interval elements present (the chip names them).hillFormulaorders C, H, then alphabetical (all alphabetical without carbon) and appends the charge.roundDecimalStringrounds a decimal string half away from zero with carries;formatFormulaMassuses it.parentFraction(parent, whole)returns the parent's share of the whole input's weight, the shortfall1 − share, and the counter-ion mass, from the two scaled integers.
FORMULA_MASS_METHOD (formula-mass 1.0.0, constants ciaaw-2021) cites Prohaska et al., Pure Appl Chem 2022;94:573-600, doi:10.1515/pac-2019-0603.
The compound on the sheet
Molarity, dilution (for mixed molar and mass units) and serial use one resolved compound, CompoundOnSheet, built by resolveCompound in model/compound.ts:
- A compound payload (
cpd, from Molecule or the picker's PubChem lookup) suppliesformula,parentFormula, the sender's weight (reported), InChIKey refs and dropped counter-ions. Both formulas are recomputed with CIAAW 2021. When the parent's computed weight is below the whole input's,saltholdsparentFraction; when the payload has no parseable formula butparentMass < formulaWeight, the shortfall comes from the sender's two weights instead. - Otherwise a reagent row (
rg) suppliesformulaAsWeighed(hydrate waters included) and aninchikey:ref. - Otherwise a typed
fwalone gives originlink.
The weight in force follows the brief's rule: the vial or CoA weight lw wins; else the computed formula weight (basis formula or reagent); else the sender's reported weight; else the typed fw. Purity pur is read as a fraction; a value outside (0, 100] sets purityInvalid, and the molarity calculator then refuses to compute rather than assume 100 percent (audit M5, calculators.test.ts). isSeedCompound keeps a task page's example compound labelled "Example" rather than "From the shared link".
Inside the sheet two further sources can override the resolved compound: a compound chosen through the Workspace input picker (boundCalculatorCompound in workspace-state.ts, applied only when the typed fw equals the bound record's weight and no cpd or rg is present), and the compound snapshot saved with a named task (readCalculatorCompound), applied while the compound keys still match the saved state.
The compound card (panels/compound-card.tsx) is the "Compound on the sheet" panel (D-0084, frames 040 to 044), its name added to the title for assistive technology: the origin pill ("From the molecule app", "From the reagent table"), the name, Pick a reagent and Clear the compound; the weight details as tinted tiles (the CIAAW weight with its formula, and for a salt the parent fraction with the parent's weight) with the chip and the sender's weight "as reported" beneath (the FAQ and limits say the two can differ in the second or third decimal because RDKit's own table uses S 32.067 and Cl 35.453); the salt-form sentence in a note box; the fw field only when no weight is known; and lw and pur as a "More inputs: purity, weight from the vial" disclosure row that stands open while either is set (an override is never hidden). Bare (bare), it is the body alone, for the sheet's serial plan, where it sits inside a disclosure row until a compound is chosen. Every field's helper sentence shows while the field has focus and stays its description (Field helpOnFocus; instrument design system §2 rule 4; UI design review of 11 October 2026, UI-25).
Calculators
Each subsection gives the model function, equations, inputs and units, validation, display and method record. "Base units" means mol/L, L, g, g/mol and g/L.
Molarity and stocks
Model molarityResult (model/molarity.ts), engine src/lib/dilution/stock.ts, method stock-salt-purity 1.0.0 plus formula-mass.
mass mode: m = C · V · FW / purity
volume mode: V = m · purity / (C · FW) ("weigh what you weighed")
concentration mode: C = m · purity / (FW · V); ρ = C · FW
parent equivalent: m_parent = m · MW_parent / FW; shortfall = 1 − MW_parent / FW_whole
- Inputs: concentration in M, mM, µM, nM or pM; volume in L, mL, µL or nL; mass in g, mg, µg or ng; formula weight and purity from the compound card; a free-text solvent name (default DMSO) used on the label.
- Order of checks: invalid purity → "needs purity"; no weight → "needs weight"; then the mode's inputs.
- Output units chosen with
bestUnitFor; mass concentration in g/L, mg/mL or µg/mL. The parent-equivalent answer appears only when both formulas were computed and a salt was detected; it uses the parent's computed weight against the weight in force (which may be the vial weight). - The result card shows the mode's answer as the headline (
Answer tone="headline"), the purity note as a pill, then rows restating the typed inputs, the weight in force to five significant figures with where it comes from (basis), the purity when below 100 and the solvent; the recipe summary card restates the same values as three steps. Both wait while the inputs are incomplete or an entry cannot be read. - Worked numbers checked in tests: 10 mM imatinib mesylate in 1 mL is 5.897 mg, 6.018 mg at 98 percent purity; the parent is 83.7 percent of the mesylate, a 16.3 percent shortfall.
C1V1 dilution
Model dilutionResult (model/dilution.ts), engine solveC1V1 (src/lib/dilution/c1v1.ts), method c1v1 1.0.0 (no chip; arithmetic).
C1 · V1 = C2 · V2; diluent = V2 − V1; fold = C1 / C2
- Exactly three of
c1,v1,cf,vfmust be given (need-three); each must be positive (not-positive); a result with C2 > C1 is refused (stock-weaker-than-final). C1 = C2 is accepted (fold 1, no diluent). - Concentration units: M, mM, µM, nM, pM, mg/mL, µg/mL, ng/µL and
%w/v. Molar and mass units on the two sides are reconciled through the compound's weight in force; without one the result isneeds-weight. With both sides in mass units the solution is computed in g/L (molar: false). - The answer shows the stock volume and diluent (or the solved concentration) with the fold. When the intermediate planner is active the two volumes are relabelled as the theoretical direct volumes.
- While
c1sis set (C1 came from a recorded stock, contract 05 5b), a molar C1 beside a mass-concentration final, or the reverse, is withheld asstock-dimension-holdwhatever formula weight is on the sheet: a stock's stated basis may be another form or an aggregate, so it is never bridged (CSO addendum A, 17 October 2026). Scaling within one dimension still applies. A hand edit ofc1orc1uclearsc1s, and the ordinary bridge returns.
Intermediate dilution planner
An optional section inside C1V1 (panels/intermediate-panel.tsx, loaded on demand), engine planIntermediateDilution (src/lib/dilution/intermediate.ts), contract and reader in intermediate-contract.ts and intermediate-input.ts, method one-intermediate-dilution 1.0.0 ("unit-tested"). Design: docs/design/intermediate-dilution-increment.md.
Inputs (ip, all required; explicit zeros allowed; values in litres and fractions, shown in µL and percent): minimum and maximum single transfer, usable stock available, intermediate vessel capacity, extra intermediate volume to leave behind, carrier name (or "none"), and carrier fractions in the stock, the intermediate diluent and the final diluent, plus the maximum final carrier fraction. No constraint is prefilled.
q = (C_final / C_stock) · V_final direct stock volume
direct plan if q and V_final − q are each 0 or within [min, max]
and F_s·q/V_final + F_f·(V_final − q)/V_final ≤ F_max
otherwise, for intermediate factor r (> 1) and stock draw x:
V_int = x·r; V_int,diluent = x·(r − 1); C_int = C_stock / r
t = q·r (final transfer); V_final,diluent = V_final − t; V_int − t ≥ extra
F_int = F_s/r + F_i·(r − 1)/r
F_final = F_f + (F_s − F_i)·q/V_final + (F_i − F_f)·q·r/V_final (linear in r)
max(min, min/(r − 1), q + extra/r) ≤ x ≤ min(max, available, capacity/r, max/(r − 1))
The code reduces these to one interval in r: a lower bound max(1 + min/b, cap/(cap − min), min/q, extra/(b − q)) with b = min(max, available), and an upper bound min(cap/min, 1 + max/min, (cap − extra)/q, max/q, root), where root is the positive root of r² − (1 + max/q − extra/q)·r − extra/q = 0 (the maximum-diluent condition). The carrier limit tightens one end depending on the sign of the slope; with zero slope an excessive intercept is refused as solvent-limit. Candidates are the lowest r that leaves a final diluent within range, and r = V_final/q (no final diluent). The smallest feasible r is chosen, so the smallest final transfer, then the smallest stock draw. Every candidate plan is checked by readIntermediatePlan, which re-verifies all balances and bounds with a relative tolerance of 1e-10 and no absolute volume tolerance.
Refusals: incomplete, invalid-range, invalid-source (no valid C1V1 above), insufficient-stock, solvent-limit, no-single-intermediate, numeric-range. Multiple intermediates and split additions are never searched. INTERMEDIATE_QUALIFICATIONS (four sentences) travel with every export and recorded result.
Serial dilution
Model serialResult (model/serial.ts), engine planSerialDilution (src/lib/dilution/serial.ts), methods serial-dilution 2.0.0, pipette-minimum-2ul 1.0.0 and dmso-carry-over 1.0.0 (dmso.ts). DILUTION_VERSION is 2.0.0 (src/lib/dilution/version.ts).
for replicates R, well volume W, fold f, points n, top C_top, stock C_stock, stock solvent fraction F:
forWells = R·W
tube volume V = R·W·f/(f − 1) for every tube, the last included (2.0.0)
transfer between tubes t = V/f = R·W/(f − 1); the last tube keeps V − R·W = V/f over
C_i = C_top / f^i
top tube: stock volume s = V·C_top/C_stock, diluent V − s
solvent at point i = F · C_i / C_stock
flag transfer-below-minimum when transferIn < 2 µL (strict; exact value, not the display)
flag solvent-above-threshold when solvent fraction > 0.005
- Inputs: top concentration and stock in molar units, well volume in volume units, fold (positive, must exceed 1), points (integer 2 to 24,
MAX_POINTS), replicates (integer, 1 to 96 by the address check), stock solvent percent (field limited to 0 to 100) and solvent name. - Refusals:
not-positive,fold-at-most-one,points-too-few,points-too-many,stock-weaker-than-top(equal is allowed). - While
cruis set (a recorded stock without a carrier or carrier percentage was selected), the plan is withheld ascarrier-unresolveduntil bothsolanddmsoare typed;DEFAULTS.solandDEFAULTS.dmsonever fill them, on screen or in a saved draft (pickInputs). Withoutcruthe existing DMSO-at-100% default still applies (SCI-156). - Output: stock draw and total diluent, the first tube's volume and the transfer, the bench flags, and a per-point table (cards below the
smbreakpoint). The desktop table sits in a labelled, keyboard-focusable scrolling region with a visible focus ring; the first row's transfer says "from the stock" in its cell and a footnote under the table says that each later point is pipetted from the tube before. The flags are grouped under "Plan checks" by rule (pipetting volume, solvent carried over, and the stock's strength when flagged), each row with a status light and either its flag sentences, unchanged, with their chips, or a clear line. The planner assumes each tube is made in one step from the previous one and that the diluent carries no solvent (registry limit). - Version 2.0.0 (the CSO's ruling of 10 October 2026) corrects the last tube: 1.0.0 made it to R·W with the same transfer t, so its concentration was C/(f − 1) of the tube before rather than C/f. The registry limit states that any plan printed from 1.0.0 is affected. Saved serial recipes pin the
dilutionversion they were made with; a read-time notice marks 1.x recipes on open, historical review and Assay handoff, without rewriting the original. Newly recorded transfer/carrier warnings become qualified result status. Reopening makes clear that the editable plan uses corrected volumes.dilution.jsonkeeps 1.0.0's cases; 2.0.0 is checked againstserial-2.json. - The plan becomes a
tablepayload (serialTableFor): top value and unit as typed, fold, points, replicates, layoutcolumns, solvent name and fractions, and concentrations in the top unit at full precision. This is what Assay reads.
First-tube preparation review (16 October 2026, D-0090). On the full bench sheet, a valid first transfer below the existing 2 µL convention offers Review an intermediate dilution, explicitly opening a new tab. The pure adapter model/serial-preparation.ts takes the existing serial result's full first tubeVolume (before transfer to the next tube), retaining the stock/top concentration units and well-volume display unit through the existing converter. It never substitutes the volume retained for wells. It refuses incompatible unit dimensions, non-finite/non-positive volumes and invalid carrier fractions before constructing new inputs.
The destination is an ordinary, independently editable C1V1 dilution with ip constraints; it reuses the existing lazy intermediate planner, exports, Save work, Record result and recipe reopen path. Its editable minimum starts at the labelled handling convention; the effective carrier name/fraction and the serial method's carrier-free final-diluent assumption carry over. Maximum transfer, stock availability, vessel capacity, extra volume, intermediate-diluent carrier and final carrier limit remain null until entered. Incomplete/refused constraints cannot produce a recordable intermediate plan, and a carrier-free intermediate still cannot remove stock-derived carrier at the same final analyte concentration. No solver or method version changed.
The native noopener noreferrer link leaves the source and other open drafts mounted and unchanged; it performs no forced save or mutation. Only the fresh scalar dilution inputs and optional compound label travel in the existing #s= grammar. Source recipe, work, Experiment, private compound IDs and evidence bindings do not travel. A fresh tab may seed the last valid Study through the existing device preference; its Save controls show the actual destination. A link over the shared 2,000-character cap is withheld with a shorter-name/manual-entry alternative. Closing the review cancels it without side effects; opening again uses current valid source inputs. The source series and later transfer warnings remain unchanged: the separate preparation is not an integrated serial protocol or evidence of physical preparation. Task-page islands and worked examples do not gain this action.
Plate preparation sheet
An optional section of the Serial tab (panels/plate-sheet-section.tsx, loading plate-sheet-panel.tsx on demand), engine planPlateSheet (src/lib/dilution/plate-sheet.ts), method plate-sheet 1.0.0 (plate-sheet-method.ts, a module of its own so a recipe can pin it without the planner). Inputs are PlateSheetInput v1 in the ps key (src/lib/handoff/payloads/plate-sheet.ts, at most 4,000 characters and 12 control lines): plate format 96 or 384, start well, direction, control lines (vehicle, positive or blank, each a typed well range), dead volume, overage mode and percent, an optional intermediate stock, minimum transfer, maximum carrier percent and stock available. Unfinished values are kept as null; the engine refuses what cannot be planned.
keep per tube = batch(R, W', p', D) with the allowance rule batch(N, V, p, D) = N·V·(1 + p/100) + D (allowances.ts)
bulk (default): W' = W, p' = p; per well: W' = W(1 + p/100), p' = 0; both: W' = W(1 + p/100), p' = p
tube volume = keep · f/(f − 1); transfer = tube/f (constant transfer, as serial-dilution 2.0.0)
top tube from the stock, or from an intermediate made to topDraw + D at C_int (C_top ≤ C_int < C_stock)
vehicle source: batch(n_vehicle, W', p', D) of diluent with the top point's carrier fraction, made from neat carrier
dispense: each dose well W from its tube; vehicle wells W of vehicle; blank wells W of diluent;
positive wells listed with no concentration (the person supplies that control)
dead volume D charged once per prepared source (each tube, the intermediate, the vehicle), never per well
Dose positions come from the shared proposePlacement (src/lib/table/plate-map.ts), so Calc and Assay cannot disagree. Three concentrations are kept apart: the intermediate as prepared, the concentration a transfer is taken from, and the final in the well. Any refusal gives no sheet: overage (outside 0 to 1,000), dead-volume, serial, stock-weaker-than-top, intermediate, control-range (the unreadable token named), start, placement (does not fit), control-overlaps-dose, control-overlap, transfer-below-minimum (the first source named), carrier-above-maximum, insufficient-stock. Totals: stock, diluent (including blank wells), carrier, dead volume, overage, the last tube's surplus, leftover, dose and control well counts. The panel shows a Prepare table in physical order and a Dispense table by well (cards grouped by source at 375 px; print always uses the tables), everything labelled planned, and its own CSV. The serial recipe carries the sheet as plateSheet and pins plate-sheet in its methods (withPlateSheet in bench-sheet.tsx); "Open plan in Assay" sends it, and Assay places it as described on Assay. Registry limit: planned, not measured; volumes additive; repeated wells share a stock and are not independent replicates.
The analysis plan (A-4 item 2, "pre-registered analysis, lightly"). Under the sheet's inputs, "Settle the analysis with this plan" adds the optional analysis block of PlateSheetInput (AnalysisPlan in src/lib/handoff/payloads/analysis-plan.ts: v: 1, settledOn (YYYY-MM-DD, the person's own calendar), model: "4pl", norm, fix, weighting, ci, ic, exclusion (the rule in the person's words, at most 400 characters; empty means none planned) and test: "rank-trend"). Any change settles it again that day; "Remove the analysis plan" removes it. readAnalysisPlan is strict (unknown keys, a malformed date, a repeated fixed parameter or a longer rule refuse the block and so the sheet); readPlateSheetInput reads it. It rides the sheet's ps key, the serial recipe's plateSheet and Assay's plannedSheet unchanged. Copy: plateSheetText.analysis (content/apps/lab-calculators.plate-sheet.ts). What Assay does with it is on Assay.
A280 and A260
Model a280Result (model/a280.ts), engines extinction280 and proteinMass (src/lib/seq/protein-mass.ts) and src/lib/seq/absorbance.ts, methods extinction-280-pace, protein-average-mass (1.0.0, "verified"), a280-beer-lambert 1.1.0 ("verified" against extinction.json for ε280 and absorbance-a280.json for the concentration, SCI-149), a260-a280-ratio 1.0.0 ("verified" against absorbance-ratio.json, SCI-149) and a260-rules-of-thumb 1.1.0 ("verified" since 17 October 2026, against a260-2.json from 1.1.0 and a260.json before, SCI-147 and SCI-148; constants table a260-rules, registry source a260-conventions, SCI-146).
ε280 (all Cys paired) = 5500·nTrp + 1490·nTyr + 125·floor(nCys/2) M⁻¹ cm⁻¹ (Pace 1995)
ε280 (all Cys reduced) = 5500·nTrp + 1490·nTyr
MW = water + Σ residue masses (ProtParam average residue masses, water 18.01524; DEFAULT_RESIDUE_TABLE "protparam")
c (mol/L) = A280 / (ε · l); ρ (g/L = mg/mL) = c · MW
A260: ρ (g/L) = (A260 / l) · factor · 0.001, factor µg/mL per AU: dsDNA 50, RNA 40, ssDNA 33
refused (a260Problem, in this order): A260 not finite; A260 ≤ 0; l ≤ 0
ratio = A260 / A280 (shown as an observation, not a purity measurement)
A280 refused (a280Problem, in this order): ε not finite and > 0; M not finite and > 0; A280 not finite; A280 ≤ 0; l not finite and > 0
ratio refused (ratioProblem, in this order): A260 not finite; A260 ≤ 0; A280 not finite; A280 ≤ 0
- Residues: a pasted sequence (
pseq) is the person's own statement and is read as protein. Residues that arrive (seq, or a stored sequence found by accession) are read only when their stated alphabet isprotein; DNA, RNA or circular DNA are refused with the reason, and residues without a stated alphabet are refused as unstated. The alphabet is never inferred from the letters. Letters outside the priced alphabet are left out of the mass (skipped), and non-letters are stripped. - Tag:
his6prependsMGSSHHHHHHSSGLVPRGSH(pET-28a, Novagen) to the residues before both ε280 and MW. - Convention:
epsselects cystines (default) or reduced. - Path length defaults to 1 cm. A280 and path must be positive. With ε280 = 0 (no Trp or Tyr) the panel says A280 cannot give a concentration.
- A260 refusals (method 1.1.0, SCI-148; the CSO's ruling of 17 October 2026,
docs/board/memos/2026-10-17-cso-a260-refusals.md):a260Problem(src/lib/seq/absorbance.ts) namesreading-not-finite,reading-not-positiveorpath-not-positive, andnucleicAcidConcentrationFromA260returns null for each. A reading at the blank means not detected, not 0 µg/mL; 1.0.0 returned 0 for zero and a negative concentration for a negative reading.a280Resultcarries the problem asa260Refused, and the panel shows its sentence (a260Refusalsincontent/apps/lab-calculators.workspace.ts) in place of the estimate. The field's minimum (0.0001) and the address check (positive) already stop most such readings before the engine; the engine no longer relies on them. A280 and the ratio do the same since SCI-149 (below). - A280 and ratio refusals (
a280-beer-lambert1.1.0 and the new methoda260-a280-ratio1.0.0, SCI-149; the CSO's ruling of 17 October 2026,docs/board/memos/2026-10-17-cso-a280-ratio-refusals.md):a280Problemnamesextinction-not-positive,molar-mass-not-positive,reading-not-finite,reading-not-positiveorpath-not-positive, the sequence's reasons first;proteinConcentrationFromA280returns null for each (1.0.0 returned 0 for zero and a negative concentration for a negative reading, and accepted an infinite ε, mass or path).ratioProblemnamesa260-not-finite,a260-not-positive,a280-not-finiteora280-not-positive, andabsorbanceRatioreturns null for each, never 0 or a negative ratio. The model carriesa280RefusedandratioRefused; the panel keeps the no-aromatic sentence for ε = 0, shows thea280Refusalssentence otherwise, and under an A260 estimate shows "The A260/A280 ratio needs both readings above zero." when the ratio is not given. The bench-sheet export takes the A280 method's id and version fromA280_METHOD(it had1.0.0typed in). - The 50 and 40 factors cite a Thermo Fisher page read 6 October 2026. The 33 for ssDNA is the convention NanoDrop instruments apply (Thermo Fisher NanoDrop One FAQ and technical note MCS-TN1315-EN) and IDT states, read 17 October 2026: ε260 of 10 mM⁻¹ cm⁻¹ per nucleotide at 330 g/mol, Cavaluzzi and Borer's (2004) "Method 1"; they measured 38 for non-repetitive ssDNA of 40–80% GC. The 6 October page prints 37 without a source and is not used (SCI-145; the CSO's ruling,
docs/board/memos/2026-10-17-cso-ssdna-a260-factor.md). The A260 card says oligonucleotides need their own ε260 and that the A260 ratios do not apply to them. Oligonucleotide ε260 by nearest neighbour is deliberately not computed (excluded source in the registry). The chip cites the registry sourcea260-conventions(Thermo Fisher NanoDrop One FAQ, technical note MCS-TN1315-EN and the conversions page; IDT; Cavaluzzi and Borer 2004, Method 1;cited-data, read 17 October 2026), mapped froma260-rulesinprovenance.ts(SCI-146; until then it citedpace-1995). The panel has no dilution field: a stock is the reading times the fold, which the fixture checks by calling the engine on the dilution-corrected reading. - Example verified in tests and the task page: P01308 (110 residues) gives ε280 17,335 (paired) and 16,960 (reduced) and MW 11,980.9 Da; A280 0.5 at 1 cm gives 28.8 µM and 0.346 mg/mL.
Buffer pKa at temperature
Model bufferResult (model/buffer.ts), engine src/lib/dilution/buffer.ts, method pka-temperature-goldberg 1.1.0, constants goldberg-2002.
pK(T) = pK(Tr) − (ΔrH°/(R ln 10))·(1/Tr − 1/T) − (ΔrCp°/(R ln 10))·(Tr/T − 1 + ln(T/Tr))
R = 8.31451 J K⁻¹ mol⁻¹ (as the paper uses), Tr = 298.15 K, T = t(°C) + 273.15, ΔrH° entered in kJ/mol
pH(T) = pH set at 25 °C + pK(T) − pK(Tr) (buffer ratio assumed unchanged)
linear slope near 25 °C = −ΔrH° / (R ln 10 · Tr²) (Tris: −0.02788 per K)
- The table
BUFFERSholds 34 reactions from Goldberg, Kishore and Lennen, J Phys Chem Ref Data 2002;31:231-370, doi:10.1063/1.1416902, section 8 (298.15 K, zero ionic strength), equation 12 without the ∂ΔrCp°/∂T term. The source comment records that the values were transcribed and checked once by a second reading. All 34 entries carry a ΔrCp° value; thevanTHoffOnlyoption and the null-ΔrCp° path exist but no shipped entry uses them. - Inputs: buffer id (default Tris), working temperature (field 0 to 100 °C; link check non-negative), pH set at 25 °C (field 0 to 14). Defaults 4 °C and pH 8.
- Outside 274 to 350 K (
VALID_RANGE_K) no pK is given: since 1.1.0 (Wave 3, CSO memo §2.5) the panel shows "No pK is given outside 274 to 350 K (about 0.85 to 76.85 °C) … the equation is not extrapolated" instead of an answer with an extrapolation note. - Ionic strength is not applied (stated limit); the panel links to phbuffers.org rather than using its data, whose licence is not stated.
- Tris at pH 8.0 and 25 °C: pK(4 °C) 8.710, pH about 8.64 at 4 °C.
Recipe table
Model recipeResult and recipeComponents (model/recipe.ts), engine planRecipe (src/lib/dilution/recipe.ts), method recipe-table 2.0.0.
For a final volume V and each component:
volume fraction f (0 < f ≤ 1): V_add = f · V
mass concentration ρ, solid: m = ρ · V / purity
mass concentration ρ, from a stock ρ_s: V_add = ρ · V / ρ_s
mass concentration ρ, from a molar stock C_s: V_add = ρ · V / (C_s · FW)
molarity C, solid: m = C · V · FW / purity
molarity C, from a molar stock C_s: V_add = C · V / C_s
molarity C, from a mass stock ρ_s: V_add = C · V · FW / ρ_s
diluent = V − Σ V_add
- Refusals per line:
needs-formula-weight,not-positive,invalid-stock,invalid-purity(outside (0, 1]),non-finite. The recipe as a whole isinvalid-componentsif any line fails, orstocks-exceed-final-volumewhen the liquid additions exceed V by more than 64·ε·V (audit B7, fixed). An invalid recipe hasdiluent: null. - The panel offers a final volume (
rv,rvu) and rows with name, final concentration (molar units, mg/mL, µg/mL, ng/µL,%w/v), formula weight and an optional stock strength. It has no purity field; a row's purity can only arrive from a recipe payload. Volume-fraction targets are supported by the engine but not offered by the panel. - Default rows: Tris 50 mM (121.136 g/mol), NaCl 150 mM (58.44 g/mol), EDTA 1 mM at 372.238 g/mol from a 500 mM stock.
- Rows are held in React state and written into the address as a compressed
multi-componentrecipe payload on each edit. - pH line (
panels/recipe-ph-section.tsx, Wave 3): an optional section naming the buffering component (one of the rows), the buffer, the pH wanted at the working temperature and the temperature at which the pH is adjusted. It shows "Adjust to pH x at T °C for about pH y at T′ °C" frombufferSetpoint(below) with the CSO's qualification; a buffer outsideRECIPE_BUFFER_IDS("other") gets the no-correction line and is not saved. The block travels in the recipe payload asph(RecipePhBlock:v: 1,component,bufferId,targetPh0 to 14,useTemperatureC,adjustTemperatureC; temperatures in °C with the unit in the key, never throughquantity.ts), only on abufferormulti-componentrecipe and only when it names a component the recipe still has; the recipe then pinsbuffer-recipeandpka-temperature-goldberg.
Buffer recipe
Engine planBufferRecipe and bufferSetpoint (src/lib/dilution/buffer-recipe.ts), method buffer-recipe 1.1.0, used by the recipe table's pH line and the buffer-recipe task page.
(a) m = C · V · M, M the CIAAW 2021 sum of the exact formula named (hydrate and salt parts included)
(b) pH_set(T_prep) = pH_target(T_work) + pK(T_prep) − pK(T_work), pK(T) by Goldberg equation 12 at I = 0
Only the 15 buffers transcribed twice (RECIPE_BUFFER_IDS: tris, hepes, mops, mes, pipes, phosphate-2, tes, imidazole, ches, taps, glycine-2, citrate-3, acetate, bicine, tricine) are offered. Refusals: carbonate (open-system); any other buffer (not-second-transcribed); no form named (no-form); a formula that does not parse (formula); a non-positive or missing concentration or volume (amount); missing pH or temperatures (ph); either temperature outside 274 to 350 K (temperature-range); a target more than 1 pH unit from the computed pK at the working temperature (outside-buffer-range); a target within 2 units of another pK of the same substance at that same working temperature (overlapping-pk). The parent compound is never substituted for the form. No conjugate-pair ratio or titrant estimate (that waits on the NIST SRM 186 certificate). Celsius to kelvin uses kelvinFromCelsius (t + 273.15).
Master mix
Model model/master-mix.ts, engine planMasterMix (src/lib/dilution/master-mix.ts), method master-mix-preparation 1.0.0 ("unit-tested"), citing Thermo Fisher MAN0025706 B pp. 12–13 and an INTEGRA dead-volume note (read 9 October 2026). Design: docs/design/master-mix-increment.md.
per reaction: volume component as entered; concentration component V = R · T / S;
the one diluent row fills R − Σ other components
M = shared (mix) volume per reaction; separate additions per reaction = R − M
B = N · M · (1 + overage%/100) + dead volume
shared component total = per-reaction · (B / M); separate component total = per-reaction · N
- Input
mm(MasterMixInputv1): reaction count (integer 1 to 1,000,000), final reaction volume, overage percent (0 to 1,000), dead volume (non-negative), optional minimum transfer, and up to 40 components (each with id, name,mixorseparate, and mode volume, concentration or diluent). The whole JSON is limited to 30,000 characters. Concentration units are molar, mass concentration, or the factor×; stock and target must share one family (incompatible-units). - Refusals:
invalid-input,reaction-count,reaction-volume,overage,dead-volume,minimum-transfer,components,component-name,component-volume,concentrations,incompatible-units,multiple-diluents,exceeds-volume,unfilled-volume(positive remainder without a diluent row),empty-mix,non-finite. Tolerance 64·ε·R for the volume balance. - Warnings (
below-minimum) are raised only for real pipetting operations: each shared component's batch addition, each separate addition per reaction, and the mix dispense. readMasterMixPlanvalidates a recorded plan's balances (relative 256·ε) without recomputing it.- Example (
masterMixExample, 24 × 20 µL): 432 µL of shared mix, 24 equivalents, 18 µL dispensed and 2 µL sample per reaction.
Cell seeding and splitting
Preparation kind cells. All five kinds go through calculatePreparation (src/lib/preparation/calculate.ts), which reads the input with readEverydayPreparation and dispatches to the kind's module in src/lib/preparation/kinds/ (<kind>.ts the input and reader, <kind>-calculate.ts the calculation and method record, <kind>-receipt.ts the rows a result must carry); contract.ts keeps the readers and empty inputs, so pages that only read a saved preparation carry no calculation. Each kind has its own method record in PREPARATION_METHODS, carried in the recipe's methods beside the envelope everyday-preparation 1.0.0, which a saved result keeps as its id: cell-seeding 1.0.0, centrifuge-rcf-rpm 1.0.0, percent-solution 1.0.0, antibody-dilution 1.1.0, od600-cell-estimate 1.0.0, all "verified" (see Verification). Design: docs/design/pre-release-app-depth/calc.md and implementation.md.
measured mode:
ρ_viable = ρ_measured · (viability/100 if the count is total) · (counting factor if "apply")
target per vessel N_t = cells; or (cells/area) · area; or (cells/volume) · V_final
V_stock per vessel = N_t / ρ_viable, required 0 < V_stock ≤ V_final
scale = n_vessels · (1 + overage%/100)
split mode (1:n):
V_stock per vessel = V_available / n_split, n_vessels ≤ n_split, scale = n_vessels (no overage)
both: total stock = V_stock · scale ≤ V_available · (1 + 64ε);
medium per vessel = V_final − V_stock; prepared volume = V_final · scale
Inputs carry their own units: volumes µL, mL, L; densities cells/mL, cells/µL, cells/L; targets in cells, cells/cm² or cells/mm² with an area in cm², mm² or m², or a per-volume density. Vessels are integers 1 to 1,000,000; viability in (0, 100]; counting factor ≥ 1; overage 0 to 1,000 percent. Each failure has its own problem code (cell-volumes, split-parts, density, viability, counting-factor, cell-target, cell-area, overage, negative-diluent, insufficient-stock). No confluence-to-count conversion is made.
Cells from OD600 (an estimate)
Preparation kind od600 (kinds/od600.ts, kinds/od600-calculate.ts), method od600-cell-estimate 1.0.0, in the Cells and centrifuge group.
estimate per mL = (OD600 − blank) × dilution × k
k, its basis (cells, CFU or microsphere equivalents), its source, the instrument and vessel, the organism and the calibrated OD range are all supplied by the person; no default or strain factor exists. The output is labelled an estimate in k's basis (cells/mL, CFU/mL, microsphere equivalents/mL), never "viable", and never feeds cell seeding. OD600 is its own attenuance dimension in quantity.ts, so the A260 and A280 tools never accept it. Refusals in order: no positive k, basis or source (od-factor); output basis different from k's (od-basis-mismatch); an invalid range (od-range); a missing reading or negative blank (od-reading); dilution below 1 (od-dilution); corrected reading not above zero (od-not-above-blank); corrected reading above or below the calibrated range by more than 1e-12 relative (od-above-range, od-below-range).
From paired calibration data (od600-calibration-section.tsx, loaded on demand; engine od600FromCalibration in src/lib/preparation/od600-calibration.ts, method od600-calibration 1.0.0, Wave 3 step 9). The person enters pairs of OD600 and counted density (at least three distinct densities, each pair complete) and optional blanks. The pairs become standards for the existing calibration engine (fitCalibration, linear, one mean OD per density, free intercept, mean-blank subtraction when blanks are given; the engine's concentration unit is a label it never scales by). The reading is read back only inside the densities measured and multiplied by its dilution. Refusals: od-pairs, od-basis, od-reading, od-dilution, od-flat (no usable line), od-below-range, od-above-range. The result shows the line, R² (descriptive), the range and the blank used. The pairs stay on the page only: they are not in the link, the recipe or the receipt.
RCF and RPM
Preparation kind centrifuge.
RCF (× g) = 1.118 × 10⁻⁵ · r(cm) · RPM²; RPM = sqrt(RCF / (1.118 × 10⁻⁵ · r(cm)))
The radius (cm or mm) and a radius convention (maximum, average, minimum) are required. An optional rotor maximum adds a qualification when the converted speed exceeds it. The coefficient is the rounded published value (Thermo Fisher TR0040.1, cited in the copy). Example: 7 cm at 5,000 RPM gives 1,956.5 × g.
Percent solutions
Preparation kind percent. Three bases are kept apart:
w/v: solute mass (g) = p · V_final(L) · 10 (g per 100 mL final solution)
w/w: solute mass = p/100 · total mass; other material = total − solute
v/v: solute volume = p/100 · V_final (laboratory convention, mL per 100 mL)
w/w and v/v percentages must not exceed 100; w/v has no upper bound. No density, solvent volume or molarity is inferred. Example: 5 percent w/v in 200 mL is 10 g.
Antibody dilution
Preparation kind antibody (method antibody-dilution 1.1.0), using solveC1V1 and, optionally, the intermediate planner.
nominal volume = volume per vessel · vessels
batch V2 = batch(vessels, volume per vessel, overage %, dead volume) (allowances.ts; 1.1.0, none unless chosen)
concentration mode: C1 = stock, C2 = target (same dimension, molar or mass concentration)
ratio mode 1:n (n ≥ 1): C2 = C1 / n, with C1 = 1 (relative) when no stock concentration is given
V1 = C2 · V2 / C1; diluent = V2 − V1; dilution factor = C1 / C2
Overage (0 to 1,000 percent) and one reservoir's dead volume use Wave 1's allowance rule (batchWithAllowances in src/lib/dilution/allowances.ts, shared with the master mix and the plate sheet), each charged once; inputs saved before 1.1.0 read with neither, so they give the same batch as before. Without a stock concentration no absolute final concentration is reported and a qualification says so. An optional minimum transfer adds a qualification when the stock volume, or a positive diluent volume, is below it. The intermediate planner requires an absolute stock (intermediate-needs-stock); its refusals are reported as intermediate-<problem>. Clone, catalogue, lot, assay and source note are free-text metadata kept with the inputs. Example: 1 mg/mL stock to 2 µg/mL in 10 mL is 20 µL of stock, a 500-fold dilution.
Every preparation result is a list of keyed rows with fixed units and a list of qualifications (the shared "planned" sentence first, then the kind's notes from content/apps/lab-calculators.preparation.*.notes.ts; a cell count carries no "planned" sentence, its own observation sentence first). readPreparationResult (kinds/result.ts) checks the envelope, keys, units, the kind's receipt (required and exclusive rows), balances (relative 1e-10) and any embedded intermediate plan; finishResult returns numeric-range if the kind's own output fails that reader. Refusal codes per kind: centrifuge-input, percent-input, antibody-input, minimum-transfer, intermediate-needs-stock, intermediate-<problem>, and the cells, counting, dose and OD600 codes above.
Cell counting and viability
Preparation kind cell-counting (method cell-counting-volume 1.0.0, Contract 10). Volume-weighted cell density and viability across 1 to 64 counted regions.
Viable cell density = (Σ live_cells · dilution) / Σ volume
Total cell density = (Σ total_cells · dilution) / Σ volume
Viability (%) = (Σ live_cells / Σ total_cells) · 100
- Counting modes:
live-dead(paired live and dead cell counts; viability calculated when total cells > 0),live-only(live cells counted; viability percentage unavailable),total-only(total cells counted; viability percentage and viable density unavailable). - Regions: 1 to 64 regions. Each region takes a region identifier, count field(s) matching the mode, and volume in
nL,µL, ormL. Dilution factor (≥ 1, 1 for undiluted) is applied once to the pooled density. - Unavailable viability: Shown as a descriptive row ("Not available: no live or dead cells were counted, so there is no denominator" / "Not available: live-only counts have no total" / "Not available: total-only counts have no live count"), never 0 or blank.
- Seeding bridge:
prepareCountingToSeedinghands positive viable density to Cell seeding withdensityBasis: "viable",viability: null,countingDilution: "already-applied",countingFactor: null. A confirmation dialog offers Keep and Replace when the destination draft holds a value, and prints the density as the result table does (displayValue, "500,000 cells/mL"). A count the bridge cannot use says why in the same slot, with the engine's reason fromcellCountingNotes.seeding(a total-only count; no positive viable density); before 20 October 2026 the second case showed nothing and the reasons were typed in the engine (audit counting-dose m4, m6). - Receipt (
CELL_COUNTING_RECEIPT,cell-counting-receipt.ts): the rows each mode permits and no other (a total-only receipt has no viable density, a live-only one no viability, a zero-total one no numeric viability); canonical units only,Lfor the counted volume andcells/Lfor both densities, as the engine writes them; the viability identity and both density identities,viableDensity = sumLiveCount × dilutionFactor / totalCountedVolumeandtotalDensity = sumTotalCount × dilutionFactor / totalCountedVolume, withinclose(relative 1e-10). Before 20 October 2026 it admitted mL, µL, nL and cells/mL and checked the viability identity alone, so a doubled density read as valid (audit counting-dose M1; the CSO's memo of 20 October 2026, section 3); old receipts, all written in L and cells/L by the same engine, still read. Checked againstreceipt-integrity-v1.json(0.9.0). - Refusals (
CELL_COUNTING_PROBLEMS,cell-counting-calculate.ts;refusal-parity.test.tsholds it equal to the content's keys):counting-no-observations,counting-too-many-observations,counting-missing-count,counting-invalid-count,counting-unsafe-count,counting-mixed-modes,counting-missing-volume,counting-non-positive-volume(also a unit outside the volume dimension),counting-missing-dilution,counting-dilution-below-one,counting-zero-total-volume,numeric-range,invalid-input.
Dose per cell planning
Preparation kind dose-plan (method dose-per-cell-arithmetic 1.0.0, Contract 10). Neutral calculation of required vector quantity and stock volume.
Required vector quantity = desired_dose · cell_count
Required stock volume = required_quantity / vector_titre
- Quantity bases: Exactly one of
TU,PFU,IFU,vg,particles. Changing basis clears both dose and titre. - Units: Titre volume denominator offers
L,mL,µLonly (no nL). Desired dose is in stated basis per cell; titre is in stated basis per volume unit. - Cells: Positive cell number ("Cell number at the target time") with denominator
viableortotal, statusmeasuredorestimated(requiring explanation), source context, observation time, and stated target time ("Target time for dosing"; the CPO's labels of 17 October 2026). Missing target-time context withholds the receipt. - Allocation: Allocation (10c) is deferred; vessel, volume, overage, and dead-volume fields are not present; the form displays a fixed notice, "Allocation: not assessed. Vessels, overage and dead volume are outside this calculation." (no contract reference in the copy).
- Reference: Lock et al. 2010, Hum Gene Ther 21(10):1273-1285, doi:10.1089/hum.2009.223, for keeping the bases distinct, not for the arithmetic (the CSO's memo of 20 October 2026, section 3; the DOI cited before 20 October, 10.1089/hgtb.2010.131, does not exist).
- Receipt (
DOSE_PLAN_RECEIPT,dose-plan-receipt.ts): the four rows, no allocation row, a non-negative quantity, volume and dose, and the required quantity's basis equal to the entered dose's (TUbesideTU per viable cellorTU per total cell). Before 20 October 2026 each unit was checked against its list alone, so a PFU quantity beside a TU per viable cell dose read as valid (audit counting-dose M2). The receipt carries no titre, so the stock volume cannot be rechecked on read; a receipt version that carries it is owed (REL-397). Checked againstreceipt-integrity-v1.json(0.9.0). - Refusals (
DOSE_PLAN_PROBLEMS,dose-plan-calculate.ts;refusal-parity.test.tsholds it equal to the content's keys):dose-missing-cells,dose-non-positive-cells,dose-cell-denominator,dose-cell-status(alsomeasuredwithout a source or time),dose-estimate-explanation,dose-missing-target-time,dose-missing-dose,dose-negative-dose,dose-missing-titre,dose-non-positive-titre,dose-titre-unit,dose-titre-source,dose-basis-mismatch,numeric-range,invalid-input(an unknown basis among them).
Lab unit converter
Task page unit-converter only (UnitConverterPanel, island unit-converter); there is no bench-sheet calculator. Engine convertUnits (src/lib/units/unit-converter.ts), methods unit-conversion 1.0.0 and temperature-offset 1.0.0 (src/lib/units/temperature.ts).
- Within one quantity (mass, volume, amount, molarity, mass concentration, molar mass with Da read as g/mol, length, area, cell count, cell density, cell surface density, absorbance;
CONVERTER_UNITS): exact decimal scaling of the text as typed, by the difference of the two units' powers of ten (scaleText), with no floating point. Negative values are refused. - Temperature (°C and K only): an offset, kept out of
quantity.ts, whose callers readtoBaseas a multiplication. t/°C = T/K − 273.15 on the decimal text with integer arithmetic at two or more decimal places, so 37 °C is exactly 310.15 K; refused below 0 K (below-absolute-zero). - Molar bridges (molarity ↔ mass concentration, amount ↔ mass) only with a positive formula weight for the exact form, in g/mol: base value × FW or ÷ FW in floating point, shown to 12 significant figures (
kind: "molar"). - Layout (D-0084, frame 064): the island lays out in two panels, Convert units at the left and Converted quantity at the right, whose one tile is the result with the full equation line beneath it; a refusal or the missing-value line stands in its place, with the route to the OD600 or centrifuge page where one applies.
- Refused with a reason, never computed:
%andmol/kg(density),N(normality),UandIU(activity),ppmandppb,×(fold),OD600,RPMand× g, a volume against a mass or mass concentration (density), any other pair across quantities (incompatible), a missing formula weight, unknown units, text that is not a plain decimal.
Reagent table
pipeline/lab-calculators/reagents.csv holds 75 hand-curated reagents (name, formula, hydrate waters, category, Goldberg buffer ids, PubChem CID, InChIKey, PubChem formula and weight, title, read date 2026-10-06, notes). curate-reagents.py filled the PubChem columns from PUG REST and checked PubChem's formula against the hand-typed formula plus waters. build-reagents.mts computes each formula weight with the shared CIAAW engine (anhydrous and as weighed), joins pK, ΔrH°, ΔrCp° and slope from BUFFERS by id (no pKa is typed in the CSV), and writes public/data/lab-calculators/reagents.<hash>.json with the manifest reagents-manifest.ts (hash eaed3a5f92, count 75, generated 2026-10-06). There is no CAS column; CAS numbers are an excluded source.
loadReagents fetches the file once per page with credentials: "same-origin" (REL-439: a protected preview needs its login cookie on the site's own files) and refuses it if the row count differs from the manifest. searchReagents matches every query word against name, formulas, id, category and notes after removing diacritics. The picker (panels/reagent-picker.tsx, a dialog) lists the matches as the table the redesign atlas draws (D-0084, frame 062): reagent with its hydrate line, formula as weighed, FW by CIAAW 2021, anhydrous FW, category, pKa at 25 °C and PubChem CID, with Use on every row, in a labelled scroll region; its "More compounds" section below offers the catalogue and PubChem. Picking a reagent sets rg and name and clears cpd, fw, lw and pur. The reagents task page renders the same file on the server. The stock label dialog (panels/label-dialog.tsx) keeps its fields and preview as two titled groups (frame 063).
See Data pipeline for how the pipeline scripts are run and refreshed.
Method records, provenance chips and the inspector
content/apps/lab-calculators.methods.ts builds METHODS from each engine's own method record joined with a verification state and the badge sentence from lab-calculators.verification.ts:
| Key | Id, version | Verification |
|---|---|---|
formulaMass |
formula-mass 1.0.0 |
verified (58 fixtures) |
stock, c1v1, dmso |
stock-salt-purity, c1v1, dmso-carry-over, all 1.0.0 |
verified (31 dilution fixtures) |
serial |
serial-dilution 2.0.0 |
verified (10 serial-2.json cases, each step exactly the fold) |
recipe |
recipe-table 2.0.0 |
verified (the same 31-fixture note; one fixture is a recipe) |
buffer |
pka-temperature-goldberg 1.1.0 |
verified (15 buffers) |
bufferRecipe |
buffer-recipe 1.0.0 |
verified (18 cases, 8 refusals) |
prepCells, prepCentrifuge, prepPercent, prepOd600, prepAntibody |
cell-seeding, centrifuge-rcf-rpm, percent-solution, od600-cell-estimate 1.0.0; antibody-dilution 1.1.0 |
verified (preparation-3.json, od600.json, antibody-batch.json); PREPARATION_METHOD_KEYS maps each kind to its key |
mastermix, intermediate |
master-mix-preparation, one-intermediate-dilution 1.0.0 |
verified (14 and 13 cases, version 0.2.0) |
unitConversion, temperature |
unit-conversion, temperature-offset 1.0.0 |
verified (44 cases) |
extinction, a280 |
extinction-280-pace, a280-beer-lambert 1.0.0 |
verified (22 sequences) |
proteinMass |
protein-average-mass 1.0.0 |
verified (Protein's 9 recorded ProtParam outputs) |
prepCellCounting, prepDosePlan |
cell-counting-volume, dose-per-cell-arithmetic 1.0.0 |
verified for the arithmetic (the CSO's memo of 20 October 2026, section 3, on the audit's independent recomputation): 28 cases, 15 of them refusals, plus 6 receipt-integrity cases (cell-counting-v3.json) and 35 cases, 23 of them refusals, plus 7 (dose-plan-v3.json), 0.7.0, Python 3.9.6; the receipt reader against receipt-integrity-v1.json (0.9.0): 25 cell-counting receipts, 11 of them refused, and 24 dose-plan receipts, 12 of them refused |
pipette |
pipette-minimum-2ul 1.0.0 |
unit-tested; source is "Bench convention set by the CSO, 6 October 2026; no external citation read" |
a280 |
a280-beer-lambert 1.1.0 |
verified: exact rational arithmetic (Python fractions, 3.13.16) on 14 cases, ten of them refusals, with ε280 and the mass as inputs (absorbance-a280.json, 0.6.0); ε280 against Biopython (extinction.json) |
ratio |
a260-a280-ratio 1.0.0 |
verified: exact rational arithmetic on 10 cases, eight of them refusals (absorbance-ratio.json, 0.6.0); an observation |
a260 |
a260-rules-of-thumb 1.1.0 |
verified: exact rational arithmetic (Python fractions, 3.13.16) on 19 cases, seven of them refusals; relative 1e-9, refusals exact; 17 October 2026 (a260-2.json, 0.5.0; 1.0.0 was checked against a260.json, 0.4.0) |
plate-sheet 1.0.0 and od600-calibration 1.0.0 have no METHODS entry and so no chip: content/apps/lab-calculators.static-methods.ts is one module that every task island carries, and the islands had no room (docs/NEXT.md). The plate sheet is still pinned in the recipe and listed in the plate-sheet-2 verification set.
methodProvenance (provenance.ts) turns a record into a chip: the constants table is parsed from the implementation line ("… (constants ciaaw-2021)"), mapped to a registry source id, and the citation joins the method name, the constants words, the reference and the implementation path. Chips appear on the formula weight, the salt-form figures, ε280, protein mass, the A280 and A260 answers, pK(T), the recipe, the serial plan and the bench flags. C1V1 and unit conversion carry no chip, by design (brief §4.4).
The inspector (inspector.tsx) shows, for the current task, the equation sentence from workspaceCopy.inspector.equations, the constants tables, each method's verification sentence and reference, and the units, dilution and seq module versions. The Method panel under the calculator (panels/method-panel.tsx) shows the same equation sentence with the task's method chips (methodsUsed), so the method is visible without the inspector (mock-ups 05 and 06). The method lists per task are written twice, in inspector.tsx (METHODS_BY_TASK) and bench-sheet.tsx (methodsFor).
Named tasks and recorded results
Bench integration is shared machinery described in Storage and state; this section covers Calc's adapter.
What can be saved. bench-sheet.tsx builds workRecipe for molarity, dilution (with intermediate constraints when present), serial, master mix and preparation (all seven kinds). For these the sheet shows WorkCaptureControls in the view toolbar under the section tabs (D-0084) with schema CALC_WORK_SCHEMA (lab-calculators.recipe, version 1). An unfinished calculation is still saveable: when recipeFor returns null, a draft recipe with the inputs is saved instead, so clearing a field never revives an old result. A280, buffer and recipe table cannot be saved as work yet; the Save work key stays in place with the live key's label and resting icon, aria-disabled, with the reason from saveUnsupportedText ("… cannot be saved as work yet"; D-0102, the CPO's memo of 20 October 2026, section 5; before it the disabled key read "Save to Bench").
The saved payload is a RecipePayload (see below) with a workspace block: calculator.state (the effective address state for the task, with recipe removed) and calculator.compound (the resolved compound as plain JSON, formula maps packed as entries, from captureCalculatorCompound), plus any Workspace input bindings.
Resume. calculatorRecipeState restores the exact saved state (a serial recipe's plateSheet back into ps). Every saved key is re-checked with the address schema; an unknown or failing key throws a WorkspaceValidationError rather than being dropped. For a dilution, the saved ip must equal the recipe's intermediateDilution. Older recipes without a workspace block are restored from inputs and the first component (name, weight, purity).
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 Calc task opens, the bench sheet reads the task's draft (readOpenWorkDraft), accepts it only through readRecipePayload, and puts it on screen once by setting the recipe key, so calculatorRecipeState restores it as it restores a saved recipe; onDraftResolved puts the saved recipe back on Discard and the draft back on its Undo. The open path sets appliedDraft with the restored inputs. While a draft exists, the hand-offs ("Start a serial plan from this stock", "Open plan in Assay", "Send sheet to Assay" and the response template) are refused with workflow.assayUnsaved, announced as a toast and shown under the key while the same task shows the same edits (the template is withheld in the export menu), until Save work; they never consume the draft (the CPO's ruling 4 of 12 October 2026). The row's ⋯ menu then offers only workflow.menuUnsaved and the Bench input item, since its object actions (Open in Assay, Pin, Add to Study, Keep and Open its page) write the on-screen inputs into the saved record (the PR 21 review, finding 3).
Recording a result. captureRecorded is offered when the current result is complete:
| Calculator | Recorded when |
|---|---|
| Molarity | mode mass only, with a finite positive mass |
| Dilution | the C1V1 result is valid, and the intermediate plan is valid when constraints are present |
| Serial | the plan is valid |
| Master mix | the plan is valid |
| Preparation (all five kinds) | the result is valid |
captureCalculatorWorkspaceResult re-runs the engine for master mix, everyday preparation and intermediate dilution and refuses to record if the computed value differs from the current inputs (stale-result guard) or if the method version is missing. It writes an InputSnapshot with the evidence calculatorInputEvidence gives, the bound input snapshots, and a ResultSnapshot with schema lab-calculators.recorded-result version 1: settings are the recipe inputs, output { recipeKind, calculation }, methods from the recipe, data versions from its constants tables, a lead info qualification, a warning qualification for each master-mix transfer warning, preparation qualification or intermediate qualification, and reporting status qualified when any warning exists, else reportable. Bench projects recorded Calc results from what was stored, without rerunning (src/lib/workspace/calc-projection.ts).
A saved count is an observation, not a plan (the CSO's memo of 20 October 2026, section 3; contract 10 lines 26 and 105; audit counting-dose M3). Every Calc input is manual-assertion / raw / planned with the lead qualification calculation-plan ("Calculated plan. Saving it does not record a physical preparation or measurement."), the dose plan among them, and old records keep what they captured, except a cell-counting input: with count origin observation its evidence is observation / raw / recorded; with example it is simulation / raw / recorded with dataset: { id: "cell-counting-worked-example@1", kind: "synthetic" }, plus edited: true once the input differs from the worked example (Assay's convention for synthetic data; the counting input has no edited flag of its own, so a count edited back to the example's exact values reads as unedited). "Recorded" means the count was entered as observed, not certified. Its lead qualification is count-observation (info): "Density was calculated from the entered counts and counted volumes, applying the stated sample dilution once. Recording an entered count does not certify the observation."; the engine's own qualifications (the live-only and total-only sentences among them) follow as before. The capture's provenance says preparation: "observation" for a count. In Bench and Study a count has no "planned" fact or reason: its input leads with the fact "Count origin: observation" or "Count origin: synthetic example", with the certification sentence as its detail, and the result's reason is that sentence (countOriginFact and cellCountingText.evidence).
Input picker. "Use a Bench input" is an item of the subject's ⋯ menu (MoreActions when there is no recipe object yet), which opens WorkspaceInputPicker app="lab-calculators" (trigger="none") beside the cluster, so the cluster keeps to Save to Bench, Record result, pin and ⋯ (UI-16). The picker offers saved calculator inputs (stock, dilution, serial-dilution, master-mix and everyday-preparation recipes) and a compound's formula weight (src/lib/workspace/input-actions.ts). Using saved inputs clears the address and starts a new recipe id; using a compound binds its identity so the weight cannot later be attributed to another compound.
Continuations.
- Start a serial plan from this stock (molarity, mass mode, molar unit): copies the stock concentration and unit into
sc/scuof a new serial draft, keeps the compound context, and when the tab's working task is a saved Calc task whose stored payload hashes equal to the on-screen one, adds a binding with rolestock-plan. It flushes pending saves first and aborts if the inputs, the research context or the ownership scope changed meanwhile. - Open plan in Assay (serial with a table, or master mix): sends the recipe (a serial recipe with its
plateSheetwhen the sheet is filled in) throughsendTo("assay-analyser", …)with the compressed payload; when a saved source exists its content hash must match the on-screen plan. Assay receives a dose plan or, for a master mix, preparation context only (no readings).
Repeat setup, the Calc side (contract 06, 17 October 2026)
The Serial tab's recipe assembly lives in src/apps/lab-calculators/serial-recipe.ts (serialRecipeFor, serialRecipeMethods, pickInputs), extracted from bench-sheet.tsx unchanged so that Repeat setup builds a repeated plan exactly as the tab does. src/apps/lab-calculators/repeat-setup.ts is loaded only after the Study's Repeat setup intent:
readRepeatCalcSourceaccepts a savedserial-dilutionrecipe pinned to serial-dilution 2.0.0 (a 1.x pin is refused, never upgraded; any pin outside units, dilution, seq, the serial method and its two rules, and plate-sheet 1.0.0 is refused) with its savedcalculator.state, whose keys must agree with the recipe'sinputs, whose table must equal the oneserialResultandserialTableForgive for that state, whosepsmust equal the recipe'splateSheet, and with exactly one component that names its compound by ref (a typed name is not an identity). It returns the source's values as proposals; a missing one is "Not recorded".reviewSerialInputschecks the reviewed text field by field beforeserialResultruns, so none of the calculators' defaults (fold 3, 8 points, 3 wells, 100% carrier) can supply a value: positive stock, top and well volume; molarity units for stock and top and a volume unit for the well (written back as the unit table's symbol); fold above 1; whole points 2–24 and replicate wells 1–96; a named carrier and its percentage 0–100, zero included; a concentration basis. The stock must be at least the top (the engine's own refusal). An optional plate sheet keeps absent allowances and limits null and refuses a part-typed or wrong-dimension quantity, then runsplanPlateSheet; its refusal blocks creation. Checked againstrepeat-setup-1.json.buildRepeatedCalcRecipewrites the new state (task, the eleven serial keys,pcand, with a sheet,ps, carrying an analysis block only when the person kept one) throughserialRecipeFor, with the selected compound as identity only (no formula weight, purity or reagent),madeFromand the component'scompoundRefset to it, and one Workspace binding, rolematerial, schemadrybench.material-identityv1, whose payload is the compound's ref and label only (CSO memo of 17 October 2026). The stated concentration basis is the new address keyscb("stock concentration basis", text up to 200 characters), kept in the state and in the recipe'sinputs; the input preview and the Recorded results view show it beside the stock concentration (input-preview.ts,calc-projection.ts). The Serial tab shows it as an optional "Stock concentration basis" field, and states the carrier assumption (the named carrier comes from the stock, the diluent contributes none of it, volumes are additive) under its inputs (SCI-151, SCI-152). A browser test shows a re-saved repeated plan keeps itsmaterialbinding. No old recipe, stock,cpd, reagent or weight binding survives. The new task resumes in the Serial tab like any saved plan.
Use a recorded stock (contract 05, 5b, 17 October 2026)
D-0096 rebuild 2. The rules come from contract 05's 5b paragraphs and the CSO and CPO memos of 17 October 2026 (docs/board/memos/2026-10-17-cso-stock-autofill.md, with its addendum, and 2026-10-17-cpo-stock-autofill.md). The removed Gemini version (31c4252) seeded example tubes, treated every carrier as DMSO and deducted a wrongly computed draw from the stock; none of it was reused.
src/lib/inventory/stock-input.tsreads a live stock's selected container (readStockSource): refused when the stock is deleted, unreadable (the strict 5a reader), the container id is absent, its recorded status is not Available, or no concentration is recorded. The payload, schemadrybench.stock-inputv1, rolesource-stock, is exactly{ stockLabel, containerId, containerLabel, concentration { value, unit, basis, enteredAt }, carrier? { label, percentVv?, enteredAt }, material? { ref, rev, label } }, copied from the committed record; amount, location, lot, expiry, notes, status and every other container are never read into it.readStockInputreads it back strictly;stockInputBindingpins the stock's revision and the payload's hash.materialis the stock's captured association (the shape of Repeat setup'sdrybench.material-identitypayload plus its revision), context only: no second binding, and the sheet's compound,pc,fw,lwandpurare never written.src/apps/lab-calculators/stock-binding.ts(small; the bench sheet reads it every render) maps a payload onto a target. C1V1 writesc1,c1uandc1s; it takes M, mM, µM, nM, mg/mL, µg/mL and ng/µL and refuses g/L. Serial writessc,scu,scb,sol,dmsoandcru; it takes the four molar units only. The number is ECMAScript's own text withe+writtene, accepted only when Calc's own address check takes it back unchanged and it reads back to the same double (so 1e21 →1e21, while 1e-100 and 5e-324 are refused); units are written in Calc's spelling with U+00B5; the basis and carrier label are refused, never trimmed, if Calc's text check would change them, and a carrier label over 120 code points is refused for Serial. No carrier:solanddmsoremoved andcruset; a label without a percentage:solfilled,dmsoremoved,cruset; a recorded 0% is written0.stockBindingHoldskeeps asource-stockbinding on a task only while every field it filled holds the string it wrote, so a hand edit, a reload or a reopened saved plan never attributes a typed value to the stock.src/apps/lab-calculators/stock-input.ts(loaded with the dialog) joins the two, refuses C1V1 while its intermediate step is on (ip; the step has its own carrier fields), lists each visible field that would change for review, and flags a sheet compound whose canonical ref differs from the stock's material (labels and revisions are never compared).- Nothing in this path writes the stock. The reported amount is not read; there is no decrement, reservation, remaining balance or sufficiency claim (contract 05; CSO item 7). Recording what is left stays the explicit, revision-checked edit on the stock page.
- A hand edit of Serial's stock concentration or its unit also clears
scb(handEditPatch), whether a stock or Repeat setup wrote it: the basis describes one value and has no field on the Serial tab (CSO build review, finding 1).scbis inTASK_KEYS.serial. - Recorded results (the
stock-input-projection.tspart; see Storage and state) show the stock, container, the concentration exactly as stored with its stated basis, one of three carrier lines, the material context and the capture revision and time; a payload with any other field is unsupported, not partly shown. Source impact reads the schema withreadStockInput. - The bench sheet offers Use a recorded stock in the ⋯ menu beside "Use a Bench input", on C1V1 and Serial only (not on a kept example).
views/stock-input-dialog.tsx, loaded on demand, lists the active library's stocks (searched over the 5astockSearchText: name, lot, material label, container labels and locations), then every retained container with its recorded status, location and reported amount in text; only Available containers can be chosen and none is preselected. Choosing one prepares the input; a refusal is shown inline with its reason. Review lists each Calc field that would change (current → new), the concentration and basis, the entered date ("when it was typed, not when it was measured"), the container, the carrier line, the material line, the mismatch line where it applies, and that the amount is not used and calculations do not change the stock. Use these values re-reads the stock and re-prepares against the current fields; a changed revision, hash, field set or Calc field keeps the dialog open on the stale line and applies nothing. Cancel changes nothing. - Applying writes only the reviewed keys (
set(params)) and keeps the binding in the sheet's state (stockPick);workRecipeadds it to the task's bindings and keeps anysource-stockbinding, including one reopened with a saved plan, only whilestockBindingHolds. Save to Bench and Record result then carry it like any other binding (captureBoundInputs). A hand edit of a filled field removes it in the same render and announces "Stock selection removed: {field} was changed by hand."; a hand edit ofc1orc1ualso clearsc1s(panelSet). A task change, an example or a saved recipe from "Use a Bench input" forgets the selection. Until the task is saved, the binding lives in the tab: a reload keeps the values and markers from the fragment but not the origin line (REL-306). views/stock-origin.tsxshows, under the inputs, "From stock {stock} · {container}: user-entered concentration …. Stated basis: …." from the captured payload, the carrier line ("Carrier from the stock" or "Carrier entered in Calc, not from the stock record."), and an "Open {stock}" link that reads the live record only to say it has changed or is unavailable; beside the stock to draw (Serial) or the stock volume (C1V1): "The stock to draw is a plan, not a record of what was used. This plan does not change the stock record. To record what is left, edit the container on its stock page." Copy:content/apps/stock-input.ts(dialog),stock-input.sheet.ts(the bench sheet's origin, draw and drop lines),stock-input.panels.ts(Serial's unresolved-carrier and C1V1's hold lines, one object per panel so each task island loads only its own),stock-input.recorded.ts(shared with Recorded results). Review rows carry the unit on each side, the old value with the sheet's unit or its default. While the stock records a carrier but no percentage, Serial's withheld line names it: "Enter the carrier percentage. The selected stock records the carrier ({label}) but not its percentage, and Calc will not assume 100%."- Checked against
src/lib/workspace/fixtures/stock-input-1.json(97 cases,pipeline/workspace/stock_input_reference.py, Python standard library, written before the engine):src/apps/lab-calculators/stock-input.test.tsandsrc/lib/workspace/stock-input-projection.test.ts.
Objects, Studies and hand-off
Shared-object mechanics (refs, links, sendTo, hand-off records) are in Storage and state; the registry fields in Registry and content.
Registry. objects.owns: ["recipe"], reads: ["compound", "target", "sequence", "construct", "recipe"], writes: ["recipe", "compound"], tabs on the five read types. Cross-links: the compound card links to Molecule by name, the A280 panel to Protein by accession.
The recipe payload (src/lib/handoff/payloads/recipe.ts) is one shape for a kept object, a hand-off and an export: v: 1, kind (stock, dilution, serial-dilution, buffer, multi-component, selection, master-mix, everyday-preparation), label, inputs by bench-sheet key, components (name, optional compound ref, amount with unit, formula weight with basis, purity), optional table, preparation, everydayPreparation, intermediateDilution, ph (the recipe table's pH line, on a buffer or multi-component recipe), plateSheet (on a serial-dilution recipe with a table; checked here for shape only, a versioned object, and read with readPlateSheetInput by every consumer), methods ({ module, version, name? }, at most 16), constants, madeFrom and workspace. It carries inputs, not results. readRecipePayload caps strings and drops malformed inputs or components; it refuses a payload whose nested preparation does not match its kind, and rebuilds each method pin from its three keys, refusing the recipe for an entry with another key, a module or version that is not a string of 1 to 200 (version 1 to 40) characters, or a name that is not 1 to 200 characters (the CTO's method-pins ruling of 12 October 2026, 4; before it, a bad entry was dropped). Recipes built by recipeFor pin the module versions units, dilution and seq (recipeMethods), which are nameless and give no method line. A method record is pinned with its name through recipeMethodPin(record): a serial recipe always pins serial-dilution ("Serial dilution plan"), pipette-minimum-2ul ("Transfers below 2 µL flagged") and dmso-carry-over ("Final percent DMSO across a series") (serialRecipeMethods in bench-sheet.tsx; before the ruling, only with a plate sheet), and plate-sheet ("Dilution-to-plate preparation sheet") with a plate sheet; the pH line pins buffer-recipe and pka-temperature-goldberg with names (withPh, and the buffer page's worked example); an intermediate dilution pins one-intermediate-dilution with its name. Master-mix and preparation recipes pin their own method ids, still without names. recipeMethodLines(methods) gives the method lines in the shared { id, name, version } shape: one per method record, the name stored with the recipe and never looked up in the live registry, the id in the name's place for a pin kept before names were stored ("Method: {id}, version {version}"); the template README's method lines (the depth builder's, exports.ts) are to read it.
Keeping. "Keep" (in the More menu) writes the recipe for the current calculator as mine:recipe/<uuid> through host.objects.upsert, one id per calculator so keeping again updates the same object. It also:
- upserts the compound with
as: "carry"when the browser has no record of it (a reagent carries PubChem's CID and weight as afactsblock; a hand-off record keeps its formula and SMILES indata); - links the recipe
made-fromthe compound andmentionsthe parent for a salt; - for a stock in mass mode, adds the person's numbers to the compound:
stock-concentration(in mol/L, with the entered value and unit),lot-formula-weightwhen a vial weight was typed andlot-puritywhen purity is below 100 percent, each with the recipe as source andapp: "lab-calculators"; - in a guided walk (J3,
src/components/suite/walk/definitions.ts), names the recipe and links a planmade-fromthe walk's stock.
The stock label is offered only for a keepable molarity, dilution, serial, buffer or recipe calculation.
Inbound. objectInbound in objects.ts: a compound with a cpd field travels privately as a hand-off record, otherwise by name; a target travels by accession with its range and, when present, its residues marked alphabet=protein; a sequence or construct is offered only when its record states it is protein; a recipe always travels privately. Senders today include Molecule (sendTo("lab-calculators", …) with the compound payload), Protein, Sequence (links into Calc from its panels) and Assay ("Review preparation in Calc" for a master-mix context). Bioreactor names Calc in its pump view but sends nothing.
Object page tabs (object-tab.tsx, loaded on demand):
- Compound: the CIAAW weight from a cached formula fact, the
dataformula, or a label that is itself a formula; the recipes linked to it; a link opening the molarity calculator with the name and weight. - Target, sequence, construct: ε280 in both conventions and the molecular weight, only for residues stated (or, for a
uniprot:target, known) to be protein; the person'sa280andprotein-concentrationnumbers (Calc itself writes neither; they come from the object page's "Add a number"); a link to the A280 panel carrying residues up to 1,800 letters. - Recipe: inputs, components and pinned methods, with a link that reopens the sheet from the inputs.
Studies. Kept recipes join Studies through the shared verbs and the Study export bundle; Calc adds no Study logic of its own.
Exports
From the shell's Export menu (exports.ts):
| Id | File | Content |
|---|---|---|
calculators.csv |
<base>.csv via buildTabularExport |
sheetRows: the active calculator's rows only (sheetSectionApplies): the compound's rows on molarity, dilution and serial, that calculator's inputs as typed with their effective defaults (calculatorInputRows, scoped by TASK_KEYS, plus the Serial plan's compound), and its answers, with method, version and constants table per row. Each row's version is its method record's own (sheetMethodVersion, from METHODS), and blank on a row without a method. Before 20 October 2026 every calculator's defaults and valid answers were listed whatever the task (a molarity sheet carried the default Tris pK at 4 °C), and every row without an explicit version said 1.0.0, so the serial plan's rows said serial-dilution 1.0.0 and the recipe table's recipe-table 1.0.0 beside their 2.0.0 engines (audit gemini-wp M4, m1). When the Serial tab has a plate sheet that plans (ps), its Prepare steps and Dispense wells follow as rows of the calculator "Plate preparation sheet (planned)" with method plate-sheet and its version (plateSheetRows in panels/plate-sheet-rows.ts, loaded on demand with the planner). Masses, volumes and concentrations in this file are formatted with show(…, 6) (six significant figures) except serial concentrations, which are raw mol/L. A "Preparation status" column says the plan is not a performed preparation. The provenance block (app, subject, canonical URL, sources actually used, methods, limits, regulatory and advice statements) is built by provenanceFor. For master mix and preparation tasks the rows come from those features' own inputs and outputs. |
calculators.table |
<base>.tsv |
The serial plan as TSV (tablePayloadToTsv): point, concentration in the top unit, solvent percent |
calculators.assay-template |
CSV via buildTabularExport |
The response template (roadmap-v0-depth A2; CPO memo of 11 October 2026, Q5), rows from templateRows in src/lib/table/response-template.ts. Columns exactly plate, well, row, column, role (sample, vehicle, positive-control, blank: the sheet's preparation words, CPO ruling of 12 October 2026 §D.23), compound, concentration (<unit>), replicate, point, response (blank), replicate qualification, preparation status. With a plate sheet that plans, one row per planned well, sorted by plate then well (row-major), plate 1; dose wells are sample rows with the plan's compound and its own concentration, point and replicate; the compound is the Serial tab's optional "Compound" field (pc, prefilled from the stock's compound; it also names the recipe's component, the plate sheet and the bench sheet's compound row) and is never the tab's name: with none, the compound cells are blank, the README lead asks for them to be filled or named in Assay, the sidecar records no compound, and the file and the provenance subject take the plan's label (CPO §B.1); vehicle, positive-control and blank wells are rows with their role and no compound, concentration, replicate or point. Without a sheet, one sample row per planned position with no well. The replicate qualification column reads "repeated wells of one tube; not independent replicates". The export delivers the CSV with its <base>.provenance.json sidecar, whose query.params carry recipeRef (the kept mine:recipe/…, empty until kept), recipeLabel and methods (the recipe's pinned method versions), and its README (always, since 12 October 2026): the lead (asking for the compound column when none is named), the column notes (workflow.templateNotes, the CSO's ruling of 12 October 2026 on the role words: what each role put in the well, that 100 and 0 percent are assigned in Assay, a control's concentration empty, never 0, the response as the instrument reports it, the replicate qualification, point 1 the top, the sheet's "Dose" for the template's sample rows); its method lines are the shared README's "Methods and parameters", read from the provenance block's methods (the Serial tab's method records). Assay reads a filled template back as a plate (Assay) |
calculators.recipe |
<base>.json |
The recipe payload in the suite's JSON export envelope with the provenance block |
calculators.print |
printed page | window.print() |
calculators.label |
printed label | Stock label dialog (offered when keepable; withheld for a walk's example stock) |
Panel-level exports: the master-mix CSV and a tab-separated copy to the clipboard (master-mix-export.ts, per-reaction and total volumes in µL, typed inputs, method and warnings on every row); the preparation CSV (preparation-export.ts, every input with unit, method versions, output rows and qualifications); the intermediate plan CSV (intermediate-export.ts, all constraints including unsupplied ones, the plan, and the four qualifications).
Stock label (panels/label-dialog.tsx, own chunk): name, concentration line, solvent, date, initials and storage temperature on Avery 5160 (66.7 × 25.4 mm), Avery 5167 (44.5 × 12.7 mm), Brady 25.4 × 25.4 mm or a small 38 × 19 mm label. The QR code is drawn on the device by uqr 0.1.3 (MIT) as inline SVG with error-correction level L, and only when the link is at most 260 characters (QR_MAX_CHARS); a longer link prints without the code. Printing uses a page-size @page rule. Licence handling for uqr is in Dependencies and licences.
Network and privacy
All calculations run in the browser; no Web Worker is used. Network use:
- The reagent table, a same-origin static file.
- The reagent picker's "More compounds" uses the suite's shipped search catalogues for suggestions. The shared PubChem supplement is idle (SEC-02): typing no longer requests autocomplete. A selected public lookup uses
/api/search/compound?name=<name>, a server route that queries PubChem PUG REST for SMILES, formula, InChIKey and title and returns a compound payload (pubchemCalculatorPayload). That selected name reaches this site's server and PubChem. The registry declarespubchem.ncbi.nlm.nih.govindataPolicy.endpointswithconsent: "none", so the host appears in Calc's generated Content Security Policy even though the browser calls only the same-origin routes. Direct lookup consent remains open under SEC-02. See Security and network. - Links to Molecule, Protein, ExPASy ProtParam, phbuffers.org and PubChem compound pages are ordinary navigations.
Shared links carry inputs in the fragment only; the limits state that a link stays in browser history and wherever it is pasted. A label's QR code carries the same link.
Methods and checks dialog
The methods and checks dialog (D-0085; Shared shell, Registry and content) reads content/apps/lab-calculators.methods-sheet.ts: one group per calculator a person picks (OPERATION_IDS, each preparation kind its own), with the inspector's equation and the methods from METHODS_BY_OPERATION in content/apps/lab-calculators.method-groups.ts (every step a result can take, the intermediate dilution and the recipe's buffer pH section included), then the unit converter's task page. Summaries are the registry tasks' descriptions where a task exists. definition.methods.group() returns the bench store's operation. The method inspector reads METHODS_BY_TASK from the same module and ends with "Methods and checks", which opens the dialog at its calculator (MethodsLink).
Verification
Wider test infrastructure is in Build, test and CI; the oracle policy across apps is in Oracles and verification.
Unit tests
All run under npm test (node --test). On 10 October 2026 the first 22 files below ran 220 tests, all passing (Node 22.22). On 11 October 2026, after Wave 3, src/lib/units ran 118 tests, src/lib/dilution 159 and src/lib/preparation 79, all passing.
| File | What it checks |
|---|---|
src/lib/units/units.test.ts |
Atomic weight table shape and precision; imatinib and mesylate exact sums, half-up display, parent share; formula grammar (hydrates, groups, multipliers, charges, Hill order); decimal rounding with carries; exact power-of-ten scaling round trips; unit spellings; best unit and percent w/v; molarity helpers |
src/lib/units/formula-mass-fixtures.test.ts |
The 58 formula-weight fixtures exactly |
src/lib/units/quantity-entry.test.ts |
Unit-bearing paste and typed entry |
src/lib/dilution/dilution.test.ts |
C1V1 solving and refusals; imatinib stock, purity, weigh-what-you-weighed, parent share; strict 2 µL flag on exact values and DMSO carry-over; 10-point series; recipe cases; Goldberg Tris values and table shape |
src/lib/dilution/fixtures.test.ts |
The 31 dilution fixtures (one test per case) and the 15 buffer fixtures (within 0.01, and 0.0001 against the same equation); the buffer constants agree with the fixture's second transcription; Tris pH 8.64 at 4 °C |
src/lib/dilution/science.test.ts |
Audit B7: combined stock volumes, invalid stocks and purity, overflow |
src/lib/dilution/master-mix.test.ts |
The hand-worked reference table of master-mix-increment.md, unit families, warnings, invalid input, conservation, plan reader |
src/lib/dilution/intermediate.test.ts |
Hand-worked 10 mM to 1 µM in 1 mL (2 + 38 µL, then 2 + 998 µL), direct preference, carrier rules, bounds, refusals, plan reader, 600 constructed feasible cases |
src/lib/dilution/intermediate-oracle.test.ts |
Constructed feasible stock/diluent/transfer combinations must yield conserved plans |
src/lib/preparation/preparation.test.ts |
Cell bases and corrections, area and volume targets, split mode, RCF worked value and inverse, percent bases, antibody ratio and mass units, antibody intermediate, structural readers |
src/lib/seq/extinction-fixtures.test.ts |
The 22 ε280 fixtures |
src/lib/fixture-hashes.test.ts |
SHA-256 of every fixture file matches fixture-hashes.json and the published copy path |
pipeline/lab-calculators/verify-reagents.test.ts |
The shipped table is what the CSV builds under the manifest's hashed name; every weight recomputes; hydrates add n × 18.015; identifiers well formed, no CAS column; PubChem formula equals typed formula plus waters; buffer ids exist and no pKa is typed |
src/apps/lab-calculators/calculators.test.ts |
Address schema and taskFromState; the example cpd decodes to imatinib mesylate with 589.72 and 16.3 percent; model worked numbers; inbound rules; recipe objects; compound input; every chip method cited and verified or unit-tested; protein-only alphabet rule; purity refusal; task-page example labelling |
src/apps/lab-calculators/{intermediate,master-mix,preparation}-workspace.test.ts |
Resume, record, CSV and stale-result refusal for those features |
src/apps/lab-calculators/serial-handoff.test.ts |
The serial table keeps full precision |
src/components/tools/lab-calculators/operations.test.ts, input-rows.test.ts |
Group and operation mapping; CSV input rows scoped to the calculator, its answer sections, and each method's own version in the rows |
src/lib/workspace/intermediate-continuity.test.ts, preparation-projection.test.ts |
Projections of recorded Calc results |
src/lib/dilution/{serial-2,plate-sheet,buffer-recipe,preparation}-oracle.test.ts, src/lib/preparation/{preparation,od600,antibody-batch}-oracle.test.ts, src/lib/units/unit-converter-oracle.test.ts |
The Wave 1 and Wave 3 oracle fixtures below; each checks the fixture hash first |
src/lib/preparation/od600-calibration.test.ts |
The paired-calibration estimate: exact line, range refusals, dilution, blanks (no independent fixture) |
src/lib/table/sheet-placement.test.ts, src/components/tools/lab-calculators/address-deep.test.ts, preparation-drafts.test.ts |
Sheet placement on an Assay layout; the deep checks of ep, mm, ip, ps; per-kind drafts |
src/lib/preparation/cell-counting-oracle.test.ts |
Every case of cell-counting-v2.json (25) and cell-counting-v3.json (28) through readCellCountingInput and the engine: volume-weighted densities, viabilities, refusal codes, at each fixture's declared about.tolerances (asserted to be 1e-9 relative); the seeding bridge (case 14) and its two refusals with their content reasons; the v3 receipt-integrity cases fail closed |
src/lib/preparation/receipt-integrity-oracle.test.ts |
Every case of receipt-integrity-v1.json (0.9.0) through readPreparationResult: must-read receipts read unchanged, the others read as null; the fixture equals its published copy and rests on the published 0.7.0 v3 files (their hashes), and holds every refusal the CSO listed |
src/lib/preparation/dose-plan-oracle.test.ts |
Every case of dose-plan-v2.json (24) and dose-plan-v3.json (35) through readDosePlanInput and the engine: dose per cell arithmetic, bases TU/PFU/IFU/vg/particles, volume units L/mL/µL, target time context, refusal codes, qualifications mapped to their content sentences, at each fixture's declared about.tolerances (asserted to be 1e-9 relative); the v3 receipt-integrity cases fail closed |
scripts/calc-chunk-imports.test.mjs |
Asserts that no calculator island and no existing kind definition statically reaches the new calculate, receipt or form modules |
Oracle fixtures
pipeline/lab-calculators/reference.py (Python 3.11, pinned requirements.txt: RDKit 2026.3.6, NumPy 2.4.6, Biopython 1.85; build-time only) writes the fixtures; publish copies them to public/verification/lab-calculators/<version>/ and writes fixture-hashes.json.
| Set | File | Count | Oracle | Tolerance used by the tests |
|---|---|---|---|---|
| Formula weights | src/lib/units/fixtures/formula-mass.json |
58 (21 organic, 15 organic salts, 14 hydrates, 8 inorganic) | CIAAW 2021 sum in Python decimal; organic formulas from RDKit CalcMolFormula on PubChem SMILES; RDKit's own weight recorded beside, never expected |
exact |
| Dilution | src/lib/dilution/fixtures/dilution.json |
31 (mass for molarity 7, serial 7, C1V1 5, concentration from mass 3, volume for mass 3, purity 3, parent equivalent 1, minimum-transfer top volume 1, recipe 1) | fractions.Fraction and decimal |
nine significant figures |
| Buffers | src/lib/dilution/fixtures/buffers.json |
15 of the 34 buffers at 4, 25 and 37 °C | Goldberg equation 12 in NumPy, constants transcribed a second time | within 0.01 |
| Extinction | src/lib/seq/fixtures/extinction.json |
22 sequences | Biopython ProtParam | exact |
| Master mix (0.2.0) | src/lib/dilution/fixtures/master-mix.json (master_mix_reference.py) |
14 (both MAN0025706 plate formats, a dead volume, warnings, six refusals) | Python fractions 3.11.15 |
nine significant figures; refusals exact |
| Intermediate dilution (0.2.0) | intermediate-dilution.json (intermediate_reference.py) |
13, each infeasible case with an exact certificate | Exact brute-force grid over stock draw and final transfer | feasibility exact; plan within one grid step; every constraint re-checked |
| Serial 2.0.0 (0.3.0) | serial-2.json (serial_reference_2.py) |
10 | Python fractions |
1e-9 relative; each step exactly the fold; flags exact |
| Plate sheet (0.3.0) | plate-sheet-2.json (plate_sheet_reference_2.py, constant transfer; plate-sheet.json is the earlier recording, made before the constant-transfer ruling) |
22 | Python fractions |
1e-9 relative; wells, roles, sources and refusals exact; compound, carrier and volume balances asserted |
| Preparation (0.3.0) | src/lib/preparation/fixtures/preparation-3.json (preparation_reference_3.py; preparation.json and preparation-2.json are earlier recordings) |
41: cells 13, centrifuge 10, percent 8, antibody 10 | Python fractions and decimal 3.13.16 |
1e-9 relative; refusals, flags and text rows exact; the antibody intermediate against Wave 1's grid |
| Antibody batch (0.3.0) | antibody-batch.json |
10 with allowances | Python fractions |
1e-9 relative; refusals exact |
| OD600 (0.3.0) | od600.json |
14, 9 refusals (every factor a stated example of a person's own calibration) | Python fractions |
1e-9 relative; refusals exact |
| Buffer recipe (0.3.0) | src/lib/dilution/fixtures/buffer-recipe.json |
18, 8 refusals | CIAAW 2021 sum in Python decimal; Goldberg equation 12 in NumPy 2.5.3 |
mass 1e-9 relative; setpoint within 0.01 pH and equal to two decimals; pK within 1e-3; refusals exact |
| Unit converter (0.3.0) | src/lib/units/fixtures/unit-converter.json |
44, 16 refusals | Python fractions with BIPM SI factors |
prefix changes and temperature exact as decimal strings; molar bridges 1e-9 relative; refusals exact |
| A260 (0.4.0) | src/lib/seq/fixtures/a260.json (a260_reference.py) |
14: dsDNA 50, RNA 40 and ssDNA 33 at 1 cm, three dilution and three path-length cases (two combined), a zero reading, two refusals (path ≤ 0) | Python fractions 3.13.16, factors transcribed a second time from the sources |
1e-9 relative; refusals exact. Superseded by 0.5.0 for method 1.1.0; stays published and hashed |
| A260 (0.5.0) | src/lib/seq/fixtures/a260-2.json (a260_reference_2.py) |
19: 0.4.0's cases carried forward unchanged except zero-reading, now refused-reading-zero; negative, NaN, +∞ and −∞ readings refused; the smallest accepted reading, 0.0001 |
Python fractions 3.13.16 |
1e-9 relative; refusals exact by problem code (src/lib/seq/a260-fixtures.test.ts, which also checks the carry-forward; the 1 cm cases also in protein-mass.test.ts) |
| A280 concentration (0.6.0) | src/lib/seq/fixtures/absorbance-a280.json (a280_reference.py) |
14: the unit tests' example (0.5 AU, ε 16960, 1 cm, 11980.91 g/mol: 29.48 µM, 0.3532 mg/mL) and at 0.5 cm, a synthetic case, the smallest accepted reading; zero, negative, NaN and ±∞ readings, path 0 and ∞, ε 0, mass 0, and ε 0 with a negative reading (the order) refused | Python fractions 3.13.16; ε280 and mass are inputs |
1e-9 relative; refusals exact by problem code (src/lib/seq/absorbance-fixtures.test.ts) |
| A260/A280 ratio (0.6.0) | src/lib/seq/fixtures/absorbance-ratio.json (a280_reference.py) |
10: 1.8 and 5/3; A260 zero, negative, NaN, −∞, A280 zero, negative, ∞, and both refused (the order) | Python fractions 3.13.16 |
1e-9 relative; refusals exact by problem code |
| Cell counting (0.7.0) | src/lib/preparation/fixtures/cell-counting-v3.json (cell_counting_reference_3.py; cell-counting-v2.json is the frozen recording) |
28: mandatory cases, single/multiple regions, live-dead/live-only/total-only modes, 1 to 64 regions, all-zero edge cases, numeric-range, seeding bridge equality to 1e-9; 15 of them refusals, plus 6 receipt-integrity cases | Python fractions and decimal 3.9.6 |
1e-9 relative; refusals exact |
| Dose per cell (0.7.0) | src/lib/preparation/fixtures/dose-plan-v3.json (dose_plan_reference_3.py; dose-plan-v2.json is the frozen recording) |
35: mandatory cases, bases TU/PFU/IFU/vg/particles, volume units L/mL/µL, measured/estimated cells, zero dose, numeric-range, all 10 cross-basis pairs; 23 of them refusals, plus 7 receipt-integrity cases | Python fractions and decimal 3.9.6 |
1e-9 relative; refusals exact |
| Contract 10 receipts (0.9.0) | src/lib/preparation/fixtures/receipt-integrity-v1.json (receipt_integrity_reference.py, from the published v3 fixtures) |
49 complete receipts: 26 that must read (every calculated v3 receipt, one density within float rounding), 23 that must read as null (density identities, canonical units, the PFU-beside-TU case and the ten basis pairs) | Python fractions 3.13.16 |
Each receipt reads unchanged or reads as null, exactly |
fixture-hashes.json records each file's hash, publication path and, for the later files, its own version, script and script SHA-256; its top-level version still reads 0.1.0. publish_preparation.py publishes the Wave 1 and Wave 3 fixtures under public/verification/lab-calculators/0.2.0/, 0.3.0/, 0.4.0/, 0.5.0/, 0.6.0/ and 0.7.0/, each with a manifest.json listing only the files recorded for that version (0.1.0 has none; 0.2.0 also holds byte copies of the four 0.1.0 files). The registry's verificationSets name the version each set was recorded for. The docs page renders a pass/fail table from the fixtures at build (verification-table.marketing.tsx). The 2 µL rule and the OD600 paired calibration have no published fixture.
Browser tests
| Spec | Covers |
|---|---|
e2e/lab-calculators/counting-dose.spec.ts |
Group selector navigation to Cell counting and Dose per cell; both forms blank on open (no worked example is loaded; the dose form has no example key); two regions entered by hand with unselected volume units; the viable density and viability rows; no horizontal scroll at the project's viewport; the bridge's Keep/Replace dialog with the formatted density, and Replace seeding Cell seeding; the dose form's placeholders, the titre unit withholding the result until chosen, the 1,000x µL case, the allocation row and notice, and basis clearing |
e2e/lab-calculators/workspace.spec.ts (8; amended for the view toolbar and the tile names, D-0084, and for the keys on the tab row, the CEO, 14 October 2026) |
Mass from two numbers and a weight, announced once, link keeps inputs; Molecule hand-off with parent and shortfall (H1); target accession to ε280, MW and concentration (H3); serial plan, DMSO flag and TSV export; Tris on ice; a kept stock reopened, added to a Study and shown on the compound tab; the label QR carries the canonical link within the cap; reagent picker |
e2e/lab-calculators/tasks.spec.ts (5) |
Molarity task page answers without the shell; Tris page; reagents page with PubChem ids and no CAS column; every task page linked and present; each page loads only its own island |
e2e/calc-preparation.spec.ts (1) |
Everyday preparation bases, finite allocations, exports and exact saved inputs |
e2e/intermediate-dilution.spec.ts (1) |
Explicit constraints, qualified results and exports |
e2e/lab-calculators/serial-preparation.spec.ts (2) |
First-transfer review in a fresh tab; preserved dirty source and other dilution; ordinary independent save/record/reload; unknown constraints and solvent refusal; free review, cancellation, changed-source repeat and link-cap refusal |
e2e/improvements/stock-autofill.spec.ts (3; desktop, phone, dark) |
A stock recorded through the Objects form fills Serial after review: a Used up container refused, Cancel changes nothing, the unrecorded carrier percentage withholds the plan on screen and for a share-link recipient (no stock ref or location in the link), the typed percentage plans it, the origin and draw lines; Save to Bench and Record result capture the stock input with no lot, location or amount; a TSV export, the printed sheet and Methods text read it; a hand edit drops the selection and the basis; the stock record byte-identical throughout; C1V1 holds a mg/mL stock against a µM final with a formula weight on the sheet and solves against µg/mL; g/L refused; a review made stale by an edit in another tab applies nothing |
e2e/scientific-continuity.spec.ts |
Master mix separate additions, incomplete totals refused, reaching Assay as context |
e2e/objects/j3.spec.ts |
The guided walk from a stock through a serial plan to an Assay IC50 |
e2e/perf/two-second.spec.ts |
The two-second gate: the molarity task page, cache primed, CPU 4× and slow 4G, answer within 2,000 ms of navigation start (cold load reported, not gated) |
Browser tests were not run for this page.
Size budgets
scripts/size-budgets/lab-calculators.json: the definition chunk 21.4 KB, the label dialog 16 KB, the five original task islands 49 KB under the interim rule D-0048 (measured 42.5 to 44.0 KB on 10 October 2026), with releaseKB: 40 to be restored at the pre-live pass, and the six Wave 3 islands (centrifuge-rcf-rpm, percent-solutions, cell-seeding, unit-converter, od600-cell-estimate, antibody-dilution) at 40 KB with no interim, born under budget. Wave 3 step 0 made room by checking the JSON keys structurally in the schema (address-deep.ts runs the readers in the sheet), moving CALCULATOR_IDS to its own module and splitting the copy; docs/NEXT.md records the molarity island at 40.0 of 40 KB afterwards, and the shared method-chip module as the reason no plate-sheet chip was added. Contract 10's two forms have their own chunks (calculators-cell-counting-chunk 1.8 KB, calculators-dose-plan-chunk 1.5 KB, loaded by preparation-panel.tsx through next/dynamic), which hold the form JSX only: the two kind definitions, and through them the engines, readers, receipts and content, are imported statically by preparation/all.ts and land in the bench-sheet chunk. That chunk carries the marker drybench-island:calc-bench-sheet (BenchSheet.displayName); its calculators-bench-sheet-chunk row waits for a measured size from a build (the CTO's memo of 20 October 2026, item 3). Loading the definitions on demand is owed (REL-396): the synchronous consumers of PREPARATION_KIND_DEFS in bench-sheet.tsx (the counting bridge's calculation and the CSV's kind definition) and src/components/tools/lab-calculators/model/preparation.ts (preparationRecipe, preparationExample) must become awaited, and the bench-sheet ceiling is then lowered to the new measured size plus 15 percent.
Known gaps, defects and divergences
Found while writing this page by reading the code (and, where stated, by calling the pure modules with node). They are not fixed here.
Calculation and input defects
- A link can pair a quantity with a unit of the wrong dimension.
unitCheck(src/components/tools/lab-calculators/address.ts) accepts any known unit for any…ukey, andbaseOf(model/common.ts:54) converts without checking the dimension. Verified:c=10&cu=mg/mL&fw=100&v=1&vu=mLgives a mass of 1 g (10 g/L treated as 10 mol/L);vu=mgtreats a mass as a volume. The panels' unit selects only offer the right units, so this needs a hand-edited or crafted link. - The recipe table loses a component's liquid-stock source whenever its rows are re-read from a payload:
RecipeComponentPayloadhas no stock field,recipeForomits it, androwsFromPayloadsetsstock: null(model/recipe.ts:72). After a reload or shared link the default EDTA row turns from "add 2 µL of 500 mM stock" into "weigh 0.372 mg" (verified with the model). Rows with no amount are also dropped from the payload. - In the recipe panel the formula-weight field is disabled once a stock is set (
panels/recipe-panel.tsx:59), but a molar target from a mass-concentration stock (or the reverse) needs a formula weight, so such a row can only be fixed by clearing the stock first. - The recipe panel has no purity field and does not offer volume-fraction targets, though the engine supports both.
- Molarity can be kept or labelled only in mass mode:
recipeForneeds bothcandv(views/bench-sheet.tsx:1237), but volume mode hidesvand concentration mode hidesc. A recorded result is likewise offered only in mass mode (bench-sheet.tsx:490). molarFactor(bench-sheet.tsx:1177) converts the stock concentration for thestock-concentrationnumber with float factors and returns 1 for any non-molar unit, instead of using the units module.- The serial
dmsolink value is only checked as non-negative (address.ts:110); a link withdmso=500gives solvent fractions above 1. The field itself limits 0 to 100. - Quantity entry accepts
1e-400 mMas 0 (verified), because the number underflows before the overflow check inconvertEnteredQuantitysees it. - The intermediate panel converts with
* 1e6and* 100(panels/intermediate-panel.tsx:26,:30) rather thanscaleByPow10, so 7.7 µL can reload as 7.700000000000001 (10 October audit). - A formula written
Fe3+is read as three iron atoms with charge +1 (src/lib/units/formula-mass.ts:51); the parser's convention is sign then magnitude (Fe+3), which the header documents. Verified:Fe3+gives 167.535 g/mol.
Labelling and provenance
- A compound chosen from PubChem in the reagent picker is resolved with
origin: "molecule"(model/compound.ts:112) and its arrival is attributed to Molecule (bench-sheet.tsx:373), so it may be labelled "From the molecule app". Found by reading; not confirmed in a browser. - The A280 task page says the His6 tag adds 2,244 Da (
tasks.marketing.tsx:138); the engine adds 2,163.3 Da (verified). - The default recipe row named "EDTA" uses 372.238 g/mol, the disodium dihydrate (
model/recipe.ts:28). - The "How it is checked" copy says "28 dilution and molarity cases" (
content/tools/lab-calculators.ts:32); the fixture file has 31. The same file says "the app contacts no host" (line 26) and that nothing is fetched while you work (line 69), which is untrue of the PubChem search. The brief (§7, §3.4) also still says no hosts and no PubChem lookup in v1. - The comment on
BufferEntry.formuladescribes a buffering range (src/lib/dilution/buffer.ts:46).
Objects
- The recipe object tab maps any kind other than stock, dilution, serial-dilution and buffer to the recipe table (
object-tab.tsx:149), so a kept master mix or preparation reopens on an empty recipe table; its "open" link carries onlyinputs, not the recipe payload (lines 188 to 195), so a multi-component recipe also reopens without its rows. - A280, buffer and recipe-table work cannot be saved as named Bench tasks; A280 cannot be kept as an object at all (
recipeForhas no A280 branch).
Verification and process
- The 2 µL rule and the OD600 paired calibration have no published oracle fixture. (A260 since 0.4.0, SCI-147; master mix, intermediate dilution, the preparation kinds, the buffer recipe, the unit converter, serial 2.0.0 and the plate sheet now do.)
fixture-hashes.json's top-levelversionstill says 0.1.0 although it lists files of three versions. - Serial dilution 1.0.0 under-diluted the last point by f/(f − 1). Saved serial recipes pin
dilution 1.0.0, and nothing marks them on open yet; the CSO asks for a visible notice (docs/NEXT.md). - The cells, centrifuge and percent method records name the input modules (
kinds/cells.ts,centrifuge.ts,percent.ts) as their implementation, while the arithmetic is inkinds/*-calculate.ts; the antibody and OD600 records name the calculation modules. - The everyday-preparation fixtures were recorded with Python 3.13.16 (
fixture-hashes.json, the registry's second "Python fractions and decimal" oracle), not the 3.11.15 the other Calc oracles name; no requirements file pins it. - Plate sheet follow-ups (
docs/NEXT.md): the sheet is in the bench sheet's CSV (12 October 2026) and the response template carries its wells and roles; print relies on the section's own tables being on the page (print:block), and the browser specs for the CSV, the print and the hand-off awaitmain's build (record:docs/progress/assay-depth-1.md). The OD600 paired calibration keeps its pairs on the page only. - Only 15 of the 34 buffers are compared with the NumPy oracle; the other 19 rest on the source transcription alone.
recipe-tableis version 2.0.0 (changed by the B7 fix) while its "verified" badge rests on the 6 October dilution fixtures, of which one case is a recipe.- The brief's science section differs from the code: it asks for "all Cys reduced" first for tagged constructs (the code always defaults to cystines), a Gill and von Hippel alternative (absent), nearest-neighbour oligo ε260 (excluded), an FW tolerance of 0.01 against RDKit (the code checks exact CIAAW sums), and about 60 reagents (75 ship).
- The five original task islands exceed their 40 KB release budget and run under the D-0048 interim 49 KB; the six Wave 3 islands have no interim margin.
Atlas composition (D-0084, 14 October 2026)
- Frame 040 draws the compound as an inline search field; the sheet's search is the reagent picker dialog that Pick a reagent opens (the picker searches the shipped table, the clinical catalogue and PubChem), and no second search was built.
- Frame 047, "Constrained intermediate dilution", is listed with the serial frames in the pass brief. The planner remains on C1V1 (its Preparation row) and the antibody kind; D-0090 now links the first serial transfer warning to an independent C1V1 preparation review, without inserting preparation stages into the serial payload.
- Frame 048 draws the recipe's Components as a data table; they stay editable rows (each with its mass or volume inline), since a table would lose the fields.
- Frames 055 to 061 name two to four tiles per preparation kind; the kinds'
headlinerows give them, and the full table beneath keeps every row. - Fixed in the pass: the A280 answer region was empty before a reading and the Nucleic-acid estimate before an A260 reading (each now says what is missing); the qualified line drew a dash before its sentence; the recipe table's two refusal sentences lived in the component (now
recipe.refusalsin the workspace copy).
Code conventions
- User-visible English outside content files:
panels/reagent-picker.tsx:88,:112,:119,workspace-adapter.ts:27to:42(resume errors shown in a toast),views/bench-sheet.tsx:290and:1286, and the worked-example sentences intasks.marketing.tsx. Several of those sentences (A280, serial) are hard-coded numbers rather than computed. - The per-task method lists are repeated in
inspector.tsxandbench-sheet.tsx;UNIT_DISPLAYis exported empty.
Adding a calculator safely
- Engine first. Put the arithmetic in a pure module under
src/lib/(relative.tsimports, no React, no@/), working in base units and never rounding. Validate every input in the engine and return a typed problem rather than a non-finite number. Export a method record (id,name,implementation,version,reference, andconstantsTablewhen constants are involved). - Reference values. Add an independent fixture from a named external oracle through
pipeline/lab-calculators/reference.py(new sub-command), publish it withpublish --version <app version>, and test against it. Hand-worked tables in the design document are the minimum; the roadmap rule (docs/roadmap.md) is that a method ships with an independent fixture and a methods entry. Register the set in the registry'sverificationSetsandoracles. - Ids and copy. Add the id to
CALCULATOR_IDS(if it needs its own addresstask) or as a preparation kind, toOPERATION_IDS, and to a group inlab-calculators.sheet.ts. All copy, including refusal messages and the inspector equation, goes incontent/apps/lab-calculators*.ts. - Address. Add keys to
calculatorsSchemawith checks that also enforce the dimension and range the panel enforces, and list them inTASK_KEYS. A structured input goes in one JSON key with a structural reader that keeps unfinished values (seemm,ep,ip,ps); the schema checks its shape only (versionedJson), and the full reader goes inDEEP_CHECKS(address-deep.ts), so the islands stay small. - Model and panel. Add
model/<id>.ts(pure) andpanels/<id>-panel.tsx, usingQuantityFieldfor unit-bearing fields so typed and pasted units are handled, andAnswer,Needs,WithheldandMethodChipfrompanels/parts.tsx. Mount it inBenchSheet'spanel()switch andresults. - Methods and provenance. Add the record to
METHODSwith its verification state, map any constants table inprovenance.tsto the right registry source, and add the task to bothmethodsForandMETHODS_BY_TASK. - Persistence. Decide whether the calculation is a recipe kind. If so, extend
RecipePayloadandreadRecipePayload(keeping old payloads readable),recipeFor,calculatorRecipeState,captureCalculatorWorkspaceResult(with a stale-result guard if the plan has its own reader), the projection insrc/lib/workspace/calc-projection.ts, andobject-tab.tsx. Make sure every editable input round-trips through the payload; the recipe stock defect above is what happens when one does not. If it cannot be saved yet, add a sentence tosaveUnsupportedText. - Exports. Add rows to
sheetRowsand, if useful, a feature CSV writer that keeps full precision and every unit. - Shortcuts and budgets. Keys
1to8are taken; update the registry'sshortcutsto match the definition. If the calculator has a task page, add its island totask-islands.tsxand a budget toscripts/size-budgets/lab-calculators.json. - Checks. Run
npm run lint,npm test,npm run build,npx tsc --noEmitandnpm run test:e2e, and add a browser test for the new panel at desktop and 375 px.
Related pages
Architecture, Registry and content, Storage and state, Security and network, Shared shell, Data pipeline, Oracles and verification, Build, test and CI, Assay, Molecule, Protein, Sequence, Known issues.
Scientific audit corrections — 11 October 2026
OD600 paired calibration 1.1.0 refuses decreasing density-versus-OD slopes even when R² is 1; ordinary descending assay calibration remains supported. Buffer-recipe 1.1.0 applies its suitability rule at the target working temperature; the cold Tris worked example now uses pH 8.0. Two formerly accepted cold-Tris cases in the immutable 1.0.0 fixture are now explicitly refused under the new policy, while their original mass/pKa/setpoint arithmetic remains checked. New independent temperature cases verify the corrected acceptance boundary.
Serial capture binds the plan, units and current method to its consumed inputs. Pipetting/carrier flags become saved warning qualifications. A read-time 1.x-method advisory accompanies old plans and downstream assay use; frozen inputs/results and their hashes are never migrated or recalculated. See verification (docs/science/2026-10-11-scientific-audit-fixes.md).
Numeric entry sizing (16 October 2026)
The shared NumberField reserves six characters plus horizontal padding for
the editable value. Units retain their own width; a narrow flex row cannot
collapse the number to zero. Every Calc operation is exercised with 12345
at desktop and 390 px by e2e/improvements/oct16-fixes.spec.ts. Parsing,
precision, invalid-input handling and keyboard controls are unchanged.
Printable protocols (16 October 2026 candidate)
A completed direct/intermediate dilution or multi-component buffer recipe offers
Make protocol. protocol.ts projects existing solved values; it adds no
calculator. The on-demand protocol-view.tsx freezes the source, presents a reagent
table and ordered checkboxes, and prints using the shared methods/limits/source
appendix. Source links carry captured inputs in the fragment. Following one
recalculates with the current implementation; method versions identify the printed
plan. Checkboxes are transient and do not assert preparation or consume inventory.
Stock, diluent and final volumes remain distinct. Recipes require final-volume make-up; no water amount is inferred from powder displacement. Intermediate constraints, solvent fractions and qualifications survive projection. Invalid inputs or infeasible entered constraints withhold the action. Optional pH lines use the existing buffer setpoint implementation and qualifications.
Recipe component payloads now retain optional liquid-stock concentration and unit. Older components without that field retain their existing powder interpretation; malformed new fields refuse the payload. This fixes reopening a liquid-stock recipe as powder. The protocol tests cover this round trip and an exact source link.
Printable plate preparation plan (16 October 2026)
With a serial dilution and plate layout open, Export → Print plate plan opens a lazy, frozen preview of the current inputs. It is available without Workspace entitlement. Back preserves the editable calculation; opening the preview, cancelling the browser's print dialog and printing do not save a recipe or record an experiment. A saved-task label is used only when the captured input fingerprint matches the saved editable task; current unsaved edits are labelled as a draft. Invalid live number text withholds printing rather than exposing the last valid values retained by the calculator.
plate-print.ts projects the existing serial and plate-sheet planners. It
adds no scientific arithmetic. Every preparation row includes its source and
source concentration, prepared volume, source transfer, diluent, prepared
concentration and carrier fraction; every dispense row is retained, including
positive-control quantities that are explicitly Not planned. The map
uses labelled tube/control codes, with unassigned wells marked separately.
A 96-well plate has one map; 384 wells use four labelled quadrants with their
original coordinates. Complete preparation and dispense tables continue over
pages with repeating headers; there is no generic report row cap.
The preview captures its inputs before asynchronous report generation. Back or unmount cancels pending work; an account/task context change masks the captured report. The SHA-256 input identity covers the planning inputs, compound reference, solvent and method pins, and excludes capture time. The shared print appendix retains methods, entered constraints, provenance, qualifications and optional analysis-plan selections. The constraints state which operations the existing planner checks; analysis selections and their calendar date do not imply a completed analysis or preregistration. The hash is a content identity, not experimental verification. This is a current plan, not a replay of a historical recorded result.
The plate copy now states that this preparation plan does not assign response
endpoints or perform background subtraction. Those choices belong to the
measured-readout analysis; this corrects the previous blanket claim about
blank subtraction. The CSO's boundary and wording are recorded in
docs/board/memos/2026-10-16-cso-print-and-recorded-methods.md. Independent
fixture projection and browser/PDF verification are recorded in
docs/progress/feature-02-printable-bench-sheet.md.
The lazy preview has its own 24 KB gzip budget (calculators-plate-print-chunk,
drybench-island:calc-plate-print), approved by the CTO on 16 October 2026
after measuring 21,318 bytes including CSS. Existing budgets are unchanged.
On phones the print action calls the shell’s optional closeMenu() after
capturing its inputs. The idempotent hook dismisses only the Menu sheet;
it does not save, navigate, reset inputs or alter other export actions.
It retains the drawer’s existing close/focus behavior (CPO ruling,
16 October 2026). Paper sections flow naturally and each labelled map
quadrant stays whole; table headers repeat across continuation pages.
Only while this preview is printed, the main region's screen padding is
removed. The eight Prepare columns use automatic layout with width hints;
text labels and headers may wrap, while numerical values remain intact at
10 pt. This avoids clipping long decimal values and empty padding-only pages
without changing the existing quantity formatters or other print views.
A click during initial account bootstrap can receive a retryable refusal if the captured owner changes before the report is ready. Back and reopening capture the settled context. Direct-link capture waits for bootstrap; the strict scope, epoch and research-context guard is retained.
Methods text from immutable Calc results (16 October 2026)
recorded-methods-calc.ts reads recipe/result v1 with units 1.0.0, dilution 2.0.0
and seq 1.1.0 pins. The supported paragraphs describe a calculated preparation
plan: stock mass with explicit formula-weight basis, purity fraction and final
volume; the recorded direct C1V1 solution and its three supplied quantities;
and serial dilution 2.0.0 with its named pipette/carrier rule pins. Direct-input
consistency checks use an explicit historical unit-spelling allowlist and pinned
unit dimensions/exponents; %w/v retains units-1 g/100 mL semantics. No missing
quantity is solved again. Mixed bases require captured weight and basis.
Serial text preserves carrier assumptions, warning flags and planned-well qualifications. Each threshold is labelled independently as a captured setting or the frozen method convention; absent fields never masquerade as recorded values. Legacy serial 1.x stays unavailable with its existing last-tube advisory. Intermediate dilution, plate-sheet preparation and other recipe variants remain explicitly outside this first text format. Unrelated saved tab state does not block a supported recipe. Parent-equivalent stock mass is not exported. Text1 identifies wording only and is independent of current library version constants.
Public reference workflows (16 October 2026)
Five reference workflows run native calculators from their existing URL schemas. The sixth, Gibson quantities, uses Plasmid’s one-insert Numbers view. /calc is the public hub; existing calculator workspace and task URLs remain valid. See Public reference workflows for the shared route, state, source and export contracts.
Bench additions after the 16–17 October review (17 October 2026)
The Gemini additions of 16–17 October were reviewed against the CSO's rulings and the feature contracts (docs/reviews/2026-10-17-recent-work-review/). Removed: the "AI" protocol parser and assistant, the 0.5 µL sub-microlitre guardrail with its fixed 1:10 step, the overfill selector, the ionic buffer formulator (it computed the conjugate-pair ratio the CSO withheld pending the NIST SRM 186 certificate), the haemocytometer/viral MOI planner and the freezer-stock autofill. Pipetting flags remain the engines' own: the 2 µL convention (dmso.ts) in serial dilution and the plate sheet, and the master mix's user-set minimum transfer; the route to a larger transfer is the existing intermediate planner. What stays:
Sample normalisation for gel loading
src/lib/dilution/normalization.ts, method sample-normalisation 1.0.0, the Dilution calculator's second view (norm=1, a declared #s= key in calculatorsSchema and TASK_KEYS.dilution). For a load volume V and dye factor k (0 or above 1), dye D = V/k and A = V − D is left for sample and diluent; sample s = m/c for a target mass, or C·V/c for a target concentration, with c in mg/mL only. A row with s > A is withheld (no volumes; the largest deliverable amount c·A µg, or c·A/V mg/mL, is shown); otherwise diluent = A − s, and the planned mass is s·c on unrounded volumes. Each positive transfer below 2 µL is flagged, never refused; the panel tells the person to make a larger volume or dilute the sample and enter its new concentration. A zero or negative concentration is an invalid row; a non-positive load or target, or a dye factor of 1x, refuses the batch. Checked against pipeline/lab-calculators/fixtures/normalization-v1.json (12 cases, Python fractions; pipeline/lab-calculators/normalization/reference.py) at relative 1e−12 (normalization.test.ts). The view opens empty; a labelled worked example (five illustrative lysates, one planned, one flagged below 2 µL, two withheld) loads only when chosen, carries a banner until cleared, and is never written to a worklist (CPO condition 7; D-0039, D-0044). The view and its worklist writer load on demand (next/dynamic and import()), outside the Dilution island. Copy: content/apps/lab-calculators.normalization.ts. Exports: tab-separated table, a Markdown table (serializeToGfm) and a Tecan worklist. Limits: concentrations as entered; no check of the assay, the protein or the well's capacity; volumes additive.
Sample concentrations from Assay (REL-211, 17–19 October 2026). A normalisation recipe (the recipe hand-off key, by record from Assay's standard curve; Calc's objectInbound.assay reads an assay only with one) is restored by calculatorRecipeState to task=dilution&norm=1&ns=<JSON>. ns is a declared #s= key (calculatorsSchema, TASK_KEYS.dilution, shape-checked as versioned JSON up to 60,000 characters and deep-checked by DEEP_CHECKS.ns in address-deep.ts) read by readNormalisationSamples (src/lib/handoff/payloads/normalisation-samples.ts), which refuses unknown keys, a protocol other than Bradford or BCA, a non-mass unit, duplicate ids or a non-positive value, and preserves optional calibration fit qualifications (at most 30 non-empty strings of up to 1,000 characters each, normalisation-samples.ts; duplicates are kept, so the panel keys the list by position). The panel seeds its rows once per arrival, converting each value from the curve's unit to mg/mL exactly (sampleConcMgPerMl, a power of ten by scaleByPow10), and shows the arrival note (source, model and fit date, the method line, any calibration fit qualifications, what Assay withheld, the limits line), and beside each arrived row its source value and dilution, its interval (shown, not used) and the neutral limit-of-quantification note, which never changes the plan. An unreadable ns adds nothing and says so. The arrival strip reads "… sample concentrations from a standard curve. Set the target and load volume." When a dilution, serial or buffer calculation is refused or flagged on the bench sheet, calculatorRefusalGuidance (content/apps/lab-calculators.refusal-guidance.ts, rendered in views/bench-sheet.tsx as [data-calculator-refusal-guidance="<codes>"]; no task island imports it) states each fired code's own statement and links to /dilutions-buffers/app, labelled with the tool's title from content/education-tools.ts (CSO memo of 20 October 2026, §7). The codes are the engines': the C1V1 problem kind, the direct transfer below PIPETTE_MIN_VOLUME (shown until an intermediate plan answers it), each IntermediateProblem with its own statement or none (incomplete, invalid-range, invalid-source and numeric-range have none: the planner's own words stand), the serial problem kind and each serial flag (the bounds read from dmso.ts), and the buffer starter outside VALID_RANGE_K (274–350 K, Goldberg et al. 2002), named with the working temperature. Missing inputs and withheld answers show nothing. Each map is exhaustive over its engine's union; src/lib/methods/refusal-guidance.test.ts holds the parity and drives the engines. Tests: src/lib/fit/calibration-normalisation.test.ts, src/lib/methods/refusal-guidance.test.ts, e2e/assay-analyser/calibration-normalisation.spec.ts.
Edited normalisation plans and captured sheets (22 October 2026)
normalization-input.ts defines additive np v1 input beside retained ns.
It carries every configuration field (nullable unfinished numbers), ordered rows
with stable keys, explicit calibration source IDs or manual concentrations, and
the example flag. A compact canonical source tuple covers source metadata,
ordered samples, intervals, flags, withheld counts and qualifications. It checks
snapshot consistency, not authenticity. Source labels must equal their source
IDs; manual rows with the same label never inherit calibration evidence.
Source concentrations are derived from ns with the existing unit conversion.
The scientific engine, thresholds and defaults are unchanged.
np is bounded at 200,000 characters and 384 rows; ns remains bounded at
60,000. The aggregate draft bound is checked before accepting edits. The address
schema deliberately retains malformed input, or an oversize refusal sentinel,
for visible recovery rather than dropping it through deepDropped. Legacy
source arrivals seed once; corrupt, unsupported and inconsistent drafts do not
reseed. Reset explicitly discards adjustments. Blank/unreadable numeric entries
persist as null drafts; finite scientifically invalid values remain invalid.
normalizationRecipe stores the full input in workspace.calculator.state,
not the recipe’s 200-character scalar input fields. Named work preserves it with
method sample-normalisation 1.0.0. calculatorRecipeState restores this state;
the arrival effect gives a matching explicit fragment draft precedence over an
older packed source recipe. A source mismatch opens a refusal state. Saving an
unfinished draft is supported. Normalisation does not record C1V1 output or
expose its protocol, stock label, material links or Inspector explanation.
Since 24 October, the distinct normalisation capture below records a result under schema v2; unrelated C1V1 recording stays excluded.
Capture lazily imports calibration-bench-sheet.ts, freezing the selected
source rows, full settings, source evidence and calculation together. Edits keep
the old capture, visibly mark it stale and disable download until Refresh.
Generation/download failure keeps inputs and prior capture and offers retry.
Manual/example rows and empty selections visibly block this source-only sheet;
Calc-refused rows remain in it without transfer volumes. A capture itself is not
saved: reopening restores the editable plan and requires a new capture.
Since 24 October, the generic input picker retains the strict np/ns calculator state in its consumed snapshot. Manual-only plans also support named saving through the existing controls, with no fabricated calibration source.
Tests: normalization-workspace.test.ts, existing normalisation oracle tests,
calibration-bench-sheet.test.ts and the extended
e2e/assay-analyser/calibration-normalisation.spec.ts. The latter exercises the
actual calibration, edit, capture, stale refresh, download retry, reload, save,
reopen and recapture journey, plus corrupt/empty/example recovery.
Liquid-handler worklists
src/lib/export/liquid-handlers/ writes Tecan worklists (tecan.ts) and Echo pick lists (echo.ts) against pipeline/lab-calculators/fixtures/liquid-handler-formats-v1.json, transcribed on 17 October 2026 from TeselaGen's Tecan and Echo worklist pages and robotools' example worklists. Tecan records are A;RackLabel;;;Position;;Volume[;LiquidClass], then D;… and W;, positions numbered rear to front then left to right (A1 = 1, B1 = 2, A2 = 9 on 96 wells), µL at two decimals with a warning when rounding moves a volume by more than 0.1 percent; C; comments state that the file is a plan. Echo pick lists use Source Plate Name, Source Well, Destination Plate Name, Destination Well and Transfer Volume (nL), whole droplets (25 nL Echo 525, 2.5 nL Echo 650 series) with a warning above 1 percent quantisation. Either writer returns no file text when any transfer is invalid, so a refused file is never downloaded. The plate sheet (plate-sheet-worklist.ts) writes its Dispense table from a tube rack "Tubes" (sources in order of first use, listed in comments) to "Plate" in µL; sample normalisation (normalization-worklist.ts) writes diluent, dye, then sample per row from "Diluent", "Dye" and "Samples" to the 96-well "Prep" plate, and refuses a batch with a withheld row or more than 96 samples. Both writers load only when a worklist is requested. Neither offers an Echo pick list: both draw from tubes, and an Echo draws only from source-plate wells.
Bench checklist
src/components/suite/bench/glove-mode-view.tsx (copy content/apps/lab-calculators.bench-checklist.ts), opened from C1V1, serial dilution, master mix, sample normalisation and the printable protocol. Each calculator's checklist (panels/bench-checklists.tsx) loads only when opened. It lists the plan's own steps and volumes (no added procedure) with large tick boxes, keeps ticks by step id while open and clears them on close, and carries the plan's own flags (2 µL convention, master-mix minimum). A tick is a reminder on the screen, not a record that a step was done (feature 14 contract). The optional timer counts down from a stored end time. No sound, emoji or looping animation.
Objects as inputs
The @Object pickers in the compound card and A280 load after the calculator (next/dynamic) and keep the chosen object's ref in component state only: a person's own ref never travels in a link. Choosing a compound weight clears cpd, rg, lw and pur so a name never sits beside another compound's weight (resolveCompound ranks the vial weight, then a record's formula, above a typed weight). A280 accepts only a UniProt ref and clears the sequence keys. C1V1 has no stock picker: filling C1 from a saved stock must carry the stock's stated concentration basis, which waits on contract 05 (feature 5b; CSO stock memo, 16 October 2026).
Command-palette solver
src/lib/omnibox/scientific-sniffer.ts (copy content/apps/omnibox.ts), shown in the command palette for the typed or pasted query only (no clipboard read), and loaded with the first typed query rather than with the shell. "50 mL of 0.5 mM from 1 M" goes through solveC1V1; a stock transfer below 2 µL is flagged and its link opens Calc with the intermediate planner (ip). "MW 180.16, 100 mL of 50 mM" gives C × V × formula weight. "Tm ACGT…" is oligoMelting at the Sequence app's defaults (250 nM oligo in excess, 50 mM Na⁺, no Mg²⁺, Owczarzy 2004), stated in the answer; it matches Biopython Tm_NN (DNA_NN3, saltcorr 6) to 0.01 °C on the review's eight primers. Natural bench-task queries (14 task intents in TASK_INTENTS, kind: "task-intent") open the matching workspace and sub-tool across Calc (#s=task=dilution&norm=1, #s=task=serial, #s=task=dilution, #s=task=preparation, #s=task=a280, #s=task=buffer, #s=task=mastermix), Assay (#s=view=fit, #s=view=import), Sequence (#s=op=oligo-tm, #s=op=primer-check, #s=op=mutagenesis-primers), Plasmid (#s=view=enzymes) and Bioreactor (/bioreactor/app). Links are #s= state with keys the target schema declares (c1, cf, vf for C1V1; id and pdb for Protein; seq for Sequence; #s=view=… for Assay); a sequence longer than SEQ_MAX_LENGTH gets no link rather than a truncated one. Enter runs the highlighted row; with no rows it follows the solver's link.
Shared-workspace verification hosting
Calc verification 0.8.0 hosts run-comparison-v1.json, the contract 07A
selection/integrity fixture, through the existing publish_preparation.py
arbitrary-path support. It verifies shared Study organisation, not calculator
arithmetic; it changes no Calc engine, record or application version. See
Oracles and verification.
Calculator composition (23 October 2026)
CalculatorLayout places inputs and answer side by side from 50rem of main
width. The inputs occupy two parts and the answer three parts, leaving enough
room for results and their copy keys. On narrower screens the inputs precede
the answer. The compound/reagent picker sits above the inputs in the left
column on the stock, dilution and serial views, instead of spanning the full
page. Its saved-object control is an icon-only button with an accessible name;
the same four-tile object glyph identifies Objects in the suite rail. The same
layout serves the workspace and standalone task pages. Calc's
work controls use the SubjectHeader action slot; the operation tabs remain
navigation. Displayed answer units are marked for the shared status summary.
Study summaries and both report audiences share captured preparation context. They retain the recipe's stock/form/mass basis, explicit concentration/volume units, serial scb and planned replication, alongside the existing projector's constraints and qualifications. Absent units remain Not recorded. Captured stock carrier and accepted material mismatch retain their existing meanings; no current material or calculator default fills an absent historical value.
Immutable normalisation results and input reuse (24 October 2026)
Manual-only, source-backed and mixed plans use the existing recipe envelope v1 and np v1. A source-less recipe requires explicit valid manual np, null source key, no ns, and the allowlisted dilution/normalisation state. Legacy source recipes remain readable; incomplete nullable drafts remain saveable. A present malformed source/draft fails visibly and is never seeded as a manual plan. normalization-input-reader.ts is the one pure strict codec, re-exported by normalization-input.ts; seeding and calculations retain their existing defaults and engine.
The generic Bench picker now accepts supported normalisation recipes, current or explicitly selected historical versions. Its consumed snapshot retains only task, norm, optional exact ns, and exact np, with compound null and nested bindings empty. The hash, object revision and optional version identity describe those exact consumed inputs. Preview and application use the same retained payload. Application replaces the task and strict scalar inputs together with the packed recipe, after flushing pending work and rechecking the workspace scope. A successful normalisation selection starts an independent save lifetime; subsequent edits, Save and Record remain within that work. The already-applied recipe marker prevents delayed decoding from overwriting later edits; the controlled picker closes before the task changes. Recipe/input/result schemas and existing restored-work behaviour are unchanged. Ordinary recipe variants keep their prior behaviour. Source-impact witness matching retains authoritative normalisation state while stripping recursive bindings, so another draft at the same revision cannot lend its lineage.
Record result uses lab-calculators.recorded-result v2 only for normalisation; all existing calculation/result v1 readers remain. The input snapshot remains lab-calculators.recipe v1. Capture detaches the work, recipe, full settings, source receipt/qualifications, ordered stable row keys/origins and result before asynchronous writes. It verifies the shown output against the existing sample-normalisation 1.0.0 calculation and method pin, and records explicit mg/mL, µL and µg units. Non-finite output or blank required settings visibly refuse recording while preserving editable work. Partial/all-refused batches retain each refusal; an all-refused review is qualified with “Nothing planned” and no transfer volumes. Synthetic examples retain planned simulation classification and qualifications.
normalization-record.ts, normalization-record-reader.ts and normalization-projection.ts read saved values, source and row identity mirrors without importing the planner. Missing exact primary inputs, different state ref/revision/schema, unsupported method/version, malformed units/rows or mismatched source origins withhold numerical projection. Original source concentration, dilution, wells, interval/LOQ, upstream qualifications/counts, deliberate omissions and Calc refusals stay distinct. Source intervals are not propagated into volumes; dilution is not applied twice. Historical method text explicitly describes the retained calculated plan. Scientific comparisons use the exact primary tuple even when other recipe bindings are captured and include the full ordered np/source changes.
Version 2 methods load through the existing asynchronous formatter boundary into recorded-methods-normalization.ts; the v1 Calc formatter is unchanged. The shared exact-record reader has no formatting dependency. The generic picker uses normalization-input-preview.ts to describe the strict retained settings, stable ordered row origins, concentrations and source qualifications directly, without loading result projection or the planner. Null, unknown or future method pins, invalid labels and malformed provenance fields refuse the modern strict input contract.
The standard result history/notebook/report and workspace Study recovery paths carry these snapshots. Recovery remaps outer scope/identities and rehashes envelopes while retaining scientific bodies and original transfer provenance; it does not introduce a Calc .drybench project type. The existing calibrated bench-sheet capture/download remains source-only and keeps its manual/example refusal and refresh/stale behaviour. No equation, oracle fixture, method version, dependency or execution-plan kind changed. New contract tests reuse the pre-existing Python-fractions normalization fixture; tests are not laboratory observations.