mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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:
commit
22db9c43f3
5 changed files with 382 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue