checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the tests and the instruments' arithmetic are untouched; every number below was re-measured against the committed 16-day capture before it was written down. R1 - app/control.py carried a justification the decision record and MUTATION.md habit 22 had already retracted, and it was false about the code it sits in. It said an unconditional placement "would also pull the integrator toward a command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The 118 was measured against a different pair; on the pair the sentence names the two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow form that shipped DOES move a saturation-frozen integrator: it runs before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap changes `i_w` on 1095, and on 679 of those it moves it further out on the side it was already on - putting back magnitude the freeze had just let fall, which the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at -176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal (the freeze clipped on 75 512 ticks, the slew bound on none of them), and that is stated too. It is also where SAFETY-13's settle values went: gating the cap on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part 4(b) does not come back - that one moved for the winding reason. R3 - the record read its own sensitivity table backwards. `fit_plant()` picks lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while the tool's own printed fidelity measure (export against the recorded 29.43 kWh) prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s release/1.0's worst quarter is ALREADY 3434 W and this card changes it by exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a carries the table; the export reduction now leads every summary and the peak is presented as lag-dependent. The form-A rejection is strengthened rather than weakened: form A is worse on the billed quarter at every lag where release/1.0 produces a peak at all, and at every top-N cut of the quarters. R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads with the export reduction, says the figures are simulated, and says plainly that no capacity-tariff improvement is promised. R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and at the slowest slews it is slightly WORSE" while printing a monotonically falling column. That sentence is TRUE on release/1.0 (reproduced: 958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law (777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out of the law it imported and narrates from the column it just printed. Both branches of that narration were driven. R5 - three counts in the L table were decoration and are re-driven in the CI image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the function names exchanged and 5+4 with the branch bodies exchanged (two mutations wearing one name, now listed separately), L6 is 6+3. The separable- state count is grid-dependent and three sweeps disagree, so all three are quoted with their grids and the load-bearing claim is WHERE they separate. R7 - "a large import answered too weakly" is not what the aggregate shows. Form A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers 8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm is a tail redistribution inside quarters that already net to offtake. The conclusion stands; the sentence is narrowed to it. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
781 lines
44 KiB
Python
781 lines
44 KiB
Python
"""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.
|
|
"""
|
|
|
|
import math
|
|
from dataclasses import dataclass
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SAFETY-39. THE ONE RELATIONSHIP IN THIS FILE THAT IS NOT A NUMBER.
|
|
#
|
|
# ⚠️ WHAT WENT WRONG, because the shape of it is the reason this is a ratio and
|
|
# not a constant. `slew_w: 1000` was derived on 2026-08-22 against a clamp of
|
|
# 1500 W (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"),
|
|
# and its stated justification was that it "puts most of the correction in the
|
|
# first cycle WHILE STILL CAPPING HOW FAR THE LOOP CAN JUMP AHEAD OF THE
|
|
# INVERTER". At 1500 W that sentence was true: 1000 W/cycle reaches the rail in
|
|
# two cycles and the rail itself bounds the excursion.
|
|
#
|
|
# The rail then moved to 5000 W on the live install and the constant did not.
|
|
# 1000 W per 2 s tick is 500 W/s, so the loop traverses its WHOLE OUTPUT RANGE
|
|
# in 10 s against a plant NOTES.md §5.10 measured at ~18 s up / ~23 s down for a
|
|
# 1.78 kW step. It commands its entire range before it can observe its answer to
|
|
# the first step. Measured on the owner's system 2026-09-05 11:43-11:46: a real
|
|
# 3.4 kW export, the setpoint ramping 380 -> 5000 in five ticks, the battery at
|
|
# -5154 W against a 5000 VA inverter, and the grid pushed from 3.4 kW of EXPORT
|
|
# to 2.8 kW of IMPORT - the loop manufactured the error it then chased.
|
|
#
|
|
# ⚠️ SO THE FIX IS NOT A NEW NUMBER. A number is what went stale. What is
|
|
# written here is the RELATIONSHIP the 2026-08-22 sentence was really asserting:
|
|
#
|
|
# THE LOOP MUST NOT TRAVERSE ITS FULL OUTPUT RANGE FASTER THAN THE PLANT
|
|
# TAKES TO SETTLE.
|
|
#
|
|
# slew_w <= max_w * LOOP_TICK_S / PLANT_SETTLE_S
|
|
#
|
|
# `max_w` here is the SITE rail (main.py builds `Tuning(max_w=site_max_w)`, the
|
|
# roster sum), so the bound moves with the roster and a second inverter cannot
|
|
# re-open this by arriving. validate.py rule 10 refuses a configuration that
|
|
# breaks it; `slew_w = 0` below means "follow the rail" and is the shipped
|
|
# default, exactly as `integrator_max_w = None` already means "follow max_w".
|
|
#
|
|
# ⚠️ BOTH NUMBERS ARE WRITTEN OUT RATHER THAN THEIR QUOTIENT, AND THAT IS NOT
|
|
# TIDINESS. The quotient is only meaningful in the regime the two were measured
|
|
# in, and one of them is NOT a property of this code:
|
|
#
|
|
# PLANT_SETTLE_S is the inverter and the pack. goodwe/NOTES.md §5.10,
|
|
# 2026-08-22, a 1.78 kW kettle step driven from HA with a
|
|
# plug that meters its own draw: ~18 s from the step to the
|
|
# deadband going up, ~23 s coming down. 18 is taken because
|
|
# it is the SHORTER of the two and therefore the LOOSER
|
|
# bound - this is a refusal boundary, and rules 5 and 6 in
|
|
# validate.py refuse the impossible case only, never "close
|
|
# to the rail".
|
|
#
|
|
# ⚠️ AND THAT CHOICE IS LOCAL TO THIS BOUND. §5.10 MEASURED THE STEP IN BOTH
|
|
# DIRECTIONS, so "the plant's settling time" is a PAIR and not a number, and
|
|
# which half is the safe one depends on which way being wrong hurts. Taking
|
|
# 18 for a REFUSAL is right: the looser number refuses less. Taking 18 for a
|
|
# question of the form "how long does the plant remain a candidate
|
|
# explanation" is WRONG, because the looser number stops believing the plant
|
|
# too early - and there the answer is a false accusation. SAFETY-13 asks that
|
|
# second question and so reads PLANT_SETTLE_DOWN_S below, deliberately not
|
|
# this constant. Anything else added here has to say which of the two it
|
|
# means and why.
|
|
#
|
|
# LOOP_TICK_S is the METER, not this loop. `run_control()` runs a cycle on
|
|
# a changed meter value or on `cycle_floor_s`, whichever comes
|
|
# first, so the tick is the meter's own update rate. 2.0 s is
|
|
# what the live install measured on 2026-09-05 (the setpoint
|
|
# stepped +1000 W every 2 s), and it is the fastest meter this
|
|
# project has ever run.
|
|
#
|
|
# ⚠️ AND THIS ONE IS NOT WRONG IN THE SAFE DIRECTION, WHICH IS WHY IT IS SAID
|
|
# HERE. A SLOWER meter makes this bound conservative - it refuses some
|
|
# configurations that would have been fine, which is visible immediately and
|
|
# costs one edit. A meter FASTER than 2 s makes it loose by exactly that ratio,
|
|
# and nothing in this add-on would say so. The tick cannot go below 1 s
|
|
# (`run_control()`'s `await asyncio.sleep(1)` bounds the loop body), so the
|
|
# worst case is a factor of two - but if a faster meter is ever fitted, this
|
|
# constant has to be RE-DERIVED and not merely re-read. Compare
|
|
# validate.FIRMWARE_INGEST_VETO_S, which carries the same kind of copy and can
|
|
# only ever be wrong the other way.
|
|
PLANT_SETTLE_S = 18.0
|
|
LOOP_TICK_S = 2.0
|
|
SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S # 0.111..., i.e. 9 ticks
|
|
|
|
# The other half of the same measurement: the descent of the same 1.78 kW step,
|
|
# ~23 s from a -1784 W export excursion back into the deadband (goodwe/NOTES.md
|
|
# §5.10, the same run, the same day, the same plug). It is slower than the ramp
|
|
# up, which is the ordinary asymmetry of a pack that is pushed one way and
|
|
# coasts the other. Nothing above this line reads it; SAFETY-13 below does.
|
|
PLANT_SETTLE_DOWN_S = 23.0
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SAFETY-13. HOW LONG THE PLANT STAYS A CANDIDATE EXPLANATION.
|
|
#
|
|
# The output freeze below stops the command growing while the inverter is not
|
|
# following. Two completely different things produce that divergence and the
|
|
# freeze cannot tell them apart on magnitude alone:
|
|
#
|
|
# (a) THE PLANT HAS NOT CAUGHT UP YET, or it is at a limit - a cold pack, a
|
|
# curtailment, an export cap. Asking for more is pointless, and the
|
|
# freeze is right to hold. It also CLEARS ITSELF: the command is pinned
|
|
# at something the inverter can do, so the inverter catches up to it and
|
|
# `saturated_now` goes false.
|
|
#
|
|
# ⚠️ AND (a) INCLUDES THE CASE `unasked_power()` BELOW CANNOT SEE AS (a):
|
|
# the command has fallen THROUGH ZERO and the pack is still coasting in
|
|
# the old direction, so the reading has the sign the command used to have
|
|
# and not the one it has now. That is this loop's own power, arriving
|
|
# late. It is the reason the time bound is what does the work here and
|
|
# the predicate alone is not enough.
|
|
#
|
|
# (b) SOMETHING ELSE IS DRIVING THE ACTUATOR - the §1.5 non-goal, two writers
|
|
# on one actuator. The freeze CANNOT clear itself here, because clearing
|
|
# it needs the inverter to track OUR command and the other writer is what
|
|
# stops it. Nothing ends this but the other writer going away.
|
|
#
|
|
# ⚠️ SO THE BOUND IS ONLY EVER A REASON TO STOP BELIEVING (a). It does not
|
|
# establish (b) and nothing below claims it does: what it says is that the
|
|
# plant has run out of alibi, and (b) is then the only explanation this loop
|
|
# has left - not the only one there is. Everything downstream of the release is
|
|
# written to be safe when that inference is wrong, because sometimes it is.
|
|
#
|
|
# Case (a) is bounded by the plant, and the plant was measured:
|
|
#
|
|
# SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S)
|
|
#
|
|
# ⚠️ THE **DOWN** NUMBER, AND THE DIRECTION IS THE WHOLE POINT. §5.10 measured
|
|
# the same step both ways - 18 s up, 23 s down - and SAFETY-39 takes 18 because
|
|
# 18 is the LOOSER bound for a REFUSAL. This is the opposite job. Here the
|
|
# question is "how long may the plant still be the explanation", and the
|
|
# conservative answer is the LONGER of the two: believe the plant for as long
|
|
# as the plant has ever been observed to take. Deriving this from 18 gives 9,
|
|
# which is three cycles SHORT of the plant's own measured down-ramp - and being
|
|
# short here does not refuse anything, it ACCUSES. Measured, single inverter,
|
|
# no second writer anywhere, 1280 closed-loop scenarios: at 9 the exit fires in
|
|
# 113 of them and at 12 in 24. The direction of that error is why the pair is
|
|
# now two named constants instead of one.
|
|
#
|
|
# ⚠️ AND IT IS NO LONGER THE RECIPROCAL OF SLEW_RAIL_FRAC. It was, at 9, and
|
|
# that coincidence read as a derivation - two different questions that happened
|
|
# to share an answer while both were being asked of the same half of a two-
|
|
# sided measurement. They are 9 and 12 now. If they ever coincide again it is
|
|
# arithmetic, not agreement.
|
|
#
|
|
# ⚠️ IT INHERITS SAFETY-39's CAVEAT ABOUT LOOP_TICK_S, in the opposite
|
|
# direction, which is why it is repeated rather than cross-referenced. A meter
|
|
# SLOWER than 2 s makes these 12 cycles cover MORE than 23 s, so the freeze
|
|
# holds longer than the plant needs: that costs energy - see the case in
|
|
# DOCS.md - and never safety. A meter FASTER than 2 s makes them cover LESS,
|
|
# so the freeze can release while the plant is genuinely still moving. That one
|
|
# is bounded twice over and by neither this constant nor luck: the INTEGRATOR
|
|
# freeze is not released with the output freeze, so the command can only reach
|
|
# the integrator's frozen value, and the output clamp bounds that in turn.
|
|
#
|
|
# ⚠️ AND A PLANT SLOWER THAN THE ONE THAT WAS MEASURED DOES THE SAME THING,
|
|
# which no constant derived from a measurement can fix. In the same 1280
|
|
# scenarios, holding the bound at 12 and lengthening only the plant's down-ramp
|
|
# past the measured 23 s, the exit starts firing again on a single inverter:
|
|
# 8/320 at 23 s, 59/320 at 34 s, 268/320 at 50 s. A re-measured plant means a
|
|
# re-derived constant here, exactly as for the two above it.
|
|
SETTLE_CYCLES = math.ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) # 12
|
|
|
|
# The saturation counter's ceiling, one above the longest bound read from it. A
|
|
# counter capped BELOW that bound would silently make the bound unreachable -
|
|
# i.e. delete the exit condition - so it is written against SETTLE_CYCLES and
|
|
# not as a literal, and it moves when SETTLE_CYCLES moves.
|
|
#
|
|
# ⚠️ IT WAS 10, WHICH IS ALSO config.yaml's MAXIMUM FOR `saturation_cycles`,
|
|
# AND THAT COLLISION IS GONE. At a bound of 9 the largest accepted
|
|
# `saturation_cycles` (10) sat ABOVE the exit, so a legal configuration could
|
|
# push the freeze's engagement past its own release and the no-start block
|
|
# stopped existing. At 12 every accepted value 1..10 is strictly below the
|
|
# bound, so the freeze always engages before its exit is due and no accepted
|
|
# configuration can delete the block. `test_control.py` pins that at the schema
|
|
# edge rather than in prose, because it is a property of two files agreeing.
|
|
_SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SAFETY-48. HOW FAR THE INTEGRATOR MAY STAND AHEAD OF THE COMMAND.
|
|
#
|
|
# ⚠️ THE MISSING CASE, AND IT IS A CASE AND NOT A CONSTANT. The freeze below
|
|
# stops the integrator accumulating, and the comment beside it says what the
|
|
# requirement is: "the INTEGRATOR stop accumulating, not only the command". But
|
|
# `frozen` is computed from ONE signal - `abs(prev_w - actual_w) >
|
|
# saturation_w`, the inverter not tracking - and SLEW-LIMITING NEVER ENTERS IT.
|
|
# When the command hits the step ceiling, the integrator goes on winding at
|
|
# `gain * error` until it reaches `integrator_max_w`.
|
|
#
|
|
# The arithmetic, and it is the whole card:
|
|
#
|
|
# the integrator moves gain * error per cycle
|
|
# the command moves at most slew per cycle
|
|
# so the integrator outruns the command whenever
|
|
#
|
|
# |error| > slew / gain
|
|
#
|
|
# At the live install's shape (gain 0.6, slew_w 400) that is 667 W. The median
|
|
# grid error at the onset of a recorded episode is 1114 W. This is why
|
|
# SAFETY-39's `slew_w` retune did not fix it: a smaller step makes the
|
|
# threshold LOWER, so the case fires more often, not less.
|
|
#
|
|
# ⚠️ WHAT IS BOUNDED IS THE LEAD, NOT THE INTEGRATOR. Two forms were measured
|
|
# over 16.0 days of the live install's own recorded disturbances
|
|
# (`tools/replay_recorded_loop.py`, and DECISIONS/SAFETY-48 has the table):
|
|
#
|
|
# (a) reuse the asymmetric freeze on a carried slew flag - "may not wind
|
|
# further in the direction it is already pushing", exactly as saturation
|
|
# does. It cuts export 13 % AND RAISES THE BILLED QUANTITY: the worst
|
|
# quarter-hour average OFFTAKE goes 3739 -> 3935 W and the mean quarter
|
|
# moves +2.7 W, in five of six plant and tuning variants, and on the
|
|
# billed quarter it is worse at every plant lag where release/1.0
|
|
# produces a peak at all (a tie at lag 0, worse at 2.5/5/7.5/10 s)
|
|
# and at every top-N cut of the quarters.
|
|
#
|
|
# ⚠️ AND THE MECHANISM IS A REDISTRIBUTION, NOT A WEAKER ANSWER. An
|
|
# earlier draft of this line said a frozen integrator undershoots by
|
|
# (1 - gain) "so a large import is answered too weakly". The aggregate
|
|
# does not show that: form A cuts total import by 5.45 kWh, and paired
|
|
# tick by tick against release/1.0 it delivers 8.56 kWh LESS offtake
|
|
# energy on the ticks release/1.0 was importing. What it moves is the
|
|
# TAIL - within quarters that already net to offtake, the worst of them
|
|
# gets worse - and the capacity tariff bills the worst quarter and
|
|
# nothing else. That is the wrong trade here and the reason this is not
|
|
# the fix that ships.
|
|
#
|
|
# (b) cap how far the integrator may LEAD the command, one slew step. Export
|
|
# 39.6 -> 34.1 kWh, wasted throughput 4.7 -> 3.2 kWh, sign reversals
|
|
# 2992 -> 2770, worst quarter-hour offtake 3739 -> 3434 W at the fitted
|
|
# plant lag (⚠️ AND AT NO SHORTER ONE: at every lag <= 5.0 s release/1.0
|
|
# is already at 3434 W and this changes it by zero - DECISIONS/SAFETY-48
|
|
# §3a), median recovery 232 -> 199 s. It is what ships.
|
|
#
|
|
# ⚠️ AND THE CAP IS NOT ZERO. `i_w = slewed` is full back-calculation, and this
|
|
# module's own docstring says what that costs: passing `i_w=None` "reduces this
|
|
# function to the exact velocity form it replaced". A zero cap makes `i_w`
|
|
# identically the command on every cycle, which is that same collapse written
|
|
# from the other end - it measures 32.0 kWh, better than (b) by 2 kWh, and it
|
|
# throws away the reason the accumulator was split out at all. One step is the
|
|
# smallest cap that is not that.
|
|
#
|
|
# WHAT THE NUMBER MEANS: the command can cover a one-step lead in ONE cycle, so
|
|
# the worst-case unwind after a reversal is one cycle instead of
|
|
# `integrator_max_w / slew_w` - 12.5 cycles at the live shape, ~60 s at the
|
|
# meter's measured 5.0 s tick.
|
|
#
|
|
# ⚠️ IT IS IN STEPS RATHER THAN IN WATTS FOR SAFETY-39's REASON, WHICH IS THE
|
|
# REASON THIS FILE KEEPS RE-LEARNING. A watt figure is a constant that outlives
|
|
# the `slew_w` it was derived against; expressed in steps it moves with
|
|
# `slew_limit_w()` and therefore with the rail.
|
|
SLEW_LEAD_STEPS = 1
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Tuning:
|
|
gain: float = 0.6
|
|
max_w: float = 2000.0
|
|
# SAFETY-39. How far the command may move in one cycle. ⚠️ 0 MEANS "FOLLOW
|
|
# THE RAIL" - `SLEW_RAIL_FRAC * max_w` - and it is the default and the
|
|
# recommended setting, for the reason written at the top of this file: a
|
|
# fixed number here is a constant that outlives the rail it was derived
|
|
# against, and that is the defect measured on the live system on
|
|
# 2026-09-05. Read `slew_limit_w()`, never this field.
|
|
#
|
|
# It stays settable, because slower than the rail implies is a legitimate
|
|
# and sometimes necessary choice - the live install has run an explicit
|
|
# 400 W since 2026-09-05 12:00 - and because `slew_w` is an option an
|
|
# operator already has. FASTER than the rail implies is what validate.py
|
|
# rule 10 refuses.
|
|
slew_w: float = 0.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
|
|
# SAFETY-03. Did a clamp actually engage this cycle - i.e. did the RAW
|
|
# computed value differ from the bounded one?
|
|
#
|
|
# ⚠️ THESE EXIST BECAUSE `reason` CANNOT ANSWER THAT QUESTION. `reason` is
|
|
# one string and every later stage overwrites it: a cycle that clamps the
|
|
# integrator and is then slew limited reports "slew-limited", and one that
|
|
# clamps while saturated reports "saturated-freeze". Both ARE the winding
|
|
# case, and on the string the clamp signal is gone before anything can alarm
|
|
# on it. These two are written where the clamp happens and are never
|
|
# overwritten, so they mean exactly what they say and nothing else.
|
|
#
|
|
# `i_clamped` is the one that fires on a default install: the integrator
|
|
# bound is reached first, so `clamped` is unreachable unless
|
|
# `integrator_max_w` is deliberately set above `max_w`. Alarm on BOTH - see
|
|
# the comment at the bound below, and clamp.py.
|
|
i_clamped: bool = False
|
|
clamped: bool = False
|
|
|
|
|
|
def slew_limit_w(tuning: Tuning) -> float:
|
|
"""How far the command may move this cycle, in W. Always positive.
|
|
|
|
⚠️ THE ONE READER OF `Tuning.slew_w`, and everything else must come through
|
|
here. `slew_w = 0` means "follow the rail" and resolves to
|
|
`SLEW_RAIL_FRAC * max_w`, which is the whole of SAFETY-39: the limit is a
|
|
RATIO to the rail, so raising `max_w` moves it and cannot leave it behind.
|
|
A caller that read the field directly would see 0 and apply no limit at all
|
|
- the exact opposite of the setting's meaning - which is why this exists as
|
|
a function rather than as a comment telling people to remember.
|
|
|
|
⚠️ AND IT IS NOT CLAMPED TO THE BOUND. An explicit `slew_w` above the rail
|
|
fraction is refused at STARTUP by validate.py rule 10, not silently reduced
|
|
here. Silently reducing it is the failure this whole module's docstring is
|
|
about - an operator's stated number quietly meaning something else - and
|
|
validate.py's own docstring calls it case (a).
|
|
"""
|
|
if tuning.slew_w:
|
|
return abs(float(tuning.slew_w))
|
|
return SLEW_RAIL_FRAC * abs(float(tuning.max_w))
|
|
|
|
|
|
def unasked_power(prev_w: float, actual_w: float, saturation_w: float) -> bool:
|
|
"""True when `actual_w` cannot be explained by THIS cycle's command not being met.
|
|
|
|
SAFETY-13. The one question the saturation magnitude test cannot answer:
|
|
is the inverter FAILING TO REACH what we asked, or is it doing something
|
|
the command in front of it does not account for?
|
|
|
|
⚠️ THE ANSWER IS PHYSICAL, NOT STATISTICAL, WHICH IS WHY IT IS A PREDICATE
|
|
AND NOT A THRESHOLD. A limit can only make the inverter do LESS than the
|
|
command, in the command's own direction. It cannot make it overshoot the
|
|
command, and it cannot make it produce power of the OPPOSITE sign. So the
|
|
whole set of readings a limit can produce is the closed interval spanned by
|
|
0 and `prev_w`, widened by `saturation_w` for measurement noise, ramp lag
|
|
and the inverter's own idle draw.
|
|
|
|
⚠️ WHAT IT DOES NOT SAY - AND AN EARLIER VERSION OF THIS DOCSTRING DID -
|
|
IS THAT ANYTHING OUTSIDE THE INTERVAL WAS PUT THERE BY SOMETHING OTHER THAN
|
|
THIS LOOP. THAT IS FALSE AND IT WAS MEASURED FALSE. This predicate has no
|
|
memory: `prev_w` is one cycle's command, while the plant carries every
|
|
command before it. Drive ONE inverter, with nothing else on the bus, from a
|
|
discharge down through zero, and the pack is still coasting in the old
|
|
direction after the command has crossed. Measured in the container, one
|
|
`compute()` against one plant: eight cycles after a +1990 W command the
|
|
command is 0 and the pack still reads +522.3 W, so
|
|
`unasked_power(0, 522.3, 500)` is True - and every watt of that 522.3 was
|
|
asked for by this loop. In 1280 single-inverter closed-loop scenarios with
|
|
no second writer in them at all, the exit built on this predicate fires in
|
|
24 of them.
|
|
|
|
So what it guarantees is exactly the sentence at the top: this cycle's
|
|
command does not account for this cycle's reading. What it CANNOT do is
|
|
tell "somebody else is writing" from "we are". That gap is the reason
|
|
SETTLE_CYCLES is derived from the plant's slower direction, why the caller
|
|
checks both, and why the reason string it produces is a place to start
|
|
looking rather than a finding.
|
|
|
|
Worked, at `saturation_w = 500`:
|
|
|
|
prev +2000, actual +700 -> inside [0, 2000] a limit. False.
|
|
prev -5000, actual -700 -> inside [-5000, 0] a limit. False.
|
|
prev 0, actual +700 -> outside [-500, 500] unasked. True.
|
|
prev -800, actual +700 -> outside [-1300, 500] unasked. True.
|
|
|
|
⚠️ AT `prev_w == 0` THIS IS EXACTLY `saturated_now`, and that is not a
|
|
coincidence to be tidied away - it is the reason the freeze's zero case is
|
|
the one the card was written about. With nothing commanded there is no
|
|
interval left for a LIMIT to live in, so at zero this test cannot be
|
|
answered by "the inverter is not reaching what we asked" at all. It can
|
|
still be answered by "the inverter has not finished leaving what we asked
|
|
for LAST time", which is the coast-down above and is why zero needs the
|
|
time bound as much as any other `prev_w` does.
|
|
|
|
⚠️ IT IS NOT A DETECTOR AND MUST NOT BE ALARMED ON. A saturated inverter
|
|
whose reading is stale, wrong-signed or simply noisy lands outside the
|
|
interval too, and so does our own ramp, above. All this predicate decides
|
|
is whether the output freeze gets an EXIT; being wrong about it costs at
|
|
most a bounded command that the frozen integrator and the output clamp both
|
|
still bound. SAFETY-30's `unit_sign_disagree_w` is the thing that alarms on
|
|
sign, and it is deliberately a different test with a different threshold.
|
|
"""
|
|
lo = min(0.0, float(prev_w)) - abs(float(saturation_w))
|
|
hi = max(0.0, float(prev_w)) + abs(float(saturation_w))
|
|
return not (lo <= float(actual_w) <= hi)
|
|
|
|
|
|
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 still not a unit
|
|
# of time - but it is now a BOUNDED one. run_control() runs a cycle on a
|
|
# changed meter value OR on `cycle_floor_s` elapsing, whichever comes first
|
|
# (SAFETY-12), so three cycles is at most 3 x cycle_floor_s = 45 s at the
|
|
# defaults, and usually ~15 s at the reference P1's ~5 s update rate.
|
|
#
|
|
# ⚠️ THIS COMMENT USED TO CLAIM there was no upper bound and that it did not
|
|
# matter, "because the same condition that stalls the counter stalls the
|
|
# whole loop, so nothing accumulates in the meantime". The first clause was
|
|
# true and the second was the bug: a stalled loop does not stop commanding,
|
|
# it goes on emitting its last command through the heartbeat. Windup was
|
|
# never the hazard; staleness was.
|
|
#
|
|
# The wall-clock window lives in Controller, which has a clock, exactly as
|
|
# this comment always said it should. ponytail: this function stays
|
|
# clockless; the remaining ceiling is that saturation_cycles expresses a
|
|
# bound in seconds rather than an exact number of them.
|
|
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
|
sat_count = min(sat_count + 1, _SAT_COUNT_CAP) 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.
|
|
#
|
|
# ⚠️ EXACTLY ZERO IS ITS OWN CASE, and it must be handled explicitly
|
|
# rather than falling into one of the two branches. "May not wind
|
|
# further in the direction it is already pushing" has no referent at
|
|
# zero: nothing is wound, and neither direction is "further". Writing
|
|
# this as `if i_w > 0 ... else ...` silently files zero under
|
|
# rising-only and permanently blocks the first push toward charging -
|
|
# the same deadlock as the shrink-only encoding above, mirrored in sign,
|
|
# and reachable because main.py resets i_w to exactly 0.0 on every stop
|
|
# and every reseed. Measured before the fix: 12 800 of 25 920 frozen
|
|
# ticks at i_w == 0.0 held the integrator, 8 304 of them changing the
|
|
# emitted command, worst case abandoning a 2 kW charge into a 4 kW
|
|
# export.
|
|
#
|
|
# Freezing at zero would also be pointless: the freeze exists to stop
|
|
# accumulation running away, and a first step from zero is bounded by
|
|
# the gain, the output clamp and the slew limit like any other.
|
|
if not frozen or i_w == 0.0:
|
|
i_w = moved
|
|
elif i_w > 0:
|
|
i_w = min(moved, i_w)
|
|
else:
|
|
i_w = 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.
|
|
bounded = max(-limit, min(limit, i_w))
|
|
i_clamped = bounded != i_w
|
|
if i_clamped:
|
|
# ⚠️ SAFETY-03 (alarm whenever the loop winds into a rail) must watch
|
|
# for THIS, not for "clamped" below. At the default limit == max_w the
|
|
# integrator bound is reached first and the command derived from it can
|
|
# then never exceed max_w, so "clamped" is unreachable on a default
|
|
# install - it survives only for a configuration that deliberately lets
|
|
# the integrator run above the rail. Two reasons rather than one
|
|
# because the two events want different alarms: "i-clamped" is the loop
|
|
# winding, "clamped" is a command that came out over the rating anyway.
|
|
reason = "i-clamped"
|
|
i_w = bounded
|
|
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))
|
|
clamped = target != want
|
|
if clamped:
|
|
reason = "clamped"
|
|
|
|
slew = slew_limit_w(tuning)
|
|
slewed = max(prev_w - slew, min(prev_w + slew, target))
|
|
if slewed != target:
|
|
reason = "slew-limited"
|
|
# --- SAFETY-48: the step ceiling reaches the integrator too ---------
|
|
# ⚠️ INSIDE THIS BRANCH AND NOWHERE ELSE - AND THE REASON IS NOT THE
|
|
# SATURATION FREEZE. An earlier draft of this comment said that applied
|
|
# unconditionally the cap "would also pull the integrator toward a
|
|
# command the SATURATION freeze is pinning", and priced that at 118 of
|
|
# 256 250 ticks. Both halves were invented, and MUTATION.md habit 22 is
|
|
# what fell out of it. The 118 had been measured against a DIFFERENT
|
|
# pair - a band applied after quantisation, at the very end of
|
|
# `compute()`. On the pair the sentence actually names - this same cap
|
|
# moved out of this branch to just after `target = slewed` - the two
|
|
# forms are BIT-IDENTICAL on the shipped configuration: 0 of 256 246
|
|
# ticks differ over the 16-day recording. They separate only where
|
|
# `integrator_max_w` is deliberately set ABOVE `max_w`, the one
|
|
# configuration this file says can reach its `clamped` reason; there
|
|
# the integrator is meant to run above the rail and an unconditional
|
|
# cap would drag it back to the command every cycle and make the option
|
|
# inert. THAT is why the cap stays in the branch, and
|
|
# `test_telemetry.py` part 4 drives it. (The separable-state COUNT is a
|
|
# property of whatever grid was swept - 2050 of 257 400 on this card's,
|
|
# 1220 of 246 064 on the review's, 776 of 40 320 on a third - so the
|
|
# claim to carry forward is WHERE they separate, not how many.)
|
|
#
|
|
# ⚠️ AND THE HALF A READER WILL OTHERWISE ASSUME AWAY: THIS CAP DOES
|
|
# MOVE A SATURATION-FROZEN INTEGRATOR. It runs BEFORE the freeze block
|
|
# below and is not gated on `frozen`. Measured over the same recording
|
|
# at the live shape: of 79 160 frozen ticks the cap changed `i_w` on
|
|
# 1095 of them, and on 679 of those it moved it FURTHER OUT on the side
|
|
# it was already on - putting back magnitude the freeze had just let
|
|
# fall, which is the one direction the freeze's own rule ("may fall,
|
|
# never rise") exists to forbid. Worst case 2424 W, and the worked tick
|
|
# is `prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3`:
|
|
# the freeze leaves the integrator at -176 W, the slew binds, and the
|
|
# cap drags it to -2600 W. It never overrides a SAME-CYCLE refusal -
|
|
# the freeze clipped `moved` on 75 512 ticks and the slew bound on none
|
|
# of them - but it does not leave the frozen path alone either.
|
|
#
|
|
# ⚠️ AND THAT IS WHERE SAFETY-13's SETTLE VALUES WENT. Gating this cap
|
|
# on `not frozen` puts `test_control.py` part 5 back at release/1.0's
|
|
# -1190 W exactly; part 4(b)'s -2390 -> -1200 W does NOT come back,
|
|
# because that one moved for the other reason - the winding BEFORE the
|
|
# freeze is bounded now. The gate is refused anyway: measured the same
|
|
# way it leaves a worst observed lead of 4961 W, i.e. no bound at all
|
|
# on the quantity this card exists to bound. DECISIONS/SAFETY-48 §5
|
|
# carries the rest of the price.
|
|
#
|
|
# ⚠️ WRITTEN ONE-SIDED, AND IT IS **EQUIVALENT** TO THE TWO-SIDED BAND
|
|
# `max(slewed - cap, min(slewed + cap, i_w))` - MEASURED, NOT REASONED.
|
|
# An earlier draft of this comment claimed the two-sided spelling was a
|
|
# bug that would drag a TRAILING integrator up toward the command. That
|
|
# is false and the sweep said so: `target` is monotone in `i_w`
|
|
# (`target = clamp(clamp(i_w, +-limit), +-max_w)`) and `slewed` is
|
|
# `target` pulled toward `prev_w`, so whenever the slew binds, `i_w`
|
|
# lies on the same side of `slewed` as `target` does and the other
|
|
# clamp cannot reach. The two spellings agree on **205 920 of 205 920**
|
|
# cases across four tunings, every sign of `prev_w`, `grid_w`,
|
|
# `actual_w` and `i_w`, and the `i_w=None` path.
|
|
#
|
|
# It stays one-sided because it SAYS which side the lead is on - the
|
|
# side the command wanted to move and could not - and because a reader
|
|
# reaching for the band spelling should find the sweep rather than an
|
|
# argument. ⚠️ The "descending" case is NOT an exception to the rule: a
|
|
# command falling from +2400 W toward an integrator at +100 W has an
|
|
# integrator 2300 W ahead of it IN THE DIRECTION OF TRAVEL, and it is
|
|
# pulled back to one step beyond the emitted command exactly as a
|
|
# rising one is.
|
|
#
|
|
# ⚠️ `i_w` IS CHANGED AND `target` IS NOT. This cycle's command is
|
|
# already decided; what is bounded is what gets CARRIED. That is also
|
|
# why the exhaustive `i_w=None` equivalence sweep in test_control.py is
|
|
# untouched by this card: it compares `(target_w, sat_count, frozen,
|
|
# reason)`, none of which this moves, and the seeded path re-seeds from
|
|
# `prev_w` next cycle anyway.
|
|
_lead_cap = SLEW_LEAD_STEPS * slew
|
|
if target > slewed:
|
|
i_w = min(i_w, slewed + _lead_cap)
|
|
elif target < slewed:
|
|
i_w = max(i_w, slewed - _lead_cap)
|
|
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.
|
|
#
|
|
# --- SAFETY-13: ...AND IT IS NOT THE MOMENT FOREVER EITHER ----------
|
|
# Everything above stands. What was missing is an END. Held at
|
|
# `prev_w == 0` this is a refusal to START, and nothing in this
|
|
# function or above it clears it - not the error growing, not the
|
|
# integrator moving, not time. Measured on release/1.0 @ c8dcb38, at
|
|
# the live install's shape (max_w 5000, slew_w 400, target_grid_w -10):
|
|
# a site resting in the deadband with another writer holding the pack
|
|
# at +700 W reaches sat_count 3 with the command at 0, and 0 W is then
|
|
# a FIXED POINT - a 2 kW export arriving afterwards leaves the command
|
|
# at exactly 0.0 W on every subsequent cycle, with the integrator
|
|
# standing at -1194 W the whole time.
|
|
#
|
|
# ⚠️ AND THE ZERO CASE IS NOT SPECIAL, WHICH IS WHY THE FIX IS NOT
|
|
# SPECIAL EITHER. The same run pinned at prev_w == -800 W, against its
|
|
# own integrator standing at -2388 W, for as long as it ran.
|
|
# `prev_w == 0` is only the instance where the pinned magnitude happens
|
|
# to be zero - i.e. where the standing cost is the whole of what the
|
|
# battery could have done.
|
|
# A rule written for the zero case alone would have left the other
|
|
# instances exactly as unbounded as it found them.
|
|
#
|
|
# ⚠️ WHAT DECIDES IS NOT HOW LONG, IT IS WHETHER THE FREEZE CAN EVER
|
|
# END ON ITS OWN. See SETTLE_CYCLES and `unasked_power()` at the top:
|
|
# a divergence a LIMIT could produce clears itself once the inverter
|
|
# catches up to the pinned command, so it needs no exit and gets none -
|
|
# unbounded, exactly as shipped. A divergence a limit CANNOT produce
|
|
# does not clear itself by being waited on, so it gets one.
|
|
#
|
|
# ⚠️ AND THAT IS NOT THE SAME SENTENCE AS "THE OTHER WRITER". An
|
|
# earlier version of this comment said the two conditions below fire
|
|
# only in case (b); they do not, and the sweep says so. Our own plant
|
|
# coasting through a command that has just crossed zero satisfies
|
|
# `unasked_power()` too, because that predicate compares against ONE
|
|
# cycle's command and the pack remembers more than one - measured, a
|
|
# single inverter with nothing else on the bus reaching this branch in
|
|
# 24 of 1280 closed-loop scenarios. `SETTLE_CYCLES` is what keeps that
|
|
# rare rather than routine (113 of the same 1280 at the old bound of
|
|
# 9), and it does it by believing the plant for as long as the plant
|
|
# has ever been measured to take. It is a bound on how long we wait,
|
|
# not a proof about what we found.
|
|
#
|
|
# ⚠️ SO THE RELEASE IS WRITTEN TO BE SURVIVABLE WHEN IT IS WRONG, and
|
|
# that is the paragraph below, not this one: what a false release costs
|
|
# is one slew step per cycle toward a frozen integrator. If it ever has
|
|
# to cost less than that, the thing to change is what happens after the
|
|
# release, not the confidence of the sentence before it.
|
|
#
|
|
# ⚠️ RELEASING IS NOT UNFREEZING. The INTEGRATOR freeze above is not
|
|
# lifted, and that is what makes this bounded rather than merely
|
|
# different: the command is free to move, but only as far as an
|
|
# integrator that stopped accumulating cycles ago, through the same
|
|
# slew limit as any other cycle. It buys back the standing error, not
|
|
# the rail.
|
|
#
|
|
# `reason` is authoritative here in a way it is not elsewhere in this
|
|
# function: nothing after this line writes it.
|
|
if unasked_power(prev_w, actual_w, tuning.saturation_w) \
|
|
and sat_count >= SETTLE_CYCLES:
|
|
reason = "freeze-released"
|
|
else:
|
|
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),
|
|
i_clamped, clamped)
|
|
|
|
|
|
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
|