diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index b74c3b63..5a855920 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -13,6 +13,7 @@ selection, the overlay and `recommend_solar` land in later slices. from __future__ import annotations +import math from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -21,7 +22,9 @@ from datatypes.epc.domain.epc_property_data import ( PvBatteries, PvBattery, ) +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP +from domain.building_geometry import roof_area from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.products import Products, SolarCostInputs from domain.modelling.measure_type import MeasureType @@ -61,6 +64,48 @@ _USABLE_PANEL_FRACTION = 0.70 # At most this many competing configs go to the Optimiser (× battery on/off). _MAX_CONFIGS = 5 +# ADR-0038 Dwelling-Roof Cap. Google's `maxArrayPanelsCount` reflects whatever +# building its imagery matched — on medium-quality imagery (common for semi- +# detached / terraced homes) that is the *conflated* whole-building roof, so the +# 0.70 cap above sizes the array to two or more dwellings. We additionally bound +# the array to the dwelling's OWN usable roof, derived from the EPC. +# +# `_DWELLING_USABLE_ROOF_FRACTION` is the share of the roof plan area that takes +# panels — roughly one non-north plane minus chimney/vent/edge setbacks. A +# documented, tunable constant (cf. `_USABLE_PANEL_FRACTION`). +_DWELLING_USABLE_ROOF_FRACTION = 0.5 +# RdSAP §11.1 snaps most roofs to 30°, and Google's imagery pitches cluster +# there; used only to convert the EPC plan roof area to the pitched-surface +# units Google's panel footprint is measured in. Residual error is absorbed by +# the usable fraction. +_NOMINAL_ROOF_PITCH_DEG = 30.0 +# Fallback panel footprint (m²) when Google omits panel dimensions — a standard +# ~1.88 × 1.05 m domestic module. +_DEFAULT_PANEL_AREA_M2 = 1.96 + + +def _dwelling_roof_panel_cap( + potential: SolarPotential, dwelling_roof_area_m2: Optional[float] +) -> Optional[float]: + """The maximum panel count that fits the dwelling's own usable roof + (ADR-0038), or None when no usable dwelling roof area is known (fall back to + Google's `maxArrayPanelsCount` cap alone). Budget = roof plan area ÷ + cos(pitch) × usable fraction, divided by the panel's pitched-surface + footprint.""" + if dwelling_roof_area_m2 is None or dwelling_roof_area_m2 <= 0.0: + return None + panel_area_m2: float = ( + potential.panel_height_m * potential.panel_width_m + if potential.panel_height_m and potential.panel_width_m + else _DEFAULT_PANEL_AREA_M2 + ) + usable_surface_m2: float = ( + dwelling_roof_area_m2 + / math.cos(math.radians(_NOMINAL_ROOF_PITCH_DEG)) + * _DWELLING_USABLE_ROOF_FRACTION + ) + return usable_surface_m2 / panel_area_m2 + # Google Solar inverter DC→AC efficiency — the canonical rate the legacy # `GoogleSolarApi.dc_to_ac_rate` uses (mid of the 93–98% range); distinct from # the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback. @@ -139,13 +184,25 @@ def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfigura def select_conservative_configs( potential: SolarPotential, + dwelling_roof_area_m2: Optional[float] = None, ) -> tuple[SolarPanelConfiguration, ...]: """Choose up to five conservatively-sized array configs for the Optimiser (ADR-0026): drop north-facing planes, cap usable panels at ~70% of maxArrayPanelsCount, then sample five spanning min→max by expected generation (the size-suitability proxy) so the size/cost choice is genuine. - Returns an empty tuple when nothing usable remains.""" + Returns an empty tuple when nothing usable remains. + + When the dwelling's own roof area is known, the panel count is additionally + bounded by the Dwelling-Roof Cap (ADR-0038) — `min(0.70 × + maxArrayPanelsCount, dwelling-roof budget)` — so Google footprint conflation + can't size the array to a neighbour's roof. The cap is a no-op when Google's + roof already fits the dwelling's (a correctly-matched home).""" panel_cap: float = _USABLE_PANEL_FRACTION * potential.max_array_panels_count + roof_cap: Optional[float] = _dwelling_roof_panel_cap( + potential, dwelling_roof_area_m2 + ) + if roof_cap is not None: + panel_cap = min(panel_cap, roof_cap) feasible: list[SolarPanelConfiguration] = [ trimmed for config in potential.configurations @@ -264,7 +321,7 @@ def recommend_solar( if solar_potential is None or not _solar_eligible(epc, restrictions): return None configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs( - solar_potential + solar_potential, _dwelling_roof_area_m2(epc) ) if not configs: return None @@ -278,6 +335,16 @@ def recommend_solar( return Recommendation(surface=_SOLAR_SURFACE, options=tuple(options)) +def _dwelling_roof_area_m2(epc: EpcPropertyData) -> Optional[float]: + """The dwelling's own roof plan area for the Dwelling-Roof Cap (ADR-0038), + or None when the EPC has no MAIN building part to measure (the cap then + falls back to Google's `maxArrayPanelsCount` cap).""" + try: + return roof_area(epc, BuildingPartIdentifier.MAIN) + except StopIteration: + return None + + def _solar_eligible( epc: EpcPropertyData, restrictions: PlanningRestrictions ) -> bool: