"""The grid-following control law. Pure functions on purpose: this is the part that moves real power, so it must be testable without Home Assistant, without MQTT and without an inverter. See test_control.py, and run it before shipping any change to this file. Sign convention, used everywhere in this add-on: grid > 0 = IMPORTING from the grid target > 0 = inverter should DISCHARGE target < 0 = inverter should CHARGE Every constant here was measured on real hardware, not chosen for elegance. The reasoning lives in the field guide under "Why the tuning is what it is"; the short version is in the comments below. Do not "clean this up" - each rule exists because its absence produced a specific, observed failure. """ from dataclasses import dataclass @dataclass(frozen=True) class Tuning: gain: float = 0.6 max_w: float = 2000.0 slew_w: float = 1000.0 deadband_w: float = 15.0 step_w: int = 10 saturation_w: float = 500.0 saturation_cycles: int = 3 # ⚠️ The integrator's OWN bound, and deliberately not max_w. A commercial # controller on this same site clamped only its output and still reported # 14 768 W: with the inverter switched off its integrator climbed ~130 W # every 4 s past 10 kW while the output sat on the 5 kW rail, so the moment # the error flipped there were minutes of accumulated wind to burn off # before the command moved at all. Bounding the accumulator is what makes # recovery time finite; bounding the output only hides it. # Headroom above max_w is wanted (a legitimate large error must not be # truncated at the rail), headroom without limit is the bug. integrator_max_w: float = 3000.0 # What the meter should rest at, in W. Negative = a slight export. # ⚠️ The deadband is a one-way ratchet: any resting point inside it holds # forever, and the meter's IMPORT register counts every positive one with # no export to cancel it. Resting at 0 W therefore leaks ~deadband/2 W of # billed import all day (15 W deadband ≈ 0.2-0.35 kWh). Biasing the rest # point below zero moves that leak into the export register, which is not # billed. Cost is ~|bias| W of given-away export; keep it small. target_grid_w: float = 0.0 @dataclass(frozen=True) class Decision: target_w: float sat_count: int frozen: bool reason: str # The integrator AFTER this cycle, pre-clamp-to-max_w. Carry it back in as # `i_w` next cycle; that is what keeps it a separate quantity from the # command, which is the whole point of the bound above. i_w: float = 0.0 def compute( prev_w: float, grid_w: float, actual_w: float, tuning: Tuning, sat_count: int = 0, i_w: float | None = None, ) -> Decision: """One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). `prev_w` what we last commanded `grid_w` net grid power, + = importing `actual_w` what the inverter reports it is doing, + = discharging `i_w` the carried integrator, or None to seed it from `prev_w` """ reason = "tracking" # --- saturation, WITH the duration term ------------------------------- # Command and actual diverging means the inverter cannot follow - it is at # a limit. Then the magnitude may fall but never rise, which is the # anti-windup that the vendor controller lacked: it once commanded # -14547 W against an inverter reporting -5250 W and kept climbing. # # ⚠️ The duration term is not optional. Tested instantaneously, this fires # on EVERY large correction, because the plant itself needs 3-6 s to settle # while a cycle is ~5 s. Requiring N consecutive saturated cycles is what # lets slew be larger than saturation_w. # # The spec states this window twice and differently: "> 10 s" (§11.2) and # "3 samples" (§10.3). Cycles are authoritative here because this function # has no clock - it is driven one cycle per meter update by run_control(), # which only calls cycle() when the meter value changes. At the ~5 s # HomeWizard P1 cadence the default 3 cycles is ~15 s, i.e. the stricter # reading of the two. On a faster meter it is not, so saturation_cycles is # configurable and must be raised to keep the window over 10 s. # ponytail: a seconds-based window would mean plumbing wall-clock or dt # into a pure function whose whole value is that it has neither. saturated_now = abs(prev_w - actual_w) > tuning.saturation_w sat_count = min(sat_count + 1, 10) if saturated_now else 0 frozen = sat_count >= tuning.saturation_cycles # --- deadband ---------------------------------------------------------- # Inside the meter's own noise, hold. Measured on the reference install # while regulating: mean |error| 15.4 W, max 27 W. A 10 W deadband makes # ~69 % of cycles act and the command never rests; 15 W leaves a # recognisable resting state, which is worth more than it looks - "flat for # 70 s" is how a healthy loop is recognised at a glance, and a command # frozen where it should not be is how two real bugs were caught. error = grid_w - tuning.target_grid_w # --- the integrator ---------------------------------------------------- # This loop is in velocity form: the accumulator IS the commanded power, so # for years "the integrator" and "the output" were one variable and could # not be bounded apart. `i_w` is that accumulator made explicit. A caller # that passes nothing gets the old behaviour exactly - seeded from the last # command every cycle - and main.py carries it instead, which is what turns # the two clamps below into two independent limits. if i_w is None: i_w = float(prev_w) if abs(error) < tuning.deadband_w: reason = "deadband" else: step_i = tuning.gain * error # ⚠️ Freeze means "may not wind FURTHER", not "may not move". A strict # freeze would strand the command at whatever it had reached until the # inverter started tracking again - and the inverter is not tracking, # that is what saturation means, so nothing would ever release it. The # unwind direction is the escape route and stays open; the same rule is # applied again to the output below. if not frozen or abs(i_w + step_i) < abs(i_w): i_w = i_w + step_i # ⚠️ Applied EVERY cycle, frozen or not, and before the output clamp: the # freeze is conditional, this bound is not. Order matters only in that the # command below is derived from the already-bounded integrator, so no # accumulated value can reach the wire even once. i_w = max(-tuning.integrator_max_w, min(tuning.integrator_max_w, i_w)) want = i_w # ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live # here. It now belongs to arbiter.py as limit claims, so that precedence # between strategies is decided in ONE place. This function is again what it # should be: a controller that knows only about tracking the meter. # --- ORDER MATTERS: clamp -> slew -> freeze ---------------------------- # An early draft applied a floor after the clamp and let demand escape it. target = max(-tuning.max_w, min(tuning.max_w, want)) if target != want: reason = "clamped" slewed = max(prev_w - tuning.slew_w, min(prev_w + tuning.slew_w, target)) if slewed != target: reason = "slew-limited" target = slewed if frozen: # Magnitude may fall, never rise. # ⚠️ At prev == 0 this forbids charging while saturated, because # max(t, 0) wins. That is deliberate and matches the reference # implementation: commanding 0 while the inverter reports >500 W means # something else is driving the bus, and that is not the moment to # start pushing power the other way. target = min(target, prev_w) if prev_w > 0 else max(target, prev_w) reason = "saturated-freeze" # --- quantise ---------------------------------------------------------- # 10 W. The register is 1 W and the inverter reports at 1 W, but its # response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent # with a fixed DC-side current step). So sub-17 W precision is nominal; # 10 W simply avoids a visible staircase on dashboards. step = max(1, int(tuning.step_w)) target = round(target / step) * step return Decision(float(target), sat_count, frozen, reason, float(i_w)) def maintenance_charge_floor( charge_w: float, peak_forecast_w: float | None, peak_cap_w: float, ) -> float: """How hard a maintenance charge may pull, in W (positive number). ⚠️ CAPACITY TARIFF. In Belgium (capaciteitstarief) and similar markets the bill carries the month's worst quarter-hour AVERAGE OFFTAKE. A maintenance charge is the only thing this system does that is big enough and long enough to set that peak, so it is capped by the headroom left under the site's cap. Discharge is never capped this way: export is not offtake. `peak_forecast_w is None` means the site has no capacity tariff (or no forecast sensor) - then there is nothing to protect and the configured rate is used as-is. """ if peak_forecast_w is None: return max(0.0, charge_w) headroom = max(0.0, peak_cap_w - peak_forecast_w) return max(0.0, min(charge_w, headroom)) def peak_at_risk(peak_forecast_w: float | None, peak_cap_w: float) -> bool: """True when the quarter-hour projection is already over the site's cap. ⚠️ MONEY OUTRANKS THE MAINTENANCE SCHEDULE. While charging, the battery is clamped out of discharging and therefore cannot shave a peak - and one oven during a charge phase can cost more than the whole cycle saves. When this is True the charge-only clamp is dropped and normal grid-following resumes; the charge picks up again afterwards. """ if peak_forecast_w is None: return False return peak_forecast_w > peak_cap_w