microgrid 0.1.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.
- microgrid/__init__.py +12 -0
- microgrid/_checks.py +29 -0
- microgrid/cli/__init__.py +35 -0
- microgrid/cli/check_plant.py +171 -0
- microgrid/cli/dataset.py +463 -0
- microgrid/cli/evaluation.py +375 -0
- microgrid/cli/fit_battery.py +430 -0
- microgrid/cli/history.py +457 -0
- microgrid/cli/inputs.py +248 -0
- microgrid/cli/main.py +572 -0
- microgrid/cli/offline.py +464 -0
- microgrid/cli/replay.py +176 -0
- microgrid/cli/sources/__init__.py +39 -0
- microgrid/cli/sources/config.py +499 -0
- microgrid/cli/sources/emhass.py +122 -0
- microgrid/cli/sources/entity.py +133 -0
- microgrid/cli/sources/fetch.py +119 -0
- microgrid/cli/sources/home_assistant.py +153 -0
- microgrid/cli/sources/predbat.py +99 -0
- microgrid/cli/sources/read.py +254 -0
- microgrid/cli/sources/solcast.py +147 -0
- microgrid/cli/sources/tariffkit.py +147 -0
- microgrid/cli/toml_text.py +75 -0
- microgrid/engine/__init__.py +126 -0
- microgrid/engine/_api.py +1289 -0
- microgrid/engine/_assess.py +77 -0
- microgrid/engine/_engine_config.py +214 -0
- microgrid/engine/_inputs.py +108 -0
- microgrid/engine/_live.py +305 -0
- microgrid/engine/_stored.py +142 -0
- microgrid/engine/_supply.py +138 -0
- microgrid/engine/_surplus.py +146 -0
- microgrid/engine/_timeline.py +40 -0
- microgrid/engine/_value.py +1101 -0
- microgrid/engine/analysis/__init__.py +2 -0
- microgrid/engine/analysis/ground_truth.py +549 -0
- microgrid/engine/analysis/scoring.py +334 -0
- microgrid/engine/extend.py +66 -0
- microgrid/engine/ledger.py +450 -0
- microgrid/engine/py.typed +0 -0
- microgrid/engine/schemas/valuation-2.schema.json +375 -0
- microgrid/engine/series.py +821 -0
- microgrid/engine/testing.py +284 -0
- microgrid/engine/units.py +125 -0
- microgrid/plant/__init__.py +74 -0
- microgrid/plant/_battery.py +235 -0
- microgrid/plant/_config.py +1249 -0
- microgrid/plant/_meters.py +241 -0
- microgrid/plant/_paths.py +1455 -0
- microgrid/plant/_plant.py +456 -0
- microgrid/plant/examples.py +162 -0
- microgrid/plant/flows.py +417 -0
- microgrid/plant/port.py +241 -0
- microgrid/py.typed +0 -0
- microgrid-0.1.0.dist-info/METADATA +106 -0
- microgrid-0.1.0.dist-info/RECORD +59 -0
- microgrid-0.1.0.dist-info/WHEEL +4 -0
- microgrid-0.1.0.dist-info/entry_points.txt +2 -0
- microgrid-0.1.0.dist-info/licenses/LICENSE +21 -0
microgrid/cli/dataset.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""A dataset: a directory of the library's series (``dataset/1``), so every command
|
|
2
|
+
runs without the plant it came from.
|
|
3
|
+
|
|
4
|
+
**The format, ``dataset/1``,** is the library's own terms, not any integration's:
|
|
5
|
+
|
|
6
|
+
- ``manifest.json``: ``{"format": "dataset/1", "entries": {name: {"file": ...,
|
|
7
|
+
"kind": ..., "forecasts": bool}}}``. An entry's ``kind`` is how its values read:
|
|
8
|
+
``number``, ``bool``, ``price`` (``{"import": ..., "export": ...}``, per kWh) or
|
|
9
|
+
``quantiles`` (``{"0.1": ..., "0.5": ..., "0.9": ...}``, or ``"0.5"`` alone).
|
|
10
|
+
- A series file (``forecasts`` false): ``{"unit": ..., "known_until": ...,
|
|
11
|
+
"samples": [[at, value], ...]}``, each value held until the next, ``null`` for none.
|
|
12
|
+
- A forecasts file (``forecasts`` true): ``{"unit": ..., "known_until": ...,
|
|
13
|
+
"issues": [{"received_at": ..., "issued_at": ..., "source": ..., "intervals":
|
|
14
|
+
[[start, end, value], ...]}, ...]}``, each issue as the client received it;
|
|
15
|
+
``{"received_at": ..., "issue": null}`` where the source answered nothing.
|
|
16
|
+
|
|
17
|
+
Times are ISO 8601 with an offset (read as UTC). Units are the source's, stated in
|
|
18
|
+
each file: :func:`microgrid.cli.inputs.inputs` converts them to the engine's
|
|
19
|
+
(:mod:`microgrid.engine.units`) and refuses one it cannot, so a value in another unit
|
|
20
|
+
is never used as the engine's.
|
|
21
|
+
|
|
22
|
+
**The names** a dataset gives its entries are conventional.
|
|
23
|
+
:func:`microgrid.cli.inputs.inputs` reads the engine's inputs: :data:`PRICES`,
|
|
24
|
+
:data:`PV_FORECAST` (or ``pv_forecast.<array>``, one per array, for a plant that takes
|
|
25
|
+
PV per array), :data:`LOAD_FORECAST`, :data:`DC_LOAD_FORECAST`, :data:`SOC` and
|
|
26
|
+
:data:`BATTERY_FIRST`. The rest are what was metered, for the commands that score
|
|
27
|
+
against it: the plant's meters by the plant's names (``pv``, ``grid``, ``battery``,
|
|
28
|
+
``dc_load``), :data:`HOUSE`, the house meter, and :data:`HEARTBEAT`, a series whose
|
|
29
|
+
samples prove the telemetry path alive. A name is letters, digits, ``_``, ``-`` and
|
|
30
|
+
``.``: each entry's file stays in the directory.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import math
|
|
37
|
+
import re
|
|
38
|
+
from bisect import bisect_left
|
|
39
|
+
from collections.abc import Mapping
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from datetime import UTC, datetime, timedelta
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, Final, Literal, cast
|
|
44
|
+
|
|
45
|
+
from microgrid.cli.history import Limits
|
|
46
|
+
from microgrid.engine.series import (
|
|
47
|
+
Forecast,
|
|
48
|
+
Interval,
|
|
49
|
+
Price,
|
|
50
|
+
Quantiles,
|
|
51
|
+
Sample,
|
|
52
|
+
Series,
|
|
53
|
+
require_utc,
|
|
54
|
+
)
|
|
55
|
+
from microgrid.engine.units import UnitError, normalise
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"BATTERY_FIRST",
|
|
59
|
+
"DC_LOAD_FORECAST",
|
|
60
|
+
"DOMAIN",
|
|
61
|
+
"EXPORT_PRICE",
|
|
62
|
+
"FORMAT",
|
|
63
|
+
"HEARTBEAT",
|
|
64
|
+
"HOUSE",
|
|
65
|
+
"IMPORT_PRICE",
|
|
66
|
+
"INVERTER",
|
|
67
|
+
"LOAD_FORECAST",
|
|
68
|
+
"MANIFEST",
|
|
69
|
+
"PRICES",
|
|
70
|
+
"PV_FORECAST",
|
|
71
|
+
"SOC",
|
|
72
|
+
"Dataset",
|
|
73
|
+
"DatasetError",
|
|
74
|
+
"DatasetHistory",
|
|
75
|
+
"Entry",
|
|
76
|
+
"Kind",
|
|
77
|
+
"Value",
|
|
78
|
+
"read_dataset",
|
|
79
|
+
"write_dataset",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
FORMAT: Final = "dataset/1"
|
|
83
|
+
MANIFEST: Final = "manifest.json"
|
|
84
|
+
|
|
85
|
+
PRICES: Final = "prices"
|
|
86
|
+
PV_FORECAST: Final = "pv_forecast"
|
|
87
|
+
LOAD_FORECAST: Final = "load_forecast"
|
|
88
|
+
DC_LOAD_FORECAST: Final = "dc_load_forecast"
|
|
89
|
+
SOC: Final = "soc"
|
|
90
|
+
BATTERY_FIRST: Final = "battery_first"
|
|
91
|
+
HOUSE: Final = "house"
|
|
92
|
+
HEARTBEAT: Final = "heartbeat"
|
|
93
|
+
#: The import and export prices in force, as recorded (TariffKit's price sensors' history):
|
|
94
|
+
#: what the battery's lots were charged and valued at (``history.read_recorded``).
|
|
95
|
+
IMPORT_PRICE: Final = "import_price"
|
|
96
|
+
EXPORT_PRICE: Final = "export_price"
|
|
97
|
+
#: The battery inverter's AC power, > 0 out: for fitting the battery's conversion.
|
|
98
|
+
INVERTER: Final = "inverter"
|
|
99
|
+
#: A dataset's entries as entity IDs (``dataset.<name>``), so bindings name them as they
|
|
100
|
+
#: name any recorder's (:class:`DatasetHistory`, ``history.Bindings``).
|
|
101
|
+
DOMAIN: Final = "dataset"
|
|
102
|
+
|
|
103
|
+
type Kind = Literal["number", "bool", "price", "quantiles"]
|
|
104
|
+
type Value = float | bool | Price | Quantiles
|
|
105
|
+
_KINDS: Final = ("number", "bool", "price", "quantiles")
|
|
106
|
+
_NAME: Final = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]*")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class DatasetError(ValueError):
|
|
110
|
+
"""A dataset that cannot be read: the message names the file and what is wrong."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class Entry:
|
|
115
|
+
"""One series or series of forecasts of a dataset, as written or read."""
|
|
116
|
+
|
|
117
|
+
kind: Kind
|
|
118
|
+
#: A series of values, or of forecasts as received (``forecasts``), the values of
|
|
119
|
+
#: the entry's kind (``Series`` is invariant, so any series of them).
|
|
120
|
+
series: Series[Any]
|
|
121
|
+
forecasts: bool = False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class Dataset:
|
|
126
|
+
"""A dataset's entries by name, and how its record's silences are judged where the
|
|
127
|
+
manifest says (``limits``; None: the command's flags, else the defaults)."""
|
|
128
|
+
|
|
129
|
+
entries: Mapping[str, Entry]
|
|
130
|
+
limits: Limits | None = None
|
|
131
|
+
|
|
132
|
+
def series(self, name: str) -> Series[Value] | None:
|
|
133
|
+
"""A series of values by name; None where the dataset has none."""
|
|
134
|
+
entry = self.entries.get(name)
|
|
135
|
+
if entry is None:
|
|
136
|
+
return None
|
|
137
|
+
if entry.forecasts:
|
|
138
|
+
raise DatasetError(f"{name}: is forecasts, not a series of values")
|
|
139
|
+
return cast("Series[Value]", entry.series)
|
|
140
|
+
|
|
141
|
+
def forecasts(self, name: str) -> Series[Forecast[Value]] | None:
|
|
142
|
+
"""A series of forecasts by name; None where the dataset has none."""
|
|
143
|
+
entry = self.entries.get(name)
|
|
144
|
+
if entry is None:
|
|
145
|
+
return None
|
|
146
|
+
if not entry.forecasts:
|
|
147
|
+
raise DatasetError(f"{name}: is a series of values, not forecasts")
|
|
148
|
+
return cast("Series[Forecast[Value]]", entry.series)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# -- reading -------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _time(value: object, where: str) -> datetime:
|
|
155
|
+
if not isinstance(value, str):
|
|
156
|
+
raise DatasetError(f"{where}: a time must be an ISO 8601 string, not {value!r}")
|
|
157
|
+
try:
|
|
158
|
+
moment = datetime.fromisoformat(value)
|
|
159
|
+
except ValueError as err:
|
|
160
|
+
raise DatasetError(f"{where}: not an ISO 8601 time: {value!r}") from err
|
|
161
|
+
if moment.tzinfo is None:
|
|
162
|
+
raise DatasetError(f"{where}: {value!r} has no offset")
|
|
163
|
+
return moment.astimezone(UTC)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _number(value: object, where: str) -> float:
|
|
167
|
+
if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value):
|
|
168
|
+
raise DatasetError(f"{where}: not a finite number: {value!r}")
|
|
169
|
+
return float(value)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _value(kind: Kind, value: object, where: str) -> Value:
|
|
173
|
+
match kind:
|
|
174
|
+
case "number":
|
|
175
|
+
return _number(value, where)
|
|
176
|
+
case "bool":
|
|
177
|
+
if not isinstance(value, bool):
|
|
178
|
+
raise DatasetError(f"{where}: not true or false: {value!r}")
|
|
179
|
+
return value
|
|
180
|
+
case "price":
|
|
181
|
+
if not isinstance(value, Mapping) or set(value) != {"import", "export"}:
|
|
182
|
+
raise DatasetError(f"{where}: a price is {{import, export}}: {value!r}")
|
|
183
|
+
return Price(_number(value["import"], where), _number(value["export"], where))
|
|
184
|
+
case "quantiles":
|
|
185
|
+
if not isinstance(value, Mapping):
|
|
186
|
+
raise DatasetError(f"{where}: quantiles are {{quantile: value}}: {value!r}")
|
|
187
|
+
try:
|
|
188
|
+
return Quantiles({float(q): _number(v, where) for q, v in value.items()})
|
|
189
|
+
except ValueError as err:
|
|
190
|
+
raise DatasetError(f"{where}: {err}") from err
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_entry(path: Path, kind: Kind, forecasts: bool) -> Entry:
|
|
194
|
+
try:
|
|
195
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
196
|
+
except (OSError, ValueError) as err:
|
|
197
|
+
raise DatasetError(f"{path}: cannot be read as JSON: {err}") from err
|
|
198
|
+
if not isinstance(data, Mapping):
|
|
199
|
+
raise DatasetError(f"{path}: must be an object")
|
|
200
|
+
unit = data.get("unit")
|
|
201
|
+
if unit is not None and not isinstance(unit, str):
|
|
202
|
+
raise DatasetError(f"{path}: unit must be a string or null")
|
|
203
|
+
known_until = _time(data.get("known_until"), f"{path}: known_until")
|
|
204
|
+
key, other = ("issues", "samples") if forecasts else ("samples", "issues")
|
|
205
|
+
rows = data.get(key)
|
|
206
|
+
if not isinstance(rows, list) or other in data:
|
|
207
|
+
raise DatasetError(
|
|
208
|
+
f"{path}: a {'forecasts' if forecasts else 'series'} file has a list {key!r}"
|
|
209
|
+
f" (and no {other!r}), as its manifest entry says"
|
|
210
|
+
)
|
|
211
|
+
try:
|
|
212
|
+
if not forecasts:
|
|
213
|
+
samples: list[Sample[Value]] = []
|
|
214
|
+
for i, row in enumerate(rows):
|
|
215
|
+
where = f"{path}: samples[{i}]"
|
|
216
|
+
if not isinstance(row, list) or len(row) != 2:
|
|
217
|
+
raise DatasetError(f"{where}: a sample is [at, value]")
|
|
218
|
+
at = _time(row[0], where)
|
|
219
|
+
samples.append(Sample(at, None if row[1] is None else _value(kind, row[1], where)))
|
|
220
|
+
return Entry(kind, Series(tuple(samples), known_until, unit))
|
|
221
|
+
issues: list[Sample[Forecast[Value]]] = []
|
|
222
|
+
for i, issue in enumerate(rows):
|
|
223
|
+
where = f"{path}: issues[{i}]"
|
|
224
|
+
if not isinstance(issue, Mapping):
|
|
225
|
+
raise DatasetError(f"{where}: an issue is an object")
|
|
226
|
+
received = _time(issue.get("received_at"), f"{where}.received_at")
|
|
227
|
+
if "issue" in issue and issue["issue"] is None:
|
|
228
|
+
issues.append(Sample(received, None))
|
|
229
|
+
continue
|
|
230
|
+
rows_of = issue.get("intervals")
|
|
231
|
+
if not isinstance(rows_of, list):
|
|
232
|
+
raise DatasetError(f"{where}.intervals: must be a list")
|
|
233
|
+
intervals = []
|
|
234
|
+
for j, row in enumerate(rows_of):
|
|
235
|
+
spot = f"{where}.intervals[{j}]"
|
|
236
|
+
if not isinstance(row, list) or len(row) != 3:
|
|
237
|
+
raise DatasetError(f"{spot}: an interval is [start, end, value]")
|
|
238
|
+
try:
|
|
239
|
+
intervals.append(
|
|
240
|
+
Interval(
|
|
241
|
+
_time(row[0], spot), _time(row[1], spot), _value(kind, row[2], spot)
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
except ValueError as err: # the library's: one that ends before it starts
|
|
245
|
+
raise DatasetError(f"{spot}: {err}") from err
|
|
246
|
+
source = issue.get("source", "")
|
|
247
|
+
if not isinstance(source, str):
|
|
248
|
+
raise DatasetError(f"{where}.source: must be a string")
|
|
249
|
+
issued_at = _time(issue.get("issued_at"), f"{where}.issued_at")
|
|
250
|
+
try:
|
|
251
|
+
forecast = Forecast(tuple(intervals), issued_at, source)
|
|
252
|
+
except ValueError as err: # the library's: intervals out of order
|
|
253
|
+
raise DatasetError(f"{where}: {err}") from err
|
|
254
|
+
issues.append(Sample(received, forecast))
|
|
255
|
+
return Entry(kind, Series(tuple(issues), known_until, unit), forecasts=True)
|
|
256
|
+
except ValueError as err: # the library's own checks (order, known_until)
|
|
257
|
+
raise DatasetError(f"{path}: {err}") from err
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def read_dataset(directory: Path) -> Dataset:
|
|
261
|
+
"""The dataset in ``directory``; raises :class:`DatasetError` naming what is wrong."""
|
|
262
|
+
manifest_path = directory / MANIFEST
|
|
263
|
+
try:
|
|
264
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
265
|
+
except (OSError, ValueError) as err:
|
|
266
|
+
raise DatasetError(f"{manifest_path}: cannot be read as JSON: {err}") from err
|
|
267
|
+
if not isinstance(manifest, Mapping) or manifest.get("format") != FORMAT:
|
|
268
|
+
raise DatasetError(f"{manifest_path}: format must be {FORMAT!r}")
|
|
269
|
+
entries = manifest.get("entries")
|
|
270
|
+
if not isinstance(entries, Mapping):
|
|
271
|
+
raise DatasetError(f"{manifest_path}: entries must be an object")
|
|
272
|
+
limits = _limits(manifest.get("limits"), f"{manifest_path}: limits")
|
|
273
|
+
read: dict[str, Entry] = {}
|
|
274
|
+
root = directory.resolve()
|
|
275
|
+
for name, spec in entries.items():
|
|
276
|
+
where = f"{manifest_path}: entries.{name}"
|
|
277
|
+
_check_name(str(name), where)
|
|
278
|
+
if not isinstance(spec, Mapping) or not isinstance(spec.get("file"), str):
|
|
279
|
+
raise DatasetError(f"{where}: needs a file")
|
|
280
|
+
if not (directory / spec["file"]).resolve().is_relative_to(root):
|
|
281
|
+
raise DatasetError(f"{where}: its file must be in the dataset's directory")
|
|
282
|
+
kind = spec.get("kind")
|
|
283
|
+
if kind not in _KINDS:
|
|
284
|
+
raise DatasetError(f"{where}: kind must be one of {list(_KINDS)}")
|
|
285
|
+
forecasts = spec.get("forecasts", False)
|
|
286
|
+
if not isinstance(forecasts, bool):
|
|
287
|
+
raise DatasetError(f"{where}: forecasts must be true or false")
|
|
288
|
+
read[str(name)] = _read_entry(directory / spec["file"], cast("Kind", kind), forecasts)
|
|
289
|
+
return Dataset(read, limits)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
#: The manifest's ``limits``: each key, and the :class:`~microgrid.cli.history.Limits`
|
|
293
|
+
#: field it sets (seconds).
|
|
294
|
+
_LIMITS: Final = {"telemetry_seconds": "telemetry", "dc_stale_after_seconds": "dc_stale_after"}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _limits(value: object, where: str) -> Limits | None:
|
|
298
|
+
"""The manifest's optional ``limits`` (``{"telemetry_seconds": 180,
|
|
299
|
+
"dc_stale_after_seconds": 300}``, each optional): how the record's silences are
|
|
300
|
+
judged, as its recorder's cadence asks (``history.TELEMETRY``,
|
|
301
|
+
``history.DC_STALE_AFTER`` where a key is absent)."""
|
|
302
|
+
if value is None:
|
|
303
|
+
return None
|
|
304
|
+
if not isinstance(value, Mapping) or set(value) - set(_LIMITS):
|
|
305
|
+
raise DatasetError(f"{where}: an object of {sorted(_LIMITS)}")
|
|
306
|
+
given = {}
|
|
307
|
+
for key, field_name in _LIMITS.items():
|
|
308
|
+
if key in value:
|
|
309
|
+
number = _number(value[key], f"{where}.{key}")
|
|
310
|
+
if number <= 0:
|
|
311
|
+
raise DatasetError(f"{where}.{key}: must be more than 0")
|
|
312
|
+
given[field_name] = timedelta(seconds=number)
|
|
313
|
+
return Limits(**given)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _check_name(name: str, where: str) -> None:
|
|
317
|
+
if not _NAME.fullmatch(name) or ".." in name:
|
|
318
|
+
raise DatasetError(f"{where}: a name is letters, digits, '_', '-' and '.'")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# -- writing -------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _iso(moment: datetime) -> str:
|
|
325
|
+
return moment.astimezone(UTC).isoformat()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _plain(value: Value) -> object:
|
|
329
|
+
if isinstance(value, Price):
|
|
330
|
+
return {"import": value.p_import, "export": value.p_export}
|
|
331
|
+
if isinstance(value, Quantiles):
|
|
332
|
+
return {str(q): v for q, v in value.kw.items()}
|
|
333
|
+
return value
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def write_dataset(
|
|
337
|
+
directory: Path, entries: Mapping[str, Entry], *, limits: Limits | None = None
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Write ``entries`` as a dataset in ``directory`` (made if it does not exist): one
|
|
340
|
+
file per entry, ``series/<name>.json`` or ``forecasts/<name>.json``, and the record's
|
|
341
|
+
``limits`` where given."""
|
|
342
|
+
manifest: dict[str, object] = {"format": FORMAT, "entries": {}}
|
|
343
|
+
if limits is not None:
|
|
344
|
+
manifest["limits"] = {
|
|
345
|
+
key: getattr(limits, name).total_seconds() for key, name in _LIMITS.items()
|
|
346
|
+
}
|
|
347
|
+
for name, entry in entries.items():
|
|
348
|
+
_check_name(name, f"{directory}: entry {name!r}")
|
|
349
|
+
folder = "forecasts" if entry.forecasts else "series"
|
|
350
|
+
relative = f"{folder}/{name}.json"
|
|
351
|
+
(directory / folder).mkdir(parents=True, exist_ok=True)
|
|
352
|
+
series = entry.series
|
|
353
|
+
body: dict[str, object] = {"unit": series.unit, "known_until": _iso(series.known_until)}
|
|
354
|
+
if entry.forecasts:
|
|
355
|
+
issues: list[object] = []
|
|
356
|
+
for sample in series.samples:
|
|
357
|
+
forecast = sample.value
|
|
358
|
+
if forecast is None:
|
|
359
|
+
issues.append({"received_at": _iso(sample.at), "issue": None})
|
|
360
|
+
continue
|
|
361
|
+
assert isinstance(forecast, Forecast)
|
|
362
|
+
issues.append(
|
|
363
|
+
{
|
|
364
|
+
"received_at": _iso(sample.at),
|
|
365
|
+
"issued_at": _iso(forecast.issued_at),
|
|
366
|
+
"source": forecast.source,
|
|
367
|
+
"intervals": [
|
|
368
|
+
[_iso(i.start), _iso(i.end), _plain(i.value)]
|
|
369
|
+
for i in forecast.intervals
|
|
370
|
+
],
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
body["issues"] = issues
|
|
374
|
+
else:
|
|
375
|
+
body["samples"] = [
|
|
376
|
+
[_iso(s.at), None if s.value is None else _plain(cast("Value", s.value))]
|
|
377
|
+
for s in series.samples
|
|
378
|
+
]
|
|
379
|
+
(directory / relative).write_text(json.dumps(body, indent=None) + "\n", encoding="utf-8")
|
|
380
|
+
cast("dict[str, object]", manifest["entries"])[name] = {
|
|
381
|
+
"file": relative,
|
|
382
|
+
"kind": entry.kind,
|
|
383
|
+
"forecasts": entry.forecasts,
|
|
384
|
+
}
|
|
385
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
386
|
+
(directory / MANIFEST).write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# -- as recorded history -------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@dataclass(frozen=True, slots=True)
|
|
393
|
+
class DatasetHistory:
|
|
394
|
+
"""A dataset read as recorded history (:class:`~microgrid.cli.history.History`), each
|
|
395
|
+
entity ``dataset.<name>`` its entry ``name``: the ledger, the ground truth and the
|
|
396
|
+
battery's fit run on a dataset as on any recorder."""
|
|
397
|
+
|
|
398
|
+
dataset: Dataset
|
|
399
|
+
|
|
400
|
+
def _window(
|
|
401
|
+
self, entity_id: str, start: datetime, end: datetime, carry_in: timedelta
|
|
402
|
+
) -> Series[Value]:
|
|
403
|
+
"""The entry's samples over ``[start, end)``, the last before ``start`` (within
|
|
404
|
+
``carry_in``) first, as a recorder reads them; known until ``end``."""
|
|
405
|
+
require_utc(start, end)
|
|
406
|
+
domain, _, name = entity_id.partition(".")
|
|
407
|
+
series = self.dataset.series(name) if domain == DOMAIN else None
|
|
408
|
+
if series is None:
|
|
409
|
+
raise DatasetError(f"{entity_id}: not an entry of the dataset")
|
|
410
|
+
if end > series.known_until:
|
|
411
|
+
raise DatasetError(f"{entity_id}: known until {series.known_until}, not {end}")
|
|
412
|
+
samples = series.samples
|
|
413
|
+
times = [sample.at for sample in samples]
|
|
414
|
+
first, last = bisect_left(times, start), bisect_left(times, end)
|
|
415
|
+
before = [
|
|
416
|
+
sample
|
|
417
|
+
for sample in samples[max(first - 1, 0) : first]
|
|
418
|
+
if start - carry_in <= sample.at < start and sample.value is not None
|
|
419
|
+
]
|
|
420
|
+
# A sample with no value is kept: a gap the record vouches for (``Series``).
|
|
421
|
+
kept = tuple(samples[first:last])
|
|
422
|
+
return Series((*before, *kept), end, series.unit)
|
|
423
|
+
|
|
424
|
+
async def power_series(
|
|
425
|
+
self,
|
|
426
|
+
entity_id: str,
|
|
427
|
+
start: datetime,
|
|
428
|
+
end: datetime,
|
|
429
|
+
*,
|
|
430
|
+
carry_in: timedelta,
|
|
431
|
+
chunk: timedelta | None = None,
|
|
432
|
+
) -> Series[float]:
|
|
433
|
+
"""Power in kW, converted from the entry's unit."""
|
|
434
|
+
window = cast("Series[float]", self._window(entity_id, start, end, carry_in))
|
|
435
|
+
try:
|
|
436
|
+
return normalise(window, "power")
|
|
437
|
+
except UnitError as err:
|
|
438
|
+
raise DatasetError(f"{entity_id}: {err}") from err
|
|
439
|
+
|
|
440
|
+
async def numeric_series(
|
|
441
|
+
self,
|
|
442
|
+
entity_id: str,
|
|
443
|
+
start: datetime,
|
|
444
|
+
end: datetime,
|
|
445
|
+
*,
|
|
446
|
+
carry_in: timedelta,
|
|
447
|
+
chunk: timedelta | None = None,
|
|
448
|
+
) -> Series[float]:
|
|
449
|
+
"""Any numeric entry's values, the unit not carried (``History.numeric_series``)."""
|
|
450
|
+
window = self._window(entity_id, start, end, carry_in)
|
|
451
|
+
return Series(cast("Series[float]", window).samples, end)
|
|
452
|
+
|
|
453
|
+
async def sample_times(
|
|
454
|
+
self,
|
|
455
|
+
entity_id: str,
|
|
456
|
+
start: datetime,
|
|
457
|
+
end: datetime,
|
|
458
|
+
*,
|
|
459
|
+
carry_in: timedelta,
|
|
460
|
+
chunk: timedelta | None = None,
|
|
461
|
+
) -> tuple[datetime, ...]:
|
|
462
|
+
"""When the entry's samples arrived (a liveness entry's)."""
|
|
463
|
+
return tuple(s.at for s in self._window(entity_id, start, end, carry_in).samples)
|