Skip to content

Commit 127756d

Browse files
xchillxxclaude
andcommitted
Device reason 'battery would not last' names when it would be empty (v3.1.21)
User 11.10.: the battery line said 6.8 h left, PV start in 5.2 h, yet the miner stayed off. The reason now says when the battery would hit its reserve with the device (same load as the rule: learned night base + devices) and until when it must last (solar start + 1 h). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3c28527 commit 127756d

6 files changed

Lines changed: 43 additions & 3 deletions

File tree

‎custom_components/surplus_pilot/coordinator.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -930,6 +930,11 @@ def explain(self) -> list[str]:
930930
if not dec.on and fp.get("start") is not None and fp.get("need_h", 0) > 0:
931931
extra += (f" — Mindestlaufzeit: fehlen {fp['need_h']:.1f} h, Pflichtlauf ab "
932932
f"{dt_util.as_local(fp['start']).strftime('%H:%M')}").replace(".", ",")
933+
if dec.battery_empty is not None:
934+
need = dt_util.as_local(dt_util.parse_datetime(d["solar_start"])) + timedelta(
935+
hours=P.BATTERY_PATH_BUFFER_H)
936+
extra += (f" (damit leer ~{dt_util.as_local(dec.battery_empty).strftime('%H:%M')}, "
937+
f"nötig bis {need.strftime('%H:%M')})")
933938
lines.append(f"{names.get(did, did)}: {'an' if dec.on else 'aus'} — "
934939
f"{REASON_TEXT.get(dec.reason, dec.reason)}{extra}")
935940
return lines

‎custom_components/surplus_pilot/manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
"iot_class": "local_polling",
1010
"issue_tracker": "https://github.com/xchillxx/surplus-pilot/issues",
1111
"requirements": [],
12-
"version": "3.1.20"
12+
"version": "3.1.21"
1313
}

‎custom_components/surplus_pilot/planner.py‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ class DeviceDecision:
157157
on: bool
158158
reason: str
159159
path: str = "" # "ueberschuss" | "akku" | "pflicht" | ""
160+
battery_empty: datetime | None = None # akku_reicht_nicht: home battery at its reserve with this device
160161

161162

162163
@dataclass
@@ -336,6 +337,25 @@ def battery_lasts(inp: Inputs, planned_on: dict[str, bool], extra: DeviceInput |
336337
return avail >= load
337338

338339

340+
def battery_empty_at(inp: Inputs, planned_on: dict[str, bool], extra: DeviceInput) -> datetime | None:
341+
"""When the home battery would reach its reserve with the same load as
342+
battery_lasts (night base load + planned devices + this one, each until
343+
its window end) - the reason's "why" in one time."""
344+
if inp.battery_soc is None:
345+
return None
346+
left = max(0.0, (inp.battery_soc - inp.battery_min_soc) / 100.0 * inp.battery_capacity_kwh)
347+
devs = [d for d in inp.devices if planned_on.get(d.id) or d.id == extra.id]
348+
t, step = inp.now, timedelta(minutes=5)
349+
for _ in range(24 * 12):
350+
kw = inp.night_base_kw + sum(d.decision_kw for d in devs if d.window_end is None or d.window_end > t)
351+
use = kw * step.total_seconds() / 3600.0
352+
if use >= left:
353+
return t + step * (left / use)
354+
left -= use
355+
t += step
356+
return None
357+
358+
339359
def _hours(inp: Inputs, t: datetime) -> float:
340360
return (t - inp.now).total_seconds() / 3600.0
341361

@@ -874,7 +894,8 @@ def _devices(inp: Inputs, remaining: float,
874894
reason = "kein_ueberschuss"
875895
else:
876896
reason = "akku_reicht_nicht"
877-
out[d.id] = DeviceDecision(False, reason)
897+
empty = battery_empty_at(inp, planned, d) if reason == "akku_reicht_nicht" else None
898+
out[d.id] = DeviceDecision(False, reason, battery_empty=empty)
878899
return out
879900

880901

‎custom_components/surplus_pilot/sensor.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ def extra_state_attributes(self):
224224
"mindestlauf_ab": co.forced_run_start(did),
225225
"leistung_kw": co._decision_kw(self._dev),
226226
"name": self._dev.get(CONF_DEV_NAME),
227+
"akku_leer_um": dec.battery_empty.isoformat() if dec and dec.battery_empty else None,
227228
}
228229

229230

‎dashboards/surplus-pilot.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ views:
138138
content: |
139139
| Device | | Reason | Runtime today | Power |
140140
|---|---|---|---|---|
141-
{% for e in integration_entities('surplus_pilot') | sort if e.startswith('sensor.') and state_attr(e, 'laufzeit_heute_h') is not none %}{% set a = states[e].attributes %}| **{{ a.name }}** | {{ '🟢 on' if a.an else '⚪ off' }}{% if a.schaltet_in_s %} → {{ 'on' if a.soll_an else 'off' }} in {{ (a.schaltet_in_s / 60) | round(0) | int }} min{% endif %} | {{ state_translated(e) }} | {{ a.laufzeit_heute_h | round(1) }} h | {{ a.leistung_kw | round(2) }} kW |
141+
{% for e in integration_entities('surplus_pilot') | sort if e.startswith('sensor.') and state_attr(e, 'laufzeit_heute_h') is not none %}{% set a = states[e].attributes %}| **{{ a.name }}** | {{ '🟢 on' if a.an else '⚪ off' }}{% if a.schaltet_in_s %} → {{ 'on' if a.soll_an else 'off' }} in {{ (a.schaltet_in_s / 60) | round(0) | int }} min{% endif %} | {{ state_translated(e) }}{% if a.akku_leer_um %} · empty ~{{ (as_datetime(a.akku_leer_um) | as_local).strftime('%H:%M') }}{% endif %} | {{ a.laufzeit_heute_h | round(1) }} h | {{ a.leistung_kw | round(2) }} kW |
142142
{% endfor %}
143143
# one tile per device: Off = Surplus Pilot leaves the device alone
144144
- type: tile

‎tests/test_planner.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,3 +517,16 @@ def test_charge_windows_show_the_planned_grid_charging_ahead():
517517
# target already reached -> nothing planned
518518
car.soc = 55.0
519519
assert P.charge_windows(base_inputs(now, car=car, departures=[dep], prices=prices)) == []
520+
521+
522+
def test_battery_reason_names_when_the_battery_would_be_empty():
523+
"""11.10. live 03:56: battery 31 %, 13.8 kWh, night base 0.39 kW, miner
524+
0.142 kW -> 2.2 kWh / 0.53 kW: empty ~08:06, needed until 10:09."""
525+
now = datetime(2026, 10, 11, 3, 56, tzinfo=TZ)
526+
m = miner()
527+
m.decision_kw = 0.142
528+
plan = P.make_plan(base_inputs(now, battery_soc=31.0, battery_capacity_kwh=13.8, night_base_kw=0.39,
529+
devices=[m]))
530+
dec = plan.devices["miner"]
531+
assert dec.reason == "akku_reicht_nicht"
532+
assert abs((dec.battery_empty - now.replace(hour=8, minute=5)).total_seconds()) < 5 * 60

0 commit comments

Comments
 (0)