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.
Files changed (47) hide show
  1. carebundle/__init__.py +37 -0
  2. carebundle/benchmark/__init__.py +23 -0
  3. carebundle/benchmark/cqm.py +205 -0
  4. carebundle/builders/__init__.py +0 -0
  5. carebundle/builders/clinical.py +188 -0
  6. carebundle/builders/orders.py +104 -0
  7. carebundle/builders/people.py +64 -0
  8. carebundle/calibration/__init__.py +0 -0
  9. carebundle/calibration/custom.py +231 -0
  10. carebundle/calibration/data/nhanes_targets.json +2445 -0
  11. carebundle/calibration/nhanes.py +356 -0
  12. carebundle/calibration/xpt.py +130 -0
  13. carebundle/cli.py +276 -0
  14. carebundle/conformance/__init__.py +0 -0
  15. carebundle/conformance/validator.py +162 -0
  16. carebundle/core/__init__.py +0 -0
  17. carebundle/core/bundle.py +110 -0
  18. carebundle/core/ids.py +41 -0
  19. carebundle/core/safety.py +115 -0
  20. carebundle/core/uscore.py +27 -0
  21. carebundle/correlation/__init__.py +0 -0
  22. carebundle/correlation/distributions.py +358 -0
  23. carebundle/correlation/engine.py +84 -0
  24. carebundle/correlation/relations.py +312 -0
  25. carebundle/fidelity/__init__.py +0 -0
  26. carebundle/fidelity/report.py +472 -0
  27. carebundle/generate.py +462 -0
  28. carebundle/history.py +260 -0
  29. carebundle/imperfection/__init__.py +30 -0
  30. carebundle/imperfection/defects.py +250 -0
  31. carebundle/models/__init__.py +0 -0
  32. carebundle/models/r4.py +1046 -0
  33. carebundle/profiles/__init__.py +0 -0
  34. carebundle/profiles/base.py +219 -0
  35. carebundle/profiles/library.py +657 -0
  36. carebundle/py.typed +0 -0
  37. carebundle/spec/__init__.py +0 -0
  38. carebundle/spec/codegen.py +307 -0
  39. carebundle/terminology/__init__.py +0 -0
  40. carebundle/terminology/codes.py +398 -0
  41. carebundle/terminology/systems.py +28 -0
  42. carebundle/terminology/verify.py +174 -0
  43. carebundle-0.1.0.dist-info/METADATA +511 -0
  44. carebundle-0.1.0.dist-info/RECORD +47 -0
  45. carebundle-0.1.0.dist-info/WHEEL +4 -0
  46. carebundle-0.1.0.dist-info/entry_points.txt +2 -0
  47. 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