Model/domain/scenario_export/scenario_sheet.py
Khalim Conn-Kowlessar b52b38e6f3 Roll up each Property's SAP points and savings across its measures 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:11:36 +00:00

152 lines
5.7 KiB
Python

"""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",
)
# Property identity columns, leading every sheet before the measure columns.
_ID_COLUMNS: tuple[str, ...] = ("property_id", "landlord_property_id")
# Per-Property roll-ups of the selected measures' attributed impact, trailing the
# measure columns (ADR-0065).
_SAVINGS_COLUMNS: tuple[str, ...] = (
"sap_points",
"co2_equivalent_savings",
"kwh_savings",
"energy_cost_savings",
)
def _sum(values: Sequence[Optional[float]]) -> float:
"""Sum optional numbers, treating an absent contribution as zero."""
return sum(value or 0.0 for value in values)
@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)."""
columns: tuple[str, ...] = _ID_COLUMNS + MEASURE_COLUMNS + _SAVINGS_COLUMNS
rows: list[dict[str, Any]] = []
for prop in properties:
row: dict[str, Any] = {
"property_id": prop.property_id,
"landlord_property_id": prop.landlord_property_id,
}
# Every measure column is present, blank unless this Property has it, so
# all scenario sheets share one contract (ADR-0065).
for column in MEASURE_COLUMNS:
row[column] = ""
for measure in prop.measures:
if measure.measure_type in MEASURE_COLUMNS:
row[measure.measure_type] = measure.estimated_cost
# Roll the selected measures' attributed impact up to the Property.
row["sap_points"] = _sum([m.sap_points for m in prop.measures])
row["co2_equivalent_savings"] = _sum(
[m.co2_equivalent_savings for m in prop.measures]
)
row["kwh_savings"] = _sum([m.kwh_savings for m in prop.measures])
row["energy_cost_savings"] = _sum(
[m.energy_cost_savings for m in prop.measures]
)
rows.append(row)
return ExportSheet(columns=columns, rows=tuple(rows))