Slice S0380.23: RdSAP §11.1 b) PV %-of-roof-area synthesis — closes cert 6835 -13.37 → +0.72

RdSAP 10 specification page 60 §11.1 b) (Photovoltaics): "If the kWp
(or DNC) is not known use the following: PV area is roof area for
heat loss (before amendment for any room-in-roof), times percent of
roof area covered by PVs, and if pitched roof divided by cos(35°).
If there is an extension, the roof area is adjusted by the cosine
factor only for those parts having a pitched roof. kWp is 0.12 ×
PV area. If not provided in the RdSAP data set then facing South,
pitch 30°, modest overshading."

Wire-through:
  1. `Renewables.pv_percent_roof_area: Optional[int]` — new field on
     the Elmhurst site-notes dataclass.
  2. Elmhurst extractor `_extract_renewables` parses Summary §19.0
     row "Proportion of roof area" (cert 6835: "40").
  3. Elmhurst mapper `from_elmhurst_site_notes` surfaces it through
     `epc.sap_energy_source.photovoltaic_supply.none_or_no_details
     .percent_roof_area` — mirrors the API mapper's lodgement shape.
  4. `cert_to_inputs._synthesize_pv_arrays_from_percent_roof_area`
     synthesizes a single PV array via the spec formula when
     `photovoltaic_arrays` is empty AND a `percent_roof_area > 0`
     lodgement is present. Fires inside
     `_pv_generation_kwh_per_yr`, so both rating + demand cascades
     pick it up.

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 20 → 20
  ±0.07..0.5:   1 → 1
  ±0.5..1:      1 → **2**  (cert 6835 closes -13.37 → +0.72)
  ±1..5:        1 → 1
  ±5+:          2 → **1**  (-1: cert 6835 moves out of big-gap band)

