Skip to content

fix(ev): make charging dispatch authoritative and solar-aware - #806

Merged
woopstar merged 5 commits into
mainfrom
fix/797-ev-dispatch-authoritative-solar-aware
Aug 25, 2026
Merged

fix(ev): make charging dispatch authoritative and solar-aware#806
woopstar merged 5 commits into
mainfrom
fix/797-ev-dispatch-authoritative-solar-aware

Conversation

@woopstar

Copy link
Copy Markdown
Owner

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/MilpBoundsBuilder architecture (the fork has no equivalent — it uses a separate _solver_execution.py file that doesn't exist here).

COMMIT 2 — EV discharge permission and held-export authority (4afcbb5)
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. Adapted to this repo's simpler, inlined applier.py (the fork assumes primary_battery_hold, price_actionable/export_price_available, and a unified _desired_battery_discharge_cap_w() helper — none of which exist locally; primary_battery_hold is 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-contained utils/live_power.py half; 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, and LivePowerWindow requires 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.py does 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.py alone +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)
  • All files within the 30 KB / 1000-line size limit
  • New regression tests: amp-lattice/target-cap behavior (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 new test_live_power_coordinator.py (30 tests covering the replan budget state machine)

Closes #797

🤖 Generated with Claude Code

woopstar and others added 5 commits August 24, 2026 12:06
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>
@github-actions github-actions Bot added bug Something isn't working enhancement New feature or request labels Aug 24, 2026
@woopstar
woopstar merged commit 5300956 into main Aug 25, 2026
21 checks passed
@woopstar
woopstar deleted the fix/797-ev-dispatch-authoritative-solar-aware branch August 25, 2026 07:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ev): make charging dispatch authoritative and solar-aware (port Ambilights/hsem-ambilights#31)

1 participant