SAFETY-24: an energy floor under the peak guard's forced discharge #21
@@ -2,6 +2,50 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
**The capacity-peak guard's forced discharge now has an EMS-side energy floor
|
||||
(SAFETY-24).** SAFETY-07 can command a discharge to hold a billed quarter down,
|
||||
and nothing below it consulted the state of charge — `soc_floor` belongs to the
|
||||
maintenance cycle, and freeze.py already recorded that for the grid-following
|
||||
loop "the BMS is the sole energy backstop". On the 2026-08-20 replay the guard
|
||||
asks for **878 Wh in a day**. Relying on the BMS is not absurd — it is a real
|
||||
floor and it works — but it is the pack's protection rather than the operator's,
|
||||
and a month of aggressive quarters leaves the battery repeatedly at that cut-out
|
||||
with no EMS-side record of why.
|
||||
|
||||
`decide()` now takes `soc` and `soc_floor`, both **required** and neither
|
||||
defaulted. Above the floor nothing changes. At or below it the guard files a
|
||||
**0 W** floor instead of a sized one — one-directional, so charging stays blocked
|
||||
and no discharge is capped — and reports `floor_limited` with the reason it
|
||||
bound. It is not a trip: every stop in §11.2's table still outranks the claim,
|
||||
and the floor can never be negative, so §9.1's "discharge and export are never
|
||||
capped by the peak guard" survives as a property of the object (swept: 0 negative
|
||||
floors across 1296 combinations of load, elapsed, battery, SoC and floor).
|
||||
|
||||
**Failing to shave is not silent.** A floor-limited cycle raises one notification
|
||||
per episode (`goodwe_peak_soc_floor`), distinct from the saturation alarm because
|
||||
the two ask the operator for opposite things, and renders its own status row.
|
||||
An **unreadable SoC is floor-limited, not a full battery** — a full battery is
|
||||
exactly the value that would disarm the floor, and this project has shipped that
|
||||
defaulting once before (`| float(100)` in the stage-5 loop).
|
||||
|
||||
**Verified by replay, not by argument.** The 2026-08-20 capture is driven against
|
||||
a modelled pack sized from the day's own measured appetite, so it genuinely
|
||||
exhausts: without the floor it reaches the BMS cut-out and sits there for 1227
|
||||
guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The
|
||||
energy withheld equals the energy between the two floors to within one cycle at
|
||||
the rail. The honest half is reported too — the worst quarter goes from 4338 W to
|
||||
5030 W, because a floor-limited guard shaves less on purpose.
|
||||
|
||||
**Two new options, mirrored once.** `peak_soc_floor` (20) and `bms_soc_floor`
|
||||
(10), whose defaults live in `peakguard.PEAK_OPTION_DEFAULTS` — TARIFF-02's
|
||||
pattern — imported by `validate.py` and `main.py` rather than typed as literals,
|
||||
and pinned to `config.yaml` by a check that reads the file. CFG-03 still tracks
|
||||
three places. `validate.py` rule 8 refuses `peak_soc_floor <= bms_soc_floor` at
|
||||
startup, in CFG-01/MAINT-04's manner. `maintenance_soc_floor` is deliberately
|
||||
**not** reused and deliberately **not** renamed: it is a drain target, not a
|
||||
spend limit, and the rename is recorded as a decision to revisit if a third
|
||||
floor ever appears.
|
||||
|
||||
**Price arbitrage - the first strategy that trades on a price (OPT-04).**
|
||||
Four cards built the price stack and nothing traded on it. `app/arbitrage.py`
|
||||
is §10.1's priority 3: charge in the cheapest published hour, discharge in the
|
||||
|
||||
@@ -420,7 +420,7 @@ help - see "Why the tuning is what it is".
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||||
| `maintenance_soc_target` | 99 | Charge target |
|
||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||||
|
||||
@@ -430,6 +430,8 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** Not optional and not the same thing as `maintenance_soc_floor` — see below |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
|
||||
On a capacity-tariff site the maintenance charge is capped by the headroom left
|
||||
@@ -530,11 +532,46 @@ projected over — raises one notification (`goodwe_peak_saturated`), because th
|
||||
is the one state where the money will be spent and shedding load is the only
|
||||
thing left that changes the bill.
|
||||
|
||||
**No new option.** The ceiling is `peak_cap_w`, which already shipped. The
|
||||
**No new option for the CEILING.** It is `peak_cap_w`, which already shipped. The
|
||||
coverage threshold is `meter_max_age_s`, the freeze predicate is
|
||||
`meter_freeze_s`, and the achievable discharge comes from `max_w` and the device
|
||||
rating — every one of them a key that already exists.
|
||||
|
||||
#### The energy floor under the forced discharge (`peak_soc_floor`)
|
||||
|
||||
The guard above can **command a discharge**, and until SAFETY-24 nothing in the
|
||||
add-on bounded how far down it took the pack — the BMS was the only backstop.
|
||||
On the 2026-08-20 replay the guard asks for **878 Wh in a day**.
|
||||
|
||||
That is not absurd: the BMS floor is real and it works. But it is *the pack's*
|
||||
protection, not *yours*. A month of aggressive quarters can leave the battery
|
||||
repeatedly parked at that cut-out with no record on this side of why, and the
|
||||
first symptom — a battery that is never available when you want it — looks
|
||||
exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
|
||||
- **`peak_soc_floor` (20 %)** is where the forced discharge stops. Below it the
|
||||
guard files a **0 W** floor instead of a sized one: charging stays blocked
|
||||
(that can only ever lower your offtake), and no further discharge is asked for.
|
||||
- **It does not stop the ordinary self-consumption loop.** That loop still has no
|
||||
SoC guard of its own. This bounds what the *peak guard* spends for money.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
||||
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
||||
the only thing left that changes the bill. Failing to shave is never silent —
|
||||
the euro figure is the whole point of the guard.
|
||||
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
|
||||
full battery is exactly the value that would disarm this floor.
|
||||
- **`bms_soc_floor` (10 %)** is the pack's own cut-out — register `0x0560` on the
|
||||
reference GW5000S-BP, consistent with DoD 90. It exists so the add-on can
|
||||
**refuse to start** when `peak_soc_floor` is at or below it: such a floor can
|
||||
never be the thing that stops the discharge, and a guard that advertises a
|
||||
backstop it does not have is the one thing this project will not ship. Equal is
|
||||
refused too — equal buys zero margin.
|
||||
|
||||
Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||||
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||||
smaller, or your quarters are worse, raise it.
|
||||
|
||||
### The winding-loop alarm (the output clamp)
|
||||
|
||||
Every setpoint this add-on writes is clamped to `max_w` before it leaves the
|
||||
|
||||
@@ -229,14 +229,25 @@ class Controller:
|
||||
# the action log and the notification fire on transitions rather than
|
||||
# once per cycle for a quarter of an hour.
|
||||
#
|
||||
# ⚠️ NO CONFIG OF ITS OWN, for the reason the clamp watchdog gives ~90
|
||||
# lines up and TARIFF-01 repeats: the ceiling is `peak_cap_w`, already
|
||||
# shipped and already validated, and every other term is read off
|
||||
# something the arbiter or the averager already owns. CFG-03 still
|
||||
# tracks three places, not four.
|
||||
# ⚠️ THE CEILING HAS NO CONFIG OF ITS OWN, for the reason the clamp
|
||||
# watchdog gives ~90 lines up and TARIFF-01 repeats: it is `peak_cap_w`,
|
||||
# already shipped and already validated, and every other term of the
|
||||
# projection is read off something the arbiter or the averager already
|
||||
# owns.
|
||||
#
|
||||
# ⚠️ SAFETY-24'S FLOOR DOES HAVE ONE, AND IT COULD NOT NOT HAVE. How far
|
||||
# down the guard may spend the pack for money is a number only the site
|
||||
# knows, and refusing one set below the pack's own cut-out means knowing
|
||||
# that cut-out too. So this card adds exactly two keys and reads their
|
||||
# defaults from `peakguard.PEAK_OPTION_DEFAULTS` - TARIFF-02's mirror
|
||||
# pattern - rather than typing literals here. THAT is what keeps CFG-03
|
||||
# at three places: the divergence validate.py's rule 6 documents
|
||||
# (`maintenance_charge_w` 2000 there, 2500 here) is exactly what a
|
||||
# literal in this file would repeat.
|
||||
self.peak_guard = None
|
||||
self._peak_engaged = False
|
||||
self._peak_saturated = False
|
||||
self._peak_floor_limited = False
|
||||
self._maint_shape_suspended = False
|
||||
# Set by amain() once the transport is built, so the status page can show
|
||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||
@@ -534,6 +545,17 @@ class Controller:
|
||||
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
||||
reading=self.peak.peak(now),
|
||||
achievable_w=min(max_w, abs(float(dev_top))),
|
||||
# ⚠️ SAFETY-24. `self.soc` IS THE MEASURED READING AND MAY BE None,
|
||||
# and it is handed over as None rather than defaulted here. An early
|
||||
# draft of the stage-5 loop read SoC as `| float(100)` and thereby
|
||||
# invented a full battery (NOTES.md 4.4b); a full battery is
|
||||
# precisely the value that disarms this floor. decide() treats None
|
||||
# as floor-limited, which is the direction that costs a shave rather
|
||||
# than the pack.
|
||||
soc=self.soc,
|
||||
soc_floor=float(self.o.get(
|
||||
"peak_soc_floor",
|
||||
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
|
||||
max_hold_s=self.p1.averager.max_hold_s,
|
||||
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
|
||||
standing, max_w, self.freeze_wd.cfg),
|
||||
@@ -780,6 +802,16 @@ class Controller:
|
||||
# method assigned above and nothing since has touched.
|
||||
await self.check_peak_saturated()
|
||||
|
||||
# --- SAFETY-24's alarm, and it is the OTHER way the money gets spent --
|
||||
# Same boundary argument as check_peak_saturated(): it grades
|
||||
# `self.peak_guard`, which cycle() assigned above and nothing since has
|
||||
# touched. Kept as its own method rather than folded into that one,
|
||||
# because the two states are mutually exclusive and want OPPOSITE
|
||||
# operator actions - saturation says "shed load, the inverter is already
|
||||
# flat out"; this says "the pack is at its floor and this quarter is not
|
||||
# being shaved at all".
|
||||
await self.check_peak_floor_limited()
|
||||
|
||||
# --- TARIFF-01, and it is LAST on purpose -----------------------------
|
||||
# Banking a quarter is bookkeeping about a moment that has already
|
||||
# happened; a slow SD card must never sit between the meter and the
|
||||
@@ -924,6 +956,70 @@ class Controller:
|
||||
f"thing that changes the bill."),
|
||||
})
|
||||
|
||||
async def check_peak_floor_limited(self) -> None:
|
||||
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
|
||||
|
||||
⚠️ FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
|
||||
The guard exists for one euro figure - the capaciteitstarief bills the
|
||||
month's single worst quarter - so a cycle in which it CHOSE not to shave
|
||||
is a cycle in which money is being spent on purpose. Under-shaving
|
||||
quietly would leave the operator with a bill and a status page full of
|
||||
green.
|
||||
|
||||
The distinction from check_peak_saturated() is the operator's action,
|
||||
which is why these are two notifications and not one:
|
||||
|
||||
saturated the guard asked for everything the inverter has and it
|
||||
was not enough. SHED LOAD - the controller has already
|
||||
done all it can, and it is still discharging.
|
||||
floor-limited the guard asked for NOTHING, because the pack is at or
|
||||
under the EMS floor, or its SoC could not be read.
|
||||
Nothing is being discharged for this quarter at all.
|
||||
|
||||
They cannot both be true: peakguard.decide()'s floor-limited branch
|
||||
returns before the `achievable_w` clamp that sets `saturated`.
|
||||
|
||||
Edge-triggered, and de-asserting arms it again - FreezeWatchdog's shape,
|
||||
for FreezeWatchdog's reason: one notification per episode, not one per
|
||||
cycle for a quarter of an hour.
|
||||
"""
|
||||
g = self.peak_guard
|
||||
limited = bool(g is not None and g.floor_limited)
|
||||
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||
if not limited or was:
|
||||
return
|
||||
# No prefix: `reason` already opens with PEAK GUARD FLOOR-LIMITED, and
|
||||
# a second copy is the kind of stutter that makes a 40-line action log
|
||||
# read like boilerplate. The saturated path needs its prefix because its
|
||||
# reason carries the word in the middle of a sentence, not at the front.
|
||||
self.log_event(g.reason)
|
||||
_LOG.error(
|
||||
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
|
||||
"still blocked, but it is not discharging to hold this quarter, so "
|
||||
"the quarter will set or raise the month's billed capacity peak. "
|
||||
"Shed load, or lower peak_soc_floor if this pack can afford it.",
|
||||
g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A battery at its
|
||||
# energy floor is not a saturated inverter and is not a frozen meter,
|
||||
# and an operator who has to dismiss three identical-looking cards
|
||||
# learns to dismiss the one that mattered.
|
||||
await self.hass.call("persistent_notification", "create", {
|
||||
"notification_id": "goodwe_peak_soc_floor",
|
||||
"title": "GoodWe: the capacity-peak guard has run out of battery",
|
||||
"message": (
|
||||
f"{g.reason}.\n\nTHIS QUARTER WILL GO OVER THE "
|
||||
f"{g.ceiling_w:.0f} W ceiling and will set or raise the "
|
||||
f"month's billed capaciteitstarief peak, because the peak guard "
|
||||
f"is not allowed to discharge any further: {g.soc_floor_reason}."
|
||||
f" This is not a controller fault - it is the EMS-side energy "
|
||||
f"floor (peak_soc_floor) doing what it is set to do, and it sits "
|
||||
f"deliberately above the pack's own BMS cut-out. Charging stays "
|
||||
f"blocked. Shedding load is the only thing that changes the bill "
|
||||
f"now; if this pack can afford a deeper discharge, lower "
|
||||
f"peak_soc_floor."),
|
||||
})
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -1265,6 +1361,13 @@ class Controller:
|
||||
elif _pg.saturated:
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": "PEAK GUARD SATURATED - " + _pg.reason})
|
||||
elif _pg.floor_limited:
|
||||
# SAFETY-24. NOT ok, like saturation: in both states the money
|
||||
# is going to be spent and a human is the only actuator left. A
|
||||
# separate row from saturation because the two ask for opposite
|
||||
# things - see check_peak_floor_limited().
|
||||
out.append({"ok": False, "warn": False,
|
||||
"text": _pg.explain()})
|
||||
elif _pg.blind:
|
||||
out.append({"ok": False, "warn": True, "text": _pg.explain()})
|
||||
else:
|
||||
|
||||
@@ -246,19 +246,100 @@ validate.py are untouched by this ticket, exactly as TARIFF-01 left them, and
|
||||
mismatch trap 14's own warning box describes.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
⚠️ WHAT THIS GUARD DOES NOT HAVE: AN ENERGY BACKSTOP. SEE SAFETY-24.
|
||||
SAFETY-24: THE ENERGY BACKSTOP THIS GUARD SHIPPED WITHOUT
|
||||
|
||||
The graded branch can FORCE DISCHARGE, and nothing in this file or in the loop
|
||||
below it consults the state of charge - `soc_floor` belongs to the maintenance
|
||||
cycle, and freeze.py already records that for the grid-following loop "the BMS
|
||||
is the sole energy backstop". On the 2026-08-20 replay the guard asks for 878 Wh
|
||||
in a day. Against a pack sitting near 9 % that is not nothing, and the guard will
|
||||
keep asking as long as the meter says the quarter is over the ceiling.
|
||||
SAFETY-07 left this file saying, in its own words, that the graded branch can
|
||||
FORCE DISCHARGE and that nothing below it consults the state of charge - "the
|
||||
BMS is the sole energy backstop" (freeze.py). On the 2026-08-20 replay the guard
|
||||
asks for 878 Wh in a day, and it keeps asking for as long as the meter says the
|
||||
quarter is over the ceiling. That is the behaviour working as designed, and it
|
||||
is unbounded from below by anything in the EMS.
|
||||
|
||||
That is deliberate for THIS card and it is filed as SAFETY-24. It is also the
|
||||
reason this must not go to a client install before SAFETY-24 lands: on the
|
||||
reference site the BMS is a known quantity and an operator is present, and on a
|
||||
client site neither is true.
|
||||
⚠️ STATE THE RISK FAIRLY, because overstating it is how a real one gets ignored.
|
||||
Relying on the BMS is not absurd: it is a genuine floor and it works. But it is
|
||||
THE PACK's protection, not the OPERATOR's. A month of aggressive quarters can
|
||||
leave the battery repeatedly parked at that floor with no EMS-side record of
|
||||
why, and the first symptom - a pack that is never available when it is wanted -
|
||||
is indistinguishable from the SoC drift MAINT-01 exists to correct.
|
||||
|
||||
So `decide()` now takes `soc` and `soc_floor`, and `discharge_permitted()` is
|
||||
the one place the question is answered. Four things about the shape of it:
|
||||
|
||||
1. IT IS NOT A TRIP, AND IT MUST NOT BECOME ONE. SAFETY-07's claim is a LIMIT
|
||||
and every stop in 11.2's table still outranks it. This floor is about
|
||||
ENERGY - how far down the guard may spend the pack for money - not about
|
||||
last-resort safety. The action when it binds is therefore to stop ASKING
|
||||
for more discharge, not to command anything.
|
||||
|
||||
2. THE ACTION IS `floor_w = 0.0`, WHICH IS THE BLIND BRANCH'S ACTION FOR THE
|
||||
BLIND BRANCH'S REASON. Zero is one-directional: it forbids CHARGING, which
|
||||
can only ever lower delivered offtake, and it forbids nothing else. It
|
||||
cannot pull the loop off a discharge it is already doing, because a `lo`
|
||||
limit structurally cannot cap discharge - the property this module's whole
|
||||
shape rests on, and one a NEGATIVE floor would destroy. Nothing here can
|
||||
produce a negative floor: the only two values `floor_w` ever takes are 0.0
|
||||
and `min(wanted, achievable)` with `wanted > 0` already established.
|
||||
|
||||
⚠️ AND IT DOES NOT BOUND THE LOOP. Self-consumption may still drain the
|
||||
pack past this floor, exactly as SAFETY-18 recorded. That is not fixed
|
||||
here and must not be claimed: this card bounds THE PEAK GUARD's OWN forced
|
||||
discharge, which is the thing SAFETY-07 introduced. A loop-side SoC guard
|
||||
is a different lever with a different right value.
|
||||
|
||||
3. IT IS GRADED ONLY WHERE IT IS NOT DEGENERATE. The SoC question is asked
|
||||
AFTER `wanted > 0` is established - i.e. only where a forced discharge is
|
||||
actually being asked for. Asked earlier it would grade a quantity that
|
||||
governs nothing, and would report "floor-limited" on a quiet afternoon
|
||||
where the guard was never going to discharge anyway.
|
||||
|
||||
4. AN UNREADABLE SoC IS NOT A FULL BATTERY. `soc=None` is floor-limited, not
|
||||
permitted. This is `PeakReading`'s lesson in the one place it bites
|
||||
hardest: a full battery is exactly the value that would disarm this floor,
|
||||
so a failed read defaulting to one would silently restore the unbounded
|
||||
discharge this card exists to remove. Both new arguments are REQUIRED
|
||||
keyword-only and neither has a default, which is TEST-14 read as a rule
|
||||
rather than as a story: a required field that gains a default is how three
|
||||
separate files in this repo shipped a quiet fallback.
|
||||
|
||||
AND FAILING TO SHAVE MUST NOT BE SILENT. The whole point of the guard is the
|
||||
euro figure, so a floor-limited cycle carries `floor_limited=True` and main.py
|
||||
raises ONE notification on the edge, saying the quarter will be exceeded - the
|
||||
same shape, and the same argument, as `saturated`. The two are mutually
|
||||
exclusive by construction: a floor-limited decision asks for nothing, so it
|
||||
cannot have run out of inverter.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
CFG-03: TWO NEW OPTIONS, MIRRORED ONCE, AND WHY BOTH ARE NEEDED
|
||||
|
||||
SAFETY-07 added none and said so. This card cannot: an EMS-side floor is a
|
||||
number only the site knows, and refusing one set below the pack's own cut-out
|
||||
means knowing that cut-out too.
|
||||
|
||||
* `peak_soc_floor` - the EMS floor. ⚠️ NOT `maintenance_soc_floor`. That is
|
||||
the maintenance cycle's DRAIN TARGET: the depth a monthly recalibration
|
||||
deliberately goes to, once, under supervision. This is the depth a
|
||||
money-saving discharge may never reach, on any day of the month. Sharing
|
||||
one key would couple two features whose right values differ and would widen
|
||||
CFG-03 rather than narrow it.
|
||||
* `bms_soc_floor` - the pack's own cut-out (register 0x0560 on the reference
|
||||
unit, 10 %). It is here so validate.py rule 8 can REFUSE a `peak_soc_floor`
|
||||
at or below it. That configuration is CFG-01 rule (a): the operator states
|
||||
a floor, the BMS enforces a different one, and the status page reports an
|
||||
energy backstop that can never bind.
|
||||
|
||||
Both defaults are mirrored ONCE, in `PEAK_OPTION_DEFAULTS` below, which is
|
||||
TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern - imported by validate.py and by
|
||||
main.py, pinned to config.yaml by test_peakguard.py rather than transcribed.
|
||||
This module still reads no option itself: `decide()` takes both as ARGUMENTS,
|
||||
makes no `.get()` call, and the mirror is a default table, not a read.
|
||||
|
||||
⚠️ A DECISION RECORDED, NOT A RENAME DONE. Now that a second SoC floor exists,
|
||||
`maintenance_soc_floor` is the ambiguous name of the two - it reads like "the
|
||||
SoC floor" and is not. Renaming it is an option migration on a live install for
|
||||
a clarity gain, so the decision recorded here is: KEEP IT, and carry the
|
||||
distinction in DOCS.md and in this comment. Revisit if a THIRD floor appears -
|
||||
a loop-side guard would be the third - at which point the migration buys enough
|
||||
to be worth its cost.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
AND IT READS NO PER-PHASE QUANTITY, ON PURPOSE
|
||||
@@ -287,8 +368,44 @@ BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
|
||||
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
|
||||
# for BLIND_*'s reason: the status page, the action log and the operator
|
||||
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||
"cycle, and an unreadable SoC is not a full battery")
|
||||
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR THIS CARD'S OPTIONS, and
|
||||
# it is TARIFF-02's `PRICE_OPTION_DEFAULTS` pattern for TARIFF-02's reason: the
|
||||
# add-on already carries option defaults in three places (config.yaml's
|
||||
# `options:`, config.yaml's `schema:`, and main.py's literal `.get()`
|
||||
# fallbacks), and a fourth is the open ticket. validate.py and main.py both
|
||||
# import THIS dict rather than typing a literal, and test_peakguard.py pins it
|
||||
# to config.yaml by READING the file - so a drift goes red instead of shipping.
|
||||
#
|
||||
# ⚠️ NOT A READ. `decide()` takes both values as arguments and this module makes
|
||||
# no `.get()` call at all; the dict is a default table for its two callers.
|
||||
PEAK_OPTION_DEFAULTS = {
|
||||
# The EMS-side floor on SAFETY-07's forced discharge, in percent.
|
||||
#
|
||||
# 20 is DERIVED from the two numbers this repository already has, not
|
||||
# chosen. The reference pack is 300 Ah at 51.2 V with DoD 90 - 13.8 kWh
|
||||
# usable, the figure validate.py's rule 6 note already quotes - and the
|
||||
# 2026-08-20 replay models 878 Wh of guard discharge in a day, which is
|
||||
# 6.4 % of that. A floor 10 points above the pack's own 10 % cut-out
|
||||
# therefore leaves the guard roughly 1.6 of its worst observed days of room
|
||||
# before it stops, which is the smallest margin that lets the guard do its
|
||||
# job on a bad day and still never reach the BMS.
|
||||
"peak_soc_floor": 20,
|
||||
# The pack's OWN cut-out, in percent: what the BMS enforces regardless of
|
||||
# anything here. On the reference unit this is register 0x0560, read as 10,
|
||||
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
|
||||
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
|
||||
"bms_soc_floor": 10,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeakDecision:
|
||||
@@ -309,9 +426,19 @@ class PeakDecision:
|
||||
excess_w: float = 0.0
|
||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||
saturated: bool = False
|
||||
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
|
||||
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||
# construction: this branch asks for nothing, so it cannot have run out of
|
||||
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
|
||||
floor_limited: bool = False
|
||||
soc: float | None = None
|
||||
soc_floor: float | None = None
|
||||
soc_floor_reason: str | None = None
|
||||
reason: str = ""
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.floor_limited:
|
||||
return self.reason
|
||||
if not self.engaged:
|
||||
if self.projected_w is None:
|
||||
return f"peak guard idle - {self.reason}"
|
||||
@@ -383,7 +510,44 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
||||
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||
|
||||
Returns None when it may, and otherwise the REASON it may not - which the
|
||||
decision, the status page, the action log and the operator notification all
|
||||
carry, because "floor-limited" on its own tells nobody what to do.
|
||||
|
||||
THE ONE PLACE THIS QUESTION IS ANSWERED, and it is public for the reason
|
||||
`freeze.looks_frozen()`, `watchdog.loop_is_following()` and
|
||||
`shaping_suspended()` are: a safety predicate transcribed into its own check
|
||||
is one copy plus a future disagreement about what the check means, and the
|
||||
copy that drifts is always the grading one.
|
||||
|
||||
⚠️ `soc is None` IS NOT PERMISSION. A failed read is not an observation, and
|
||||
a FULL BATTERY is exactly the value that would disarm this floor - so a
|
||||
`float(soc or 100)` here would restore the unbounded discharge this card
|
||||
removes, silently, on the one install whose SoC sensor is misnamed. There is
|
||||
deliberately no default argument on either parameter.
|
||||
|
||||
⚠️ AND `soc_floor is None` IS NOT PERMISSION EITHER, for the same reason one
|
||||
step further out: a caller that has no floor configured has not thereby been
|
||||
granted an unbounded one.
|
||||
|
||||
Boundary: `<=`, not `<`. A pack sitting exactly ON the floor has already
|
||||
spent everything this guard was allowed to spend; permitting one more cycle
|
||||
there would make the floor mean "one cycle below the floor".
|
||||
"""
|
||||
if soc is None:
|
||||
return FLOOR_SOC_UNREADABLE
|
||||
if soc_floor is None:
|
||||
return ("no peak guard SoC floor is configured, so there is nothing to "
|
||||
"bound a forced discharge with")
|
||||
if float(soc) <= float(soc_floor):
|
||||
return FLOOR_AT_SOC_FLOOR
|
||||
return None
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
@@ -395,6 +559,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
||||
claim can NEVER produce `lo > hi` in the resolver - a
|
||||
contradiction there resolves to 0 W, which would park the
|
||||
battery in the exact minute it should be discharging hardest.
|
||||
`soc` SAFETY-24. MEASURED state of charge, percent. None when the
|
||||
sensor for it did not answer - which is floor-limited, not
|
||||
permission.
|
||||
`soc_floor` SAFETY-24. The percent below which this guard may not spend
|
||||
the pack for money. REQUIRED, and deliberately without a
|
||||
default: a default here would be CFG-03's fourth defaults
|
||||
site and TEST-14's quiet fallback in one line.
|
||||
"""
|
||||
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||
ceil_top = abs(float(achievable_w))
|
||||
@@ -454,12 +625,41 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w,
|
||||
wanted_w=wanted,
|
||||
reason="the load is under the level this quarter can afford")
|
||||
|
||||
# SAFETY-24 - AND ONLY HERE. `wanted > 0` is now established, so a forced
|
||||
# discharge really is being asked for and the SoC is the quantity that
|
||||
# decides whether it may be sized. Asked one line earlier it would grade a
|
||||
# number that governs nothing on every quiet cycle of the day, and would
|
||||
# report "floor-limited" over a guard that was never going to discharge.
|
||||
denied = discharge_permitted(soc, soc_floor)
|
||||
if denied is not None:
|
||||
# 0.0, not `wanted`, and not a negative number. Zero forbids CHARGING -
|
||||
# one-directional, cannot cost pack energy - and forbids nothing else;
|
||||
# a `lo` limit structurally cannot cap the discharge the loop is already
|
||||
# doing. See the module note: this bounds THE GUARD's forced discharge,
|
||||
# not the grid-following loop, which still has no SoC guard.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
||||
soc_floor_reason=denied,
|
||||
reason=(
|
||||
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
|
||||
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
|
||||
f"it, but {denied}"
|
||||
+ ("" if soc is None else f" ({float(soc):.0f} %")
|
||||
+ ("" if soc is None or soc_floor is None
|
||||
else f" vs {float(soc_floor):.0f} %")
|
||||
+ ("" if soc is None else ")")
|
||||
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
|
||||
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
|
||||
|
||||
floor = min(wanted, ceil_top)
|
||||
saturated = floor < wanted
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated,
|
||||
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
@@ -513,5 +713,13 @@ def claim(decision: PeakDecision) -> Claim | None:
|
||||
"""
|
||||
if not decision.engaged or decision.floor_w is None:
|
||||
return None
|
||||
# Three reasons, not two. A `lo = 0 W` claim reaches the arbiter from THREE
|
||||
# different states now - blind, floor-limited, and a genuine floor that
|
||||
# happens to be zero - and the arbiter's explain() is what an operator reads
|
||||
# when the battery stops charging. Spelling them the same way would make the
|
||||
# SAFETY-24 case indistinguishable from the SAFETY-18 one on the one line
|
||||
# that gets read.
|
||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||
reason=("blind" if decision.blind else "quarter peak"))
|
||||
reason=("blind" if decision.blind
|
||||
else "SoC floor" if decision.floor_limited
|
||||
else "quarter peak"))
|
||||
|
||||
@@ -131,6 +131,25 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
installed base is on a fixed or static tariff. Refusing to start without a
|
||||
price feed would lock those sites out of the add-on entirely.
|
||||
|
||||
8. `peak_soc_floor` at or below `bms_soc_floor`. [(a)]
|
||||
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
||||
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
||||
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
||||
own cut-out and enforces it regardless. Set the EMS floor at or below that
|
||||
cut-out and THE BMS WINS: the EMS bound can never be the thing that stops
|
||||
the discharge, so the add-on carries an energy backstop that is incapable
|
||||
of binding while its status page reports one - rule 5's "a disarmed guard
|
||||
that advertises itself as armed", on the guard whose failure mode is a pack
|
||||
that is quietly never available when it is wanted.
|
||||
|
||||
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
||||
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
||||
EMS floor buys ZERO margin - it can never prevent the pack reaching the BMS
|
||||
limit, which is the entire thing this option exists to prevent. That is not
|
||||
"close to the rail", it is the disarmed case with an extra step. Rule 6
|
||||
permits equality because equality there leaves one reachable point that
|
||||
works; here it leaves none.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -151,12 +170,17 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
||||
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
||||
not refused.
|
||||
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
||||
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
||||
every ordering of the two is a legitimate site configuration. Refusing on
|
||||
an ordering would be inventing a relationship the system does not have.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
|
||||
@@ -370,6 +394,36 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
|
||||
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
||||
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
||||
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
|
||||
# comment documents a LIVE divergence in this repo caused by typing 2000
|
||||
# here and 2500 in main.py; this card refuses to add a second instance of
|
||||
# it. The dict is the single in-code mirror, pinned to config.yaml by
|
||||
# test_peakguard.py, and main.py reads the same one.
|
||||
soc_floor = _number(opts, "peak_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
||||
bms_floor = _number(opts, "bms_soc_floor",
|
||||
PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
"peak_soc_floor and bms_soc_floor must both be numbers (got "
|
||||
f"{opts.get('peak_soc_floor')!r} / {opts.get('bms_soc_floor')!r})")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
||||
f"(bms_soc_floor) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
||||
"the pack's protection rather than yours. The guard would report a "
|
||||
"SoC floor on its status page while being incapable of ever being "
|
||||
"the thing that stops the discharge, and the symptom of that is a "
|
||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
||||
f"(the shipped default is "
|
||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
||||
"bms_soc_floor if it does not match this pack.")
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||
|
||||
@@ -163,6 +163,37 @@ options:
|
||||
peak_forecast_entity: ""
|
||||
peak_cap_w: 3500
|
||||
|
||||
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
|
||||
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
||||
# peak_cap_w. Until this card it was bounded from below by nothing but the
|
||||
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
|
||||
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
|
||||
#
|
||||
# peak_soc_floor is the percent below which that discharge is not sized. When
|
||||
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
|
||||
# floor), and the add-on raises ONE notification saying the quarter will be
|
||||
# exceeded - failing to shave is never silent, because the euro figure is the
|
||||
# whole point of the guard.
|
||||
#
|
||||
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
|
||||
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
|
||||
# depth the cycle deliberately goes to, once a month, on purpose. This is the
|
||||
# depth a money-saving discharge may never reach on any day. The right values
|
||||
# differ, and sharing one key would couple two features that want to move
|
||||
# independently.
|
||||
#
|
||||
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
|
||||
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
|
||||
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
|
||||
# and still never reaches the BMS.
|
||||
peak_soc_floor: 20
|
||||
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
|
||||
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
|
||||
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
|
||||
# it: such a floor can never bind, the BMS wins, and the status page would
|
||||
# report an energy backstop that does not exist.
|
||||
bms_soc_floor: 10
|
||||
|
||||
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
||||
# add-on delegating the whole price question to Home Assistant: main.py reads
|
||||
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
||||
@@ -343,6 +374,12 @@ schema:
|
||||
|
||||
peak_forecast_entity: str?
|
||||
peak_cap_w: int(500,15000)
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
# a supported way to have no energy bound at all. The ranges OVERLAP on
|
||||
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
|
||||
# rule 8 is what refuses the pair, with a message naming both numbers.
|
||||
peak_soc_floor: int(5,60)
|
||||
bms_soc_floor: int(0,50)
|
||||
price_now_entity: str?
|
||||
price_avg_entity: str?
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user