Model/domain/modelling/generators/solar_recommendation.py
Khalim Conn-Kowlessar d58ac60d29 feat(modelling): MeasureType StrEnum as the canonical measure vocabulary
Introduce domain/modelling/measure_type.py — a StrEnum with one member per
modelled measure (the 15 the generators emit). A StrEnum so each member *is*
its string value: it persists straight into the `recommendation` varchar
column, is the optimiser's group-by key, and compares equal to the catalogue /
EPC strings — so it replaces the per-generator string constants with no
persistence or optimiser change.

Repoint every generator's measure-type constant/literal to a MeasureType
member (wall, solid_wall, roof, floor, glazing, lighting, ventilation,
heating, solar). Field annotations stay `str` for now; tightening them to
MeasureType is the next slice.

This is the enum the historical engine deferred (engine.py:970
"TODO - formalise property measure types into an enum") and the vocabulary the
forthcoming `considered_measures` allowlist will speak (mirroring the legacy
`inclusions`).

Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on the enum + generators.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:54:04 +00:00

312 lines
13 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
from typing import Optional
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
PhotovoltaicArray,
PvBatteries,
PvBattery,
)
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
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"). Non-load-bearing for the SAP cascade; carried for
# fidelity. 1 = connected, the modal install case.
_PV_CONNECTED_TO_DWELLING = 1
# 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
# 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,
) -> 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."""
panel_cap: float = _USABLE_PANEL_FRACTION * potential.max_array_panels_count
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
)
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 _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