moat-dev-heat-sim 0.0.1__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.
- moat/dev/heat/sim/__init__.py +16 -0
- moat/dev/heat/sim/_main.py +48 -0
- moat/dev/heat/sim/_util.py +61 -0
- moat/dev/heat/sim/buffer.py +112 -0
- moat/dev/heat/sim/circuits.py +129 -0
- moat/dev/heat/sim/config.py +265 -0
- moat/dev/heat/sim/heat_pump.py +237 -0
- moat/dev/heat/sim/link.py +143 -0
- moat/dev/heat/sim/loop.py +366 -0
- moat/dev/heat/sim/paths.py +92 -0
- moat/dev/heat/sim/pellet.py +147 -0
- moat/dev/heat/sim/plant.py +349 -0
- moat/dev/heat/sim/scenario.py +136 -0
- moat/dev/heat/sim/trace.py +149 -0
- moat/dev/heat/sim/weather.py +80 -0
- moat_dev_heat_sim-0.0.1.dist-info/METADATA +64 -0
- moat_dev_heat_sim-0.0.1.dist-info/RECORD +20 -0
- moat_dev_heat_sim-0.0.1.dist-info/WHEEL +5 -0
- moat_dev_heat_sim-0.0.1.dist-info/licenses/LICENSE.txt +14 -0
- moat_dev_heat_sim-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration of the plant simulator.
|
|
3
|
+
|
|
4
|
+
Units throughout: °C, l/min (flows), kW (power), kJ (energy), kW/K
|
|
5
|
+
(conductances), kJ/K (heat capacities), seconds.
|
|
6
|
+
|
|
7
|
+
The defaults describe a plausible SolvisMax buffer with a SolvisLea air
|
|
8
|
+
heat pump and a KWB pellet burner; they are not measured values.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
14
|
+
|
|
15
|
+
from typing import Any, Self
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BufferCfg",
|
|
19
|
+
"DhwCfg",
|
|
20
|
+
"HeatPumpCfg",
|
|
21
|
+
"HeatingCfg",
|
|
22
|
+
"PelletCfg",
|
|
23
|
+
"PelletCodes",
|
|
24
|
+
"PlantCfg",
|
|
25
|
+
"PumpCfg",
|
|
26
|
+
"SensorCfg",
|
|
27
|
+
"SwitchCfg",
|
|
28
|
+
"WeatherCfg",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Cfg:
|
|
33
|
+
"""Mixin: build a (nested) config dataclass from a dict."""
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict[str, Any] | None = None) -> Self:
|
|
37
|
+
"""Build an instance, recursing into nested config classes."""
|
|
38
|
+
if not data:
|
|
39
|
+
return cls()
|
|
40
|
+
assert is_dataclass(cls)
|
|
41
|
+
kw = {}
|
|
42
|
+
ftypes = {f.name: f for f in fields(cls)}
|
|
43
|
+
for k, v in data.items():
|
|
44
|
+
if k not in ftypes:
|
|
45
|
+
raise KeyError(f"{cls.__name__}: unknown key {k!r}")
|
|
46
|
+
dflt = getattr(cls(), k)
|
|
47
|
+
if is_dataclass(dflt) and isinstance(v, dict):
|
|
48
|
+
kw[k] = type(dflt).from_dict(v)
|
|
49
|
+
else:
|
|
50
|
+
kw[k] = v
|
|
51
|
+
return cls(**kw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class BufferCfg(_Cfg):
|
|
56
|
+
"""Stratified buffer tank. Heights are fractions from the top (0) to the bottom (1)."""
|
|
57
|
+
|
|
58
|
+
volume: float = 900
|
|
59
|
+
layers: int = 12
|
|
60
|
+
ua: float = 0.003 # total loss to the room
|
|
61
|
+
k_cond: float = 0.004 # conduction between adjacent layers
|
|
62
|
+
t_amb: float = 15
|
|
63
|
+
t_init: tuple[float, float] = (55, 25) # top, bottom; linear in between
|
|
64
|
+
|
|
65
|
+
tap_water: float = 0.03 # tb_water
|
|
66
|
+
tap_heat: float = 0.35 # tb_heat
|
|
67
|
+
tap_mid: float = 0.55 # tb_mid
|
|
68
|
+
tap_low: float = 0.95 # tb_low (return)
|
|
69
|
+
|
|
70
|
+
hp_in_water: float = 0.0 # heat pump supply while the switchover valve is set
|
|
71
|
+
hp_in_heat: float = 0.35 # heat pump supply otherwise
|
|
72
|
+
hp_out: float = 1.0 # heat pump return
|
|
73
|
+
pellet_in: float = 0.0
|
|
74
|
+
pellet_out: float = 1.0
|
|
75
|
+
heat_out: float = 0.35 # heating circuit supply
|
|
76
|
+
heat_in: float = 1.0 # heating circuit return
|
|
77
|
+
dhw_out: float = 0.0 # fresh-water station primary supply
|
|
78
|
+
dhw_in: float = 1.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class HeatPumpCfg(_Cfg):
|
|
83
|
+
"""Air/water heat pump."""
|
|
84
|
+
|
|
85
|
+
p_nom: float = 12 # thermal output at full speed, t_air = t_ref
|
|
86
|
+
t_ref: float = 7
|
|
87
|
+
p_slope: float = 0.025 # relative Pmax change per K of air temperature
|
|
88
|
+
eta: float = 0.45 # fraction of Carnot COP
|
|
89
|
+
cop_min: float = 1.5
|
|
90
|
+
cop_max: float = 8
|
|
91
|
+
lift_hot: float = 3 # condenser above t_out
|
|
92
|
+
lift_cold: float = 5 # evaporator below t_air
|
|
93
|
+
|
|
94
|
+
mode_off: int = 0 # cmd.mode value meaning "off"
|
|
95
|
+
min_load: float = 0.04 # commanded load below this means "off"
|
|
96
|
+
min_mod: float = 0.35 # minimum compressor speed while running
|
|
97
|
+
start_speed: float = 0.6 # speed during the start program
|
|
98
|
+
warmup: float = 300
|
|
99
|
+
ramp: float = 0.01 # speed change per second
|
|
100
|
+
start_delay: float = 60 # after mode on, before the compressor starts
|
|
101
|
+
|
|
102
|
+
aux: float = 0.15 # electrical draw of fan/controls while running
|
|
103
|
+
standby: float = 0.01
|
|
104
|
+
|
|
105
|
+
# The pipes between buffer and heat pump are long and insulated; each is
|
|
106
|
+
# a chain of mixed segments, which gives them some transport latency.
|
|
107
|
+
# Capacities include the pipe itself.
|
|
108
|
+
cap_pipe: float = 30 # supply pipe, buffer → heat pump; t_in at its end
|
|
109
|
+
ua_pipe: float = 0.006
|
|
110
|
+
n_pipe: int = 10
|
|
111
|
+
cap_ret: float = 30 # return pipe, heat pump → buffer
|
|
112
|
+
ua_ret: float = 0.006
|
|
113
|
+
n_ret: int = 10
|
|
114
|
+
outdoor: float = 0.5 # fraction of each pipe that is outside; the rest is at t_room
|
|
115
|
+
t_room: float = 15
|
|
116
|
+
cap_hx: float = 17 # condenser (t_out sensor)
|
|
117
|
+
ua_hx: float = 0.002
|
|
118
|
+
k_bleed: float = 0.03 # conduction between the t_in and t_out ends inside the unit
|
|
119
|
+
|
|
120
|
+
trip_out: float = 65 # t_out over-temperature trip
|
|
121
|
+
trip_in: float = 58 # t_in over-temperature trip
|
|
122
|
+
trip_reset: float = 5 # must cool this far below the trip point to reset
|
|
123
|
+
trip_code_out: int = 1101
|
|
124
|
+
trip_code_in: int = 1102
|
|
125
|
+
trip_path: str = "trip" # error subpath for trips
|
|
126
|
+
|
|
127
|
+
ice_t_max: float = 6 # no icing above this air temperature
|
|
128
|
+
ice_t_span: float = 4 # full icing rate this far below ice_t_max
|
|
129
|
+
ice_rh_min: float = 0.6 # no icing below this relative humidity
|
|
130
|
+
ice_time: float = 2700 # s at full speed and worst conditions until de-icing
|
|
131
|
+
deice_time: float = 240
|
|
132
|
+
deice_power: float = 4 # heat taken from the water while de-icing
|
|
133
|
+
deice_el: float = 1.0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class PumpCfg(_Cfg):
|
|
138
|
+
"""Heat-pump circulation pump, PWM controlled."""
|
|
139
|
+
|
|
140
|
+
flow_max: float = 30
|
|
141
|
+
dead: float = 0.05 # PWM dead zone (the frost task pulses at 1/16, "more than 5%")
|
|
142
|
+
exp: float = 0.8 # flow = flow_max * ((pwm-dead)/(1-dead)) ** exp
|
|
143
|
+
flow_min: float = 2 # minimum flow once the pump runs
|
|
144
|
+
residual: float = 0.6 # flow with the pump off and the loop valve open
|
|
145
|
+
tau: float = 3 # first-order lag
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class HeatingCfg(_Cfg):
|
|
150
|
+
"""Heating circuit with a mixing valve, radiators, and the house."""
|
|
151
|
+
|
|
152
|
+
flow: float = 15
|
|
153
|
+
k_rad: float = 0.08 # radiator output = k_rad * (t_supply - t_room) ** n_rad
|
|
154
|
+
n_rad: float = 1.3
|
|
155
|
+
ua_house: float = 0.25
|
|
156
|
+
cap_house: float = 20000
|
|
157
|
+
t_room: float = 20.5
|
|
158
|
+
sensor_tau: float = 120 # lag of the supply temperature sensor
|
|
159
|
+
off_codes: tuple[int, ...] = (5,) # setting.heat.mode values meaning "off"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclass
|
|
163
|
+
class DhwCfg(_Cfg):
|
|
164
|
+
"""Fresh-water station (domestic hot water)."""
|
|
165
|
+
|
|
166
|
+
t_hot: float = 45
|
|
167
|
+
t_cold: float = 10
|
|
168
|
+
approach: float = 5 # primary return is this much above t_cold
|
|
169
|
+
min_delta: float = 2 # buffer top must exceed the tap temperature by this
|
|
170
|
+
primary_max: float = 30
|
|
171
|
+
daily: tuple[tuple[float, float, float], ...] = () # (hour, duration s, l/min)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class PelletCodes(_Cfg):
|
|
176
|
+
"""KWB state codes as interpreted by the legacy controller."""
|
|
177
|
+
|
|
178
|
+
off: int = 0
|
|
179
|
+
prepare: int = 11
|
|
180
|
+
ignite: int = 2
|
|
181
|
+
run: int = 4
|
|
182
|
+
standby: int = 30
|
|
183
|
+
burnout: int = 31
|
|
184
|
+
afterrun: int = 32
|
|
185
|
+
fault: int = 44
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class PelletCfg(_Cfg):
|
|
190
|
+
"""Pellet burner (KWB) with its own boiler water and charging pump."""
|
|
191
|
+
|
|
192
|
+
p_nom: float = 15
|
|
193
|
+
min_mod: float = 0.3
|
|
194
|
+
ramp: float = 0.05 # kW per second
|
|
195
|
+
cap: float = 400
|
|
196
|
+
ua: float = 0.02
|
|
197
|
+
t_amb: float = 15
|
|
198
|
+
t_init: float = 20
|
|
199
|
+
flow: float = 20 # charging pump; must carry p_nom at the boiler goal
|
|
200
|
+
pump_on: float = 65 # default charging-pump start temperature
|
|
201
|
+
pump_hyst: float = 3
|
|
202
|
+
pump_min_delta: float = 2 # boiler must be this much warmer than the return
|
|
203
|
+
|
|
204
|
+
t_prepare: float = 120
|
|
205
|
+
t_ignite: float = 480
|
|
206
|
+
ignite_retries: int = 1
|
|
207
|
+
t_burnout: float = 600
|
|
208
|
+
t_afterrun: float = 300
|
|
209
|
+
hyst_off: float = 5 # burn out when this far above the goal temperature
|
|
210
|
+
hyst_on: float = 10 # restart from standby when this far below
|
|
211
|
+
|
|
212
|
+
codes: PelletCodes = field(default_factory=PelletCodes)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@dataclass
|
|
216
|
+
class WeatherCfg(_Cfg):
|
|
217
|
+
"""Outside air. Daily sine plus seeded AR(1) noise, or a breakpoint profile."""
|
|
218
|
+
|
|
219
|
+
mean: float = 2
|
|
220
|
+
amp: float = 4
|
|
221
|
+
t_min_hour: float = 5
|
|
222
|
+
rh_mean: float = 0.8
|
|
223
|
+
rh_amp: float = 0.1 # humidity peaks when it's coldest
|
|
224
|
+
sigma: float = 0.5
|
|
225
|
+
tau: float = 3600
|
|
226
|
+
# (seconds, temperature) breakpoints; replaces the sine when set
|
|
227
|
+
profile: tuple[tuple[float, float], ...] | None = None
|
|
228
|
+
forecast_horizon: float = 6 * 3600
|
|
229
|
+
forecast_step: float = 900
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class SwitchCfg(_Cfg):
|
|
234
|
+
"""Automatic switchover valve (Solvis side): heat pump charges the water section."""
|
|
235
|
+
|
|
236
|
+
hyst_on: float = 2 # on when tb_water < water_setpoint - hyst_on
|
|
237
|
+
hyst_off: float = 2 # off when tb_water >= water_setpoint + hyst_off
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class SensorCfg(_Cfg):
|
|
242
|
+
"""Sensor imperfections."""
|
|
243
|
+
|
|
244
|
+
noise: float = 0.05 # σ of temperature noise
|
|
245
|
+
tau_pipe: float = 60 # lag of the t_in / t_out thermometers (in wells)
|
|
246
|
+
res_temp: float = 0.1
|
|
247
|
+
res_flow: float = 0.1
|
|
248
|
+
res_power: float = 0.01
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class PlantCfg(_Cfg):
|
|
253
|
+
"""The whole plant."""
|
|
254
|
+
|
|
255
|
+
buffer: BufferCfg = field(default_factory=BufferCfg)
|
|
256
|
+
heat_pump: HeatPumpCfg = field(default_factory=HeatPumpCfg)
|
|
257
|
+
pump: PumpCfg = field(default_factory=PumpCfg)
|
|
258
|
+
heating: HeatingCfg = field(default_factory=HeatingCfg)
|
|
259
|
+
dhw: DhwCfg = field(default_factory=DhwCfg)
|
|
260
|
+
pellet: PelletCfg = field(default_factory=PelletCfg)
|
|
261
|
+
weather: WeatherCfg = field(default_factory=WeatherCfg)
|
|
262
|
+
switch: SwitchCfg = field(default_factory=SwitchCfg)
|
|
263
|
+
sensor: SensorCfg = field(default_factory=SensorCfg)
|
|
264
|
+
max_dt: float = 10 # internal step limit
|
|
265
|
+
day_offset: float = 0 # seconds from local midnight at t=0
|