Model/docs/adr/0026-solar-pv-eligibility-sizing-overlay-costing.md
Khalim Conn-Kowlessar 222704cbc2 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>
2026-06-08 09:44:02 +00:00

8.8 KiB
Raw Blame History

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_arraysone 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 = Truefolded 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.650.90→2 · 0.4250.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 14).

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).