Chapter 3
How WIA-BIO-019 turns a cryovial in a freezer into a record that software can store, exchange and score, and where the two quantitative metrics of v1.0 hold up or break down.
Chapter 2 described biospecimens as a laboratory scientist sees them: tubes of plasma, extracted DNA, snap-frozen tissue, living cells in liquid nitrogen. This chapter turns to the same material as a database sees it. A biobank that cannot say, without walking to the freezer, what a tube contains, whose it is, where it sits, how it has been treated and whether it is still fit for purpose, is only a cold room. WIA-BIO-019 answers those questions with a small set of typed records defined in the reference SDK (api/typescript/src/types.ts) and a wire format sketched in the API section of the specification (§9.1.2).
The chapter has two halves. Sections 3.1 to 3.4 walk through the record structure: the Sample itself, the de-identified DonorInfo, the StorageLocation hierarchy, Aliquot genealogy, identifiers and labels, and the QualityMetrics container. Sections 3.5 and 3.6 cover the two numbers the standard computes from those records, the Sample Integrity Score (SIS) and the Storage Quality Index (SQI). Section 3.7 closes with serialization rules. Throughout, the text separates what v1.0 actually says from what the reference implementation does, because the two are not always the same, and from the book's own commentary, which is labeled as such.
Sample
Every physical container a biobank tracks is represented by one Sample object. The interface is deliberately flat at the top level. Identity, type, amount, storage and status are plain fields, and richer information is pushed into nested objects (donor, location, quality). The table below lists every field as declared in types.ts, whether the SDK's registerSample() requires it, and what it means in practice.
| Field | Type | Required at registration | Meaning and notes |
|---|---|---|---|
id | string | Yes | Globally unique sample identifier. The SDK rejects an empty or duplicate ID with B001 INVALID_SAMPLE_ID. Format rules are in 3.3. |
type | SampleType | Yes | One of the 14 enum values introduced in Chapter 2 (for example plasma, rna, tissue_ffpe). |
donor | DonorInfo | Yes | De-identified donor block; see below. |
collectionDate | Date | No (defaults to "now") | Date and time of collection. Defaulting to the registration time is convenient but wrong for any sample registered after the fact; supply it explicitly. |
volume, volumeUnit | number, string | Yes | Remaining amount and its unit (mL, µL, mg). Aliquoting decrements volume on the parent. |
storageCondition | StorageCondition | Yes | The intended temperature tier (LN₂, −80 °C, −20 °C, 4 °C, ambient). |
location | StorageLocation | No (defaults to UNASSIGNED) | Physical position; see 3.2. |
status | SampleStatus | Set by SDK | Always initialized to collected; later transitions are the subject of Chapter 5. |
quality | QualityMetrics | No | Container for measured quality indicators; see 3.4. |
aliquots | Aliquot[] | Set by SDK | Initialized to an empty array; populated by createAliquot(). |
parentSampleId | string | No | Declared for samples derived from another sample. See the caveat in 3.2. |
riskGroup | RiskGroup | No | Biosafety risk group rg1–rg4; drives handling rules covered in Chapter 7. |
notes | string | No | Free-text handling notes. |
createdAt, updatedAt | Date | Set by SDK | System timestamps, distinct from the scientific collectionDate. |
DonorInfo carries exactly one mandatory field, donorId, which the type comment describes as "de-identified". Everything else is optional: age at collection, sex (male | female | other | unknown), ethnicity, collectionSite, medicalHistory[], medications[], fasting and consentId. The design follows the coded model of spec §8.3.1. The sample record holds a code, and the table that links that code to a named person lives in a separate, access-controlled database. Chapter 6 covers the governance of that linkage.
Two practical cautions apply. First, "optional" does not mean "harmless". Age, a collection site and a rare diagnosis in medicalHistory can together single out a person even when no name is present. A biobank should populate these fields only when a downstream use actually needs them, and should consider coarsening them, for example recording an age band instead of an exact age. Second, consentId is the only bridge from a sample to its consent record. When it is present, registerSample() looks the consent up and refuses registration with B005 CONSENT_EXPIRED if the stored consent is marked invalid. When it is absent, or refers to a consent the SDK instance has never seen, registration proceeds silently. That is a reason to treat consentId as mandatory in local policy even though the type does not.
The following object is a plasma sample registered in the SDK. The values are illustrative; the location and quality indices mirror the registration example in spec §9.1.2 so that the wire form in 3.7 can be compared field by field.
import {
Sample, SampleType, StorageCondition, SampleStatus, RiskGroup,
} from '@wia/bio-019';
const plasma: Sample = {
id: 'SAM-7K2Q9X', // random code, no date or sequence (§8.3.1)
type: SampleType.PLASMA, // enum value 'plasma'
donor: {
donorId: 'D-789012', // coded donor ID; linkage key held elsewhere
age: 54, // optional, include only if needed
sex: 'female',
fasting: true,
consentId: 'CNS-456789', // bridge to the Consent record (Chapter 6)
},
collectionDate: new Date('2025-12-26T08:30:00Z'),
volume: 10.5,
volumeUnit: 'mL',
storageCondition: StorageCondition.ULTRA_LOW_FREEZER, // -80 °C tier
location: {
freezer: 'F-01', rack: 'R-03', box: 'B-25', position: 'A5',
fullLocation: 'F-01/R-03/B-25/A5',
},
status: SampleStatus.STORED,
quality: {
blood: { hemolysisIndex: 25, totalProtein: 72 }, // spec threshold only, see 3.4
lastChecked: new Date('2025-12-26T10:40:00Z'),
},
aliquots: [],
riskGroup: RiskGroup.RG2, // example value
createdAt: new Date('2025-12-26T10:45:30Z'),
updatedAt: new Date('2025-12-26T10:45:30Z'),
};
Note what the record does not contain. It has no donor name, no hospital number and no free-text clinical narrative. It also holds no history. The Sample object is a snapshot of the current state, and the sequence of events that produced that state belongs to the chain of custody, which Chapter 5 builds as a separate hash-linked log.
StorageLocation and Aliquot Genealogy
Physical storage in almost every biobank is nested. A freezer or LN₂ tank holds racks (or, in tanks, canes and towers), each rack holds boxes, and each box is a grid of positions addressed by row letter and column number. StorageLocation models this directly with four fields, freezer (required), rack, box and position, plus an optional denormalized fullLocation string. The spec's API example uses the slash-joined form F-01/R-03/B-25/A5, which is what a technician reads off a pick list.
StorageLocation hierarchy. The highlighted path is the address stored in the example record.
Storing both the parts and the joined string is a classic denormalization, and it creates a classic hazard: the two can disagree. The SDK does not derive fullLocation from the parts, nor does it validate that they match. A sound implementation treats the four fields as the source of truth and computes fullLocation on output. It should also reject a location whose freezer is unknown, which is the purpose of error code B009 INVALID_LOCATION, although v1.0 of the SDK never actually raises it. When no location is supplied at registration, the SDK stores the placeholder { freezer: 'UNASSIGNED', fullLocation: 'UNASSIGNED' }. A query that looks for "unplaced" samples should search for that literal value.
v1.0 does not standardize position addressing. Row letter plus column number (A5) is common for 9×9 and 10×10 cryoboxes, while some sites number positions 1–81 or 1–100. Whichever convention a site uses, it belongs in the SOPs and must stay stable.
Aliquoting splits a parent sample into smaller portions so that each use thaws only what it needs. Chapter 2 explained why that matters for freeze-thaw damage. The data model records the split in two places:
aliquots[] array holds Aliquot objects with id, parentSampleId, volume, volumeUnit, location, createdDate and status.Sample may carry parentSampleId, which lets a derived sample (for example DNA extracted from buffy coat) point back to its source even when it is a first-class sample with its own type.
The SDK's createAliquot(parentSampleId, volume, aliquotId?) uses only the first mechanism. It checks that the parent has enough volume (otherwise B003 INSUFFICIENT_VOLUME), builds an ID of the form <parent>-A<n> if none is given, copies the parent's location, sets the aliquot's status to stored, subtracts the volume from the parent and writes an aliquoting custody event. Three consequences are worth knowing before building on it:
getSample('SAM-7K2Q9X-A1') fails with B002. An inventory that must query aliquots directly has to register them as samples too.registerSample() does not persist parentSampleId. The field is declared on Sample but not copied when the record is constructed. An implementer who relies on it for derived samples should set it through updateSample() after registration, or fix the constructor.
Genealogy matters beyond bookkeeping. When a donor withdraws consent, every descendant of every sample from that donor must be found (Chapter 6). When a parent is found to have been mishandled, its aliquots inherit the doubt. A single, queryable parent link, whether stored as parentSampleId on every record or as a separate edge table, is the minimum needed to answer "what came from this tube?".
Spec §8.3.1 sets the coding rule for sample identifiers: a random alphanumeric code of the form SAM-XXXXXX, with "no embedded information (date, sequence)", and donor-to-sample linkage kept in a separate database. The reasoning is privacy. A sequential number reveals collection order, and a date embedded in an ID can be joined with a clinic schedule to narrow down who a donor is. §3.3 adds that the ID must be globally unique.
The helper generateSampleId(prefix = 'SAM') returns `${prefix}-${Date.now()}-${random}`. The middle part is the millisecond Unix timestamp at the moment of generation, and the random suffix comes from Math.random(). That output contradicts §8.3.1 on both counts. It embeds a date, and because it is time-ordered, it effectively embeds sequence. Math.random() is also not a cryptographically secure generator, so the suffix should not be relied on to resist guessing. The book's recommendation is to keep the spec's rule and replace the helper:
import { randomBytes } from 'crypto';
// Book recommendation: spec §8.3.1-conformant ID.
// 8 characters from a 32-symbol alphabet without look-alikes (no I, L, O, U).
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
export function newSampleId(prefix = 'SAM'): string {
const bytes = randomBytes(8);
let body = '';
for (const b of bytes) body += ALPHABET[b & 31]; // 32 symbols, 5 bits each
return `${prefix}-${body}`; // e.g. SAM-7K2Q9XHD
}
// Uniqueness is still enforced by the registry (B001 on duplicates),
// never assumed from randomness alone.
Eight symbols from a 32-character alphabet give 240 (about 1.1 × 1012) possible codes. That is ample for a single biobank, but the registry must still reject collisions rather than assume they cannot happen. The spec's XXXXXX suggests six characters. Six symbols from the same alphabet give about 1.07 × 109 codes, which is also workable, but collisions become likely much sooner as a collection grows (the birthday effect). Sites that federate IDs across institutions should add a site prefix, since the spec requires global uniqueness.
Aliquot IDs produced by createAliquot() (SAM-7K2Q9X-A1, -A2, …) do carry a sequence, but only relative to the parent, and they reveal nothing about the donor or the collection date. That is consistent with the intent of §8.3.1.
§3.3 allows two symbologies, Code 128 (a linear, one-dimensional barcode) and Data Matrix (a two-dimensional matrix code). The choice is mostly about geometry. A linear barcode needs length along the tube wall and reads well with an ordinary handheld scanner. A Data Matrix symbol fits on the flat bottom or cap of a cryovial, which is what allows a rack reader to image an entire box of tubes at once. It also carries built-in error correction, so it survives partial frost or scratching better. Many biobanks print both, a Data Matrix on the base and a linear code plus human-readable text on the side.
| Item | Requirement | Implementation note |
|---|---|---|
| Symbology | Code 128 or Data Matrix | Data Matrix preferred for tube bottoms and rack scanning. |
| Sample ID | Globally unique | Encode exactly the id field; nothing else in the symbol. |
| Collection date | YYYY-MM-DD | Human-readable text on the label, not part of the ID. |
| Sample type, storage condition | Printed | Use the same vocabulary as the SampleType and StorageCondition enums. |
| Biohazard status | Printed | Derived from riskGroup. |
| Durability | Cryogenic-resistant (−196 °C), solvent-resistant, permanent adhesive | Qualify the label stock for the coldest tier the tube may reach, including LN₂ immersion if liquid-phase storage is used. |
QualityMetrics Container
QualityMetrics gathers every measured indicator of a sample's fitness into one optional object. It has two computed scores (integrityScore and storageQualityIndex, both nominally 0–1), five material-specific sub-objects, an excursion log and a timestamp. Only the sub-objects relevant to the sample type are expected to be filled in. A DNA sample has dna, a PBMC vial has cell, and a plasma tube has blood.
| Sub-object | Key fields | Scale or unit as declared | Spec reference values |
|---|---|---|---|
dna | concentration, a260_280, a260_230, din?, fragmentSize? | ng/µL; ratios; bp | A260/A280 1.8–2.0; A260/A230 2.0–2.2; fragments >10 kb (§6.3.1) |
rna | concentration, rin, a260_280, ratio_28s_18s? | ng/µL; RIN 1–10 | RIN ≥7 for RNA-seq and microarray, ≥6 for qRT-PCR; 28S/18S ≈2.0 (§6.3.2) |
protein | concentration, method, degradation, activity? | mg/mL or g/L; enum; activity 0–1 | At most 3 freeze-thaw cycles (§6.3.3) |
cell | cellCount, viability, viabilityMethod, postThawRecovery?, contamination? | cells/mL; viability 0–100 (%); recovery 0–1 | Viability >80%; recovery >70% (§6.3.4) |
blood | hemolysisIndex?, icterusIndex?, lipemiaIndex?, totalProtein?, clottingTime? | Analyzer-dependent indices; g/L | Hemolysis index below the spec's threshold of 50 (§2.1.1; units depend on the analyzer) |
temperatureExcursions[] | startTime, endTime, targetTemp, actualTempRange, duration, severity | °C; minutes; minor | moderate | severe | critical | Excursion handling in Chapter 5 |
lastChecked | Date | ISO 8601 on the wire | — |
The scales column shows the first practical trap. Within the same interface, CellQuality.viability is a percentage (0–100), while postThawRecovery and ProteinQuality.activity are fractions (0–1). The SIS function in the next section accepts only fractions. A pipeline that feeds a stored viability of 85 straight into it gets an exception rather than a score. Every conversion into SIS inputs should therefore pass through one explicit normalization function, and that function should be unit-tested.
The second trap is that the blood indices carry no unit field. Hemolysis, icterus and lipemia indices are reported on different scales by different analyzer platforms. The standard's threshold of 50 is only meaningful for the scale it was written against, which v1.0 does not name. A biobank should record the analyzer and method alongside the value, for example in notes or in an extension field, until the standard adds a proper unit.
Finally, temperatureExcursions[] makes the excursion history part of the quality record. The SDK's recordTemperatureExcursion() appends to this array and writes a quality_check custody event. That link matters for SIS. The standard's integrity formula uses a single temperature deviation, and the excursion log is where an implementer finds the data to choose that number, or to replace it with something better (3.5 and Chapter 8).
Spec §6.1.1 defines a single number between 0 and 1 meant to summarize how much a sample has deteriorated:
SIS = (Vt / V0) × (1 − ΔT / Tmax) × (1 − Δt / tmax)
Vt current viability or quality metric
V0 initial viability or quality metric
ΔT temperature deviation from optimal (°C)
Tmax maximum acceptable temperature deviation (°C)
Δt time since collection (days)
tmax maximum recommended storage duration (days)
Each factor answers a separate question. The quality factor Vt/V0 asks how much of the originally measured quality remains. The temperature factor asks how close the worst deviation came to the tolerated limit. The time factor asks how much of the recommended shelf life has been used up. Because the factors multiply, any one of them at zero forces the score to zero.
§6.1.2 gives five bands. The spec's wording ("0.8–0.9", "0.7–0.8") leaves the shared boundaries ambiguous. The SDK resolves them as shown in the right-hand column, and implementations should follow the SDK so that the same number never receives two verdicts.
| Band | Spec wording | SDK condition | SDK recommendation (abridged) |
|---|---|---|---|
excellent | > 0.9 | score > 0.9 | Suitable for all applications |
good | 0.8–0.9 | score >= 0.8 | Suitable for most applications |
acceptable | 0.7–0.8 | score >= 0.7 | Verify suitability for intended use |
marginal | 0.6–0.7 | score >= 0.6 | Use with caution; not for sensitive assays |
poor | < 0.6 | otherwise | Consider discarding or non-critical use only |
So a score of exactly 0.9 is good, not excellent, and exactly 0.6 is marginal. The SDK also guards its inputs. It throws B004 QUALITY_BELOW_THRESHOLD if either viability value lies outside 0–1, which is really a validation error reported under a quality code, and it clamps the temperature and time factors with Math.max(0, …), so a deviation or age beyond the maximum yields zero rather than a negative score. It does not clamp the quality factor or the result. If a re-measurement comes out slightly higher than the original, Vt/V0 exceeds 1, and so can the score. If V0 is zero, the division produces Infinity or NaN. A negative ΔT (a sample held colder than its set-point) also inflates the temperature factor above 1. Callers should clamp all three cases before relying on the output.
The first row reproduces the spec's own example from §6.1.3. The other two are illustrative cases constructed for this book, with shelf-life limits chosen from the spec's stated guidance (for example, "5 years at −80 °C" for RNA) and normalization made explicit.
| Case | V0 → Vt (normalized) | Quality factor | ΔT / Tmax | Temp. factor | Δt / tmax (days) | Time factor | SIS | Band |
|---|---|---|---|---|---|---|---|---|
| DNA, spec §6.1.3 | A260/A280 1.85 → 1.82, ÷2.0 → 0.925 → 0.910 | 0.984 | 3 / 10 | 0.700 | 1,825 / 18,250 | 0.900 | 0.620 | marginal |
| RNA (example) | RIN 9.0 → 8.2, ÷10 → 0.90 → 0.82 | 0.911 | 0 / 5 | 1.000 | 365 / 1,825 | 0.800 | 0.729 | acceptable |
| PBMC (example) | viability 92% → 85%, ÷100 → 0.92 → 0.85 | 0.924 | 2 / 10 | 0.800 | 730 / 3,650 | 0.800 | 0.591 | poor |
| Perfect sample, half shelf life | 1.00 → 1.00 | 1.000 | 0 / 10 | 1.000 | 9,125 / 18,250 | 0.500 | 0.500 | poor |
In the spec's DNA example the arithmetic is correct (0.984 × 0.7 × 0.9 ≈ 0.62), but the input cannot be passed to the SDK as written. An A260/A280 of 1.85 is greater than 1, so calculateSampleIntegrity() throws. Dividing both readings by the same constant leaves the ratio unchanged, which is why the normalized row gives the same score. The example also deserves a scientific caveat. A260/A280 is a purity ratio, not a degradation measure. Both 1.85 and 1.82 sit inside the spec's own acceptable range of 1.8–2.0, and a difference of 0.03 is within the ordinary repeatability of a spectrophotometer. Almost all of the "marginal" verdict comes from the temperature and time factors, not from any measured loss of quality. For DNA, an integrity measure such as DIN or fragment size would be a more meaningful V.
import { calculateSampleIntegrity } from '@wia/bio-019';
// Normalize before calling: the SDK accepts only 0..1.
const toFraction = (v: number, scale: number) => Math.min(1, Math.max(0, v / scale));
const r = calculateSampleIntegrity({
initialViability: toFraction(9.0, 10), // RIN 9.0
currentViability: toFraction(8.2, 10), // RIN 8.2
tempDeviation: 0,
maxTempDeviation: 5,
storageTime: 365, // days
maxStorageTime: 1825, // spec guidance for RNA at -80 °C
});
// r.score ≈ 0.729, r.interpretation === 'acceptable'
// r.details = { viabilityFactor: 0.911, temperatureFactor: 1, timeFactor: 0.8 }
SIS is useful as a transparent, reproducible triage number, since anyone can recompute it from the three stored inputs. Two structural properties limit how far it should be trusted.
The time factor is linear in shelf life. The last row of the table makes the point. A sample that has lost no measurable quality and never left its set-point scores 0.5, "poor", once it reaches half of tmax. With the spec's own DNA example (tmax = 50 years), a flawless sample drops out of excellent after 5 years, and after 20 years it reaches the marginal/poor boundary without any measured change. Real degradation at cryogenic temperatures is not a linear countdown. The time factor effectively penalizes age as such, which can push a biobank toward discarding perfectly good material. Where a fresh quality measurement exists, the quality factor already captures actual change. A defensible local policy is to report the three factors separately and base keep-or-discard decisions on the measured factor, using time only as a trigger for re-testing.
ΔT is a single number. The formula uses one temperature deviation, with no duration attached. A freezer that drifted 3 °C for five minutes during a door opening and one that sat 3 °C warm for a whole weekend produce the same factor. The temperatureExcursions[] log already holds start and end times and the temperature range, so the data to weight excursions by duration is there. v1.0 simply does not use it. Chapter 8 sketches a time-weighted alternative as a proposal for v1.x.
One smaller point also matters. The spec defines Δt as "time since collection", while the SDK's parameter is named storageTime. For samples that spent days in transit or processing before being frozen, the two differ. Pick one definition, document it, and apply it consistently.
§6.2.1 introduces a second metric meant to estimate how much a sample's quality should have declined purely from time spent at a given storage temperature. It is presented as an application of the Arrhenius equation, which relates the rate of a chemical reaction to absolute temperature:
SQI = exp(−k × t × exp(Ea / RT)) (spec §6.2.1, as written)
k degradation rate constant (1/year, sample-specific)
t storage time (years)
Ea activation energy (kJ/mol)
R gas constant, 8.314 J/(mol·K)
T storage temperature (K)
The same expression is implemented in the SDK's calculateStorageQuality() and in the CLI's storage-quality command. The sample-specific parameters come from §6.2.2 and BIO_CONSTANTS.DEGRADATION_PARAMS:
| Spec row | SDK key | k (1/year) | Ea (kJ/mol) | "Half-life at −80 °C" as listed | ln 2 / k |
|---|---|---|---|---|---|
| DNA | dna | 0.001 | 80 | >100 years | ≈ 693 years |
| RNA | rna | 0.05 | 60 | 14 years | ≈ 13.9 years |
| Proteins | plasma | 0.02 | 70 | 35 years | ≈ 34.7 years |
| Cells (frozen) | pbmc | 0.005 | 90 | 140 years | ≈ 138.6 years |
The last two columns show where the "half-life" figures come from. Each is simply ln 2 divided by k, rounded. The activation energy and temperature play no part. The figures therefore say nothing specific about −80 °C. The RNA value of 14 years also conflicts with the spec's own statement in §2.2.2 that RNA is stable for 5 years at −80 °C. They should not be quoted as shelf lives. Note too that the spec's "Proteins" row is keyed to SampleType.PLASMA in the SDK, and that sample types without an entry fall back to k = 0.01 and Ea = 70 on a code path that skips the final clamp to [0, 1].
In the Arrhenius equation the rate constant is k = A·exp(−Ea/RT). The exponent is negative, so the rate falls as temperature falls. The v1.0 formula uses exp(+Ea/RT), and the sign error does not merely skew the result. It overwhelms it. For DNA at −80 °C (T = 193 K), Ea/RT = 80,000 / (8.314 × 193) ≈ 49.9, and exp(49.9) ≈ 4.5 × 1021. Multiplied by k = 0.001 and one year, the outer exponent is about −4.5 × 1018, and exp of that is zero in any floating-point arithmetic. Colder is worse: at 77 K the inner factor is about 1.9 × 1054. Even for a storage time of one day, the result is still 0. The formula returns exactly 1 only at t = 0 and 0 for every positive storage time at every temperature in the table. Any SQI value produced by v1.0 tooling should therefore be disregarded.
The standard Arrhenius way to use a rate measured at one temperature to predict another is to scale it relative to a reference temperature:
k(T) = k_ref × exp( −(Ea / R) × (1/T − 1/T_ref) )
SQI(t) = exp( −k(T) × t )
At T = T_ref the scaling factor is 1. Above the reference temperature the rate increases, and below it the rate decreases, which is the behavior the standard intends. The spec does not say at which temperature its k values apply. The sketch below assumes they refer to −80 °C (193.15 K), since the parameter table is labeled "at −80 °C". This is an assumption of the book, not a statement of the standard.
const R = 8.314; // J/(mol·K)
// v1.0 as written (spec §6.2.1, SDK calculateStorageQuality, CLI storage-quality)
function sqiAsWritten(k: number, EaKJ: number, tYears: number, T: number): number {
return Math.exp(-k * tYears * Math.exp((EaKJ * 1000) / (R * T)));
}
// Book commentary, NOT normative: reference-temperature Arrhenius scaling
function sqiArrhenius(kRef: number, EaKJ: number, tYears: number,
T: number, TRef = 193.15): number {
const kT = kRef * Math.exp(-(EaKJ * 1000) / R * (1 / T - 1 / TRef));
return Math.exp(-kT * tYears);
}
sqiAsWritten(0.001, 80, 1, 193); // 0 (inner exp ≈ 4.5e21)
sqiAsWritten(0.001, 80, 1 / 365, 193); // 0 (even after one day)
sqiArrhenius(0.001, 80, 10, 193.15); // 0.990 DNA, 10 y at −80 °C
sqiArrhenius(0.001, 80, 10, 198.15); // 0.966 DNA, 10 y at −75 °C
sqiArrhenius(0.001, 80, 10, 203.15); // 0.890 DNA, 10 y at −70 °C
Applied to all four parameter sets, the corrected form gives the ten-year values below. They are computed from the v1.0 parameters, which are not traceable to any cited measurement. The numbers show how the model behaves, not how real samples behave.
| SDK key | −80 °C | −75 °C | −70 °C | −150 °C and colder |
|---|---|---|---|---|
dna (Ea 80) | 0.990 | 0.966 | 0.890 | 1.000 |
rna (Ea 60) | 0.607 | 0.277 | 0.043 | 1.000 |
plasma (Ea 70) | 0.819 | 0.548 | 0.181 | 1.000 |
pbmc (Ea 90) | 0.951 | 0.814 | 0.454 | 1.000 |
Two lessons follow. First, with activation energies in this range, a few degrees matter a great deal. Moving from −80 °C to −70 °C multiplies the DNA rate constant by roughly 12 and the PBMC rate by roughly 16. That is why set-point decisions and excursion handling are worth the attention Chapters 5 and 8 give them. Second, the model breaks down when extrapolated far from its reference. At −20 °C the corrected form drives every sample type to essentially zero within a year, including DNA, which the spec itself describes as stable for decades at that temperature. That is not a paradox. Arrhenius scaling assumes one reaction mechanism operating in one physical state, while frozen biological material passes through different regimes (freeze-concentrated liquid, glassy states, crystalline ice) as temperature changes. Parameters fitted in one regime cannot be carried across into another. A v1.x revision should publish the reference temperature, cite the source of each k and Ea, and state the temperature range over which they are valid.
Practical rule for implementers. Do not store or display SQI values computed with the v1.0 formula. If a storage-based estimate is needed, implement the reference-temperature form, label it as a local, non-normative model, keep it within the temperature range its parameters were derived for, and never let it override a fresh quality measurement.
§9.2 assigns a format to each kind of exchange:
| Format | Spec purpose | Practical guidance |
|---|---|---|
| JSON | API exchanges | snake_case field names as in §9.1.2; UTF-8; one object per sample. |
| CSV / TSV | Bulk data | One row per sample or aliquot; flatten location into four columns plus full_location; keep a separate unit column for every quantity. |
| XML | HL7 messages | XML applies to HL7 v3/CDA and to FHIR's XML form. HL7 v2 messages are normally pipe-delimited, and FHIR also has a JSON form. Mapping to FHIR resources is covered in Chapter 6. |
| Reports and labels | Print layouts only. Never a data-exchange channel. |
The API in §9.1.2 uses snake_case names and a flatter shape than the SDK. The same plasma sample from 3.1, expressed as the register request body of §9.1.2, looks like this:
{
"sample_id": "SAM-7K2Q9X",
"type": "blood_plasma",
"donor_id": "D-789012",
"collection_date": "2025-12-26T08:30:00Z",
"volume": 10.5,
"volume_unit": "mL",
"storage_condition": "minus_80",
"location": {
"freezer": "F-01",
"rack": "R-03",
"box": "B-25",
"position": "A5"
},
"quality": {
"hemolysis_index": 25,
"total_protein": 72
},
"consent_id": "CNS-456789"
}
Laid side by side with the SDK object, this reveals several mappings that v1.0 leaves implicit. They have to be written down explicitly in any real integration:
| SDK (camelCase) | Wire (spec §9.1.2) | Difference |
|---|---|---|
id | sample_id | Renamed. |
type: 'plasma' | "type": "blood_plasma" | Different vocabulary. The spec's example token is not an SDK enum value. |
donor.donorId | donor_id | Flattened out of the donor block. |
donor.consentId | consent_id | Flattened and moved to the top level. |
storageCondition: 'ultra_low_freezer' | "storage_condition": "minus_80" | Different vocabulary. |
quality.blood.hemolysisIndex | quality.hemolysis_index | Nesting removed. |
location.fullLocation | (absent in request; "location": "F-01/R-03/B-25/A5" string in inventory responses) | Same key, different type across endpoints. |
v1.0 shows only two wire tokens for the enums (blood_plasma, minus_80) and defines no complete wire vocabulary. The pragmatic course is for each implementation to publish a mapping table for all 14 sample types and 5 storage conditions and to accept both forms on input during a transition period. The lowest-risk choice is to use the SDK enum values on the wire, because they already exist as a closed set. Whatever the choice, it must be fixed in the API description and not left to each client. Chapter 4 returns to this when it walks through the endpoints. Note also that the inventory example in the same section of the spec contains "total_count": 2,450, which is not valid JSON. A number on the wire has no thousands separator.
Two rules prevent most serialization defects:
volume is never sent without volume_unit. Temperatures in the model are in °C, except for the SQI input storageTemp, which is in kelvin. Use celsiusToKelvin() (which adds 273.15) rather than the rounded constants in BIO_CONSTANTS.STORAGE_TEMPS (193 for −80 °C). If a controlled unit vocabulary is wanted, UCUM, which HL7 FHIR also uses, is a reasonable choice, but it is the book's suggestion, not a v1.0 requirement.Z suffix (2025-12-26T08:30:00Z). JavaScript's Date serializes with milliseconds (…T08:30:00.000Z). Both are valid ISO 8601, but they are different strings, which matters as soon as a record is hashed (Chapter 5). Date-only values such as the inventory's "collection_date": "2025-12-26" lose the time of collection, and with it the ability to check time-to-processing limits. Send full timestamps where the time is known.Sample is a current-state snapshot. Identity, type, amount, storage tier, location, status and quality are in the record, while history belongs to the chain of custody.DonorInfo is coded, not anonymous. Minimize optional fields, and treat consentId as mandatory by local policy.StorageLocation is a four-level hierarchy. Derive fullLocation from the parts. SDK aliquots copy the parent's location and are not stored as samples.generateSampleId() embeds a timestamp and should be replaced.generateSampleId() fall short of that rule?calculateSampleIntegrity(), and what does the SDK do if it is passed unchanged?tmax can a sample with no measured quality loss and no temperature deviation no longer be rated excellent?Sample object and the §9.1.2 register request body, and explain why each one needs an explicit mapping rule.