From 30a48c12491a7aac6f893ac4a7cb3c872be28b9f Mon Sep 17 00:00:00 2001 From: Merge Test Date: Sun, 13 Sep 2026 05:06:37 +0000 Subject: [PATCH 1/2] fix(applier): exempt batteries_discharge_mode from the hold-derived 0 W discharge cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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 --- .github/memories.md | 22 ++ .../hsem/custom_sensors/applier.py | 4 +- .../hsem/custom_sensors/applier_caps.py | 36 ++ docs/planner-spec.md | 38 ++ docs/troubleshooting-guide.md | 25 ++ tests/test_discharge_mode_cap_oscillation.py | 345 ++++++++++++++++++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 tests/test_discharge_mode_cap_oscillation.py diff --git a/.github/memories.md b/.github/memories.md index 3cc76b7a..dfc76945 100644 --- a/.github/memories.md +++ b/.github/memories.md @@ -1757,3 +1757,25 @@ Tests: `tests/test_coordinator_tracking_savings_baseline.py` (baseline exceeds t **Fix (the "cleaner" option from the issue, not the minimal one):** dropped `_solar_corrector_processed` entirely — from `coordinator.py.__init__`, the `CoordinatorSharedState` protocol (`coordinator_state.py`), the `accumulate_forecast_actuals()` signature, and its `coordinator_cycle.py` call site. The loop in `accumulate_forecast_actuals()` now gates purely on `solar_corrector.processed_through` (`frec.start <= processed_through` → skip) and calls `solar_corrector.mark_processed(frec.start)` immediately after the `update_hour`/`update_residual` pair. Since `processed_through` is restored before the next update cycle runs, a restored corrector correctly skips every slot it already learned pre-restart — see `docs/forecast-accuracy-tracking.md` → "Reboot persistence" for the full restore chain. Test: `tests/test_coordinator_tracking_solar_corrector.py::test_restored_solar_corrector_does_not_relearn_finalised_slot` — learns one finalised slot, persists+restores the corrector into a brand-new instance (simulating a restart) against the _same_ forecast tracker still holding that finalised record, then asserts the per-hour history length stays at 1 (not 2) after a second `accumulate_forecast_actuals()` call. + +## `batteries_discharge_mode` Slot Capped to 0 W and Oscillating Against the Rated Max (issue #983) + +**Real bug, reported against 6.3.0 with a register-level timeline** (#978 comments by AndersN76 and Extati): the Huawei `Maximum discharging power` register flipped 2500 W ↔ 0 W six times over ~2 h of an evening discharge window (~44 min at 0 W, ~0.56 kWh of avoidable grid import) while `sensor.hsem_workingmode_sensor` stayed on `batteries_discharge_mode` the whole time. **Explicitly not #939/#941:** that one wrote rated max then 0 W inside the _same_ apply cycle; here the writes are minutes apart and belong to separate replan/apply events, with the #941 single-write-per-cycle fix present and working. + +**Root cause — a derivation that is only valid for some labels was applied to all of them.** `applier_caps._primary_battery_hold()` infers "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 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 exact behaviour the mode exists for and contradicts the `Recommendations` enum's own contract for it ("discharge battery to cover house load"). + +**Why nothing damped it:** the published recommendation never changed, and both existing hysteresis layers key off a _recommendation_ change — plan-level hysteresis (#372) stabilises candidate selection, window hysteresis (#315) holds the current slot's label (and `window_hysteresis.py::_rec_category` lets any transition involving a neutral recommendation through immediately anyway). Nothing guarded this final actuator boundary. + +**Fix:** new `applier_caps._primary_battery_cap_hold(rec)` — `_primary_battery_hold(rec)` **and** the recommendation is not `batteries_discharge_mode` — used for the discharge-cap decision in `applier.py` only. `_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 anyway, so that branch is untouched). Every other 0 W path is independent of the hold and keeps immediate precedence: EV permission gating (#797), the planned-EV rate cap and phase-headroom reservation (#816), the solar-charge-only cap (#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. + +**Two alternatives considered and rejected, worth knowing if this resurfaces:** + +- _Relabel `batteries_discharge_mode` → `batteries_wait_mode` when the solved discharge is zero_ (the reporter's third suggestion, and the symmetrical-looking change to `soc_simulation.py`). Fixes the dashboard-vs-hardware contradiction but **not** the churn: window hysteresis explicitly does not hold transitions to a neutral recommendation, so the label — and therefore the register — would still flip every replan. It also changes planner semantics and ripples into the charge/discharge schedulers. +- _A slot-scoped actuator latch_ (freeze the current slot's hold decision at slot entry, mirroring `_ev_held_slot_start` / `coordinator_ev_command_stability.py`). Works, but adds cross-cycle state to the applier to damp the output of a mis-derivation instead of removing the wrong input. Keep this option in mind only if a future report shows oscillation on a slot whose _recommendation_ genuinely changes each replan — that is the case the latch would cover and this fix does not. + +**`applier.py` size gotcha:** the file was at 29 859 bytes against the 30 KB hard limit, so the helper and its full rationale live in `applier_caps.py` (8.5 KB) and `applier.py` carries a one-line comment plus the call. It is now 29 915 bytes — anything further in that file needs a split first. + +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; and 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. diff --git a/custom_components/hsem/custom_sensors/applier.py b/custom_components/hsem/custom_sensors/applier.py index cb9c57df..0135916f 100644 --- a/custom_components/hsem/custom_sensors/applier.py +++ b/custom_components/hsem/custom_sensors/applier.py @@ -46,6 +46,7 @@ _fmt_live_power_w, _held_planned_export_is_authoritative, _planned_ev_discharge_cap_w, + _primary_battery_cap_hold, _primary_battery_hold, _wait_mode_self_consumption_cap_w, ) @@ -163,7 +164,8 @@ async def async_apply_battery_settings( ) recommendation = rec.recommendation - primary_battery_hold = _primary_battery_hold(rec) + # Cap-scoped: exempts batteries_discharge_mode (issue #983). + primary_battery_hold = _primary_battery_cap_hold(rec) held_planned_export = _held_planned_export_is_authoritative(rec) # Huawei exposes ONE global battery discharge limit, shared with every EV. diff --git a/custom_components/hsem/custom_sensors/applier_caps.py b/custom_components/hsem/custom_sensors/applier_caps.py index 90b5c59b..eb563b54 100644 --- a/custom_components/hsem/custom_sensors/applier_caps.py +++ b/custom_components/hsem/custom_sensors/applier_caps.py @@ -11,6 +11,7 @@ from custom_components.hsem.models.live_state import EVLiveState from custom_components.hsem.models.sensor_config import SensorConfig +from custom_components.hsem.utils.recommendations import Recommendations from custom_components.hsem.utils.units import is_material_planned_energy_kwh if TYPE_CHECKING: @@ -196,6 +197,41 @@ def _primary_battery_hold(rec: HourlyRecommendation) -> bool: ) and not is_material_planned_energy_kwh(rec.batteries_discharged_kwh) +def _primary_battery_cap_hold(rec: HourlyRecommendation) -> bool: + """Return whether a hold-derived 0 W discharge cap applies to this slot. + + :func:`_primary_battery_hold` *derives* an explicit hold from a + near-zero energy pair. That derivation is only valid for a slot whose + label carries no independent discharge intent. A + ``batteries_discharge_mode`` slot is exempt (issue #983): + + - ``soc_simulation.py`` relabels only ``force_batteries_discharge`` / + ``force_export`` to wait when the simulated discharge is zero. A + schedule discharge window deliberately keeps its label — it is the + user's configured window, not a forced action — so a solved discharge + that merely rounds below the materiality threshold still reads as an + explicit hold here, which it is not. + - The slot executes as ``MaximizeSelfConsumption``, where this cap is a + *ceiling* the firmware ramps within from live house load, not a + setpoint. A 0 W cap disables the exact behaviour the mode exists for + and contradicts :class:`~utils.recommendations.Recommendations`' + own contract for it ("discharge battery to cover house load"). + + Without the exemption the cap flips between 0 W and the rated maximum + every time the re-solved current slot crosses the 0.001 kWh boundary, + while the published recommendation — and therefore every existing + hysteresis layer — never changes. + + Only the discharge-cap decision uses this wrapper. Every other 0 W + path is independent of it and keeps immediate precedence: EV permission + gating, the solar-charge-only cap, the wait-mode reserve floor, the SoC + reserve guard, and the read-only/degraded gates. + """ + if rec.recommendation == Recommendations.BatteriesDischargeMode.value: + return False + return _primary_battery_hold(rec) + + def _held_planned_export_is_authoritative(rec: HourlyRecommendation) -> bool: """Return whether a held slot must preserve its solved grid export. diff --git a/docs/planner-spec.md b/docs/planner-spec.md index 1f667ff6..03332880 100644 --- a/docs/planner-spec.md +++ b/docs/planner-spec.md @@ -2073,6 +2073,8 @@ applier (`applier._planned_ev_discharge_cap_w()` + **Primary battery hold**: independent of any EV, when the solved plan scheduled neither charge nor discharge for the primary battery this slot (`primary_battery_hold` — see below), the cap is unconditionally 0 W. +`batteries_discharge_mode` is exempt from this particular 0 W path — see +"Discharge-mode exemption" below. **Solar-charge-only slot (issue #922)**: when the recommendation is `batteries_charge_solar` and no EV is active/planned, the cap is @@ -2117,6 +2119,42 @@ TOU) and `ev_smart_charging` (which otherwise always executes as MSC to retain unexpected solar). `held_planned_export` takes priority over self-consumption-with-reserve too — see issue #954 below. +#### Discharge-mode exemption (issue #983) + +The hold is _derived_ from a near-zero energy pair, which is only a valid +reading of "the plan explicitly holds the battery" for a slot whose label +carries no independent discharge intent. `batteries_discharge_mode` does +carry one, so the **discharge-cap decision** uses +`applier_caps._primary_battery_cap_hold(rec)` — `_primary_battery_hold(rec)` +**and** the recommendation is not `batteries_discharge_mode` — instead: + +- The SoC simulation relabels only `force_batteries_discharge` / + `force_export` to `batteries_wait_mode` when the simulated discharge is + zero (`planner/soc_simulation.py`). A schedule discharge window + deliberately keeps its label: it is the user's configured window, not a + forced action. A solved discharge that merely rounds below + `PLANNED_ENERGY_ROUNDING_KWH` therefore still satisfies the derived hold + without the plan ever having decided to hold. +- `batteries_discharge_mode` executes as `MaximizeSelfConsumption`, where + the cap is a **ceiling the firmware ramps within** from live house load, + not a setpoint. A 0 W cap disables the behaviour the mode exists for and + contradicts the mode's own contract ("discharge battery to cover house + load"). + +Without the exemption the cap flips between 0 W and the rated maximum every +time the re-solved current slot crosses the materiality boundary, while the +published recommendation — and therefore plan-level hysteresis (#372) and +window hysteresis (#315), which only react to a _recommendation_ change — +never moves. Nothing else guards this actuator boundary. + +The exemption is scoped to the cap decision only. `_primary_battery_hold()` +keeps its meaning for `_held_planned_export_is_authoritative()` and for the +`batteries_wait_mode` working-mode branch. Every other 0 W path is +independent of the hold and keeps immediate precedence: EV permission +gating (#797), the solar-charge-only cap (#922), the wait-mode reserve floor +(#954), the `current_required_battery_kwh` SoC guard (#592), and the +read-only / degraded-mode gates. + ### Wait-mode self-consumption reserve (issue #914) **Discharge cap is an SoC-floor stop-discharge gate, not a rate spread over diff --git a/docs/troubleshooting-guide.md b/docs/troubleshooting-guide.md index 97899f6a..af6af137 100644 --- a/docs/troubleshooting-guide.md +++ b/docs/troubleshooting-guide.md @@ -587,6 +587,30 @@ set to 0, the battery cannot export regardless of HSEM's decision. integration or FusionSolar app. Ensure export is permitted at the inverter level. +**7i. Maximum discharging power capped at 0 W** + +The battery physically cannot discharge while the Huawei _Maximum +discharging power_ register is 0 W, even if the recommendation looks +correct. HSEM writes that cap deliberately in several situations: + +- an EV is charging or about to be commanded and has not been given + permission via _Force max discharge power_, +- the slot is a solar-charge-only slot (`batteries_charge_solar`), where the + grid — not the battery — covers any house-load deficit, +- the slot is a genuine `batteries_wait_mode` hold, or wait-mode + self-consumption has reached its reserve floor, +- the remaining battery energy is at or below the reserve the planner needs + for its upcoming scheduled plans. + +- **Check:** `number.*_maximum_discharging_power` (or the equivalent entity + configured in the Huawei Solar config step) against + `sensor.hsem_workingmode_sensor`. Then turn on + `switch.hsem_verbose_logging` and search `hsem.log` for + `capped max discharge power to` — the log line names the exact reason. +- **Fix:** Address whichever reason the log reports. If the cap is 0 W + while the recommendation is `batteries_discharge_mode` and none of the + above applies, that is the bug fixed in issue #983 — upgrade. + --- ## When to check the logs @@ -607,6 +631,7 @@ Search for these patterns in `hsem.log`: | `[selector] No eligible candidates` | All plans rejected during validation | | `[selector] HYSTERESIS kept previous plan` | Plan switch suppressed by hysteresis | | `Sensor read failed for entity_id` | Specific entity reading error — check entity | +| `capped max discharge power to` | Battery discharge limited — line names the reason | | `EV is physically charging but no slot has load > 0` | EV charging without planned load | ### Home Assistant log (`home-assistant.log`) diff --git a/tests/test_discharge_mode_cap_oscillation.py b/tests/test_discharge_mode_cap_oscillation.py new file mode 100644 index 00000000..d616aa8f --- /dev/null +++ b/tests/test_discharge_mode_cap_oscillation.py @@ -0,0 +1,345 @@ +"""Tests for the ``batteries_discharge_mode`` discharge-cap exemption (issue #983). + +A schedule discharge window keeps its ``batteries_discharge_mode`` label even +when the simulated discharge is zero (``planner/soc_simulation.py`` relabels +only the two force modes). The derived +``applier_caps._primary_battery_hold()`` therefore read such a slot as an +explicit hold and wrote a 0 W Huawei discharge cap, which oscillated against +the rated maximum every time the re-solved current slot crossed the 0.001 kWh +materiality boundary — while the published recommendation, and therefore every +existing hysteresis layer, never moved. + +Covers: +- ``_primary_battery_cap_hold()`` — the cap-scoped hold wrapper +- ``async_apply_battery_settings`` — no 0 W cap on a discharge slot, and no + repeated writes across replans +- The 0 W paths that must keep immediate precedence (EV permission, + solar-charge-only, wait-mode hold, SoC reserve guard) +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from custom_components.hsem.const import DEFAULT_HSEM_BATTERIES_WAIT_MODE +from custom_components.hsem.custom_sensors.applier import async_apply_battery_settings +from custom_components.hsem.custom_sensors.applier_caps import ( + _primary_battery_cap_hold, + _primary_battery_hold, +) +from custom_components.hsem.models.hourly_recommendation import HourlyRecommendation +from custom_components.hsem.models.live_state import LiveState +from custom_components.hsem.models.sensor_config import SensorConfig +from custom_components.hsem.utils.degraded_mode import DegradedMode +from custom_components.hsem.utils.inverter_verify import ApplyResult, ApplyStatus +from custom_components.hsem.utils.recommendations import Recommendations +from custom_components.hsem.utils.workingmodes import WorkingModes + +_LOGGER_PATCH = "custom_components.hsem.utils.logger.HSEM_LOGGER.debug" +_NOW = datetime(2026, 9, 12, 19, 0, tzinfo=UTC) + +# Below ``PLANNED_ENERGY_ROUNDING_KWH`` — what the solver rounds a marginal +# discharge down to, and the exact value that used to trigger the 0 W cap. +_NEAR_ZERO_KWH = 0.001 +_MATERIAL_KWH = 0.5 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _sensor() -> MagicMock: + sensor = MagicMock() + sensor.hass = MagicMock() + return sensor + + +def _cfg() -> SensorConfig: + cfg = SensorConfig() + cfg.read_only = False + cfg.batteries_wait_mode_behavior = "strict" + cfg.huawei_solar_batteries_working_mode = "select.wm" + cfg.huawei_solar_batteries_maximum_discharging_power = "number.maxdis" + cfg.huawei_solar_batteries_excess_pv_energy_use_in_tou = "select.excess" + cfg.huawei_solar_batteries_tou_charging_and_discharging_periods = "sensor.tou" + cfg.huawei_solar_device_id_batteries = "bat1" + return cfg + + +def _live(*, max_discharge_power_w: int = 2500) -> LiveState: + """Live snapshot for the reporter's setup: LUNA2000 5 kWh, 2500 W rated.""" + live = LiveState() + live._degraded_mode = DegradedMode.OK + live.battery_current_capacity_kwh = 3.0 + live.huawei_batteries_rated_capacity_wh = 5000 + live.huawei_batteries_max_discharge_power_w = max_discharge_power_w + live.huawei_batteries_working_mode = WorkingModes.MaximizeSelfConsumption.value + live.huawei_batteries_excess_pv_use_in_tou = "charge" + live.tou_periods.periods = list(DEFAULT_HSEM_BATTERIES_WAIT_MODE) + return live + + +def _rec( + *, + recommendation: str = Recommendations.BatteriesDischargeMode.value, + charged_kwh: float = 0.0, + discharged_kwh: float = _NEAR_ZERO_KWH, + grid_export_kwh: float = 0.0, +) -> HourlyRecommendation: + """An evening schedule discharge slot with a near-zero solved discharge.""" + return HourlyRecommendation( + start=_NOW, + end=_NOW + timedelta(hours=1), + recommendation=recommendation, + avg_house_consumption_kwh=0.8, + avg_house_consumption_1d_kwh=0.0, + avg_house_consumption_3d_kwh=0.0, + avg_house_consumption_7d_kwh=0.0, + avg_house_consumption_14d_kwh=0.0, + batteries_charged_kwh=charged_kwh, + batteries_discharged_kwh=discharged_kwh, + estimated_battery_capacity_kwh=3.0, + estimated_battery_soc_pct=60.0, + estimated_cost_currency=0.0, + estimated_net_consumption_kwh=0.8, + export_price=0.05, + grid_export_kwh=grid_export_kwh, + grid_import_kwh=0.8, + import_price=1.95, + solcast_pv_estimate_kwh=0.0, + ) + + +async def _write_and_verify_ok(entity_id, desired, writer, reader, **kwargs): # type: ignore[no-untyped-def] # local test shim mirrors async_write_and_verify signature + await writer() + return ApplyResult( + entity_id=entity_id, + desired=desired, + actual=desired, + status=ApplyStatus.OK, + attempts=1, + ) + + +# --------------------------------------------------------------------------- +# _primary_battery_cap_hold unit tests +# --------------------------------------------------------------------------- + + +class TestPrimaryBatteryCapHold: + """The cap-scoped wrapper exempts ``batteries_discharge_mode`` only.""" + + def test_discharge_mode_with_near_zero_energy_is_not_a_cap_hold(self): + """The regression: the derived hold is True, the cap hold is not.""" + rec = _rec() + assert _primary_battery_hold(rec) is True + assert _primary_battery_cap_hold(rec) is False + + def test_discharge_mode_with_material_discharge_is_not_a_cap_hold(self): + rec = _rec(discharged_kwh=_MATERIAL_KWH) + assert _primary_battery_cap_hold(rec) is False + + def test_wait_mode_with_near_zero_energy_is_still_a_cap_hold(self): + rec = _rec(recommendation=Recommendations.BatteriesWaitMode.value) + assert _primary_battery_cap_hold(rec) is True + + def test_ev_smart_charging_relabel_is_still_a_cap_hold(self): + """Relabelling never touches the energy fields, so the hold survives.""" + rec = _rec(recommendation=Recommendations.EVSmartCharging.value) + assert _primary_battery_cap_hold(rec) is True + + def test_solar_charge_with_material_charge_is_not_a_cap_hold(self): + rec = _rec( + recommendation=Recommendations.BatteriesChargeSolar.value, + charged_kwh=_MATERIAL_KWH, + ) + assert _primary_battery_cap_hold(rec) is False + + def test_wrapper_does_not_change_the_underlying_derivation(self): + """``_primary_battery_hold()`` itself keeps its meaning (held export).""" + rec = _rec() + assert _primary_battery_hold(rec) is True + + +# --------------------------------------------------------------------------- +# async_apply_battery_settings integration +# --------------------------------------------------------------------------- + + +def _apply_patches(): + """Patch context for one ``async_apply_battery_settings`` call.""" + return ( + patch(_LOGGER_PATCH, new_callable=MagicMock), + patch( + "custom_components.hsem.custom_sensors.applier.async_write_and_verify", + side_effect=_write_and_verify_ok, + ), + patch( + "custom_components.hsem.custom_sensors.applier.async_set_select_option", + new_callable=AsyncMock, + ), + patch( + "custom_components.hsem.custom_sensors.applier.async_set_number_value", + new_callable=AsyncMock, + ), + ) + + +async def _apply( + cfg: SensorConfig, + live: LiveState, + rec: HourlyRecommendation, + required_battery_kwh: float = 0.0, +) -> AsyncMock: + """Run one apply cycle and return the number-write mock.""" + sensor = _sensor() + logger_p, verify_p, select_p, number_p = _apply_patches() + with logger_p, verify_p, select_p, number_p as patched: + await async_apply_battery_settings(sensor, cfg, live, rec, required_battery_kwh) + mock_number: AsyncMock = patched + return mock_number + + +def _discharge_cap_writes(mock_number: AsyncMock) -> list[int]: + """Return the values written to the max-discharge-power entity.""" + return [ + call.args[2] + for call in mock_number.await_args_list + if call.args[1] == "number.maxdis" + ] + + +class TestDischargeModeKeepsItsCap: + """A discharge window must never be capped to 0 W by the derived hold.""" + + @pytest.mark.asyncio + async def test_near_zero_discharge_does_not_write_zero_cap(self): + """The reporter's case: no EV, near-zero solved discharge, cap stays.""" + live = _live(max_discharge_power_w=2500) + mock_number = await _apply(_cfg(), live, _rec()) + assert _discharge_cap_writes(mock_number) == [] + + @pytest.mark.asyncio + async def test_zero_cap_left_by_the_bug_is_restored_to_rated_max(self): + """Hardware stuck at 0 W must be raised back to the rated maximum.""" + live = _live(max_discharge_power_w=0) + mock_number = await _apply(_cfg(), live, _rec()) + assert _discharge_cap_writes(mock_number) == [2500] + + @pytest.mark.asyncio + async def test_material_discharge_is_unchanged(self): + live = _live(max_discharge_power_w=0) + mock_number = await _apply(_cfg(), live, _rec(discharged_kwh=_MATERIAL_KWH)) + assert _discharge_cap_writes(mock_number) == [2500] + + @pytest.mark.asyncio + async def test_discharge_slot_still_runs_maximize_self_consumption(self): + """The 0 W cap used to disable the very mode the slot executes in.""" + sensor = _sensor() + live = _live(max_discharge_power_w=2500) + live.huawei_batteries_working_mode = WorkingModes.TimeOfUse.value + logger_p, verify_p, select_p, number_p = _apply_patches() + with logger_p, verify_p, select_p as mock_select, number_p: + await async_apply_battery_settings(sensor, _cfg(), live, _rec(), 0.0) + mock_select.assert_any_await( + sensor, "select.wm", WorkingModes.MaximizeSelfConsumption.value + ) + + +class TestNoOscillationAcrossReplans: + """The oscillation regression (issue #983). + + Replays the reporter's timeline: the same discharge slot is re-applied on + every replan while the re-solved ``batteries_discharged_kwh`` flips across + the materiality boundary. Before the fix this wrote 0 W / 2500 W / 0 W …; + now the register is written once and then left alone. + """ + + @pytest.mark.asyncio + async def test_flipping_solved_discharge_writes_the_cap_only_once(self): + cfg = _cfg() + # Hardware starts where the bug left it, so the first cycle legitimately + # writes once; every later cycle must be a no-op. + live = _live(max_discharge_power_w=0) + writes: list[int] = [] + + for cycle in range(8): + discharged = _NEAR_ZERO_KWH if cycle % 2 == 0 else _MATERIAL_KWH + mock_number = await _apply(cfg, live, _rec(discharged_kwh=discharged)) + cycle_writes = _discharge_cap_writes(mock_number) + writes.extend(cycle_writes) + # The applier verifies its writes, so reflect them in live state. + if cycle_writes: + live.huawei_batteries_max_discharge_power_w = cycle_writes[-1] + + assert writes == [2500] + assert live.huawei_batteries_max_discharge_power_w == 2500 + + +class TestZeroCapPathsKeepPrecedence: + """Every other 0 W path is independent of the exemption.""" + + @pytest.mark.asyncio + async def test_ev_without_permission_still_forces_zero(self): + """Issue #797: an unpermitted EV blocks discharge, discharge slot or not.""" + cfg = _cfg() + live = _live(max_discharge_power_w=2500) + live.ev.is_charging = True + live.ev.force_max_discharge_power = False + mock_number = await _apply(cfg, live, _rec()) + assert _discharge_cap_writes(mock_number) == [0] + + @pytest.mark.asyncio + async def test_permitted_ev_gets_the_planned_rate_cap(self): + """Issue #797: an opted-in EV clamps the cap to the solved rate.""" + cfg = _cfg() + live = _live(max_discharge_power_w=2500) + live.ev.is_charging = True + live.ev.force_max_discharge_power = True + live.ev.max_discharge_power_w = 3000 + # 1.0 kWh over a 1 h slot -> 1000 W, below both ceilings. + mock_number = await _apply(cfg, live, _rec(discharged_kwh=1.0)) + assert _discharge_cap_writes(mock_number) == [1000] + + @pytest.mark.asyncio + async def test_soc_reserve_guard_still_forces_zero(self): + """Issue #592: the EV-path reserve guard is unaffected.""" + cfg = _cfg() + live = _live(max_discharge_power_w=2500) + live.battery_current_capacity_kwh = 1.0 + live.ev.is_charging = True + live.ev.force_max_discharge_power = True + live.ev.max_discharge_power_w = 3000 + mock_number = await _apply( + cfg, live, _rec(discharged_kwh=1.0), required_battery_kwh=2.0 + ) + assert _discharge_cap_writes(mock_number) == [0] + + @pytest.mark.asyncio + async def test_solar_charge_only_still_forces_zero(self): + """Issue #922: a solar-charge slot keeps its unconditional 0 W cap.""" + cfg = _cfg() + live = _live(max_discharge_power_w=2500) + rec = _rec( + recommendation=Recommendations.BatteriesChargeSolar.value, + charged_kwh=_MATERIAL_KWH, + discharged_kwh=0.0, + ) + mock_number = await _apply(cfg, live, rec) + assert _discharge_cap_writes(mock_number) == [0] + + @pytest.mark.asyncio + async def test_genuine_wait_slot_still_forces_zero(self): + """Issue #797/#914: a held Wait slot keeps the hold cap.""" + cfg = _cfg() + live = _live(max_discharge_power_w=2500) + rec = _rec( + recommendation=Recommendations.BatteriesWaitMode.value, + discharged_kwh=0.0, + ) + mock_number = await _apply(cfg, live, rec) + assert _discharge_cap_writes(mock_number) == [0] From 303eb345eebd887f331ea94f44d428518b39bcc6 Mon Sep 17 00:00:00 2001 From: Merge Test Date: Sun, 13 Sep 2026 05:20:52 +0000 Subject: [PATCH 2/2] test(tracking): back-port stale accumulate_forecast_actuals call-sites from main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_coordinator_tracking_forecast.py | 1 - tests/test_coordinator_tracking_solar_corrector.py | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_coordinator_tracking_forecast.py b/tests/test_coordinator_tracking_forecast.py index 05edcfdb..c2800367 100644 --- a/tests/test_coordinator_tracking_forecast.py +++ b/tests/test_coordinator_tracking_forecast.py @@ -86,7 +86,6 @@ def _accumulate( forecast_tracker=tracker, last_accumulation_ts=last_accumulation_ts, solar_corrector=SolarForecastCorrector(), - solar_corrector_processed=set(), prediction_tracker=PredictionTracker(), last_planner_output=None, update_interval_minutes=1, diff --git a/tests/test_coordinator_tracking_solar_corrector.py b/tests/test_coordinator_tracking_solar_corrector.py index e579fda9..06283a91 100644 --- a/tests/test_coordinator_tracking_solar_corrector.py +++ b/tests/test_coordinator_tracking_solar_corrector.py @@ -95,6 +95,7 @@ def test_restored_solar_corrector_does_not_relearn_finalised_slot() -> None: solar_corrector=solar_corrector, prediction_tracker=prediction_tracker, last_planner_output=None, + update_interval_minutes=5, ) assert solar_corrector.hour_factors.get(9) == pytest.approx(0.75) @@ -124,6 +125,7 @@ def test_restored_solar_corrector_does_not_relearn_finalised_slot() -> None: solar_corrector=restored_corrector, prediction_tracker=prediction_tracker, last_planner_output=None, + update_interval_minutes=5, ) # Still exactly one sample -- the restored corrector did not re-learn