Skip to content

Commit b88cbd8

Browse files
bootcclaude
andauthored
fix(octopus): choose on purpose between conflicting catalogue entries for a car (#5438) (#5439)
* fix(octopus): choose on purpose between conflicting catalogue entries for a car (#5438) Octopus's charge-point and vehicle catalogue isn't unique by make and model: myenergi's "zappi (all models)" is listed at both 7.400 and 22.000 kW, and 142 charge-point and 169 vehicle names in the current catalogue map to more than one value. The device record gives only the make and model to match on, and the lookup kept whichever match came last, so a 7.4 kW zappi was planned at 22 kW. fetch.py then takes the larger of that and car_charging_rate, so the user's own setting couldn't bring it back down. catalogue_value() now gathers every matching value and, when they disagree, picks one on purpose: - A charge point gets the lowest power listed: the most every variant can do. Since Octopus's figure only ever raises car_charging_rate, a user with a faster charger can still set the rate higher, and the warning says so. - A vehicle gets the largest battery size listed, so the car's remaining charge is over- rather than under-estimated. Using no value instead would fall back to car_charging_battery_size, which is 100 kWh when it isn't set. The component warns once per make and model. The warning names the values and the one in use. For a charger it also says that input_number.predbat_car_charging_rate decides the rate when it's higher; for a vehicle it says car_charging_battery_size can't change it. It also converts the figures to float, ignoring any that are missing, non-numeric, non-finite or not above zero. GraphQL returns them as strings ("22.000"), which the discovery coordinator's ratings container refused ("value does not fit the container's type - dropped"). The planner was unaffected by that part, because fetch.py already converts them. Two things are deliberately left alone: - fetch.py's max(rate, car_charging_rate), whose misleading "Octopus over reports" comment is corrected. It was added in #2855, and the default car_charging_rate (7.4) can't be told apart from one the user set, so letting it cap the Octopus figure could lower the rate for owners of faster chargers who rely on it. - An Octopus battery size still replaces car_charging_battery_size. The issue asked for the configured size to be able to win, but four templates (givenergy_cloud, givenergy_ems, huawei, solaredge) ship car_charging_battery_size: [75] uncommented. Users of those templates would lose the real size from Octopus for the template's placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(octopus): name the car charging rate setting with the entity prefix in use (#5438) The ambiguous-charger warning told the user to set input_number.predbat_car_charging_rate, but the entity prefix is configurable (apps.yaml prefix), so with a custom prefix it named an entity that doesn't exist. It now uses the component's own prefix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 5001c8e commit b88cbd8

5 files changed

Lines changed: 352 additions & 15 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1562,7 +1562,8 @@ def fetch_sensor_data_cars(self, save=True):
15621562
if car_n < len(self.car_charging_manual_soc) and not self.car_charging_manual_soc[car_n]:
15631563
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
15641564
if rate:
1565-
# Take the max as Octopus over reports
1565+
# Octopus's figure only ever raises the configured rate, never lowers it. Where Octopus's catalogue
1566+
# gives the charger more than one power, the component passes on the lowest (catalogue_value(), #5438)
15661567
self.log("Car {} rate from Octopus is {}kW and configured rate {}".format(car_n, rate, self.car_charging_rate[car_n]))
15671568
self.car_charging_rate[car_n] = max(rate, self.car_charging_rate[car_n])
15681569

‎apps/predbat/octopus.py‎

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"""
1515

1616
import asyncio
17+
import math
1718
import requests
1819
import re
1920
from datetime import datetime, timedelta, timezone
@@ -56,6 +57,18 @@
5657
# The EV/charge-point catalogue is static reference data, so it is refreshed daily
5758
CATALOGUE_FRESH_MINUTES = 24 * 60
5859
CATALOGUE_STALE_MINUTES = 25 * 60
60+
# Logged by log_catalogue_ambiguity() when the catalogue gives a make and model more than one value (issue #5438).
61+
# A charge point's power only ever raises input_number.{prefix}_car_charging_rate (fetch.py), so that is what decides
62+
# the rate; a vehicle's battery size from Octopus replaces car_charging_battery_size, so there is no setting to change.
63+
# {prefix} is the entity prefix in use (apps.yaml prefix, "predbat" by default).
64+
CATALOGUE_AMBIGUOUS_CHARGER = (
65+
"Octopus's catalogue lists charge point {device} at more than one power ({values} {unit}) and Predbat can't tell which yours is,"
66+
" 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"
67+
)
68+
CATALOGUE_AMBIGUOUS_VEHICLE = (
69+
"Octopus's catalogue lists vehicle {device} with more than one battery size ({values} {unit}) and Predbat can't tell which yours is,"
70+
" 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"
71+
)
5972
OCTOPUS_SLOT_MAX_DEFAULT = 48 # 24 hours with 30-minute slots
6073
OCTOPUS_SLOT_MAX_CAPPED = 12 # 6 hours with 30-minute slots
6174
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):
90103
return True
91104

92105

106+
def catalogue_value(entries, make, model, field, pick):
107+
"""
108+
Look up one make and model's value in an Octopus catalogue list (chargePointVariants or electricVehicles).
109+
110+
The catalogue's names aren't unique: the same make and model can be listed more than once with
111+
different values - myenergi's "zappi (all models)" is there at both 7.4 and 22 kW - and the name is
112+
all a device record gives to match on (issue #5438). Taking whichever match came last planned a
113+
7.4 kW zappi at 22 kW. So when the matches disagree, pick chooses between them, deliberately rather
114+
than by catalogue order: min for a charge point's power (the most every variant can do), max for a
115+
vehicle's battery size (so the car's remaining charge is over- rather than under-estimated).
116+
117+
GraphQL returns these figures as strings ("22.000"), so they are converted to float here. Entries
118+
whose value is missing, not a number, not finite, or not above zero are ignored.
119+
120+
Args:
121+
entries (list): The catalogue list, each entry {"make": ..., "models": [{"model": ..., field: ...}]}
122+
make (str): The device's make
123+
model (str): The device's model
124+
field (str): The value to read from each matching model, e.g. "powerInKw" or "batterySize"
125+
pick (callable): Chooses one value from the list when the matches disagree, e.g. min or max
126+
127+
Returns:
128+
tuple: (value, values) - value is the matches' value as a float (pick's choice when they
129+
disagree), or None if nothing usable matched; values is the sorted list of the distinct values
130+
the matches gave, so more than one means the catalogue was ambiguous.
131+
"""
132+
values = set()
133+
for entry in entries or []:
134+
if entry.get("make", None) != make:
135+
continue
136+
for info in entry.get("models", None) or []:
137+
if info.get("model", None) != model:
138+
continue
139+
try:
140+
value = float(info.get(field, None))
141+
except (TypeError, ValueError):
142+
continue
143+
if math.isfinite(value) and value > 0:
144+
values.add(value)
145+
values = sorted(values)
146+
return (pick(values) if values else None), values
147+
148+
93149
def parse_date(dt_str):
94150
"""Convert a date string to a date object."""
95151
try:
@@ -628,6 +684,9 @@ def initialize(self, key, account_id, automatic):
628684
self.saving_sessions = {}
629685
self.saving_sessions_to_join = []
630686
self.intelligent_devices = {}
687+
# (message, make, model) the catalogue gave conflicting values for and that have been logged, so the
688+
# warning is given once rather than on every device poll (see catalogue_value())
689+
self.catalogue_ambiguity_logged = set()
631690
# Signature of the dispatches last published, so a poll that changes them can request a replan
632691
self.intelligent_dispatch_signature = None
633692
# 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
24242483

24252484
return None
24262485

2486+
def log_catalogue_ambiguity(self, message, make, model, values, value, unit):
2487+
"""
2488+
Warn, once per make and model, when the catalogue lists a device more than once with different values.
2489+
2490+
The log line is the only sign a user gets that catalogue_value() had to choose, so it names the
2491+
values the catalogue gave and the one Predbat is using.
2492+
2493+
Args:
2494+
message (str): What was looked up and what the user can do about it, formatted with the
2495+
device ({device}), the values ({values}), the one in use ({value}), the unit ({unit}) and
2496+
the entity prefix in use ({prefix})
2497+
make (str): The device's make
2498+
model (str): The device's model
2499+
values (list): The distinct values the catalogue gave, from catalogue_value()
2500+
value (float): The value catalogue_value() chose
2501+
unit (str): The values' unit
2502+
"""
2503+
if len(values) < 2:
2504+
return
2505+
key = (message, make, model)
2506+
if key in self.catalogue_ambiguity_logged:
2507+
return
2508+
self.catalogue_ambiguity_logged.add(key)
2509+
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))
2510+
24272511
async def async_get_vehicle_catalogue(self):
24282512
"""
24292513
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):
25262610
device_setting_result[setting_key] = cached_device[setting_key]
25272611

