Chapter 3
The records that carry a biomarker result from the analyser to the validation study: the measurement envelope, reference ranges, multi-analyte definitions, study and report records, the omics file formats they point to, and the clinical-exchange forms they must survive.
Chapter 2 treated a biomarker result as a number that is either above or below a cutoff. In practice that number never travels alone. It arrives with a unit, a reference interval, a specimen type, a collection time, an assay, a laboratory and a set of warnings about the specimen itself. If any of those companions is lost or silently changed on the way from the instrument to the analysis, the sensitivity and specificity computed later describe a different experiment from the one that was run. The purpose of the WIA-BIO-004 data formats is to keep the number and its context together.
This chapter walks through each record the standard defines, compares it with the matching type in the @wia/bio-004 TypeScript SDK, and shows how the same result is expressed in the formats a hospital or a trial sponsor already uses: LOINC and UCUM, HL7 FHIR, CDISC SDTM and GA4GH Phenopackets. Where the specification, its phase annexes and the SDK disagree, the disagreement is named and a working convention is recommended. Those recommendations are this book's editorial choices, not normative text.
Every exchange record in §7 of the specification begins with two envelope fields: "standard": "WIA-BIO-004" and "version": "1.0.0". They look trivial, but they are the first thing a receiving system should check. The standard identifier tells a router which parser to use when several WIA formats share one message queue, and the semantic version tells the parser which field set to expect. A receiver that meets an unknown major version should reject the record rather than guess; an unknown minor version can be accepted if the unknown fields are preserved and ignored (versioning rules are discussed in Chapter 8).
Beneath the envelope, a measurement record (§7.1) has three blocks with deliberately different lifetimes. measurement describes one analytical result and never changes once released. patient describes the person at the time of collection and is pseudonymised. clinicalContext describes why the test was ordered and what is known about the person's disease state; it is the block most likely to be updated later, for example when a diagnosis is confirmed.
The specification's own example is a high-sensitivity C-reactive protein (CRP) result. It is reproduced here because every later mapping in this chapter starts from it:
{
"standard": "WIA-BIO-004",
"version": "1.0.0",
"measurement": {
"biomarkerID": "BIO-12345",
"biomarkerName": "C-Reactive Protein",
"biomarkerType": "protein",
"value": 12.5,
"unit": "mg/L",
"referenceRange": { "min": 0, "max": 10, "unit": "mg/L" },
"measurementMethod": "High-sensitivity immunoturbidimetric assay",
"laboratoryID": "LAB-789",
"dateCollected": "2025-01-15T08:30:00Z",
"dateMeasured": "2025-01-15T10:45:00Z",
"sampleType": "blood_serum",
"qualityFlags": { "hemolyzed": false, "lipemic": false, "icteric": false }
},
"patient": {
"patientID": "PATIENT-456",
"age": 55,
"sex": "male",
"fastingStatus": true,
"medications": ["aspirin", "metformin"]
},
"clinicalContext": {
"indication": "Cardiovascular risk assessment",
"diseaseStatus": null,
"priorHistory": "Type 2 diabetes"
}
}
Several design decisions are visible in this short record. The value is a JSON number, not a string, so that a consumer never has to parse "12,5" or ">10". The unit sits next to the value and is repeated inside referenceRange; the duplication is intentional, because a reference interval copied from another laboratory may be in different units and the record must make that visible rather than hide it. The biomarker is identified twice: biomarkerID is a registry key that software compares, while biomarkerName is a label for people and must never be used as a join key. Finally, biomarkerType is the modality axis introduced in Chapter 1 (protein, genetic, metabolic, imaging, cellular); the clinical role of the result, such as diagnostic or prognostic, is a property of the study that uses it and does not belong in a single measurement.
Both timestamps are ISO 8601 date-times in UTC with the Z designator. Storing local time without an offset is the most common reason why "time to processing" cannot be reconstructed afterwards, and processing delay is one of the pre-analytical variables Chapter 5 asks us to control. dateCollected is when the specimen left the patient; dateMeasured is when the analyser produced the value. In the example the gap is two hours and fifteen minutes, which a quality rule can compare with the processing limit the laboratory has adopted.
patientID is a pseudonymous study or laboratory identifier, never a medical-record number or national identifier; how it is generated and protected is the subject of Chapter 7. The age is recorded in whole years, which is sufficient for reference-range partitioning and less identifying than a date of birth.
Unknown is not the same as negative. In §7.1, clinicalContext.diseaseStatus is null, meaning that when the test was run nobody knew whether this person had the condition of interest. A converter must never turn that null into false: doing so would move an unclassified sample into the control group of a later validation study and bias its specificity. The SDK's DiseaseStatus type has an explicit 'unknown' member for the same purpose, and this book maps JSON null to 'unknown' and back.
Free text is the enemy of pooled analysis. A study that receives "serum", "Serum", "SST" and "blood (serum)" from four sites has four specimen types until someone writes a mapping table. WIA-BIO-004 therefore fixes a small number of enumerations. The values below are those defined in §3.1.1 of the specification and in the SDK's types.ts; they are identical between the two for specimen type.
| Field | Allowed values | Notes |
|---|---|---|
sampleType | blood_serum, blood_plasma, whole_blood, urine, tissue_biopsy, cerebrospinal_fluid, saliva, stool | Serum and plasma are separate values because many analytes differ between them; the anticoagulant for plasma is not encoded and belongs in the collection details. |
MeasurementMethod (SDK) | ELISA, mass_spectrometry, PCR, sequencing, immunoassay, clinical_chemistry, flow_cytometry, imaging, other | A method family, not an assay. The specific kit or platform goes in free text. |
DiseaseStatus (SDK) | diseased, healthy, at_risk, unknown | §7.1 uses null for unknown; treat it as unknown. |
BiomarkerType | protein, genetic, metabolic, imaging, cellular | Modality only; not diagnostic/prognostic role. |
One mismatch deserves attention. In §7.1 the field is called measurementMethod and holds a descriptive string ("High-sensitivity immunoturbidimetric assay"), while the SDK's BiomarkerMeasurement calls it method and restricts it to the nine-value enumeration. Both pieces of information are useful: the enumeration allows grouping across sites, and the description distinguishes, for example, a high-sensitivity CRP assay from a conventional one. A practical convention is to keep the descriptive string in the exchange record and derive the enumerated family during import, here immunoassay, storing the original text in metadata so nothing is lost.
The qualityFlags object records the condition of the specimen, not the correctness of the assay. §7.1 defines three booleans for the classic serum-index interferences; the SDK's QualityFlags interface adds four more members.
| Flag | Defined in | Meaning | Typical effect |
|---|---|---|---|
hemolyzed | §7.1, SDK | Red-cell lysis released haemoglobin and intracellular content | Falsely raised potassium, LDH and similar; spectral interference in photometric assays |
lipemic | §7.1, SDK | Turbidity from high lipoprotein content | Light scattering in turbidimetric and photometric methods; volume displacement |
icteric | §7.1, SDK | High bilirubin | Spectral and chemical interference in some assays |
clotted | SDK only | Clot in a sample that should be anticoagulated | Consumption of platelets and clotting factors; aspiration errors |
diluted | SDK only | Sample was diluted before analysis | Result must be multiplied back; flags a value above the measuring range |
outOfRange | SDK only | Value outside the assay's reportable range | Value is censored rather than exact |
other | SDK only | Array of free-text issues | Reviewed by a person before inclusion |
A flag set to false means "checked and not present"; an absent flag means "not checked". The difference matters when a laboratory's serum-index measurement is switched off: an analysis that treats absent as false will quietly include specimens nobody inspected. Whether a flagged specimen is excluded or merely annotated is a study decision, and section 3.8 turns it into an explicit rule.
A reference interval is itself a dataset with a population behind it (Chapter 5 describes how it is established). §3.3.2 defines a stand-alone record that lists intervals partitioned by age group and sex. The specification's example uses age-specific PSA intervals:
{
"biomarker": "PSA",
"unit": "ng/mL",
"referenceRanges": [
{ "ageGroup": "40-49", "sex": "male", "lowerLimit": 0.0, "upperLimit": 2.5, "median": 0.7 },
{ "ageGroup": "50-59", "sex": "male", "lowerLimit": 0.0, "upperLimit": 3.5, "median": 1.0 }
]
}
The SDK does not model a list of partitions. Its ReferenceRange interface describes one interval, with different field names and one additional attribute:
| §3.3.2 record | SDK ReferenceRange | §7.1 inline range | Comment |
|---|---|---|---|
lowerLimit | min | min | Same meaning; rename on import |
upperLimit | max | max | Same meaning |
unit (record level) | unit (per interval) | unit | Copy the record-level unit into every interval |
median | median? | — | Optional in the SDK |
ageGroup | ageGroup? | — | String such as "40-49"; parse bounds explicitly |
sex | sex?: male | female | all | — | Use all for unpartitioned intervals |
| — | population? | — | Describes the reference population; recommended |
Two practical points follow. First, when a measurement is created, the importer must choose the partition that matches the patient's age and sex and copy it into the measurement's inline referenceRange, so that the measurement remains interpretable even if the reference-range table is later revised. Second, the ageGroup string is ambiguous at the edges ("40-49" is presumably inclusive of 49 years and 364 days, but the format does not say so). The book recommends interpreting it as inclusive whole years and rejecting overlapping partitions at load time. The sex vocabularies also differ: PatientSample.sex allows other, ReferenceRange.sex allows all. A patient recorded as other should be matched to an all interval if one exists, and otherwise flagged for review rather than forced into a sex-specific interval.
A single analyte can be described by biomarkerID, a name and a modality. Many modern tests, however, combine several analytes into a score. The data-format annex (PHASE-1) defines a richer biomarker definition for that case, with blocks for the indication, the analytes, the scoring algorithm, validation evidence, the specimen and regulatory status. The annex illustrates it with a commercial breast-cancer assay, but the weights, formula and performance figures in that example could not be verified and are not reproduced here. The structure is shown instead with a deliberately fictional three-analyte score:
{
"biomarkerId": "BM-EXAMPLE-CS3",
"name": "Example Composite Score 3 (fictional)",
"version": "1.0",
"type": "diagnostic",
"indication": {
"disease": "Colon cancer (illustrative use)",
"icd10": "C18",
"population": "Adults referred for colonoscopy"
},
"analytes": [
{ "id": "PROT-A", "type": "protein_concentration", "method": "immunoassay", "unit": "ng/mL", "weight": 0.6 },
{ "id": "PROT-B", "type": "protein_concentration", "method": "immunoassay", "unit": "U/mL", "weight": 0.3 },
{ "id": "MET-C", "type": "metabolite_level", "method": "mass_spectrometry", "unit": "umol/L", "weight": 0.1 }
],
"algorithm": {
"type": "weighted_sum",
"formula": "score = 0.6*z(PROT-A) + 0.3*z(PROT-B) + 0.1*z(MET-C), rescaled to 0-100",
"scoreRange": [0, 100],
"interpretation": {
"low": { "range": [0, 39] },
"intermediate": { "range": [40, 69] },
"high": { "range": [70, 100] }
}
},
"validation": {
"analyticalValidation": { "reference": "see validation-study records" },
"clinicalValidation": { "studyIDs": ["VALID-EXAMPLE-001"] }
},
"specimen": { "type": "blood_plasma", "storage": "-80 C until analysis" },
"regulatoryStatus": { "intendedUse": "research use only" }
}
All numbers in this record are placeholders chosen for readability. Three structural features are worth copying into real definitions.
Note also that the annex's type field holds the clinical role (prognostic in the annex, diagnostic above), whereas biomarkerType in §7.1 holds the modality. The names are confusingly similar. This book reads PHASE-1 type strictly as role and, for single-modality panels, recommends recording the modality alongside it; a mixed panel such as the one above simply has no single modality. Interpretation bands should carry only the cutoffs that the validation evidence supports; the example deliberately omits any "risk" percentages, because those can only come from a calibrated outcome study.
§7.2 defines how a completed validation study is exchanged. Its top-level structure is:
{
"standard": "WIA-BIO-004",
"version": "1.0.0",
"studyID": "...",
"studyType": "...",
"biomarker": { "id": "...", "name": "...", "type": "protein" },
"population": { "totalParticipants": 0, "cases": 0, "controls": 0,
"ageRange": [0, 0], "sexDistribution": { }, "ethnicity": { } },
"referenceStandard": { "method": "...", "sensitivity": 100, "specificity": 100 },
"results": {
"rocAnalysis": { "auc": 0, "aucCI95": [0, 0], "optimalThreshold": { } },
"performanceMetrics": { "sensitivity": 0, "specificity": 0, "ppv": 0, "npv": 0, ... },
"subgroupAnalysis": [ { "subgroup": "...", "n": 0, "auc": 0 } ]
},
"validation": {
"crossValidation": "10-fold",
"externalValidation": true,
"validationCohorts": [ { "cohort": "...", "n": 0, "auc": 0 } ]
}
}
The record answers, in order, the questions a reviewer asks of any diagnostic-accuracy study: who was studied, against what reference standard, with what discrimination and at which threshold, whether the result holds in subgroups, and whether it was reproduced outside the development data. Chapter 6 fills this skeleton with a complete example and checks its arithmetic; here the concern is the shape and three conventions that follow from it.
DiagnosticPerformance also uses percent, but its ROC points (ROCPoint.sensitivity) are fractions between 0 and 1. A field name alone therefore does not tell you the scale; the record type does. Converters should normalise at the boundary and never mix the two in one object.unit inside optimalThreshold. A cutoff without a unit is not reusable.§5.3.1 defines a shorter report for publishing the headline performance of a marker. The specification's example states prevalence 0.50, sensitivity 82.0 % and specificity 88.0 %, but gives PPV 86.0 and NPV 84.5, which do not follow from those inputs. At a prevalence of 50 %, PPV = 0.82 / (0.82 + 0.12) = 87.2 % and NPV = 0.88 / (0.88 + 0.18) = 83.0 %. The corrected example is:
{
"biomarker": "Example Biomarker",
"studyPopulation": { "cases": 150, "controls": 150, "prevalence": 0.50 },
"diagnosticPerformance": {
"auc": 0.85,
"aucCI95": [0.80, 0.90],
"optimalThreshold": 125.5,
"sensitivity": 82.0,
"sensitivityCI95": [75.0, 88.0],
"specificity": 88.0,
"specificityCI95": [82.0, 93.0],
"ppv": 87.2,
"npv": 83.0,
"lrPositive": 6.83,
"lrNegative": 0.20,
"accuracy": 85.0
}
}
Values other than PPV and NPV are left as the specification gives them and are illustrative. The lesson is general: any record that stores both the inputs (sensitivity, specificity, prevalence) and derived quantities (PPV, NPV, likelihood ratios, accuracy) can be checked automatically, and a validator should do so with a tolerance of a rounding step. Note also that optimalThreshold here is a bare number, whereas §7.2 wraps it in an object with a unit; a report should add the unit even though the example omits it.
The SDK offers two related types, and neither is a one-to-one image of §7.2.
ValidationStudy holds the raw material of a study: population is an array of PatientSample objects, measurements is an array of BiomarkerMeasurement, results is a full ValidationResult from validateBiomarker, and cross-validation is an object with method, folds, averageAUC and aucStdDev rather than the string "10-fold". External cohorts appear as externalValidation.cohorts[] with AUC, sensitivity and specificity per cohort.BiomarkerReport holds the summary: its population is an aggregate object (totalParticipants, cases, controls, optional demographics), which is the same shape as §7.2's population.
So a §7.2 document is best understood as a serialised BiomarkerReport plus the study-design fields of ValidationStudy, without the patient-level arrays. That is sensible for exchange: patient-level data travel under separate access controls (Chapter 7), while the summary can be shared widely.
studyType vocabularies| Source | Values seen | Book's mapping to the SDK enum |
|---|---|---|
| Main spec §7.2 | diagnostic_validation | validation |
| API annex (PHASE-2) | clinical_validation | validation |
SDK ValidationStudy | discovery, validation, clinical_utility, multi_center | Use as is |
The book recommends the SDK enumeration because it is the only one that is machine-checked. It has one weakness: multi_center describes the design of a study, not its stage in the life cycle of Chapter 1, so a multi-centre clinical-validation study has to choose between two correct labels. Until the vocabularies are reconciled (listed as an open issue in Chapter 8), record the stage in studyType and the number of centres in the population description.
biomarkerID is a registry key inside a WIA deployment; it means nothing to a hospital information system. For exchange, each measurable biomarker should also carry a LOINC code, which identifies the observation (what was measured, in which specimen, on which scale, and sometimes by which method), and each unit should be a UCUM expression. LOINC codes are specific: CRP in serum or plasma measured as a mass concentration has a different code from high-sensitivity CRP, and choosing the wrong one merges results that should not be pooled. Codes that this book has checked and uses in examples are PSA 2857-1, cardiac troponin I 10839-9, HbA1c 4548-4, and high-sensitivity CRP 30522-7. Any other code should be looked up in the current LOINC release rather than copied from an example, including those listed in the annex.
UCUM matters because unit strings that look identical to a person are different to software. UCUM is case-sensitive and has its own spellings: "μg/mL" written with a Greek mu must be expressed as ug/mL; and "IU/L" in UCUM is [IU]/L. A WIA importer should store the UCUM code exactly and, when two results for the same LOINC code arrive in convertible units, convert to one canonical unit before any statistics are run.
Genetic biomarkers are exchanged as variants, and PHASE-1 points to VCF version 4.3 for them. The annex's example uses real variant identifiers that do not match the alleles shown, so a neutral illustration is used here, with invented coordinates and no rsIDs:
##fileformat=VCFv4.3
##INFO=<ID=AF,Number=A,Type=Float,Description="Allele frequency in a reference population">
##INFO=<ID=CLINSIG,Number=.,Type=String,Description="Clinical significance (source stated in header)">
##INFO=<ID=GENE,Number=1,Type=String,Description="Gene symbol">
#CHROM POS ID REF ALT QUAL FILTER INFO
chr1 1000001 . C T 99 PASS AF=0.0002;CLINSIG=Pathogenic;GENE=GENE_X
chr2 2000002 . A AG 99 PASS AF=0.0100;CLINSIG=Uncertain_significance;GENE=GENE_Y
AF is a reserved INFO key in the VCF specification; CLINSIG and GENE are not reserved and must therefore be declared in the header, as shown. The WIA measurement record should not duplicate the VCF. Instead, a genetic biomarker result refers to the variant (file, genome build, chromosome, position, reference and alternate allele) and records the called genotype or the derived biomarker status. The genome build is not optional: the same position means different bases in different assemblies.
Mass-spectrometry data are exchanged in the HUPO-PSI mzML format, in which every property of a spectrum is a cvParam from the PSI-MS controlled vocabulary. Three accessions from the annex example are enough to read most files: MS:1000511 (ms level), MS:1000514 (m/z array) and MS:1000515 (intensity array); retention time appears as MS:1000016 (scan start time) with a unit from the Unit Ontology. The binary arrays are base64-encoded and often compressed, so they are never copied into WIA JSON. The WIA record stores the quantified result (a concentration with a UCUM unit) and a pointer to the mzML file and spectrum identifiers from which it was derived.
Metabolomic studies frequently use ISA-Tab, a set of tab-separated files: an investigation file (i_), which declares ontology sources; study files (s_), one row per sample; and assay files (a_), one row per measurement. A single-row sketch looks like this:
a_assay.txt (illustrative values)
Sample Name Protocol REF Metabolite Identifier Concentration Unit
SAMPLE_001 LC-MS/MS HMDB0000122 5.4 mmol/L
HMDB0000122 is the Human Metabolome Database entry for D-glucose; a ChEBI identifier can be used in the same way. When importing, each ISA-Tab row becomes one WIA measurement with biomarkerType: "metabolic", method: "mass_spectrometry", the metabolite identifier kept as a cross-reference, and the sample name resolved to a pseudonymous patientID.
The annex's format-priority table summarises the division of labour: JSON or FHIR for biomarker definitions, VCF/BAM for genomic data, mzML/mzIdentML for proteomics, mzML/ISA-Tab for metabolomics, and FHIR or CDISC for clinical data. The pattern behind it is that WIA JSON carries results and their context, while the domain formats carry raw evidence, and the two are linked by reference.
A biomarker result that stays inside a research database is of limited use. It has to reach an electronic health record, a trial submission or a rare-disease registry, each with its own model. The table below maps every field of the §7.1 record to the SDK, to HL7 FHIR R4 and to CDISC SDTM. In the SDTM column, -- stands for the two-letter domain prefix; laboratory-type results normally use LB, and the annex example uses a custom BM prefix (see below).
| WIA JSON (§7.1) | SDK property | FHIR R4 | SDTM |
|---|---|---|---|
measurement.biomarkerID | BiomarkerMeasurement.biomarkerID | Observation.code (local coding plus LOINC) | --TESTCD; LOINC in --LOINC where the IG version defines it |
biomarkerName | Biomarker.name | code.text | --TEST |
biomarkerType | Biomarker.type | no direct element (profile or category) | --CAT (sponsor-defined) |
value | value | valueQuantity.value | --ORRES, --STRESN |
unit | unit | valueQuantity.unit/code (UCUM system) | --ORRESU, --STRESU |
referenceRange.min/max | ReferenceRange.min/max | referenceRange.low/high | --ORNRLO, --ORNRHI |
measurementMethod | method (enum) | Observation.method | --METHOD |
laboratoryID | laboratoryID | performer (Organization) | --NAM |
dateCollected | dateCollected | effectiveDateTime | --DTC |
dateMeasured | dateMeasured | no exact element; issued is release time | supplemental qualifier |
sampleType | sampleType | specimen → Specimen.type | --SPEC |
qualityFlags | qualityFlags | Specimen.condition or Observation.note | --SPCCND |
patient.patientID | patientID | subject (Patient) | USUBJID |
patient.age, sex | PatientSample.age, sex | Patient.gender; age derived | DM.AGE, DM.SEX |
patient.fastingStatus | PatientSample.fastingStatus | profile-specific (e.g. a component) | --FAST |
clinicalContext.diseaseStatus | PatientSample.diseaseStatus | separate Condition resource | separate domain (e.g. MH) |
Two rows are approximations and should be documented in any interface specification. FHIR's issued is the time the result was made available, which is often later than the moment of analysis; if the difference matters to a study, carry dateMeasured in an extension. And disease status is not a property of an observation in either FHIR or SDTM; it is a diagnosis recorded elsewhere and linked by patient and time.
Applied to the §7.1 record, the mapping gives the following resource. Because the value 12.5 mg/L lies above the inline upper limit of 10 mg/L, the converter derives the interpretation H; the WIA record itself carries no interpretation field, so the rule "compare value with the inline range" must be part of the converter and not left to the receiving system.
{
"resourceType": "Observation",
"id": "bio-12345-patient-456-20250115",
"status": "final",
"category": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/observation-category",
"code": "laboratory"
}]
}],
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "30522-7",
"display": "C reactive protein [Mass/volume] in Serum or Plasma by High sensitivity method"
}],
"text": "C-Reactive Protein"
},
"subject": { "reference": "Patient/PATIENT-456" },
"effectiveDateTime": "2025-01-15T08:30:00Z",
"issued": "2025-01-15T10:45:00Z",
"performer": [{ "reference": "Organization/LAB-789" }],
"valueQuantity": {
"value": 12.5, "unit": "mg/L",
"system": "http://unitsofmeasure.org", "code": "mg/L"
},
"interpretation": [{
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
"code": "H", "display": "High"
}]
}],
"method": { "text": "High-sensitivity immunoturbidimetric assay" },
"specimen": { "reference": "Specimen/serum-PATIENT-456-20250115" },
"referenceRange": [{
"low": { "value": 0, "unit": "mg/L", "system": "http://unitsofmeasure.org", "code": "mg/L" },
"high": { "value": 10, "unit": "mg/L", "system": "http://unitsofmeasure.org", "code": "mg/L" }
}]
}
In this example issued is set from dateMeasured on the assumption that the laboratory released the result immediately; a real interface should take it from the release event. The resource id is built deterministically from the biomarker, the pseudonymous patient and the collection date so that re-sending the same result updates rather than duplicates it; Chapter 7 obtains the same idempotency with a stable business identifier and a conditional create. The negative quality flags are not sent: FHIR has no need to state that a specimen was not haemolysed, but a positive flag should become a Specimen.condition entry or a note so the clinician sees it.
For clinical-trial submissions the same result becomes one row in a findings domain. The annex example uses a BM prefix with variables such as BMTESTCD, BMORRES, BMSTRESN, BMSTRESU, VISIT and BMDTC. Before adopting a custom two-letter code, check it against the current SDTM Implementation Guide and controlled terminology, because domain codes are reserved; in many submissions a soluble protein measured by a central laboratory simply belongs in LB, and genomic findings have their own domain. The variables below are shown with the LB prefix:
STUDYID USUBJID LBTESTCD LBTEST LBORRES LBORRESU LBORNRLO LBORNRHI LBSTRESN LBSTRESU LBNRIND LBSPEC LBFAST LBDTC LBLOINC
EX-001 EX-001-PAT-456 CRP C-Reactive Protein 12.5 mg/L 0 10 12.5 mg/L HIGH SERUM Y 2025-01-15T08:30:00 30522-7
Note how SDTM separates the result as originally reported (--ORRES, a character value with its unit) from the standardised numeric result (--STRESN, in the sponsor's standard unit). The WIA record carries only one value and one unit, so the conversion to a standard unit, if any, is the step that fills --STRESN. SDTM date-times are ISO 8601 but are usually written without a UTC designator; the converter must decide whether to convert to local study time and document that choice.
Phenopackets (version 2) describe an individual for rare-disease and genomic research, and a biomarker result appears as an entry in measurements[]:
{
"assay": { "id": "LOINC:30522-7", "label": "C reactive protein [Mass/volume] in Serum or Plasma by High sensitivity method" },
"value": {
"quantity": {
"unit": { "id": "UCUM:mg/L", "label": "milligram per liter" },
"value": 12.5,
"referenceRange": {
"unit": { "id": "UCUM:mg/L", "label": "milligram per liter" },
"low": 0, "high": 10
}
}
},
"timeObserved": { "timestamp": "2025-01-15T08:30:00Z" }
}
The phenopacket uses CURIE-style identifiers (LOINC:, UCUM:), so the same LOINC code and UCUM unit chosen for FHIR are reused unchanged. This is the practical payoff of section 3.6: once the test and unit are coded correctly in the WIA record, every downstream format is a mechanical transformation.
A format is only as good as the checks applied to it at the boundary. The main specification describes the records but does not publish a machine-readable schema with a list of required fields, and the SDK's validateBiomarker checks sample size and class balance but does not inspect units, reference ranges or quality flags. The ingestion layer must therefore do that work. The rules below are the minimum this book recommends; the error codes are taken from the SDK's BiomarkerErrorCode set, whose use for record-level validation is a suggestion rather than a requirement of v1.0.
| Rule | Check | On failure | Suggested code |
|---|---|---|---|
| R1 Envelope | standard equals WIA-BIO-004; major version of version is supported | Reject | — |
| R2 Required fields | biomarkerID, value (finite number), unit, sampleType, dateCollected present | Reject | B002 |
| R3 Vocabulary | sampleType, biomarkerType and derived method family are enumeration members | Reject or map | B002 |
| R4 Unit consistency | referenceRange.unit equals unit, or a known UCUM conversion exists | Convert, else reject | B003 |
| R5 Range sanity | min ≤ max | Reject | B003 |
| R6 Time order | dateCollected ≤ dateMeasured; both UTC | Reject | B002 |
| R7 Specimen quality | Any positive flag in qualityFlags; any non-empty other | Exclude or annotate per study rule | B008 |
| R8 Unknown status | diseaseStatus is null or unknown | Keep, but exclude from case/control sets | B009 if the study needs it |
| R9 Derived metrics | PPV/NPV/LR/accuracy recomputable from the stated inputs within rounding | Reject report | B010 |
The following sketch implements rules R2, R4, R6 and R7 for a §7.1 record. It is deliberately small and makes no attempt at UCUM conversion, which in production would be delegated to a UCUM library.
type Issue = { rule: string; code: string; message: string };
export function checkMeasurement(rec: any, excludeFlagged = true): Issue[] {
const issues: Issue[] = [];
const m = rec?.measurement ?? {};
// R2: required fields
for (const f of ['biomarkerID', 'unit', 'sampleType', 'dateCollected']) {
if (m[f] === undefined || m[f] === '') {
issues.push({ rule: 'R2', code: 'B002', message: `missing ${f}` });
}
}
if (typeof m.value !== 'number' || !Number.isFinite(m.value)) {
issues.push({ rule: 'R2', code: 'B002', message: 'value must be a finite number' });
}
// R4: unit consistency (exact match; conversion handled elsewhere)
const rr = m.referenceRange;
if (rr && rr.unit !== m.unit) {
issues.push({ rule: 'R4', code: 'B003',
message: `range unit ${rr.unit} differs from value unit ${m.unit}` });
}
// R6: collection must not be after measurement
const tc = Date.parse(m.dateCollected);
const tm = Date.parse(m.dateMeasured);
if (!Number.isNaN(tc) && !Number.isNaN(tm) && tc > tm) {
issues.push({ rule: 'R6', code: 'B002', message: 'dateMeasured precedes dateCollected' });
}
// R7: specimen quality flags
const q = m.qualityFlags ?? {};
const raised = Object.entries(q)
.filter(([k, v]) => v === true || (k === 'other' && Array.isArray(v) && v.length > 0))
.map(([k]) => k);
if (raised.length > 0 && excludeFlagged) {
issues.push({ rule: 'R7', code: 'B008', message: `flagged specimen: ${raised.join(', ')}` });
}
return issues;
}
Run against the CRP record of section 3.1, the function returns an empty list: the required fields are present, the units match, collection precedes measurement by 2 h 15 min, and all three flags are false. Changing referenceRange.unit to "mg/dL" produces one R4 issue. That is exactly the situation in which a naive numeric comparison misclassifies the result, because a limit of 10 mg/dL is 100 mg/L. Chapter 8 turns such mutations into negative test vectors.
Two further points complete the picture. Rule R7 is parameterised because the right action depends on the analyte: haemolysis invalidates a potassium result but may be irrelevant to a genetic test run on the same draw. The study protocol should state, per biomarker, which flags exclude and which annotate, and the decision should be recorded with the result so that the analysis can be repeated. And rule R9 is the one most often skipped, even though it is the cheapest: the correction to §5.3.1 in section 3.5 was found by exactly this recomputation.
sampleType, method family, DiseaseStatus, BiomarkerType) make pooling possible; null disease status means unknown and must never become false.lowerLimit/upperLimit partitions; the SDK uses single min/max intervals. Choose the matching partition at import and copy it into the measurement.studyType enumeration.