Merge pull request #1497 from Hestia-Homes/feature/small-roof-solar-min-array

Sub-Ladder PV Configurations: small roofs get the array their caps allow (ADR-0058)
This commit is contained in:
KhalimCK 2026-07-08 12:50:41 +01:00 committed by GitHub
commit 22db9c43f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 382 additions and 5 deletions

View file

@ -329,9 +329,13 @@ The bound that limits a **Solar PV Recommendation**'s array to the dwelling's *o
_Avoid_: roof-area haircut (overloads the MCS coverage haircut), double-property halving (the legacy semi-detached-only mechanism this replaces)
**Solar PV Recommendation**:
The single "Solar PV" **Recommendation** for a Property, carrying competing whole-array **Measure Options** built from the **Solar Potential** (ADR-0026). Up to **five conservatively-sized array configs** (capped, ranked by energy generation as the size-suitability proxy; deliberately *not* full-roof — imagery can miss obstructions, so a coverage/edge-setback haircut applies per MCS, and the **Dwelling-Roof Cap** bounds the array to the dwelling's own roof under **Footprint Conflation**), each offered **with and without a battery** (≤ 10 Options). Each Option is priced at a **single price point** from the rate sheet (kWp band + scaffolding-by-elevation + optional battery/diverter) — no multi-product price variants. The PV Overlay sets `photovoltaic_arrays` (one per segment: peak_power, orientation, pitch, overshading) plus `pv_diverter_present`, `pv_connection`, and **`is_dwelling_export_capable=True`** (an export meter is ensured post-install); the battery variant adds `pv_batteries`.
The single "Solar PV" **Recommendation** for a Property, carrying competing whole-array **Measure Options** built from the **Solar Potential** (ADR-0026). Up to **five conservatively-sized array configs** (capped, ranked by energy generation as the size-suitability proxy; deliberately *not* full-roof — imagery can miss obstructions, so a coverage/edge-setback haircut applies per MCS, and the **Dwelling-Roof Cap** bounds the array to the dwelling's own roof under **Footprint Conflation**), each offered **with and without a battery** (≤ 10 Options). Each Option is priced at a **single price point** from the rate sheet (kWp band + scaffolding-by-elevation + optional battery/diverter) — no multi-product price variants. The PV Overlay sets `photovoltaic_arrays` (one per segment: peak_power, orientation, pitch, overshading) plus `pv_diverter_present`, `pv_connection`, and **`is_dwelling_export_capable=True`** (an export meter is ensured post-install); the battery variant adds `pv_batteries`. When no Google rung fits under the caps, **Sub-Ladder Configurations** (ADR-0058) supply the small rungs instead of dropping PV entirely.
_Avoid_: solar bundle (PV is competing sized Options, not one fixed bundle like ASHP)
**Sub-Ladder Configuration**:
A PV array configuration Modelling derives **below Google's smallest offered rung**, so a small roof gets the array its caps allow instead of no PV at all (ADR-0058). Derived only when no `solarPanelConfigs` rung fits under the caps and at least 2 panels do: north planes dropped first, the rung's panels filled from segments ranked by per-panel yield (the **Dwelling-Roof Cap**'s fill-by-generation precedent), each kept segment's yearly energy scaled **pro-rata** (conservative — Google places panels best-first). One rung per whole panel count from **2** (the install floor) up to the cap; both caps (0.70 × max, **Dwelling-Roof Cap**) unchanged. Flows through the standard Option construction (battery pairing, pricing, PV Overlay) with no special cases.
_Avoid_: trimmed configuration ("trimmed" already means the north-segment drop), synthesized configuration ("synthesis" belongs to Reduced-Field Synthesis / EPC Prediction), minimum array (names the 2-panel floor, not the derived-rung concept)
**Solar PV Eligibility**:
The rule fixing whether the **Solar PV Recommendation** is offered (ADR-0026): a **house or bungalow**, **not listed and not heritage**, with **no existing PV**, and a **feasible Solar Potential** (the Google Solar API returned usable, non-north roof segments). Crucially a **conservation area does NOT block PV** — panels are offered (installed sympathetically), so the planning gate is `not blocks_internal` (listed/heritage only), **not** `blocks_external`; this is the opposite of an external fabric measure like EWI, and is deliberate (legacy + planning practice allow conservation-area PV on non-prominent roofs). Flats/maisonettes (building-level shared roof) are deferred.
_Avoid_: blocking conservation-area PV (only listed/heritage block), roof-area-from-floor-area estimate (eligibility uses the real Solar Potential)

View file

@ -0,0 +1,90 @@
---
status: accepted (extends ADR-0026 and ADR-0038)
---
# Sub-Ladder PV Configurations give small roofs the array their caps allow
ADR-0026 sizes a PV array by picking from Google's `solarPanelConfigs` ladder
under a conservative cap (0.70 × `maxArrayPanelsCount`, min'd with the
ADR-0038 Dwelling-Roof Cap when it resolves). Google's ladder typically starts
at **4 panels**, so on a small roof the cap falls below the smallest rung and
the feasible set is **empty** — the dwelling silently gets **no Solar PV
Measure Option at all** rather than a small array. ADR-0038 already states the
intent "never emit a zero-panel or unbounded array", but that governs the cap
fallback, not the ladder; the ladder cliff violated its spirit.
Motivation: portfolio 824 / scenario 1278 audit (2026-07-07). Property 750701
(UPRN 100010328594) — an unrestricted house short of goal C at D/67 on an
unlimited budget — has `maxArrayPanelsCount = 5` with rungs at 4 and 5 panels;
cap 0.70 × 5 = 3.5 < 4 no PV offered, while ~2 SAP of feasible PV would
plausibly reach C. The cliff affects every small-roofed house in every
portfolio, silently.
## Decision
**When no Google rung fits under the caps, derive Sub-Ladder Configurations —
array rungs below Google's smallest offered configuration — instead of
dropping PV entirely.**
- **Trigger**: the feasible set (north-dropped Google configs with
`panels_count <= panel_cap`) is empty AND `floor(panel_cap) >= 2`. When
`floor(panel_cap) < 2`, or nothing survives the north-segment drop, the
outcome stays no-PV — a roof that cannot take 2 panels is never offered
them.
- **Floor = 2 panels** — the practical install floor. Economics stay the
Optimiser's job (ADR-0024/0026 doctrine: eligibility and sizing encode
physical installability only); on least-cost-to-target a poor-£/kWh small
array is bought exactly when it is the only route to the goal band, which
is when the landlord wants it offered.
- **Every rung, not one**: emit a Sub-Ladder Configuration at every whole
panel count from 2 to `floor(panel_cap)` (in practice at most rungs 2 and
3 — the trigger implies `floor(panel_cap) <= 3`). Preserves ADR-0026's
genuine size/cost choice; the Optimiser stops at the cheapest array that
reaches goal.
- **Derivation**: start from Google's smallest rung, drop north planes first
(as for every config), then fill the rung's panel count from the remaining
segments **ranked by per-panel yield** — the ADR-0038 "fill by generation"
precedent.
- **Yield accounting is pro-rata per segment**: a kept segment contributes
`yearly_energy × kept_panels / segment_panels`. Provably conservative —
Google places panels best-first within a segment, so the kept panels' true
yield ≥ the pro-rata figure. Needs no new `SolarPotential` projection
fields.
- **Cap regime unchanged, no new gate**: the same `min(0.70 × max,
dwelling-roof budget)` applies; when the Dwelling-Roof Cap cannot resolve,
the 0.70-Google cap alone governs — exactly as it already does for full
configs, and a smaller array is strictly more conservative than what those
dwellings are offered today.
- **Downstream unchanged**: Sub-Ladder Configurations flow through the
existing option construction — with-and-without battery pairing, single
price point, PV Overlay — with no special cases.
## Considered options
- **Floor the cap at Google's smallest rung** (offer the 4-panel config when
the dwelling roof confirms it fits). Rejected: breaches the 70%
conservatism the cap encodes — the cap exists precisely to not trust
Google's maximum placement wholesale.
- **Exact per-panel yields** (extend the projection to carry Google's
`solarPanels` list and sum true kept-panel energies). Rejected for now:
new plumbing for precision that cannot change an Optimiser decision at 23
panels; pro-rata errs conservative. Revisit if sub-ladder sizing ever
extends beyond the small-roof trigger.
- **3-panel floor**. Rejected: gives up the smallest roofs (Google max 34
panels), where 2 panels is all the cap allows and may still clear a
near-boundary goal.
- **Require the Dwelling-Roof Cap to resolve before deriving**. Rejected:
stricter than today's treatment of full configs on unresolved roofs —
an inconsistent trust model.
## Consequences
- Small-roofed houses gain a PV path toward goal; on the motivating property
the expected offer is a 3-panel rung (~950 kWh/yr pro-rata from the
4-panel rung's 1,278). Follow-up: re-model 750701 after implementation to
confirm the +2 SAP to band C.
- A no-PV outcome on a small roof now always means `floor(panel_cap) < 2`,
no usable non-north segment, or ineligibility — never the ladder cliff.
- The audit skill's small-roof expectation (a `solar` row with no `solar_pv`
candidate is not automatically the solar-blind batch bug) shrinks to the
sub-2-panel residue once portfolios are re-modelled.

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,65 @@ 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 — taken from the
highest per-panel-yield segments first (the ADR-0038 fill-by-generation
precedent) 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)."""
by_yield: list[SolarRoofSegment] = sorted(
config.segments,
key=lambda s: s.yearly_energy_dc_kwh / s.panels_count,
reverse=True,
)
kept: list[SolarRoofSegment] = []
remaining: int = panels
for segment in by_yield:
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(
usable: list[SolarPanelConfiguration], 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 of the (already
north-dropped) ``usable`` configs."""
rung_max: int = math.floor(panel_cap)
if rung_max < _MIN_SUB_LADDER_PANELS or not usable:
return ()
smallest: SolarPanelConfiguration = min(usable, key=lambda c: c.panels_count)
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,
@ -207,14 +267,19 @@ def select_conservative_configs(
)
if roof_cap is not None:
panel_cap = min(panel_cap, roof_cap)
feasible: list[SolarPanelConfiguration] = [
usable: list[SolarPanelConfiguration] = [
trimmed
for config in potential.configurations
for trimmed in (_drop_north_segments(config),)
if trimmed.segments and trimmed.panels_count <= panel_cap
if trimmed.segments
]
feasible: list[SolarPanelConfiguration] = [
config for config in usable if config.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(usable, 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

View file

@ -219,3 +219,174 @@ def test_all_north_or_empty_yields_no_configs() -> None:
# 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]

View file

@ -14,7 +14,11 @@ from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.generators.solar_recommendation import recommend_solar
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.solar_potential import SolarPotential
from domain.modelling.solar_potential import (
SolarPanelConfiguration,
SolarPotential,
SolarRoofSegment,
)
from repositories.product.product_repository import ProductRepository
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
@ -242,3 +246,46 @@ def test_infeasible_potential_yields_no_recommendation() -> None:
# Act / Assert
assert recommend_solar(baseline, _StubProducts(), north_only) is None
def test_small_roof_house_gets_sub_ladder_options_instead_of_nothing() -> None:
# ADR-0058 end-to-end at the generator seam — the 824/1278 audit's
# property-750701 shape: Google max 5 (cap 3.5), ladder starting at 4,
# which yielded NO recommendation before Sub-Ladder Configurations.
# Arrange — an eligible house with a small-roof Solar Potential.
baseline = _eligible_house()
small_roof = SolarPotential(
panel_capacity_watts=400.0,
max_array_panels_count=5,
configurations=(
SolarPanelConfiguration(
panels_count=4,
yearly_energy_dc_kwh=1278.0,
segments=(
SolarRoofSegment(
segment_index=0,
panels_count=4,
azimuth_degrees=180.0,
pitch_degrees=30.0,
yearly_energy_dc_kwh=1278.0,
),
),
),
),
)
# Act
recommendation: Optional[Recommendation] = recommend_solar(
baseline, _StubProducts(), small_roof
)
# Assert — rungs 2 and 3 x {no battery, battery} = 4 competing Options,
# each overlay's array peak power matching its rung (0.8 / 1.2 kWp).
assert recommendation is not None
assert len(recommendation.options) == 4
peak_powers = sorted(
sum(a.peak_power for a in o.overlay.solar.photovoltaic_arrays)
for o in recommendation.options
)
assert peak_powers == [0.8, 0.8, 1.2, 1.2]