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/__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
|