87 lines
3.7 KiB
Python
87 lines
3.7 KiB
Python
"""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)
|