Chapter 5
What each call in the reference SDK and command-line tool really does, with the edge cases that decide whether an implementation can be trusted.
Chapter 4 described the WIA-BIO-011 records as data: a Biosensor with its configuration, a CalibrationData object, a stream of MeasurementResult records and the quality-control types around them. This chapter turns to the code that creates and changes those records. WIA-BIO-011 v1.0 ships two reference interfaces: the TypeScript package @wia/bio-011, whose main export is the class BiosensorSDK (api/typescript/src/index.ts, with its types in types.ts), and a Bash script, wia-bio-011 (cli/wia-bio-011.sh). The specification text itself does not define an API. In practice, therefore, the behaviour of these two programs is the de facto interface of the standard, and an implementer needs to know it in detail.
The approach here is deliberately literal. Every behaviour described below was checked by compiling the v1.0 sources and running them against the worked examples in spec Appendix A. Where the code does something different from what its names or the README suggest, the chapter says so and proposes a workaround. The workarounds are book guidance, not part of the standard. Operating procedures (how often to calibrate, how to act on QC failures) are left to Chapter 6. This chapter concentrates on what the interfaces accept, return and throw.
BiosensorSDK is a synchronous, in-memory object. Its constructor does nothing. All state lives in two private maps: sensors (sensor ID to Biosensor) and measurements (sensor ID to an array of MeasurementResult). The SDK performs no I/O, has no persistence layer and has no concept of users or devices. When the object is garbage-collected, every sensor, calibration and measurement it held is gone. A production system must therefore treat the SDK as a calculation and validation engine and store the returned records itself, in the JSON form set out in Chapter 4.
| Method | Input | Returns | Errors actually thrown | Side effects |
|---|---|---|---|---|
getVersion() | none | '1.0.0' | none | none |
createSensor(config) | BiosensorConfig | Biosensor | BIO002 | Adds the sensor with status idle; may attach a default lifetime |
getSensor(id) | sensor ID | the stored Biosensor object (a live reference) | BIO001 | none |
calibrateSensor(req) | CalibrationRequest | CalibrationData | BIO001, BIO002, BIO010, BIO011 | Status calibrating then idle; replaces sensor.calibration |
measureAnalyte(req) | MeasurementRequest | MeasurementResult | BIO001, BIO007, BIO003, BIO004, BIO002 | Status measuring then idle; increments measurementCount; appends to the measurement log |
calculateLOD(params) | LODCalculation | LODResult | none (bad input yields NaN) | none |
updatePerformanceMetrics(id, lod, t) | sensor ID, LODResult, response time in s | PerformanceMetrics | BIO001, BIO003 | Replaces sensor.performance |
validateSensor(params) | ValidationParameters | ValidationResult | BIO001 | none; the result is not stored |
runQualityControl(id, sample, c) | sensor ID, QualityControlSample, measured concentration | QualityControlResult | BIO001 | none; the result is not stored |
getMeasurements(id) | sensor ID | MeasurementResult[] | none (unknown ID gives []) | none |
getAllSensors() | none | Biosensor[] | none | none |
Two features of the table matter for integration. First, getSensor returns the object held in the map, not a copy. Anything the caller changes on it, such as a validUntil date or the status, changes the SDK state directly. This is convenient in tests and dangerous in production code that passes sensors around. Second, only the calibration, the performance metrics and the measurement log are written back into the SDK. Validation and QC results are returned and then forgotten. If they are to form part of the device history, the application must store them.
createSensor checks only the three fields that Chapter 4 marked as required: type, biorecognitionElement and targetAnalyte. Every other field is copied into config without inspection, so an appliedPotential of 60 instead of 0.6 is accepted without complaint. The ID has the form BS-<epoch milliseconds>-<9 random base-36 characters>. The default lifetime (180 days, 1,000 measurements, "2-8°C, dry") is attached when the recognition element string contains oxidase or merely ase. That catches glucose-oxidase, urease and alkaline-phosphatase, but it misses the generic value enzyme. The rule is a substring heuristic and not a statement about enzyme stability.
Four functions are exported next to the class: calculateSensitivity, calculateLOD, processBiosensorSignal and calibrateSensor. Each constructs a fresh BiosensorSDK, calls one method on it and discards it. For the first three this is harmless. calculateSensitivity returns the least-squares slope (1.15853 for the Appendix A.2 data), and processBiosensorSignal exposes the private signal processor. The processor implements only baseline subtraction and temperature correction. The filtering and driftCompensation fields of SignalProcessingParams are accepted and ignored, and the snr field of the result is never filled. The standalone calibrateSensor, however, cannot work. Its fresh instance holds no sensors, so every call ends in BIO001 "Sensor … not found", whatever ID is supplied. It appears in the README import list, but it should not be used.
The package declares tsup and tsc --noEmit scripts, but the sources as published do not type-check. Both files export each declaration twice: once where it is declared (export class, export interface) and again in a re-export list at the end of the file. TypeScript rejects this with TS2323 ("Cannot redeclare exported variable") and TS2484 ("Export declaration conflicts with exported declaration"). The fix is mechanical and changes no behaviour:
// types.ts: delete the trailing re-export lists (the declarations are already exported)
// export type { BiosensorType, ... TimeSeries };
// export { BIOSENSOR_CONSTANTS, BiosensorErrorCode, BiosensorError };
// index.ts: delete the duplicate class export near the end
// export { BiosensorSDK };
// then:
$ tsc --strict --target es2020 --module commonjs --outDir dist src/index.ts
After this edit the two files compiled cleanly under --strict with TypeScript 5.9 when this chapter was prepared, and all the outputs quoted below come from that build.
calibrateSensor is the most important method in the SDK, because every later concentration depends on the coefficients it stores. Its request carries the sensor ID, two parallel arrays (standardConcentrations, measuredSignals) and optional units (default 'mM' and 'μA'), calibrationType (default 'linear') and forceRecalibration. The method then runs these steps in order:
calibrating.STANDARD_CALIBRATION_POINTS (5) entries with BIO010.MIN_R_SQUARED (0.99) with BIO011.y − ŷ and the largest absolute residual.CAL-<epoch ms>, validUntil = now + 24 h, qualityControl.passed = true.sensor.calibration and set the status back to idle.The core lines are short enough to quote:
const regression = this.linearRegression(standardConcentrations, measuredSignals);
if (rSquared < BIOSENSOR_CONSTANTS.MIN_R_SQUARED) { // 0.99 -> BIO011
throw new BiosensorError(BiosensorErrorCode.POOR_LINEARITY, ...);
}
const linearRange = {
min: Math.min(...standardConcentrations.filter(c => c > 0)), // lowest non-zero standard
max: Math.max(...standardConcentrations),
unit: concentrationUnit,
};
// ...
validUntil: new Date(Date.now() + 24 * 60 * 60 * 1000), // +24 h
qualityControl: { passed: true, residuals, maxResidual }, // always true
The five-point rule counts array entries, not concentration levels. The blank counts, and so does every replicate. [0, 1, 5, 10, 50] passes with only four non-zero levels, and [5, 5, 5, 10, 10] passes with two. Spec §7.1 asks for at least five concentration levels plus a blank, which Chapter 6 discusses. An application that wants to enforce the specification has to count distinct non-zero levels before calling the SDK.
Whatever calibrationType says, the method fits a straight line and writes slope and intercept. If a caller passes '4pl' for an immunoassay, calibration succeeds and stores a record typed '4pl' that contains linear coefficients. The first measureAnalyte call then fails with BIO002 "Unsupported calibration type", because inversion is implemented only for 'linear'. The error therefore surfaces at measurement time, possibly hours later and at the point of care, instead of at calibration time. Until a later version implements non-linear inversion, the book recommends rejecting any type other than 'linear' before calling the SDK.
qualityControl.passed is always true. The only check the SDK makes on the fit is R² ≥ 0.99, and a high R² says little about the shape of the residuals (Chapter 3). For the Appendix A.2 data the method returns:
| Standard (mM) | Signal (μA) | Fitted (μA) | Residual (μA) | Residual as concentration (mM) |
|---|---|---|---|---|
| 0 | 0.05 | 0.0474 | +0.0026 | +0.002 |
| 1 | 1.20 | 1.2059 | −0.0059 | −0.005 |
| 5 | 5.85 | 5.8400 | +0.0100 | +0.009 |
| 10 | 11.50 | 11.6327 | −0.1327 | −0.115 |
| 50 | 58.20 | 57.9738 | +0.2262 | +0.195 |
| 100 | 115.80 | 115.9003 | −0.1003 | −0.087 |
The fit is excellent by R² (0.999993), yet the residual at 10 mM corresponds to about −1.2 % of the level. Absolute residuals grow with concentration because the unweighted fit is dominated by the 50 and 100 mM standards. If the clinically important region is the low end, a weighted fit (for example 1/x) or a narrower calibration range gives better coefficients there. The SDK offers neither. The residuals are returned, however, so the caller can apply the residual criteria of Chapter 6 and set its own pass flag.
linearRange.min is the lowest non-zero standard, not the LOQ. With the A.2 data it is 1 mM, which later drives the below-lod flag (§5.3).sensitivityUnit "μA/mM". Many keyboards and instruments emit the micro sign (U+00B5) instead. The two look identical but compare unequal, so normalize unit strings at the boundary.forceRecalibration is accepted and never read. CalibrationPoint.replicates, stdDev and CalibrationData.operator are never filled.CAL-<epoch ms> has no random part, so two sensors calibrated in the same millisecond receive the same calibration ID. Prefix it with the sensor ID when storing.NaN. Because NaN < 0.99 is false in JavaScript, BIO011 is not raised and the calibration is stored. Always check Number.isFinite(cal.rSquared).
measureAnalyte turns one raw signal into a concentration record. Its request carries the sensor ID and the signal, and optionally the signal unit, temperature (default 25), pH, sample ID and type, an explicit calibration and a replicateNumber. The method runs its checks in a fixed order, and the order decides which error a caller sees when several things are wrong at once.
| Step | Check or operation | Outcome |
|---|---|---|
| 1 | Look up the sensor; set status measuring | BIO001 if unknown |
| 2 | lifetime.expirationDate earlier than now | BIO007 SENSOR_EXPIRED |
| 3 | Use request.calibration, else sensor.calibration | BIO003 "Sensor not calibrated" if neither exists |
| 4 | validUntil earlier than now | BIO004 CALIBRATION_EXPIRED |
| 5 | Temperature correction if T ≠ 25 °C | correctedSignal |
| 6 | Linear inversion C = (y − a)/b | BIO002 unless type is 'linear' with a non-zero slope |
| 7 | Quality flags and withinRange | see below |
| 8 | Heuristic confidence and standard error | 0.95/0.70; 5 % of C |
| 9 | Store the result; increment the count; status idle | MeasurementResult |
The correction uses a fixed reference of 25 °C and the SDK default ENZYME_TEMP_COEFFICIENT of 3 %/°C: ycorr = y / [1 + 0.03 (T − 25)]. As Chapter 3 showed, the specification writes the same factor as a multiplication, so the two conventions give different values of α for the same sensor. In the SDK the reference temperature is not taken from config.temperature, the coefficient cannot be configured, and the enzyme coefficient is also applied to antibody, aptamer and optical sensors, where it has no particular justification. If the request omits temperature, the default of 25 means that no correction is applied and no flag is raised. A reader that cannot measure temperature should say so in the record rather than let the default stand in for a measurement.
The flag array always starts as ['ok'], and warnings are appended to it. The rules are as follows. below-lod is added when sensor.performance.lod exists and C is below it. below-loq is added in the same way for the LOQ. If C lies below linearRange.min, a second below-lod is appended and withinRange becomes false. Above linearRange.max, above-range is added. temperature-out-of-range is added when |T − 25| > 10 °C. Confidence is 0.95 when the result is within range and carries no extra flag, and 0.70 otherwise. The standard error is simply 5 % of |C|. Neither number comes from the calibration statistics, so neither should be reported as an uncertainty. The following results were produced with the calibration of §5.2 and the performance metrics of §5.4 in place:
| Signal (μA) | T (°C) | C (mM) | qualityFlags | withinRange | Confidence |
|---|---|---|---|---|---|
| 23.5 | 25 | 20.243 | ok | true | 0.95 |
| 23.5 | 37 | 14.874 | ok, temperature-out-of-range | true | 0.70 |
| 0.6 | 25 | 0.477 | ok, below-lod | false | 0.70 |
| 0.052 | 25 | 0.0040 | ok, below-lod, below-loq, below-lod | false | 0.70 |
| 0.04 | 25 | −0.0064 | as the previous row | false | 0.70 |
| 130 | 25 | 112.17 | ok, above-range | false | 0.70 |
Three rows deserve comment. At 0.6 μA the result, 0.477 mM, is about 24 times the LOD, yet it is flagged below-lod because it lies below the lowest standard. The correct flag would say "below the calibrated range". At 0.052 μA the flag appears twice, once from the LOD rule and once from the range rule. Finally, signals below the intercept give negative concentrations, which are returned without clamping. The first row also shows why the literature value "20.3 mM" from Appendix A.2 does not reappear: the specification inverts with its printed coefficients 1.157 and 0.043, which do not reproduce from its own data (Chapter 3), whereas the SDK inverts with the full-precision fit and gets 20.24 mM.
Two further inputs are passed through without effect. pH is copied into the result but never checked against config.pH, and replicateNumber is ignored. The method also accepts a calibration object that belongs to a different sensor, without comparing IDs. A calibration passed explicitly bypasses the sensor's own record, but the expiry check in step 4 still applies to it.
calculateLOD takes an array of blank signals, a sensitivity and an optional method. It computes the blank mean and the sample standard deviation (divisor n − 1), then applies one of three multipliers. The LOQ is always 10σ/S, whichever method is chosen. For the ten Appendix A.1 blanks (mean 0.056 μA, σ = 0.006992 μA) and the sensitivity from §5.2 (1.15853 μA/mM), the three methods give:
method | Multiplier k | LOD = kσ/S (mM) | LOQ (mM) | Comment |
|---|---|---|---|---|
'iupac' (default) | 3.3 | 0.01992 | 0.06035 | The usual 3.3σ/S form |
'3-sigma' | 3 | 0.01811 | 0.06035 | Classical 3σ criterion |
'epa' | 3.14 (fixed) | 0.01895 | 0.06035 | Not the EPA procedure; see below |
The 'epa' branch multiplies by a constant 3.14. In the statistical procedure it imitates, the multiplier is a one-sided 99 % Student-t value for n − 1 degrees of freedom. That value is about 3.14 only for seven replicates. For the ten blanks used here it would be about 2.82. The branch should therefore not be described as EPA-compliant. The confidenceLevel parameter is accepted and ignored by all three methods. The returned unit is the literal string 'concentration units', so the caller must supply the real unit (here mM) when storing the result.
The field snrAtLOD is computed as (mean + 3.3σ)/σ, which is 11.31 for these data. The number depends on the blank offset and is not a signal-to-noise ratio in any conventional sense. By construction, the net signal at the LOD is 3.3σ, so the net SNR at the LOD is 3.3. The CLI prints exactly that value (§5.7), which means the two reference tools report different numbers for the same data. Neither should be recorded as a measured performance figure. With fewer than two blanks the divisor n − 1 makes σ NaN, and a zero sensitivity gives Infinity. Neither case raises an error.
updatePerformanceMetrics(sensorId, lodResult, responseTime) requires an existing calibration (BIO003 otherwise). It writes a PerformanceMetrics record containing the LOD, the LOQ, dynamicRange = log₁₀(linearRange.max / LOD) (for this example log₁₀(100 / 0.01992) = 3.70 decades), the calibration's concentration unit, the response time and a timestamp. The response time is whatever number the caller passes. The SDK never measures time, so the value must come from a real t₉₀ experiment (Chapter 3). The optional selectivity, accuracy, precision and stability fields stay empty, and validateSensor does not fill them either.
There is an ordering dependency here. The below-lod and below-loq rules in measureAnalyte read sensor.performance. Until updatePerformanceMetrics has been called, only the range rule can flag low results. Recalibration also leaves the old performance record in place, so after a new calibration the LOD in sensor.performance may belong to the previous sensitivity. Recompute it after each calibration.
Every failure in the SDK is reported by throwing a BiosensorError. This class extends Error, sets name to 'BiosensorError', and carries a code from the string enum BiosensorErrorCode and an optional details record. The SDK never fills details. The types file also declares Result<T, E> and AsyncResult<T, E> as discriminated unions for code that prefers returned errors. No SDK method uses them: every method is synchronous and throws.
| Code | Enum member | Raised in v1.0 by |
|---|---|---|
| BIO001 | SENSOR_NOT_FOUND | getSensor, hence every method that takes a sensor ID; always from the standalone calibrateSensor |
| BIO002 | INVALID_CONFIGURATION | createSensor (missing required field); calibrateSensor (array lengths differ); measureAnalyte (non-linear calibration type) |
| BIO003 | CALIBRATION_FAILED | measureAnalyte and updatePerformanceMetrics when no calibration exists |
| BIO004 | CALIBRATION_EXPIRED | measureAnalyte after validUntil |
| BIO005 | MEASUREMENT_OUT_OF_RANGE | never (reported as the flag above-range) |
| BIO006 | BELOW_LOD | never (reported as the flag below-lod) |
| BIO007 | SENSOR_EXPIRED | measureAnalyte after lifetime.expirationDate |
| BIO008 | TEMPERATURE_OUT_OF_RANGE | never (reported as a flag) |
| BIO009 | QC_FAILED | never (runQualityControl returns passed: false) |
| BIO010 | INSUFFICIENT_CALIBRATION_POINTS | calibrateSensor with fewer than 5 entries |
| BIO011 | POOR_LINEARITY | calibrateSensor with R² < 0.99 (but not when R² is NaN) |
| BIO012 | INVALID_SIGNAL | never (NaN or negative signals are processed) |
| BIO013 | SENSOR_DEGRADED | never |
The split is intentional in one respect. Conditions that still allow a usable, if qualified, number (out of range, below the LOD, off-temperature) become flags. Conditions that make any number meaningless become exceptions. The six reserved codes are still useful. They give a remote service or a device log a shared vocabulary for events the reference SDK does not detect, such as a QC failure that locks the sensor or an invalid ADC reading. An implementation that raises them should document when it does, because v1.0 gives no trigger conditions.
calibrateSensor sets the status to calibrating and measureAnalyte sets it to measuring before their checks run. When a check throws, nothing resets the status. After a rejected calibration the sensor stays calibrating, and after an expired-calibration error it stays measuring, indefinitely. The SDK never assigns error, expired or active, although all three are in the status enum. The wrapper below restores a meaningful status and converts exceptions to the Result form. Its state choices anticipate the lifecycle of Chapter 6 and are book guidance.
import { BiosensorSDK, BiosensorError, BiosensorErrorCode, MeasurementRequest, MeasurementResult, Result } from '@wia/bio-011';
function safeMeasure(sdk: BiosensorSDK, req: MeasurementRequest): Result<MeasurementResult, BiosensorError> {
const sensor = sdk.getSensor(req.sensorId); // may itself throw BIO001
try {
return { success: true, data: sdk.measureAnalyte(req) };
} catch (e) {
if (!(e instanceof BiosensorError)) throw e; // programming error: re-throw
sensor.status = 'error'; // v1.0 leaves 'measuring' behind
switch (e.code) {
case BiosensorErrorCode.CALIBRATION_EXPIRED: // BIO004: schedule a recalibration
case BiosensorErrorCode.CALIBRATION_FAILED: // BIO003: never calibrated
sensor.status = 'idle';
break;
case BiosensorErrorCode.SENSOR_EXPIRED: // BIO007: retire the sensor
sensor.status = 'expired';
break;
}
return { success: false, error: e };
}
}
The following session exercises the main path of the SDK with the specification's own example data: Appendix A.2 for the calibration and sample, Appendix A.1 for the blanks. Expected values are shown as comments and were reproduced by running the compiled v1.0 code. Values that must come from the laboratory, the response time and the QC targets, are marked as illustrative.
import { BiosensorSDK } from '@wia/bio-011';
const sdk = new BiosensorSDK();
// 1. Create: the three required fields plus descriptive ones
const sensor = sdk.createSensor({
type: 'electrochemical',
electrochemicalType: 'amperometric',
biorecognitionElement: 'glucose-oxidase',
targetAnalyte: 'glucose',
analyteCategory: 'metabolite',
workingElectrode: 'platinum',
referenceElectrode: 'ag-agcl',
counterElectrode: 'platinum',
appliedPotential: 0.6, // V vs Ag/AgCl
sampleType: 'serum',
});
// sensor.id -> 'BS-<epoch ms>-<9 random chars>'
// sensor.status -> 'idle'
// sensor.lifetime-> { expirationDate: now + 180 d, maxMeasurements: 1000,
// storageConditions: '2-8°C, dry' }
// 2. Calibrate with the spec Appendix A.2 data (blank + 5 levels = 6 points)
const cal = sdk.calibrateSensor({
sensorId: sensor.id,
standardConcentrations: [0, 1, 5, 10, 50, 100], // mM
measuredSignals: [0.05, 1.20, 5.85, 11.50, 58.20, 115.80], // μA
});
// cal.regression -> { slope: 1.15853, intercept: 0.04736 }
// cal.rSquared -> 0.999993
// cal.linearRange -> { min: 1, max: 100, unit: 'mM' }
// cal.qualityControl.maxResidual -> 0.2262 (at the 50 mM standard)
// 3. LOD from the Appendix A.1 blanks
const lod = sdk.calculateLOD({
blankSignals: [0.05, 0.06, 0.05, 0.07, 0.05, 0.06, 0.05, 0.06, 0.05, 0.06],
sensitivity: cal.sensitivity,
});
// lod.blankMean -> 0.056, lod.blankStdDev -> 0.006992
// lod.lod -> 0.01992 (mM), lod.loq -> 0.06035 (mM), lod.snrAtLOD -> 11.31
// 4. Store performance; the response time is an input, 23 s is only an example (as in Chapter 3)
const perf = sdk.updatePerformanceMetrics(sensor.id, lod, 23);
// perf.dynamicRange -> 3.70 (decades, log10(100 / 0.01992))
// 5. Measure the Appendix A.2 sample at the reference temperature
const m = sdk.measureAnalyte({
sensorId: sensor.id, signal: 23.5, temperature: 25,
sampleId: 'S-0001', sampleType: 'serum',
});
// m.concentration -> 20.243 (mM) m.qualityFlags -> ['ok']
// m.confidence -> 0.95 m.standardError -> 1.012 (5 % of value)
// 6. Run a medium-level control (target values are illustrative)
const qc = sdk.runQualityControl(sensor.id, {
level: 'medium', targetConcentration: 10,
acceptableRange: { min: 9, max: 11 },
historicalMean: 10.1, historicalStdDev: 0.3, lotNumber: 'QC-LOT-EX',
}, 10.4);
// qc.passed -> true, qc.deviation -> 4.0 (%), qc.zScore -> 1.0,
// qc.correctiveAction -> undefined
Each step leaves a trace that a real system should persist. After step 1 the sensor record exists with its default lifetime. Step 2 attaches a calibration valid for 24 hours. Step 4 gives the sensor an LOD, which step 5 then uses for flagging. The measurement in step 5 is appended to the in-memory log (getMeasurements now returns one entry), and measurementCount becomes 1. The QC result in step 6 is returned but not stored. The deviation is +4.0 % from target and the z-score against the historical mean is 1.0, so no corrective action is proposed.
The pass decision uses only acceptableRange. The z-score is computed when both historical values are present but plays no part in the decision. When the control fails, the suggested action depends on the absolute deviation from target: above 20 % the text is "Recalibrate sensor immediately", between 10 and 20 % "Run additional QC sample to confirm", and below 10 % "Monitor next QC result". The last case arises when a laboratory sets a range tighter than ±10 %. With the range 9–11 mM, a reading of 11.5 mM fails with +15 % and the second action, and 12.5 mM fails with +25 % and the first. With a range of 9.5–10.5 mM, a reading of 10.8 mM fails with +8 % and the third. The action is a free-text string. Chapter 6 shows how to turn these tiers, together with the z-score, into a proper decision procedure.
validateSensor evaluates up to four optional blocks, and the overall validated flag is false if any evaluated block fails:
const v = sdk.validateSensor({
sensorId: sensor.id,
precision: { replicates: [5.80, 5.86, 5.91, 5.83, 5.88], requiredCV: 5 },
accuracy: {
spikedSamples: [
{ expectedConcentration: 5, measuredConcentration: 4.9 },
{ expectedConcentration: 10, measuredConcentration: 10.3 },
],
requiredRecovery: [90, 110],
},
selectivity: {
targetSignal: 5.85,
interferents: [
{ name: 'fructose', concentration: 5, signal: 0.15 },
{ name: 'ascorbate', concentration: 0.1, signal: 0.08 },
],
requiredSelectivity: 100,
},
stability: { measurements: [{ time: 0, signal: 10 }, { time: 24, signal: 5 }],
requiredStability: 95 },
});
// v.precision -> { passed: true, cv: 0.73 }
// v.accuracy -> { passed: true, recovery: 100.5 }
// v.selectivity -> { passed: false, coefficients: { fructose: 39, ascorbate: 73.1 } }
// v.stability -> (absent: the 50 % signal loss is never evaluated)
// v.validated -> false
Linearity re-runs the regression and compares R² with the caller's threshold. Accuracy averages the individual recoveries, so a sample at 80 % and another at 120 % would pass with a mean of 100 %. Checking each recovery individually is safer. Precision computes the CV of the replicate array. Selectivity divides targetSignal by each interferent's raw signal and ignores the concentration field it asks for. As Chapter 3 showed, normalizing by concentration turns the ascorbate ratio from 73 into about 1.5, so the SDK overstates selectivity whenever interferents are tested at lower concentrations than the analyte. Finally, the stability block is part of the type but is never read. In the example the signal halves in 24 hours and nothing is reported.
wia-bio-011 is a Bash script. It uses bc for arithmetic and GNU date -d for timestamps, and it colours its output with ANSI escape codes. It is not a front end to the SDK. It reimplements some of the calculations in shell and keeps no state between invocations. The --sensor-id option is echoed in the output but is never looked up. It is useful for quick calculations and demonstrations, and it should not be used to produce records.
| Command | Options (defaults) | What it actually does |
|---|---|---|
create-sensor | --type (electrochemical), --bioelement (glucose-oxidase), --analyte (glucose) | Prints a new ID BS-<epoch s>-<8 chars> on the last line. For electrochemical types it prints fixed electrode text. Nothing is validated or saved. |
calibrate | --sensor-id, --standards, --signals (both required) | Least-squares fit in bc; slope, intercept and R²; a ≥ 5 point check (exit 1); linear range; a +24 h "Valid Until". |
measure | --signal (10.5), --temp (25), --slope (1.157), --intercept (0.043) | Divides by 1 + 0.03(T − 25), inverts, then flags against a fixed 0.1–30 mM window. |
calc-lod | --blank-stddev (0.015), --sensitivity (1.157) | LOD = 3.3σ/S and LOQ = 10σ/S, in mM and μM, plus a verbal rating. |
validate | --accuracy (95), --precision (3) | Checks the recovery against 90–110 % and the CV against ≤ 5 %. R² 0.9995 and a 15 s response time are hard-coded. |
qc-report | --sensor-id | Prints a canned report (2.48, 10.15 and 24.82 mM) for any sensor. |
version, help | none | Prints the version (1.0.0) or the usage text. |
The session below mirrors the walkthrough (output trimmed):
$ wia-bio-011 create-sensor --type electrochemical --bioelement glucose-oxidase --analyte glucose
Sensor ID: BS-<epoch s>-<8 chars>
...
Applied Potential: +0.6 V
✓ Sensor created successfully
BS-<epoch s>-<8 chars> # last line = the ID, nothing is stored
$ wia-bio-011 calibrate --sensor-id BS-001 \
--standards "0,1,5,10,50,100" --signals "0.05,1.20,5.85,11.50,58.20,115.80"
✓ Slope (Sensitivity): 1.1585 μA/mM
✓ Intercept: 0.0474 μA
✓ R²: 0.999993
Linear Range: 1 - 100 mM
Signal(μA) = 0.0474 + 1.1585 × Concentration(mM)
$ wia-bio-011 measure --sensor-id BS-001 --signal 23.5 --slope 1.1585 --intercept 0.0474
✓ Concentration: 20.24 mM
Quality Flags: OK
$ wia-bio-011 measure --sensor-id BS-001 --signal 23.5 --temp 37 --slope 1.1585 --intercept 0.0474
Temperature-Corrected Signal: 17.28 μA
✓ Concentration: 14.87 mM
Quality Flags: OK # the SDK would add temperature-out-of-range
$ wia-bio-011 calc-lod --blank-stddev 0.0070 --sensitivity 1.1585
✓ LOD: 0.0199 mM (19.9 μM)
✓ LOQ: 0.0604 mM (60.4 μM)
SNR at LOD: 3.3:1 # the SDK reports 11.31 for the same data
measure defaults to the rounded Appendix A.2 coefficients (1.157, 0.043), not to the output of the preceding calibrate. Without --slope and --intercept the 23.5 μA sample gives 20.27 mM, the SDK gives 20.24 mM, and the specification prints 20.3 mM.measure flags use a hard-coded 0.1–30 mM window, whatever the analyte or calibration. At 37 °C no temperature flag is raised, and the confidence is always printed as 95 %.calibrate with R² < 0.99 prints a warning and exits with status 0. Scripts cannot detect the failure from the exit code. Fewer than five points exits with 1.grep -v '^0', which removes every standard beginning with the digit 0. With standards 0,0.5,1,5,10,20, the reported range is "1 - 20 mM" and the 0.5 mM standard disappears.validate reports R² 0.9995 and 15 s for every sensor, and qc-report prints the same three control results every time. Neither may be filed as evidence.--signl 23.5 therefore measures the default 10.5 μA. The README's wia-bio-011 --version is rejected as an unknown command, and the working form is wia-bio-011 version.create-sensor the ID is on the last line (| tail -n 1). Other outputs are meant for people, not for parsing.
WIA-BIO-011 v1.0 defines no network API. The file spec/PHASE-2-API-INTERFACE.md is generic template text that carries a different standard ID, and it cannot be treated as normative. The only parts worth keeping are the general security lines it shares with the other phase documents: OAuth 2.0 with API keys for authentication, TLS 1.3 in transit, AES-256 at rest, role-based access control, audit logging and an OpenAPI 3.0 description. The README also names WIA-OMNI-API and WIA-HEALTH as integration targets without defining any interface. What follows is therefore the book's sketch of how the SDK objects could be exposed as REST resources, for teams that need a service in front of the SDK. It is not a WIA requirement, and no base URL or rate limit is implied.
| Resource and verb (sketch) | SDK operation | Body / response type |
|---|---|---|
POST /sensors | createSensor | BiosensorConfig → Biosensor |
GET /sensors/{id} | getSensor | Biosensor |
POST /sensors/{id}/calibrations | calibrateSensor | CalibrationRequest → CalibrationData |
POST /sensors/{id}/lod | calculateLOD + updatePerformanceMetrics | blanks and response time → PerformanceMetrics |
POST /sensors/{id}/measurements | measureAnalyte | MeasurementRequest → MeasurementResult |
GET /sensors/{id}/measurements | getMeasurements | MeasurementResult[], paginated |
POST /sensors/{id}/qc | runQualityControl | control sample and value → QualityControlResult (stored) |
POST /sensors/{id}/validations | validateSensor | ValidationParameters → ValidationResult (stored) |
A service like this should make three choices explicit. The first is error mapping. BIO001 maps naturally to 404, input problems (BIO002, BIO010, BIO011) to 422, and state conflicts (BIO003, BIO004, BIO007) to 409, with the BIO code kept in the body so that clients can act on it. The second is serialization. The TypeScript types use JavaScript Date objects, which should cross the wire as ISO 8601 UTC strings, and every number should travel with its unit string, as recommended in Chapter 4. The third is the flag vocabulary. The example below drops the unconditional 'ok', as §5.9 recommends. All values are illustrative apart from the computed concentration.
POST /sensors/{sensorId}/measurements
Content-Type: application/json
{ "signal": 23.5, "signalUnit": "μA", "temperature": 37, "sampleId": "S-0001" }
HTTP/1.1 200 OK
{
"id": "M-...", "sensorId": "BS-...",
"concentration": 14.874, "concentrationUnit": "mM",
"signal": 23.5, "correctedSignal": 17.279, "signalUnit": "μA",
"qualityFlags": ["temperature-out-of-range"],
"withinRange": true, "temperature": 37,
"timestamp": "2026-01-15T08:30:00Z"
}
HTTP/1.1 409 Conflict
{ "error": { "code": "BIO004", "message": "Calibration has expired",
"details": { "validUntil": "2026-01-14T08:00:00Z" } } }
Unlike the in-memory SDK, such a service would also store QC and validation results, return the calibration ID used for each measurement, and record the authenticated operator in operator. Those are the fields an auditor asks for first.
The behaviours described above are collected here in one place. Each has been reproduced with the v1.0 code, and each has a simple defence.
| Pitfall | What you observe | Defence |
|---|---|---|
| README SDK example at 37 °C | ≈ 14.9 mM with temperature-out-of-range, not the 20.3 mM of spec A.2 (which is at 25 °C with slightly different data) | Never mix the two examples; state the temperature of every worked result |
First README createSensor call | BIO002, because targetAnalyte is missing; transducer is not a config field; calibration.lod is undefined | Use electrochemicalType; take the LOD from calculateLOD |
'ok' always present | ['ok', 'above-range'] | Treat a result as clean only if the array is exactly ['ok']; drop 'ok' when storing |
Range rule reuses below-lod | Results above the LOD but below the lowest standard are flagged below-lod, sometimes twice | Recompute flags against the stored LOD, the LOQ and the range separately |
maxMeasurements not enforced | The 1,001st measurement succeeds | Compare measurementCount with the lifetime before each call |
NaN R² accepted | A calibration from flat or constant data is stored | Check Number.isFinite(rSquared) and the number of distinct levels |
| Status left behind after errors | A sensor stuck in calibrating or measuring | Reset in catch/finally (§5.5) |
Standalone calibrateSensor | Always BIO001 | Use the class method |
Non-linear calibrationType | Calibration succeeds, then every measurement fails with BIO002 | Reject anything but 'linear' up front |
| Double exports in the sources | TS2323/TS2484 at build time | Remove the trailing re-export lists (§5.1) |
| μ (U+03BC) vs µ (U+00B5) | Unit strings that look equal compare unequal | Normalize units at input |
| In-memory state | All sensors and results vanish on restart | Persist every returned record |
| CLI defaults and ignored options | Results computed from 1.157/0.043 or from a default signal | Always pass --slope, --intercept and --signal explicitly; check the echoed inputs |
None of these issues makes the reference implementation useless. The calculations at its core (least squares, LOD and LOQ, linear inversion and temperature scaling) are correct and easy to verify by hand, as this chapter has done. The problems lie at the edges: in what is left unchecked, in labels that promise more than the code does, and in heuristics presented as statistics. An implementation that adds the checks listed above and stores every record it receives will behave as the specification intends. Chapter 6 builds on that foundation and turns these calls into an operating protocol: when to calibrate, how to accept a calibration, how to run and interpret QC, and when a sensor must be taken out of service.