From 1a34e788d1d0bc8ea357e484f9ba33926eaca6bd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:05:37 +0000 Subject: [PATCH] =?UTF-8?q?A=20small=20roof=20derives=20Sub-Ladder=20rungs?= =?UTF-8?q?=20below=20Google's=20smallest=20config=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../generators/solar_recommendation.py | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index f97a5752f..c344d0447 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -14,6 +14,7 @@ selection, the overlay and `recommend_solar` land in later slices. from __future__ import annotations import math +from dataclasses import replace from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -186,6 +187,66 @@ def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfigura ) +# ADR-0058 — the practical install floor for a Sub-Ladder Configuration; a +# roof whose cap resolves below this is never offered PV. +_MIN_SUB_LADDER_PANELS = 2 + + +def _resized_to( + config: SolarPanelConfiguration, panels: int +) -> SolarPanelConfiguration: + """A copy of ``config`` filled to ``panels`` panels, each kept segment's + yearly energy scaled pro-rata (conservative: Google places panels + best-first within a segment, so the kept panels' true yield is at least + the pro-rata figure).""" + kept: list[SolarRoofSegment] = [] + remaining: int = panels + for segment in config.segments: + if remaining <= 0: + break + take: int = min(segment.panels_count, remaining) + factor: float = take / segment.panels_count + kept.append( + replace( + segment, + panels_count=take, + yearly_energy_dc_kwh=segment.yearly_energy_dc_kwh * factor, + ) + ) + remaining -= take + return SolarPanelConfiguration( + panels_count=sum(segment.panels_count for segment in kept), + yearly_energy_dc_kwh=sum(segment.yearly_energy_dc_kwh for segment in kept), + segments=tuple(kept), + ) + + +def _sub_ladder_configs( + potential: SolarPotential, panel_cap: float +) -> tuple[SolarPanelConfiguration, ...]: + """Sub-Ladder Configurations (ADR-0058): rungs derived BELOW Google's + smallest offered config, for when nothing on the ladder fits under the + caps — a small roof gets the array its caps allow instead of no PV at + all. One rung per whole panel count from the install floor (2) up to + ``floor(panel_cap)``, derived from the smallest north-dropped config.""" + rung_max: int = math.floor(panel_cap) + if rung_max < _MIN_SUB_LADDER_PANELS: + return () + smallest: Optional[SolarPanelConfiguration] = None + for config in potential.configurations: + dropped = _drop_north_segments(config) + if not dropped.segments: + continue + if smallest is None or dropped.panels_count < smallest.panels_count: + smallest = dropped + if smallest is None: + return () + return tuple( + _resized_to(smallest, panels) + for panels in range(_MIN_SUB_LADDER_PANELS, rung_max + 1) + ) + + def select_conservative_configs( potential: SolarPotential, dwelling_roof_area_m2: Optional[float] = None, @@ -214,7 +275,9 @@ def select_conservative_configs( if trimmed.segments and trimmed.panels_count <= panel_cap ] if not feasible: - return () + # Nothing on Google's ladder fits under the caps — a small roof, not + # an unusable one. Derive Sub-Ladder Configurations (ADR-0058). + return _sub_ladder_configs(potential, panel_cap) # Collapse rungs that trimmed to the same usable size (north-drop can make # distinct original rungs coincide), keeping the higher-generation layout — # the Optimiser's dial is panel count (≈ kWp ≈ cost), so duplicates of the