mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""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 93–98% 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.65–0.90→2,
|
||
# 0.425–0.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
|