@peerbits/fhir-observation-generator 1.0.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/dist/units.js ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Standard UCUM unit conversions for @peerbits/fhir-observation-generator.
3
+ *
4
+ * All formulas use exact standard UCUM conversion factors.
5
+ * Reference unit conversions are verified against clinical standards.
6
+ */
7
+ const SUPPORTED_UCUM_UNITS = new Set([
8
+ "[degF]",
9
+ "Cel",
10
+ "[lb_av]",
11
+ "kg",
12
+ "g",
13
+ "[oz_av]",
14
+ "mg/dL",
15
+ "mmol/L",
16
+ "/min",
17
+ "%",
18
+ "mm[Hg]",
19
+ ]);
20
+ /**
21
+ * Normalizes input unit strings into standard UCUM unit codes.
22
+ */
23
+ export function normalizeUcumUnit(unit) {
24
+ const clean = unit.trim().toLowerCase();
25
+ switch (clean) {
26
+ // Temperature
27
+ case "degf":
28
+ case "°f":
29
+ case "f":
30
+ case "[degf]":
31
+ case "fahrenheit":
32
+ return "[degF]";
33
+ case "degc":
34
+ case "°c":
35
+ case "c":
36
+ case "cel":
37
+ case "[degc]":
38
+ case "celsius":
39
+ return "Cel";
40
+ // Weight
41
+ case "lb":
42
+ case "lbs":
43
+ case "[lb_av]":
44
+ case "pound":
45
+ case "pounds":
46
+ return "[lb_av]";
47
+ case "kg":
48
+ case "kilogram":
49
+ case "kilograms":
50
+ return "kg";
51
+ case "g":
52
+ case "gram":
53
+ case "grams":
54
+ return "g";
55
+ case "oz":
56
+ case "ounce":
57
+ case "ounces":
58
+ case "[oz_av]":
59
+ return "[oz_av]";
60
+ // Glucose
61
+ case "mg/dl":
62
+ case "mg/dL":
63
+ return "mg/dL";
64
+ case "mmol/l":
65
+ case "mmol/L":
66
+ return "mmol/L";
67
+ // Rate / frequency
68
+ case "bpm":
69
+ case "beats/min":
70
+ case "breaths/min":
71
+ case "count/min":
72
+ case "/min":
73
+ case "1/min":
74
+ return "/min";
75
+ // Percentage / SpO2
76
+ case "%":
77
+ case "percent":
78
+ return "%";
79
+ // Pressure
80
+ case "mmhg":
81
+ case "mm[hg]":
82
+ case "mm hg":
83
+ return "mm[Hg]";
84
+ default:
85
+ return unit;
86
+ }
87
+ }
88
+ /**
89
+ * Gets human-readable display string for a UCUM unit code.
90
+ */
91
+ export function getUnitDisplay(ucumCode) {
92
+ switch (ucumCode) {
93
+ case "[degF]":
94
+ return "°F";
95
+ case "Cel":
96
+ return "°C";
97
+ case "[lb_av]":
98
+ return "lbs";
99
+ case "kg":
100
+ return "kg";
101
+ case "g":
102
+ return "g";
103
+ case "[oz_av]":
104
+ return "oz";
105
+ case "mg/dL":
106
+ return "mg/dL";
107
+ case "mmol/L":
108
+ return "mmol/L";
109
+ case "/min":
110
+ return "beats/min";
111
+ case "%":
112
+ return "%";
113
+ case "mm[Hg]":
114
+ return "mmHg";
115
+ default:
116
+ return ucumCode;
117
+ }
118
+ }
119
+ /**
120
+ * Converts a numeric value from source unit to target unit.
121
+ * If units are identical, returns value unchanged.
122
+ * If targetUnit is not provided, defaults to standard UCUM unit or source unit.
123
+ */
124
+ export function convertUnit(value, fromUnit, targetUnit) {
125
+ if (!Number.isFinite(value)) {
126
+ throw new Error("Invalid measurement value: expected a finite number");
127
+ }
128
+ if (typeof fromUnit !== "string" || !fromUnit.trim()) {
129
+ throw new Error("Invalid source unit: expected a non-empty string");
130
+ }
131
+ if (targetUnit !== undefined && (typeof targetUnit !== "string" || !targetUnit.trim())) {
132
+ throw new Error("Invalid target unit: expected a non-empty string");
133
+ }
134
+ const sourceUcum = normalizeUcumUnit(fromUnit);
135
+ const targetUcum = targetUnit ? normalizeUcumUnit(targetUnit) : sourceUcum;
136
+ if (!SUPPORTED_UCUM_UNITS.has(sourceUcum)) {
137
+ throw new Error(`Unsupported source unit: '${fromUnit}'`);
138
+ }
139
+ if (!SUPPORTED_UCUM_UNITS.has(targetUcum)) {
140
+ throw new Error(`Unsupported target unit: '${targetUnit}'`);
141
+ }
142
+ if (sourceUcum === targetUcum) {
143
+ return {
144
+ value: roundToFourDecimals(value),
145
+ unit: getUnitDisplay(targetUcum),
146
+ ucumCode: targetUcum,
147
+ };
148
+ }
149
+ // Temperature Conversions
150
+ if (sourceUcum === "[degF]" && targetUcum === "Cel") {
151
+ const converted = ((value - 32) * 5) / 9;
152
+ return {
153
+ value: roundToFourDecimals(converted),
154
+ unit: "°C",
155
+ ucumCode: "Cel",
156
+ };
157
+ }
158
+ if (sourceUcum === "Cel" && targetUcum === "[degF]") {
159
+ const converted = (value * 9) / 5 + 32;
160
+ return {
161
+ value: roundToFourDecimals(converted),
162
+ unit: "°F",
163
+ ucumCode: "[degF]",
164
+ };
165
+ }
166
+ // Weight Conversions (1 lb = 0.45359237 kg exact UCUM definition)
167
+ if (sourceUcum === "[lb_av]" && targetUcum === "kg") {
168
+ const converted = value * 0.45359237;
169
+ return {
170
+ value: roundToFourDecimals(converted),
171
+ unit: "kg",
172
+ ucumCode: "kg",
173
+ };
174
+ }
175
+ if (sourceUcum === "kg" && targetUcum === "[lb_av]") {
176
+ const converted = value / 0.45359237;
177
+ return {
178
+ value: roundToFourDecimals(converted),
179
+ unit: "lbs",
180
+ ucumCode: "[lb_av]",
181
+ };
182
+ }
183
+ if (sourceUcum === "g" && targetUcum === "kg") {
184
+ const converted = value / 1000;
185
+ return {
186
+ value: roundToFourDecimals(converted),
187
+ unit: "kg",
188
+ ucumCode: "kg",
189
+ };
190
+ }
191
+ if (sourceUcum === "[oz_av]" && targetUcum === "kg") {
192
+ const converted = value * 0.028349523125;
193
+ return {
194
+ value: roundToFourDecimals(converted),
195
+ unit: "kg",
196
+ ucumCode: "kg",
197
+ };
198
+ }
199
+ // Glucose Conversions (18.0182 mg/dL per mmol/L based on glucose molar mass 180.156 g/mol)
200
+ if (sourceUcum === "mg/dL" && targetUcum === "mmol/L") {
201
+ const converted = value / 18.0182;
202
+ return {
203
+ value: roundToFourDecimals(converted),
204
+ unit: "mmol/L",
205
+ ucumCode: "mmol/L",
206
+ };
207
+ }
208
+ if (sourceUcum === "mmol/L" && targetUcum === "mg/dL") {
209
+ const converted = value * 18.0182;
210
+ return {
211
+ value: roundToFourDecimals(converted),
212
+ unit: "mg/dL",
213
+ ucumCode: "mg/dL",
214
+ };
215
+ }
216
+ throw new Error(`Unsupported unit conversion: '${fromUnit}' to '${targetUnit}'`);
217
+ }
218
+ /**
219
+ * Rounds numbers to 4 decimal places to prevent floating point noise (e.g. 37.00000000000001).
220
+ */
221
+ function roundToFourDecimals(num) {
222
+ return Math.round(num * 10000) / 10000;
223
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Structural self-check for generated FHIR Observation resources.
3
+ *
4
+ * Verifies mandatory fields according to the FHIR R4 Observation specification
5
+ * and US Core Vital Signs Profile requirements:
6
+ * - resourceType: "Observation"
7
+ * - status
8
+ * - category (containing vital-signs coding)
9
+ * - code (containing LOINC coding)
10
+ * - subject.reference
11
+ * - effectiveDateTime
12
+ * - valueQuantity OR component array (with valid values)
13
+ */
14
+ import { FhirObservation, ValidationResult } from "./types.js";
15
+ export declare function validateObservation(observation: FhirObservation): ValidationResult;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Structural self-check for generated FHIR Observation resources.
3
+ *
4
+ * Verifies mandatory fields according to the FHIR R4 Observation specification
5
+ * and US Core Vital Signs Profile requirements:
6
+ * - resourceType: "Observation"
7
+ * - status
8
+ * - category (containing vital-signs coding)
9
+ * - code (containing LOINC coding)
10
+ * - subject.reference
11
+ * - effectiveDateTime
12
+ * - valueQuantity OR component array (with valid values)
13
+ */
14
+ import { LOINC_SYSTEM } from "./loinc-map.js";
15
+ export function validateObservation(observation) {
16
+ const errors = [];
17
+ if (!observation) {
18
+ return { valid: false, errors: ["Observation is null or undefined"] };
19
+ }
20
+ if (observation.resourceType !== "Observation") {
21
+ errors.push(`Invalid resourceType: expected 'Observation', got '${observation.resourceType}'`);
22
+ }
23
+ if (!observation.status) {
24
+ errors.push("Missing required field: status");
25
+ }
26
+ if (!Array.isArray(observation.category) || observation.category.length === 0) {
27
+ errors.push("Missing required field: category (must be non-empty array)");
28
+ }
29
+ else {
30
+ const hasVitalSignsCategory = observation.category.some((cat) => cat.coding?.some((c) => c.code === "vital-signs"));
31
+ if (!hasVitalSignsCategory) {
32
+ errors.push("Missing required category coding with code 'vital-signs'");
33
+ }
34
+ }
35
+ if (!observation.code || !Array.isArray(observation.code.coding) || observation.code.coding.length === 0) {
36
+ errors.push("Missing required field: code (must contain coding array)");
37
+ }
38
+ else {
39
+ const hasLoincCoding = observation.code.coding.some((c) => c.system === LOINC_SYSTEM && c.code && c.code.length > 0);
40
+ if (!hasLoincCoding) {
41
+ errors.push(`Missing valid LOINC coding under system '${LOINC_SYSTEM}' in code field`);
42
+ }
43
+ }
44
+ if (!observation.subject || typeof observation.subject.reference !== "string" || !observation.subject.reference.trim()) {
45
+ errors.push("Missing required field: subject.reference");
46
+ }
47
+ if (!observation.effectiveDateTime || isNaN(Date.parse(observation.effectiveDateTime))) {
48
+ errors.push("Missing or invalid required field: effectiveDateTime (must be valid ISO date string)");
49
+ }
50
+ // Value check: Must have valueQuantity OR component array
51
+ const hasValueQuantity = observation.valueQuantity &&
52
+ typeof observation.valueQuantity.value === "number" &&
53
+ Number.isFinite(observation.valueQuantity.value) &&
54
+ typeof observation.valueQuantity.unit === "string";
55
+ const hasComponents = Array.isArray(observation.component) &&
56
+ observation.component.length >= 2 &&
57
+ observation.component.every((comp) => comp.code &&
58
+ Array.isArray(comp.code.coding) &&
59
+ comp.valueQuantity &&
60
+ typeof comp.valueQuantity.value === "number" &&
61
+ Number.isFinite(comp.valueQuantity.value));
62
+ if (!hasValueQuantity && !hasComponents) {
63
+ errors.push("Observation must contain either a valid valueQuantity or a valid component array with at least 2 components");
64
+ }
65
+ return {
66
+ valid: errors.length === 0,
67
+ errors,
68
+ };
69
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@peerbits/fhir-observation-generator",
3
+ "version": "1.0.0",
4
+ "description": "Generate FHIR Observation resources from RPM device readings, with correct LOINC coding and unit conversion",
5
+ "license": "Apache-2.0",
6
+ "author": "PeerbitsSolution",
7
+ "homepage": "https://github.com/PeerbitsSolution/fhir-observation-generator#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/PeerbitsSolution/fhir-observation-generator/issues"
10
+ },
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ }
19
+ },
20
+ "sideEffects": false,
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json",
26
+ "lint": "eslint .",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "vitest run",
29
+ "prepack": "npm run build && npm run typecheck && npm test",
30
+ "prepublishOnly": "npm run lint && npm run prepack"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/PeerbitsSolution/fhir-observation-generator.git"
35
+ },
36
+ "keywords": [
37
+ "fhir",
38
+ "rpm",
39
+ "remote-patient-monitoring",
40
+ "loinc",
41
+ "healthcare",
42
+ "typescript",
43
+ "ucum",
44
+ "observation"
45
+ ],
46
+ "devDependencies": {
47
+ "@typescript-eslint/eslint-plugin": "^8.0.0",
48
+ "@typescript-eslint/parser": "^8.0.0",
49
+ "eslint": "^9.0.0",
50
+ "typescript": "^5.5.0",
51
+ "vitest": "^4.1.10"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }