Chapter 4
The records that carry a biosensor from configuration through calibration to a reported concentration: what each field means, which unit it silently assumes, what the v1.0 SDK actually fills in, and how to serialize the whole chain so that another system can trust it.
Chapters 1 to 3 dealt with chemistry, physics and statistics. This chapter deals with bookkeeping, which sounds less interesting until you try to reconstruct, six months later, why a meter reported 20.2 mM for a sample that a reference analyzer called 18 mM. At that point the only evidence is the data record. If it does not say which calibration was used, at what temperature the signal was corrected, or whether the value sat inside the calibrated range, the investigation stops there.
A word on sources. The WIA-BIO-011 specification (§1–§10) describes sensors, metrics and procedures in prose and formulas, but it contains no data-format section. The file spec/PHASE-1-DATA-FORMAT.md that sits next to it is generic template text: it carries the identifier of a different standard (WIA-BIO-010) and only says, in general terms, that data should use standard formats such as JSON or XML and follow the FAIR principles (findable, accessible, interoperable, reusable). In practice the data model of WIA-BIO-011 v1.0 is defined by the TypeScript declarations in api/typescript/src/types.ts, and its behaviour by the reference SDK in index.ts. This chapter therefore treats types.ts as the de facto Phase-1 format. Where the declarations are silent or ambiguous, the text gives recommendations and labels them as book guidance, not as requirements of the standard. How the SDK methods compute their outputs is the subject of Chapter 5. Here we look only at the shape and meaning of what they produce.
Seven groups of types make up the model. A BiosensorConfig describes what a sensor is. A Biosensor wraps that configuration with an identity, a status and a lifetime. CalibrationData holds the calibration points and the fitted curve. PerformanceMetrics and LODResult summarize analytical performance, and ValidationResult records a formal check of that performance. MeasurementResult is the record of one reading. Around these sit the quality-control, batch, point-of-care and time-series records. The diagram below shows how they relate. Solid lines are links that exist in the v1.0 types. Dotted lines are links that v1.0 does not record and that the book recommends adding once records leave the SDK process.
+------------------+ embedded +---------------------+ current only +--------------------+
| BiosensorConfig |-------------| Biosensor |----------------| CalibrationData |
+------------------+ (config) | id BS-... | (calibration) | id CAL-... |
+--+-------+-------+--+ +---------+----------+
+--------------------+ (perf.) | | | :
| PerformanceMetrics |--------------+ | | history, sensorId : (book guidance)
+--------------------+ | | :
sensorId | | sensorId : calibrationId
1..N | | 1..N : (book guidance)
+-------------------+--+ +-+-------------------+ :
| BatchMeasurement | | MeasurementResult |.........:
| measurements[] ----+->| id M-... |
| qualityControl[] | +---------------------+
+----------+-----------+
| 1..N
+----------+-----------+ embedded copy +------------------------+
| QualityControlResult |-------------------| QualityControlSample |
+----------------------+ +------------------------+
ValidationResult (sensorId) TimeSeries (sensorId) 1..N TimeSeriesPoint
POCTestResult (device.serialNumber, device.lotNumber; no sensorId in v1.0)
Two features of this picture shape the rest of the chapter. First, a Biosensor holds only its current calibration. When the SDK calibrates again, the previous CalibrationData object is replaced, and nothing in the types points back to it. Second, neither MeasurementResult nor QualityControlResult records which calibration produced it. Inside a running SDK instance this does not matter, because there is only one current calibration. Once records are stored or exchanged, it matters a great deal.
BiosensorConfig: describing what the sensor isThe configuration record answers three questions: how the signal is transduced, what does the recognizing, and what is being measured. The table lists every field in the v1.0 declaration.
| Field | Type / allowed values | Required | Meaning and notes |
|---|---|---|---|
type | BiosensorType: electrochemical, optical, piezoelectric, thermal, impedance, calorimetric | Yes | Transduction family (Chapter 2). |
electrochemicalType | amperometric, potentiometric, voltammetric, conductometric, impedimetric | No | Meaningful only when type is electrochemical. |
opticalType | absorbance, fluorescence, luminescence, spr, fret, colorimetric | No | Meaningful only when type is optical. |
biorecognitionElement | BiorecognitionElement | EnzymeType | any string | Yes | Generic class (enzyme, antibody, aptamer, dna, rna, whole-cell, bacteriophage, molecularly-imprinted-polymer) or a named enzyme (glucose-oxidase, urease …). |
targetAnalyte | string | Yes | Free text, for example "glucose". |
analyteCategory | metabolite, protein, nucleic-acid, pathogen, toxin, drug, ion, cell | No | Coarse class of the analyte. |
workingElectrode | platinum, gold, carbon, silver, glassy-carbon | No | Electrode material. |
referenceElectrode | ag-agcl, sce, calomel, pseudo-reference | No | Defines the zero of appliedPotential. |
counterElectrode | platinum, gold, carbon | No | Auxiliary electrode material. |
appliedPotential | number, volts | No | Working-electrode potential against the reference electrode. |
wavelength | { excitation?, emission? }, nm | No | Optical sensors only. |
sampleType | blood, serum, plasma, urine, saliva, water, food, environmental | No | Intended matrix. |
temperature | number, °C | No | Controlled operating temperature, if any. |
pH | number | No | Operating or optimum pH. |
TypeScript marks three fields as non-optional: type, biorecognitionElement and targetAnalyte. The SDK's private validateConfig enforces the same three at run time, and nothing else. It checks only that each is truthy. If one is missing or empty, createSensor throws BiosensorError with code BIO002 (INVALID_CONFIGURATION). No value is compared against its enumeration at run time, so a JavaScript caller can pass type: "electrochemcial" and the typo is stored. No cross-field consistency is checked either. An optical sensor with appliedPotential: 0.6 and opticalType set on an electrochemical sensor are both accepted.
The README's first SDK example shows why this matters. It passes transducer: 'amperometric', a field that does not exist in BiosensorConfig, and omits targetAnalyte. Compiled as TypeScript, the object literal is rejected for the unknown property and the missing required field. Run as plain JavaScript, it reaches validateConfig and fails with BIO002, "Target analyte is required". The correct form names the subtype through electrochemicalType and states the analyte:
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, // volts vs Ag/AgCl
sampleType: 'blood',
pH: 7.0
});
Two places in the vocabulary allow the same sensor to be described in two ways. BiosensorType contains both electrochemical and impedance, while ElectrochemicalType contains impedimetric. An EIS immunosensor can therefore be written as {type: 'impedance'} or as {type: 'electrochemical', electrochemicalType: 'impedimetric'}. The same applies to thermal and calorimetric. Neither form is wrong under v1.0, but a database that groups sensors by type will split one population into two. The book's recommendation, following Chapter 2, is electrochemical + impedimetric for electrode-based EIS (keeping impedance for whole-cell impedance counters without a three-electrode cell), calorimetric for sensors that infer a reaction enthalpy and thermal for other temperature-based transduction, and to document that choice in the implementation's profile.
biorecognitionElement is deliberately open. Its declared type is the union of two enumerations and string, which in TypeScript collapses to plain string. This lets integrators name a specific antibody clone or aptamer sequence identifier, but it also means that "GOx", "glucose oxidase" and "glucose-oxidase" are three different values. targetAnalyte is plain text for the same reason. When records are pooled across devices, map both fields to a controlled list at the point of entry and keep the original text only as a display label. Chapter 7 discusses mapping the analyte to health-record codes.
appliedPotential is in volts, wavelength in nanometres and temperature in degrees Celsius. These units appear in source comments, not in the data. Inside one SDK that is harmless. In a JSON document read by another team, "appliedPotential": 600 (a millivolt value typed by a firmware engineer) is indistinguishable from a correct entry. The potential is also meaningless without referenceElectrode: +0.6 V against Ag/AgCl and +0.6 V against a pseudo-reference are different experiments. Record the reference electrode whenever a potential is recorded.
Biosensor: identity, status and lifetime
createSensor wraps the configuration in a Biosensor object with these fields: id, config, optional calibration and performance, status, created, optional lastMeasurement, measurementCount and optional lifetime.
The SDK builds the id as BS- + the current time in milliseconds since the Unix epoch + - + nine base-36 characters from Math.random(), for example BS-1767139200000-k3x9q2m7a. The CLI uses a similar but not identical pattern: epoch seconds and eight lowercase alphanumerics from /dev/urandom. Both are fine as local handles. Neither is a registered or globally unique identifier, and neither encodes the physical device. When sensors from many readers flow into one store, the book recommends keeping the SDK id as a local key and adding a stable global key, such as the device serial number plus the strip or electrode lot, or a UUID assigned at manufacture.
status takes six values: active, calibrating, measuring, idle, error and expired. The v1.0 SDK uses only three of them. A new sensor starts as idle, becomes calibrating during calibrateSensor and measuring during measureAnalyte, and returns to idle when either succeeds. It never sets active, error or expired. There is also a side effect worth knowing when you persist status: the status is changed before the input checks run, and it is not restored when a check throws. A calibration rejected for poor linearity leaves the sensor in calibrating, and a measurement refused because the calibration expired leaves it in measuring. A stored status is therefore a hint, not a fact. Chapter 6 proposes explicit state transitions for this lifecycle.
The optional lifetime block has three fields: expirationDate, maxMeasurements and a free-text storageConditions. When the recognition element string contains "oxidase" or "ase", the SDK fills this block with defaults: expiry 180 days after creation, 1,000 measurements and "2-8°C, dry". The substring test catches every named enzyme in EnzymeType, including urease, acetylcholinesterase, horseradish peroxidase and alkaline phosphatase. It does not catch the generic value enzyme, and it would catch any unrelated free-text string that happens to contain "ase". These values are SDK defaults, not measured shelf-life data. A product should overwrite them with the expiry of the lot and the storage conditions on its label.
Of the three fields, only expirationDate is enforced: measureAnalyte throws BIO007 once it has passed. measurementCount is incremented on every successful measurement but is never compared with maxMeasurements. A consumer that wants that limit must check it itself.
CalibrationPoint
Each calibration standard is recorded as a CalibrationPoint with concentration, signal, signalUnit, concentrationUnit, and optionally replicates (an array of raw readings) and stdDev. Specification §7.1 asks for duplicate measurements at each level, and §7.2.2 asks for RSD below 5 % with n ≥ 3. The point record can carry that evidence, but the SDK's calibrateSensor takes one signal per standard and never fills replicates or stdDev. The book recommends that the caller average the replicates, pass the mean as the signal, and then write the raw replicates and their sample standard deviation back into the stored points. Without them, the precision criterion of §7.2.2 cannot be audited from the record.
CalibrationData| Field | Type | Filled by calibrateSensor |
|---|---|---|
id | string | CAL- + epoch milliseconds. Two calibrations in the same millisecond would collide. |
points | CalibrationPoint[] | One point per standard, without replicates. |
type | linear | polynomial | logarithmic | 4pl | michaelis-menten | Copied from the request (default linear), whatever model was actually fitted. |
regression | { slope?, intercept?, coefficients?, fourPL? } | Only slope and intercept. |
rSquared | number | Coefficient of determination of the straight-line fit. |
sensitivity | number | Equal to the slope. |
sensitivityUnit | string | signalUnit + "/" + concentrationUnit, for example μA/mM. |
linearRange | { min, max, unit } | min = lowest non-zero standard, max = highest standard. |
calibratedAt | Date | Current time. |
validUntil | Date | Current time + 24 h. |
operator | string | Never set. |
qualityControl | { passed, residuals[], maxResidual } | passed is always true, because a failing fit throws instead of being recorded. |
Three points in this table need care when records are read by other software.
The type label and the regression block must agree. The SDK fits a straight line no matter which calibrationType is requested, but it stores the requested label. A request with '4pl' therefore produces a record that says "type": "4pl" while carrying a slope and intercept and no fourPL block. measureAnalyte later refuses to invert any non-linear type and throws BIO002. Chapter 5 covers the behaviour. For the data model, the rule is simple: a validator should reject a record whose type does not match the populated part of regression.
The 4PL parameter comments assume a rising curve. fourPL holds a, b, c and d, with source comments "min asymptote", "Hill slope", "IC50" and "max asymptote". In the usual form y = d + (a − d)/[1 + (x/c)b], a is the response at zero concentration and d the response at very high concentration. That makes a the minimum only for a sandwich-type assay whose signal rises with concentration. In a competitive immunoassay the signal falls, a is the maximum, and c is the IC50 in the literal sense. Store the parameters by their role in the equation, and document the equation, rather than relying on "min" and "max".
linearRange is asserted, not tested. The SDK sets the lower bound to the lowest non-zero standard and the upper bound to the highest standard. It does not check that the response is actually linear between them, and the lower bound has no relation to the LOQ. Chapter 3 explained why the linear range and the dynamic range must be kept apart. The record should be read as "the interval spanned by the calibration standards".
Specification Appendix A.2 gives six standards (0, 1, 5, 10, 50 and 100 mM) with signals of 0.05, 1.20, 5.85, 11.50, 58.20 and 115.80 μA. A least-squares fit of these data gives a slope of about 1.1585 μA/mM, an intercept of about 0.0474 μA and R² ≈ 0.99999. The appendix prints 1.157, 0.043 and 0.9998, which do not reproduce from its own data (Chapter 3). The residuals, which calibrateSensor stores in qualityControl.residuals, are approximately +0.003, −0.006, +0.010, −0.133, +0.226 and −0.100 μA, so maxResidual is about 0.226 μA at the 50 mM standard. Section 4.8 shows the full record.
The same calibration also illustrates the request record, CalibrationRequest. It carries sensorId, the two parallel arrays standardConcentrations and measuredSignals, optional units (defaults mM and μA), optional calibrationType, and a forceRecalibration flag that v1.0 accepts but does not use.
CalibrationData has no sensorId field, because in the SDK it lives inside a Biosensor. As soon as a record is stored on its own, for example in a calibration log, it loses its owner. The book recommends two additions whenever calibrations are persisted: a sensorId on each stored calibration, and an append-only history in which a new calibration never overwrites an old one. Together these make it possible to answer "which curve converted this signal?" for every stored measurement.
PerformanceMetrics
PerformanceMetrics is attached to a sensor as sensor.performance and summarizes its analytical characteristics. The required fields are lod, loq, dynamicRange, concentrationUnit, responseTime and measuredAt. The optional fields are selectivity (a map from interferent name to coefficient), accuracy, precision and a stability block. updatePerformanceMetrics fills only the required fields. It computes dynamicRange as log10(linearRange.max / lod), that is, in decades. The other fields must be supplied from separate studies.
Using the Appendix A.1 blanks (ten readings with a mean of 0.056 μA and a sample standard deviation of about 0.0070 μA) with the A.2 slope of 1.1585 μA/mM gives LOD = 3.3σ/S ≈ 0.0199 mM, LOQ = 10σ/S ≈ 0.0604 mM and a dynamic range of log10(100 / 0.0199) ≈ 3.70 decades. These are the values used in the example record in Section 4.8.
The selectivity map stores numbers without saying how they were defined. As Chapter 3 showed, a ratio of raw signals at unequal concentrations and a ratio of concentration-normalized sensitivities can differ by an order of magnitude. The IUPAC potentiometric selectivity coefficient also runs the other way, so that a smaller value is better. The book's convention is K = Starget/Sinterferent with both sensitivities per unit concentration, so that larger is better. Any record that uses a different convention should say so in an accompanying note.
Most numeric fields in the performance and validation records have fixed units that exist only in source comments, and the units differ from record to record. The table collects them.
| Record.field | Implied unit | Comment |
|---|---|---|
PerformanceMetrics.lod, .loq | concentrationUnit of the same record | Explicit unit string present. |
PerformanceMetrics.dynamicRange | decades (log10) | Not a concentration interval. |
PerformanceMetrics.responseTime | seconds | Normally t90 (Chapter 3). |
PerformanceMetrics.accuracy | percent | The definition (recovery or relative error) is not stated. |
PerformanceMetrics.precision | percent CV | |
stability.shelfLife | days | |
stability.operationalStability | number of measurements | |
stability.drift | percent per month | |
ValidationParameters.stability.measurements[].time | hours | Differs from shelfLife in days. |
ValidationResult.stability.halfLife | hours | |
SignalProcessingParams.temperatureCorrection.coefficient | percent per °C | SDK default 3 %/°C. |
LODResult.unit | the literal text "concentration units" | Hard-coded by the SDK. Replace it with the real unit. |
The last row deserves emphasis. calculateLOD cannot know the concentration unit, because it receives only blank signals and a sensitivity. It therefore writes the placeholder "concentration units" into LODResult.unit. Code that stores the result should replace the placeholder with the concentrationUnit of the calibration that supplied the sensitivity.
LODResult and LODCalculation
LODResult carries lod, loq, blankMean, blankStdDev, snrAtLOD, unit and method. blankStdDev is the sample standard deviation (n − 1 denominator). snrAtLOD is computed as (blank mean + 3.3σ)/σ. That is a convenience figure, not a signal-to-noise ratio in the sense of Chapter 3, and it should not be reported as a measured SNR. The input record LODCalculation has a confidenceLevel field (0.95 or 0.99) that the SDK accepts but never uses. The stored method string (iupac, 3-sigma or epa) is the only trace of how the number was produced, so keep it.
ValidationParameters and ValidationResult
The validation pair mirrors the acceptance criteria of specification §7.2. ValidationParameters can hold five optional studies: linearity (concentrations, signals, requiredR2), accuracy (spiked samples with expected and measured concentrations, requiredRecovery as a two-number interval such as [90, 110]), precision (replicates and requiredCV), selectivity (interferent names, concentrations and signals, a targetSignal and requiredSelectivity) and stability (time/signal pairs and a required percentage retained). ValidationResult returns a passed flag and the computed statistic for each study, an overall validated flag, a timestamp and optional operator and comments.
Two gaps affect how a stored result should be interpreted. The SDK never evaluates the stability study, so ValidationResult.stability stays empty even when stability data were supplied, and validated: true says nothing about stability. The selectivity study stores interferent concentrations but computes each coefficient as targetSignal divided by the interferent's raw signal, ignoring the concentrations. That is the unnormalized ratio that Chapter 3 corrected. Chapter 8 returns to validation as a regulatory activity. For now, the practical rule is that a ValidationResult must be read together with the parameters that produced it, so store both.
MeasurementRequest carries sensorId and the raw signal, and optionally signalUnit (default μA), temperature, pH, sampleId, sampleType, a specific calibration to use instead of the sensor's current one, and replicateNumber, which the v1.0 SDK ignores. One default has consequences for the stored result. If no temperature is supplied, the SDK assumes 25 °C, applies no correction and writes 25 into the result. A stored temperature of 25 °C can therefore mean "measured at 25 °C" or "not measured at all". Readers that need temperature, as every enzymatic sensor does, should always send it, and a stored record should mark a defaulted value.
| Field | Type | Content in v1.0 |
|---|---|---|
id | string | M- + epoch ms + - + six base-36 characters. |
sensorId | string | From the request. |
concentration | number | (correctedSignal − intercept) / slope. |
concentrationUnit | string | Taken from calibration.linearRange.unit. |
signal | number | Raw signal as received. |
signalUnit | string | From the request, default μA. |
correctedSignal | number | Signal after temperature correction. Equal to signal at 25 °C. |
confidence | number 0–1 | 0.95 if within range with no warning flag, otherwise 0.70. A heuristic, not a probability. |
standardError | number | Always 5 % of the concentration. A placeholder, not an estimated uncertainty. |
qualityFlags | QualityFlag[] | Always begins with ok; see Table 4-5. |
withinRange | boolean | False if the concentration is below linearRange.min or above linearRange.max. |
temperature | number, °C | From the request, or 25 if absent. |
pH | number | From the request. |
timestamp | Date | Time the result was computed. |
sample | { id, type, metadata? } | Created if either sampleId or sampleType is given. The missing one becomes "unknown". metadata is never set. |
operator | string | Never set. |
Raw and corrected signal. Keeping both values is one of the better decisions in the model, because it lets a later reviewer recompute the concentration with a different correction. The record does not say which correction was applied, though. The SDK divides the raw signal by [1 + α(T − 25 °C)] with α = 3 %/°C, whereas specification §5.4 writes the compensation as a multiplication by [1 + α(T − Tref)]. As Chapter 3 explained, the two conventions assign α different meanings. The internal ProcessedSignal object lists the steps applied in stepsApplied, but that list is not copied into the result. The book recommends storing the correction convention, α and Tref with each result, for example in sample.metadata or in an implementation extension.
Which calibration? The result has no calibrationId. Its concentrationUnit comes from the calibration, but the calibration itself is not identified. When a request passes an explicit calibration object, the stored result cannot even be linked through the sensor's current calibration. This is the single most important addition an implementation should make: record the id of the calibration that performed the inversion.
Worked example. With the Appendix A.2 calibration, a signal of 23.5 μA measured at 25 °C gives (23.5 − 0.0474)/1.1585 ≈ 20.24 mM. The value lies between 1 and 100 mM, so withinRange is true, the flags are ["ok"], confidence is 0.95 and standardError is about 1.01 mM. The README example submits the same signal with temperature: 37 and a slightly different calibration set (5.8 instead of 5.85 μA at 5 mM). That is a different record, and it should not be mixed with the appendix example: the SDK divides the signal by 1.36, reports about 14.9 mM and adds temperature-out-of-range because 37 °C is more than 10 °C from 25 °C. Chapter 5 dissects that example.
QualityFlag is a closed list of ten strings. It is the part of the model that downstream software reads most often, and also the part where the v1.0 SDK is least complete. The table gives each flag's meaning, the condition under which the SDK emits it, and a recommended trigger for the flags the SDK never emits.
| Flag | Meaning | Emitted by v1.0 SDK when | Recommended trigger (book guidance) |
|---|---|---|---|
ok | No problem detected | Always, as the first element | Only when no other flag is present |
below-lod | Value cannot be distinguished from blank | concentration < performance.lod, and also when concentration < linearRange.min | concentration < LOD only |
below-loq | Detected, but not quantifiable to the stated precision | concentration < performance.loq | Same |
above-range | Above the highest calibration standard | concentration > linearRange.max | Same; dilute and repeat (Chapter 6) |
temperature-out-of-range | Temperature outside the validated window | |T − 25 °C| > 10 °C | Outside the window declared for the product |
drift-detected | Baseline or sensitivity has shifted since calibration | Never | QC deviation trend, or baseline change above a set limit |
calibration-expired | Calibration older than its validity | Never (the SDK throws BIO004 instead) | Use when a result is still reported in a degraded mode |
sensor-expired | Sensor past expiry or maximum uses | Never (the SDK throws BIO007 instead) | As above; also when maxMeasurements is exceeded |
high-noise | Signal noise above the acceptance limit | Never | SNR of the reading window below the product limit |
interference-detected | Evidence of an interfering substance or matrix effect | Never | Failed interference check, for example an anomalous signal on a blank electrode |
Three consequences follow for anyone reading flags. First, because ok is always present, a test such as flags.includes('ok') is true for every SDK result and proves nothing. Test for the absence of warning flags instead. Second, the SDK labels any value below the lowest non-zero standard as below-lod, even when it is far above the LOD. With the A.2 calibration, a result of 0.5 mM is flagged below-lod although the LOD is about 0.02 mM. Third, a value that is below both the LOD and the lowest standard receives below-lod twice. A careful consumer should deduplicate flags, treat ok as exclusive, and interpret below-lod in the light of withinRange and the stored LOD.
QualityControlSample
A control material is described by level (blank, low, medium or high), targetConcentration, acceptableRange {min, max}, optional historicalMean and historicalStdDev, lotNumber and expirationDate. The record has no unit field. The unit is assumed to be that of the sensor's calibration, so a control expressed in mg/dL checked against a sensor calibrated in mM would compare 90 with 5 without complaint. Add a unit when storing controls.
The acceptance window is stored as numbers, not derived from a rule. Specification §7.4 sets two criteria: within ±10 % of target, and within ±2 SD of the historical mean. For a medium control with a target of 50 mM, the first criterion gives an acceptableRange of 45–55 mM. The second criterion is represented only indirectly, through the historical statistics.
QualityControlResult
runQualityControl returns the control sample (embedded as a copy), measuredConcentration, passed, deviation in percent of target, an optional zScore, a timestamp and an optional correctiveAction in free text. The SDK decides passed by the range check alone. The z-score is computed and stored but does not affect the verdict. Suppose, for illustration, that the 50 mM control has a historical mean of 49.6 mM and a historical SD of 1.2 mM and reads 52.1 mM. The deviation is +4.2 % and the value lies inside 45–55 mM, so the record says passed: true. But z = (52.1 − 49.6)/1.2 ≈ 2.08, which is outside the ±2 SD criterion of §7.4. A reader who looks only at passed will miss this. Chapter 6 builds the complete decision rule. At the data level, keep the z-score and evaluate it.
The blank level exposes a numerical edge case. Deviation is computed as (measured − target)/target × 100. For a blank control the target is 0, so the deviation is infinite, or not a number when the reading is also exactly 0. Neither value survives JSON serialization: JSON.stringify writes both as null. The z-score has a related problem, because the SDK computes it only when the historical mean and SD are truthy, and a blank's historical mean of 0 is falsy in JavaScript. For blanks, store an absolute deviation in concentration units instead of a percentage.
Like the measurement result, QualityControlResult does not record which sensor or calibration it checked, although runQualityControl receives the sensor id. The link exists only when the result is placed inside a BatchMeasurement.
BatchMeasurement
A batch groups the measurements and QC results of one run: id, sensorId, measurements, optional qualityControl, optional statistics {mean, median, stdDev, cv, min, max}, startedAt and completedAt. The v1.0 SDK has no method that creates batches, so the caller builds them. The declaration does not say what the statistics describe. For replicate readings of one sample, statistics of the concentrations are meaningful. For a batch of different patient samples, a mean and CV mix unrelated values and mean little. The book recommends computing statistics only over replicates of the same sample or control, stating CV in percent, and letting the batch be the unit that ties QC to the patient results it covers.
POCTestResult
The point-of-care result is a separate, report-oriented record: id, testType (glucose, cardiac-marker, infectious-disease, pregnancy, drug-test, other), analyte, value, unit, interpretation, optional referenceRange {min, max, unit} and cutoff, timestamp, optional patient {id, age?, sex?}, a required qualityControl {passed, flags}, optional operator and an optional device {serialNumber, lotNumber, expirationDate}.
The six interpretation values fall into two families. normal, abnormal and borderline suit quantitative tests judged against a reference range. positive, negative and indeterminate suit qualitative tests judged against a cutoff. The model does not stop a record from mixing them, so an implementation should fix one family per testType. cutoff has no unit of its own and is read in the unit of value. The record has no sensorId: its link to a physical sensor is the device block, where the lot number is what matters for recalls and QC. No SDK function produces a POCTestResult. The application builds it from a MeasurementResult plus clinical context. Chapter 7 covers that mapping, the clinical cutoffs and the health-record side.
TimeSeries
Continuous sensors, such as a CGM that reports every few minutes, produce streams better held as a TimeSeries than as thousands of separate results. A series has id, sensorId, analyte, an array data of TimeSeriesPoint and an open metadata object with optional patientId and studyId. Each point carries time, value, unit and at most one quality flag.
The single flag per point is a lossy compression of the flag array in MeasurementResult. When converting, drop ok, keep the most severe remaining flag, and, if more than one warning must survive, keep the full results elsewhere and reference them. Repeating the unit on every point costs bytes but protects against a stream whose unit changes mid-way, for example after a firmware update switches from mg/dL to mmol/L. It is worth keeping.
The types are written for an in-memory TypeScript program, and v1.0 defines no wire format. The following rules make the records exchangeable without losing meaning. They are the book's recommendations, consistent with the general JSON guidance in the PHASE-1 template text.
"2026-01-15T08:30:00.000Z". JSON.stringify already writes a JavaScript Date this way, but JSON.parse returns a string, not a Date. A reader that compares validUntil with the current time must convert first, or it will be comparing text.NaN and Infinity become null, which is why the blank-control deviation above must be handled before serialization.μA with the Greek letter mu (U+03BC). Keyboards and many instruments produce the micro sign (U+00B5), which looks identical and compares unequal. Normalize to one form, or use an ASCII unit syntax such as UCUM's uA at system boundaries.glucose-oxidase, below-loq). Reject unknown values instead of storing them.POCTestResult.patient.id and TimeSeries.metadata.patientId should hold a pseudonym issued by the clinical system, never a name, date of birth or national identifier. Chapter 7 discusses security and privacy controls.calibrationId in a measurement, under a clearly named extension object so that a strict reader can ignore them.
The document below serializes one sensor, its calibration and one measurement, built from Appendix A data. Identifiers and timestamps are illustrative. Numeric values are those computed in this chapter, rounded for readability. The x-wia-book objects hold the book's recommended additions and are not part of the v1.0 types.
{
"sensor": {
"id": "BS-1768465800000-k3x9q2m7a",
"config": {
"type": "electrochemical",
"electrochemicalType": "amperometric",
"biorecognitionElement": "glucose-oxidase",
"targetAnalyte": "glucose",
"analyteCategory": "metabolite",
"workingElectrode": "platinum",
"referenceElectrode": "ag-agcl",
"appliedPotential": 0.6
},
"status": "idle",
"created": "2026-01-15T08:30:00.000Z",
"measurementCount": 1,
"lastMeasurement": "2026-01-15T09:05:00.000Z",
"lifetime": {
"expirationDate": "2026-07-14T08:30:00.000Z",
"maxMeasurements": 1000,
"storageConditions": "2-8°C, dry"
},
"performance": {
"lod": 0.0199,
"loq": 0.0604,
"dynamicRange": 3.70,
"concentrationUnit": "mM",
"responseTime": 23,
"measuredAt": "2026-01-15T08:50:00.000Z"
}
},
"calibration": {
"id": "CAL-1768466400000",
"type": "linear",
"points": [
{ "concentration": 0, "signal": 0.05, "signalUnit": "μA", "concentrationUnit": "mM" },
{ "concentration": 1, "signal": 1.20, "signalUnit": "μA", "concentrationUnit": "mM" },
{ "concentration": 5, "signal": 5.85, "signalUnit": "μA", "concentrationUnit": "mM" },
{ "concentration": 10, "signal": 11.50, "signalUnit": "μA", "concentrationUnit": "mM" },
{ "concentration": 50, "signal": 58.20, "signalUnit": "μA", "concentrationUnit": "mM" },
{ "concentration": 100, "signal": 115.80, "signalUnit": "μA", "concentrationUnit": "mM" }
],
"regression": { "slope": 1.1585, "intercept": 0.0474 },
"rSquared": 0.99999,
"sensitivity": 1.1585,
"sensitivityUnit": "μA/mM",
"linearRange": { "min": 1, "max": 100, "unit": "mM" },
"calibratedAt": "2026-01-15T08:40:00.000Z",
"validUntil": "2026-01-16T08:40:00.000Z",
"qualityControl": {
"passed": true,
"residuals": [0.003, -0.006, 0.010, -0.133, 0.226, -0.100],
"maxResidual": 0.226
},
"x-wia-book": { "sensorId": "BS-1768465800000-k3x9q2m7a" }
},
"measurement": {
"id": "M-1768467900000-p4t8wz",
"sensorId": "BS-1768465800000-k3x9q2m7a",
"concentration": 20.24,
"concentrationUnit": "mM",
"signal": 23.5,
"signalUnit": "μA",
"correctedSignal": 23.5,
"confidence": 0.95,
"standardError": 1.01,
"qualityFlags": ["ok"],
"withinRange": true,
"temperature": 25,
"timestamp": "2026-01-15T09:05:00.000Z",
"sample": { "id": "S-0001", "type": "serum" },
"x-wia-book": {
"calibrationId": "CAL-1768466400000",
"temperatureSource": "measured",
"temperatureCompensation": { "convention": "divide", "alphaPercentPerC": 3.0, "referenceC": 25 }
}
}
}
Reading the document from top to bottom retraces the chapter. The configuration says what the sensor is and against which reference its potential is set. lifetime holds SDK defaults that a real product would replace with lot data. responseTime is an illustrative input, because the SDK never measures it. The calibration shows the recomputed regression, not the appendix's printed digits, and its residuals reveal that the largest deviation sits at 50 mM, a pattern Chapter 6 examines as a linearity check. The measurement carries both signals, one flag, and heuristic confidence and standardError values that should not be presented as statistics. The extension objects close the two traceability gaps: the owner of the calibration and the calibration behind the measurement.
types.ts are the working data model, and the PHASE-1 file is template text with the wrong standard identifier.BiosensorConfig requires only type, biorecognitionElement and targetAnalyte. Enumerations are not checked at run time, some vocabularies overlap, and units live in comments.Biosensor holds only its current calibration. Status can be left stale after an error, lifetime values are SDK defaults, and maxMeasurements is not enforced.CalibrationData should have a type that matches its regression block, a linearRange read as "the span of the standards", replicates stored with each point, and an owner and history once persisted.MeasurementResult keeps both raw and corrected signal but does not identify its calibration or its correction convention. confidence and standardError are heuristics.QualityFlag values, the SDK emits five. ok is always present, and below-lod is overloaded. Consumers must interpret flags, not just read them.passed by range alone, fail numerically for blanks, and do not name the sensor. Batches and POC results are assembled by the application.
Chapter 5 turns from the records to the code that produces them: the BiosensorSDK methods, their exact computations and error codes, and the wia-bio-011 command-line tool.