fix(applier): exempt batteries_discharge_mode from the hold-derived 0 W discharge cap - #984
Merged
woopstar merged 2 commits intoSep 13, 2026
Conversation
… W discharge cap `_primary_battery_hold()` infers an explicit hold from a near-zero (batteries_charged_kwh, batteries_discharged_kwh) pair. That derivation is unsound for a schedule discharge window: `soc_simulation.py` relabels only the two force modes to wait when the simulated discharge is zero, so a `batteries_discharge_mode` slot whose re-solved discharge rounds below the 0.001 kWh materiality threshold keeps its label and still reads as a hold — and the slot executes as MaximizeSelfConsumption, where the cap is a ceiling the firmware ramps within, not a setpoint. The result was the Huawei maximum-discharge-power register flipping between 0 W and the rated maximum across replans while the published recommendation never changed, so neither plan-level (#372) nor window (#315) hysteresis applied. Adds `applier_caps._primary_battery_cap_hold()` and uses it for the discharge-cap decision only. `_primary_battery_hold()` is unchanged and keeps its meaning for `_held_planned_export_is_authoritative()` and the `batteries_wait_mode` working-mode branch. Every other 0 W path keeps immediate precedence: EV permission gating (#797), solar-charge-only (#922), wait-mode reserve floor (#954), the SoC reserve guard (#592), and the read-only/degraded gates. Fixes #983 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 tasks
…s from main The v6.3.0 tag shipped three test call-sites that no longer matched `coordinator_tracking.accumulate_forecast_actuals()`: one still passed the `solar_corrector_processed` argument dropped by #973, and two omitted the required keyword-only `update_interval_minutes`. Four tests error out on a clean v6.3.0 checkout, and mypy reports three call-arg errors. `coordinator_tracking.py` is byte-identical between v6.3.0 and main, so this is the exact test-only delta already on main (from #904) — no production behaviour is touched. It unblocks CI on the 6.3.x hotfix line. Co-Authored-By: Claude Opus 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
Fixes the Huawei
Maximum discharging powerregister oscillating between 0 W and the rated maximumduring a
batteries_discharge_modewindow while the published recommendation never changes.Reported against 6.3.0 by @AndersN76 in #978 (comment)
and confirmed by @Extati in #978 (comment).
This PR targets
v6.3.0-hotfixso the fix can be released on the 6.3.x line whilemainmovestoward 7.0.0. The same change is proposed against
mainseparately.Root cause
applier_caps._primary_battery_hold()derives "the solved plan explicitly holds the primarybattery" from a near-zero
(batteries_charged_kwh, batteries_discharged_kwh)pair, andapplier.pyturns that into an unconditional 0 W discharge cap. Two facts make that unsound for a schedule
discharge window:
planner/soc_simulation.pyrelabels onlyforce_batteries_discharge/force_exporttobatteries_wait_modewhen the simulated discharge is zero —batteries_discharge_modedeliberately keeps its label, because it is the user's configured window, not a forced action. So
a discharge window whose re-solved
batteries_discharged_kwhrounds belowPLANNED_ENERGY_ROUNDING_KWH(0.001 kWh) keeps the discharge label and satisfies the derivedhold, without the plan ever having decided to hold.
batteries_discharge_modeexecutes asMaximizeSelfConsumption, where this cap is a ceilingthe firmware ramps within from live house load — not a setpoint. Writing 0 W there disables the
behaviour the mode exists for and contradicts the
Recommendationsenum's own contract for it("discharge battery to cover house load").
Because the published recommendation never changed, neither existing damping layer applied:
plan-level hysteresis (#372) stabilises candidate selection, and window hysteresis (#315) only
reacts to a recommendation change (and lets any transition involving a neutral recommendation
through immediately anyway). Nothing guarded this final actuator boundary.
This is not #939/#941: that bug wrote rated max then 0 W inside the same apply cycle. Here the
writes are minutes apart across separate replan/apply events, with the #941 single-write-per-cycle
fix present and working.
Changes
custom_components/hsem/custom_sensors/applier_caps.py— new_primary_battery_cap_hold(rec):_primary_battery_hold(rec)and the recommendation is notbatteries_discharge_mode.custom_components/hsem/custom_sensors/applier.py— the discharge-cap decision uses the newwrapper.
_primary_battery_hold()itself is unchanged and keeps its meaning for_held_planned_export_is_authoritative()and thebatteries_wait_modeworking-mode branch (wherethe two are identical, so that branch is untouched).
docs/planner-spec.md— new "Discharge-mode exemption (issue fix(inverter): batteries_discharge_mode slot writes a 0 W Huawei discharge cap and oscillates against the rated maximum #983)" subsection plus a pointer fromthe discharge-power-cap section.
docs/troubleshooting-guide.md— new "7i. Maximum discharging power capped at 0 W" with thelegitimate causes and the
capped max discharge power tolog pattern..github/memories.md— engineering-log entry, including the two rejected alternatives.tests/test_discharge_mode_cap_oscillation.py— new.tests/test_coordinator_tracking_{forecast,solar_corrector}.py— unrelated CI back-port, seebelow.
All other 0 W paths keep immediate precedence and are covered by regression tests: EV permission
gating (#797), the planned-EV rate cap, solar-charge-only (#922), the wait-mode reserve floor (#954),
the
current_required_battery_kwhSoC guard (#592), and the read-only/degraded gates.force_batteries_discharge/force_exportalready bypassed this branch entirely.Tests
tests/test_discharge_mode_cap_oscillation.py— 16 tests:_primary_battery_cap_hold()unit coverage, including theev_smart_chargingrelabel andwait-mode cases
slot still runs
MaximizeSelfConsumptionmateriality boundary, asserting exactly one cap write
solar-charge-only, and a genuine held Wait slot
Like #939's tests these assert the exact list of writes to the entity, not just the final value —
the pre-fix behaviour passes a final-value-only assertion. Verified failing before the fix and
passing after.
Second commit — stale test call-sites back-ported from
main303eb34is a separate, test-only commit unrelated to #983, included so CI can actually gate the6.3.x release.
The
v6.3.0tag shipped three test call-sites that no longer matchedcoordinator_tracking.accumulate_forecast_actuals(): one still passed thesolar_corrector_processedargument dropped by #973, and two omitted the required keyword-only
update_interval_minutes. Fourtests error out on a clean
v6.3.0checkout and mypy reports threecall-argerrors — bothreproduced in a clean worktree of the tag before touching anything, so neither came from this PR.
coordinator_tracking.pyis byte-identical betweenv6.3.0andmain, so this is exactly thetest-only delta already on
main(landed there as collateral in #904) — 3 lines, no productionbehaviour touched. Happy to split it into its own PR if you'd rather keep this one single-purpose.
Quality gates
./scripts/quality.sh lint./scripts/quality.sh typing./scripts/quality.sh quality./scripts/quality.sh test./scripts/quality.sh translationsChecklist
docs/planner-spec.mdread and updated (semantics of the discharge cap changed)no planner output is mutated
Fixes #983
🤖 Generated with Claude Code