Chapter 6

Chapter 6: SDK and CLI Interfaces and Calculation Rules

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.

6.1 Package layout and entry points

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.

SurfaceAvailable asPurpose
calculateYieldclass method and standalone functionProduct mass per mass of substrate supplied
calculateProductivityclass method and standalone functionVolumetric productivity in g/L/h and g/L/day
monitorBioreactorclass method and standalone functionThreshold checks on one status snapshot
designPurificationclass method and standalone functionTemplate chromatography train with recovery, time and cost estimates
validateBatchclass method and standalone functionPass/fail screen on titer, viability, lactate and ammonia
optimizeFermentationclass method and standalone functionReturns recommended setpoints (fixed in v1.0)
calculateProcessCapabilityclass method onlyCp and Cpk from a data series and specification limits
calculateGrowthKineticsclass method onlyμ, td and heuristics from a CellLine (Chapter 2)
getVersionclass method onlyReturns '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 shapeSDK typeDifference
YieldResponse.efficiency is the band 'excellent' | 'good' | 'fair' | 'poor'YieldResultThe band is called performance; efficiency is the yield expressed as a percentage (a number)
Flat BioreactorStatus with cellDensity, pH, productTiter at top levelBioreactorStatusNested: conditions (CultureConditions) and metrics (CellCultureMetrics), plus required timestamp, phase, alarms, status
MonitoringResult without critical alarmsMonitoringResultAdds reactorId, timestamp, criticalAlarms and optional predictedHarvest
PurificationConfig (no starting concentration)PurificationDesignRequires startingConcentration; adds optional budgetConstraint; productType accepts all ProductType values
estimatedRecovery, estimatedPurity, estimatedCostPurificationProtocolNamed 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.

6.2 calculateYield: the headline yield

The 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.

6.3 calculateProductivity: volumetric productivity

Volumetric 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.

6.4 monitorBioreactor: thresholds and status aggregation

The 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.

QuantityCritical alarm whenWarning whenNo findingRecommendation added
Viability (%)< 70 (BIO_CONSTANTS.MIN_VIABILITY)70 to < 85≥ 85On critical: consider harvesting or investigate conditions
pH< 6.8 or > 7.46.8–<6.9 or >7.3–7.46.9–7.3On critical: check pH control and calibration
Temperature (°C)< 35 or > 3835–<36 or >37.5–3836–37.5On critical: check temperature control
Dissolved oxygen (% air sat.)< 20 or > 6020–<30 or >50–6030–50On critical: adjust agitation or aeration
Lactate (mM), optional> 40> 30 to 40≤ 30On critical: reduce glucose feed / feeding strategy
Ammonia (mM), optionalnever> 8≤ 8On 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:

  1. critical if there is at least one entry in criticalAlarms;
  2. otherwise warning if there are more than two warnings (three or more);
  3. otherwise acceptable if there are one or two warnings;
  4. otherwise optimal.

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.

6.5 Purification, capability, batch validation and optimisation

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.

6.5.1 designPurification

The 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.

OutputHow v1.0 obtains itAntibody example (1,000 L at 5 g/L)
Step recoveriesFixed: 0.95, 0.92, 0.90 (antibody); 0.90, 0.90 (others)95 %, 92 %, 90 %
Step puritiesFixed: 95, 98, 99 % (antibody); 80, 99 % (others)Final 99 %
Starting purityAssumed 30 %, then overwritten; never reported—
DBCFixed: 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 recoveryProduct of the fixed recoveries0.95 × 0.92 × 0.90 = 78.66 %
Duration8 h per chromatography step + 2 h viral inactivation + 4 h UF/DF and formulation30 h
Cost per gramAntibody: 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.

6.5.2 calculateProcessCapability

This 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.

6.5.3 validateBatch

Batch 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.

6.5.4 optimizeFermentation

The 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.

6.6 The error model

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.

CodeEnum memberSpec §9.3 meaning and actionRaised by v1.0 SDK
B001LOW_VIABILITYLow cell viability: check culture conditionsNo (monitor emits a text alarm instead)
B002HIGH_LACTATEHigh lactate: reduce glucose feedNo (text alarm)
B003LOW_TITERLow titer: optimise expressionNo (validateBatch text)
B004pH_EXCURSIONpH excursion: calibrate sensorsNo (text alarm)
B005CONTAMINATIONContamination detected: investigate, discard batchNo
B006EQUIPMENT_FAILUREEquipment failure: maintenance requiredNo
B007INVALID_PARAMETERSNot listedYes: calculateYield, calculateProductivity
B008GMP_VIOLATIONNot listedNo
B009OUT_OF_SPECNot listedNo
B010INSUFFICIENT_DATANot listedNo (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.

6.7 The wia-bio-015 command-line tool

The 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.

CommandOptions (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:

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.

Summary

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.