Skip to content
Open
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
10 changes: 10 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@

INVERTER_TEST = False # Run inverter control self test

# Sentinel values for an export window's target SoC/limit (export_limits_best and friends).
# A real target is any value below EXPORT_LIMIT_FREEZE, expressed as a percentage 0-100
# (see calc_percent_limit) with the fractional part sometimes encoding a low-power export rate.
# prediction_kernel.cpp hardcodes the same two literals independently (it can't import this
# file) - unlike PREDBAT_MAX_CARS/PK_MAX_CARS above, they aren't yet named there too, so a value
# change here needs the matching literals found and updated by hand, in lockstep with a parity
# revision bump and a rebuild of all platform binaries.
EXPORT_LIMIT_FREEZE = 99.0 # Hold SoC, export only genuine PV surplus - no forced discharge
EXPORT_LIMIT_IDLE = 100.0 # Export window disabled entirely

# Create an array of times in the day in 5-minute intervals
BASE_TIME = datetime.strptime("00:00:00", "%H:%M:%S")
OPTIONS_TIME = [((BASE_TIME + timedelta(seconds=minute * 60)).strftime("%H:%M:%S")) for minute in range(0, 24 * 60, 5)]
Expand Down
5 changes: 3 additions & 2 deletions apps/predbat/enphase.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import aiohttp

from component_base import ComponentBase
from const import EXPORT_LIMIT_FREEZE
from mock_base import MockBase
from predbat_metrics import record_api_call

Expand Down Expand Up @@ -1056,8 +1057,8 @@ def _desired_schedule_families(self, local):
dtg_limit = max(export_soc, int(local.get("reserve", 0)))
return {
"cfg": {"enabled": bool(charge.get("enable")), "start": ha_time_to_enphase(charge.get("start_time", "00:00:00")), "end": ha_time_to_enphase(charge.get("end_time", "00:00:00")), "limit": charge.get("soc", 100)},
"dtg": {"enabled": export_enabled and export_soc < 99, "start": export_start, "end": export_end, "limit": dtg_limit},
"rbd": {"enabled": export_enabled and export_soc == 99, "start": export_start, "end": export_end, "limit": None},
"dtg": {"enabled": export_enabled and export_soc < EXPORT_LIMIT_FREEZE, "start": export_start, "end": export_end, "limit": dtg_limit},
"rbd": {"enabled": export_enabled and export_soc == EXPORT_LIMIT_FREEZE, "start": export_start, "end": export_end, "limit": None},
}

async def _cleanup_family(self, site_id, family_key, target, force_recreate):
Expand Down
18 changes: 9 additions & 9 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
# pylint: disable=attribute-defined-outside-init

