Model/tests/domain/modelling/test_solar_config_selection.py
Khalim Conn-Kowlessar 0a2ed67e94 Harden Dwelling-Roof Cap on real data: positional segments, ground-floor basis 🟩
Three corrections found by re-running property 742003 end-to-end:
- roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field
  the fixture happened to carry; key the centre/area lookup by array position.
- Base the cap on ground_floor_area (the footprint the roof covers), not the
  greatest per-storey area; roof_area is the fallback.
- Clamp the basis by total_floor_area: predicted EPCs borrow the structural
  template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from
  the predicted 55 m² (ADR-0029), so without the clamp the cap reads the
  template's larger footprint.

Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests +
orchestration threading + products green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 12:24:52 +00:00

221 lines
7.7 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 == ()