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.
Files changed (59) hide show
  1. microgrid/__init__.py +12 -0
  2. microgrid/_checks.py +29 -0
  3. microgrid/cli/__init__.py +35 -0
  4. microgrid/cli/check_plant.py +171 -0
  5. microgrid/cli/dataset.py +463 -0
  6. microgrid/cli/evaluation.py +375 -0
  7. microgrid/cli/fit_battery.py +430 -0
  8. microgrid/cli/history.py +457 -0
  9. microgrid/cli/inputs.py +248 -0
  10. microgrid/cli/main.py +572 -0
  11. microgrid/cli/offline.py +464 -0
  12. microgrid/cli/replay.py +176 -0
  13. microgrid/cli/sources/__init__.py +39 -0
  14. microgrid/cli/sources/config.py +499 -0
  15. microgrid/cli/sources/emhass.py +122 -0
  16. microgrid/cli/sources/entity.py +133 -0
  17. microgrid/cli/sources/fetch.py +119 -0
  18. microgrid/cli/sources/home_assistant.py +153 -0
  19. microgrid/cli/sources/predbat.py +99 -0
  20. microgrid/cli/sources/read.py +254 -0
  21. microgrid/cli/sources/solcast.py +147 -0
  22. microgrid/cli/sources/tariffkit.py +147 -0
  23. microgrid/cli/toml_text.py +75 -0
  24. microgrid/engine/__init__.py +126 -0
  25. microgrid/engine/_api.py +1289 -0
  26. microgrid/engine/_assess.py +77 -0
  27. microgrid/engine/_engine_config.py +214 -0
  28. microgrid/engine/_inputs.py +108 -0
  29. microgrid/engine/_live.py +305 -0
  30. microgrid/engine/_stored.py +142 -0
  31. microgrid/engine/_supply.py +138 -0
  32. microgrid/engine/_surplus.py +146 -0
  33. microgrid/engine/_timeline.py +40 -0
  34. microgrid/engine/_value.py +1101 -0
  35. microgrid/engine/analysis/__init__.py +2 -0
  36. microgrid/engine/analysis/ground_truth.py +549 -0
  37. microgrid/engine/analysis/scoring.py +334 -0
  38. microgrid/engine/extend.py +66 -0
  39. microgrid/engine/ledger.py +450 -0
  40. microgrid/engine/py.typed +0 -0
  41. microgrid/engine/schemas/valuation-2.schema.json +375 -0
  42. microgrid/engine/series.py +821 -0
  43. microgrid/engine/testing.py +284 -0
  44. microgrid/engine/units.py +125 -0
  45. microgrid/plant/__init__.py +74 -0
  46. microgrid/plant/_battery.py +235 -0
  47. microgrid/plant/_config.py +1249 -0
  48. microgrid/plant/_meters.py +241 -0
  49. microgrid/plant/_paths.py +1455 -0
  50. microgrid/plant/_plant.py +456 -0
  51. microgrid/plant/examples.py +162 -0
  52. microgrid/plant/flows.py +417 -0
  53. microgrid/plant/port.py +241 -0
  54. microgrid/py.typed +0 -0
  55. microgrid-0.1.0.dist-info/METADATA +106 -0
  56. microgrid-0.1.0.dist-info/RECORD +59 -0
  57. microgrid-0.1.0.dist-info/WHEEL +4 -0
  58. microgrid-0.1.0.dist-info/entry_points.txt +2 -0
  59. microgrid-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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)