fix(ev): make charging dispatch authoritative and solar-aware - #806
Merged
Conversation
Port the core of Ambilights/hsem-ambilights#31 COMMIT 1, adapted to this repo's declared-column-layout MILP architecture (no fork _solver_execution.py equivalent exists here; the amp lattice is wired through the existing MilpColumnLayout/MilpBoundsBuilder machinery instead). Replaces the continuous-EV-variable-then-quantize-in-writeback approach with solver-native semi-integer amp variables (HiGHS type 3): each managed EV's DC charge energy is linked to an executable whole-amp command by an equality constraint during the solve itself, so a solved plan can never diverge from what the charger can execute. Production write-out for managed EVs is now a verbatim pass-through; the legacy concentration/redistribution/quantization helpers remain available for direct/compatibility callers. - EVConfig.force_max_discharge_power/max_discharge_power_w: permission (not a command) for the Huawei house battery to discharge while an EV charges, wired end-to-end from EVChargerConfig through PlannerInput to the MILP. - planner/milp/_ev_amp_lattice.py (new): resolves each EV's amp-lattice plan, writes ev_{i}_amps/ev_{i}_on bounds, and links amp<->ev_c and amp<->discharge-permission via the equality/conditional constraint rows. - planner/milp/_incumbent.py (new): validates a time-limited HiGHS incumbent against the complete model before accepting it, protecting against the new semi-integer variables' higher solve cost. - _objective.py: drop the large negative pre-deadline benefit in favor of the existing slack penalty + a tiny per-kWh tiebreak cost. - _constraints.py: target-cap constraint permits one activation quantum (the charger's startup minimum) above the exact target, so a target with no exact whole-amp solution is no longer reported as an avoidable miss. - engine_ev_milp.py: managed_session_cap_only sentinel keeps a live session's discharge permission/ceiling without manufacturing charge demand once its target is already met; config_eff rounding coherence. - _session_window.py: managed sessions no longer pin any slot's bounds (including the current one) — measured power is telemetry only. - milp_optimizer.py: admit EVs with a live session even when max_charge_per_slot is zero, so the sentinel above stays modeled. Closes #797 (COMMIT 1 of 3 — control semantics and coordinator live-power budget v2 land in follow-up commits). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…authoritative (#797) Port the control-semantics half of Ambilights/hsem-ambilights#31 COMMIT 2, adapted to this repo's applier architecture, which differs substantially from the fork's current state (no primary_battery_hold field, no price_actionable/export_price_available, no unified _desired_battery_discharge_cap_w helper, and phase_charge_limiter.py does not exist here at all — that module is skipped, tracked separately). - EVConfig.force_max_discharge_power/max_discharge_power_w become a permission and ceiling, never a command: any EV that is charging or about to be commanded must opt in before the Huawei battery may discharge at all this slot. The cap is the planner's own solved discharge rate, clamped to every opted-in EV's ceiling and the hardware maximum — not the old historical/live-net house-only-load heuristic (compute_ev_discharge_cap_w, issue #592). - applier_caps.py: _ev_is_active_or_planned + _planned_ev_discharge_cap_w replace compute_ev_discharge_cap_w; _primary_battery_hold derives the "solved plan holds the primary battery" signal locally from near-zero batteries_charged_kwh/batteries_discharged_kwh (upstream persists this as a dedicated field that doesn't exist here — relabelling never touches these energy fields, so the derivation survives it too); _held_planned_export_is_authoritative gates on hold + material grid_export_kwh only (upstream's extra price_actionable/ export_price_available guard has no local equivalent). - applier.py: EVSmartCharging always executes as Maximize Self Consumption unless the slot is a held, authoritative export (then TOU wait + fed_to_grid); BatteriesWaitMode gets the same hold/export split ahead of the existing self-consumption-with-reserve branch; the now-redundant separate V2H discharge-power override is folded into the single permission-gated cap block. - const.py: remove DEFAULT_HSEM_EV_CHARGER_TOU_MODES (unused after the EVSmartCharging rewrite above). - utils/units.py: add is_material_planned_energy_kwh (3-decimal publication-residue threshold), shared by both new applier_caps helpers. - ADR-004: EV relabelling requires a positive accepted HSEM command, not just measured/accounted load (doc-only; recommendation_resolver.py already implements this). Skipped: custom_sensors/phase_charge_limiter.py does not exist locally (fork-only module from an earlier, never-ported feature) — its _planned_primary_grid_charge_kwh grid-funded-share change is out of scope here. working_mode_sensor.py's _hardware_intent EV-field extension has no local base to extend (no such change-detection signature exists) and is also skipped. Part of #797 (COMMIT 2 of 3 — coordinator live-power budget v2 lands in a follow-up commit). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
COMMIT 3 of issue #797: build the coordinator-level live-power window and bounded corrective-replan machinery that was flagged as missing before this port started (see .github/memories.md's "#31" row and the comment on issue #797). Issue #792 ported only the self-contained utils/live_power.py half (LivePowerWindow/LivePowerEstimate); the coordinator-level wiring Ambilights/hsem-ambilights#29 built on top of it, and #31's "budget 1 -> 2" delta, never existed here. This commit builds both directly at budget=2, adapted to this repo's split coordinator (coordinator.py + coordinator_{builder,cycle,lifecycle,live_power,planner_phase,state}.py) instead of the fork's monolithic coordinator.py, and with PowMr/secondary- storage normalization stripped (no such subsystem exists here). Key adaptation: the fork reuses an existing 10-second force-discharge monitor tick to sample live power. No such tick exists locally, and LivePowerWindow requires samples fresher than its 20s max-sample-age threshold — samples spaced at the existing per-cycle interval (minutes) would never keep the window "available", making the feature permanently inert. coordinator_live_power.py therefore registers its own dedicated 10-second async_track_time_interval tick, independent of the main timer. - coordinator_live_power.py (new): LivePowerWindow lifecycle, EV-ambiguity gating, materiality/debounce mismatch tracking, and the bounded replan budget (one correction + one debounced, provably-opposite-direction reversal per slot — e.g. a cloud dip followed by a genuine PV rebound). - coordinator_lifecycle.py: register/unregister the dedicated fast timer alongside the existing hourly/interval timers. - coordinator_cycle.py: seed the window from each full cycle's snapshot; 1-minute update interval while any_ev_charging (matches the existing missing-entities/pending-consumption fast-retry pattern) so a delivered- energy target crossing is caught promptly instead of waiting several minutes. - coordinator_planner_phase.py: wire the live-power replan signal into _should_replan; extend the already-existing (locally native) _ev_delivered_energy_requires_replan with a target-crossing bypass — the whole-amp MILP lattice (issue #797 COMMIT 1) may deliberately plan up to one activation quantum past the exact target, and that bounded excess must stop the moment delivered energy crosses the accepted target, independent of the ordinary cadence/materiality gates. - coordinator_builder.py: build_planner_input prefers the rolling median estimate over the single boundary sample per channel when available. - utils/ev_delivered_energy.py: an equal-timestamp sample is a valid zero-duration observation (was previously discarded like a reversed clock), so a same-second coordinator refresh no longer loses the following measurable interval. Closes #797. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Update user-facing docs (config-flow-reference.md, home.md, sensors- reference.md) to match the applier semantics from COMMIT 2: force_max_ discharge_power/max_discharge_power grant permission and set a ceiling for the planner's own solved discharge rate — they never command discharge on their own, and without the permission the Huawei battery discharges 0 W whenever the EV is active or planned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
milp_optimizer.py and _constraints.py grew past the repository's 30 KB file-size limit from the issue #797 changes. Extract self-contained pieces to bring both back under the limit — pure moves, no behaviour change: - _incumbent.py: solve_and_validate() wraps the linprog call, time-limit detection, and incumbent validation that previously lived inline in milp_optimizer.py. - _diagnostics.py: _compute_terminal_soc_credit() extracts the post-hoc terminal-SoC diagnostic calculation. - _ev_amp_lattice.py: target_cap_activation_quantum_dc() extracts the activation-quantum computation shared by the target-cap constraint; milp_optimizer.py's active-EV admission loop now reuses the existing ev_has_live_session() helper instead of duplicating its try/except logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ports Ambilights/hsem-ambilights#31 ("make charging dispatch authoritative and solar-aware"), adapted to this repo's architecture, which diverges substantially from the fork's current state. Three commits, each independently tested and quality-gated:
COMMIT 1 — Solver-native whole-amp EV charging lattice (
535ad63)Replaces the continuous-EV-variable-then-quantize-in-writeback approach with solver-native semi-integer amp variables (HiGHS type 3): each managed EV's DC charge energy is linked to an executable whole-amp command by an equality constraint during the solve itself, eliminating the plan-vs-execution divergence class of bug by construction. Adapted to this repo's declared
MilpColumnLayout/MilpBoundsBuilderarchitecture (the fork has no equivalent — it uses a separate_solver_execution.pyfile that doesn't exist here).COMMIT 2 — EV discharge permission and held-export authority (
4afcbb5)EVConfig.force_max_discharge_power/max_discharge_power_wbecome a permission and ceiling, never a command: any EV that is charging or about to be commanded must opt in before the Huawei battery may discharge at all. Adapted to this repo's simpler, inlinedapplier.py(the fork assumesprimary_battery_hold,price_actionable/export_price_available, and a unified_desired_battery_discharge_cap_w()helper — none of which exist locally;primary_battery_holdis derived from near-zero charged/discharged energy instead of a dedicated field).COMMIT 3 — Coordinator live-power replan budget, built from scratch (
fc35313)Builds the coordinator-level live-power window and bounded corrective-replan machinery that was flagged as missing before this port started (see
.github/memories.md's "#31" row and the same-day comment on issue #797). Issue #792 only ported the self-containedutils/live_power.pyhalf; the coordinator wiring never existed here. Built directly at "budget 2" (one correction + one proven-opposite-direction reversal per slot), with a dedicated 10-second timer — the fork reuses an existing force-discharge monitor tick that doesn't exist locally, andLivePowerWindowrequires samples fresher than its 20s max-age threshold, which the existing minutes-scale coordinator interval cannot provide.Plus a follow-up refactor commit (
c60a24c) to keep two MILP files under the repo's 30 KB size limit (pure extraction, no behaviour change), and a docs commit (fb5422a) correcting user-facing descriptions of the (redefined) EV discharge permission setting.Scope notes — investigated and explicitly deferred
Several pieces of the upstream PR assume fork-only infrastructure that was never ported here and is unrelated to issue #797 itself:
custom_sensors/phase_charge_limiter.pydoes not exist locally at all (whole module, ~327 lines, from an earlier never-ported fork feature). Its grid-funded-charge-share change is skipped.working_mode_sensor.py's hardware-intent change-detection tuple has no local base to extend (no such signature/dedup mechanism exists) — skipped.price_actionable/export_price_available(a "forecast authority" concept spanning ~15 fork files,coordinator.pyalone +582 lines in the fork commit that introduced it) has no local equivalent — dropped from the held-export-authority gate, which is stricter as a result (economic impact only — export vs. retain surplus PV — not a hardware-safety regression).Test plan
./scripts/quality.sh lint— clean./scripts/quality.sh typing— 0 errors./scripts/quality.sh quality(pyright + vulture) — 0 errors/warnings./scripts/quality.sh test— 2792 passed (83% coverage)test_ev_portioned_charging.py,test_session_ev.py,test_milp_ev.py,test_ev_planned_load.py), discharge permission + held-export integration tests (test_safety_gates.py), and a newtest_live_power_coordinator.py(30 tests covering the replan budget state machine)Closes #797
🤖 Generated with Claude Code