from datetime import timedelta, datetime
from const import MINUTE_WATT
from const import MINUTE_WATT, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE
from utils import dp0, dp2, dp3, calc_percent_limit, find_charge_rate
from predbat_metrics import metrics
from inverter import Inverter
Expand Down Expand Up @@ -436,8 +436,8 @@ def execute_plan(self):
discharge_end_time = self.midnight_utc + timedelta(minutes=(minutes_end + export_adjust)) # Add in 1 minute margin to allow Predbat to restore demand mode
discharge_soc = max((int(self.export_limits_best[0]) * self.soc_max) / 100.0, self.reserve, self.best_soc_min)
self.log("Next export window will be: {} - {} at reserve {}".format(discharge_start_time, discharge_end_time, self.export_limits_best[0]))
if (self.minutes_now >= minutes_start) and (self.minutes_now < minutes_end) and (self.export_limits_best[0] < 100.0):
if not self.set_export_freeze_only and self.export_limits_best[0] < 99.0 and (self.soc_kw > discharge_soc):
if (self.minutes_now >= minutes_start) and (self.minutes_now < minutes_end) and (self.export_limits_best[0] < EXPORT_LIMIT_IDLE):
if not self.set_export_freeze_only and self.export_limits_best[0] < EXPORT_LIMIT_FREEZE and (self.soc_kw > discharge_soc):
if self.set_export_low_power:
export_rate_adjust = 1 - (self.export_limits_best[0] - int(self.export_limits_best[0]))
else:
Expand All @@ -462,7 +462,7 @@ def execute_plan(self):
else:
inverter.adjust_force_export(False)
disabled_export = True
if self.set_export_freeze and self.export_limits_best[0] == 99:
if self.set_export_freeze and self.export_limits_best[0] == EXPORT_LIMIT_FREEZE:
# In export freeze mode we disable charging during export slots
if inverter.inv_charge_discharge_with_rate:
inverter.adjust_charge_rate(0)
Expand Down Expand Up @@ -490,7 +490,7 @@ def execute_plan(self):
self.isExporting_Target = inverter.soc_percent
self.log("Export Hold (Demand mode) as export is now at/below target or freeze only is set - current SoC {}kWh and target {}kWh".format(self.soc_kw, discharge_soc))
else:
if (self.minutes_now < minutes_end) and ((minutes_start - self.minutes_now) <= self.set_window_minutes) and (self.export_limits_best[0] < 99.0):
if (self.minutes_now < minutes_end) and ((minutes_start - self.minutes_now) <= self.set_window_minutes) and (self.export_limits_best[0] < EXPORT_LIMIT_FREEZE):
# We can't schedule freeze export only full export
# Don't turn off ECO mode for GE inverters except when we are within the export window as it will stop the battery being used
ge_inverters = inverter.inv_has_ge_eco_toggle or inverter.inv_has_ge_inverter_mode
Expand Down Expand Up @@ -606,12 +606,12 @@ def execute_plan(self):
self.adjust_battery_target_multi(inverter, 0, isCharging, isExporting)

# Immediate controls
if self.set_export_freeze and self.export_limits_best[0] == 99:
if self.set_export_freeze and self.export_limits_best[0] == EXPORT_LIMIT_FREEZE:
inverter.adjust_export_immediate(inverter.soc_percent, freeze=True)
elif not disabled_export:
inverter.adjust_export_immediate(int(self.export_limits_best[0]))
else:
inverter.adjust_export_immediate(100) # Dead code right, but kept in case other logic changes
inverter.adjust_export_immediate(int(EXPORT_LIMIT_IDLE)) # Dead code right, but kept in case other logic changes

elif self.charge_limit_best and (self.minutes_now < inverter.charge_end_time_minutes) and ((inverter.charge_start_time_minutes - self.minutes_now) <= self.set_soc_minutes) and not (disabled_charge_window):
if inverter.inv_has_charge_enable_time or isCharging:
Expand Down Expand Up @@ -698,7 +698,7 @@ def execute_plan(self):
else:
inverter.adjust_charge_immediate(0)
if not isExporting and self.set_export_window:
inverter.adjust_export_immediate(100)
inverter.adjust_export_immediate(int(EXPORT_LIMIT_IDLE))

# Reset reserve as discharge is enable but not running right now
if self.set_reserve_enable and resetReserve:
Expand Down Expand Up @@ -794,7 +794,7 @@ def reset_inverter(self):
if self.set_export_window or (self.inverter_needs_reset_force in ["set_read_only", "mode"]):
inverter.adjust_discharge_rate(inverter.battery_rate_max_discharge * MINUTE_WATT)
inverter.adjust_force_export(False)
inverter.adjust_export_immediate(100)
inverter.adjust_export_immediate(int(EXPORT_LIMIT_IDLE))
self.isExporting = False

self.inverter_needs_reset = False
Expand Down
9 changes: 5 additions & 4 deletions apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import uuid
import traceback
from utils import calc_percent_limit
from const import EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE
import pytz as _pytz

