diff --git a/apps/predbat/annual.py b/apps/predbat/annual.py index 8dbdce9f9..ffdae3fbd 100644 --- a/apps/predbat/annual.py +++ b/apps/predbat/annual.py @@ -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), } @@ -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): diff --git a/apps/predbat/tests/test_annual_cli.py b/apps/predbat/tests/test_annual_cli.py index 450814c80..855eadb18 100644 --- a/apps/predbat/tests/test_annual_cli.py +++ b/apps/predbat/tests/test_annual_cli.py @@ -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, diff --git a/apps/predbat/tests/test_web_annual.py b/apps/predbat/tests/test_web_annual.py index 9f51789b2..0fbe9dfdb 100644 --- a/apps/predbat/tests/test_web_annual.py +++ b/apps/predbat/tests/test_web_annual.py @@ -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, @@ -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 payback table") + if "Battery cycles" not in html: + print(" ERROR: expected the Battery cycles column header in the rendered HTML, got:\n{}".format(html)) + failed = True + if "3.00 (+1.00)" not in html: + print(" ERROR: expected the with Predbat battery cycles value (3.00 (+1.00)) in the rendered HTML, got:\n{}".format(html)) + failed = True + print("Test: the validated colourblind-safe palette is used, not the house trio") for colour in ["#0072B2", "#D55E00", "#009E73"]: if colour not in html: diff --git a/apps/predbat/web_annual.py b/apps/predbat/web_annual.py index 02b424e5d..2bb1a4f12 100644 --- a/apps/predbat/web_annual.py +++ b/apps/predbat/web_annual.py @@ -1289,7 +1289,12 @@ def render_results(self, results, runs, selected_id): text += "\n" for key in SCENARIO_ORDER: entry = scenarios.get(key, {}) - text += "\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 += "\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 += "
ScenarioCostImportExport
{}{}{} kWh{} kWh
{}{}{} kWh{} kWh
\n" savings = annual.get("savings", {}) or {} text += "

PV and battery save {} against no system.

\n".format(self._pounds(savings.get("pv_battery_vs_none_p", 0))) @@ -1365,7 +1370,8 @@ def _render_payback(self, results): text += "

No PV or battery is configured, so there is nothing to price a payback for.

\n" return text - text += "\n\n" + scenarios = annual.get("scenarios", {}) or {} + text += "
OptionCapitalSaving a yearPays back in
\n\n" for key, label in rows: row = payback.get(key) or {} if row.get("pays_back") and row.get("years") is not None: @@ -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 += " (after £{:,.0f}/year for Predbat)".format(row["predbat_annual_gbp"]) - text += "\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 += "\n".format(label, row.get("capital_gbp", 0), saving, years, cycles_display) text += "
OptionCapitalSaving a yearPays back inBattery cycles
{}£{:,.0f}{}{}
{}£{:,.0f}{}{}{}
\n" text += "

Simple payback: capital divided by the modelled annual saving. It ignores panel degradation, price inflation, battery replacement and finance costs.

\n" + text += "

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.

\n" + return text def _render_chart(self, results): @@ -1442,12 +1471,12 @@ def _render_month_table(self, results): energy" rather than "this scenario was never modelled for this month". """ text = "

By month

\n\n" - text += "\n" + text += "\n" for entry in results.get("months", []): name = calendar.month_abbr[entry["month"]] if entry.get("status") not in ("ok", "degraded"): reason = html.escape(str(entry.get("reason", "no result")), quote=True) - text += "\n".format(name, reason) + text += "\n".format(name, reason) continue suffix = " (degraded — {} sampled day(s) failed)".format(len(entry.get("failed_days", []))) if entry.get("status") == "degraded" else "" synthesised = entry.get("rates_synthesised") or [] @@ -1463,7 +1492,7 @@ def _render_month_table(self, results): if key not in month_scenarios: continue scenario = month_scenarios[key] - text += "\n".format( + text += "\n".format( name if first_row else "", suffix if first_row else "", SCENARIO_LABELS[key], @@ -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 += "
MonthScenarioCostImportExportPVBattery
MonthScenarioCostImportExportPVBatteryBattery cycles
{}unavailable — {}
{}unavailable — {}
{}{}{}{}{} kWh{} kWh{} kWh{} kWh
{}{}{}{}{} kWh{} kWh{} kWh{} kWh{}
\n"