mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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:
parent
7136edf2fb
commit
9a091234cf
5 changed files with 160 additions and 1 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
m², 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue