Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/predbat/gecloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,8 @@ def initialize(self, ge_cloud_direct, api_key, automatic, automatic_evc=False, e
self.ems_slot_warned = set()
# (device, key) registers whose option validation text could not be parsed, so the warning is logged once
self.validation_parse_warned = set()
# Hybrid model names whose inverter rating could not be parsed, so the fallback warning is logged once
self.model_rate_warned = set()
Comment thread
springfall2008 marked this conversation as resolved.
self.devices_dict = {}
self.device_list = []
self.ems_device = None
Expand Down Expand Up @@ -910,18 +912,40 @@ def get_max_inverter_rate_from_model(self, model, max_charge_rate=None):
- GIV-HY3.6 - 3.6kW inverter
- GIV-HY5.0 - 5.0kW inverter
- GIV-HY-8.0-G3-HV. - 8kW inverter
- GIV-3HY-11 - 11kW 3-phase hybrid (integer rating as its own segment after 3HY)
Comment thread
springfall2008 marked this conversation as resolved.
- GIV-AIO-GW2 - Gateway variant, the trailing 2 is not a rating
- Plant EMS - Not an inverter, should use the individual inverter values
"""

# GE Cloud can send an explicit null model, which would crash every regex below
if not isinstance(model, str):
return max_charge_rate

# Trailing whitespace or a trailing '.' (GIV-HY-8.0-G3-HV.) must not defeat the segment anchors below
model_clean = model.strip().rstrip(".")

# Integer rating only as a whole segment straight after the 3-phase hybrid token (GIV-3HY-11),
# bounded by '-' or end of string so suffixes such as GW2 / G3 are never read as a rating.
# Checked before the decimal pass so a later decimal segment cannot hide the explicit 3HY rating
match = re.search(r"(?:^|-)3HY-(\d{1,2})(?=-|$)", model_clean, re.IGNORECASE)
if match:
return int(match.group(1)) * 1000

# Find all decimal numbers anywhere in the model string (e.g. 3.6, 10.0, 12.0)
# Use the last match so that e.g. GIV-AIO-AC-13.5-12.0 resolves to 12kW not 13.5kW
matches = re.findall(r"\d+\.\d+", model)
matches = re.findall(r"\d+\.\d+", model_clean)
if matches:
try:
max_inverter_rate = int(float(matches[-1]) * 1000)
return max_inverter_rate
except ValueError:
pass

# A hybrid with no parseable rating is using a battery rate as its AC rating, warn once per model
if "HY" in model_clean.upper() and model not in self.model_rate_warned:
fallback = "max charge rate {}W".format(max_charge_rate) if max_charge_rate is not None else "no rating"
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))
self.model_rate_warned.add(model)
return max_charge_rate

async def publish_account(self, account):
Expand Down
69 changes: 69 additions & 0 deletions apps/predbat/tests/test_ge_cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ def __init__(self):
self.gateway_device = None
self.ems_slot_warned = set()
self.validation_parse_warned = set()
self.model_rate_warned = set()
self._discovery_report = None
self._now_utc_exact = datetime.now(timezone.utc)
self.settings_from_cache = False
Expand Down Expand Up @@ -7521,6 +7522,26 @@ def _test_get_max_inverter_rate_from_model(my_predbat):
("Plant EMS", None, None, "Plant EMS => None (no number)"),
# No number, but max_charge_rate provided as fallback
("Gateway", 2600, 2600, "Gateway => fallback to max_charge_rate"),
# 3-phase hybrid carries an integer rating as its own segment after 3HY (GH#5136)
("GIV-3HY-11", 9984, 11000, "GIV-3HY-11 => 11kW, not the 9984W battery rate"),
("GIV-3HY-8", 7987, 8000, "GIV-3HY-8 => 8kW"),
("giv-3hy-5", 4000, 5000, "giv-3hy-5 => 5kW (case-insensitive)"),
# Integer suffixes that are not ratings keep the fallback
("GIV-AIO-GW2", 2600, 2600, "GIV-AIO-GW2 => fallback, GW2 is not a rating"),
("GIV-3HY-G3", 9984, 9984, "GIV-3HY-G3 => fallback, G3 is not a rating"),
("GIV-3HY-110", 9984, 9984, "GIV-3HY-110 => fallback, three digits is not a plausible rating"),
# Non-3HY hybrids still use the decimal pass
("ALPS HY-6.0-GL", 3600, 6000, "ALPS HY-6.0-GL => 6kW"),
# The explicit 3HY segment wins over a later decimal segment
("GIV-3HY-11-9.5", 9984, 11000, "GIV-3HY-11-9.5 => 11kW, later decimal does not hide the 3HY rating"),
# A 3HY decimal rating is not a whole integer segment and falls through to the decimal pass
("GIV-3HY-10.0", 9984, 10000, "GIV-3HY-10.0 => 10kW via the decimal pass"),
# Trailing punctuation or whitespace does not defeat the segment anchors
("GIV-3HY-11.", 9984, 11000, "GIV-3HY-11. => 11kW (trailing period)"),
("GIV-3HY-11 ", 9984, 11000, "GIV-3HY-11<space> => 11kW (trailing space)"),
("GIV-HY-8.0-G3-HV.", None, 8000, "GIV-HY-8.0-G3-HV. => 8kW (trailing period)"),
# An explicit null model from GE Cloud falls back rather than raising TypeError
(None, 3600, 3600, "None model => fallback to max_charge_rate"),
]

failed = 0
Expand All @@ -7533,6 +7554,54 @@ def _test_get_max_inverter_rate_from_model(my_predbat):
else:
print("OK {}: got {}".format(description, result))

# A hybrid model that cannot be parsed warns once per model, non-hybrids stay silent
ge_cloud.model_rate_warned = set()
Comment thread
springfall2008 marked this conversation as resolved.
ge_cloud.log_messages = []
ge_cloud.get_max_inverter_rate_from_model("GIV-HYX", 2600)
ge_cloud.get_max_inverter_rate_from_model("GIV-HYX", 2600)
ge_cloud.get_max_inverter_rate_from_model("Gateway", 2600)
ge_cloud.get_max_inverter_rate_from_model("GIV-3HY-11", 9984)
warnings = [message for message in ge_cloud.log_messages if "Unable to determine inverter rating" in message]
if len(warnings) != 1 or "GIV-HYX" not in warnings[0]:
print("ERROR expected exactly one fallback warning naming GIV-HYX, got {}".format(warnings))
failed += 1
else:
print("OK unparsed hybrid model warns once")

# With no max_charge_rate the warning must not print "NoneW"
ge_cloud.model_rate_warned = set()
ge_cloud.log_messages = []
ge_cloud.get_max_inverter_rate_from_model("GIV-HYZ", None)
warnings = [message for message in ge_cloud.log_messages if "Unable to determine inverter rating" in message]
if len(warnings) != 1 or "NoneW" in warnings[0]:
print("ERROR expected one fallback warning without 'NoneW', got {}".format(warnings))
failed += 1
else:
print("OK fallback warning with no max_charge_rate is readable")

# End to end through publish_info: the 3-phase hybrid publishes its 11kW rating, not the fluctuating battery rate (GH#5136)
ge_cloud.config_args["prefix"] = "predbat"
ge_cloud.dashboard_items.clear()
info_3hy = {"info": {"battery": {"nominal_capacity": 186, "nominal_voltage": 51.2}, "model": "GIV-3HY-11", "max_charge_rate": 9984}}
run_async(ge_cloud.publish_info("dev3hy", info_3hy))
rate = ge_cloud.dashboard_items.get("sensor.predbat_gecloud_dev3hy_max_inverter_rate", {}).get("state")
if rate != 11000:
print("ERROR publish_info GIV-3HY-11 expected max_inverter_rate 11000, got {}".format(rate))
failed += 1
else:
print("OK publish_info GIV-3HY-11 publishes 11000")

# An explicit null model in the payload publishes the fallback instead of crashing the refresh
ge_cloud.dashboard_items.clear()
info_null_model = {"info": {"battery": {"nominal_capacity": 186, "nominal_voltage": 51.2}, "model": None, "max_charge_rate": 3600}}
run_async(ge_cloud.publish_info("devnull", info_null_model))
rate = ge_cloud.dashboard_items.get("sensor.predbat_gecloud_devnull_max_inverter_rate", {}).get("state")
if rate != 3600:
print("ERROR publish_info null model expected max_inverter_rate 3600, got {}".format(rate))
failed += 1
else:
print("OK publish_info null model publishes the fallback")

return 1 if failed else 0


Expand Down
Loading