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
7 changes: 7 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@
# Recognised "not plugged in" states, so only a genuinely unrecognised state is worth warning about
CAR_UNPLUGGED_RESPONSE = ["off", "false", "no", "0", "disconnected", "unavailable", "unknown", "none", ""]

# The charging decision Predbat publishes per car as sensor.<prefix>_car_charging_mode. Solar is the
# resting state, so a charger that follows the sun keeps doing so - and keeps its own departure plan
# alive - while off is reserved for a deliberate "do not charge from the surplus"
CAR_MODE_NOW = "now"
CAR_MODE_SOLAR = "solar"
CAR_MODE_OFF = "off"

# Create an array of times in the day in 5-minute intervals
BASE_TIME = datetime.strptime("00:00:00", "%H:%M:%S")
OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M:%S")) for minute in range(0, 24 * 60, 5)]
Expand Down
32 changes: 20 additions & 12 deletions apps/predbat/evcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from mock_base import MockBase as SharedMockBase
from predbat_metrics import record_api_call
from utils import str2time, dp2, dp3
from const import CAR_MODE_NOW, CAR_MODE_SOLAR

# Modes evcc accepts on POST /api/loadpoints/{id}/mode/{mode}
EVCC_MODE_OFF = "off"
Expand Down Expand Up @@ -680,21 +681,28 @@ def desired_mode(self, car_n):
"""
Work out the evcc mode Predbat wants for a car, returning (mode, reason).

The decision comes from the two binary sensors publish_car_plan() writes during Predbat's
own cycle. A missing charging-slot sensor means Predbat has not planned yet, which must
never be read as "off".
The decision itself is Predbat's, published as sensor.<prefix>_car_charging_mode during its
own cycle - see Output.publish_car_charging_mode - so evcc and a plain Home Assistant
automation act on identical logic. This only maps those three states onto evcc's own modes.
A missing sensor means Predbat has not planned yet, which must never be read as "off".

Solar maps to pv (or minpv), and it is the resting state rather than off because off disables
the loadpoint in evcc and takes its own departure plan down with it - the plan this component
exists to read.
"""
postfix = self.car_postfix(car_n)
slot = self.get_state_wrapper(entity_id="binary_sensor.{}_car_charging_slot{}".format(self.prefix, postfix))
if slot is None:
entity_id = "sensor.{}_car_charging_mode{}".format(self.prefix, postfix)
mode = self.get_state_wrapper(entity_id=entity_id)
if mode is None:
return None, "no_plan_yet"
solar = self.get_state_wrapper(entity_id="binary_sensor.{}_car_charging_solar_slot{}".format(self.prefix, postfix))

if str(slot).lower() == "on":
return EVCC_MODE_NOW, "grid_slot"
if str(solar).lower() == "on":
return (EVCC_MODE_MINPV if self.use_minpv else EVCC_MODE_PV), "solar"
return EVCC_MODE_OFF, "idle"
reason = self.get_state_wrapper(entity_id=entity_id, attribute="reason") or str(mode)

mode = str(mode).lower()
if mode == CAR_MODE_NOW:
return EVCC_MODE_NOW, reason
if mode == CAR_MODE_SOLAR:
return (EVCC_MODE_MINPV if self.use_minpv else EVCC_MODE_PV), reason
return EVCC_MODE_OFF, reason

def should_write(self, car_n, mode, reason):
"""
Expand Down
57 changes: 51 additions & 6 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import copy
from datetime import datetime, timedelta
from config import THIS_VERSION
from const import TIME_FORMAT, PREDICT_STEP, CAR_SOLAR_EXPORT_ALWAYS
from const import TIME_FORMAT, PREDICT_STEP, CAR_SOLAR_EXPORT_ALWAYS, CAR_MODE_NOW, CAR_MODE_SOLAR, CAR_MODE_OFF
from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state
from prediction import Prediction

Expand Down Expand Up @@ -73,30 +73,74 @@ class Output:

def publish_car_solar_slot(self, car_n, postfix):
"""
Publish whether solar surplus should be diverted to this car right now.
Publish whether solar surplus should be diverted to this car right now, returning (on, reason).

On means the surplus is worth less exported than the charge it displaces, so an external charger
(e.g. EVCC) should run in its solar/PV mode. Off means sell the surplus instead and let the planned
grid slots - published as binary_sensor.<prefix>_car_charging_slot - cover the car.
"""
if not self.car_charging_solar[car_n]:
return
return False, "solar_disabled"

threshold = self.car_charging_solar_export_threshold[car_n]
export_rate = self.rate_export.get(self.minutes_now, 0)
allowed = self.car_charging_plugged[car_n] and export_rate <= threshold

# Why it is off matters to the caller: only "export_better" is a decision to stop charging from
# the surplus, and an external charger should be left following the sun for the other reasons
if allowed:
reason = "solar"
elif not self.car_charging_plugged[car_n]:
reason = "not_plugged"
else:
reason = "export_better"

self.dashboard_item(
"binary_sensor." + self.prefix + "_car_charging_solar_slot" + postfix,
state="on" if allowed else "off",
attributes={
"friendly_name": "Predbat divert solar to car" + postfix,
"reason": reason,
"export_rate": dp2(export_rate),
"threshold": None if threshold >= CAR_SOLAR_EXPORT_ALWAYS else dp2(threshold),
"plugged_in": self.car_charging_plugged[car_n],
"icon": "mdi:solar-power-variant",
},
)
return allowed, reason

def publish_car_charging_mode(self, car_n, postfix, slot, solar_allowed, solar_reason):
"""
Publish the single charging decision for this car: charge now, follow the sun, or do not charge.

This is the decision an external charger should act on, whatever drives it - the evcc component
maps it onto evcc's own modes, and a Home Assistant automation can read it directly. It is one
sensor rather than two so the three states cannot be combined into something Predbat never meant.

Solar is the resting state rather than off: off means "do not charge from the sun", which for a
charger that has its own departure plan (evcc) takes that plan down with it. Off is therefore
reserved for the two cases where it is a decision - the surplus is worth more exported, or this
car does not do solar charging at all.
"""
if slot:
mode, reason = CAR_MODE_NOW, "grid_slot"
elif solar_allowed:
mode, reason = CAR_MODE_SOLAR, "solar"
elif solar_reason in ("export_better", "solar_disabled"):
mode, reason = CAR_MODE_OFF, solar_reason
else:
mode, reason = CAR_MODE_SOLAR, "idle"

self.dashboard_item(
"sensor." + self.prefix + "_car_charging_mode" + postfix,
state=mode,
attributes={
"friendly_name": "Predbat car charging mode" + postfix,
"reason": reason,
"icon": "mdi:ev-station",
},
)
return mode, reason

def publish_car_plan(self):
"""
Expand All @@ -107,7 +151,8 @@ def publish_car_plan(self):
for car_n in range(self.num_cars):
if car_n > 0:
postfix = "_" + str(car_n)
self.publish_car_solar_slot(car_n, postfix)
solar_allowed, solar_reason = self.publish_car_solar_slot(car_n, postfix)
slot = False
if not self.car_charging_slots[car_n]:
self.dashboard_item(
"binary_sensor." + self.prefix + "_car_charging_slot" + postfix,
Expand All @@ -131,8 +176,6 @@ def publish_car_plan(self):
window = self.car_charging_slots[car_n][0]
if self.minutes_now >= window["start"] and self.minutes_now < window["end"] and window["kwh"] > 0:
slot = True
else:
slot = False

time_format_time = "%H:%M:%S"
car_startt = self.midnight_utc + timedelta(minutes=window["start"])
Expand Down Expand Up @@ -183,6 +226,8 @@ def publish_car_plan(self):
},
)

self.publish_car_charging_mode(car_n, postfix, slot, solar_allowed, solar_reason)

def publish_rates_export(self):
"""
Publish the export rates
Expand Down
46 changes: 46 additions & 0 deletions apps/predbat/tests/test_car_charging_in_load_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# pylint: disable=attribute-defined-outside-init
from prediction import Prediction
from tests.test_infra import reset_rates2, reset_inverter
from const import CAR_SOLAR_EXPORT_ALWAYS


def build_prediction(my_predbat, pv_amount=0.0, load_amount=1.0):
Expand Down Expand Up @@ -158,3 +159,48 @@ def run_car_plugged_state_tests(my_predbat):
my_predbat.args[key] = value

return failed


def run_car_charging_mode_tests(my_predbat):
"""
Test the charging decision Predbat publishes for an external charger, with or without evcc

Solar is the resting state so a sun-following charger keeps following the sun (and keeps its own
departure plan alive); off is only published when it is a decision - the surplus is worth more
exported, or this car does no solar charging at all.
"""
failed = False
print("**** Running Car charging mode tests ****")

saved = {name: getattr(my_predbat, name) for name in ["num_cars", "car_charging_solar", "car_charging_plugged", "car_charging_slots", "car_charging_solar_export_threshold", "rate_export"]}
my_predbat.num_cars = 1
my_predbat.car_charging_slots = [[]]
my_predbat.rate_export = {my_predbat.minutes_now: 10.0}

# (solar enabled, plugged, export threshold, grid slot now, expected mode, expected reason)
cases = [
(True, True, CAR_SOLAR_EXPORT_ALWAYS, True, "now", "grid_slot"),
(True, True, CAR_SOLAR_EXPORT_ALWAYS, False, "solar", "solar"),
# Export beats the cheap charge it would displace - the one case worth turning the charger off for
(True, True, 5.0, False, "off", "export_better"),
# A grid slot always wins, even when the surplus should be sold
(True, True, 5.0, True, "now", "grid_slot"),
# Not plugged in is an absence, not a decision - keep the charger following the sun
(True, False, CAR_SOLAR_EXPORT_ALWAYS, False, "solar", "idle"),
# The car does no solar charging, so there is nothing to leave it in solar for
(False, True, CAR_SOLAR_EXPORT_ALWAYS, False, "off", "solar_disabled"),
]
for solar, plugged, threshold, slot, expect_mode, expect_reason in cases:
my_predbat.car_charging_solar = [solar]
my_predbat.car_charging_plugged = [plugged]
my_predbat.car_charging_solar_export_threshold = [threshold]
solar_allowed, solar_reason = my_predbat.publish_car_solar_slot(0, "")
mode, reason = my_predbat.publish_car_charging_mode(0, "", slot, solar_allowed, solar_reason)
if (mode, reason) != (expect_mode, expect_reason):
print("ERROR: solar {} plugged {} threshold {} slot {} gave ({}, {}), expected ({}, {})".format(solar, plugged, threshold, slot, mode, reason, expect_mode, expect_reason))
failed = True

for name, value in saved.items():
setattr(my_predbat, name, value)

return failed
47 changes: 25 additions & 22 deletions apps/predbat/tests/test_evcc.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from components import COMPONENT_LIST
from config import APPS_SCHEMA
from evcc import (
EVCC_MODE_MINPV,
EVCC_MODE_NOW,
EVCC_MODE_OFF,
EVCC_MODE_PV,
Expand Down Expand Up @@ -398,33 +399,35 @@ def test_desired_mode_and_gates():
api.loadpoint_map = {0: 0}
api.state_time = api.now_utc

def set_sensors(slot, solar):
"""Publish the two decision sensors the component reads."""
if slot is not None:
api.entities["binary_sensor.predbat_car_charging_slot"] = {"state": slot, "attributes": {}}
def set_mode(mode, reason=None):
"""Publish the charging-mode sensor Predbat writes, which is all this component reads."""
if mode is not None:
api.entities["sensor.predbat_car_charging_mode"] = {"state": mode, "attributes": {"reason": reason or mode}}
else:
api.entities.pop("binary_sensor.predbat_car_charging_slot", None)
if solar is not None:
api.entities["binary_sensor.predbat_car_charging_solar_slot"] = {"state": solar, "attributes": {}}
else:
api.entities.pop("binary_sensor.predbat_car_charging_solar_slot", None)
api.entities.pop("sensor.predbat_car_charging_mode", None)

set_sensors("on", "off")
set_mode("now", "grid_slot")
check("grid_slot", api.desired_mode(0), (EVCC_MODE_NOW, "grid_slot"), failures)
set_sensors("on", "on")
check("grid_wins", api.desired_mode(0)[0], EVCC_MODE_NOW, failures)
set_sensors("off", "on")
set_mode("solar", "solar")
check("solar", api.desired_mode(0), (EVCC_MODE_PV, "solar"), failures)
set_sensors("off", "off")
check("idle", api.desired_mode(0), (EVCC_MODE_OFF, "idle"), failures)
# A solar sensor is only published for solar cars, so its absence means "no solar"
set_sensors("off", None)
check("no_solar_sensor", api.desired_mode(0)[0], EVCC_MODE_OFF, failures)
# No charging-slot sensor means Predbat has not planned yet - that must never read as "off"
set_sensors(None, "on")
# Resting in pv rather than off, so evcc keeps its own departure plan alive
set_mode("solar", "idle")
check("idle", api.desired_mode(0), (EVCC_MODE_PV, "idle"), failures)
# Off is a decision, not an absence: selling the surplus pays better, or the car does no solar
set_mode("off", "export_better")
check("export_better", api.desired_mode(0), (EVCC_MODE_OFF, "export_better"), failures)
set_mode("off", "solar_disabled")
check("solar_disabled", api.desired_mode(0), (EVCC_MODE_OFF, "solar_disabled"), failures)
# minpv replaces pv everywhere pv would be used, resting state included
api.use_minpv = True
set_mode("solar", "idle")
check("idle_minpv", api.desired_mode(0), (EVCC_MODE_MINPV, "idle"), failures)
api.use_minpv = False
# No mode sensor means Predbat has not planned yet - that must never read as "off"
set_mode(None)
check("no_plan_yet", api.desired_mode(0), (None, "no_plan_yet"), failures)

set_sensors("off", "off")
set_mode("solar", "idle")
check("allowed", api.should_write(0, EVCC_MODE_OFF, "idle"), (True, "idle"), failures)
check("mode_none", api.should_write(0, None, "no_plan_yet")[1], "no_plan_yet", failures)

Expand Down Expand Up @@ -483,7 +486,7 @@ def test_apply_modes_writes():
api.state = {"loadpoints": [dict(SAMPLE_STATE["loadpoints"][0], mode="off")]}
api.state_time = api.now_utc
api.loadpoint_map = {0: 0}
api.entities["binary_sensor.predbat_car_charging_slot"] = {"state": "on", "attributes": {}}
api.entities["sensor.predbat_car_charging_mode"] = {"state": "now", "attributes": {"reason": "grid_slot"}}

writes = []

Expand Down
3 changes: 2 additions & 1 deletion apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
from tests.test_futurerate_auto import test_futurerate_auto
from tests.test_car_charging_smart import run_car_charging_smart_tests
from tests.test_evcc import test_evcc
from tests.test_car_charging_in_load_history import run_car_charging_in_load_history_tests, run_car_plugged_state_tests
from tests.test_car_charging_in_load_history import run_car_charging_in_load_history_tests, run_car_plugged_state_tests, run_car_charging_mode_tests
from tests.test_plugin_startup import test_plugin_startup_order
from tests.test_active_flag import test_active_flag
from tests.test_component_health_status import test_component_health_status
Expand Down Expand Up @@ -453,6 +453,7 @@ def main():
("car_charging_smart", run_car_charging_smart_tests, "Car charging smart tests", False),
("car_charging_in_load_history", run_car_charging_in_load_history_tests, "Car charging in load history tests", False),
("car_plugged_state", run_car_plugged_state_tests, "Car plugged-in sensor state tests", False),
("car_charging_mode", run_car_charging_mode_tests, "Car charging mode decision tests", False),
("evcc", test_evcc, "evcc EV charger API tests (plans, power band, auto-config, mode control)", False),
("intersect_window", run_intersect_window_tests, "Intersect window tests", False),
("clone_windows", run_clone_windows_tests, "Clone windows tests", False),
Expand Down
28 changes: 21 additions & 7 deletions docs/car-charging.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,15 +531,29 @@ export price, while in autumn and winter it often does.
does not.

The decision is published per car as **binary_sensor.predbat_car_charging_solar_slot** (`_1`, `_2`, … for further cars),
with the current export rate and the threshold as attributes. It is On when the surplus should go to the car and Off
when it should be sold. Together with **binary_sensor.predbat_car_charging_slot** it gives you the three EVCC modes
directly:
with the current export rate, the threshold and a `reason` as attributes. It is On when the surplus should go to the car
and Off when it should be sold.

| car_charging_slot | car_charging_solar_slot | EVCC mode |
### The charging mode

Rather than leaving you to combine that sensor with **binary_sensor.predbat_car_charging_slot**, Predbat publishes the
decision itself as **sensor.predbat_car_charging_mode** (`_1`, `_2`, … for further cars), with a `reason` attribute
saying why. This is what an external charger should follow - the [evcc component](components.md#evcc-ev-charger-evcc)
maps it straight onto evcc's own modes, and a Home Assistant automation driving any other charger can read the same
sensor and get identical behaviour.

| Mode | `reason` | Meaning |
|---|---|---|
| On | either | fast / now - Predbat has planned a grid slot to hit your target |
| Off | On | solar / PV - take the surplus, it is worth less exported |
| Off | Off | off - sell the surplus, the car is charged from the planned cheap slots |
| `now` | `grid_slot` | Predbat has planned a grid slot to hit your target - charge at full rate (evcc: `now`) |
| `solar` | `solar` | Take the surplus, it is worth less exported than the charge it displaces (evcc: `pv`, or `minpv` with `evcc_use_minpv`) |
| `solar` | `idle` | Nothing planned and the car is not plugged in - keep following the sun (evcc: `pv`) |
| `off` | `export_better` | Sell the surplus instead; the car is charged from the planned cheap slots |
| `off` | `solar_disabled` | This car does not do solar charging (`car_charging_solar` is off) |

Solar is the **resting** state rather than off. Off is a decision - "do not charge from the surplus" - and for a charger
that keeps its own departure plan, evcc included, off takes that plan down with it. So off is published only when it is
meant: the export pays better, or the car does no solar charging at all. Not being plugged in is an absence, not a
decision, and leaves the charger following the sun.

Safeguards, so the switch can never leave the car short:

Expand Down
Loading
Loading