moat-dev-heat-sim 0.0.1__tar.gz

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 (26) hide show
  1. moat_dev_heat_sim-0.0.1/LICENSE.txt +14 -0
  2. moat_dev_heat_sim-0.0.1/Makefile +14 -0
  3. moat_dev_heat_sim-0.0.1/PKG-INFO +64 -0
  4. moat_dev_heat_sim-0.0.1/README.md +39 -0
  5. moat_dev_heat_sim-0.0.1/pyproject.toml +39 -0
  6. moat_dev_heat_sim-0.0.1/setup.cfg +4 -0
  7. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/__init__.py +16 -0
  8. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/_main.py +48 -0
  9. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/_util.py +61 -0
  10. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/buffer.py +112 -0
  11. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/circuits.py +129 -0
  12. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/config.py +265 -0
  13. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/heat_pump.py +237 -0
  14. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/link.py +143 -0
  15. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/loop.py +366 -0
  16. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/paths.py +92 -0
  17. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/pellet.py +147 -0
  18. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/plant.py +349 -0
  19. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/scenario.py +136 -0
  20. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/trace.py +149 -0
  21. moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/weather.py +80 -0
  22. moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/PKG-INFO +64 -0
  23. moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/SOURCES.txt +24 -0
  24. moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/dependency_links.txt +1 -0
  25. moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/requires.txt +6 -0
  26. moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/top_level.txt +1 -0
