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.
Files changed (50) hide show
  1. dirigent_contrib/__init__.py +1 -0
  2. dirigent_contrib/chap/__init__.py +103 -0
  3. dirigent_contrib/chap/alerts.py +240 -0
  4. dirigent_contrib/chap/backtests.py +110 -0
  5. dirigent_contrib/chap/bodies.py +120 -0
  6. dirigent_contrib/chap/configured_models.py +44 -0
  7. dirigent_contrib/chap/connection.py +183 -0
  8. dirigent_contrib/chap/datasets.py +175 -0
  9. dirigent_contrib/chap/jobs.py +155 -0
  10. dirigent_contrib/chap/messages.py +83 -0
  11. dirigent_contrib/chap/predictions.py +143 -0
  12. dirigent_contrib/chap/shelves/chap/README.md +22 -0
  13. dirigent_contrib/chap/shelves/chap/chap-backtest.yaml +61 -0
  14. dirigent_contrib/chap/shelves/chap/chap-import-dataset-dry-run.yaml +56 -0
  15. dirigent_contrib/chap/shelves/chap/chap-models.yaml +42 -0
  16. dirigent_contrib/chap/shelves/chap/chap-predict-and-read.yaml +87 -0
  17. dirigent_contrib/chap/shelves/chap/chap-thresholds.yaml +54 -0
  18. dirigent_contrib/chap/shelves/chap-climate/README.md +9 -0
  19. dirigent_contrib/chap/shelves/chap-climate/climate-to-chap-backtest.yaml +135 -0
  20. dirigent_contrib/chap/shelves/chap-dhis2/README.md +8 -0
  21. dirigent_contrib/chap/shelves/chap-dhis2/chap-predict-alert-review-notify.yaml +187 -0
  22. dirigent_contrib/chap/thresholds.py +75 -0
  23. dirigent_contrib/chap/web.py +80 -0
  24. dirigent_contrib/climate/__init__.py +95 -0
  25. dirigent_contrib/climate/aggregate.py +58 -0
  26. dirigent_contrib/climate/batch.py +251 -0
  27. dirigent_contrib/climate/connection.py +175 -0
  28. dirigent_contrib/climate/datasets.py +50 -0
  29. dirigent_contrib/climate/exports.py +232 -0
  30. dirigent_contrib/climate/graphs.py +99 -0
  31. dirigent_contrib/climate/ingestions.py +244 -0
  32. dirigent_contrib/climate/messages.py +73 -0
  33. dirigent_contrib/climate/payload.py +99 -0
  34. dirigent_contrib/climate/shelves/climate/README.md +21 -0
  35. dirigent_contrib/climate/shelves/climate/climate-aggregate-to-dhis2.yaml +80 -0
  36. dirigent_contrib/climate/shelves/climate/climate-batch-and-export.yaml +95 -0
  37. dirigent_contrib/climate/shelves/climate/climate-datasets.yaml +41 -0
  38. dirigent_contrib/climate/shelves/climate/climate-ingest.yaml +44 -0
  39. dirigent_contrib/climate/shelves/climate/climate-sync.yaml +42 -0
  40. dirigent_contrib/climate/shelves/climate-dhis2/README.md +8 -0
  41. dirigent_contrib/climate/shelves/climate-dhis2/climate-sync-to-dhis2.yaml +141 -0
  42. dirigent_contrib/climate/web.py +80 -0
  43. dirigent_contrib/limits.py +145 -0
  44. dirigent_contrib/paths.py +20 -0
  45. dirigent_contrib/py.typed +0 -0
  46. dirigent_contrib-0.28.0.dist-info/METADATA +91 -0
  47. dirigent_contrib-0.28.0.dist-info/RECORD +50 -0
  48. dirigent_contrib-0.28.0.dist-info/WHEEL +4 -0
  49. dirigent_contrib-0.28.0.dist-info/entry_points.txt +4 -0
  50. 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))