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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ tmp/ # Throwaway experiments — NEVER committed
shaving, solar limit, external overrides like evcc/API/grid-charge-lock) is recorded as a
`DecisionRecord` in a `DecisionTrace` (`logic/decision_trace.py`), collected into an
in-memory `DecisionJournal` (`decision_journal.py`) that notifies listeners on a mode or
value change. The MQTT "Decision" sensor (`mqtt_api.py`) is the first listener. See
value change (listeners registered with `refresh=True` also on a reason change and every
15 min). The MQTT "Decision" sensor (`mqtt_api.py`) is the first listener. See
`docs/development/decision-trace.md` and `docs/features/decision-sensor.md`.

## Change Checklist
Expand Down
74 changes: 62 additions & 12 deletions docs/development/decision-trace.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ produces a **decision trace**: the ordered list of decision steps that were
evaluated, each with its inputs and a stable reason code. The traces are kept in
an in-memory **journal**, and registered listeners are called whenever the
inverter status changes (new mode, or a significant change of the charge rate /
PV limit).
PV limit). Listeners that show the explanation (the "Decision" sensor) can
also ask for refreshes, so its numbers and reason stay current.

This is the foundation for answering "why did batcontrol do this?" from the
outside, for example from a chat bot or an MCP server. For what the Home
Expand Down Expand Up @@ -41,7 +42,8 @@ explains why something did **not** happen.
| `solar_limit` | `limit_set` | `CLIP_ABSORPTION_LIMIT` | only if it changed the limit set by an earlier rule |
| `solar_limit` | `not_needed` | `NO_LIMIT_NEEDED`, `NO_CLIP_PREDICTED` | no |
| `solar_limit` | `skipped` | `NO_PV_PRODUCTION`, `FORCE_CHARGE_ACTIVE`, `DISCHARGE_NOT_ALLOWED` | no |
| `override` | `applied` | `EXTERNAL_DISCHARGE_BLOCK`, `EXTERNAL_DISCHARGE_UNBLOCK`, `GRID_CHARGE_LOCK`, `FORECAST_ERROR_FALLBACK`, `CALCULATION_FAILED`, `API_REQUEST` (input `requested_mode` shows the mode the API asked for, which can differ if it fell back), `PV_CHARGE_RATE_CLAMPED`, `GRID_CHARGE_RATE_CLAMPED` | yes |
| `override` | `applied` | `EXTERNAL_DISCHARGE_BLOCK`, `EXTERNAL_DISCHARGE_UNBLOCK`, `GRID_CHARGE_LOCK`, `FORECAST_ERROR_FALLBACK`, `CALCULATION_FAILED`, `API_REQUEST` (input `requested_mode` shows the mode the API asked for, which can differ if it fell back) | yes |
| `override` | `applied` | `PV_CHARGE_RATE_CLAMPED`, `GRID_CHARGE_RATE_CLAMPED` | no (note, see below) |
| `mode` | `allow_discharging`, `limit_battery_charge_rate`, `avoid_discharging`, `force_charge` | reason of the decisive step | - |

`grid_recharge`/`no_charge` has five reasons for the same outcome, told
Expand All @@ -62,16 +64,31 @@ apart purely from `high_price_slots`/`high_price_energy_demand`/
amount is below the minimum charge amount, so nothing is charged for it
(`recharge_energy_before_minimum` > 0).

This step is only reached when the `discharge` step forbade discharging, so
it is the decisive step of the Avoid Discharge mode. It therefore also
carries `stored_usable_energy` and `reserved_energy` from that step, and its
explanation says both why the battery is held and why it is not recharged
from the grid, e.g. `battery is held for upcoming more expensive hours
(usable 900 Wh, reserve 2500 Wh); no grid charging: solar is forecast to
cover the demand of the hours where it would pay off`. The two are not a
contradiction: the reserve counts every upcoming hour that is more
expensive than now, grid charging only pays off for hours that are more
expensive by at least the (dynamic) minimum price difference.

