dirigent-contrib 0.28.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_contrib/__init__.py +1 -0
- dirigent_contrib/chap/__init__.py +103 -0
- dirigent_contrib/chap/alerts.py +240 -0
- dirigent_contrib/chap/backtests.py +110 -0
- dirigent_contrib/chap/bodies.py +120 -0
- dirigent_contrib/chap/configured_models.py +44 -0
- dirigent_contrib/chap/connection.py +183 -0
- dirigent_contrib/chap/datasets.py +175 -0
- dirigent_contrib/chap/jobs.py +155 -0
- dirigent_contrib/chap/messages.py +83 -0
- dirigent_contrib/chap/predictions.py +143 -0
- dirigent_contrib/chap/shelves/chap/README.md +22 -0
- dirigent_contrib/chap/shelves/chap/chap-backtest.yaml +61 -0
- dirigent_contrib/chap/shelves/chap/chap-import-dataset-dry-run.yaml +56 -0
- dirigent_contrib/chap/shelves/chap/chap-models.yaml +42 -0
- dirigent_contrib/chap/shelves/chap/chap-predict-and-read.yaml +87 -0
- dirigent_contrib/chap/shelves/chap/chap-thresholds.yaml +54 -0
- dirigent_contrib/chap/shelves/chap-climate/README.md +9 -0
- dirigent_contrib/chap/shelves/chap-climate/climate-to-chap-backtest.yaml +135 -0
- dirigent_contrib/chap/shelves/chap-dhis2/README.md +8 -0
- dirigent_contrib/chap/shelves/chap-dhis2/chap-predict-alert-review-notify.yaml +187 -0
- dirigent_contrib/chap/thresholds.py +75 -0
- dirigent_contrib/chap/web.py +80 -0
- dirigent_contrib/climate/__init__.py +95 -0
- dirigent_contrib/climate/aggregate.py +58 -0
- dirigent_contrib/climate/batch.py +251 -0
- dirigent_contrib/climate/connection.py +175 -0
- dirigent_contrib/climate/datasets.py +50 -0
- dirigent_contrib/climate/exports.py +232 -0
- dirigent_contrib/climate/graphs.py +99 -0
- dirigent_contrib/climate/ingestions.py +244 -0
- dirigent_contrib/climate/messages.py +73 -0
- dirigent_contrib/climate/payload.py +99 -0
- dirigent_contrib/climate/shelves/climate/README.md +21 -0
- dirigent_contrib/climate/shelves/climate/climate-aggregate-to-dhis2.yaml +80 -0
- dirigent_contrib/climate/shelves/climate/climate-batch-and-export.yaml +95 -0
- dirigent_contrib/climate/shelves/climate/climate-datasets.yaml +41 -0
- dirigent_contrib/climate/shelves/climate/climate-ingest.yaml +44 -0
- dirigent_contrib/climate/shelves/climate/climate-sync.yaml +42 -0
- dirigent_contrib/climate/shelves/climate-dhis2/README.md +8 -0
- dirigent_contrib/climate/shelves/climate-dhis2/climate-sync-to-dhis2.yaml +141 -0
- dirigent_contrib/climate/web.py +80 -0
- dirigent_contrib/limits.py +145 -0
- dirigent_contrib/paths.py +20 -0
- dirigent_contrib/py.typed +0 -0
- dirigent_contrib-0.28.0.dist-info/METADATA +91 -0
- dirigent_contrib-0.28.0.dist-info/RECORD +50 -0
- dirigent_contrib-0.28.0.dist-info/WHEEL +4 -0
- dirigent_contrib-0.28.0.dist-info/entry_points.txt +4 -0
- dirigent_contrib-0.28.0.dist-info/licenses/LICENSE +15 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Block packs for dirigent that reach outside systems, each a subpackage with an entry point of its own."""
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""The Chap pack: the ``chap`` connection kind and the blocks for one Chap server."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from importlib.resources import files
|
|
5
|
+
from importlib.resources.abc import Traversable
|
|
6
|
+
|
|
7
|
+
from dirigent_contrib.chap.alerts import (
|
|
8
|
+
ChapAlertCounts,
|
|
9
|
+
ChapAlertsListConfig,
|
|
10
|
+
ChapAlertsListOperator,
|
|
11
|
+
ChapAlertsOutput,
|
|
12
|
+
ChapAlertsRecordConfig,
|
|
13
|
+
ChapAlertsRecordOperator,
|
|
14
|
+
ChapAlertsRecordOutput,
|
|
15
|
+
ChapAlertsReviewedConfig,
|
|
16
|
+
ChapAlertsReviewedSensor,
|
|
17
|
+
)
|
|
18
|
+
from dirigent_contrib.chap.backtests import ChapBacktestRunConfig, ChapBacktestRunOperator, ChapBacktestRunOutput
|
|
19
|
+
from dirigent_contrib.chap.configured_models import ChapModelsListConfig, ChapModelsListOperator, ChapModelsListOutput
|
|
20
|
+
from dirigent_contrib.chap.connection import ChapConnectionConfig, ChapConnectionKind, build_client, client_for
|
|
21
|
+
from dirigent_contrib.chap.datasets import ChapDatasetImportConfig, ChapDatasetImportOperator, ChapDatasetImportOutput
|
|
22
|
+
from dirigent_contrib.chap.messages import CHAP
|
|
23
|
+
from dirigent_contrib.chap.predictions import (
|
|
24
|
+
ChapPredictionReadConfig,
|
|
25
|
+
ChapPredictionReadOperator,
|
|
26
|
+
ChapPredictionReadOutput,
|
|
27
|
+
ChapPredictionRunConfig,
|
|
28
|
+
ChapPredictionRunOperator,
|
|
29
|
+
ChapPredictionRunOutput,
|
|
30
|
+
)
|
|
31
|
+
from dirigent_contrib.chap.thresholds import ChapThresholdsConfig, ChapThresholdsOperator, ChapThresholdsOutput
|
|
32
|
+
from dirigent_plugin import Contribution, extension
|
|
33
|
+
|
|
34
|
+
#: The directory the pack's example shelves live in, inside this distribution.
|
|
35
|
+
SHELVES_DIRECTORY = "shelves"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ChapPlugin:
|
|
39
|
+
"""The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
|
|
40
|
+
|
|
41
|
+
@extension
|
|
42
|
+
def contribute(self) -> Contribution:
|
|
43
|
+
"""Contribute the Chap blocks and the ``chap`` connection kind they are configured from."""
|
|
44
|
+
return Contribution(
|
|
45
|
+
operators=[
|
|
46
|
+
ChapDatasetImportOperator(),
|
|
47
|
+
ChapBacktestRunOperator(),
|
|
48
|
+
ChapPredictionRunOperator(),
|
|
49
|
+
ChapPredictionReadOperator(),
|
|
50
|
+
ChapModelsListOperator(),
|
|
51
|
+
ChapThresholdsOperator(),
|
|
52
|
+
ChapAlertsRecordOperator(),
|
|
53
|
+
ChapAlertsListOperator(),
|
|
54
|
+
],
|
|
55
|
+
sensors=[ChapAlertsReviewedSensor()],
|
|
56
|
+
connection_kinds=[ChapConnectionKind()],
|
|
57
|
+
labels=[CHAP],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@extension
|
|
61
|
+
def examples(self) -> Sequence[Traversable]:
|
|
62
|
+
"""Contribute the example shelves this distribution carries."""
|
|
63
|
+
return [files(__package__ or "dirigent_contrib.chap") / SHELVES_DIRECTORY]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
plugin = ChapPlugin()
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"SHELVES_DIRECTORY",
|
|
70
|
+
"ChapAlertCounts",
|
|
71
|
+
"ChapAlertsListConfig",
|
|
72
|
+
"ChapAlertsListOperator",
|
|
73
|
+
"ChapAlertsOutput",
|
|
74
|
+
"ChapAlertsRecordConfig",
|
|
75
|
+
"ChapAlertsRecordOperator",
|
|
76
|
+
"ChapAlertsRecordOutput",
|
|
77
|
+
"ChapAlertsReviewedConfig",
|
|
78
|
+
"ChapAlertsReviewedSensor",
|
|
79
|
+
"ChapBacktestRunConfig",
|
|
80
|
+
"ChapBacktestRunOperator",
|
|
81
|
+
"ChapBacktestRunOutput",
|
|
82
|
+
"ChapConnectionConfig",
|
|
83
|
+
"ChapConnectionKind",
|
|
84
|
+
"ChapDatasetImportConfig",
|
|
85
|
+
"ChapDatasetImportOperator",
|
|
86
|
+
"ChapDatasetImportOutput",
|
|
87
|
+
"ChapModelsListConfig",
|
|
88
|
+
"ChapModelsListOperator",
|
|
89
|
+
"ChapModelsListOutput",
|
|
90
|
+
"ChapPlugin",
|
|
91
|
+
"ChapPredictionReadConfig",
|
|
92
|
+
"ChapPredictionReadOperator",
|
|
93
|
+
"ChapPredictionReadOutput",
|
|
94
|
+
"ChapPredictionRunConfig",
|
|
95
|
+
"ChapPredictionRunOperator",
|
|
96
|
+
"ChapPredictionRunOutput",
|
|
97
|
+
"ChapThresholdsConfig",
|
|
98
|
+
"ChapThresholdsOperator",
|
|
99
|
+
"ChapThresholdsOutput",
|
|
100
|
+
"build_client",
|
|
101
|
+
"client_for",
|
|
102
|
+
"plugin",
|
|
103
|
+
]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Chap's alert release gate: ``chap.alerts.record`` raises alerts, ``chap.alerts.reviewed`` waits on their review.
|
|
2
|
+
|
|
3
|
+
An alert lands in Chap as ``pending``, and a reviewer moves it to ``approved`` or ``declined``
|
|
4
|
+
through ``POST /v1/crud/alerts/$approve`` from wherever they work. These blocks never decide
|
|
5
|
+
on an alert: they record them, read where each one stands, and hand the decided ones on.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import ClassVar, Final, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
12
|
+
|
|
13
|
+
from dirigent_common import BlockModel
|
|
14
|
+
from dirigent_contrib.chap.bodies import PassThroughConfig, body_of
|
|
15
|
+
from dirigent_contrib.chap.connection import client_for
|
|
16
|
+
from dirigent_contrib.chap.messages import ALERTS_NOT_A_LIST, ALERTS_NOT_RECORDED, ALERTS_OUTSIDE, ALERTS_REVIEWING
|
|
17
|
+
from dirigent_contrib.chap.web import call_json, mapping
|
|
18
|
+
from dirigent_contrib.limits import BoundedClient
|
|
19
|
+
from dirigent_plugin import (
|
|
20
|
+
BlockFailure,
|
|
21
|
+
ConnectionRef,
|
|
22
|
+
ErrorClass,
|
|
23
|
+
NotYet,
|
|
24
|
+
Operator,
|
|
25
|
+
OperatorSpec,
|
|
26
|
+
Sensor,
|
|
27
|
+
SensorSpec,
|
|
28
|
+
StepContext,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ALERTS_PATH: Final = "/v1/crud/alerts"
|
|
32
|
+
|
|
33
|
+
#: The gate states that are a reviewer's decision. Any other state, ``pending`` or one Chap adds
|
|
34
|
+
#: later, is still waiting on one.
|
|
35
|
+
APPROVED: Final = "approved"
|
|
36
|
+
DECLINED: Final = "declined"
|
|
37
|
+
|
|
38
|
+
#: How often the review is looked at when the step does not say: a person decides, not a job.
|
|
39
|
+
REVIEW_POLL: Final = timedelta(minutes=5)
|
|
40
|
+
|
|
41
|
+
#: How long the review may take when the step does not say.
|
|
42
|
+
REVIEW_DEADLINE: Final = timedelta(days=3)
|
|
43
|
+
|
|
44
|
+
type Release = Literal["all", "any"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChapAlertsListConfig(BlockModel):
|
|
48
|
+
"""Which alerts a step looks at."""
|
|
49
|
+
|
|
50
|
+
connection: ConnectionRef
|
|
51
|
+
"""The code of the chap connection naming the server."""
|
|
52
|
+
|
|
53
|
+
prediction_id: int | None = None
|
|
54
|
+
"""Only the alerts this prediction raised; every prediction's when unset."""
|
|
55
|
+
|
|
56
|
+
alert_policy_id: int | None = None
|
|
57
|
+
"""Only the alerts raised against this policy; every policy when unset."""
|
|
58
|
+
|
|
59
|
+
org_units: list[str] | None = None
|
|
60
|
+
"""Only the alerts for these org units; every org unit when unset."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ChapAlertCounts(BlockModel):
|
|
64
|
+
"""How many alerts stand where in the release gate."""
|
|
65
|
+
|
|
66
|
+
total: int = 0
|
|
67
|
+
"""Every alert the step looked at."""
|
|
68
|
+
|
|
69
|
+
approved: int = 0
|
|
70
|
+
"""The alerts cleared for release."""
|
|
71
|
+
|
|
72
|
+
declined: int = 0
|
|
73
|
+
"""The alerts decided against."""
|
|
74
|
+
|
|
75
|
+
pending: int = 0
|
|
76
|
+
"""The alerts still waiting on a reviewer."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ChapAlertsOutput(BlockModel):
|
|
80
|
+
"""The alerts, split by where they stand in the release gate."""
|
|
81
|
+
|
|
82
|
+
approved: list[JsonValue] = Field(default_factory=list[JsonValue])
|
|
83
|
+
"""The alerts a reviewer cleared for release, as Chap answers with them: `{id, timePeriod, orgUnit,
|
|
84
|
+
alertPolicyId, level, predictionId, approvedBy, approvedAt, ...}`."""
|
|
85
|
+
|
|
86
|
+
declined: list[JsonValue] = Field(default_factory=list[JsonValue])
|
|
87
|
+
"""The alerts a reviewer decided not to release, in the same shape."""
|
|
88
|
+
|
|
89
|
+
pending: list[JsonValue] = Field(default_factory=list[JsonValue])
|
|
90
|
+
"""The alerts nobody has decided on yet, in the same shape."""
|
|
91
|
+
|
|
92
|
+
counts: ChapAlertCounts = Field(default_factory=ChapAlertCounts)
|
|
93
|
+
"""How many alerts there are, and how many of them are approved, declined and pending."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def list_alerts(
|
|
97
|
+
client: BoundedClient, config: "ChapAlertsListConfig | ChapAlertsReviewedConfig"
|
|
98
|
+
) -> ChapAlertsOutput:
|
|
99
|
+
"""Read the alerts a step looks at, and split them by their gate state.
|
|
100
|
+
|
|
101
|
+
A row outside the prediction or policy the step named is refused, not skipped.
|
|
102
|
+
"""
|
|
103
|
+
params: dict[str, int] = {}
|
|
104
|
+
if config.prediction_id is not None:
|
|
105
|
+
params["predictionId"] = config.prediction_id
|
|
106
|
+
if config.alert_policy_id is not None:
|
|
107
|
+
params["alertPolicyId"] = config.alert_policy_id
|
|
108
|
+
answer = await call_json(client, "GET", ALERTS_PATH, params=params)
|
|
109
|
+
if not isinstance(answer, list):
|
|
110
|
+
raise BlockFailure(ALERTS_NOT_A_LIST, error_class=ErrorClass.UNKNOWN, where=f"GET {ALERTS_PATH}")
|
|
111
|
+
wanted = set(config.org_units) if config.org_units is not None else None
|
|
112
|
+
output = ChapAlertsOutput()
|
|
113
|
+
for alert in answer:
|
|
114
|
+
for field, asked in params.items():
|
|
115
|
+
if mapping(alert).get(field) != asked:
|
|
116
|
+
raise BlockFailure(
|
|
117
|
+
ALERTS_OUTSIDE, error_class=ErrorClass.UNKNOWN, where=f"GET {ALERTS_PATH}", field=field, asked=asked
|
|
118
|
+
)
|
|
119
|
+
if wanted is not None and mapping(alert).get("orgUnit") not in wanted:
|
|
120
|
+
continue
|
|
121
|
+
state = mapping(alert).get("approved")
|
|
122
|
+
if state == APPROVED:
|
|
123
|
+
output.approved.append(alert)
|
|
124
|
+
elif state == DECLINED:
|
|
125
|
+
output.declined.append(alert)
|
|
126
|
+
else:
|
|
127
|
+
output.pending.append(alert)
|
|
128
|
+
output.counts = ChapAlertCounts(
|
|
129
|
+
total=len(output.approved) + len(output.declined) + len(output.pending),
|
|
130
|
+
approved=len(output.approved),
|
|
131
|
+
declined=len(output.declined),
|
|
132
|
+
pending=len(output.pending),
|
|
133
|
+
)
|
|
134
|
+
return output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ChapAlertsReviewedConfig(BlockModel):
|
|
138
|
+
"""Which prediction's alerts to wait on, and when their review counts as done."""
|
|
139
|
+
|
|
140
|
+
connection: ConnectionRef
|
|
141
|
+
"""The code of the chap connection naming the server."""
|
|
142
|
+
|
|
143
|
+
prediction_id: int
|
|
144
|
+
"""The prediction whose alerts are waited on, as `chap.prediction.run` outputs it."""
|
|
145
|
+
|
|
146
|
+
alert_policy_id: int | None = None
|
|
147
|
+
"""Only the alerts raised against this policy; every policy when unset."""
|
|
148
|
+
|
|
149
|
+
org_units: list[str] | None = None
|
|
150
|
+
"""Only the alerts for these org units; every org unit when unset."""
|
|
151
|
+
|
|
152
|
+
release: Release = "all"
|
|
153
|
+
"""When the wait ends: `all` once no alert is pending, `any` as soon as one is decided. A
|
|
154
|
+
prediction that raised no alerts ends it at once either way."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ChapAlertsReviewedSensor(Sensor[ChapAlertsReviewedConfig, ChapAlertsOutput]):
|
|
158
|
+
"""Waits for a reviewer to decide on a prediction's alerts in Chap; each poke is one listing."""
|
|
159
|
+
|
|
160
|
+
spec = SensorSpec(
|
|
161
|
+
id="chap.alerts.reviewed",
|
|
162
|
+
summary="Wait for a Chap prediction's alerts to be reviewed.",
|
|
163
|
+
default_poll=REVIEW_POLL,
|
|
164
|
+
default_deadline=REVIEW_DEADLINE,
|
|
165
|
+
)
|
|
166
|
+
config_model: ClassVar[type[BaseModel]] = ChapAlertsReviewedConfig
|
|
167
|
+
output_model: ClassVar[type[BaseModel]] = ChapAlertsOutput
|
|
168
|
+
|
|
169
|
+
async def poke(self, config: ChapAlertsReviewedConfig, ctx: StepContext) -> ChapAlertsOutput | NotYet:
|
|
170
|
+
"""List the prediction's alerts once, and succeed when the review is as far as `release` asks.
|
|
171
|
+
|
|
172
|
+
A refusal Chap answers with fails the poke, classified as every block in the pack
|
|
173
|
+
classifies one: a 5xx and a 429 are retried, any other 4xx is not.
|
|
174
|
+
"""
|
|
175
|
+
async with client_for(ctx, config.connection) as client:
|
|
176
|
+
alerts = await list_alerts(client, config)
|
|
177
|
+
counts = alerts.counts
|
|
178
|
+
reviewed = counts.approved + counts.declined
|
|
179
|
+
done = counts.pending == 0 if config.release == "all" else reviewed > 0
|
|
180
|
+
if counts.total == 0 or done:
|
|
181
|
+
return alerts
|
|
182
|
+
return NotYet(
|
|
183
|
+
message=ALERTS_REVIEWING.render(reviewed=reviewed, total=counts.total),
|
|
184
|
+
progress=reviewed / counts.total,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ChapAlertsListOperator(Operator[ChapAlertsListConfig, ChapAlertsOutput]):
|
|
189
|
+
"""Reads where alerts stand in the release gate, once, without waiting on anything."""
|
|
190
|
+
|
|
191
|
+
spec = OperatorSpec(id="chap.alerts.list", summary="List Chap alerts by their review state.", idempotent=True)
|
|
192
|
+
config_model: ClassVar[type[BaseModel]] = ChapAlertsListConfig
|
|
193
|
+
output_model: ClassVar[type[BaseModel]] = ChapAlertsOutput
|
|
194
|
+
|
|
195
|
+
async def execute(self, config: ChapAlertsListConfig, ctx: StepContext) -> ChapAlertsOutput:
|
|
196
|
+
"""List the alerts once."""
|
|
197
|
+
async with client_for(ctx, config.connection) as client:
|
|
198
|
+
return await list_alerts(client, config)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class ChapAlertsRecordConfig(PassThroughConfig):
|
|
202
|
+
"""The alerts to record; any undeclared key is sent to Chap as written."""
|
|
203
|
+
|
|
204
|
+
alerts: list[dict[str, JsonValue]] = Field(min_length=1)
|
|
205
|
+
"""The alerts as Chap spells them: `{timePeriod, orgUnit, alertPolicyId, level, predictionId}`, each
|
|
206
|
+
`level` one the policy defines."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ChapAlertsRecordOutput(BlockModel):
|
|
210
|
+
"""The alerts Chap recorded."""
|
|
211
|
+
|
|
212
|
+
alert_ids: list[int]
|
|
213
|
+
"""The recorded alerts' ids, in the order they were sent; each starts `pending`."""
|
|
214
|
+
|
|
215
|
+
count: int
|
|
216
|
+
"""How many alerts were recorded."""
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ChapAlertsRecordOperator(Operator[ChapAlertsRecordConfig, ChapAlertsRecordOutput]):
|
|
220
|
+
"""Records a batch of alerts against a policy, all of them or none."""
|
|
221
|
+
|
|
222
|
+
spec = OperatorSpec(id="chap.alerts.record", summary="Record alerts in Chap's review queue.")
|
|
223
|
+
config_model: ClassVar[type[BaseModel]] = ChapAlertsRecordConfig
|
|
224
|
+
output_model: ClassVar[type[BaseModel]] = ChapAlertsRecordOutput
|
|
225
|
+
|
|
226
|
+
async def execute(self, config: ChapAlertsRecordConfig, ctx: StepContext) -> ChapAlertsRecordOutput:
|
|
227
|
+
"""Record the batch once.
|
|
228
|
+
|
|
229
|
+
An unknown policy, level or prediction, and a second alert for the same prediction,
|
|
230
|
+
org unit and period, are answered 422 and never retried.
|
|
231
|
+
"""
|
|
232
|
+
body = body_of(config, "alerts")
|
|
233
|
+
async with client_for(ctx, config.connection) as client:
|
|
234
|
+
answer = mapping(await call_json(client, "POST", ALERTS_PATH, json=body))
|
|
235
|
+
ids = answer.get("ids")
|
|
236
|
+
if not isinstance(ids, list) or not all(isinstance(alert_id, int) for alert_id in ids):
|
|
237
|
+
raise BlockFailure(ALERTS_NOT_RECORDED, error_class=ErrorClass.UNKNOWN, where=f"POST {ALERTS_PATH}")
|
|
238
|
+
alert_ids = [alert_id for alert_id in ids if isinstance(alert_id, int)]
|
|
239
|
+
ctx.log.info("alerts recorded", count=len(alert_ids))
|
|
240
|
+
return ChapAlertsRecordOutput(alert_ids=alert_ids, count=len(alert_ids))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""``chap.backtest.run``: train and evaluate a configured model on a stored dataset, as a Chap job."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar, Final
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, JsonValue
|
|
6
|
+
|
|
7
|
+
from dirigent_common import BlockModel
|
|
8
|
+
from dirigent_contrib.chap.bodies import PassThroughConfig, body_of
|
|
9
|
+
from dirigent_contrib.chap.connection import client_for
|
|
10
|
+
from dirigent_contrib.chap.jobs import JOB_POLL, ChapJobOperator, job_id_of, submit
|
|
11
|
+
from dirigent_contrib.chap.web import mapping
|
|
12
|
+
from dirigent_plugin import OperatorSpec, RemoteHandle, StepContext
|
|
13
|
+
|
|
14
|
+
CREATE_BACKTEST_PATH: Final = "/v1/analytics/create-backtest"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ChapBacktestRunConfig(PassThroughConfig):
|
|
18
|
+
"""What one backtest asks of Chap.
|
|
19
|
+
|
|
20
|
+
A parameter left unset takes the server's saved default, and any undeclared key is sent to
|
|
21
|
+
Chap as written.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str = Field(min_length=1)
|
|
25
|
+
"""The name the backtest is stored under."""
|
|
26
|
+
|
|
27
|
+
model_id: int | str
|
|
28
|
+
"""The configured model to evaluate, by its id or its name."""
|
|
29
|
+
|
|
30
|
+
dataset_id: int
|
|
31
|
+
"""The stored dataset to evaluate it on, as `chap.dataset.import` outputs it."""
|
|
32
|
+
|
|
33
|
+
n_periods: int | None = Field(default=None, ge=1)
|
|
34
|
+
"""How many periods ahead each split forecasts; Chap's default is 3."""
|
|
35
|
+
|
|
36
|
+
n_splits: int | None = Field(default=None, ge=1)
|
|
37
|
+
"""How many train/test splits are evaluated; Chap's default is 7."""
|
|
38
|
+
|
|
39
|
+
stride: int | None = Field(default=None, ge=1)
|
|
40
|
+
"""How many periods one split moves on from the last; Chap's default is 1."""
|
|
41
|
+
|
|
42
|
+
n_retrain: int | None = Field(default=None, ge=1)
|
|
43
|
+
"""How many times the model is retrained across the splits; Chap's default is 1."""
|
|
44
|
+
|
|
45
|
+
future_weather_provider: str | None = None
|
|
46
|
+
"""Where the forecast window's weather comes from; Chap's default is `climatology`."""
|
|
47
|
+
|
|
48
|
+
target_column: str | None = None
|
|
49
|
+
"""The dataset column the model forecasts; Chap's default is `disease_cases`."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ChapBacktestRunOutput(BlockModel):
|
|
53
|
+
"""The backtest the finished job stored, as Chap describes it."""
|
|
54
|
+
|
|
55
|
+
backtest_id: int | None = None
|
|
56
|
+
"""The stored backtest's id."""
|
|
57
|
+
|
|
58
|
+
job_id: str
|
|
59
|
+
"""The evaluation job Chap ran."""
|
|
60
|
+
|
|
61
|
+
aggregate_metrics: JsonValue = None
|
|
62
|
+
"""The backtest's `aggregateMetrics`, lifted out of it."""
|
|
63
|
+
|
|
64
|
+
backtest: JsonValue = None
|
|
65
|
+
"""The backtest exactly as Chap's evaluation result answers with it."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ChapBacktestRunOperator(ChapJobOperator[ChapBacktestRunConfig, ChapBacktestRunOutput]):
|
|
69
|
+
"""Submits a backtest and waits for its evaluation job."""
|
|
70
|
+
|
|
71
|
+
spec = OperatorSpec(
|
|
72
|
+
id="chap.backtest.run", summary="Run a Chap backtest on a stored dataset.", default_poll=JOB_POLL
|
|
73
|
+
)
|
|
74
|
+
config_model: ClassVar[type[BaseModel]] = ChapBacktestRunConfig
|
|
75
|
+
output_model: ClassVar[type[BaseModel]] = ChapBacktestRunOutput
|
|
76
|
+
result_helper: ClassVar[str] = "evaluation_result"
|
|
77
|
+
|
|
78
|
+
async def execute(self, config: ChapBacktestRunConfig, ctx: StepContext) -> RemoteHandle:
|
|
79
|
+
"""Submit the backtest and hand back its job."""
|
|
80
|
+
body = body_of(
|
|
81
|
+
config,
|
|
82
|
+
"name",
|
|
83
|
+
"model_id",
|
|
84
|
+
"dataset_id",
|
|
85
|
+
"n_periods",
|
|
86
|
+
"n_splits",
|
|
87
|
+
"stride",
|
|
88
|
+
"n_retrain",
|
|
89
|
+
"future_weather_provider",
|
|
90
|
+
"target_column",
|
|
91
|
+
)
|
|
92
|
+
async with client_for(ctx, config.connection) as client:
|
|
93
|
+
answer = await submit(client, CREATE_BACKTEST_PATH, body)
|
|
94
|
+
job_id = job_id_of(answer, f"POST {CREATE_BACKTEST_PATH}")
|
|
95
|
+
ctx.log.info("backtest submitted", job=job_id)
|
|
96
|
+
return self.handle(job_id)
|
|
97
|
+
|
|
98
|
+
async def fetch(
|
|
99
|
+
self, handle: RemoteHandle, config: ChapBacktestRunConfig, ctx: StepContext
|
|
100
|
+
) -> ChapBacktestRunOutput:
|
|
101
|
+
"""Read the stored backtest off the finished job."""
|
|
102
|
+
backtest = await self.result(handle, config, ctx)
|
|
103
|
+
described = mapping(backtest)
|
|
104
|
+
backtest_id = described.get("id")
|
|
105
|
+
return ChapBacktestRunOutput(
|
|
106
|
+
backtest_id=backtest_id if isinstance(backtest_id, int) else None,
|
|
107
|
+
job_id=handle.ref,
|
|
108
|
+
aggregate_metrics=described.get("aggregateMetrics"),
|
|
109
|
+
backtest=backtest,
|
|
110
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""A Chap request body, built from a step's config: its declared fields renamed, the rest passed through.
|
|
2
|
+
|
|
3
|
+
Chap owns the shape of what it is sent and changes it often, so a block declares only the
|
|
4
|
+
fields it reads and lets the rest through: a key a step writes that the block does not
|
|
5
|
+
declare is sent to Chap exactly as written, in Chap's own camelCase spelling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import csv
|
|
9
|
+
import io
|
|
10
|
+
import math
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from pydantic import ConfigDict, Field, JsonValue, model_validator
|
|
14
|
+
|
|
15
|
+
from dirigent_contrib.chap.jobs import ChapJobConfig
|
|
16
|
+
from dirigent_contrib.chap.messages import CSV_BAD_VALUE, CSV_MISSING_COLUMN, CSV_TOO_LARGE, OBSERVATIONS_MISSING
|
|
17
|
+
from dirigent_plugin import BlockFailure, ErrorClass
|
|
18
|
+
|
|
19
|
+
#: The columns of a Chap CSV that are not features.
|
|
20
|
+
PERIOD_COLUMN: Final = "time_period"
|
|
21
|
+
LOCATION_COLUMN: Final = "location"
|
|
22
|
+
|
|
23
|
+
#: How many observations a CSV may unfold to when the step does not say.
|
|
24
|
+
MAX_OBSERVATIONS: Final = 500_000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PassThroughConfig(ChapJobConfig):
|
|
28
|
+
"""A config whose undeclared keys are sent to Chap as written."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(use_attribute_docstrings=True, extra="allow")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def camel(name: str) -> str:
|
|
34
|
+
"""Spell a snake_case field the way Chap's JSON spells it."""
|
|
35
|
+
head, *rest = name.split("_")
|
|
36
|
+
return head + "".join(part.title() for part in rest)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def body_of(config: PassThroughConfig, *declared: str) -> dict[str, JsonValue]:
|
|
40
|
+
"""Build the body: each declared field that is set, renamed to camelCase, then every passed-through key.
|
|
41
|
+
|
|
42
|
+
A declared field left at None is left out, so Chap applies its own default to it.
|
|
43
|
+
"""
|
|
44
|
+
body: dict[str, JsonValue] = {}
|
|
45
|
+
for name in declared:
|
|
46
|
+
value = getattr(config, name)
|
|
47
|
+
if value is not None:
|
|
48
|
+
body[camel(name)] = config.model_dump(mode="json", include={name})[name]
|
|
49
|
+
body.update(config.model_extra or {})
|
|
50
|
+
return body
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ObservationsConfig(PassThroughConfig):
|
|
54
|
+
"""The observations a step sends: rows as Chap spells them, a Chap CSV, or both."""
|
|
55
|
+
|
|
56
|
+
provided_data: list[dict[str, JsonValue]] = Field(default_factory=list[dict[str, JsonValue]])
|
|
57
|
+
"""Observations as Chap spells them: `{period, orgUnit, value, featureName}`, one per row."""
|
|
58
|
+
|
|
59
|
+
csv: str | None = None
|
|
60
|
+
"""A wide Chap CSV, the shape `aggregate_to_chap_csv` writes: `time_period`, `location`, and one
|
|
61
|
+
column per feature. Each feature cell becomes one observation, sent beside `provided_data`."""
|
|
62
|
+
|
|
63
|
+
max_observations: int = Field(default=MAX_OBSERVATIONS, ge=1)
|
|
64
|
+
"""The most observations `csv` may unfold to, columns times rows; a larger one fails the step and sends nothing."""
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def _require_observations(self) -> "ObservationsConfig":
|
|
68
|
+
"""Refuse a step that sends no observations at all."""
|
|
69
|
+
if not self.provided_data and not self.csv:
|
|
70
|
+
raise ValueError(OBSERVATIONS_MISSING.render())
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def observations(self) -> list[JsonValue]:
|
|
74
|
+
"""Every observation the step sends: the rows as written, then the CSV's cells."""
|
|
75
|
+
rows: list[JsonValue] = list(self.provided_data)
|
|
76
|
+
if self.csv:
|
|
77
|
+
rows.extend(csv_observations(self.csv, self.max_observations))
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def csv_observations(text: str, limit: int = MAX_OBSERVATIONS) -> list[JsonValue]:
|
|
82
|
+
"""Unfold a wide Chap CSV into one observation per feature cell, refusing more than the limit before any is built.
|
|
83
|
+
|
|
84
|
+
An empty cell is a missing value and is sent as null, which Chap reads as missing. A row
|
|
85
|
+
counts for every feature whether or not it has a cell for it, as it is unfolded.
|
|
86
|
+
"""
|
|
87
|
+
reader = csv.DictReader(io.StringIO(text))
|
|
88
|
+
columns = reader.fieldnames or []
|
|
89
|
+
for column in (PERIOD_COLUMN, LOCATION_COLUMN):
|
|
90
|
+
if column not in columns:
|
|
91
|
+
raise BlockFailure(CSV_MISSING_COLUMN, error_class=ErrorClass.REJECTED, column=column)
|
|
92
|
+
features = [column for column in columns if column not in (PERIOD_COLUMN, LOCATION_COLUMN)]
|
|
93
|
+
count = len(features) * (sum(1 for row in csv.reader(io.StringIO(text)) if row) - 1)
|
|
94
|
+
if count > limit:
|
|
95
|
+
raise BlockFailure(CSV_TOO_LARGE, error_class=ErrorClass.REJECTED, count=count, limit=limit)
|
|
96
|
+
rows: list[JsonValue] = []
|
|
97
|
+
for line, record in enumerate(reader, start=2):
|
|
98
|
+
for feature in features:
|
|
99
|
+
rows.append(
|
|
100
|
+
{
|
|
101
|
+
"period": record[PERIOD_COLUMN],
|
|
102
|
+
"orgUnit": record[LOCATION_COLUMN],
|
|
103
|
+
"featureName": feature,
|
|
104
|
+
"value": _number(record.get(feature), feature, line),
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
return rows
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _number(cell: str | None, column: str, line: int) -> float | None:
|
|
111
|
+
"""Read one CSV cell as a number, an empty one or a NaN as missing."""
|
|
112
|
+
if cell is None or not cell.strip():
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
number = float(cell)
|
|
116
|
+
except ValueError as error:
|
|
117
|
+
raise BlockFailure(
|
|
118
|
+
CSV_BAD_VALUE, error_class=ErrorClass.REJECTED, column=column, line=line, value=cell
|
|
119
|
+
) from error
|
|
120
|
+
return number if math.isfinite(number) else None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""``chap.models.list``: the models a Chap server has configured, which a backtest names."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, JsonValue
|
|
6
|
+
|
|
7
|
+
from dirigent_common import BlockModel
|
|
8
|
+
from dirigent_contrib.chap.connection import CONFIGURED_MODELS_PATH, client_for
|
|
9
|
+
from dirigent_contrib.chap.messages import NOT_A_LIST
|
|
10
|
+
from dirigent_contrib.chap.web import call_json
|
|
11
|
+
from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Operator, OperatorSpec, StepContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChapModelsListConfig(BlockModel):
|
|
15
|
+
"""Which server to list the models of."""
|
|
16
|
+
|
|
17
|
+
connection: ConnectionRef
|
|
18
|
+
"""The code of the chap connection naming the server."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChapModelsListOutput(BlockModel):
|
|
22
|
+
"""The configured models, as Chap describes them."""
|
|
23
|
+
|
|
24
|
+
models: list[JsonValue]
|
|
25
|
+
"""Each configured model as Chap answers with it: its `id`, `name`, `covariates`, `target` and the rest."""
|
|
26
|
+
|
|
27
|
+
count: int
|
|
28
|
+
"""How many models there are."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ChapModelsListOperator(Operator[ChapModelsListConfig, ChapModelsListOutput]):
|
|
32
|
+
"""Lists the configured models."""
|
|
33
|
+
|
|
34
|
+
spec = OperatorSpec(id="chap.models.list", summary="List the models a Chap server has configured.", idempotent=True)
|
|
35
|
+
config_model: ClassVar[type[BaseModel]] = ChapModelsListConfig
|
|
36
|
+
output_model: ClassVar[type[BaseModel]] = ChapModelsListOutput
|
|
37
|
+
|
|
38
|
+
async def execute(self, config: ChapModelsListConfig, ctx: StepContext) -> ChapModelsListOutput:
|
|
39
|
+
"""Read the list once."""
|
|
40
|
+
async with client_for(ctx, config.connection) as client:
|
|
41
|
+
answer = await call_json(client, "GET", CONFIGURED_MODELS_PATH)
|
|
42
|
+
if not isinstance(answer, list):
|
|
43
|
+
raise BlockFailure(NOT_A_LIST, error_class=ErrorClass.UNKNOWN, where=f"GET {CONFIGURED_MODELS_PATH}")
|
|
44
|
+
return ChapModelsListOutput(models=list(answer), count=len(answer))
|