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.