diff --git a/CONTEXT.md b/CONTEXT.md index 7c325f66..d16f7336 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -238,6 +238,18 @@ _Avoid_: selected measures, default measures, optimal solution, recommended bund The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insulation`, `ashp`); one or more Recommendations reference the same Measure Type with property-specific cost and impact. _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. +_Avoid_: solar config (ambiguous — the API response vs the chosen array set), Google insights (the raw JSON, not the typed projection) + +**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`. +_Avoid_: solar bundle (PV is competing sized Options, not one fixed bundle like ASHP) + +**Solar PV Eligibility**: +The rule fixing whether the **Solar PV Recommendation** is offered (ADR-0026): a **house or bungalow**, **not listed and not heritage**, with **no existing PV**, and a **feasible Solar Potential** (the Google Solar API returned usable, non-north roof segments). Crucially a **conservation area does NOT block PV** — panels are offered (installed sympathetically), so the planning gate is `not blocks_internal` (listed/heritage only), **not** `blocks_external`; this is the opposite of an external fabric measure like EWI, and is deliberate (legacy + planning practice allow conservation-area PV on non-prominent roofs). Flats/maisonettes (building-level shared roof) are deferred. +_Avoid_: blocking conservation-area PV (only listed/heritage block), roof-area-from-floor-area estimate (eligibility uses the real Solar Potential) + **External Wall Insulation (EWI)** / **Internal Wall Insulation (IWI)**: The two competing **Measure Options** for insulating a *solid* (non-cavity) main wall — insulation fixed to the outside face (`wall_insulation_type = 1`) or the room side (`wall_insulation_type = 3`), both 100 mm at λ = 0.04 W/m·K; the calculator **derives** the post-insulation U-value from the type + thickness (the lodged cert carries no U-value). IWI additionally lowers the wall's thermal-mass parameter (changing heating demand); EWI does not. _Avoid_: solid wall insulation (that names the pair, not one Option), cladding, drylining diff --git a/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md b/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md new file mode 100644 index 00000000..eacfc78f --- /dev/null +++ b/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md @@ -0,0 +1,62 @@ +# Solar PV — Eligibility, Sizing, Overlay, and Costing + +The solar Recommendation Generator offers competing whole-array PV Options built from **real Google Solar imagery**, not an estimate. Unlike the heating bundles (ADR-0024/0025), the SAP scoring side is already mature (the calculator does Appendix M β-split, G4 diverter, SEG export, batteries, monthly E_PV); this ADR fixes the *recommendation* side: where the array config comes from, how it's sized, the new PV Overlay surface, and the composite cost. The bulk production path is **API-sourced** data; the Elmhurst before/after Summaries are overlay→SAP cascade pins, not the production input. + +## Decision + +**One "Solar PV" Recommendation, competing whole-array Options, the Optimiser picks at most one.** `recommend_solar(epc, products, solar_potential, restrictions)` emits a single Recommendation whose Options are **up to five conservatively-sized array configs × {no battery, battery}** (≤ 10 Options). A **free Optimiser candidate** — it raises SAP, but the Optimiser owns whether and at what size to install it. + +**The array config comes from a typed `SolarPotential`, never the EPC.** The EPC's `photovoltaic_arrays` is the dwelling's *existing* PV (empty for a non-PV dwelling); the thing we install is the **Google Solar potential**, fetched by Ingestion (raw `buildingInsights` JSON persisted as JSONB, never re-fetched) and projected into a strictly-typed `SolarPotential` that Modelling reads and threads into `recommend_solar` (mirroring how `planning_restrictions` is threaded). The Solar-API JSON → `SolarPotential` mapping is its own validated boundary. + +**Sizing: a five-config spread, conservatively capped.** Google returns a ladder of `solarPanelConfigs` (increasing panels). We filter to a conservative feasible set — **drop north-facing segments** (azimuth ∈ [−30°, 30°]) and cap usable panels at **~70% of `maxArrayPanelsCount`** (imagery misses obstructions; MCS wants a ~0.3 m edge setback) — then, if more than five remain, sample **five spanning min→max by energy generation** so the Optimiser gets a genuine size/cost choice. Energy generation is the size-suitability proxy. + +**The PV Overlay is a new (sixth) surface.** A flat `SolarOverlay`; `_fold_solar` writes onto `sap_energy_source`: +- `photovoltaic_arrays` — **one `PhotovoltaicArray` per roof segment**: `peak_power = panelsCount × panelCapacityWatts / 1000`; `orientation` = `azimuthDegrees` bucketed to the SAP octant (1=N…5=S…8=NW); `pitch` = `pitchDegrees` snapped to the RdSAP enum {0°→1, 30°→2, 45°→3, 60°→4, 90°→5}; `overshading` = the generation-calibrated code (below). +- `pv_diverter_present = True` — **folded in, conditional on a hot-water cylinder** (App G4 routes surplus PV to the cylinder immersion; a combi has nothing to divert to). +- `pv_connection` = connected to the dwelling's meter. +- **`is_dwelling_export_capable = True`** — set absolutely; an export meter is *ensured* post-install (drives the SEG export credit), regardless of the before. +- battery variant — `pv_batteries` at a single fixed representative capacity (**5 kWh**). + +**Overshading is derived from Google's expected generation, not just peak power.** Google's `yearlyEnergyDcKwh` per segment already encodes real orientation, tilt and shading from imagery. Per segment we compute the effective shading factor and snap it to the RdSAP bucket: +``` +G_ac = yearlyEnergyDcKwh × 0.955 # inverter DC→AC (GoogleSolarApi rate) +E_unshaded = 0.8 × kWp × S(orientation, pitch) # SAP App M/U, ZPV = 1 (reuse calculator S) +ZPV_target = G_ac / E_unshaded # orientation+tilt cancel → residual ≈ shading +overshading = nearest bucket {1:1.0, 2:0.8, 3:0.5, 4:0.35}, clamped: + ≥0.90→1 · 0.65–0.90→2 · 0.425–0.65→3 · <0.425→4 ; ZPV_target>1.0 → 1 +``` +Because both numerator (Google) and denominator (SAP `S`) already account for orientation/tilt, the ratio isolates shading; the calibration makes SAP's Appendix-M output track Google's imagery-derived generation with overshading as the only free lever. `ZPV_target` absorbs minor model slack (it is *effective* shading, which is what we want). The four cutpoints are documented constants, re-validated against Google example responses; RdSAP has **no "Severe" bucket** (the calculator's map is 1–4). + +**Eligibility encodes physical/legal installability only.** Offer ⇔ house/bungalow ∧ not listed ∧ not heritage ∧ no existing PV ∧ a feasible `SolarPotential`. **A conservation area does NOT block PV** (offered, installed sympathetically) — so the gate is `not restrictions.blocks_internal` (listed/heritage), the *same* predicate as ASHP, **not** `blocks_external`. Counter-intuitive for an external measure, but it matches the legacy and planning reality (panels routinely get consent in conservation areas on non-prominent roofs). Flats/maisonettes need building-level shared-roof coordination → deferred. + +**Costing: a composite per-dwelling cost from the EA-rate schedule, one price point.** +``` +PV bundle = pv_system(kWp band) # ECOPV07-13 EA rate, nearest kWp + + scaffolding(£900 + £450 × (elevations − 1)) # default 2 elevations + + enabling base (EICR £150 + DNO £50 + consumer unit £330) + + [diverter £980 if cylinder] + + [battery £2,000 if the with-battery variant] + → Cost(total, contingency_rate = 0.15) +``` +The **EA-rate** column is canonical; the PM (Domna) column adds principal contracting, priced separately. Conditional extras (bird protection, GSM, isolator) are absorbed by contingency, not itemised. Composed via the `Products` collection's `solar_bundle_cost`, mirroring ASHP (ADR-0025). + +## Considered options + +- **One optimally-sized array (take-it-or-leave-it).** Rejected: PV size is a genuine cost/benefit dial the Optimiser should own (CONTEXT already models PV as competing kWp Options); a single array removes that choice. +- **Read the potential array off the EPC's `photovoltaic_arrays`.** Rejected: conflates *existing* PV with *potential* PV. The potential comes from the Solar API as a separate typed input. +- **Default every array to "no shading."** Rejected in favour of generation-calibrated overshading — the Solar API carries real expected generation, so we use it rather than assume. +- **Derive shading from raw `sunshineQuantiles ÷ maxSunshineHoursPerYear`.** Rejected: the max is the best-oriented point, so the ratio conflates shading with orientation. Dividing Google's generation by SAP's own `S` cancels orientation/tilt cleanly. +- **Full-roof / 80% coverage (legacy).** Rejected as too optimistic — imagery misses obstructions; 70% + north-exclusion is the conservative cap. +- **Multiple product price points (legacy).** Rejected: it exploded the option count. One price point per config from the new rate sheet. +- **The PM (Domna) price column.** Rejected as the install cost: it bundles principal contracting, a separately-priced concern. +- **Blocking PV in conservation areas (`blocks_external`).** Rejected: legacy and planning practice allow sympathetic conservation-area installs; only listed/heritage block. + +## Consequences + +- A new **`SolarPotential`** domain type + a strictly-typed Google `buildingInsights` projection (per the API doc); the existing `SolarRepository`/JSONB store already anticipates it. `ModellingOrchestrator` reads it and threads it into `recommend_solar`. +- `EpcSimulation` grows its **sixth** overlay surface (`solar: Optional[SolarOverlay]`) + `_fold_solar` onto `sap_energy_source` (`photovoltaic_arrays`, `pv_diverter_present`, `pv_connection`, `is_dwelling_export_capable`, `pv_batteries`). +- `Products` gains `solar_bundle_cost`; the EA rate lines land in the committed costs file (ADR-0025 pattern). PV priced under the legacy `MEASURE_MAP` name `solar_pv` (contingency 0.15). +- **Flagged estimates** (replace from the DB / cleaner data): the **£2,000 battery** and **5 kWh** capacity are not on the rate cards; the overshading cutpoints await calibration against Google example responses. +- A **cylinder `"No Insulation"` mapper gap** blocks parsing the example certs → fix as a slice (maps to `cylinder_insulation_type = None`; the API path hits it too). +- Validation: Elmhurst before/after Summaries pin the overlay→calculator cascade across **orientation / pitch / overshading** (the varied test vectors); a **before with no export meter** is wanted to pin the export-capable flip; the Solar-API→`SolarPotential`→overshading mapping is pinned against Google example responses separately. +- **Deferred (named gaps):** building-level (shared-roof) flats; existing-PV top-up; the sympathetic / non-street-facing siting caveat (no caveat field, no reliable street geometry); size-to-dwelling battery; `sunshineQuantiles` cutpoint calibration; the financial-analysis / ROI data Google also returns (not needed for SAP).