From 3ef432c8b2ef4622601c6c974e2a78c571116560 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 21:57:53 +0000 Subject: [PATCH 01/10] =?UTF-8?q?ADR-0058:=20Sub-Ladder=20PV=20Configurati?= =?UTF-8?q?ons=20give=20small=20roofs=20the=20array=20their=20caps=20allow?= =?UTF-8?q?=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grilled decisions (issue #1496): 2-panel floor, every rung 2..floor(cap), pro-rata per-segment yield (conservative), fill-by-generation, same cap regime as today (no new roof gate), symmetric battery pairing. Glossary gains "Sub-Ladder Configuration" ("trimmed"/"synthesized" deliberately avoided — both collide with existing vocabulary). Co-Authored-By: Claude Fable 5 --- CONTEXT.md | 6 +- ...adder-pv-configurations-for-small-roofs.md | 90 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0058-sub-ladder-pv-configurations-for-small-roofs.md diff --git a/CONTEXT.md b/CONTEXT.md index caaf3029e..d24ce42fd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -317,9 +317,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) diff --git a/docs/adr/0058-sub-ladder-pv-configurations-for-small-roofs.md b/docs/adr/0058-sub-ladder-pv-configurations-for-small-roofs.md new file mode 100644 index 000000000..7c6e936bf --- /dev/null +++ b/docs/adr/0058-sub-ladder-pv-configurations-for-small-roofs.md @@ -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 2–3 + 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 3–4 + 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. From 79d7743bb1a85c8806375000f4602c73768c6ef5 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:03:43 +0000 Subject: [PATCH 02/10] =?UTF-8?q?A=20small=20roof=20derives=20Sub-Ladder?= =?UTF-8?q?=20rungs=20below=20Google's=20smallest=20config=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../modelling/test_solar_config_selection.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index da08fd1a2..e24f54cd1 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -219,3 +219,40 @@ 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] From 1a34e788d1d0bc8ea357e484f9ba33926eaca6bd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:05:37 +0000 Subject: [PATCH 03/10] =?UTF-8?q?A=20small=20roof=20derives=20Sub-Ladder?= =?UTF-8?q?=20rungs=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 From 0b33f7dc65054116b5824c992b41a680c2136528 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:06:10 +0000 Subject: [PATCH 04/10] =?UTF-8?q?Sub-Ladder=20rungs=20fill=20from=20the=20?= =?UTF-8?q?highest-yield=20segment=20first=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../modelling/test_solar_config_selection.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index e24f54cd1..53b6dca18 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -256,3 +256,51 @@ def test_small_roof_derives_sub_ladder_rungs_below_googles_smallest_config() -> 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), + } From c1a8c27bc1aa8b03c77d877fe1ed0954d67ff16e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:06:30 +0000 Subject: [PATCH 05/10] =?UTF-8?q?Sub-Ladder=20rungs=20fill=20from=20the=20?= =?UTF-8?q?highest-yield=20segment=20first=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 --- .../modelling/generators/solar_recommendation.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index c344d0447..6b5b18f4a 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -195,13 +195,19 @@ _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).""" + """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 config.segments: + for segment in by_yield: if remaining <= 0: break take: int = min(segment.panels_count, remaining) From 5a382948d51b29975411fa20fd250ff9eea4a50c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:07:18 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Sub-Ladder=20rungs=20never=20take=20from?= =?UTF-8?q?=20north=20planes=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins behavior delivered with the tracer slice (derivation starts from the north-dropped remainder) — no red phase; the mutation is discriminating (a kept north panel would win fill-by-generation and shift the yields). Co-Authored-By: Claude Fable 5 --- .../modelling/test_solar_config_selection.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index 53b6dca18..6aae14dd9 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -304,3 +304,47 @@ def test_sub_ladder_rungs_fill_from_the_highest_yield_segment_first() -> None: (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) From 1756976d7eef972149b262638bcdcbc34f9ed638 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:07:39 +0000 Subject: [PATCH 07/10] =?UTF-8?q?A=20roof=20capped=20below=20two=20panels?= =?UTF-8?q?=20is=20still=20offered=20no=20PV=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the ADR-0058 install floor delivered with the tracer slice (rung_max < 2 returns empty) — no red phase. Co-Authored-By: Claude Fable 5 --- .../modelling/test_solar_config_selection.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index 6aae14dd9..01dba8533 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -348,3 +348,28 @@ def test_sub_ladder_rungs_never_take_from_north_planes() -> None: 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 == () From 024a01b1286271ee85f012ed69b8b2ea1d8b2ff4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:08:28 +0000 Subject: [PATCH 08/10] =?UTF-8?q?The=20Dwelling-Roof=20Cap=20bounds=20Sub-?= =?UTF-8?q?Ladder=20rungs=20too=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins that the unchanged cap regime (min of 0.7×Google and the ADR-0038 budget) flows into ADR-0058 derivation — no red phase; the tracer implementation passed the already-min'd cap through. Co-Authored-By: Claude Fable 5 --- .../modelling/test_solar_config_selection.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index 01dba8533..eb4f7f757 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -373,3 +373,20 @@ def test_roof_capped_below_two_panels_still_yields_no_configs() -> None: # 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] From 5d0a7751e66fea332a26741818935a942236a7ef Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:09:18 +0000 Subject: [PATCH 09/10] =?UTF-8?q?A=20small-roof=20house=20gets=20Sub-Ladde?= =?UTF-8?q?r=20solar=20Options=20instead=20of=20nothing=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end pin at the generator seam (recommend_solar) — passed on first run since option construction is unchanged by design (ADR-0058). Co-Authored-By: Claude Fable 5 --- .../modelling/test_solar_recommendation.py | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/tests/domain/modelling/test_solar_recommendation.py b/tests/domain/modelling/test_solar_recommendation.py index 43337ab51..05cc1c369 100644 --- a/tests/domain/modelling/test_solar_recommendation.py +++ b/tests/domain/modelling/test_solar_recommendation.py @@ -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] From fbedf4541b0423cc42241477a2b18ead0ba60e56 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 7 Jul 2026 22:10:37 +0000 Subject: [PATCH 10/10] =?UTF-8?q?A=20small=20roof=20derives=20Sub-Ladder?= =?UTF-8?q?=20rungs=20below=20Google's=20smallest=20config=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit North-drop now runs once: the selection computes the usable (north-dropped) ladder, feasibility filters it, and Sub-Ladder derivation consumes it. Co-Authored-By: Claude Fable 5 --- .../generators/solar_recommendation.py | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index 6b5b18f4a..110599a0b 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -228,25 +228,18 @@ def _resized_to( def _sub_ladder_configs( - potential: SolarPotential, panel_cap: float + 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 north-dropped config.""" + ``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: - 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: + 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) @@ -274,16 +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: # 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) + 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