@peerbits/fhir-observation-generator 1.0.0 → 1.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/README.md CHANGED
@@ -34,7 +34,7 @@ This library provides a focused, auditable conversion layer for the supported vi
34
34
 
35
35
  ## 2. Features
36
36
 
37
- - **7 Core RPM Vital Types Supported:** Blood pressure, heart rate, body weight, SpO2, body temperature, blood glucose, and respiratory rate.
37
+ - **Extended RPM coverage:** Blood pressure, heart rate, SDNN HRV, ECG events, weight/body composition, SpO2, postductal perfusion index, temperature, glucose/CGM, respiratory rate, spirometry, and sleep sessions.
38
38
  - **Component-Based Blood Pressure:** Generates exactly one Observation with systolic (`8480-6`) and diastolic (`8462-4`) components under panel LOINC `85354-9`.
39
39
  - **Verified UCUM Unit Conversion:** Converts raw units (e.g. °F to °C, lbs to kg, mg/dL to mmol/L) using standard reference formulas.
40
40
  - **Single Source LOINC Mapping:** Fully auditable table of codes (`http://loinc.org`) and FHIR categories (`vital-signs`).
@@ -60,7 +60,9 @@ npm ci
60
60
  npm run build
61
61
  ```
62
62
 
63
- ## 4. Quick Start
63
+ ## 4. Demo and Quick Start
64
+
65
+ [Peerbits HealthTech - Fhir Observation Generator Demo](https://healthcare.peerbits.com/demo/fhir-generator)
64
66
 
65
67
  ### Before (Raw RPM Device Reading JSON)
66
68
 
package/dist/bundle.d.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  /**
2
2
  * Batch / Bundle Generator for @peerbits/fhir-observation-generator.
3
3
  *
4
- * Converts an array of DeviceReading objects into a FHIR R4 transaction Bundle.
4
+ * Converts an array of DeviceReading objects into a FHIR transaction Bundle.
5
5
  * Formatted for direct execution with @peerbits/fhir-client's batch/transaction builder.
6
6
  */
7
- import { DeviceReading, FhirBundle, GeneratorConfig } from "./types.js";
7
+ import { DeviceReading, FhirBundle, FhirR5Bundle, GeneratorConfig } from "./types.js";
8
8
  export declare function readingsToBundle(readings: DeviceReading[], config?: GeneratorConfig): FhirBundle;
9
+ /**
10
+ * Converts readings into a FHIR R5 transaction Bundle.
11
+ *
12
+ * Vital-sign data generated by this package uses fields common to both FHIR R4
13
+ * and R5; this function provides an explicit R5-typed integration boundary.
14
+ */
15
+ export declare function readingsToR5Bundle(readings: DeviceReading[], config?: GeneratorConfig): FhirR5Bundle;
package/dist/bundle.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Batch / Bundle Generator for @peerbits/fhir-observation-generator.
3
3
  *
4
- * Converts an array of DeviceReading objects into a FHIR R4 transaction Bundle.
4
+ * Converts an array of DeviceReading objects into a FHIR transaction Bundle.
5
5
  * Formatted for direct execution with @peerbits/fhir-client's batch/transaction builder.
6
6
  */
7
- import { toObservation } from "./generators/index.js";
7
+ import { toObservation, toR5Observation } from "./generators/index.js";
8
8
  export function readingsToBundle(readings, config) {
9
9
  if (!Array.isArray(readings)) {
10
10
  throw new Error("Invalid input to readingsToBundle: expected array of DeviceReading objects");
@@ -25,3 +25,25 @@ export function readingsToBundle(readings, config) {
25
25
  entry: entries,
26
26
  };
27
27
  }
28
+ /**
29
+ * Converts readings into a FHIR R5 transaction Bundle.
30
+ *
31
+ * Vital-sign data generated by this package uses fields common to both FHIR R4
32
+ * and R5; this function provides an explicit R5-typed integration boundary.
33
+ */
34
+ export function readingsToR5Bundle(readings, config) {
35
+ if (!Array.isArray(readings)) {
36
+ throw new Error("Invalid input to readingsToR5Bundle: expected array of DeviceReading objects");
37
+ }
38
+ return {
39
+ resourceType: "Bundle",
40
+ type: "transaction",
41
+ entry: readings.map((reading) => ({
42
+ resource: toR5Observation(reading, config),
43
+ request: {
44
+ method: "POST",
45
+ url: "Observation",
46
+ },
47
+ })),
48
+ };
49
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Blood Pressure Observation Generator.
3
3
  *
4
- * Grounded in FHIR R4 and US Core Blood Pressure Profile:
4
+ * Grounded in FHIR R4/R5 and the US Core Blood Pressure Profile:
5
5
  * Produces EXACTLY ONE Observation resource with systolic and diastolic
6
6
  * represented as component elements within the same Observation, NEVER as two separate Observations.
7
7
  */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Blood Pressure Observation Generator.
3
3
  *
4
- * Grounded in FHIR R4 and US Core Blood Pressure Profile:
4
+ * Grounded in FHIR R4/R5 and the US Core Blood Pressure Profile:
5
5
  * Produces EXACTLY ONE Observation resource with systolic and diastolic
6
6
  * represented as component elements within the same Observation, NEVER as two separate Observations.
7
7
  */
@@ -0,0 +1,7 @@
1
+ import type { DeviceReading, FhirObservation, GeneratorConfig } from '../types.js';
2
+ export declare function extendedScalarToObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
3
+ export declare function continuousGlucoseToObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
4
+ export declare function bodyCompositionToObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
5
+ export declare function spirometryToObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
6
+ export declare function sleepToObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
7
+ export declare function ecgToObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
@@ -0,0 +1,126 @@
1
+ import { formatCategory, getLoincMapping, LOINC_SYSTEM, UCUM_SYSTEM } from '../loinc-map.js';
2
+ import { convertUnit } from '../units.js';
3
+ import { validateObservation } from '../validate.js';
4
+ const COMPONENTS = {
5
+ weight: { code: '29463-7', display: 'Body weight', unit: 'kg' },
6
+ bmi: { code: '39156-5', display: 'Body mass index', unit: 'kg/m2' },
7
+ bodyFat: { code: '41982-0', display: 'Percentage of body fat measured', unit: '%' },
8
+ fatMass: { code: '73708-0', display: 'Body fat mass calculated', unit: 'kg' },
9
+ muscleMass: { code: '73964-9', display: 'Body muscle mass calculated', unit: 'kg' },
10
+ boneMass: { code: '101685-6', display: 'Body bone mass', unit: 'kg' },
11
+ fev1: { code: '20150-9', display: 'FEV1', unit: 'L' },
12
+ fvc: { code: '19868-9', display: 'Forced vital capacity by spirometry', unit: 'L' },
13
+ pef: { code: '33452-4', display: 'Peak expiratory flow', unit: 'L/min' },
14
+ fev1FvcRatio: { code: '19926-5', display: 'FEV1/FVC', unit: '%' },
15
+ deepSleep: { code: '93831-6', display: 'Deep sleep duration', unit: 'min' },
16
+ lightSleep: { code: '93830-8', display: 'Light sleep duration', unit: 'min' },
17
+ remSleep: { code: '93829-0', display: 'REM sleep duration', unit: 'min' },
18
+ awakeTime: { code: '93828-2', display: 'Nighttime awakening duration', unit: 'min' },
19
+ };
20
+ function concept(code, display) {
21
+ return { coding: [{ system: LOINC_SYSTEM, code, display }], text: display };
22
+ }
23
+ function base(reading, config) {
24
+ if (!reading.patientRef?.trim())
25
+ throw new Error('FHIR Observation generation requires patientRef.');
26
+ if (!reading.timestamp || Number.isNaN(Date.parse(reading.timestamp)))
27
+ throw new Error('FHIR Observation generation requires a valid timestamp.');
28
+ const mapping = getLoincMapping(reading.deviceType);
29
+ return {
30
+ resourceType: 'Observation', status: config?.status ?? 'final', category: formatCategory(mapping.category),
31
+ code: concept(mapping.loincCode, mapping.display), subject: { reference: reading.patientRef }, effectiveDateTime: reading.timestamp,
32
+ ...(reading.deviceId ? { device: { reference: reading.deviceId } } : {}),
33
+ ...(reading.waveformRef ? { derivedFrom: [{ reference: reading.waveformRef.reference }] } : {}),
34
+ };
35
+ }
36
+ function checked(observation, config) {
37
+ if (config?.validate) {
38
+ const result = validateObservation(observation);
39
+ if (!result.valid)
40
+ throw new Error(`Structural validation failed: ${result.errors.join(', ')}`);
41
+ }
42
+ return observation;
43
+ }
44
+ function metric(reading, key, fallback, fallbackUnit) {
45
+ return reading.metrics?.[key] ?? (fallback === undefined || !fallbackUnit ? undefined : { value: fallback, unit: fallbackUnit });
46
+ }
47
+ function component(key, input) {
48
+ const definition = COMPONENTS[key];
49
+ if (!definition)
50
+ throw new Error(`No standard LOINC mapping for component '${key}'.`);
51
+ const converted = convertUnit(input.value, input.unit, definition.unit);
52
+ return { code: concept(definition.code, definition.display), valueQuantity: { value: converted.value, unit: converted.unit, system: UCUM_SYSTEM, code: converted.ucumCode } };
53
+ }
54
+ export function extendedScalarToObservation(reading, config) {
55
+ if (!['heart-rate-variability', 'perfusion-index'].includes(reading.deviceType) || typeof reading.value !== 'number')
56
+ throw new Error(`Invalid scalar reading for '${reading.deviceType}'.`);
57
+ if (reading.deviceType === 'heart-rate-variability' && reading.metadata?.hrvMethod !== 'sdnn') {
58
+ throw new Error(`HRV generation requires metadata.hrvMethod='sdnn'; no safe default can be inferred.`);
59
+ }
60
+ if (reading.deviceType === 'perfusion-index' && reading.metadata?.measurementSite !== 'postductal') {
61
+ throw new Error(`Perfusion-index generation requires metadata.measurementSite='postductal' for the default LOINC mapping.`);
62
+ }
63
+ const mapping = getLoincMapping(reading.deviceType);
64
+ const converted = convertUnit(reading.value, reading.unit, config?.targetUnit ?? mapping.defaultUcumUnit);
65
+ return checked({ ...base(reading, config), valueQuantity: { value: converted.value, unit: converted.unit, system: UCUM_SYSTEM, code: converted.ucumCode } }, config);
66
+ }
67
+ export function continuousGlucoseToObservation(reading, config) {
68
+ if (reading.deviceType !== 'continuous-glucose' || typeof reading.value !== 'object' || reading.value === null || !('glucose' in reading.value))
69
+ throw new Error('Invalid continuous-glucose reading.');
70
+ const value = reading.value.glucose;
71
+ if (typeof value !== 'number')
72
+ throw new Error('Continuous glucose value must be numeric.');
73
+ const converted = convertUnit(value, reading.unit, config?.targetUnit ?? 'mg/dL');
74
+ return checked({ ...base(reading, config), valueQuantity: { value: converted.value, unit: converted.unit, system: UCUM_SYSTEM, code: converted.ucumCode } }, config);
75
+ }
76
+ export function bodyCompositionToObservation(reading, config) {
77
+ if (reading.deviceType !== 'body-composition')
78
+ throw new Error('Invalid body-composition reading.');
79
+ const supported = Object.keys(COMPONENTS).filter((key) => ['weight', 'bmi', 'bodyFat', 'fatMass', 'muscleMass', 'boneMass'].includes(key));
80
+ const unsupported = Object.keys(reading.metrics ?? {}).filter((key) => !supported.includes(key));
81
+ if (unsupported.length)
82
+ throw new Error(`Body composition contains metrics without a safe default FHIR mapping: ${unsupported.join(', ')}.`);
83
+ const components = supported.flatMap((key) => reading.metrics?.[key] ? [component(key, reading.metrics[key])] : []);
84
+ if (!components.length)
85
+ throw new Error('Body composition has no FHIR-mappable metrics.');
86
+ return checked({ ...base(reading, config), component: components }, config);
87
+ }
88
+ export function spirometryToObservation(reading, config) {
89
+ if (reading.deviceType !== 'spirometry' || typeof reading.value !== 'object' || reading.value === null)
90
+ throw new Error('Invalid spirometry reading.');
91
+ const value = reading.value;
92
+ const components = ['fev1', 'fvc', 'pef', 'fev1FvcRatio'].flatMap((key) => {
93
+ const found = metric(reading, key, value[key], key === 'pef' ? 'L/min' : key === 'fev1FvcRatio' ? '%' : 'L');
94
+ return found ? [component(key, found)] : [];
95
+ });
96
+ if (!components.length)
97
+ throw new Error('Spirometry reading contains no measurements.');
98
+ return checked({ ...base(reading, config), component: components }, config);
99
+ }
100
+ export function sleepToObservation(reading, config) {
101
+ if (reading.deviceType !== 'sleep' || typeof reading.value !== 'object' || reading.value === null)
102
+ throw new Error('Invalid sleep reading.');
103
+ const value = reading.value;
104
+ const total = metric(reading, 'totalSleep', value.totalDuration, reading.unit);
105
+ if (!total)
106
+ throw new Error('Sleep reading requires total duration.');
107
+ const converted = convertUnit(total.value, total.unit, config?.targetUnit ?? 'min');
108
+ const components = ['deepSleep', 'lightSleep', 'remSleep', 'awakeTime'].flatMap((key) => {
109
+ const found = metric(reading, key, value[key], reading.unit);
110
+ return found ? [component(key, found)] : [];
111
+ });
112
+ const observation = base(reading, config);
113
+ if (reading.metadata?.periodStart && reading.metadata?.periodEnd && typeof reading.metadata.periodStart === 'string' && typeof reading.metadata.periodEnd === 'string') {
114
+ observation.effectivePeriod = { start: reading.metadata.periodStart, end: reading.metadata.periodEnd };
115
+ delete observation.effectiveDateTime;
116
+ }
117
+ return checked({ ...observation, valueQuantity: { value: converted.value, unit: converted.unit, system: UCUM_SYSTEM, code: converted.ucumCode }, ...(components.length ? { component: components } : {}) }, config);
118
+ }
119
+ export function ecgToObservation(reading, config) {
120
+ if (reading.deviceType !== 'ecg' || typeof reading.value !== 'object' || reading.value === null)
121
+ throw new Error('Invalid ECG reading.');
122
+ const value = reading.value;
123
+ if (!value.rhythmClassification)
124
+ throw new Error('ECG event requires rhythmClassification.');
125
+ return checked({ ...base(reading, config), valueCodeableConcept: { text: value.rhythmClassification } }, config);
126
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Per-vital FHIR Observation generators index and generic dispatcher.
3
3
  */
4
- import { DeviceReading, FhirObservation, GeneratorConfig } from "../types.js";
4
+ import { DeviceReading, FhirObservation, FhirR5Observation, GeneratorConfig } from "../types.js";
5
5
  import { bloodPressureToObservation } from "./blood-pressure.js";
6
6
  import { heartRateToObservation } from "./heart-rate.js";
7
7
  import { weightToObservation } from "./weight.js";
@@ -9,9 +9,18 @@ import { spo2ToObservation } from "./spo2.js";
9
9
  import { temperatureToObservation } from "./temperature.js";
10
10
  import { glucoseToObservation } from "./glucose.js";
11
11
  import { respiratoryRateToObservation } from "./respiratory-rate.js";
12
- export { bloodPressureToObservation, heartRateToObservation, weightToObservation, spo2ToObservation, temperatureToObservation, glucoseToObservation, respiratoryRateToObservation, };
12
+ import { bodyCompositionToObservation, continuousGlucoseToObservation, ecgToObservation, extendedScalarToObservation, sleepToObservation, spirometryToObservation } from "./extended.js";
13
+ export { bloodPressureToObservation, heartRateToObservation, weightToObservation, spo2ToObservation, temperatureToObservation, glucoseToObservation, respiratoryRateToObservation, bodyCompositionToObservation, continuousGlucoseToObservation, ecgToObservation, extendedScalarToObservation, sleepToObservation, spirometryToObservation, };
13
14
  /**
14
15
  * Generic dispatcher that maps any supported DeviceReading to a FHIR Observation
15
16
  * based on reading.deviceType.
16
17
  */
17
18
  export declare function toObservation(reading: DeviceReading, config?: GeneratorConfig): FhirObservation;
19
+ /**
20
+ * Generates a FHIR R5 Observation for a device reading.
21
+ *
22
+ * The supported vital-sign elements are common to FHIR R4 and R5. This
23
+ * explicit R5 entry point preserves the existing R4 API while providing an
24
+ * R5-specific return type for R5 clients.
25
+ */
26
+ export declare function toR5Observation(reading: DeviceReading, config?: GeneratorConfig): FhirR5Observation;
@@ -8,7 +8,8 @@ import { spo2ToObservation } from "./spo2.js";
8
8
  import { temperatureToObservation } from "./temperature.js";
9
9
  import { glucoseToObservation } from "./glucose.js";
10
10
  import { respiratoryRateToObservation } from "./respiratory-rate.js";
11
- export { bloodPressureToObservation, heartRateToObservation, weightToObservation, spo2ToObservation, temperatureToObservation, glucoseToObservation, respiratoryRateToObservation, };
11
+ import { bodyCompositionToObservation, continuousGlucoseToObservation, ecgToObservation, extendedScalarToObservation, sleepToObservation, spirometryToObservation } from "./extended.js";
12
+ export { bloodPressureToObservation, heartRateToObservation, weightToObservation, spo2ToObservation, temperatureToObservation, glucoseToObservation, respiratoryRateToObservation, bodyCompositionToObservation, continuousGlucoseToObservation, ecgToObservation, extendedScalarToObservation, sleepToObservation, spirometryToObservation, };
12
13
  /**
13
14
  * Generic dispatcher that maps any supported DeviceReading to a FHIR Observation
14
15
  * based on reading.deviceType.
@@ -32,7 +33,30 @@ export function toObservation(reading, config) {
32
33
  return glucoseToObservation(reading, config);
33
34
  case "respiratory-rate":
34
35
  return respiratoryRateToObservation(reading, config);
36
+ case "heart-rate-variability":
37
+ case "perfusion-index":
38
+ return extendedScalarToObservation(reading, config);
39
+ case "continuous-glucose":
40
+ return continuousGlucoseToObservation(reading, config);
41
+ case "body-composition":
42
+ return bodyCompositionToObservation(reading, config);
43
+ case "spirometry":
44
+ return spirometryToObservation(reading, config);
45
+ case "sleep":
46
+ return sleepToObservation(reading, config);
47
+ case "ecg":
48
+ return ecgToObservation(reading, config);
35
49
  default:
36
50
  throw new Error(`Unsupported deviceType: '${reading.deviceType}'`);
37
51
  }
38
52
  }
53
+ /**
54
+ * Generates a FHIR R5 Observation for a device reading.
55
+ *
56
+ * The supported vital-sign elements are common to FHIR R4 and R5. This
57
+ * explicit R5 entry point preserves the existing R4 API while providing an
58
+ * R5-specific return type for R5 clients.
59
+ */
60
+ export function toR5Observation(reading, config) {
61
+ return toObservation(reading, config);
62
+ }
package/dist/loinc-map.js CHANGED
@@ -28,6 +28,14 @@ export const LOINC_MAP = {
28
28
  defaultUcumUnit: "/min",
29
29
  defaultUnitDisplay: "beats/min",
30
30
  },
31
+ "heart-rate-variability": {
32
+ deviceType: "heart-rate-variability", loincCode: "80404-7", display: "R-R interval standard deviation (heart rate variability)",
33
+ category: VITAL_SIGNS_CATEGORY, defaultUcumUnit: "ms", defaultUnitDisplay: "ms",
34
+ },
35
+ ecg: {
36
+ deviceType: "ecg", loincCode: "8619-9", display: "Rhythm segment interpretation by EKG",
37
+ category: { ...VITAL_SIGNS_CATEGORY, code: "exam", display: "Exam" }, defaultUcumUnit: "1", defaultUnitDisplay: "",
38
+ },
31
39
  weight: {
32
40
  deviceType: "weight",
33
41
  loincCode: "29463-7",
@@ -36,6 +44,10 @@ export const LOINC_MAP = {
36
44
  defaultUcumUnit: "kg",
37
45
  defaultUnitDisplay: "kg",
38
46
  },
47
+ "body-composition": {
48
+ deviceType: "body-composition", loincCode: "43143-7", display: "Weighing device panel",
49
+ category: VITAL_SIGNS_CATEGORY, defaultUcumUnit: "1", defaultUnitDisplay: "",
50
+ },
39
51
  spo2: {
40
52
  deviceType: "spo2",
41
53
  loincCode: "59408-5",
@@ -44,6 +56,10 @@ export const LOINC_MAP = {
44
56
  defaultUcumUnit: "%",
45
57
  defaultUnitDisplay: "%",
46
58
  },
59
+ "perfusion-index": {
60
+ deviceType: "perfusion-index", loincCode: "73794-0", display: "Perfusion index by pulse oximetry",
61
+ category: VITAL_SIGNS_CATEGORY, defaultUcumUnit: "%", defaultUnitDisplay: "%",
62
+ },
47
63
  temperature: {
48
64
  deviceType: "temperature",
49
65
  loincCode: "8310-5",
@@ -60,6 +76,10 @@ export const LOINC_MAP = {
60
76
  defaultUcumUnit: "mg/dL",
61
77
  defaultUnitDisplay: "mg/dL",
62
78
  },
79
+ "continuous-glucose": {
80
+ deviceType: "continuous-glucose", loincCode: "99504-3", display: "Glucose in interstitial fluid",
81
+ category: { ...VITAL_SIGNS_CATEGORY, code: "laboratory", display: "Laboratory" }, defaultUcumUnit: "mg/dL", defaultUnitDisplay: "mg/dL",
82
+ },
63
83
  "respiratory-rate": {
64
84
  deviceType: "respiratory-rate",
65
85
  loincCode: "9279-1",
@@ -68,6 +88,14 @@ export const LOINC_MAP = {
68
88
  defaultUcumUnit: "/min",
69
89
  defaultUnitDisplay: "breaths/min",
70
90
  },
91
+ spirometry: {
92
+ deviceType: "spirometry", loincCode: "81459-0", display: "Spirometry panel",
93
+ category: { ...VITAL_SIGNS_CATEGORY, code: "exam", display: "Exam" }, defaultUcumUnit: "1", defaultUnitDisplay: "",
94
+ },
95
+ sleep: {
96
+ deviceType: "sleep", loincCode: "93832-4", display: "Sleep duration",
97
+ category: { ...VITAL_SIGNS_CATEGORY, code: "activity", display: "Activity" }, defaultUcumUnit: "min", defaultUnitDisplay: "min",
98
+ },
71
99
  };
72
100
  /**
73
101
  * Component LOINC mappings for blood pressure components.
package/dist/types.d.ts CHANGED
@@ -1,14 +1,53 @@
1
1
  /**
2
2
  * Device reading and FHIR Observation types for @peerbits/fhir-observation-generator.
3
- * Grounded in US Core Vital Signs Profile and FHIR R4 Observation resource specification.
3
+ * Grounded in US Core Vital Signs Profile and the FHIR R4/R5 Observation resource specifications.
4
4
  */
5
- export type DeviceType = "blood-pressure" | "heart-rate" | "weight" | "spo2" | "temperature" | "glucose" | "respiratory-rate";
5
+ export type DeviceType = "blood-pressure" | "heart-rate" | "heart-rate-variability" | "ecg" | "weight" | "body-composition" | "spo2" | "perfusion-index" | "temperature" | "glucose" | "continuous-glucose" | "respiratory-rate" | "spirometry" | "sleep";
6
6
  export declare const SUPPORTED_DEVICE_TYPES: DeviceType[];
7
7
  export interface BloodPressureValue {
8
8
  systolic: number;
9
9
  diastolic: number;
10
10
  }
11
- export type DeviceReadingValue = number | BloodPressureValue;
11
+ export interface MetricValue {
12
+ value: number;
13
+ unit: string;
14
+ }
15
+ export interface BodyCompositionValue {
16
+ weight?: number;
17
+ bmi?: number;
18
+ bodyFat?: number;
19
+ fatMass?: number;
20
+ fatFreeMass?: number;
21
+ muscleMass?: number;
22
+ hydration?: number;
23
+ boneMass?: number;
24
+ }
25
+ export interface ContinuousGlucoseValue {
26
+ glucose: number;
27
+ rateOfChange?: number;
28
+ trend?: string;
29
+ }
30
+ export interface SpirometryValue {
31
+ fev1?: number;
32
+ fvc?: number;
33
+ pef?: number;
34
+ fev1FvcRatio?: number;
35
+ }
36
+ export interface SleepValue {
37
+ totalDuration: number;
38
+ deepSleep?: number;
39
+ lightSleep?: number;
40
+ remSleep?: number;
41
+ awakeTime?: number;
42
+ timeInBed?: number;
43
+ efficiency?: number;
44
+ }
45
+ export interface EcgValue {
46
+ averageHeartRate?: number;
47
+ rhythmClassification?: string;
48
+ durationSeconds?: number;
49
+ }
50
+ export type DeviceReadingValue = number | BloodPressureValue | BodyCompositionValue | ContinuousGlucoseValue | SpirometryValue | SleepValue | EcgValue;
12
51
  export interface DeviceReading {
13
52
  /**
14
53
  * The vital type of the device reading.
@@ -34,6 +73,15 @@ export interface DeviceReading {
34
73
  * Optional opaque device identifier (e.g. "Device/d-98765").
35
74
  */
36
75
  deviceId?: string;
76
+ metrics?: Record<string, MetricValue | undefined>;
77
+ metadata?: Record<string, unknown>;
78
+ waveformRef?: {
79
+ reference: string;
80
+ lead?: string;
81
+ sampleCount?: number;
82
+ samplingRateHz?: number;
83
+ checksum?: string;
84
+ };
37
85
  }
38
86
  export interface GeneratorConfig {
39
87
  /**
@@ -56,7 +104,7 @@ export interface FhirCoding {
56
104
  display?: string;
57
105
  }
58
106
  export interface FhirCodeableConcept {
59
- coding: FhirCoding[];
107
+ coding?: FhirCoding[];
60
108
  text?: string;
61
109
  }
62
110
  export interface FhirQuantity {
@@ -68,23 +116,44 @@ export interface FhirQuantity {
68
116
  export interface FhirObservationComponent {
69
117
  code: FhirCodeableConcept;
70
118
  valueQuantity?: FhirQuantity;
119
+ valueCodeableConcept?: FhirCodeableConcept;
71
120
  }
72
- export interface FhirObservation {
121
+ export interface FhirReference {
122
+ reference: string;
123
+ }
124
+ export interface FhirObservationBase {
73
125
  resourceType: "Observation";
74
126
  id?: string;
75
127
  status: string;
76
128
  category: FhirCodeableConcept[];
77
129
  code: FhirCodeableConcept;
78
- subject: {
79
- reference: string;
130
+ subject: FhirReference;
131
+ effectiveDateTime?: string;
132
+ effectivePeriod?: {
133
+ start: string;
134
+ end: string;
80
135
  };
81
- effectiveDateTime: string;
82
136
  valueQuantity?: FhirQuantity;
83
137
  component?: FhirObservationComponent[];
84
- device?: {
85
- reference: string;
86
- };
138
+ valueCodeableConcept?: FhirCodeableConcept;
139
+ derivedFrom?: FhirReference[];
140
+ device?: FhirReference;
141
+ }
142
+ /** FHIR R4 Observation produced by the existing generator API. */
143
+ export type FhirR4Observation = FhirObservationBase;
144
+ /**
145
+ * FHIR R5 Observation.
146
+ *
147
+ * The generated vital-sign fields are shared by R4 and R5. R5 additionally
148
+ * permits an ObservationDefinition reference and a BodyStructure reference.
149
+ */
150
+ export interface FhirR5Observation extends FhirObservationBase {
151
+ instantiatesCanonical?: string;
152
+ instantiatesReference?: FhirReference;
153
+ bodyStructure?: FhirReference;
87
154
  }
155
+ /** @deprecated Use FhirR4Observation or FhirR5Observation explicitly. */
156
+ export type FhirObservation = FhirR4Observation;
88
157
  export interface FhirBundleEntry {
89
158
  resource: FhirObservation;
90
159
  request: {
@@ -97,6 +166,18 @@ export interface FhirBundle {
97
166
  type: "transaction";
98
167
  entry: FhirBundleEntry[];
99
168
  }
169
+ export interface FhirR5BundleEntry {
170
+ resource: FhirR5Observation;
171
+ request: {
172
+ method: "POST";
173
+ url: string;
174
+ };
175
+ }
176
+ export interface FhirR5Bundle {
177
+ resourceType: "Bundle";
178
+ type: "transaction";
179
+ entry: FhirR5BundleEntry[];
180
+ }
100
181
  export interface ValidationResult {
101
182
  valid: boolean;
102
183
  errors: string[];
package/dist/types.js CHANGED
@@ -1,13 +1,20 @@
1
1
  /**
2
2
  * Device reading and FHIR Observation types for @peerbits/fhir-observation-generator.
3
- * Grounded in US Core Vital Signs Profile and FHIR R4 Observation resource specification.
3
+ * Grounded in US Core Vital Signs Profile and the FHIR R4/R5 Observation resource specifications.
4
4
  */
5
5
  export const SUPPORTED_DEVICE_TYPES = [
6
6
  "blood-pressure",
7
7
  "heart-rate",
8
+ "heart-rate-variability",
9
+ "ecg",
8
10
  "weight",
11
+ "body-composition",
9
12
  "spo2",
13
+ "perfusion-index",
10
14
  "temperature",
11
15
  "glucose",
16
+ "continuous-glucose",
12
17
  "respiratory-rate",
18
+ "spirometry",
19
+ "sleep",
13
20
  ];
package/dist/units.js CHANGED
@@ -16,6 +16,15 @@ const SUPPORTED_UCUM_UNITS = new Set([
16
16
  "/min",
17
17
  "%",
18
18
  "mm[Hg]",
19
+ "ms",
20
+ "s",
21
+ "min",
22
+ "h",
23
+ "L",
24
+ "mL",
25
+ "L/min",
26
+ "kg/m2",
27
+ "1",
19
28
  ]);
20
29
  /**
21
30
  * Normalizes input unit strings into standard UCUM unit codes.
@@ -81,6 +90,33 @@ export function normalizeUcumUnit(unit) {
81
90
  case "mm[hg]":
82
91
  case "mm hg":
83
92
  return "mm[Hg]";
93
+ case "ms": return "ms";
94
+ case "s":
95
+ case "sec":
96
+ case "second":
97
+ case "seconds": return "s";
98
+ case "min":
99
+ case "minute":
100
+ case "minutes": return "min";
101
+ case "h":
102
+ case "hr":
103
+ case "hour":
104
+ case "hours": return "h";
105
+ case "l":
106
+ case "liter":
107
+ case "liters":
108
+ case "litre":
109
+ case "litres": return "L";
110
+ case "ml":
111
+ case "milliliter":
112
+ case "milliliters":
113
+ case "millilitre":
114
+ case "millilitres": return "mL";
115
+ case "l/min": return "L/min";
116
+ case "kg/m2":
117
+ case "kg/m^2": return "kg/m2";
118
+ case "1":
119
+ case "ratio": return "1";
84
120
  default:
85
121
  return unit;
86
122
  }
@@ -146,6 +182,17 @@ export function convertUnit(value, fromUnit, targetUnit) {
146
182
  ucumCode: targetUcum,
147
183
  };
148
184
  }
185
+ const durationInSeconds = { ms: 0.001, s: 1, min: 60, h: 3600 };
186
+ if (sourceUcum in durationInSeconds && targetUcum in durationInSeconds) {
187
+ const converted = value * durationInSeconds[sourceUcum] / durationInSeconds[targetUcum];
188
+ return { value: roundToFourDecimals(converted), unit: getUnitDisplay(targetUcum), ucumCode: targetUcum };
189
+ }
190
+ if (sourceUcum === "mL" && targetUcum === "L") {
191
+ return { value: roundToFourDecimals(value / 1000), unit: "L", ucumCode: "L" };
192
+ }
193
+ if (sourceUcum === "L" && targetUcum === "mL") {
194
+ return { value: roundToFourDecimals(value * 1000), unit: "mL", ucumCode: "mL" };
195
+ }
149
196
  // Temperature Conversions
150
197
  if (sourceUcum === "[degF]" && targetUcum === "Cel") {
151
198
  const converted = ((value - 32) * 5) / 9;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Structural self-check for generated FHIR Observation resources.
3
3
  *
4
- * Verifies mandatory fields according to the FHIR R4 Observation specification
5
- * and US Core Vital Signs Profile requirements:
4
+ * Verifies generated vital-sign fields shared by the FHIR R4 and R5 Observation
5
+ * specifications and US Core Vital Signs Profile requirements:
6
6
  * - resourceType: "Observation"
7
7
  * - status
8
8
  * - category (containing vital-signs coding)
@@ -11,5 +11,12 @@
11
11
  * - effectiveDateTime
12
12
  * - valueQuantity OR component array (with valid values)
13
13
  */
14
- import { FhirObservation, ValidationResult } from "./types.js";
14
+ import { FhirObservation, FhirR5Observation, ValidationResult } from "./types.js";
15
15
  export declare function validateObservation(observation: FhirObservation): ValidationResult;
16
+ /**
17
+ * Validates the vital-sign subset emitted by this package for FHIR R5.
18
+ *
19
+ * The generated fields have the same shape in R4 and R5. This explicit entry
20
+ * point lets consumers validate R5 output without relying on the legacy R4 API.
21
+ */
22
+ export declare function validateR5Observation(observation: FhirR5Observation): ValidationResult;
package/dist/validate.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Structural self-check for generated FHIR Observation resources.
3
3
  *
4
- * Verifies mandatory fields according to the FHIR R4 Observation specification
5
- * and US Core Vital Signs Profile requirements:
4
+ * Verifies generated vital-sign fields shared by the FHIR R4 and R5 Observation
5
+ * specifications and US Core Vital Signs Profile requirements:
6
6
  * - resourceType: "Observation"
7
7
  * - status
8
8
  * - category (containing vital-signs coding)
@@ -27,9 +27,9 @@ export function validateObservation(observation) {
27
27
  errors.push("Missing required field: category (must be non-empty array)");
28
28
  }
29
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'");
30
+ const hasCategoryCoding = observation.category.some((cat) => cat.coding?.some((c) => c.system && c.code));
31
+ if (!hasCategoryCoding) {
32
+ errors.push("Category must contain at least one coding with system and code");
33
33
  }
34
34
  }
35
35
  if (!observation.code || !Array.isArray(observation.code.coding) || observation.code.coding.length === 0) {
@@ -44,8 +44,11 @@ export function validateObservation(observation) {
44
44
  if (!observation.subject || typeof observation.subject.reference !== "string" || !observation.subject.reference.trim()) {
45
45
  errors.push("Missing required field: subject.reference");
46
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)");
47
+ const validDateTime = !!observation.effectiveDateTime && !isNaN(Date.parse(observation.effectiveDateTime));
48
+ const validPeriod = !!observation.effectivePeriod && !isNaN(Date.parse(observation.effectivePeriod.start)) &&
49
+ !isNaN(Date.parse(observation.effectivePeriod.end)) && Date.parse(observation.effectivePeriod.start) <= Date.parse(observation.effectivePeriod.end);
50
+ if (!validDateTime && !validPeriod) {
51
+ errors.push("Observation requires a valid effectiveDateTime or effectivePeriod");
49
52
  }
50
53
  // Value check: Must have valueQuantity OR component array
51
54
  const hasValueQuantity = observation.valueQuantity &&
@@ -59,7 +62,9 @@ export function validateObservation(observation) {
59
62
  comp.valueQuantity &&
60
63
  typeof comp.valueQuantity.value === "number" &&
61
64
  Number.isFinite(comp.valueQuantity.value));
62
- if (!hasValueQuantity && !hasComponents) {
65
+ const hasCodeableConcept = !!observation.valueCodeableConcept &&
66
+ (!!observation.valueCodeableConcept.text || !!observation.valueCodeableConcept.coding?.length);
67
+ if (!hasValueQuantity && !hasComponents && !hasCodeableConcept) {
63
68
  errors.push("Observation must contain either a valid valueQuantity or a valid component array with at least 2 components");
64
69
  }
65
70
  return {
@@ -67,3 +72,12 @@ export function validateObservation(observation) {
67
72
  errors,
68
73
  };
69
74
  }
75
+ /**
76
+ * Validates the vital-sign subset emitted by this package for FHIR R5.
77
+ *
78
+ * The generated fields have the same shape in R4 and R5. This explicit entry
79
+ * point lets consumers validate R5 output without relying on the legacy R4 API.
80
+ */
81
+ export function validateR5Observation(observation) {
82
+ return validateObservation(observation);
83
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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",
3
+ "version": "1.1.0",
4
+ "description": "Generate FHIR R4 and R5 Observation resources from RPM device readings, with correct LOINC coding and unit conversion",
5
5
  "license": "Apache-2.0",
6
6
  "author": "PeerbitsSolution",
7
7
  "homepage": "https://github.com/PeerbitsSolution/fhir-observation-generator#readme",