Skip to content

fix(applier): exempt batteries_discharge_mode from the hold-derived 0 W discharge cap - #984

Merged
woopstar merged 2 commits into
v6.3.0-hotfixfrom
fix/983-discharge-mode-zero-cap-oscillation
Sep 13, 2026
Merged

woopstar merged 2 commits into
v6.3.0-hotfixfrom
fix/983-discharge-mode-zero-cap-oscillation

Conversation

@woopstar

@woopstar woopstar commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the Huawei Maximum discharging power register oscillating between 0 W and the rated maximum
during a batteries_discharge_mode window 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-hotfix so the fix can be released on the 6.3.x line while main moves
toward 7.0.0. The same change is proposed against main separately.

Root cause

applier_caps._primary_battery_hold() derives "the solved plan explicitly holds the primary
battery" from a near-zero (batteries_charged_kwh, batteries_discharged_kwh) pair, and applier.py
turns that into an unconditional 0 W discharge cap. Two facts make that unsound for a schedule
discharge window:

  1. planner/soc_simulation.py relabels only force_batteries_discharge / force_export to
    batteries_wait_mode when the simulated discharge is zero — batteries_discharge_mode
    deliberately keeps its label, because it is the user's configured window, not a forced action. So
    a discharge window whose re-solved batteries_discharged_kwh rounds below
    PLANNED_ENERGY_ROUNDING_KWH (0.001 kWh) keeps the discharge label and satisfies the derived
    hold, without the plan ever having decided to hold.
  2. batteries_discharge_mode executes as MaximizeSelfConsumption, where this cap is a ceiling
    the firmware ramps within
    from live house load — not a setpoint. Writing 0 W there disables the
    behaviour the mode exists for and contradicts the Recommendations enum'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 not batteries_discharge_mode.
  • custom_components/hsem/custom_sensors/applier.py — the discharge-cap decision uses the new
    wrapper. _primary_battery_hold() itself is unchanged and keeps its meaning for
    _held_planned_export_is_authoritative() and the batteries_wait_mode working-mode branch (where
    the 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 from
    the discharge-power-cap section.
  • docs/troubleshooting-guide.md — new "7i. Maximum discharging power capped at 0 W" with the
    legitimate causes and the capped max discharge power to log 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}.pyunrelated CI back-port, see
    below.

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_kwh SoC guard (#592), and the read-only/degraded gates.
force_batteries_discharge / force_export already bypassed this branch entirely.

Tests

tests/test_discharge_mode_cap_oscillation.py — 16 tests:

  • _primary_battery_cap_hold() unit coverage, including the ev_smart_charging relabel and
    wait-mode cases
  • no 0 W write on a near-zero-discharge slot; a hardware cap left at 0 W restored to rated max; the
    slot still runs MaximizeSelfConsumption
  • an 8-cycle replay of the reporter's timeline with the solved discharge flipping across the
    materiality boundary, asserting exactly one cap write
  • precedence regressions for unpermitted EV, permitted-EV rate cap, SoC reserve guard,
    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 main

303eb34 is a separate, test-only commit unrelated to #983, included so CI can actually gate the
6.3.x release.

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 — both
reproduced in a clean worktree of the tag before touching anything, so neither came from this PR.

coordinator_tracking.py is byte-identical between v6.3.0 and main, so this is exactly the
test-only delta already on main (landed there as collateral in #904) — 3 lines, no production
behaviour touched. Happy to split it into its own PR if you'd rather keep this one single-purpose.

Quality gates

Gate Result
./scripts/quality.sh lint pass
./scripts/quality.sh typing mypy 0 errors
./scripts/quality.sh quality pyright 0 errors, 0 warnings; vulture unchanged
./scripts/quality.sh test 3318 passed, 0 failed
./scripts/quality.sh translations 0 errors (no user-facing strings changed)

Checklist

  • docs/planner-spec.md read and updated (semantics of the discharge cap changed)
  • Energy balance / SoC bounds / cost identity untouched — this is an execution-layer change only,
    no planner output is mutated
  • Read-only / degraded / dry-run gates unaffected (they return before the cap block)
  • Tests added covering the changed behaviour and every preserved 0 W path
  • Docs updated (planner spec, troubleshooting guide, engineering log)

Fixes #983

🤖 Generated with Claude Code

… 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>
…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>
@woopstar
woopstar merged commit 96d330d into v6.3.0-hotfix Sep 13, 2026
9 checks passed
@woopstar
woopstar deleted the fix/983-discharge-mode-zero-cap-oscillation branch September 13, 2026 05:25
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.

1 participant