mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
feat(modelling): generation-calibrated PV overshading derivation
Slice 3 of the Solar PV Recommendation Generator (ADR-0026). Per roof segment,
back-solve the effective overshading factor ZPV from Google's expected
generation against SAP's own unshaded annual output:
ZPV = (yearlyEnergyDcKwh × 0.955) / (0.8 × kWp × S)
reusing the calculator's Appendix U3.3 annual solar radiation S via a new
public seam `pv_annual_solar_radiation_kwh_per_m2`. Dividing Google's
generation by SAP's S cancels orientation/tilt and isolates shading; the
result snaps to the RdSAP bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35} via the
ADR-0026 midpoint cutpoints (≥0.90→1, 0.65–0.90→2, 0.425–0.65→3, <0.425→4;
ZPV>1→1). The real London example's planes all back-solve to ZPV>1 → code 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f31d5bcff9
commit
82c3422788
3 changed files with 172 additions and 0 deletions
70
domain/modelling/generators/solar_recommendation.py
Normal file
70
domain/modelling/generators/solar_recommendation.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""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 domain.modelling.solar_potential import SolarRoofSegment
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
pv_annual_solar_radiation_kwh_per_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.
|
||||
_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)
|
||||
|
|
@ -738,6 +738,19 @@ def _pv_annual_s_kwh_per_m2(
|
|||
total += days * s_m
|
||||
return _HOURS_PER_DAY_OVER_1000 * total
|
||||
|
||||
|
||||
def pv_annual_solar_radiation_kwh_per_m2(
|
||||
orientation_code: int, pitch_code: int, climate: int = 0
|
||||
) -> float:
|
||||
"""Public seam over the SAP 10.2 Appendix U3.3 annual PV solar radiation
|
||||
`S` (kWh/m²/yr) for a plane of given SAP orientation octant + RdSAP pitch
|
||||
code. `climate` defaults to 0 (UK average, the rating cascade). Reused by
|
||||
the Modelling solar overshading calibration (ADR-0026), which back-solves
|
||||
the overshading factor ZPV from Google's expected generation against this
|
||||
unshaded `S`."""
|
||||
return _pv_annual_s_kwh_per_m2(orientation_code, pitch_code, climate)
|
||||
|
||||
|
||||
# SAP 10.2 Table M1 — PV overshading factor ZPV. RdSAP10 omits SAP10.2's
|
||||
# 5th "Severe" bucket; the four RdSAP codes map directly:
|
||||
# 1 = very little / none → 1.0
|
||||
|
|
|
|||
89
tests/domain/modelling/test_solar_overshading.py
Normal file
89
tests/domain/modelling/test_solar_overshading.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Slice 3 — generation-calibrated PV overshading (ADR-0026).
|
||||
|
||||
Google's `yearlyEnergyDcKwh` per segment already encodes real orientation,
|
||||
tilt and shading from imagery. Dividing its AC equivalent by SAP's own
|
||||
unshaded annual output (0.8 × kWp × S) cancels orientation/tilt and isolates
|
||||
the effective overshading factor ZPV, which snaps to the RdSAP bucket
|
||||
{1:1.0, 2:0.8, 3:0.5, 4:0.35}.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from domain.modelling.generators.solar_recommendation import (
|
||||
overshading_code_from_zpv,
|
||||
segment_overshading_code,
|
||||
)
|
||||
from domain.modelling.solar_potential import SolarPotential, SolarRoofSegment
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
pv_annual_solar_radiation_kwh_per_m2,
|
||||
)
|
||||
|
||||
_FIXTURE: Path = (
|
||||
Path(__file__).resolve().parent
|
||||
/ "fixtures"
|
||||
/ "google_building_insights_001431.json"
|
||||
)
|
||||
_DC_TO_AC_RATE = 0.955
|
||||
_SAP_PV_PERFORMANCE_FACTOR = 0.8
|
||||
|
||||
|
||||
def _insights() -> dict[str, Any]:
|
||||
with _FIXTURE.open(encoding="utf-8") as handle:
|
||||
data: dict[str, Any] = json.load(handle)
|
||||
return data
|
||||
|
||||
|
||||
def test_overshading_cutpoints_snap_to_rdsap_buckets() -> None:
|
||||
# Arrange / Act / Assert — ADR-0026 midpoints: ≥0.90→1, 0.65–0.90→2,
|
||||
# 0.425–0.65→3, <0.425→4, and ZPV>1 clamps to 1.
|
||||
assert overshading_code_from_zpv(1.20) == 1
|
||||
assert overshading_code_from_zpv(0.90) == 1
|
||||
assert overshading_code_from_zpv(0.89) == 2
|
||||
assert overshading_code_from_zpv(0.65) == 2
|
||||
assert overshading_code_from_zpv(0.64) == 3
|
||||
assert overshading_code_from_zpv(0.425) == 3
|
||||
assert overshading_code_from_zpv(0.42) == 4
|
||||
assert overshading_code_from_zpv(0.10) == 4
|
||||
|
||||
|
||||
def _segment_with_zpv(target_zpv: float) -> SolarRoofSegment:
|
||||
"""A south-facing 30°-tilt 2 kWp segment whose Google generation is set so
|
||||
its back-solved overshading factor is ``target_zpv``."""
|
||||
orientation, pitch_code, panels, capacity = 5, 2, 5, 400.0 # 5 × 400 W = 2 kWp
|
||||
kwp = panels * capacity / 1000
|
||||
s = pv_annual_solar_radiation_kwh_per_m2(orientation, pitch_code)
|
||||
g_ac = _SAP_PV_PERFORMANCE_FACTOR * kwp * s * target_zpv
|
||||
yearly_dc = g_ac / _DC_TO_AC_RATE
|
||||
return SolarRoofSegment(
|
||||
segment_index=0,
|
||||
panels_count=panels,
|
||||
azimuth_degrees=180.0, # S → octant 5
|
||||
pitch_degrees=30.0, # → code 2
|
||||
yearly_energy_dc_kwh=yearly_dc,
|
||||
)
|
||||
|
||||
|
||||
def test_segment_overshading_recovers_each_bucket() -> None:
|
||||
# Arrange / Act / Assert — a segment dialled to each bucket midpoint
|
||||
capacity = 400.0
|
||||
assert segment_overshading_code(_segment_with_zpv(1.0), capacity) == 1
|
||||
assert segment_overshading_code(_segment_with_zpv(0.8), capacity) == 2
|
||||
assert segment_overshading_code(_segment_with_zpv(0.5), capacity) == 3
|
||||
assert segment_overshading_code(_segment_with_zpv(0.35), capacity) == 4
|
||||
|
||||
|
||||
def test_real_example_segments_are_unshaded() -> None:
|
||||
# Arrange
|
||||
potential = SolarPotential.from_building_insights(_insights())
|
||||
largest = potential.configurations[-1]
|
||||
|
||||
# Act
|
||||
codes = {
|
||||
segment_overshading_code(seg, potential.panel_capacity_watts)
|
||||
for seg in largest.segments
|
||||
}
|
||||
|
||||
# Assert — a clear London roof: every plane back-solves to ZPV > 1 → code 1
|
||||
assert codes == {1}
|
||||
Loading…
Add table
Reference in a new issue