Skip to content

Commit 104f6af

Browse files
CIclaude
andcommitted
feat(gateway): add gateway_shared_ct for inverters sharing one CT clamp
Two or more inverters on one Predbat Gateway with no EMS or Gateway coordinating them can share a single grid CT clamp. Each then reports the same grid and load power, and Predbat sums them, so the house's grid and load power read once per inverter. gateway_shared_ct: true binds grid_power and load_power to the first inverter only, with zeros for the rest - the same pattern as ge_cloud_automatic_shared_ct on the GivEnergy Cloud path. Off by default, and it does nothing with a single inverter. Battery and PV power stay per inverter. The energy counters were already taken from the first inverter. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent fc3354f commit 104f6af

5 files changed

Lines changed: 93 additions & 1 deletion

File tree

‎apps/predbat/components.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,7 @@ def load_component_class(component_info):
714714
"gateway_inverter_serial": {"required": False, "config": "gateway_inverter_serial", "default": None},
715715
"gateway_evc_automatic": {"required": False, "config": "gateway_evc_automatic", "default": False},
716716
"gateway_evc_control": {"required": False, "config": "gateway_evc_control", "default": False},
717+
"gateway_shared_ct": {"required": False, "config": "gateway_shared_ct", "default": False},
717718
},
718719
"phase": 1,
719720
"can_restart": True,

‎apps/predbat/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2896,6 +2896,7 @@
28962896
"gateway_mqtt_host": {"type": "string", "empty": False},
28972897
"gateway_mqtt_port": {"type": "integer", "zero": False},
28982898
"gateway_mqtt_token": {"type": "string", "empty": False},
2899+
"gateway_shared_ct": {"type": "boolean"},
28992900
# User-maintained log/debug redaction denylist (GH#4770): literal strings to mask wherever a
29002901
# value appears in predbat.log or a debug dump, for anything Predbat cannot recognise as a
29012902
# credential from its own config - an MPAN or account number surfaced by a third-party HA

‎apps/predbat/gateway.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ class GatewayMQTT(ComponentBase):
212212
# control writes (engine thread) and by acks arriving on the MQTT listener's loop.
213213
_command_lock = threading.Lock()
214214

215-
def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqtt_token=None, gateway_inverter_serial=None, gateway_evc_automatic=False, gateway_evc_control=False, **kwargs):
215+
def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqtt_token=None, gateway_inverter_serial=None, gateway_evc_automatic=False, gateway_evc_control=False, gateway_shared_ct=False, **kwargs):
216216
"""Initialize gateway configuration and build MQTT topic strings.
217217
218218
Args:
@@ -228,11 +228,16 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt
228228
gateway_evc_control: When True (requires gateway_evc_automatic), check once per minute whether
229229
the current time falls inside a planned car-charging window and send RemoteStartTransaction
230230
plus SetChargingProfile on window entry, or RemoteStopTransaction on window exit.
231+
gateway_shared_ct: When True, several inverters share one grid CT clamp, so each reports the
232+
same grid and load power. Only the first inverter's readings are then used, so the
233+
house's grid and load are not counted once per inverter. Off by default, where each
234+
inverter is taken to have its own clamp and the readings are summed.
231235
**kwargs: Additional keyword arguments (ignored).
232236
"""
233237
self.gateway_device_id = gateway_device_id
234238
self.gateway_evc_automatic = bool(gateway_evc_automatic)
235239
self.gateway_evc_control = bool(gateway_evc_control)
240+
self.gateway_shared_ct = bool(gateway_shared_ct)
236241
self.mqtt_host = mqtt_host
237242
self.mqtt_port = mqtt_port
238243
self.mqtt_token = mqtt_token
@@ -1368,6 +1373,12 @@ def automatic_config(self):
13681373
self.set_arg("soc_max", soc_max_entities)
13691374
self.set_arg("battery_power", battery_power_entities)
13701375
self.set_arg("pv_power", pv_power_entities)
1376+
# Inverters sharing one grid CT clamp each report the same grid and load reading, so they must
1377+
# not be summed. Same "single source + zeros" pattern as gecloud.py's ge_cloud_automatic_shared_ct.
1378+
if self.gateway_shared_ct and num_inverters > 1:
1379+
self.log("Info: GatewayMQTT: Multiple inverters sharing a single CT clamp (gateway_shared_ct) — using first inverter only for grid and load measurements")
1380+
grid_power_entities = grid_power_entities[:1] + [0 for _ in range(num_inverters - 1)]
1381+
load_power_entities = load_power_entities[:1] + [0 for _ in range(num_inverters - 1)]
13711382
self.set_arg("grid_power", grid_power_entities)
13721383
self.set_arg("load_power", load_power_entities)
13731384
self.set_arg("charge_rate", charge_rate_entities)

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,7 @@ def _make_gateway(self):
878878
gw.gateway_inverter_serial = []
879879
gw.gateway_evc_automatic = False
880880
gw.gateway_evc_control = False
881+
gw.gateway_shared_ct = False
881882
gw._dashboard_calls = {}
882883

