Skip to content

fix(decision): refresh Decision sensor and clarify its texts - #450

Merged
MaStr merged 1 commit into
mainfrom
copilot/bugfix-decision-sensor-refresh
Oct 9, 2026
Merged

MaStr merged 1 commit into
mainfrom
copilot/bugfix-decision-sensor-refresh

Conversation

@MaStr

@MaStr MaStr commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Problem

  • The Decision sensor kept the trace of the last mode change. refresh_static_values() republished that same event every cycle, so the numbers in the text (e.g. usable energy of Discharge Allowed) stayed stale for hours, and a new reason with the same mode never showed up.
  • Avoid Discharge was 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.
  • Charge rate clamps were decisive steps and replaced the actual reason of the mode in the text.

Changes

Publish behaviour (decision_journal.py, core.py, mqtt_api.py)

  • Listeners registered with add_listener(listener, refresh=True) additionally get kind='refresh' events when the decisive reason changes or the last event is 15 minutes old. Checked in commit() (once per 3-min control cycle), no timer.
  • Refreshes do not move the status change reference (last_status_change(), 25 % comparison). New last_event() returns the newest event incl. refreshes and is what the cycle republishes.
  • Only MQTT registers with 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_idle now also records stored_usable_energy/reserved_energy.
  • Clamps (PV_/GRID_CHARGE_RATE_CLAMPED) are non-decisive notes, appended in brackets via DecisionTrace.explanation().
  • Reworded: always-allow level (now with Wh, new input 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.
  • Reason codes unchanged (stable contract).

Docs: docs/development/decision-trace.md, docs/features/decision-sensor.md, one line in CLAUDE.md.

Tests

  • New: refresh events (interval, reason change, mode change wins, value reference kept, last_event(), re-registration), notes, peak shaving variants, Avoid Discharge texts incl. 255-char HA state limit.
  • Updated existing tests for new texts and clamp semantics.
  • ./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

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
Copilot AI balanced review requested due to automatic review settings October 9, 2026 05:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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 refresh events to DecisionJournal: listeners registered with add_listener(..., refresh=True) also receive kind='refresh' events when the decisive reason changes or the last event is 15 minutes old, without moving the status-change reference; adds last_event().
  • Rework explanation texts in _REASON_EXPLANATIONS (shared _HELD prefix for Avoid Discharge, callable _peak_shaving_explanation), add NOTE_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.

@MaStr
MaStr merged commit 7fbf094 into main Oct 9, 2026
12 checks passed
@MaStr
MaStr deleted the copilot/bugfix-decision-sensor-refresh branch October 9, 2026 05:53
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.

3 participants