Dwelling-Roof Cap bounds the PV array to the dwelling's own roof 🟩

select_conservative_configs now also caps panels by the dwelling's own usable
roof — min(0.70 × maxArrayPanelsCount, roof_area/cos(pitch) × 0.5 / panel_area)
— threaded from recommend_solar via roof_area(epc, MAIN) (ADR-0038). No-op on
correctly-matched homes; falls back to the Google cap when the EPC has no MAIN
part. Defeats Google footprint conflation (semi-detached/terraced).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-26 12:17:07 +00:00
parent 904c205e3a
commit edce0f46af

View file

@ -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 9398% 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 minmax 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: