feat: tomorrow schedule, observer readiness and phase safety - #722
feat: tomorrow schedule, observer readiness and phase safety#722tintinz wants to merge 4 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
traktore-org
left a comment
There was a problem hiding this comment.
Thanks Patrik — one gate before the full review, so this is a fast fail rather than the deep pass.
CI is red. Both required test jobs fail on the same single test:
test (3.12)andtest (3.13)→tests/test_666_ev_accumulator_keys.py::TestAccumulatorKeyRoundTrip666::test_the_probe_actually_integratesAssertionError: expected 7 daily categories, found ['battery_charge', 'battery_discharge', 'calendar_ev', 'ev', 'grid_export', 'grid_import', 'home', 'solar']
Root cause (verified against the diff and against develop): this PR adds a new daily_calendar_ev field to EnergyTotals — the calendar-day EV energy for the Sankey node. The #666 guard's _categories() derives straight from the dataclass fields, so the count went 7 → 8, and the tripwire at tests/test_666_ev_accumulator_keys.py:92 is pinned at == 7. The PR doesn't touch that file, so the tripwire trips.
That's the guard working as designed — it's a deliberate "a category changed, come confirm the round-trip still holds" tripwire (see its docstring), not a test to silence. To clear it:
- Bump the count assertion
== 7→== 8. - Confirm
calendar_evgenuinely round-trips (#666 / #668): if it's persisted, its daily/monthly/yearly keys must all be written and read under the same category and be registered in the shared persistence list. The parametrizedtest_daily_monthly_yearly_move_together[calendar_ev]case is present and passing in this same run (only the tripwire fails), so the read/write plumbing already looks correct — you mainly need the tripwire to acknowledge the new category. Ifcalendar_evis display-only (no monthly/yearly siblings, not persisted), a one-line note in the guard on why it's daily-only keeps the next reader honest.
One note on the description: it reports a green local run ("3,887 passed"), but CI is the gate here and both jobs are red on this one test — the four-way local filename split most likely didn't include the updated test_666 file. CI needs to go green before I run the full correctness + SEM-invariants review.
Re-request review once CI passes and I'll pick up the full adversarial pass — the change is otherwise well-scoped. Thanks again.
…dule-phase-guard # Conflicts: # dashboard/card/dist/sem-cards.js
traktore-org
left a comment
There was a problem hiding this comment.
Thanks Patrik — the #666 tripwire from the earlier pass is cleanly resolved (the 7 → 8 bump with the "calendar_ev is intentionally daily-only" rationale is exactly right), CI is green, and the branch is rebased. I ran the full two-lens adversarial pass this time. Most of it holds up well; one item needs a fix before merge, plus one confirmed correctness bug and a few quality notes.
Held up under challenge
- Observer countdown is genuinely advisory / fail-closed. Nothing in the tree reads
observation_progress,ready_for_manual_activation,observation_remaining_seconds, orautomatic_activationto arm control — all actuation gates still read the unchanged_observer_modebool;automatic_activationis hardcodedFalse; missing/invalid/future start clamps toelapsed=0, ready=False. The legacy-install_persist_flag(True)on add is guarded byself._is_on, and_skip_options_reloadprevents a reload loop. Matches the #702/#695/#698 spirit. daily_calendar_evis a correct, display-only surface of the pre-existingmidnight_evaccumulator — no new persistence key (#668 intact), daily-only by design so no monthly/yearly siblings,last_resetresolves to midnight. The Sankey swap tosensor.sem_daily_calendar_ev_energy(×4, old sensor fully removed) is the right fix for the deadline-vs-calendar day-boundary class (#628/#703), no double-count / sign flip / negate.- No FLEET-READ / #589 regression — the new per-charger tomorrow-plan loop reuses already-computed per-charger locals; zero new
power.ev_powerreads orcoord._ev_*/pccwrites. - The debounce never delays a genuine safety block — the recovery
elifsits afterif unsafe:, and the phase guard blocks independently of notifications. Translation parity is complete — all 8 newdashboard/translations.jsonkeys present ×16 locales,{location}/{reason}placeholders preserved;daily_calendar_ev_energyname added tostrings.json+ all 16translations/*.json.
Needs a change before merge
HIGH — coordinator/notifications.py, the incident_kind classifier in notify_phase_guard_transition — a mixed multi-phase reading masks a real over-limit as a sensor fault, suppressing the very escalation this PR is meant to protect.
stop_reason is a comma-joined list of every unsafe lane/phase (coordinator/dual_phase_guard.py:275 → ",".join(reasons), e.g. "grid:l2:over_limit,inverter:l1:invalid_current"). The new classifier tests sensor-fault tokens first with any(token in reason ...), so any snapshot where one phase is genuinely over the limit and another phase's sensor is stale/invalid is classified sensor_fault. Consequences:
- The over-limit escalation carve-out (
unsafe and incident_kind == "over_limit" and _last != "over_limit") never fires, so a real over-current arriving during an open sensor-fault incident (samephase_guard_observer_warning/phase_guard_blockedstate) is swallowed by thestate == previous → returnunder the 300 s hold. - Even when it does notify, the message says "sensor data is invalid" for a genuine over-current.
This is exactly the case your own comment forbids ("A real over-limit event must never be hidden behind a data-quality incident…"). On a 3-phase install, one phase over-limit while another phase's CT goes stale is an ordinary independent-failure combination. Fail-closed actuation is unaffected (this path is notification-only, the guard still blocks) — hence HIGH, not blocker — but a suppressed/mislabeled over-current alert is a real defect in the feature being added.
Fix: let a real limit breach outrank a co-occurring data-quality token — check"over_limit" in reasonbefore the sensor-fault tokens, or classify per-lane and treat "any lane over_limit" asover_limit. Drive the escalation guard off that.
HIGH (same root) — the two new tests pass vacuously w.r.t. this case. test_over_limit_escalates_immediately_during_sensor_fault_incident and test_invalid_sensor_incident_stays_open… use only single-token reasons, so they pass precisely because they avoid the mixed string that fails. Please add a case with reason="grid:l1:invalid_current,grid:l2:over_limit" asserting a second notification fires and the message names the over-limit.
For merge time (non-blocking, tick at your leisure)
- MEDIUM —
coordinator/energy_calculator.py_build_current_totals(~line 494) omitsdaily_calendar_ev. This is the gap/clock-backwards early-return path (called from lines 286/294). It re-readsdaily_evand every other field from accumulators but leavesdaily_calendar_evat the dataclass default0.0. On a clock-backwards or large-gap cycle,daily_calendar_ev_energypublishes0→ the Sankey EV node + its 3 links momentarily collapse to zero whiledaily_ev_energystays correct, self-healing next clean cycle. Addenergy.daily_calendar_ev = self._get_daily(MIDNIGHT_EV_CATEGORY, today)alongside the sibling reads. - MEDIUM —
_PHASE_GUARD_SENSOR_FAULT_REASONSis incomplete and has a dead token. The guard also emitsinvalid_voltage,invalid_power,invalid_unit,not_configured,unsupported_topology, etc. (none listed → generic wording + no debounce), whileno_sampleis listed but never produced. Deriving the fault set from the actual guard tokens (or inverting: treatover_limitas the only non-fault) would make this robust — and folds neatly into the HIGH fix. - MEDIUM — Tomorrow tab can read "final" with zero price rows on 15-min providers.
compose_tomorrow_planfilterstariff_upcoming, which is capped at 48 points (~12 h for 15-min providers like Tibber NL / ENTSO-E), so early in the day it never reaches tomorrow 00:00; the coordinator'sschedule_tomorrow_statusoverride can then flip status to "final" over an empty plan. Consider sourcing tomorrow's points from the provider day schedule rather than the 48-point cap. - MEDIUM — duplicate display name.
daily_ev_energyand the newdaily_calendar_ev_energyare both named "Today EV" and both enabled-by-default on every install (incl. EV-less ones). Give the new one a distinguishing name (e.g. "Today EV (calendar)") and/orentity_registry_enabled_default=False, since its stated purpose is the Sankey feed. Unique_ids are distinct, so this is display-only. - NIT —
compose_tomorrow_plandrops all prices ifnowis tz-naive (naiveday_startvs tz-aware ISO points →TypeErrorswallowed). Latent in prod (dt_util.now()is aware) but the helper is public/unit-tested with naiveNOW. Normalize to tz-aware before comparison. - NIT —
coordinator.pyexception path setstomorrow_plan/tomorrow_plan_statusbut nottomorrow_plan_date(reads asNonedownstream, no crash — set it for symmetry). The_fleet_tomorrow_plan is Nonefallback is also effectively dead (_plan_cfgsalways has ≥1 entry) and would skip the status override if ever hit. - NIT —
sem-schedule-card.jsreadsforecast_peak_time_tomorrow, which is not a real sensor (degrades to null; thetomorrow_plansolar_peakrow is the real source). Remove the stale reference.
Really solid feature otherwise — the day-boundary reasoning, the advisory countdown design, and the persistence handling are all sound. Fix the mixed-reason classification (+ the mixed-reason test) and I'll re-run the pass. Thanks again, Patrik.
|
One more for the merge-time list, Patrik — found on a separate pass while checking this against the overnight-planner branch (which it merges cleanly with, no conflicts outside the generated bundle, and no shared state). This one is distinct from the "final over empty rows" MEDIUM already in the review: it's about the night-window endpoints, not the price points. MEDIUM — The coordinator resolves the endpoints as a next occurrence ( So before tonight's window opens, (Reproduced against your head merged into develop, with a full 24 h of tomorrow price points supplied — Two things fall out of that:
Fix: re-anchor the endpoints onto the target day inside Unrelated note while I was in there: |
traktore-org
left a comment
There was a problem hiding this comment.
Thanks Patrik — I re-ran the full two-lens pass against the new head (ed5b2762df, "fix tomorrow plan and phase guard regressions"). Almost everything from the last pass is cleanly resolved, and the two prior HIGHs are genuinely closed. One new issue rides in with the fix and needs a change before merge.
Held up under challenge (re-verified on this head)
- Both prior HIGHs fixed. The classifier now reads
incident_kind = "over_limit" if "over_limit" in reason else "sensor_fault"(coordinator/notifications.py) — a real limit breach in a comma-joined multi-phase reason (inverter:l1:invalid_current,grid:l2:over_limit) now outranks the co-occurring data-quality token. I enumerated every tokendual_phase_guard.py/active_phase_guard.pycan emit; the inversion is sound for all of them, and the empty/None-reason path can't carry a real over-current. The two tests are no longer vacuous — they use the mixed string and assert the message says "limit exceeded", not "sensor data". -
control_authorizeddefault flipped toFalsein the recoveryelifis correctly fail-closed: enforcing snapshots always set the key, so a genuine armed-recovery still fires; a missing key can no longer close an open incident (test_missing_control_authorized_cannot_close_an_open_incident). -
_build_current_totalsgap/clock-backwards path now setsdaily_calendar_evfromMIDNIGHT_EV_CATEGORY/today— the Sankey EV feed no longer collapses to 0 on a skipped cycle (test_gap_return_preserves_calendar_day_ev_total). - Tomorrow price sourcing now composes from all local-calendar-day points instead of the 48-point slice, and the "final" status override is gated on
if not _tariff_dynamic:so a dynamic provider stays preliminary over an empty plan.is_dynamicis a realTariffDatafield (no AttributeError),_tariff_local_date/_tomorrow_price_dateare consistently local. 15-min-day coverage is pinned bytest_tomorrow_plan_keeps_late_blocks_from_full_15_minute_day. - The three prior NITs are closed: exception path now sets
tomorrow_plan_date; tz-naive price handling no longer drops aware points (test_tomorrow_plan_handles_naive_now_with_aware_prices); the staleforecast_peak_time_tomorrowcard read is gone. - Safety invariants all still hold: observer countdown is inert (no production reader arms control,
automatic_activationhard-False, fail-closed clamps), no FLEET-READ/#589 regression in the tomorrow-plan loop, no new persistence key (#668 intact), sign conventions clean, translation parity verified independently acrossstrings.json+ 16 locales + the 8 dashboard keys.
Needs a change before merge
HIGH — the Sankey EV node is bound to an entity that is now disabled by default, so the feature this PR headlines ("fix the Energy Sankey EV node") is blank on a fresh install.
The follow-up commit resolved the duplicate-"Today EV" display-name note by adding both the rename and entity_registry_enabled_default=False to the daily_calendar_ev_energy description (sensor.py). But dashboard/sem_dashboard_template.yaml points the Sankey at sensor.sem_daily_calendar_ev_energy in 4 places (the EV node plus the Sol>EV / Grid>EV / Bat>EV children) and drops every reference to the old sensor.sem_daily_ev_energy — which was enabled by default.
SEMSolarSensor never force-enables (its DISABLED_BY_DEFAULT override set is empty), and the dashboard generator does not enable entities it references. So on a default install the mirror entity is registry-disabled → has no state → custom:sankey-chart renders the EV node and its three inbound flows as empty/zero. Result: the EV column silently disappears from the default Energy dashboard even while the car charged overnight — strictly worse than before the PR, where daily_ev_energy was enabled and showed data.
Note tests/test_dashboard_generator.py::test_energy_flow_uses_calendar_day_ev_total only asserts the YAML text (4 references, old sensor absent), so it passes while pinning the broken wiring rather than catching it.
Fix (either): drop entity_registry_enabled_default=False from daily_calendar_ev_energy — a shipped card needs it, so it should be a normal enabled daily sensor, and the rename to "Today EV (calendar day)" already fully resolves the duplicate-name concern on its own; or keep the Sankey pointed at the enabled sensor.sem_daily_ev_energy. Don't ship a card bound to a disabled entity. A test asserting the referenced entity is enabled would keep the two in sync.
For merge time (non-blocking)
- MEDIUM — still open from your own Aug-5 note:
compose_tomorrow_planinherits today's night window (night_start/night_endare passed as today's next-occurrence, un-re-anchored), sonight_open/ev_charge_start/ev_min_reacheddrop out of Tomorrow for ~21 h/day and the view isn't time-invariant. This head doesn't touch it (the onlytoday_plan.pychange is the tz-naive handling). The passingtest_tomorrow_plan_keeps_overnight_ev_window_inside_calendar_dayhand-anchorsnight_start=TOMORROW 00:00, so it doesn't exercise the coordinator's today-anchored value. Fix: shiftnight_start/night_endontoday_start's date insidecompose_tomorrow_plan. - LOW / confirm intent — the sensor-fault 300 s recovery hold is evaluated before the armed-recovery check, so after a data-quality incident the "restored & armed" notification can lag the actual re-arm by up to 5 min. This looks like your intended anti-flap hysteresis (incident stays open until readings are continuously valid) rather than a bug — just flagging it so it's a deliberate choice.
Really strong iteration — the classifier fix, the fail-closed default, and the tomorrow-price sourcing are all exactly right. Just un-disable the mirror sensor (or repoint the Sankey) so the EV node lights up out of the box, and I'll re-run the pass. Thanks again, Patrik.
|
Separate from the review above — a note on the shape of the PR rather than any single finding. Credit first: the test coverage is thorough, translation parity is complete across all 16 locales with the placeholders intact, and you turned the #666 tripwire around fast. None of what follows is about the quality of the work. One request: could you pull the Sankey calendar-day EV fix into its own PR? That piece is ready to merge as-is, and it's the only part of this branch that is. It's also a real bug rather than a feature: the Sankey's EV node is bucketed on the EV charge deadline while every other node on that chart is calendar-day, so between midnight and the deadline the EV branch still carries last night's charge while every source it is drawn from has already reset. A conservation diagram that doesn't conserve, out by roughly an overnight charge, every night, on every install using the default 07:00 deadline. We've filed the underlying class as #723 — your fix is the right one and we'd like it in soon rather than waiting on the rest of this branch. The files are self-contained:
None of them touch One thing to settle inside it: Everything else stays right here. The Tomorrow schedule, observer countdown and phase-guard work can keep sharing this PR — just fix the classifier HIGH from the review in place. We are not asking you to break the branch into four. Two small things for whichever PR they land in:
If splitting even one piece off a finished branch is more hassle than it's worth, tell us and we'll do the extraction ourselves with your authorship preserved on the commit — just say which you'd prefer. |
|
Heads-up on direction before you iterate further on the Tomorrow-schedule half — the review items on the observer/phase work stand unchanged, and the Sankey split-out request from earlier is unaffected. This is about where the schedule surface itself is heading. On the 1.8 planner branch, the "Tonight's Plan" card has become the Energy Plan — one card rendering the planner's ledger for the whole energy day (daylight + night in one timeline: demands, verdicts, blocks, price bands, night window). As part of 1.8 it will absorb the schedule/today-plan cards, so the Control tab has one timeline fed by one data source. Your Today | Tomorrow idea is being adopted into that card — genuinely: the toggle becomes the horizon selector, and your preliminary/final publication chip is exactly the honest detail the Tomorrow view needs. What changes is the data path: Tomorrow renders as a preview of the next energy day's ledger (prices anchored to the target day, forecast over tomorrow's real sun times), which structurally removes the time-invariance issue from the review (the today-anchored Practically: the tomorrow-schedule half of this PR would land on a surface that's being replaced, so we'd rather not have you polish it further. Options, your pick: (a) we fold the concept in with your authorship credited on the toggle/status-chip work, or (b) you build the toggle on the new card with us once the branch is public — we'll share the mockup and the entity contract. The observer-countdown and phase-guard halves are unaffected and continue here. |
…one data path tintinz's Today|Tomorrow idea adopted (PR #722, direction posted): the Energy Plan sensor now carries attrs.tomorrow — forecast surplus windows (sine over tomorrow's real sun times), cheap windows, the preliminary/final price status, night_open and the stamp time — built by day_ledger.tomorrow_preview from the SAME slot machinery and the SAME tariff accessors as the plan itself (tariff_price_at/tariff_cheap_at extracted pure so night slots, day slots and the preview cannot drift apart). Anchored to tomorrow's date throughout — which is what makes it time-invariant, the #722 review's open MEDIUM being a tomorrow view composed from today-anchored values. No demands, no verdicts: that plan honestly does not exist until it stamps.
The Energy Plan card is the Control tab's schedule now. Today|Tomorrow toggle (tintinz's #722 idea, adopted with the preliminary/final price chip and its explaining tooltips); the Tomorrow view renders the books preview from attrs.tomorrow — surplus and cheap bands on the same strip language plus the honest 'plan stamps at ~06:07' line. The Today view gains the night-window shading and the now marker the retired Schedule Timeline used to draw — computed from the same sensor the pending face already read. sem-schedule-card removed from the template (the element stays in the bundle for custom dashboards); placement pin now also pins the RETIREMENT — a schedule card creeping back is a second data path. 8 i18n keys × 16, localize regenerated, bundle rebuilt.
Summary
Safety
Verification
Notes
A monolithic pytest invocation exceeded the local 10-minute command limit at 84%; the complete suite was therefore run in four exhaustive filename groups, all green.