Model/tests/domain/modelling/test_solar_config_selection.py
Khalim Conn-Kowlessar 79d7743bb1 A small roof derives Sub-Ladder rungs below Google's smallest config 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:03:43 +00:00

258 lines
9.2 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.

"""Slice 4 — conservative PV config selection (ADR-0026).
From Google's full `solarPanelConfigs` ladder, drop north-facing segments
(within 30° of due north), cap usable panels at ~70% of maxArrayPanelsCount
(imagery misses obstructions; MCS wants an edge setback), then sample up to
five configs spanning min→max by energy so the Optimiser gets a genuine
size/cost choice.
"""
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
SapBuildingPart,
SapFloorDimension,
)
from domain.modelling.generators.solar_recommendation import (
_dwelling_roof_area_m2,
select_conservative_configs,
)
from domain.modelling.solar_potential import (
SolarPanelConfiguration,
SolarPotential,
SolarRoofSegment,
)
def _epc_with_roof(per_storey_areas: tuple[float, ...], total_floor_area: float) -> EpcPropertyData:
epc: EpcPropertyData = object.__new__(EpcPropertyData)
epc.total_floor_area_m2 = total_floor_area
part: SapBuildingPart = object.__new__(SapBuildingPart)
part.identifier = BuildingPartIdentifier.MAIN
dims: list[SapFloorDimension] = []
for floor, area in enumerate(per_storey_areas):
fd: SapFloorDimension = object.__new__(SapFloorDimension)
fd.floor = floor # 0 = ground
fd.total_floor_area_m2 = area
dims.append(fd)
part.sap_floor_dimensions = dims
epc.sap_building_parts = [part]
return epc
def test_dwelling_roof_basis_is_ground_floor_clamped_by_total_floor_area() -> None:
# ADR-0038/0029: the basis is the ground-floor footprint, clamped by total
# floor area. A predicted EPC's building-part geometry is the structural
# template's (here a 118 m² ground floor), decoupled from the predicted
# floor area (55 m²); a footprint can't exceed total floor area, so the cap
# basis clamps to 55 — not the borrowed template's 118.
predicted = _epc_with_roof(per_storey_areas=(118.0,), total_floor_area=55.0)
assert _dwelling_roof_area_m2(predicted) == 55.0
# A consistent 2-storey house — ground 55, upper 50, total 105 — uses the
# GROUND floor (55), not the greatest per-storey area, and the clamp is inert.
lodged = _epc_with_roof(per_storey_areas=(55.0, 50.0), total_floor_area=105.0)
assert _dwelling_roof_area_m2(lodged) == 55.0
_FIXTURE: Path = (
Path(__file__).resolve().parent
/ "fixtures"
/ "google_building_insights_001431.json"
)
def _insights() -> dict[str, Any]:
with _FIXTURE.open(encoding="utf-8") as handle:
data: dict[str, Any] = json.load(handle)
return data
def _segment(panels: int, azimuth: float, energy: float) -> SolarRoofSegment:
return SolarRoofSegment(
segment_index=0,
panels_count=panels,
azimuth_degrees=azimuth,
pitch_degrees=30.0,
yearly_energy_dc_kwh=energy,
)
def test_real_example_samples_five_spanning_configs() -> None:
# Arrange
potential = SolarPotential.from_building_insights(_insights())
assert potential is not None
# Act
configs = select_conservative_configs(potential)
# Assert — five rungs spanning the conservative range, ascending by size,
# all ≤ 70% of maxArrayPanelsCount (49 → 34.3)
assert [c.panels_count for c in configs] == [4, 12, 19, 26, 34]
assert all(c.panels_count <= 0.70 * potential.max_array_panels_count for c in configs)
def test_north_facing_segments_are_dropped() -> None:
# Arrange — a single config with a due-north plane and a south plane
south = _segment(panels=6, azimuth=180.0, energy=2000.0)
north = _segment(panels=4, azimuth=5.0, energy=900.0)
near_north = _segment(panels=2, azimuth=345.0, energy=400.0) # within 30° of N
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=20,
configurations=(
SolarPanelConfiguration(
panels_count=12,
yearly_energy_dc_kwh=3300.0,
segments=(south, north, near_north),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — only the south plane survives; counts/energy recomputed to it
assert len(configs) == 1
only = configs[0]
assert only.panels_count == 6
assert abs(only.yearly_energy_dc_kwh - 2000.0) <= 1e-4
assert [s.azimuth_degrees for s in only.segments] == [180.0]
def test_cap_excludes_configs_above_seventy_percent() -> None:
# Arrange — max 10 panels → cap 7; a 6-panel and an 8-panel rung
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=10,
configurations=(
SolarPanelConfiguration(
panels_count=6,
yearly_energy_dc_kwh=2000.0,
segments=(_segment(6, 180.0, 2000.0),),
),
SolarPanelConfiguration(
panels_count=8,
yearly_energy_dc_kwh=2600.0,
segments=(_segment(8, 180.0, 2600.0),),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — only the 6-panel rung (≤7) survives
assert [c.panels_count for c in configs] == [6]
def _south(panels: int) -> SolarRoofSegment:
return _segment(panels=panels, azimuth=180.0, energy=panels * 100.0)
def _potential_with_panel_dims(
max_panels: int, panel_counts: tuple[int, ...]
) -> SolarPotential:
# Google panel footprint 1.879 × 1.045 ≈ 1.964 m².
return SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=max_panels,
configurations=tuple(
SolarPanelConfiguration(
panels_count=n,
yearly_energy_dc_kwh=n * 100.0,
segments=(_south(n),),
)
for n in panel_counts
),
panel_height_m=1.879,
panel_width_m=1.045,
)
def test_dwelling_roof_cap_bounds_a_small_dwelling() -> None:
# ADR-0038: Google's maxArrayPanelsCount (58) reflects a conflated whole-
# building roof; a 55 m² dwelling's own usable roof (≈ 55/cos30° × 0.5 ≈
# 32 m² ≈ 16 panels) must bound the array, well below the 0.70×58 ≈ 40 cap.
potential = _potential_with_panel_dims(58, (4, 12, 20, 30, 41, 58))
# Google cap alone allows up to the 30-panel rung (41/58 exceed 0.70×58).
assert max(c.panels_count for c in select_conservative_configs(potential)) == 30
capped = select_conservative_configs(potential, dwelling_roof_area_m2=55.0)
assert capped # still offers the small rungs
assert all(c.panels_count <= 16 for c in capped)
assert max(c.panels_count for c in capped) == 12
def test_dwelling_roof_cap_is_a_no_op_on_a_matched_home() -> None:
# ADR-0038: on a correctly-matched home Google's roof ≈ the dwelling's, so
# the area budget is ≳ what Google offers and the cap does NOT bite.
potential = _potential_with_panel_dims(58, (4, 12, 20, 30, 41, 58))
baseline = [c.panels_count for c in select_conservative_configs(potential)]
matched = select_conservative_configs(potential, dwelling_roof_area_m2=300.0)
assert [c.panels_count for c in matched] == baseline
def test_all_north_or_empty_yields_no_configs() -> None:
# Arrange — every plane faces north
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=20,
configurations=(
SolarPanelConfiguration(
panels_count=4,
yearly_energy_dc_kwh=800.0,
segments=(_segment(4, 10.0, 800.0),),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert
assert configs == ()
def test_small_roof_derives_sub_ladder_rungs_below_googles_smallest_config() -> None:
# ADR-0058 — the 824/1278 audit's property 750701 (UPRN 100010328594):
# max 5 panels → cap 3.5, Google's ladder starts at 4, so today the
# dwelling gets NO PV at all. Instead, derive Sub-Ladder Configurations at
# every whole rung from 2 (the install floor) to floor(cap), from the
# smallest rung, with per-segment yield scaled pro-rata (conservative —
# Google places panels best-first).
# Arrange — Google max 5, rungs at 4 (1278 kWh/yr) and 5 (1582), one
# south plane; cap = 0.70 × 5 = 3.5 < 4 → nothing fits the ladder.
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=5,
configurations=(
SolarPanelConfiguration(
panels_count=4,
yearly_energy_dc_kwh=1278.0,
segments=(_segment(4, 180.0, 1278.0),),
),
SolarPanelConfiguration(
panels_count=5,
yearly_energy_dc_kwh=1582.0,
segments=(_segment(5, 180.0, 1582.0),),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — rungs 2 and 3 (ascending), yields pro-rata off the 4-panel
# rung (1278 × 2/4 = 639, × 3/4 = 958.5), segments resized to the rung.
assert [c.panels_count for c in configs] == [2, 3]
assert [c.yearly_energy_dc_kwh for c in configs] == [639.0, 958.5]
assert [sum(s.panels_count for s in c.segments) for c in configs] == [2, 3]