mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
904c205e3a
commit
edce0f46af
1 changed files with 69 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue