"""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)