"""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 == () def test_small_roof_derives_sub_ladder_rungs_below_googles_smallest_config() -> None: # ADR-0058 — the 824/1278 audit's property 750701 (UPRN 100010328594): # max 5 panels → cap 3.5, Google's ladder starts at 4, so today the # dwelling gets NO PV at all. Instead, derive Sub-Ladder Configurations at # every whole rung from 2 (the install floor) to floor(cap), from the # smallest rung, with per-segment yield scaled pro-rata (conservative — # Google places panels best-first). # Arrange — Google max 5, rungs at 4 (1278 kWh/yr) and 5 (1582), one # south plane; cap = 0.70 × 5 = 3.5 < 4 → nothing fits the ladder. potential = SolarPotential( panel_capacity_watts=400.0, max_array_panels_count=5, configurations=( SolarPanelConfiguration( panels_count=4, yearly_energy_dc_kwh=1278.0, segments=(_segment(4, 180.0, 1278.0),), ), SolarPanelConfiguration( panels_count=5, yearly_energy_dc_kwh=1582.0, segments=(_segment(5, 180.0, 1582.0),), ), ), ) # Act configs = select_conservative_configs(potential) # Assert — rungs 2 and 3 (ascending), yields pro-rata off the 4-panel # rung (1278 × 2/4 = 639, × 3/4 = 958.5), segments resized to the rung. assert [c.panels_count for c in configs] == [2, 3] assert [c.yearly_energy_dc_kwh for c in configs] == [639.0, 958.5] assert [sum(s.panels_count for s in c.segments) for c in configs] == [2, 3] def test_sub_ladder_rungs_fill_from_the_highest_yield_segment_first() -> None: # ADR-0058 — filling follows the ADR-0038 "by generation" precedent: the # derived rung takes panels from the better-yielding plane first, each # kept segment scaled pro-rata, never biased by segment tuple order. # Arrange — smallest rung spans SE (2 panels, 250/panel) listed FIRST and # SW (2 panels, 350/panel) listed second; max 5 → cap 3.5 → rungs 2 and 3. south_east = SolarRoofSegment( segment_index=0, panels_count=2, azimuth_degrees=135.0, pitch_degrees=30.0, yearly_energy_dc_kwh=500.0, ) south_west = SolarRoofSegment( segment_index=1, panels_count=2, azimuth_degrees=225.0, pitch_degrees=30.0, yearly_energy_dc_kwh=700.0, ) potential = SolarPotential( panel_capacity_watts=400.0, max_array_panels_count=5, configurations=( SolarPanelConfiguration( panels_count=4, yearly_energy_dc_kwh=1200.0, segments=(south_east, south_west), ), ), ) # Act configs = select_conservative_configs(potential) # Assert — rung 2 is the SW plane alone (700); rung 3 adds one SE panel # pro-rata (700 + 250 = 950). assert [c.panels_count for c in configs] == [2, 3] assert [c.yearly_energy_dc_kwh for c in configs] == [700.0, 950.0] rung_two, rung_three = configs assert [s.azimuth_degrees for s in rung_two.segments] == [225.0] assert {(s.azimuth_degrees, s.panels_count) for s in rung_three.segments} == { (225.0, 2), (135.0, 1), } def test_sub_ladder_rungs_never_take_from_north_planes() -> None: # ADR-0058 — derivation starts from the north-dropped remainder: a north # plane never contributes panels to a Sub-Ladder rung, even when its # per-panel yield would win the fill-by-generation ordering. # Arrange — smallest rung = a due-north panel (900/panel, best yield) plus # a south plane (4 panels, 1278); max 5 → cap 3.5; after the north drop # the 4-panel south remainder still exceeds the cap → Sub-Ladder fires. north = SolarRoofSegment( segment_index=0, panels_count=1, azimuth_degrees=2.0, pitch_degrees=30.0, yearly_energy_dc_kwh=900.0, ) south = SolarRoofSegment( segment_index=1, panels_count=4, azimuth_degrees=180.0, pitch_degrees=30.0, yearly_energy_dc_kwh=1278.0, ) potential = SolarPotential( panel_capacity_watts=400.0, max_array_panels_count=5, configurations=( SolarPanelConfiguration( panels_count=5, yearly_energy_dc_kwh=2178.0, segments=(north, south), ), ), ) # Act configs = select_conservative_configs(potential) # Assert — rungs 2 and 3 built from the south plane alone, pro-rata off # its 1278 (639 / 958.5); the 900-yield north panel never appears. assert [c.panels_count for c in configs] == [2, 3] assert [c.yearly_energy_dc_kwh for c in configs] == [639.0, 958.5] assert all(s.azimuth_degrees == 180.0 for c in configs for s in c.segments) def test_roof_capped_below_two_panels_still_yields_no_configs() -> None: # ADR-0058 — the install floor: a roof whose cap resolves below 2 panels # is never offered PV. Max 2 → cap 1.4 < 2; the ladder cliff is the # correct outcome here, not a Sub-Ladder rung. # Arrange potential = SolarPotential( panel_capacity_watts=400.0, max_array_panels_count=2, configurations=( SolarPanelConfiguration( panels_count=2, yearly_energy_dc_kwh=640.0, segments=(_segment(2, 180.0, 640.0),), ), ), ) # Act configs = select_conservative_configs(potential) # Assert assert configs == () def test_dwelling_roof_cap_bounds_the_sub_ladder_too() -> None: # ADR-0058 — the cap regime is unchanged: when the Dwelling-Roof Cap # (ADR-0038) resolves BELOW the 0.7×Google cap, it bounds the Sub-Ladder # rungs as well — a conflated Google roof can't inflate even a small array. # Arrange — max 6 (0.7 cap = 4.2) with Google's ladder starting at 4; # a ~8.5 m² dwelling roof caps at ≈2.5 panels → only the 2-panel rung. potential = _potential_with_panel_dims(max_panels=6, panel_counts=(4,)) # Act configs = select_conservative_configs(potential, dwelling_roof_area_m2=8.5) # Assert — one rung at 2 panels, pro-rata off the 4-panel rung's 400. assert [c.panels_count for c in configs] == [2] assert [c.yearly_energy_dc_kwh for c in configs] == [200.0]