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.
- moat_dev_heat_sim-0.0.1/LICENSE.txt +14 -0
- moat_dev_heat_sim-0.0.1/Makefile +14 -0
- moat_dev_heat_sim-0.0.1/PKG-INFO +64 -0
- moat_dev_heat_sim-0.0.1/README.md +39 -0
- moat_dev_heat_sim-0.0.1/pyproject.toml +39 -0
- moat_dev_heat_sim-0.0.1/setup.cfg +4 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/__init__.py +16 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/_main.py +48 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/_util.py +61 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/buffer.py +112 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/circuits.py +129 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/config.py +265 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/heat_pump.py +237 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/link.py +143 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/loop.py +366 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/paths.py +92 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/pellet.py +147 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/plant.py +349 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/scenario.py +136 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/trace.py +149 -0
- moat_dev_heat_sim-0.0.1/src/moat/dev/heat/sim/weather.py +80 -0
- moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/PKG-INFO +64 -0
- moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/SOURCES.txt +24 -0
- moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/dependency_links.txt +1 -0
- moat_dev_heat_sim-0.0.1/src/moat_dev_heat_sim.egg-info/requires.txt +6 -0
- 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,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
|