From 904c205e3afa7786e873966dbac09f0c384229de Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:15:28 +0000 Subject: [PATCH] =?UTF-8?q?Dwelling-Roof=20Cap=20bounds=20the=20PV=20array?= =?UTF-8?q?=20to=20the=20dwelling's=20own=20roof=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select_conservative_configs must accept the dwelling's roof area and cap panels to its usable roof (ADR-0038) — bounding a 55m² dwelling to ~16 panels under Google footprint conflation, while staying a no-op on correctly-matched homes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modelling/test_solar_config_selection.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index d8f4bcc2..dca22f69 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -111,6 +111,57 @@ def test_cap_excludes_configs_above_seventy_percent() -> None: 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(