diff --git a/backend/documents_parser/elmhurst_extractor.py b/backend/documents_parser/elmhurst_extractor.py index 012a9573..1763b8f5 100644 --- a/backend/documents_parser/elmhurst_extractor.py +++ b/backend/documents_parser/elmhurst_extractor.py @@ -1164,6 +1164,13 @@ class ElmhurstSiteNotesExtractor: hydro_raw = self._next_val("Electricity generated [kWh/year]") hydro = float(hydro_raw) if hydro_raw else 0.0 + # RdSAP 10 §11.1 b): the Summary §19.0 may lodge a "% of roof + # area" row when the surveyor doesn't capture detailed kWp / + # orientation / pitch. `_int_val` returns 0 when the label is + # absent (cert lodges detailed pv_arrays instead) — collapse to + # None so downstream can distinguish "no PV" from "PV via % + # roof area path". + pv_pct = self._int_val("Proportion of roof area") return Renewables( solar_water_heating=self._bool_val("Solar Water Heating"), wwhrs_present=self._bool_val("Is WWHRS present in the property?"), @@ -1174,6 +1181,7 @@ class ElmhurstSiteNotesExtractor: wind_turbines_terrain_type=terrain, hydro_electricity_generated_kwh=hydro, pv_arrays=self._extract_pv_arrays(), + pv_percent_roof_area=pv_pct if pv_pct > 0 else None, ) def _extract_pv_arrays(self) -> List[ElmhurstPvArray]: diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 68818ee6..ce8162ed 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -339,6 +339,20 @@ class EpcPropertyDataMapper: wind_turbines_terrain_type=survey.renewables.wind_turbines_terrain_type, electricity_smart_meter_present=survey.meters.electricity_smart_meter, photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables), + # RdSAP 10 §11.1 b): when the cert lodges only a "% of + # roof area" PV figure (no detailed kWp / orientation), + # surface it through `photovoltaic_supply` so the + # cascade can synthesize an array via the 0.12 × area + # formula. Cohort-2 cert 6835 hits this path. + photovoltaic_supply=( + PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=survey.renewables.pv_percent_roof_area, + ) + ) + if survey.renewables.pv_percent_roof_area is not None + else None + ), ), sap_building_parts=_map_elmhurst_building_parts( survey, is_flat=property_type.lower() == "flat", diff --git a/datatypes/epc/surveys/elmhurst_site_notes.py b/datatypes/epc/surveys/elmhurst_site_notes.py index 27833d14..b955f5c8 100644 --- a/datatypes/epc/surveys/elmhurst_site_notes.py +++ b/datatypes/epc/surveys/elmhurst_site_notes.py @@ -267,6 +267,13 @@ class Renewables: pv_arrays: List["ElmhurstPvArray"] = field( default_factory=lambda: [] # type: ignore[reportUnknownLambdaType] ) + # RdSAP 10 §11.1 b) "Proportion of roof area" PV lodgement — + # populated when the surveyor lodges only a % roof coverage + # (no detailed kWp / orientation / pitch). Cohort-2 cert 6835 + # surfaces this path: Summary §19.0 row "Proportion of roof area + # = 40". The cascade then synthesizes a single PV array with + # kWp = 0.12 × PV area, defaulting to South / 30° / Modest. + pv_percent_roof_area: Optional[int] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index e14702e5..5f7c2310 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -49,6 +49,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification from __future__ import annotations +import math from dataclasses import dataclass from typing import Callable, Final, Literal, Optional @@ -802,13 +803,89 @@ def _pv_generation_kwh_per_yr( process to each and sum the monthly electricity generation figures." `climate` selects UK-average (region 0) for the rating cascade or postcode-specific (PCDB Table 172) for the demand cascade. + + Falls back to RdSAP 10 §11.1 b) when the cert lodges only a "% of + roof area" PV figure (no detailed kWp): synthesize a single PV + array with kWp = 0.12 × PV area, South orientation, 30° pitch, + Modest overshading. """ arrays = epc.sap_energy_source.photovoltaic_arrays + if not arrays: + arrays = _synthesize_pv_arrays_from_percent_roof_area(epc) if not arrays: return 0.0 return sum(_pv_array_generation_kwh_per_yr(a, climate) for a in arrays) +# RdSAP 10 §11.1 b): when the kWp is not lodged but the cert lodges a +# "% of roof area" PV figure, derive the PV peak power as +# `0.12 × PV area`, with PV area being the dwelling's roof area for +# heat loss (Σ top-floor areas across BPs, divided by cos(35°) for +# pitched parts), times the percent coverage. Defaults: South, 30°, +# Modest overshading. +_PV_PEAK_POWER_KWP_PER_M2: Final[float] = 0.12 +_PV_PITCHED_ROOF_COS_FACTOR_DEG: Final[float] = 35.0 +_PV_PERCENT_ROOF_AREA_DEFAULT_ORIENTATION_CODE: Final[int] = 5 # South +_PV_PERCENT_ROOF_AREA_DEFAULT_PITCH_CODE: Final[int] = 2 # 30° +_PV_PERCENT_ROOF_AREA_DEFAULT_OVERSHADING_CODE: Final[int] = 2 # Modest + + +def _synthesize_pv_arrays_from_percent_roof_area( + epc: EpcPropertyData, +) -> Optional[list[PhotovoltaicArray]]: + """RdSAP 10 §11.1 b) "Proportion of roof area" PV synthesis. + + The spec text (RdSAP 10 specification, page 60): + "If the kWp (or DNC) is not known use the following: PV area is + roof area for heat loss (before amendment for any room-in-roof), + times percent of roof area covered by PVs, and if pitched roof + divided by cos(35°). If there is an extension, the roof area is + adjusted by the cosine factor only for those parts having a + pitched roof. kWp is 0.12 × PV area." + + Returns None when the percent_roof_area lodgement is missing or + zero, or when no building-part geometry is available. Otherwise + returns a single-array list (RdSAP's "% of roof area" path lodges + one aggregate figure, not per-array). + """ + pv_supply = epc.sap_energy_source.photovoltaic_supply + if pv_supply is None: + return None + pct = pv_supply.none_or_no_details.percent_roof_area + if pct <= 0: + return None + parts = epc.sap_building_parts or [] + if not parts: + return None + cos_factor = math.cos(math.radians(_PV_PITCHED_ROOF_COS_FACTOR_DEG)) + pv_area_m2 = 0.0 + for part in parts: + if not part.sap_floor_dimensions: + continue + # Roof area for heat loss per RdSAP 10 §3.8 = the greatest of + # the floor areas on each level (i.e. the top floor's area). + top_floor_area = max( + (fd.total_floor_area_m2 or 0.0) for fd in part.sap_floor_dimensions + ) + roof_type = (part.roof_construction_type or "").lower() + is_pitched = "pitched" in roof_type or "sloping" in roof_type + bp_pv_area = top_floor_area * (pct / 100.0) + if is_pitched: + bp_pv_area /= cos_factor + pv_area_m2 += bp_pv_area + kwp = _PV_PEAK_POWER_KWP_PER_M2 * pv_area_m2 + if kwp <= 0: + return None + return [ + PhotovoltaicArray( + peak_power=kwp, + pitch=_PV_PERCENT_ROOF_AREA_DEFAULT_PITCH_CODE, + orientation=_PV_PERCENT_ROOF_AREA_DEFAULT_ORIENTATION_CODE, + overshading=_PV_PERCENT_ROOF_AREA_DEFAULT_OVERSHADING_CODE, + ), + ] + + def _pv_export_credit_gbp_per_kwh() -> float: """PV cost credit per kWh generated. Per ADR-0010 §10 the rating cascade uses RdSAP10 Table 32 prices; code 60 (PV export to grid) diff --git a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py index a5fce393..c1a0f59a 100644 --- a/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py @@ -18,7 +18,11 @@ from typing import Final import pytest -from datatypes.epc.domain.epc_property_data import MainHeatingDetail, PhotovoltaicArray +from datatypes.epc.domain.epc_property_data import ( + MainHeatingDetail, + PhotovoltaicArray, + SapFloorDimension, +) from domain.sap10_ml.tests._fixtures import ( make_building_part, @@ -467,6 +471,55 @@ def test_pv_generation_differentiates_arrays_by_pitch() -> None: assert tilted_kwh > vertical_kwh +def test_pv_generation_synthesizes_array_from_percent_roof_area_per_rdsap_11_1() -> None: + """RdSAP 10 §11.1 b) (page 60): "If the kWp (or DNC) is not known… + PV area is roof area for heat loss times percent of roof area + covered by PVs, and if pitched roof divided by cos(35°)… kWp is + 0.12 × PV area." Defaults to South, 30° pitch, Modest overshading. + + Cohort-2 cert 6835 (Semi-Detached bungalow, single storey, TFA 36.9 + m², pitched roof) lodges only "Proportion of roof area = 40" — the + cascade must synthesize a 2.16 kWp array (= 36.9 × 0.40 / cos(35°) + × 0.12) and route the generation through the Appendix M cost + cascade. Verified against the worksheet's "Cells Peak = 2.16, + Orientation = South, Elevation = 30°, Overshading = Modest" line. + """ + from datatypes.epc.domain.epc_property_data import ( + PhotovoltaicSupply, PhotovoltaicSupplyNoneOrNoDetails, + ) + + # Arrange — single-storey 36.9 m² bungalow, pitched roof, 40% of + # roof area covered by PV. No explicit kWp lodged. + epc = _typical_semi_detached_epc() + epc.total_floor_area_m2 = 36.9 + epc.sap_building_parts[0].roof_construction_type = ( + "Pitched (slates/tiles), access to loft" + ) + epc.sap_building_parts[0].sap_floor_dimensions = [ + SapFloorDimension( + floor=0, room_height_m=2.5, total_floor_area_m2=36.9, + party_wall_length_m=0.0, heat_loss_perimeter_m=23.17, + ), + ] + epc.sap_energy_source.photovoltaic_arrays = None + epc.sap_energy_source.photovoltaic_supply = PhotovoltaicSupply( + none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( + percent_roof_area=40, + ), + ) + + # Act + gen_kwh = cert_to_inputs(epc).pv_generation_kwh_per_yr + + # Assert — kWp = 0.12 × 36.9 × 0.40 / cos(35°) = 2.1622. With S = + # ~862 kWh/m²/yr at South 30° UK-avg (Appendix U3.3) and ZPV = 0.8 + # (Modest), annual EPV = 0.8 × 2.1622 × 862 × 0.8 ≈ 1490 kWh/yr — + # within 1% of the worksheet's 1492.33 kWh/yr. + assert 1450.0 < gen_kwh < 1530.0, ( + f"expected ~1490 kWh/yr (per RdSAP §11.1 b synthesis), got {gen_kwh:.2f}" + ) + + def test_pv_generation_uses_postcode_climate_in_demand_cascade() -> None: # Arrange — SAP 10.2 Appendix U: rating cascade uses UK-average # climate (region 0); demand cascade uses postcode-specific climate