`core.py` applies two hardware/config-level clamps *after* the logic has
already decided a value: `force_charge()` caps the grid recharge rate to
`max_grid_charge_rate`, and `limit_battery_charge_rate()` caps the PV limit
to `max_pv_charge_rate`/`min_pv_charge_rate`. When a clamp actually changes
the value, it is recorded as a decisive `override`/`applied` step
the value, it is recorded as a non-decisive `override`/`applied` step
(`GRID_CHARGE_RATE_CLAMPED` / `PV_CHARGE_RATE_CLAMPED`, inputs `requested_w`
and `applied_w`) between the step that decided the pre-clamp value and the
`mode` record -- so the trace's "why" always matches what was actually
applied to the inverter, instead of silently keeping the earlier step's
now-stale reason. A clamp that does not change anything (the decided value
`mode` record. Such a step is a **note**: it adjusts the value, but the
reason for the mode stays with the decisive step, and the note is appended
to its explanation in brackets (`DecisionTrace.explanation()`, reasons in
`NOTE_REASONS`), e.g. `... so 1600 Wh is charged from the grid (charge rate
capped from 3200 W to 1000 W by the configured maximum grid charge rate)`.
So the "why" matches what was actually applied to the inverter without
hiding why the mode was chosen. A clamp that does not change anything (the decided value
was already within limits) adds no step. This only applies to mode changes
made from within a control cycle (`Batcontrol.run()`); a direct API call
(`api_set_charge_rate`/`api_set_limit_battery_charge_rate`) has no decision
Expand All @@ -93,16 +110,25 @@ not renamed lightly.
`DecisionRecord.explanation()` renders one sentence for a reason code, meant
for a human rather than the source code -- it is what ends up in the "Decision"
sensor's state (`DecisionTrace.status_text()`) and as `why` in `to_dict()`
(both on the record and, for the decisive step, on the trace itself). The
(on the record; on the trace it is `DecisionTrace.explanation()`, the
decisive step's sentence plus the notes). The
mapping lives in `_REASON_EXPLANATIONS` (`decision_trace.py`): most entries
are a plain string with `{input_key}` placeholders, filled in via
`str.format(**record.inputs)` from the record's own inputs -- the same
numbers already captured for the log line, so adding an explanation never
needs a new input. A reason with no placeholders is used as-is. If filling
needs a new input. A reason with no placeholders is used as-is. An entry can
also be a function of the inputs, for a sentence that depends on the values
rather than only showing them (`PV_CHARGE_LIMITED` names the purpose of each
active peak shaving limit). If filling
in the placeholders fails (a key is missing, e.g. because a record was built
before an explanation existed for its reason), `explanation()` falls back to
a readable version of the reason code itself and never raises.

Guidelines for the sentence of a decisive reason: it answers "why is the
inverter in this mode" (not why something else did not happen), and it does
not repeat the value of the mode, which precedes it in the status text
(`Limit Battery Charge 1200 W - PV charging is limited so ...`).

## Logging

Each record can render itself as one line (`DecisionRecord.summary()`). The
Expand Down Expand Up @@ -139,6 +165,7 @@ journal = batcontrol.decision_journal

journal.latest() # trace of the most recent decision
journal.last_status_change() # why the inverter is in its current state
journal.last_event() # same, or a newer refresh of it (see below)
journal.history(10) # newest 10 traces, oldest first
```

Expand Down Expand Up @@ -182,16 +209,39 @@ What a listener does is up to the listener. The journal only guarantees:
Cycles without a status change do not call listeners; they are still stored in
the journal and available via `latest()` and `history()`.

### Refresh events

A status change keeps its trace, so the numbers in its explanation get old
while the mode stays the same (for example the usable energy of `Discharge
Allowed` over several hours), and a new reason with the same mode would not
show up at all. A listener that shows the explanation registers with
`add_listener(listener, refresh=True)` and additionally gets events of
`kind == 'refresh'` with the trace of the latest cycle when

- the reason of the decisive step changed, or
- the last event (of any kind) is `DEFAULT_REFRESH_INTERVAL` (15 minutes)
old.

The check runs in `commit()`, i.e. once per control cycle (every 3 minutes),
so no timer is needed. Refresh events have `previous_mode=None` and
`previous_value=None`, and they do not move the reference of the status
change detection: `last_status_change()` and the 25 % comparison stay at the
last `mode`/`value` event. `last_event()` returns the newest event including
refreshes. Listeners registered without `refresh=True` never see refresh
events.

### Built-in listener: Home Assistant "Decision" sensor

If MQTT is enabled, `MqttApi.publish_status_change` is registered as listener.
If MQTT is enabled, `MqttApi.publish_status_change` is registered as listener
with `refresh=True`.
It publishes the mode with its value and reason as text on `<base>/decision`
(e.g. `Charge from Grid 1250 W - Grid recharge required`) and the trace as JSON
on `<base>/decision/attributes`. Both are retained. Home Assistant discovers
them as the **Decision** sensor, with the JSON as attributes. The content
changes on status changes only, not on every evaluation. The last status change
is published again in every evaluation, like the mode, so an event that
happened while the broker was unreachable reaches the sensor afterwards.
changes on status changes and refresh events, not on every evaluation, so it
is at most 15 minutes old. The last event (`last_event()`) is published again
in every evaluation, like the mode, so an event that happened while the
broker was unreachable reaches the sensor afterwards.
The attributes are published first, and the text only if that publish's
return code reports success, so the text topic never advances ahead of the
attributes topic. The return code of the text publish is checked too; if
Expand Down
56 changes: 41 additions & 15 deletions docs/features/decision-sensor.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ The state always has the same shape:
- **why** is one sentence explaining the mode, built from the numbers
batcontrol actually used to decide.

The sensor only updates when something meaningful changes: a different
mode, or the charge rate / PV limit changing by 25&nbsp;% or more. Small,
continuous adjustments between evaluations do not create a new state.
The sensor updates when something meaningful changes: a different mode,
the charge rate / PV limit changing by 25&nbsp;% or more, or a different
reason. Otherwise it is refreshed every 15 minutes, so the numbers in the
text are at most 15 minutes old. Small adjustments between evaluations do
not create a new state right away.

If a configured limit changes the charge rate or the PV limit that
batcontrol calculated (`max_grid_charge_rate`, `max_pv_charge_rate`,
`min_pv_charge_rate`), the reason stays the same and the adjustment is
added in brackets at the end of the text.

## Worked examples

Expand All @@ -40,33 +47,52 @@ exactly what `status_text()` produces for the numbers shown.

**Battery is allowed to discharge, there is enough energy**
> Discharge Allowed - usable energy (3500 Wh) exceeds the 1200 Wh reserved
> for upcoming expensive hours
> for upcoming more expensive hours

batcontrol is holding back 1200 Wh for a price spike it has already seen
coming; the rest (3500 Wh usable) is free to use now.

**Battery is charged from the grid**
> Charge from Grid 2133 W - usable energy (900 Wh) is below the 2500 Wh
> reserved for upcoming expensive hours, so 1600 Wh is charged from the grid
> reserved for upcoming more expensive hours, so 1600 Wh is charged from the
> grid

The 900 Wh left is not enough to cover the reserve for the next expensive
hours, so the missing 1600 Wh is bought now while it is cheap, at a rate
that fills the battery before the current price slot ends.

**Battery is held, but not charged from the grid**
> Avoid Discharge - battery is held for upcoming more expensive hours
> (usable 1300 Wh, reserve 1400 Wh); no grid charging: solar is forecast to
> cover the demand of the hours where it would pay off

The first part says why the battery is not discharged now: the energy is
needed later, when power is more expensive than now. The second part says
why it is not topped up from the grid either. Buying from the grid only pays
off for hours that are clearly more expensive (by at least the minimum price
difference), and here the solar forecast already covers those. Other
endings are, for example, `the 8000 Wh grid charge limit is reached`, `no
upcoming price is far enough above the current price to pay off` or `the
missing 50 Wh are below the minimum charge amount`.

**Peak shaving slows down PV charging**
> Limit Battery Charge 1200 W - PV charging is capped at 1200 W
> (time,price) so the battery does not fill up before 16:00
> Limit Battery Charge 1200 W - PV charging is limited so the battery is
> not full before 16:00 and room is kept for solar surplus in cheap-price
> hours

See [Peak Shaving](peak-shaving.md): without the cap, the battery would
See [Peak Shaving](peak-shaving.md): without the limit, the battery would
fill up early and stop absorbing solar, well before the configured target
hour (16:00 here).
hour (16:00 here). The text names the active parts of peak shaving: the
target hour (time based) and/or the cheap-price hours (price based).

**Solar feed-in limit (`Solarspitzengesetz`) absorption**
> Limit Battery Charge 900 W - the battery charges at 900 W to absorb
> solar surplus that would otherwise be clipped at the 6000 W feed-in limit
> Limit Battery Charge 900 W - PV charging is limited to keep battery room
> for the solar surplus above the 6000 W feed-in limit, which would
> otherwise be curtailed

Production is about to exceed the configured 6000 W feed-in limit; rather
than curtailing (= wasting) the surplus, the battery absorbs it.
than curtailing (= wasting) the surplus, the battery keeps room for it and
absorbs it.

**An external system blocks discharge**
> Avoid Discharge - an external system (such as evcc) requested that the
Expand All @@ -84,14 +110,14 @@ had no limit configured, so batcontrol fell back to a safe mode instead.

**Forecasts could not be refreshed**
> Discharge Allowed - forecast data could not be refreshed for 185
> seconds, falling back to a safe mode
> seconds, so discharging is allowed as a safe fallback

batcontrol could not reach a forecast provider; after a short grace period
it falls back to allowing discharge rather than guessing.

**Battery is full enough to always allow discharge**
> Discharge Allowed - stored energy (8200 Wh) is above the
> always-allow-discharge level (80% of capacity)
> Discharge Allowed - battery holds 8200 Wh, above the
> always-allow-discharge level of 80% (8000 Wh)

Above the configured `always_allow_discharge_limit`, batcontrol always
allows discharging, regardless of price.
Expand Down
24 changes: 14 additions & 10 deletions src/batcontrol/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,9 +398,10 @@ def __init__(self, configdict: dict):
)
self.mqtt_api.wait_ready()
# The "Decision" sensor follows the status changes of the
# decision journal
# decision journal, plus the refreshes that keep the numbers
# and the reason in its text current
self.decision_journal.add_listener(
self.mqtt_api.publish_status_change)
self.mqtt_api.publish_status_change, refresh=True)
# Register for callbacks
self.mqtt_api.register_set_callback(
'mode',
Expand Down Expand Up @@ -975,14 +976,17 @@ def __record_clamp(self, reason: str, requested: float, applied: float,
max_pv_charge_rate, max_grid_charge_rate) changed the value the
logic decided -- so the trace's "why" matches what was
actually applied, instead of the logic's pre-clamp number
silently drifting from the mode record's value. A no-op if
there is no pending trace to add to (e.g. a direct API call
with no decision scope open) or if the clamp did not change
anything. """
silently drifting from the mode record's value. The clamp is
recorded as a note, not as a decisive step: it adjusts the
value, but the reason for the mode stays with the logic's step
and the clamp is appended to its explanation (see
DecisionTrace.explanation). A no-op if there is no pending
trace to add to (e.g. a direct API call with no decision scope
open) or if the clamp did not change anything. """
if self._pending.trace is None or applied == requested:
return
self._pending.trace.step(
Decision.OVERRIDE, Outcome.APPLIED, reason, decisive=True,
Decision.OVERRIDE, Outcome.APPLIED, reason,
requested_w=requested, applied_w=applied, **extra)

def __commit_decision(self, mode, control_source: str, value):
Expand Down Expand Up @@ -1288,9 +1292,9 @@ def refresh_static_values(self) -> None:
self.mqtt_api.publish_mode(self.last_mode)
# like the mode, so a status change that happened while the
# broker was unreachable reaches the Decision sensor later
last_change = self.decision_journal.last_status_change()
if last_change is not None:
self.mqtt_api.publish_status_change(last_change)
last_event = self.decision_journal.last_event()
if last_event is not None:
self.mqtt_api.publish_status_change(last_event)
self.mqtt_api.publish_charge_rate(self.last_charge_rate)
self.mqtt_api.publish_limit_battery_charge_rate(
self._limit_battery_charge_rate)
Expand Down
Loading
Loading