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.
@@ -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