diff --git a/apps/predbat/const.py b/apps/predbat/const.py index 3ddcc6b79..2ff0ab606 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -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._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)] diff --git a/apps/predbat/evcc.py b/apps/predbat/evcc.py index 16cdca6e5..e9a8c17e7 100644 --- a/apps/predbat/evcc.py +++ b/apps/predbat/evcc.py @@ -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" @@ -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._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): """ diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 42afcb94d..1b530b6b6 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -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 @@ -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._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): """ @@ -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, @@ -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"]) @@ -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 diff --git a/apps/predbat/tests/test_car_charging_in_load_history.py b/apps/predbat/tests/test_car_charging_in_load_history.py index e8b4ab3bf..525e98fac 100644 --- a/apps/predbat/tests/test_car_charging_in_load_history.py +++ b/apps/predbat/tests/test_car_charging_in_load_history.py @@ -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): @@ -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 diff --git a/apps/predbat/tests/test_evcc.py b/apps/predbat/tests/test_evcc.py index 6d52205ef..42471cfed 100644 --- a/apps/predbat/tests/test_evcc.py +++ b/apps/predbat/tests/test_evcc.py @@ -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, @@ -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) @@ -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 = [] diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 07bae35dd..c62c3b5e7 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -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 @@ -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), diff --git a/docs/car-charging.md b/docs/car-charging.md index 2e79c35dd..eb2be039e 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -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: diff --git a/docs/components.md b/docs/components.md index 7f53011f5..68f013eda 100644 --- a/docs/components.md +++ b/docs/components.md @@ -585,9 +585,15 @@ With `evcc_control: True`, Predbat maps its own decision onto evcc's modes: | Predbat state | evcc mode | | ------------- | --------- | -| `binary_sensor.predbat_car_charging_slot` on - a grid slot is planned | `now` | -| otherwise `binary_sensor.predbat_car_charging_solar_slot` on | `pv` (or `minpv` with `evcc_use_minpv`) | -| otherwise | `off` | +| `now` - a grid slot is planned | `now` | +| `solar` - take the surplus, or nothing is planned | `pv` (or `minpv` with `evcc_use_minpv`) | +| `off` - the export pays better, or this car does no solar charging | `off` | + +The decision is not made here: it is Predbat's own `sensor.predbat_car_charging_mode`, described under +[the charging mode](car-charging.md#the-charging-mode), so evcc and a plain Home Assistant automation +driving some other charger act on identical logic. Note that `solar` is the resting state - evcc is left +in `pv` rather than `off` when nothing is planned, because `off` disables the loadpoint and takes evcc's +own departure plan down with it, which is the plan this component exists to read. Writes only happen when the plan is valid and fresh, `switch.predbat_set_read_only` is off, and the runtime switch is on; every refusal is published as the `reason` attribute on `sensor.predbat_evcc_target_mode`, so diff --git a/docs/customisation.md b/docs/customisation.md index 313a88f76..9dc9fbd30 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -296,6 +296,7 @@ These are described in detail in [Car Charging](car-charging.md) and are listed - **input_number.predbat_car_charging_solar_min_soc** - home battery SoC threshold before opportunistic solar is diverted to the car - **switch.predbat_car_charging_solar_export_smart** - only divert solar to the car while the export rate is at or below the cheapest import before the car must be ready, otherwise sell the surplus and charge from the planned cheap slots - **binary_sensor.predbat_car_charging_solar_slot** - set to On by Predbat when solar surplus should be diverted to the car +- **sensor.predbat_car_charging_mode** - the charging decision for an external charger: `now`, `solar` or `off`, with a `reason` attribute saying why - **switch.predbat_car_charging_plan_smart** - allows Predbat to allocate car charging slots to the cheapest times rather than all low-rate slots - **input_number.predbat_car_charging_plan_max_price** - maximum price per kWh to pay when charging your car - **switch.predbat_car_charging_from_battery** - prevent the car from draining the home battery when charging