Skip to content

Commit fc0ed1c

Browse files
CIclaude
andcommitted
Merge main into fix/hub-charge-slot-pair
Resolve the run_gateway_tests conflict with #5496 and #5532 by registering TestGatewayChargeWindow, TestGatewayDischargeWindow and TestGatewayDisplayOnly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 08d96da + c24750e commit fc0ed1c

19 files changed

Lines changed: 1273 additions & 76 deletions

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ enph
202202
enphase
203203
enphaseenergy
204204
Enpower
205+
entityfilter
205206
entrez
206207
epod
207208
epvtoday

‎apps/predbat/components.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,7 @@ def load_component_class(component_info):
727727
"mqtt_port": {"required": False, "config": "gateway_mqtt_port", "default": 8883},
728728
"mqtt_token": {"required": True, "secret": True, "config": "gateway_mqtt_token"},
729729
"gateway_inverter_serial": {"required": False, "config": "gateway_inverter_serial", "default": None},
730+
"gateway_display_only": {"required": False, "config": "gateway_display_only", "default": False},
730731
"gateway_evc_automatic": {"required": False, "config": "gateway_evc_automatic", "default": False},
731732
"gateway_evc_control": {"required": False, "config": "gateway_evc_control", "default": False},
732733
"gateway_shared_ct": {"required": False, "config": "gateway_shared_ct", "default": True},
@@ -1039,11 +1040,11 @@ def inverter_source_active(self):
10391040
question rather than a per-inverter one: a component serves whichever inverters it
10401041
discovered, and Inverter only needs to know whether to keep waiting.
10411042
"""
1042-
return any(COMPONENT_LIST.get(name, {}).get("inverter", False) and component for name, component in self.components.items())
1043+
return any(COMPONENT_LIST.get(name, {}).get("inverter", False) and component and getattr(component, "gateway_display_only", False) is not True for name, component in self.components.items())
10431044

10441045
def inverter_source_names(self):
10451046
"""The display names of every active inverter component, for user-facing messages."""
1046-
return [COMPONENT_LIST[name]["name"] for name, component in self.components.items() if component and COMPONENT_LIST.get(name, {}).get("inverter", False)]
1047+
return [COMPONENT_LIST[name]["name"] for name, component in self.components.items() if component and COMPONENT_LIST.get(name, {}).get("inverter", False) and getattr(component, "gateway_display_only", False) is not True]
10471048

10481049
def inverter_source_status(self):
10491050
"""
@@ -1076,6 +1077,8 @@ def inverter_source_status(self):
10761077
continue
10771078
load_error = self.load_error(name)
10781079
component = self.components.get(name, None)
1080+
if component and getattr(component, "gateway_display_only", False) is True:
1081+
continue
10791082
if load_error is not None:
10801083
state = "failed to start: {}".format(load_error)
10811084
elif not component:

‎apps/predbat/execute.py‎

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -609,7 +609,11 @@ def execute_plan(self):
609609
else:
610610
# If there is a previous set charge window, but its more than the set window minutes in the future but we are not close to it then we can leave it enabled for now
611611
# saves register writes and avoids turning off the window before the inverter knows it finished
612-
if (inverter.charge_start_time_minutes != inverter.charge_end_time_minutes) and (inverter.charge_start_time_minutes - self.minutes_now) > self.set_window_minutes:
612+
# Only for an inverter with no charge enable switch, where turning the charge off means rewriting the
613+
# window itself (#2993). With a switch it is one write that leaves the times alone, and leaving it on
614+
# arms every other slot the inverter has: on a GivEnergy 3-phase the unused slots, held at 00:00-00:00,
615+
# are live for the first minute of the day and charged from the grid at full rate (#5371)
616+
if (not inverter.inv_has_charge_enable_time) and (inverter.charge_start_time_minutes != inverter.charge_end_time_minutes) and (inverter.charge_start_time_minutes - self.minutes_now) > self.set_window_minutes:
613617
self.log(
614618
"Inverter {} leaving charge window as already set and more than set_window_minutes {} away (now {} charge start time {})".format(
615619
inverter.id, self.set_window_minutes, self.time_abs_str(self.minutes_now), self.time_abs_str(inverter.charge_start_time_minutes)
@@ -790,25 +794,32 @@ def execute_plan(self):
790794
# Don't disable discharge during force charge/discharge slots but otherwise turn it off to prevent
791795
# from draining the battery
792796
if not isExporting:
793-
if inverter.inv_has_timed_pause:
794-
if resetPause:
795-
inverter.adjust_pause_mode(pause_discharge=True)
796-
pause_discharge_requested = True
797-
resetPause = False
797+
# Not while this inverter is actually charging, unless it can pause: the battery is being
798+
# filled from the grid, so it cannot be feeding the car. A discharge rate of 0 adds nothing
799+
# then, and some GivEnergy inverters will not grid-charge at all with it at 0 and the SoC on
800+
# the reserve (#5419); pinning reserve just above a rising SoC costs a write for every 1% of
801+
# the climb (#3899). Both are left to reset below for the duration, and applied once charging
802+
# stops - which is the point the inverter returns to demand and the hold starts to mean
803+
# something. A timed pause is one latched write that blocks only discharge, so it stays on
804+
# through the charge, covering the moments the inverter reaches its target before Predbat
805+
# sees it. The sibling iBoost hold below already sits out a charge for the same reason.
806+
hold_deferred = (not inverter.inv_has_timed_pause) and status_per_inverter.get(inverter.id) == "Charging"
807+
if hold_deferred:
808+
self.log("Inverter {} is charging, so the hold for car {} leaves the discharge rate and reserve alone until charging stops".format(inverter.id, car_n))
798809
else:
799-
if discharge_rate is None:
800-
discharge_rate = 0
801-
# Not while actually charging: the battery is being filled from the grid, so it
802-
# cannot be feeding the car, and pinning reserve just above a rising SoC costs a
803-
# write for every 1% of the climb (#3899). Left to reset below for the duration,
804-
# and latched at the SoC reached once charging stops - which is the point the
805-
# inverter returns to demand and the hold starts to mean something. The sibling
806-
# iBoost hold below already sits out a charge for the same reason.
807-
if self.set_reserve_enable and status != "Charging":
808-
inverter.adjust_reserve(min(inverter.soc_percent + 1, 100))
809-
resetReserve = False
810+
if inverter.inv_has_timed_pause:
811+
if resetPause:
812+
inverter.adjust_pause_mode(pause_discharge=True)
813+
pause_discharge_requested = True
814+
resetPause = False
815+
else:
816+
if discharge_rate is None:
817+
discharge_rate = 0
818+
if self.set_reserve_enable:
819+
inverter.adjust_reserve(min(inverter.soc_percent + 1, 100))
820+
resetReserve = False
821+
self.log("Disabling battery discharge whilst car {} is charging".format(car_n))
810822
carHolding = True
811-
self.log("Disabling battery discharge whilst car {} is charging".format(car_n))
812823
if ("Hold for car" not in status) and (status_hold_car == ""):
813824
if status == "Demand":
814825
status = "Hold for car"
@@ -1235,11 +1246,6 @@ def fetch_inverter_data(self, quick=False):
12351246
self.charge_window = inverter.charge_window
12361247
self.export_window = inverter.export_window
12371248
self.export_limits = inverter.export_limits
1238-
if not inverter.inv_support_discharge_freeze:
1239-
# Force off unsupported feature
1240-
self.log("Note: Inverter does not support discharge freeze - disabled")
1241-
self.set_export_freeze = False
1242-
self.set_export_freeze_only = False
12431249
if not inverter.inv_support_charge_freeze:
12441250
# Force off unsupported feature
12451251
self.log("Note: Inverter does not support charge freeze - disabled")
@@ -1249,6 +1255,14 @@ def fetch_inverter_data(self, quick=False):
12491255
self.set_reserve_enable = False
12501256
self.set_reserve_hold = False
12511257
self.set_discharge_during_charge = True
1258+
# Fleet-wide like the capability below, and for the same reason: the prediction models one
1259+
# combined battery, so a single inverter that keeps charging from solar through a Freeze
1260+
# Export means the plan's hold does not happen (#5082).
1261+
if not inverter.inv_support_discharge_freeze:
1262+
# Force off unsupported feature
1263+
self.log("Note: Inverter {} does not support discharge freeze - disabled".format(inverter.id))
1264+
self.set_export_freeze = False
1265+
self.set_export_freeze_only = False
12521266
# Unlike the first-inverter-only settings above this is a fleet-wide capability: the
12531267
# prediction models one combined battery, so a single inverter that just disables
12541268
# charging during Freeze Export means the fleet as a whole cannot recapture PV.

‎apps/predbat/fetch.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3599,7 +3599,7 @@ def check_export_more_solar_effective(self):
35993599
elif not self.set_export_freeze:
36003600
# Ask the inverter rather than the switch: an unsupported inverter forces set_export_freeze off
36013601
# whatever the switch says, so advising the user to turn the switch on would change nothing.
3602-
if self.inverters and not self.inverters[0].inv_support_discharge_freeze:
3602+
if any(not inverter.inv_support_discharge_freeze for inverter in self.inverters):
36033603
reason = "the inverter does not support Freeze Export. Turn export_more_solar off."
36043604
else:
36053605
reason = "set_export_freeze is off. Enable set_export_freeze, or turn export_more_solar off."

0 commit comments

Comments
 (0)