Skip to content

Commit 385ddda

Browse files
CIclaude
andcommitted
fix(gecloud): harden model rating parse - null model guard, trailing punctuation, 3HY before decimal, readable fallback warning, publish_info tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent f052cd5 commit 385ddda

2 files changed

Lines changed: 63 additions & 10 deletions

File tree

‎apps/predbat/gecloud.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -917,25 +917,34 @@ def get_max_inverter_rate_from_model(self, model, max_charge_rate=None):
917917
- Plant EMS - Not an inverter, should use the individual inverter values
918918
"""
919919

920+
# GE Cloud can send an explicit null model, which would crash every regex below
921+
if not isinstance(model, str):
922+
return max_charge_rate
923+
924+
# Trailing whitespace or a trailing '.' (GIV-HY-8.0-G3-HV.) must not defeat the segment anchors below
925+
model_clean = model.strip().rstrip(".")
926+
927+
# Integer rating only as a whole segment straight after the 3-phase hybrid token (GIV-3HY-11),
928+
# bounded by '-' or end of string so suffixes such as GW2 / G3 are never read as a rating.
929+
# Checked before the decimal pass so a later decimal segment cannot hide the explicit 3HY rating
930+
match = re.search(r"(?:^|-)3HY-(\d{1,2})(?=-|$)", model_clean, re.IGNORECASE)
931+
if match:
932+
return int(match.group(1)) * 1000
933+
920934
# Find all decimal numbers anywhere in the model string (e.g. 3.6, 10.0, 12.0)
921935
# Use the last match so that e.g. GIV-AIO-AC-13.5-12.0 resolves to 12kW not 13.5kW
922-
matches = re.findall(r"\d+\.\d+", model)
936+
matches = re.findall(r"\d+\.\d+", model_clean)
923937
if matches:
924938
try:
925939
max_inverter_rate = int(float(matches[-1]) * 1000)
926940
return max_inverter_rate
927941
except ValueError:
928942
pass
929943

930-
# Integer rating only as a whole segment straight after the 3-phase hybrid token (GIV-3HY-11),
931-
# bounded by '-' or end of string so suffixes such as GW2 / G3 are never read as a rating
932-
match = re.search(r"(?:^|-)3HY-(\d{1,2})(?=-|$)", model, re.IGNORECASE)
933-
if match:
934-
return int(match.group(1)) * 1000
935-
936944
# A hybrid with no parseable rating is using a battery rate as its AC rating, warn once per model
937-
if "HY" in model.upper() and model not in self.model_rate_warned:
938-
self.log("GECloud: Warn: Unable to determine inverter rating from model '{}', using max charge rate {}W instead - set inverter_limit in apps.yaml if this is wrong".format(model, max_charge_rate))
945+
if "HY" in model_clean.upper() and model not in self.model_rate_warned:
946+
fallback = "max charge rate {}W".format(max_charge_rate) if max_charge_rate is not None else "no rating"
947+
self.log("GECloud: Warn: Unable to determine inverter rating from model '{}', using {} instead - set inverter_limit in apps.yaml if this is wrong".format(model, fallback))
939948
self.model_rate_warned.add(model)
940949
return max_charge_rate
941950

‎apps/predbat/tests/test_ge_cloud.py‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7530,8 +7530,18 @@ def _test_get_max_inverter_rate_from_model(my_predbat):
75307530
("GIV-AIO-GW2", 2600, 2600, "GIV-AIO-GW2 => fallback, GW2 is not a rating"),
75317531
("GIV-3HY-G3", 9984, 9984, "GIV-3HY-G3 => fallback, G3 is not a rating"),
75327532
("GIV-3HY-110", 9984, 9984, "GIV-3HY-110 => fallback, three digits is not a plausible rating"),
7533-
# Decimal pass still wins over the 3HY integer pass
7533+
# Non-3HY hybrids still use the decimal pass
75347534
("ALPS HY-6.0-GL", 3600, 6000, "ALPS HY-6.0-GL => 6kW"),
7535+
# The explicit 3HY segment wins over a later decimal segment
7536+
("GIV-3HY-11-9.5", 9984, 11000, "GIV-3HY-11-9.5 => 11kW, later decimal does not hide the 3HY rating"),
7537+
# A 3HY decimal rating is not a whole integer segment and falls through to the decimal pass
7538+
("GIV-3HY-10.0", 9984, 10000, "GIV-3HY-10.0 => 10kW via the decimal pass"),
7539+
# Trailing punctuation or whitespace does not defeat the segment anchors
7540+
("GIV-3HY-11.", 9984, 11000, "GIV-3HY-11. => 11kW (trailing period)"),
7541+
("GIV-3HY-11 ", 9984, 11000, "GIV-3HY-11<space> => 11kW (trailing space)"),
7542+
("GIV-HY-8.0-G3-HV.", None, 8000, "GIV-HY-8.0-G3-HV. => 8kW (trailing period)"),
7543+
# An explicit null model from GE Cloud falls back rather than raising TypeError
7544+
(None, 3600, 3600, "None model => fallback to max_charge_rate"),
75357545
]
75367546

75377547
failed = 0
@@ -7558,6 +7568,40 @@ def _test_get_max_inverter_rate_from_model(my_predbat):
75587568
else:
75597569
print("OK unparsed hybrid model warns once")
75607570

7571+
# With no max_charge_rate the warning must not print "NoneW"
7572+
ge_cloud.model_rate_warned = set()
7573+
ge_cloud.log_messages = []
7574+
ge_cloud.get_max_inverter_rate_from_model("GIV-HYZ", None)
7575+
warnings = [message for message in ge_cloud.log_messages if "Unable to determine inverter rating" in message]
7576+
if len(warnings) != 1 or "NoneW" in warnings[0]:
7577+
print("ERROR expected one fallback warning without 'NoneW', got {}".format(warnings))
7578+
failed += 1
7579+
else:
7580+
print("OK fallback warning with no max_charge_rate is readable")
7581+
7582+
# End to end through publish_info: the 3-phase hybrid publishes its 11kW rating, not the fluctuating battery rate (GH#5136)
7583+
ge_cloud.config_args["prefix"] = "predbat"
7584+
ge_cloud.dashboard_items.clear()
7585+
info_3hy = {"info": {"battery": {"nominal_capacity": 186, "nominal_voltage": 51.2}, "model": "GIV-3HY-11", "max_charge_rate": 9984}}
7586+
run_async(ge_cloud.publish_info("dev3hy", info_3hy))
7587+
rate = ge_cloud.dashboard_items.get("sensor.predbat_gecloud_dev3hy_max_inverter_rate", {}).get("state")
7588+
if rate != 11000:
7589+
print("ERROR publish_info GIV-3HY-11 expected max_inverter_rate 11000, got {}".format(rate))
7590+
failed += 1
7591+
else:
7592+
print("OK publish_info GIV-3HY-11 publishes 11000")
7593+
7594+
# An explicit null model in the payload publishes the fallback instead of crashing the refresh
7595+
ge_cloud.dashboard_items.clear()
7596+
info_null_model = {"info": {"battery": {"nominal_capacity": 186, "nominal_voltage": 51.2}, "model": None, "max_charge_rate": 3600}}
7597+
run_async(ge_cloud.publish_info("devnull", info_null_model))
7598+
rate = ge_cloud.dashboard_items.get("sensor.predbat_gecloud_devnull_max_inverter_rate", {}).get("state")
7599+
if rate != 3600:
7600+
print("ERROR publish_info null model expected max_inverter_rate 3600, got {}".format(rate))
7601+
failed += 1
7602+
else:
7603+
print("OK publish_info null model publishes the fallback")
7604+
75617605
return 1 if failed else 0
75627606

75637607

0 commit comments

Comments
 (0)