Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/memories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion custom_components/hsem/custom_sensors/applier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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.
Expand Down
36 changes: 36 additions & 0 deletions custom_components/hsem/custom_sensors/applier_caps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
38 changes: 38 additions & 0 deletions docs/planner-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions docs/troubleshooting-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`)
Expand Down
1 change: 0 additions & 1 deletion tests/test_coordinator_tracking_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions tests/test_coordinator_tracking_solar_corrector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading