Chapter 6
What the reference SDK and CLI of WIA-BIO-015 actually compute, line by line: formulas, thresholds, placeholders, errors, and the places where the command line and the library disagree.
The earlier chapters built up the science: growth kinetics, oxygen transfer, chromatography and quality attributes. This chapter turns to the software that the standard ships with. WIA-BIO-015 v1.0 includes a TypeScript package, @wia/bio-015, and a Bash command-line tool, wia-bio-015. Together they are the only executable definition of several rules that the specification states only in prose or in Appendix A. Anyone building a compliant Yield Calculator, Bioreactor Monitor, Optimization Engine, Purification Designer or Quality Control component (the five required components of spec §9.1) will be measured, in practice, against what these tools return.
The approach is deliberately forensic. For each function we give the formula, the input checks, the classification bands, and a reproduction of the worked example in the specification. We also say plainly which outputs are real calculations and which are fixed placeholders, because a number that looks computed but is actually a constant is the most dangerous thing a process engineer can copy into a batch record. Every behaviour described below was checked by running the v1.0 source.
The package is small: two source files and a build configuration. src/types.ts (about a thousand lines) holds the data model: the interfaces for culture conditions, bioreactor status, yields, purification steps, quality attributes and PAT readings, plus the BIO_CONSTANTS object, the BioErrorCode enumeration and the BioManufacturingError class. src/index.ts holds the behaviour: a class, BioManufacturingSDK, and six standalone functions that wrap it.
@wia/bio-015/
├── package.json "main": dist/index.js (CommonJS)
│ "module": dist/index.mjs (ES module)
│ "types": dist/index.d.ts
├── src/
│ ├── index.ts BioManufacturingSDK class + 6 standalone functions
│ └── types.ts interfaces, BIO_CONSTANTS, BioErrorCode, BioManufacturingError
└── dist/ produced by: tsup src/index.ts src/types.ts --format cjs,esm --dts --clean
import { monitorBioreactor, BioErrorCode } from '@wia/bio-015'; // "." entry
import type { BioreactorStatus } from '@wia/bio-015/types'; // "./types" subpath
The build uses tsup to emit CommonJS, ES module and declaration outputs for both source files, so consumers can import values from the root entry and import only types from the ./types subpath without pulling in any runtime code. The package declares no runtime dependencies and requires Node.js 16 or later. Tests are configured for Jest with ts-jest, matching files under __tests__ or named *.test.ts / *.spec.ts; the v1.0 source tree contains no test files, which is one reason Chapter 8 proposes a conformance suite.
| Surface | Available as | Purpose |
|---|---|---|
calculateYield | class method and standalone function | Product mass per mass of substrate supplied |
calculateProductivity | class method and standalone function | Volumetric productivity in g/L/h and g/L/day |
monitorBioreactor | class method and standalone function | Threshold checks on one status snapshot |
designPurification | class method and standalone function | Template chromatography train with recovery, time and cost estimates |
validateBatch | class method and standalone function | Pass/fail screen on titer, viability, lactate and ammonia |
optimizeFermentation | class method and standalone function | Returns recommended setpoints (fixed in v1.0) |
calculateProcessCapability | class method only | Cp and Cpk from a data series and specification limits |
calculateGrowthKinetics | class method only | μ, td and heuristics from a CellLine (Chapter 2) |
getVersion | class method only | Returns '1.0.0' |
The class holds no meaningful state: its constructor sets a private initialized flag that nothing reads, and every standalone function simply creates a new instance and delegates. The practical consequence is that all calculations are pure functions of their arguments, safe to call concurrently and trivial to test. Two functions, however, are reachable only through the class, so an integrator who relies solely on the named function exports will not find Cp/Cpk and must write new BioManufacturingSDK().calculateProcessCapability(...).
Build defect in v1.0. index.ts declares export class BioManufacturingSDK and later repeats export { BioManufacturingSDK }; types.ts likewise exports every interface and constant at its declaration and again in closing export type { … } and export { … } lists. ECMAScript modules forbid exporting the same name twice. When we loaded the unmodified source as a module, Node.js stopped with SyntaxError: Duplicate export of 'BioManufacturingSDK'. Delete the redundant trailing export statements before building. A second, minor packaging point: in the exports map the "types" condition is listed after "require" and "import"; TypeScript's documentation recommends placing it first, because conditions are matched in order.
The TypeScript data model is not identical to the interfaces printed in spec §9.2. The specification sketches flat request and response shapes; the SDK uses richer, nested types. Implementers who start from the specification text will therefore produce objects the SDK does not accept, and vice versa. The differences that matter most are these:
| Spec §9.2 shape | SDK type | Difference |
|---|---|---|
YieldResponse.efficiency is the band 'excellent' | 'good' | 'fair' | 'poor' | YieldResult | The band is called performance; efficiency is the yield expressed as a percentage (a number) |
Flat BioreactorStatus with cellDensity, pH, productTiter at top level | BioreactorStatus | Nested: conditions (CultureConditions) and metrics (CellCultureMetrics), plus required timestamp, phase, alarms, status |
MonitoringResult without critical alarms | MonitoringResult | Adds reactorId, timestamp, criticalAlarms and optional predictedHarvest |
PurificationConfig (no starting concentration) | PurificationDesign | Requires startingConcentration; adds optional budgetConstraint; productType accepts all ProductType values |
estimatedRecovery, estimatedPurity, estimatedCost | PurificationProtocol | Named overallRecovery, finalPurity, costPerGram; adds id, name, viralInactivation |
Our recommendation, which the rest of the book follows, is to treat the SDK types as the working interface and the §9.2 listings as an informal summary. Chapter 8 lists these mismatches again as proposed errata.
calculateYield: the headline yieldThe yield in WIA-BIO-015 is an overall mass ratio: grams of product at harvest divided by grams of substrate supplied. Spec §1.4 words the definition as product "relative to substrate consumed", but Appendix A and the SDK both divide by the substrate supplied (concentration at the start times volume, plus any feed); residual substrate at harvest is never subtracted. It is therefore not a stoichiometric yield coefficient such as YP/S, and it says nothing about cell mass. The SDK computes
totalProduct = productConcentration × finalVolume [g]
totalSubstrate = substrateConcentration × initialVolume + feedSubstrate × feedVolume [g]
Y = totalProduct / totalSubstrate [dimensionless]
efficiency = 100 × Y [%]
Both feed arguments default to zero, which reduces the formula to the simple batch case of Appendix A.1. The function validates its inputs before computing anything. If productConcentration is negative or finalVolume is zero or negative, it throws a BioManufacturingError with code B007 (INVALID_PARAMETERS) and the message "Product concentration and volume must be positive". If substrateConcentration or initialVolume is zero or negative, it throws B007 with "Substrate concentration and volume must be positive". Note the asymmetry: a product concentration of exactly zero is accepted and returns a yield of zero, which is correct for a failed run and should not be treated as an error.
The result is then placed in one of four performance bands using strict inequalities: excellent if Y > 0.15, good if Y > 0.10, fair if Y > 0.05, and poor otherwise. A yield of exactly 0.10 is therefore "fair", not "good". The bands are the standard's own convention; they are not derived from any published benchmark, and they only make sense for mammalian cell culture where glucose is the reference substrate. A microbial fermentation with a different carbon source, or a process where the "substrate" is defined differently, needs its own bands.
import { calculateYield } from '@wia/bio-015';
// Appendix A.1 of the specification (batch, no feed)
const a1 = calculateYield({
productConcentration: 5.2, // g/L at harvest
finalVolume: 1000, // L
substrateConcentration: 50, // g/L at inoculation
initialVolume: 1000, // L
});
// a1.yield === 0.104, a1.totalProduct === 5200, a1.totalSubstrate === 50000
// a1.efficiency === 10.4, a1.performance === 'good', a1.yieldFormatted === '10.40% (0.1040)'
// Illustrative fed-batch case: the feed carries most of the substrate
const fb = calculateYield({
productConcentration: 5.2,
finalVolume: 1000, // must already include the added feed volume
substrateConcentration: 6, // g/L glucose in the 800 L starting medium
initialVolume: 800,
feedVolume: 200, // L of feed added over the run
feedSubstrate: 100, // g/L glucose in the feed
});
// totalSubstrate = 6*800 + 100*200 = 24,800 g -> yield ≈ 0.2097, performance 'excellent'
Running the Appendix A.1 inputs gives exactly the published result: 5,200 g of product from 50,000 g of substrate, Y = 0.104, efficiency 10.40 %, band "good", and the formatted string 10.40% (0.1040). The illustrative fed-batch call shows why the feed terms matter: omitting them would divide 5,200 g by only 4,800 g and report a physically meaningless yield above 1.
Three limits of the implementation should be understood before the result is recorded anywhere. First, the function does not check that finalVolume equals initial volume plus feed volume minus sampling and evaporation losses; it trusts whatever the caller passes. Second, it does not reject non-finite numbers. Because every comparison with NaN is false, a NaN product concentration passes validation and produces a NaN yield classified as "poor". A production wrapper should test Number.isFinite on every input. Third, there is no unit handling: the SDK assumes g/L and L throughout, and a caller who passes mg/L will get a yield a thousand times too small without any warning.
calculateProductivity: volumetric productivityVolumetric productivity tells the plant how much product each litre of reactor capacity generates per unit time, which is the figure that drives facility throughput. The SDK computes it from the change in product concentration over the culture time:
Qp = (finalConcentration − initialConcentration) / cultureTime [g/L/h]
Qp,day = 24 × Qp [g/L/day]
totalProduct = finalConcentration × volume [g], only if volume is supplied
Validation throws B007 when cultureTime is zero or negative ("Culture time must be positive") and when the final concentration is lower than the initial one ("Final concentration cannot be less than initial"). The second check is sensible for a single production run but would reject legitimate data from a perfusion process where the concentration in the vessel can fall while product accumulates in the harvest stream; for perfusion, productivity should be computed from harvested mass, not vessel concentration.
Classification is done on the daily figure, again with strict inequalities: excellent above 0.8 g/L/day, good above 0.5, fair above 0.3, poor otherwise. The source code comments that these are benchmarks for monoclonal antibodies; the specification's Appendix A.2 describes 0.3–0.8 g/L/day as the typical mAb range. They are not meaningful for enzymes, vaccines or microbial products.
Reproducing Appendix A.2: a final titer of 5.2 g/L from zero after 240 h gives Qp = 5.2 / 240 = 0.021667 g/L/h, which the SDK formats as 0.0217 g/L/h, and 0.520 g/L/day. Because 0.520 is greater than 0.5, the band is "good", matching the appendix. The formatted string is 0.520 g/L/day (0.0217 g/L/h), and with volume: 1000 the total product is 5,200 g. As a further illustration, 12 g/L reached in 14 days (336 h) would give about 0.857 g/L/day and the band "excellent".
One subtlety concerns the choice of time base. The SDK divides by whatever cultureTime the caller supplies. Whether that is inoculation to harvest, or inoculation to the end of downstream processing, or includes turnaround time for cleaning and sterilisation, changes the number considerably. For comparisons between batches, record the definition alongside the value; for facility planning, add turnaround explicitly.
monitorBioreactor: thresholds and status aggregationThe Bioreactor Monitor is the component most likely to be wired into a live control room, so its exact behaviour deserves the closest reading. The function receives one BioreactorStatus snapshot and inspects six quantities: viability, pH, temperature and dissolved oxygen (always), and lactate and ammonia (only when present in metrics). Each check can add an entry to criticalAlarms or warnings, and some add a recommendation. All comparisons are strict, so a value lying exactly on a boundary falls into the milder class.
| Quantity | Critical alarm when | Warning when | No finding | Recommendation added |
|---|---|---|---|---|
| Viability (%) | < 70 (BIO_CONSTANTS.MIN_VIABILITY) | 70 to < 85 | ≥ 85 | On critical: consider harvesting or investigate conditions |
| pH | < 6.8 or > 7.4 | 6.8–<6.9 or >7.3–7.4 | 6.9–7.3 | On critical: check pH control and calibration |
| Temperature (°C) | < 35 or > 38 | 35–<36 or >37.5–38 | 36–37.5 | On critical: check temperature control |
| Dissolved oxygen (% air sat.) | < 20 or > 60 | 20–<30 or >50–60 | 30–50 | On critical: adjust agitation or aeration |
| Lactate (mM), optional | > 40 | > 30 to 40 | ≤ 30 | On critical: reduce glucose feed / feeding strategy |
| Ammonia (mM), optional | never | > 8 | ≤ 8 | On warning: monitor closely |
The thresholds are written for mammalian (typically CHO) culture at 37 °C. They would flag almost every E. coli or yeast fermentation as critical, since those organisms are commonly grown with different pH and dissolved-oxygen targets. Implementations serving several expression systems need a threshold profile per organism; the v1.0 SDK has no such parameter.
The alarm texts contain their own "target" ranges, and two of them disagree with the logic. The pH critical message says "target: 6.9-7.4", while the no-finding band is 6.9–7.3. The temperature message says "target: 36-37°C", while values up to 37.5 °C raise nothing. Operators reading the text will be given a range that the code does not enforce. When the monitor is placed behind a user interface, show the configured bands from one table rather than the strings.
After the checks, the overall status is aggregated from the counts:
criticalAlarms;This counting rule has a consequence worth stating: a culture with two warnings, say viability at 80 % and lactate at 35 mM, is reported as "acceptable", while a culture with three trivial warnings becomes "warning". Severity does not add up; count does. Many plants will prefer a weighted rule, but a conformant implementation of v1.0 must reproduce this one, and Chapter 8 includes boundary tests for it.
The following example builds a nested snapshot, runs the monitor and dispatches on the result. It also shows how a calculation error surfaces as an exception.
import {
monitorBioreactor,
calculateYield,
BioManufacturingError,
BioErrorCode,
} from '@wia/bio-015';
import type { BioreactorStatus, MonitoringResult } from '@wia/bio-015/types';
// A day-9 snapshot from a CHO fed-batch run (values are illustrative)
const snapshot: BioreactorStatus = {
reactorId: 'BR-201',
timestamp: new Date('2026-01-01T08:00:00Z'),
conditions: {
temperature: 37.0, // °C
pH: 6.85, // inside 6.8–7.4, outside 6.9–7.3 -> warning
dissolvedOxygen: 25, // % air saturation, inside 20–60, outside 30–50 -> warning
agitation: 90, // rpm (not evaluated by the v1.0 monitor)
},
metrics: {
cellDensity: 2.5e6, // viable cells/mL
viability: 82, // % -> warning (< 85)
lactate: 33, // mM -> warning (> 30)
ammonia: 6, // mM -> no finding (<= 8)
},
phase: 'stationary',
alarms: [], // not read by monitorBioreactor in v1.0
status: 'running',
};
function handle(result: MonitoringResult): void {
switch (result.status) {
case 'critical':
// at least one entry in criticalAlarms: page the shift lead
result.criticalAlarms.forEach((a) => console.error(`[CRITICAL] ${a}`));
break;
case 'warning':
// three or more warnings and no critical alarm
result.warnings.forEach((w) => console.warn(`[WARN] ${w}`));
break;
case 'acceptable':
// one or two warnings: log only
console.info(`${result.reactorId}: ${result.warnings.length} warning(s)`);
break;
default:
console.info(`${result.reactorId}: optimal`);
}
result.recommendations.forEach((r) => console.info(` -> ${r}`));
}
handle(monitorBioreactor(snapshot)); // status 'warning' (4 warnings, 0 critical)
// Calculation errors are thrown, not returned
try {
calculateYield({
productConcentration: 5.2,
finalVolume: 1000,
substrateConcentration: 0, // invalid: must be > 0
initialVolume: 1000,
});
} catch (err) {
if (err instanceof BioManufacturingError && err.code === BioErrorCode.INVALID_PARAMETERS) {
// err.code === 'B007', err.message === 'Substrate concentration and volume must be positive'
console.error(`${err.code}: ${err.message}`);
} else {
throw err; // anything else is a genuine defect
}
}
The monitor returns the object below for that snapshot. Four warnings and no critical alarm yield the status "warning". The recommendations array is empty, because v1.0 only adds recommendations for critical findings and for elevated ammonia; a lactate of 33 mM, which is already above the warning threshold, carries no advice.
{
"reactorId": "BR-201",
"timestamp": "2026-01-01T08:00:00.000Z",
"status": "warning",
"warnings": [
"Viability declining: 82.0%",
"pH near limits: 6.85",
"DO suboptimal: 25.0%",
"Lactate accumulating: 33.0 mM"
],
"criticalAlarms": [],
"recommendations": [],
"trends": [
{ "parameter": "Cell Density", "current": 2500000, "trend": "stable",
"rateOfChange": 0, "alert": "none" },
{ "parameter": "Viability", "current": 82, "trend": "decreasing",
"rateOfChange": -0.5, "alert": "none" }
]
}
Several parts of this output are placeholders and must not be presented as analysis. The trends array always contains exactly two entries. "Cell Density" is always reported as 'stable' with a rate of change of 0. "Viability" is reported as 'decreasing' with a fixed rate of −0.5 whenever the current viability is below 90 %, regardless of history, because the function sees only one snapshot. predictedHarvest is never filled. The input fields phase, alarms, status and the optional agitation, aeration, pressure and co2 are not evaluated at all. A real monitor needs a time series (the SensorReading and TimeSeriesPoint types exist for this) and should compute rates of change from it; Chapter 7 describes that loop.
The remaining functions cover the other three §9.1 components. They differ sharply in how much genuine computation they contain, so each is described with a clear distinction between calculated and fixed outputs.
designPurificationThe Purification Designer returns a template chromatography train chosen by product type alone. For 'antibody' it produces the classic three-column route: Protein A capture, anion-exchange polishing in flow-through mode, and hydrophobic interaction chromatography (HIC). For every other product type it produces two steps: cation-exchange capture followed by HIC. Resin volume for each step is computed as the mass loaded divided by the dynamic binding capacity (DBC) assumed for that resin; that division is the only engineering calculation in the function. Everything else comes from constants.
| Output | How v1.0 obtains it | Antibody example (1,000 L at 5 g/L) |
|---|---|---|
| Step recoveries | Fixed: 0.95, 0.92, 0.90 (antibody); 0.90, 0.90 (others) | 95 %, 92 %, 90 % |
| Step purities | Fixed: 95, 98, 99 % (antibody); 80, 99 % (others) | Final 99 % |
| Starting purity | Assumed 30 %, then overwritten; never reported | — |
| DBC | Fixed: 40 g/L (Protein A), 60 g/L (AEX), 50 g/L (CEX), 40 g/L (HIC) | Resin 125 L, 79.2 L, 109.25 L |
| Overall recovery | Product of the fixed recoveries | 0.95 × 0.92 × 0.90 = 78.66 % |
| Duration | 8 h per chromatography step + 2 h viral inactivation + 4 h UF/DF and formulation | 30 h |
| Cost per gram | Antibody: 50 + 20 × steps; others: 30 + 15 × steps (US$/g) | US$ 110 /g |
id | 'PURIF-' + Date.now() | Not unique within one millisecond |
The inputs targetPurity, targetRecovery and budgetConstraint are accepted but never used. In the example above the caller asked for 80 % recovery and received a protocol with 78.66 %, without any warning. The cost figure is not a cost model: it does not depend on scale, titer, resin price or resin lifetime. The step purities are not a function of the feed. The anion-exchange step is labelled flow-through, yet its resin volume is sized with a binding capacity for the product as if it were a bind-and-elute step. The load volume of later steps is the harvest volume multiplied by the mass recovery, which mixes up volume and mass. In short, designPurification is a useful skeleton for a user interface and a reasonable default sequence for a mAb, but every number in the output apart from resin volume should be labelled "template value" when displayed. In our runs, two calls made in the same millisecond also received the same id, so persistence layers must generate their own identifiers.
calculateProcessCapabilityThis is a correct, textbook implementation. It computes the mean and the sample standard deviation s (dividing by n − 1), then
Cp = (USL − LSL) / (6s) Cpk = min[(USL − x̄) / (3s), (x̄ − LSL) / (3s)]
and classifies on Cpk: capable at 1.33 or above, marginally-capable from 1.0 up to 1.33, not-capable below 1.0. For purity data 98.9, 99.1, 99.0, 98.8 and 99.2 % against limits of 98 and 100 %, the mean is 99.0 %, s ≈ 0.158, and Cp = Cpk ≈ 2.11 ("capable"). Five points are far too few to estimate s reliably, which is exactly why Chapter 8 discusses sample size. The function has two edge cases that should be guarded. With a single data point the variance is 0/0, so s, Cp and Cpk are NaN and the result is "not-capable" with a recommendation to improve the process, which is misleading; B010 (INSUFFICIENT_DATA) would be the right response. With identical values s = 0 and the indices become infinite.
validateBatchBatch validation screens four summary values. Despite the parameter names targetTiter and targetViability, the function treats them as the values the batch actually achieved. The rules are:
The result is valid when there are no errors. The quality score starts at 100 and loses 20 points per error and 5 per warning, with a floor of 0. The compliance object is where caution is essential: gmp is true when GMP is not required, or when it is required and there are no errors; cqas is true when there are no errors; documentation simply mirrors gmpRequired; and sterility is always true, with a source comment noting that a real sterility test would be needed. A batch with titer 1.8 g/L, viability 88 %, peak lactate 42 mM and ammonia 6 mM returns isValid: true, two warnings and a score of 90. None of this is a release decision. It is a pre-screen, and any display of the sterility flag must come from the QC laboratory's actual result, not from this function.
optimizeFermentationThe Optimization Engine in v1.0 does not optimise. It returns the same setpoints for every input: initial density 3 × 105 cells/mL, glucose 6.0 g/L, glutamine 4.0 mM, temperature 37.0 °C and pH 7.1, a predicted response of 5 × 106 cells/mL with a fixed ±10 % interval, and a constant recommendation to use exponential glucose feeding with a temperature shift at day 7 (a shift that the returned 37.0 °C setpoint does not reflect). The only input-dependent field is improvement, which projects the initial density forward at μ = 0.03 h−1 and reports how far that projection falls short of 5 × 106, floored at zero. Starting at 3 × 105 cells/mL, a 96 h culture already projects past the target and returns 0 %, while a 48 h culture returns about 295 %. volume, initialSubstrate, feedRate and feedConcentration are ignored. A real Optimization Engine would fit a response-surface model to DOE data using the DOEConfig and DOEFactor types, as Chapter 8 describes.
All errors raised by the SDK are instances of BioManufacturingError, a subclass of Error with three fields: code (a BioErrorCode), message, and an optional details record for structured context. Its name is set to 'BioManufacturingError', which survives serialisation where class identity does not. The enumeration defines ten codes. Spec §9.3 documents only the first six, each with a recommended action; codes B007 to B010 exist only in the SDK.
| Code | Enum member | Spec §9.3 meaning and action | Raised by v1.0 SDK |
|---|---|---|---|
| B001 | LOW_VIABILITY | Low cell viability: check culture conditions | No (monitor emits a text alarm instead) |
| B002 | HIGH_LACTATE | High lactate: reduce glucose feed | No (text alarm) |
| B003 | LOW_TITER | Low titer: optimise expression | No (validateBatch text) |
| B004 | pH_EXCURSION | pH excursion: calibrate sensors | No (text alarm) |
| B005 | CONTAMINATION | Contamination detected: investigate, discard batch | No |
| B006 | EQUIPMENT_FAILURE | Equipment failure: maintenance required | No |
| B007 | INVALID_PARAMETERS | Not listed | Yes: calculateYield, calculateProductivity |
| B008 | GMP_VIOLATION | Not listed | No |
| B009 | OUT_OF_SPEC | Not listed | No |
| B010 | INSUFFICIENT_DATA | Not listed | No (natural fit for Cp/Cpk with n < 2) |
The codes fall into two families that should be handled differently. B001 to B006 describe the state of the process: they are observations about living cells and equipment, and they occur during perfectly correct software operation. Such findings belong in data, as BioreactorAlarm records with a severity and an acknowledgement flag, and in the MonitoringResult. Throwing an exception because a culture's viability dropped would abort the very code path that needs to report it. B007 and B010, by contrast, describe bad input to a calculation; they are the caller's problem and are suitably raised as exceptions. B008 and B009 sit in between: they are quality findings that normally arise in validation and release workflows and should be recorded as deviations rather than thrown.
In v1.0 the monitor reports process findings only as free-text strings. Mapping them back to B001–B004 requires matching on message prefixes such as "Low viability" or "pH out of range", which breaks as soon as the wording changes or is translated. We recommend that implementations attach a code to each finding (for example by producing BioreactorAlarm objects alongside the strings) and that the next revision of the standard add a code field to monitor findings. The operator actions in §9.3 then attach naturally to the code, not to the prose.
Finally, types.ts declares a discriminated union Result<T, E> ({ success: true; data } or { success: false; error }) and an AsyncResult alias, but no SDK function returns them. They are useful at service boundaries, where exceptions should not leak across a network. A small adapter converts the throwing style into the declared union while letting genuine defects propagate:
import { calculateProductivity, BioManufacturingError, BioErrorCode } from '@wia/bio-015';
import type { Result, ProductivityParameters } from '@wia/bio-015/types';
// Adapter: turn the SDK's throwing style into the Result<T, E> union declared in types.ts
export function attempt<T>(fn: () => T): Result<T, BioManufacturingError> {
try {
return { success: true, data: fn() };
} catch (err) {
if (err instanceof BioManufacturingError) {
return { success: false, error: err };
}
throw err; // do not convert programming defects into business errors
}
}
// Usage at a service boundary; `body` is the parsed request payload
export function productivityEndpoint(body: ProductivityParameters) {
const r = attempt(() => calculateProductivity(body));
if (!r.success) {
const httpStatus = r.error.code === BioErrorCode.INVALID_PARAMETERS ? 400 : 500;
return { httpStatus, body: { code: r.error.code, message: r.error.message } };
}
return { httpStatus: 200, body: r.data };
}
The adapter re-throws anything that is not a BioManufacturingError. That distinction matters: a TypeError from a missing field is a bug in the integration, and converting it into a polite 400 response would hide it. Because the SDK never fills details, the adapter does not return it; implementations that throw their own B007 errors should put the offending field names in details so that clients can highlight them.
wia-bio-015 command-line toolThe CLI is a self-contained Bash script. It does not call the TypeScript SDK; it reimplements the same rules with bc for floating-point arithmetic, so bc must be installed on the host. The script uses set -e, prints coloured output with ANSI escape codes, and ignores any option it does not recognise.
| Command | Options (default) | What it prints |
|---|---|---|
calc-yield | --product (5.2 g/L), --substrate (50 g/L), --volume (1000 L) | Total product, total substrate, yield, band |
calc-productivity | --final (5.2 g/L), --initial (0), --time (240 h) | g/L/h, g/L/day, band |
monitor | --reactor (BR-001), --cells (2.5e6), --viability (95), --ph (7.2), --temp (37), --do (40) | Per-parameter checks and an overall status |
design-purification | --product (antibody), --scale (1000) | Fixed step list and totals |
optimize | --cell-line (CHO), --product (mAb), --target (8.0 g/L) | Fixed setpoints, feeding plan, duration = target / 0.6 days |
version, help | — | Version banner, usage text |
A session reproducing Appendix A.1 and the monitor snapshot of section 6.4 looks like this (the banner and separator lines are omitted):
$ wia-bio-015 calc-yield --product 5.2 --substrate 50 --volume 1000
▶ Product Yield Calculation
Product Concentration: 5.2 g/L
Final Volume: 1000 L
Substrate Concentration: 50 g/L
Initial Volume: 1000 L
Total Product: 5200.00 g
Total Substrate: 50000.00 g
▶ Results
✓ Yield: 0.1040 (10.40%)
✓ Performance: GOOD
$ wia-bio-015 monitor --reactor BR-201 --viability 82 --ph 6.85 --do 25
▶ Bioreactor Monitoring
Reactor ID: BR-201
Timestamp: 2026-09-25 02:06:36
▶ Culture Metrics
Viable Cell Density: 2.5e6 cells/mL
Viability: 82%
pH: 6.85
Temperature: 37°C
Dissolved Oxygen: 25%
▶ Status Checks
⚠ Viability: WARNING (<85%)
✗ pH: OUT OF RANGE (6.9-7.4)
✓ Temperature: OPTIMAL
⚠ Dissolved Oxygen: SUBOPTIMAL (30-50%)
▶ Overall Status
⚠ Status: WARNING - Monitor closely
The yield output matches the SDK exactly. The monitor output does not, and the difference is instructive. For the same pH of 6.85, the SDK issues a warning (6.85 lies inside the 6.8–7.4 critical band), while the CLI prints "OUT OF RANGE" because it uses a single band of 6.9–7.4 with no warning zone. The CLI does not accept lactate or ammonia at all. Its temperature check only ever warns (outside 36–37 °C), so 38.5 °C, a critical alarm in the SDK, is a warning in the CLI and 37.4 °C, silent in the SDK, is a warning in the CLI. Its overall status looks only at viability and pH, so a dissolved-oxygen reading of 15 %, printed as out of range in the status checks, still ends with "Status: OPTIMAL". The CLI is convenient for demonstrations but is not a conformant Bioreactor Monitor.
Other known gaps, all verified against the v1.0 script:
calc-yield passes the single --volume value as both final and initial volume and has no feed options, so it cannot compute a fed-batch yield.--scale 1000L; the CLI never uses the scale in a calculation and simply echoes it, printing "Scale: 1000L L". The README also shows wia-bio-015 --version; the script only understands version and exits with status 1 ("Unknown command '--version'").calc-productivity --time 0 makes bc report "Divide by zero" and syntax errors, after which the script still prints a productivity of 0.0000 g/L/h with a success tick, a "POOR" band, and exits with status 0. Scripts that call the CLI cannot detect the failure from the exit code.designPurification returns 30 hours and US$ 110 per gram for the same three-step route. For other products the CLI lists three steps (CEX, HIC, SEC) with "~75 %" recovery, while the SDK returns two steps and 81 %.optimize prints a production temperature of 33 °C after a day-7 shift and a dissolved-oxygen setpoint of 40 %, neither of which appears in optimizeFermentation's parameters.The practical rule for this book is simple. Where the CLI and the SDK disagree, the SDK is the reference, because it is the executable form of the data model and its numbers reproduce Appendix A. Treat the CLI as a teaching aid and a quick calculator for yield and productivity, and do not script operational decisions around its exit codes.
The reference SDK is a compact, dependency-free set of pure functions. Two of them, calculateYield and calculateProductivity, implement the Appendix A calculations exactly and reproduce 0.104 and 0.0217 g/L/h (0.52 g/L/day). calculateProcessCapability is a correct Cp/Cpk calculation that needs a guard for small samples. monitorBioreactor encodes a precise threshold table and a count-based status rule that conformant implementations must match, while its trends are placeholders. designPurification, validateBatch and optimizeFermentation are largely templates whose outputs must be labelled as such. Only B007 is ever thrown; process findings should be carried as coded alarms rather than exceptions. The CLI is a separate Bash reimplementation with different monitor bands and several input-handling gaps. Chapter 7 places these functions in a running plant, and Chapter 8 turns the behaviours documented here into a conformance test suite.