@cosyte/astm 0.0.21 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +411 -0
- package/README.md +558 -223
- package/dist/index.cjs +309 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1054 -177
- package/dist/index.d.ts +1054 -177
- package/dist/index.mjs +301 -65
- package/dist/index.mjs.map +1 -1
- package/package.json +58 -31
package/README.md
CHANGED
|
@@ -7,77 +7,229 @@
|
|
|
7
7
|
|
|
8
8
|
# @cosyte/astm
|
|
9
9
|
|
|
10
|
-
> ASTM
|
|
11
|
-
|
|
10
|
+
> Read a real analyzer's ASTM traffic in one line, and never get a confident wrong value back.
|
|
11
|
+
|
|
12
|
+
[](https://www.npmjs.com/package/@cosyte/astm)
|
|
13
|
+
[](https://github.com/cosyte/astm/actions/workflows/ci.yml)
|
|
14
|
+
[](https://github.com/cosyte/astm/blob/main/LICENSE)
|
|
15
|
+
[](https://nodejs.org)
|
|
16
|
+
|
|
17
|
+
ASTM parser, serializer, and builder for Node.js and TypeScript: lenient on parse, spec-clean on emit.
|
|
18
|
+
|
|
19
|
+
- [Why this exists](#why-this-exists)
|
|
20
|
+
- [Status](#status)
|
|
21
|
+
- [Install](#install)
|
|
22
|
+
- [Usage](#usage)
|
|
23
|
+
- [PHI and safety](#phi-and-safety)
|
|
24
|
+
- [API](#api)
|
|
25
|
+
- [Compatibility](#compatibility)
|
|
26
|
+
- [Contributing](#contributing)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
## Why this exists
|
|
30
|
+
|
|
31
|
+
Lab integration teams receive ASTM off analyzers that each read the standard a little differently, and the usual answer is a hand-rolled split on `|` and `^` that hands back a confidently wrong value the first time a vendor escapes a delimiter or redeclares the set mid-stream. `@cosyte/astm` removes that: it reads the delimiters each header declares, decodes escape sequences before it splits a value, keeps the practice- and laboratory-assigned patient IDs distinct, and reports every deviation it tolerated as a stable, value-free warning. The nearest alternatives are the open-source ASTM codecs, `python-astm` and `senaite.astm`, which are Python and which hardcode the canonical `|\^&` delimiter set. This is a typed, zero-dependency Node.js package built on the rule those do not enforce: a deviation it cannot resolve is surfaced to you rather than resolved on your behalf, so it does not hand back a value the bytes do not carry.
|
|
32
|
+
|
|
33
|
+
## Status
|
|
34
|
+
|
|
35
|
+
`0.1.0`. The public API is settled and is stable enough to depend on. The record, framing, transport, emit, profile and terminology layers are all shipped, and their exported names, options and return shapes are what a consumer builds against. Warning and error codes are part of that surface: consumers branch on `w.code`, so renaming one is a breaking change and is treated as one.
|
|
36
|
+
|
|
37
|
+
Two public surfaces are still moving, named here rather than left to be found:
|
|
38
|
+
|
|
39
|
+
- **The contents of the built-in profile registry.** `defineAstmProfile()`, the safety gate and the registry API are settled; what ships today is `default` plus the corpus-grounded `referenceCorpus`. Named per-vendor profiles are deliberately withheld until a public, vendor-attributed quirk document grounds them, so that set will grow.
|
|
40
|
+
- **The warning and error code sets.** A code is added when a deviation is measured that nothing reported before, and a new code is safety-critical by default, so a profile that tolerated the old reports does not tolerate the new one. Adding a code is additive, though it can change which streams a `{ strict: true }` parse refuses; renaming one is breaking and is treated as breaking.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Requires Node.js >=22.0.0. Ships dual ESM + CJS, with zero runtime dependencies.
|
|
46
|
+
pnpm add @cosyte/astm
|
|
47
|
+
# or
|
|
48
|
+
npm install @cosyte/astm
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **Node.js `>=22.0.0`.** That floor is `package.json` `engines.node`; nothing below it is supported.
|
|
52
|
+
- **Dual ESM and CJS.** `import` resolves to `dist/index.mjs` and `require` to `dist/index.cjs`, each with its own type declarations, so the package loads from either module system without an interop shim.
|
|
53
|
+
- **Zero runtime dependencies.** `dependencies` is empty and the package imports no Node built-in.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
Hand a de-framed record stream to `parseAstmRecords` and read the result in one line. Every value below is synthetic.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { parseAstmRecords, patient, results } from "@cosyte/astm";
|
|
61
|
+
|
|
62
|
+
// Synthetic ASTM records, CR-delimited. The header declares the delimiters.
|
|
63
|
+
const stream =
|
|
64
|
+
"H|\\^&|||analyzer^1|||||||P|LIS02-A2|20240115103000\r" +
|
|
65
|
+
"P|1|PRACTICE-0001|LAB-0002|||||F\r" +
|
|
66
|
+
"O|1|SPEC-7|ACC-42|^^^687|||20240115102500\r" +
|
|
67
|
+
"R|1|^^^687|28.6|U/L|10-40|N||F\r" +
|
|
68
|
+
"L|1|N\r";
|
|
69
|
+
|
|
70
|
+
const msg = parseAstmRecords(stream);
|
|
71
|
+
const [first] = results(msg);
|
|
72
|
+
|
|
73
|
+
console.log(first?.value, first?.units);
|
|
74
|
+
console.log(first?.status.meaning, first?.status.isActiveFinal);
|
|
75
|
+
console.log(first?.flag?.meaning);
|
|
76
|
+
console.log(first?.universalTestId?.localCode);
|
|
77
|
+
console.log(patient(msg)?.practiceAssignedId, patient(msg)?.laboratoryAssignedId);
|
|
78
|
+
console.log(msg.warnings.length);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
28.6 U/L
|
|
83
|
+
final true
|
|
84
|
+
normal
|
|
85
|
+
687
|
|
86
|
+
PRACTICE-0001 LAB-0002
|
|
87
|
+
0
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Six things are worth reading off that output. The value and the units are surfaced raw, never converted and never defaulted. `status.isActiveFinal` is `true` only for a plain `F`, so a correction (`C`) or a cancellation (`X`) can never read as an active final result. An unrecognized abnormal flag reads `undefined`, never `normal`. The identifier a result is keyed on is the vendor local code in the Universal Test ID's fourth component. The practice- and laboratory-assigned patient IDs stay distinct, because collapsing them is the primary result-misfiling path. And `warnings` is empty here only because this stream is spec-clean: on vendor-quirky input it fills with stable, value-free codes instead of throwing, which is what the rest of this file is about.
|
|
91
|
+
|
|
92
|
+
That block is executed on every test run and its output is asserted against the text beside it (`test/readme-usage.test.ts`), so an example that drifts from the package fails the build rather than misleading a reader.
|
|
93
|
+
|
|
94
|
+
## PHI and safety
|
|
95
|
+
|
|
96
|
+
ASTM instrument traffic carries PHI. This section says what the library does with it and, where a guarantee cannot be grounded in the code, states the limit instead.
|
|
97
|
+
|
|
98
|
+
**What the library does not do.** It never logs: there is no logger, no `console` call and no log sink anywhere in `src/`. It never writes to disk and never opens a network connection; it imports no Node built-in and declares zero runtime dependencies. It retains nothing across calls: `parseAstmRecords` returns a deep-frozen model, and the only module-level state in the package is which vendor profile is selected as the default, which is configuration rather than data. It owns no socket: `ltpReduce` is a pure reducer that returns the actions to take, and you write the bytes.
|
|
99
|
+
|
|
100
|
+
**What can reach a log through this library.** Every warning and every fatal error raised on stream content carries a stable code plus a position (the record's ordinal index, its type letter, and the 1-based field and component indices) and never a field value, so one can be logged verbatim without leaking a name, an identifier or a result value. Three messages in the package do quote a value back, and all three quote an argument you passed rather than anything read off the wire: an out-of-range `startFrameNumber`, and the options object and the profile name handed to `defineAstmProfile()`. That is the bound, stated rather than rounded up into a claim that no message ever quotes anything.
|
|
101
|
+
|
|
102
|
+
**What you still own.** The parsed model holds the patient data you handed it, in your process, for as long as you keep it, so retention, encryption at rest, transport security, access control and audit are all yours. So is anything you print: a value-free warning does not make `JSON.stringify(msg)` safe, and the raw line the parser preserves verbatim on a deviation is the patient's data. The library also never redacts, masks or de-identifies. It surfaces what arrived.
|
|
103
|
+
|
|
104
|
+
**One limit about this repository rather than about your data.** The committed PHI scan (`pnpm phi-scan`) is a starter. It enforces a cross-cutting SSN and non-test-email floor plus a check on the `P` record's name components, and structured field-level detection for address, phone and comment free text is not implemented here. It guards this repository's own fixtures, tests and examples; it makes no claim about a stream you feed the library. Every record in this file and in the test suite is synthetic.
|
|
105
|
+
|
|
106
|
+
## API
|
|
12
107
|
|
|
13
108
|
`@cosyte/astm` is a zero-dependency TypeScript toolkit that follows the cosyte parser archetype: a lenient
|
|
14
109
|
parser that turns real-world, vendor-quirky input into **warnings** rather than failures, paired with
|
|
15
110
|
a serializer that always emits spec-clean output (Postel's Law). It mirrors the API shape of the
|
|
16
111
|
reference parser, [`@cosyte/hl7`](https://github.com/cosyte/hl7).
|
|
17
112
|
|
|
18
|
-
|
|
19
|
-
> feature-complete, but the public surface can still change within `0.0.x`, before `0.1.0`.
|
|
113
|
+
Every export carries JSDoc that compiles into `dist/index.d.ts`, so the full signature reference is what your editor already shows on hover; the source is at [github.com/cosyte/astm/tree/main/src](https://github.com/cosyte/astm/tree/main/src). What follows is the shape of each layer and the caveats that bite.
|
|
20
114
|
|
|
21
|
-
|
|
115
|
+
### Parse records
|
|
22
116
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
component. Result semantics are modeled and fail-safe: HL7 Table 0078 abnormal flags, result status
|
|
26
|
-
(a correction `C` or cancel `X` never reads as active-final), reference ranges kept verbatim, and a
|
|
27
|
-
missing unit flagged rather than defaulted. The practice-, laboratory-, and third patient IDs stay
|
|
28
|
-
distinct; a `C` comment attaches to its parent by position and an orphan is surfaced, never dropped;
|
|
29
|
-
a partial timestamp is preserved and flagged, never zero-filled. A `Q`-bearing message is classified
|
|
30
|
-
as a host query and is never read as a result set, and `M`/`S` vendor QC and calibration records are
|
|
31
|
-
surfaced verbatim rather than interpreted into clinical fields.
|
|
32
|
-
- **Framing and transport (E1381 / LIS01-A2).** `decodeAstmFrames` turns a framed byte stream into
|
|
33
|
-
frames plus reassembled record bytes: it verifies the modulo-256 checksum (a bad frame is surfaced
|
|
34
|
-
untrusted and never merged), tracks frame-number sequencing (a gap is never silently bridged), and
|
|
35
|
-
reassembles the 240-byte-limited multi-frame records. `detectFraming` routes framed streams (serial,
|
|
36
|
-
cobas 4800, Iguana) from raw ones (cobas b121, framing dropped), and `ltpReduce` is a pure,
|
|
37
|
-
socket-free `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine that NAKs a frame the codec did not vouch
|
|
38
|
-
for rather than fabricating an ACK. `parseFramedAstm` composes both layers at the edge.
|
|
39
|
-
- **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
|
|
40
|
-
delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
|
|
41
|
-
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
42
|
-
construction, and a delimiter set that fails any of the three conditions readback requires (one
|
|
43
|
-
character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
|
|
44
|
-
and lost. Those three read the set alone, so each record is additionally checked against the set it
|
|
45
|
-
is written with: a separator equal to a record's own type letter escapes that letter away and the
|
|
46
|
-
record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
|
|
47
|
-
output. That second check is about **transcoding**, not about which set you asked for, so it can
|
|
48
|
-
fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
|
|
49
|
-
whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
|
|
50
|
-
reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
|
|
51
|
-
byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
|
|
52
|
-
character encoding for you, and it never quietly writes a different character than the one you gave
|
|
53
|
-
it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
|
|
54
|
-
record is a typed error as well, in either form: those three are what the decoder reads as the shape
|
|
55
|
-
of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
|
|
56
|
-
that byte, silently losing a whole record whenever the two bytes after it happened to be the short
|
|
57
|
-
frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
|
|
58
|
-
wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
|
|
59
|
-
compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
|
|
60
|
-
record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
|
|
61
|
-
`7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
|
|
62
|
-
the **default** start; a non-default one writes a continuation of a sequence already in progress,
|
|
63
|
-
and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
|
|
64
|
-
about and not emitted.
|
|
65
|
-
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
66
|
-
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
67
|
-
ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
|
|
68
|
-
status, range, or units warning, any patient or comment context, any message-kind ambiguity, any
|
|
69
|
-
unrecognized record type, and any frame or transport integrity warning. The gate runs when a
|
|
70
|
-
profile is defined **and** again when a warning would be downgraded, so a profile assembled as a
|
|
71
|
-
plain object rather than through `defineAstmProfile()` gets the same answer. A profile can never
|
|
72
|
-
make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says a
|
|
73
|
-
message boundary went unrecognized. Named per-vendor profiles await a public, vendor-attributed
|
|
74
|
-
quirk document.
|
|
75
|
-
- **Terminology, bring your own.** `applyLivd(msg, catalog)` maps an analyzer's local test code to a
|
|
76
|
-
LOINC from a consumer-supplied IICC LIVD catalog as an additive, advisory annotation that never
|
|
77
|
-
mutates the raw code or value and never guesses a LOINC. No LOINC, SNOMED, or LIVD dictionary is
|
|
78
|
-
bundled: the package stays a structural recognizer and you bring the catalog.
|
|
117
|
+
```ts
|
|
118
|
+
import { parseAstmRecords, results, patient } from "@cosyte/astm";
|
|
79
119
|
|
|
80
|
-
|
|
120
|
+
// A de-framed ASTM record stream (CR-delimited records; the header declares the delimiters).
|
|
121
|
+
const msg = parseAstmRecords(raw);
|
|
122
|
+
|
|
123
|
+
results(msg)[0]?.value; // the measured value, surfaced raw
|
|
124
|
+
results(msg)[0]?.units; // vendor free-text units (a missing unit is a warning, never a default)
|
|
125
|
+
patient(msg)?.practiceAssignedId; // kept distinct from laboratoryAssignedId (the misfiling guard)
|
|
126
|
+
msg.warnings; // stable, value-free positional tolerance warnings (never throws on quirks)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The parser is **lenient by default** (vendor quirks become warnings, not failures) and refuses to
|
|
130
|
+
produce a confident wrong value: an embedded escaped delimiter reads as one component, an unknown
|
|
131
|
+
record type is surfaced (never dropped), and a missing unit is flagged (never defaulted). A
|
|
132
|
+
`{ strict: true }` mode escalates every tolerated deviation to a thrown error.
|
|
133
|
+
|
|
134
|
+
### Several messages in one stream
|
|
135
|
+
|
|
136
|
+
A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
|
|
137
|
+
splits a parsed stream into them, and each entry carries only its own records, so a patient is only
|
|
138
|
+
ever paired with the results that message actually carried:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { parseAstmRecords, messages } from "@cosyte/astm";
|
|
142
|
+
|
|
143
|
+
for (const m of messages(parseAstmRecords(raw))) {
|
|
144
|
+
m.patient?.practiceAssignedId; // the P for THIS message
|
|
145
|
+
m.results; // the Rs for THIS message
|
|
146
|
+
m.delimiters; // the set THIS message's records were read with
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
|
|
151
|
+
so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
|
|
152
|
+
across patients:
|
|
153
|
+
|
|
154
|
+
- `ASTM_AMBIGUOUS_MULTI_MESSAGE` from any of the five, when the stream carries more than one message.
|
|
155
|
+
- `ASTM_AMBIGUOUS_MULTI_PATIENT` from `patient()` only, when a **single** message carries more than
|
|
156
|
+
one `P` record. "The first `P`" is a guess about whose result it is, so it is refused there too.
|
|
157
|
+
|
|
158
|
+
**Both are breaking**, and the second one reaches single-message callers: a lone message carrying
|
|
159
|
+
several patients used to answer with the first of them. A stream that is one message with at most one
|
|
160
|
+
patient is unchanged, and so is a result-only message with no `P` at all, which still answers
|
|
161
|
+
`undefined`. `commentsFor()` is unchanged on every stream, because the parent record you hand it
|
|
162
|
+
already names the message.
|
|
163
|
+
|
|
164
|
+
Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
|
|
165
|
+
you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
|
|
166
|
+
byte for instance, opens no message, and the messages either side of it merge back into one, so a
|
|
167
|
+
patient can end up holding results that arrived under a different header. The
|
|
168
|
+
parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
|
|
169
|
+
only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
|
|
170
|
+
a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
|
|
171
|
+
not gate on the warning count, though, because the records that merged in can raise warnings of
|
|
172
|
+
their own.
|
|
173
|
+
|
|
174
|
+
Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
|
|
175
|
+
records after it are read with the previous set and their fields can be lost rather than merely
|
|
176
|
+
misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
|
|
177
|
+
the delimiters in force found no field separator in it at all, so the whole line read back as one
|
|
178
|
+
field and none of its modeled fields survived. On a result record that is the value, the units and
|
|
179
|
+
the status at once, so treat it as a lost result, not a formatting nit. The fields are never
|
|
180
|
+
reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
|
|
181
|
+
code is safety-critical, and it does not need a mangled header to fire: a lone record written in
|
|
182
|
+
another set trips it too.
|
|
183
|
+
|
|
184
|
+
**Its absence does not certify that a record was read in its own set**, and this is the important
|
|
185
|
+
half. The check tests one of the four delimiter roles, the **field** separator, and only in its
|
|
186
|
+
total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
|
|
187
|
+
outside it:
|
|
188
|
+
|
|
189
|
+
- A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
|
|
190
|
+
the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
|
|
191
|
+
the value, the units and the status with no warning at all, while the identical record without
|
|
192
|
+
that one byte is reported. This also happens **inside** a run of these warnings, so even a run
|
|
193
|
+
does not mean every record in it was checked.
|
|
194
|
+
- A set differing in the **repeat, component or escape** role usually splits into fields normally,
|
|
195
|
+
and the damage then varies. A mis-split component can cost a test identity while the value and
|
|
196
|
+
units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
|
|
197
|
+
longer merges the rest of the record (it reads as a literal and raises
|
|
198
|
+
`ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
|
|
199
|
+
that is itself a delimiter in force is an opaque
|
|
200
|
+
atom, so that delimiter does not split and the value, the units and the status can still go
|
|
201
|
+
together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
|
|
202
|
+
alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
|
|
203
|
+
where the leftmost alignment lets a delimiter split that a competing alignment would have held,
|
|
204
|
+
gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
|
|
205
|
+
not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
|
|
206
|
+
an escape character heading no sequence it can interpret, every later field shifts and a
|
|
207
|
+
result's units
|
|
208
|
+
and status are read out of slots the other alignment does not put them in: that raises
|
|
209
|
+
`ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
|
|
210
|
+
nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
|
|
211
|
+
truncates a value and costs a test identity or a patient name the components that sat after it:
|
|
212
|
+
that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
|
|
213
|
+
**component** boundary nothing leaves the record and every component after it moves along the
|
|
214
|
+
component list, so a coding scheme, a vendor local code or a given name is read out of a position the other
|
|
215
|
+
alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
|
|
216
|
+
tolerable either.
|
|
217
|
+
|
|
218
|
+
All are accepted limits, for two different reasons: widening the field-separator check would mean
|
|
219
|
+
deciding which set a record ought to have had, which is the same guess the parser declines to make
|
|
220
|
+
elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
|
|
221
|
+
written down rather than papered over. Read the warning as "this record definitely lost its
|
|
222
|
+
fields", never as "no other record did". If
|
|
223
|
+
delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
|
|
224
|
+
outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
|
|
225
|
+
invalidating what follows it rather than expecting this warning to enumerate the damage.
|
|
226
|
+
|
|
227
|
+
An unrecognized type letter also makes the message **kind** unknowable, because the letter that
|
|
228
|
+
could not be read may have been the very `Q` that decides it. `classification.kind` is
|
|
229
|
+
`indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
|
|
230
|
+
says why. A `Q` that was read still wins outright.
|
|
231
|
+
|
|
232
|
+
### Decode a framed byte stream
|
|
81
233
|
|
|
82
234
|
```ts
|
|
83
235
|
import { decodeAstmFrames, parseFramedAstm, results } from "@cosyte/astm";
|
|
@@ -96,7 +248,7 @@ A checksum mismatch, a sequence gap, an unterminated frame, and an oversize (>24
|
|
|
96
248
|
**warning** in the default lenient mode (surfaced, flagged, never silently trusted) and a thrown
|
|
97
249
|
`AstmFrameStrictError` under `{ strict: true }`.
|
|
98
250
|
|
|
99
|
-
|
|
251
|
+
### Drive the transport (framed vs raw) + the LTP protocol
|
|
100
252
|
|
|
101
253
|
ASTM transport is not uniform: **serial** always frames, but over **TCP it varies within a single
|
|
102
254
|
vendor**, the cobas 4800 and Iguana keep the full `ENQ`/`ACK` + `STX`/checksum framing, while the
|
|
@@ -140,30 +292,339 @@ fabricated `sendAck`, and is **never** appended to a record. A `NAK` drives retr
|
|
|
140
292
|
The interactive contention/timeout/retransmit **timing** is the consumer's: this layer models the
|
|
141
293
|
state transitions, not the wall-clock timers.
|
|
142
294
|
|
|
143
|
-
|
|
295
|
+
### Map local codes to LOINC (LIVD, bring-your-own)
|
|
144
296
|
|
|
145
|
-
|
|
146
|
-
|
|
297
|
+
An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
|
|
298
|
+
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
|
|
299
|
+
mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
|
|
300
|
+
ambiguous code is surfaced as such, **never a guessed LOINC**.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
|
|
304
|
+
|
|
305
|
+
// Your LIVD catalog: the vendor transmission code (Vendor Analyte Code) → LOINC.
|
|
306
|
+
const catalog = defineLivdCatalog([{ vendorCode: "687", loinc: "1920-8", loincLongName: "AST" }]);
|
|
307
|
+
|
|
308
|
+
const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
|
|
309
|
+
const { annotations, warnings } = applyLivd(msg, catalog);
|
|
310
|
+
|
|
311
|
+
annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
|
|
312
|
+
warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
|
|
147
313
|
```
|
|
148
314
|
|
|
149
|
-
|
|
315
|
+
**Your catalog answers the analyte-identity question, and the wire never does.** The Universal Test
|
|
316
|
+
ID's first component is a LOINC slot, and the guide this catalog format comes from puts transmitting
|
|
317
|
+
LOINC directly from IVD instruments explicitly out of scope: the analyte arrives as a vendor-defined
|
|
318
|
+
code. So the lookup happens whenever a vendor local code is present, keyed on that code alone, and a
|
|
319
|
+
populated first component neither answers for it nor selects among candidates. **This package
|
|
320
|
+
performs no LOINC validation of any kind**: it never decides whether such a value "looks like" a
|
|
321
|
+
LOINC, so `Glucose` and `2345-7` there are treated identically. The value is carried verbatim as
|
|
322
|
+
`unvalidatedWireValue`, on every disposition, and is never reported as a LOINC.
|
|
150
323
|
|
|
151
324
|
```ts
|
|
152
|
-
|
|
325
|
+
const msg = parseAstmRecords("H|\\^&\rR|1|Glucose^^^687|28.6|U/L||N||F\rL|1\r");
|
|
326
|
+
const [a] = applyLivd(msg, catalog).annotations;
|
|
153
327
|
|
|
154
|
-
//
|
|
155
|
-
|
|
328
|
+
a?.mapping.status; // "mapped": your catalog vouched for 1920-8 (the lookup used "687")
|
|
329
|
+
a?.reportedCode; // "687": the code the catalog was consulted WITH, verbatim
|
|
330
|
+
a?.unvalidatedWireValue; // "Glucose": carried verbatim, vouched for by nothing
|
|
331
|
+
a?.wireValueDisagreesWithCatalog; // true: the two differ, and that is ALL this says
|
|
332
|
+
```
|
|
156
333
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
334
|
+
`mapping.status` is a closed discriminant, so a `switch` over it is exhaustive:
|
|
335
|
+
|
|
336
|
+
| `status` | what happened | warning |
|
|
337
|
+
| ---------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
|
|
338
|
+
| `mapped` | your catalog vouched for exactly one LOINC for the vendor local code | none |
|
|
339
|
+
| `unmapped` | the vendor local code was looked up and your catalog held no entry | `ASTM_LIVD_UNMAPPED_CODE` |
|
|
340
|
+
| `ambiguous` | several distinct LOINCs the reported units did not settle; all surfaced, **none chosen** | `ASTM_LIVD_AMBIGUOUS_MAPPING` |
|
|
341
|
+
| `no-vendor-code` | component 1 is populated and there is no vendor local code: nothing was looked up | none |
|
|
342
|
+
| `no-code` | the Universal Test ID carried no code at all | none |
|
|
343
|
+
|
|
344
|
+
`unvalidatedWireValue` and `wireValueDisagreesWithCatalog` sit **beside** that discriminant and can
|
|
345
|
+
accompany any of its cases. `wireValueDisagreesWithCatalog` is `true` only where your catalog
|
|
346
|
+
vouched for exactly one LOINC, component 1 is populated, and the two are not byte-identical; it is
|
|
347
|
+
`false` everywhere else and is never absent, so an ordinary `R|1|^^^687|...` record ships no
|
|
348
|
+
standing false disagreement. **It reports the difference and nothing else**: both values stay
|
|
349
|
+
surfaced, neither is marked correct, neither is rewritten, and nothing here says the difference was
|
|
350
|
+
settled. Deciding which source to believe is a clinical judgement this library will not make for
|
|
351
|
+
you.
|
|
352
|
+
|
|
353
|
+
Two corners worth knowing before you write a catalog adapter: a `lookup` that **throws** propagates
|
|
354
|
+
to your caller unchanged rather than reading as a catalog miss (your crash must not be
|
|
355
|
+
indistinguishable from "this code is not in the catalog"), and a hit whose `loinc` is a zero-length
|
|
356
|
+
string is reported as a miss rather than as a vouched-for empty LOINC.
|
|
357
|
+
|
|
358
|
+
#### One vendor code, several LOINCs: the units decide
|
|
359
|
+
|
|
360
|
+
One vendor analyte code mapping to several LOINCs is the **ordinary** case, not an edge: the guide
|
|
361
|
+
this catalog format comes from gives the commonest chemistry analytes as its own worked examples, a
|
|
362
|
+
serum glucose reported as a mass concentration versus a substance concentration, and a urine analyte
|
|
363
|
+
reported as a spot concentration versus a 24 hour excretion rate. Its remedy is a mapping per unit,
|
|
364
|
+
so a catalog row can carry a **representative unit of measure** beside the vendor code, and the
|
|
365
|
+
`R` record already states its units on the wire.
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
|
|
369
|
+
|
|
370
|
+
const glucose = defineLivdCatalog([
|
|
371
|
+
{ vendorCode: "GLU", loinc: "2345-7", representativeUnit: "mg/dL" },
|
|
372
|
+
{ vendorCode: "GLU", loinc: "14749-6", representativeUnit: "mmol/L" },
|
|
373
|
+
]);
|
|
374
|
+
|
|
375
|
+
const msg = parseAstmRecords("H|\\^&\rR|1|^^^GLU|5.5|mmol/L||N||F\rL|1\r");
|
|
376
|
+
applyLivd(msg, glucose).annotations[0]?.mapping;
|
|
377
|
+
// { status: "mapped", loinc: "14749-6", representativeUnit: "mmol/L",
|
|
378
|
+
// unitComparison: { comparison: "verbatim-case-sensitive", ucumSemantic: false, ... },
|
|
379
|
+
// source: "livd", derived: true }
|
|
161
380
|
```
|
|
162
381
|
|
|
163
|
-
The
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
382
|
+
**The comparison is verbatim and case sensitive, and it is not UCUM.** A catalog saying `mg/dL` does
|
|
383
|
+
not match a feed saying `MG/DL`, and it never matches `g/L` however convertible the two are: nothing
|
|
384
|
+
is normalized, case folded, scaled or converted on either side. UCUM defines a case-insensitive
|
|
385
|
+
variant of every terminal symbol and requires a program claiming full conformance to compare unit
|
|
386
|
+
expressions by their **semantics**, so this is a deliberately limited choice, the only comparison
|
|
387
|
+
that cannot invent an equivalence. Because you could otherwise read a matched unit as a conformance
|
|
388
|
+
claim this package does not make, every unit-selected answer carries `unitComparison` saying exactly
|
|
389
|
+
what was compared.
|
|
390
|
+
|
|
391
|
+
Refusing still beats guessing, so all four of these are `ambiguous` with **every candidate surfaced
|
|
392
|
+
and no LOINC chosen**: the units match no candidate, they match more than one, the record reported no
|
|
393
|
+
units (absent, empty, or whitespace only) and the candidates differ only by unit, or the units could
|
|
394
|
+
not be read at all because the record was truncated or malformed. An unreadable units field reads as
|
|
395
|
+
"no units reported": you still get a typed annotation, never an exception and never a dropped record.
|
|
396
|
+
The answer's `reason` says which case it was, and `candidateDetails` carries each candidate row's
|
|
397
|
+
LOINC and LIVD attributes so a human can choose what this package will not.
|
|
398
|
+
|
|
399
|
+
A candidate whose `representativeUnit` is **absent, empty or whitespace only** is not unit qualified:
|
|
400
|
+
it is never selected by a unit comparison, and it is still surfaced among the candidates. And a
|
|
401
|
+
vendor code carrying exactly **one** candidate LOINC is answered whether or not the units agree, with
|
|
402
|
+
no `unitComparison`, because no unit chose anything there.
|
|
403
|
+
|
|
404
|
+
On a `mapped` answer, `representativeUnit` is provenance about the **catalog row** rather than a
|
|
405
|
+
restatement of what the record reported: where a code carries a single candidate LOINC across several
|
|
406
|
+
rows that spell the unit differently, the answer takes the first row's attributes, so that field can
|
|
407
|
+
name a unit the record did not report. **Only `unitComparison` says the two were equal**, and it is
|
|
408
|
+
present only where a unit actually chose between candidates. Branch on that field, not on this one.
|
|
409
|
+
|
|
410
|
+
Two attributes ride along and are **never matched on**: `vendorSpecimenDescription` and
|
|
411
|
+
`vendorResultDescription`. Both are free text, and the guide states directly that this information is
|
|
412
|
+
not intended to be parsed by software that automates the mapping, so they are stored and surfaced
|
|
413
|
+
verbatim for a human to read. Only the representative unit ever selects. All three attributes are
|
|
414
|
+
**optional**: a catalog carrying none of them answers exactly as it did before they existed, for
|
|
415
|
+
`mapped`, `unmapped` and `ambiguous` alike.
|
|
416
|
+
|
|
417
|
+
**No LOINC / SNOMED / LIVD dictionary is bundled.** LOINC is © Regenstrief (redistributable only with
|
|
418
|
+
its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
|
|
419
|
+
separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
|
|
420
|
+
its license obligations).
|
|
421
|
+
|
|
422
|
+
> **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
|
|
423
|
+
> to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
|
|
424
|
+
> instruments that reuse the same code for different analytes would both match: supply a catalog built
|
|
425
|
+
> for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
|
|
426
|
+
> surfaced as `ambiguous`, never resolved to a guess.)
|
|
427
|
+
|
|
428
|
+
### Read a date without inventing a timezone
|
|
429
|
+
|
|
430
|
+
**An ASTM timestamp carries no timezone, so it is not an instant.** The value is local to the
|
|
431
|
+
instrument that wrote it, and nothing on the wire says which zone that was. `toDate` therefore
|
|
432
|
+
returns `undefined` until **you** say what the offset was: it never reads the host machine's zone
|
|
433
|
+
and never assumes UTC. Both guesses are silent, and both shift a collection date by a day in every
|
|
434
|
+
negative-offset zone.
|
|
435
|
+
|
|
436
|
+
`parseAstmDate` reads the digit run into an `AstmDate` that keeps whatever precision the instrument
|
|
437
|
+
sent (`"20240315"` is a **day**, not a fabricated midnight). Three conversions read that value, and
|
|
438
|
+
they carry the same names, the same shapes and the same timezone rule in every `@cosyte` parser:
|
|
439
|
+
|
|
440
|
+
| function | returns | on a value it cannot convert |
|
|
441
|
+
| ------------------------- | ------------------------ | --------------------------------------------- |
|
|
442
|
+
| `toObject(value)` | `DateParts \| undefined` | `undefined` |
|
|
443
|
+
| `toISO(value)` | `string \| undefined` | `undefined` |
|
|
444
|
+
| `toDate(value, options?)` | `Date \| undefined` | `undefined`, including when no zone was given |
|
|
445
|
+
|
|
446
|
+
```ts
|
|
447
|
+
import { parseAstmDate, toObject, toISO, toDate } from "@cosyte/astm";
|
|
448
|
+
|
|
449
|
+
const collected = parseAstmDate("20240315"); // day precision, no time, no zone
|
|
450
|
+
|
|
451
|
+
toObject(collected); // { year: 2024, month: 3, day: 15 } only what was stated
|
|
452
|
+
toISO(collected); // "2024-03-15" no trailing Z, ever
|
|
453
|
+
toDate(collected); // undefined the zone is unstated
|
|
454
|
+
|
|
455
|
+
// You know the analyzer runs on US Eastern standard time, so you say so:
|
|
456
|
+
toDate(collected, { assumeOffsetMinutes: -300 }); // 2024-03-15T05:00:00.000Z
|
|
457
|
+
toDate(collected, { assumeOffsetMinutes: 0 }); // 2024-03-15T00:00:00.000Z, "read it as UTC"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
`assumeOffsetMinutes` is signed minutes east of UTC, and an explicit `0` is a real answer ("treat
|
|
461
|
+
this value as UTC"), not a default. Components below the stated precision fill to their lowest legal
|
|
462
|
+
value for the instant only (month and day to 1, the time to 0); the value itself is untouched, so
|
|
463
|
+
`toObject` and `toISO` keep reporting the precision that arrived. A four-digit year below 100 stays
|
|
464
|
+
that year: `"00500101"` is the year 50, never 1950.
|
|
465
|
+
|
|
466
|
+
An offset that names no usable zone is refused rather than applied, and **the answer is never an
|
|
467
|
+
`Invalid Date`**: not a `NaN` or infinite `assumeOffsetMinutes`, and not one so large that applying
|
|
468
|
+
it lands outside the range a JS `Date` represents. Both give you `undefined`, like every other case
|
|
469
|
+
this surface cannot answer.
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
toDate(collected, { assumeOffsetMinutes: Number.NaN }); // undefined
|
|
473
|
+
toDate(collected, { assumeOffsetMinutes: 1e15 }); // undefined, past the representable range
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
An `Invalid Date` would satisfy the `Date | undefined` return type and defeat its point: you cannot
|
|
477
|
+
tell one from a real instant without testing `getTime()` for `NaN`, and calling `toISOString()` on it
|
|
478
|
+
throws. Checking for `undefined` is the only test you need.
|
|
479
|
+
|
|
480
|
+
`toObject` returns a **frozen** object carrying only the components the value stated, so
|
|
481
|
+
`Object.keys()` is the precision. There is no `precision`, `raw` or `truncated` key on it (that is
|
|
482
|
+
the parse record, and it stays on `AstmDate`), no zero-filling, and no key holding `undefined`. The
|
|
483
|
+
month is spec-native **1 to 12**, and the keys are singular, so deleting `offsetMinutes` (which an
|
|
484
|
+
ASTM value never has) leaves an object `Temporal.PlainDateTime.from` and luxon's
|
|
485
|
+
`DateTime.fromObject` accept with no key rename and no value adjustment. Neither library is a
|
|
486
|
+
dependency here: the shape is the interoperability, not an import.
|
|
487
|
+
|
|
488
|
+
**A component that is not on the calendar is refused, not normalised.** The parser is lenient by
|
|
489
|
+
design and will read `"19881301"` into month 13 rather than drop the field, so the conversions are
|
|
490
|
+
where that has to be answered, and all three of them answer `undefined`:
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
parseAstmDate("19881301")?.month; // 13: the parser reports what arrived
|
|
494
|
+
toObject(parseAstmDate("19881301")); // undefined
|
|
495
|
+
toISO(parseAstmDate("19881301")); // undefined
|
|
496
|
+
toDate(parseAstmDate("19881301"), { assumeOffsetMinutes: 0 }); // undefined
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
A JS `Date` would have rolled that month 13 into January **1989** and handed back a date of birth a
|
|
500
|
+
year out, with nothing to say so. A month outside 1 to 12, a day past the end of its own month (29
|
|
501
|
+
February in a common year included), an hour past 23 and a minute or second past 59 are all refused
|
|
502
|
+
the same way. The refusal is of the whole value, never of the offending component alone: `undefined`
|
|
503
|
+
rather than a year-precision `{ year: 1988 }` the instrument never sent.
|
|
504
|
+
|
|
505
|
+
`astmDateToLocalISO` is unchanged and still exported. `toISO` returns the same string for every value
|
|
506
|
+
this parser produces **whose components are a real calendar date**, under the name every `@cosyte`
|
|
507
|
+
parser answers to. On a value whose components are not, the two differ on purpose and in one
|
|
508
|
+
direction: `astmDateToLocalISO` still renders the digits it was handed (`"1988-13-01"`), which is its
|
|
509
|
+
long-standing behaviour, and `toISO` gives you `undefined` instead of a string no ISO-8601 reader
|
|
510
|
+
accepts.
|
|
511
|
+
|
|
512
|
+
#### Using two `@cosyte` parsers in one file
|
|
513
|
+
|
|
514
|
+
The three names are deliberately identical across the packages, so importing two of them collides.
|
|
515
|
+
Alias them, or namespace-import:
|
|
516
|
+
|
|
517
|
+
```ts
|
|
518
|
+
import { parseAstmDate, toISO as astmToISO, toDate as astmToDate } from "@cosyte/astm";
|
|
519
|
+
import { toISO as hl7ToISO } from "@cosyte/hl7";
|
|
520
|
+
|
|
521
|
+
// Or namespace the whole package:
|
|
522
|
+
import * as astm from "@cosyte/astm";
|
|
523
|
+
|
|
524
|
+
astmToISO(parseAstmDate("202403150930")); // "2024-03-15T09:30"
|
|
525
|
+
astm.toDate(parseAstmDate("20240315"), { assumeOffsetMinutes: -300 });
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
An HL7 v2 `DTM` can state its own offset and an ASTM timestamp never can, so the two answer
|
|
529
|
+
differently on purpose: `hl7ToISO` may end in `Z` or `+HH:MM`, `astmToISO` never does. That
|
|
530
|
+
difference is the standards', not the API's.
|
|
531
|
+
|
|
532
|
+
### The cosyte parser archetype
|
|
533
|
+
|
|
534
|
+
- **Postel's Law**: liberal parser (lenient default + warnings), conservative serializer (always
|
|
535
|
+
spec-clean), so quirks don't propagate downstream on round-trip.
|
|
536
|
+
- **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
|
|
537
|
+
Tier 3 fatal always.
|
|
538
|
+
- **Stable warning codes**: warnings carry stable string codes + positional context; consumers
|
|
539
|
+
branch on `w.code`, so renaming a code is a breaking change.
|
|
540
|
+
- **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
|
|
541
|
+
- **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
|
|
542
|
+
- **Immutability**: parsed models are immutable; mutation is via explicit methods.
|
|
543
|
+
- **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
|
|
544
|
+
through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
|
|
545
|
+
to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
|
|
546
|
+
default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
|
|
547
|
+
- **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
|
|
548
|
+
(`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. The
|
|
549
|
+
catalog answers for the analyte identity, never the wire, and no LOINC validation of any kind is
|
|
550
|
+
performed. No LOINC / SNOMED / LIVD data is bundled.
|
|
551
|
+
|
|
552
|
+
## Compatibility
|
|
553
|
+
|
|
554
|
+
- **Record content: ASTM E1394 / CLSI LIS02-A2.** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read; an unrecognized type letter is surfaced, never dropped.
|
|
555
|
+
- **Framing and transport: ASTM E1381 / CLSI LIS01-A2.** Modulo-256 checksum, frame-number sequencing, the 240-byte multi-frame split, and a pure `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine.
|
|
556
|
+
- **Delimiters come from the stream, never from an assumption.** They are read at every `H` and scoped forward, and records already read keep the set they were read with.
|
|
557
|
+
- **Framed and raw TCP are both handled.** Serial always frames; over TCP it varies within one vendor, so `detectFraming` routes cobas 4800 and Iguana (framed) from cobas b121 (framing dropped).
|
|
558
|
+
- **No named per-vendor profile ships.** The engine, the registry and the safety gate are public; the built-in set is `default` plus the corpus-grounded `referenceCorpus`. Named profiles for cobas, Sysmex, ADVIA, Mindray and Snibe stay gated behind a public, vendor-attributed quirk document, and firsthand inspection of the public corpus found the record layer spec-clean for them.
|
|
559
|
+
- **Three behaviors are reasoned from this reader rather than cited to a clause.** LIS02-A2 sections 5.4 and 6.2 are withheld from CLSI's free sample and the paywalled editions were not read here, so the forward-scoping rule for redeclared delimiters, the Latin-1 wire encoding and the reserved-byte set are this package's reading, not a quotation.
|
|
560
|
+
- **The result-status letter set is bound by no citable published source**, and every interpreted status reports that in its `vocabulary` rather than implying an attribution it does not have. Abnormal flags are graded against HL7 v3 ObservationInterpretation and report that vocabulary too, recognized or not.
|
|
561
|
+
- **The wire is read as Latin-1**, one byte per character. A character above `U+00FF` handed to the frame encoder is a typed error rather than a silently truncated byte, and a raw `STX`, `ETB` or `ETX` byte in a record is refused for the same reason: framing has no escape sequence for them.
|
|
562
|
+
- **No LOINC, SNOMED or LIVD dictionary is bundled**, and no LOINC is ever guessed. You bring the catalog and its license obligations.
|
|
563
|
+
|
|
564
|
+
### What it covers
|
|
565
|
+
|
|
566
|
+
- **Records (E1394 / LIS02-A2).** `H`/`P`/`O`/`R`/`C`/`Q`/`M`/`S`/`L` are read with per-header
|
|
567
|
+
delimiter self-declaration and escape decode, so an escaped delimiter inside a value reads as one
|
|
568
|
+
component. Result semantics are modeled and fail-safe: abnormal flags graded against the HL7 v3
|
|
569
|
+
ObservationInterpretation code system, result status (a correction `C` or cancel `X` never reads as
|
|
570
|
+
active-final), reference ranges kept verbatim, and a missing unit flagged rather than defaulted.
|
|
571
|
+
Every interpreted flag and status reports the vocabulary it was graded against, recognized or not,
|
|
572
|
+
so an unknown code is distinguishable from one the library has not caught up to; the status letter
|
|
573
|
+
set reports that **no citable published source** binds it. The practice-, laboratory-, and third patient IDs stay
|
|
574
|
+
distinct; a `C` comment attaches to its parent by position and an orphan is surfaced, never dropped;
|
|
575
|
+
a partial timestamp is preserved and flagged, never zero-filled. A `Q`-bearing message is classified
|
|
576
|
+
as a host query and is never read as a result set, and `M`/`S` vendor QC and calibration records are
|
|
577
|
+
surfaced verbatim rather than interpreted into clinical fields.
|
|
578
|
+
- **Framing and transport (E1381 / LIS01-A2).** `decodeAstmFrames` turns a framed byte stream into
|
|
579
|
+
frames plus reassembled record bytes: it verifies the modulo-256 checksum (a bad frame is surfaced
|
|
580
|
+
untrusted and never merged), tracks frame-number sequencing (a gap is never silently bridged), and
|
|
581
|
+
reassembles the 240-byte-limited multi-frame records. `detectFraming` routes framed streams (serial,
|
|
582
|
+
cobas 4800, Iguana) from raw ones (cobas b121, framing dropped), and `ltpReduce` is a pure,
|
|
583
|
+
socket-free `ENQ`/`ACK`/`NAK`/`EOT` receiver state machine that NAKs a frame the codec did not vouch
|
|
584
|
+
for rather than fabricating an ACK. `parseFramedAstm` composes both layers at the edge.
|
|
585
|
+
- **Emit.** `serializeAstmRecords` and `buildAstmMessage` emit canonical `H|\^&` records with embedded
|
|
586
|
+
delimiters re-escaped and nothing clinical fabricated; `composeAstmFrames` and `serializeFramedAstm`
|
|
587
|
+
frame them with computed checksums, frame numbers, and the 240-byte split. Both layers round-trip by
|
|
588
|
+
construction, and a delimiter set that fails any of the three conditions readback requires (one
|
|
589
|
+
character per separator, no `CR`/`LF`, no two the same) is a typed error rather than bytes written
|
|
590
|
+
and lost. Those three read the set alone, so each record is additionally checked against the set it
|
|
591
|
+
is written with: a separator equal to a record's own type letter escapes that letter away and the
|
|
592
|
+
record re-reads as a different record, which is `ASTM_EMIT_TYPE_LETTER_COLLISION` rather than
|
|
593
|
+
output. That second check is about **transcoding**, not about which set you asked for, so it can
|
|
594
|
+
fire with no delimiter argument at all: a stream read under a vendor's own set may carry a record
|
|
595
|
+
whose type letter the canonical set escapes away, and `serializeFramedAstm` refuses it for the same
|
|
596
|
+
reason. A frame carries **bytes**, so a record handed to `composeAstmFrames` as a `string` is one
|
|
597
|
+
byte per character and a character above `U+00FF` is a typed error too: the encoder will not pick a
|
|
598
|
+
character encoding for you, and it never quietly writes a different character than the one you gave
|
|
599
|
+
it. Encode such content yourself and pass the `Uint8Array`. A raw `STX`, `ETB` or `ETX` **byte** in a
|
|
600
|
+
record is a typed error as well, in either form: those three are what the decoder reads as the shape
|
|
601
|
+
of a frame, framing has no escape sequence for them, and writing one through truncated the frame at
|
|
602
|
+
that byte, silently losing a whole record whenever the two bytes after it happened to be the short
|
|
603
|
+
frame's checksum. The **record** layer still carries them, because a returned string is not yet on a
|
|
604
|
+
wire and a raw-transport consumer round-trips such a value exactly. `startFrameNumber` lets you
|
|
605
|
+
compose one transfer across several calls, and `composeAstmFrames` checks it before it reads a
|
|
606
|
+
record: a frame's number is a single ASCII digit, so a value that is not a whole number from `0` to
|
|
607
|
+
`7` is a typed error rather than whatever byte the arithmetic truncated to. The round trip above is
|
|
608
|
+
the **default** start; a non-default one writes a continuation of a sequence already in progress,
|
|
609
|
+
and a continuation read on its own opens on a frame-sequence gap, so its first record is warned
|
|
610
|
+
about and not emitted.
|
|
611
|
+
- **Vendor profiles.** `defineAstmProfile()` builds a provenance-backed profile whose tolerances
|
|
612
|
+
downgrade _expected_, non-safety-critical deviations to a `PROFILE_QUIRK_APPLIED` warning without
|
|
613
|
+
ever altering a value, behind a safety gate that refuses to tolerate any result value, flag,
|
|
614
|
+
status, range, or units warning, any patient or comment context, any message-kind ambiguity, any
|
|
615
|
+
unrecognized record type, and any frame or transport integrity warning. The gate runs when a
|
|
616
|
+
profile is defined **and** again when a warning would be downgraded, so a profile assembled as a
|
|
617
|
+
plain object rather than through `defineAstmProfile()` gets the same answer. A profile can never
|
|
618
|
+
make a bad checksum "ok", a cancelled result read "final", or quiet the warning that says a
|
|
619
|
+
message boundary went unrecognized. Named per-vendor profiles await a public, vendor-attributed
|
|
620
|
+
quirk document.
|
|
621
|
+
- **Terminology, bring your own.** `applyLivd(msg, catalog)` maps an analyzer's local test code to a
|
|
622
|
+
LOINC from a consumer-supplied IICC LIVD catalog as an additive, advisory annotation that never
|
|
623
|
+
mutates the raw code or value and never guesses a LOINC. The catalog is consulted whenever a
|
|
624
|
+
vendor local code is present and is keyed on that code alone; a value in the Universal Test ID's
|
|
625
|
+
first component is carried verbatim as an **unvalidated wire value**, never validated, never
|
|
626
|
+
reported as a LOINC, and never used as a lookup key. No LOINC, SNOMED, or LIVD dictionary is
|
|
627
|
+
bundled: the package stays a structural recognizer and you bring the catalog.
|
|
167
628
|
|
|
168
629
|
### An unescaped ampersand does not cost you the rest of the record
|
|
169
630
|
|
|
@@ -268,9 +729,9 @@ alignment gives it. A field's modeled value and components are taken from its **
|
|
|
268
729
|
alone**, so where the gained boundary is the **first** one everything past it stays on the wire,
|
|
269
730
|
stays in `repeats`, and leaves every modeled slot. On the canonical set, `R|1|^^^687|28.6&S&\&U/L|U/L||||F` reads a value of `28.6^`
|
|
270
731
|
and drops `&U/L`, and `R|1|&F&\&687|28.6|U/L||||F` reads a Universal Test ID of one component
|
|
271
|
-
holding a decoded field separator, so the local code `687` is in no modeled slot and the
|
|
272
|
-
|
|
273
|
-
same way. That raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, which **no profile may tolerate**;
|
|
732
|
+
holding a decoded field separator, so the local code `687` is in no modeled slot and the record is
|
|
733
|
+
left with no code to key on at all, only an unvalidated wire value nobody wrote. A patient name
|
|
734
|
+
loses its given and middle names the same way. That raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, which **no profile may tolerate**;
|
|
274
735
|
before it existed the only warnings on those streams were tolerable ones, so a strict parse under a
|
|
275
736
|
legal profile accepted a truncated value and an emptied test identity. The reading is unchanged: it
|
|
276
737
|
reports the gained boundary rather than repairing it. **At a LATER boundary it fires and no modeled
|
|
@@ -340,154 +801,28 @@ because every such set is by definition non-canonical, so the only warning it us
|
|
|
340
801
|
always refused these sets (`ASTM_EMIT_INVALID_DELIMITERS`), which is how the gap first showed: a
|
|
341
802
|
message that parsed clean threw when it was serialized back against its own declared delimiters.
|
|
342
803
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
A message runs from its `H` header to its `L` terminator, so a stream can carry several. `messages()`
|
|
346
|
-
splits a parsed stream into them, and each entry carries only its own records, so a patient is only
|
|
347
|
-
ever paired with the results that message actually carried:
|
|
804
|
+
## Contributing
|
|
348
805
|
|
|
349
|
-
|
|
350
|
-
import { parseAstmRecords, messages } from "@cosyte/astm";
|
|
806
|
+
Issues and pull requests are welcome at [github.com/cosyte/astm](https://github.com/cosyte/astm). Ask a question by opening an [issue](https://github.com/cosyte/astm/issues); there is no separate forum.
|
|
351
807
|
|
|
352
|
-
|
|
353
|
-
m.patient?.practiceAssignedId; // the P for THIS message
|
|
354
|
-
m.results; // the Rs for THIS message
|
|
355
|
-
m.delimiters; // the set THIS message's records were read with
|
|
356
|
-
}
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
The flat accessors above (`patient`, `results`, `orders`, `comments`, `query`) read the whole stream,
|
|
360
|
-
so they **throw** `AstmAmbiguousStreamError` on a stream they cannot answer for rather than answering
|
|
361
|
-
across patients:
|
|
808
|
+
A contribution has to clear the same gates CI runs, and every one of them runs locally:
|
|
362
809
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
Splitting reads each record's type letter, so check for an `ASTM_RECORD_UNKNOWN_TYPE` warning before
|
|
374
|
-
you trust the split. A header the reader does not recognize as a header, one carrying a stray leading
|
|
375
|
-
byte for instance, opens no message, and the messages either side of it merge back into one, so a
|
|
376
|
-
patient can end up holding results that arrived under a different header. The
|
|
377
|
-
parser warns on that record and a `{ strict: true }` parse refuses the stream. That warning is the
|
|
378
|
-
only report the merge produces, so a profile is not allowed to tolerate it: the code is refused when
|
|
379
|
-
a profile is defined, and a warning carrying it is not downgraded whatever profile is in force. Do
|
|
380
|
-
not gate on the warning count, though, because the records that merged in can raise warnings of
|
|
381
|
-
their own.
|
|
382
|
-
|
|
383
|
-
Delimiters are re-read at each header too, so if the unrecognized one declared a different set, the
|
|
384
|
-
records after it are read with the previous set and their fields can be lost rather than merely
|
|
385
|
-
misfiled. `ASTM_RECORD_FIELDS_UNSEPARATED` reports a record that suffered the total form of that:
|
|
386
|
-
the delimiters in force found no field separator in it at all, so the whole line read back as one
|
|
387
|
-
field and none of its modeled fields survived. On a result record that is the value, the units and
|
|
388
|
-
the status at once, so treat it as a lost result, not a formatting nit. The fields are never
|
|
389
|
-
reconstructed, because the set the sender used is unknown and guessing at it would invent data. The
|
|
390
|
-
code is safety-critical, and it does not need a mangled header to fire: a lone record written in
|
|
391
|
-
another set trips it too.
|
|
392
|
-
|
|
393
|
-
**Its absence does not certify that a record was read in its own set**, and this is the important
|
|
394
|
-
half. The check tests one of the four delimiter roles, the **field** separator, and only in its
|
|
395
|
-
total form, where no unescaped separator occurs in the line. Two classes of the same loss sit
|
|
396
|
-
outside it:
|
|
397
|
-
|
|
398
|
-
- A foreign set whose **field** separator happens to occur somewhere in the line still splits, on
|
|
399
|
-
the wrong boundaries and in silence. A single stray `|` in an otherwise `*`-separated result loses
|
|
400
|
-
the value, the units and the status with no warning at all, while the identical record without
|
|
401
|
-
that one byte is reported. This also happens **inside** a run of these warnings, so even a run
|
|
402
|
-
does not mean every record in it was checked.
|
|
403
|
-
- A set differing in the **repeat, component or escape** role usually splits into fields normally,
|
|
404
|
-
and the damage then varies. A mis-split component can cost a test identity while the value and
|
|
405
|
-
units survive. The escape role's worst case has **narrowed, not gone**: a bare escape character no
|
|
406
|
-
longer merges the rest of the record (it reads as a literal and raises
|
|
407
|
-
`ASTM_UNPAIRED_ESCAPE_CHARACTER`), but an `&X&` sequence whose body is an unrecognized character
|
|
408
|
-
that is itself a delimiter in force is an opaque
|
|
409
|
-
atom, so that delimiter does not split and the value, the units and the status can still go
|
|
410
|
-
together. That one raises `ASTM_RECORD_DELIMITER_SWALLOWED_BY_ESCAPE`, which is not tolerable,
|
|
411
|
-
alongside the tolerable `ASTM_UNKNOWN_ESCAPE_SEQUENCE`. The split itself is unchanged. Its mirror,
|
|
412
|
-
where the leftmost alignment lets a delimiter split that a competing alignment would have held,
|
|
413
|
-
gains a boundary instead of losing one and raises `ASTM_RECORD_AMBIGUOUS_ESCAPE_ALIGNMENT`, also
|
|
414
|
-
not tolerable. Where that gained boundary is a **field** boundary and the reading taken resumes on
|
|
415
|
-
an escape character heading no sequence it can interpret, every later field shifts and a
|
|
416
|
-
result's units
|
|
417
|
-
and status are read out of slots the other alignment does not put them in: that raises
|
|
418
|
-
`ASTM_RECORD_ALIGNMENT_SHIFTED_FIELDS`, not tolerable either. Where it is a **repeat** boundary
|
|
419
|
-
nothing shifts, but the field is read out of its first repeat alone, so a gained first boundary
|
|
420
|
-
truncates a value and costs a test identity or a patient name the components that sat after it:
|
|
421
|
-
that raises `ASTM_RECORD_ALIGNMENT_TRUNCATED_FIELD`, not tolerable either. Where it is a
|
|
422
|
-
**component** boundary nothing leaves the record and every component after it moves along the
|
|
423
|
-
component list, so a coding scheme, a vendor local code or a given name is read out of a position the other
|
|
424
|
-
alignment does not put it in: that raises `ASTM_RECORD_ALIGNMENT_SHIFTED_COMPONENTS`, not
|
|
425
|
-
tolerable either.
|
|
426
|
-
|
|
427
|
-
All are accepted limits, for two different reasons: widening the field-separator check would mean
|
|
428
|
-
deciding which set a record ought to have had, which is the same guess the parser declines to make
|
|
429
|
-
elsewhere, and narrowing the escape atom would break the guarantee it exists for. So they are
|
|
430
|
-
written down rather than papered over. Read the warning as "this record definitely lost its
|
|
431
|
-
fields", never as "no other record did". If
|
|
432
|
-
delimiter drift is a real risk on your feed, parse with `{ strict: true }`, which refuses both an
|
|
433
|
-
outright collapse and an unrecognized type letter, and treat `ASTM_RECORD_UNKNOWN_TYPE` as
|
|
434
|
-
invalidating what follows it rather than expecting this warning to enumerate the damage.
|
|
435
|
-
|
|
436
|
-
An unrecognized type letter also makes the message **kind** unknowable, because the letter that
|
|
437
|
-
could not be read may have been the very `Q` that decides it. `classification.kind` is
|
|
438
|
-
`indeterminate` in that case rather than `results` or `orders`, and `classification.hasUnrecognized`
|
|
439
|
-
says why. A `Q` that was read still wins outright.
|
|
440
|
-
|
|
441
|
-
## Map local codes to LOINC (LIVD, bring-your-own)
|
|
442
|
-
|
|
443
|
-
An analyzer sends a proprietary local test code in the Universal Test ID; a standard LOINC is mapped
|
|
444
|
-
downstream. Supply your own IICC LIVD ("LOINC to Vendor IVD") catalog and annotate a message, the
|
|
445
|
-
mapping is **additive and advisory**: it never touches the raw code or value, and an unmapped or
|
|
446
|
-
ambiguous code is surfaced as such, **never a guessed LOINC**.
|
|
447
|
-
|
|
448
|
-
```ts
|
|
449
|
-
import { parseAstmRecords, defineLivdCatalog, applyLivd } from "@cosyte/astm";
|
|
450
|
-
|
|
451
|
-
// Your LIVD catalog: the vendor transmission code (Vendor Analyte Code) → LOINC.
|
|
452
|
-
const catalog = defineLivdCatalog([{ vendorCode: "687", loinc: "1920-8", loincLongName: "AST" }]);
|
|
453
|
-
|
|
454
|
-
const msg = parseAstmRecords("H|\\^&\rR|1|^^^687|28.6|U/L||N||F\rL|1\r");
|
|
455
|
-
const { annotations, warnings } = applyLivd(msg, catalog);
|
|
456
|
-
|
|
457
|
-
annotations[0]?.mapping; // { status: "mapped", loinc: "1920-8", loincLongName: "AST", source: "livd", derived: true }
|
|
458
|
-
warnings; // ASTM_LIVD_UNMAPPED_CODE / ASTM_LIVD_AMBIGUOUS_MAPPING (value-free) for codes with no single LOINC
|
|
810
|
+
```bash
|
|
811
|
+
pnpm install
|
|
812
|
+
pnpm run typecheck
|
|
813
|
+
pnpm run lint
|
|
814
|
+
pnpm run test
|
|
815
|
+
pnpm run build
|
|
816
|
+
pnpm run format:check
|
|
817
|
+
pnpm run check:no-emdash
|
|
818
|
+
pnpm run check:no-internal-refs
|
|
819
|
+
pnpm run phi-scan
|
|
459
820
|
```
|
|
460
821
|
|
|
461
|
-
**No
|
|
462
|
-
its attribution notice) and the public CDC LIVD file is SARS-CoV-2-specific and carries
|
|
463
|
-
separately-licensed SNOMED CT, so the package ships no terminology data and you bring the catalog (and
|
|
464
|
-
its license obligations).
|
|
465
|
-
|
|
466
|
-
> **Scope your catalog to the source device fleet.** The ASTM Universal Test ID carries no manufacturer
|
|
467
|
-
> to disambiguate against, so the catalog keys on the vendor transmission code alone. Two different
|
|
468
|
-
> instruments that reuse the same code for different analytes would both match: supply a catalog built
|
|
469
|
-
> for the analyzers you actually receive from. (Conflicting entries _within_ one catalog are caught and
|
|
470
|
-
> surfaced as `ambiguous`, never resolved to a guess.)
|
|
471
|
-
|
|
472
|
-
## The cosyte parser archetype
|
|
822
|
+
Three house rules reject a change on sight. **No real patient data**, in a fixture, a test, an example or a commit message: every record in this repository is synthetic, and `pnpm phi-scan` runs on every commit. **No em dash anywhere**, including commit messages (`pnpm run check:no-emdash`); write a comma, a colon, a period or parentheses instead. **No internal project bookkeeping on a public surface** (`pnpm run check:no-internal-refs`): this file, the shipped docs and every JSDoc comment say what the software does, never how the change got made.
|
|
473
823
|
|
|
474
|
-
|
|
475
|
-
spec-clean), so quirks don't propagate downstream on round-trip.
|
|
476
|
-
- **Tiered tolerance**: Tier 0/1 silent, Tier 2 warning + recovery (escalates in strict mode),
|
|
477
|
-
Tier 3 fatal always.
|
|
478
|
-
- **Stable warning codes**: warnings carry stable string codes + positional context; consumers
|
|
479
|
-
branch on `w.code`, so renaming a code is a breaking change.
|
|
480
|
-
- **Zero runtime dependencies**: Node stdlib only (healthcare integrations vet every dependency).
|
|
481
|
-
- **Dual ESM + CJS**: built with `tsup`, validated with `attw`.
|
|
482
|
-
- **Immutability**: parsed models are immutable; mutation is via explicit methods.
|
|
483
|
-
- **Profile system**: a `defineAstmProfile()` API for vendor quirks, with built-in profiles authored
|
|
484
|
-
through the same public API. A profile only ever downgrades an _expected_, non-safety-critical warning
|
|
485
|
-
to `PROFILE_QUIRK_APPLIED` (it never alters a value) and may force the raw-vs-framed transport; a
|
|
486
|
-
default-deny safety gate refuses to tolerate any safety-critical deviation at definition time.
|
|
487
|
-
- **Terminology recognizer, not a dictionary.** LIVD-aware LOINC recognition is bring-your-own
|
|
488
|
-
(`applyLivd` over a consumer-supplied catalog): additive, advisory, and never a guessed LOINC. No
|
|
489
|
-
LOINC / SNOMED / LIVD data is bundled.
|
|
824
|
+
A behavior change needs a test that fails without it. Renaming a published warning or error code is a breaking change; adding one is not, though it can change which streams a `{ strict: true }` parse refuses, so say so.
|
|
490
825
|
|
|
491
826
|
## License
|
|
492
827
|
|
|
493
|
-
MIT
|
|
828
|
+
MIT (SPDX identifier `MIT`). Copyright (c) 2026 Cosyte. Full text: [LICENSE](https://github.com/cosyte/astm/blob/main/LICENSE).
|