883884
def capture_set_arg(key, value):
@@ -1606,6 +1607,7 @@ def _make_gateway(self):
16061607
gw.gateway_inverter_serial = [] # default: no serial filter
16071608
gw.gateway_evc_automatic = False
16081609
gw.gateway_evc_control = False
1610+
gw.gateway_shared_ct = False
16091611

16101612
def capture_set_arg(key, value):
16111613
gw._args[key] = value
@@ -1826,6 +1828,76 @@ def test_multi_inverter_battery_args_have_one_entry_per_inverter(self):
18261828
assert gw._args["battery_rate_max"] == [f"sensor.{base0}_battery_rate_max", f"sensor.{base1}_battery_rate_max"]
18271829
assert gw._args["inverter_time"] == [f"sensor.{base0}_inverter_time", f"sensor.{base1}_inverter_time"]
18281830

1831+
def _two_inverter_status(self):
1832+
"""Two primary inverters with no EMS or Gateway coordinating them."""
1833+
status = pb.GatewayStatus()
1834+
status.device_id = "pbgw_multi"
1835+
status.firmware = "1.0.0"
1836+
status.schema_version = 1
1837+
self._make_inverter(status, serial="CE2223G800", primary=True)
1838+
self._make_inverter(status, serial="CE2225G400", primary=True)
1839+
return status
1840+
1841+
def test_multi_inverter_grid_and_load_power_are_summed_by_default(self):
1842+
"""Without gateway_shared_ct each inverter's grid and load power is bound, as each has its own CT clamp."""
1843+
gw = self._make_gateway()
1844+
gw._last_status = self._two_inverter_status()
1845+
gw.automatic_config()
1846+
1847+
base0 = f"{gw.prefix}_gateway_23g800"
1848+
base1 = f"{gw.prefix}_gateway_25g400"
1849+
assert gw._args["grid_power"] == [f"sensor.{base0}_grid_power", f"sensor.{base1}_grid_power"]
1850+
assert gw._args["load_power"] == [f"sensor.{base0}_load_power", f"sensor.{base1}_load_power"]
1851+
1852+
def test_shared_ct_uses_first_inverter_for_grid_and_load_power(self):
1853+
"""gateway_shared_ct binds grid and load power to the first inverter only, with zeros for the rest.
1854+
1855+
Two inverters on one CT clamp both report the same grid and load reading, so summing them doubles it.
1856+
The battery and PV args stay per inverter - those are measured by each inverter itself.
1857+
"""
1858+
gw = self._make_gateway()
1859+
gw.gateway_shared_ct = True
1860+
gw._last_status = self._two_inverter_status()
1861+
gw.automatic_config()
1862+
1863+
base0 = f"{gw.prefix}_gateway_23g800"
1864+
base1 = f"{gw.prefix}_gateway_25g400"
1865+
assert gw._args["grid_power"] == [f"sensor.{base0}_grid_power", 0]
1866+
assert gw._args["load_power"] == [f"sensor.{base0}_load_power", 0]
1867+
assert gw._args["battery_power"] == [f"sensor.{base0}_battery_power", f"sensor.{base1}_battery_power"]
1868+
assert gw._args["pv_power"] == [f"sensor.{base0}_pv_power", f"sensor.{base1}_pv_power"]
1869+
assert gw._args["num_inverters"] == 2
1870+
logged = " ".join(str(call) for call in gw.log.call_args_list)
1871+
assert "sharing a single CT clamp" in logged
1872+
1873+
def test_shared_ct_has_no_effect_on_a_single_inverter(self):
1874+
"""With one inverter there is nothing to double, so gateway_shared_ct changes nothing."""
1875+
gw = self._make_gateway()
1876+
gw.gateway_shared_ct = True
1877+
gw._last_status = self._basic_status()
1878+
gw.automatic_config()
1879+
1880+
base = f"{gw.prefix}_gateway_456789"
1881+
assert gw._args["grid_power"] == [f"sensor.{base}_grid_power"]
1882+
assert gw._args["load_power"] == [f"sensor.{base}_load_power"]
1883+
1884+
def test_shared_ct_is_read_from_the_component_argument(self):
1885+
"""initialize() takes gateway_shared_ct from apps.yaml and defaults it to off."""
1886+
from gateway import GatewayMQTT
1887+
from unittest.mock import MagicMock
1888+
1889+
gw = GatewayMQTT.__new__(GatewayMQTT)
1890+
gw.base = MagicMock()
1891+
gw.args = {}
1892+
gw.initialize(gateway_device_id="pbgw_test", mqtt_host="mqtt.example", mqtt_token="token")
1893+
assert gw.gateway_shared_ct is False
1894+
1895+
gw = GatewayMQTT.__new__(GatewayMQTT)
1896+
gw.base = MagicMock()
1897+
gw.args = {}
1898+
gw.initialize(gateway_device_id="pbgw_test", mqtt_host="mqtt.example", mqtt_token="token", gateway_shared_ct=True)
1899+
assert gw.gateway_shared_ct is True
1900+
18291901
# ------------------------------------------------------------------
18301902
# Secondary (cloud) and unsupported feature args
18311903
# ------------------------------------------------------------------
@@ -3208,6 +3280,7 @@ def _make_gateway(self):
32083280
gw.gateway_inverter_serial = []
32093281
gw.gateway_evc_automatic = False
32103282
gw.gateway_evc_control = False
3283+
gw.gateway_shared_ct = False
32113284
gw._configured_ev_chargers = frozenset()
32123285

32133286
def capture_set_arg(key, value):

‎docs/apps-yaml.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,12 @@ See also **ge_cloud_automatic_split_ct** which takes priority over this setting
613613
Use this to override automatic shared-CT detection if Predbat incorrectly identifies your system as sharing a CT clamp (e.g. when duplicate meter serials are reported by the cloud API but the inverters actually have separate CT clamps).
614614
This setting takes priority over **ge_cloud_automatic_shared_ct** if both are set.
615615

616+
- **gateway_shared_ct** - Optional, defaults to false. The equivalent of **ge_cloud_automatic_shared_ct** for inverters controlled through the Predbat Gateway.
617+
When set to `true`, Predbat will treat multiple inverters as sharing a single physical CT clamp for grid and load measurement,
618+
so only the first inverter's grid and load power readings are used (the rest are zeroed out), preventing double-counting of grid import/export and house load.
619+
Use this if you have two or more inverters connected to a single CT clamp with no EMS or Gateway coordinating them. The Predbat Gateway does not detect a shared CT automatically.
620+
Battery and solar readings are unaffected and are still taken from each inverter.
621+
616622
- **ge_cloud_automatic_split_pv** - Optional, defaults to false. When set to `true`, Predbat will also include any standalone PV-only inverters (e.g. a GivEnergy AC-coupled PV inverter with no battery attached) in **pv_today** and **pv_power**, in addition to the battery inverters.
617623
Use this if you have a separate PV-only inverter alongside your battery inverter(s) and want its solar generation included in Predbat's totals. Leave this off (the default) if your battery inverters already report all of your solar generation, to avoid duplicating or including unwanted readings.
618624

0 commit comments

Comments
 (0)