Skip to content

Commit ed9c417

Browse files
bootcclaude
andcommitted
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>
1 parent d7d8c60 commit ed9c417

5 files changed

Lines changed: 341 additions & 14 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: 86 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,17 @@
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.predbat_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+
CATALOGUE_AMBIGUOUS_CHARGER = (
64+
"Octopus's catalogue lists charge point {device} at more than one power ({values} {unit}) and Predbat can't tell which yours is,"
65+
" so it uses the lowest, {value} {unit}, unless input_number.predbat_car_charging_rate (_1, _2 for further cars) is higher. Set that to your charger's rate"
66+
)
67+
CATALOGUE_AMBIGUOUS_VEHICLE = (
68+
"Octopus's catalogue lists vehicle {device} with more than one battery size ({values} {unit}) and Predbat can't tell which yours is,"
69+
" 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"
70+
)
5971
OCTOPUS_SLOT_MAX_DEFAULT = 48 # 24 hours with 30-minute slots
6072
OCTOPUS_SLOT_MAX_CAPPED = 12 # 6 hours with 30-minute slots
6173
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 +102,49 @@ def is_active(now_utc, activeFrom, activeTo):
90102
return True
91103

92104

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

24252483
return None
24262484

2485+
def log_catalogue_ambiguity(self, message, make, model, values, value, unit):
2486+
"""
2487+
Warn, once per make and model, when the catalogue lists a device more than once with different values.
2488+
2489+
The log line is the only sign a user gets that catalogue_value() had to choose, so it names the
2490+
values the catalogue gave and the one Predbat is using.
2491+
2492+
Args:
2493+
message (str): What was looked up and what the user can do about it, formatted with the
2494+
device ({device}), the values ({values}), the one in use ({value}) and the unit ({unit})
2495+
make (str): The device's make
2496+
model (str): The device's model
2497+
values (list): The distinct values the catalogue gave, from catalogue_value()
2498+
value (float): The value catalogue_value() chose
2499+
unit (str): The values' unit
2500+
"""
2501+
if len(values) < 2:
2502+
return
2503+
key = (message, make, model)
2504+
if key in self.catalogue_ambiguity_logged:
2505+
return
2506+
self.catalogue_ambiguity_logged.add(key)
2507+
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))
2508+
24272509
async def async_get_vehicle_catalogue(self):
24282510
"""
24292511
Get the global EV / charge-point catalogue, cached across polls and instances.
@@ -2526,19 +2608,11 @@ async def async_get_intelligent_devices(self, account_id, device_id):
25262608
device_setting_result[setting_key] = cached_device[setting_key]
25272609

25282610
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)
2611+
chargePointPowerInKw, values = catalogue_value(chargePointVariants, make, model, "powerInKw", min)
2612+
self.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_CHARGER, make, model, values, chargePointPowerInKw, "kW")
25352613
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)
2614+
vehicleBatterySizeInKwh, values = catalogue_value(electricVehicles, make, model, "batterySize", max)
2615+
self.log_catalogue_ambiguity(CATALOGUE_AMBIGUOUS_VEHICLE, make, model, values, vehicleBatterySizeInKwh, "kWh")
25422616

25432617
intelligent_device = {
25442618
"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)