mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
docs(modelling): ADR-0026 solar PV eligibility, sizing, overlay, costing
Designed via grill-with-docs. API-first (Google Solar -> typed SolarPotential): one Solar PV Recommendation, up to 5 conservatively-sized configs x battery on/off; new PV overlay surface (per-roof-segment arrays, diverter conditional on cylinder, export-capable ensured); overshading generation-calibrated from the API's expected generation; eligibility offers conservation areas (only listed/ heritage block); composite costing from EA rates (contingency 0.15). CONTEXT gains Solar Potential, Solar PV Recommendation, Solar PV Eligibility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
11fb82b485
commit
222704cbc2
2 changed files with 74 additions and 0 deletions
12
CONTEXT.md
12
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
|
||||
|
|
|
|||
62
docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md
Normal file
62
docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md
Normal file
|
|
@ -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).
|
||||
Loading…
Add table
Reference in a new issue