diff --git a/harness/__init__.py b/harness/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/harness/plan_table.py b/harness/plan_table.py new file mode 100644 index 00000000..b654a7ee --- /dev/null +++ b/harness/plan_table.py @@ -0,0 +1,61 @@ +"""Render a Plan as a plain-text sense-check table. + +The DB-less inspection harness prints this so the modelled package — its SAP +band transition, cost, and each Plan Measure's attributed SAP / bill impact — +can be eyeballed and debugged by hand. Pure presentation: it reads a `Plan` +domain object and returns a string, computing nothing. +""" + +from __future__ import annotations + +from typing import Optional + +from datatypes.epc.domain.epc import Epc +from domain.modelling.plan import Plan + +_KG_PER_TONNE = 1000.0 + + +def _band(sap_continuous: float) -> str: + return Epc.from_sap_score(round(sap_continuous)).value + + +def _signed_gbp(value: Optional[float]) -> str: + return "n/a" if value is None else f"{value:+,.0f}" + + +def _money(value: Optional[float]) -> str: + if value is None: + return "n/a" + sign = "-" if value < 0 else "" + return f"{sign}£{abs(value):,.0f}" + + +def _signed_kwh(value: Optional[float]) -> str: + return "n/a" if value is None else f"{value:+,.0f}" + + +def format_plan_table(plan: Plan) -> str: + """A multi-line table: one package summary line, then one line per Plan + Measure (signed so positive is an improvement / a saving).""" + co2_tonnes_saved: float = plan.co2_savings_kg_per_yr / _KG_PER_TONNE + header = ( + f"Plan SAP {plan.baseline.sap_continuous:.1f} ({_band(plan.baseline.sap_continuous)})" + f" -> {plan.post_sap_continuous:.1f} ({plan.post_epc_rating.value})" + f" CO2 saved {co2_tonnes_saved:.2f} t/yr" + f" cost £{plan.cost_of_works:,.0f} (+£{plan.contingency_cost:,.0f} cont.)" + f" bill saved {_money(plan.energy_bill_savings)}/yr" + ) + columns = ( + f" {'measure':<30}{'SAP':>7}{'cost':>10}" + f"{'kWh/yr':>10}{'£/yr':>9}" + ) + rows = [ + f" {measure.measure_type:<30}" + f"{measure.impact.sap_points:>+7.1f}" + f"{('£' + format(measure.cost.total, ',.0f')):>10}" + f"{_signed_kwh(measure.kwh_savings):>10}" + f"{_signed_gbp(measure.energy_cost_savings):>9}" + for measure in plan.measures + ] + return "\n".join([header, columns, *rows]) diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/harness/test_plan_table.py b/tests/harness/test_plan_table.py new file mode 100644 index 00000000..42dc1b1a --- /dev/null +++ b/tests/harness/test_plan_table.py @@ -0,0 +1,65 @@ +"""The sense-check table the DB-less harness prints for a Plan.""" + +from __future__ import annotations + +from domain.modelling.plan import Plan, PlanMeasure +from domain.modelling.recommendation import Cost +from domain.modelling.scoring.package_scorer import Score +from domain.modelling.scoring.scoring import MeasureImpact +from harness.plan_table import format_plan_table + + +def _plan() -> Plan: + baseline = Score( + sap_continuous=57.4, co2_kg_per_yr=3000.0, primary_energy_kwh_per_yr=300.0 + ) + post = Score( + sap_continuous=61.2, co2_kg_per_yr=2100.0, primary_energy_kwh_per_yr=240.0 + ) + measures = ( + PlanMeasure( + measure_type="cavity_wall_insulation", + description="Cavity wall insulation", + cost=Cost(total=500.0, contingency_rate=0.1), + impact=MeasureImpact( + sap_points=3.1, + co2_savings_kg_per_yr=600.0, + energy_savings_kwh_per_yr=1200.0, + ), + kwh_savings=900.0, + energy_cost_savings=120.0, + ), + PlanMeasure( + measure_type="mechanical_ventilation", + description="Mechanical extract ventilation", + cost=Cost(total=900.0, contingency_rate=0.26), + impact=MeasureImpact( + sap_points=-1.3, + co2_savings_kg_per_yr=-50.0, + energy_savings_kwh_per_yr=-200.0, + ), + kwh_savings=-150.0, + energy_cost_savings=-30.0, + ), + ) + return Plan(measures=measures, baseline=baseline, post_retrofit=post) + + +def test_table_shows_package_transition_and_each_measure() -> None: + # Arrange + plan: Plan = _plan() + + # Act + table: str = format_plan_table(plan) + + # Assert — the package SAP transition (both bands resolve to D), and each + # measure's signed SAP contribution against its type. + assert "57.4" in table + assert "61.2" in table + assert "(D)" in table + assert "cavity_wall_insulation" in table + assert "+3.1" in table + assert "mechanical_ventilation" in table + assert "-1.3" in table + # The package cost of works (500 + 900) appears. + assert "1,400" in table diff --git a/tests/orchestration/test_first_run_without_database.py b/tests/orchestration/test_first_run_without_database.py index ea7b5a81..17f1a023 100644 --- a/tests/orchestration/test_first_run_without_database.py +++ b/tests/orchestration/test_first_run_without_database.py @@ -28,6 +28,7 @@ from repositories.fuel_rates.fuel_rates_static_file_repository import ( FuelRatesStaticFileRepository, ) from repositories.geospatial.geospatial_repository import GeospatialRepository +from harness.plan_table import format_plan_table from repositories.product.product_json_repository import ProductJsonRepository from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_000490 import ( build_epc as _build_uninsulated_cavity_and_floor_epc, @@ -147,5 +148,6 @@ def test_first_run_produces_a_multi_measure_plan_without_a_database() -> None: # Assert — a Plan was persisted in memory for (property 10, scenario 7), # with at least one Plan Measure and a post-retrofit SAP no worse than baseline. plan = plan_repo.saved[(10, 7)] + print("\n" + format_plan_table(plan)) # visible under `pytest -s` for sense-checking assert len(plan.measures) >= 1 assert plan.post_sap_continuous >= plan.baseline.sap_continuous