dirigent-examples 0.15.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.
- dirigent_examples/__init__.py +22 -0
- dirigent_examples/py.typed +0 -0
- dirigent_examples/shelves/README.md +299 -0
- dirigent_examples/shelves/composition/README.md +18 -0
- dirigent_examples/shelves/composition/chained-instances.yaml +97 -0
- dirigent_examples/shelves/composition/composition-child.yaml +64 -0
- dirigent_examples/shelves/composition/composition-parent.yaml +89 -0
- dirigent_examples/shelves/connections.yaml +52 -0
- dirigent_examples/shelves/demo/README.md +19 -0
- dirigent_examples/shelves/demo/markdown-showcase.yaml +117 -0
- dirigent_examples/shelves/demo/params-showcase.yaml +92 -0
- dirigent_examples/shelves/demo/requires.yaml +65 -0
- dirigent_examples/shelves/demo/weekly-import-malawi.yaml +54 -0
- dirigent_examples/shelves/demo/weekly-import-nepal.yaml +75 -0
- dirigent_examples/shelves/docker/README.md +29 -0
- dirigent_examples/shelves/docker/docker-build-push.yaml +110 -0
- dirigent_examples/shelves/docker/docker-build-run.yaml +106 -0
- dirigent_examples/shelves/docker/docker-compose-database.yaml +124 -0
- dirigent_examples/shelves/docker/docker-compose-failing-up.yaml +83 -0
- dirigent_examples/shelves/docker/docker-compose-file.yaml +117 -0
- dirigent_examples/shelves/docker/docker-compose-profiles-env.yaml +133 -0
- dirigent_examples/shelves/docker/docker-compose-stack.yaml +70 -0
- dirigent_examples/shelves/docker/docker-hello.yaml +53 -0
- dirigent_examples/shelves/docker/docker-remote-daemon.yaml +92 -0
- dirigent_examples/shelves/docker/docker-run-failing-teardown.yaml +94 -0
- dirigent_examples/shelves/docker/docker-ticker.yaml +49 -0
- dirigent_examples/shelves/execute/README.md +16 -0
- dirigent_examples/shelves/execute/long-log.yaml +89 -0
- dirigent_examples/shelves/failure/README.md +20 -0
- dirigent_examples/shelves/failure/error-handler.yaml +89 -0
- dirigent_examples/shelves/failure/optional-step.yaml +82 -0
- dirigent_examples/shelves/failure/retries.yaml +75 -0
- dirigent_examples/shelves/failure/retry-budget.yaml +82 -0
- dirigent_examples/shelves/failure/step-timeout.yaml +96 -0
- dirigent_examples/shelves/git/README.md +32 -0
- dirigent_examples/shelves/git/git-checkout-build.yaml +125 -0
- dirigent_examples/shelves/git/git-checkout-compose.yaml +138 -0
- dirigent_examples/shelves/git/git-checkout-public.yaml +84 -0
- dirigent_examples/shelves/graph/README.md +22 -0
- dirigent_examples/shelves/graph/deep-chain.yaml +119 -0
- dirigent_examples/shelves/graph/fan-in.yaml +80 -0
- dirigent_examples/shelves/graph/fan-out.yaml +66 -0
- dirigent_examples/shelves/graph/linear.yaml +66 -0
- dirigent_examples/shelves/graph/parallel-branches.yaml +57 -0
- dirigent_examples/shelves/graph/parallel-sleep.yaml +55 -0
- dirigent_examples/shelves/graph/skip-diamond.yaml +105 -0
- dirigent_examples/shelves/graph/wide-fan.yaml +147 -0
- dirigent_examples/shelves/hello-world.yaml +30 -0
- dirigent_examples/shelves/open-data/README.md +67 -0
- dirigent_examples/shelves/open-data/feeds-composition.yaml +120 -0
- dirigent_examples/shelves/open-data/gdacs-disaster-updates.yaml +230 -0
- dirigent_examples/shelves/open-data/github-releases-relay.yaml +225 -0
- dirigent_examples/shelves/open-data/hdx-dataset-watch.yaml +211 -0
- dirigent_examples/shelves/open-data/kobo-submissions-to-csv.yaml +149 -0
- dirigent_examples/shelves/open-data/nominatim-geocode-facilities.yaml +169 -0
- dirigent_examples/shelves/open-data/odk-central-submissions.yaml +146 -0
- dirigent_examples/shelves/open-data/open-meteo-weekly-report.yaml +142 -0
- dirigent_examples/shelves/open-data/overpass-health-facilities.yaml +154 -0
- dirigent_examples/shelves/open-data/usgs-earthquakes-alert.yaml +208 -0
- dirigent_examples/shelves/open-data/who-gho-indicators-to-parquet.yaml +163 -0
- dirigent_examples/shelves/open-data/wikidata-country-reference.yaml +146 -0
- dirigent_examples/shelves/open-data/world-bank-population-trend.yaml +166 -0
- dirigent_examples/shelves/patterns/README.md +144 -0
- dirigent_examples/shelves/patterns/concurrency-queue.yaml +89 -0
- dirigent_examples/shelves/patterns/concurrency-replace.yaml +92 -0
- dirigent_examples/shelves/patterns/concurrency-skip.yaml +97 -0
- dirigent_examples/shelves/patterns/connections-referenced-vs-carried.yaml +140 -0
- dirigent_examples/shelves/patterns/deadline-on-a-sensor.yaml +106 -0
- dirigent_examples/shelves/patterns/fan-out-continue.yaml +88 -0
- dirigent_examples/shelves/patterns/fan-out-fail-fast.yaml +80 -0
- dirigent_examples/shelves/patterns/fan-out-from-params.yaml +84 -0
- dirigent_examples/shelves/patterns/fan-out-item-wise.yaml +111 -0
- dirigent_examples/shelves/patterns/fan-out-literal-list.yaml +81 -0
- dirigent_examples/shelves/patterns/fan-out-nested-objects.yaml +107 -0
- dirigent_examples/shelves/patterns/fan-out-then-join.yaml +86 -0
- dirigent_examples/shelves/patterns/log-levels.yaml +119 -0
- dirigent_examples/shelves/patterns/outputs-inline-vs-storage.yaml +140 -0
- dirigent_examples/shelves/patterns/params-every-type.yaml +259 -0
- dirigent_examples/shelves/patterns/params-validation-refuses.yaml +131 -0
- dirigent_examples/shelves/patterns/pipeline-run-child.yaml +96 -0
- dirigent_examples/shelves/patterns/pipeline-run-fire-and-forget.yaml +99 -0
- dirigent_examples/shelves/patterns/pipeline-run-strict.yaml +103 -0
- dirigent_examples/shelves/patterns/pipeline-run-wait.yaml +107 -0
- dirigent_examples/shelves/patterns/pipeline-run-with-params.yaml +124 -0
- dirigent_examples/shelves/patterns/poll-cadence.yaml +102 -0
- dirigent_examples/shelves/patterns/priority-layered.yaml +120 -0
- dirigent_examples/shelves/patterns/references-cheat-sheet.yaml +186 -0
- dirigent_examples/shelves/patterns/retry-budget-exhausted.yaml +92 -0
- dirigent_examples/shelves/patterns/retry-exponential-backoff.yaml +88 -0
- dirigent_examples/shelves/patterns/retry-only-transient.yaml +108 -0
- dirigent_examples/shelves/patterns/retry-with-jitter.yaml +102 -0
- dirigent_examples/shelves/patterns/rule-all-done.yaml +83 -0
- dirigent_examples/shelves/patterns/rule-all-success.yaml +79 -0
- dirigent_examples/shelves/patterns/rule-always.yaml +92 -0
- dirigent_examples/shelves/patterns/rule-one-failed.yaml +86 -0
- dirigent_examples/shelves/patterns/schedule-at-once.yaml +110 -0
- dirigent_examples/shelves/patterns/schedule-cron-timezone.yaml +121 -0
- dirigent_examples/shelves/patterns/schedule-interval.yaml +109 -0
- dirigent_examples/shelves/patterns/schedule-window-half-open.yaml +105 -0
- dirigent_examples/shelves/patterns/sensor-http-ready.yaml +121 -0
- dirigent_examples/shelves/patterns/sensor-storage-exists.yaml +134 -0
- dirigent_examples/shelves/patterns/step-names-and-keys.yaml +99 -0
- dirigent_examples/shelves/patterns/timeout-fails-the-step.yaml +94 -0
- dirigent_examples/shelves/patterns/timeout-skips-the-step.yaml +102 -0
- dirigent_examples/shelves/patterns/webhook-mapping-nested-payload.yaml +125 -0
- dirigent_examples/shelves/patterns/webhook-signed.yaml +144 -0
- dirigent_examples/shelves/preview/s3-parquet-to-ingestion.yaml +92 -0
- dirigent_examples/shelves/python/README.md +31 -0
- dirigent_examples/shelves/python/apply_and_run.py +52 -0
- dirigent_examples/shelves/python/ci_gate.py +76 -0
- dirigent_examples/shelves/python/connections.py +61 -0
- dirigent_examples/shelves/python/error_handling.py +84 -0
- dirigent_examples/shelves/python/follow_logs.py +39 -0
- dirigent_examples/shelves/python/list_and_filter.py +52 -0
- dirigent_examples/shelves/queues/README.md +59 -0
- dirigent_examples/shelves/queues/kafka-consume-then-transform.yaml +105 -0
- dirigent_examples/shelves/queues/kafka-produce-then-consume.yaml +124 -0
- dirigent_examples/shelves/queues/rabbitmq-consume-ack-on-success.yaml +102 -0
- dirigent_examples/shelves/queues/report-to-kafka.yaml +105 -0
- dirigent_examples/shelves/queues/report-to-rabbitmq.yaml +105 -0
- dirigent_examples/shelves/recipes/README.md +130 -0
- dirigent_examples/shelves/recipes/csv-header-rules.yaml +147 -0
- dirigent_examples/shelves/recipes/csv-to-ndjson.yaml +107 -0
- dirigent_examples/shelves/recipes/etl-csv-clean-validate-parquet.yaml +207 -0
- dirigent_examples/shelves/recipes/filter-by-predicate.yaml +116 -0
- dirigent_examples/shelves/recipes/filter-then-map-then-reduce.yaml +109 -0
- dirigent_examples/shelves/recipes/http-fetch-validate-post.yaml +142 -0
- dirigent_examples/shelves/recipes/http-follow-redirects.yaml +100 -0
- dirigent_examples/shelves/recipes/http-get-with-query.yaml +96 -0
- dirigent_examples/shelves/recipes/http-headers-and-auth-connection.yaml +110 -0
- dirigent_examples/shelves/recipes/http-post-file-from-storage.yaml +117 -0
- dirigent_examples/shelves/recipes/http-post-json-echo.yaml +105 -0
- dirigent_examples/shelves/recipes/http-post-report.yaml +183 -0
- dirigent_examples/shelves/recipes/http-save-body-to-storage.yaml +106 -0
- dirigent_examples/shelves/recipes/http-success-status-list.yaml +80 -0
- dirigent_examples/shelves/recipes/http-timeout-override.yaml +104 -0
- dirigent_examples/shelves/recipes/jq-dedupe-by-key.yaml +78 -0
- dirigent_examples/shelves/recipes/jq-defaults-and-nulls.yaml +91 -0
- dirigent_examples/shelves/recipes/jq-group-by-and-sum.yaml +74 -0
- dirigent_examples/shelves/recipes/jq-join-two-lists.yaml +77 -0
- dirigent_examples/shelves/recipes/jq-long-to-wide.yaml +76 -0
- dirigent_examples/shelves/recipes/jq-nested-to-flat.yaml +89 -0
- dirigent_examples/shelves/recipes/jq-pivot-wide-to-long.yaml +65 -0
- dirigent_examples/shelves/recipes/jq-running-totals.yaml +82 -0
- dirigent_examples/shelves/recipes/jq-string-cleaning.yaml +88 -0
- dirigent_examples/shelves/recipes/jq-top-n.yaml +85 -0
- dirigent_examples/shelves/recipes/jq-validate-in-jq-vs-schema.yaml +124 -0
- dirigent_examples/shelves/recipes/jq-window-dates.yaml +82 -0
- dirigent_examples/shelves/recipes/json-to-csv-flattening.yaml +141 -0
- dirigent_examples/shelves/recipes/large-output-to-storage.yaml +134 -0
- dirigent_examples/shelves/recipes/map-enrich-with-lookup.yaml +96 -0
- dirigent_examples/shelves/recipes/ndjson-to-parquet.yaml +130 -0
- dirigent_examples/shelves/recipes/pagination-by-fan-out.yaml +115 -0
- dirigent_examples/shelves/recipes/parquet-round-trip-types.yaml +163 -0
- dirigent_examples/shelves/recipes/reconcile-two-sources.yaml +159 -0
- dirigent_examples/shelves/recipes/report-built-in.yaml +72 -0
- dirigent_examples/shelves/recipes/report-daily-digest.yaml +186 -0
- dirigent_examples/shelves/recipes/report-to-file.yaml +131 -0
- dirigent_examples/shelves/recipes/report-to-webhook.yaml +136 -0
- dirigent_examples/shelves/recipes/schema-carried.yaml +112 -0
- dirigent_examples/shelves/recipes/schema-formats.yaml +107 -0
- dirigent_examples/shelves/recipes/schema-referenced.yaml +86 -0
- dirigent_examples/shelves/recipes/schema-refuses-then-rule.yaml +127 -0
- dirigent_examples/shelves/recipes/storage-copy-dated-archive.yaml +114 -0
- dirigent_examples/shelves/recipes/storage-exists-gate.yaml +127 -0
- dirigent_examples/shelves/recipes/storage-manifest-of-a-fan-out.yaml +104 -0
- dirigent_examples/shelves/recipes/storage-write-then-read.yaml +119 -0
- dirigent_examples/shelves/recipes/webhook-post-hmac.yaml +132 -0
- dirigent_examples/shelves/recipes/webhook-post-summary.yaml +142 -0
- dirigent_examples/shelves/s3/README.md +34 -0
- dirigent_examples/shelves/s3/report-to-s3.yaml +93 -0
- dirigent_examples/shelves/s3/s3-copy-and-verify.yaml +105 -0
- dirigent_examples/shelves/s3/s3-csv-report.yaml +87 -0
- dirigent_examples/shelves/s3/s3-parquet-report.yaml +77 -0
- dirigent_examples/shelves/s3/s3-round-trip.yaml +125 -0
- dirigent_examples/shelves/schemas/README.md +36 -0
- dirigent_examples/shelves/schemas/echo-reading.json +18 -0
- dirigent_examples/shelves/schemas/ou-record.json +13 -0
- dirigent_examples/shelves/schemas/station-reading.json +13 -0
- dirigent_examples/shelves/sensors/README.md +16 -0
- dirigent_examples/shelves/sensors/sensor-gate.yaml +65 -0
- dirigent_examples/shelves/sensors/time-window.yaml +61 -0
- dirigent_examples/shelves/sql/README.md +52 -0
- dirigent_examples/shelves/sql/duckdb-parquet-to-report.yaml +146 -0
- dirigent_examples/shelves/sql/sql-postgres-readonly.yaml +111 -0
- dirigent_examples/shelves/sql/sql-query-to-storage.yaml +85 -0
- dirigent_examples/shelves/sql/sql-sqlite-roundtrip.yaml +114 -0
- dirigent_examples/shelves/sql/warehouse.sql +42 -0
- dirigent_examples/shelves/transform/README.md +36 -0
- dirigent_examples/shelves/transform/csv-report.yaml +55 -0
- dirigent_examples/shelves/transform/jq-filter-and-map.yaml +70 -0
- dirigent_examples/shelves/transform/jq-group-and-aggregate.yaml +70 -0
- dirigent_examples/shelves/transform/jq-join-two-sources.yaml +98 -0
- dirigent_examples/shelves/transform/jq-reshape.yaml +91 -0
- dirigent_examples/shelves/transform/jq-stream-through-storage.yaml +112 -0
- dirigent_examples/shelves/transform/ndjson-round-trip.yaml +56 -0
- dirigent_examples/shelves/transform/parquet-round-trip.yaml +68 -0
- dirigent_examples/shelves/transform/std-convert-fan-out.yaml +142 -0
- dirigent_examples/shelves/transform/xml-feed-to-ndjson.yaml +116 -0
- dirigent_examples/shelves/transform/yaml-config-to-json.yaml +104 -0
- dirigent_examples/shelves/triggers/README.md +45 -0
- dirigent_examples/shelves/triggers/at-one-time.yaml +78 -0
- dirigent_examples/shelves/triggers/cron-nightly.yaml +79 -0
- dirigent_examples/shelves/triggers/cron-windowed.yaml +86 -0
- dirigent_examples/shelves/triggers/document-nightly.yaml +80 -0
- dirigent_examples/shelves/triggers/interval-rolling.yaml +88 -0
- dirigent_examples/shelves/triggers/managed-and-manual.yaml +109 -0
- dirigent_examples/shelves/triggers/webhook-trigger.yaml +75 -0
- dirigent_examples/shelves/validate/README.md +31 -0
- dirigent_examples/shelves/validate/expects-a-shape.yaml +56 -0
- dirigent_examples/shelves/validate/the-shape-is-wrong.yaml +46 -0
- dirigent_examples-0.15.0.dist-info/METADATA +21 -0
- dirigent_examples-0.15.0.dist-info/RECORD +216 -0
- dirigent_examples-0.15.0.dist-info/WHEEL +4 -0
- dirigent_examples-0.15.0.dist-info/entry_points.txt +3 -0
- dirigent_examples-0.15.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# A week of weather for one point, from a public archive, ending as a csv in storage.
|
|
2
|
+
#
|
|
3
|
+
# Open-Meteo's archive API serves reanalysed historical weather for any coordinate, with no
|
|
4
|
+
# key, no account and no quota worth worrying about. It answers columnar: one `daily.time`
|
|
5
|
+
# array of dates and one array per variable, aligned by index. That shape is convenient for a
|
|
6
|
+
# chart and wrong for everything else, so the first thing this pipeline does is turn it into
|
|
7
|
+
# rows.
|
|
8
|
+
#
|
|
9
|
+
# What happens, hop by hop:
|
|
10
|
+
#
|
|
11
|
+
# covered the run's window, an interval of instants, turned into the two dates the API
|
|
12
|
+
# asks for. The window is half-open -- start included, end excluded -- and
|
|
13
|
+
# Open-Meteo's end_date is inclusive, so the end is pulled back one day here
|
|
14
|
+
# rather than being quietly off by one in the output.
|
|
15
|
+
# fetch one GET. The response is the columnar payload above, held inline: a week of
|
|
16
|
+
# three variables is a few kilobytes, so nothing needs to go to storage yet.
|
|
17
|
+
# rows the transposition. `daily.time` indexes everything, so walking its indices and
|
|
18
|
+
# reading each variable at the same offset is the whole job.
|
|
19
|
+
# staged storage.write, the rows as one json object. A converter reads one URI and writes
|
|
20
|
+
# another, so a value the run is holding is put down before it is re-encoded.
|
|
21
|
+
# report the codec, json to csv, from that object to the one a person opens.
|
|
22
|
+
#
|
|
23
|
+
# The archive lags real weather by about five days, so a schedule that reads the week that
|
|
24
|
+
# just closed is reading a week the archive already has. That is why the cadence below fires
|
|
25
|
+
# on Monday for the week before, and why the ad hoc window in the run line is in the past.
|
|
26
|
+
#
|
|
27
|
+
# To make it yours: point latitude and longitude at your site, name the variables you care
|
|
28
|
+
# about in `daily`, and change the schedule's timezone to the one your week is counted in.
|
|
29
|
+
# For many points, this is the pipeline a fan-out calls once per site.
|
|
30
|
+
#
|
|
31
|
+
# dg run --local examples/open-data/open-meteo-weekly-report.yaml --window 2026-08-17..2026-08-24
|
|
32
|
+
# dg run --local examples/open-data/open-meteo-weekly-report.yaml --window 2026-08-17..2026-08-24 \
|
|
33
|
+
# -p latitude=-1.29 -p longitude=36.82
|
|
34
|
+
|
|
35
|
+
format: dirigent/v1
|
|
36
|
+
kind: pipeline
|
|
37
|
+
code: open-meteo-weekly-report
|
|
38
|
+
name: Weekly weather report
|
|
39
|
+
description: |
|
|
40
|
+
A week of daily temperature and precipitation for one coordinate, read from the
|
|
41
|
+
**Open-Meteo archive API**, transposed from columns to rows and written as csv.
|
|
42
|
+
|
|
43
|
+
The week is the run's window, so the same document serves the Monday schedule and a
|
|
44
|
+
backfill of any week the archive holds.
|
|
45
|
+
|
|
46
|
+
tags: [open-data, http, storage, transform, csv, schedule, starter]
|
|
47
|
+
|
|
48
|
+
requires:
|
|
49
|
+
blocks:
|
|
50
|
+
- transform.jq
|
|
51
|
+
- http.request
|
|
52
|
+
- storage.write
|
|
53
|
+
- convert.std
|
|
54
|
+
|
|
55
|
+
params:
|
|
56
|
+
type: object
|
|
57
|
+
additionalProperties: false
|
|
58
|
+
properties:
|
|
59
|
+
latitude:
|
|
60
|
+
type: number
|
|
61
|
+
default: -13.98
|
|
62
|
+
description: Degrees north; the default is Lilongwe, Malawi.
|
|
63
|
+
longitude:
|
|
64
|
+
type: number
|
|
65
|
+
default: 33.79
|
|
66
|
+
description: Degrees east.
|
|
67
|
+
daily:
|
|
68
|
+
type: string
|
|
69
|
+
default: temperature_2m_max,temperature_2m_min,precipitation_sum
|
|
70
|
+
description: The archive's daily variables, comma-separated, as the API names them.
|
|
71
|
+
|
|
72
|
+
steps:
|
|
73
|
+
covered:
|
|
74
|
+
block: transform.jq
|
|
75
|
+
config:
|
|
76
|
+
input:
|
|
77
|
+
start: ${run.window.start}
|
|
78
|
+
end: ${run.window.end}
|
|
79
|
+
# fromdateiso8601 wants a Z, and a window edge carries a numeric offset, so the string
|
|
80
|
+
# is cut to seconds and given one. The subtraction is the half-open-to-inclusive fix.
|
|
81
|
+
program: |
|
|
82
|
+
def as_date: .[0:19] + "Z" | fromdateiso8601;
|
|
83
|
+
{
|
|
84
|
+
start_date: .start[0:10],
|
|
85
|
+
end_date: (.end | as_date | . - 86400 | strftime("%Y-%m-%d"))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
fetch:
|
|
89
|
+
block: http.request
|
|
90
|
+
depends_on: [covered]
|
|
91
|
+
config:
|
|
92
|
+
url: https://archive-api.open-meteo.com/v1/archive
|
|
93
|
+
query:
|
|
94
|
+
latitude: ${params.latitude}
|
|
95
|
+
longitude: ${params.longitude}
|
|
96
|
+
start_date: ${steps.covered.output.value.start_date}
|
|
97
|
+
end_date: ${steps.covered.output.value.end_date}
|
|
98
|
+
daily: ${params.daily}
|
|
99
|
+
# UTC rather than the station's local zone, so a row's date means the same thing here
|
|
100
|
+
# as it does in every other pipeline on this shelf.
|
|
101
|
+
timezone: UTC
|
|
102
|
+
|
|
103
|
+
rows:
|
|
104
|
+
block: transform.jq
|
|
105
|
+
depends_on: [fetch]
|
|
106
|
+
config:
|
|
107
|
+
input: ${steps.fetch.output.body}
|
|
108
|
+
# The arrays under daily are parallel, so the index is the join key. reduce over the
|
|
109
|
+
# keys rather than naming the variables keeps this program correct when the `daily`
|
|
110
|
+
# parameter asks for a different set.
|
|
111
|
+
program: |
|
|
112
|
+
. as $body
|
|
113
|
+
| ($body.daily | keys_unsorted - ["time"]) as $vars
|
|
114
|
+
| [range($body.daily.time | length) as $i
|
|
115
|
+
| reduce $vars[] as $var
|
|
116
|
+
({date: $body.daily.time[$i]}; .[$var] = $body.daily[$var][$i])]
|
|
117
|
+
|
|
118
|
+
staged:
|
|
119
|
+
block: storage.write
|
|
120
|
+
depends_on: [rows]
|
|
121
|
+
config:
|
|
122
|
+
target: ${run.scratch}/weather/${run.window.start}.json
|
|
123
|
+
value: ${steps.rows.output.value}
|
|
124
|
+
|
|
125
|
+
report:
|
|
126
|
+
block: convert.std
|
|
127
|
+
depends_on: [staged]
|
|
128
|
+
config:
|
|
129
|
+
source: ${steps.staged.output.uri}
|
|
130
|
+
# The window start names the file, so a backfill of ten weeks writes ten objects and
|
|
131
|
+
# overwrites none of them.
|
|
132
|
+
target: ${run.scratch}/weather/${run.window.start}.csv
|
|
133
|
+
from: json
|
|
134
|
+
to: csv
|
|
135
|
+
|
|
136
|
+
triggers:
|
|
137
|
+
schedules:
|
|
138
|
+
- code: weekly
|
|
139
|
+
name: Monday morning, for the week before
|
|
140
|
+
description: Fires once the archive has settled on the week that just closed.
|
|
141
|
+
cron: "0 6 * * 1"
|
|
142
|
+
timezone: UTC
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Every hospital and clinic OpenStreetMap knows about inside a box, as csv and as parquet.
|
|
2
|
+
#
|
|
3
|
+
# Overpass is a query engine over live OSM data, keyless and public. Its query language is its
|
|
4
|
+
# own -- `[out:json];(node[...](bbox);way[...](bbox););out center;` -- and it answers
|
|
5
|
+
# {"version": ..., "elements": [...]}. An element is a node, which carries lat and lon itself,
|
|
6
|
+
# or a way, which is a shape and carries a computed `center` instead because `out center` asked
|
|
7
|
+
# for one. Everything else about a facility lives in `tags`, a free-form map: `name`, `amenity`,
|
|
8
|
+
# `healthcare`, `operator`, whatever a mapper typed.
|
|
9
|
+
#
|
|
10
|
+
# On the request: Overpass takes the query as the body of a POST, written in its own language
|
|
11
|
+
# and not in JSON. `http.request` sends a string body as it stands and serialises anything else
|
|
12
|
+
# as JSON, so the query goes in `body` as text -- a JSON-quoted query is a parse error at
|
|
13
|
+
# Overpass, not a query -- and `content_type` says what the server is being handed.
|
|
14
|
+
#
|
|
15
|
+
# What happens, hop by hop:
|
|
16
|
+
#
|
|
17
|
+
# fetch one POST carrying the query, holding the answer inline. The box below is a city,
|
|
18
|
+
# which is a few hundred elements.
|
|
19
|
+
# rows the flattening. Nodes and ways are reconciled to one lat/lon pair, and the four
|
|
20
|
+
# tags worth having become columns; an element with no name still becomes a row,
|
|
21
|
+
# because an unnamed clinic is a fact about the map rather than a broken record.
|
|
22
|
+
# staged storage.write, the rows as one json object. A converter reads one URI and writes
|
|
23
|
+
# another, so both codecs below read this one object.
|
|
24
|
+
# csv for a person: opens in anything, loses the types.
|
|
25
|
+
# parquet for a pipeline: keeps the types, and is what any later analysis wants to read.
|
|
26
|
+
# The same rows are written twice on purpose; that is the point of having both
|
|
27
|
+
# codecs, and neither one re-fetches anything.
|
|
28
|
+
#
|
|
29
|
+
# Overpass is a shared public instance with a per-IP quota, and it answers a slow query with a
|
|
30
|
+
# 504 rather than queueing it. The retry below is not decoration: it is how this pipeline
|
|
31
|
+
# behaves on an ordinary busy afternoon. Heavy or scheduled use belongs on your own instance.
|
|
32
|
+
#
|
|
33
|
+
# To make it yours: move the four bbox edges, and change amenities to whatever OSM calls the
|
|
34
|
+
# thing you are mapping -- `pharmacy`, `doctors`, `school`. The regex in the query is what
|
|
35
|
+
# makes the list a list.
|
|
36
|
+
#
|
|
37
|
+
# dg run --local examples/open-data/overpass-health-facilities.yaml
|
|
38
|
+
# dg run --local examples/open-data/overpass-health-facilities.yaml \
|
|
39
|
+
# -p south=27.65 -p west=85.25 -p north=27.78 -p east=85.40 -p amenities='pharmacy|doctors'
|
|
40
|
+
|
|
41
|
+
format: dirigent/v1
|
|
42
|
+
kind: pipeline
|
|
43
|
+
code: overpass-health-facilities
|
|
44
|
+
name: Health facilities from OpenStreetMap
|
|
45
|
+
description: |
|
|
46
|
+
Hospitals and clinics inside a bounding box, read from the **Overpass API**, flattened into
|
|
47
|
+
rows and written twice: csv for a person and parquet for the next pipeline.
|
|
48
|
+
|
|
49
|
+
tags: [open-data, http, storage, transform, csv, parquet, starter]
|
|
50
|
+
|
|
51
|
+
requires:
|
|
52
|
+
blocks:
|
|
53
|
+
- http.request
|
|
54
|
+
- transform.jq
|
|
55
|
+
- storage.write
|
|
56
|
+
- convert.std
|
|
57
|
+
- convert.arrow
|
|
58
|
+
|
|
59
|
+
params:
|
|
60
|
+
type: object
|
|
61
|
+
additionalProperties: false
|
|
62
|
+
properties:
|
|
63
|
+
south:
|
|
64
|
+
type: number
|
|
65
|
+
default: -14.05
|
|
66
|
+
west:
|
|
67
|
+
type: number
|
|
68
|
+
default: 33.70
|
|
69
|
+
north:
|
|
70
|
+
type: number
|
|
71
|
+
default: -13.90
|
|
72
|
+
east:
|
|
73
|
+
type: number
|
|
74
|
+
default: 33.90
|
|
75
|
+
description: The four edges of the box; the default is Lilongwe, Malawi.
|
|
76
|
+
amenities:
|
|
77
|
+
type: string
|
|
78
|
+
default: hospital|clinic
|
|
79
|
+
description: An Overpass regex alternation of amenity values.
|
|
80
|
+
|
|
81
|
+
steps:
|
|
82
|
+
fetch:
|
|
83
|
+
block: http.request
|
|
84
|
+
# A public instance under load answers 504 in seconds rather than queueing the query, so
|
|
85
|
+
# the wait between tries is long enough for the queue in front of it to drain.
|
|
86
|
+
retry:
|
|
87
|
+
max_attempts: 3
|
|
88
|
+
backoff: 30s
|
|
89
|
+
multiplier: 2.0
|
|
90
|
+
config:
|
|
91
|
+
url: https://overpass-api.de/api/interpreter
|
|
92
|
+
method: POST
|
|
93
|
+
# The [timeout:60] inside the query is Overpass's own budget for running it; the
|
|
94
|
+
# timeout below is how long this step waits for the whole exchange. The inner
|
|
95
|
+
# one has to be the smaller of the two, or the server is still working when the
|
|
96
|
+
# client has given up.
|
|
97
|
+
body: |
|
|
98
|
+
[out:json][timeout:60];
|
|
99
|
+
(
|
|
100
|
+
node["amenity"~"^(${params.amenities})$"](${params.south},${params.west},${params.north},${params.east});
|
|
101
|
+
way["amenity"~"^(${params.amenities})$"](${params.south},${params.west},${params.north},${params.east});
|
|
102
|
+
);
|
|
103
|
+
out center;
|
|
104
|
+
# What the interpreter is handed: OverpassQL as plain text, said by the step rather than
|
|
105
|
+
# left for the client to guess.
|
|
106
|
+
content_type: text/plain; charset=utf-8
|
|
107
|
+
timeout: 2m
|
|
108
|
+
|
|
109
|
+
rows:
|
|
110
|
+
block: transform.jq
|
|
111
|
+
depends_on: [fetch]
|
|
112
|
+
config:
|
|
113
|
+
input: ${steps.fetch.output.body}
|
|
114
|
+
# A way has no coordinates of its own; `out center` gives it a centroid, and `// ` is
|
|
115
|
+
# what reconciles the two shapes into one pair of columns.
|
|
116
|
+
program: |
|
|
117
|
+
[.elements[]
|
|
118
|
+
| {
|
|
119
|
+
osm_kind: .type,
|
|
120
|
+
osm_id: .id,
|
|
121
|
+
name: (.tags.name // null),
|
|
122
|
+
amenity: (.tags.amenity // null),
|
|
123
|
+
healthcare: (.tags.healthcare // null),
|
|
124
|
+
operator: (.tags.operator // null),
|
|
125
|
+
latitude: (.lat // .center.lat),
|
|
126
|
+
longitude: (.lon // .center.lon)
|
|
127
|
+
}]
|
|
128
|
+
| sort_by(.name // "")
|
|
129
|
+
|
|
130
|
+
staged:
|
|
131
|
+
block: storage.write
|
|
132
|
+
depends_on: [rows]
|
|
133
|
+
config:
|
|
134
|
+
target: ${run.scratch}/facilities.json
|
|
135
|
+
value: ${steps.rows.output.value}
|
|
136
|
+
|
|
137
|
+
csv:
|
|
138
|
+
block: convert.std
|
|
139
|
+
depends_on: [staged]
|
|
140
|
+
config:
|
|
141
|
+
source: ${steps.staged.output.uri}
|
|
142
|
+
target: ${run.scratch}/facilities.csv
|
|
143
|
+
from: json
|
|
144
|
+
to: csv
|
|
145
|
+
|
|
146
|
+
parquet:
|
|
147
|
+
block: convert.arrow
|
|
148
|
+
depends_on: [staged]
|
|
149
|
+
config:
|
|
150
|
+
# The same object the csv is made from, read a second time: two codecs, one source.
|
|
151
|
+
source: ${steps.staged.output.uri}
|
|
152
|
+
target: ${run.scratch}/facilities.parquet
|
|
153
|
+
from: json
|
|
154
|
+
to: parquet
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Everything the ground did in the last day, cut down to what is near you and big enough to care.
|
|
2
|
+
#
|
|
3
|
+
# The USGS summary feeds are keyless GeoJSON, updated every minute: all_day.geojson is a
|
|
4
|
+
# FeatureCollection of every event in the past 24 hours, a couple of hundred of them on an
|
|
5
|
+
# ordinary day. Each feature carries the magnitude, the place, and the epoch-milliseconds time
|
|
6
|
+
# in `properties`, and the position as [longitude, latitude, depth_km] in `geometry.coordinates`
|
|
7
|
+
# -- longitude first, which is the GeoJSON order and the opposite of how people say it.
|
|
8
|
+
#
|
|
9
|
+
# The interesting part is not the fetch, it is the quiet day. An alert that posts "nothing
|
|
10
|
+
# happened" every hour is an alert nobody reads, so this pipeline has to be able to do nothing.
|
|
11
|
+
# There is no `if` in the format, and there is no conditional edge; what there is is a sensor
|
|
12
|
+
# that can skip, and a step behind a skipped sensor is skipped with it. So the digest is written
|
|
13
|
+
# to storage first, and `storage.exists` is asked for it with a floor of one byte: an empty
|
|
14
|
+
# digest writes an empty file, the floor is never met, the sensor times out and skips, the
|
|
15
|
+
# webhook skips behind it, and the run still ends succeeded. A matching event writes bytes, the
|
|
16
|
+
# very first poke sees them, and the post goes out.
|
|
17
|
+
#
|
|
18
|
+
# What happens, hop by hop:
|
|
19
|
+
#
|
|
20
|
+
# feed one GET, held inline: the whole day is well under a megabyte.
|
|
21
|
+
# candidates every feature flattened into a row, with the great-circle distance worked out
|
|
22
|
+
# and the two thresholds copied onto it. jq has the trigonometry, so geography
|
|
23
|
+
# needs no subprocess and no allowlist entry. The thresholds ride along as data
|
|
24
|
+
# because that is the only way the filter below can see them: a jq program is
|
|
25
|
+
# compiled once and never has a value spliced into its text.
|
|
26
|
+
# nearby filter.jq, comparing each row's own numbers against the limits it carries.
|
|
27
|
+
# digest map.jq drops the limits again, leaving the row a person reads.
|
|
28
|
+
# ordered the digest, biggest magnitude first, which is the order the file below holds.
|
|
29
|
+
# staged storage.write, that list as one json object. A converter reads one URI and writes
|
|
30
|
+
# another, so a value the run is holding is put down before it is re-encoded.
|
|
31
|
+
# written json to ndjson. ndjson is what makes the gate work: an empty list encodes as an
|
|
32
|
+
# empty file, where an empty json array would still be two bytes.
|
|
33
|
+
# matched the gate. Three seconds is plenty when the file is already there; on a quiet
|
|
34
|
+
# day it is three seconds of waiting and then a clean skip.
|
|
35
|
+
# notify the digest, posted. It points at Postman Echo so this example runs for real
|
|
36
|
+
# without standing anything up; on your instance it is your own receiver, and
|
|
37
|
+
# `sign_with` naming a connection with an hmac_secret is how the receiver knows
|
|
38
|
+
# the post came from you.
|
|
39
|
+
#
|
|
40
|
+
# To make it yours: set latitude, longitude and radius_km to your area of responsibility, raise
|
|
41
|
+
# min_magnitude until the alert is worth waking up for, and point webhook_url at your receiver.
|
|
42
|
+
# A schedule firing hourly is the natural cadence; the feed only ever holds a day.
|
|
43
|
+
#
|
|
44
|
+
# dg run --local examples/open-data/usgs-earthquakes-alert.yaml
|
|
45
|
+
# dg run --local examples/open-data/usgs-earthquakes-alert.yaml -p min_magnitude=7 -p radius_km=50
|
|
46
|
+
|
|
47
|
+
format: dirigent/v1
|
|
48
|
+
kind: pipeline
|
|
49
|
+
code: usgs-earthquakes-alert
|
|
50
|
+
name: Earthquakes near a point
|
|
51
|
+
description: |
|
|
52
|
+
The **USGS** past-day earthquake feed, filtered by magnitude and great-circle distance from a
|
|
53
|
+
point, posted onward as a digest -- and posted only when something matched.
|
|
54
|
+
|
|
55
|
+
A quiet day ends `succeeded` with the gate and the post skipped, which is what an alert
|
|
56
|
+
pipeline has to be able to do.
|
|
57
|
+
|
|
58
|
+
tags: [open-data, http, sensor, storage, transform, webhook, filter, starter]
|
|
59
|
+
|
|
60
|
+
requires:
|
|
61
|
+
blocks:
|
|
62
|
+
- http.request
|
|
63
|
+
- filter.jq
|
|
64
|
+
- map.jq
|
|
65
|
+
- transform.jq
|
|
66
|
+
- storage.write
|
|
67
|
+
- convert.std
|
|
68
|
+
- storage.exists
|
|
69
|
+
- webhook.post
|
|
70
|
+
|
|
71
|
+
params:
|
|
72
|
+
type: object
|
|
73
|
+
additionalProperties: false
|
|
74
|
+
properties:
|
|
75
|
+
latitude:
|
|
76
|
+
type: number
|
|
77
|
+
default: 36.0
|
|
78
|
+
description: Degrees north of the point everything is measured from.
|
|
79
|
+
longitude:
|
|
80
|
+
type: number
|
|
81
|
+
default: -119.5
|
|
82
|
+
description: Degrees east; the default point is California's Central Valley.
|
|
83
|
+
radius_km:
|
|
84
|
+
type: number
|
|
85
|
+
default: 800
|
|
86
|
+
description: How far from that point an event still counts.
|
|
87
|
+
min_magnitude:
|
|
88
|
+
type: number
|
|
89
|
+
default: 2.0
|
|
90
|
+
description: Raise this until the digest is worth reading.
|
|
91
|
+
webhook_url:
|
|
92
|
+
type: string
|
|
93
|
+
default: https://postman-echo.com/post
|
|
94
|
+
description: Where the digest goes; Postman Echo so the example posts for real.
|
|
95
|
+
|
|
96
|
+
steps:
|
|
97
|
+
feed:
|
|
98
|
+
block: http.request
|
|
99
|
+
# The feed is regenerated every minute and served from a cache that is occasionally slow;
|
|
100
|
+
# a second try is cheaper than a missed hour.
|
|
101
|
+
retry:
|
|
102
|
+
max_attempts: 2
|
|
103
|
+
backoff: 10s
|
|
104
|
+
config:
|
|
105
|
+
url: https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson
|
|
106
|
+
|
|
107
|
+
candidates:
|
|
108
|
+
block: transform.jq
|
|
109
|
+
depends_on: [feed]
|
|
110
|
+
config:
|
|
111
|
+
# Everything the program needs arrives as data: the features, the point, and the two
|
|
112
|
+
# limits. Nothing about this input is spliced into the program's text.
|
|
113
|
+
input:
|
|
114
|
+
features: ${steps.feed.output.body.features}
|
|
115
|
+
latitude: ${params.latitude}
|
|
116
|
+
longitude: ${params.longitude}
|
|
117
|
+
radius_km: ${params.radius_km}
|
|
118
|
+
min_magnitude: ${params.min_magnitude}
|
|
119
|
+
# GeoJSON coordinates are [longitude, latitude, depth], longitude first. USGS times are
|
|
120
|
+
# epoch milliseconds, so a digest a person reads has to divide before it formats.
|
|
121
|
+
program: |
|
|
122
|
+
def radians: . * 3.141592653589793 / 180;
|
|
123
|
+
. as {$latitude, $longitude, $radius_km, $min_magnitude}
|
|
124
|
+
| [.features[]
|
|
125
|
+
| .geometry.coordinates as $c
|
|
126
|
+
| ((($latitude - $c[1]) | radians / 2 | sin)) as $dp
|
|
127
|
+
| ((($longitude - $c[0]) | radians / 2 | sin)) as $dl
|
|
128
|
+
| ($dp * $dp + ($c[1] | radians | cos) * ($latitude | radians | cos) * $dl * $dl) as $a
|
|
129
|
+
| {
|
|
130
|
+
id: .id,
|
|
131
|
+
magnitude: (.properties.mag // -1),
|
|
132
|
+
place: .properties.place,
|
|
133
|
+
at: (.properties.time / 1000 | todate),
|
|
134
|
+
depth_km: $c[2],
|
|
135
|
+
distance_km: (2 * 6371 * (($a | sqrt) | asin) | round),
|
|
136
|
+
url: .properties.url,
|
|
137
|
+
radius_km: $radius_km,
|
|
138
|
+
min_magnitude: $min_magnitude
|
|
139
|
+
}]
|
|
140
|
+
|
|
141
|
+
nearby:
|
|
142
|
+
block: filter.jq
|
|
143
|
+
depends_on: [candidates]
|
|
144
|
+
config:
|
|
145
|
+
input: ${steps.candidates.output.value}
|
|
146
|
+
# Every number in this comparison is a field of the row being tested, which is why the
|
|
147
|
+
# program is three words long and needs no parameters of its own.
|
|
148
|
+
program: |
|
|
149
|
+
.magnitude >= .min_magnitude and .distance_km <= .radius_km
|
|
150
|
+
|
|
151
|
+
digest:
|
|
152
|
+
block: map.jq
|
|
153
|
+
depends_on: [nearby]
|
|
154
|
+
config:
|
|
155
|
+
input: ${steps.nearby.output.value}
|
|
156
|
+
# The limits did their job in the filter; what goes out is the event.
|
|
157
|
+
program: |
|
|
158
|
+
del(.radius_km, .min_magnitude)
|
|
159
|
+
|
|
160
|
+
ordered:
|
|
161
|
+
block: transform.jq
|
|
162
|
+
depends_on: [digest]
|
|
163
|
+
config:
|
|
164
|
+
input: ${steps.digest.output.value}
|
|
165
|
+
program: |
|
|
166
|
+
sort_by(-.magnitude)
|
|
167
|
+
|
|
168
|
+
staged:
|
|
169
|
+
block: storage.write
|
|
170
|
+
depends_on: [ordered]
|
|
171
|
+
config:
|
|
172
|
+
target: ${run.scratch}/matched.json
|
|
173
|
+
value: ${steps.ordered.output.value}
|
|
174
|
+
|
|
175
|
+
written:
|
|
176
|
+
block: convert.std
|
|
177
|
+
depends_on: [staged]
|
|
178
|
+
config:
|
|
179
|
+
source: ${steps.staged.output.uri}
|
|
180
|
+
target: ${run.scratch}/matched.ndjson
|
|
181
|
+
from: json
|
|
182
|
+
to: ndjson
|
|
183
|
+
|
|
184
|
+
matched:
|
|
185
|
+
block: storage.exists
|
|
186
|
+
depends_on: [written]
|
|
187
|
+
poll: 1s
|
|
188
|
+
deadline: 3s
|
|
189
|
+
# The whole point: nothing matched is not a failure, it is a run with nothing to say.
|
|
190
|
+
on_timeout: skip
|
|
191
|
+
config:
|
|
192
|
+
uri: ${steps.written.output.target}
|
|
193
|
+
# One byte is the difference between an empty digest and a digest.
|
|
194
|
+
min_size: 1b
|
|
195
|
+
|
|
196
|
+
notify:
|
|
197
|
+
block: webhook.post
|
|
198
|
+
depends_on: [digest, matched]
|
|
199
|
+
config:
|
|
200
|
+
url: ${params.webhook_url}
|
|
201
|
+
body:
|
|
202
|
+
kind: earthquake-digest
|
|
203
|
+
near:
|
|
204
|
+
- ${params.latitude}
|
|
205
|
+
- ${params.longitude}
|
|
206
|
+
radius_km: ${params.radius_km}
|
|
207
|
+
min_magnitude: ${params.min_magnitude}
|
|
208
|
+
events: ${steps.digest.output.value}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# One WHO indicator, several countries, one table, one parquet file, and a manifest over the batch.
|
|
2
|
+
#
|
|
3
|
+
# The WHO Global Health Observatory serves its whole indicator catalogue as OData, keyless:
|
|
4
|
+
# /api/<INDICATOR_CODE> answers {"@odata.context": ..., "value": [ ... ]}, one object per
|
|
5
|
+
# observation, each carrying the country in SpatialDim, the year in TimeDim, the disaggregation
|
|
6
|
+
# in Dim1, and the number in NumericValue. The envelope is OData's; the rows inside it are what
|
|
7
|
+
# anyone actually wants.
|
|
8
|
+
#
|
|
9
|
+
# What this shows, beyond the fetch: how a fan-out is read back. `for_each` is expanded when the
|
|
10
|
+
# run is created, so it can read params, run and item -- and never another step's output. And a
|
|
11
|
+
# fan-out step stores one output: the list of its items' outputs, in item order, with a failed
|
|
12
|
+
# item absent from it. So there is no way for item three of one step to ask item three of another
|
|
13
|
+
# what it produced; what a downstream step gets is the whole list at once. That is why the
|
|
14
|
+
# flattening below is a single step over every envelope, and why the country is read out of
|
|
15
|
+
# SpatialDim: the join key lives in the data, where a fan-in can see it.
|
|
16
|
+
#
|
|
17
|
+
# What happens, hop by hop:
|
|
18
|
+
#
|
|
19
|
+
# fetch one GET per country. items: continue means a country the indicator has no data
|
|
20
|
+
# for, or one the endpoint is slow for, does not take the rest of the batch down
|
|
21
|
+
# with it -- and its absence from this step's output list is what the manifest
|
|
22
|
+
# below counts.
|
|
23
|
+
# rows the fan-in: every envelope this step is handed, opened at `.value` and flattened
|
|
24
|
+
# into one table sorted by country and year.
|
|
25
|
+
# staged storage.write, that table as one json object. A converter reads one URI and
|
|
26
|
+
# writes another, so a value the run is holding is put down before it is
|
|
27
|
+
# re-encoded.
|
|
28
|
+
# parquet convert.arrow, json to parquet. Parquet is bytes, so it never travels as a value:
|
|
29
|
+
# the step names a source URI and a target URI and nothing is carried between them.
|
|
30
|
+
# manifest what the run has to say for itself: how many countries were asked for, how many
|
|
31
|
+
# answered, how many rows landed, and where the file is.
|
|
32
|
+
# index the manifest written beside the parquet, which is the object another pipeline
|
|
33
|
+
# lists a dataset from.
|
|
34
|
+
#
|
|
35
|
+
# To make it yours: change indicator to any code from https://ghoapi.azureedge.net/api/Indicator
|
|
36
|
+
# and countries to the ISO3 codes you report on. Point the targets at s3:// on an instance with
|
|
37
|
+
# an object store, and the manifest becomes a dataset index other pipelines read.
|
|
38
|
+
#
|
|
39
|
+
# dg run --local examples/open-data/who-gho-indicators-to-parquet.yaml
|
|
40
|
+
# dg run --local examples/open-data/who-gho-indicators-to-parquet.yaml \
|
|
41
|
+
# -p indicator=WHOSIS_000015 -p countries='["NPL","BGD"]'
|
|
42
|
+
|
|
43
|
+
format: dirigent/v1
|
|
44
|
+
kind: pipeline
|
|
45
|
+
code: who-gho-indicators-to-parquet
|
|
46
|
+
name: WHO GHO indicator to parquet
|
|
47
|
+
description: |
|
|
48
|
+
One **GHO** indicator fetched once per country, flattened out of the OData envelopes into one
|
|
49
|
+
table and written as parquet, with a manifest counting what came back.
|
|
50
|
+
|
|
51
|
+
A fan-out's results are read back as one list, because a fan-out step stores one output and
|
|
52
|
+
`for_each` cannot read an upstream step's output at all.
|
|
53
|
+
|
|
54
|
+
tags: [open-data, http, storage, transform, fan-out, parquet, starter]
|
|
55
|
+
|
|
56
|
+
requires:
|
|
57
|
+
blocks:
|
|
58
|
+
- http.request
|
|
59
|
+
- transform.jq
|
|
60
|
+
- storage.write
|
|
61
|
+
- convert.arrow
|
|
62
|
+
|
|
63
|
+
params:
|
|
64
|
+
type: object
|
|
65
|
+
additionalProperties: false
|
|
66
|
+
properties:
|
|
67
|
+
indicator:
|
|
68
|
+
type: string
|
|
69
|
+
default: WHOSIS_000001
|
|
70
|
+
description: A GHO indicator code; the default is life expectancy at birth.
|
|
71
|
+
countries:
|
|
72
|
+
type: array
|
|
73
|
+
default: [MWI, NPL, ETH]
|
|
74
|
+
items:
|
|
75
|
+
type: string
|
|
76
|
+
description: ISO3 country code, as GHO writes it in SpatialDim.
|
|
77
|
+
|
|
78
|
+
steps:
|
|
79
|
+
fetch:
|
|
80
|
+
block: http.request
|
|
81
|
+
for_each: ${params.countries}
|
|
82
|
+
# A country with no data for this indicator answers an empty value list rather than an
|
|
83
|
+
# error, so what this policy really tolerates is the endpoint being slow or briefly down.
|
|
84
|
+
items: continue
|
|
85
|
+
# The GHO endpoint sits behind a CDN that occasionally takes seconds to answer a cold
|
|
86
|
+
# indicator; one retry costs nothing and saves the batch.
|
|
87
|
+
retry:
|
|
88
|
+
max_attempts: 3
|
|
89
|
+
backoff: 5s
|
|
90
|
+
config:
|
|
91
|
+
url: https://ghoapi.azureedge.net/api/${params.indicator}
|
|
92
|
+
query:
|
|
93
|
+
# OData's filter language, not a URL convention: the quotes around the code are part
|
|
94
|
+
# of the expression and the server parses them.
|
|
95
|
+
$filter: SpatialDim eq '${item}'
|
|
96
|
+
timeout: 1m
|
|
97
|
+
|
|
98
|
+
rows:
|
|
99
|
+
block: transform.jq
|
|
100
|
+
depends_on: [fetch]
|
|
101
|
+
config:
|
|
102
|
+
# One fan-out step, one output: the list of what its items answered. A country whose
|
|
103
|
+
# item failed is simply not in it.
|
|
104
|
+
input: ${steps.fetch.output}
|
|
105
|
+
# NumericValue rather than Value: Value is the display string, "48.0 [46.7-49.6]", and
|
|
106
|
+
# a column that has to be parsed before it can be added up is not a number.
|
|
107
|
+
program: |
|
|
108
|
+
[.[]
|
|
109
|
+
| .body.value[]
|
|
110
|
+
| {country: .SpatialDim,
|
|
111
|
+
year: .TimeDim,
|
|
112
|
+
dimension: .Dim1,
|
|
113
|
+
value: .NumericValue,
|
|
114
|
+
low: .Low,
|
|
115
|
+
high: .High}]
|
|
116
|
+
| sort_by(.country, .year, .dimension)
|
|
117
|
+
|
|
118
|
+
staged:
|
|
119
|
+
block: storage.write
|
|
120
|
+
depends_on: [rows]
|
|
121
|
+
config:
|
|
122
|
+
target: ${run.scratch}/rows/${params.indicator}.json
|
|
123
|
+
value: ${steps.rows.output.value}
|
|
124
|
+
|
|
125
|
+
parquet:
|
|
126
|
+
block: convert.arrow
|
|
127
|
+
depends_on: [staged]
|
|
128
|
+
config:
|
|
129
|
+
source: ${steps.staged.output.uri}
|
|
130
|
+
target: ${run.scratch}/parquet/${params.indicator}.parquet
|
|
131
|
+
from: json
|
|
132
|
+
to: parquet
|
|
133
|
+
|
|
134
|
+
manifest:
|
|
135
|
+
block: transform.jq
|
|
136
|
+
depends_on: [fetch, rows, parquet]
|
|
137
|
+
config:
|
|
138
|
+
input:
|
|
139
|
+
indicator: ${params.indicator}
|
|
140
|
+
requested: ${params.countries}
|
|
141
|
+
answered: ${steps.fetch.output}
|
|
142
|
+
rows: ${steps.rows.output.value}
|
|
143
|
+
file: ${steps.parquet.output.target}
|
|
144
|
+
bytes: ${steps.parquet.output.bytes_written}
|
|
145
|
+
# `answered` is shorter than `requested` exactly when an item failed, which is the only
|
|
146
|
+
# place in the run where that difference is visible as data rather than as a status.
|
|
147
|
+
program: |
|
|
148
|
+
{
|
|
149
|
+
indicator,
|
|
150
|
+
file,
|
|
151
|
+
bytes,
|
|
152
|
+
requested: (.requested | length),
|
|
153
|
+
answered: (.answered | length),
|
|
154
|
+
rows: (.rows | length),
|
|
155
|
+
countries: ([.rows[].country] | unique)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
index:
|
|
159
|
+
block: storage.write
|
|
160
|
+
depends_on: [manifest]
|
|
161
|
+
config:
|
|
162
|
+
target: ${run.scratch}/parquet/${params.indicator}-manifest.json
|
|
163
|
+
value: ${steps.manifest.output.value}
|