mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
221 lines
7.7 KiB
Python
221 lines
7.7 KiB
Python
"""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 == ()
|