dirigent-integration 0.23.2__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.
- dirigent_integration/__init__.py +29 -0
- dirigent_integration/py.typed +0 -0
- dirigent_integration/shelves/dhis2-analytics-to-csv-report.yaml +174 -0
- dirigent_integration/shelves/dhis2-export-to-s3.yaml +66 -0
- dirigent_integration/shelves/dhis2-metadata-snapshot.yaml +143 -0
- dirigent_integration/shelves/dhis2-tracker-weekly-window.yaml +162 -0
- dirigent_integration/shelves/dhis2-values-per-org-unit-to-parquet.yaml +186 -0
- dirigent_integration/shelves/dhis2-values-to-parquet.yaml +64 -0
- dirigent_integration/shelves/fhir/README.md +79 -0
- dirigent_integration/shelves/fhir/dhis2-to-fhir-observations.yaml +212 -0
- dirigent_integration/shelves/fhir/fhir-capture-bundle-to-data-values.yaml +230 -0
- dirigent_integration/shelves/fhir/fhir-conceptmap-driven-mapping.yaml +222 -0
- dirigent_integration/shelves/fhir/fhir-encounter-to-event.yaml +248 -0
- dirigent_integration/shelves/fhir/fhir-measure-report-to-analytics-check.yaml +205 -0
- dirigent_integration/shelves/fhir/fhir-nightly-window-sync.yaml +182 -0
- dirigent_integration/shelves/fhir/fhir-patient-to-tracked-entity.yaml +225 -0
- dirigent_integration/shelves/fhir/fhir-questionnaire-response-to-data-values.yaml +187 -0
- dirigent_integration/shelves/fhir/fhir-subscription-webhook-to-dhis2.yaml +218 -0
- dirigent_integration/shelves/inbound/README.md +32 -0
- dirigent_integration/shelves/inbound/csv-drop-to-data-values.yaml +176 -0
- dirigent_integration/shelves/inbound/fan-out-per-facility-import.yaml +202 -0
- dirigent_integration/shelves/inbound/fhir-observations-to-data-values.yaml +193 -0
- dirigent_integration/shelves/inbound/http-json-to-data-values.yaml +151 -0
- dirigent_integration/shelves/inbound/outside-to-dhis2-with-checks.yaml +246 -0
- dirigent_integration/shelves/inbound/parquet-lakehouse-to-dhis2.yaml +158 -0
- dirigent_integration/shelves/inbound/webhook-payload-to-tracker-event.yaml +176 -0
- dirigent_integration/shelves/inbound/weekly-window-pull-and-import.yaml +175 -0
- dirigent_integration/shelves/parquet-to-dhis2-import.yaml +195 -0
- dirigent_integration-0.23.2.dist-info/METADATA +231 -0
- dirigent_integration-0.23.2.dist-info/RECORD +33 -0
- dirigent_integration-0.23.2.dist-info/WHEEL +4 -0
- dirigent_integration-0.23.2.dist-info/entry_points.txt +3 -0
- dirigent_integration-0.23.2.dist-info/licenses/LICENSE +15 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# One clinical Encounter and its Observations collapsed into one DHIS2 program stage event.
|
|
2
|
+
#
|
|
3
|
+
# THE FLOW, END TO END. Find an Encounter on a general-purpose FHIR server, read every
|
|
4
|
+
# Observation that names it, fold the two into a single `/api/tracker` event whose data values
|
|
5
|
+
# are the observations, and post it with importMode=VALIDATE.
|
|
6
|
+
#
|
|
7
|
+
# WHY NOT THE FACADE. `d2w fhir serve` serves no Encounter and no Observation -- it publishes
|
|
8
|
+
# DHIS2 as forms and captures, so its whole clinical surface is the capture pair, and asking
|
|
9
|
+
# it for an Observation answers an OperationOutcome with code `not-supported`. Encounters come
|
|
10
|
+
# from the other kind of FHIR server: a hospital's own, an OpenMRS, a HAPI. That is the point
|
|
11
|
+
# of this example, so `fhir_base` defaults to the public HAPI R4 test server. It is a shared
|
|
12
|
+
# sandbox anyone can write to, so the resources it holds change under you; every id below is a
|
|
13
|
+
# parameter for that reason, and the searches are written to survive not finding one.
|
|
14
|
+
#
|
|
15
|
+
# THE MODELLING QUESTION THIS ANSWERS. FHIR splits a visit into one Encounter plus one
|
|
16
|
+
# Observation per measurement. DHIS2 tracker puts the whole visit in one event: an event has
|
|
17
|
+
# an occurredAt, an org unit, an enrollment, and a bag of dataValues. So the cardinalities do
|
|
18
|
+
# not line up, and the translation is a FOLD -- many Observations into one event -- keyed on
|
|
19
|
+
# Observation.encounter. Nothing else in this document is hard.
|
|
20
|
+
#
|
|
21
|
+
# WHAT ARRIVES. One Encounter:
|
|
22
|
+
#
|
|
23
|
+
# {"resourceType": "Encounter", "id": "137202491", "status": "finished",
|
|
24
|
+
# "class": {"code": "AMB"}, "subject": {"reference": "Patient/137202428"},
|
|
25
|
+
# "period": {"start": "2026-07-20T09:00:00Z"}}
|
|
26
|
+
#
|
|
27
|
+
# and its Observations, each a code and a value:
|
|
28
|
+
#
|
|
29
|
+
# {"resourceType": "Observation", "status": "final",
|
|
30
|
+
# "code": {"coding": [{"system": "http://loinc.org", "code": "29463-7",
|
|
31
|
+
# "display": "Body weight"}]},
|
|
32
|
+
# "encounter": {"reference": "Encounter/137202491"},
|
|
33
|
+
# "valueQuantity": {"value": 61.5, "unit": "kg"}}
|
|
34
|
+
#
|
|
35
|
+
# WHAT LEAVES. One `/api/tracker` document holding one event:
|
|
36
|
+
#
|
|
37
|
+
# {"events": [{"event": "EvAaBbCcDd1", "program": "IpHINAT79UW",
|
|
38
|
+
# "programStage": "A03MvHHogjR", "enrollment": "EnAaBbCcDd1",
|
|
39
|
+
# "trackedEntity": "coxwIkMqNBB", "orgUnit": "DiszpKrYNg8",
|
|
40
|
+
# "occurredAt": "2026-07-20", "status": "COMPLETED",
|
|
41
|
+
# "dataValues": [{"dataElement": "UXz7xuGCEhU", "value": "61.5"}]}]}
|
|
42
|
+
#
|
|
43
|
+
# WHICH CODE MAPS TO WHICH DATA ELEMENT.
|
|
44
|
+
#
|
|
45
|
+
# Encounter.period.start (or Encounter.period.end) -> occurredAt
|
|
46
|
+
# Encounter.status "finished" -> event status COMPLETED
|
|
47
|
+
# Observation.code.coding LOINC code -> dataElement, through `loinc_to_de`
|
|
48
|
+
# Observation.valueQuantity.value -> that data value's value
|
|
49
|
+
#
|
|
50
|
+
# WHERE A READER CHANGES IT. `loinc_to_de` is the mapping and the only place codes appear:
|
|
51
|
+
# each entry is one LOINC code and the DHIS2 data element uid it lands in. The stage the event
|
|
52
|
+
# belongs to is `program_stage`, and the program and enrollment are `program` and
|
|
53
|
+
# `enrollment_uid`. An observation whose LOINC code is not in the table is dropped and counted
|
|
54
|
+
# -- conceptmap-driven-mapping.yaml is the same mapping fetched from a server instead.
|
|
55
|
+
#
|
|
56
|
+
# WHAT THIS DOES NOT DO. It does not resolve the Encounter's Patient to a DHIS2 tracked entity.
|
|
57
|
+
# That is a real lookup against a real identifier crosswalk, and patient-to-tracked-entity.yaml
|
|
58
|
+
# is the half that does it; here the entity and its enrollment are parameters.
|
|
59
|
+
#
|
|
60
|
+
# dg run --local examples/fhir/fhir-encounter-to-event.yaml
|
|
61
|
+
# dg run --local examples/fhir/fhir-encounter-to-event.yaml -p encounter_id=137202491
|
|
62
|
+
|
|
63
|
+
format: dirigent/v1
|
|
64
|
+
kind: pipeline
|
|
65
|
+
code: fhir-encounter-to-event
|
|
66
|
+
name: Encounter and Observations to one event
|
|
67
|
+
description: |
|
|
68
|
+
Fold one FHIR `Encounter` and every `Observation` naming it into a single DHIS2 tracker
|
|
69
|
+
event, posted with `importMode=VALIDATE`.
|
|
70
|
+
|
|
71
|
+
The cardinalities do not line up: FHIR spreads a visit over an Encounter plus one
|
|
72
|
+
Observation per measurement, and a DHIS2 event holds the whole visit with a bag of data
|
|
73
|
+
values. The translation is a fold keyed on `Observation.encounter`.
|
|
74
|
+
|
|
75
|
+
Read against a general clinical FHIR server, because `d2w fhir serve` publishes DHIS2 as
|
|
76
|
+
forms and captures and serves no `Encounter` or `Observation` at all.
|
|
77
|
+
|
|
78
|
+
tags: [fhir, dhis2, cross-boundary]
|
|
79
|
+
|
|
80
|
+
requires:
|
|
81
|
+
blocks:
|
|
82
|
+
- http.request
|
|
83
|
+
- transform.jq
|
|
84
|
+
|
|
85
|
+
connections:
|
|
86
|
+
# A public FHIR R4 sandbox, open and unauthenticated, standing in for a hospital's own
|
|
87
|
+
# server. Repoint it and only the two searches below have to survive the move.
|
|
88
|
+
fhir-clinical:
|
|
89
|
+
kind: http
|
|
90
|
+
config:
|
|
91
|
+
base_url: https://hapi.fhir.org/baseR4
|
|
92
|
+
timeout: 60s
|
|
93
|
+
dhis2-demo:
|
|
94
|
+
kind: dhis2
|
|
95
|
+
config:
|
|
96
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
97
|
+
basic_username: admin
|
|
98
|
+
basic_password: district
|
|
99
|
+
timeout: 60s
|
|
100
|
+
|
|
101
|
+
params:
|
|
102
|
+
type: object
|
|
103
|
+
properties:
|
|
104
|
+
encounter_status:
|
|
105
|
+
type: string
|
|
106
|
+
description: Which encounters to consider. A visit that has not finished has not
|
|
107
|
+
produced a completed event.
|
|
108
|
+
default: finished
|
|
109
|
+
program:
|
|
110
|
+
type: string
|
|
111
|
+
default: IpHINAT79UW
|
|
112
|
+
program_stage:
|
|
113
|
+
type: string
|
|
114
|
+
description: The stage the visit lands in; one Questionnaire per stage on the facade.
|
|
115
|
+
default: A03MvHHogjR
|
|
116
|
+
org_unit:
|
|
117
|
+
type: string
|
|
118
|
+
default: DiszpKrYNg8
|
|
119
|
+
tracked_entity:
|
|
120
|
+
type: string
|
|
121
|
+
description: The DHIS2 person the Encounter's Patient resolves to. A real crosswalk is
|
|
122
|
+
patient-to-tracked-entity.yaml.
|
|
123
|
+
default: coxwIkMqNBB
|
|
124
|
+
enrollment_uid:
|
|
125
|
+
type: string
|
|
126
|
+
default: EnAaBbCcDd1
|
|
127
|
+
event_uid:
|
|
128
|
+
type: string
|
|
129
|
+
description: The event's own uid, minted by the client. Deriving it from the source
|
|
130
|
+
resource is what makes a rehearsal and an import name the same event, and a
|
|
131
|
+
re-forward collide (E1030) rather than duplicate.
|
|
132
|
+
default: EvAaBbCcDd1
|
|
133
|
+
loinc_to_de:
|
|
134
|
+
type: object
|
|
135
|
+
description: One LOINC code per DHIS2 data element uid. An observation coded outside
|
|
136
|
+
this table is dropped and counted.
|
|
137
|
+
default:
|
|
138
|
+
"29463-7": UXz7xuGCEhU
|
|
139
|
+
"8302-2": lw1SqmMlnfh
|
|
140
|
+
"8480-6": XorIxxprsOp
|
|
141
|
+
|
|
142
|
+
steps:
|
|
143
|
+
# `_sort=-_lastUpdated` asks the busiest end of a shared sandbox for something that exists
|
|
144
|
+
# today. On a real server this is the visit selector: a patient, a date range, a location.
|
|
145
|
+
find_encounter:
|
|
146
|
+
block: http.request
|
|
147
|
+
config:
|
|
148
|
+
connection: fhir-clinical
|
|
149
|
+
path: /Encounter
|
|
150
|
+
method: GET
|
|
151
|
+
query:
|
|
152
|
+
status: "${params.encounter_status}"
|
|
153
|
+
_sort: "-_lastUpdated"
|
|
154
|
+
_count: 1
|
|
155
|
+
headers:
|
|
156
|
+
Accept: application/fhir+json
|
|
157
|
+
|
|
158
|
+
# `encounter` is a reference search parameter: it takes `Encounter/<id>`, and it is how the
|
|
159
|
+
# measurements of one visit are gathered without walking every Observation on the server.
|
|
160
|
+
# A server that also supports it would answer the same question in one call with
|
|
161
|
+
# `_revinclude=Observation:encounter` on the search above; two calls is the portable shape.
|
|
162
|
+
read_observations:
|
|
163
|
+
block: http.request
|
|
164
|
+
depends_on: [find_encounter]
|
|
165
|
+
config:
|
|
166
|
+
connection: fhir-clinical
|
|
167
|
+
path: /Observation
|
|
168
|
+
method: GET
|
|
169
|
+
query:
|
|
170
|
+
encounter: "Encounter/${steps.find_encounter.output.body.entry.0.resource.id}"
|
|
171
|
+
_count: 50
|
|
172
|
+
headers:
|
|
173
|
+
Accept: application/fhir+json
|
|
174
|
+
|
|
175
|
+
# The fold. One event out, however many observations came in, plus the codes that mapped to
|
|
176
|
+
# nothing so the receipt can say so.
|
|
177
|
+
fold_to_event:
|
|
178
|
+
block: transform.jq
|
|
179
|
+
depends_on: [find_encounter, read_observations]
|
|
180
|
+
config:
|
|
181
|
+
input:
|
|
182
|
+
encounters: "${steps.find_encounter.output.body}"
|
|
183
|
+
observations: "${steps.read_observations.output.body}"
|
|
184
|
+
codes: "${params.loinc_to_de}"
|
|
185
|
+
program: "${params.program}"
|
|
186
|
+
program_stage: "${params.program_stage}"
|
|
187
|
+
org_unit: "${params.org_unit}"
|
|
188
|
+
tracked_entity: "${params.tracked_entity}"
|
|
189
|
+
enrollment: "${params.enrollment_uid}"
|
|
190
|
+
event: "${params.event_uid}"
|
|
191
|
+
program: |
|
|
192
|
+
. as {$encounters, $observations, $codes, $program, $program_stage,
|
|
193
|
+
$org_unit, $tracked_entity, $enrollment, $event}
|
|
194
|
+
| (($encounters.entry // [])[0].resource) as $e
|
|
195
|
+
| if $e == null then error("no encounter matched; widen the search") else . end
|
|
196
|
+
# A date, not an instant: DHIS2 keeps an event's occurredAt as a zone-less wall clock,
|
|
197
|
+
# so the instant is cut at the T rather than reformatted.
|
|
198
|
+
| (($e.period.start // $e.period.end // "") | split("T") | first) as $occurred
|
|
199
|
+
| [($observations.entry // [])[].resource
|
|
200
|
+
| select(.status == "final" or .status == "amended")
|
|
201
|
+
| {code: ([(.code.coding // [])[]
|
|
202
|
+
| select(.system == "http://loinc.org").code][0]),
|
|
203
|
+
value: (.valueQuantity.value // .valueInteger
|
|
204
|
+
// .valueString // .valueCodeableConcept.coding[0].code)}
|
|
205
|
+
| select(.code != null and .value != null)] as $seen
|
|
206
|
+
| {document: {events: [{
|
|
207
|
+
event: $event,
|
|
208
|
+
program: $program,
|
|
209
|
+
programStage: $program_stage,
|
|
210
|
+
enrollment: $enrollment,
|
|
211
|
+
trackedEntity: $tracked_entity,
|
|
212
|
+
orgUnit: $org_unit,
|
|
213
|
+
occurredAt: $occurred,
|
|
214
|
+
status: (if $e.status == "finished" then "COMPLETED" else "ACTIVE" end),
|
|
215
|
+
dataValues: [$seen[] | select($codes[.code] != null)
|
|
216
|
+
| {dataElement: $codes[.code], value: (.value | tostring)}]}]},
|
|
217
|
+
encounter: $e.id,
|
|
218
|
+
unmapped_codes: [$seen[] | select($codes[.code] == null).code] | unique}
|
|
219
|
+
|
|
220
|
+
rehearse_import:
|
|
221
|
+
block: http.request
|
|
222
|
+
depends_on: [fold_to_event]
|
|
223
|
+
config:
|
|
224
|
+
connection: dhis2-demo
|
|
225
|
+
path: /api/tracker
|
|
226
|
+
method: POST
|
|
227
|
+
query:
|
|
228
|
+
# /api/tracker's dry run. /api/dataValueSets spells the same intention dryRun=true.
|
|
229
|
+
importMode: VALIDATE
|
|
230
|
+
headers:
|
|
231
|
+
Content-Type: application/json
|
|
232
|
+
body: "${steps.fold_to_event.output.value.document}"
|
|
233
|
+
success_status: [200, 201, 409]
|
|
234
|
+
|
|
235
|
+
receipt:
|
|
236
|
+
block: transform.jq
|
|
237
|
+
depends_on: [rehearse_import]
|
|
238
|
+
config:
|
|
239
|
+
input:
|
|
240
|
+
report: "${steps.rehearse_import.output.body}"
|
|
241
|
+
folded: "${steps.fold_to_event.output.value}"
|
|
242
|
+
program: |
|
|
243
|
+
{encounter: .folded.encounter,
|
|
244
|
+
data_values: (.folded.document.events[0].dataValues | length),
|
|
245
|
+
unmapped_codes: .folded.unmapped_codes,
|
|
246
|
+
status: .report.status,
|
|
247
|
+
errors: [((.report.validationReport // .report.response.validationReport).errorReports // [])[]
|
|
248
|
+
| {code: .errorCode, message: .message}]}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# A FHIR MeasureReport held against DHIS2 analytics for the same period, and the gap reported.
|
|
2
|
+
#
|
|
3
|
+
# THE FLOW, END TO END. Read a MeasureReport for one measure and one period off a FHIR server,
|
|
4
|
+
# ask DHIS2's own analytics for the indicator that is supposed to say the same thing over the
|
|
5
|
+
# same period and org unit, subtract one from the other, and POST the verdict to a receiver.
|
|
6
|
+
#
|
|
7
|
+
# WHY THIS EXISTS. Two systems computing the same number from the same events is the normal
|
|
8
|
+
# state of a country's data, and the useful question is not "what is the number" but "do the
|
|
9
|
+
# two agree, and by how much". This is a reconciliation, so it writes NOTHING to DHIS2 and it
|
|
10
|
+
# is safe to run on a schedule.
|
|
11
|
+
#
|
|
12
|
+
# WHERE THE MEASUREREPORT COMES FROM. Not from `d2w fhir serve`: that facade publishes DHIS2 as
|
|
13
|
+
# forms and captures and serves no Measure and no MeasureReport. A report is produced by
|
|
14
|
+
# SCORING a measure -- a CQL library with the five conventional population defines (Initial
|
|
15
|
+
# population, Denominator, Denominator exclusion, Numerator, Numerator exclusion) -- over a
|
|
16
|
+
# Bundle of FHIR data, and a measure evaluation engine is what turns that scoring into an R4
|
|
17
|
+
# MeasureReport. The report is then a resource like any other, so this document fetches it
|
|
18
|
+
# from wherever it was published. `fhir_base` defaults to a public HAPI R4 sandbox.
|
|
19
|
+
#
|
|
20
|
+
# WHAT ARRIVES -- the report. A proportion measure scored over a period:
|
|
21
|
+
#
|
|
22
|
+
# {"resourceType": "MeasureReport", "status": "complete", "type": "summary",
|
|
23
|
+
# "measure": "http://example.org/Measure/measles-coverage",
|
|
24
|
+
# "period": {"start": "2026-08-01", "end": "2026-08-31"},
|
|
25
|
+
# "group": [{"population": [
|
|
26
|
+
# {"code": {"coding": [{"code": "initial-population"}]}, "count": 4},
|
|
27
|
+
# {"code": {"coding": [{"code": "denominator"}]}, "count": 4},
|
|
28
|
+
# {"code": {"coding": [{"code": "numerator"}]}, "count": 3}],
|
|
29
|
+
# "measureScore": {"value": 0.75}}]}
|
|
30
|
+
#
|
|
31
|
+
# WHAT ARRIVES -- the grid. DHIS2 analytics answers a grid, not objects: `headers` names the
|
|
32
|
+
# columns and `rows` is a list of lists, so a value is read by finding its header's index.
|
|
33
|
+
# That indirection is the one awkward thing about analytics and it is why the jq below looks
|
|
34
|
+
# up `value` rather than indexing a fixed position.
|
|
35
|
+
#
|
|
36
|
+
# {"headers": [{"name": "dx"}, {"name": "pe"}, {"name": "ou"}, {"name": "value"}],
|
|
37
|
+
# "rows": [["fbfJHSPpUQD", "202608", "ImspTQPwCqd", "0.71"]]}
|
|
38
|
+
#
|
|
39
|
+
# WHAT LEAVES. One discrepancy record, POSTed whether or not it found a gap, because "we
|
|
40
|
+
# checked and they agree" is a fact worth delivering too:
|
|
41
|
+
#
|
|
42
|
+
# {"measure": "...", "period": "202608", "org_unit": "ImspTQPwCqd",
|
|
43
|
+
# "fhir_score": 0.75, "dhis2_value": 0.71, "difference": 0.04, "discrepant": true}
|
|
44
|
+
#
|
|
45
|
+
# WHICH CODES LINE UP WITH WHAT. `measure_url` and `indicator` are the two halves of one claim
|
|
46
|
+
# -- that this FHIR measure and this DHIS2 data element or indicator count the same thing --
|
|
47
|
+
# and a reader changes both together or neither. `period` is a DHIS2 ISO period; the FHIR side
|
|
48
|
+
# carries a date range, so the two are matched on the range the period covers.
|
|
49
|
+
#
|
|
50
|
+
# WHEN IT FINDS NOTHING. A missing report and an empty grid are ordinary answers, not failures:
|
|
51
|
+
# the diff says `unavailable` and the receiver hears about it. A reconciliation that crashed
|
|
52
|
+
# when one side was quiet would be silent exactly when something is wrong.
|
|
53
|
+
#
|
|
54
|
+
# dg run --local examples/fhir/fhir-measure-report-to-analytics-check.yaml
|
|
55
|
+
# dg run --local examples/fhir/fhir-measure-report-to-analytics-check.yaml -p tolerance=0.1
|
|
56
|
+
|
|
57
|
+
format: dirigent/v1
|
|
58
|
+
kind: pipeline
|
|
59
|
+
code: fhir-measure-report-to-analytics-check
|
|
60
|
+
name: MeasureReport against DHIS2 analytics
|
|
61
|
+
description: |
|
|
62
|
+
Hold a FHIR `MeasureReport` against the DHIS2 analytics value for the same period and org
|
|
63
|
+
unit, and deliver the gap.
|
|
64
|
+
|
|
65
|
+
A reconciliation, so it writes nothing: safe on a schedule. The report is produced by
|
|
66
|
+
scoring a CQL measure over FHIR data -- `d2w fhir serve` publishes no `Measure` or
|
|
67
|
+
`MeasureReport` of its own -- and read here from wherever it was published.
|
|
68
|
+
|
|
69
|
+
tags: [fhir, dhis2, cross-boundary]
|
|
70
|
+
|
|
71
|
+
requires:
|
|
72
|
+
blocks:
|
|
73
|
+
- http.request
|
|
74
|
+
- dhis2.analytics_query
|
|
75
|
+
- transform.jq
|
|
76
|
+
- webhook.post
|
|
77
|
+
|
|
78
|
+
connections:
|
|
79
|
+
fhir-reports:
|
|
80
|
+
kind: http
|
|
81
|
+
config:
|
|
82
|
+
base_url: https://hapi.fhir.org/baseR4
|
|
83
|
+
timeout: 60s
|
|
84
|
+
dhis2-demo:
|
|
85
|
+
kind: dhis2
|
|
86
|
+
config:
|
|
87
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
88
|
+
basic_username: admin
|
|
89
|
+
basic_password: district
|
|
90
|
+
timeout: 60s
|
|
91
|
+
# Where a discrepancy is delivered. Postman Echo stands in for the receiver so the document
|
|
92
|
+
# runs; hmac_secret would let webhook.post sign the body, which is what a receiver that
|
|
93
|
+
# trusts nobody wants.
|
|
94
|
+
discrepancy-sink:
|
|
95
|
+
kind: http
|
|
96
|
+
config:
|
|
97
|
+
base_url: https://postman-echo.com
|
|
98
|
+
timeout: 30s
|
|
99
|
+
|
|
100
|
+
params:
|
|
101
|
+
type: object
|
|
102
|
+
properties:
|
|
103
|
+
measure_url:
|
|
104
|
+
type: string
|
|
105
|
+
description: The measure whose report is read. Half of the claim that the two systems
|
|
106
|
+
count the same thing.
|
|
107
|
+
default: http://cms.gov/fhir/hqr/Measure/synthetic-test-measure
|
|
108
|
+
indicator:
|
|
109
|
+
type: string
|
|
110
|
+
description: The DHIS2 data element or indicator uid that is the other half of it.
|
|
111
|
+
default: fbfJHSPpUQD
|
|
112
|
+
org_unit:
|
|
113
|
+
type: string
|
|
114
|
+
default: ImspTQPwCqd
|
|
115
|
+
period:
|
|
116
|
+
type: string
|
|
117
|
+
description: A DHIS2 ISO period; the FHIR side is matched on the range it covers.
|
|
118
|
+
default: "202608"
|
|
119
|
+
tolerance:
|
|
120
|
+
type: number
|
|
121
|
+
description: How far apart the two may be before the difference is called a discrepancy.
|
|
122
|
+
default: 0.02
|
|
123
|
+
|
|
124
|
+
steps:
|
|
125
|
+
# `measure` is the canonical of the measure, which is what makes a report findable without
|
|
126
|
+
# knowing its id. `_sort=-date` takes the most recent scoring when a measure was scored more
|
|
127
|
+
# than once for the same period.
|
|
128
|
+
read_report:
|
|
129
|
+
block: http.request
|
|
130
|
+
config:
|
|
131
|
+
connection: fhir-reports
|
|
132
|
+
path: /MeasureReport
|
|
133
|
+
method: GET
|
|
134
|
+
query:
|
|
135
|
+
measure: "${params.measure_url}"
|
|
136
|
+
_sort: "-date"
|
|
137
|
+
_count: 1
|
|
138
|
+
headers:
|
|
139
|
+
Accept: application/fhir+json
|
|
140
|
+
# A search that matched nothing is a 200 with an empty Bundle, so nothing special is
|
|
141
|
+
# needed here; this only says a 404 on the endpoint itself is still a failure.
|
|
142
|
+
success_status: [200]
|
|
143
|
+
|
|
144
|
+
# The same question, asked of DHIS2. An aggregate query takes its axes as `dimension`
|
|
145
|
+
# strings in DHIS2's own vocabulary: dx is the data, pe the period, ou the org unit.
|
|
146
|
+
read_analytics:
|
|
147
|
+
block: dhis2.analytics_query
|
|
148
|
+
config:
|
|
149
|
+
connection: dhis2-demo
|
|
150
|
+
mode: aggregate
|
|
151
|
+
dimension:
|
|
152
|
+
- "dx:${params.indicator}"
|
|
153
|
+
- "pe:${params.period}"
|
|
154
|
+
# A dimension the answer is not broken down BY, only filtered to. Putting ou here rather
|
|
155
|
+
# than in dimension is what makes the grid one row instead of one row per unit.
|
|
156
|
+
filter:
|
|
157
|
+
- "ou:${params.org_unit}"
|
|
158
|
+
|
|
159
|
+
# The subtraction. Both sides may be absent, and each absence is reported as itself rather
|
|
160
|
+
# than folded into a zero: a measure nobody scored and a coverage of 0% are different facts.
|
|
161
|
+
diff:
|
|
162
|
+
block: transform.jq
|
|
163
|
+
depends_on: [read_report, read_analytics]
|
|
164
|
+
config:
|
|
165
|
+
input:
|
|
166
|
+
reports: "${steps.read_report.output.body}"
|
|
167
|
+
grid: "${steps.read_analytics.output.body}"
|
|
168
|
+
measure: "${params.measure_url}"
|
|
169
|
+
period: "${params.period}"
|
|
170
|
+
org_unit: "${params.org_unit}"
|
|
171
|
+
tolerance: "${params.tolerance}"
|
|
172
|
+
program: |
|
|
173
|
+
. as {$reports, $grid, $measure, $period, $org_unit, $tolerance}
|
|
174
|
+
| (($reports.entry // [])[0].resource) as $report
|
|
175
|
+
| ($report.group[0].measureScore.value) as $score
|
|
176
|
+
# The grid's columns are named in headers, so the value column is found by name and
|
|
177
|
+
# never by position: DHIS2 adds columns, and a fixed index would start reading one.
|
|
178
|
+
| ([($grid.headers // []) | to_entries[] | select(.value.name == "value") | .key][0]) as $col
|
|
179
|
+
| (if $col == null then null
|
|
180
|
+
else [($grid.rows // [])[] | .[$col] | tonumber?][0] end) as $value
|
|
181
|
+
| {measure: $measure,
|
|
182
|
+
period: $period,
|
|
183
|
+
org_unit: $org_unit,
|
|
184
|
+
fhir_score: $score,
|
|
185
|
+
dhis2_value: $value,
|
|
186
|
+
populations: [(($report.group[0].population // [])[])
|
|
187
|
+
| {code: .code.coding[0].code, count: .count}],
|
|
188
|
+
difference: (if $score == null or $value == null then null
|
|
189
|
+
else (($score - $value) | fabs) end),
|
|
190
|
+
verdict: (if $score == null then "unavailable: no report was scored for this measure"
|
|
191
|
+
elif $value == null then "unavailable: analytics answered no value"
|
|
192
|
+
elif (($score - $value) | fabs) > $tolerance then "discrepant"
|
|
193
|
+
else "agrees" end)}
|
|
194
|
+
|
|
195
|
+
# Delivered on every run. A receiver that only ever hears about disagreements cannot tell a
|
|
196
|
+
# healthy silence from a pipeline that stopped running.
|
|
197
|
+
report_discrepancy:
|
|
198
|
+
block: webhook.post
|
|
199
|
+
depends_on: [diff]
|
|
200
|
+
config:
|
|
201
|
+
connection: discrepancy-sink
|
|
202
|
+
path: /post
|
|
203
|
+
body: "${steps.diff.output.value}"
|
|
204
|
+
headers:
|
|
205
|
+
X-Dirigent-Check: fhir-measure-vs-dhis2-analytics
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# A nightly incremental pull: everything a FHIR server changed last night, type by type.
|
|
2
|
+
#
|
|
3
|
+
# THE FLOW, END TO END. A cron schedule fires at five and hands the run the interval that just
|
|
4
|
+
# closed. One step fans out over the resource types being synced, each item asking the server
|
|
5
|
+
# for what changed inside that interval, and a join reads the settled fan-out back into one
|
|
6
|
+
# summary that says how much of each type moved and which types could not be reached.
|
|
7
|
+
#
|
|
8
|
+
# THE WINDOW IS A PROPERTY OF THE RUN, NOT OF THIS DOCUMENT. Nothing below declares it: the
|
|
9
|
+
# cadence computes it, and every schedule-fired run carries `${run.window.start}` up to but not
|
|
10
|
+
# including `${run.window.end}`. Half-open is the whole point of an incremental sync -- `ge` on
|
|
11
|
+
# the start and `lt` on the end means two consecutive nights tile the timeline without
|
|
12
|
+
# double-reading a resource that landed exactly on the boundary, and without leaving a hole.
|
|
13
|
+
# An ad hoc run has to be given one, and a run carrying no window REFUSES the reference rather
|
|
14
|
+
# than resolving it to nothing and asking the server for its entire history.
|
|
15
|
+
#
|
|
16
|
+
# THE FACADE HAS NO _lastUpdated. `d2w fhir serve` publishes a fixed store of definitional
|
|
17
|
+
# resources and a spool of receipts; its searches take `_id`, `url`, `identifier` and
|
|
18
|
+
# `questionnaire`, and there is no modified-since anywhere. Its incremental surface is
|
|
19
|
+
# `GET /facade/spool`, whose receipts carry a lifecycle and a `received_at`, paged by an opaque
|
|
20
|
+
# token. So this document is written against a general FHIR server -- `fhir_base` defaults to a
|
|
21
|
+
# public HAPI R4 sandbox -- and the shape it teaches is the one a spool pull would use too.
|
|
22
|
+
#
|
|
23
|
+
# WHAT ARRIVES. One searchset Bundle per resource type, each holding what changed:
|
|
24
|
+
#
|
|
25
|
+
# {"resourceType": "Bundle", "type": "searchset", "total": 41,
|
|
26
|
+
# "link": [{"relation": "self", "url": "..."},
|
|
27
|
+
# {"relation": "next", "url": ".../Patient?_getpages=...&_getpagesoffset=50"}],
|
|
28
|
+
# "entry": [{"resource": {"resourceType": "Patient", "id": "137202428",
|
|
29
|
+
# "meta": {"lastUpdated": "2026-09-04T22:14:03Z"}}}]}
|
|
30
|
+
#
|
|
31
|
+
# WHAT LEAVES. One summary record per run:
|
|
32
|
+
#
|
|
33
|
+
# {"window": {"from": "2026-09-04T05:00:00Z", "to": "2026-09-05T05:00:00Z"},
|
|
34
|
+
# "types": [{"type": "Patient", "total": 41, "read": 50, "more_pages": true}],
|
|
35
|
+
# "changed": 41, "types_reached": 3, "types_asked": 4}
|
|
36
|
+
#
|
|
37
|
+
# WHY items: continue. Each resource type is its own item and its own attempt. Under the
|
|
38
|
+
# default, `fail_fast`, one type the server chokes on fails the whole night's sync. Under
|
|
39
|
+
# `items: continue` that type's failure is recorded against its own run item, the others
|
|
40
|
+
# finish, and the run reports `completed_with_errors`. The cost is worth knowing: a failed item
|
|
41
|
+
# is ABSENT from the step's output -- not null, absent -- so the join sees only what worked,
|
|
42
|
+
# which is why the summary counts the types it asked for as well as the ones it reached.
|
|
43
|
+
#
|
|
44
|
+
# WHY rule: all_done ON THE JOIN. The default `all_success` edge would skip the summary exactly
|
|
45
|
+
# on the night something went wrong, which is the night it is worth having.
|
|
46
|
+
#
|
|
47
|
+
# WHY THE ABSOLUTE URL. A half-open range needs `_lastUpdated` twice in one search, and the
|
|
48
|
+
# `query` map holds one value per key, so the search is written as a URL. That is also the
|
|
49
|
+
# honest shape for FHIR paging: `next` is an opaque link to be followed verbatim, never a page
|
|
50
|
+
# number to be constructed, and a real sync follows it until it is gone. This one reads the
|
|
51
|
+
# first page and reports whether there were more, which is the smallest thing that does not
|
|
52
|
+
# quietly lie about completeness.
|
|
53
|
+
#
|
|
54
|
+
# WHERE A READER CHANGES IT. `resource_types` is the fan-out and the whole scope of the sync;
|
|
55
|
+
# `page_size` is what one page holds. Add a type to the list and the grid grows on the next
|
|
56
|
+
# firing with nothing else edited.
|
|
57
|
+
#
|
|
58
|
+
# dg run --local examples/fhir/fhir-nightly-window-sync.yaml --window 2026-09-01..2026-09-02
|
|
59
|
+
# dg apply examples/fhir/fhir-nightly-window-sync.yaml
|
|
60
|
+
# dg backfill fhir-nightly-window-sync --schedule nightly \
|
|
61
|
+
# --from 2026-09-01T05:00:00Z --to 2026-09-08T05:00:00Z --dry-run
|
|
62
|
+
|
|
63
|
+
format: dirigent/v1
|
|
64
|
+
kind: pipeline
|
|
65
|
+
code: fhir-nightly-window-sync
|
|
66
|
+
name: Nightly windowed FHIR sync
|
|
67
|
+
description: |
|
|
68
|
+
Pull everything a FHIR server changed inside the interval a nightly schedule just closed,
|
|
69
|
+
one fan-out item per resource type, and join the results into one summary.
|
|
70
|
+
|
|
71
|
+
The window belongs to the run, not to this document: `${run.window.start}` and
|
|
72
|
+
`${run.window.end}` are half-open ISO instants, so consecutive firings tile the timeline
|
|
73
|
+
without overlap or gap. `items: continue` lets one unreachable type cost only itself.
|
|
74
|
+
|
|
75
|
+
tags: [fhir, dhis2, cross-boundary]
|
|
76
|
+
|
|
77
|
+
requires:
|
|
78
|
+
blocks:
|
|
79
|
+
- http.request
|
|
80
|
+
- transform.jq
|
|
81
|
+
|
|
82
|
+
connections:
|
|
83
|
+
# Carried for its timeout and TLS setting; the searches below give absolute URLs, because a
|
|
84
|
+
# half-open range needs the same query parameter twice and the query map holds one per key.
|
|
85
|
+
fhir-source:
|
|
86
|
+
kind: http
|
|
87
|
+
config:
|
|
88
|
+
base_url: https://hapi.fhir.org/baseR4
|
|
89
|
+
timeout: 60s
|
|
90
|
+
# Not read by this document, which only pulls. It is here because the sync exists to feed
|
|
91
|
+
# DHIS2, and the shelf's other documents are what turn each of these types into an import:
|
|
92
|
+
# Patient through patient-to-tracked-entity.yaml, Encounter through encounter-to-event.yaml,
|
|
93
|
+
# QuestionnaireResponse through capture-bundle-to-data-values.yaml.
|
|
94
|
+
dhis2-demo:
|
|
95
|
+
kind: dhis2
|
|
96
|
+
config:
|
|
97
|
+
base_url: https://play.im.dhis2.org/stable-2-43-1
|
|
98
|
+
basic_username: admin
|
|
99
|
+
basic_password: district
|
|
100
|
+
timeout: 60s
|
|
101
|
+
|
|
102
|
+
params:
|
|
103
|
+
type: object
|
|
104
|
+
properties:
|
|
105
|
+
fhir_base:
|
|
106
|
+
type: string
|
|
107
|
+
description: The server to sync from. Not the facade -- it answers no _lastUpdated.
|
|
108
|
+
default: https://hapi.fhir.org/baseR4
|
|
109
|
+
resource_types:
|
|
110
|
+
type: array
|
|
111
|
+
description: The scope of the sync. One fan-out item each.
|
|
112
|
+
default: [Patient, Encounter, Observation]
|
|
113
|
+
items:
|
|
114
|
+
type: string
|
|
115
|
+
page_size:
|
|
116
|
+
type: integer
|
|
117
|
+
description: How many resources one page holds. A server may answer fewer; it never
|
|
118
|
+
answers more.
|
|
119
|
+
default: 50
|
|
120
|
+
|
|
121
|
+
steps:
|
|
122
|
+
# for_each is expanded when the run is created, so it reads params, run and item -- never
|
|
123
|
+
# another step's output, because the cardinality of the grid has to be known before anything
|
|
124
|
+
# executes. That is why the types are a parameter and not something discovered from
|
|
125
|
+
# /metadata: a run's shape is fixed before its first request.
|
|
126
|
+
pull:
|
|
127
|
+
block: http.request
|
|
128
|
+
for_each: "${params.resource_types}"
|
|
129
|
+
items: continue
|
|
130
|
+
config:
|
|
131
|
+
url: "${params.fhir_base}/${item}?_lastUpdated=ge${run.window.start}&_lastUpdated=lt${run.window.end}&_count=${params.page_size}&_sort=_lastUpdated"
|
|
132
|
+
method: GET
|
|
133
|
+
headers:
|
|
134
|
+
Accept: application/fhir+json
|
|
135
|
+
# A type this server does not serve answers 404 or an OperationOutcome, and under
|
|
136
|
+
# items: continue that costs one item rather than the night.
|
|
137
|
+
success_status: [200]
|
|
138
|
+
|
|
139
|
+
# The join. A fan-out step's output is the list of its items' outputs in item order, so the
|
|
140
|
+
# object carrying each response is unwrapped once here.
|
|
141
|
+
summarise:
|
|
142
|
+
block: transform.jq
|
|
143
|
+
depends_on: [pull]
|
|
144
|
+
rule: all_done
|
|
145
|
+
config:
|
|
146
|
+
input:
|
|
147
|
+
pages: "${steps.pull.output}"
|
|
148
|
+
asked: "${params.resource_types}"
|
|
149
|
+
from: "${run.window.start}"
|
|
150
|
+
to: "${run.window.end}"
|
|
151
|
+
program: |
|
|
152
|
+
. as {$pages, $asked, $from, $to}
|
|
153
|
+
| [$pages[] | .body
|
|
154
|
+
| {type: ((.entry // [])[0].resource.resourceType
|
|
155
|
+
// (.link[0].url | split("/") | last | split("?") | first)),
|
|
156
|
+
# `total` is the server's count of the whole searchset, a different number from
|
|
157
|
+
# how many rode in this page: a sync reporting the page size would say the same
|
|
158
|
+
# thing on a quiet night and a busy one. A server is allowed to omit it on a
|
|
159
|
+
# large searchset, and HAPI does, so it is nullable and the count below says so.
|
|
160
|
+
total: .total,
|
|
161
|
+
read: ((.entry // []) | length),
|
|
162
|
+
more_pages: ([(.link // [])[] | select(.relation == "next")] | length > 0)}] as $seen
|
|
163
|
+
| {window: {from: $from, to: $to},
|
|
164
|
+
types: $seen,
|
|
165
|
+
changed: ([$seen[] | .total // .read] | add // 0),
|
|
166
|
+
counted_from_pages: [$seen[] | select(.total == null) | .type],
|
|
167
|
+
types_reached: ($seen | length),
|
|
168
|
+
types_asked: ($asked | length),
|
|
169
|
+
# The gap between the two is what items: continue bought, and saying it out loud is
|
|
170
|
+
# what keeps a partial sync from reading like a complete one.
|
|
171
|
+
types_missed: ($asked - [$seen[] | .type])}
|
|
172
|
+
|
|
173
|
+
triggers:
|
|
174
|
+
schedules:
|
|
175
|
+
- code: nightly
|
|
176
|
+
name: Nightly, Oslo time
|
|
177
|
+
description: Reads the day that closed at five this morning.
|
|
178
|
+
cron: "0 5 * * *"
|
|
179
|
+
# The zone belongs to the schedule, so the window it derives is a day of Oslo's clock
|
|
180
|
+
# rather than a fixed 24 hours: the spring-forward night is 23 hours long and the
|
|
181
|
+
# fall-back night is 25, and a pair of instants says so honestly.
|
|
182
|
+
timezone: Europe/Oslo
|