first version of the boiler controlled by ebusd
This commit is contained in:
1 parent
c21291e9dd
commit
a2f40f81ba
7 files changed
+819
-2
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user