first version of the boiler controlled by ebusd

This commit is contained in:
Pierre committed 2026-10-10 15:27:31 +02:00
1 parent c21291e9dd
commit a2f40f81ba
7 files changed
+819 -2

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
"""Weather compensation and room PI correction, expressed in water degrees."""
from dataclasses import dataclass
import math
def finite(value):
value = float(value)
if not math.isfinite(value):
raise ValueError('Temperature must be finite')
return value
def clamp(value, low, high):
return max(low, min(high, value))
@dataclass
class Regulation:
flow: float
enabled: bool
curve: float
proportional: float
integral: float
reason: str
class BoilerRegulator:
def __init__(self, *, slope=1.5, offset=10, kp=5, ki=0.2,
integral_limit=8, correction_limit=12, min_flow=30,
max_flow=50, overheat=0.5, restart_margin=0.1):
self.slope, self.offset = finite(slope), finite(offset)
self.kp, self.ki = finite(kp), finite(ki)
self.integral_limit, self.correction_limit = finite(integral_limit), finite(correction_limit)
self.min_flow, self.max_flow = finite(min_flow), finite(max_flow)
self.overheat, self.restart_margin = finite(overheat), finite(restart_margin)
if not (0 <= self.min_flow < self.max_flow <= 80):
raise ValueError('Require 0 <= min_flow < max_flow <= 80')
if min(self.slope, self.kp, self.ki, self.integral_limit, self.correction_limit) < 0:
raise ValueError('Gains and limits must be non-negative')
if not 0 <= self.restart_margin < self.overheat:
raise ValueError('Require 0 <= restart_margin < overheat')
self.integral = 0.0
self.enabled = False
def reset(self):
self.integral = 0.0
self.enabled = False
def update(self, target, indoor, outdoor, dt):
target, indoor, outdoor, dt = map(finite, (target, indoor, outdoor, dt))
error = target - indoor
curve = target + self.slope * (target - outdoor) + self.offset
proportional = self.kp * error
# ki is water °C / (room °C * hour). No accumulated downtime.
candidate = clamp(self.integral + self.ki * error * max(0, dt) / 3600,
-self.integral_limit, self.integral_limit)
raw = curve + clamp(proportional + candidate, -self.correction_limit, self.correction_limit)
# Anti-windup for BOTH the water bounds and the correction bound.
saturated_high = raw >= self.max_flow or proportional + candidate >= self.correction_limit
saturated_low = raw <= self.min_flow or proportional + candidate <= -self.correction_limit
if not ((saturated_high and error > 0) or (saturated_low and error < 0)):
self.integral = candidate
raw = curve + clamp(proportional + self.integral, -self.correction_limit, self.correction_limit)
if indoor >= target + self.overheat:
self.enabled = False
reason = 'room_overheat'
elif raw <= self.min_flow - 1:
self.enabled = False
reason = 'low_demand'
elif not self.enabled:
if indoor <= target + self.restart_margin and raw >= self.min_flow + 1:
self.enabled = True
reason = 'regulating' if self.enabled else 'waiting_for_demand'
else:
reason = 'regulating'
flow = round(clamp(raw, self.min_flow, self.max_flow) * 2) / 2
return Regulation(flow, self.enabled, curve, proportional, self.integral, reason)
def setmode(flow, enabled):
"""Full C6 SetMode payload. No new domestic hot-water setpoint."""
return dict(hcmode='auto', flowtempdesired=finite(flow) if enabled else 0,
hwctempdesired=None, hwcflowtempdesired=None,
disablehc=0 if enabled else 1, disablehwctapping=0,
disablehwcload=0, remotecontrolhcpump=0,
releasebackup=0, releasecooling=0)