From 1a0e87e593d42fbef507a4bd654e856daf1070b8 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Jul 2026 23:08:26 +0000 Subject: [PATCH] =?UTF-8?q?Pivot=20a=20Property's=20measure=20cost=20onto?= =?UTF-8?q?=20its=20scenario-sheet=20column=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/scenario_export/__init__.py | 0 domain/scenario_export/scenario_sheet.py | 111 ++++++++++++++++++ tests/domain/scenario_export/__init__.py | 0 .../scenario_export/test_scenario_sheet.py | 67 +++++++++++ 4 files changed, 178 insertions(+) create mode 100644 domain/scenario_export/__init__.py create mode 100644 domain/scenario_export/scenario_sheet.py create mode 100644 tests/domain/scenario_export/__init__.py create mode 100644 tests/domain/scenario_export/test_scenario_sheet.py diff --git a/domain/scenario_export/__init__.py b/domain/scenario_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/domain/scenario_export/scenario_sheet.py b/domain/scenario_export/scenario_sheet.py new file mode 100644 index 000000000..9bdb5ff95 --- /dev/null +++ b/domain/scenario_export/scenario_sheet.py @@ -0,0 +1,111 @@ +"""The Scenario Export sheet shape (ADR-0065). + +Pure logic: given the persisted, override-resolved data for the Properties in one +Scenario, lay out the wide export sheet — property identity + Effective-EPC +descriptive fields, each measure's cost pivoted onto its own column against a +frozen column contract, savings summed per Property, and the Plan's post-works +figures. No I/O: the repository reads the rows and the infrastructure layer +renders the workbook; this module only shapes one sheet's rows. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Sequence + +# The measure cost columns always present on every sheet, in a stable order, so +# every scenario sheet in the workbook shares one column contract regardless of +# which measures a given selection happens to contain (ADR-0065). A Property that +# lacks a measure carries a blank in that column, never a missing column. +MEASURE_COLUMNS: tuple[str, ...] = ( + "suspended_floor_insulation", + "solid_floor_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "cavity_wall_insulation", + "loft_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "secondary_glazing", + "double_glazing", + "solar_pv", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "boiler_upgrade", + "gas_boiler_upgrade", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "low_energy_lighting", + "mechanical_ventilation", + "system_tune_up", + "system_tune_up_zoned", +) + + +@dataclass(frozen=True) +class ExportMeasure: + """One selected measure on a Property's default Plan for the Scenario: the + measure type (the pivot column), its installed cost, and the SAP / carbon / + energy / bill savings attributed to it. ``includes_battery`` distinguishes a + solar-with-battery Option so it pivots to its own column (ADR-0065).""" + + measure_type: str + estimated_cost: Optional[float] = None + sap_points: Optional[float] = None + co2_equivalent_savings: Optional[float] = None + kwh_savings: Optional[float] = None + energy_cost_savings: Optional[float] = None + includes_battery: bool = False + + +@dataclass(frozen=True) +class PropertyScenarioData: + """One Property's persisted data for one Scenario: identity, the + Effective-EPC descriptive fields (already override-resolved by the + repository), the default Plan's post-works figures, and the selected + measures. The input row the shaper turns into one wide sheet row.""" + + property_id: int + landlord_property_id: Optional[str] = None + uprn: Optional[str] = None + address: Optional[str] = None + postcode: Optional[str] = None + property_type: Optional[str] = None + walls: Optional[str] = None + roof: Optional[str] = None + floor: Optional[str] = None + windows: Optional[str] = None + heating: Optional[str] = None + hot_water: Optional[str] = None + lighting: Optional[str] = None + total_floor_area: Optional[float] = None + number_of_rooms: Optional[int] = None + lodgement_date: Optional[str] = None + is_expired: Optional[bool] = None + current_epc_rating: Optional[str] = None + current_sap_points: Optional[float] = None + post_sap_points: Optional[float] = None + post_epc_rating: Optional[str] = None + cost_of_works: Optional[float] = None + contingency_cost: Optional[float] = None + co2_savings: Optional[float] = None + energy_bill_savings: Optional[float] = None + energy_consumption_savings: Optional[float] = None + valuation_increase: Optional[float] = None + measures: Sequence[ExportMeasure] = () + + +@dataclass(frozen=True) +class ExportSheet: + """One laid-out scenario sheet: the ordered column contract and the rows, + each a column-keyed mapping ready for the workbook renderer.""" + + columns: tuple[str, ...] + rows: tuple[dict[str, Any], ...] + + +def shape_scenario_sheet( + properties: Sequence[PropertyScenarioData], +) -> ExportSheet: + """Shape one Scenario's Properties into a wide export sheet (ADR-0065).""" + raise NotImplementedError diff --git a/tests/domain/scenario_export/__init__.py b/tests/domain/scenario_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/domain/scenario_export/test_scenario_sheet.py b/tests/domain/scenario_export/test_scenario_sheet.py new file mode 100644 index 000000000..01b20f8ed --- /dev/null +++ b/tests/domain/scenario_export/test_scenario_sheet.py @@ -0,0 +1,67 @@ +"""The Scenario Export sheet shape (ADR-0065) — pure layout of one scenario +sheet: measure costs pivoted onto a frozen column contract, savings summed per +Property, and the Plan's post-works figures.""" + +from typing import Optional, Sequence + +from domain.scenario_export.scenario_sheet import ( + ExportMeasure, + PropertyScenarioData, + shape_scenario_sheet, +) + + +def _measure( + *, + measure_type: str = "loft_insulation", + estimated_cost: Optional[float] = 1000.0, + sap_points: Optional[float] = None, + co2_equivalent_savings: Optional[float] = None, + kwh_savings: Optional[float] = None, + energy_cost_savings: Optional[float] = None, + includes_battery: bool = False, +) -> ExportMeasure: + return ExportMeasure( + measure_type=measure_type, + estimated_cost=estimated_cost, + sap_points=sap_points, + co2_equivalent_savings=co2_equivalent_savings, + kwh_savings=kwh_savings, + energy_cost_savings=energy_cost_savings, + includes_battery=includes_battery, + ) + + +def _property( + *, + property_id: int = 1, + landlord_property_id: Optional[str] = "LP1", + measures: Sequence[ExportMeasure] = (), + **fields: object, +) -> PropertyScenarioData: + return PropertyScenarioData( + property_id=property_id, + landlord_property_id=landlord_property_id, + measures=measures, + **fields, # pyright: ignore[reportArgumentType] + ) + + +def test_a_measures_cost_lands_in_its_own_column_with_the_property_identity() -> None: + # arrange — one Property with one loft-insulation measure priced at £1200. + prop = _property( + property_id=42, + landlord_property_id="LP42", + measures=[_measure(measure_type="loft_insulation", estimated_cost=1200.0)], + ) + + # act + sheet = shape_scenario_sheet([prop]) + + # assert — one row carrying the Property identity and the cost in the + # loft_insulation column. + assert len(sheet.rows) == 1 + row = sheet.rows[0] + assert row["property_id"] == 42 + assert row["landlord_property_id"] == "LP42" + assert row["loft_insulation"] == 1200.0