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>
156 lines
5.6 KiB
Python
156 lines
5.6 KiB
Python
"""Slice 2 — the typed `SolarPotential` projection over a Google Solar
|
|
`buildingInsights` response (ADR-0026).
|
|
|
|
Pins the orientation/pitch mappings and the projection against the real
|
|
London `buildingInsights` example (mirrored into fixtures from the
|
|
user-provided RTF).
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from domain.modelling.solar_potential import (
|
|
SolarPotential,
|
|
azimuth_to_sap_octant,
|
|
pitch_to_sap_code,
|
|
)
|
|
|
|
_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 test_azimuth_to_sap_octant_cardinals_and_diagonals() -> None:
|
|
# Arrange / Act / Assert — Google azimuth 0=N clockwise → SAP octant code
|
|
assert azimuth_to_sap_octant(0.0) == 1 # N
|
|
assert azimuth_to_sap_octant(45.0) == 2 # NE
|
|
assert azimuth_to_sap_octant(90.0) == 3 # E
|
|
assert azimuth_to_sap_octant(135.0) == 4 # SE
|
|
assert azimuth_to_sap_octant(180.0) == 5 # S
|
|
assert azimuth_to_sap_octant(225.0) == 6 # SW
|
|
assert azimuth_to_sap_octant(270.0) == 7 # W
|
|
assert azimuth_to_sap_octant(315.0) == 8 # NW
|
|
assert azimuth_to_sap_octant(360.0) == 1 # wraps to N
|
|
|
|
|
|
def test_pitch_to_sap_code_snaps_to_rdsap_enum() -> None:
|
|
# Arrange / Act / Assert — RdSAP 10 §11.1 fixed tilts
|
|
assert pitch_to_sap_code(0.0) == 1
|
|
assert pitch_to_sap_code(30.0) == 2
|
|
assert pitch_to_sap_code(45.0) == 3
|
|
assert pitch_to_sap_code(60.0) == 4
|
|
assert pitch_to_sap_code(90.0) == 5
|
|
# Real Google pitches (~32-34°) snap to the 30° code
|
|
assert pitch_to_sap_code(33.65681) == 2
|
|
assert pitch_to_sap_code(31.896425) == 2
|
|
|
|
|
|
def test_projection_returns_none_when_array_sizing_absent() -> None:
|
|
# Arrange — Google returns a solarPotential block with no array-sizing
|
|
# fields for buildings with no usable solar estimate; this previously
|
|
# KeyError'd on `maxArrayPanelsCount` and failed the whole property.
|
|
insights = _insights()
|
|
del insights["solarPotential"]["maxArrayPanelsCount"]
|
|
|
|
# Act
|
|
potential = SolarPotential.from_building_insights(insights)
|
|
|
|
# Assert — no usable solar potential, not a crash
|
|
assert potential is None
|
|
|
|
|
|
def test_projection_reads_potential_level_fields() -> None:
|
|
# Arrange
|
|
insights = _insights()
|
|
|
|
# Act
|
|
potential = SolarPotential.from_building_insights(insights)
|
|
|
|
# Assert
|
|
assert potential is not None
|
|
assert abs(potential.panel_capacity_watts - 400.0) <= 1e-4
|
|
assert potential.max_array_panels_count == 49
|
|
assert len(potential.configurations) == 46
|
|
|
|
|
|
def test_projection_first_config_single_segment() -> None:
|
|
# Arrange
|
|
insights = _insights()
|
|
|
|
# Act
|
|
potential = SolarPotential.from_building_insights(insights)
|
|
assert potential is not None
|
|
first = potential.configurations[0]
|
|
|
|
# Assert — the smallest rung: 4 panels on one SE roof plane
|
|
assert first.panels_count == 4
|
|
assert len(first.segments) == 1
|
|
segment = first.segments[0]
|
|
assert segment.segment_index == 1
|
|
assert segment.panels_count == 4
|
|
assert abs(segment.azimuth_degrees - 136.27895) <= 1e-4
|
|
assert abs(segment.yearly_energy_dc_kwh - 1617.0192) <= 1e-4
|
|
assert segment.sap_orientation == 4 # SE
|
|
assert segment.sap_pitch_code == 2 # ~32° → 30°
|
|
|
|
|
|
def test_projection_reads_panel_dimensions() -> None:
|
|
# The Dwelling-Roof Cap (ADR-0038) sizes the array by usable roof area, so
|
|
# it needs each panel's physical footprint — Google reports it at the
|
|
# solarPotential level.
|
|
potential = SolarPotential.from_building_insights(_insights())
|
|
|
|
assert potential is not None
|
|
assert potential.panel_height_m is not None
|
|
assert potential.panel_width_m is not None
|
|
assert abs(potential.panel_height_m - 1.879) <= 1e-4
|
|
assert abs(potential.panel_width_m - 1.045) <= 1e-4
|
|
|
|
|
|
def test_projection_enriches_segments_with_centre_and_area() -> None:
|
|
# The Dwelling-Roof Cap (ADR-0038) ranks segments by distance from the
|
|
# dwelling and bounds the array by usable roof area, so each segment must
|
|
# carry its centre + area — sourced from the top-level roofSegmentStats,
|
|
# keyed by segmentIndex (the per-config roofSegmentSummaries omit them).
|
|
insights = _insights()
|
|
# roofSegmentStats are positional — segmentIndex refers to the array index.
|
|
stats_by_index = {
|
|
i: s for i, s in enumerate(insights["solarPotential"]["roofSegmentStats"])
|
|
}
|
|
|
|
potential = SolarPotential.from_building_insights(insights)
|
|
|
|
assert potential is not None
|
|
segment = potential.configurations[0].segments[0] # segment_index == 1
|
|
expected = stats_by_index[segment.segment_index]
|
|
assert segment.area_m2 is not None
|
|
assert abs(segment.area_m2 - expected["stats"]["areaMeters2"]) <= 1e-4
|
|
assert segment.center_latitude is not None
|
|
assert segment.center_longitude is not None
|
|
assert abs(segment.center_latitude - expected["center"]["latitude"]) <= 1e-9
|
|
assert abs(segment.center_longitude - expected["center"]["longitude"]) <= 1e-9
|
|
|
|
|
|
def test_projection_largest_config_spans_all_segments() -> None:
|
|
# Arrange
|
|
insights = _insights()
|
|
|
|
# Act
|
|
potential = SolarPotential.from_building_insights(insights)
|
|
assert potential is not None
|
|
largest = potential.configurations[-1]
|
|
|
|
# Assert — the 49-panel rung spans all four roof planes
|
|
assert largest.panels_count == 49
|
|
assert sum(s.panels_count for s in largest.segments) == 49
|
|
octants = {s.sap_orientation for s in largest.segments}
|
|
assert octants == {8, 4, 2, 6} # NW, SE, NE, SW
|