Cert 6835 verified end-to-end:
  - kWp = 0.12 × 36.9 × 0.40 / cos(35°) = 2.1622
    (worksheet "Cells Peak = 2.16, Orientation = South, Elevation =
    30°, Overshading = Modest")
  - Cascade PV generation = 1493.88 kWh/yr vs worksheet 1492.33
    (<0.1% delta — kWp-rounding artefact).
  - Cascade SAP 80.92 vs worksheet 80.20 (+0.72, in the ±0.5..1 band).

The residual +0.72 likely traces to the PV-cost cascade's
used-in-dwelling / exported split rather than the synthesis — the
kWh figure is within rounding of the worksheet.

Pyright per-file: net-zero
  - cert_to_inputs.py 35 → 35
  - test_cert_to_inputs.py 13 → 13
  - mapper.py 32 → 32
  - elmhurst_site_notes.py 0 → 0
  - elmhurst_extractor.py 0 → 0

Tests: 702 → 703 pass (+1 new RdSAP §11.1 b synthesis test), 10
expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 09:35:38 +00:00 committed by Jun-te Kim
parent 7136edf2fb
commit 9a091234cf
5 changed files with 160 additions and 1 deletions

View file

@ -1164,6 +1164,13 @@ class ElmhurstSiteNotesExtractor:
hydro_raw = self._next_val("Electricity generated [kWh/year]")
hydro = float(hydro_raw) if hydro_raw else 0.0
# RdSAP 10 §11.1 b): the Summary §19.0 may lodge a "% of roof
# area" row when the surveyor doesn't capture detailed kWp /
# orientation / pitch. `_int_val` returns 0 when the label is
# absent (cert lodges detailed pv_arrays instead) — collapse to
# None so downstream can distinguish "no PV" from "PV via %
# roof area path".
pv_pct = self._int_val("Proportion of roof area")
return Renewables(
solar_water_heating=self._bool_val("Solar Water Heating"),
wwhrs_present=self._bool_val("Is WWHRS present in the property?"),
@ -1174,6 +1181,7 @@ class ElmhurstSiteNotesExtractor:
wind_turbines_terrain_type=terrain,
hydro_electricity_generated_kwh=hydro,
pv_arrays=self._extract_pv_arrays(),
pv_percent_roof_area=pv_pct if pv_pct > 0 else None,
)
def _extract_pv_arrays(self) -> List[ElmhurstPvArray]:

View file

@ -339,6 +339,20 @@ class EpcPropertyDataMapper:
wind_turbines_terrain_type=survey.renewables.wind_turbines_terrain_type,
electricity_smart_meter_present=survey.meters.electricity_smart_meter,
photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables),
# RdSAP 10 §11.1 b): when the cert lodges only a "% of
# roof area" PV figure (no detailed kWp / orientation),
# surface it through `photovoltaic_supply` so the
# cascade can synthesize an array via the 0.12 × area
# formula. Cohort-2 cert 6835 hits this path.
photovoltaic_supply=(
PhotovoltaicSupply(
none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails(
percent_roof_area=survey.renewables.pv_percent_roof_area,
)
)
if survey.renewables.pv_percent_roof_area is not None
else None
),
),
sap_building_parts=_map_elmhurst_building_parts(
survey, is_flat=property_type.lower() == "flat",

View file

@ -267,6 +267,13 @@ class Renewables:
pv_arrays: List["ElmhurstPvArray"] = field(
default_factory=lambda: [] # type: ignore[reportUnknownLambdaType]
)
# RdSAP 10 §11.1 b) "Proportion of roof area" PV lodgement —
# populated when the surveyor lodges only a % roof coverage
# (no detailed kWp / orientation / pitch). Cohort-2 cert 6835
# surfaces this path: Summary §19.0 row "Proportion of roof area
# = 40". The cascade then synthesizes a single PV array with
# kWp = 0.12 × PV area, defaulting to South / 30° / Modest.
pv_percent_roof_area: Optional[int] = None
@dataclass

View file

@ -49,6 +49,7 @@ Reference: RdSAP 10 specification (10-06-2025); SAP 10.2 specification
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Callable, Final, Literal, Optional
@ -802,13 +803,89 @@ def _pv_generation_kwh_per_yr(
process to each and sum the monthly electricity generation figures."
`climate` selects UK-average (region 0) for the rating cascade or
postcode-specific (PCDB Table 172) for the demand cascade.
Falls back to RdSAP 10 §11.1 b) when the cert lodges only a "% of
roof area" PV figure (no detailed kWp): synthesize a single PV
array with kWp = 0.12 × PV area, South orientation, 30° pitch,
Modest overshading.
"""
arrays = epc.sap_energy_source.photovoltaic_arrays
if not arrays:
arrays = _synthesize_pv_arrays_from_percent_roof_area(epc)
if not arrays:
return 0.0
return sum(_pv_array_generation_kwh_per_yr(a, climate) for a in arrays)
# RdSAP 10 §11.1 b): when the kWp is not lodged but the cert lodges a
# "% of roof area" PV figure, derive the PV peak power as
# `0.12 × PV area`, with PV area being the dwelling's roof area for
# heat loss (Σ top-floor areas across BPs, divided by cos(35°) for
# pitched parts), times the percent coverage. Defaults: South, 30°,
# Modest overshading.
_PV_PEAK_POWER_KWP_PER_M2: Final[float] = 0.12
_PV_PITCHED_ROOF_COS_FACTOR_DEG: Final[float] = 35.0
_PV_PERCENT_ROOF_AREA_DEFAULT_ORIENTATION_CODE: Final[int] = 5 # South
_PV_PERCENT_ROOF_AREA_DEFAULT_PITCH_CODE: Final[int] = 2 # 30°
_PV_PERCENT_ROOF_AREA_DEFAULT_OVERSHADING_CODE: Final[int] = 2 # Modest
def _synthesize_pv_arrays_from_percent_roof_area(
epc: EpcPropertyData,
) -> Optional[list[PhotovoltaicArray]]:
"""RdSAP 10 §11.1 b) "Proportion of roof area" PV synthesis.
The spec text (RdSAP 10 specification, page 60):
"If the kWp (or DNC) is not known use the following: PV area is
roof area for heat loss (before amendment for any room-in-roof),
times percent of roof area covered by PVs, and if pitched roof
divided by cos(35°). If there is an extension, the roof area is
adjusted by the cosine factor only for those parts having a
pitched roof. kWp is 0.12 × PV area."
Returns None when the percent_roof_area lodgement is missing or
zero, or when no building-part geometry is available. Otherwise
returns a single-array list (RdSAP's "% of roof area" path lodges
one aggregate figure, not per-array).
"""
pv_supply = epc.sap_energy_source.photovoltaic_supply
if pv_supply is None:
return None
pct = pv_supply.none_or_no_details.percent_roof_area
if pct <= 0:
return None
parts = epc.sap_building_parts or []
if not parts:
return None
cos_factor = math.cos(math.radians(_PV_PITCHED_ROOF_COS_FACTOR_DEG))
pv_area_m2 = 0.0
for part in parts:
if not part.sap_floor_dimensions:
continue
# Roof area for heat loss per RdSAP 10 §3.8 = the greatest of
# the floor areas on each level (i.e. the top floor's area).
top_floor_area = max(
(fd.total_floor_area_m2 or 0.0) for fd in part.sap_floor_dimensions
)
roof_type = (part.roof_construction_type or "").lower()
is_pitched = "pitched" in roof_type or "sloping" in roof_type
bp_pv_area = top_floor_area * (pct / 100.0)
if is_pitched:
bp_pv_area /= cos_factor
pv_area_m2 += bp_pv_area
kwp = _PV_PEAK_POWER_KWP_PER_M2 * pv_area_m2
if kwp <= 0:
return None
return [
PhotovoltaicArray(
peak_power=kwp,
pitch=_PV_PERCENT_ROOF_AREA_DEFAULT_PITCH_CODE,
orientation=_PV_PERCENT_ROOF_AREA_DEFAULT_ORIENTATION_CODE,
overshading=_PV_PERCENT_ROOF_AREA_DEFAULT_OVERSHADING_CODE,
),
]
def _pv_export_credit_gbp_per_kwh() -> float:
"""PV cost credit per kWh generated. Per ADR-0010 §10 the rating
cascade uses RdSAP10 Table 32 prices; code 60 (PV export to grid)

