carebundle 0.1.0__py3-none-any.whl
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.
- carebundle/__init__.py +37 -0
- carebundle/benchmark/__init__.py +23 -0
- carebundle/benchmark/cqm.py +205 -0
- carebundle/builders/__init__.py +0 -0
- carebundle/builders/clinical.py +188 -0
- carebundle/builders/orders.py +104 -0
- carebundle/builders/people.py +64 -0
- carebundle/calibration/__init__.py +0 -0
- carebundle/calibration/custom.py +231 -0
- carebundle/calibration/data/nhanes_targets.json +2445 -0
- carebundle/calibration/nhanes.py +356 -0
- carebundle/calibration/xpt.py +130 -0
- carebundle/cli.py +276 -0
- carebundle/conformance/__init__.py +0 -0
- carebundle/conformance/validator.py +162 -0
- carebundle/core/__init__.py +0 -0
- carebundle/core/bundle.py +110 -0
- carebundle/core/ids.py +41 -0
- carebundle/core/safety.py +115 -0
- carebundle/core/uscore.py +27 -0
- carebundle/correlation/__init__.py +0 -0
- carebundle/correlation/distributions.py +358 -0
- carebundle/correlation/engine.py +84 -0
- carebundle/correlation/relations.py +312 -0
- carebundle/fidelity/__init__.py +0 -0
- carebundle/fidelity/report.py +472 -0
- carebundle/generate.py +462 -0
- carebundle/history.py +260 -0
- carebundle/imperfection/__init__.py +30 -0
- carebundle/imperfection/defects.py +250 -0
- carebundle/models/__init__.py +0 -0
- carebundle/models/r4.py +1046 -0
- carebundle/profiles/__init__.py +0 -0
- carebundle/profiles/base.py +219 -0
- carebundle/profiles/library.py +657 -0
- carebundle/py.typed +0 -0
- carebundle/spec/__init__.py +0 -0
- carebundle/spec/codegen.py +307 -0
- carebundle/terminology/__init__.py +0 -0
- carebundle/terminology/codes.py +398 -0
- carebundle/terminology/systems.py +28 -0
- carebundle/terminology/verify.py +174 -0
- carebundle-0.1.0.dist-info/METADATA +511 -0
- carebundle-0.1.0.dist-info/RECORD +47 -0
- carebundle-0.1.0.dist-info/WHEEL +4 -0
- carebundle-0.1.0.dist-info/entry_points.txt +2 -0
- carebundle-0.1.0.dist-info/licenses/LICENSE +202 -0
carebundle/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Clinically coherent synthetic FHIR(R) R4 test data.
|
|
2
|
+
|
|
3
|
+
FHIR(R) is the registered trademark of HL7 and is used with the permission of HL7.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from carebundle.calibration.custom import Quartiles, calibrate_profile, forget_profile
|
|
9
|
+
from carebundle.core.bundle import to_json
|
|
10
|
+
from carebundle.generate import (
|
|
11
|
+
generate_bundle,
|
|
12
|
+
generate_cohort,
|
|
13
|
+
generate_draw,
|
|
14
|
+
generate_patient,
|
|
15
|
+
)
|
|
16
|
+
from carebundle.history import generate_history
|
|
17
|
+
from carebundle.imperfection import Defect, Imperfection, inject_defects
|
|
18
|
+
from carebundle.profiles.library import PROFILES
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"PROFILES",
|
|
24
|
+
"Defect",
|
|
25
|
+
"Imperfection",
|
|
26
|
+
"Quartiles",
|
|
27
|
+
"__version__",
|
|
28
|
+
"calibrate_profile",
|
|
29
|
+
"forget_profile",
|
|
30
|
+
"generate_bundle",
|
|
31
|
+
"generate_cohort",
|
|
32
|
+
"generate_draw",
|
|
33
|
+
"generate_history",
|
|
34
|
+
"generate_patient",
|
|
35
|
+
"inject_defects",
|
|
36
|
+
"to_json",
|
|
37
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Clinical quality measures computed over generated output.
|
|
2
|
+
|
|
3
|
+
This exists to answer one question with evidence rather than assertion: does the
|
|
4
|
+
generated population reproduce the quality-measure rates a real population produces?
|
|
5
|
+
|
|
6
|
+
The measures are computed from the emitted FHIR, not from the internal draw. That is
|
|
7
|
+
deliberate — a measure engine reading `ProfileDraw` would be marking its own homework,
|
|
8
|
+
and the thing users receive is the bundle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from carebundle.benchmark.cqm import (
|
|
12
|
+
MEASURES,
|
|
13
|
+
MeasureResult,
|
|
14
|
+
controlling_high_blood_pressure,
|
|
15
|
+
run_measure,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"MEASURES",
|
|
20
|
+
"MeasureResult",
|
|
21
|
+
"controlling_high_blood_pressure",
|
|
22
|
+
"run_measure",
|
|
23
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""CMS/HEDIS clinical quality measures, computed from generated bundles.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
Synthea's published validation (Chen J, Chun D, Patel M, Chiang E, James J, "The
|
|
6
|
+
validity of synthetic clinical data: a validation study of a leading synthetic data
|
|
7
|
+
generator (Synthea) using clinical quality measures", BMC Med Inform Decis Mak 2019)
|
|
8
|
+
measured Synthea against four CMS quality measures and found it tracks reality on the
|
|
9
|
+
*process* measure and collapses on every *outcome* measure:
|
|
10
|
+
|
|
11
|
+
Colorectal cancer screening 68.7% vs 69.8% US (process, close)
|
|
12
|
+
COPD 30-day mortality 0.7% vs 8.0% US (outcome)
|
|
13
|
+
Hip/knee complications 0.0% vs 2.8% US (outcome)
|
|
14
|
+
Controlling high blood pressure 0.0% vs 69.7% US (outcome)
|
|
15
|
+
|
|
16
|
+
The authors name the mechanism: synthetic generators "do not currently model for
|
|
17
|
+
deviations in care and the potential outcomes that may result from care deviations."
|
|
18
|
+
That is a statement about architecture. A state machine over care pathways decides
|
|
19
|
+
*whether a patient was screened*; it has no representation of what the blood pressure
|
|
20
|
+
did afterwards, so a control rate cannot emerge from it.
|
|
21
|
+
|
|
22
|
+
This project models clinical state directly, which is the machinery an outcome measure
|
|
23
|
+
needs, so this is the ground on which it can be compared and win.
|
|
24
|
+
|
|
25
|
+
Reading the numbers honestly
|
|
26
|
+
----------------------------
|
|
27
|
+
Denominators differ between sources and conflating them produces a wrong answer that
|
|
28
|
+
looks right:
|
|
29
|
+
|
|
30
|
+
* NHANES reports control over *all* adults with hypertension, including the unaware
|
|
31
|
+
and untreated, and since the 2017 ACC/AHA guideline it uses a **<130/80** threshold.
|
|
32
|
+
The August 2021-August 2023 figure is 20.7%.
|
|
33
|
+
* HEDIS/CMS `Controlling High Blood Pressure` (CBP) uses **<140/90** over a much
|
|
34
|
+
narrower denominator: members aged 18-85 with a *diagnosed* hypertension and an
|
|
35
|
+
outpatient encounter. That is the ~70% figure, and it is the one the Synthea
|
|
36
|
+
validation study used.
|
|
37
|
+
|
|
38
|
+
These profiles emit a coded hypertension diagnosis and an encounter, so they are the
|
|
39
|
+
HEDIS denominator, and <140/90 is the applicable threshold. Using NHANES's 20.7%
|
|
40
|
+
as the target here would be comparing against a different population and a different
|
|
41
|
+
cut-off.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from typing import Any
|
|
49
|
+
|
|
50
|
+
from carebundle.terminology import codes, systems
|
|
51
|
+
|
|
52
|
+
# LOINC panel and component codes for an office blood pressure.
|
|
53
|
+
_BP_PANEL = "85354-9"
|
|
54
|
+
_SYSTOLIC = codes.BP_SYSTOLIC.code
|
|
55
|
+
_DIASTOLIC = codes.BP_DIASTOLIC.code
|
|
56
|
+
|
|
57
|
+
# NCQA HEDIS CBP: adults 18-85 with diagnosed hypertension, controlled at <140/90.
|
|
58
|
+
CBP_SYSTOLIC_THRESHOLD = 140.0
|
|
59
|
+
CBP_DIASTOLIC_THRESHOLD = 90.0
|
|
60
|
+
CBP_MIN_AGE = 18
|
|
61
|
+
CBP_MAX_AGE = 85
|
|
62
|
+
|
|
63
|
+
HYPERTENSION_CODES = frozenset({codes.ESSENTIAL_HYPERTENSION.code})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class MeasureResult:
|
|
68
|
+
"""One measure evaluated over a population of bundles."""
|
|
69
|
+
|
|
70
|
+
measure: str
|
|
71
|
+
numerator: int
|
|
72
|
+
denominator: int
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def rate(self) -> float:
|
|
76
|
+
"""Proportion meeting the measure, or 0.0 when nobody qualifies.
|
|
77
|
+
|
|
78
|
+
A zero denominator is reported as a zero rate *and* a zero denominator, so a
|
|
79
|
+
measure nobody qualified for cannot be mistaken for a measure everybody failed.
|
|
80
|
+
That distinction is the whole difference between "not modelled" and "0%", and
|
|
81
|
+
it is the row most worth being honest about when comparing against Synthea.
|
|
82
|
+
"""
|
|
83
|
+
if self.denominator == 0:
|
|
84
|
+
return 0.0
|
|
85
|
+
return self.numerator / self.denominator
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resources(bundle: dict[str, Any], resource_type: str) -> list[dict[str, Any]]:
|
|
89
|
+
return [
|
|
90
|
+
entry["resource"]
|
|
91
|
+
for entry in bundle.get("entry", [])
|
|
92
|
+
if entry.get("resource", {}).get("resourceType") == resource_type
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _has_hypertension(bundle: dict[str, Any]) -> bool:
|
|
97
|
+
for condition in _resources(bundle, "Condition"):
|
|
98
|
+
for coding in condition.get("code", {}).get("coding", []):
|
|
99
|
+
if (
|
|
100
|
+
coding.get("system") == systems.ICD10CM
|
|
101
|
+
and coding.get("code") in HYPERTENSION_CODES
|
|
102
|
+
):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _blood_pressures(bundle: dict[str, Any]) -> list[tuple[float, float]]:
|
|
108
|
+
"""Every (systolic, diastolic) pair recorded in the bundle.
|
|
109
|
+
|
|
110
|
+
Reads the panel's components rather than trusting ordering, because a BP panel is
|
|
111
|
+
a single Observation with two components and their order is not guaranteed.
|
|
112
|
+
"""
|
|
113
|
+
readings: list[tuple[float, float]] = []
|
|
114
|
+
for observation in _resources(bundle, "Observation"):
|
|
115
|
+
panel = {
|
|
116
|
+
coding.get("code")
|
|
117
|
+
for coding in observation.get("code", {}).get("coding", [])
|
|
118
|
+
}
|
|
119
|
+
if _BP_PANEL not in panel:
|
|
120
|
+
continue
|
|
121
|
+
systolic = diastolic = None
|
|
122
|
+
for component in observation.get("component", []):
|
|
123
|
+
found = {
|
|
124
|
+
coding.get("code")
|
|
125
|
+
for coding in component.get("code", {}).get("coding", [])
|
|
126
|
+
}
|
|
127
|
+
value = component.get("valueQuantity", {}).get("value")
|
|
128
|
+
if value is None:
|
|
129
|
+
continue
|
|
130
|
+
if _SYSTOLIC in found:
|
|
131
|
+
systolic = float(value)
|
|
132
|
+
elif _DIASTOLIC in found:
|
|
133
|
+
diastolic = float(value)
|
|
134
|
+
if systolic is not None and diastolic is not None:
|
|
135
|
+
readings.append((systolic, diastolic))
|
|
136
|
+
return readings
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _age_years(bundle: dict[str, Any]) -> float | None:
|
|
140
|
+
"""Age at the encounter, from birthDate and the encounter period.
|
|
141
|
+
|
|
142
|
+
Returns None when either is missing rather than guessing — an unknown age must not
|
|
143
|
+
silently enter or leave a measure denominator.
|
|
144
|
+
"""
|
|
145
|
+
patients = _resources(bundle, "Patient")
|
|
146
|
+
encounters = _resources(bundle, "Encounter")
|
|
147
|
+
if not patients or not encounters:
|
|
148
|
+
return None
|
|
149
|
+
birth = patients[0].get("birthDate")
|
|
150
|
+
start = encounters[0].get("period", {}).get("start")
|
|
151
|
+
if not birth or not start:
|
|
152
|
+
return None
|
|
153
|
+
birth_year, birth_month, birth_day = (int(p) for p in birth.split("-"))
|
|
154
|
+
enc_year, enc_month, enc_day = (int(p) for p in start[:10].split("-"))
|
|
155
|
+
years = enc_year - birth_year
|
|
156
|
+
if (enc_month, enc_day) < (birth_month, birth_day):
|
|
157
|
+
years -= 1
|
|
158
|
+
return float(years)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def controlling_high_blood_pressure(bundle: dict[str, Any]) -> tuple[bool, bool]:
|
|
162
|
+
"""HEDIS CBP for one bundle: (in denominator, in numerator).
|
|
163
|
+
|
|
164
|
+
Denominator: age 18-85 with a coded hypertension diagnosis and a recorded BP.
|
|
165
|
+
Numerator: most recent BP below 140/90. Both components must be controlled;
|
|
166
|
+
an isolated diastolic elevation fails the measure.
|
|
167
|
+
"""
|
|
168
|
+
age = _age_years(bundle)
|
|
169
|
+
if age is None or not (CBP_MIN_AGE <= age <= CBP_MAX_AGE):
|
|
170
|
+
return False, False
|
|
171
|
+
if not _has_hypertension(bundle):
|
|
172
|
+
return False, False
|
|
173
|
+
|
|
174
|
+
readings = _blood_pressures(bundle)
|
|
175
|
+
if not readings:
|
|
176
|
+
return False, False
|
|
177
|
+
|
|
178
|
+
systolic, diastolic = readings[-1]
|
|
179
|
+
controlled = (
|
|
180
|
+
systolic < CBP_SYSTOLIC_THRESHOLD and diastolic < CBP_DIASTOLIC_THRESHOLD
|
|
181
|
+
)
|
|
182
|
+
return True, controlled
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
MEASURES: dict[str, Callable[[dict[str, Any]], tuple[bool, bool]]] = {
|
|
186
|
+
"controlling_high_blood_pressure": controlling_high_blood_pressure,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_measure(
|
|
191
|
+
measure: str, bundles: Iterable[dict[str, Any]] | Sequence[dict[str, Any]]
|
|
192
|
+
) -> MeasureResult:
|
|
193
|
+
"""Evaluate a named measure across a population of decoded bundles."""
|
|
194
|
+
if measure not in MEASURES:
|
|
195
|
+
raise KeyError(f"unknown measure {measure!r}; known: {sorted(MEASURES)}")
|
|
196
|
+
evaluate = MEASURES[measure]
|
|
197
|
+
|
|
198
|
+
numerator = denominator = 0
|
|
199
|
+
for bundle in bundles:
|
|
200
|
+
in_denominator, in_numerator = evaluate(bundle)
|
|
201
|
+
denominator += in_denominator
|
|
202
|
+
numerator += in_numerator
|
|
203
|
+
return MeasureResult(
|
|
204
|
+
measure=measure, numerator=numerator, denominator=denominator
|
|
205
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Condition, Observation and AllergyIntolerance builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from carebundle.core import uscore
|
|
8
|
+
from carebundle.core.safety import htest_meta, synthetic_narrative
|
|
9
|
+
from carebundle.models.r4 import (
|
|
10
|
+
AllergyIntolerance,
|
|
11
|
+
CodeableConcept,
|
|
12
|
+
Condition,
|
|
13
|
+
Observation,
|
|
14
|
+
ObservationComponent,
|
|
15
|
+
Quantity,
|
|
16
|
+
Reference,
|
|
17
|
+
)
|
|
18
|
+
from carebundle.terminology import codes
|
|
19
|
+
from carebundle.terminology.systems import UCUM
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ref(urn: str) -> Reference:
|
|
23
|
+
return Reference(reference=urn)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_condition(
|
|
27
|
+
*,
|
|
28
|
+
resource_id: str,
|
|
29
|
+
code: codes.Code,
|
|
30
|
+
subject_urn: str,
|
|
31
|
+
onset_date: str,
|
|
32
|
+
encounter_urn: str | None = None,
|
|
33
|
+
) -> Condition:
|
|
34
|
+
"""A US Core problem-list Condition.
|
|
35
|
+
|
|
36
|
+
Coded with ICD-10-CM. US Core's Condition code binding is extensible and admits
|
|
37
|
+
ICD-10-CM alongside SNOMED, which is what makes the no-SNOMED decision
|
|
38
|
+
(build doc Section 6) survivable here.
|
|
39
|
+
"""
|
|
40
|
+
return Condition(
|
|
41
|
+
id=resource_id,
|
|
42
|
+
meta=htest_meta(uscore.CONDITION_PROBLEMS),
|
|
43
|
+
text=synthetic_narrative(f"Condition: {code.display} (synthetic)."),
|
|
44
|
+
clinicalStatus=codes.CLINICAL_ACTIVE.concept(),
|
|
45
|
+
verificationStatus=codes.VERIFICATION_CONFIRMED.concept(),
|
|
46
|
+
category=[codes.CATEGORY_PROBLEM_LIST.concept()],
|
|
47
|
+
code=code.concept(),
|
|
48
|
+
subject=_ref(subject_urn),
|
|
49
|
+
encounter=_ref(encounter_urn) if encounter_urn else None,
|
|
50
|
+
onsetDateTime=onset_date,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_lab_observation(
|
|
55
|
+
*,
|
|
56
|
+
resource_id: str,
|
|
57
|
+
code: codes.Code,
|
|
58
|
+
subject_urn: str,
|
|
59
|
+
effective: str,
|
|
60
|
+
value: Decimal,
|
|
61
|
+
unit: tuple[str, str],
|
|
62
|
+
encounter_urn: str | None = None,
|
|
63
|
+
performer_urn: str | None = None,
|
|
64
|
+
) -> Observation:
|
|
65
|
+
"""A US Core laboratory result.
|
|
66
|
+
|
|
67
|
+
`unit` is (human display, UCUM code) — they differ often enough (mmHg vs mm[Hg])
|
|
68
|
+
that conflating them silently produces non-conformant output.
|
|
69
|
+
"""
|
|
70
|
+
display_unit, ucum_code = unit
|
|
71
|
+
return Observation(
|
|
72
|
+
id=resource_id,
|
|
73
|
+
meta=htest_meta(uscore.OBSERVATION_LAB),
|
|
74
|
+
text=synthetic_narrative(f"{code.display}: {value} {display_unit} (synthetic)."),
|
|
75
|
+
status="final",
|
|
76
|
+
category=[codes.CATEGORY_LABORATORY.concept()],
|
|
77
|
+
code=code.concept(),
|
|
78
|
+
subject=_ref(subject_urn),
|
|
79
|
+
encounter=_ref(encounter_urn) if encounter_urn else None,
|
|
80
|
+
effectiveDateTime=effective,
|
|
81
|
+
performer=[_ref(performer_urn)] if performer_urn else None,
|
|
82
|
+
valueQuantity=Quantity(
|
|
83
|
+
value=value, unit=display_unit, system=UCUM, code=ucum_code
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_blood_pressure(
|
|
89
|
+
*,
|
|
90
|
+
resource_id: str,
|
|
91
|
+
subject_urn: str,
|
|
92
|
+
effective: str,
|
|
93
|
+
systolic: Decimal,
|
|
94
|
+
diastolic: Decimal,
|
|
95
|
+
encounter_urn: str | None = None,
|
|
96
|
+
performer_urn: str | None = None,
|
|
97
|
+
) -> Observation:
|
|
98
|
+
"""A US Core blood pressure: one Observation with two components, never two
|
|
99
|
+
independent Observations. The panel code plus components is the profile's shape."""
|
|
100
|
+
display_unit, ucum_code = codes.UNIT_MMHG
|
|
101
|
+
|
|
102
|
+
def _component(code: codes.Code, value: Decimal) -> ObservationComponent:
|
|
103
|
+
return ObservationComponent(
|
|
104
|
+
code=code.concept(),
|
|
105
|
+
valueQuantity=Quantity(
|
|
106
|
+
value=value, unit=display_unit, system=UCUM, code=ucum_code
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return Observation(
|
|
111
|
+
id=resource_id,
|
|
112
|
+
meta=htest_meta(uscore.BLOOD_PRESSURE),
|
|
113
|
+
text=synthetic_narrative(
|
|
114
|
+
f"Blood pressure {systolic}/{diastolic} {display_unit} (synthetic)."
|
|
115
|
+
),
|
|
116
|
+
status="final",
|
|
117
|
+
category=[codes.CATEGORY_VITAL_SIGNS.concept()],
|
|
118
|
+
code=codes.BP_PANEL.concept(),
|
|
119
|
+
subject=_ref(subject_urn),
|
|
120
|
+
encounter=_ref(encounter_urn) if encounter_urn else None,
|
|
121
|
+
effectiveDateTime=effective,
|
|
122
|
+
performer=[_ref(performer_urn)] if performer_urn else None,
|
|
123
|
+
component=[
|
|
124
|
+
_component(codes.BP_SYSTOLIC, systolic),
|
|
125
|
+
_component(codes.BP_DIASTOLIC, diastolic),
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_vital_observation(
|
|
131
|
+
*,
|
|
132
|
+
resource_id: str,
|
|
133
|
+
code: codes.Code,
|
|
134
|
+
profile: str,
|
|
135
|
+
subject_urn: str,
|
|
136
|
+
effective: str,
|
|
137
|
+
value: Decimal,
|
|
138
|
+
unit: tuple[str, str],
|
|
139
|
+
encounter_urn: str | None = None,
|
|
140
|
+
performer_urn: str | None = None,
|
|
141
|
+
additional_codes: tuple[codes.Code, ...] = (),
|
|
142
|
+
) -> Observation:
|
|
143
|
+
"""A US Core vital-sign Observation (height, weight, BMI).
|
|
144
|
+
|
|
145
|
+
Same shape as a lab result but categorised vital-signs and asserting the specific
|
|
146
|
+
US Core vitals profile, whose value[x] must be a UCUM Quantity.
|
|
147
|
+
"""
|
|
148
|
+
display_unit, ucum_code = unit
|
|
149
|
+
# Some US Core vitals profiles slice Observation.code and require more than one
|
|
150
|
+
# coding — pulse oximetry needs both the method code and the base oxygensat code.
|
|
151
|
+
concept = CodeableConcept(
|
|
152
|
+
coding=[code.coding(), *(extra.coding() for extra in additional_codes)],
|
|
153
|
+
text=code.display,
|
|
154
|
+
)
|
|
155
|
+
return Observation(
|
|
156
|
+
id=resource_id,
|
|
157
|
+
meta=htest_meta(profile),
|
|
158
|
+
text=synthetic_narrative(f"{code.display}: {value} {display_unit} (synthetic)."),
|
|
159
|
+
status="final",
|
|
160
|
+
category=[codes.CATEGORY_VITAL_SIGNS.concept()],
|
|
161
|
+
code=concept,
|
|
162
|
+
subject=_ref(subject_urn),
|
|
163
|
+
encounter=_ref(encounter_urn) if encounter_urn else None,
|
|
164
|
+
effectiveDateTime=effective,
|
|
165
|
+
performer=[_ref(performer_urn)] if performer_urn else None,
|
|
166
|
+
valueQuantity=Quantity(
|
|
167
|
+
value=value, unit=display_unit, system=UCUM, code=ucum_code
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_allergy_intolerance(
|
|
173
|
+
*,
|
|
174
|
+
resource_id: str,
|
|
175
|
+
code: CodeableConcept,
|
|
176
|
+
patient_urn: str,
|
|
177
|
+
recorded_date: str,
|
|
178
|
+
) -> AllergyIntolerance:
|
|
179
|
+
return AllergyIntolerance(
|
|
180
|
+
id=resource_id,
|
|
181
|
+
meta=htest_meta(uscore.ALLERGY_INTOLERANCE),
|
|
182
|
+
text=synthetic_narrative("Allergy record (synthetic)."),
|
|
183
|
+
clinicalStatus=codes.ALLERGY_ACTIVE.concept(),
|
|
184
|
+
verificationStatus=codes.ALLERGY_CONFIRMED.concept(),
|
|
185
|
+
code=code,
|
|
186
|
+
patient=_ref(patient_urn),
|
|
187
|
+
recordedDate=recorded_date,
|
|
188
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Encounter, MedicationRequest and DiagnosticReport builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from carebundle.core import uscore
|
|
6
|
+
from carebundle.core.safety import htest_meta, synthetic_narrative
|
|
7
|
+
from carebundle.models.r4 import (
|
|
8
|
+
DiagnosticReport,
|
|
9
|
+
Encounter,
|
|
10
|
+
MedicationRequest,
|
|
11
|
+
Period,
|
|
12
|
+
Reference,
|
|
13
|
+
)
|
|
14
|
+
from carebundle.terminology import codes
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ref(urn: str) -> Reference:
|
|
18
|
+
return Reference(reference=urn)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_encounter(
|
|
22
|
+
*,
|
|
23
|
+
resource_id: str,
|
|
24
|
+
subject_urn: str,
|
|
25
|
+
start: str,
|
|
26
|
+
end: str,
|
|
27
|
+
type_concept,
|
|
28
|
+
) -> Encounter:
|
|
29
|
+
"""A US Core ambulatory Encounter.
|
|
30
|
+
|
|
31
|
+
`type_concept` is injected because US Core's Encounter.type value set draws on
|
|
32
|
+
CPT-4 and SNOMED CT — neither of which this project can ship (build doc Section 6).
|
|
33
|
+
Making it a caller-supplied argument keeps that constraint visible instead of
|
|
34
|
+
burying an unlicensed code in the library.
|
|
35
|
+
"""
|
|
36
|
+
return Encounter(
|
|
37
|
+
id=resource_id,
|
|
38
|
+
meta=htest_meta(uscore.ENCOUNTER),
|
|
39
|
+
text=synthetic_narrative("Ambulatory encounter (synthetic)."),
|
|
40
|
+
status="finished",
|
|
41
|
+
class_=codes.ENCOUNTER_AMBULATORY.coding(),
|
|
42
|
+
type=[type_concept],
|
|
43
|
+
subject=_ref(subject_urn),
|
|
44
|
+
period=Period(start=start, end=end),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_medication_request(
|
|
49
|
+
*,
|
|
50
|
+
resource_id: str,
|
|
51
|
+
medication: codes.Code,
|
|
52
|
+
subject_urn: str,
|
|
53
|
+
requester_urn: str,
|
|
54
|
+
authored_on: str,
|
|
55
|
+
encounter_urn: str | None = None,
|
|
56
|
+
) -> MedicationRequest:
|
|
57
|
+
"""A US Core MedicationRequest.
|
|
58
|
+
|
|
59
|
+
`requester_urn` is mandatory rather than optional: US Core requires a requester,
|
|
60
|
+
so allowing it to default to None would let non-conformant output be constructed.
|
|
61
|
+
"""
|
|
62
|
+
return MedicationRequest(
|
|
63
|
+
id=resource_id,
|
|
64
|
+
meta=htest_meta(uscore.MEDICATION_REQUEST),
|
|
65
|
+
text=synthetic_narrative(f"Prescription: {medication.display} (synthetic)."),
|
|
66
|
+
status="active",
|
|
67
|
+
intent="order",
|
|
68
|
+
medicationCodeableConcept=medication.concept(),
|
|
69
|
+
subject=_ref(subject_urn),
|
|
70
|
+
encounter=_ref(encounter_urn) if encounter_urn else None,
|
|
71
|
+
authoredOn=authored_on,
|
|
72
|
+
requester=_ref(requester_urn),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_diagnostic_report(
|
|
77
|
+
*,
|
|
78
|
+
resource_id: str,
|
|
79
|
+
code: codes.Code,
|
|
80
|
+
subject_urn: str,
|
|
81
|
+
effective: str,
|
|
82
|
+
issued: str,
|
|
83
|
+
result_urns: list[str],
|
|
84
|
+
performer_urn: str | None = None,
|
|
85
|
+
) -> DiagnosticReport:
|
|
86
|
+
"""A US Core laboratory DiagnosticReport tying together its member Observations.
|
|
87
|
+
|
|
88
|
+
`code` is the panel's own LOINC code. The generic "Laboratory report" document
|
|
89
|
+
code (11502-2) is excluded from US Core's lab test value set for good reason —
|
|
90
|
+
it describes the document, not what was measured.
|
|
91
|
+
"""
|
|
92
|
+
return DiagnosticReport(
|
|
93
|
+
id=resource_id,
|
|
94
|
+
meta=htest_meta(uscore.DIAGNOSTIC_REPORT_LAB),
|
|
95
|
+
text=synthetic_narrative("Laboratory report (synthetic)."),
|
|
96
|
+
status="final",
|
|
97
|
+
category=[codes.SERVICE_SECTION_LAB.concept()],
|
|
98
|
+
code=code.concept(),
|
|
99
|
+
subject=_ref(subject_urn),
|
|
100
|
+
effectiveDateTime=effective,
|
|
101
|
+
issued=issued,
|
|
102
|
+
performer=[_ref(performer_urn)] if performer_urn else None,
|
|
103
|
+
result=[_ref(urn) for urn in result_urns],
|
|
104
|
+
)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Patient and Practitioner builders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
from carebundle.core import uscore
|
|
8
|
+
from carebundle.core.safety import (
|
|
9
|
+
fictional_name,
|
|
10
|
+
htest_meta,
|
|
11
|
+
synthetic_mrn,
|
|
12
|
+
synthetic_narrative,
|
|
13
|
+
synthetic_npi,
|
|
14
|
+
)
|
|
15
|
+
from carebundle.models.r4 import Patient, Practitioner
|
|
16
|
+
|
|
17
|
+
SEX_TO_FHIR_GENDER = {"F": "female", "M": "male"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_patient(
|
|
21
|
+
*,
|
|
22
|
+
resource_id: str,
|
|
23
|
+
sex: str,
|
|
24
|
+
birth_date: date,
|
|
25
|
+
family_index: int,
|
|
26
|
+
given_index: int,
|
|
27
|
+
) -> Patient:
|
|
28
|
+
"""A US Core Patient. Callers supply every varying input, keeping this pure."""
|
|
29
|
+
if sex not in SEX_TO_FHIR_GENDER:
|
|
30
|
+
raise ValueError(f"sex must be one of {sorted(SEX_TO_FHIR_GENDER)}, got {sex!r}")
|
|
31
|
+
|
|
32
|
+
name = fictional_name(family_index=family_index, given_index=given_index)
|
|
33
|
+
gender = SEX_TO_FHIR_GENDER[sex]
|
|
34
|
+
|
|
35
|
+
return Patient(
|
|
36
|
+
id=resource_id,
|
|
37
|
+
meta=htest_meta(uscore.PATIENT),
|
|
38
|
+
text=synthetic_narrative(
|
|
39
|
+
f"{name.given[0]} {name.family}, {gender}, born {birth_date.isoformat()}."
|
|
40
|
+
),
|
|
41
|
+
identifier=[synthetic_mrn(resource_id[:8].upper())],
|
|
42
|
+
name=[name],
|
|
43
|
+
gender=gender,
|
|
44
|
+
birthDate=birth_date.isoformat(),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_practitioner(
|
|
49
|
+
*, resource_id: str, family_index: int, given_index: int
|
|
50
|
+
) -> Practitioner:
|
|
51
|
+
"""A US Core Practitioner.
|
|
52
|
+
|
|
53
|
+
Exists because US Core requires MedicationRequest.requester — see the Phase 1
|
|
54
|
+
notes in the build doc. Identifiers use the synthetic urn:uuid system rather than
|
|
55
|
+
the real NPI namespace: a checksum-valid NPI could collide with a real clinician.
|
|
56
|
+
"""
|
|
57
|
+
name = fictional_name(family_index=family_index, given_index=given_index)
|
|
58
|
+
return Practitioner(
|
|
59
|
+
id=resource_id,
|
|
60
|
+
meta=htest_meta(uscore.PRACTITIONER),
|
|
61
|
+
text=synthetic_narrative(f"Dr {name.given[0]} {name.family} (synthetic)."),
|
|
62
|
+
identifier=[synthetic_npi(resource_id[:10].upper())],
|
|
63
|
+
name=[name],
|
|
64
|
+
)
|
|
File without changes
|