Merge pull request #1338 from Hestia-Homes/fix/solar-pv-dwelling-roof-cap

fix(modelling): bound Solar PV array to the dwelling's own roof (ADR-0038)
This commit is contained in:
Jun-te Kim 2026-06-26 13:32:55 +01:00 committed by GitHub
commit 015ea0a293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 365 additions and 11 deletions

View file

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

View file

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

View file

@ -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 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
@ -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 9398% 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 minmax 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,34 @@ 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).
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:
footprint: float = ground_floor_area(epc, BuildingPartIdentifier.MAIN)
except StopIteration:
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(
epc: EpcPropertyData, restrictions: PlanningRestrictions
) -> bool:

View file

@ -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,50 @@ class SolarPotential:
or "panelCapacityWatts" not in solar_potential
):
return None
# 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]] = {
index: stats
for index, stats in enumerate(
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 +177,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
),
)

View file

@ -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"
@ -111,6 +149,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(

View file

@ -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()
# roofSegmentStats are positional — segmentIndex refers to the array index.
stats_by_index = {
i: s for i, s in enumerate(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()