diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 2be91da64..c435bfa30 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1562,7 +1562,8 @@ def fetch_sensor_data_cars(self, save=True): if car_n < len(self.car_charging_manual_soc) and not self.car_charging_manual_soc[car_n]: self.car_charging_soc[car_n] = (self.get_arg("car_charging_soc", 0.0, index=car_n) * self.car_charging_battery_size[car_n]) / 100.0 if rate: - # Take the max as Octopus over reports + # Octopus's figure only ever raises the configured rate, never lowers it. Where Octopus's catalogue + # gives the charger more than one power, the component passes on the lowest (catalogue_value(), #5438) self.log("Car {} rate from Octopus is {}kW and configured rate {}".format(car_n, rate, self.car_charging_rate[car_n])) self.car_charging_rate[car_n] = max(rate, self.car_charging_rate[car_n]) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 684a91e46..aade4a478 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -14,6 +14,7 @@ """ import asyncio +import math import requests import re from datetime import datetime, timedelta, timezone @@ -56,6 +57,18 @@ # The EV/charge-point catalogue is static reference data, so it is refreshed daily CATALOGUE_FRESH_MINUTES = 24 * 60 CATALOGUE_STALE_MINUTES = 25 * 60 +# Logged by log_catalogue_ambiguity() when the catalogue gives a make and model more than one value (issue #5438). +# A charge point's power only ever raises input_number.{prefix}_car_charging_rate (fetch.py), so that is what decides +# the rate; a vehicle's battery size from Octopus replaces car_charging_battery_size, so there is no setting to change. +# {prefix} is the entity prefix in use (apps.yaml prefix, "predbat" by default). +CATALOGUE_AMBIGUOUS_CHARGER = ( + "Octopus's catalogue lists charge point {device} at more than one power ({values} {unit}) and Predbat can't tell which yours is," + " so it uses the lowest, {value} {unit}, unless input_number.{prefix}_car_charging_rate (_1, _2 for further cars) is higher. Set that to your charger's rate" +) +CATALOGUE_AMBIGUOUS_VEHICLE = ( + "Octopus's catalogue lists vehicle {device} with more than one battery size ({values} {unit}) and Predbat can't tell which yours is," + " so it plans with the largest, {value} {unit}. A battery size from Octopus is used in place of car_charging_battery_size, so that setting can't change it" +) OCTOPUS_SLOT_MAX_DEFAULT = 48 # 24 hours with 30-minute slots OCTOPUS_SLOT_MAX_CAPPED = 12 # 6 hours with 30-minute slots IO_RATE_TOLERANCE = 0.01 # A dispatch lowers a rate by pence; anything closer is rate_min_base's dp2 rounding against the unrounded tariff feed (#5392) @@ -90,6 +103,49 @@ def is_active(now_utc, activeFrom, activeTo): return True +def catalogue_value(entries, make, model, field, pick): + """ + Look up one make and model's value in an Octopus catalogue list (chargePointVariants or electricVehicles). + + The catalogue's names aren't unique: the same make and model can be listed more than once with + different values - myenergi's "zappi (all models)" is there at both 7.4 and 22 kW - and the name is + all a device record gives to match on (issue #5438). Taking whichever match came last planned a + 7.4 kW zappi at 22 kW. So when the matches disagree, pick chooses between them, deliberately rather + than by catalogue order: min for a charge point's power (the most every variant can do), max for a + vehicle's battery size (so the car's remaining charge is over- rather than under-estimated). + + GraphQL returns these figures as strings ("22.000"), so they are converted to float here. Entries + whose value is missing, not a number, not finite, or not above zero are ignored. + + Args: + entries (list): The catalogue list, each entry {"make": ..., "models": [{"model": ..., field: ...}]} + make (str): The device's make + model (str): The device's model + field (str): The value to read from each matching model, e.g. "powerInKw" or "batterySize" + pick (callable): Chooses one value from the list when the matches disagree, e.g. min or max + + Returns: + tuple: (value, values) - value is the matches' value as a float (pick's choice when they + disagree), or None if nothing usable matched; values is the sorted list of the distinct values + the matches gave, so more than one means the catalogue was ambiguous. + """ + values = set() + for entry in entries or []: + if entry.get("make", None) != make: + continue + for info in entry.get("models", None) or []: + if info.get("model", None) != model: + continue + try: + value = float(info.get(field, None)) + except (TypeError, ValueError): + continue + if math.isfinite(value) and value > 0: + values.add(value) + values = sorted(values) + return (pick(values) if values else None), values + + def parse_date(dt_str): """Convert a date string to a date object.""" try: @@ -628,6 +684,9 @@ def initialize(self, key, account_id, automatic): self.saving_sessions = {} self.saving_sessions_to_join = [] self.intelligent_devices = {} + # (message, make, model) the catalogue gave conflicting values for and that have been logged, so the + # warning is given once rather than on every device poll (see catalogue_value()) + self.catalogue_ambiguity_logged = set() # Signature of the dispatches last published, so a poll that changes them can request a replan self.intelligent_dispatch_signature = None # Active device IDs automatic_config() last wired the car slots to - None until it has run. @@ -2424,6 +2483,31 @@ async def async_graphql_query(self, query, request_context, returns_data=True, i return None + def log_catalogue_ambiguity(self, message, make, model, values, value, unit): + """ + Warn, once per make and model, when the catalogue lists a device more than once with different values. + + The log line is the only sign a user gets that catalogue_value() had to choose, so it names the + values the catalogue gave and the one Predbat is using. + + Args: + message (str): What was looked up and what the user can do about it, formatted with the + device ({device}), the values ({values}), the one in use ({value}), the unit ({unit}) and + the entity prefix in use ({prefix}) + make (str): The device's make + model (str): The device's model + values (list): The distinct values the catalogue gave, from catalogue_value() + value (float): The value catalogue_value() chose + unit (str): The values' unit + """ + if len(values) < 2: + return + key = (message, make, model) + if key in self.catalogue_ambiguity_logged: + return + self.catalogue_ambiguity_logged.add(key) + self.log("Warn: OctopusAPI: " + message.format(device="{} '{}'".format(make, model), values=", ".join("{:g}".format(v) for v in values), value="{:g}".format(value), unit=unit, prefix=self.prefix)) + async def async_get_vehicle_catalogue(self): """ Get the global EV / charge-point catalogue, cached across polls and instances. @@ -2526,19 +2610,11 @@ async def async_get_intelligent_devices(self, account_id, device_id): device_setting_result[setting_key] = cached_device[setting_key] if isCharger: - for charger in chargePointVariants: - if charger.get("make", None) == make: - models = charger.get("models", []) - for charger_info in models: - if charger_info.get("model", None) == model: - chargePointPowerInKw = charger_info.get("powerInKw", None) + chargePointPowerInKw, values = catalogue_value(chargePointVariants, make, model, "powerInKw", min) + self.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_CHARGER, make, model, values, chargePointPowerInKw, "kW") else: - for vehicle in electricVehicles: - if vehicle.get("make", None) == make: - models = vehicle.get("models", []) - for vehicle_info in models: - if vehicle_info.get("model", None) == model: - vehicleBatterySizeInKwh = vehicle_info.get("batterySize", None) + vehicleBatterySizeInKwh, values = catalogue_value(electricVehicles, make, model, "batterySize", max) + self.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_VEHICLE, make, model, values, vehicleBatterySizeInKwh, "kWh") intelligent_device = { "deviceType": deviceType, diff --git a/apps/predbat/tests/test_octopus_catalogue_cache.py b/apps/predbat/tests/test_octopus_catalogue_cache.py index 2262ca38e..1bca582e3 100644 --- a/apps/predbat/tests/test_octopus_catalogue_cache.py +++ b/apps/predbat/tests/test_octopus_catalogue_cache.py @@ -142,7 +142,8 @@ async def test_octopus_catalogue_cache(my_predbat): print("\n*** Test 3: battery size resolved from cached catalogue ***") device = (result or {}).get("device-1", {}) battery = device.get("vehicle_battery_size_in_kwh") - if battery != "57.5": + # The catalogue's "57.5" string comes back as a number (catalogue_value(), issue #5438) + if battery != 57.5: print("ERROR: Expected battery size 57.5 from the cached catalogue, got {}".format(battery)) failed = True else: diff --git a/apps/predbat/tests/test_octopus_intelligent_devices.py b/apps/predbat/tests/test_octopus_intelligent_devices.py index 885a533ca..dad9b4427 100644 --- a/apps/predbat/tests/test_octopus_intelligent_devices.py +++ b/apps/predbat/tests/test_octopus_intelligent_devices.py @@ -4,10 +4,11 @@ """ import asyncio +import copy from datetime import datetime, timezone, timedelta from unittest.mock import AsyncMock, MagicMock, patch -from octopus import OctopusAPI, DATE_TIME_STR_FORMAT, OCTOPUS_CAR_ENTITY_SPEC +from octopus import OctopusAPI, DATE_TIME_STR_FORMAT, OCTOPUS_CAR_ENTITY_SPEC, CATALOGUE_AMBIGUOUS_CHARGER def test_octopus_intelligent_devices_wrapper(my_predbat): @@ -29,6 +30,9 @@ def test_octopus_intelligent_devices_wrapper(my_predbat): failed += test_intelligent_dispatch_change_requests_replan(my_predbat) failed += test_car_slots_rewired_when_owner_changes(my_predbat) failed += test_car_slots_not_taken_without_a_device_to_wire(my_predbat) + failed += test_catalogue_value_picks_between_conflicting_entries(my_predbat) + failed += asyncio.run(test_intelligent_devices_ambiguous_catalogue(my_predbat)) + failed += test_ambiguous_charger_plans_at_configured_rate(my_predbat) return failed @@ -1559,3 +1563,251 @@ def test_discovery_report_produced_when_automatic_is_false(my_predbat): if failed == 0: print("PASS: run() reports to the discovery catalogue even when self.automatic is False") return failed + + +def test_catalogue_value_picks_between_conflicting_entries(my_predbat): + """ + catalogue_value() returns a make and model's value as a float, and lets the caller choose when the + catalogue's matching entries disagree. + + The Octopus catalogue lists some makes and models more than once with different values - myenergi's + "zappi (all models)" at both 7.400 and 22.000 kW - and the device record only gives the make and model + to match on (issue #5438). Taking the last match planned a 7.4 kW zappi at 22 kW. The values arrive + from GraphQL as strings; missing, non-numeric, non-finite and non-positive ones are ignored. + """ + print("\n*** Test: catalogue_value() picks deliberately between conflicting catalogue entries ***") + from octopus import catalogue_value + + chargers = [ + {"make": "Myenergi", "models": [{"model": "zappi (all models)", "powerInKw": "7.400"}, {"model": "zappi (all models)", "powerInKw": "22.000"}]}, + {"make": "Wallbox", "models": [{"model": "Pulsar", "powerInKw": "7.400"}, {"model": "Pulsar Plus", "powerInKw": "11.000"}]}, + # The same make listed twice, agreeing on the model's value + {"make": "Andersen", "models": [{"model": "A2", "powerInKw": "7.000"}]}, + {"make": "Andersen", "models": [{"model": "A2", "powerInKw": "7.0"}, {"model": "A3", "powerInKw": "not a number"}, {"model": "A3", "powerInKw": None}]}, + # Unusable figures alongside a real one, which is the only one kept + {"make": "Sample", "models": [{"model": "One", "powerInKw": v} for v in ("nan", "inf", "-7.4", "0", "7.400")] + [{"model": "One"}]}, + {"make": "Ohme", "models": None}, + ] + cases = [ + ("ambiguous match, lowest", "Myenergi", "zappi (all models)", min, (7.4, [7.4, 22.0])), + ("ambiguous match, highest", "Myenergi", "zappi (all models)", max, (22.0, [7.4, 22.0])), + ("unique match, string converted", "Wallbox", "Pulsar", min, (7.4, [7.4])), + ("duplicates that agree", "Andersen", "A2", max, (7.0, [7.0])), + ("non-numeric and null values ignored", "Andersen", "A3", min, (None, [])), + ("non-finite, negative, zero and missing values ignored", "Sample", "One", max, (7.4, [7.4])), + ("no such model", "Wallbox", "Commander", min, (None, [])), + ("no such make", "Unlisted", "Pulsar", min, (None, [])), + ("make with no models", "Ohme", "Home Pro", min, (None, [])), + ] + failed = 0 + for name, make, model, pick, expected in cases: + got = catalogue_value(chargers, make, model, "powerInKw", pick) + if got != expected or (got[0] is not None and type(got[0]) is not float): + print(f"ERROR: {name}: catalogue_value({make!r}, {model!r}, {pick.__name__}) = {got}, expected {expected}") + failed += 1 + if catalogue_value(None, "Myenergi", "zappi (all models)", "powerInKw", min) != (None, []): + print("ERROR: a missing catalogue list should give no value") + failed += 1 + if not failed: + print("PASS: catalogue_value() converts to float, ignores unusable values and lets the caller pick between conflicting ones") + return failed + + +AMBIGUOUS_CATALOGUE = { + "chargePointVariants": [ + {"make": "Myenergi", "models": [{"model": "zappi (all models)", "powerInKw": "7.400"}, {"model": "zappi (all models)", "powerInKw": "22.000"}]}, + {"make": "Wallbox", "models": [{"model": "Pulsar", "powerInKw": "7.400"}]}, + # Listed in the opposite order to the zappi, so neither first- nor last-match-wins gets every answer right + {"make": "Andersen", "models": [{"model": "A2", "powerInKw": "22.000"}, {"model": "A2", "powerInKw": "7.000"}]}, + ], + "electricVehicles": [ + {"make": "Kia", "models": [{"model": "EV6 GT", "batterySize": "74.00"}, {"model": "EV6 GT", "batterySize": "80.00"}, {"model": "EV6 GT", "batterySize": "77.40"}]}, + {"make": "Tesla", "models": [{"model": "Model 3", "batterySize": "75.00"}]}, + ], +} + +AMBIGUOUS_CATALOGUE_DEVICES = [ + ("zappi", "SmartFlexChargePoint", "Myenergi", "zappi (all models)"), + ("pulsar", "SmartFlexChargePoint", "Wallbox", "Pulsar"), + ("andersen", "SmartFlexChargePoint", "Andersen", "A2"), + ("ev6", "SmartFlexVehicle", "Kia", "EV6 GT"), + ("model3", "SmartFlexVehicle", "Tesla", "Model 3"), +] + + +def _make_catalogue_api(my_predbat, account_id, devices): + """ + An OctopusAPI whose GraphQL queries return the given LIVE devices (with no dispatches) and AMBIGUOUS_CATALOGUE, + and whose log lines are collected in api.logged rather than printed. + """ + device_data = {"devices": [{"deviceType": "ELECTRIC_VEHICLES", "status": {"current": "LIVE"}, "__typename": typename, "make": make, "model": model, "id": device_id} for device_id, typename, make, model in devices]} + settings_data = {"devices": [{"id": device_id, "status": {"isSuspended": False}, "chargingPreferences": {}} for device_id, _, _, _ in devices]} + + async def mock_query(query, context, ignore_errors=False, returns_data=True): + """Answer the device and settings queries; dispatches come back empty.""" + if "get-intelligent-devices" in context: + return device_data + if "get-intelligent-settings" in context: + return settings_data + if "get-intelligent-dispatches" in context: + return {"flexPlannedDispatches": [], "completedDispatches": []} + return None + + api = _make_discovery_api(my_predbat, account_id, automatic=False) + api.get_intelligent_completed_dispatches = MagicMock(return_value=[]) + api.async_graphql_query = AsyncMock(side_effect=mock_query) + api.async_get_vehicle_catalogue = AsyncMock(return_value=AMBIGUOUS_CATALOGUE) + api.logged = [] + api.log = lambda message: api.logged.append(message) + return api + + +async def test_intelligent_devices_ambiguous_catalogue(my_predbat): + """ + A device whose make and model the catalogue gives conflicting values for gets the lowest charge point + power or the largest battery size, with one warning however many polls; every value arrives as a number, + which survives the coordinator's typed ratings container (before, the raw "22.000" string was dropped with + "value does not fit the container's type"). + """ + from coordinator import Coordinator + from mock_base import MockBase as SharedMockBase + + print("\n*** Test: an ambiguous catalogue entry gives the lowest power or largest battery, as a number ***") + api = _make_catalogue_api(my_predbat, "ambiguous-catalogue", AMBIGUOUS_CATALOGUE_DEVICES) + + failed = 0 + + def check(condition, message): + """Record one failed assertion, printing its message, without aborting the remaining checks.""" + nonlocal failed + if not condition: + print("ERROR: " + message) + failed += 1 + + result = await api.async_get_intelligent_devices("ambiguous-catalogue", "zappi") + result = await api.async_get_intelligent_devices("ambiguous-catalogue", "zappi") + + expected = { + "zappi": ("charge_point_power_in_kw", 7.4), + "pulsar": ("charge_point_power_in_kw", 7.4), + "andersen": ("charge_point_power_in_kw", 7.0), + "ev6": ("vehicle_battery_size_in_kwh", 80.0), + "model3": ("vehicle_battery_size_in_kwh", 75.0), + } + for device_id, (field, value) in expected.items(): + got = result.get(device_id, {}).get(field, "missing") + check(got == value and type(got) is float, "{} {} should be {!r}, got {!r}".format(device_id, field, value, got)) + + warnings = [message for message in api.logged if "catalogue lists" in message] + # One per ambiguous make and model - the two chargers share a message but not a make and model + check(len(warnings) == 3, "expected one warning each for the zappi, the Andersen and the EV6 over two polls, got {}".format(warnings)) + check(any("Andersen 'A2'" in message and "the lowest, 7 kW" in message for message in warnings), "expected a warning for the Andersen A2 using 7 kW, got {}".format(warnings)) + zappi_warning = next((message for message in warnings if "zappi" in message), "") + check( + zappi_warning.startswith("Warn:") and "(7.4, 22 kW)" in zappi_warning and "the lowest, 7.4 kW" in zappi_warning and "input_number.predbat_car_charging_rate" in zappi_warning, + "the zappi warning should list both powers, the one used and the setting to change: {!r}".format(zappi_warning), + ) + # The setting is named with the entity prefix in use, which apps.yaml can change from "predbat" + custom = _make_catalogue_api(my_predbat, "ambiguous-catalogue-prefix", []) + custom.prefix = "house2" + custom.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_CHARGER, "Myenergi", "zappi (all models)", [7.4, 22.0], 7.4, "kW") + check( + len(custom.logged) == 1 and "input_number.house2_car_charging_rate" in custom.logged[0] and "predbat_car_charging_rate" not in custom.logged[0], + "with prefix house2 the warning should name input_number.house2_car_charging_rate: {}".format(custom.logged), + ) + ev6_warning = next((message for message in warnings if "EV6" in message), "") + check( + ev6_warning.startswith("Warn:") and "(74, 77.4, 80 kWh)" in ev6_warning and "the largest, 80 kWh" in ev6_warning and "car_charging_battery_size" in ev6_warning, + "the EV6 warning should list all three sizes, the one used, and say car_charging_battery_size can't change it: {!r}".format(ev6_warning), + ) + + # Every value reaches the coordinator's ratings as a number, so none is dropped + api.intelligent_devices = result + for device_id in expected: + _publish_car_entities(my_predbat, api, device_id) + coordinator = Coordinator(SharedMockBase()) + coordinator.report("octopus", api.build_discovery()) + ratings = {record["device_id"]: record.get("ratings", {}) for record in coordinator.reports["octopus"]["cars"]} + check(set(ratings) == {"octopus:" + device_id for device_id in expected}, "expected a car record for each device, got {}".format(sorted(ratings))) + for device_id, rating, value in (("zappi", "charge_point_power_kw", 7.4), ("pulsar", "charge_point_power_kw", 7.4), ("andersen", "charge_point_power_kw", 7.0), ("ev6", "vehicle_battery_kwh", 80.0), ("model3", "vehicle_battery_kwh", 75.0)): + got = ratings.get("octopus:" + device_id, {}).get(rating) + check(got == value, "{}'s {} should survive the coordinator as {}, ratings {}".format(device_id, rating, value, ratings.get("octopus:" + device_id))) + + if not failed: + print("PASS: ambiguous catalogue entries give the lowest power or largest battery, warned about once; all values are numbers") + return failed + + +def test_ambiguous_charger_plans_at_configured_rate(my_predbat): + """ + End to end for issue #5438: a zappi the catalogue lists at both 7.4 and 22 kW, published on the + component's own intelligent_dispatch sensor and read by the real fetch_sensor_data_cars(), is planned + at 7.4 kW - not 22 kW, as it was when the last catalogue match won. Octopus's figure only ever raises + the configured rate: a lower one is raised to the catalogue's 7.4 kW, and a higher one still wins. + """ + print("\n*** Test: a car on an ambiguously listed charger is planned at the configured rate ***") + api = _make_catalogue_api(my_predbat, "ambiguous-charger-plan", [AMBIGUOUS_CATALOGUE_DEVICES[0]]) + result = asyncio.run(api.async_get_intelligent_devices("ambiguous-charger-plan", "zappi")) + api.intelligent_devices = result + asyncio.run(api.async_intelligent_update_sensor("ambiguous-charger-plan")) + entity_id = api.get_entity_name("binary_sensor", "intelligent_dispatch", index=api.device_id_to_index_suffix("zappi")) + + saved_args = {key: my_predbat.args.get(key, None) for key in ("octopus_intelligent_slot", "car_charging_soc", "car_charging_limit", "car_charging_loss")} + saved = {key: copy.deepcopy(getattr(my_predbat, key, None)) for key in CAR_STATE_KEYS} + failed = 0 + try: + for configured, expected in ((7.4, 7.4), (3.7, 7.4), (11.0, 11.0)): + my_predbat.num_cars = 1 + my_predbat.car_charging_rate = [configured] + my_predbat.car_charging_battery_size = [64.0] + my_predbat.car_charging_limit = [64.0] + my_predbat.car_charging_planned = [True] + my_predbat.car_charging_now = [False] + my_predbat.car_charging_manual_soc = [False] + my_predbat.car_charging_exclusive = [False] + my_predbat.car_charging_slots = [[]] + my_predbat.octopus_intelligent_charging = True + my_predbat.octopus_slots = [[]] + my_predbat.args["octopus_intelligent_slot"] = [entity_id] + my_predbat.args["car_charging_soc"] = [50.0] + my_predbat.args["car_charging_limit"] = [100.0] + my_predbat.args["car_charging_loss"] = 0.0 + my_predbat.fetch_sensor_data_cars() + got = my_predbat.car_charging_rate[0] + if got != expected: + print(f"ERROR: with car_charging_rate {configured} and the catalogue's lowest 7.4 kW the car should be planned at {expected} kW, got {got}") + failed += 1 + finally: + for key, value in saved_args.items(): + if value is None: + my_predbat.args.pop(key, None) + else: + my_predbat.args[key] = value + for key, value in saved.items(): + setattr(my_predbat, key, value) + if not failed: + print("PASS: the car is planned at the higher of the configured rate and the catalogue's lowest, never its 22 kW") + return failed + + +# The PredBat car state test_ambiguous_charger_plans_at_configured_rate() and fetch_sensor_data_cars() set, +# restored afterwards: every test in this module's wrapper shares one PredBat instance +CAR_STATE_KEYS = ( + "num_cars", + "car_charging_rate", + "car_charging_battery_size", + "car_charging_limit", + "car_charging_planned", + "car_charging_now", + "car_charging_manual_soc", + "car_charging_exclusive", + "car_charging_slots", + "car_charging_soc", + "car_charging_loss", + "car_charging_limit_model", + "car_charging_plan_time", + "car_charging_soc_next", + "dispatch_timeline_pending", + "octopus_intelligent_charging", + "octopus_slots", +) diff --git a/docs/car-charging.md b/docs/car-charging.md index acbc0bf2e..72cc60736 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -153,6 +153,13 @@ If you are using Octopus-led charging with the [Octopus direct connection](energ - Predbat gets its Octopus charging slot information direct from the Octopus API, so comment out or delete octopus_intelligent_slot, octopus_ready_time and octopus_charge_limit from `apps.yaml`. +- Predbat also looks up your enrolled charger's power, or your enrolled car's battery size, in Octopus's catalogue of chargers and cars, by make and model. +A battery size found there is used in place of **car_charging_battery_size**, so that setting can't change it. +A charger power found there is used if it is higher than **input_number.predbat_car_charging_rate** (or **input_number.predbat_car_charging_rate_1** etc. for further cars), but never to lower it. +Some makes and models are listed more than once with different values (myenergi's "zappi (all models)" is listed at both 7.4 and 22 kW), and the name alone can't tell them apart. +For those, Predbat takes the lowest of the charger powers listed and the largest of the battery sizes, and logs a warning naming the values the catalogue gave. +As the car is planned at whichever is higher of that charger power and **input_number.predbat_car_charging_rate**, set **input_number.predbat_car_charging_rate** to your charger's rate. + If you are using Predbat-led charging: The following entries are pre-configured in the `apps.yaml` template: