Model/tests/domain/modelling/test_solar_config_selection.py
Khalim Conn-Kowlessar 024a01b128 The Dwelling-Roof Cap bounds Sub-Ladder rungs too 🟩
Pins that the unchanged cap regime (min of 0.7×Google and the ADR-0038
budget) flows into ADR-0058 derivation — no red phase; the tracer
implementation passed the already-min'd cap through.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:08:28 +00:00

392 lines
14 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]
def test_sub_ladder_rungs_fill_from_the_highest_yield_segment_first() -> None:
# ADR-0058 — filling follows the ADR-0038 "by generation" precedent: the
# derived rung takes panels from the better-yielding plane first, each
# kept segment scaled pro-rata, never biased by segment tuple order.
# Arrange — smallest rung spans SE (2 panels, 250/panel) listed FIRST and
# SW (2 panels, 350/panel) listed second; max 5 → cap 3.5 → rungs 2 and 3.
south_east = SolarRoofSegment(
segment_index=0,
panels_count=2,
azimuth_degrees=135.0,
pitch_degrees=30.0,
yearly_energy_dc_kwh=500.0,
)
south_west = SolarRoofSegment(
segment_index=1,
panels_count=2,
azimuth_degrees=225.0,
pitch_degrees=30.0,
yearly_energy_dc_kwh=700.0,
)
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=5,
configurations=(
SolarPanelConfiguration(
panels_count=4,
yearly_energy_dc_kwh=1200.0,
segments=(south_east, south_west),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — rung 2 is the SW plane alone (700); rung 3 adds one SE panel
# pro-rata (700 + 250 = 950).
assert [c.panels_count for c in configs] == [2, 3]
assert [c.yearly_energy_dc_kwh for c in configs] == [700.0, 950.0]
rung_two, rung_three = configs
assert [s.azimuth_degrees for s in rung_two.segments] == [225.0]
assert {(s.azimuth_degrees, s.panels_count) for s in rung_three.segments} == {
(225.0, 2),
(135.0, 1),
}
def test_sub_ladder_rungs_never_take_from_north_planes() -> None:
# ADR-0058 — derivation starts from the north-dropped remainder: a north
# plane never contributes panels to a Sub-Ladder rung, even when its
# per-panel yield would win the fill-by-generation ordering.
# Arrange — smallest rung = a due-north panel (900/panel, best yield) plus
# a south plane (4 panels, 1278); max 5 → cap 3.5; after the north drop
# the 4-panel south remainder still exceeds the cap → Sub-Ladder fires.
north = SolarRoofSegment(
segment_index=0,
panels_count=1,
azimuth_degrees=2.0,
pitch_degrees=30.0,
yearly_energy_dc_kwh=900.0,
)
south = SolarRoofSegment(
segment_index=1,
panels_count=4,
azimuth_degrees=180.0,
pitch_degrees=30.0,
yearly_energy_dc_kwh=1278.0,
)
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=5,
configurations=(
SolarPanelConfiguration(
panels_count=5,
yearly_energy_dc_kwh=2178.0,
segments=(north, south),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert — rungs 2 and 3 built from the south plane alone, pro-rata off
# its 1278 (639 / 958.5); the 900-yield north panel never appears.
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 all(s.azimuth_degrees == 180.0 for c in configs for s in c.segments)
def test_roof_capped_below_two_panels_still_yields_no_configs() -> None:
# ADR-0058 — the install floor: a roof whose cap resolves below 2 panels
# is never offered PV. Max 2 → cap 1.4 < 2; the ladder cliff is the
# correct outcome here, not a Sub-Ladder rung.
# Arrange
potential = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=2,
configurations=(
SolarPanelConfiguration(
panels_count=2,
yearly_energy_dc_kwh=640.0,
segments=(_segment(2, 180.0, 640.0),),
),
),
)
# Act
configs = select_conservative_configs(potential)
# Assert
assert configs == ()
def test_dwelling_roof_cap_bounds_the_sub_ladder_too() -> None:
# ADR-0058 — the cap regime is unchanged: when the Dwelling-Roof Cap
# (ADR-0038) resolves BELOW the 0.7×Google cap, it bounds the Sub-Ladder
# rungs as well — a conflated Google roof can't inflate even a small array.
# Arrange — max 6 (0.7 cap = 4.2) with Google's ladder starting at 4;
# a ~8.5 m² dwelling roof caps at ≈2.5 panels → only the 2-panel rung.
potential = _potential_with_panel_dims(max_panels=6, panel_counts=(4,))
# Act
configs = select_conservative_configs(potential, dwelling_roof_area_m2=8.5)
# Assert — one rung at 2 panels, pro-rata off the 4-panel rung's 400.
assert [c.panels_count for c in configs] == [2]
assert [c.yearly_energy_dc_kwh for c in configs] == [200.0]