From 58f6b868a7309622ab24eff204e036e9679e4e2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:08:11 +0000 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20ADR-0038=20+=20glossary=20=E2=80=94?= =?UTF-8?q?=20bound=20Solar=20PV=20array=20to=20the=20dwelling's=20own=20r?= =?UTF-8?q?oof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0026 sized PV at 0.70 x Google maxArrayPanelsCount, assuming Google's roof is the dwelling's. Medium-imagery footprint conflation (semi-detached/terraced) returns the whole building's roof -> oversized arrays (property 742003: 55m2 bungalow -> ~16 kWp -> effective E/49.7 -> A/92.4 on solar alone). ADR-0038 bounds the array to the dwelling's own usable roof (roof_area-based budget, proximity segment selection anchored on the dwelling), subsuming the legacy GoogleSolarApi guards the rewrite dropped. CONTEXT: add Footprint Conflation + Dwelling-Roof Cap, extend Solar Potential (per-segment centre + area). Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 12 ++- .../0038-solar-pv-bounded-to-dwelling-roof.md | 80 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0038-solar-pv-bounded-to-dwelling-roof.md diff --git a/CONTEXT.md b/CONTEXT.md index 48fa76ee..d03f7d8e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -281,11 +281,19 @@ The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insul _Avoid_: measure (ambiguous), category **Solar Potential**: -The typed domain projection over a Property's Google Solar `buildingInsights` response (fetched by **Ingestion**, persisted whole as JSONB, read by Modelling — never re-fetched). It carries the per-**roof-segment** geometry the PV simulation needs — `azimuthDegrees` (→ SAP orientation octant), `pitchDegrees` (→ SAP pitch), per-segment panel counts, panel capacity, and `sunshineQuantiles` (→ overshading) — plus the candidate panel layouts (`solarPanelConfigs`). It is the source of the **PV Overlay**: the solar Recommendation Generator reads the Solar Potential (NOT the EPC's `photovoltaic_arrays`, which is the dwelling's *existing* PV) and builds competing PV Options from it. Distinct from existing/installed PV. +The typed domain projection over a Property's Google Solar `buildingInsights` response (fetched by **Ingestion**, persisted whole as JSONB, read by Modelling — never re-fetched). It carries the per-**roof-segment** geometry the PV simulation needs — `azimuthDegrees` (→ SAP orientation octant), `pitchDegrees` (→ SAP pitch), per-segment panel counts, **per-segment centre + area** (for the **Dwelling-Roof Cap**), panel capacity, and `sunshineQuantiles` (→ overshading) — plus the candidate panel layouts (`solarPanelConfigs`). It is the source of the **PV Overlay**: the solar Recommendation Generator reads the Solar Potential (NOT the EPC's `photovoltaic_arrays`, which is the dwelling's *existing* PV) and builds competing PV Options from it. Distinct from existing/installed PV. _Avoid_: solar config (ambiguous — the API response vs the chosen array set), Google insights (the raw JSON, not the typed projection) +**Footprint Conflation**: +When Google Solar (typically on medium-quality imagery, and most often for **semi-detached / terraced** homes) reports the **whole building's** roof for a single dwelling — merging the target with its attached neighbour — so the **Solar Potential** describes more roof than the dwelling owns. The failure mode the **Dwelling-Roof Cap** corrects; without it the array is sized to two or more homes. +_Avoid_: duplicate surfaces (the legacy heuristic's name, not the concept) + +**Dwelling-Roof Cap**: +The bound that limits a **Solar PV Recommendation**'s array to the dwelling's *own* usable roof, defeating **Footprint Conflation** (ADR-0038). A surface-area budget — the EPC's RdSAP §3.8 roof plan area (`roof_area`) ÷ cos(pitch) × a usable fraction (~0.5, one non-north plane) — caps the panel count via `min(budget, 0.70 × maxArrayPanelsCount)`, so it is a **no-op on correctly-matched homes** and only bites under conflation. Segments are ranked by proximity to the **dwelling's** own lat/long (not Google's building centre) to keep the home's roof and drop the neighbour's. Used only for **sizing** — distinct from **Solar PV Eligibility**, which still uses the real Solar Potential, never a roof-area-from-floor-area estimate. +_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), 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`. _Avoid_: solar bundle (PV is competing sized Options, not one fixed bundle like ASHP) **Solar PV Eligibility**: diff --git a/docs/adr/0038-solar-pv-bounded-to-dwelling-roof.md b/docs/adr/0038-solar-pv-bounded-to-dwelling-roof.md new file mode 100644 index 00000000..2644ec5f --- /dev/null +++ b/docs/adr/0038-solar-pv-bounded-to-dwelling-roof.md @@ -0,0 +1,80 @@ +--- +status: accepted (extends ADR-0026) +--- + +# Solar PV array is bounded to the dwelling's own roof + +ADR-0026 sized a PV array at **0.70 × Google `maxArrayPanelsCount`**, implicitly +assuming Google's roof *is* the dwelling's roof. On medium-quality imagery — +common for **semi-detached and terraced** homes — Google conflates the dwelling +with its neighbour and returns the **whole building's** roof, so the array is +sized to two (or more) homes. Property 742003 (portfolio 812) is a **55 m² +bungalow** but Google reports a 158.8 m² roof / 363 m² footprint → a ~16 kWp +array → the plan jumps effective **E/49.7 → A/92.4 on solar alone**. We now +**bound the array to the dwelling's own usable roof**, derived from the EPC, so +Google can only ever influence the array *within* the dwelling's physical roof. + +## Decision + +**A dwelling-roof area budget caps the array, and segment selection is anchored +on the dwelling, not Google's building centre.** + +- **Budget** = `roof_area(epc, MAIN) / cos(pitch) × USABLE_FRACTION` + (`USABLE_FRACTION = 0.5` — one usable, non-north plane minus setbacks / + obstructions; a documented, tunable constant alongside the existing + `_USABLE_PANEL_FRACTION = 0.70`). `roof_area` is the RdSAP §3.8 plan area + (`domain/building_geometry.py`); dividing by `cos(pitch)` converts it to the + pitched-surface units Google reports. +- **Final cap** = `min(area-budget panels, 0.70 × maxArrayPanelsCount)`. This is + a **no-op on correctly-matched homes** (Google's roof ≈ the dwelling's, so the + area budget is ≳ what Google offers) — it only bites when Google's roof + materially exceeds the dwelling's, i.e. conflation. +- **Trim unit = panels** (sub-segment partial fills allowed) — the budget is + routinely smaller than a single roof segment (~33 m² here, vs a 40.5 m² + segment). +- **Segment selection by proximity:** rank Google's roof segments by distance + from the **dwelling's own lat/long** (`property_details_spatial`, *not* + Google's building `center`, which sits at the conflated centroid), keep the + home's roof and drop the neighbour's, then fill the budget from the home's + segments **by generation** (so a near-but-NE plane doesn't win over the home's + SW plane). This requires the `SolarPotential` projection to carry each + segment's **centre + area** (from `roofSegmentStats`, keyed by + `segment_index`), which it previously dropped. +- **Fallback:** when `roof_area` is missing or absurd, fall back to the existing + 0.70-Google cap — never emit a zero-panel or unbounded array. + +## Considered options + +- **Port the legacy `GoogleSolarApi.py` guards as-is** (`ROOF_AREA_TOLERANCE` + correction, `exclude_likely_duplicate_surfaces` + `double_property` halving, + `PERCENTAGE_OF_ROOF_LIMIT`). Rejected: fragile and case-specific — the + duplicate-surface heuristic only fires for `built_form == "Semi-Detached" and + extension_count == 0` (it would not fire for *this* bungalow), and relies on + azimuth-similarity + distance-to-Google-centre tolerances. A single physical + budget (this ADR) subsumes all three guards. +- **OS building-footprint polygon clipping now** — geometrically intersect + Google's segments with the dwelling's MasterMap outline. Deferred: no GIS + footprint data is ingested today (we have OS *point* coordinates only). Logged + as the principled future enhancement — it fixes *which* segments robustly and + would let us delete the proximity heuristic entirely. +- **Fill the budget strictly nearest-first.** Rejected: the nearest segment can + be poorly oriented (here the nearest is NE). Within the home's own segments we + fill by generation. +- **Bound directly on Google's per-segment `areaMeters2`** instead of the EPC + roof. Rejected as the primary basis: those areas are themselves the conflated + roof; the EPC `roof_area` is the dwelling-scale ground truth. (We still read + the segment areas — to convert the budget into a panel count per segment.) + +## Consequences + +- `SolarPotential` / `SolarRoofSegment` grow a per-segment **centre + area**; + the Google `buildingInsights` → `SolarPotential` projection maps them from + `roofSegmentStats`. `recommend_solar` / `select_conservative_configs` thread + the dwelling's `roof_area` + anchor coordinate. +- **SAP scores fall on conflated homes** (the intended correction); correctly + matched homes are unchanged. Regression-anchored on property 742003 + (~16 kWp → ~6.5 kWp). +- **Out of scope (separate ticket):** the solar bundle cost is flat (£6,550) + across 5.2–16 kWp, so the optimiser had no cost reason to prefer a smaller + array — with sizing bounded this stops mattering for the conflation case, but + the cost curve saturating is its own issue. From cf2780ed77f93262d289126b60e4c414ad21f151 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:12:12 +0000 Subject: [PATCH 2/6] =?UTF-8?q?SolarPotential=20carries=20panel=20dims=20+?= =?UTF-8?q?=20per-segment=20centre/area=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dwelling-Roof Cap (ADR-0038) sizes by usable roof area and ranks segments by distance from the dwelling, so the projection must carry each panel's footprint and each segment's centre + area (from roofSegmentStats). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/modelling/test_solar_potential.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/domain/modelling/test_solar_potential.py b/tests/domain/modelling/test_solar_potential.py index 5b1ddb54..3c92914d 100644 --- a/tests/domain/modelling/test_solar_potential.py +++ b/tests/domain/modelling/test_solar_potential.py @@ -103,6 +103,43 @@ def test_projection_first_config_single_segment() -> None: assert segment.sap_pitch_code == 2 # ~32° → 30° +def test_projection_reads_panel_dimensions() -> None: + # The Dwelling-Roof Cap (ADR-0038) sizes the array by usable roof area, so + # it needs each panel's physical footprint — Google reports it at the + # solarPotential level. + potential = SolarPotential.from_building_insights(_insights()) + + assert potential is not None + assert potential.panel_height_m is not None + assert potential.panel_width_m is not None + assert abs(potential.panel_height_m - 1.879) <= 1e-4 + assert abs(potential.panel_width_m - 1.045) <= 1e-4 + + +def test_projection_enriches_segments_with_centre_and_area() -> None: + # The Dwelling-Roof Cap (ADR-0038) ranks segments by distance from the + # dwelling and bounds the array by usable roof area, so each segment must + # carry its centre + area — sourced from the top-level roofSegmentStats, + # keyed by segmentIndex (the per-config roofSegmentSummaries omit them). + insights = _insights() + stats_by_index = { + int(s["segmentIndex"]): s + for s in insights["solarPotential"]["roofSegmentStats"] + } + + potential = SolarPotential.from_building_insights(insights) + + assert potential is not None + segment = potential.configurations[0].segments[0] # segment_index == 1 + expected = stats_by_index[segment.segment_index] + assert segment.area_m2 is not None + assert abs(segment.area_m2 - expected["stats"]["areaMeters2"]) <= 1e-4 + assert segment.center_latitude is not None + assert segment.center_longitude is not None + assert abs(segment.center_latitude - expected["center"]["latitude"]) <= 1e-9 + assert abs(segment.center_longitude - expected["center"]["longitude"]) <= 1e-9 + + def test_projection_largest_config_spans_all_segments() -> None: # Arrange insights = _insights() From d6a59be950ccaefb6a1cbc45f53374afd05cb22b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:14:00 +0000 Subject: [PATCH 3/6] =?UTF-8?q?SolarPotential=20carries=20panel=20dims=20+?= =?UTF-8?q?=20per-segment=20centre/area=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enrich each SolarRoofSegment from roofSegmentStats (centre + areaMeters2, keyed by segmentIndex) and read panelHeightMeters/panelWidthMeters onto SolarPotential — the geometry the Dwelling-Roof Cap (ADR-0038) needs. All Optional; existing projection + config-selection tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/modelling/solar_potential.py | 65 +++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/domain/modelling/solar_potential.py b/domain/modelling/solar_potential.py index d805e131..cf9c13ad 100644 --- a/domain/modelling/solar_potential.py +++ b/domain/modelling/solar_potential.py @@ -62,6 +62,14 @@ class SolarRoofSegment: azimuth_degrees: float pitch_degrees: float yearly_energy_dc_kwh: float + # Per-segment centre + roof-plane area, enriched from the top-level + # `roofSegmentStats` (keyed by `segmentIndex`) — the per-config + # `roofSegmentSummaries` omit them. Used by the Dwelling-Roof Cap + # (ADR-0038) to rank segments by distance from the dwelling and bound the + # array by usable roof area. None when the stats block lacks the segment. + center_latitude: Optional[float] = None + center_longitude: Optional[float] = None + area_m2: Optional[float] = None @property def sap_orientation(self) -> int: @@ -94,6 +102,11 @@ class SolarPotential: panel_capacity_watts: float max_array_panels_count: int configurations: tuple[SolarPanelConfiguration, ...] + # Physical panel footprint (Google `panelHeightMeters` / `panelWidthMeters`) + # — the Dwelling-Roof Cap (ADR-0038) converts a usable roof-area budget into + # a panel count via this. None for partial blocks lacking the fields. + panel_height_m: Optional[float] = None + panel_width_m: Optional[float] = None @classmethod def from_building_insights( @@ -111,18 +124,46 @@ class SolarPotential: or "panelCapacityWatts" not in solar_potential ): return None + # Per-segment centre + area live on the top-level `roofSegmentStats`, + # keyed by `segmentIndex`; the per-config `roofSegmentSummaries` carry + # only the panel/orientation fields. Build the lookup once. + stats_by_index: dict[int, Mapping[str, Any]] = { + int(stats["segmentIndex"]): stats + for stats in solar_potential.get("roofSegmentStats", []) + } + + def _segment(summary: Mapping[str, Any]) -> SolarRoofSegment: + index: int = int(summary["segmentIndex"]) + stats: Optional[Mapping[str, Any]] = stats_by_index.get(index) + center: Mapping[str, Any] = ( + stats.get("center", {}) if stats is not None else {} + ) + area: Optional[float] = ( + float(stats["stats"]["areaMeters2"]) + if stats is not None and "stats" in stats + else None + ) + return SolarRoofSegment( + segment_index=index, + panels_count=int(summary["panelsCount"]), + azimuth_degrees=float(summary["azimuthDegrees"]), + pitch_degrees=float(summary["pitchDegrees"]), + yearly_energy_dc_kwh=float(summary["yearlyEnergyDcKwh"]), + center_latitude=( + float(center["latitude"]) if "latitude" in center else None + ), + center_longitude=( + float(center["longitude"]) if "longitude" in center else None + ), + area_m2=area, + ) + configurations: tuple[SolarPanelConfiguration, ...] = tuple( SolarPanelConfiguration( panels_count=int(config["panelsCount"]), yearly_energy_dc_kwh=float(config["yearlyEnergyDcKwh"]), segments=tuple( - SolarRoofSegment( - segment_index=int(summary["segmentIndex"]), - panels_count=int(summary["panelsCount"]), - azimuth_degrees=float(summary["azimuthDegrees"]), - pitch_degrees=float(summary["pitchDegrees"]), - yearly_energy_dc_kwh=float(summary["yearlyEnergyDcKwh"]), - ) + _segment(summary) for summary in config.get("roofSegmentSummaries", []) ), ) @@ -132,4 +173,14 @@ class SolarPotential: panel_capacity_watts=float(solar_potential["panelCapacityWatts"]), max_array_panels_count=int(solar_potential["maxArrayPanelsCount"]), configurations=configurations, + panel_height_m=( + float(solar_potential["panelHeightMeters"]) + if "panelHeightMeters" in solar_potential + else None + ), + panel_width_m=( + float(solar_potential["panelWidthMeters"]) + if "panelWidthMeters" in solar_potential + else None + ), ) From 904c205e3afa7786e873966dbac09f0c384229de Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:15:28 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Dwelling-Roof=20Cap=20bounds=20the=20PV=20a?= =?UTF-8?q?rray=20to=20the=20dwelling's=20own=20roof=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select_conservative_configs must accept the dwelling's roof area and cap panels to its usable roof (ADR-0038) — bounding a 55m² dwelling to ~16 panels under Google footprint conflation, while staying a no-op on correctly-matched homes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../modelling/test_solar_config_selection.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index d8f4bcc2..dca22f69 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -111,6 +111,57 @@ def test_cap_excludes_configs_above_seventy_percent() -> None: assert [c.panels_count for c in configs] == [6] +def _south(panels: int) -> SolarRoofSegment: + return _segment(panels=panels, azimuth=180.0, energy=panels * 100.0) + + +def _potential_with_panel_dims( + max_panels: int, panel_counts: tuple[int, ...] +) -> SolarPotential: + # Google panel footprint 1.879 × 1.045 ≈ 1.964 m². + return SolarPotential( + panel_capacity_watts=400.0, + max_array_panels_count=max_panels, + configurations=tuple( + SolarPanelConfiguration( + panels_count=n, + yearly_energy_dc_kwh=n * 100.0, + segments=(_south(n),), + ) + for n in panel_counts + ), + panel_height_m=1.879, + panel_width_m=1.045, + ) + + +def test_dwelling_roof_cap_bounds_a_small_dwelling() -> None: + # ADR-0038: Google's maxArrayPanelsCount (58) reflects a conflated whole- + # building roof; a 55 m² dwelling's own usable roof (≈ 55/cos30° × 0.5 ≈ + # 32 m² ≈ 16 panels) must bound the array, well below the 0.70×58 ≈ 40 cap. + potential = _potential_with_panel_dims(58, (4, 12, 20, 30, 41, 58)) + + # Google cap alone allows up to the 30-panel rung (41/58 exceed 0.70×58). + assert max(c.panels_count for c in select_conservative_configs(potential)) == 30 + + capped = select_conservative_configs(potential, dwelling_roof_area_m2=55.0) + + assert capped # still offers the small rungs + assert all(c.panels_count <= 16 for c in capped) + assert max(c.panels_count for c in capped) == 12 + + +def test_dwelling_roof_cap_is_a_no_op_on_a_matched_home() -> None: + # ADR-0038: on a correctly-matched home Google's roof ≈ the dwelling's, so + # the area budget is ≳ what Google offers and the cap does NOT bite. + potential = _potential_with_panel_dims(58, (4, 12, 20, 30, 41, 58)) + baseline = [c.panels_count for c in select_conservative_configs(potential)] + + matched = select_conservative_configs(potential, dwelling_roof_area_m2=300.0) + + assert [c.panels_count for c in matched] == baseline + + def test_all_north_or_empty_yields_no_configs() -> None: # Arrange — every plane faces north potential = SolarPotential( From edce0f46af58e10866f73901d90ff8b0aea56cbf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:17:07 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Dwelling-Roof=20Cap=20bounds=20the=20PV=20a?= =?UTF-8?q?rray=20to=20the=20dwelling's=20own=20roof=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select_conservative_configs now also caps panels by the dwelling's own usable roof — min(0.70 × maxArrayPanelsCount, roof_area/cos(pitch) × 0.5 / panel_area) — threaded from recommend_solar via roof_area(epc, MAIN) (ADR-0038). No-op on correctly-matched homes; falls back to the Google cap when the EPC has no MAIN part. Defeats Google footprint conflation (semi-detached/terraced). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../generators/solar_recommendation.py | 71 ++++++++++++++++++- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index b74c3b63..5a855920 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -13,6 +13,7 @@ selection, the overlay and `recommend_solar` land in later slices. from __future__ import annotations +import math from typing import Optional from datatypes.epc.domain.epc_property_data import ( @@ -21,7 +22,9 @@ from datatypes.epc.domain.epc_property_data import ( PvBatteries, PvBattery, ) +from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP +from domain.building_geometry import roof_area from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.products import Products, SolarCostInputs from domain.modelling.measure_type import MeasureType @@ -61,6 +64,48 @@ _USABLE_PANEL_FRACTION = 0.70 # At most this many competing configs go to the Optimiser (× battery on/off). _MAX_CONFIGS = 5 +# ADR-0038 Dwelling-Roof Cap. Google's `maxArrayPanelsCount` reflects whatever +# building its imagery matched — on medium-quality imagery (common for semi- +# detached / terraced homes) that is the *conflated* whole-building roof, so the +# 0.70 cap above sizes the array to two or more dwellings. We additionally bound +# the array to the dwelling's OWN usable roof, derived from the EPC. +# +# `_DWELLING_USABLE_ROOF_FRACTION` is the share of the roof plan area that takes +# panels — roughly one non-north plane minus chimney/vent/edge setbacks. A +# documented, tunable constant (cf. `_USABLE_PANEL_FRACTION`). +_DWELLING_USABLE_ROOF_FRACTION = 0.5 +# RdSAP §11.1 snaps most roofs to 30°, and Google's imagery pitches cluster +# there; used only to convert the EPC plan roof area to the pitched-surface +# units Google's panel footprint is measured in. Residual error is absorbed by +# the usable fraction. +_NOMINAL_ROOF_PITCH_DEG = 30.0 +# Fallback panel footprint (m²) when Google omits panel dimensions — a standard +# ~1.88 × 1.05 m domestic module. +_DEFAULT_PANEL_AREA_M2 = 1.96 + + +def _dwelling_roof_panel_cap( + potential: SolarPotential, dwelling_roof_area_m2: Optional[float] +) -> Optional[float]: + """The maximum panel count that fits the dwelling's own usable roof + (ADR-0038), or None when no usable dwelling roof area is known (fall back to + Google's `maxArrayPanelsCount` cap alone). Budget = roof plan area ÷ + cos(pitch) × usable fraction, divided by the panel's pitched-surface + footprint.""" + if dwelling_roof_area_m2 is None or dwelling_roof_area_m2 <= 0.0: + return None + panel_area_m2: float = ( + potential.panel_height_m * potential.panel_width_m + if potential.panel_height_m and potential.panel_width_m + else _DEFAULT_PANEL_AREA_M2 + ) + usable_surface_m2: float = ( + dwelling_roof_area_m2 + / math.cos(math.radians(_NOMINAL_ROOF_PITCH_DEG)) + * _DWELLING_USABLE_ROOF_FRACTION + ) + return usable_surface_m2 / panel_area_m2 + # Google Solar inverter DC→AC efficiency — the canonical rate the legacy # `GoogleSolarApi.dc_to_ac_rate` uses (mid of the 93–98% range); distinct from # the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback. @@ -139,13 +184,25 @@ def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfigura def select_conservative_configs( potential: SolarPotential, + dwelling_roof_area_m2: Optional[float] = None, ) -> tuple[SolarPanelConfiguration, ...]: """Choose up to five conservatively-sized array configs for the Optimiser (ADR-0026): drop north-facing planes, cap usable panels at ~70% of maxArrayPanelsCount, then sample five spanning min→max by expected generation (the size-suitability proxy) so the size/cost choice is genuine. - Returns an empty tuple when nothing usable remains.""" + Returns an empty tuple when nothing usable remains. + + When the dwelling's own roof area is known, the panel count is additionally + bounded by the Dwelling-Roof Cap (ADR-0038) — `min(0.70 × + maxArrayPanelsCount, dwelling-roof budget)` — so Google footprint conflation + can't size the array to a neighbour's roof. The cap is a no-op when Google's + roof already fits the dwelling's (a correctly-matched home).""" panel_cap: float = _USABLE_PANEL_FRACTION * potential.max_array_panels_count + roof_cap: Optional[float] = _dwelling_roof_panel_cap( + potential, dwelling_roof_area_m2 + ) + if roof_cap is not None: + panel_cap = min(panel_cap, roof_cap) feasible: list[SolarPanelConfiguration] = [ trimmed for config in potential.configurations @@ -264,7 +321,7 @@ def recommend_solar( if solar_potential is None or not _solar_eligible(epc, restrictions): return None configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs( - solar_potential + solar_potential, _dwelling_roof_area_m2(epc) ) if not configs: return None @@ -278,6 +335,16 @@ def recommend_solar( return Recommendation(surface=_SOLAR_SURFACE, options=tuple(options)) +def _dwelling_roof_area_m2(epc: EpcPropertyData) -> Optional[float]: + """The dwelling's own roof plan area for the Dwelling-Roof Cap (ADR-0038), + or None when the EPC has no MAIN building part to measure (the cap then + falls back to Google's `maxArrayPanelsCount` cap).""" + try: + return roof_area(epc, BuildingPartIdentifier.MAIN) + except StopIteration: + return None + + def _solar_eligible( epc: EpcPropertyData, restrictions: PlanningRestrictions ) -> bool: From 0a2ed67e94d06f7e855571c15db27b8533ad3ccf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 26 Jun 2026 12:24:52 +0000 Subject: [PATCH 6/6] =?UTF-8?q?Harden=20Dwelling-Roof=20Cap=20on=20real=20?= =?UTF-8?q?data:=20positional=20segments,=20ground-floor=20basis=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three corrections found by re-running property 742003 end-to-end: - roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field the fixture happened to carry; key the centre/area lookup by array position. - Base the cap on ground_floor_area (the footprint the roof covers), not the greatest per-storey area; roof_area is the fallback. - Clamp the basis by total_floor_area: predicted EPCs borrow the structural template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from the predicted 55 m² (ADR-0029), so without the clamp the cap reads the template's larger footprint. Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests + orchestration threading + products green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../generators/solar_recommendation.py | 26 +++++++++++-- domain/modelling/solar_potential.py | 14 ++++--- .../modelling/test_solar_config_selection.py | 38 +++++++++++++++++++ .../domain/modelling/test_solar_potential.py | 4 +- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/domain/modelling/generators/solar_recommendation.py b/domain/modelling/generators/solar_recommendation.py index 5a855920..f5bf6e10 100644 --- a/domain/modelling/generators/solar_recommendation.py +++ b/domain/modelling/generators/solar_recommendation.py @@ -24,7 +24,7 @@ from datatypes.epc.domain.epc_property_data import ( ) from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP -from domain.building_geometry import roof_area +from domain.building_geometry import ground_floor_area, roof_area from domain.geospatial.planning_restrictions import PlanningRestrictions from domain.modelling.products import Products, SolarCostInputs from domain.modelling.measure_type import MeasureType @@ -338,11 +338,29 @@ def recommend_solar( def _dwelling_roof_area_m2(epc: EpcPropertyData) -> Optional[float]: """The dwelling's own roof plan area for the Dwelling-Roof Cap (ADR-0038), or None when the EPC has no MAIN building part to measure (the cap then - falls back to Google's `maxArrayPanelsCount` cap).""" + falls back to Google's `maxArrayPanelsCount` cap). + + The roof covers the **ground-floor footprint**, so the basis is + `ground_floor_area` (not the greatest per-storey area); `roof_area` is the + fallback when no ground (floor 0) dimension is lodged. + + Clamped by total floor area: a footprint can never exceed the whole + dwelling's floor area (= footprint × storeys), so the clamp is a no-op on a + consistent lodged EPC. It matters for a **predicted** EPC, whose building-part + geometry is the structural template's — decoupled from the predicted floor + area (ADR-0029) — so without it the cap would read the template neighbour's + (larger) footprint rather than the predicted dwelling's size.""" try: - return roof_area(epc, BuildingPartIdentifier.MAIN) + footprint: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN) except StopIteration: - return None + try: + footprint = roof_area(epc, BuildingPartIdentifier.MAIN) + except StopIteration: + return None + total: Optional[float] = epc.total_floor_area_m2 + if total is not None and total > 0.0: + return min(footprint, total) + return footprint def _solar_eligible( diff --git a/domain/modelling/solar_potential.py b/domain/modelling/solar_potential.py index cf9c13ad..2881c189 100644 --- a/domain/modelling/solar_potential.py +++ b/domain/modelling/solar_potential.py @@ -124,12 +124,16 @@ class SolarPotential: or "panelCapacityWatts" not in solar_potential ): return None - # Per-segment centre + area live on the top-level `roofSegmentStats`, - # keyed by `segmentIndex`; the per-config `roofSegmentSummaries` carry - # only the panel/orientation fields. Build the lookup once. + # Per-segment centre + area live on the top-level `roofSegmentStats`; + # the per-config `roofSegmentSummaries` carry only the panel/orientation + # fields. `roofSegmentSummaries[].segmentIndex` refers to the POSITION in + # `roofSegmentStats` (the entries are positional — Google omits an + # explicit `segmentIndex` field on them), so key the lookup by position. stats_by_index: dict[int, Mapping[str, Any]] = { - int(stats["segmentIndex"]): stats - for stats in solar_potential.get("roofSegmentStats", []) + index: stats + for index, stats in enumerate( + solar_potential.get("roofSegmentStats", []) + ) } def _segment(summary: Mapping[str, Any]) -> SolarRoofSegment: diff --git a/tests/domain/modelling/test_solar_config_selection.py b/tests/domain/modelling/test_solar_config_selection.py index dca22f69..da08fd1a 100644 --- a/tests/domain/modelling/test_solar_config_selection.py +++ b/tests/domain/modelling/test_solar_config_selection.py @@ -11,7 +11,14 @@ import json from pathlib import Path from typing import Any +from datatypes.epc.domain.epc_property_data import ( + BuildingPartIdentifier, + EpcPropertyData, + SapBuildingPart, + SapFloorDimension, +) from domain.modelling.generators.solar_recommendation import ( + _dwelling_roof_area_m2, select_conservative_configs, ) from domain.modelling.solar_potential import ( @@ -20,6 +27,37 @@ from domain.modelling.solar_potential import ( SolarRoofSegment, ) + +def _epc_with_roof(per_storey_areas: tuple[float, ...], total_floor_area: float) -> EpcPropertyData: + epc: EpcPropertyData = object.__new__(EpcPropertyData) + epc.total_floor_area_m2 = total_floor_area + part: SapBuildingPart = object.__new__(SapBuildingPart) + part.identifier = BuildingPartIdentifier.MAIN + dims: list[SapFloorDimension] = [] + for floor, area in enumerate(per_storey_areas): + fd: SapFloorDimension = object.__new__(SapFloorDimension) + fd.floor = floor # 0 = ground + fd.total_floor_area_m2 = area + dims.append(fd) + part.sap_floor_dimensions = dims + epc.sap_building_parts = [part] + return epc + + +def test_dwelling_roof_basis_is_ground_floor_clamped_by_total_floor_area() -> None: + # ADR-0038/0029: the basis is the ground-floor footprint, clamped by total + # floor area. A predicted EPC's building-part geometry is the structural + # template's (here a 118 m² ground floor), decoupled from the predicted + # floor area (55 m²); a footprint can't exceed total floor area, so the cap + # basis clamps to 55 — not the borrowed template's 118. + predicted = _epc_with_roof(per_storey_areas=(118.0,), total_floor_area=55.0) + assert _dwelling_roof_area_m2(predicted) == 55.0 + + # A consistent 2-storey house — ground 55, upper 50, total 105 — uses the + # GROUND floor (55), not the greatest per-storey area, and the clamp is inert. + lodged = _epc_with_roof(per_storey_areas=(55.0, 50.0), total_floor_area=105.0) + assert _dwelling_roof_area_m2(lodged) == 55.0 + _FIXTURE: Path = ( Path(__file__).resolve().parent / "fixtures" diff --git a/tests/domain/modelling/test_solar_potential.py b/tests/domain/modelling/test_solar_potential.py index 3c92914d..098b886e 100644 --- a/tests/domain/modelling/test_solar_potential.py +++ b/tests/domain/modelling/test_solar_potential.py @@ -122,9 +122,9 @@ def test_projection_enriches_segments_with_centre_and_area() -> None: # carry its centre + area — sourced from the top-level roofSegmentStats, # keyed by segmentIndex (the per-config roofSegmentSummaries omit them). insights = _insights() + # roofSegmentStats are positional — segmentIndex refers to the array index. stats_by_index = { - int(s["segmentIndex"]): s - for s in insights["solarPotential"]["roofSegmentStats"] + i: s for i, s in enumerate(insights["solarPotential"]["roofSegmentStats"]) } potential = SolarPotential.from_building_insights(insights)