Skip to content

Commit 53d4c65

Browse files
Merge remote-tracking branch 'origin/main' into fix/smart-control-off-planned-slots-5339
Conflicts: - apps/predbat/fetch.py (fetch_sensor_data_cars): main (#5520/#5533) now collects the planned dispatches a car uses into planned_used, inserts the held half hours from octopus_held_dispatches() ahead of them and records them in octopus_slots_planned for octopus_held_update(). This branch filtered the planned dispatches through smart_control_off_slots() while Smart Control is off. Combined: the filtered list (planned_kept) is what the plugged-in check gates, so the held dispatches, octopus_slots and octopus_slots_planned all see only the dispatches Predbat still uses. A re-tagged charger-schedule slot is not billed off-peak, so it is never held; a half hour already held from before Smart Control went off stays held until the completed list has it, as Octopus billed it off-peak. - apps/predbat/octopus.py (imports): keep both OCTOPUS_MANUAL_DISPATCH_SOURCES (this branch) and OCTOPUS_DISPATCH_HOLD_MINUTES (main). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 6aa6764 + d4c3a9a commit 53d4c65

37 files changed

Lines changed: 5165 additions & 707 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/car_charger_control.py‎

Lines changed: 656 additions & 0 deletions
Large diffs are not rendered by default.

‎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/config.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,13 @@
883883
"type": "switch",
884884
"default": True,
885885
},
886+
{
887+
"name": "octopus_intelligent_charger_follows_car",
888+
"friendly_name": "Drive the charger to the Intelligent dispatches when Octopus drives the car",
889+
"type": "switch",
890+
"default": False,
891+
"enable": "expert_mode",
892+
},
886893
{
887894
"name": "octopus_saving_auto_join",
888895
"friendly_name": "Octopus Saving Session Auto Join",

‎apps/predbat/const.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
# A car_charging_now reading only confirms the half hour it is in (keeping that half hour cheap once the car
4040
# stops, GH#5316) from this many minutes into it: the sensor can lag a car that stopped just before the boundary
4141
DYNAMIC_LOAD_CAR_CONFIRM_MINUTES = 2
42+
# How long a half hour an Octopus Intelligent planned dispatch ran in keeps the dispatch rate after it ends,
43+
# while waiting for Octopus to list it as a completed dispatch - seen to take 15 to 60 minutes (GH#5520)
44+
OCTOPUS_DISPATCH_HOLD_MINUTES = 90
4245

4346
# Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the
4447
# downside case diverges harder than the central one
@@ -62,6 +65,7 @@
6265
PV_SCENARIO_PV10 = 1
6366
PV_SCENARIO_PV90 = 2
6467
LOAD_FORECAST_HISTORY_MAX_DAYS = 30 # Max days of history used by the weighted-bucket load forecast (days_previous_auto)
68+
LOAD_FORECAST_RECORDED_MIN_MINUTES = 60 # Recorded load needed before its average fills slots no history covers (new installs, batpred#5548)
6569
CONFIG_ROOTS = ["/config", "/conf", "/homeassistant", "./"]
6670
TIME_FORMAT_HA = "%Y-%m-%dT%H:%M:%S%z"
6771
TIME_FORMAT_HA_TZ = "%Y-%m-%dT%H:%M:%S.%f%z"

‎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.

0 commit comments

Comments
 (0)