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
48 changes: 42 additions & 6 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
import copy
from datetime import datetime, timedelta
from config import THIS_VERSION
from const import TIME_FORMAT, PREDICT_STEP
from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state
from const import TIME_FORMAT, PREDICT_STEP, MINUTE_WATT
from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate
from prediction import Prediction

# Per-slot plan "why" reason templates. Keyed by a stable reason code, each template is
Expand All @@ -40,10 +40,10 @@
"demand_before_export_steady": "Until {split_time}, the battery level is expected to stay steady.",
"freeze_charge": "Freeze charging — the battery holds at the current level rather than charging further this slot (import rate {rate}p/kWh vs. the calculated {threshold}p/kWh threshold).",
"hold_charge_at_target": "Holding — the battery is already predicted to be at or above the {target_percent}% target for this window without charging further.",
"charge_low_rate": "Charging up to {target_percent}% at the import rate for this slot of ({rate}p/kWh).",
"charge_low_rate": "Charging up to {target_percent}% at {rate_kw}kW at the import rate for this slot of ({rate}p/kWh).",
"freeze_export": "Freezing export — solar surplus passes straight to the grid, but it's not worth discharging the battery to sell more this slot.",
"hold_export_unreachable": "Export window active but not triggered — the battery isn't predicted to reach the {target_percent}% level needed to export this slot.",
"export_high_rate": "Exporting down to {target_percent}% at the export rate of ({rate}p/kWh) using stored energy back to the grid.",
"export_high_rate": "Exporting down to {target_percent}% at {rate_kw}kW at the export rate of ({rate}p/kWh) using stored energy back to the grid.",
"manual_override_charge": "You manually set this slot to charge.",
"manual_override_freeze_charge": "You manually set this slot to freeze charging.",
"manual_override_export": "You manually set this slot to export.",
Expand Down Expand Up @@ -997,6 +997,39 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p

return sentence

def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step):
"""
Work out the actual planned charge rate (kW) for a charge_window_best slot. Unlike export,
which stores a fixed reduced rate in the fractional part of export_limits_best, low power
charging (set_charge_low_power) throttles the rate dynamically minute-by-minute, so it has
to be recomputed the same way the prediction engine works it out (find_charge_rate()).
"""
window = self.charge_window_best[charge_window_n]
soc = self.predict_soc_best.get(minute_relative_start, self.soc_kw)
pv_window_kwh = 0.0
if self.set_charge_low_power:
window_end_rel = min(window["end"] - self.minutes_now, self.forecast_minutes)
pv_window_kwh = sum(pv_forecast_minute_step.get(m, 0.0) for m in range(minute_relative_start, window_end_rel, PREDICT_STEP))
_, charge_rate_now_curve = find_charge_rate(
minute_start,
soc,
window,
self.charge_limit_best[charge_window_n],
self.battery_rate_max_charge,
self.soc_max,
self.battery_charge_power_curve,
self.set_charge_low_power,
self.charge_low_power_margin,
self.battery_rate_min,
self.battery_rate_max_scaling,
self.battery_loss,
None,
self.battery_temperature,
self.battery_temperature_charge_curve,
pv_window_kwh=pv_window_kwh,
)
return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0)

