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
microgrid/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """microgrid: a solar + battery plant modelled, and its marginal energy prices.
2
+
3
+ - :mod:`microgrid.plant`: the plant model and its simulator, on their own: a plant
4
+ described as data (``plant/2``), projected slot by slot. No I/O.
5
+ - :mod:`microgrid.engine`: the marginal prices, a client of the plant model. Data in,
6
+ data out: no I/O.
7
+ - :mod:`microgrid.cli`: the ``microgrid`` command, the library run on a dataset's files
8
+ (``dataset/1``) or on a site's live sources (``sources/1``, the ``sources`` extra). A
9
+ client of the public names, as any other.
10
+
11
+ A requirement cited as R-n is the documentation's (``docs/methodology/requirements.md``).
12
+ """
microgrid/_checks.py ADDED
@@ -0,0 +1,29 @@
1
+ """The checks every part of the engine makes of its inputs: neutral.
2
+
3
+ Shared by the price model, the plant models and the neutral types, so it imports
4
+ nothing from the engine. Every time in the core is **UTC**: Python compares and
5
+ subtracts two aware datetimes that share a ``tzinfo`` by wall clock, which is wrong
6
+ across a DST transition, so adapters convert at the edge and the core never sees
7
+ local time.
8
+
9
+ Pure: no I/O.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from datetime import UTC, datetime
16
+
17
+
18
+ def require_utc(*moments: datetime) -> None:
19
+ """Raise unless every moment is timezone-aware UTC."""
20
+ for moment in moments:
21
+ if moment.tzinfo is not UTC:
22
+ raise ValueError(f"core times must be UTC: {moment!r}")
23
+
24
+
25
+ def require_finite(owner: str, **values: float) -> None:
26
+ """Raise unless every value is a finite number."""
27
+ for name, value in values.items():
28
+ if not math.isfinite(value):
29
+ raise ValueError(f"{owner} {name} must be finite, not {value}")
@@ -0,0 +1,35 @@
1
+ """The ``microgrid`` command (:mod:`microgrid.cli.main`): the library run on a dataset's
2
+ files (``dataset/1``), or on a site's live sources (``sources/1``: ``now``, ``sources``,
3
+ ``fetch``).
4
+
5
+ - :mod:`.dataset`: the dataset format, read and written; :mod:`.inputs`, a dataset as
6
+ the engine's inputs.
7
+ - :mod:`.history`: what a plant's meters recorded, read through a port a dataset (or a
8
+ client's recorder) answers, for the commands that score against it and for the
9
+ battery's lots.
10
+ - :mod:`.offline`, :mod:`.replay`, :mod:`.check_plant`, :mod:`.fit_battery`,
11
+ :mod:`.evaluation`: the commands' work on a dataset.
12
+ - :mod:`.sources`: the readers of the live sources a ``sources/1`` file names (Home
13
+ Assistant, TariffKit, Solcast, EMHASS, Predbat, any entity), behind the ``sources``
14
+ extra. A service that runs the library on a site builds on them.
15
+
16
+ **The public surface** is each module's ``__all__``: what another client may import. The
17
+ library's architecture test holds every module to declaring one.
18
+
19
+ It reads and writes files and the network; the engine (:mod:`microgrid.engine`) does no
20
+ I/O, and this package uses only the engine's public names.
21
+ """
22
+
23
+ __all__ = [
24
+ "check_plant",
25
+ "dataset",
26
+ "evaluation",
27
+ "fit_battery",
28
+ "history",
29
+ "inputs",
30
+ "main",
31
+ "offline",
32
+ "replay",
33
+ "sources",
34
+ "toml_text",
35
+ ]
@@ -0,0 +1,171 @@
1
+ """``microgrid check-plant``: the plant model against the plant's own meters (the
2
+ library's method, ``microgrid.engine.analysis.ground_truth``).
3
+
4
+ uv run microgrid check-plant --data DIR --from 2026-10-21 --to 2026-10-27
5
+
6
+ Feeds the plant model what the meters recorded (metered PV, the load the plant served,
7
+ the metered SOC at the start), from each local midnight for the day and from each hour
8
+ over 48 h, and prints the model against the meters: each day, then over the days the
9
+ telemetry left no gap, each measure's bias and error, the SOC's error by hours ahead,
10
+ and how often the first event agreed. The days are local, ``--to`` included.
11
+
12
+ What a site decides about the result, which days its model does not describe and the
13
+ bars a plant must meet, is the site's.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import statistics
20
+ from collections.abc import Sequence
21
+ from datetime import date, datetime, timedelta
22
+
23
+ from microgrid.cli.history import Bindings, History, read_recording
24
+ from microgrid.engine.analysis import ground_truth as lgt
25
+ from microgrid.engine.analysis.scoring import BUCKETS, bucket_label
26
+ from microgrid.engine.extend import PlantModel, SlotFlow
27
+ from microgrid.engine.series import SLOT
28
+ from microgrid.plant import Plant
29
+
30
+ __all__ = [
31
+ "add_arguments",
32
+ "plant_model",
33
+ "run",
34
+ ]
35
+
36
+
37
+ def plant_model(plant: Plant, step: timedelta = SLOT) -> lgt.Projector:
38
+ """The ground truth's one call into a plant model: the port's ``project``, on the
39
+ plant behind it, over whole slots of ``step``."""
40
+ model: PlantModel = plant
41
+
42
+ def project(
43
+ starts: Sequence[datetime],
44
+ pv: Sequence[float],
45
+ load: Sequence[float],
46
+ soc: float,
47
+ /,
48
+ *,
49
+ dc_load: Sequence[float] | None = None,
50
+ held_at_start: bool | None = None,
51
+ ) -> tuple[SlotFlow, ...]:
52
+ if dc_load is None:
53
+ return model.project(starts, [step] * len(starts), pv, load, soc)
54
+ return model.project(
55
+ starts,
56
+ [step] * len(starts),
57
+ pv,
58
+ load,
59
+ soc,
60
+ dc_load_kw=dc_load,
61
+ held_at_start=held_at_start,
62
+ )
63
+
64
+ return project
65
+
66
+
67
+ def add_arguments(parser: argparse.ArgumentParser) -> None:
68
+ """The command's flags (beside the dataset's)."""
69
+ parser.add_argument(
70
+ "--from", dest="first", type=date.fromisoformat, required=True, help="first local day"
71
+ )
72
+ parser.add_argument(
73
+ "--to", dest="last", type=date.fromisoformat, required=True, help="last local day"
74
+ )
75
+
76
+
77
+ def _clock(day: date, minutes: float | None, bindings: Bindings) -> str:
78
+ return " - " if minutes is None else lgt.clock(day, minutes, bindings.timezone)
79
+
80
+
81
+ def _report(found: lgt.Projection, bindings: Bindings, first: date, last: date) -> None:
82
+ print(f"plant model against the meters, {first} .. {last} ({bindings.timezone.key})")
83
+ print(
84
+ "day import kWh m/r export kWh m/r SOC MAE/max pp "
85
+ "top-up m|r full m|r reasons"
86
+ )
87
+ for d in found.days:
88
+ if d.pairs is None or d.times is None:
89
+ print(f"{d.day} (not run) {', '.join(d.reasons)}")
90
+ continue
91
+ imp, exp = d.pairs["import kWh/day"], d.pairs["export kWh/day"]
92
+ top, full = d.times["top-up start"], d.times["first full"]
93
+ print(
94
+ f"{d.day} {imp[0]:5.1f} / {imp[1]:5.1f} {exp[0]:5.1f} / {exp[1]:5.1f} "
95
+ f"{d.soc_mae_pp or 0.0:5.1f} / {d.soc_max_pp or 0.0:5.1f} "
96
+ f"{_clock(d.day, top[0], bindings)}|{_clock(d.day, top[1], bindings)} "
97
+ f"{_clock(d.day, full[0], bindings)}|{_clock(d.day, full[1], bindings)} "
98
+ f"{', '.join(d.reasons) or '-'}"
99
+ )
100
+ ran = [d for d in found.days if d.pairs is not None and lgt.GAP not in d.reasons]
101
+ print(f"\nover {len(ran)} days without a telemetry gap (model - meters):")
102
+ if ran:
103
+ for name in ran[0].pairs or {}:
104
+ errors = [m - r for d in ran if d.pairs is not None for m, r in [d.pairs[name]]]
105
+ print(
106
+ f" {name:22s} bias {statistics.fmean(errors):+7.3f}"
107
+ f" MAE {statistics.fmean(abs(e) for e in errors):6.3f}"
108
+ )
109
+ for name in ran[0].times or {}:
110
+ gaps = [
111
+ abs(m - r)
112
+ for d in ran
113
+ if d.times is not None
114
+ for m, r in [d.times[name]]
115
+ if m is not None and r is not None
116
+ ]
117
+ median = f"{statistics.median(gaps):5.0f}" if gaps else " - "
118
+ print(f" {name:22s} median |Δ| {median} min (n {len(gaps)})")
119
+ soc = [d.soc_mae_pp for d in ran if d.soc_mae_pp is not None]
120
+ if soc:
121
+ print(f" {'SOC MAE, pp':22s} {statistics.fmean(soc):6.2f}")
122
+ runs = [r for r in found.runs if lgt.GAP not in r.reasons]
123
+ by_bucket: dict[str, list[float]] = {}
124
+ for run in runs:
125
+ for label, error in run.soc_errors:
126
+ by_bucket.setdefault(label, []).append(error)
127
+ order = [bucket_label(low, high) for low, high in BUCKETS]
128
+ shown = [
129
+ f"{label} {statistics.fmean(errs):.1f}/{max(errs):.1f} pp (n {len(errs)})"
130
+ for label in order
131
+ if (errs := by_bucket.get(label))
132
+ ]
133
+ if shown:
134
+ print(" SOC by hours ahead, MAE/max: " + ", ".join(shown))
135
+ events = [r.event for r in runs if r.event is not None]
136
+ if events:
137
+ agreed = sum(model == metered for model, metered in events)
138
+ print(f" first event agreed in {agreed} of {len(events)} hourly runs")
139
+ print("\nrouting, model -> meters (slots):")
140
+ for pair, count in sorted(found.routing.items(), key=lambda kv: -kv[1])[:12]:
141
+ print(f" {pair:36s} {count}")
142
+
143
+
144
+ async def run(
145
+ args: argparse.Namespace, bindings: Bindings, history: History, *, step: timedelta = SLOT
146
+ ) -> int:
147
+ """The plant model against the meters over ``--from`` to ``--to``, on slots of
148
+ ``step`` (``EngineConfig.slot``), printed."""
149
+ first, last = args.first, args.last
150
+ if first > last:
151
+ raise SystemExit(f"--from ({first}) is after --to ({last})")
152
+ plant = bindings.plant
153
+ if plant.battery is None:
154
+ raise SystemExit("the plant has no battery: nothing for the model to project")
155
+ recording = await read_recording(history, bindings, plant, first, last, step=step)
156
+ draws = (
157
+ [kw or 0.0 for kw in recording.dc_load_kw]
158
+ if plant.has_dc_loads and recording.dc_load_kw is not None
159
+ else None
160
+ )
161
+ found = lgt.project_against_meters(
162
+ recording,
163
+ pinned=plant,
164
+ plant=plant,
165
+ project=plant_model(plant, step),
166
+ tz=bindings.timezone,
167
+ labels={},
168
+ dc_draws=draws,
169
+ )
170
+ _report(found, bindings, first, last)
171
+ return 0