Model/domain/modelling/generators/solar_recommendation.py
Khalim Conn-Kowlessar 8606cab5f0 fix(pv): credit PV only when connected to the dwelling's meter 🟩
Gate PV generation/credit in cert_to_inputs on gov-API pv_connection:
credit only when ==2 ('connected'); ==1 ('present but not connected to the
dwelling's meter') contributes zero to the dwelling's cost/CO2/PE per
RdSAP 10 §11.1 / SAP 10.2 Appendix M. Non-int (None / site-notes str) keeps
the credit-if-array behaviour, so the Elmhurst/Summary + synthetic paths are
unchanged (no regression).

Corpus: all 5 pv_connection=1 PV certs move inside ±0.5 (e.g. 100051118081
+6.5→+0.5); MAE 0.760→0.740, within-0.5 73.8→74.3%, no regression
(pv_connection=2 certs keep their credit).

Also corrects a now-load-bearing latent bug: the solar-recommendation
overlay tagged recommended arrays pv_connection=1 ('not connected') — which
the new gate would zero. A new install connects to the dwelling's meter, so
it must be 2; pinned by the overlay test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 13:40:51 +00:00

401 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""The Solar PV Recommendation Generator (ADR-0026).
Offers competing whole-array PV Options built from real Google Solar imagery
(a typed `SolarPotential`), not an estimate. Unlike the heating bundles, the
SAP scoring side is already mature — the calculator does Appendix M β-split,
G4 diverter, SEG export, batteries and monthly E_PV — so this generator fixes
the *recommendation* side: where the array config comes from, how it is
conservatively sized, the new PV Overlay surface, and the composite cost.
This slice covers the generation-calibrated overshading derivation; config
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 (
EpcPropertyData,
PhotovoltaicArray,
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 ground_floor_area, roof_area
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.products import Products, SolarCostInputs
from domain.modelling.measure_type import MeasureType
from domain.modelling.recommendation import Cost, MeasureOption, Recommendation
from domain.modelling.simulation import EpcSimulation, SolarOverlay
from domain.modelling.solar_potential import (
SolarPanelConfiguration,
SolarPotential,
SolarRoofSegment,
)
from domain.sap10_calculator.rdsap.cert_to_inputs import (
pv_annual_solar_radiation_kwh_per_m2,
)
from repositories.product.product_repository import ProductRepository
_SOLAR_SURFACE = "Solar PV"
_SOLAR_MEASURE_TYPE = MeasureType.SOLAR_PV
# The fixed, representative battery capacity for the with-battery variant
# (ADR-0026) — a flagged estimate (see the rate sheet), 5 kWh.
_BATTERY_CAPACITY_KWH = 5.0
# Watts → kilowatts for peak-power.
_WATTS_PER_KW = 1000.0
# The dwelling's PV connects to its own meter (the after-cert §19 "Connected to
# the dwelling's meter: Yes"). LOAD-BEARING: `cert_to_inputs` credits PV to the
# dwelling's SAP only when `pv_connection == 2` ("connected"); value 1 means
# "present but NOT connected" and zeroes the credit. gov-API enum (corpus- and
# Elmhurst-validated): 0 = no PV, 1 = not connected, 2 = connected (the modal
# install case, 52 vs 5 on the RdSAP-21.0.1 corpus). A newly-installed
# recommended array is connected to the dwelling's own meter.
_PV_CONNECTED_TO_DWELLING = 2
# A roof plane within this many degrees of due north (0°/360°, Google compass
# convention) is dropped: it generates little and is not worth panelling. The
# legacy `GoogleSolarApi.NORTH_FACING_AZIMUTH_RANGE` used the same ±30° band.
_NORTH_AZIMUTH_HALF_WIDTH = 30.0
# Cap usable panels at ~70% of Google's maxArrayPanelsCount — imagery misses
# obstructions (flues, dormers) and MCS wants a ~0.3 m edge setback, so the
# theoretical maximum is optimistic.
_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.
_DC_TO_AC_RATE = 0.955
# SAP 10.2 Appendix M PV annual output: E = 0.8 × kWp × S × ZPV. The 0.8 is the
# in-system performance factor; back-solving for ZPV isolates the effective
# overshading once orientation (S) and size (kWp) are divided out.
_SAP_PV_PERFORMANCE_FACTOR = 0.8
# ADR-0026 overshading cutpoints — the lower bound of each RdSAP bucket's ZPV
# midpoint band {1:1.0, 2:0.8, 3:0.5, 4:0.35}: ≥0.90→1, 0.650.90→2,
# 0.4250.65→3, <0.425→4. ZPV > 1 (Google beats SAP's unshaded model) clamps
# to 1 via the ≥0.90 branch. RdSAP10 has no "Severe" 5th bucket.
_OVERSHADING_LOWER_BOUNDS: tuple[tuple[float, int], ...] = (
(0.90, 1),
(0.65, 2),
(0.425, 3),
)
_OVERSHADING_HEAVY_CODE = 4
def overshading_code_from_zpv(zpv_target: float) -> int:
"""Snap a back-solved effective shading factor ZPV to the RdSAP overshading
code (1 = very little/none … 4 = heavy), per the ADR-0026 cutpoints."""
for lower_bound, code in _OVERSHADING_LOWER_BOUNDS:
if zpv_target >= lower_bound:
return code
return _OVERSHADING_HEAVY_CODE
def segment_overshading_code(
segment: SolarRoofSegment, panel_capacity_watts: float
) -> int:
"""Derive a roof segment's RdSAP overshading code from Google's expected
generation (ADR-0026). Google's `yearlyEnergyDcKwh` already encodes real
orientation, tilt and shading; dividing its AC equivalent by SAP's own
unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and leaves
the effective overshading factor ZPV, which snaps to the bucket."""
kwp: float = segment.panels_count * panel_capacity_watts / 1000.0
s: float = pv_annual_solar_radiation_kwh_per_m2(
segment.sap_orientation, segment.sap_pitch_code
)
unshaded_ac_kwh: float = _SAP_PV_PERFORMANCE_FACTOR * kwp * s
if unshaded_ac_kwh <= 0.0:
# No panels, or an orientation the calculator scores as zero — nothing
# to shade; the modal "no shading" code.
return 1
generation_ac_kwh: float = segment.yearly_energy_dc_kwh * _DC_TO_AC_RATE
zpv_target: float = generation_ac_kwh / unshaded_ac_kwh
return overshading_code_from_zpv(zpv_target)
def _is_north_facing(azimuth_degrees: float) -> bool:
"""Whether a roof plane faces within 30° of due north (Google compass: 0°/
360° = N), handling the 360° wrap."""
return (
azimuth_degrees <= _NORTH_AZIMUTH_HALF_WIDTH
or azimuth_degrees >= 360.0 - _NORTH_AZIMUTH_HALF_WIDTH
)
def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfiguration:
"""Trim a configuration to its non-north planes, recomputing the array's
panel count and expected generation to the usable subset."""
kept: tuple[SolarRoofSegment, ...] = tuple(
segment
for segment in config.segments
if not _is_north_facing(segment.azimuth_degrees)
)
return SolarPanelConfiguration(
panels_count=sum(segment.panels_count for segment in kept),
yearly_energy_dc_kwh=sum(segment.yearly_energy_dc_kwh for segment in kept),
segments=kept,
)
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.
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
for trimmed in (_drop_north_segments(config),)
if trimmed.segments and trimmed.panels_count <= panel_cap
]
if not feasible:
return ()
# Collapse rungs that trimmed to the same usable size (north-drop can make
# distinct original rungs coincide), keeping the higher-generation layout —
# the Optimiser's dial is panel count (≈ kWp ≈ cost), so duplicates of the
# same size add no choice.
best_by_size: dict[int, SolarPanelConfiguration] = {}
for config in feasible:
incumbent = best_by_size.get(config.panels_count)
if incumbent is None or config.yearly_energy_dc_kwh > incumbent.yearly_energy_dc_kwh:
best_by_size[config.panels_count] = config
unique: list[SolarPanelConfiguration] = sorted(
best_by_size.values(), key=lambda c: c.yearly_energy_dc_kwh
)
if len(unique) > _MAX_CONFIGS:
last: int = len(unique) - 1
sampled_indices: list[int] = sorted(
{round(i * last / (_MAX_CONFIGS - 1)) for i in range(_MAX_CONFIGS)}
)
unique = [unique[index] for index in sampled_indices]
return tuple(sorted(unique, key=lambda c: c.panels_count))
def _array_for_segment(
segment: SolarRoofSegment, panel_capacity_watts: float
) -> PhotovoltaicArray:
"""Project a chosen roof segment into a SAP `PhotovoltaicArray`: peak power
from its panels, orientation/pitch from its geometry, and the
generation-calibrated overshading code (ADR-0026)."""
return PhotovoltaicArray(
peak_power=segment.panels_count * panel_capacity_watts / _WATTS_PER_KW,
pitch=segment.sap_pitch_code,
orientation=segment.sap_orientation,
overshading=segment_overshading_code(segment, panel_capacity_watts),
)
def _solar_overlay(
config: SolarPanelConfiguration,
panel_capacity_watts: float,
has_cylinder: bool,
with_battery: bool,
) -> SolarOverlay:
"""Build the `SolarOverlay` for one array config variant: one
`PhotovoltaicArray` per segment, export ensured, a diverter when the
dwelling has a cylinder, and a battery for the with-battery variant."""
return SolarOverlay(
photovoltaic_arrays=[
_array_for_segment(segment, panel_capacity_watts)
for segment in config.segments
],
# App G4 routes surplus PV to the cylinder immersion; a combi has nothing
# to divert to, so leave the field unset (None) when there is no cylinder.
pv_diverter_present=True if has_cylinder else None,
pv_connection=_PV_CONNECTED_TO_DWELLING,
is_dwelling_export_capable=True,
pv_batteries=(
PvBatteries(pv_battery=PvBattery(battery_capacity=_BATTERY_CAPACITY_KWH))
if with_battery
else None
),
)
def _option(
config: SolarPanelConfiguration,
panel_capacity_watts: float,
has_cylinder: bool,
with_battery: bool,
products: ProductRepository,
) -> MeasureOption:
"""Assemble one competing Solar PV Measure Option for a config variant."""
peak_power_kwp: float = config.panels_count * panel_capacity_watts / _WATTS_PER_KW
cost: Cost = Products().solar_bundle_cost(
SolarCostInputs(
peak_power_kwp=peak_power_kwp,
has_cylinder=has_cylinder,
has_battery=with_battery,
)
)
battery_suffix: str = " with a 5 kWh battery" if with_battery else ""
description: str = (
f"Install a {peak_power_kwp:.1f} kWp roof-mounted solar PV array"
f"{battery_suffix}, ensuring an export meter"
)
return MeasureOption(
measure_type=_SOLAR_MEASURE_TYPE,
description=description,
overlay=EpcSimulation(
solar=_solar_overlay(
config, panel_capacity_watts, has_cylinder, with_battery
)
),
cost=cost,
material_id=products.get(_SOLAR_MEASURE_TYPE).id,
)
def recommend_solar(
epc: EpcPropertyData,
products: ProductRepository,
solar_potential: Optional[SolarPotential],
restrictions: PlanningRestrictions = PlanningRestrictions(),
) -> Optional[Recommendation]:
"""Return a "Solar PV" Recommendation of competing whole-array Options —
up to five conservatively-sized configs × {no battery, battery} — for an
eligible dwelling with feasible Google solar potential, else None
(ADR-0026). A free Optimiser candidate; the Optimiser owns whether and at
what size to install it."""
if solar_potential is None or not _solar_eligible(epc, restrictions):
return None
configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs(
solar_potential, _dwelling_roof_area_m2(epc)
)
if not configs:
return None
has_cylinder: bool = bool(epc.has_hot_water_cylinder)
capacity: float = solar_potential.panel_capacity_watts
options: list[MeasureOption] = [
_option(config, capacity, has_cylinder, with_battery, products)
for config in configs
for with_battery in (False, True)
]
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).
The roof covers the **ground-floor footprint**, so the basis is
`ground_floor_area` (not the greatest per-storey area); `roof_area` is the
fallback when no ground (floor 0) dimension is lodged.
Clamped by total floor area: a footprint can never exceed the whole
dwelling's floor area (= footprint × storeys), so the clamp is a no-op on a
consistent lodged EPC. It matters for a **predicted** EPC, whose building-part
geometry is the structural template's — decoupled from the predicted floor
area (ADR-0029) — so without it the cap would read the template neighbour's
(larger) footprint rather than the predicted dwelling's size."""
try:
footprint: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
except StopIteration:
try:
footprint = roof_area(epc, BuildingPartIdentifier.MAIN)
except StopIteration:
return None
total: Optional[float] = epc.total_floor_area_m2
if total is not None and total > 0.0:
return min(footprint, total)
return footprint
def _solar_eligible(
epc: EpcPropertyData, restrictions: PlanningRestrictions
) -> bool:
"""Solar PV suits a non-flat house/bungalow that is not fabric-protected and
has no existing PV (ADR-0026). Eligibility encodes only physical/legal
installability — the Optimiser owns the economics. A conservation area does
NOT block PV (offered, installed sympathetically); a listed/heritage
protection (`blocks_internal`) does — the same gate as ASHP."""
if restrictions.blocks_internal:
return False
if not _is_house_or_bungalow(epc):
return False
return not _has_existing_pv(epc)
def _has_existing_pv(epc: EpcPropertyData) -> bool:
"""Whether the dwelling already has PV — the *existing* arrays on the EPC
(existing-PV top-up is deferred), distinct from the Google potential."""
arrays: Optional[list[PhotovoltaicArray]] = epc.sap_energy_source.photovoltaic_arrays
return bool(arrays)
def _is_house_or_bungalow(epc: EpcPropertyData) -> bool:
"""Whether the dwelling is a house or bungalow (not a flat/maisonette). The
Elmhurst path lodges the name; the API path a stringified RdSAP code
(`PROPERTY_TYPE_LOOKUP`: 0 House, 1 Bungalow, 2 Flat, 3 Maisonette)."""
raw: str = (epc.property_type or "").strip()
if raw.lower() in ("house", "bungalow"):
return True
if raw.isdigit():
return PROPERTY_TYPE_LOOKUP.get(int(raw)) in ("House", "Bungalow")
return False