Chapter 3

Chapter 3: The Data Model and Integrity Metrics — Sample, Aliquot, SIS and SQI

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.

3.1 The Core Record: 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.

Fields of the Sample interface (WIA-BIO-019 reference SDK v1.0.0)
FieldTypeRequired at registrationMeaning and notes
idstringYesGlobally unique sample identifier. The SDK rejects an empty or duplicate ID with B001 INVALID_SAMPLE_ID. Format rules are in 3.3.
typeSampleTypeYesOne of the 14 enum values introduced in Chapter 2 (for example plasma, rna, tissue_ffpe).
donorDonorInfoYesDe-identified donor block; see below.
collectionDateDateNo (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, volumeUnitnumber, stringYesRemaining amount and its unit (mL, µL, mg). Aliquoting decrements volume on the parent.
storageConditionStorageConditionYesThe intended temperature tier (LN₂, −80 °C, −20 °C, 4 °C, ambient).
locationStorageLocationNo (defaults to UNASSIGNED)Physical position; see 3.2.
statusSampleStatusSet by SDKAlways initialized to collected; later transitions are the subject of Chapter 5.
qualityQualityMetricsNoContainer for measured quality indicators; see 3.4.
aliquotsAliquot[]Set by SDKInitialized to an empty array; populated by createAliquot().
parentSampleIdstringNoDeclared for samples derived from another sample. See the caveat in 3.2.
riskGroupRiskGroupNoBiosafety risk group rg1–rg4; drives handling rules covered in Chapter 7.
notesstringNoFree-text handling notes.
createdAt, updatedAtDateSet by SDKSystem timestamps, distinct from the scientific collectionDate.

Keeping the donor block de-identified

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.

An annotated example

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.

3.2 Where a Sample Lives: StorageLocation and Aliquot Genealogy

The four-level location hierarchy

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.

Freezer F-01 Rack R-01 Rack R-02 Rack R-03 Box B-24 Box B-25 Pos A5 fullLocation = "F-01/R-03/B-25/A5"
Figure 3-1. The 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.

Aliquots and parent–child genealogy

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:

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:

  1. Aliquots are not samples in the SDK's store. They live only inside the parent's array, so getSample('SAM-7K2Q9X-A1') fails with B002. An inventory that must query aliquots directly has to register them as samples too.
  2. The copied location is a placeholder. Two tubes cannot occupy position A5 in the same box. The aliquot's real position must be written back after it is racked.
  3. 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?".

3.3 Identifiers and Labels

What the specification requires

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.

What the reference SDK generates

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.

Barcodes and cryogenic labels

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

Label content and durability requirements (spec §3.3)
ItemRequirementImplementation note
SymbologyCode 128 or Data MatrixData Matrix preferred for tube bottoms and rack scanning.
Sample IDGlobally uniqueEncode exactly the id field; nothing else in the symbol.
Collection dateYYYY-MM-DDHuman-readable text on the label, not part of the ID.
Sample type, storage conditionPrintedUse the same vocabulary as the SampleType and StorageCondition enums.
Biohazard statusPrintedDerived from riskGroup.
DurabilityCryogenic-resistant (−196 °C), solvent-resistant, permanent adhesiveQualify the label stock for the coldest tier the tube may reach, including LN₂ immersion if liquid-phase storage is used.

3.4 The 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-objects of QualityMetrics and their scales
Sub-objectKey fieldsScale or unit as declaredSpec reference values
dnaconcentration, a260_280, a260_230, din?, fragmentSize?ng/µL; ratios; bpA260/A280 1.8–2.0; A260/A230 2.0–2.2; fragments >10 kb (§6.3.1)
rnaconcentration, rin, a260_280, ratio_28s_18s?ng/µL; RIN 1–10RIN ≥7 for RNA-seq and microarray, ≥6 for qRT-PCR; 28S/18S ≈2.0 (§6.3.2)
proteinconcentration, method, degradation, activity?mg/mL or g/L; enum; activity 0–1At most 3 freeze-thaw cycles (§6.3.3)
cellcellCount, viability, viabilityMethod, postThawRecovery?, contamination?cells/mL; viability 0–100 (%); recovery 0–1Viability >80%; recovery >70% (§6.3.4)
bloodhemolysisIndex?, icterusIndex?, lipemiaIndex?, totalProtein?, clottingTime?Analyzer-dependent indices; g/LHemolysis 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 | criticalExcursion handling in Chapter 5
lastCheckedDateISO 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).

3.5 The Sample Integrity Score (SIS)

The formula

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.

Interpretation bands and the SDK's boundaries

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

SIS interpretation bands (spec §6.1.2) and exact boundaries in calculateSampleIntegrity()
BandSpec wordingSDK conditionSDK recommendation (abridged)
excellent> 0.9score > 0.9Suitable for all applications
good0.8–0.9score >= 0.8Suitable for most applications
acceptable0.7–0.8score >= 0.7Verify suitability for intended use
marginal0.6–0.7score >= 0.6Use with caution; not for sensitive assays
poor< 0.6otherwiseConsider 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.

Worked examples

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.

SIS worked examples (values rounded to three decimals)
CaseV0 → Vt (normalized)Quality factorΔT / TmaxTemp. factorΔt / tmax (days)Time factorSISBand
DNA, spec §6.1.3A260/A280 1.85 → 1.82, ÷2.0 → 0.925 → 0.9100.9843 / 100.7001,825 / 18,2500.9000.620marginal
RNA (example)RIN 9.0 → 8.2, ÷10 → 0.90 → 0.820.9110 / 51.000365 / 1,8250.8000.729acceptable
PBMC (example)viability 92% → 85%, ÷100 → 0.92 → 0.850.9242 / 100.800730 / 3,6500.8000.591poor
Perfect sample, half shelf life1.00 → 1.001.0000 / 101.0009,125 / 18,2500.5000.500poor

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 }

What the score can and cannot tell you

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.

3.6 The Storage Quality Index (SQI) and the Arrhenius Equation

The formula as written in v1.0

§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:

SQI parameters in v1.0 (spec §6.2.2 and BIO_CONSTANTS)
Spec rowSDK keyk (1/year)Ea (kJ/mol)"Half-life at −80 °C" as listedln 2 / k
DNAdna0.00180>100 years≈ 693 years
RNArna0.056014 years≈ 13.9 years
Proteinsplasma0.027035 years≈ 34.7 years
Cells (frozen)pbmc0.00590140 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].

Why the as-written formula always returns zero

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.

A physically consistent form (book commentary, not normative)

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.

Ten-year SQI from the corrected form, assuming k_ref at −80 °C (illustrative; v1.0 parameters)
SDK key−80 °C−75 °C−70 °C−150 °C and colder
dna (Ea 80)0.9900.9660.8901.000
rna (Ea 60)0.6070.2770.0431.000
plasma (Ea 70)0.8190.5480.1811.000
pbmc (Ea 90)0.9510.8140.4541.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.

3.7 Serialization: From Objects to Files and Messages

Formats by purpose

§9.2 assigns a format to each kind of exchange:

Data formats in spec §9.2 and how to apply them
FormatSpec purposePractical guidance
JSONAPI exchangessnake_case field names as in §9.1.2; UTF-8; one object per sample.
CSV / TSVBulk dataOne row per sample or aliquot; flatten location into four columns plus full_location; keep a separate unit column for every quantity.
XMLHL7 messagesXML 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.
PDFReports and labelsPrint layouts only. Never a data-exchange channel.

The wire form of the example sample

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 object paths versus v1.0 wire names (register request)
SDK (camelCase)Wire (spec §9.1.2)Difference
idsample_idRenamed.
type: 'plasma'"type": "blood_plasma"Different vocabulary. The spec's example token is not an SDK enum value.
donor.donorIddonor_idFlattened out of the donor block.
donor.consentIdconsent_idFlattened and moved to the top level.
storageCondition: 'ultra_low_freezer'"storage_condition": "minus_80"Different vocabulary.
quality.blood.hemolysisIndexquality.hemolysis_indexNesting 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.

Units and time

Two rules prevent most serialization defects:

3.8 Summary

Review Questions

  1. Why does spec §8.3.1 forbid dates and sequence numbers inside sample IDs, and in what two ways does the SDK's generateSampleId() fall short of that rule?
  2. A PBMC vial has a stored viability of 88. What must happen to that value before it can be passed to calculateSampleIntegrity(), and what does the SDK do if it is passed unchanged?
  3. Using the SIS formula, after what fraction of tmax can a sample with no measured quality loss and no temperature deviation no longer be rated excellent?
  4. Show numerically why the v1.0 SQI formula returns zero for RNA stored one year at −80 °C, and write the reference-temperature form that corrects it.
  5. List three differences between the SDK Sample object and the §9.1.2 register request body, and explain why each one needs an explicit mapping rule.