sensor-modeling 0.2.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.
- sensor_modeling/__init__.py +45 -0
- sensor_modeling/alerts/__init__.py +26 -0
- sensor_modeling/alerts/alert.py +532 -0
- sensor_modeling/analysis/__init__.py +43 -0
- sensor_modeling/analysis/_frame.py +19 -0
- sensor_modeling/analysis/behavioral_analysis.py +57 -0
- sensor_modeling/analysis/behavioral_metrics.py +66 -0
- sensor_modeling/analysis/comparison.py +164 -0
- sensor_modeling/analysis/dependency_network.py +408 -0
- sensor_modeling/analysis/granger_causality.py +314 -0
- sensor_modeling/analysis/pipeline.py +168 -0
- sensor_modeling/analysis/reporting.py +109 -0
- sensor_modeling/baseline/__init__.py +30 -0
- sensor_modeling/baseline/adaptive.py +520 -0
- sensor_modeling/baseline/features.py +224 -0
- sensor_modeling/change_point/__init__.py +13 -0
- sensor_modeling/change_point/_validation.py +31 -0
- sensor_modeling/change_point/adaptive_normalization.py +55 -0
- sensor_modeling/change_point/embedding_cpd.py +60 -0
- sensor_modeling/change_point/energy_efficient.py +57 -0
- sensor_modeling/change_point/genetic_optimization.py +65 -0
- sensor_modeling/cli.py +416 -0
- sensor_modeling/context/__init__.py +33 -0
- sensor_modeling/context/occupancy.py +529 -0
- sensor_modeling/data/__init__.py +5 -0
- sensor_modeling/data/loaders.py +146 -0
- sensor_modeling/data/preprocessing.py +83 -0
- sensor_modeling/data/synthetic.py +121 -0
- sensor_modeling/data/validation.py +81 -0
- sensor_modeling/evaluation/__init__.py +92 -0
- sensor_modeling/evaluation/ablation.py +303 -0
- sensor_modeling/evaluation/attribution.py +474 -0
- sensor_modeling/evaluation/detection.py +297 -0
- sensor_modeling/evaluation/metrics.py +541 -0
- sensor_modeling/evaluation/provenance.py +309 -0
- sensor_modeling/examples/__init__.py +1 -0
- sensor_modeling/examples/demos/__init__.py +1 -0
- sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
- sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
- sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
- sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
- sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
- sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
- sensor_modeling/examples/tutorials/__init__.py +1 -0
- sensor_modeling/fusion/__init__.py +46 -0
- sensor_modeling/fusion/defaults.py +296 -0
- sensor_modeling/fusion/emissions.py +339 -0
- sensor_modeling/fusion/estimate.py +375 -0
- sensor_modeling/fusion/filter.py +323 -0
- sensor_modeling/health/__init__.py +31 -0
- sensor_modeling/health/monitor.py +590 -0
- sensor_modeling/health/status.py +74 -0
- sensor_modeling/hmm/__init__.py +15 -0
- sensor_modeling/hmm/adaptive_hmm.py +22 -0
- sensor_modeling/hmm/base.py +134 -0
- sensor_modeling/hmm/circadian_hmm.py +22 -0
- sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
- sensor_modeling/hmm/hierarchical_hmm.py +35 -0
- sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
- sensor_modeling/interop/__init__.py +57 -0
- sensor_modeling/interop/fhir.py +418 -0
- sensor_modeling/interop/privacy.py +308 -0
- sensor_modeling/models/__init__.py +12 -0
- sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
- sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
- sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
- sensor_modeling/models/change_point_detection/__init__.py +10 -0
- sensor_modeling/models/change_point_detection/deep.py +65 -0
- sensor_modeling/models/change_point_detection/pelt.py +159 -0
- sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
- sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
- sensor_modeling/models/nhpp_pelt/cli.py +243 -0
- sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
- sensor_modeling/models/nhpp_pelt/io.py +58 -0
- sensor_modeling/models/nhpp_pelt/model.py +408 -0
- sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
- sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
- sensor_modeling/models/nhpp_pelt/quad.py +72 -0
- sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
- sensor_modeling/models/nhpp_pelt/utils.py +174 -0
- sensor_modeling/observations/__init__.py +59 -0
- sensor_modeling/observations/adapters.py +195 -0
- sensor_modeling/observations/ingest.py +269 -0
- sensor_modeling/observations/observation.py +270 -0
- sensor_modeling/observations/registry.py +262 -0
- sensor_modeling/observations/stream.py +342 -0
- sensor_modeling/observations/types.py +107 -0
- sensor_modeling/observations/units.py +117 -0
- sensor_modeling/online/__init__.py +36 -0
- sensor_modeling/online/benchmarks.py +242 -0
- sensor_modeling/online/pipeline.py +485 -0
- sensor_modeling/simulation/__init__.py +54 -0
- sensor_modeling/simulation/faults.py +191 -0
- sensor_modeling/simulation/household.py +862 -0
- sensor_modeling/states/__init__.py +23 -0
- sensor_modeling/states/markov.py +105 -0
- sensor_modeling/states/ontology.py +238 -0
- sensor_modeling/utils/__init__.py +41 -0
- sensor_modeling/utils/data_io.py +199 -0
- sensor_modeling/utils/logging_config.py +10 -0
- sensor_modeling/utils/missing.py +188 -0
- sensor_modeling/utils/plotting.py +98 -0
- sensor_modeling/utils/validation.py +117 -0
- sensor_modeling/visualization/__init__.py +3 -0
- sensor_modeling/visualization/clinical.py +67 -0
- sensor_modeling/visualization/interactive.py +208 -0
- sensor_modeling/visualization/research.py +60 -0
- sensor_modeling/visualization/web_app.py +137 -0
- sensor_modeling-0.2.0.dist-info/METADATA +683 -0
- sensor_modeling-0.2.0.dist-info/RECORD +114 -0
- sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
- sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
- sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
- sensor_modeling-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""FHIR-style export that keeps measurement and inference distinguishable.
|
|
2
|
+
|
|
3
|
+
The hazard this module exists to avoid is specific. Once a behavioural
|
|
4
|
+
conclusion is written into a clinical record it looks like every other entry
|
|
5
|
+
there, and a downstream reader has no way to tell that ``sleeping`` was
|
|
6
|
+
inferred by a Markov filter from a bed sensor and a wearable rather than
|
|
7
|
+
measured. Exporting inferred states as though they were observations is how a
|
|
8
|
+
research prototype ends up quoted as a clinical fact.
|
|
9
|
+
|
|
10
|
+
So the four kinds this platform keeps separate stay separate on the way out:
|
|
11
|
+
|
|
12
|
+
.. code-block:: text
|
|
13
|
+
|
|
14
|
+
measured observation -> Observation, status "final",
|
|
15
|
+
derived from a physical Device
|
|
16
|
+
derived feature -> Observation, status "final", but carrying the
|
|
17
|
+
upstream device's own confidence
|
|
18
|
+
inferred state -> Observation, status "preliminary", with an
|
|
19
|
+
explicit method, the full posterior as
|
|
20
|
+
components, and derivedFrom provenance
|
|
21
|
+
algorithmic alert -> DetectedIssue, never an Observation
|
|
22
|
+
|
|
23
|
+
Every exported resource carries provenance: what produced it, from what, when,
|
|
24
|
+
and with what quality. Inferred resources additionally carry the coverage and
|
|
25
|
+
attribution behind them, and a note recording that they are algorithmic
|
|
26
|
+
output from a research toolkit.
|
|
27
|
+
|
|
28
|
+
.. warning::
|
|
29
|
+
|
|
30
|
+
This is a **FHIR-style** export for interoperability prototyping. It is not
|
|
31
|
+
a validated FHIR profile, it has not been conformance-tested against a FHIR
|
|
32
|
+
server, and none of the codes below come from a recognised terminology. Do
|
|
33
|
+
not present its output as clinically validated data.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
from ..alerts.alert import Alert, AlertKind, AlertSeverity
|
|
44
|
+
from ..fusion.estimate import StateEstimate
|
|
45
|
+
from ..observations.observation import Observation
|
|
46
|
+
from ..observations.registry import SensorRegistry
|
|
47
|
+
from ..observations.types import ObservationKind
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
#: Local code system for this toolkit's own vocabulary. Deliberately a
|
|
52
|
+
#: project URI rather than a borrowed clinical one: these codes have no
|
|
53
|
+
#: standardised meaning and must not be mistaken for LOINC or SNOMED.
|
|
54
|
+
CODE_SYSTEM = "https://github.com/DiogoRibeiro7/behavioral-sensing-research/codes"
|
|
55
|
+
|
|
56
|
+
#: Fixed disclaimer attached to every inferred resource.
|
|
57
|
+
RESEARCH_NOTE = (
|
|
58
|
+
"Algorithmically inferred from ambient sensor data by the sensor-modeling "
|
|
59
|
+
"research toolkit. Not a measured clinical observation, not a diagnosis, "
|
|
60
|
+
"and not the output of a medical device."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: How an exported resource was produced.
|
|
64
|
+
PROVENANCE_MEASURED = "measured"
|
|
65
|
+
PROVENANCE_DERIVED_FEATURE = "derived-feature"
|
|
66
|
+
PROVENANCE_INFERRED = "inferred"
|
|
67
|
+
|
|
68
|
+
_SEVERITY_TO_FHIR = {
|
|
69
|
+
AlertSeverity.INFORMATION: "low",
|
|
70
|
+
AlertSeverity.ATTENTION: "moderate",
|
|
71
|
+
AlertSeverity.URGENT: "high",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _instant(moment: datetime) -> str:
|
|
76
|
+
"""Render a timezone-aware instant in the FHIR ``instant`` format."""
|
|
77
|
+
return moment.isoformat()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _coding(code: str, display: str) -> dict[str, Any]:
|
|
81
|
+
"""Build a CodeableConcept in this toolkit's own code system."""
|
|
82
|
+
return {
|
|
83
|
+
"coding": [{"system": CODE_SYSTEM, "code": code, "display": display}],
|
|
84
|
+
"text": display,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _provenance_extension(kind: str, detail: str) -> dict[str, Any]:
|
|
89
|
+
"""Build the extension that records how a resource was produced.
|
|
90
|
+
|
|
91
|
+
This is the load-bearing piece of the export. A reader that understands
|
|
92
|
+
nothing else about these resources can still read this and know whether
|
|
93
|
+
they are looking at a measurement or at a conclusion.
|
|
94
|
+
"""
|
|
95
|
+
return {
|
|
96
|
+
"url": f"{CODE_SYSTEM}/provenance",
|
|
97
|
+
"extension": [
|
|
98
|
+
{"url": "kind", "valueCode": kind},
|
|
99
|
+
{"url": "detail", "valueString": detail},
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def observation_resource(
|
|
105
|
+
observation: Observation, registry: SensorRegistry | None = None
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Export one sensor observation as a FHIR-style ``Observation``.
|
|
108
|
+
|
|
109
|
+
A record whose ``confidence`` is below one is exported as a *derived
|
|
110
|
+
feature* rather than a measurement, because something upstream computed
|
|
111
|
+
it and attached its own uncertainty.
|
|
112
|
+
"""
|
|
113
|
+
spec = registry.get(observation.sensor_id) if registry is not None else None
|
|
114
|
+
derived = observation.confidence < 1.0
|
|
115
|
+
kind = PROVENANCE_DERIVED_FEATURE if derived else PROVENANCE_MEASURED
|
|
116
|
+
description = (
|
|
117
|
+
spec.description
|
|
118
|
+
if spec is not None and spec.description
|
|
119
|
+
else f"{observation.modality.value} sensor reading"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
resource: dict[str, Any] = {
|
|
123
|
+
"resourceType": "Observation",
|
|
124
|
+
"id": f"obs-{observation.sensor_id}-{int(observation.timestamp.timestamp())}",
|
|
125
|
+
"status": "final",
|
|
126
|
+
"category": [_coding("device-measurement", "Device measurement")],
|
|
127
|
+
"code": _coding(
|
|
128
|
+
f"sensor.{observation.modality.value}",
|
|
129
|
+
description,
|
|
130
|
+
),
|
|
131
|
+
"effectiveDateTime": _instant(observation.timestamp),
|
|
132
|
+
"valueQuantity": {
|
|
133
|
+
"value": observation.value,
|
|
134
|
+
"unit": observation.unit.value,
|
|
135
|
+
"system": f"{CODE_SYSTEM}/units",
|
|
136
|
+
"code": observation.unit.value,
|
|
137
|
+
},
|
|
138
|
+
"device": {"display": observation.sensor_id},
|
|
139
|
+
"extension": [
|
|
140
|
+
_provenance_extension(
|
|
141
|
+
kind,
|
|
142
|
+
(
|
|
143
|
+
"Feature computed by the reporting device; the value is an "
|
|
144
|
+
"estimate, not a direct measurement."
|
|
145
|
+
if derived
|
|
146
|
+
else "Direct sensor measurement."
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
{
|
|
150
|
+
"url": f"{CODE_SYSTEM}/quality",
|
|
151
|
+
"extension": [
|
|
152
|
+
{"url": "quality", "valueDecimal": observation.quality},
|
|
153
|
+
{"url": "confidence", "valueDecimal": observation.confidence},
|
|
154
|
+
{"url": "kind", "valueCode": observation.kind.value},
|
|
155
|
+
{"url": "source", "valueString": observation.source},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
}
|
|
160
|
+
if spec is not None and spec.room:
|
|
161
|
+
resource["bodySite"] = _coding(f"room.{spec.room}", spec.room)
|
|
162
|
+
if observation.flags:
|
|
163
|
+
resource["extension"].append(
|
|
164
|
+
{
|
|
165
|
+
"url": f"{CODE_SYSTEM}/ingestion-flags",
|
|
166
|
+
"valueString": ",".join(
|
|
167
|
+
sorted(flag.value for flag in observation.flags)
|
|
168
|
+
),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
# An event stream's silence is not a zero, so the export says so rather
|
|
172
|
+
# than leaving a reader to infer a value for the gaps.
|
|
173
|
+
if observation.kind is ObservationKind.EVENT:
|
|
174
|
+
resource["extension"].append(
|
|
175
|
+
{
|
|
176
|
+
"url": f"{CODE_SYSTEM}/event-semantics",
|
|
177
|
+
"valueString": (
|
|
178
|
+
"Event stream: absence of a record is absence of evidence, "
|
|
179
|
+
"not an observation of zero."
|
|
180
|
+
),
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
return resource
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def state_resource(
|
|
187
|
+
estimate: StateEstimate,
|
|
188
|
+
*,
|
|
189
|
+
subject: str = "Patient/example",
|
|
190
|
+
attribution: float | None = None,
|
|
191
|
+
) -> dict[str, Any]:
|
|
192
|
+
"""Export an inferred behavioural state as a FHIR-style ``Observation``.
|
|
193
|
+
|
|
194
|
+
Marked ``preliminary`` rather than ``final``, given an explicit ``method``
|
|
195
|
+
describing the inference, and accompanied by the whole posterior as
|
|
196
|
+
components. A reader is never shown only the winning label.
|
|
197
|
+
|
|
198
|
+
An abstaining estimate exports a ``dataAbsentReason`` instead of a value,
|
|
199
|
+
which is the correct FHIR idiom for "we do not know" and stops
|
|
200
|
+
``unknown`` from being read as a behavioural finding.
|
|
201
|
+
"""
|
|
202
|
+
resource: dict[str, Any] = {
|
|
203
|
+
"resourceType": "Observation",
|
|
204
|
+
"id": f"state-{int(estimate.at.timestamp())}",
|
|
205
|
+
"status": "preliminary",
|
|
206
|
+
"category": [_coding("activity", "Activity")],
|
|
207
|
+
"code": _coding("behavioural-state", "Inferred behavioural state"),
|
|
208
|
+
"subject": {"reference": subject},
|
|
209
|
+
"effectiveDateTime": _instant(estimate.at),
|
|
210
|
+
"method": _coding(
|
|
211
|
+
"multimodal-bayes-filter",
|
|
212
|
+
"Recursive Bayesian fusion of multimodal ambient sensor evidence",
|
|
213
|
+
),
|
|
214
|
+
"note": [{"text": RESEARCH_NOTE}],
|
|
215
|
+
"extension": [
|
|
216
|
+
_provenance_extension(
|
|
217
|
+
PROVENANCE_INFERRED,
|
|
218
|
+
"Posterior over latent behavioural states; not a measurement.",
|
|
219
|
+
),
|
|
220
|
+
{
|
|
221
|
+
"url": f"{CODE_SYSTEM}/inference-quality",
|
|
222
|
+
"extension": [
|
|
223
|
+
{"url": "confidence", "valueDecimal": estimate.confidence},
|
|
224
|
+
{"url": "completeness", "valueDecimal": estimate.completeness},
|
|
225
|
+
{
|
|
226
|
+
"url": "normalisedEntropy",
|
|
227
|
+
"valueDecimal": estimate.normalised_entropy,
|
|
228
|
+
},
|
|
229
|
+
{"url": "abstained", "valueBoolean": estimate.abstained},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
"component": [
|
|
234
|
+
{
|
|
235
|
+
"code": _coding(f"state.{state.value}", state.value),
|
|
236
|
+
"valueQuantity": {"value": probability, "unit": "probability"},
|
|
237
|
+
}
|
|
238
|
+
for state, probability in estimate.probabilities.items()
|
|
239
|
+
],
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if estimate.abstained:
|
|
243
|
+
resource["dataAbsentReason"] = _coding(
|
|
244
|
+
"insufficient-evidence",
|
|
245
|
+
"Insufficient or unreliable sensor evidence to infer a state",
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
resource["valueCodeableConcept"] = _coding(
|
|
249
|
+
f"state.{estimate.state.value}", estimate.state.value
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
if attribution is not None:
|
|
253
|
+
resource["extension"].append(
|
|
254
|
+
{
|
|
255
|
+
"url": f"{CODE_SYSTEM}/attribution",
|
|
256
|
+
"extension": [
|
|
257
|
+
{"url": "residentProbability", "valueDecimal": attribution},
|
|
258
|
+
{
|
|
259
|
+
"url": "note",
|
|
260
|
+
"valueString": (
|
|
261
|
+
"Probability that the monitored resident, rather "
|
|
262
|
+
"than another person present, generated the "
|
|
263
|
+
"ambient evidence behind this estimate."
|
|
264
|
+
),
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
contributing = [c.sensor_id for c in estimate.evidence if c.informative]
|
|
271
|
+
if contributing:
|
|
272
|
+
resource["derivedFrom"] = [
|
|
273
|
+
{"display": sensor_id} for sensor_id in sorted(contributing)
|
|
274
|
+
]
|
|
275
|
+
if estimate.missing:
|
|
276
|
+
resource["extension"].append(
|
|
277
|
+
{
|
|
278
|
+
"url": f"{CODE_SYSTEM}/absent-evidence",
|
|
279
|
+
"valueString": ",".join(sorted(estimate.missing)),
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
return resource
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def alert_resource(alert: Alert, *, subject: str = "Patient/example") -> dict[str, Any]:
|
|
286
|
+
"""Export an alert as a FHIR-style ``DetectedIssue``.
|
|
287
|
+
|
|
288
|
+
Deliberately **not** an ``Observation``. An alert is an algorithmic
|
|
289
|
+
judgement that something warrants attention, not a record of anything
|
|
290
|
+
being observed, and giving it the same resource type as a measurement is
|
|
291
|
+
precisely the conflation this module exists to prevent.
|
|
292
|
+
"""
|
|
293
|
+
resource: dict[str, Any] = {
|
|
294
|
+
"resourceType": "DetectedIssue",
|
|
295
|
+
"id": f"alert-{alert.identifier}",
|
|
296
|
+
"status": "preliminary",
|
|
297
|
+
"severity": _SEVERITY_TO_FHIR[alert.severity],
|
|
298
|
+
"code": _coding(f"alert.{alert.kind.value}", alert.kind.value),
|
|
299
|
+
"identifiedDateTime": _instant(alert.at),
|
|
300
|
+
"detail": alert.summary,
|
|
301
|
+
"author": {"display": "sensor-modeling research toolkit"},
|
|
302
|
+
"extension": [
|
|
303
|
+
_provenance_extension(
|
|
304
|
+
PROVENANCE_INFERRED,
|
|
305
|
+
"Algorithmic alert derived from inferred behaviour.",
|
|
306
|
+
),
|
|
307
|
+
{
|
|
308
|
+
"url": f"{CODE_SYSTEM}/alert-grading",
|
|
309
|
+
"extension": [
|
|
310
|
+
{"url": "score", "valueDecimal": alert.score},
|
|
311
|
+
{"url": "confidence", "valueDecimal": alert.confidence},
|
|
312
|
+
{"url": "subjectOfAlert", "valueString": alert.subject},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
],
|
|
316
|
+
"note": [{"text": RESEARCH_NOTE}],
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if alert.kind is AlertKind.SYSTEM_HEALTH:
|
|
320
|
+
# A failing sensor is an equipment issue. Attaching it to a patient
|
|
321
|
+
# would make a maintenance problem look like a clinical finding.
|
|
322
|
+
resource["code"] = _coding("alert.system-health", "Sensing system health")
|
|
323
|
+
else:
|
|
324
|
+
resource["patient"] = {"reference": subject}
|
|
325
|
+
|
|
326
|
+
if alert.caveats:
|
|
327
|
+
resource["mitigation"] = [
|
|
328
|
+
{"action": _coding("caveat", caveat)} for caveat in alert.caveats
|
|
329
|
+
]
|
|
330
|
+
return resource
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def bundle(
|
|
334
|
+
*,
|
|
335
|
+
observations: Iterable[Observation] = (),
|
|
336
|
+
estimates: Iterable[StateEstimate] = (),
|
|
337
|
+
alerts: Iterable[Alert] = (),
|
|
338
|
+
registry: SensorRegistry | None = None,
|
|
339
|
+
subject: str = "Patient/example",
|
|
340
|
+
attribution: Mapping[datetime, float] | None = None,
|
|
341
|
+
) -> dict[str, Any]:
|
|
342
|
+
"""Assemble a FHIR-style ``Bundle`` of the requested resources.
|
|
343
|
+
|
|
344
|
+
Parameters
|
|
345
|
+
----------
|
|
346
|
+
observations, estimates, alerts
|
|
347
|
+
The measurements, inferences and alerts to export. Each is rendered
|
|
348
|
+
with the provenance appropriate to its kind.
|
|
349
|
+
registry
|
|
350
|
+
Sensor declarations, used to label observations with their room and
|
|
351
|
+
documented semantics.
|
|
352
|
+
subject
|
|
353
|
+
Reference for the monitored person.
|
|
354
|
+
attribution
|
|
355
|
+
Optional per-estimate attribution probability, keyed by estimate time.
|
|
356
|
+
"""
|
|
357
|
+
entries: list[dict[str, Any]] = []
|
|
358
|
+
for observation in observations:
|
|
359
|
+
entries.append({"resource": observation_resource(observation, registry)})
|
|
360
|
+
for estimate in estimates:
|
|
361
|
+
share = attribution.get(estimate.at) if attribution is not None else None
|
|
362
|
+
entries.append(
|
|
363
|
+
{"resource": state_resource(estimate, subject=subject, attribution=share)}
|
|
364
|
+
)
|
|
365
|
+
for alert in alerts:
|
|
366
|
+
entries.append({"resource": alert_resource(alert, subject=subject)})
|
|
367
|
+
|
|
368
|
+
logger.info("Assembled FHIR-style bundle with %d entries", len(entries))
|
|
369
|
+
return {
|
|
370
|
+
"resourceType": "Bundle",
|
|
371
|
+
"type": "collection",
|
|
372
|
+
"entry": entries,
|
|
373
|
+
"meta": {
|
|
374
|
+
"tag": [
|
|
375
|
+
{
|
|
376
|
+
"system": CODE_SYSTEM,
|
|
377
|
+
"code": "research-prototype",
|
|
378
|
+
"display": (
|
|
379
|
+
"FHIR-style export from a research toolkit. Not a "
|
|
380
|
+
"validated profile and not clinically validated data."
|
|
381
|
+
),
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
},
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def summarise_provenance(payload: Mapping[str, Any]) -> dict[str, int]:
|
|
389
|
+
"""Count how many resources in a bundle are measured versus inferred.
|
|
390
|
+
|
|
391
|
+
Provided so a consumer can assert, in one line, that it has not been
|
|
392
|
+
handed inferences dressed as measurements.
|
|
393
|
+
"""
|
|
394
|
+
counts: dict[str, int] = {}
|
|
395
|
+
for entry in payload.get("entry", []):
|
|
396
|
+
resource = entry.get("resource", {})
|
|
397
|
+
for extension in resource.get("extension", []):
|
|
398
|
+
if extension.get("url") != f"{CODE_SYSTEM}/provenance":
|
|
399
|
+
continue
|
|
400
|
+
for inner in extension.get("extension", []):
|
|
401
|
+
if inner.get("url") == "kind":
|
|
402
|
+
kind = str(inner.get("valueCode"))
|
|
403
|
+
counts[kind] = counts.get(kind, 0) + 1
|
|
404
|
+
return counts
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def measured_only(payload: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
|
|
408
|
+
"""Return only the genuinely measured resources from a bundle."""
|
|
409
|
+
return [
|
|
410
|
+
entry["resource"]
|
|
411
|
+
for entry in payload.get("entry", [])
|
|
412
|
+
if any(
|
|
413
|
+
inner.get("valueCode") == PROVENANCE_MEASURED
|
|
414
|
+
for extension in entry.get("resource", {}).get("extension", [])
|
|
415
|
+
if extension.get("url") == f"{CODE_SYSTEM}/provenance"
|
|
416
|
+
for inner in extension.get("extension", [])
|
|
417
|
+
)
|
|
418
|
+
]
|