Skip to content

feat(#638): the overnight joint planner — pure packer + shadow mode (G2+G3) - #754

Open
traktore-org wants to merge 246 commits into
mainfrom
release/1.8
Open

feat(#638): the overnight joint planner — pure packer + shadow mode (G2+G3)#754
traktore-org wants to merge 246 commits into
mainfrom
release/1.8

Conversation

@traktore-org

Copy link
Copy Markdown
Owner

One schedule for every floor: EV per-charger night targets (#634), load
min-runtime deficits (#620), battery pre-charge — packed over one hourly
price curve under one peak cap in one priority order (#576).

coordinator/overnight_planner.py — plan_overnight(): a greedy priority-
packer over price-sorted slots. Deliberately NOT an optimizer: pure,
deterministic (ties break price->start->priority->id), ~170 lines, every
allocation carries a one-line reason. Device floors are respected per
slot: EV min-amps (a thin slot is skipped, a continuous battery takes
it), switch loads are binary, the last slot is shortened rather than
over-delivered. Floors stay guarantees — the packer moves WHEN/how-fast,
never WHETHER; what doesn't fit under the cap is REPORTED as yielding
(the 22:00 answer: "everything fits / X yields").

Shadow (G3): _shadow_overnight_plan runs after scheduler.evaluate — the
same trigger cadence — building demands from the REAL models:
build_night_target_map per charger (mode-gated; the #652 closure — the
INFO line logs the scheduler's phantom EV model against the real map),
load runtime deficits, the scheduler's own battery deficit_kwh. Hourly
slots via tariff get_price_at; cap = peak_limit - expected overnight
home. Logs summary at INFO, allocations at DEBUG, stashes
_overnight_shadow_plan for diagnostics. Never actuates; any internal
error degrades to a debug line (test-pinned with a hostile fake).

test_battery_scheduler_rolling_horizon: the get_price_at assert_not_called
pin loosened to what it meant — break-even rates come from the series
(the 0.99 sentinel proves it); the shadow's curve reads are legitimate.

Corpus: 13 packing scenarios incl. the #634 ensemble shape (EV + heater +
battery under one cap — every floor met where reactive greed collides).

Verification ladder: G1 baseline + G3 shadow accrue tonight on PROD from
the same night; G4 (actuation flip) only on explicit call.

Refs #638 #652 #634 #630 #576 #620

…G2+G3)

One schedule for every floor: EV per-charger night targets (#634), load
min-runtime deficits (#620), battery pre-charge — packed over one hourly
price curve under one peak cap in one priority order (#576).

coordinator/overnight_planner.py — plan_overnight(): a greedy priority-
packer over price-sorted slots. Deliberately NOT an optimizer: pure,
deterministic (ties break price->start->priority->id), ~170 lines, every
allocation carries a one-line reason. Device floors are respected per
slot: EV min-amps (a thin slot is skipped, a continuous battery takes
it), switch loads are binary, the last slot is shortened rather than
over-delivered. Floors stay guarantees — the packer moves WHEN/how-fast,
never WHETHER; what doesn't fit under the cap is REPORTED as yielding
(the 22:00 answer: "everything fits / X yields").

Shadow (G3): _shadow_overnight_plan runs after scheduler.evaluate — the
same trigger cadence — building demands from the REAL models:
build_night_target_map per charger (mode-gated; the #652 closure — the
INFO line logs the scheduler's phantom EV model against the real map),
load runtime deficits, the scheduler's own battery deficit_kwh. Hourly
slots via tariff get_price_at; cap = peak_limit - expected overnight
home. Logs summary at INFO, allocations at DEBUG, stashes
_overnight_shadow_plan for diagnostics. Never actuates; any internal
error degrades to a debug line (test-pinned with a hostile fake).

test_battery_scheduler_rolling_horizon: the get_price_at assert_not_called
pin loosened to what it meant — break-even rates come from the series
(the 0.99 sentinel proves it); the shadow's curve reads are legitimate.

Corpus: 13 packing scenarios incl. the #634 ensemble shape (EV + heater +
battery under one cap — every floor met where reactive greed collides).

Verification ladder: G1 baseline + G3 shadow accrue tonight on PROD from
the same night; G4 (actuation flip) only on explicit call.

Refs #638 #652 #634 #630 #576 #620
…ties + docs

Guido's review caught the modeling gap: WHERE a demand draws from changes
what constrains it. A Tier-2 "finish overnight from battery" load touches
no grid meter — no peak cap, no price — it spends the shared battery
budget (usable kWh above the reserve SOC). Demand.source ('grid'|'battery')
+ plan_overnight(battery_budget_kwh): battery demands pack time-ordered
(earliest, price-blind), cost 0, never consume slot cap; grid demands
(EV floors — never-from-battery standing rule — cheap-hours loads, the
battery pre-charge) compete for cap and pay the curve. 4 new corpus
scenarios pin it.

Priority sourcing corrected to the canonical one-list accessors (#576):
EV via _ev_priority_for(cid) (drag override wins immediately — same
accessor decide() uses; the cfg dict key was a weaker source), battery
via _device_registry.battery_surplus_priority() (the config key the
shadow read does not exist), loads unchanged (dev.priority). Expected
overnight home draw now uses _expected_night_home_w(energy) — the same
estimator the EV peak-managed rate uses — instead of a flat 300 W.

docs/OVERNIGHT_PLANNER.md: worked examples — with a peak cap (EV owns
the cheapest hours, a binary heater skips the thin remainder and still
fits, the battery soaks leftover cap), without one (everyone overlaps
freely, the report still catches deadline-unreachable floors), the
Tier-2 free-rider, and the 22:00 fits/yields answer. Priority = the one
drag list; accuracy = calibrated rated power + replan triggers.

Refs #638 #576 #652
…enabled

Caught live on PROD within the first hour of the soak: zero OVERNIGHT-PLAN
lines. Root cause — not log level (PROD logs SEM at debug) but placement:
the shadow ran inside `if scheduler.enabled:` and
battery_charge_scheduler_enabled defaults False (PROD: static tariff,
summer, scheduler idle). An install without battery night charging still
has EV floors and Tier-2 loads to plan every night.

_run_battery_pipeline now carries its own once-per-evening trigger
(_shadow_plan_date stamp, at the scheduler's trigger_hour) OUTSIDE the
enabled branch; when the scheduler IS enabled its evaluate/replans still
recompute on top. phantom=None (scheduler off) renders as "n/a" in the
#652 evidence line instead of crashing the format.

Pinned by test_shadow_fires_without_the_battery_scheduler (placement +
None-phantom path).

Refs #638
…review)

One shared structure over existing accessors; four planners pack into it;
explicit simplicity contract (non-goals). Red-team verified: 6/7 claims
confirmed, discharge-limit refutation folded in (SEM's flow rules exist —
the ledger reads them, doesn't reinvent). Refs #638
…iced slots honest + replan, level-consistency for cheap-hours loads

Guido's stress test ('will this survive Rien's tariff or just mine?'):
the ledger is provider-agnostic by construction, but three clauses were
implicit and one was a trap — packing a cheap_hours load into the
cheapest slot of a flat/static curve that execution's price_is_cheap
level-gate would never fire. Refs #638
… consciously

build_night_ledger walks the night once: home draws the battery to the
sunrise floor (power-bounded), the remainder lands on the meter, headroom
follows — the takeover hour is DERIVED. Every battery mutation (Tier-2
draw, pre-charge arrival) re-walks the trajectory, so one Tier-2 kWh
tonight honestly moves the takeover earlier and shrinks later headroom.
pack_night packs demands by the one list against the ledger with the
spec's tariff clauses: unpriced slots (day-ahead not published) are packed
around and noted for the fingerprint replan; cheap-hours loads pack only
into LEVEL-cheap slots (execution's price_is_cheap gate — a flat/static
night with no cheap level yields honestly instead of scheduling a run
execution would never fire); slots follow the market granularity (15-min
native). #688 anti-cycle quantization: runs shorter than min-run are
planned AT min-run (what can_deactivate would do), battery runs stay
energy-capped (the reserve gate), and blocks of one load never sit closer
than min-gap — contiguous slots are one continuous run. plan_overnight
stays as a thin flat-slot adapter for the parked shadow hook.

Corpus revision (conscious, per spec): the sunrise-floor scenario had
Tier-2 outranking home — spec says home's claim on the battery is
absolute, so 0.5 kWh above the floor goes to home's first hour and Tier-2
yields with zero; the min-gap scenario asserted a gap between contiguous
blocks — contiguous is one run, a REAL gap (non-adjacent cheap slot inside
the pause window) is now forced and pinned. Packer bug caught by corpus:
battery constrains ENERGY/duration, not power — a binary 2 kW heater with
1 kWh above floor runs 2 kW for 30 min, not "average 1 kW below its floor".

31 tests green (13 flat-packing kept via the adapter + 4 source-axis +
trajectory/takeover/sunrise/pre-charge + tariff robustness + quantization).

Refs #638
…level_at

The shadow hook now builds the ledger the spec describes instead of flat
capped slots: live SOC → kWh, sunrise floor = max(reserve, the scheduler's
public target_soc), home per slot from the weekday-aware hourly profile
(flat _expected_night_home_w fallback), honest None prices for unpriced
day-ahead tails, and per-slot cheap LEVELS from the new shared accessor.
build_night_ledger derives the takeover hour; pack_night packs against it;
the log now states "battery carries home until HH:MM — the grid takes
over" and the stash carries the takeover for diagnostics. Load demands
gain needs_cheap_level (cheap_hours) + the #688 min_run/min_gap windows.

tariff: get_price_level_at(when) — base None (unknown = conservatively
not-cheap), Static/Calendar by their NT rule, Dynamic from the classified
series point covering the moment. One shared source of level truth so the
plan packs cheap-hours loads exactly where execution's price_is_cheap
would fire (spec §tariff clause 3); execution can adopt the same accessor
at G4.

Shadow test updated by design: while the battery carries home, home is
NOT on the meter — the full peak is headroom (the old flat model
subtracted home from every slot; the trajectory is exact) — and the
no-takeover night is pinned. TestPriceLevelAt pins the accessor.

Refs #638
…ked-state Tier-2 avail + predictor gap hours

Reviewer HIGH (real): _walk recomputed headroom from peak − home_grid,
WIPING in-place grid decrements — after a Tier-2 draw re-walked the
trajectory, a later grid demand could over-subscribe a slot the EV
already filled and the plan would log a confidently wrong "fits".
LedgerSlot.grid_committed_w now records allocations and every walk
derives headroom = peak − home_grid − committed (cap-override path too).

Reviewer HIGH (refuted with evidence, pinned): higher priority number =
first claim IS the one-list semantics (get_devices_sorted reverse=True;
decide()'s reclaim gate; DEFAULT_BATTERY_SURPLUS_PRIORITY=100 outranks
the default charger 3 by design — battery charges before the EV yields
surplus, day and night). The negation is correct; a corpus test now pins
the realistic values so the semantics cannot drift silently.

Reviewer MEDIUM ×2 (real): Tier-2 avail now reads the WALKED state
(soc + batt_in − floor − home's ACTUAL battery share − booked draws) so
a second battery demand in the same slot is not under-scheduled; and
_home_at treats the profile's 0.0 fallback hours as data gaps (a 0 W
house is not a forecast) falling back to the flat estimate.

36 corpus/shadow tests green.

Refs #638
… evening

hour >= 21 went blind between midnight and morning — a restart at 00:48
(caught live on TEST) still owes the rest of the night a plan. Trigger =
inside the window (>= trigger_hour or < 07), stamped by night-of date
(now − 12h) so one night never double-fires across midnight.

Refs #638
…ver gate

Second placement lesson caught live on TEST (observer rig): the trigger sat
inside _run_battery_pipeline, which is gated on 'not observer_mode' — the
shadow, itself a log-only observer construct, was silenced exactly where
shadow computation belongs. Moved above the gate; runs in both modes.

Refs #638
Journals rotate (ha core logs caps at 100 lines — the one-shot nightly
INFO burst scrolls off under 10s observer chatter); the stashed plan
doesn't. diagnose now carries overnight_plan_shadow when a plan exists —
the G3 verification surface and the 22:00 answer on demand.

Refs #638
…NG on failure

Three placement bugs were invisible because every degraded path was
silent or debug-level in a 100-line journal window. 'Nothing needs the
night' is itself a valid 22:00 answer (INFO + stashed record); an empty
window says so; an internal failure logs WARNING during the shadow soak.

Refs #638
The first refresh after a restart sees ZERO registered devices (delayed
rediscovery) — the shadow fired there, answered 'no demands' and stamped
the whole night (caught live via the self-explaining counts:
loads_seen=0, ev_targets={}). The hook now returns False on that
degenerate warm-up shape so the trigger retries next cycle and converges
the moment registration completes; real answers (incl. a genuine
nothing-needs-the-night with loads_seen>0) stamp as before, and a
failure stamps too (one WARNING per night, not per cycle).

Refs #638
…and gate

Finding #2 (PROD 02:14): the SOC sensor read unavailable at the first
post-deploy refresh — None became 0 kWh, a 77%-full battery planned as
empty and the ledger derived a bogus 03:00 takeover (reality: home rode
the battery all night, 98→64%, 0.48 kWh grid). Power readiness is the
second warm-up dimension: a configured battery with no SOC reading is a
not-ready world — the hook returns False and the trigger retries.

Finding #1 (same plan): the off-mode heizband "YIELDS 3.1 kWh" — but
compute_load_intent never night-runs an off/peak_only device; the demand
builder now mirrors the intent gate (SURPLUS mode only).

With both inputs corrected, the model's prediction for last night matches
what the hardware actually did. Two pins added; 39 green.

Refs #638
…k, not average

Guido's review from the config card: the Battery total discharge limit
(battery_max_discharge_power — the exact knob the ledger already reads)
was applied per consumer, not as the shared inverter capability. And the
corpus caught my first fix summing AVERAGE power — allocations start at
slot start and OVERLAP, so the bound is instantaneous: 500 W home + a
1 kW pump for 30 min + a second 1 kW pump is 2.5 kW while they overlap.
LedgerSlot.batt_out_w tracks concurrent committed power; a draw that
would exceed the limit packs into a later slot or yields. Pinned.

Refs #638
Guido: 'this kind of explication I like to get for the doc as well — good
example and drawing, just to understand what SEM does at this stage.' The
doc now leads with the four steps (ledger table → trajectory walk with the
takeover drawing → drag-order packing with the coupling timeline → the
22:00 answer), the yield-is-a-report semantics, when it runs, where to see
it, and the two battery limits — written for any user, worked example
numbers, no vendor comparison.

Refs #638
Keeps TEST/PROD = shadow + latest 1.7.5-train fixes (the coexistence
model: fixes reach the soaked machines through the soak owner's branch).
Refs #638 #689
… self-heal guards

The soak owns TEST (and PROD) — bug fixes reach the hardware through this
branch so there is a single writer. Shadow observation is unaffected: neither
fix touches the overnight planner.

Refs #688, #690, #638
…r 18:30

The planner window is now→night_end, so slot count (and every energy figure
derived from it) moved with the hour the suite ran at. Written just after
midnight, where a 7-slot night made "400 W fits under the 5 kWh above the
floor" true; from ~18:30 local the window is 14 slots, 5.6 kWh, and the
battery hands over to the grid at 05:00 — a correct plan failing a
wall-clock-shaped assertion. Freeze at 22:00, the hour the real trigger fires.

Refs #638
… a night plan on it

Live-caught on HA-TEST during the G3b scenario battery. Two restarts
minutes apart produced ledgers 2.4 kWh apart (12.2 → 9.8 kWh usable)
while the published fleet SOC never moved. The floor was 4.5 kWh in both
runs (30% x 15.0 kWh), so capacity was stable and the swing had to be
SOC — and 9.8/15.0 = 65.3% is battery 2's SOC to the decimal.

The boot log confirms it: 10 s into the restart battery 1's power sensor
was still unavailable, so the SOC average was taken over the ONE unit
that could be read, and the shadow planner — which fires ~2 min after
boot and stamps its answer for the whole night — built the ledger on a
battery 1.7 kWh smaller than the real one. Consequence in that run:
takeover moved 2 h earlier (01:00 vs 00:00 in the sibling run) and a
tier-2 load was told it "YIELDS 0.4 kWh" it would in fact have received.

This is the len>1 fleet-read class again (beta.11 ev_power, #616
ev_connected): an aggregate silently degrades to the subset of members
that happen to be readable, and nothing downstream can tell.

Two layers, because the reader lying is the root and the planner acting
on the lie is the damage:

- sensor_reader records SOC coverage (expected vs read) and warns once
  per gap instead of averaging a subset in silence.
- PowerReadings carries battery_soc_partial / units_expected /
  units_read, so any consumer that must not act on a half-resolved
  fleet can ask.
- _shadow_overnight_plan gains a THIRD readiness dimension: a partial
  fleet is a not-ready world, retry next cycle. Readiness was already
  "configured battery" (finding #1) and "SOC readable" (finding #2);
  "every configured unit readable" is the missing one.

Tests: TestPartialFleetIsNotReady — partial retries, complete plans, and
a single-battery install is never partial (flag absent reads as ready).
Mutation-checked: disabling the gate fails the first test.

Refs #638
… a warming one, and bound the wait

Review of 5844e7c found the gate could starve the very thing it protects.
Two ways:

1. BLOCKER — ``_soc_units_expected`` counted CONFIGURED units, but a unit
   whose SOC sensor auto-detect returns None is skipped every cycle. On an
   install where one battery's SOC sensor isn't discoverable (name carries
   no SOC keyword, no device-registry match, ambiguous global scan), the
   fleet reads 1-of-2 forever — permanently "not ready", so that install
   would never get a night plan at all. ``expected`` now counts units whose
   SOC sensor was DISCOVERED, and the undiscoverable case gets its own
   warning in its own words: no amount of waiting fixes a config gap, so it
   must not be phrased as "still warming".

2. HIGH — the wait was unbounded. A permanently offline unit (dead battery,
   integration removed) held the plan back every cycle, all night. No plan
   is worse than a plan on a visible subset, so the wait is now bounded by
   _SHADOW_PARTIAL_GRACE_S (10 min): a boot warm-up resolves in seconds and
   always wins the wait, while a unit silent past the window is treated as
   offline — plan on what reports, WARNING once, and carry the shortfall on
   the plan itself as ``battery_fleet_partial`` so a degraded plan can never
   read like a healthy one. That silence is what made this bug invisible.

Also: reset the one-shot partial warning when nothing is readable, so
partial → all-offline → partial is heard twice instead of once; declare the
coverage/one-shot flags and the shadow's own state in __init__ instead of
lazy getattr.

Tests: +3 coordinator (bounded wait plans anyway with the note, the clock
starts on the first short cycle, a whole fleet clears clock and note) and
+4 reader (TestFleetSocCoverage: undiscoverable is not "expected",
discovered-but-unavailable is, full coverage, one-shot reset). 20 in the
file, 1088 in the SOC/battery/reader subset.

Refs #638
… on whether it is reporting

The live re-verify on TEST contradicted my own root cause, which is the
useful part. After deploying the coverage fix the log said:

  No SOC sensor could be found for 1 of 2 configured battery units

— at 23:31, nowhere near a boot. So battery 1's SOC was not "warming up"; it
was *undiscoverable on that cycle*. And the earlier 19:46 plan had happily
averaged both units (81.25% = mean of 97.5 and 65). Same rig, same config,
two different answers minutes apart.

Root cause, one level below the fleet average: every strategy in
_auto_detect_battery_soc rejects a candidate whose state is currently
unknown/unavailable — sound when CHOOSING between candidates, wrong as a
permanent verdict, because detection runs every cycle and nothing is
remembered. So the mapping battery-power-sensor → SOC-sensor is a function
of whether the SOC sensor happens to be reporting right now. A blip on
battery 1 therefore did not report "battery 1's SOC is unreadable" (a
partial fleet — something you can wait for); it reported "battery 1 has no
SOC sensor" (a whole fleet of one), and the average became battery 2's 65%
with the coverage counters agreeing that 1-of-1 is complete.

That also explains why the previous commit's careful discovered-vs-readable
split couldn't work: both sides were computed from the same state-dependent
function, so they always agreed.

Fix: remember the mapping. Once resolved, a battery's SOC entity stays its
SOC entity; only an entity that no longer exists at all (renamed, removed)
earns a re-detect. A later unavailable state is then what it actually is — a
read failure of a known sensor, i.e. the transient partial the planner waits
for. Bonus: no full state-machine scan every cycle.

Coverage is now three numbers, because there are three states wanting three
reactions: CONFIGURED units, units whose SOC sensor is KNOWN, units that
READ this cycle. read < known → wait (bounded). known < configured → a real
config gap; waiting never fixes it, so plan now — but the plan says
``battery fleet partial: 1/2 units`` either way. A subset's answer must
never be readable as the fleet's; that silence is the whole bug.

Tests: TestSocEntityIdentityIsSticky (a blip keeps the mapping, a vanished
entity is re-detected), the config-gap plan carries the label without
waiting, and the configured count is recorded. 24 in the file; 1106 green in
the SOC/battery/detection subset (incl. #250/#523/#529 autodetect suites).

Refs #638
…isn't there

The sticky map from the previous commit only helps once a sensor has been
read at least once — and the night plan is stamped ~10 s after boot, before
the modbus units have published anything. TEST proved it: at 23:44 the log
still said "No SOC sensor could be found for 1 of 2 configured battery
units", the plan went ahead on battery 2's 65% alone, and by the time the
fleet read 75% (85/65) the stamp was already made.

The classification was the bug. Detection needs a value before it will name
a candidate, so at boot "no readable candidate" and "no candidate at all"
give the same answer — and the three-count model read that as a
configuration gap, which by design never waits.

Existence is the honest discriminator, so ask it without looking at any
value: _soc_candidate_exists() walks the same longest-stem-first candidate
names as detection (shared _SOC_NAME_KEYWORDS so they cannot drift) plus a
registry-only same-device scan, and counts a present-but-silent sensor as a
KNOWN unit. Then read 1 < known 2 is partial, the shadow waits its bounded
600 s, and the plan lands on the whole fleet. A unit with no candidate at
all still never gets waited for.

No counterpart to detection's global last-resort scan on purpose: that one
is a uniqueness guess, not this unit's sensor identity.

Refs #638
Three findings from the HA-TEST scenario battery, all the same class: the
plan answered a different question than the night loop does.

**Finding #4 — an off-mode charger became a demand.** `build_night_target_map`
answers "how much does this charger still NEED", not "will SEM give it any
tonight". The night loop decides that separately with
`_mode_allows_night_charging` (`off`, and `solar_only` without a per-charger
"At least" floor). Live on TEST: EV mode Off, SEM's own state reading "Night
charging disabled", and the shadow still planned 10 kWh of grid for it
(cost 3.387). The demand builder now asks the same gate; an unevaluable gate
plans the charger anyway (over-planning is visible in the summary, a demand
deleted by a broken gate would not be). Opt-outs are named in the summary and
in the no-demands `why` — "the plan has no EV line" read identically whether
the charger opted out or the builder lost it. Sibling of finding #1 (the
off-mode load).

**Finding #5 — the plan ignored a cap execution enforces.** The packer read
`config["peak_limit_w"]`, a key NOTHING in the repo writes: not the config
flow, not a migration. So it was 0 on every install, the packer ran with
infinite headroom ("headroom left inf W" in the live allocations), and the
plan handed a 10 kW EV slot to a house on a 5 kW limit. Now reads the
canonical execution authority `_get_peak_limit_w()` — load manager first,
`config["target_peak_limit"]` (kW → W) behind it. A cap the plan ignores
makes every `fits` verdict meaningless.

**Review fixes (finding #3 follow-through).** The readiness block now sits
ABOVE `if not demands:` — the "nothing needs the night" answer is stamped for
the whole night exactly like a full ledger, so a half-read world must not
produce one either. That answer carries `battery_fleet_partial` too. An empty
night window clears `_shadow_partial_since` so no later night inherits a
running clock. The SOC existence probe accepts a `battery` device-class
sibling with no unit: the probe only decides whether waiting is worthwhile,
not which sensor to read, so it is deliberately looser than detection's
signature check.

Tests: 40 in test_638_shadow_mode.py (was 29), including a registry-level
existence-probe class — registered-but-stateless counts, disabled does not,
another device's SOC is not a candidate (#250 class). Both new gates
mutation-checked: reverting either fails exactly one named test, and dropping
the fallback's `* 1000` fails the kW-read-as-W mutation. Full suite 5448
passed, 2 skipped, 2 xfailed.

Refs #638
Finding #6, live on TEST. `LoadManager` enters SHEDDING at
`peak >= target_peak_limit` on the 15-minute rolling average
(features/load_management.py:639) and `_progressive_load_shedding` then trims
down to `target - hysteresis` (:773). So an hour booked AT the cap is the one
allocation execution is guaranteed to kill — and that is exactly what the
packer wrote once finding #5 gave it a real cap:

    ev:ev_charger: 5000 W 02:00-03:00 @ 0.339 (slot #1 cheapest, headroom left 0 W)

against a 5.0 kW limit, reported as `fits — 10.0 kWh planned`. Sustained for
the hour that IS the shed trigger.

The ledger now gets the level execution actually holds: cap minus this
install's `peak_hysteresis` (0.2 kW default, read from config rather than
hardcoded, because the shedder settles wherever this install's band puts it).
Never allowed to reach 0 — that is the packer's "no limit at all" sentinel
(`headroom_w = inf`), so a cap smaller than the hysteresis would flip the
TIGHTEST houses on the fleet into planning as if unlimited.

Live proof, same world before and after: `5000 W ×2 slots (headroom left 0 W)`
became `4800 W ×2 + 4140 W 04:00-04:05 (headroom left 660 W)` — the 10 kWh
honestly needs a third block once the margin is respected.

3 tests, each mutation-checked to fail alone: dropping the subtraction fails
the threshold test and the configured-hysteresis test; hardcoding the 0.2
default fails the configured-hysteresis test; relaxing the sentinel guard to
`max(0.0, ...)` fails the tight-cap test. Full suite 5451 passed, 2 skipped,
2 xfailed.

Refs #638
…e 1.7 fixes

The coexistence rule: the enhancement branch periodically absorbs the 1.7
fix lane and PROD redeploys from HERE, so PROD = latest fixes + the soaking
planner with one writer to the hardware. The fix branch itself goes to
develop separately (1.7.5-beta train) — the planner never does (1.8 line).

Refs #638 #692 #693 #694 #695
…branch

Lane reconciliation only — the fixes themselves were already here; this
brings the manifest bump + CHANGELOG header so the next branch deploy
reports the right version. Refs #638
…t closes

evaluate() keeps every economic guarantee (deficit, break-even, anchored
target SOC, charge power, negative-price override, replan triggers) and
loses the window pick: the find_cheapest_hours call, _plan_night_schedule,
_infer_slot_hours, NightChargeSchedule and TimeSlot are deleted, along with
the phantom EV co-model (ev_kwh_needed/ev_max_power_w — #652's model dies
here; the joint plan carries the real per-charger demands).

Entity compat: battery_scheduler_schedule now derives from the stamped
plan's battery blocks (schedule_view_from_plan — the exact as_dict shape,
ev_w honestly 0). Reboot compat: the SCHEDULED verdict persists beside the
plan (serialize/restore_battery_verdict) so a restart mid-block outside the
evaluation window still actuates the restored night.

The one-selector allowlist is EMPTY — the joint planner is the one
selector left, and the ratchet is now the permanent pin. Refs #652
…ch dies

Comfort demands packed as comfort:{did} but the window collector only ever
asked the gate for load:{did} — a comfort block could never reach its
device, so banking runs never actuated. merge_load_gates (pure, in
overnight_actuation) now merges both demands per device: either in-block →
a run verdict; a hold only when EVERY covered demand holds; the earliest
block start wins the until.

PlanVerdict carries in_block — the run half. compute_load_intent (and its
imperative twin, which is what PROD runs) gains the ONE sanctioned place
the plan CREATES a run: a WILLING comfort band inside its planned block
banks from cheap_grid; forced rooms stay reactive; peak_freeze and
anti-cycle still gate the start.

The dead load_window helper and the legacy plan_window bool param are
deleted — the verdict is the only plan input left.
…e; the valve stays closed

The advisor's discharge_blocks finally actuate — under three separated
authorities: the PLAN says WHEN (arbitrage_sell_gate reads the stamped
plan's sell blocks under the same trust rules as every gate), the LIVE
economics say WHETHER (evaluate_arbitrage's verdict is now the in-block
validity check, no longer a window authority), the user's modes say MAY
(per-battery allow_arbitrage scan is real again — the v1.7.3 _any_allow_arb
hardcode is gone — but every DEFAULT keeps the path dormant; #533 stands).

Power discipline: the sell is capped at the block-implied watts (the
advisor bounded delivery by the home's own draw — avoided import, never
export-at-max) and fleet-split via effective_battery_count, resolving the
multi-battery re-activation guard the old branch carried as a warning
comment. Refs #533 Refs #523
…— ×16 languages

The one gate concentrates every decision, so the card now concentrates the
explanation. The plan payload carries structured not_scheduled rows (mode /
disconnected — prose in summary is for logs, not rendering) and a live
coverage map (the per-demand verdict, the user-facing twin of the
'#638 coverage' log line, riding beside actuation so a mid-night fall to
reactive changes the card next cycle, not next stamp).

The Energy Plan card renders: a 'reactive — why' chip on any uncovered
demand (the gate's named doubt translated — no internal jargon reaches a
rendered surface), a 'Not scheduled tonight' list naming every deliberate
exclusion with its why, and block prices on the row tooltips (why THIS
window). 12 new keys ×16 languages; sem-localize regenerated; bundle
rebuilt.
…documented

With the private selectors retired, actuation-off would silently remove all
cheap-window timing from a solar_plus_cheap install — the default flips ON
and switch.sem_overnight_actuation remains the kill-switch.

OVERNIGHT_PLANNER.md gains the one-gate section (no second window-picker
anywhere, the per-family fail-open table, which reactive gates stay senior)
and the user-facing 'How to read tonight's plan' walk. CHANGELOG entry is
deliberately UNNUMBERED — the branch takes its version at merge time; the
autopilot owns develop's number space (the beta-collision lesson).
…ne retires from the card

Guido's first live look at the one-gate card: 'ev_targets={...},
mode_opted_out=[]...' rendered verbatim read as an unfinished placeholder.
The idle payload now carries machine codes (ev_target_met /
no_load_needs_night / battery_no_deficit) plus the structured not_scheduled
rows; the card renders translated sentences (3 keys ×16 languages) with the
prose demoted to the hover tooltip. An unplugged car no longer counts as
'target met' — it appears under Not scheduled tonight with its real why.
…writing

praun's report: the write sent raw watts with no unit validation — a
kW-native setpoint got 3000 (its range clamp turned that into full tilt)
and a current-native number would take watts as amperes. The sibling
discharge-limit path already had the rule (_native_power_scale); the
setpoint write now consults the SAME helper through the new public
native_power_scale — one validation rule, not two.

Non-power units and unreadable states REFUSE loudly (warning once per
adapter, False to the caller so intent is never recorded — the #589
honesty contract). Watts stay the internal currency: the entity's native
min/max scale INTO watts for the clamp and the de-dup, and the value
converts to native only at the service-call boundary — so a kW entity's
de-dup threshold stays 100 W, not 100 kW.
…lusion's other half, finally real

Azlinon's live night: peak reached CRITICAL, the load manager shed his
chest freezers, and the EVSE held 32 A — the single largest controllable
load was exempt from peak management entirely. The actuation exclusion in
load_management stays (#649: one writer per device); what was missing is
the compensating control its docstring assumed: decide() never saw the
peak state during the day.

peak_state now resolves ONCE per cycle into FleetCycleState (the same
effective_peak_state vocabulary the surplus controller gets) and rides the
one-place thread into every charger's view. decide() applies a SENIOR
clamp after mode dispatch — peak shed is a guarantee, senior even to
always_max: SHEDDING clamps the commanded amps to the effective minimum
(vehicle handshake floor respected), EMERGENCY idles the charger
un-bridgeably. The EV throttles on the FIRST cycle of a shed level,
before the LM's delayed progressive shed reaches anyone's freezer.
…harge-unit validation + the #748 surplus seam (beta.14). Refs #747 Refs #749 Refs #748
The #282 persistence was working — PROD's storage held the full session —
but the restart's sensor warm-up published ev_connected=False before the
charger integration had loaded, the end-detection took it as a real
unplug, finalized the session into lifetime stats, and a fresh session
started when charging resumed: 6.0 kWh became 1.6 kWh on the top chip and
the session's cost and solar share were silently rewritten (the real
session was mostly daytime solar; the amputated tail mostly evening grid).

A disconnect is only real once CONFIRMED: never inside the two-minute boot
warm-up window (a sensor that has not spoken yet is not a sensor saying
no — the same class as the 23 kWh target blip logged the same evening),
and only after three consecutive disconnected cycles, which also absorbs
the KEBA UDP blip family (#35/#595). While unconfirmed the edge stays
armed and the session keeps accumulating the moment the sensor returns.
…he last private timeline retires

Live 08.08: the Energy Plan card said WAITS·00:00 while the EV tab's
mini-strip painted the static tariff's reactive prediction. When the gate
covers the charger, compose_today_plan now takes the plan's own blocks:
one charge-start row per future block (detail plan_ev_charge_joint, ×16
languages) and the min-reached row at the LAST block's end — the plan's
promise, not a rate estimate. A malformed block distrusts the set (the
gate's rule); no blocks keeps the legacy composition as the honest
uncovered fallback. One data source, every surface.
…t pessimism — the 1.8 half

The ledger's day_kwh was already the raw forecast (#598), so the packer
sees the sky's own number. The real poisoning was the LEARNING: under an
export limit the measured production is clamped to consumption, and the
dampening tracker read that as 'the forecast over-promised' — sinking the
factor and making every dampened consumer (fleet remaining-solar, the
forecast night target) under-plan exactly the hidden kilowatts the probe
reveals. Only a probe-CONFIRMED day (state harvest — production followed)
skips the sample; a failed probe is plain clouds and still teaches.
Phases 1+2 built the drift models and the direction-aware ask; C5 gave
banking its actuation. The missing depth: the ask sized energy only to
TARGET, but pre-cooling's whole point is buying the band — cool to
target − offset (heat: target + offset) in the cheap/free window so the
room coasts through the expensive one. banking_energy_kwh gains offset_c
(0 keeps the target-depth contract for offset-less callers) and
comfort_plan_demand passes the band's own offset. The run's natural stop
already agrees: crossing the edge flips the state to banked, which ends
the C5 clause's hold. A cooling ClimateDevice end-to-end pre-cool ask is
now pinned — the direction machinery was already in place.
…ws down to 1 A

Two reporters, one ask: vehicle-side control (Tesla BLE, ESP32 numbers)
charges below 6 A, but every current slider was hard-floored at min=6 so
the config could never say what the hardware supports. The sliders now
allow 1–N; enforcement lives where it always belonged — the current write
clamps to the control entity's own min/max (#487) and brand adapters keep
their own minima (KEBA's 6 A is its adapter's truth, untouched). Defaults
stay 6 everywhere: nothing changes for existing installs.

Bonus root-cause: effective_min_amps crashed with TypeError on a
present-as-None ev_min_current (the classic config trap) — hardened with
the or-chain fallback.
…es UI, multi-heat-pump path

#688 (the remaining half): start_reserve_w — a fresh START needs margin on
top of the threshold (600 W pump, 800 W ask) so the start plus the next
cloud doesn't flip the surplus negative and cycle the device. Starts only:
a running load keeps the plain threshold — reserving against a healthy run
would CREATE the cycling this prevents. Settable through the same goal
surface as every #620 knob; default 0 = today's behaviour.

#751: the four power-strategy VALUES (active/idle/self-consume/off) get
their config-card fields, shown when a strategy entity is configured —
Sessy's defaults as placeholders, any integration's option names mappable
(5 i18n keys ×16; the card gains its first generic text-option row).

#685: multiple heat pumps documented as climate devices — each unit gets
its own priority, comfort band, drift model and planner demands; SG-Ready
stays the one-unit relay interface. Split metering → template-sum sensor.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 207 files, which is 107 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fe09a099-5509-438e-9a11-17b5f3a388c4

📥 Commits

Reviewing files that changed from the base of the PR and between 83fb8b6 and 8431889.

⛔ Files ignored due to path filters (1)
  • dashboard/card/dist/sem-cards.js is excluded by !**/dist/**
📒 Files selected for processing (207)
  • CHANGELOG.md
  • README.md
  • __init__.py
  • config_flow.py
  • consts/core.py
  • coordinator/active_phase_guard.py
  • coordinator/arbitrage.py
  • coordinator/battery_adapters/base.py
  • coordinator/battery_charge_scheduler.py
  • coordinator/build_view.py
  • coordinator/charger_types.py
  • coordinator/comfort_drift.py
  • coordinator/coordinator.py
  • coordinator/curtailment.py
  • coordinator/day_ledger.py
  • coordinator/decide.py
  • coordinator/decide_battery.py
  • coordinator/dual_phase_guard.py
  • coordinator/energy_calculator.py
  • coordinator/ev_availability.py
  • coordinator/ev_control.py
  • coordinator/ev_taper_detector.py
  • coordinator/ev_tariff_planner.py
  • coordinator/notifications.py
  • coordinator/overnight_actuation.py
  • coordinator/overnight_planner.py
  • coordinator/phase_current_discovery.py
  • coordinator/plan_verdict.py
  • coordinator/power_control.py
  • coordinator/publish_diag.py
  • coordinator/repair_issues.py
  • coordinator/sensor_reader.py
  • coordinator/storage.py
  • coordinator/surplus_controller.py
  • coordinator/today_plan.py
  • coordinator/types.py
  • coordinator/units.py
  • dashboard/card/build_localize.py
  • dashboard/card/sem-localize.cs.js
  • dashboard/card/sem-localize.da.js
  • dashboard/card/sem-localize.de.js
  • dashboard/card/sem-localize.es.js
  • dashboard/card/sem-localize.fi.js
  • dashboard/card/sem-localize.fr.js
  • dashboard/card/sem-localize.hu.js
  • dashboard/card/sem-localize.it.js
  • dashboard/card/sem-localize.js
  • dashboard/card/sem-localize.nl.js
  • dashboard/card/sem-localize.no.js
  • dashboard/card/sem-localize.pl.js
  • dashboard/card/sem-localize.pt.js
  • dashboard/card/sem-localize.ro.js
  • dashboard/card/sem-localize.sv.js
  • dashboard/card/sem-localize.zh.js
  • dashboard/card/src/base/sem-lit-base.js
  • dashboard/card/src/cards/sem-battery-card.js
  • dashboard/card/src/cards/sem-config-card.js
  • dashboard/card/src/cards/sem-energy-plan-card.js
  • dashboard/card/src/cards/sem-ev-status-card.js
  • dashboard/card/src/cards/sem-grid-card.js
  • dashboard/card/src/cards/sem-load-priority-card.js
  • dashboard/card/src/cards/sem-system-diagram-card.js
  • dashboard/card/src/sem-cards.js
  • dashboard/card/src/util/night-arc.js
  • dashboard/card/src/util/temperature.js
  • dashboard/card/test/night-arc-sliver.test.js
  • dashboard/card/test/temperature-unit.test.js
  • dashboard/sem_dashboard_template.yaml
  • dashboard/translations.json
  • devices/base.py
  • diagnostics.py
  • docs/BUG_CLASSES.md
  • docs/EV_CHARGING_LOGIC.md
  • docs/KNOWN_LIMITATIONS.md
  • docs/OVERNIGHT_PLANNER.md
  • docs/QUICK_START.md
  • docs/README.md
  • docs/SETUP_GUIDE.md
  • docs/TROUBLESHOOTING.md
  • docs/USER_GUIDE.md
  • docs/superpowers/specs/2026-07-28-overnight-flow-plan-design.md
  • features/device_registry.py
  • features/load_device_discovery.py
  • features/load_management.py
  • ha_energy_reader.py
  • hardware_detection.py
  • manifest.json
  • scripts/regenerate_localize.py
  • sensor.py
  • services.yaml
  • strings.json
  • switch.py
  • tariff/calendar_provider.py
  • tariff/tariff_provider.py
  • tests/scenario_harness.py
  • tests/scenarios/2026-08-11_planned_night_window.yaml
  • tests/test_545_min_floor.py
  • tests/test_547_refresh_runtime_config.py
  • tests/test_553_quota_stop.py
  • tests/test_564_battery_temperature.py
  • tests/test_612_price_feed_recipe.py
  • tests/test_618_docs_anchors.py
  • tests/test_620_device_goal_model.py
  • tests/test_629_ev_orchestration.py
  • tests/test_638_actuation.py
  • tests/test_638_arbitrage.py
  • tests/test_638_c3_ev_retirement.py
  • tests/test_638_c4_battery_seam.py
  • tests/test_638_c5_comfort_gate.py
  • tests/test_638_c6_arbitrage_sell.py
  • tests/test_638_c7_legibility.py
  • tests/test_638_comfort_drift.py
  • tests/test_638_coverage_log.py
  • tests/test_638_day_ledger.py
  • tests/test_638_demand_replan.py
  • tests/test_638_mode_matrix.py
  • tests/test_638_night3_findings.py
  • tests/test_638_one_selector.py
  • tests/test_638_overnight_planner.py
  • tests/test_638_pack_order_wpa.py
  • tests/test_638_plan_is_consulted.py
  • tests/test_638_plan_surface.py
  • tests/test_638_planning_peak.py
  • tests/test_638_shadow_mode.py
  • tests/test_641_units.py
  • tests/test_674_translation_parity.py
  • tests/test_681_night_solar_counter.py
  • tests/test_688_start_reserve.py
  • tests/test_705_ph3_precool.py
  • tests/test_705_thermal_comfort.py
  • tests/test_708_diagnostics.py
  • tests/test_708_estimate_falls_while_charging.py
  • tests/test_708_false_full_anchor.py
  • tests/test_708_soc_cap_percharger.py
  • tests/test_708_soc_lag_ceiling.py
  • tests/test_708_soc_provenance.py
  • tests/test_716_night_plan_hardware_model.py
  • tests/test_716_peak_limit_unlimited.py
  • tests/test_717_peak_limit_range.py
  • tests/test_723_view_day_boundary.py
  • tests/test_724_ev_day_boundary_move.py
  • tests/test_728_discrete_tou_normal.py
  • tests/test_728_tier_classification.py
  • tests/test_729_window_end_boundary.py
  • tests/test_732_flat_price_array.py
  • tests/test_735_efficiency_config_field.py
  • tests/test_735_efficiency_single_source.py
  • tests/test_737_options_flow_label_coverage.py
  • tests/test_738_localize_split.py
  • tests/test_739_charging_badge_floor.py
  • tests/test_740_failsafe_off.py
  • tests/test_740_night_gate_police.py
  • tests/test_742_strip_joint_blocks.py
  • tests/test_743_curtailment_ledger.py
  • tests/test_743_curtailment_probe.py
  • tests/test_743_export_limit_autodetect.py
  • tests/test_743_probe_observability.py
  • tests/test_743_wiring.py
  • tests/test_744_load_power_derivation.py
  • tests/test_744_rated_ratchet.py
  • tests/test_745_load_on_off_from_state.py
  • tests/test_747_ev_shed.py
  • tests/test_748_charger_duplicate_depersist.py
  • tests/test_748_surplus_seam.py
  • tests/test_749_discharge_unit.py
  • tests/test_750_sub6_min_amps.py
  • tests/test_753_session_warmup.py
  • tests/test_active_phase_guard.py
  • tests/test_battery_arbitrage_523.py
  • tests/test_battery_charge_scheduler.py
  • tests/test_battery_modes_523.py
  • tests/test_battery_review_fixes.py
  • tests/test_battery_scheduler_rolling_horizon.py
  • tests/test_calculate_remaining_need_no_estimated_soc.py
  • tests/test_config_flow_migration.py
  • tests/test_config_flow_slim_install.py
  • tests/test_decide.py
  • tests/test_dual_phase_guard.py
  • tests/test_ev_connected_physics_defence.py
  • tests/test_ev_deadline_tariff.py
  • tests/test_ev_taper_detector.py
  • tests/test_ev_tariff_planner.py
  • tests/test_grid_import_surcharge.py
  • tests/test_integration.py
  • tests/test_per_charger_entities.py
  • tests/test_phase_current_discovery.py
  • tests/test_phase_guard_notifications.py
  • tests/test_phase_guard_options_flow.py
  • tests/test_switch.py
  • tests/test_tariff_pricing_review.py
  • tests/test_today_plan.py
  • translations/cs.json
  • translations/da.json
  • translations/de.json
  • translations/en.json
  • translations/es.json
  • translations/fi.json
  • translations/fr.json
  • translations/hu.json
  • translations/it.json
  • translations/nl.json
  • translations/no.json
  • translations/pl.json
  • translations/pt.json
  • translations/ro.json
  • translations/sv.json
  • translations/zh-Hans.json

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…k estimate no longer teaches the ratchet

A 24 W Shelly PM load showed ~1.0 kW: the kWh-tick estimate (0.01 kWh over
a short window reads ~1 kW instant) fed calibrate_rated_power's up-only
ratchet, whose deriver cap (2x rated) re-based on every adoption — a
geometric climb, persisted across restarts by the calibrated-ratings
snapshot. An estimate must never teach the model (the #743/#753 class).
…d; the announcement reports the observation

The taper latch (_declining_phase), session peak, SOC anchor with its
timestamp and the stability give-up streak/backoff now ship in the
diagnostics download — the promised half of #708 that doesn't need the
reporter's car. And 'SOC anchored at 100%' asserted something SEM cannot
know; the log now reports what was observed: the charge completed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants