diff --git a/CLAUDE.md b/CLAUDE.md
index d1644a39..47c350b0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/docs/development/decision-trace.md b/docs/development/decision-trace.md
index e0118cb2..a47e745c 100644
--- a/docs/development/decision-trace.md
+++ b/docs/development/decision-trace.md
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
```
@@ -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 `/decision`
(e.g. `Charge from Grid 1250 W - Grid recharge required`) and the trace as JSON
on `/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
diff --git a/docs/features/decision-sensor.md b/docs/features/decision-sensor.md
index 4fdcdad5..df178eec 100644
--- a/docs/features/decision-sensor.md
+++ b/docs/features/decision-sensor.md
@@ -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 % 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 % 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
@@ -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
@@ -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.
diff --git a/src/batcontrol/core.py b/src/batcontrol/core.py
index ef1a846c..0c55af70 100644
--- a/src/batcontrol/core.py
+++ b/src/batcontrol/core.py
@@ -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',
@@ -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):
@@ -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)
diff --git a/src/batcontrol/decision_journal.py b/src/batcontrol/decision_journal.py
index 6c818d4d..43c10ef5 100644
--- a/src/batcontrol/decision_journal.py
+++ b/src/batcontrol/decision_journal.py
@@ -11,12 +11,23 @@
- the value belonging to the mode (charge rate of force charge, PV limit of
the limit mode) differs from the value of the last event by at least
``value_change_factor`` (``kind == 'value'``).
+
+Listeners that show the explanation of the decision (the MQTT "Decision"
+sensor) also need its numbers and reason to stay current while the status
+does not change. They register with ``refresh=True`` and additionally get a
+``kind == 'refresh'`` event when
+
+- the reason of the decisive step changes, or
+- the last event is ``refresh_interval`` (15 minutes) old.
+
+Refresh events do not change the reference of the status change detection.
"""
import logging
import threading
+import time
from collections import deque
from dataclasses import dataclass
-from typing import Any, Callable, Deque, Dict, List, Optional
+from typing import Any, Callable, Deque, Dict, List, Optional, Tuple
from .logic.decision_trace import DecisionTrace, plain_value
@@ -26,18 +37,25 @@
# 0.25: an event is generated when the value changes by 25 % or more
DEFAULT_VALUE_CHANGE_FACTOR = 0.25
+# a listener registered with refresh=True gets a refresh event at the latest
+# after this time, so values in the explanation are at most this old
+DEFAULT_REFRESH_INTERVAL = 15 * 60 # seconds
+
KIND_MODE = 'mode'
KIND_VALUE = 'value'
+KIND_REFRESH = 'refresh'
@dataclass(frozen=True)
class StatusChangeEvent: # pylint: disable=too-many-instance-attributes
- """The inverter status changed.
-
- ``kind`` is ``'mode'`` or ``'value'``. ``previous_mode`` is None for the
- first mode set after start. ``value`` is the value belonging to the mode
- (W), None for modes without one. ``previous_value`` is only set for
- ``kind == 'value'``: the value of the last event.
+ """The inverter status changed, or (``kind == 'refresh'``) the current
+ status is reported again with the trace of the latest decision.
+
+ ``kind`` is ``'mode'``, ``'value'`` or ``'refresh'``. ``previous_mode``
+ is None for the first mode set after start and for refresh events.
+ ``value`` is the value belonging to the mode (W), None for modes without
+ one. ``previous_value`` is only set for ``kind == 'value'``: the value of
+ the last event.
"""
kind: str
previous_mode: Optional[int]
@@ -64,41 +82,54 @@ def to_dict(self) -> Dict[str, Any]:
StatusChangeListener = Callable[[StatusChangeEvent], None]
-class DecisionJournal:
+class DecisionJournal: # pylint: disable=too-many-instance-attributes
"""Thread safe ring buffer of decision traces plus listener registry."""
def __init__(self, max_traces: int = DEFAULT_MAX_TRACES,
- value_change_factor: float = DEFAULT_VALUE_CHANGE_FACTOR):
+ value_change_factor: float = DEFAULT_VALUE_CHANGE_FACTOR,
+ refresh_interval: float = DEFAULT_REFRESH_INTERVAL,
+ clock: Callable[[], float] = time.monotonic):
if value_change_factor <= 0:
raise ValueError('value_change_factor must be greater than 0, '
f'got {value_change_factor}')
self._value_change_factor = value_change_factor
+ self._refresh_interval = refresh_interval
+ self._clock = clock
self._lock = threading.Lock()
self._traces: Deque[DecisionTrace] = deque(maxlen=max_traces)
- self._listeners: List[StatusChangeListener] = []
+ # (listener, wants refresh events)
+ self._listeners: List[Tuple[StatusChangeListener, bool]] = []
# Mode and value of the last event are the reference for the next
# commit. Values are compared against the last event, not the last
# cycle, so a slow drift in small steps is detected as well.
self._last_change: Optional[StatusChangeEvent] = None
+ # Last event of any kind (including refresh), with the time it was
+ # created: the reference for the next refresh.
+ self._last_event: Optional[StatusChangeEvent] = None
+ self._last_event_time = 0.0
- def add_listener(self, listener: StatusChangeListener) -> None:
+ def add_listener(self, listener: StatusChangeListener,
+ refresh: bool = False) -> None:
"""Register a callback, called with a :class:`StatusChangeEvent` on
- every status change.
+ every status change. With ``refresh=True`` it is also called with
+ the refresh events (see module docstring).
Listeners run synchronously in the thread that changed the mode
(scheduler or API thread). They must return quickly; hand slow work
(network calls) over to a queue or thread. Exceptions are logged and
- do not affect the control loop.
+ do not affect the control loop. Registering a listener again
+ updates its ``refresh`` setting.
"""
with self._lock:
- if listener not in self._listeners:
- self._listeners.append(listener)
+ self._listeners = [
+ entry for entry in self._listeners if entry[0] != listener]
+ self._listeners.append((listener, refresh))
def remove_listener(self, listener: StatusChangeListener) -> None:
"""Unregister a callback. Unknown callbacks are ignored."""
with self._lock:
- if listener in self._listeners:
- self._listeners.remove(listener)
+ self._listeners = [
+ entry for entry in self._listeners if entry[0] != listener]
def _value_changed(self, value: Optional[float],
reference: Optional[float]) -> bool:
@@ -111,34 +142,58 @@ def _value_changed(self, value: Optional[float],
return abs(value - reference) / abs(reference) \
>= self._value_change_factor
+ @staticmethod
+ def _reason(trace: Optional[DecisionTrace]) -> Optional[str]:
+ """Reason code of the decisive step of a trace."""
+ decisive = trace.decisive_record() if trace is not None else None
+ return decisive.reason if decisive is not None else None
+
+ def _refresh_due(self, trace: DecisionTrace, now: float) -> bool:
+ """True if listeners registered with refresh=True need the trace:
+ the reason changed, or the last event is too old."""
+ if self._last_event is None:
+ return False
+ if self._reason(trace) != self._reason(self._last_event.trace):
+ return True
+ return now - self._last_event_time >= self._refresh_interval
+
def commit(self, trace: DecisionTrace, mode: int, control_source: str,
value: Optional[float] = None) -> Optional[StatusChangeEvent]:
"""Store the trace of a finished decision.
``value`` is the value belonging to the mode (charge rate, PV limit).
- Returns the event if the status changed (listeners were notified),
- otherwise None.
+ Returns the event if the status changed or a refresh was due
+ (listeners were notified), otherwise None.
"""
with self._lock:
self._traces.append(trace)
+ now = self._clock()
last = self._last_change
if last is None or mode != last.mode:
kind = KIND_MODE
elif self._value_changed(value, last.value):
kind = KIND_VALUE
+ elif self._refresh_due(trace, now):
+ kind = KIND_REFRESH
else:
return None
event = StatusChangeEvent(
kind=kind,
- previous_mode=last.mode if last is not None else None,
+ previous_mode=(last.mode if last is not None
+ and kind != KIND_REFRESH else None),
mode=mode,
control_source=control_source,
trace=trace,
value=value,
previous_value=last.value if kind == KIND_VALUE else None,
)
- self._last_change = event
- listeners = list(self._listeners)
+ if kind != KIND_REFRESH:
+ self._last_change = event
+ self._last_event = event
+ self._last_event_time = now
+ listeners = [listener for listener, wants_refresh
+ in self._listeners
+ if wants_refresh or kind != KIND_REFRESH]
for listener in listeners:
try:
@@ -154,11 +209,17 @@ def latest(self) -> Optional[DecisionTrace]:
return self._traces[-1] if self._traces else None
def last_status_change(self) -> Optional[StatusChangeEvent]:
- """The most recent status change, i.e. the answer to "why is the
- inverter in its current state"."""
+ """The most recent status change (mode or value), i.e. the answer
+ to "why is the inverter in its current state"."""
with self._lock:
return self._last_change
+ def last_event(self) -> Optional[StatusChangeEvent]:
+ """The most recent event including refresh events: the current
+ status with the most recent explanation that was published."""
+ with self._lock:
+ return self._last_event
+
def history(self, count: Optional[int] = None) -> List[DecisionTrace]:
"""Stored traces, oldest first. ``count`` limits to the newest ones."""
with self._lock:
diff --git a/src/batcontrol/logic/decision_records.py b/src/batcontrol/logic/decision_records.py
index f895c3a0..7d6e233a 100644
--- a/src/batcontrol/logic/decision_records.py
+++ b/src/batcontrol/logic/decision_records.py
@@ -10,10 +10,11 @@
def discharge_always_allowed(calc_input: CalculationInput,
- always_allow_discharge_limit: float
- ) -> DecisionRecord:
+ always_allow_discharge_limit: float,
+ max_capacity: float) -> DecisionRecord:
"""Discharge is allowed because the battery is above the always allow
- discharge limit."""
+ discharge limit. The limit is a ratio of ``max_capacity``; its energy
+ is recorded as well, so it can be compared with the stored energy."""
return DecisionRecord(
decision=Decision.DISCHARGE,
outcome=Outcome.ALLOWED,
@@ -21,6 +22,8 @@ def discharge_always_allowed(calc_input: CalculationInput,
inputs={
'stored_energy': calc_input.stored_energy,
'always_allow_discharge_limit': always_allow_discharge_limit,
+ 'always_allow_discharge_energy':
+ max_capacity * always_allow_discharge_limit,
},
decisive=True,
)
@@ -107,7 +110,8 @@ def grid_recharge_charge(calc_input: CalculationInput, # pylint: disable=too-ma
)
-def grid_recharge_idle(calc_input: CalculationInput, *, # pylint: disable=too-many-arguments,too-many-positional-arguments
+def grid_recharge_idle(calc_input: CalculationInput, # pylint: disable=too-many-arguments,too-many-positional-arguments
+ calc_output: CalculationOutput, *,
is_charging_possible: bool,
charge_limit_capacity: float,
required_recharge_energy: float,
@@ -135,6 +139,12 @@ def grid_recharge_idle(calc_input: CalculationInput, *, # pylint: disable=too-m
high_price_slots is a relative slot index list, same as on
discharge_evaluated/grid_recharge_charge -- interval_minutes is
included for the same reason.
+
+ This step is only reached when the reserved energy rule forbids
+ discharging, so it is the decisive step of the Avoid Discharge mode.
+ ``stored_usable_energy``/``reserved_energy`` (the numbers of that rule)
+ are recorded as well, so its explanation can say both why the battery
+ is held and why it is not recharged from the grid.
"""
if not is_charging_possible:
reason = Reason.GRID_CHARGE_LIMIT_REACHED
@@ -152,6 +162,8 @@ def grid_recharge_idle(calc_input: CalculationInput, *, # pylint: disable=too-m
inputs={
'current_price': calc_input.prices[0],
'stored_energy': calc_input.stored_energy,
+ 'stored_usable_energy': calc_input.stored_usable_energy,
+ 'reserved_energy': calc_output.reserved_energy,
'charge_limit_capacity': charge_limit_capacity,
'required_recharge_energy': required_recharge_energy,
'high_price_slots': high_price_slots,
diff --git a/src/batcontrol/logic/decision_trace.py b/src/batcontrol/logic/decision_trace.py
index 2f687503..fcd02b77 100644
--- a/src/batcontrol/logic/decision_trace.py
+++ b/src/batcontrol/logic/decision_trace.py
@@ -17,7 +17,7 @@
import datetime
import logging
from dataclasses import dataclass, field
-from typing import Any, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional, Union
import numpy as np
@@ -177,6 +177,28 @@ def _reason_text(reason: str) -> str:
return ' '.join(words)
+def _peak_shaving_explanation(inputs: Dict[str, Any]) -> str:
+ """PV_CHARGE_LIMITED: name the purpose of each active limit. The limit
+ itself is already part of the status text (mode value)."""
+ components = str(inputs['active_components']).split(',')
+ purposes = []
+ if 'time' in components:
+ purposes.append('the battery is not full before '
+ f"{inputs['allow_full_battery_after']}:00")
+ if 'price' in components:
+ purposes.append('room is kept for solar surplus in cheap-price hours')
+ if not purposes:
+ raise KeyError('active_components')
+ return 'PV charging is limited so ' + ' and '.join(purposes)
+
+
+# Shared start of the explanations for "no grid charging" while discharging
+# is blocked (mode Avoid Discharge): the reason why the battery is held,
+# then why it is not recharged from the grid either.
+_HELD = ('battery is held for upcoming more expensive hours '
+ '(usable {stored_usable_energy:.0f} Wh, reserve '
+ '{reserved_energy:.0f} Wh); no grid charging: ')
+
# One-sentence, plain-language explanation per reason code, for a human
# reading the Home Assistant "Decision" sensor or the trace JSON rather than
# the source code. Entries with ``{placeholder}`` fields are filled in by
@@ -184,47 +206,47 @@ def _reason_text(reason: str) -> str:
# numbers already captured for the log line, so no extra data is needed.
# ``:.0%`` turns a 0..1 ratio into a percentage, same as elsewhere in the
# format mini-language. A static string (no placeholders) is used as-is.
+# A callable gets the inputs and returns the text, for explanations that
+# depend on the values (not only show them).
+# The explanation of a decisive step answers "why is the inverter in this
+# mode"; the mode label and its value precede it in the status text, so the
+# value is not repeated here.
# See :meth:`DecisionRecord.explanation`.
-_REASON_EXPLANATIONS = {
+_REASON_EXPLANATIONS: Dict[str, Union[str, Callable[[Dict[str, Any]], str]]] = {
Reason.ALWAYS_ALLOW_DISCHARGE_LIMIT:
- 'stored energy ({stored_energy:.0f} Wh) is above the '
- 'always-allow-discharge level ({always_allow_discharge_limit:.0%} '
- 'of capacity)',
+ 'battery holds {stored_energy:.0f} Wh, above the '
+ 'always-allow-discharge level of {always_allow_discharge_limit:.0%} '
+ '({always_allow_discharge_energy:.0f} Wh)',
Reason.USABLE_ENERGY_EXCEEDS_RESERVE:
'usable energy ({stored_usable_energy:.0f} Wh) exceeds the '
- '{reserved_energy:.0f} Wh reserved for upcoming expensive hours',
+ '{reserved_energy:.0f} Wh reserved for upcoming more expensive hours',
Reason.RESERVE_REQUIRED:
'usable energy ({stored_usable_energy:.0f} Wh) is below the '
- '{reserved_energy:.0f} Wh reserved for upcoming expensive hours',
+ '{reserved_energy:.0f} Wh reserved for upcoming more expensive hours',
Reason.GRID_RECHARGE_REQUIRED:
'usable energy ({stored_usable_energy:.0f} Wh) is below the '
- '{reserved_energy:.0f} Wh reserved for upcoming expensive hours, '
+ '{reserved_energy:.0f} Wh reserved for upcoming more expensive hours, '
'so {recharge_energy:.0f} Wh is charged from the grid',
Reason.GRID_CHARGE_LIMIT_REACHED:
- 'stored energy ({stored_energy:.0f} Wh) is already at or above '
- 'the {charge_limit_capacity:.0f} Wh grid-charging limit',
+ _HELD + 'the {charge_limit_capacity:.0f} Wh grid charge limit is '
+ 'reached ({stored_energy:.0f} Wh stored)',
Reason.NO_HIGH_PRICE_SLOTS:
- 'no upcoming price is high enough to justify reserving or '
- 'recharging the battery for it',
+ _HELD + 'no upcoming price is far enough above the current price '
+ 'to pay off',
Reason.HIGH_PRICE_DEMAND_COVERED_BY_PRODUCTION:
- 'solar production is forecast to cover the demand at the upcoming '
- 'expensive hours, so no grid charging is needed',
+ _HELD + 'solar is forecast to cover the demand of the hours where '
+ 'it would pay off',
Reason.NO_RECHARGE_REQUIRED:
- 'stored energy is sufficient until prices rise again, '
- 'so no grid charging is needed',
+ _HELD + 'the stored energy already covers the demand of the hours '
+ 'where it would pay off',
Reason.RECHARGE_BELOW_MINIMUM:
- 'a grid recharge of {recharge_energy_before_minimum:.0f} Wh would be '
- 'needed, but that is below the minimum charge amount, so no grid '
- 'charging happens',
- Reason.PV_CHARGE_LIMITED:
- 'PV charging is capped at {final_limit_w} W ({active_components}) '
- 'so the battery does not fill up before {allow_full_battery_after}:00',
- # final_limit_w is only ever None when this rule did not set a limit,
- # i.e. for a different reason (NO_LIMIT_NEEDED) than this one.
+ _HELD + 'the missing {recharge_energy_before_minimum:.0f} Wh are '
+ 'below the minimum charge amount',
+ Reason.PV_CHARGE_LIMITED: _peak_shaving_explanation,
Reason.CLIP_ABSORPTION_LIMIT:
- 'the battery charges at {final_limit_w} W to absorb solar surplus '
- 'that would otherwise be clipped at the {feed_in_limit_w:.0f} W '
- 'feed-in limit',
+ 'PV charging is limited to keep battery room for the solar surplus '
+ 'above the {feed_in_limit_w:.0f} W feed-in limit, which would '
+ 'otherwise be curtailed',
Reason.NO_LIMIT_NEEDED: 'no PV-charge limit is needed right now',
Reason.NO_CLIP_PREDICTED:
'no clipping is predicted at the {feed_in_limit_w:.0f} W feed-in '
@@ -240,35 +262,45 @@ def _reason_text(reason: str) -> str:
'the battery is already above the always-allow-discharge level, '
'so this rule is skipped',
Reason.FORCE_CHARGE_ACTIVE:
- 'grid charging (mode -1) is active and takes priority over this rule',
+ 'not applied because the battery is charging from the grid',
Reason.DISCHARGE_NOT_ALLOWED:
- 'the battery is preserved for high-price hours, so this rule has no effect',
+ 'not applied because discharging is currently blocked',
Reason.EVCC_CHARGING: 'evcc is actively charging the car, so peak shaving is paused',
Reason.EVCC_EV_EXPECTS_PV_SURPLUS:
'an EV is connected in solar-surplus mode, so peak shaving is paused',
Reason.EXTERNAL_DISCHARGE_BLOCK:
'an external system (such as evcc) requested that the battery not discharge',
Reason.EXTERNAL_DISCHARGE_UNBLOCK:
- 'the external discharge block was lifted; the next evaluation decides what to do',
+ 'the external discharge block was lifted; discharging stays blocked '
+ 'until the next evaluation decides the mode',
Reason.GRID_CHARGE_LOCK:
- 'an external request (e.g. a grid operator signal) is blocking '
- 'charging from the grid',
+ 'grid charging was locked by an external request (e.g. HEMS or grid '
+ 'operator signal), so the running grid charge was stopped',
Reason.FORECAST_ERROR_FALLBACK:
'forecast data could not be refreshed for {seconds_since_error} '
- 'seconds, falling back to a safe mode',
+ 'seconds, so discharging is allowed as a safe fallback',
Reason.CALCULATION_FAILED:
- 'the control calculation failed; falling back to a safe mode',
+ 'the control calculation failed, so discharging is allowed as a '
+ 'safe fallback',
Reason.API_REQUEST: 'requested via the API or Home Assistant',
Reason.PV_CHARGE_RATE_CLAMPED:
- 'the PV charge rate was limited by the logic to {requested_w} W, '
- 'but is further capped to {applied_w} W by the configured PV '
- 'charge rate limits',
+ 'PV limit adjusted from {requested_w} W to {applied_w} W by the '
+ 'configured min/max PV charge rate',
Reason.GRID_CHARGE_RATE_CLAMPED:
- 'the grid recharge rate was calculated as {requested_w} W, but is '
- 'capped to {applied_w} W by the configured maximum grid charge rate',
+ 'charge rate capped from {requested_w} W to {applied_w} W by the '
+ 'configured maximum grid charge rate',
Reason.UNSPECIFIED: 'no specific reason was recorded for this change',
}
+# Reasons of notes: steps that adjust the value the decisive step chose,
+# without changing why the mode was chosen. They are recorded as
+# non-decisive steps and appended to the decisive explanation in brackets,
+# see :meth:`DecisionTrace.explanation`.
+NOTE_REASONS = frozenset({
+ Reason.PV_CHARGE_RATE_CLAMPED,
+ Reason.GRID_CHARGE_RATE_CLAMPED,
+})
+
@dataclass(frozen=True)
class DecisionRecord:
@@ -306,6 +338,8 @@ def explanation(self) -> str:
template = _REASON_EXPLANATIONS.get(self.reason)
if template is not None:
try:
+ if callable(template):
+ return template(self.inputs)
return template.format(**self.inputs)
except (KeyError, IndexError, ValueError, TypeError):
logger.debug(
@@ -371,10 +405,24 @@ def decisive_record(self) -> Optional[DecisionRecord]:
return record
return None
+ def explanation(self) -> Optional[str]:
+ """Plain language "why" of this trace: the explanation of the
+ decisive step, followed by the notes (value adjustments such as
+ the charge rate clamps, see ``NOTE_REASONS``) in brackets.
+ None if the trace has no decisive step."""
+ decisive = self.decisive_record()
+ if decisive is None:
+ return None
+ text = decisive.explanation()
+ for record in self.records:
+ if record.reason in NOTE_REASONS and not record.decisive:
+ text += f' ({record.explanation()})'
+ return text
+
def status_text(self) -> str:
"""The resulting mode with its value and a plain language
explanation as one string, e.g. ``Charge from Grid 1250 W - usable
- energy (900 Wh) is below the 2500 Wh reserved for upcoming
+ energy (900 Wh) is below the 2500 Wh reserved for upcoming more
expensive hours, so 1600 Wh is charged from the grid``.
Empty if the trace has no mode record yet."""
mode = next((r for r in reversed(self.records)
@@ -388,9 +436,9 @@ def status_text(self) -> str:
# The mode record's own inputs do not carry the numbers behind the
# decision (those live on the decisive step); use that step's
# explanation, falling back to the reason code if there is none.
- decisive = self.decisive_record()
- explanation = decisive.explanation() if decisive is not None \
- else _reason_text(mode.reason)
+ explanation = self.explanation()
+ if explanation is None:
+ explanation = _reason_text(mode.reason)
return f'{text} - {explanation}'
def to_dict(self) -> Dict[str, Any]:
@@ -404,6 +452,6 @@ def to_dict(self) -> Dict[str, Any]:
'outcome': decisive.outcome,
'reason': decisive.reason}
if decisive is not None else None),
- 'why': decisive.explanation() if decisive is not None else None,
+ 'why': self.explanation(),
'records': [record.to_dict() for record in self.records],
}
diff --git a/src/batcontrol/logic/default.py b/src/batcontrol/logic/default.py
index 329b8211..f67ec4a2 100644
--- a/src/batcontrol/logic/default.py
+++ b/src/batcontrol/logic/default.py
@@ -249,7 +249,8 @@ def calculate_inverter_mode(self, calc_input: CalculationInput,
# keep current charge level. recharge if solar surplus available
inverter_control_settings.allow_discharge = False
self.decision_trace.add(grid_recharge_idle(
- calc_input, is_charging_possible=is_charging_possible,
+ calc_input, self.calculation_output,
+ is_charging_possible=is_charging_possible,
charge_limit_capacity=charge_limit_capacity,
required_recharge_energy=required_recharge_energy,
high_price_slots=high_price_slots,
@@ -752,7 +753,8 @@ def _is_discharge_allowed(self, calc_input: CalculationInput,
if self.common.is_discharge_always_allowed_capacity(calc_input.stored_energy):
self.decision_trace.add(discharge_always_allowed(
- calc_input, self.common.get_always_allow_discharge_limit()),
+ calc_input, self.common.get_always_allow_discharge_limit(),
+ self.common.max_capacity),
logger)
return True
diff --git a/src/batcontrol/mqtt_api.py b/src/batcontrol/mqtt_api.py
index de49049f..900e3d34 100644
--- a/src/batcontrol/mqtt_api.py
+++ b/src/batcontrol/mqtt_api.py
@@ -31,8 +31,9 @@
- /control_source: source that last selected the current control state (api or optimizer)
- /decision: current mode with its value and the reason as text, e.g.
"Charge from Grid 1250 W - Grid recharge required" (retained). Updated on a status
- change of the decision journal: new mode, or the value of the mode changed by 25 %
-- /decision/attributes: JSON with the decision trace of that status change (retained),
+ change of the decision journal (new mode, or the value of the mode changed by 25 %)
+ and on its refresh events (reason changed, or 15 minutes since the last update)
+- /decision/attributes: JSON with the decision trace behind that text (retained),
used as attributes of the Home Assistant "Decision" sensor
- /solar_surplus_wh: expected solar surplus energy in Wh (>0 means usable surplus available)
- /solar_active: bool indicating whether solar is currently producing (slot 0 > 0)
@@ -669,9 +670,10 @@ def publish_control_source(self, source: str) -> None:
)
def publish_status_change(self, event: 'StatusChangeEvent') -> None:
- """ Publish a status change of the decision journal: the mode with
- its value and reason as text, and the decision trace behind it
- as JSON attributes. Registered as journal listener in core.py.
+ """ Publish a status change or refresh event of the decision
+ journal: the mode with its value and reason as text, and the
+ decision trace behind it as JSON attributes. Registered as
+ journal listener (with refresh events) in core.py.
/decision
/decision/attributes
"""
diff --git a/tests/batcontrol/logic/test_decision_trace.py b/tests/batcontrol/logic/test_decision_trace.py
index 623d399f..6f249cc4 100644
--- a/tests/batcontrol/logic/test_decision_trace.py
+++ b/tests/batcontrol/logic/test_decision_trace.py
@@ -259,7 +259,7 @@ def test_explains_the_numbers_behind_a_grid_recharge(self):
assert trace.status_text() == (
'Charge from Grid 2133 W - usable energy (900 Wh) is below the '
- '2500 Wh reserved for upcoming expensive hours, so 1600 Wh is '
+ '2500 Wh reserved for upcoming more expensive hours, so 1600 Wh is '
'charged from the grid')
def test_limit_mode_with_pv_limit(self):
@@ -283,11 +283,10 @@ def test_zero_limit_is_shown(self):
])
def test_static_explanation_replaces_the_raw_reason_code(
self, outcome, label):
- trace = self._trace(outcome, Reason.NO_RECHARGE_REQUIRED)
+ trace = self._trace(outcome, Reason.NO_PV_PRODUCTION)
assert trace.status_text() == (
- f'{label} - stored energy is sufficient until prices rise '
- 'again, so no grid charging is needed')
+ f'{label} - there is currently no solar production')
def test_falls_back_to_the_reason_code_when_inputs_are_missing(self):
"""A data-driven explanation whose inputs are not on the decisive
@@ -341,16 +340,18 @@ def test_data_driven_explanation_uses_the_record_s_inputs(self):
assert record.explanation() == (
'usable energy (900 Wh) is below the 2500 Wh reserved for '
- 'upcoming expensive hours')
+ 'upcoming more expensive hours')
def test_format_spec_turns_a_ratio_into_a_percentage(self):
record = _record(
reason=Reason.ALWAYS_ALLOW_DISCHARGE_LIMIT,
- inputs={'stored_energy': 8200.0, 'always_allow_discharge_limit': 0.8})
+ inputs={'stored_energy': 8200.0,
+ 'always_allow_discharge_limit': 0.8,
+ 'always_allow_discharge_energy': 8000.0})
assert record.explanation() == (
- 'stored energy (8200 Wh) is above the always-allow-discharge '
- 'level (80% of capacity)')
+ 'battery holds 8200 Wh, above the always-allow-discharge '
+ 'level of 80% (8000 Wh)')
def test_static_explanation_ignores_inputs(self):
record = _record(reason=Reason.NO_PV_PRODUCTION, inputs={})
@@ -400,3 +401,104 @@ def test_trace_to_dict_why_is_none_without_a_decisive_step(self):
trace.add(_record(decisive=False))
assert trace.to_dict()['why'] is None
+
+
+class TestNotes:
+ """Value adjustments (charge rate clamps) do not replace the reason of
+ the mode, they are appended to its explanation."""
+
+ @staticmethod
+ def _trace_with_clamp():
+ trace = DecisionTrace()
+ trace.add(DecisionRecord(
+ Decision.GRID_RECHARGE, Outcome.CHARGE,
+ Reason.GRID_RECHARGE_REQUIRED,
+ {'stored_usable_energy': 900.0, 'reserved_energy': 2500.0,
+ 'recharge_energy': 1600.0}, decisive=True))
+ trace.add(DecisionRecord(
+ Decision.OVERRIDE, Outcome.APPLIED,
+ Reason.GRID_CHARGE_RATE_CLAMPED,
+ {'requested_w': 3200, 'applied_w': 1000}))
+ trace.add(DecisionRecord(Decision.MODE, 'force_charge',
+ Reason.GRID_RECHARGE_REQUIRED,
+ {'value': 1000}))
+ return trace
+
+ def test_note_is_appended_to_the_decisive_explanation(self):
+ trace = self._trace_with_clamp()
+
+ assert trace.decisive_record().reason == Reason.GRID_RECHARGE_REQUIRED
+ assert trace.status_text() == (
+ 'Charge from Grid 1000 W - usable energy (900 Wh) is below the '
+ '2500 Wh reserved for upcoming more expensive hours, so 1600 Wh '
+ 'is charged from the grid (charge rate capped from 3200 W to '
+ '1000 W by the configured maximum grid charge rate)')
+ assert trace.to_dict()['why'] == trace.explanation()
+
+ def test_no_explanation_without_decisive_step(self):
+ trace = DecisionTrace()
+ trace.add(DecisionRecord(
+ Decision.OVERRIDE, Outcome.APPLIED, Reason.PV_CHARGE_RATE_CLAMPED,
+ {'requested_w': 5000, 'applied_w': 2000}))
+
+ assert trace.explanation() is None
+
+
+class TestPeakShavingExplanation:
+ """PV_CHARGE_LIMITED names the purpose of the active limits."""
+
+ @pytest.mark.parametrize('components, expected', [
+ ('time', 'PV charging is limited so the battery is not full '
+ 'before 14:00'),
+ ('price', 'PV charging is limited so room is kept for solar '
+ 'surplus in cheap-price hours'),
+ ('time,price', 'PV charging is limited so the battery is not full '
+ 'before 14:00 and room is kept for solar surplus in '
+ 'cheap-price hours'),
+ ])
+ def test_purpose_per_component(self, components, expected):
+ record = _record(reason=Reason.PV_CHARGE_LIMITED,
+ inputs={'active_components': components,
+ 'allow_full_battery_after': 14})
+
+ assert record.explanation() == expected
+
+ def test_unknown_components_fall_back_to_reason_code(self):
+ record = _record(reason=Reason.PV_CHARGE_LIMITED,
+ inputs={'active_components': 'none',
+ 'allow_full_battery_after': 14})
+
+ assert record.explanation() == 'PV charge limited'
+
+
+class TestAvoidDischargeExplanation:
+ """Regression: the Avoid Discharge text only said why the battery is
+ not charged from the grid, not why it is not discharged."""
+
+ _INPUTS = {
+ 'stored_energy': 99999.0, 'stored_usable_energy': 99999.0,
+ 'reserved_energy': 99999.0, 'charge_limit_capacity': 99999.0,
+ 'recharge_energy_before_minimum': 99999.0,
+ }
+
+ @pytest.mark.parametrize('reason', [
+ Reason.GRID_CHARGE_LIMIT_REACHED,
+ Reason.NO_HIGH_PRICE_SLOTS,
+ Reason.HIGH_PRICE_DEMAND_COVERED_BY_PRODUCTION,
+ Reason.NO_RECHARGE_REQUIRED,
+ Reason.RECHARGE_BELOW_MINIMUM,
+ ])
+ def test_says_why_the_battery_is_held_and_not_recharged(self, reason):
+ trace = DecisionTrace()
+ trace.add(DecisionRecord(Decision.GRID_RECHARGE, Outcome.NO_CHARGE,
+ reason, dict(self._INPUTS), decisive=True))
+ trace.add(DecisionRecord(Decision.MODE, 'avoid_discharging', reason,
+ {'value': None}))
+
+ text = trace.status_text()
+
+ assert text.startswith(
+ 'Avoid Discharge - battery is held for upcoming more expensive '
+ 'hours (usable 99999 Wh, reserve 99999 Wh); no grid charging: ')
+ assert text.isascii()
+ assert len(text) <= 255
diff --git a/tests/batcontrol/logic/test_decision_trace_logic.py b/tests/batcontrol/logic/test_decision_trace_logic.py
index 2b4e8de7..d23e1f74 100644
--- a/tests/batcontrol/logic/test_decision_trace_logic.py
+++ b/tests/batcontrol/logic/test_decision_trace_logic.py
@@ -193,8 +193,9 @@ def test_high_price_demand_covered_by_production_keeps_battery(
assert recharge.inputs['high_price_slots'] == [2]
assert recharge.inputs['high_price_energy_demand'] == 0
assert recharge.explanation() == (
- 'solar production is forecast to cover the demand at the '
- 'upcoming expensive hours, so no grid charging is needed')
+ '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')
def test_no_recharge_required_keeps_battery_when_demand_is_stored(
self, logic_cls):
@@ -241,8 +242,9 @@ def test_recharge_below_minimum_keeps_battery(self, logic_cls):
assert recharge.inputs['high_price_energy_demand'] == 1550
assert recharge.inputs['recharge_energy_before_minimum'] == 50
assert recharge.explanation() == (
- 'a grid recharge of 50 Wh would be needed, but that is below '
- 'the minimum charge amount, so no grid charging happens')
+ 'battery is held for upcoming more expensive hours (usable '
+ '1500 Wh, reserve 1550 Wh); no grid charging: the missing 50 Wh '
+ 'are below the minimum charge amount')
def test_grid_charge_limit_reached(self, logic_cls):
logic = _logic(logic_cls)
diff --git a/tests/batcontrol/test_core_decisions.py b/tests/batcontrol/test_core_decisions.py
index 18fad7d9..8c968183 100644
--- a/tests/batcontrol/test_core_decisions.py
+++ b/tests/batcontrol/test_core_decisions.py
@@ -217,9 +217,14 @@ def test_grid_charge_rate_clamp_is_reflected_in_the_trace(self, setup):
override = event.trace.records[2]
assert override.reason == Reason.GRID_CHARGE_RATE_CLAMPED
assert override.inputs['applied_w'] == 1000
+ # the clamp is a note: the reason for the mode stays with the logic
+ assert not override.decisive
mode = self._mode_record(event.trace)
- assert mode.reason == Reason.GRID_CHARGE_RATE_CLAMPED
+ assert mode.reason == Reason.GRID_RECHARGE_REQUIRED
assert mode.inputs['value'] == 1000
+ assert event.trace.explanation().startswith('usable energy (')
+ assert event.trace.explanation().endswith(
+ 'to 1000 W by the configured maximum grid charge rate)')
inverter.set_mode_force_charge.assert_called_once_with(1000)
def test_pv_charge_rate_clamp_is_reflected_in_the_trace(self, setup):
@@ -245,8 +250,8 @@ def test_pv_charge_rate_clamp_is_reflected_in_the_trace(self, setup):
'max_pv_charge_rate': 2000,
'min_pv_charge_rate': 0,
}
+ assert not override.decisive
mode = self._mode_record(event.trace)
- assert mode.reason == Reason.PV_CHARGE_RATE_CLAMPED
assert mode.inputs['value'] == 2000
inverter.set_mode_limit_battery_charge.assert_called_once_with(2000)
@@ -363,7 +368,7 @@ def test_last_status_change_is_republished(self, mock_config, mocker):
CommonLogic._instance = None # pylint: disable=protected-access
mqtt_api.publish_status_change.assert_called_once_with(
- bc.decision_journal.last_status_change())
+ bc.decision_journal.last_event())
def test_nothing_is_republished_before_the_first_status_change(
self, mock_config, mocker):
diff --git a/tests/batcontrol/test_decision_journal.py b/tests/batcontrol/test_decision_journal.py
index 41f1f476..4733783d 100644
--- a/tests/batcontrol/test_decision_journal.py
+++ b/tests/batcontrol/test_decision_journal.py
@@ -6,7 +6,7 @@
import pytest
from batcontrol.decision_journal import (
- DecisionJournal, KIND_MODE, KIND_VALUE,
+ DecisionJournal, KIND_MODE, KIND_REFRESH, KIND_VALUE,
)
from batcontrol.logic.decision_trace import (
Decision, DecisionRecord, DecisionTrace, Outcome, Reason,
@@ -269,3 +269,129 @@ def test_to_dict_converts_numpy_values(self):
assert type(data['value']) is int # pylint: disable=unidiomatic-typecheck
assert json.dumps(data)
+
+
+class _Clock:
+ """Manually advanced clock for the refresh interval."""
+
+ def __init__(self):
+ self.now = 1000.0
+
+ def __call__(self):
+ return self.now
+
+
+class TestRefreshEvents:
+ """Listeners registered with refresh=True also get the latest trace when
+ the reason changes or the last event is 15 minutes old, so the numbers
+ and the reason in the Decision sensor text do not go stale while the
+ status stays the same."""
+
+ @staticmethod
+ def _journal():
+ clock = _Clock()
+ journal = DecisionJournal(refresh_interval=900, clock=clock)
+ status, refresh = [], []
+ journal.add_listener(status.append)
+ journal.add_listener(refresh.append, refresh=True)
+ return journal, clock, status, refresh
+
+ def test_refresh_after_interval_only_for_refresh_listeners(self):
+ journal, clock, status, refresh = self._journal()
+ journal.commit(_trace(), 10, 'optimizer')
+
+ clock.now += 899
+ assert journal.commit(_trace(), 10, 'optimizer') is None
+ clock.now += 1
+ latest = _trace()
+ event = journal.commit(latest, 10, 'optimizer')
+
+ assert event.kind == KIND_REFRESH
+ assert event.trace is latest
+ assert event.previous_mode is None
+ assert [e.kind for e in status] == [KIND_MODE]
+ assert [e.kind for e in refresh] == [KIND_MODE, KIND_REFRESH]
+
+ def test_refresh_restarts_the_interval(self):
+ journal, clock, _status, refresh = self._journal()
+ journal.commit(_trace(), 10, 'optimizer')
+
+ for _ in range(6):
+ clock.now += 300
+ journal.commit(_trace(), 10, 'optimizer')
+
+ assert [e.kind for e in refresh] == [KIND_MODE] + [KIND_REFRESH] * 2
+
+ def test_reason_change_is_a_refresh(self):
+ """Regression: the mode stayed Discharge Allowed while the reason
+ changed from the always-allow level to the reserve rule, the text
+ kept showing the old reason."""
+ journal, _clock, status, refresh = self._journal()
+ journal.commit(_trace(Reason.ALWAYS_ALLOW_DISCHARGE_LIMIT), 10,
+ 'optimizer')
+
+ changed = _trace(Reason.USABLE_ENERGY_EXCEEDS_RESERVE)
+ journal.commit(changed, 10, 'optimizer')
+ journal.commit(_trace(Reason.USABLE_ENERGY_EXCEEDS_RESERVE), 10,
+ 'optimizer')
+
+ assert [e.kind for e in refresh] == [KIND_MODE, KIND_REFRESH]
+ assert refresh[-1].trace is changed
+ assert len(status) == 1
+
+ def test_mode_change_wins_over_refresh(self):
+ journal, clock, _status, refresh = self._journal()
+ journal.commit(_trace(), 10, 'optimizer')
+
+ clock.now += 900
+ journal.commit(_trace(Reason.RESERVE_REQUIRED), 0, 'optimizer')
+
+ assert [e.kind for e in refresh] == [KIND_MODE, KIND_MODE]
+
+ def test_refresh_keeps_the_value_reference(self):
+ """The 25 % comparison stays against the last status change, a
+ refresh with a slightly different value must not move it."""
+ journal, clock, status, _refresh = self._journal()
+ journal.commit(_trace(), -1, 'optimizer', 1000)
+ first = journal.last_status_change()
+
+ clock.now += 900
+ journal.commit(_trace(), -1, 'optimizer', 1200)
+ assert journal.last_event().kind == KIND_REFRESH
+ assert journal.last_status_change() is first
+
+ journal.commit(_trace(), -1, 'optimizer', 1250)
+
+ assert [e.kind for e in status] == [KIND_MODE, KIND_VALUE]
+ assert status[-1].previous_value == 1000
+
+ def test_last_event_includes_refresh(self):
+ journal, clock, _status, _refresh = self._journal()
+ journal.commit(_trace(), 10, 'optimizer')
+ clock.now += 900
+ latest = _trace()
+ journal.commit(latest, 10, 'optimizer')
+
+ assert journal.last_event().kind == KIND_REFRESH
+ assert journal.last_event().trace is latest
+ assert journal.last_status_change().kind == KIND_MODE
+
+ def test_registering_again_updates_the_refresh_setting(self):
+ journal = DecisionJournal(refresh_interval=900, clock=_Clock())
+ events = []
+ journal.add_listener(events.append)
+ journal.add_listener(events.append, refresh=True)
+
+ journal.commit(_trace(Reason.ALWAYS_ALLOW_DISCHARGE_LIMIT), 10, 'x')
+ journal.commit(_trace(Reason.USABLE_ENERGY_EXCEEDS_RESERVE), 10, 'x')
+
+ assert [e.kind for e in events] == [KIND_MODE, KIND_REFRESH]
+
+ def test_refresh_event_is_json_serializable(self):
+ journal, clock, _status, refresh = self._journal()
+ journal.commit(_trace(), 10, 'optimizer')
+ clock.now += 900
+ journal.commit(_trace(), 10, 'optimizer')
+
+ data = json.loads(json.dumps(refresh[-1].to_dict()))
+ assert data['kind'] == 'refresh'