From 46bca47365f9d96e4525caf9af854204a095dd2a Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 12:10:27 +0000 Subject: [PATCH] feat(modelling): Products.solar_bundle_cost + committed solar rate sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 7 of the Solar PV Recommendation Generator (ADR-0026). Adds the composite per-dwelling Solar PV cost on the Products collection (ADR-0025 pattern): pv_system(kWp band, nearest of the ECOPV06-13 EA bands 1.0→4.5 kWp, floor/cap at the ends) + scaffolding(£900 first elevation + £450 each additional, default 2) + enabling base (EICR £150 + DNO £50 + 2-way consumer unit £330) + [diverter £980 if cylinder] + [battery if the with-battery variant] → Cost(total, contingency_rate 0.15). Rates are data in the committed solar_rates.json (Southern Housing "SOLAR PV & BATTERY" EA column), loaded via SolarRates.from_json/.default and injectable. The £2,000 / 5 kWh battery is NOT on the rate sheet — a flagged estimate (battery_estimate=true), confirmed with the user to stand in until a DB rate. Co-Authored-By: Claude Opus 4.8 --- domain/modelling/contingencies.py | 1 + domain/modelling/products.py | 107 +++++++++++- domain/modelling/solar_rates.json | 21 +++ tests/domain/modelling/test_solar_products.py | 164 ++++++++++++++++++ 4 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 domain/modelling/solar_rates.json create mode 100644 tests/domain/modelling/test_solar_products.py diff --git a/domain/modelling/contingencies.py b/domain/modelling/contingencies.py index 7114313d..b4308ed6 100644 --- a/domain/modelling/contingencies.py +++ b/domain/modelling/contingencies.py @@ -20,6 +20,7 @@ _CONTINGENCY_RATES: dict[str, float] = { "low_energy_lighting": 0.26, "high_heat_retention_storage_heaters": 0.10, "air_source_heat_pump": 0.25, + "solar_pv": 0.15, } diff --git a/domain/modelling/products.py b/domain/modelling/products.py index 7b62d8a5..1e88fc95 100644 --- a/domain/modelling/products.py +++ b/domain/modelling/products.py @@ -25,10 +25,14 @@ from domain.modelling.contingencies import contingency_rate from domain.modelling.recommendation import Cost _ASHP_MEASURE_TYPE = "air_source_heat_pump" +_SOLAR_MEASURE_TYPE = "solar_pv" # The committed ASHP rate sheet (ADR-0025) — structured rate rows the flat # scalar catalogue cannot hold; loaded into `AshpRates`. _ASHP_RATES_PATH = Path(__file__).resolve().parent / "ashp_rates.json" +# The committed Solar PV rate sheet (ADR-0026) — the Southern Housing "SOLAR PV +# & BATTERY" EA-rate column; loaded into `SolarRates`. +_SOLAR_RATES_PATH = Path(__file__).resolve().parent / "solar_rates.json" _MIN_RADIATORS = 4 _MAX_RADIATORS = 12 @@ -94,6 +98,67 @@ class AshpRates: ) +@dataclass(frozen=True) +class SolarRates: + """The Southern Housing "SOLAR PV & BATTERY" EA rate table (ADR-0026) — + fully-loaded supply+install rates. Data, not code: the committed default + loads from `solar_rates.json`, and a caller can inject a variant (e.g. to + replace the flagged battery estimate with a DB rate).""" + + # pv_system install price by kWp band (ECOPV06-13, slate roof), ascending. + pv_system_by_kwp: tuple[tuple[float, float], ...] + scaffolding_first_elevation: float + scaffolding_additional_elevation: float + enabling_eicr: float + enabling_dno: float + enabling_consumer_unit: float + # Myenergi Eddi microgeneration diverter (ECOPV30). + diverter: float + # Battery supply+install — NOT on the rate sheet; a flagged estimate + # (`battery_estimate`) confirmed with the user to stand in until a DB rate. + battery: float + battery_estimate: bool + + @classmethod + def default(cls) -> "SolarRates": + """Load the committed Southern Housing solar rate sheet.""" + return cls.from_json(_SOLAR_RATES_PATH) + + @classmethod + def from_json(cls, path: Path) -> "SolarRates": + with path.open(encoding="utf-8") as handle: + raw: dict[str, Any] = json.load(handle) + bands: dict[str, Any] = raw["pv_system_by_kwp"] + return cls( + pv_system_by_kwp=tuple( + sorted( + (float(kwp), float(price)) for kwp, price in bands.items() + ) + ), + scaffolding_first_elevation=float(raw["scaffolding_first_elevation"]), + scaffolding_additional_elevation=float( + raw["scaffolding_additional_elevation"] + ), + enabling_eicr=float(raw["enabling_eicr"]), + enabling_dno=float(raw["enabling_dno"]), + enabling_consumer_unit=float(raw["enabling_consumer_unit"]), + diverter=float(raw["diverter"]), + battery=float(raw["battery"]), + battery_estimate=bool(raw["battery_estimate"]), + ) + + +@dataclass(frozen=True) +class SolarCostInputs: + """The dwelling facts the Solar PV catalogue math needs — produced by the + modelling layer's interpretation of a chosen array config (ADR-0026).""" + + peak_power_kwp: float + has_cylinder: bool + has_battery: bool + elevations: int = 2 + + class AshpExistingSystem(Enum): """The dwelling's pre-retrofit heating system, as it bears on decommission cost and whether a wet distribution system can be reused (ADR-0025). The @@ -125,8 +190,15 @@ class Products: is not a single catalogue scalar (the ASHP bundle — ADR-0025). The ASHP rate table is data, injected as `AshpRates` (default: the committed rate sheet).""" - def __init__(self, rates: AshpRates | None = None) -> None: + def __init__( + self, + rates: AshpRates | None = None, + solar_rates: SolarRates | None = None, + ) -> None: self._rates: AshpRates = rates if rates is not None else AshpRates.default() + self._solar_rates: SolarRates = ( + solar_rates if solar_rates is not None else SolarRates.default() + ) def ashp_bundle_cost(self, inputs: AshpCostInputs) -> Cost: """Compose the fully-loaded ASHP bundle total for a dwelling and pair it @@ -141,6 +213,39 @@ class Products: total=total, contingency_rate=contingency_rate(_ASHP_MEASURE_TYPE) ) + def solar_bundle_cost(self, inputs: SolarCostInputs) -> Cost: + """Compose the fully-loaded Solar PV bundle total for a dwelling and + pair it with the separate 15% solar contingency (ADR-0026).""" + rates = self._solar_rates + total: float = ( + self._pv_system(inputs.peak_power_kwp) + + self._scaffolding(inputs.elevations) + + rates.enabling_eicr + + rates.enabling_dno + + rates.enabling_consumer_unit + + (rates.diverter if inputs.has_cylinder else 0.0) + + (rates.battery if inputs.has_battery else 0.0) + ) + return Cost( + total=total, contingency_rate=contingency_rate(_SOLAR_MEASURE_TYPE) + ) + + def _pv_system(self, peak_power_kwp: float) -> float: + """Price the pv_system install at the kWp band nearest the array size, + flooring below the smallest band and capping at the largest.""" + bands = self._solar_rates.pv_system_by_kwp + nearest_kwp, _ = min(bands, key=lambda band: abs(band[0] - peak_power_kwp)) + return dict(bands)[nearest_kwp] + + def _scaffolding(self, elevations: int) -> float: + """£900 for the first elevation + £450 for each additional.""" + rates = self._solar_rates + additional: int = max(0, elevations - 1) + return ( + rates.scaffolding_first_elevation + + additional * rates.scaffolding_additional_elevation + ) + def _heat_pump(self, design_heat_loss_kw: float) -> float: """Price the install at the smallest band that covers the design heat loss (round up); above the largest band, the top rate applies.""" diff --git a/domain/modelling/solar_rates.json b/domain/modelling/solar_rates.json new file mode 100644 index 00000000..44eaf86d --- /dev/null +++ b/domain/modelling/solar_rates.json @@ -0,0 +1,21 @@ +{ + "_source": "20260409 Eco Approach - DOMNA Southern Housing Group Rates.xlsx, 'SOLAR PV & BATTERY' tab, EA Rates column (ADR-0026).", + "pv_system_by_kwp": { + "1.0": 2410.0, + "1.5": 2635.0, + "2.0": 2890.0, + "2.5": 2965.0, + "3.0": 3115.0, + "3.5": 3380.0, + "4.0": 3490.0, + "4.5": 3690.0 + }, + "scaffolding_first_elevation": 900.0, + "scaffolding_additional_elevation": 450.0, + "enabling_eicr": 150.0, + "enabling_dno": 50.0, + "enabling_consumer_unit": 330.0, + "diverter": 980.0, + "battery": 2000.0, + "battery_estimate": true +} diff --git a/tests/domain/modelling/test_solar_products.py b/tests/domain/modelling/test_solar_products.py new file mode 100644 index 00000000..08fe808a --- /dev/null +++ b/tests/domain/modelling/test_solar_products.py @@ -0,0 +1,164 @@ +"""Behaviour of `Products.solar_bundle_cost` — the composite, per-dwelling +Solar PV bundle cost (ADR-0026). Pure catalogue math over the Southern Housing +"SOLAR PV & BATTERY" EA-rate column: pv_system(kWp band) + scaffolding + +enabling base + [diverter if cylinder] + [battery if the with-battery variant], +carrying the separate 15% solar contingency. No EpcPropertyData / calculator. + +Pinned against the real rate sheet (delta <= 1e-9), mirroring the cascade-pin +philosophy. The £2,000 battery is a flagged estimate — it is not on the rate +sheet (ADR-0026), confirmed with the user to stand in until a DB rate lands. +""" + +from dataclasses import replace + +from domain.modelling.products import Products, SolarCostInputs, SolarRates +from domain.modelling.recommendation import Cost + +# pv_system EA bands (slate roof, ECOPV06-13) + the fixed adders. +_ENABLING = 150.0 + 50.0 + 330.0 # EICR + DNO + 2-way consumer unit = 530 +_SCAFFOLD_TWO = 900.0 + 450.0 # first elevation + one additional = 1350 + + +def test_solar_bundle_cost_composes_a_small_array_with_cylinder() -> None: + # Arrange — a 1.6 kWp array (4 × 400 W) on a dwelling with a cylinder (so a + # diverter is added), two elevations of scaffolding, no battery. + products = Products() + inputs = SolarCostInputs( + peak_power_kwp=1.6, has_cylinder=True, has_battery=False, elevations=2 + ) + + # Act + cost: Cost = products.solar_bundle_cost(inputs) + + # Assert — pv(1.5 band 2635) + scaffold 1350 + enabling 530 + diverter 980 + # = 5495, with the separate 15% solar contingency. + assert abs(cost.total - (2635.0 + _SCAFFOLD_TWO + _ENABLING + 980.0)) <= 1e-9 + assert abs(cost.total - 5495.0) <= 1e-9 + assert abs(cost.contingency_rate - 0.15) <= 1e-9 + + +def test_solar_bundle_cost_composes_a_large_array_with_battery_no_cylinder() -> None: + # Arrange — a 4.8 kWp array (12 × 400 W) caps at the 4.5 kWp band; no + # cylinder (no diverter), the with-battery variant. + products = Products() + inputs = SolarCostInputs( + peak_power_kwp=4.8, has_cylinder=False, has_battery=True, elevations=2 + ) + + # Act + cost: Cost = products.solar_bundle_cost(inputs) + + # Assert — pv(4.5 band 3690) + scaffold 1350 + enabling 530 + battery 2000 + # = 7570 (no diverter). + assert abs(cost.total - (3690.0 + _SCAFFOLD_TWO + _ENABLING + 2000.0)) <= 1e-9 + assert abs(cost.total - 7570.0) <= 1e-9 + + +def _pv_line(products: Products, peak_power_kwp: float) -> float: + """Isolate the pv_system line: no cylinder, no battery, one elevation + (scaffold 900) + enabling 530 = 1430 base, so total - base is the band + price for ``peak_power_kwp``.""" + inputs = SolarCostInputs( + peak_power_kwp=peak_power_kwp, + has_cylinder=False, + has_battery=False, + elevations=1, + ) + return products.solar_bundle_cost(inputs).total - (900.0 + _ENABLING) + + +def test_pv_system_snaps_to_the_nearest_kwp_band() -> None: + # Arrange + products = Products() + + # Act / Assert — EA bands {1.0:2410, 1.5:2635, 2.0:2890, 2.5:2965, 3.0:3115, + # 3.5:3380, 4.0:3490, 4.5:3690}. Sub-1.0 floors to 1.0; above 4.5 caps to + # 4.5; otherwise the nearest band. + assert abs(_pv_line(products, 0.4) - 2410.0) <= 1e-9 # 1 panel → floor 1.0 + assert abs(_pv_line(products, 1.0) - 2410.0) <= 1e-9 + assert abs(_pv_line(products, 1.6) - 2635.0) <= 1e-9 # 4 panels → 1.5 + assert abs(_pv_line(products, 2.0) - 2890.0) <= 1e-9 + assert abs(_pv_line(products, 4.8) - 3690.0) <= 1e-9 # 12 panels → cap 4.5 + assert abs(_pv_line(products, 7.6) - 3690.0) <= 1e-9 # 19 panels → cap 4.5 + + +def test_scaffolding_scales_with_elevations() -> None: + # Arrange — isolate scaffolding: 1.0 kWp (2410) + enabling 530, no cylinder, + # no battery; total - 2940 is the scaffolding line. + products = Products() + base = 2410.0 + _ENABLING + + def scaffold(elevations: int) -> float: + return ( + products.solar_bundle_cost( + SolarCostInputs( + peak_power_kwp=1.0, + has_cylinder=False, + has_battery=False, + elevations=elevations, + ) + ).total + - base + ) + + # Act / Assert — £900 first elevation + £450 each additional + assert abs(scaffold(1) - 900.0) <= 1e-9 + assert abs(scaffold(2) - 1350.0) <= 1e-9 + assert abs(scaffold(3) - 1800.0) <= 1e-9 + + +def test_diverter_is_priced_only_with_a_cylinder() -> None: + # Arrange — identical arrays differing only in cylinder presence. + products = Products() + with_cylinder = SolarCostInputs( + peak_power_kwp=2.0, has_cylinder=True, has_battery=False, elevations=2 + ) + without_cylinder = replace(with_cylinder, has_cylinder=False) + + # Act / Assert — the cylinder dwelling pays the £980 Myenergi Eddi diverter. + delta = ( + products.solar_bundle_cost(with_cylinder).total + - products.solar_bundle_cost(without_cylinder).total + ) + assert abs(delta - 980.0) <= 1e-9 + + +def test_battery_is_priced_only_for_the_with_battery_variant() -> None: + # Arrange + products = Products() + no_battery = SolarCostInputs( + peak_power_kwp=2.0, has_cylinder=False, has_battery=False, elevations=2 + ) + with_battery = replace(no_battery, has_battery=True) + + # Act / Assert — the £2,000 flagged-estimate battery line. + delta = ( + products.solar_bundle_cost(with_battery).total + - products.solar_bundle_cost(no_battery).total + ) + assert abs(delta - 2000.0) <= 1e-9 + + +def test_solar_bundle_cost_uses_injected_rates() -> None: + # Arrange — rates are data: a tweaked battery rate prices that battery. + rates: SolarRates = replace(SolarRates.default(), battery=1500.0) + products = Products(solar_rates=rates) + inputs = SolarCostInputs( + peak_power_kwp=2.0, has_cylinder=False, has_battery=True, elevations=2 + ) + + # Act + cost: Cost = products.solar_bundle_cost(inputs) + + # Assert — pv(2.0 band 2890) + scaffold 1350 + enabling 530 + battery 1500 = + # 6270. + assert abs(cost.total - 6270.0) <= 1e-9 + + +def test_battery_rate_is_flagged_as_an_estimate() -> None: + # Arrange / Act — the £2,000 battery is not on the rate sheet (ADR-0026). + rates = SolarRates.default() + + # Assert — flagged so it can be swapped from the DB later. + assert rates.battery_estimate is True + assert abs(rates.battery - 2000.0) <= 1e-9