def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, load_minutes_step, load_minutes_step10, end_record, publish=True, prediction=None):
"""
Publish the current plan in HTML format
Expand Down Expand Up @@ -1318,7 +1351,8 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
state = "Chrg↗"
state_color = "#3AEE85"
raw_state = "Chrg"
reason_parts.append({"code": "charge_low_rate", "params": {"target_percent": limit_percent, "rate": rate_text_import}})
rate_kw = self.get_charge_rate_kw(charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step)
reason_parts.append({"code": "charge_low_rate", "params": {"target_percent": limit_percent, "rate": rate_text_import, "rate_kw": "{:.2f}".format(rate_kw)}})

if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times:
state += " ⅎ"
Expand Down Expand Up @@ -1388,7 +1422,9 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10,
else:
state += "Exp↘"
raw_state = "Exp"
reason_parts.append({"code": "export_high_rate", "params": {"target_percent": dp2(target), "rate": rate_text_export}})
export_rate_adjust = 1 - (limit - int(limit))
rate_kw = dp2(self.battery_rate_max_export * export_rate_adjust * MINUTE_WATT / 1000.0)
reason_parts.append({"code": "export_high_rate", "params": {"target_percent": dp2(target), "rate": rate_text_export, "rate_kw": "{:.2f}".format(rate_kw)}})
show_limit = str(dp2(target))
raw_state_target = str(dp2(target))

Expand Down
50 changes: 46 additions & 4 deletions apps/predbat/tests/test_plan_why_reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,14 @@ def render():
_, raw_plan = render()
templates = raw_plan["reason_templates"]
row = _get_row(raw_plan, minutes_now)
expected_charge_rate_kw = "{:.2f}".format(my_predbat.battery_rate_max_charge * 60)
if row is None or _codes(row) != ["charge_low_rate"]:
print("ERROR: Chrg reasons unexpected: {}".format(row and _codes(row)))
failed = True
elif row["reasons"][0]["params"] != {"target_percent": 80, "rate": "{:.2f}".format(row["import_rate"])}:
elif row["reasons"][0]["params"] != {"target_percent": 80, "rate": "{:.2f}".format(row["import_rate"]), "rate_kw": expected_charge_rate_kw}:
print("ERROR: Chrg params unexpected: {}".format(row["reasons"][0]["params"]))
failed = True
elif "Charging up to 80" not in _render(row, templates):
elif "Charging up to 80" not in _render(row, templates) or "{}kW".format(expected_charge_rate_kw) not in _render(row, templates):
print("ERROR: Chrg rendered text unexpected: {}".format(_render(row, templates)))
failed = True

Expand Down Expand Up @@ -188,6 +189,32 @@ def render():
failed = True
my_predbat.manual_charge_times = []

# --- Test 4b: Chrg low power (set_charge_low_power throttles the rate below max, computed
# dynamically via find_charge_rate() rather than a fixed fraction like export's snail encoding) ---
print("Test Chrg low power reason shows the throttled rate_kw, not the nameplate max")
low_power_window = [{"start": minutes_now, "end": minutes_now + 60, "average": 10.0}]
my_predbat.charge_window_best = low_power_window
my_predbat.charge_limit_best = [8.0] # small gap above the 7.9 current SoC - easily reached even throttled
my_predbat.predict_soc_best = _flat_soc(my_predbat, 7.9)
my_predbat.set_charge_low_power = True
my_predbat.charge_low_power_margin = 10
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
max_rate_kw = my_predbat.battery_rate_max_charge * 60
rate_kw = float(row["reasons"][0]["params"]["rate_kw"]) if row is not None and _codes(row) == ["charge_low_rate"] else None
if row is None or _codes(row) != ["charge_low_rate"]:
print("ERROR: Chrg low power reasons unexpected: {}".format(row and _codes(row)))
failed = True
elif rate_kw is None or rate_kw >= max_rate_kw:
print("ERROR: Chrg low power rate_kw not throttled below the {}kW max: got {}".format(max_rate_kw, rate_kw))
failed = True
elif "{:.2f}kW".format(rate_kw) not in _render(row, templates):
print("ERROR: Chrg low power rendered text missing the throttled rate: {}".format(_render(row, templates)))
failed = True
my_predbat.set_charge_low_power = False
my_predbat.charge_window_best = window
my_predbat.charge_limit_best = [8.0]

# --- Test 5: Exp ---
print("Test Exp reason")
my_predbat.charge_window_best = []
Expand All @@ -197,16 +224,31 @@ def render():
my_predbat.predict_soc_best = _flat_soc(my_predbat, 9.0) # 90%, well above the 50% target
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
expected_export_rate_kw = "{:.2f}".format(my_predbat.battery_rate_max_export * 60)
if row is None or _codes(row) != ["export_high_rate"]:
print("ERROR: Exp reasons unexpected: {}".format(row and _codes(row)))
failed = True
elif row["reasons"][0]["params"] != {"target_percent": 50.0, "rate": "{:.2f}".format(row["export_rate"])}:
elif row["reasons"][0]["params"] != {"target_percent": 50.0, "rate": "{:.2f}".format(row["export_rate"]), "rate_kw": expected_export_rate_kw}:
print("ERROR: Exp params unexpected: {}".format(row["reasons"][0]["params"]))
failed = True
elif "Exporting down to" not in _render(row, templates):
elif "Exporting down to" not in _render(row, templates) or "{}kW".format(expected_export_rate_kw) not in _render(row, templates):
print("ERROR: Exp rendered text unexpected: {}".format(_render(row, templates)))
failed = True

# --- Test 5b: Exp slow (fractional limit -> reduced rate, the snail-symbol encoding) ---
print("Test Exp slow reason shows the reduced rate_kw, not the nameplate max")
my_predbat.export_limits_best = [50.3] # limit.tens_of_percentage_rate_reduction -> 70% of max rate
_, raw_plan = render()
row = _get_row(raw_plan, minutes_now)
expected_slow_export_rate_kw = "{:.2f}".format(my_predbat.battery_rate_max_export * 60 * 0.7)
if row is None or _codes(row) != ["export_high_rate"]:
print("ERROR: Exp slow reasons unexpected: {}".format(row and _codes(row)))
failed = True
elif row["reasons"][0]["params"].get("rate_kw") != expected_slow_export_rate_kw:
print("ERROR: Exp slow rate_kw unexpected: expected {}, got {}".format(expected_slow_export_rate_kw, row["reasons"][0]["params"].get("rate_kw")))
failed = True
my_predbat.export_limits_best = [50.0]

# --- Test 6: HoldExp ---
print("Test HoldExp reason")
my_predbat.export_limits_best = [95.0] # 95% target, unreachable this window
Expand Down
Loading