Compare commits
5
Commits
147456c2a2
...
SAFETY-04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680461c9bf | ||
|
|
389d9ecd6d | ||
|
|
53d301b920 | ||
|
|
7123aa00a4 | ||
|
|
e46175559b |
@@ -59,12 +59,35 @@ phase having charged nothing.
|
|||||||
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
| `target_grid_w` | -10 | What the meter should rest at. Negative = a slight export |
|
||||||
| `step_w` | 10 | Quantisation |
|
| `step_w` | 10 | Quantisation |
|
||||||
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
| `saturation_w` | 500 | Divergence that counts as "the inverter is at a limit" |
|
||||||
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. **Do not set to 1** |
|
| `saturation_cycles` | 3 | How many consecutive cycles before freezing. A cycle is one *changed* meter reading, not a fixed period - see the note below. **Do not set to 1** |
|
||||||
| `integrator_max_w` | 3000 | Bound on the loop's accumulator, separate from `max_w`. Caps how much stale error can be waiting to unwind when the sign flips. **Keep it above `max_w`, and do not set it equal to `max_w`** |
|
| `integrator_max_w` | 0 | Bound on the loop's accumulator, and 0 means "same as `max_w`". Caps how much stale error can be waiting to unwind when the sign flips. **Do not raise it above `max_w`** - the output clamp already bounds what is commanded, so the only thing extra headroom buys is more cycles of wrong-direction power after every saturation event. Lowering it below `max_w` is the useful direction |
|
||||||
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
| `heartbeat_s` | 10 | Refresh interval; must stay well under the firmware watchdog |
|
||||||
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W |
|
| `stale_input_s` | 15 | How long inputs may be missing before commanding 0 W |
|
||||||
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
| `auto_start` | false | Start controlling on boot (only after commissioning) |
|
||||||
|
|
||||||
|
#### Saturation is counted in cycles, not seconds
|
||||||
|
|
||||||
|
The specification states the saturation window as **"> 10 s"**. This add-on counts
|
||||||
|
**cycles** instead, and that is a deliberate, accepted deviation rather than an
|
||||||
|
oversight - the acceptance criterion is not met as literally written.
|
||||||
|
|
||||||
|
A cycle here is one *changed* meter reading: the controller only runs the loop when the
|
||||||
|
meter value differs from the previous poll. At the reference P1's ~5 s update rate the
|
||||||
|
default of 3 cycles is usually around 15 s, but there is **no guaranteed wall-clock
|
||||||
|
window** - a meter that repeats the same value stalls the counter for as long as it
|
||||||
|
repeats.
|
||||||
|
|
||||||
|
Two reasons that is acceptable:
|
||||||
|
|
||||||
|
- the control law is a pure function with no clock, which is what makes it testable
|
||||||
|
without hardware, and a seconds-based window would have to live in the controller;
|
||||||
|
- a stalled counter is a detection-latency limit and not a runaway risk. The condition
|
||||||
|
that stalls it - an unchanging meter - stops the whole loop, so nothing accumulates
|
||||||
|
while it is stalled.
|
||||||
|
|
||||||
|
If a guaranteed window matters on your site, raise `saturation_cycles` for a fast meter,
|
||||||
|
and treat the figure as "N meter updates" rather than "N seconds".
|
||||||
|
|
||||||
#### Why `target_grid_w` is not zero
|
#### Why `target_grid_w` is not zero
|
||||||
|
|
||||||
The deadband is a one-way ratchet: any resting point inside it holds until
|
The deadband is a one-way ratchet: any resting point inside it holds until
|
||||||
|
|||||||
@@ -28,16 +28,22 @@ class Tuning:
|
|||||||
step_w: int = 10
|
step_w: int = 10
|
||||||
saturation_w: float = 500.0
|
saturation_w: float = 500.0
|
||||||
saturation_cycles: int = 3
|
saturation_cycles: int = 3
|
||||||
# ⚠️ The integrator's OWN bound, and deliberately not max_w. A commercial
|
# The integrator's own bound. None means "follow max_w", which is the
|
||||||
# controller on this same site clamped only its output and still reported
|
# default and the recommended setting.
|
||||||
# 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
|
# ⚠️ DO NOT RAISE THIS ABOVE max_w without a measurement to justify it.
|
||||||
# the error flipped there were minutes of accumulated wind to burn off
|
# Every watt of integrator above the rail is a watt of wind that has to be
|
||||||
# before the command moved at all. Bounding the accumulator is what makes
|
# burned off before the command can start moving the other way, i.e. extra
|
||||||
# recovery time finite; bounding the output only hides it.
|
# cycles of discharge into an already-exporting meter after every
|
||||||
# Headroom above max_w is wanted (a legitimate large error must not be
|
# saturation event. Measured on the closed-loop sim, 4000 W load dropped to
|
||||||
# truncated at the rail), headroom without limit is the bug.
|
# 0: at integrator_max_w == max_w the command is 1000 W two cycles later; at
|
||||||
integrator_max_w: float = 3000.0
|
# 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.
|
# 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
|
# ⚠️ 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
|
# forever, and the meter's IMPORT register counts every positive one with
|
||||||
@@ -54,9 +60,9 @@ class Decision:
|
|||||||
sat_count: int
|
sat_count: int
|
||||||
frozen: bool
|
frozen: bool
|
||||||
reason: str
|
reason: str
|
||||||
# The integrator AFTER this cycle, pre-clamp-to-max_w. Carry it back in as
|
# The integrator AFTER this cycle, before the output clamp, the slew limit
|
||||||
# `i_w` next cycle; that is what keeps it a separate quantity from the
|
# and quantisation. Carry it back in as `i_w` next cycle; that is what keeps
|
||||||
# command, which is the whole point of the bound above.
|
# it a separate quantity from the command.
|
||||||
i_w: float = 0.0
|
i_w: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@@ -89,14 +95,19 @@ def compute(
|
|||||||
# lets slew be larger than saturation_w.
|
# lets slew be larger than saturation_w.
|
||||||
#
|
#
|
||||||
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
# The spec states this window twice and differently: "> 10 s" (§11.2) and
|
||||||
# "3 samples" (§10.3). Cycles are authoritative here because this function
|
# "3 samples" (§10.3). This counts CYCLES, and a cycle is not a unit of
|
||||||
# has no clock - it is driven one cycle per meter update by run_control(),
|
# time: run_control() calls cycle() only when the meter value CHANGES
|
||||||
# which only calls cycle() when the meter value changes. At the ~5 s
|
# (`if self.grid != last_grid`), so three cycles is three distinct meter
|
||||||
# HomeWizard P1 cadence the default 3 cycles is ~15 s, i.e. the stricter
|
# readings and nothing more. At the reference P1's ~5 s update rate that is
|
||||||
# reading of the two. On a faster meter it is not, so saturation_cycles is
|
# usually ~15 s, but there is no upper bound on it - a meter that repeats a
|
||||||
# configurable and must be raised to keep the window over 10 s.
|
# value stalls the counter.
|
||||||
# ponytail: a seconds-based window would mean plumbing wall-clock or dt
|
#
|
||||||
# into a pure function whose whole value is that it has neither.
|
# 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
|
saturated_now = abs(prev_w - actual_w) > tuning.saturation_w
|
||||||
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
sat_count = min(sat_count + 1, 10) if saturated_now else 0
|
||||||
frozen = sat_count >= tuning.saturation_cycles
|
frozen = sat_count >= tuning.saturation_cycles
|
||||||
@@ -112,32 +123,78 @@ def compute(
|
|||||||
|
|
||||||
# --- the integrator ----------------------------------------------------
|
# --- the integrator ----------------------------------------------------
|
||||||
# This loop is in velocity form: the accumulator IS the commanded power, so
|
# 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
|
# "the integrator" and "the output" were one variable and could not be
|
||||||
# not be bounded apart. `i_w` is that accumulator made explicit. A caller
|
# bounded apart. `i_w` is that accumulator made explicit; main.py carries it
|
||||||
# that passes nothing gets the old behaviour exactly - seeded from the last
|
# between cycles, which is what turns the two clamps into two limits.
|
||||||
# command every cycle - and main.py carries it instead, which is what turns
|
#
|
||||||
# the two clamps below into two independent 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:
|
if i_w is None:
|
||||||
i_w = float(prev_w)
|
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:
|
if abs(error) < tuning.deadband_w:
|
||||||
reason = "deadband"
|
reason = "deadband"
|
||||||
else:
|
else:
|
||||||
step_i = tuning.gain * error
|
moved = i_w + tuning.gain * error
|
||||||
# ⚠️ Freeze means "may not wind FURTHER", not "may not move". A strict
|
# ⚠️ Freeze means "may not wind FURTHER in the direction it is already
|
||||||
# freeze would strand the command at whatever it had reached until the
|
# pushing". It may fall, cross zero, or reverse outright.
|
||||||
# inverter started tracking again - and the inverter is not tracking,
|
#
|
||||||
# that is what saturation means, so nothing would ever release it. The
|
# It must NOT be encoded as "only corrections that shrink |i_w|": that
|
||||||
# unwind direction is the escape route and stays open; the same rule is
|
# is unsatisfiable for BOTH signs of error whenever the correction is
|
||||||
# applied again to the output below.
|
# larger than twice the integrator, i.e. every time the integrator is
|
||||||
if not frozen or abs(i_w + step_i) < abs(i_w):
|
# near zero. The loop then sits at its last value forever, because what
|
||||||
i_w = i_w + step_i
|
# 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, and before the output clamp: the
|
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||||
# freeze is conditional, this bound is not. Order matters only in that the
|
# bound is not. It is what makes the worst-case unwind time finite and
|
||||||
# command below is derived from the already-bounded integrator, so no
|
# knowable instead of a function of how long the error happened to stand.
|
||||||
# accumulated value can reach the wire even once.
|
bounded = max(-limit, min(limit, i_w))
|
||||||
i_w = max(-tuning.integrator_max_w, min(tuning.integrator_max_w, i_w))
|
if bounded != i_w:
|
||||||
|
# ⚠️ 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
|
want = i_w
|
||||||
|
|
||||||
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ class Controller:
|
|||||||
step_w=int(opts.get("step_w", 10)),
|
step_w=int(opts.get("step_w", 10)),
|
||||||
saturation_w=float(opts.get("saturation_w", 500)),
|
saturation_w=float(opts.get("saturation_w", 500)),
|
||||||
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
saturation_cycles=int(opts.get("saturation_cycles", 3)),
|
||||||
integrator_max_w=float(opts.get("integrator_max_w", 3000)),
|
# 0 / unset means "follow max_w", which is the recommended
|
||||||
|
# value. Read the note in control.py before raising it above
|
||||||
|
# max_w: every watt above the rail is unwind latency.
|
||||||
|
integrator_max_w=(float(opts["integrator_max_w"])
|
||||||
|
if opts.get("integrator_max_w") else None),
|
||||||
)
|
)
|
||||||
self.maint = Maintenance(
|
self.maint = Maintenance(
|
||||||
MaintConfig(
|
MaintConfig(
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ options:
|
|||||||
step_w: 10
|
step_w: 10
|
||||||
saturation_w: 500
|
saturation_w: 500
|
||||||
saturation_cycles: 3
|
saturation_cycles: 3
|
||||||
integrator_max_w: 3000
|
integrator_max_w: 0
|
||||||
heartbeat_s: 10
|
heartbeat_s: 10
|
||||||
stale_input_s: 15
|
stale_input_s: 15
|
||||||
auto_start: false
|
auto_start: false
|
||||||
@@ -89,7 +89,7 @@ schema:
|
|||||||
step_w: int(1,100)
|
step_w: int(1,100)
|
||||||
saturation_w: int(100,2000)
|
saturation_w: int(100,2000)
|
||||||
saturation_cycles: int(1,10)
|
saturation_cycles: int(1,10)
|
||||||
integrator_max_w: int(100,15000)
|
integrator_max_w: int(0,15000)
|
||||||
heartbeat_s: int(2,25)
|
heartbeat_s: int(2,25)
|
||||||
stale_input_s: int(5,120)
|
stale_input_s: int(5,120)
|
||||||
auto_start: bool
|
auto_start: bool
|
||||||
|
|||||||
@@ -24,6 +24,63 @@ def check(name, cond):
|
|||||||
fails.append(name)
|
fails.append(name)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# COVERAGE AUDIT - measured, not executed. Read this before adding a mechanism.
|
||||||
|
#
|
||||||
|
# THE INVARIANT: every mechanism in compute() must be noticed by AT LEAST TWO
|
||||||
|
# checks when it is deleted. If you add a mechanism to compute(), re-run the
|
||||||
|
# audit and add it to the table. If a figure here drops, a check has started
|
||||||
|
# passing for a reason other than the one it names.
|
||||||
|
#
|
||||||
|
# THE TECHNIQUE, because there is no script to run: replace one mechanism in
|
||||||
|
# control.py with a no-op, run this file, count the failures, restore. That is
|
||||||
|
# the converse of the usual mutation - not "does a wrong value fail?" but "does
|
||||||
|
# anyone notice when the mechanism is GONE?". It is kept as a comment rather
|
||||||
|
# than as tooling on purpose: the only cheap way to automate it is to key on
|
||||||
|
# source lines, which goes stale silently, and a green audit that has quietly
|
||||||
|
# stopped testing anything is precisely the failure this ticket exists to fix.
|
||||||
|
# A comment cannot go stale-green, because it never claims to be running.
|
||||||
|
#
|
||||||
|
# Measured at 389d9ec. Numbers are the lead's independent reproduction.
|
||||||
|
#
|
||||||
|
# mechanism in compute() checks that fail when deleted
|
||||||
|
# ------------------------------------------ -----------------------------
|
||||||
|
# integrator freeze (AC 3) 2
|
||||||
|
# integrator clamp (AC 1) 6
|
||||||
|
# integrator bound follows max_w 4
|
||||||
|
# output clamp 3
|
||||||
|
# slew limit 4
|
||||||
|
# output freeze 2
|
||||||
|
# deadband 5
|
||||||
|
# quantisation 2
|
||||||
|
# saturation detector, `saturated_now = False` 11
|
||||||
|
# saturation duration (AC 2), fires instantly 2
|
||||||
|
# sat counter reset on a good cycle 6
|
||||||
|
# target_grid_w bias 3
|
||||||
|
# i_w=None seeding from prev_w 6
|
||||||
|
#
|
||||||
|
# The detector figure is for the `saturated_now = False` form specifically;
|
||||||
|
# disabling it further down as `frozen = False` is a weaker mutation and gives
|
||||||
|
# 10. Reproduce the same form or the number will not match.
|
||||||
|
#
|
||||||
|
# ⚠️ IT HAS FOUND A DEAD MECHANISM TWICE, BOTH THE SAME WAY: a clamp standing in
|
||||||
|
# for the mechanism under test. Deleting the integrator freeze once failed
|
||||||
|
# NOTHING, because the fixtures sat at max_w 2000 and the integrator bound
|
||||||
|
# truncated a wound value back to exactly 2000 - the assertion passed on the
|
||||||
|
# clamp. The output clamp was masked the same way by the integrator bound.
|
||||||
|
# Hence: A FIXTURE MUST SIT CLEAR OF EVERY RAIL IT IS NOT TESTING. Where a test
|
||||||
|
# names one mechanism, make that mechanism the binding one (see TCLAMP and TF).
|
||||||
|
#
|
||||||
|
# ⚠️ RUN MUTATIONS WITH `python -B` AND CLEAR app/__pycache__. CPython
|
||||||
|
# invalidates a .pyc on (source mtime in whole seconds, source size), so a
|
||||||
|
# same-second rewrite that also preserves the file size reuses stale bytecode
|
||||||
|
# and the suite reports on code you are no longer running. It under-reported one
|
||||||
|
# mutation here as 2 where the true figure is 6. The error is one-directional -
|
||||||
|
# stale bytecode can only under-report - so every figure above is a lower bound
|
||||||
|
# at worst, and the two zeros ever recorded were both confirmed by fixing them
|
||||||
|
# and watching the count rise, which a caching artefact cannot do.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
print("control law")
|
print("control law")
|
||||||
|
|
||||||
# Deadband: inside meter noise, hold exactly - do not drift.
|
# Deadband: inside meter noise, hold exactly - do not drift.
|
||||||
@@ -41,13 +98,22 @@ check("proportional step (gain 0.6)", d.target_w == 300)
|
|||||||
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
|
d = compute(prev_w=0, grid_w=-500, actual_w=0, tuning=T)
|
||||||
check("export drives charging", d.target_w == -300)
|
check("export drives charging", d.target_w == -300)
|
||||||
|
|
||||||
# Clamp
|
# Clamp.
|
||||||
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=Tuning(max_w=2000, slew_w=5000))
|
# ⚠️ integrator_max_w is lifted clear of max_w so that the OUTPUT clamp is the
|
||||||
|
# mechanism under test. Left at the default the integrator bound truncates
|
||||||
|
# first, these two assertions pass on that alone, and deleting the output clamp
|
||||||
|
# fails nothing - the same masking that hid the integrator freeze.
|
||||||
|
TCLAMP = Tuning(max_w=2000, slew_w=5000, integrator_max_w=5000)
|
||||||
|
d = compute(prev_w=1900, grid_w=1000, actual_w=1900, tuning=TCLAMP)
|
||||||
check("clamped to max_w", d.target_w == 2000)
|
check("clamped to max_w", d.target_w == 2000)
|
||||||
|
|
||||||
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
|
# Slew: from 0 with a huge error, no more than slew_w in one cycle.
|
||||||
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
d = compute(prev_w=0, grid_w=5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
||||||
check("slew limits one cycle", d.target_w == 1000)
|
check("slew limits one cycle", d.target_w == 1000)
|
||||||
|
d = compute(prev_w=-1900, grid_w=-1000, actual_w=-1900, tuning=TCLAMP)
|
||||||
|
check("clamped to -max_w", d.target_w == -2000)
|
||||||
|
d = compute(prev_w=0, grid_w=-5000, actual_w=0, tuning=Tuning(max_w=5000, slew_w=1000))
|
||||||
|
check("slew limits one cycle, charging", d.target_w == -1000)
|
||||||
|
|
||||||
# Saturation needs DURATION: one diverging cycle must NOT freeze.
|
# Saturation needs DURATION: one diverging cycle must NOT freeze.
|
||||||
t = Tuning(saturation_w=500, saturation_cycles=3)
|
t = Tuning(saturation_w=500, saturation_cycles=3)
|
||||||
@@ -78,7 +144,7 @@ print("SAFETY-04: the integrator is bounded apart from the output")
|
|||||||
# The historical runaway, with its real numbers. A commercial controller on
|
# The historical runaway, with its real numbers. A commercial controller on
|
||||||
# this site, with the inverter switched OFF, wound ~130 W every 4 s past 10 kW
|
# this site, with the inverter switched OFF, wound ~130 W every 4 s past 10 kW
|
||||||
# and reported 14 768 W while its output clamp sat at 5 kW. At gain 0.6 that
|
# and reported 14 768 W while its output clamp sat at 5 kW. At gain 0.6 that
|
||||||
# rate is a standing error of 130/0.6 ≈ 217 W that never resolves, because the
|
# rate is a standing error of 130/0.6 = 217 W that never resolves, because the
|
||||||
# inverter is not there to resolve it. 150 cycles is past the ~113 it took to
|
# inverter is not there to resolve it. 150 cycles is past the ~113 it took to
|
||||||
# reach 14 768 W at that rate.
|
# reach 14 768 W at that rate.
|
||||||
RUNAWAY_ERROR = 130.0 / 0.6
|
RUNAWAY_ERROR = 130.0 / 0.6
|
||||||
@@ -86,12 +152,12 @@ RUNAWAY_CYCLES = 150
|
|||||||
HISTORICAL_W = 14768.0
|
HISTORICAL_W = 14768.0
|
||||||
|
|
||||||
|
|
||||||
def runaway(tuning):
|
def runaway(tuning, sign=1):
|
||||||
"""Inverter off: it reports 0 W forever, the error never clears."""
|
"""Inverter off: it reports 0 W forever, the error never clears."""
|
||||||
prev, i_w, sat = 0.0, 0.0, 0
|
prev, i_w, sat = 0.0, 0.0, 0
|
||||||
worst_i, worst_cmd = 0.0, 0.0
|
worst_i, worst_cmd = 0.0, 0.0
|
||||||
for _ in range(RUNAWAY_CYCLES):
|
for _ in range(RUNAWAY_CYCLES):
|
||||||
d = compute(prev_w=prev, grid_w=RUNAWAY_ERROR, actual_w=0.0,
|
d = compute(prev_w=prev, grid_w=sign * RUNAWAY_ERROR, actual_w=0.0,
|
||||||
tuning=tuning, sat_count=sat, i_w=i_w)
|
tuning=tuning, sat_count=sat, i_w=i_w)
|
||||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||||
worst_i = max(worst_i, abs(i_w))
|
worst_i = max(worst_i, abs(i_w))
|
||||||
@@ -99,33 +165,113 @@ def runaway(tuning):
|
|||||||
return worst_i, worst_cmd
|
return worst_i, worst_cmd
|
||||||
|
|
||||||
|
|
||||||
TR = Tuning(max_w=2000, integrator_max_w=3000)
|
TR = Tuning(max_w=2000) # integrator_max_w unset => follows max_w
|
||||||
wi, wc = runaway(TR)
|
wi, wc = runaway(TR)
|
||||||
check(f"runaway: integrator plateaus at {wi:.0f} W (<= 3000)", wi <= TR.integrator_max_w)
|
check(f"runaway: integrator plateaus at {wi:.0f} W (<= 2000)", wi <= TR.max_w)
|
||||||
check(f"runaway: emitted command peaks at {wc:.0f} W (<= 2000)", wc <= TR.max_w)
|
check(f"runaway: emitted command peaks at {wc:.0f} W (<= 2000)", wc <= TR.max_w)
|
||||||
check("runaway: nowhere near the historical 14 768 W", wc < HISTORICAL_W / 4)
|
check("runaway: nowhere near the historical 14 768 W", wc < HISTORICAL_W / 4)
|
||||||
|
|
||||||
# ...and with the saturation detector deliberately defeated, so that only the
|
# ...and with the saturation detector deliberately defeated, so that only the
|
||||||
# clamp is holding. This is the AC that says the two mechanisms are
|
# clamp is holding. Kill one mechanism, the other still bounds it.
|
||||||
# independent: kill one, the other still bounds it.
|
TD = Tuning(max_w=2000, saturation_w=1e9)
|
||||||
TD = Tuning(max_w=2000, integrator_max_w=3000, saturation_w=1e9)
|
|
||||||
wi, wc = runaway(TD)
|
wi, wc = runaway(TD)
|
||||||
check(f"runaway with the detector defeated: integrator still <= 3000 ({wi:.0f} W)",
|
check(f"runaway with the detector defeated: integrator still bounded ({wi:.0f} W)",
|
||||||
wi <= TD.integrator_max_w)
|
wi <= TD.max_w)
|
||||||
check("runaway with the detector defeated: command still <= max_w", wc <= TD.max_w)
|
check("runaway with the detector defeated: command still <= max_w", wc <= TD.max_w)
|
||||||
|
|
||||||
# The bound is not max_w. If someone "simplifies" them into one key this fails.
|
# The mirror: the same runaway driving the other way. An export that never
|
||||||
d = compute(prev_w=0, grid_w=6000, actual_w=0,
|
# clears winds the integrator negative just as hard.
|
||||||
tuning=Tuning(max_w=2000, integrator_max_w=3000, slew_w=5000))
|
wi, wc = runaway(Tuning(max_w=2000), sign=-1)
|
||||||
check("integrator bound is separate from the output clamp",
|
check(f"runaway (export direction): integrator bounded at {wi:.0f} W", wi <= 2000)
|
||||||
d.i_w == 3000 and d.target_w == 2000)
|
check("runaway (export direction): emitted command <= max_w", wc <= 2000)
|
||||||
|
|
||||||
# Freeze = does not accumulate. Same input twice; the integrator must not move.
|
# The bound is a separate quantity, and the useful direction is BELOW max_w:
|
||||||
TF = Tuning(saturation_w=500, saturation_cycles=3)
|
# there it binds first and caps unwind latency tighter than the rail does.
|
||||||
|
d = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||||
|
tuning=Tuning(max_w=2000, integrator_max_w=1000, slew_w=5000))
|
||||||
|
check("integrator bound binds independently of the output clamp",
|
||||||
|
d.i_w == 1000 and d.target_w == 1000)
|
||||||
|
|
||||||
|
# Freeze = may not wind further in the direction it is already pushing.
|
||||||
|
# ⚠️ max_w is raised WELL above the fixtures on purpose. At the default 2000
|
||||||
|
# the integrator bound truncates a wound value back to exactly 2000 and
|
||||||
|
# satisfies these assertions on its own, so deleting the freeze outright
|
||||||
|
# failed nothing - the clamp was standing in for the mechanism under test.
|
||||||
|
# Any fixture here must sit clear of every rail, or it tests the rail.
|
||||||
|
TF = Tuning(saturation_w=500, saturation_cycles=3, max_w=5000)
|
||||||
f1 = compute(prev_w=2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
f1 = compute(prev_w=2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
||||||
check("frozen: integration does not accumulate", f1.i_w == 2000.0 and f1.frozen)
|
check("frozen: integration does not wind further", f1.i_w == 2000.0 and f1.frozen)
|
||||||
f2 = compute(prev_w=2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
f2 = compute(prev_w=2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=2000.0)
|
||||||
check("frozen: unwinding is still allowed", f2.i_w < 2000.0)
|
check("frozen: unwinding is still allowed", f2.i_w < 2000.0)
|
||||||
|
# ...and the same two on the charging side. Every freeze rule in this file has
|
||||||
|
# a mirror, because the one that did not is the defect that got through review.
|
||||||
|
f3 = compute(prev_w=-2000, grid_w=-800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
|
||||||
|
check("frozen (charging): integration does not wind further", f3.i_w == -2000.0)
|
||||||
|
f4 = compute(prev_w=-2000, grid_w=800, actual_w=0, tuning=TF, sat_count=3, i_w=-2000.0)
|
||||||
|
check("frozen (charging): unwinding is still allowed", f4.i_w > -2000.0)
|
||||||
|
|
||||||
|
# ⚠️ REGRESSION, and the reason the first cut of SAFETY-04 was rejected. A
|
||||||
|
# freeze encoded as "only corrections that shrink |i_w|" is unsatisfiable for
|
||||||
|
# BOTH signs of error whenever |correction| > 2*|i_w|, so near zero the loop
|
||||||
|
# stops moving forever - the freeze cannot clear, because clearing it needs the
|
||||||
|
# inverter to track and not-tracking is what saturation means. Measured on that
|
||||||
|
# encoding: 0 W held into a 2 kW import for as long as the sim ran.
|
||||||
|
z = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
|
||||||
|
check("frozen at i_w=0: a 2 kW import still moves the command",
|
||||||
|
z.frozen and z.target_w == 1000)
|
||||||
|
# ...and the next cycle the inverter is inside saturation_w of the command, so
|
||||||
|
# the freeze clears on its own. Deadlock would show up here as frozen=True.
|
||||||
|
z2 = compute(prev_w=1000, grid_w=1000, actual_w=600, tuning=T,
|
||||||
|
sat_count=z.sat_count, i_w=z.i_w)
|
||||||
|
check("frozen at i_w=0: the freeze then clears", not z2.frozen)
|
||||||
|
# Same stranding on the other side: a small positive integrator against export.
|
||||||
|
z3 = compute(prev_w=100, grid_w=-1000, actual_w=800, tuning=T, sat_count=3, i_w=100.0)
|
||||||
|
check("frozen at i_w=+100: a 1 kW export still moves the command",
|
||||||
|
z3.frozen and z3.target_w < 0)
|
||||||
|
z4 = compute(prev_w=-100, grid_w=1000, actual_w=-800, tuning=T, sat_count=3, i_w=-100.0)
|
||||||
|
check("frozen at i_w=-100: a 1 kW import still moves the command",
|
||||||
|
z4.frozen and z4.target_w > 0)
|
||||||
|
|
||||||
|
# ⚠️ EXACTLY ZERO, BOTH DIRECTIONS. This boundary has a history: the first cut
|
||||||
|
# deadlocked here under import, and the fix for it deadlocked here under export
|
||||||
|
# because `if i_w > 0 ... else ...` files 0.0 under rising-only. main.py resets
|
||||||
|
# i_w to exactly 0.0 on every stop and every reseed, so it is a normal state,
|
||||||
|
# not a corner.
|
||||||
|
zi = compute(prev_w=0, grid_w=2000, actual_w=600, tuning=T, sat_count=3, i_w=0.0)
|
||||||
|
check("frozen at i_w=0.0: an import push moves the integrator",
|
||||||
|
zi.frozen and zi.i_w > 0)
|
||||||
|
ze = compute(prev_w=0, grid_w=-2000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
|
||||||
|
check("frozen at i_w=0.0: an export push moves the integrator",
|
||||||
|
ze.frozen and ze.i_w < 0)
|
||||||
|
# The COMMAND still holds at 0 W in that second case, and that is release/1.0's
|
||||||
|
# rule, not a leftover: at prev_w == 0 the output freeze forbids starting to
|
||||||
|
# charge while saturated, because commanding 0 while the inverter reports
|
||||||
|
# hundreds of watts means something else is driving the bus. Asserted so that
|
||||||
|
# nobody "fixes" it by accident - the integrator moving is what this ticket
|
||||||
|
# owns, the command rule belongs to the output freeze.
|
||||||
|
check("frozen at i_w=0.0: the output freeze still blocks a charge from 0 W",
|
||||||
|
ze.target_w == 0.0)
|
||||||
|
# Where prev_w is already charging the output freeze does NOT block, and there
|
||||||
|
# the difference reaches the wire: held at 0.0 the integrator abandons the
|
||||||
|
# charge mid-export.
|
||||||
|
zc = compute(prev_w=-2000, grid_w=-4000, actual_w=-600, tuning=T, sat_count=3, i_w=0.0)
|
||||||
|
check("frozen at i_w=0.0: a charge is not abandoned during heavy export",
|
||||||
|
zc.target_w == -2000.0)
|
||||||
|
|
||||||
|
# The general property, rather than another handful of points: while frozen the
|
||||||
|
# integrator may be held ONLY when the correction would push it further from
|
||||||
|
# zero on the side it already sits. Any other hold is a deadlock.
|
||||||
|
stuck = []
|
||||||
|
for i0 in [x * 25.0 for x in range(-80, 81)]:
|
||||||
|
for g in [x * 100.0 for x in range(-40, 41)]:
|
||||||
|
err = g - T.target_grid_w
|
||||||
|
if abs(err) < T.deadband_w:
|
||||||
|
continue
|
||||||
|
dd = compute(prev_w=0.0, grid_w=g, actual_w=1500.0, tuning=T, sat_count=3, i_w=i0)
|
||||||
|
if dd.i_w == i0 and not ((i0 > 0 and err > 0) or (i0 < 0 and err < 0)):
|
||||||
|
stuck.append((i0, g))
|
||||||
|
check(f"frozen integrator never deadlocks, over {161*81} states"
|
||||||
|
+ (f" (e.g. {stuck[0]})" if stuck else ""), not stuck)
|
||||||
|
|
||||||
# False-positive guard: a normal 2 kW load step must not trip the detector,
|
# False-positive guard: a normal 2 kW load step must not trip the detector,
|
||||||
# because the plant needs several cycles to catch up on every one of them.
|
# because the plant needs several cycles to catch up on every one of them.
|
||||||
@@ -137,6 +283,107 @@ for _ in range(12):
|
|||||||
froze = froze or d.frozen
|
froze = froze or d.frozen
|
||||||
check("a normal 2 kW load step does not trip the saturation freeze", not froze)
|
check("a normal 2 kW load step does not trip the saturation freeze", not froze)
|
||||||
|
|
||||||
|
# The convergence sim below runs WITHOUT a carried integrator. This is the same
|
||||||
|
# 2 kW step in the configuration that actually ships, where main.py carries it.
|
||||||
|
prev, actual, sat, i_w = 0.0, 0.0, 0, 0.0
|
||||||
|
carried = 0
|
||||||
|
for _ in range(12):
|
||||||
|
d = compute(prev, 2000.0 - actual, actual, T, sat, i_w)
|
||||||
|
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
|
||||||
|
actual = actual + 0.94 * (prev - actual)
|
||||||
|
carried += 1
|
||||||
|
if abs(2000.0 - actual) < T.deadband_w:
|
||||||
|
break
|
||||||
|
check(f"carried integrator converges in {carried} cycles (<=6)", carried <= 6)
|
||||||
|
check("carried integrator does not overshoot the load", actual <= 2000.0 + T.deadband_w)
|
||||||
|
|
||||||
|
# ⚠️ REGRESSION: an integrator allowed to wind past the rail buys nothing (the
|
||||||
|
# output clamp already bounds the wire) and costs extra cycles of
|
||||||
|
# wrong-direction power after every saturation event. 4000 W load held to
|
||||||
|
# saturation, then dropped to 0; the figure is the command on the first cycle
|
||||||
|
# after the drop. This is what makes the DOCS advice checkable.
|
||||||
|
def unwind(t):
|
||||||
|
prev, actual, sat, i_w, load = 0.0, 0.0, 0, 0.0, 4000.0
|
||||||
|
for c in range(16):
|
||||||
|
if c == 15:
|
||||||
|
load = 0.0
|
||||||
|
d = compute(prev, load - actual, actual, t, sat, i_w)
|
||||||
|
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
|
||||||
|
actual = actual + 0.94 * (prev - actual)
|
||||||
|
return prev
|
||||||
|
|
||||||
|
|
||||||
|
tight, loose = unwind(Tuning(max_w=2000)), unwind(Tuning(max_w=2000, integrator_max_w=3000))
|
||||||
|
check(f"after saturation ends the command is {tight:.0f} W (<= 1000)", tight <= 1000)
|
||||||
|
check(f"headroom above max_w makes that worse ({loose:.0f} W) - hence the default",
|
||||||
|
loose > tight)
|
||||||
|
|
||||||
|
print("SAFETY-04: the i_w=None path is still release/1.0, exactly")
|
||||||
|
|
||||||
|
|
||||||
|
def legacy(prev, grid, actual, t, sat_count):
|
||||||
|
"""release/1.0's control law, transcribed. Do not 'improve' this."""
|
||||||
|
reason = "tracking"
|
||||||
|
sc = min(sat_count + 1, 10) if abs(prev - actual) > t.saturation_w else 0
|
||||||
|
frozen = sc >= t.saturation_cycles
|
||||||
|
error = grid - t.target_grid_w
|
||||||
|
if abs(error) < t.deadband_w:
|
||||||
|
want, reason = prev, "deadband"
|
||||||
|
else:
|
||||||
|
want = prev + t.gain * error
|
||||||
|
target = max(-t.max_w, min(t.max_w, want))
|
||||||
|
if target != want:
|
||||||
|
reason = "clamped"
|
||||||
|
slewed = max(prev - t.slew_w, min(prev + t.slew_w, target))
|
||||||
|
if slewed != target:
|
||||||
|
reason = "slew-limited"
|
||||||
|
target = slewed
|
||||||
|
if frozen:
|
||||||
|
target = min(target, prev) if prev > 0 else max(target, prev)
|
||||||
|
reason = "saturated-freeze"
|
||||||
|
step = max(1, int(t.step_w))
|
||||||
|
return float(round(target / step) * step), sc, frozen, reason
|
||||||
|
|
||||||
|
|
||||||
|
# ⚠️ Compare EVERYTHING observable, not just the number. A previous version of
|
||||||
|
# this sweep compared (target_w, sat_count) only and passed 3024 cases while
|
||||||
|
# `reason` had silently lost a value - which is the kind of thing a sweep this
|
||||||
|
# broad exists to catch. `frozen` and `reason` are both in the tuple now.
|
||||||
|
#
|
||||||
|
# The one deliberate rename: what release/1.0 called "clamped" is now
|
||||||
|
# "i-clamped", because the truncation happens on the integrator before the
|
||||||
|
# command is derived from it. Aliased here rather than papered over - if any
|
||||||
|
# OTHER reason ever diverges, this check goes red.
|
||||||
|
ALIAS = {"i-clamped": "clamped"}
|
||||||
|
diffs = []
|
||||||
|
seen = set()
|
||||||
|
for tune in (Tuning(), Tuning(target_grid_w=-10.0), Tuning(max_w=5000, slew_w=5000)):
|
||||||
|
for prev in (-2000.0, -500.0, -100.0, 0.0, 100.0, 500.0, 2000.0):
|
||||||
|
for grid in (-6000.0, -1000.0, -500.0, -14.0, 0.0, 14.0, 500.0, 1000.0, 6000.0):
|
||||||
|
for actual in (-2000.0, 0.0, 600.0, 2000.0):
|
||||||
|
for sc in (0, 2, 3, 9):
|
||||||
|
d = compute(prev, grid, actual, tune, sc) # i_w defaults to None
|
||||||
|
seen.add(d.reason)
|
||||||
|
got = (d.target_w, d.sat_count, d.frozen,
|
||||||
|
ALIAS.get(d.reason, d.reason))
|
||||||
|
if got != legacy(prev, grid, actual, tune, sc):
|
||||||
|
diffs.append((prev, grid, actual, sc, got,
|
||||||
|
legacy(prev, grid, actual, tune, sc)))
|
||||||
|
check(f"i_w=None reproduces release/1.0 over {3*7*9*4*4} cases, reason included"
|
||||||
|
+ (f" (first diff {diffs[0]})" if diffs else ""), not diffs)
|
||||||
|
|
||||||
|
# ...and the rename is not a quiet deletion: the signal SAFETY-03 alarms on has
|
||||||
|
# to actually occur in that sweep, or its hook is dead.
|
||||||
|
check("the integrator clamp reports itself as 'i-clamped'", "i-clamped" in seen)
|
||||||
|
|
||||||
|
# "clamped" stays reachable, but only where the integrator is deliberately
|
||||||
|
# allowed above the rail - then BOTH fire and the output clamp, which describes
|
||||||
|
# the value actually emitted, is the one reported.
|
||||||
|
dc = compute(prev_w=0, grid_w=6000, actual_w=0,
|
||||||
|
tuning=Tuning(max_w=2000, integrator_max_w=3000, slew_w=5000))
|
||||||
|
check("the output clamp still reports 'clamped' when it is the binding one",
|
||||||
|
dc.reason == "clamped" and dc.i_w == 3000 and dc.target_w == 2000)
|
||||||
|
|
||||||
print("capacity tariff")
|
print("capacity tariff")
|
||||||
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
|
check("no forecast means no cap", maintenance_charge_floor(2500, None, 3500) == 2500)
|
||||||
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
|
check("headroom caps the charge", maintenance_charge_floor(2500, 2000, 3500) == 1500)
|
||||||
|
|||||||
Reference in New Issue
Block a user