"""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