View file

@ -18,7 +18,11 @@ from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import MainHeatingDetail, PhotovoltaicArray
from datatypes.epc.domain.epc_property_data import (
MainHeatingDetail,
PhotovoltaicArray,
SapFloorDimension,
)
from domain.sap10_ml.tests._fixtures import (
make_building_part,
@ -467,6 +471,55 @@ def test_pv_generation_differentiates_arrays_by_pitch() -> None:
assert tilted_kwh > vertical_kwh
def test_pv_generation_synthesizes_array_from_percent_roof_area_per_rdsap_11_1() -> None:
"""RdSAP 10 §11.1 b) (page 60): "If the kWp (or DNC) is not known…
PV area is roof area for heat loss times percent of roof area
covered by PVs, and if pitched roof divided by cos(35°) kWp is
0.12 × PV area." Defaults to South, 30° pitch, Modest overshading.
Cohort-2 cert 6835 (Semi-Detached bungalow, single storey, TFA 36.9
, pitched roof) lodges only "Proportion of roof area = 40" the
cascade must synthesize a 2.16 kWp array (= 36.9 × 0.40 / cos(35°)
× 0.12) and route the generation through the Appendix M cost
cascade. Verified against the worksheet's "Cells Peak = 2.16,
Orientation = South, Elevation = 30°, Overshading = Modest" line.
"""
from datatypes.epc.domain.epc_property_data import (
PhotovoltaicSupply, PhotovoltaicSupplyNoneOrNoDetails,
)
# Arrange — single-storey 36.9 m² bungalow, pitched roof, 40% of
# roof area covered by PV. No explicit kWp lodged.
epc = _typical_semi_detached_epc()
epc.total_floor_area_m2 = 36.9
epc.sap_building_parts[0].roof_construction_type = (
"Pitched (slates/tiles), access to loft"
)
epc.sap_building_parts[0].sap_floor_dimensions = [
SapFloorDimension(
floor=0, room_height_m=2.5, total_floor_area_m2=36.9,
party_wall_length_m=0.0, heat_loss_perimeter_m=23.17,
),
]
epc.sap_energy_source.photovoltaic_arrays = None
epc.sap_energy_source.photovoltaic_supply = PhotovoltaicSupply(
none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails(
percent_roof_area=40,
),
)
# Act
gen_kwh = cert_to_inputs(epc).pv_generation_kwh_per_yr
# Assert — kWp = 0.12 × 36.9 × 0.40 / cos(35°) = 2.1622. With S =
# ~862 kWh/m²/yr at South 30° UK-avg (Appendix U3.3) and ZPV = 0.8
# (Modest), annual EPV = 0.8 × 2.1622 × 862 × 0.8 ≈ 1490 kWh/yr —
# within 1% of the worksheet's 1492.33 kWh/yr.
assert 1450.0 < gen_kwh < 1530.0, (
f"expected ~1490 kWh/yr (per RdSAP §11.1 b synthesis), got {gen_kwh:.2f}"
)
def test_pv_generation_uses_postcode_climate_in_demand_cascade() -> None:
# Arrange — SAP 10.2 Appendix U: rating cascade uses UK-average
# climate (region 0); demand cascade uses postcode-specific climate