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
3 changes: 3 additions & 0 deletions apps/predbat/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2
DYNAMIC_LOAD_CAR_START_MINUTES = 3
DYNAMIC_LOAD_CAR_LOAD_MINUTES = 5
# The load test reads "low" - no car can be charging - under this fraction of car_charging_threshold. The
# margin below the threshold allows for a load sensor that under-reads while the battery is charging (GH#5461)
DYNAMIC_LOAD_CAR_LOW_FACTOR = 0.85
# A car_charging_now reading only confirms the half hour it is in (keeping that half hour cheap once the car
# stops, GH#5316) from this many minutes into it: the sensor can lag a car that stopped just before the boundary
DYNAMIC_LOAD_CAR_CONFIRM_MINUTES = 2
Expand Down
69 changes: 65 additions & 4 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
CLOUD_WINDOW_MINUTES,
DYNAMIC_LOAD_CAR_CONFIRM_MINUTES,
DYNAMIC_LOAD_CAR_LOAD_MINUTES,
DYNAMIC_LOAD_CAR_LOW_FACTOR,
DYNAMIC_LOAD_CAR_SENSOR_MINUTES,
DYNAMIC_LOAD_CAR_START_MINUTES,
PREDICT_STEP,
Expand Down Expand Up @@ -60,6 +61,7 @@
is_entity_id,
round_out_to_period,
)
from config import INVERTER_DEF
from prediction import Prediction
from prediction_kernel import kernel_status_summary, set_window_start
from predbat_metrics import metrics
Expand Down Expand Up @@ -367,20 +369,63 @@ def car_charging_threshold_kw(self):
def dynamic_load_classify(self):
"""
Classify load_last_period as "high" (above the battery's discharge rate), "low" (below both the
battery rate and the car charging threshold, so no car can be charging) or "baseline".
battery rate and the car's low-load cut-off, so no car can be charging) or "baseline".

Shared by dynamic_load(), which runs after the inverter fetch, and dynamic_load_car_check(), which
runs before it and so uses the previous cycle's battery_rate_max_discharge - it barely moves
between cycles, and the car grace period spans several of them.
"""
threshold_battery = self.battery_rate_max_discharge * MINUTE_WATT / 1000
threshold_car = self.car_charging_threshold_kw()
if self.load_last_period >= threshold_battery:
return "high"
if (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < (threshold_car * 0.9)):
if (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < self.dynamic_load_car_low_kw()):
return "low"
return "baseline"

def dynamic_load_car_low_kw(self):
"""
The load in kW under which no car can be charging: DYNAMIC_LOAD_CAR_LOW_FACTOR of car_charging_threshold.
"""
return self.car_charging_threshold_kw() * DYNAMIC_LOAD_CAR_LOW_FACTOR

def dynamic_load_car_live_load(self):
"""
The live house load from the load_power sensors, as (configured, kW): the total across inverters,
or None when any of them has no reading ("unknown", "unavailable", missing from HA) - part of the
load is not the load. configured is False when load_power is not set at all.

Summed as Inverter.update_status() sums it: load_power plus the inverter type's extra load_power_N
sensors (Solis reports the bypass load on load_power_1), where one left out of apps.yaml counts as
nothing, then load_power_invert.

Read from the sensors rather than the inverters: dynamic_load_car_check() runs before
fetch_inverter_data(), where their load_power is still the previous cycle's.
"""
entities = self.args.get("load_power", None)
if not entities:
return False, None
if not isinstance(entities, list):
entities = [entities]
total = 0.0
for inverter_n in range(len(entities)):
inverter_type = self.get_arg("inverter_type", "GE", indirect=False, index=inverter_n)
num_load_entities = INVERTER_DEF.get(inverter_type, {}).get("num_load_entities", 1)
power = 0.0
for arg in ["load_power"] + ["load_power_{}".format(extra_n) for extra_n in range(1, num_load_entities)]:
value = self.args.get(arg, None)
if isinstance(value, list):
value = value[inverter_n] if inverter_n < len(value) else None
if not value:
continue
try:
power += float(self.resolve_arg(arg, value, default=None, quiet=True, required_unit="W"))
except (ValueError, TypeError):
return True, None
if self.get_arg("load_power_invert", default=False, index=inverter_n):
power = -power
total += power
return True, total / 1000.0

def dynamic_load_car_evidence(self, car_n, minute, now, dispatch_start, dispatch_end):
"""
Whether car_n, inside a dispatch running dispatch_start to dispatch_end, is not charging at minute
Expand All @@ -399,6 +444,11 @@ def dynamic_load_car_evidence(self, car_n, minute, now, dispatch_start, dispatch
averages the PREDICT_STEP minutes up to minutes_now, so it only counts when that whole window lies
inside the dispatch, and the grace is timed on that 5 minute grid. It is skipped just after
midnight, when the load_today sensor resets, and without load history.

A low load_last_period alone is not enough where load_power is set: it lags a car that is still
ramping up, and a cloud inverter's late readings, so a car at full power can read low (GH#5461).
The live load has to be low too. Live load that is not low, or has no reading, makes a low window
no evidence either way - it neither cancels the car nor resumes it.
"""
if car_n in self.dynamic_load_car_sensors:
charging = self.car_charging_now_reading(car_n)
Expand All @@ -417,7 +467,18 @@ def dynamic_load_car_evidence(self, car_n, minute, now, dispatch_start, dispatch
return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now
if self.minutes_now - PREDICT_STEP < dispatch_start or self.minutes_now > dispatch_end:
return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now
return self.dynamic_load_classify() == "low", DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now
if self.dynamic_load_classify() != "low":
return False, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now
live_configured, live_load = self.dynamic_load_car_live_load()
live_low = not live_configured or (live_load is not None and live_load < self.dynamic_load_car_low_kw())
self.log(
"Octopus Intelligent: car {} load last period {:.2f}kW is low (under {:.2f}kW), live load {} - {}".format(
car_n, self.load_last_period, self.dynamic_load_car_low_kw(), "{:.2f}kW".format(live_load) if live_load is not None else ("unavailable" if live_configured else "not configured"), "not charging" if live_low else "no evidence"
)
)
if not live_low:
return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now
return True, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now
return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES, grid_now

def car_charging_now_reading(self, car_n):
Expand Down
2 changes: 1 addition & 1 deletion apps/predbat/tests/test_dynamic_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def test_dynamic_load_car_slot_cancellation(my_predbat):
# test_dynamic_load_car.py), so it can withhold an Intelligent dispatch's cheap rate too
print("Test 2: Low load case leaves the car slots to dynamic_load_car_check()")
my_predbat.load_last_status = "baseline" # Reset status
my_predbat.load_last_period = 2.0 # 2kW - low load (< battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000 and < car_charging_threshold * 0.9)
my_predbat.load_last_period = 2.0 # 2kW - low load (< battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000 and < car_charging_threshold * 0.85)

my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 25, "kwh": 10.0}]
my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 45, "kwh": 8.0}]
Expand Down
Loading
Loading