Chapter 5
Every calculation in the earlier chapters ends up as a field in a JSON document. This chapter reads the standard's TypeScript type definitions line by line, fixes the units each field carries, and marks the places where the written specification and the reference SDK disagree.
The data model of WIA-BIO-015 lives in a single file, api/typescript/src/types.ts, just under a thousand lines long. The specification text itself (§9.2) contains only a handful of short interface sketches; the SDK file is where the vocabulary is actually spelled out. Before walking through the individual types it is worth naming the conventions that the file follows, because they decide how a non-TypeScript system — a historian, a MES, a Python analysis notebook — should read and write the same objects.
Almost every type is a TypeScript interface with primitive members: number, string, boolean, arrays of other interfaces, and the occasional Date. There are no classes with behaviour and no circular references, so a BioreactorStatus can be sent with a plain JSON.stringify and read by any language — except that Date does not survive the round trip unchanged (§5.6).
The model uses bare number for every physical quantity. The unit is recorded only in the JSDoc comment above the field: /** Glucose concentration (g/L) */, /** Lactate concentration (mM) */, /** Dissolved oxygen (% of air saturation) */. The compiler cannot stop anyone from putting a lactate value in g/L into a field that expects mM, so the unit table in §5.6 is part of the contract: an implementation must treat the comment units as normative.
Closed vocabularies are written as unions of string literals, for example type OperationMode = 'batch' | 'fed-batch' | 'perfusion' | 'continuous'. On the wire these are plain strings, which map directly to a JSON Schema enum. Several unions end with a 'custom' member (ExpressionSystem, ProductType, BioreactorConfig.type, impellerType, spargerType); that member is an escape hatch, and a receiving system should expect a free-text description somewhere else when it sees it — the model itself does not provide a field for one. The only true TypeScript enum, which exists at run time as an object, is BioErrorCode (codes B001 to B010).
Optional members, marked with ?, are used for quantities that are not available at every sampling point: glucose, lactate, ammonia, productConcentration and so on. The reference monitor respects this: it evaluates the lactate and ammonia thresholds only when the field is present. A producer should therefore omit a field that was not measured rather than write 0, because zero is a legal measurement and will be evaluated as one.
The file defines a discriminated union Result<T, E> — either { success: true; data: T } or { success: false; error: E } — and its promise form AsyncResult<T, E>. It also defines the exception class BioManufacturingError(code, message, details). In v1.0 the SDK uses only the second style: invalid inputs are thrown as BioManufacturingError with code B007, and no function returns a Result. The Result types remain available for non-throwing APIs, such as a service boundary; Chapter 6 compares the two styles.
Build note. Both source files end with a second, redundant export block (export type { CellLine, … } in types.ts, export { BioManufacturingSDK } in index.ts) that re-exports names already declared with export. When the two files were type-checked with the TypeScript 5.9 compiler for this book, the compiler rejected those blocks with errors TS2323 ("Cannot redeclare exported variable") and TS2484 ("Export declaration conflicts with exported declaration"). The type definitions themselves are sound; deleting the duplicate export lists is enough to make the files compile cleanly. Anyone vendoring the reference code should make that change first.
Four interfaces describe what is being grown and under which conditions: CellLine, CultureConditions, CellCultureMetrics and FermentationParameters. The first is master data that changes rarely; the second and third are the measured state of a culture at one instant; the fourth is an input bundle for calculations.
CellLine identifies a production clone and carries its characteristic kinetics. Its required fields are id, name, system (an ExpressionSystem: CHO, E.coli, Yeast, Pichia, Insect, HEK-293, PER.C6 or custom), product (free text), productType, growthRate in h⁻¹, doublingTime in hours, typicalTiter in g/L and stability on a 0–1 scale. generation is optional and records how many generations or passages the characterisation refers to.
Two details deserve attention. First, growthRate and doublingTime are not independent: for exponential growth td = ln 2 / μ, as derived in Chapter 2. A record with μ = 0.03 h⁻¹ should therefore carry td ≈ 23.1 h. The model stores both without enforcing the relationship, so a validator should check that the two agree within a stated tolerance (a few percent is reasonable, since both are usually rounded). The SDK's calculateGrowthKinetics() recomputes the doubling time from growthRate and ignores the stored value, which is another reason to keep them consistent. Second, stability is a fraction between 0 and 1, whereas viability elsewhere in the model is a percentage between 0 and 100. Mixing the two scales is one of the easiest mistakes to make; §5.6 returns to it.
CultureConditions holds the controlled environment: the critical process parameters discussed in Chapter 3. Three members are required — temperature (°C), pH and dissolvedOxygen (% of air saturation) — and four are optional: agitation (rpm), aeration (vvm, gas volume per liquid volume per minute), pressure (bar) and co2 (%).
The required three are exactly the parameters that the reference monitor checks, so a snapshot without them cannot be evaluated. The optional four are recorded for context and trending but are not judged by the SDK. Two of them are under-specified in v1.0 and an implementation profile should pin them down:
pressure is labelled "bar" without saying whether it is gauge (head-space overpressure, typically a small fraction of a bar in a stirred tank) or absolute (about 1 bar plus the overpressure). The difference, about one bar, exceeds any operating value, so a receiver cannot guess.co2 is labelled "%" without a basis. It could be the CO₂ fraction in the inlet gas, in the exhaust gas, or a dissolved CO₂ partial pressure expressed as a percentage of one atmosphere. These mean different things — dissolved CO₂ accumulation is a scale-up issue (Chapter 3), inlet CO₂ a pH actuator — so the basis must be stated.CellCultureMetrics holds what the cells are doing. cellDensity is the viable cell density (VCD) in cells/mL; totalCellDensity (optional) includes dead cells; viability is a percentage. When both densities are present, viability is redundant: it should equal 100 × cellDensity / totalCellDensity, and a consistency check is cheap. The remaining optional members are growthRate (h⁻¹), productConcentration (g/L — the titer), glucose (g/L), and lactate, glutamine and ammonia, all in mM.
The mixture of mass and molar concentrations mirrors laboratory practice — glucose analysers often report g/L, while lactate and ammonia are usually discussed in mM because their inhibitory effects are expressed that way — but it means conversion is needed whenever a mass balance crosses the two groups. The conversion uses well-known molar masses: glucose 180.16 g/mol, lactic acid 90.08 g/mol, glutamine 146.14 g/mol, ammonia (NH₃) 17.03 g/mol. For example, 4.2 g/L glucose is 4.2 / 180.16 = 0.0233 mol/L, or 23.3 mM; 18.5 mM lactate is 18.5 × 90.08 = 1,666 mg/L, or about 1.67 g/L. On a molar basis, one glucose molecule can yield at most two lactate molecules, which gives a quick plausibility check on a reported lactate-per-glucose ratio.
The model does not say over which window growthRate is computed. A sensible, documented convention is the two-point value ln(X₂/X₁)/(t₂−t₁) ending at the snapshot time.
FermentationParameters is not a state record but an input to calculations, in particular optimizeFermentation(). It carries initialCellDensity (cells/mL), cultureTime (hours), volume (working volume, L) and, optionally, initialSubstrate (g/L), feedRate (L/h) and feedConcentration (g/L). With the feed fields it is enough to write the substrate mass balance of a fed-batch run, F·Sfeed, from Chapter 3. As Chapter 6 shows, the v1.0 reference implementation reads only the first three fields and returns fixed setpoints, so the feed fields are, for now, carried but unused.
The second group describes the vessel and what it reports while running. This is the part of the model that a data acquisition layer produces continuously, so its shape matters most for integration.
BioreactorConfig is equipment master data: id, name, type (stirred-tank, wave, perfusion, airlift or custom), totalVolume and workingVolume in litres, and the mode of operation. Optional geometry fields are heightDiameterRatio, impellerType (rushton, pitched-blade, marine, custom), numberOfImpellers and spargerType (ring, sintered, drilled-pipe, custom). An obvious validation rule is workingVolume ≤ totalVolume; the ratio is usually well below one to leave head space for foam and gas disengagement.
Because mode sits on the equipment record although it is really a property of the run, a system that reuses vessels across campaigns should version its BioreactorConfig records per campaign.
BioreactorStatus is the central live-state object — one snapshot of one reactor at one instant. Its members are:
reactorId: must match a BioreactorConfig.id;timestamp: a Date, the instant the snapshot describes;conditions: a nested CultureConditions;metrics: a nested CellCultureMetrics;phase: one of inoculation, lag, exponential, stationary, death, harvest;alarms: an array of BioreactorAlarm, possibly empty;status: the operational state, one of running, paused, error, complete.The nesting is the most important structural decision in the model: controlled inputs (conditions) are kept apart from biological responses (metrics). That separation matches the CPP/CQA thinking of Chapter 1 and makes it easy to plot "what we set" against "what the cells did". It also differs from the flat sketch in the specification text, which §5.7 examines.
Two members with similar names carry different meanings. phase is a biological classification of the culture — where the growth curve is — while status is the state of the equipment and the run. Neither is the monitor's health verdict, MonitoringResult.status (optimal, acceptable, warning, critical). Three status-like fields with three vocabularies invite confusion, so user interfaces should label them explicitly — "Phase", "Run state", "Health".
The following snapshot describes an example 2,000 L CHO fed-batch culture on day 7. All values are illustrative, chosen to be internally consistent (viability equals viable over total density; lactate and glucose in plausible proportions), not taken from any real batch.
{
"reactorId": "BR-2000-03",
"timestamp": "2026-03-12T08:00:00Z",
"conditions": {
"temperature": 36.8,
"pH": 7.02,
"dissolvedOxygen": 40,
"agitation": 110,
"aeration": 0.02,
"pressure": 0.2,
"co2": 6.5
},
"metrics": {
"cellDensity": 8.5e6,
"totalCellDensity": 9.1e6,
"viability": 93.4,
"growthRate": 0.012,
"productConcentration": 1.9,
"glucose": 4.2,
"lactate": 18.5,
"glutamine": 1.1,
"ammonia": 5.6
},
"phase": "exponential",
"alarms": [
{
"type": "pH",
"severity": "warning",
"message": "pH near low limit: 6.88 after bolus feed",
"timestamp": "2026-03-12T02:15:00Z",
"acknowledged": true
}
],
"status": "running"
}
In this example pressure is gauge pressure in bar and co2 is the dissolved CO₂ partial pressure as a percentage of one atmosphere — the two choices the model leaves open, stated here as the example's own convention. Passing this snapshot to the reference monitorBioreactor() (after a JSON round trip) returns status: "optimal" with no warnings: every checked value is inside the SDK's warning bands. The acknowledged pH alarm from six hours earlier is not considered, because the v1.0 monitor evaluates only conditions and metrics and never reads alarms, phase or status.
BioreactorAlarm records a single alarm event: type (pH, temperature, DO, pressure, viability, contamination or other), severity (info, warning, critical), a human-readable message, its own timestamp, and a boolean acknowledged.
Two gaps show up as soon as the type is used in a regulated plant. The alarm type list has no member for lactate or ammonia, although the monitor raises exactly those conditions and the error table assigns lactate its own code (B002); such alarms must be filed under other, which loses information. And acknowledged is a bare boolean: it records that an alarm was acknowledged but not who acknowledged it or when. Good manufacturing practice expects attributable, time-stamped records of operator actions, so a GMP implementation will need to hold that information — either in extension fields (for example acknowledgedBy and acknowledgedAt) or in an audit trail keyed to the alarm. Chapter 7 treats the acknowledgement flow as part of the batch protocol. Linking an alarm to an error code is also left open; carrying the BioErrorCode in an extension field is the simplest bridge.
Below the snapshot level the model offers three smaller types for raw and derived signals.
SensorReading is one value from one instrument: sensorId, parameter (free text), value, unit (free text), timestamp, quality (good, uncertain, bad) and calibrated. Unlike the snapshot types, it carries its unit explicitly, which makes it suitable for data from heterogeneous sources such as at-line analysers or Raman models. A reasonable rule is that only readings with quality: "good" and calibrated: true are promoted into a BioreactorStatus, with the provenance of each value kept in the data store.
ParameterTrend summarises the recent history of one parameter: current, trend (increasing, stable, decreasing), rateOfChange, an optional one-hour predicted value, and alert (none, warning, critical). The model does not say in what unit rateOfChange is expressed; "parameter unit per hour" is the natural reading and matches the one-hour horizon of predicted. Be aware that the v1.0 monitor fills trends from a single snapshot — it reports cell density as stable with a rate of zero, and viability as decreasing at −0.5 whenever it is below 90 % — so these values are placeholders until a trend is computed from real history.
TimeSeriesPoint is the minimal history record: time, value, parameter. Note the field name time, where every other type uses timestamp. It is a small inconsistency, but mapping code that assumes a uniform name will silently drop the value.
For completeness, the output of the monitor is itself a model type. MonitoringResult carries reactorId, timestamp (copied from the input snapshot), the health status, arrays of warnings, criticalAlarms and recommendations (all plain strings), trends, and an optional predictedHarvest date. The warning and alarm texts are English sentences with the value embedded, such as "Lactate accumulating: 32.0 mM" — meant for operators. A system that must react programmatically should not parse them.
The third group covers the purification train of Chapter 4 and the quality decisions that follow it.
A ChromatographyStep is one column operation. Its type is one of protein-a, cation-exchange, anion-exchange, hydrophobic, size-exclusion or multimodal. The quantitative fields are resinVolume (L), bindingCapacity (g of product per L of resin), loadVolume (L of feed), recovery and purity (both %), and an optional residenceTime in minutes. An optional buffers object holds four free-text descriptions: equilibration, load, wash, elution.
The sizing relation of Chapter 4 is embedded in these fields: the mass loaded per cycle cannot exceed resinVolume × bindingCapacity, and the load mass is loadVolume × feed concentration. The feed concentration itself is not a field of the step; it has to be taken from PurificationDesign.startingConcentration for the first step and from the previous step's output for later ones. There is no cycle count, so a small column cycled several times per batch needs an extension, and for flow-through steps (typically anion exchange) bindingCapacity has no clear meaning for the product.
PurificationDesign is the request: productType, scale (L of harvest), startingConcentration (g/L), targetPurity and targetRecovery (both %), and an optional budgetConstraint in $ per gram. PurificationProtocol is the answer: id, name, productType, an ordered array of steps, a viralInactivation flag, overallRecovery and finalPurity (%), duration (hours) and an optional costPerGram.
Because step recoveries multiply (Chapter 4), overallRecovery should equal the product of the step recoveries, expressed as fractions and converted back to a percentage. That is a cheap and useful consistency check on any protocol received from another system. The viralInactivation flag records only that an inactivation step exists — not its method, conditions or demonstrated log reduction — so it is a reminder, not evidence.
A CriticalQualityAttribute is one measured attribute with its specification: name, value, unit (free text), optional lsl, usl and target, a boolean inSpec, and a criticality of high, medium or low. One-sided limits are expressed naturally by omitting the other bound — an aggregate limit has only usl, a purity limit only lsl.
inSpec is stored, not derived. That is convenient for display but dangerous for release: a record can say inSpec: true while its value lies outside its own limits. A receiving system should recompute lsl ≤ value ≤ usl (with whichever bounds are present) and reject the record if the stored flag disagrees. Whether the limits are inclusive is not stated in v1.0; pharmacopoeial convention usually treats a stated limit as inclusive, and an implementation profile should say so explicitly.
QualityControlResult groups the attributes for one batch: batchId, product, testDate, the array cqas, an overall status (pass, fail, investigate), an optional coa (certificate of analysis identifier or text) and an array of deviations (strings). The example below uses the antibody CQAs listed in spec §7.1.1. Those limits are the specification's example mAb spec, not universal requirements — every product has its own registered specification. The residual DNA limit of 10 ng per dose corresponds to the WHO recommendation for products from continuous cell lines.
{
"batchId": "MAB-2026-011",
"product": "Example IgG1 antibody",
"testDate": "2026-04-02T00:00:00Z",
"cqas": [
{ "name": "Purity (SEC monomer)", "value": 98.6, "unit": "%",
"lsl": 95, "inSpec": true, "criticality": "high" },
{ "name": "Aggregates (SEC HMW)", "value": 1.2, "unit": "%",
"usl": 5, "inSpec": true, "criticality": "high" },
{ "name": "Charge variants (acidic + basic)", "value": 31.5, "unit": "%",
"usl": 40, "inSpec": true, "criticality": "medium" },
{ "name": "G0F glycoform", "value": 44.0, "unit": "%",
"lsl": 35, "usl": 55, "target": 45, "inSpec": true, "criticality": "high" },
{ "name": "High mannose", "value": 2.1, "unit": "%",
"usl": 5, "inSpec": true, "criticality": "medium" },
{ "name": "Host cell protein", "value": 12, "unit": "ng/mg",
"usl": 100, "inSpec": true, "criticality": "high" },
{ "name": "Residual DNA", "value": 0.8, "unit": "ng/dose",
"usl": 10, "inSpec": true, "criticality": "high" },
{ "name": "Endotoxin", "value": 0.05, "unit": "EU/mg",
"usl": 5, "inSpec": true, "criticality": "high" }
],
"status": "pass",
"coa": "COA-MAB-2026-011-v1",
"deviations": [
"DEV-2026-0142: pH warning on production day 7, acknowledged; impact assessment: none"
]
}
The HCP limit is written in ng/mg, which is the same quantity the specification calls "ppm" (nanograms of HCP per milligram of product). Writing the explicit ratio avoids the ambiguity of "ppm", which could otherwise be read as a mass-per-volume unit. The deviation string refers back to the alarm in the BioreactorStatus example; in the model this link exists only inside free text, which is one of the traceability limits discussed below.
What does status mean when every attribute is in specification but a deviation is open? v1.0 does not say. A defensible rule is: fail if any high-criticality attribute is out of specification; investigate if any other attribute is out or any deviation is still open; pass only when everything is in specification and every deviation is closed with an assessed impact. Release itself remains a decision of the quality unit, which the model records rather than makes.
ProcessCapability stores the result of a capability study for one attribute: attribute, mean, stdDev, cp, cpk, an assessment (capable, marginally-capable, not-capable) and an optional recommendation. The SDK's calculateProcessCapability() fills it using the sample standard deviation (divisor n − 1) and the thresholds Cpk ≥ 1.33 for "capable" and ≥ 1.0 for "marginally capable". The record does not store the number of observations or the limits used, both of which are needed to interpret the indices; Chapter 8 returns to this point.
BatchSummary is the one-line view of a batch: batchId, product, cellLine, startDate, optional harvestDate, finalTiter (g/L), totalProduct (g), productivity (g/L/day), a lifecycle status (running, harvested, purifying, complete, failed) and an optional qualityStatus (pass, fail, pending). Note that productivity here is per day, while ProductivityResult.value is per hour — the SDK supplies both (value and perDay), and the summary should be filled from perDay.
The diagram below shows the relationships that a batch record needs. Solid lines are links carried by an identifier field in v1.0; dashed lines are links the model implies but does not carry.
The dashed lines are the practical weakness. A BioreactorStatus has a reactorId but no batchId, so the snapshots belonging to a batch can only be found by reactor and time window (between BatchSummary.startDate and harvestDate). That works while clocks are synchronised and one batch runs per reactor, but it is fragile. PurificationProtocol has no batch reference at all, and BatchSummary.cellLine is a string whose content — name or identifier — is unspecified. An implementation should store CellLine.id there and add a batchId extension field to snapshots and executed purification records; Chapter 7 builds on these links.
Three interfaces describe design-of-experiments studies and their outcome. They are the data side of the "Optimization Engine", the third required component of spec §9.1.
DOEConfig declares a study: its type (full-factorial, fractional-factorial, central-composite, box-behnken), an array of factors, the names of the responses to be measured, and optional centerPoints and replicates. Each DOEFactor has a name, numeric lowLevel and highLevel, an optional centerPoint, a unit, and a type of continuous or categorical. OptimizationResult reports an optimum: an id, the chosen parameters as a name-to-number map, the predictedResponse, a two-element confidenceInterval, an improvement over baseline in percent, and a text recommendation.
An example study that screens three upstream factors on a two-level full factorial with three centre points might be declared as follows. The factor ranges are illustrative.
{
"type": "full-factorial",
"factors": [
{ "name": "productionTemperature", "lowLevel": 31, "highLevel": 35,
"centerPoint": 33, "unit": "°C", "type": "continuous" },
{ "name": "pHSetpoint", "lowLevel": 6.9, "highLevel": 7.1,
"centerPoint": 7.0, "unit": "pH", "type": "continuous" },
{ "name": "feedRatePercent", "lowLevel": 2, "highLevel": 4,
"centerPoint": 3, "unit": "% v/v per day", "type": "continuous" }
],
"responses": ["finalTiter_gL", "aggregates_pct", "G0F_pct"],
"centerPoints": 3,
"replicates": 1
}
With three factors at two levels the design has 2³ = 8 corner runs, plus the three centre points, for 11 runs in total. Nothing in DOEConfig states the number of levels, so "full factorial" is interpreted here as two-level; a three-level full factorial of the same factors would need 3³ = 27 runs, and a system that interprets the type differently will plan a different study. A profile should fix the convention (two-level unless stated) or add an explicit levels field.
A categorical factor such as "basal medium A or B" has no numeric levels, yet lowLevel and highLevel are numbers; code the categories as −1/+1 and publish the code table with the study. Response names carry no unit, so embedding it in the name, as in the example, keeps them unambiguous.
Finally, note what connects to what. No SDK function consumes a DOEConfig; optimizeFermentation() takes FermentationParameters instead and returns an OptimizationResult whose parameter map (initial density, glucose, glutamine, temperature, pH) is the same regardless of input. The types are therefore a well-formed container for DOE studies designed elsewhere — in a statistics package, for instance — rather than the interface of a working optimizer. Chapter 6 quantifies which outputs are placeholders.
The object BIO_CONSTANTS, declared as const so its values are read-only literals, collects the numeric defaults that the SDK relies on:
| Constant | Value | How the SDK uses it / caveat |
|---|---|---|
AVOGADRO | 6.022 × 10²³ mol⁻¹ | Physical constant; not used by any v1.0 function. |
GAS_CONSTANT | 8.314 J/(mol·K) | Physical constant; not used by any v1.0 function. |
STANDARD_TEMP, STANDARD_PRESSURE | 298 K, 101,325 Pa | 25 °C and 1 atm. Other "standard" conventions for gas volumes use 0 °C; state the reference whenever gas flows are converted between vvm and mass flow. |
WATER_DENSITY | 1.0 kg/L | Rounded; adequate for culture broth estimates. |
CELL_DENSITY | CHO 10⁵–10⁷, E. coli 10⁸–10¹⁰, yeast 10⁷–10⁹ cells/mL | Typical ranges for plausibility checks; not used by the monitor. |
GROWTH_RATE | CHO 0.03, E. coli 0.7, yeast 0.25 h⁻¹ | Typical values; the CHO value is hard-coded again inside optimizeFermentation(). |
MIN_VIABILITY | 70 % | Critical viability threshold in monitorBioreactor() and validateBatch(). |
TYPICAL_TITER | antibody 5.0, protein 2.0, enzyme 3.0 g/L | Reference defaults of the standard, not benchmarks; titers vary widely by product and process. |
Only MIN_VIABILITY actually drives a decision in the reference code. The other monitor thresholds are literals inside monitorBioreactor(), so they cannot be adjusted per cell line without changing code; an implementation serving several processes should move them into configuration keyed by ExpressionSystem or process.
The table below consolidates the units of the fields that are exchanged most often, the ranges that the standard or its reference code attaches to them, and where each comes from. "Warn" and "critical" bands are those used by the v1.0 monitor for mammalian culture; they are the standard's defaults, not process specifications.
| Field | Unit | Valid range / checks | Source |
|---|---|---|---|
conditions.temperature | °C | Warn outside 36–37.5; critical outside 35–38 | spec §3.3.2; SDK monitor |
conditions.pH | pH units | Warn outside 6.9–7.3; critical outside 6.8–7.4 | spec §3.3.1; SDK monitor |
conditions.dissolvedOxygen | % air saturation | Warn outside 30–50; critical outside 20–60 | spec §3.3.3; SDK monitor |
conditions.agitation | rpm | ≥ 0; sensor accuracy ±5 rpm | spec §6.1.1 |
conditions.aeration | vvm | ≥ 0 | types.ts |
conditions.pressure | bar (gauge or absolute: declare) | Sensor accuracy ±0.01 bar | spec §6.1.1; types.ts |
conditions.co2 | % (basis: declare) | 0–100 | types.ts |
metrics.cellDensity | viable cells/mL | At-line range 10⁵–10⁷ | spec §6.1.2; BIO_CONSTANTS |
metrics.viability | % (0–100) | Warn < 85; critical < 70 | MIN_VIABILITY; SDK monitor |
metrics.growthRate | h⁻¹ | Negative in death phase is legitimate | spec §4.1.1 |
metrics.productConcentration | g/L | ≥ 0 | spec §1.4 |
metrics.glucose | g/L | At-line range 0.1–25 | spec §6.1.2 |
metrics.lactate | mM | At-line range 0.1–40; warn > 30; critical > 40 | spec §6.1.2; SDK monitor |
metrics.glutamine | mM | At-line range 0.1–10 | spec §6.1.2 |
metrics.ammonia | mM | At-line range 0.1–10; warn > 8 | spec §6.1.2; SDK monitor |
CellLine.stability | fraction (0–1) | 0 ≤ x ≤ 1 | types.ts |
ChromatographyStep.bindingCapacity | g/L resin | > 0 for bind-and-elute steps | spec §5.2; types.ts |
ChromatographyStep.recovery, purity | % (0–100) | 0–100 | types.ts |
SpecificProductivity.qp | pg/cell/day | ≥ 0 | spec §4.3.1; types.ts |
Two observations follow from the table. The critical lactate threshold (40 mM) coincides with the top of the at-line measurement range given in spec §6.1.2. An analyser that saturates at its upper limit will report 40 mM for any higher concentration, and "40" is not greater than 40, so a saturated reading will not trigger the critical alarm. Implementations should either use an analyser with headroom above the threshold or treat an over-range flag as critical. And the temperature bands assume a culture held near 37 °C: a CHO process that shifts to 30–33 °C for production, as described in Chapter 3, will raise a critical temperature alarm on every snapshot after the shift. The bands must be phase-aware, or configurable per process, before the monitor can be used on such a process.
Because units live in comments, a wrong unit produces a wrong answer without an error. Two tests run against the reference monitor with the day-7 snapshot above make the risk concrete:
0.934 instead of 93.4) produces the critical alarm "Low viability: 0.9% (target: >70%)" on a perfectly healthy culture. This error at least fails loudly.1.67 instead of 18.5 mM) produces status: "optimal". That is also what the correct value gives — but if the true lactate were 45 mM (about 4.1 g/L), the g/L value would still pass. This error fails silently, which is worse.The defence is a validation layer at every system boundary that checks ranges before the data reaches any calculation: viability between 0 and 100 and, when it is below 1, rejected as a probable fraction; lactate and ammonia plausibility against glucose consumption; stability between 0 and 1. The same checks, expressed as a JSON Schema, also serve as the machine-readable form of the unit table.
Every timestamp in the model — BioreactorStatus.timestamp, BioreactorAlarm.timestamp, SensorReading.timestamp, QualityControlResult.testDate, BatchSummary.startDate and others — is typed as a JavaScript Date. JSON has no date type. JSON.stringify converts a Date by calling toISOString(), which produces an ISO 8601 string in UTC with a trailing Z, for example "2026-03-12T08:00:00.000Z". JSON.parse does not convert it back: the field arrives as a string while the TypeScript type still claims it is a Date.
The reference monitor happens to tolerate this — it copies timestamp into its result without touching it, so a string goes in and a string comes out — but any code that calls a Date method on the field will fail at run time. The wire format should therefore be fixed as ISO 8601 with an explicit offset (UTC with Z is simplest), and the receiving side should revive dates deliberately:
import type { BioreactorStatus } from '@wia/bio-015/types';
const DATE_KEYS = new Set(['timestamp', 'testDate', 'startDate',
'harvestDate', 'predictedHarvest', 'time']);
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
function reviveDates(key: string, value: unknown): unknown {
if (DATE_KEYS.has(key) && typeof value === 'string') {
if (!ISO_UTC.test(value)) {
throw new Error(`Field ${key} is not ISO 8601 with offset: ${value}`);
}
return new Date(value);
}
return value;
}
export function parseStatus(json: string): BioreactorStatus {
const s = JSON.parse(json, reviveDates) as BioreactorStatus;
const m = s.metrics;
if (m.viability < 0 || m.viability > 100) {
throw new Error(`viability must be a percentage 0-100, got ${m.viability}`);
}
if (m.viability > 0 && m.viability < 1) {
throw new Error('viability looks like a fraction; send percent');
}
if (m.totalCellDensity !== undefined && m.cellDensity > m.totalCellDensity) {
throw new Error('viable density exceeds total density');
}
return s;
}
The reviver is keyed by field name, which works because the model uses a small, fixed set of date names (including time from TimeSeriesPoint). Rejecting strings without an offset matters: a local time without a zone is ambiguous across sites and daylight-saving changes, and a batch record that cannot place its events on one timeline has a data-integrity problem. The import uses the ./types subpath declared in the package's package.json; the source can equally be vendored.
The specification's §9.2 gives three short interface sketches — for yield, bioreactor monitoring and purification design — and §9.3 gives an error table. The SDK implements the same functions with richer and, in places, different shapes. A system built from the specification text alone would not interoperate with one built on the SDK. The table lists every difference found.
| Object | Spec v1.0 text (§9.2–9.3) | SDK (types.ts / index.ts) | Consequence |
|---|---|---|---|
| Bioreactor status | Flat: reactorId, cellDensity, viability, pH, temperature, dissolvedOxygen, productTiter? | Nested conditions / metrics; titer field named productConcentration; adds timestamp, phase, alarms, status | A flat object passed to the SDK monitor fails immediately (it reads conditions.pH) |
| Monitoring result | status, warnings, recommendations, trends | Adds reactorId, timestamp, criticalAlarms, predictedHarvest? | Compatible superset; critical messages are only in criticalAlarms, not in warnings |
| Yield response | efficiency is an enum (excellent … poor) | efficiency is a number (yield × 100, %); grade in performance; adds yieldFormatted | Same name, different type — the most dangerous kind of mismatch |
| Yield request | Four fields | Adds optional feedVolume, feedSubstrate | Compatible; fed-batch substrate is counted only by the SDK |
| Purification request | PurificationConfig; productType limited to four values; no starting concentration | PurificationDesign; full ProductType; required startingConcentration; optional budgetConstraint | A spec-shaped request lacks a required SDK field |
| Purification protocol | estimatedRecovery, estimatedPurity, estimatedCost, duration, steps | overallRecovery, finalPurity, costPerGram?, duration, steps, plus id, name, productType, viralInactivation | Same meaning, different names |
| Error codes | B001–B006 | B001–B010 (adds B007 invalid parameters, B008 GMP violation, B009 out of spec, B010 insufficient data) | Spec-only clients will not recognise B007, the code the SDK actually throws |
The README's own example calls monitorBioreactor with the flat, spec-style object. Run against the SDK, it throws TypeError: Cannot read properties of undefined (reading 'viability') — the shapes have drifted apart, and the example was evidently never executed against the code.
This book treats the SDK shapes as the de-facto wire format of WIA-BIO-015 v1.0: they carry everything the specification sketches carry, the nested conditions/metrics split separates what is controlled from what is observed, and the SDK is the only artefact of the standard that executes.
Where a partner system has already been built from the specification text, a thin adapter is enough: map the flat status fields into the nested structure, rename productTiter to metrics.productConcentration, rename the purification result fields, and translate a numeric efficiency into the grade using the standard's bands (above 0.15 excellent, above 0.10 good, above 0.05 fair). The adapter must also fill the SDK fields the flat shape lacks — a timestamp, a phase, an empty alarms array and a status — and should record that they were defaulted rather than measured.
The following items are proposed as corrections to the specification, collected from this chapter. Chapter 8 folds them into the full list of v1.0 gaps.
BioreactorStatus, YieldResult with numeric efficiency and performance, PurificationDesign/PurificationProtocol field names).pressure (gauge or absolute) and of co2, and the unit of ParameterTrend.rateOfChange.TimeSeriesPoint.time to timestamp (or document the exception).batchId to BioreactorStatus and to an executed-purification record; specify that BatchSummary.cellLine holds CellLine.id.lactate and ammonia to the alarm types, and attributable acknowledgement fields (by whom, when) to BioreactorAlarm.CriticalQualityAttribute.inSpec must equal the result of comparing value with the limits, and whether limits are inclusive.ProcessCapability, and a number-of-levels field to DOEConfig.None of these changes alters the science of the earlier chapters; they make the existing vocabulary precise enough that two independent implementations produce and accept the same documents. With the data model settled, Chapter 6 turns to the functions that consume it — what each SDK call computes, where its numbers come from, and how its errors should be handled.