from component_base import ComponentBase
Expand Down Expand Up @@ -363,15 +364,15 @@ def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_wind
# Convert export/discharge windows to plan entries
for i, window in enumerate(export_windows or []):
limit = export_limits[i] if i < len(export_limits or []) else 0
if limit >= 100:
if limit >= EXPORT_LIMIT_IDLE:
continue
target_soc = int(limit)
export_power_w = discharge_rate_w
# Freeze export (export limit == 99): hold SoC and export only surplus PV
# Freeze export (export limit == EXPORT_LIMIT_FREEZE): hold SoC and export only surplus PV
# rather than force-discharge. There is no freeze mode, so express it as a
# discharge entry with rate 0 and target = reserve. Match core's exact == 99
# discharge entry with rate 0 and target = reserve. Match core's exact ==
# check — a fractional limit (e.g. 99.5) is a normal export, not a freeze.
if limit == 99:
if limit == EXPORT_LIMIT_FREEZE:
target_soc = reserve_percent
export_power_w = 0
else:
Expand Down
4 changes: 2 additions & 2 deletions apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import requests
from datetime import datetime, timedelta
from config import INVERTER_DEF, SOLAX_SOLIS_MODES_NEW, SOLAX_SOLIS_MODES
from const import MINUTE_WATT, TIME_FORMAT, TIME_FORMAT_OCTOPUS, INVERTER_TEST, TIME_FORMAT_SECONDS, INVERTER_MAX_RETRY, INVERTER_MAX_RETRY_REST, INVERTER_REST_TIMEOUT
from const import MINUTE_WATT, TIME_FORMAT, TIME_FORMAT_OCTOPUS, INVERTER_TEST, TIME_FORMAT_SECONDS, INVERTER_MAX_RETRY, INVERTER_MAX_RETRY_REST, INVERTER_REST_TIMEOUT, EXPORT_LIMIT_IDLE
from utils import calc_percent_limit, compute_window_minutes, dp0, dp1, dp2, dp3, dp4, time_string_to_stamp, minute_data, minute_data_state, window2minutes

TIME_FORMAT_HMS = "%H:%M:%S"
Expand Down Expand Up @@ -1669,7 +1669,7 @@ def update_status(self, minutes_now, quiet=False):
if self.discharge_enable_time:
self.export_limits = [0.0 for i in range(len(self.export_window))]
else:
self.export_limits = [100.0 for i in range(len(self.export_window))]
self.export_limits = [EXPORT_LIMIT_IDLE for i in range(len(self.export_window))]

# Idle time?
# Get previous idle start and end
Expand Down
28 changes: 14 additions & 14 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import copy
from datetime import datetime, timedelta
from config import THIS_VERSION
from const import TIME_FORMAT, PREDICT_STEP
from const import TIME_FORMAT, PREDICT_STEP, EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE
from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state
from prediction import Prediction