@@ -0,0 +1,14 @@
1
+ The code in this repository, and all MoaT submodules it refers to,
2
+ is part of the MoaT project.
3
+
4
+ Unless a submodule's LICENSE.txt states otherwise, all included files are
5
+ licensed under the LGPL V3, as published by the FSF at
6
+ https://www.gnu.org/licenses/lgpl-3.0.html .
7
+
8
+ In addition to the LGPL's terms, the author(s) respectfully ask all users of
9
+ this code to contribute any bug fixes or enhancements. Also, please link back to
10
+ https://M-o-a-T.org.
11
+
12
+ Thank you.
13
+
14
+ Copyright © 2021 ff.: the MoaT contributor(s), as per the git changelog(s).
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/make -f
2
+
3
+ PACKAGE = moat-dev-heat-sim
4
+ MAKEINCL ?= $(shell python3 -mmoat src path)/make/py
5
+
6
+ ifneq ($(wildcard $(MAKEINCL)),)
7
+ include $(MAKEINCL)
8
+ # availabe via http://github.com/smurfix/sourcemgr
9
+
10
+ else
11
+ %:
12
+ @echo "Please fix 'python3 -mmoat src path'."
13
+ @exit 1
14
+ endif
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-dev-heat-sim
3
+ Version: 0.0.1
4
+ Summary: Plant simulator and closed-loop test harness for the MoaT heating controller
5
+ Author-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: AnyIO
11
+ Classifier: Framework :: Trio
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Intended Audience :: Developers
15
+ Requires-Python: >=3.13
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.2
19
+ Requires-Dist: asyncclick~=8.3
20
+ Requires-Dist: moat-util~=0.64.8
21
+ Requires-Dist: moat-lib-path~=0.4.1
22
+ Requires-Dist: moat-link~=0.11.4
23
+ Requires-Dist: moat-dev-heat~=1.0.0
24
+ Dynamic: license-file
25
+
26
+ # Heating simulator
27
+
28
+ % start synopsis
29
+ % start main
30
+
31
+ A simulated heating plant for the heat controller (`moat-dev-heat`),
32
+ for tests and dry runs: no part of it is needed to run the heating.
33
+
34
+ % end synopsis
35
+
36
+ The plant model (`moat.dev.heat.sim.Plant`) is deterministic (seeded)
37
+ and sans-IO, advanced by `step(dt)`:
38
+
39
+ - a stratified buffer (N layers, mixing, losses) with sensor taps for
40
+ `tb_water`, `tb_heat`, `tb_mid`, `tb_low`, and the hot-water section;
41
+ - the heat pump: output = load × Pmax(t_air) with a COP curve,
42
+ `t_out = t_in + P / (c · flow)`, start delay, minimum load, power
43
+ draw, compressor speed, de-ice cycles, over-temperature trip, error
44
+ codes;
45
+ - the circulation pump: flow(PWM) with dead zone and lag;
46
+ - the heating circuit: radiator demand from the outside temperature,
47
+ valve modes, return temperature; the switchover valve;
48
+ - hot-water draw events;
49
+ - the pellet burner: KWB state-code sequence, ignition time and
50
+ warm-up, ignition failure, load → power, top temperature;
51
+ - the weather: outside temperature profile and forecast;
52
+ - fault injection: stuck or stale sensors, a dead flow pump.
53
+
54
+ `moat dev heat sim` publishes the simulator on a MoaT-Link tree (a
55
+ real or a test one) at the paths the pollers use, with a configurable
56
+ time scale, for end-to-end tests and operator dry runs. Scenario files
57
+ set up the plant and schedule events.
58
+
59
+ The closed-loop runner (`moat.dev.heat.sim.loop.ClosedLoop`) wires the
60
+ plant and the units' cores in-process, in simulated time; traces of its
61
+ runs are compared with stored references (`moat.dev.heat.sim.trace`).
62
+ The heat controller's tests use both.
63
+
64
+ % end main
@@ -0,0 +1,39 @@
1
+ # Heating simulator
2
+
3
+ % start synopsis
4
+ % start main
5
+
6
+ A simulated heating plant for the heat controller (`moat-dev-heat`),
7
+ for tests and dry runs: no part of it is needed to run the heating.
8
+
9
+ % end synopsis
10
+
11
+ The plant model (`moat.dev.heat.sim.Plant`) is deterministic (seeded)
12
+ and sans-IO, advanced by `step(dt)`:
13
+
14
+ - a stratified buffer (N layers, mixing, losses) with sensor taps for
15
+ `tb_water`, `tb_heat`, `tb_mid`, `tb_low`, and the hot-water section;
16
+ - the heat pump: output = load × Pmax(t_air) with a COP curve,
17
+ `t_out = t_in + P / (c · flow)`, start delay, minimum load, power
18
+ draw, compressor speed, de-ice cycles, over-temperature trip, error
19
+ codes;
20
+ - the circulation pump: flow(PWM) with dead zone and lag;
21
+ - the heating circuit: radiator demand from the outside temperature,
22
+ valve modes, return temperature; the switchover valve;
23
+ - hot-water draw events;
24
+ - the pellet burner: KWB state-code sequence, ignition time and
25
+ warm-up, ignition failure, load → power, top temperature;
26
+ - the weather: outside temperature profile and forecast;
27
+ - fault injection: stuck or stale sensors, a dead flow pump.
28
+
29
+ `moat dev heat sim` publishes the simulator on a MoaT-Link tree (a
30
+ real or a test one) at the paths the pollers use, with a configurable
31
+ time scale, for end-to-end tests and operator dry runs. Scenario files
32
+ set up the plant and schedule events.
33
+
34
+ The closed-loop runner (`moat.dev.heat.sim.loop.ClosedLoop`) wires the
35
+ plant and the units' cores in-process, in simulated time; traces of its
36
+ runs are compared with stored references (`moat.dev.heat.sim.trace`).
37
+ The heat controller's tests use both.
38
+
39
+ % end main
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = ["wheel","setuptools"]
4
+
5
+ [project]
6
+ classifiers = [
7
+ "Development Status :: 4 - Beta",
8
+ "Framework :: AnyIO",
9
+ "Framework :: Trio",
10
+ "Framework :: AsyncIO",
11
+ "Programming Language :: Python :: 3",
12
+ "Intended Audience :: Developers",
13
+ ]
14
+ dependencies = [
15
+ "anyio ~= 4.2",
16
+ "asyncclick ~= 8.3",
17
+ "moat-util ~= 0.64.8",
18
+ "moat-lib-path ~= 0.4.1",
19
+ "moat-link ~= 0.11.4",
20
+ "moat-dev-heat ~= 1.0.0",
21
+ ]
22
+ keywords = ["MoaT"]
23
+ requires-python = ">=3.13"
24
+ name = "moat-dev-heat-sim"
25
+ authors = [{email = "matthias@urlichs.de",name = "Matthias Urlichs"}]
26
+ description = "Plant simulator and closed-loop test harness for the MoaT heating controller"
27
+ readme = "README.md"
28
+ version = "0.0.1"
29
+
30
+ [project.urls]
31
+ homepage = "https://m-o-a-t.org"
32
+ repository = "https://github.com/M-o-a-T/moat"
33
+
34
+ [tool.setuptools]
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ "*" = ["*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,16 @@
1
+ """
2
+ Plant simulator for the heat controller.
3
+
4
+ A deterministic, seeded, sans-IO model of the heating plant (buffer,
5
+ heat pump, circulation pump, heating circuit, hot water, pellet burner,
6
+ weather), advanced by :meth:`~moat.dev.heat.sim.plant.Plant.step`. It has no MoaT-Link
7
+ dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from .config import PlantCfg
13
+ from .heat_pump import Stage
14
+ from .plant import SENSORS, Energy, Faults, Inputs, Plant
15
+
16
+ __all__ = ["SENSORS", "Energy", "Faults", "Inputs", "Plant", "PlantCfg", "Stage"]
@@ -0,0 +1,48 @@
1
+ """
2
+ ``moat dev heat sim``: run the plant simulator on MoaT-Link.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import anyio
8
+ from pathlib import Path as FSPath
9
+
10
+ import asyncclick as click
11
+
12
+ from typing import Any
13
+
14
+ __all__ = ["cli"]
15
+
16
+
17
+ @click.command(short_help="Run the plant simulator on MoaT-Link.")
18
+ @click.option(
19
+ "-s",
20
+ "--scenario",
21
+ type=click.Path(exists=True, dir_okay=False, path_type=FSPath),
22
+ help="Scenario file (plant setup and event schedule).",
23
+ )
24
+ @click.option("-t", "--dt", type=float, default=10, help="Simulated seconds per step.")
25
+ @click.option("-x", "--scale", type=float, default=1, help="Simulated seconds per real second.")
26
+ @click.option("-d", "--duration", type=float, help="Stop after this many simulated seconds.")
27
+ @click.pass_obj
28
+ async def cli(
29
+ obj: Any, scenario: FSPath | None, dt: float, scale: float, duration: float | None
30
+ ) -> None:
31
+ """
32
+ Run the plant simulator on MoaT-Link.
33
+
34
+ The simulator publishes its sensors at the real ``heat.s.*`` and
35
+ ``temp.*`` paths and reacts to the commands written there, so do not
36
+ run it next to a real plant. Use a separate MoaT-Link root (``link.root``
37
+ in the configuration) for dry runs.
38
+ """
39
+ from moat.link.client import Link # noqa: PLC0415
40
+
41
+ from .link import SimLink # noqa: PLC0415
42
+ from .scenario import Scenario # noqa: PLC0415
43
+
44
+ sc = Scenario.load(scenario) if scenario is not None else Scenario()
45
+ async with Link(obj.cfg.link) as link:
46
+ sim = SimLink(link, sc, dt=dt, scale=scale)
47
+ with anyio.move_on_after(None if duration is None else duration / scale):
48
+ await sim.run()
@@ -0,0 +1,61 @@
1
+ """Shared physics helpers for the plant simulator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ __all__ = ["C_WATER", "clamp", "kgps", "tank"]
8
+
9
+ C_WATER = 4.186
10
+ """Specific heat of water, kJ/(kg·K). One litre of water is taken as 1 kg."""
11
+
12
+
13
+ def kgps(lpm: float) -> float:
14
+ """Convert a flow in l/min to kg/s."""
15
+ return lpm / 60
16
+
17
+
18
+ def clamp(v: float, lo: float, hi: float) -> float:
19
+ """Restrict ``v`` to ``[lo, hi]``."""
20
+ return lo if v < lo else min(v, hi)
21
+
22
+
23
+ def tank(
24
+ t0: float,
25
+ cap: float,
26
+ fc: float,
27
+ t_in: float,
28
+ ua: float,
29
+ t_amb: float,
30
+ p: float,
31
+ dt: float,
32
+ ) -> tuple[float, float, float]:
33
+ """
34
+ Advance a well-mixed volume of water by ``dt`` seconds, exactly.
35
+
36
+ The volume has heat capacity ``cap`` (kJ/K). Water enters at ``t_in``
37
+ with capacity rate ``fc`` (kW/K, i.e. mass flow times c) and leaves
38
+ at the tank's own temperature. ``ua`` (kW/K) couples it to ``t_amb``;
39
+ ``p`` (kW) is added directly.
40
+
41
+ Returns ``(t_new, t_avg, e_loss)``: the final temperature, the time
42
+ average of the temperature over the interval (i.e. the effective
43
+ temperature of the outflow, whose energy is ``fc * t_avg * dt``), and
44
+ the energy lost to the surroundings (kJ).
45
+
46
+ The ODE is linear, so this is energy-conserving for any ``dt``.
47
+ """
48
+ g = fc + ua
49
+ if g <= 0:
50
+ t1 = t0 + p * dt / cap
51
+ return t1, (t0 + t1) / 2, 0.0
52
+ t_eq = (fc * t_in + ua * t_amb + p) / g
53
+ x = dt * g / cap
54
+ if x < 1e-9:
55
+ t1 = t0 + (t_eq - t0) * x
56
+ t_avg = (t0 + t1) / 2
57
+ else:
58
+ e = math.exp(-x)
59
+ t1 = t_eq + (t0 - t_eq) * e
60
+ t_avg = t_eq + (t0 - t_eq) * (1 - e) / x
61
+ return t1, t_avg, ua * (t_avg - t_amb) * dt
@@ -0,0 +1,112 @@
1
+ """Stratified buffer tank."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._util import C_WATER
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from .config import BufferCfg
11
+
12
+ __all__ = ["Buffer"]
13
+
14
+
15
+ class Buffer:
16
+ """
17
+ A stack of equal, well-mixed layers; index 0 is the top.
18
+
19
+ Flows are plug flows between an inlet and an outlet layer (upwind,
20
+ conservative). Buoyancy is restored after each step by mixing
21
+ inverted layers; conduction and losses act between layers and to
22
+ the room.
23
+ """
24
+
25
+ def __init__(self, cfg: BufferCfg) -> None:
26
+ self.cfg = cfg
27
+ self.n = n = cfg.layers
28
+ self.mass = cfg.volume / n
29
+ t_top, t_bot = cfg.t_init
30
+ self.t = [t_top + (t_bot - t_top) * i / max(n - 1, 1) for i in range(n)]
31
+
32
+ def layer(self, frac: float) -> int:
33
+ """Index of the layer at relative height ``frac`` (0 = top, 1 = bottom)."""
34
+ return min(self.n - 1, max(0, int(frac * self.n)))
35
+
36
+ def temp(self, frac: float) -> float:
37
+ """Temperature at relative height ``frac``."""
38
+ return self.t[self.layer(frac)]
39
+
40
+ @property
41
+ def energy(self) -> float:
42
+ """Stored energy relative to 0 °C, kJ."""
43
+ return self.mass * C_WATER * sum(self.t)
44
+
45
+ def flow(self, m: float, t_in: float, enter: int, leave: int) -> float:
46
+ """
47
+ Move ``m`` kg of water through the tank.
48
+
49
+ Water at ``t_in`` enters at layer ``enter``; the same mass leaves
50
+ at layer ``leave``, and the layers in between shift. Returns the
51
+ temperature of the water that left, which is that of layer
52
+ ``leave`` before the call.
53
+
54
+ ``m`` must not exceed one layer's mass.
55
+ """
56
+ t = self.t
57
+ t_out = t[leave]
58
+ if m <= 0:
59
+ return t_out
60
+ f = m / self.mass
61
+ if f > 1 + 1e-9:
62
+ raise ValueError(f"Flow {m} kg exceeds layer mass {self.mass}")
63
+ if enter < leave: # downwards
64
+ for i in range(leave, enter, -1):
65
+ t[i] += f * (t[i - 1] - t[i])
66
+ elif enter > leave: # upwards
67
+ for i in range(leave, enter):
68
+ t[i] += f * (t[i + 1] - t[i])
69
+ t[enter] += f * (t_in - t[enter])
70
+ return t_out
71
+
72
+ def relax(self, dt: float) -> float:
73
+ """Conduction between layers and losses to the room. Returns the loss, kJ."""
74
+ cfg = self.cfg
75
+ t = self.t
76
+ n = self.n
77
+ cap = self.mass * C_WATER
78
+ k = cfg.k_cond * dt / cap
79
+ if k:
80
+ flux = [k * (t[i] - t[i + 1]) for i in range(n - 1)]
81
+ for i, q in enumerate(flux):
82
+ t[i] -= q
83
+ t[i + 1] += q
84
+ ua = cfg.ua / n
85
+ loss = 0.0
86
+ for i in range(n):
87
+ q = ua * (t[i] - cfg.t_amb) * dt
88
+ t[i] -= q / cap
89
+ loss += q
90
+ return loss
91
+
92
+ def mix(self) -> None:
93
+ """Mix inverted layers (warmer water below colder) until the stack is stable."""
94
+ t = self.t
95
+ if all(t[i] >= t[i + 1] for i in range(self.n - 1)):
96
+ return
97
+ # pool adjacent violators: blocks of (sum, count), means non-increasing
98
+ blocks: list[list[float]] = []
99
+ for v in t:
100
+ blocks.append([v, 1])
101
+ while len(blocks) > 1 and (
102
+ blocks[-2][0] / blocks[-2][1] < blocks[-1][0] / blocks[-1][1]
103
+ ):
104
+ s, c = blocks.pop()
105
+ blocks[-1][0] += s
106
+ blocks[-1][1] += c
107
+ i = 0
108
+ for s, c in blocks:
109
+ v = s / c
110
+ for _ in range(int(c)):
111
+ t[i] = v
112
+ i += 1
@@ -0,0 +1,129 @@
1
+ """Heat consumers: heating circuit with house, and the fresh-water station."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from ._util import C_WATER, kgps, tank
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from .config import DhwCfg, HeatingCfg
13
+
14
+ __all__ = ["Dhw", "Heating"]
15
+
16
+
17
+ class Heating:
18
+ """
19
+ Heating circuit: mixing valve, radiators, and a one-node house.
20
+
21
+ The mixing valve limits the supply to the flow setpoint; the buffer
22
+ supplies only the hot share of the circuit flow, the rest is
23
+ recirculated return water.
24
+ """
25
+
26
+ def __init__(self, cfg: HeatingCfg) -> None:
27
+ self.cfg = cfg
28
+ self.t_room = cfg.t_room
29
+ self.t_supply = cfg.t_room # sensor value, lagging
30
+ self.q = 0.0 # radiator output, kW
31
+
32
+ def step(
33
+ self,
34
+ dt: float,
35
+ *,
36
+ on: bool,
37
+ setpoint: float,
38
+ t_tap: float,
39
+ t_air: float,
40
+ speed: float = 1.0,
41
+ ) -> tuple[float, float]:
42
+ """
43
+ Advance by ``dt``.
44
+
45
+ @speed scales the circuit flow (0…1, default 1 = full flow); zero
46
+ or missing means the pump stands still, which is thermally the
47
+ same as switching the circuit off.
48
+
49
+ Returns ``(m_buffer, t_return)``: the mass drawn from the buffer at
50
+ ``t_tap`` (kg) and the temperature it is returned at. The energy
51
+ taken is ``m_buffer * c * (t_tap - t_return)``.
52
+ """
53
+ cfg = self.cfg
54
+ m = 0.0
55
+ t_ret = t_tap
56
+ t_s = self.t_room
57
+ self.q = 0.0
58
+ flow = cfg.flow * speed if on else 0.0
59
+ if flow > 0:
60
+ t_s = min(t_tap, setpoint)
61
+ fc = kgps(flow) * C_WATER
62
+ dt_rad = t_s - self.t_room
63
+ if dt_rad > 0 and fc > 0:
64
+ q = min(cfg.k_rad * dt_rad**cfg.n_rad, fc * dt_rad)
65
+ t_ret = t_s - q / fc
66
+ if t_tap - t_ret > 0.01:
67
+ m = kgps(flow) * dt * (t_s - t_ret) / (t_tap - t_ret)
68
+ self.q = m * C_WATER * (t_tap - t_ret) / dt
69
+
70
+ self.t_room, _, _ = tank(self.t_room, cfg.cap_house, 0, 0, cfg.ua_house, t_air, self.q, dt)
71
+ a = math.exp(-dt / cfg.sensor_tau)
72
+ self.t_supply = t_s + (self.t_supply - t_s) * a
73
+ return m, t_ret
74
+
75
+
76
+ class Dhw:
77
+ """
78
+ Fresh-water station: domestic hot water is heated from the buffer top.
79
+
80
+ Draws are scheduled events. If the buffer top is too cold, the tap
81
+ temperature drops accordingly.
82
+ """
83
+
84
+ def __init__(self, cfg: DhwCfg) -> None:
85
+ self.cfg = cfg
86
+ self.events: list[tuple[float, float, float]] = [] # start, end, l/min
87
+ self.lpm = 0.0
88
+ self.t_tap = cfg.t_cold
89
+
90
+ def add(self, start: float, duration: float, lpm: float) -> None:
91
+ """Schedule a draw."""
92
+ self.events.append((start, start + duration, lpm))
93
+ self.events.sort()
94
+
95
+ def add_daily(self, day_start: float, day_offset: float) -> None:
96
+ """Schedule the configured daily draws for the day starting at ``day_start``."""
97
+ for hour, dur, lpm in self.cfg.daily:
98
+ self.add(day_start + hour * 3600 - day_offset, dur, lpm)
99
+
100
+ def demand(self, now: float) -> float:
101
+ """Current draw, l/min."""
102
+ while self.events and self.events[0][1] <= now:
103
+ self.events.pop(0)
104
+ return sum(lpm for s, e, lpm in self.events if s <= now < e)
105
+
106
+ def step(self, dt: float, now: float, t_top: float) -> tuple[float, float]:
107
+ """
108
+ Advance by ``dt``.
109
+
110
+ Returns ``(m_buffer, t_return)`` like :meth:`Heating.step`.
111
+ """
112
+ cfg = self.cfg
113
+ self.lpm = lpm = self.demand(now)
114
+ if not lpm:
115
+ self.t_tap = cfg.t_cold
116
+ return 0.0, t_top
117
+ t_ret = cfg.t_cold + cfg.approach
118
+ t_tap = max(cfg.t_cold, min(cfg.t_hot, t_top - cfg.min_delta))
119
+ if t_top <= t_ret:
120
+ self.t_tap = cfg.t_cold
121
+ return 0.0, t_top
122
+ q = kgps(lpm) * C_WATER * (t_tap - cfg.t_cold)
123
+ m = q * dt / (C_WATER * (t_top - t_ret))
124
+ m_max = kgps(cfg.primary_max) * dt
125
+ if m > m_max:
126
+ m = m_max
127
+ t_tap = cfg.t_cold + m * (t_top - t_ret) / (kgps(lpm) * dt)
128
+ self.t_tap = t_tap
129
+ return m, t_ret