Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion apps/predbat/annual.py
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,9 @@ def _billed_result(predbat, end_record, pv_step):
"export_kwh": export_kwh,
"pv_generated_kwh": pv_generated,
"battery_throughput_kwh": battery_cycle,
# One full cycle is a full discharge plus a full charge, so throughput is
# divided by 2 * usable capacity to count equivalent full cycles.
"battery_cycles": round((battery_cycle / (2 * predbat.soc_max)) if predbat.soc_max else 0.0, 4),
}


Expand Down Expand Up @@ -1247,7 +1250,7 @@ def _run_scenarios(predbat, config, weather, tariff, load_source, day, midnight_

SCENARIO_KEYS = ["no_pvbat", "pv_only", "without_predbat", "with_predbat"]

SCENARIO_FIELDS = ["cost_p", "import_kwh", "export_kwh", "pv_generated_kwh", "battery_throughput_kwh"]
SCENARIO_FIELDS = ["cost_p", "import_kwh", "export_kwh", "pv_generated_kwh", "battery_throughput_kwh", "battery_cycles"]


def _blend_results(with_car, without_car, fraction):
Expand Down
30 changes: 30 additions & 0 deletions apps/predbat/annual_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ def format_table(results, currency="p"):
for key in SCENARIO_KEYS:
total_row += "{:>20}".format(_format_pence(annual["scenarios"][key]["cost_p"], currency))
lines.append(total_row)
with_cycles = annual["scenarios"].get("with_predbat", {}).get("battery_cycles")
without_cycles = annual["scenarios"].get("without_predbat", {}).get("battery_cycles")
if with_cycles is not None or without_cycles is not None:
if with_cycles is not None and without_cycles is not None:
diff = with_cycles - without_cycles
lines.append("Estimated equivalent battery cycles: Without Predbat: {:.2f}; With Predbat: {:.2f} ({:+.2f})".format(without_cycles, with_cycles, diff))
elif with_cycles is not None:
lines.append("Estimated equivalent battery cycles with Predbat: {:.2f}".format(with_cycles))
else:
lines.append("Estimated equivalent battery cycles without Predbat: {:.2f}".format(without_cycles))
else:
lines.append("No annual total available: no month produced a usable result.")

Expand Down Expand Up @@ -198,6 +208,26 @@ def main(argv=None):
sys.stderr.write("Config error: {}\n".format(error))
return 2

# Augment JSON results with battery cycle info in payback rows so --out contains the same data
try:
annual = results.get("annual", {}) or {}
scenarios = annual.get("scenarios", {}) or {}
payback = annual.get("payback", {}) or {}
if payback:
# map cycles into payback rows where appropriate
if "pv_battery" in payback:
payback["pv_battery"]["battery_cycles"] = scenarios.get("without_predbat", {}).get("battery_cycles")
if "pv_battery_predbat" in payback:
payback["pv_battery_predbat"]["battery_cycles"] = scenarios.get("with_predbat", {}).get("battery_cycles")
# also provide an explicit change value when both present
base = payback.get("pv_battery", {}).get("battery_cycles")
curr = payback["pv_battery_predbat"].get("battery_cycles")
if base is not None and curr is not None:
payback["pv_battery_predbat"]["battery_cycles_change"] = curr - base
except Exception:
# Augmentation is optional and must not break the run; ignore any failure
pass
Comment thread
Easen marked this conversation as resolved.
Outdated

exit_code = 0
if args.out:
try:
Expand Down
13 changes: 9 additions & 4 deletions apps/predbat/tests/test_annual_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,10 @@ async def run(self, progress=None):
def sample_results():
"""Return a small results document covering an ok month and an unavailable one."""
scenarios = {
"no_pvbat": {"cost_p": 12000.0, "import_kwh": 400.0, "export_kwh": 0.0, "pv_generated_kwh": 0.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 0.0},
"pv_only": {"cost_p": 10000.0, "import_kwh": 350.0, "export_kwh": 60.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 180.0},
"without_predbat": {"cost_p": 8000.0, "import_kwh": 300.0, "export_kwh": 20.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 90.0, "export_credit_p_estimate": 300.0},
"with_predbat": {"cost_p": 6000.0, "import_kwh": 280.0, "export_kwh": 45.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 140.0, "export_credit_p_estimate": 675.0},
"no_pvbat": {"cost_p": 12000.0, "import_kwh": 400.0, "export_kwh": 0.0, "pv_generated_kwh": 0.0, "battery_throughput_kwh": 0.0, "battery_cycles": 0.0, "export_credit_p_estimate": 0.0},
"pv_only": {"cost_p": 10000.0, "import_kwh": 350.0, "export_kwh": 60.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 0.0, "battery_cycles": 0.0, "export_credit_p_estimate": 180.0},
"without_predbat": {"cost_p": 8000.0, "import_kwh": 300.0, "export_kwh": 20.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 90.0, "battery_cycles": 2.0, "export_credit_p_estimate": 300.0},
"with_predbat": {"cost_p": 6000.0, "import_kwh": 280.0, "export_kwh": 45.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 140.0, "battery_cycles": 3.0, "export_credit_p_estimate": 675.0},
}
return {
"year": 2025,
Expand Down Expand Up @@ -186,6 +186,11 @@ def test_annual_cli(my_predbat):
print(" ERROR: the export credit line must warn it is already counted inside cost, to stop it being double-counted, got:\n{}".format(table))
failed = True

print("Test: the battery cycle estimate is shown")
if "equivalent battery cycles" not in table.lower():
print(" ERROR: the table should report the equivalent battery cycles estimate, got:\n{}".format(table))
failed = True

print("Test: a degraded month (some sampled days failed) is costed and included, not treated as unavailable")
degraded_table = format_table(sample_results_with_degraded_month())
if "unavailable" in degraded_table.lower():
Expand Down
16 changes: 12 additions & 4 deletions apps/predbat/tests/test_web_annual.py
Original file line number Diff line number Diff line change
Expand Up @@ -1421,10 +1421,10 @@ def test_web_annual_store_failure_surfaces(my_predbat):
def sample_run_results():
"""Return a results document covering an ok, a degraded and an unavailable month."""
scenarios = {
"no_pvbat": {"cost_p": 18000.0, "import_kwh": 400.0, "export_kwh": 0.0, "pv_generated_kwh": 0.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 0.0},
"pv_only": {"cost_p": 13000.0, "import_kwh": 340.0, "export_kwh": 70.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 0.0, "export_credit_p_estimate": 210.0},
"without_predbat": {"cost_p": 9000.0, "import_kwh": 300.0, "export_kwh": 20.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 90.0, "export_credit_p_estimate": 300.0},
"with_predbat": {"cost_p": 6600.0, "import_kwh": 280.0, "export_kwh": 145.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 140.0, "export_credit_p_estimate": 675.0},
"no_pvbat": {"cost_p": 18000.0, "import_kwh": 400.0, "export_kwh": 0.0, "pv_generated_kwh": 0.0, "battery_throughput_kwh": 0.0, "battery_cycles": 0.0, "export_credit_p_estimate": 0.0},
"pv_only": {"cost_p": 13000.0, "import_kwh": 340.0, "export_kwh": 70.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 0.0, "battery_cycles": 0.0, "export_credit_p_estimate": 210.0},
"without_predbat": {"cost_p": 9000.0, "import_kwh": 300.0, "export_kwh": 20.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 90.0, "battery_cycles": 2.0, "export_credit_p_estimate": 300.0},
"with_predbat": {"cost_p": 6600.0, "import_kwh": 280.0, "export_kwh": 145.0, "pv_generated_kwh": 120.0, "battery_throughput_kwh": 140.0, "battery_cycles": 3.0, "export_credit_p_estimate": 675.0},
}
return {
"year": 2025,
Expand Down Expand Up @@ -1488,6 +1488,14 @@ def test_web_annual_results(my_predbat):
print(" ERROR: the Predbat saving (2400p = £24.00) should be shown")
failed = True

print("Test: the battery cycle metric appears in the annual results table")
if "cycles" not in html.lower():
print(" ERROR: expected the battery cycles column label or value in the rendered HTML, got:\n{}".format(html))
failed = True
if "3.00" not in html:
print(" ERROR: expected the with Predbat battery cycles value (3.00) in the rendered HTML, got:\n{}".format(html))
failed = True
Comment thread
Easen marked this conversation as resolved.
Outdated

print("Test: the validated colourblind-safe palette is used, not the house trio")
for colour in ["#0072B2", "#D55E00", "#009E73"]:
if colour not in html:
Expand Down
40 changes: 35 additions & 5 deletions apps/predbat/web_annual.py
Original file line number Diff line number Diff line change
Expand Up @@ -1289,7 +1289,12 @@ def render_results(self, results, runs, selected_id):
text += "<table class='comparison-table'><tr><th>Scenario</th><th>Cost</th><th>Import</th><th>Export</th></tr>\n"
for key in SCENARIO_ORDER:
entry = scenarios.get(key, {})
text += "<tr><td>{}</td><td>{}</td><td>{} kWh</td><td>{} kWh</td></tr>\n".format(SCENARIO_LABELS[key], self._pounds(entry.get("cost_p")), round(entry.get("import_kwh", 0), 1), round(entry.get("export_kwh", 0), 1))
text += "<tr><td>{}</td><td>{}</td><td>{} kWh</td><td>{} kWh</td></tr>\n".format(
SCENARIO_LABELS[key],
self._pounds(entry.get("cost_p")),
round(entry.get("import_kwh", 0), 1),
round(entry.get("export_kwh", 0), 1),
)
text += "</table>\n"
savings = annual.get("savings", {}) or {}
text += "<p><strong>PV and battery save {}</strong> against no system.</p>\n".format(self._pounds(savings.get("pv_battery_vs_none_p", 0)))
Expand Down Expand Up @@ -1365,7 +1370,8 @@ def _render_payback(self, results):
text += "<p class='annual-unavailable'>No PV or battery is configured, so there is nothing to price a payback for.</p>\n"
return text

text += "<table class='comparison-table'>\n<tr><th>Option</th><th>Capital</th><th>Saving a year</th><th>Pays back in</th></tr>\n"
scenarios = annual.get("scenarios", {}) or {}
text += "<table class='comparison-table'>\n<tr><th>Option</th><th>Capital</th><th>Saving a year</th><th>Pays back in</th><th>Battery cycles</th></tr>\n"
for key, label in rows:
row = payback.get(key) or {}
if row.get("pays_back") and row.get("years") is not None:
Expand All @@ -1375,9 +1381,32 @@ def _render_payback(self, results):
saving = "£{:,.0f}".format(row.get("annual_saving_gbp", 0))
if row.get("predbat_annual_gbp"):
saving += " <span class='annual-note'>(after £{:,.0f}/year for Predbat)</span>".format(row["predbat_annual_gbp"])
text += "<tr><td>{}</td><td>£{:,.0f}</td><td>{}</td><td>{}</td></tr>\n".format(label, row.get("capital_gbp", 0), saving, years)

# Map payback rows to scenario keys for cycles: pv_battery -> without_predbat, pv_battery_predbat -> with_predbat
cycles = None
if key == "pv_battery":
cycles = scenarios.get("without_predbat", {}).get("battery_cycles")
elif key == "pv_battery_predbat":
cycles = scenarios.get("with_predbat", {}).get("battery_cycles")

if cycles is None:
cycles_display = "—"
else:
# For the Predbat row, show the change relative to the non-Predbat case
if key == "pv_battery_predbat":
base = scenarios.get("without_predbat", {}).get("battery_cycles")
if base is not None:
diff = cycles - base
cycles_display = "{:.2f} ({:+.2f})".format(cycles, diff)
else:
cycles_display = "{:.2f}".format(cycles)
else:
cycles_display = "{:.2f}".format(cycles)
text += "<tr><td>{}</td><td>£{:,.0f}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n".format(label, row.get("capital_gbp", 0), saving, years, cycles_display)
text += "</table>\n"
text += "<p class='annual-note'>Simple payback: capital divided by the modelled annual saving. It ignores panel degradation, price inflation, battery replacement and finance costs.</p>\n"
text += "<p class='annual-note'>Predbat uses your battery to store cheap off-peak electricity and then sell it during peak times, which will increase the number of battery cycles. This could reduce the lifespan of your batteries, so check your battery manufacturer's guidelines for battery lifespan.</p>\n"

return text

def _render_chart(self, results):
Expand Down Expand Up @@ -1442,7 +1471,7 @@ def _render_month_table(self, results):
energy" rather than "this scenario was never modelled for this month".
"""
text = "<h2>By month</h2>\n<table class='comparison-table'>\n"
text += "<tr><th>Month</th><th>Scenario</th><th>Cost</th><th>Import</th><th>Export</th><th>PV</th><th>Battery</th></tr>\n"
text += "<tr><th>Month</th><th>Scenario</th><th>Cost</th><th>Import</th><th>Export</th><th>PV</th><th>Battery</th><th>Battery cycles</th></tr>\n"
Comment thread
Easen marked this conversation as resolved.
for entry in results.get("months", []):
name = calendar.month_abbr[entry["month"]]
if entry.get("status") not in ("ok", "degraded"):
Expand All @@ -1463,7 +1492,7 @@ def _render_month_table(self, results):
if key not in month_scenarios:
continue
scenario = month_scenarios[key]
text += "<tr><td>{}{}</td><td>{}</td><td>{}</td><td>{} kWh</td><td>{} kWh</td><td>{} kWh</td><td>{} kWh</td></tr>\n".format(
text += "<tr><td>{}{}</td><td>{}</td><td>{}</td><td>{} kWh</td><td>{} kWh</td><td>{} kWh</td><td>{} kWh</td><td>{}</td></tr>\n".format(
name if first_row else "",
suffix if first_row else "",
SCENARIO_LABELS[key],
Expand All @@ -1472,6 +1501,7 @@ def _render_month_table(self, results):
round(scenario.get("export_kwh", 0), 1),
round(scenario.get("pv_generated_kwh", 0), 1),
round(scenario.get("battery_throughput_kwh", 0), 1),
round(scenario.get("battery_cycles", 0), 2),
)
first_row = False
text += "</table>\n"
Expand Down
Loading