"""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 == ()