Expand Down Expand Up @@ -762,7 +762,7 @@ def get_next_export_window(self, minutes_now):
export_window_n = -1
for minute in range(minutes_now, self.forecast_minutes + minutes_now, PREDICT_STEP):
export_window_n = self.in_charge_window(self.export_window_best, minute)
if export_window_n >= 0 and self.export_limits_best[export_window_n] == 100:
if export_window_n >= 0 and self.export_limits_best[export_window_n] == EXPORT_LIMIT_IDLE:
export_window_n = -1
if export_window_n >= 0:
break
Expand All @@ -774,7 +774,7 @@ def get_charge_export_text(self, minutes_now, charge_window_n, export_window_n):
"""
if export_window_n >= 0:
target_export = self.export_window_best[export_window_n].get("target", self.export_limits_best[export_window_n])
if self.export_limits_best[export_window_n] == 99:
if self.export_limits_best[export_window_n] == EXPORT_LIMIT_FREEZE:
text = "freeze exporting for the next {}".format(self.duration_string(self.export_window_best[export_window_n]["end"] - minutes_now)) # don't include target % for freeze exporting as (the 99%) is meaningless
else:
text = "force exporting to {}% for the next {}".format(target_export, self.duration_string(self.export_window_best[export_window_n]["end"] - minutes_now))
Expand Down Expand Up @@ -817,7 +817,7 @@ def get_export_type(self, export_limit, current=False):
"""
Get the export type for the given export limit
"""
if export_limit == 99:
if export_limit == EXPORT_LIMIT_FREEZE:
if current:
return "freeze exporting"
else:
Expand Down Expand Up @@ -933,7 +933,7 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p
charge_window_n = -1

export_window_n = self.in_charge_window(self.export_window_best, self.minutes_now)
if export_window_n >= 0 and self.export_limits_best[export_window_n] == 100:
if export_window_n >= 0 and self.export_limits_best[export_window_n] == EXPORT_LIMIT_IDLE:
export_window_n = -1

charge_export_text = self.get_charge_export_text(self.minutes_now, charge_window_n, export_window_n)
Expand Down Expand Up @@ -1088,7 +1088,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,

for try_minute in range(minute_start, minute_end, PREDICT_STEP):
export_window_n = self.in_charge_window(self.export_window_best, try_minute)
if export_window_n >= 0 and self.export_limits_best[export_window_n] == 100:
if export_window_n >= 0 and self.export_limits_best[export_window_n] == EXPORT_LIMIT_IDLE:
export_window_n = -1
if export_window_n >= 0:
break
Expand All @@ -1104,7 +1104,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
discharge_intersect = -1
for try_minute in range(minute_start, charge_end_minute, PREDICT_STEP):
discharge_intersect = self.in_charge_window(self.export_window_best, try_minute)
if discharge_intersect >= 0 and self.export_limits_best[discharge_intersect] == 100:
if discharge_intersect >= 0 and self.export_limits_best[discharge_intersect] == EXPORT_LIMIT_IDLE:
discharge_intersect = -1
if discharge_intersect >= 0:
break
Expand Down Expand Up @@ -1361,7 +1361,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
if "target" in self.export_window_best[export_window_n]:
target = self.export_window_best[export_window_n]["target"]

if limit == 99: # freeze exporting
if limit == EXPORT_LIMIT_FREEZE: # freeze exporting
if not had_state:
state = ""
if state:
Expand All @@ -1373,7 +1373,7 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
raw_state = "FrzExp"
show_limit = "" # suppress displaying the limit (of 99) when freeze exporting as its a meaningless number
reason_parts.append({"code": "freeze_export", "params": {}})
elif limit < 100:
elif limit < EXPORT_LIMIT_IDLE:
if not had_state:
state = ""
if state:
Expand Down Expand Up @@ -2169,23 +2169,23 @@ def publish_export_limit(self, export_window, export_limits, best):
export_limit_time_kw = {}

export_limit_soc = self.soc_max
export_limit_percent = 100
export_limit_percent = EXPORT_LIMIT_IDLE
export_limit_first = False
prev_limit = -1

for minute in range(0, self.forecast_minutes + self.minutes_now, 5):
window_n = self.in_charge_window(export_window, minute)
minute_timestamp = self.midnight_utc + timedelta(minutes=minute)
stamp = minute_timestamp.strftime(TIME_FORMAT)
if window_n >= 0 and (export_limits[window_n] < 100.0):
if window_n >= 0 and (export_limits[window_n] < EXPORT_LIMIT_IDLE):
soc_perc = export_limits[window_n]
soc_kw = (soc_perc * self.soc_max) / 100.0
if not export_limit_first:
export_limit_soc = soc_kw
export_limit_percent = export_limits[window_n]
export_limit_first = True
else:
soc_perc = 100
soc_perc = EXPORT_LIMIT_IDLE
soc_kw = self.soc_max
if prev_limit != soc_perc:
export_limit_time[stamp] = soc_perc
Expand Down Expand Up @@ -3231,7 +3231,7 @@ def calculate_yesterday(self):
self.export_window_best.append({"start": export_start_minute, "end": export_end_minute})
if "freeze" in export_during_slot:
# Assume freeze export
self.export_limits_best.append(99.0)
self.export_limits_best.append(EXPORT_LIMIT_FREEZE)
else:
soc_was = battery_soc_yesterday_array.get(export_end_minute, 0.0)
soc_percent = calc_percent_limit(soc_was, self.soc_max)
Expand Down Expand Up @@ -3540,7 +3540,7 @@ def window_as_text(self, windows, percents, ignore_min=False, ignore_max=False):

if ignore_min and percent == 0.0:
continue
if ignore_max and percent == 100.0:
if ignore_max and percent == EXPORT_LIMIT_IDLE:
continue

if not first_window:
Expand Down
Loading
Loading