mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
commit
015ea0a293
6 changed files with 365 additions and 11 deletions
12
CONTEXT.md
12
CONTEXT.md
|
|
@ -281,11 +281,19 @@ The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insul
|
||||||
_Avoid_: measure (ambiguous), category
|
_Avoid_: measure (ambiguous), category
|
||||||
|
|
||||||
**Solar Potential**:
|
**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)
|
_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**:
|
**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)
|
_Avoid_: solar bundle (PV is competing sized Options, not one fixed bundle like ASHP)
|
||||||
|
|
||||||
**Solar PV Eligibility**:
|
**Solar PV Eligibility**:
|
||||||
|
|
|
||||||
80
docs/adr/0038-solar-pv-bounded-to-dwelling-roof.md
Normal file
80
docs/adr/0038-solar-pv-bounded-to-dwelling-roof.md
Normal 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.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.
|
||||||
|
|
@ -13,6 +13,7 @@ selection, the overlay and `recommend_solar` land in later slices.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from datatypes.epc.domain.epc_property_data import (
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
|
@ -21,7 +22,9 @@ from datatypes.epc.domain.epc_property_data import (
|
||||||
PvBatteries,
|
PvBatteries,
|
||||||
PvBattery,
|
PvBattery,
|
||||||
)
|
)
|
||||||
|
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||||
from datatypes.epc.domain.field_mappings import PROPERTY_TYPE_LOOKUP
|
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.geospatial.planning_restrictions import PlanningRestrictions
|
||||||
from domain.modelling.products import Products, SolarCostInputs
|
from domain.modelling.products import Products, SolarCostInputs
|
||||||
from domain.modelling.measure_type import MeasureType
|
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).
|
# At most this many competing configs go to the Optimiser (× battery on/off).
|
||||||
_MAX_CONFIGS = 5
|
_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
|
# 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
|
# `GoogleSolarApi.dc_to_ac_rate` uses (mid of the 93–98% range); distinct from
|
||||||
# the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback.
|
# the unrelated no-API `MEDIAN_WATTAGE_TO_AC` fallback.
|
||||||
|
|
@ -139,13 +184,25 @@ def _drop_north_segments(config: SolarPanelConfiguration) -> SolarPanelConfigura
|
||||||
|
|
||||||
def select_conservative_configs(
|
def select_conservative_configs(
|
||||||
potential: SolarPotential,
|
potential: SolarPotential,
|
||||||
|
dwelling_roof_area_m2: Optional[float] = None,
|
||||||
) -> tuple[SolarPanelConfiguration, ...]:
|
) -> tuple[SolarPanelConfiguration, ...]:
|
||||||
"""Choose up to five conservatively-sized array configs for the Optimiser
|
"""Choose up to five conservatively-sized array configs for the Optimiser
|
||||||
(ADR-0026): drop north-facing planes, cap usable panels at ~70% of
|
(ADR-0026): drop north-facing planes, cap usable panels at ~70% of
|
||||||
maxArrayPanelsCount, then sample five spanning min→max by expected
|
maxArrayPanelsCount, then sample five spanning min→max by expected
|
||||||
generation (the size-suitability proxy) so the size/cost choice is genuine.
|
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
|
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] = [
|
feasible: list[SolarPanelConfiguration] = [
|
||||||
trimmed
|
trimmed
|
||||||
for config in potential.configurations
|
for config in potential.configurations
|
||||||
|
|
@ -264,7 +321,7 @@ def recommend_solar(
|
||||||
if solar_potential is None or not _solar_eligible(epc, restrictions):
|
if solar_potential is None or not _solar_eligible(epc, restrictions):
|
||||||
return None
|
return None
|
||||||
configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs(
|
configs: tuple[SolarPanelConfiguration, ...] = select_conservative_configs(
|
||||||
solar_potential
|
solar_potential, _dwelling_roof_area_m2(epc)
|
||||||
)
|
)
|
||||||
if not configs:
|
if not configs:
|
||||||
return None
|
return None
|
||||||
|
|
@ -278,6 +335,34 @@ def recommend_solar(
|
||||||
return Recommendation(surface=_SOLAR_SURFACE, options=tuple(options))
|
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(
|
def _solar_eligible(
|
||||||
epc: EpcPropertyData, restrictions: PlanningRestrictions
|
epc: EpcPropertyData, restrictions: PlanningRestrictions
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,14 @@ class SolarRoofSegment:
|
||||||
azimuth_degrees: float
|
azimuth_degrees: float
|
||||||
pitch_degrees: float
|
pitch_degrees: float
|
||||||
yearly_energy_dc_kwh: 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
|
@property
|
||||||
def sap_orientation(self) -> int:
|
def sap_orientation(self) -> int:
|
||||||
|
|
@ -94,6 +102,11 @@ class SolarPotential:
|
||||||
panel_capacity_watts: float
|
panel_capacity_watts: float
|
||||||
max_array_panels_count: int
|
max_array_panels_count: int
|
||||||
configurations: tuple[SolarPanelConfiguration, ...]
|
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
|
@classmethod
|
||||||
def from_building_insights(
|
def from_building_insights(
|
||||||
|
|
@ -111,18 +124,50 @@ class SolarPotential:
|
||||||
or "panelCapacityWatts" not in solar_potential
|
or "panelCapacityWatts" not in solar_potential
|
||||||
):
|
):
|
||||||
return None
|
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(
|
configurations: tuple[SolarPanelConfiguration, ...] = tuple(
|
||||||
SolarPanelConfiguration(
|
SolarPanelConfiguration(
|
||||||
panels_count=int(config["panelsCount"]),
|
panels_count=int(config["panelsCount"]),
|
||||||
yearly_energy_dc_kwh=float(config["yearlyEnergyDcKwh"]),
|
yearly_energy_dc_kwh=float(config["yearlyEnergyDcKwh"]),
|
||||||
segments=tuple(
|
segments=tuple(
|
||||||
SolarRoofSegment(
|
_segment(summary)
|
||||||
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"]),
|
|
||||||
)
|
|
||||||
for summary in config.get("roofSegmentSummaries", [])
|
for summary in config.get("roofSegmentSummaries", [])
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -132,4 +177,14 @@ class SolarPotential:
|
||||||
panel_capacity_watts=float(solar_potential["panelCapacityWatts"]),
|
panel_capacity_watts=float(solar_potential["panelCapacityWatts"]),
|
||||||
max_array_panels_count=int(solar_potential["maxArrayPanelsCount"]),
|
max_array_panels_count=int(solar_potential["maxArrayPanelsCount"]),
|
||||||
configurations=configurations,
|
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
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,14 @@ import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from datatypes.epc.domain.epc_property_data import (
|
||||||
|
BuildingPartIdentifier,
|
||||||
|
EpcPropertyData,
|
||||||
|
SapBuildingPart,
|
||||||
|
SapFloorDimension,
|
||||||
|
)
|
||||||
from domain.modelling.generators.solar_recommendation import (
|
from domain.modelling.generators.solar_recommendation import (
|
||||||
|
_dwelling_roof_area_m2,
|
||||||
select_conservative_configs,
|
select_conservative_configs,
|
||||||
)
|
)
|
||||||
from domain.modelling.solar_potential import (
|
from domain.modelling.solar_potential import (
|
||||||
|
|
@ -20,6 +27,37 @@ from domain.modelling.solar_potential import (
|
||||||
SolarRoofSegment,
|
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 = (
|
_FIXTURE: Path = (
|
||||||
Path(__file__).resolve().parent
|
Path(__file__).resolve().parent
|
||||||
/ "fixtures"
|
/ "fixtures"
|
||||||
|
|
@ -111,6 +149,57 @@ def test_cap_excludes_configs_above_seventy_percent() -> None:
|
||||||
assert [c.panels_count for c in configs] == [6]
|
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:
|
def test_all_north_or_empty_yields_no_configs() -> None:
|
||||||
# Arrange — every plane faces north
|
# Arrange — every plane faces north
|
||||||
potential = SolarPotential(
|
potential = SolarPotential(
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,43 @@ def test_projection_first_config_single_segment() -> None:
|
||||||
assert segment.sap_pitch_code == 2 # ~32° → 30°
|
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:
|
def test_projection_largest_config_spans_all_segments() -> None:
|
||||||
# Arrange
|
# Arrange
|
||||||
insights = _insights()
|
insights = _insights()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue