"""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. None means "follow max_w", which is the # default and the recommended setting. # # ⚠️ DO NOT RAISE THIS ABOVE max_w without a measurement to justify it. # Every watt of integrator above the rail is a watt of wind that has to be # burned off before the command can start moving the other way, i.e. extra # cycles of discharge into an already-exporting meter after every # saturation event. Measured on the closed-loop sim, 4000 W load dropped to # 0: at integrator_max_w == max_w the command is 1000 W two cycles later; at # 1.5x max_w it is 1800 W. The output clamp already bounds what reaches the # wire, so headroom here buys nothing but unwind latency. # # It is a separate key because it has to be able to be SMALLER than max_w, # which is the only direction that buys anything: it caps unwind latency # below what the rail implies. Merging it into max_w would take that away. integrator_max_w: float | None = None # 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, before the output clamp, the slew limit # and quantisation. Carry it back in as `i_w` next cycle; that is what keeps # it a separate quantity from the command. 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). This counts CYCLES, and a cycle is not a unit of # time: run_control() calls cycle() only when the meter value CHANGES # (`if self.grid != last_grid`), so three cycles is three distinct meter # readings and nothing more. At the reference P1's ~5 s update rate that is # usually ~15 s, but there is no upper bound on it - a meter that repeats a # value stalls the counter. # # That is a detection-latency limit, not a windup hazard: the same # condition that stalls the counter stalls the whole loop, so nothing # accumulates in the meantime either. If a wall-clock window is ever # required, it belongs in Controller (which has a clock) and not here. # ponytail: this function is worth keeping clockless; the ceiling is that # saturation_cycles cannot express a guaranteed number of seconds. 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 # "the integrator" and "the output" were one variable and could not be # bounded apart. `i_w` is that accumulator made explicit; main.py carries it # between cycles, which is what turns the two clamps into two limits. # # Passing i_w=None re-seeds it from the last command every cycle. With # integrator_max_w following max_w that reduces this function to the exact # velocity form it replaced, frozen branch included - asserted by an # exhaustive comparison against a transcription of the old law in # test_control.py, not by inspection. Break either the gate or the bound # below and that test is what tells you the equivalence went with it. if i_w is None: i_w = float(prev_w) limit = tuning.max_w if tuning.integrator_max_w is None else tuning.integrator_max_w if abs(error) < tuning.deadband_w: reason = "deadband" else: moved = i_w + tuning.gain * error # ⚠️ Freeze means "may not wind FURTHER in the direction it is already # pushing". It may fall, cross zero, or reverse outright. # # It must NOT be encoded as "only corrections that shrink |i_w|": that # is unsatisfiable for BOTH signs of error whenever the correction is # larger than twice the integrator, i.e. every time the integrator is # near zero. The loop then sits at its last value forever, because what # clears the freeze is the inverter tracking again and not-tracking is # the definition of saturation. Measured on that encoding: 0 W held # indefinitely into a 2 kW import, where this form recovers next cycle. # # This is the same asymmetric rule the output freeze uses below, which # has been in service on real hardware. It is applied here as well # because the requirement is that the INTEGRATOR stop accumulating, not # only the command. if not frozen: i_w = moved else: i_w = min(moved, i_w) if i_w > 0 else max(moved, i_w) # ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this # bound is not. It is what makes the worst-case unwind time finite and # knowable instead of a function of how long the error happened to stand. i_w = max(-limit, min(limit, 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