nwp500-python 9.12.2__py3-none-any.whl → 9.13.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.
- nwp500/cli/__main__.py +59 -14
- nwp500/cli/calibration.py +147 -18
- nwp500/cli/simulate.py +48 -45
- nwp500/sim/__init__.py +18 -8
- nwp500/sim/api.py +1197 -661
- nwp500/sim/calibration.py +368 -2688
- nwp500/sim/carry.py +492 -0
- nwp500/sim/circulation.py +53 -252
- nwp500/sim/codec.py +15 -1
- nwp500/sim/cold_supply.py +61 -17
- nwp500/sim/controller.py +516 -89
- nwp500/sim/decay.py +106 -893
- nwp500/sim/defaults.py +287 -98
- nwp500/sim/delivery.py +157 -402
- nwp500/sim/draw_ensemble.py +406 -0
- nwp500/sim/draw_forecast.py +15 -10
- nwp500/sim/engine.py +1038 -1027
- nwp500/sim/extensions.py +1 -163
- nwp500/sim/heat_pump_fit.py +1016 -0
- nwp500/sim/heat_pump_recovery.py +61 -211
- nwp500/sim/heating_trajectory.py +58 -305
- nwp500/sim/history.py +249 -0
- nwp500/sim/history_frame.py +97 -0
- nwp500/sim/inputs.py +67 -9
- nwp500/sim/margins.py +272 -0
- nwp500/sim/params.py +601 -545
- nwp500/sim/physics/__init__.py +3 -2
- nwp500/sim/physics/draw.py +39 -410
- nwp500/sim/physics/element.py +20 -484
- nwp500/sim/physics/heat_pump.py +182 -0
- nwp500/sim/physics/stratified.py +1440 -0
- nwp500/sim/physics/water.py +86 -0
- nwp500/sim/profile.py +2 -3
- nwp500/sim/ready.py +111 -409
- nwp500/sim/record.py +22 -3
- nwp500/sim/result.py +74 -21
- nwp500/sim/tank_fit.py +898 -0
- nwp500/sim/tank_physics.py +730 -342
- nwp500/sim/units.py +3 -1
- nwp500/sim_adapters.py +219 -273
- nwp500/sim_calibrate.py +944 -1188
- nwp500/sim_home_assistant.py +155 -141
- nwp500/sim_score.py +2017 -0
- {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/METADATA +1 -1
- {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/RECORD +49 -42
- nwp500/sim/book.py +0 -1262
- nwp500/sim/physics/compressor.py +0 -485
- nwp500/sim/physics/refrigerant.py +0 -177
- nwp500/sim/physics/tank.py +0 -140
- {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/WHEEL +0 -0
- {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/entry_points.txt +0 -0
- {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/licenses/LICENSE.txt +0 -0
- {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/top_level.txt +0 -0
nwp500/cli/__main__.py
CHANGED
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import functools
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import math
|
|
7
8
|
import sys
|
|
8
9
|
from collections.abc import Awaitable, Callable
|
|
9
10
|
from datetime import UTC, datetime
|
|
@@ -1134,14 +1135,14 @@ _tz_option = click.option(
|
|
|
1134
1135
|
@click.option(
|
|
1135
1136
|
"--bath",
|
|
1136
1137
|
type=float,
|
|
1137
|
-
help="The bath readiness is judged against,
|
|
1138
|
-
"(default the parameters', 28.7 gal)",
|
|
1138
|
+
help="The bath readiness is judged against, as the meter reads it, "
|
|
1139
|
+
"preferred unit (default the parameters', 28.7 gal)",
|
|
1139
1140
|
)
|
|
1140
1141
|
@click.option(
|
|
1141
1142
|
"--shower",
|
|
1142
1143
|
type=float,
|
|
1143
|
-
help="A shower,
|
|
1144
|
-
"12 gal)",
|
|
1144
|
+
help="A shower, as the meter reads it, preferred unit (default the "
|
|
1145
|
+
"parameters', 12 gal)",
|
|
1145
1146
|
)
|
|
1146
1147
|
@click.option(
|
|
1147
1148
|
"--format",
|
|
@@ -1174,7 +1175,7 @@ def simulate_run(ctx: click.Context, output_format: str, **given: Any) -> None:
|
|
|
1174
1175
|
live = await sim_cli.compose_live(
|
|
1175
1176
|
mqtt, ctx.obj["api"], device, inputs, now
|
|
1176
1177
|
)
|
|
1177
|
-
keeper = await sim_cli.
|
|
1178
|
+
keeper = await sim_cli.read_tank(inputs, now)
|
|
1178
1179
|
except sim_cli.InputError as e:
|
|
1179
1180
|
raise click.UsageError(str(e)) from None
|
|
1180
1181
|
walk(live, keeper)
|
|
@@ -1254,6 +1255,15 @@ async def simulate_record(
|
|
|
1254
1255
|
await handle_recording(mqtt, device, history, every)
|
|
1255
1256
|
|
|
1256
1257
|
|
|
1258
|
+
def _finite_weeks(
|
|
1259
|
+
ctx: click.Context, param: click.Parameter, value: float
|
|
1260
|
+
) -> float:
|
|
1261
|
+
"""``--tank-weeks`` as a finite number of weeks."""
|
|
1262
|
+
if not math.isfinite(value):
|
|
1263
|
+
raise click.BadParameter(f"{value} is not a number of weeks")
|
|
1264
|
+
return value
|
|
1265
|
+
|
|
1266
|
+
|
|
1257
1267
|
@simulate.command("calibrate") # type: ignore[attr-defined]
|
|
1258
1268
|
@click.argument("history", type=click.Path(exists=True, dir_okay=False))
|
|
1259
1269
|
@click.option(
|
|
@@ -1291,6 +1301,25 @@ async def simulate_record(
|
|
|
1291
1301
|
help="A TOML file naming where the air, its humidity, the hot water "
|
|
1292
1302
|
"demand and the circulation are recorded (Home Assistant)",
|
|
1293
1303
|
)
|
|
1304
|
+
@click.option(
|
|
1305
|
+
"--holdout-from",
|
|
1306
|
+
"holdout_from",
|
|
1307
|
+
metavar="DATE",
|
|
1308
|
+
help="Fit on the history before DATE (ISO 8601, local time without "
|
|
1309
|
+
"an offset) and score the base and the fit on the events from it",
|
|
1310
|
+
)
|
|
1311
|
+
@click.option(
|
|
1312
|
+
"--tank-weeks",
|
|
1313
|
+
"tank_weeks",
|
|
1314
|
+
type=click.FloatRange(min=0.0),
|
|
1315
|
+
callback=_finite_weeks,
|
|
1316
|
+
default=7.0,
|
|
1317
|
+
show_default=True,
|
|
1318
|
+
metavar="WEEKS",
|
|
1319
|
+
help="Fit the tank on its events in the last WEEKS of the history "
|
|
1320
|
+
"(to its last status before --holdout-from), the history before read "
|
|
1321
|
+
"as their lead-in; 0 for the whole history",
|
|
1322
|
+
)
|
|
1294
1323
|
@click.pass_context
|
|
1295
1324
|
def simulate_calibrate(
|
|
1296
1325
|
ctx: click.Context,
|
|
@@ -1301,10 +1330,16 @@ def simulate_calibrate(
|
|
|
1301
1330
|
humidity: str | None,
|
|
1302
1331
|
params: str | None,
|
|
1303
1332
|
sources: str | None,
|
|
1333
|
+
holdout_from: str | None,
|
|
1334
|
+
tank_weeks: float,
|
|
1304
1335
|
) -> None:
|
|
1305
1336
|
"""Fit the heater's parameters to its recorded HISTORY.
|
|
1306
1337
|
|
|
1307
1338
|
Prints what each fit found, and why a term kept the base's value.
|
|
1339
|
+
With --holdout-from, the fit sees only the history before it, and
|
|
1340
|
+
the base and the fit are scored on the recorded events after it.
|
|
1341
|
+
The tank is fitted on the last --tank-weeks of what the fit sees,
|
|
1342
|
+
the heat pump on all of it.
|
|
1308
1343
|
"""
|
|
1309
1344
|
sim_cli = _simulator()
|
|
1310
1345
|
from . import calibration as cal_cli
|
|
@@ -1320,21 +1355,31 @@ def simulate_calibrate(
|
|
|
1320
1355
|
record = asyncio.run(
|
|
1321
1356
|
cal_cli.read_record(sim_cli.read_sources(sources), readings)
|
|
1322
1357
|
)
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
humidity=None
|
|
1358
|
+
inputs: dict[str, Any] = {
|
|
1359
|
+
"air": None if air is None else cal_cli.read_readings(air, "air"),
|
|
1360
|
+
"ambient": ambient,
|
|
1361
|
+
"humidity": None
|
|
1328
1362
|
if humidity is None
|
|
1329
1363
|
else cal_cli.read_readings(humidity, "humidity"),
|
|
1330
|
-
params
|
|
1364
|
+
"params": None
|
|
1331
1365
|
if params is None
|
|
1332
1366
|
else sim_cli.read_document(params, "parameters"),
|
|
1333
|
-
record
|
|
1334
|
-
|
|
1367
|
+
"record": record,
|
|
1368
|
+
"tank_weeks": tank_weeks or None,
|
|
1369
|
+
}
|
|
1370
|
+
held = None
|
|
1371
|
+
if holdout_from is None:
|
|
1372
|
+
done = cal_cli.calibrate(readings, **inputs)
|
|
1373
|
+
else:
|
|
1374
|
+
held = cal_cli.holdout(
|
|
1375
|
+
readings,
|
|
1376
|
+
cal_cli.parse_instant(holdout_from, "--holdout-from"),
|
|
1377
|
+
**inputs,
|
|
1378
|
+
)
|
|
1379
|
+
done = held.done
|
|
1335
1380
|
except sim_cli.InputError as e:
|
|
1336
1381
|
raise click.UsageError(str(e)) from None
|
|
1337
|
-
cal_cli.run(done, output)
|
|
1382
|
+
cal_cli.run(done, output, held=held)
|
|
1338
1383
|
|
|
1339
1384
|
|
|
1340
1385
|
if __name__ == "__main__":
|
nwp500/cli/calibration.py
CHANGED
|
@@ -10,12 +10,14 @@ This module needs the ``sim`` extra.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import csv
|
|
13
|
+
import functools
|
|
13
14
|
import json
|
|
14
15
|
import logging
|
|
15
16
|
import math
|
|
16
17
|
import sys
|
|
17
18
|
from collections.abc import Sequence
|
|
18
|
-
from
|
|
19
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
19
21
|
from datetime import datetime
|
|
20
22
|
from pathlib import Path
|
|
21
23
|
from typing import Any, TextIO
|
|
@@ -23,12 +25,14 @@ from typing import Any, TextIO
|
|
|
23
25
|
from rich.console import Console
|
|
24
26
|
from rich.table import Table
|
|
25
27
|
|
|
28
|
+
from nwp500 import sim, status_history
|
|
26
29
|
from nwp500 import sim_calibrate as sc
|
|
27
|
-
from nwp500 import
|
|
30
|
+
from nwp500 import sim_score as ss
|
|
31
|
+
from nwp500.sim import tank_fit as tf
|
|
28
32
|
from nwp500.sim.params import params_to_document
|
|
29
33
|
from nwp500.sim_calibrate import Calibrated, Term, report
|
|
30
34
|
|
|
31
|
-
from .simulate import InputError, read_params
|
|
35
|
+
from .simulate import InputError, local_zone, read_params
|
|
32
36
|
|
|
33
37
|
__author__ = "Emmanuel Levijarvi"
|
|
34
38
|
__copyright__ = "Emmanuel Levijarvi"
|
|
@@ -102,9 +106,12 @@ _OPTION_NOTES = {
|
|
|
102
106
|
sc.DEFAULT_BASE_NOTE: "refitting the 65 gal defaults (give --params "
|
|
103
107
|
"for another tank size)",
|
|
104
108
|
sc.NO_AIR_NOTE: "no air given (--air, --ambient or --sources): the "
|
|
105
|
-
"
|
|
109
|
+
"tank is not fitted",
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
#: The events each of the tank fit's workers takes at a time.
|
|
113
|
+
_CHUNK = 8
|
|
114
|
+
|
|
108
115
|
|
|
109
116
|
def calibrate(
|
|
110
117
|
readings: Sequence[status_history.TimedStatus],
|
|
@@ -114,20 +121,25 @@ def calibrate(
|
|
|
114
121
|
humidity: Sequence[Reading] | None = None,
|
|
115
122
|
params: Any = None,
|
|
116
123
|
record: Any = None,
|
|
124
|
+
tank_weeks: float | None = sc.TANK_WEEKS,
|
|
117
125
|
) -> Calibrated:
|
|
118
126
|
"""``sim_calibrate.calibrate`` with ``params`` a parameters
|
|
119
127
|
document, its errors as ``InputError`` and its notes naming the
|
|
120
|
-
command's options
|
|
128
|
+
command's options, the tank's fit spread over the machine's
|
|
129
|
+
processors. ``tank_weeks`` as for ``sim_calibrate.calibrate``."""
|
|
121
130
|
base = None if params is None else read_params(params, "--params")
|
|
122
131
|
try:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
with ProcessPoolExecutor() as pool:
|
|
133
|
+
done = sc.calibrate(
|
|
134
|
+
readings,
|
|
135
|
+
air=air,
|
|
136
|
+
ambient=ambient,
|
|
137
|
+
humidity=humidity,
|
|
138
|
+
params=base,
|
|
139
|
+
record=record,
|
|
140
|
+
map_=functools.partial(pool.map, chunksize=_CHUNK),
|
|
141
|
+
tank_weeks=tank_weeks,
|
|
142
|
+
)
|
|
131
143
|
except sc.CalibrationInputError as e:
|
|
132
144
|
raise InputError(str(e)) from None
|
|
133
145
|
return replace(
|
|
@@ -135,6 +147,117 @@ def calibrate(
|
|
|
135
147
|
)
|
|
136
148
|
|
|
137
149
|
|
|
150
|
+
def parse_instant(text: str, what: str) -> datetime:
|
|
151
|
+
"""An ISO 8601 date or instant; a date, or an instant without its
|
|
152
|
+
offset, in local time."""
|
|
153
|
+
try:
|
|
154
|
+
at = datetime.fromisoformat(text.strip())
|
|
155
|
+
except ValueError:
|
|
156
|
+
raise InputError(f"{what} {text!r} is not an ISO 8601 date") from None
|
|
157
|
+
return at.astimezone() if at.tzinfo is None else at
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True)
|
|
161
|
+
class HeldOutScore:
|
|
162
|
+
"""A calibration on the history before ``start``, and the base's
|
|
163
|
+
and the fit's scores on the events from it (``sim_score``)."""
|
|
164
|
+
|
|
165
|
+
start: datetime
|
|
166
|
+
done: Calibrated
|
|
167
|
+
base: ss.Score
|
|
168
|
+
fitted: ss.Score
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def holdout(
|
|
172
|
+
readings: Sequence[status_history.TimedStatus],
|
|
173
|
+
start: datetime,
|
|
174
|
+
*,
|
|
175
|
+
air: Sequence[Reading] | None = None,
|
|
176
|
+
ambient: float | None = None,
|
|
177
|
+
humidity: Sequence[Reading] | None = None,
|
|
178
|
+
params: Any = None,
|
|
179
|
+
record: Any = None,
|
|
180
|
+
tank_weeks: float | None = sc.TANK_WEEKS,
|
|
181
|
+
) -> HeldOutScore:
|
|
182
|
+
"""``calibrate`` on the statuses before ``start``, the tank on its
|
|
183
|
+
last ``tank_weeks`` before it, then the base and the fit scored on
|
|
184
|
+
the events from it. Raises ``InputError`` when
|
|
185
|
+
either side of ``start`` holds no status."""
|
|
186
|
+
before = [r for r in readings if r[0] < start]
|
|
187
|
+
if not before or len(before) == len(readings):
|
|
188
|
+
raise InputError(
|
|
189
|
+
f"--holdout-from {start.isoformat()} leaves no history on one "
|
|
190
|
+
"side of it"
|
|
191
|
+
)
|
|
192
|
+
done = calibrate(
|
|
193
|
+
before,
|
|
194
|
+
air=air,
|
|
195
|
+
ambient=ambient,
|
|
196
|
+
humidity=humidity,
|
|
197
|
+
params=params,
|
|
198
|
+
record=record,
|
|
199
|
+
tank_weeks=tank_weeks,
|
|
200
|
+
)
|
|
201
|
+
base = (
|
|
202
|
+
sim.default_params()
|
|
203
|
+
if params is None
|
|
204
|
+
else read_params(params, "--params")
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
ready = sc.prepare(
|
|
208
|
+
readings, air=air, ambient=ambient, humidity=humidity, record=record
|
|
209
|
+
)
|
|
210
|
+
except sc.CalibrationInputError as e:
|
|
211
|
+
raise InputError(str(e)) from None
|
|
212
|
+
found = ss.events(ready, since=start)
|
|
213
|
+
# Each event starts from the layers the history before it leaves,
|
|
214
|
+
# which needs the air read somewhere in it.
|
|
215
|
+
air_f = ready.frame.get("air_f")
|
|
216
|
+
replayed = (
|
|
217
|
+
tf.tank_record(ready.frame)
|
|
218
|
+
if air_f is not None and bool(air_f.notna().any())
|
|
219
|
+
else None
|
|
220
|
+
)
|
|
221
|
+
# Local days, as ``--holdout-from`` is read.
|
|
222
|
+
tz = local_zone() or "UTC"
|
|
223
|
+
return HeldOutScore(
|
|
224
|
+
start,
|
|
225
|
+
done,
|
|
226
|
+
ss.score(found, base, tz=tz, record=replayed),
|
|
227
|
+
ss.score(found, done.calibration.params, tz=tz, record=replayed),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def print_score(held: HeldOutScore, console: Console) -> None:
|
|
232
|
+
"""The held-out score as a table on ``console``."""
|
|
233
|
+
events = held.fitted.events
|
|
234
|
+
console.print(
|
|
235
|
+
f"Held out from {held.start.isoformat()}: "
|
|
236
|
+
f"{len(events['recoveries'])} recoveries, "
|
|
237
|
+
f"{len(events['windows'])} quiet windows"
|
|
238
|
+
)
|
|
239
|
+
table = Table(title="Held-out score (lower is better, but a bias)")
|
|
240
|
+
for column in ("Metric", "Events", "Base", "Fitted", "Fitted 90 %"):
|
|
241
|
+
table.add_column(column)
|
|
242
|
+
for name, fitted in held.fitted.metrics.items():
|
|
243
|
+
base = held.base.metrics.get(name)
|
|
244
|
+
table.add_row(
|
|
245
|
+
name,
|
|
246
|
+
str(fitted.n),
|
|
247
|
+
"" if base is None else f"{base.value:.3g}",
|
|
248
|
+
f"{fitted.value:.3g}",
|
|
249
|
+
f"{fitted.lo:.3g} to {fitted.hi:.3g}",
|
|
250
|
+
)
|
|
251
|
+
console.print(table)
|
|
252
|
+
failed = sum(held.fitted.failed.values())
|
|
253
|
+
broken = sum(held.fitted.breaks.values())
|
|
254
|
+
if failed or broken:
|
|
255
|
+
console.print(
|
|
256
|
+
f"Fitted: {failed} events the simulator could not answer, "
|
|
257
|
+
f"{broken} predictions below the air"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
138
261
|
def _kept(t: Term) -> str:
|
|
139
262
|
if t.fitted:
|
|
140
263
|
return f"fitted: {t.note}" if t.note else "fitted"
|
|
@@ -145,8 +268,7 @@ def print_report(done: Calibrated, console: Console) -> None:
|
|
|
145
268
|
"""The report as tables on ``console``."""
|
|
146
269
|
c = done.calibration
|
|
147
270
|
console.print(
|
|
148
|
-
f"{done.n_statuses} statuses;
|
|
149
|
-
f"{len(c.recoveries)} recoveries found"
|
|
271
|
+
f"{done.n_statuses} statuses; {len(c.recoveries)} recoveries found"
|
|
150
272
|
)
|
|
151
273
|
if done.n_gaps:
|
|
152
274
|
minutes = done.max_gap.total_seconds() / 60
|
|
@@ -181,12 +303,19 @@ def print_report(done: Calibrated, console: Console) -> None:
|
|
|
181
303
|
|
|
182
304
|
|
|
183
305
|
def run(
|
|
184
|
-
done: Calibrated,
|
|
306
|
+
done: Calibrated,
|
|
307
|
+
output: str | Path | None,
|
|
308
|
+
out: TextIO | None = None,
|
|
309
|
+
held: HeldOutScore | None = None,
|
|
185
310
|
) -> None:
|
|
186
|
-
"""Print the report and write the parameters
|
|
311
|
+
"""Print the report, and ``held``'s score, and write the parameters
|
|
312
|
+
to ``output``."""
|
|
187
313
|
for note in done.notes:
|
|
188
314
|
_logger.warning(note)
|
|
189
|
-
|
|
315
|
+
console = Console(file=out or sys.stdout)
|
|
316
|
+
print_report(done, console)
|
|
317
|
+
if held is not None:
|
|
318
|
+
print_score(held, console)
|
|
190
319
|
if output is not None:
|
|
191
320
|
text = json.dumps(params_to_document(done.calibration.params), indent=2)
|
|
192
321
|
Path(output).write_text(text + "\n", encoding="utf-8")
|
nwp500/cli/simulate.py
CHANGED
|
@@ -11,7 +11,6 @@ This module needs the ``sim`` extra; ``__main__`` imports it only when a
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
13
|
import csv
|
|
14
|
-
import dataclasses
|
|
15
14
|
import json
|
|
16
15
|
import logging
|
|
17
16
|
import os
|
|
@@ -43,7 +42,7 @@ if TYPE_CHECKING:
|
|
|
43
42
|
from nwp500.api_client import NavienAPIClient
|
|
44
43
|
from nwp500.models import Device
|
|
45
44
|
from nwp500.mqtt import NavienMqttClient
|
|
46
|
-
from nwp500.sim_home_assistant import
|
|
45
|
+
from nwp500.sim_home_assistant import HomeAssistantTank
|
|
47
46
|
|
|
48
47
|
__author__ = "Emmanuel Levijarvi"
|
|
49
48
|
__copyright__ = "Emmanuel Levijarvi"
|
|
@@ -130,8 +129,8 @@ class Inputs:
|
|
|
130
129
|
#: Where the air, its humidity and the inlet are read live
|
|
131
130
|
#: (``sim_home_assistant.Sources``), when the heater is.
|
|
132
131
|
sources: Any = None
|
|
133
|
-
#: The bath and the shower readiness is judged against,
|
|
134
|
-
#: in the preferred unit; None: the parameters'.
|
|
132
|
+
#: The bath and the shower readiness is judged against, as the meter
|
|
133
|
+
#: reads them, in the preferred unit; None: the parameters'.
|
|
135
134
|
bath: float | None = None
|
|
136
135
|
shower: float | None = None
|
|
137
136
|
|
|
@@ -278,18 +277,18 @@ async def compose_live(
|
|
|
278
277
|
return composed
|
|
279
278
|
|
|
280
279
|
|
|
281
|
-
async def
|
|
282
|
-
"""The
|
|
283
|
-
|
|
284
|
-
entities and the draw flow; None otherwise. Its ``
|
|
285
|
-
request, so it
|
|
280
|
+
async def read_tank(inputs: Inputs, now: datetime) -> HomeAssistantTank | None:
|
|
281
|
+
"""The tank's keeper (``sim_home_assistant.HomeAssistantTank``) with
|
|
282
|
+
the history read to ``now``, when the sources name the heater's
|
|
283
|
+
entities and the draw flow; None otherwise. Its ``layers`` makes no
|
|
284
|
+
request, so it replays after the client is closed."""
|
|
286
285
|
src = inputs.sources
|
|
287
286
|
if src is None or src.heater is None or src.entities.draw_flow is None:
|
|
288
287
|
return None
|
|
289
288
|
from nwp500 import sim_home_assistant as ha
|
|
290
289
|
|
|
291
290
|
async with ha.HomeAssistantClient(src.url, src.token) as client:
|
|
292
|
-
keeper = ha.
|
|
291
|
+
keeper = ha.HomeAssistantTank(
|
|
293
292
|
client,
|
|
294
293
|
src.heater,
|
|
295
294
|
src.entities.draw_flow,
|
|
@@ -300,34 +299,37 @@ async def read_book(inputs: Inputs, now: datetime) -> HomeAssistantBook | None:
|
|
|
300
299
|
return keeper
|
|
301
300
|
|
|
302
301
|
|
|
303
|
-
def
|
|
304
|
-
keeper:
|
|
305
|
-
) ->
|
|
306
|
-
"""
|
|
307
|
-
(``
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
302
|
+
def layered(
|
|
303
|
+
keeper: HomeAssistantTank | None, built: Built, now: datetime
|
|
304
|
+
) -> Built:
|
|
305
|
+
"""``built`` starting from the tank's layers its history leaves
|
|
306
|
+
(``HomeAssistantTank.layers``), the scenario's probes laid over
|
|
307
|
+
them: only for a scenario that starts ``now``, whose state is the
|
|
308
|
+
heater's now, and gives no layers of its own. Without them, ``built``
|
|
309
|
+
as it is, which the simulator starts from the two probes. The notes
|
|
310
|
+
are logged."""
|
|
311
|
+
state = built.scenario.state
|
|
312
|
+
if keeper is None or state.layers_f is not None:
|
|
313
|
+
return built
|
|
314
|
+
if built.scenario.start != now:
|
|
315
315
|
_logger.warning(
|
|
316
|
-
"
|
|
317
|
-
"the
|
|
318
|
-
start.isoformat(),
|
|
316
|
+
"layers: the scenario starts at %s, not now: walked from "
|
|
317
|
+
"the two probes",
|
|
318
|
+
built.scenario.start.isoformat(),
|
|
319
319
|
)
|
|
320
|
-
return
|
|
321
|
-
answer = keeper.
|
|
320
|
+
return built
|
|
321
|
+
answer = keeper.layers(now, built.params, (state.upper_f, state.lower_f))
|
|
322
322
|
for note in answer.notes:
|
|
323
|
-
if answer.
|
|
323
|
+
if answer.layers_f is None:
|
|
324
324
|
_logger.warning(note)
|
|
325
325
|
else:
|
|
326
326
|
_logger.info(note)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
if answer.layers_f is None:
|
|
328
|
+
return built
|
|
329
|
+
scenario = built.scenario.model_copy(
|
|
330
|
+
update={"state": state.model_copy(update={"layers_f": answer.layers_f})}
|
|
331
|
+
)
|
|
332
|
+
return Built(scenario, built.params, built.notes)
|
|
331
333
|
|
|
332
334
|
|
|
333
335
|
def window(inputs: Inputs, now: datetime) -> tuple[datetime, timedelta]:
|
|
@@ -413,7 +415,6 @@ def energy_totals(
|
|
|
413
415
|
|
|
414
416
|
def hot_water_lines(
|
|
415
417
|
built: Built,
|
|
416
|
-
book: sim.Booked | None,
|
|
417
418
|
bath_gal: float | None = None,
|
|
418
419
|
shower_gal: float | None = None,
|
|
419
420
|
) -> list[str]:
|
|
@@ -425,7 +426,6 @@ def hot_water_lines(
|
|
|
425
426
|
r = sim.time_to_ready(
|
|
426
427
|
built.scenario,
|
|
427
428
|
built.params,
|
|
428
|
-
book=book,
|
|
429
429
|
bath_gal=bath_gal,
|
|
430
430
|
shower_gal=shower_gal,
|
|
431
431
|
)
|
|
@@ -453,23 +453,28 @@ def print_table(
|
|
|
453
453
|
built: Built,
|
|
454
454
|
result: sim.SimulationResult,
|
|
455
455
|
console: Console,
|
|
456
|
-
book: sim.Booked | None = None,
|
|
457
456
|
bath_gal: float | None = None,
|
|
458
457
|
shower_gal: float | None = None,
|
|
459
458
|
) -> None:
|
|
460
|
-
"""The heating intervals, the energy by source, the readiness
|
|
461
|
-
|
|
459
|
+
"""The heating intervals, the energy by source, the readiness with
|
|
460
|
+
the bath and the showers, the draw windows
|
|
462
461
|
(and which ran cold), the reasons and the defaults."""
|
|
463
462
|
s = built.scenario
|
|
464
463
|
tz = s.tz
|
|
465
|
-
ready = sim.readiness(
|
|
464
|
+
ready = sim.readiness(
|
|
465
|
+
s.state,
|
|
466
|
+
s.settings,
|
|
467
|
+
built.params,
|
|
468
|
+
inlet_f=s.environment.inlet_f,
|
|
469
|
+
on=s.start.date(),
|
|
470
|
+
)
|
|
466
471
|
console.print(
|
|
467
472
|
f"[bold]{_local(s.start, tz)} to {_local(s.until, tz)}[/bold] ({tz}): "
|
|
468
473
|
f"tank {_temp(s.state.upper_f)} upper, {_temp(s.state.lower_f)} "
|
|
469
474
|
f"lower, setpoint {_temp(s.settings.setpoint_f)}; "
|
|
470
475
|
f"{_volume(ready.gal)} deliverable now ({ready.basis})"
|
|
471
476
|
)
|
|
472
|
-
for line in hot_water_lines(built,
|
|
477
|
+
for line in hot_water_lines(built, bath_gal, shower_gal):
|
|
473
478
|
console.print(line)
|
|
474
479
|
|
|
475
480
|
heating = Table(title="Heating")
|
|
@@ -565,16 +570,15 @@ def run(
|
|
|
565
570
|
now: datetime,
|
|
566
571
|
output_format: str,
|
|
567
572
|
out: TextIO | None = None,
|
|
568
|
-
keeper:
|
|
573
|
+
keeper: HomeAssistantTank | None = None,
|
|
569
574
|
) -> sim.SimulationResult:
|
|
570
575
|
"""Build the scenario, walk it and print the result: from the tank's
|
|
571
|
-
|
|
572
|
-
built = build(inputs, live, now)
|
|
576
|
+
layers when ``keeper`` (``read_tank``) gives them."""
|
|
577
|
+
built = layered(keeper, build(inputs, live, now), now)
|
|
573
578
|
for note in built.notes:
|
|
574
579
|
_logger.warning(note)
|
|
575
|
-
book = booked(keeper, built, now)
|
|
576
580
|
try:
|
|
577
|
-
result = sim.simulate(built.scenario, built.params
|
|
581
|
+
result = sim.simulate(built.scenario, built.params)
|
|
578
582
|
except SimulationError as e:
|
|
579
583
|
raise InputError(f"the scenario cannot be simulated: {e}") from None
|
|
580
584
|
stream = out or sys.stdout
|
|
@@ -588,7 +592,6 @@ def run(
|
|
|
588
592
|
built,
|
|
589
593
|
result,
|
|
590
594
|
Console(file=stream),
|
|
591
|
-
book,
|
|
592
595
|
None if inputs.bath is None else sa.to_sim_volume(inputs.bath),
|
|
593
596
|
None if inputs.shower is None else sa.to_sim_volume(inputs.shower),
|
|
594
597
|
)
|
nwp500/sim/__init__.py
CHANGED
|
@@ -10,12 +10,14 @@
|
|
|
10
10
|
can deliver now; ``time_to_ready(scenario, params)``: whether it can
|
|
11
11
|
run a bath, when it can in each heating mode, and the showers it is
|
|
12
12
|
ready for;
|
|
13
|
-
- ``element_mode(
|
|
14
|
-
|
|
13
|
+
- ``element_mode(scenario, params, mode=...)``: a stint in an element
|
|
14
|
+
mode, the scenario walked with the mode written at its start;
|
|
15
|
+
- ``carry_on(walks, tail, params)``: each walk's end walked on through
|
|
16
|
+
one tail, so changes are weighed with what each leaves behind;
|
|
15
17
|
- ``default_params(volume_code)``: one NWP500-65's parameters with no
|
|
16
18
|
mixing valve; ``site_example_params`` that installation whole, valve
|
|
17
19
|
included (``with_valve`` sets one); the parameters of one NWP500-65,
|
|
18
|
-
with the tank
|
|
20
|
+
with the tank resized to the 50 and 80 gal models' (``defaults.sized``).
|
|
19
21
|
|
|
20
22
|
``nwp500.sim_adapters`` builds a scenario's parts from the library's
|
|
21
23
|
models; ``sim.codec`` reads and writes scenarios as JSON documents.
|
|
@@ -32,9 +34,11 @@ from nwp500.sim.api import (
|
|
|
32
34
|
standby,
|
|
33
35
|
time_to_ready,
|
|
34
36
|
)
|
|
35
|
-
from nwp500.sim.
|
|
37
|
+
from nwp500.sim.carry import SETTLED_F, Carried, carry_on
|
|
36
38
|
from nwp500.sim.defaults import (
|
|
37
39
|
NO_VALVE,
|
|
40
|
+
SITE_UNDILUTED_GPM,
|
|
41
|
+
SITE_UNDILUTED_SHARE,
|
|
38
42
|
SITE_VALVE,
|
|
39
43
|
default_params,
|
|
40
44
|
site_example_params,
|
|
@@ -57,8 +61,8 @@ from nwp500.sim.inputs import (
|
|
|
57
61
|
TouPeriod,
|
|
58
62
|
Write,
|
|
59
63
|
)
|
|
64
|
+
from nwp500.sim.margins import StartMargin, start_margins
|
|
60
65
|
from nwp500.sim.params import (
|
|
61
|
-
BookParams,
|
|
62
66
|
CirculationParams,
|
|
63
67
|
HeaterParams,
|
|
64
68
|
ReadinessParams,
|
|
@@ -72,6 +76,7 @@ from nwp500.sim.ready import (
|
|
|
72
76
|
)
|
|
73
77
|
from nwp500.sim.result import (
|
|
74
78
|
ActiveCycle,
|
|
79
|
+
ElementInterval,
|
|
75
80
|
ElementModeResult,
|
|
76
81
|
Forecast,
|
|
77
82
|
RecoveryResult,
|
|
@@ -83,14 +88,17 @@ __all__ = (
|
|
|
83
88
|
"CirculationParams",
|
|
84
89
|
"CirculationRun",
|
|
85
90
|
"NO_VALVE",
|
|
91
|
+
"SITE_UNDILUTED_GPM",
|
|
92
|
+
"SITE_UNDILUTED_SHARE",
|
|
86
93
|
"SITE_VALVE",
|
|
94
|
+
"SETTLED_F",
|
|
87
95
|
"BathReady",
|
|
88
|
-
"
|
|
89
|
-
"Booked",
|
|
96
|
+
"Carried",
|
|
90
97
|
"Deliverable",
|
|
91
98
|
"Draw",
|
|
92
99
|
"DrawWindow",
|
|
93
100
|
"DrawWindowResult",
|
|
101
|
+
"ElementInterval",
|
|
94
102
|
"ElementModeResult",
|
|
95
103
|
"Environment",
|
|
96
104
|
"HeaterParams",
|
|
@@ -110,12 +118,13 @@ __all__ = (
|
|
|
110
118
|
"Settings",
|
|
111
119
|
"ShowersReady",
|
|
112
120
|
"SimulationResult",
|
|
121
|
+
"StartMargin",
|
|
113
122
|
"TouPeriod",
|
|
114
123
|
"Trace",
|
|
115
124
|
"Write",
|
|
125
|
+
"carry_on",
|
|
116
126
|
"default_params",
|
|
117
127
|
"element_mode",
|
|
118
|
-
"full_tank_book",
|
|
119
128
|
"active_cycle",
|
|
120
129
|
"forecast",
|
|
121
130
|
"next_recovery",
|
|
@@ -124,6 +133,7 @@ __all__ = (
|
|
|
124
133
|
"simulate",
|
|
125
134
|
"site_example_params",
|
|
126
135
|
"standby",
|
|
136
|
+
"start_margins",
|
|
127
137
|
"recovery_as_of",
|
|
128
138
|
"time_to_ready",
|
|
129
139
|
"with_valve",
|