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.
Files changed (53) hide show
  1. nwp500/cli/__main__.py +59 -14
  2. nwp500/cli/calibration.py +147 -18
  3. nwp500/cli/simulate.py +48 -45
  4. nwp500/sim/__init__.py +18 -8
  5. nwp500/sim/api.py +1197 -661
  6. nwp500/sim/calibration.py +368 -2688
  7. nwp500/sim/carry.py +492 -0
  8. nwp500/sim/circulation.py +53 -252
  9. nwp500/sim/codec.py +15 -1
  10. nwp500/sim/cold_supply.py +61 -17
  11. nwp500/sim/controller.py +516 -89
  12. nwp500/sim/decay.py +106 -893
  13. nwp500/sim/defaults.py +287 -98
  14. nwp500/sim/delivery.py +157 -402
  15. nwp500/sim/draw_ensemble.py +406 -0
  16. nwp500/sim/draw_forecast.py +15 -10
  17. nwp500/sim/engine.py +1038 -1027
  18. nwp500/sim/extensions.py +1 -163
  19. nwp500/sim/heat_pump_fit.py +1016 -0
  20. nwp500/sim/heat_pump_recovery.py +61 -211
  21. nwp500/sim/heating_trajectory.py +58 -305
  22. nwp500/sim/history.py +249 -0
  23. nwp500/sim/history_frame.py +97 -0
  24. nwp500/sim/inputs.py +67 -9
  25. nwp500/sim/margins.py +272 -0
  26. nwp500/sim/params.py +601 -545
  27. nwp500/sim/physics/__init__.py +3 -2
  28. nwp500/sim/physics/draw.py +39 -410
  29. nwp500/sim/physics/element.py +20 -484
  30. nwp500/sim/physics/heat_pump.py +182 -0
  31. nwp500/sim/physics/stratified.py +1440 -0
  32. nwp500/sim/physics/water.py +86 -0
  33. nwp500/sim/profile.py +2 -3
  34. nwp500/sim/ready.py +111 -409
  35. nwp500/sim/record.py +22 -3
  36. nwp500/sim/result.py +74 -21
  37. nwp500/sim/tank_fit.py +898 -0
  38. nwp500/sim/tank_physics.py +730 -342
  39. nwp500/sim/units.py +3 -1
  40. nwp500/sim_adapters.py +219 -273
  41. nwp500/sim_calibrate.py +944 -1188
  42. nwp500/sim_home_assistant.py +155 -141
  43. nwp500/sim_score.py +2017 -0
  44. {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/METADATA +1 -1
  45. {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/RECORD +49 -42
  46. nwp500/sim/book.py +0 -1262
  47. nwp500/sim/physics/compressor.py +0 -485
  48. nwp500/sim/physics/refrigerant.py +0 -177
  49. nwp500/sim/physics/tank.py +0 -140
  50. {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/WHEEL +0 -0
  51. {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/entry_points.txt +0 -0
  52. {nwp500_python-9.12.2.dist-info → nwp500_python-9.13.0.dist-info}/licenses/LICENSE.txt +0 -0
  53. {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, delivered, preferred unit "
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, delivered, preferred unit (default the parameters', "
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.read_book(inputs, now)
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
- done = cal_cli.calibrate(
1324
- readings,
1325
- air=None if air is None else cal_cli.read_readings(air, "air"),
1326
- ambient=ambient,
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=None
1364
+ "params": None
1331
1365
  if params is None
1332
1366
  else sim_cli.read_document(params, "parameters"),
1333
- record=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 dataclasses import replace
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 status_history
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
- "standby loss is not fitted",
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
- done = sc.calibrate(
124
- readings,
125
- air=air,
126
- ambient=ambient,
127
- humidity=humidity,
128
- params=base,
129
- record=record,
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; quiet test: {c.standby.quiet_by}; "
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, output: str | Path | None, out: TextIO | None = None
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 to ``output``."""
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
- print_report(done, Console(file=out or sys.stdout))
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 HomeAssistantBook
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, delivered,
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 read_book(inputs: Inputs, now: datetime) -> HomeAssistantBook | None:
282
- """The heat book's keeper (``sim_home_assistant.HomeAssistantBook``)
283
- with the history read to ``now``, when the sources name the heater's
284
- entities and the draw flow; None otherwise. Its ``book`` makes no
285
- request, so it books after the client is closed."""
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.HomeAssistantBook(
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 booked(
304
- keeper: HomeAssistantBook | None, built: Built, now: datetime
305
- ) -> sim.Booked | None:
306
- """The tank's booked heat at the scenario's start, from ``keeper``
307
- (``read_book``): only for a scenario that starts ``now``, whose
308
- state is the heater's now. Its gallons convert at the scenario's
309
- inlet where one is given, as the walk takes it. Its notes are
310
- logged."""
311
- if keeper is None:
312
- return None
313
- start = built.scenario.start
314
- if start != now:
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
- "book: the scenario starts at %s, not now: walked without "
317
- "the heat book",
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 None
321
- answer = keeper.book(now, built.params)
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.booked is None:
323
+ if answer.layers_f is None:
324
324
  _logger.warning(note)
325
325
  else:
326
326
  _logger.info(note)
327
- inlet = built.scenario.environment.inlet_f
328
- if answer.booked is None or inlet is None:
329
- return answer.booked
330
- return dataclasses.replace(answer.booked, inlet_f=inlet)
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 (from
461
- ``book`` when given) with the bath and the showers, the draw windows
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(s.state, s.settings, built.params, book=book)
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, book, bath_gal, shower_gal):
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: HomeAssistantBook | None = None,
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
- booked heat when ``keeper`` (``read_book``) books one."""
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, book=book)
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(state, settings, params, mode=..., at=...)``: a
14
- recovery in an element mode;
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 volume scaled for the 50 and 80 gal models.
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.book import Booked, full_tank_book
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
- "BookParams",
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",