Repository navigation
fix(decision): refresh Decision sensor and clarify its texts - #450
Conversation
The Decision sensor kept the trace of the last mode change, so the numbers in its text (e.g. usable energy of Discharge Allowed) stayed stale for hours and a new reason with the same mode never showed up. - DecisionJournal: listeners registered with refresh=True also get a 'refresh' event when the decisive reason changes or the last event is 15 minutes old (checked in commit(), once per control cycle). Refreshes do not move the status change reference; last_event() returns the newest event and is republished on every cycle. MQTT registers with refresh=True. - Avoid Discharge now explains why the battery is held (usable vs reserve) and then why it is not recharged from the grid; grid_recharge_idle records stored_usable_energy and reserved_energy for that. - Charge rate clamps are non-decisive notes appended in brackets instead of replacing the reason of the mode. - Reworded explanations (always-allow level with Wh, peak shaving purpose per active component, solar limit, fallbacks, unblock, grid charge lock). - Docs and tests updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmPWvYyw7TXPvM1wpqPehM
There was a problem hiding this comment.
🔵 Needs a closer look
It modifies concurrency-adjacent journal state and the user-facing MQTT "Decision" sensor contract across nine files, so final human sign-off is warranted despite the thorough test coverage.
0 open findings
What changed in this PR
This PR fixes staleness and clarity problems in the Home Assistant "Decision" sensor. Previously, the sensor kept the trace of the last mode change and republished that same event every cycle, so numbers (e.g. usable energy) went stale for hours and a new reason with the same mode never surfaced. It also reworks several explanation texts (notably Avoid Discharge, which previously explained why the battery was not charged rather than not discharged) and reclassifies charge-rate clamps from decisive steps into non-decisive "notes" appended to the real reason.
Changes:
- Add
refreshevents toDecisionJournal: listeners registered withadd_listener(..., refresh=True)also receivekind='refresh'events when the decisive reason changes or the last event is 15 minutes old, without moving the status-change reference; addslast_event(). - Rework explanation texts in
_REASON_EXPLANATIONS(shared_HELDprefix for Avoid Discharge, callable_peak_shaving_explanation), addNOTE_REASONS+DecisionTrace.explanation()so clamps are appended in brackets instead of replacing the mode reason. - Record extra inputs (
stored_usable_energy/reserved_energy,always_allow_discharge_energy) on the relevant records, and update docs (decision-trace.md,decision-sensor.md,CLAUDE.md).
| File | Description |
|---|---|
src/batcontrol/decision_journal.py |
Adds refresh-event logic, clock injection, last_event(), and listener re-registration; thread-safe under existing lock |
src/batcontrol/logic/decision_trace.py |
New _peak_shaving_explanation, _HELD prefix, callable explanation support, NOTE_REASONS, DecisionTrace.explanation() |
src/batcontrol/logic/decision_records.py |
grid_recharge_idle gains calc_output and records reserve numbers; discharge_always_allowed records always-allow energy |
src/batcontrol/logic/default.py |
Updates both builder call sites to pass calculation_output / max_capacity |
src/batcontrol/core.py |
Records clamps as non-decisive notes; registers MQTT listener with refresh=True; republishes last_event() |
src/batcontrol/mqtt_api.py |
Docstring updates for refresh events (no behavioral change) |
docs/*, CLAUDE.md |
Documents refresh behavior, note semantics, and reworded sensor texts |
tests/** |
New and updated tests for refresh events, notes, peak shaving, Avoid Discharge texts |
I verified: both changed builder call sites are updated and calculation_output is populated before use; reserved_energy, stored_usable_energy, and max_capacity all exist on their respective dataclasses; the _HELD-prefixed reasons are only ever produced by grid_recharge_idle (so required inputs are always present); reason codes are unchanged; sources are ASCII-only; new behavior is comprehensively covered by tests; and the clamp-as-note change is consistent across code, tests, and docs. I found no objective defects to comment on.
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Problem
refresh_static_values()republished that same event every cycle, so the numbers in the text (e.g. usable energy ofDischarge Allowed) stayed stale for hours, and a new reason with the same mode never showed up.Avoid Dischargewas explained by the grid-recharge step ("... so no grid charging is needed"), i.e. it answered why the battery is not charged, not why it is not discharged.Changes
Publish behaviour (
decision_journal.py,core.py,mqtt_api.py)add_listener(listener, refresh=True)additionally getkind='refresh'events when the decisive reason changes or the last event is 15 minutes old. Checked incommit()(once per 3-min control cycle), no timer.last_status_change(), 25 % comparison). Newlast_event()returns the newest event incl. refreshes and is what the cycle republishes.refresh=True; other listeners (chat bots etc.) still only see mode/value changes.Texts (
decision_trace.py,decision_records.py)Avoid Discharge:battery is held for upcoming more expensive hours (usable X Wh, reserve Y Wh); no grid charging: <reason>.grid_recharge_idlenow also recordsstored_usable_energy/reserved_energy.PV_/GRID_CHARGE_RATE_CLAMPED) are non-decisive notes, appended in brackets viaDecisionTrace.explanation().always_allow_discharge_energy), peak shaving purpose per active component (explanations may now be callables), solar limit, fallbacks name the applied mode, discharge unblock, grid charge lock, skip reasons.Docs:
docs/development/decision-trace.md,docs/features/decision-sensor.md, one line inCLAUDE.md.Tests
last_event(), re-registration), notes, peak shaving variants, Avoid Discharge texts incl. 255-char HA state limit../run_tests.sh: 1314 passed. pylint unchanged (9.63 overall, touched modules 10/10).🤖 Generated with Claude Code
https://claude.ai/code/session_01GmPWvYyw7TXPvM1wpqPehM
Generated by Claude Code