A small roof derives Sub-Ladder rungs below Google's smallest config 🟩

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-07 22:05:37 +00:00
parent 79d7743bb1
commit 1a34e788d1

View file

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