25282612
if isCharger:
2529-
for charger in chargePointVariants:
2530-
if charger.get("make", None) == make:
2531-
models = charger.get("models", [])
2532-
for charger_info in models:
2533-
if charger_info.get("model", None) == model:
2534-
chargePointPowerInKw = charger_info.get("powerInKw", None)
2613+
chargePointPowerInKw, values = catalogue_value(chargePointVariants, make, model, "powerInKw", min)
2614+
self.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_CHARGER, make, model, values, chargePointPowerInKw, "kW")
25352615
else:
2536-
for vehicle in electricVehicles:
2537-
if vehicle.get("make", None) == make:
2538-
models = vehicle.get("models", [])
2539-
for vehicle_info in models:
2540-
if vehicle_info.get("model", None) == model:
2541-
vehicleBatterySizeInKwh = vehicle_info.get("batterySize", None)
2616+
vehicleBatterySizeInKwh, values = catalogue_value(electricVehicles, make, model, "batterySize", max)
2617+
self.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_VEHICLE, make, model, values, vehicleBatterySizeInKwh, "kWh")
25422618

25432619
intelligent_device = {
25442620
"deviceType": deviceType,

‎apps/predbat/tests/test_octopus_catalogue_cache.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ async def test_octopus_catalogue_cache(my_predbat):
142142
print("\n*** Test 3: battery size resolved from cached catalogue ***")
143143
device = (result or {}).get("device-1", {})
144144
battery = device.get("vehicle_battery_size_in_kwh")
145-
if battery != "57.5":
145+
# The catalogue's "57.5" string comes back as a number (catalogue_value(), issue #5438)
146+
if battery != 57.5:
146147
print("ERROR: Expected battery size 57.5 from the cached catalogue, got {}".format(battery))
147148
failed = True
148149
else:

0 commit comments

Comments
 (0)