Merge pull request #1169 from Hestia-Homes/feature/per-cert-mapper-validation

Feature/per cert mapper validation
This commit is contained in:
KhalimCK 2026-06-05 11:50:11 +01:00 committed by GitHub
commit 3bdfa0287c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 7616 additions and 309 deletions

View file

@ -269,8 +269,8 @@ class ElmhurstSiteNotesExtractor:
)
def _wall_details_from_lines(self, lines: List[str]) -> WallDetails:
thickness_raw = self._local_val(lines, "Wall Thickness")
thickness_mm = (
thickness_raw: Optional[str] = self._local_val(lines, "Wall Thickness")
thickness_mm: Optional[int] = (
int(thickness_raw.split()[0]) if thickness_raw else None
)
# Composite / retrofit insulation thickness — Summary §7.0
@ -280,12 +280,8 @@ class ElmhurstSiteNotesExtractor:
# is local-scoped inside the §7 block so it does not collide
# with the §8 Roofs / §9 Floors blocks. None when the PDF
# omits the line (no retrofit lodged).
ins_thickness_raw = self._local_val(lines, "Insulation Thickness")
insulation_thickness_mm = (
int(ins_thickness_raw.split()[0])
if ins_thickness_raw and ins_thickness_raw.split()[0].isdigit()
else None
)
ins_thickness_raw: Optional[str] = self._local_val(lines, "Insulation Thickness")
insulation_thickness_mm: Optional[int] = self._parse_thickness_mm(ins_thickness_raw)
return WallDetails(
wall_type=self._local_str(lines, "Type"),
insulation=self._local_str(lines, "Insulation"),
@ -322,12 +318,10 @@ class ElmhurstSiteNotesExtractor:
continue
if area <= 0:
continue
thickness_raw = self._local_val(lines, f"Alternative Wall {n} Thickness")
thickness_mm = (
int(thickness_raw.split()[0])
if thickness_raw and thickness_raw.split()[0].isdigit()
else None
thickness_raw: Optional[str] = self._local_val(
lines, f"Alternative Wall {n} Thickness"
)
thickness_mm: Optional[int] = self._parse_thickness_mm(thickness_raw)
result.append(AlternativeWall(
area_m2=area,
wall_type=self._local_str(lines, f"Alternative Wall {n} Type"),
@ -356,11 +350,25 @@ class ElmhurstSiteNotesExtractor:
lines = [l.strip() for l in main_body.splitlines() if l.strip()]
return self._wall_details_from_lines(lines)
@staticmethod
def _parse_thickness_mm(raw: Optional[str]) -> Optional[int]:
"""Parse an Elmhurst "Insulation Thickness" cell ("100 mm",
"400+ mm") to integer mm. The bucket-cap "400+ mm" (Table 17/18
max tabulated row) carries a trailing "+" that a bare
`.split()[0].isdigit()` test rejects strip to the leading
digits so the cap parses through to the cascade with its numeric
value (simulated case 5: roof "400+ mm" was silently dropped
u_roof fell back to the age-J default 0.16 instead of the
300mm+ value 0.11). Returns None when the cell is absent or
carries no leading number ("As Built", "N None")."""
if not raw:
return None
match = re.match(r"\d+", raw.strip())
return int(match.group()) if match else None
def _roof_details_from_lines(self, lines: List[str]) -> RoofDetails:
thickness_raw = self._local_val(lines, "Insulation Thickness")
thickness_mm = (
int(thickness_raw.split()[0]) if thickness_raw and thickness_raw.split()[0].isdigit() else None
)
thickness_raw: Optional[str] = self._local_val(lines, "Insulation Thickness")
thickness_mm: Optional[int] = self._parse_thickness_mm(thickness_raw)
insulation = self._local_str(lines, "Insulation")
# The Summary PDF omits the "Insulation Thickness" line entirely
# when no retrofit insulation is lodged (e.g. "Insulation: N None"
@ -384,18 +392,14 @@ class ElmhurstSiteNotesExtractor:
return self._roof_details_from_lines(lines)
def _floor_details_from_lines(self, lines: List[str]) -> FloorDetails:
u_val_raw = self._local_val(lines, "Default U-value")
default_u = float(u_val_raw) if u_val_raw else None
u_val_raw: Optional[str] = self._local_val(lines, "Default U-value")
default_u: Optional[float] = float(u_val_raw) if u_val_raw else None
# RdSAP 10 §5.13 Table 20 — retro-fitted upper floors lodge an
# "Insulation Thickness: NNN mm" cell so the cascade can route
# via the per-thickness column. Mirror of the §8 roof extractor
# at `_roof_details_from_lines`.
thickness_raw = self._local_val(lines, "Insulation Thickness")
thickness_mm = (
int(thickness_raw.split()[0])
if thickness_raw and thickness_raw.split()[0].isdigit()
else None
)
thickness_raw: Optional[str] = self._local_val(lines, "Insulation Thickness")
thickness_mm: Optional[int] = self._parse_thickness_mm(thickness_raw)
return FloorDetails(
location=self._local_str(lines, "Location"),
floor_type=self._local_str(lines, "Type"),
@ -668,12 +672,24 @@ class ElmhurstSiteNotesExtractor:
# even when the main wall fields are inherited; merge
# them into the inherited WallDetails so the bp carries
# them through to its SapBuildingPart.
#
# "As Main Wall: Yes" inherits the EXTERNAL wall
# construction only — the PARTY WALL TYPE is lodged
# separately in the extension's §7 block and may differ
# (cert 001431: Main "CU Cavity masonry unfilled" U=0.5,
# 1st Extension "U Unable to determine" → RdSAP default
# U=0.25). Read the extension's own party wall type when
# present; fall back to the main's only when absent.
ext_party_wall_type = (
self._local_str(wall_lines, "Party Wall Type")
or main_walls.party_wall_type
)
walls = WallDetails(
wall_type=main_walls.wall_type,
insulation=main_walls.insulation,
thickness_unknown=main_walls.thickness_unknown,
u_value_known=main_walls.u_value_known,
party_wall_type=main_walls.party_wall_type,
party_wall_type=ext_party_wall_type,
thickness_mm=main_walls.thickness_mm,
insulation_thickness_mm=main_walls.insulation_thickness_mm,
alternative_walls=self._alternative_walls_from_lines(wall_lines),
@ -799,6 +815,12 @@ class ElmhurstSiteNotesExtractor:
"North", "South", "East", "West", "NE", "NW", "SE", "SW",
})
_BP_INLINE_TOKENS = frozenset({"Main"}) # "Extension" only appears as suffix
# A room-in-roof window (rooflight) lodges its §11 "Location" cell as
# "Roof of Room in Roof", which the layout preprocessor wraps onto two
# tokens ("Roof of Room" in the prefix block, "in Roof" in the suffix).
# Detected so the window routes to a roof window (worksheet (27a))
# and the tokens don't leak into the glazing-type phrase.
_ROOF_OF_ROOM_LOCATION_TOKENS = frozenset({"Roof of Room", "in Roof"})
# The Elmhurst Summary PDF lodges each window's glazing-type as a
# capitalised phrase like "Double between 2002" / "Double with unknown"
# / "Single" / "Triple" / "Secondary". The first token of that phrase
@ -1018,6 +1040,18 @@ class ElmhurstSiteNotesExtractor:
before = [lines[j].strip() for j in range(before_start, data_idx) if lines[j].strip()]
after = [lines[j].strip() for j in range(manuf_idx + 4, after_end) if lines[j].strip()]
# Room-in-roof windows lodge their location as "Roof of Room in
# Roof" (wrapped across the prefix/suffix blocks). Detect it, pull
# those tokens out so they don't contaminate the glazing-type
# phrase, and override the wall-keyed `location` with the roof-of-
# room marker the roof-window classifier keys on.
if any(
t in self._ROOF_OF_ROOM_LOCATION_TOKENS for t in (*before, *after)
):
location = "Roof of Room"
before = [t for t in before if t not in self._ROOF_OF_ROOM_LOCATION_TOKENS]
after = [t for t in after if t not in self._ROOF_OF_ROOM_LOCATION_TOKENS]
glazing_type, building_part, orientation = self._compose_window_descriptors(
before=before,
after=after,
@ -1326,6 +1360,8 @@ class ElmhurstSiteNotesExtractor:
fan_assisted_flue=self._local_bool(lines, "Fan Assisted Flue"),
percentage_of_heat=pct,
main_heating_sap_code=main_heating_sap_code,
heat_emitter=self._local_str(lines, "Heat Emitter"),
heating_controls_sap=self._local_str(lines, "Main Heating Controls Sap"),
)
def _extract_community_heating(self) -> Optional[CommunityHeating]:
@ -1516,6 +1552,7 @@ class ElmhurstSiteNotesExtractor:
wind_turbines_terrain_type=terrain,
hydro_electricity_generated_kwh=hydro,
pv_arrays=self._extract_pv_arrays(),
pv_diverter_present=self._bool_val("Diverter present"),
pv_percent_roof_area=pv_pct if pv_pct > 0 else None,
solar_hw_collector_orientation=solar_orientation,
solar_hw_collector_pitch_deg=solar_pitch,

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -42,6 +42,7 @@ from datatypes.epc.domain.mapper import (
EpcPropertyDataMapper,
UnmappedApiCode,
UnmappedElmhurstLabel,
_elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage]
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
@ -1471,6 +1472,68 @@ def test_summary_000474_double_glazed_windows_route_to_code_3() -> None:
)
def test_elmhurst_glazing_label_full_coverage_per_sap10_table_6b() -> None:
# Arrange — the double_glazing recommendation fixture (Summary_001431)
# exercises every RdSAP-21 §11 glazing-type lodging in one cert. Each
# label must resolve to the SAP 10.2 Table 6b cascade code whose
# `_G_LIGHT_BY_GLAZING_CODE` daylight factor g_L is correct for the
# glazing family: single 0.90, double / secondary 0.80, triple 0.70
# (the lodged manufacturer U/g drive §3/§6; the code only sets g_L).
expected: dict[str, int] = {
"Single glazing": 1,
"Single glazing, known data": 15,
"Double pre 2002": 2,
"Double between 2002 and 2021": 3,
"Double with unknown install date": 3,
"Double glazing, known data": 3,
"Double post or during 2022": 5,
"Secondary glazing": 7,
"Secondary glazing - Normal emissivity": 11,
"Secondary glazing - Low emissivity": 12,
"Triple pre 2002": 10,
"Triple between 2002 and 2021": 9,
"Triple post or during 2022": 6,
"Triple with unknown install date": 6,
}
# Act / Assert
for label, code in expected.items():
assert _elmhurst_glazing_type_code(label) == code, (
f"{label!r} should map to SAP 10.2 Table 6b code {code}"
)
def test_extension_party_wall_type_read_independently_of_as_main_wall() -> None:
# Arrange — RdSAP 10 §3.3: "As Main Wall: Yes" inherits only the
# external wall CONSTRUCTION; the party wall type is lodged
# separately per building part and may differ. The double_glazing
# fixture (Summary_001431) lodges Main party "CU Cavity masonry
# unfilled" (SAP10 wall_construction 4 → u_party_wall 0.5) but the
# 1st Extension party "U Unable to determine" (→ wall_construction 0
# → RdSAP default u_party_wall 0.25), even though the extension is
# "As Main Wall: Yes". Pre-fix the extension inherited the Main's
# party type (both 0.5), inflating worksheet (32) party heat loss.
pages = _summary_pdf_to_textract_style_pages(
_FIXTURES / "Summary_001431_double_glazing.pdf"
)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
# Act
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Assert — Main BP keeps cavity-unfilled (4); the extension BP gets
# the "Unable to determine" sentinel (0), a distinct party wall U.
party_codes = [
bp.party_wall_construction for bp in epc.sap_building_parts
]
assert party_codes == [4, 0], (
f"expected Main=4 (CU, U=0.5) + Ext=0 (Unable, U=0.25), got {party_codes}"
)
# The two map to different SAP party-wall U-values.
assert abs(u_party_wall(4) - 0.5) <= 1e-9
assert abs(u_party_wall(0) - 0.25) <= 1e-9
def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None:
# Arrange — same strict-coverage gate as the cylinder-size helper
# (Slice S0380.15 + S0380.16): silently routing an unknown glazing
@ -4330,3 +4393,79 @@ def test_from_elmhurst_site_notes_matches_hand_built_000516() -> None:
f"hand-built EpcPropertyData for cohort cert 000516:\n " +
"\n ".join(diffs)
)
def test_elmhurst_jacket_cylinder_insulation_maps_to_loose_jacket_code_2() -> None:
# Arrange — an Elmhurst §15.1 "Cylinder Insulation Type: Jacket"
# lodging is a loose jacket, which SAP 10.2 Table 2 Note 1 gives a
# separate (higher) storage-loss factor than factory foam. The SAP10
# `cylinder_insulation_type` enum uses 2 for loose jacket (1 = factory
# foam), matching the GOV.UK API path — so the Summary "Jacket" label
# must resolve to 2 for cross-mapper parity, and so the
# loose-jacket storage-loss branch (S0380.224) fires. Observed on the
# simulated-case-19 worksheet (210 L jacket cylinder + storage heaters).
from datatypes.epc.domain.mapper import _elmhurst_cylinder_insulation_code # pyright: ignore[reportPrivateUsage]
# Act
code = _elmhurst_cylinder_insulation_code("Jacket", cylinder_present=True)
# Assert
assert code == 2
def test_elmhurst_foam_cylinder_insulation_still_maps_to_factory_code_1() -> None:
# Arrange — regression guard: the factory-foam label is unchanged.
from datatypes.epc.domain.mapper import _elmhurst_cylinder_insulation_code # pyright: ignore[reportPrivateUsage]
# Act
code = _elmhurst_cylinder_insulation_code("Foam", cylinder_present=True)
# Assert
assert code == 1
def test_elmhurst_roof_construction_int_matches_api_codes() -> None:
# Arrange — cross-mapper structural parity: the gov-EPC API mapper
# populates BOTH roof_construction (int) and roof_construction_type
# (str derived via `_API_ROOF_CONSTRUCTION_TO_STR`), but the Elmhurst
# mapper set only the string, leaving the int None. The SAP cascade
# reads the string (so SAP parity held), but consumers of the int
# (e.g. domain/sap10_ml ML aggregates) saw None on every site-notes
# cert. `_elmhurst_roof_construction_int` closes the gap, mapping the
# Elmhurst roof code to the same SAP10 int the API lodges. Unmapped
# codes return None (not a raise) — the int is not cascade-load-
# bearing, so an unknown roof must not block the cert.
from datatypes.epc.domain.mapper import _elmhurst_roof_construction_int # pyright: ignore[reportPrivateUsage]
# Act / Assert — each Elmhurst roof code → the gov-EPC API int.
assert _elmhurst_roof_construction_int("F Flat") == 1
assert _elmhurst_roof_construction_int("PN Pitched (slates/tiles), no access") == 3
assert _elmhurst_roof_construction_int("PA Pitched (slates/tiles), access to loft") == 4
assert _elmhurst_roof_construction_int("PS Pitched, sloping ceiling") == 8
assert _elmhurst_roof_construction_int("S Same dwelling above") == 7
assert _elmhurst_roof_construction_int("A Another dwelling above") == 7
# Absent / unmapped → None (no raise; not cascade-load-bearing).
assert _elmhurst_roof_construction_int(None) is None
assert _elmhurst_roof_construction_int("") is None
assert _elmhurst_roof_construction_int("NR Non-residential space above") is None
def test_elmhurst_wall_is_basement_disambiguates_system_built_from_basement() -> None:
# Arrange — "SY System build" and "B Basement wall" both map to SAP10
# wall_construction=6 (canonical WALL_SYSTEM_BUILT). The explicit
# basement flag separates them: only "B" is a basement wall (drives
# RdSAP §5.17 u_basement_wall); "SY" is False so it routes through the
# normal system-built U-value table; any other code → None (the
# gov-EPC API code-6 heuristic still applies).
from datatypes.epc.domain.mapper import _elmhurst_wall_construction_int # pyright: ignore[reportPrivateUsage]
from datatypes.epc.domain.mapper import _elmhurst_wall_is_basement # pyright: ignore[reportPrivateUsage]
# Act / Assert — system-built keeps code 6 but is NOT basement.
assert _elmhurst_wall_construction_int("SY System build") == 6
assert _elmhurst_wall_is_basement("SY System build") is False
# Genuine basement: code 6 AND flagged basement.
assert _elmhurst_wall_construction_int("B Basement wall") == 6
assert _elmhurst_wall_is_basement("B Basement wall") is True
# Other constructions defer to the API code-6 heuristic.
assert _elmhurst_wall_is_basement("CA Cavity") is None
assert _elmhurst_wall_is_basement("") is None

View file

@ -324,6 +324,11 @@ class SapEnergySource:
photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None
wind_turbine_details: Optional[WindTurbineDetails] = None
pv_batteries: Optional[PvBatteries] = None
# SAP 10.2 Appendix G4 — a PV diverter present on the dwelling routes
# surplus PV to a hot-water cylinder immersion. Drives worksheet
# (63b)m. Set from the API `sap_energy_source.pv_diverter` flag or the
# Elmhurst Summary §19 "Diverter present" row.
pv_diverter_present: bool = False
@dataclass
@ -435,13 +440,24 @@ class SapAlternativeWall:
# Mirrors `SapBuildingPart.wall_thickness_mm` per the
# [[feedback-no-misleading-insulation-type]] convention.
wall_thickness_mm: Optional[int] = None
# Explicit basement determination. RdSAP10 `wall_construction == 6` is
# canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement
# heuristic hijacked it because Elmhurst lodges both "SY System build"
# and "B Basement wall" as code 6. When the source can tell them apart
# (the Elmhurst mapper, from the distinct "SY"/"B" codes) it sets this
# flag; None falls back to the gov-EPC API code-6 heuristic so the API
# path (basement lodged as integer 6, no flag) is unchanged.
is_basement: Optional[bool] = None
@property
def is_basement_wall(self) -> bool:
"""True iff this alt sub-area is the dwelling's basement wall —
identified by RdSAP10 wall_construction code = 6 (see module
constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23
applies a special U-value lookup to basement walls."""
"""True iff this alt sub-area is the dwelling's basement wall.
Honours the explicit `is_basement` flag when set; otherwise falls
back to the gov-EPC API basement sentinel `wall_construction == 6`
(`BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23 applies
a special U-value lookup to basement walls."""
if self.is_basement is not None:
return self.is_basement
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
@ -472,7 +488,15 @@ class SapBuildingPart:
)
wall_dry_lined: Optional[bool] = None # Don't think we have this in site notes
wall_thickness_mm: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
# Union[str, int]: a numeric mm value when the API lodges
# `wall_insulation_thickness == "measured"` (resolved from the
# separate measured field), else the lodged string ("NI", a numeric
# string, etc.). Mirrors `roof_insulation_thickness`.
wall_insulation_thickness: Optional[Union[str, int]] = None
# RdSAP 10 §5.8 thermal-conductivity code for measured wall insulation
# (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence
# R-value path when a measured wall thickness is lodged alongside it.
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
sap_alternative_wall_1: Optional[SapAlternativeWall] = None
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
@ -506,12 +530,23 @@ class SapBuildingPart:
# The dwelling-wide `construction_age_band` does NOT govern curtain
# walls; this field decouples them per spec.
curtain_wall_age: Optional[str] = None
# Explicit basement determination for the primary wall. See
# `SapAlternativeWall.is_basement` — RdSAP10 code 6 is canonically
# SYSTEM-BUILT, so the Elmhurst mapper sets this flag from the distinct
# "SY"/"B" codes (False for system-built, True for basement); None
# preserves the gov-EPC API code-6 heuristic.
wall_is_basement: Optional[bool] = None
@property
def main_wall_is_basement(self) -> bool:
"""True iff this part's primary wall (not an alt sub-area) is the
basement wall happens when the whole part sits below grade.
Empirically 54 of 67k parts in the 2026 sweep; rare but real."""
Empirically 54 of 67k parts in the 2026 sweep; rare but real.
Honours the explicit `wall_is_basement` flag when set (so a
SYSTEM-BUILT wall, which shares code 6, is not mis-flagged);
otherwise falls back to the gov-EPC API code-6 heuristic."""
if self.wall_is_basement is not None:
return self.wall_is_basement
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
@property
@ -714,3 +749,25 @@ class EpcPropertyData:
solar_hw_collector_orientation: Optional[str] = None
solar_hw_collector_pitch_deg: Optional[int] = None
solar_hw_overshading: Optional[str] = None
@property
def system_build(self) -> Optional[bool]:
"""Whether the dwelling's MAIN wall is system-built.
System-built is a WALL TYPE: RdSAP10 `WALL_SYSTEM_BUILT == 6` on
the main wall (the U-value cascade table is keyed on that code).
It happens to share the integer with basement walls so a code-6
main wall is system-built only when it is NOT flagged as a
basement (`main_wall_is_basement`, the dedicated basement signal
the mapper sets from the distinct "SY"/"B" labels or the cert
addendum). Reading the wall type keeps the two concerns separate:
`wall_construction` carries the construction, the basement flag
carries the below-grade attribute. Returns None when there is no
MAIN building part (unknown)."""
for part in self.sap_building_parts:
if part.identifier is BuildingPartIdentifier.MAIN:
return (
part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
and not part.main_wall_is_basement
)
return None

View file

@ -1,10 +1,12 @@
import re
from dataclasses import replace
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast
from datatypes.epc.schema.helpers import from_dict
from datatypes.epc.domain.epc_property_data import (
BASEMENT_WALL_CONSTRUCTION_CODE,
Addendum,
BuildingPartIdentifier,
EnergyElement,
@ -341,6 +343,7 @@ 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),
pv_diverter_present=survey.renewables.pv_diverter_present,
# 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
@ -560,7 +563,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
roof_construction=bp.roof_construction,
@ -693,7 +702,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
floor_insulation_thickness=None,
roof_construction=bp.roof_construction,
@ -826,7 +841,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -985,7 +1006,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1161,7 +1188,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1361,6 +1394,7 @@ class EpcPropertyDataMapper:
else None
),
pv_batteries=_first_pv_battery(es.pv_batteries),
pv_diverter_present=es.pv_diverter == "true",
),
sap_building_parts=[
SapBuildingPart(
@ -1378,7 +1412,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1587,10 +1627,24 @@ class EpcPropertyDataMapper:
schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER,
),
),
# SAP windows
# SAP windows — split vertical wall windows (27) from roof
# windows (27a) on the RdSAP `window_wall_type=4` signal.
sap_windows=[
_api_sap_window(w) for w in schema.sap_windows
_api_sap_window(w)
for w in schema.sap_windows
if not _api_is_roof_window(w)
],
# Empty → None (not []) so "no roof windows" has the single
# canonical representation the domain field defaults to
# (`Optional[List] = None`), matching the 21.0.0 path and the
# persistence round-trip (roof windows aren't yet stored — doc
# §2.4 — so a reloaded cert always reads None here).
sap_roof_windows=[
_api_sap_roof_window(w)
for w in schema.sap_windows
if _api_is_roof_window(w)
]
or None,
# SAP energy source
sap_energy_source=SapEnergySource(
mains_gas=es.mains_gas == "Y",
@ -1614,6 +1668,7 @@ class EpcPropertyDataMapper:
else None
),
pv_batteries=_first_pv_battery(es.pv_batteries),
pv_diverter_present=es.pv_diverter == "true",
),
# SAP building parts
sap_building_parts=[
@ -1632,7 +1687,13 @@ class EpcPropertyDataMapper:
building_part_number=bp.building_part_number,
wall_dry_lined=bp.wall_dry_lined == "Y",
wall_thickness_mm=bp.wall_thickness,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thickness=_api_resolve_wall_insulation_thickness(
bp.wall_insulation_thickness,
getattr(bp, "wall_insulation_thickness_measured", None),
),
wall_insulation_thermal_conductivity=getattr(
bp, "wall_insulation_thermal_conductivity", None
),
floor_heat_loss=bp.floor_heat_loss,
# API certs commonly lodge "NI" (no measured
# thickness) on floors that aren't actually
@ -1861,18 +1922,23 @@ class EpcPropertyDataMapper:
"""
data = _normalize_shower_outlets(data)
data = _default_missing_post_town(data)
schema = data.get("schema_type", "")
if schema == "RdSAP-Schema-21.0.1":
from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1
return EpcPropertyDataMapper.from_rdsap_schema_21_0_1(
from_dict(RdSapSchema21_0_1, data)
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_21_0_1(
from_dict(RdSapSchema21_0_1, data)
)
)
if schema == "RdSAP-Schema-21.0.0":
from datatypes.epc.schema.rdsap_schema_21_0_0 import RdSapSchema21_0_0
return EpcPropertyDataMapper.from_rdsap_schema_21_0_0(
from_dict(RdSapSchema21_0_0, data)
return _clear_basement_flag_when_system_built(
EpcPropertyDataMapper.from_rdsap_schema_21_0_0(
from_dict(RdSapSchema21_0_0, data)
)
)
raise ValueError(f"Unsupported EPC schema: {schema!r}")
@ -1882,6 +1948,68 @@ class EpcPropertyDataMapper:
# ---------------------------------------------------------------------------
def _clear_basement_flag_when_system_built(
epc: EpcPropertyData,
) -> EpcPropertyData:
"""When the dwelling is system-built, a `wall_construction == 6` wall
is WALL_SYSTEM_BUILT, not a basement so the gov-EPC API code-6
basement heuristic must not fire for it. The API path can't tell the
two apart at the per-wall level (both lodge code 6), so once the
cert-level `system_build` flag is known we clear the basement signal
on every code-6 wall that hasn't been explicitly determined
(`wall_is_basement` / `is_basement` still None). No-op unless the
dwelling is system-built, so genuine basements (system_build absent /
False) keep the code-6 heuristic. Returns the same object when
nothing changes.
The Elmhurst path sets the per-wall flag directly from the distinct
"SY"/"B" labels, so it never reaches here (it routes through
`from_elmhurst_site_notes`, not `from_api_response`).
Keyed on the RAW cert `addendum.system_build` signal rather than the
derived `epc.system_build` property the property reads the wall
type AFTER this clears the basement flag, so using it here would be
circular."""
if epc.addendum is None or epc.addendum.system_build is not True:
return epc
def _clear_alt(alt: Optional[SapAlternativeWall]) -> Optional[SapAlternativeWall]:
if (
alt is not None
and alt.is_basement is None
and alt.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
):
return replace(alt, is_basement=False)
return alt
new_parts: List[SapBuildingPart] = []
changed = False
for part in epc.sap_building_parts:
new_alt_1 = _clear_alt(part.sap_alternative_wall_1)
new_alt_2 = _clear_alt(part.sap_alternative_wall_2)
clear_main = (
part.wall_is_basement is None
and part.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
)
if clear_main or new_alt_1 is not part.sap_alternative_wall_1 or (
new_alt_2 is not part.sap_alternative_wall_2
):
changed = True
new_parts.append(
replace(
part,
wall_is_basement=False if clear_main else part.wall_is_basement,
sap_alternative_wall_1=new_alt_1,
sap_alternative_wall_2=new_alt_2,
)
)
else:
new_parts.append(part)
if not changed:
return epc
return replace(epc, sap_building_parts=new_parts)
def _measurement_value(field: Any) -> float:
"""SAP measurements arrive as a `Measurement` (with `.value`), a raw dict
{'value': N, 'quantity': '...'} when `from_dict` didn't coerce, or a plain
@ -2031,6 +2159,26 @@ def _normalize_shower_outlets(data: Dict[str, Any]) -> Dict[str, Any]:
return {**data, "sap_heating": new_sap_heating}
def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]:
"""Default an absent top-level `post_town` to "" before `from_dict`.
`RdSapSchema21_0_x.post_town` is a required (no-default) field, so a
real-API cert that omits it (observed on a 2026-register cert whose
town sits only in `address_line_3`) makes `from_dict` raise
"missing required field 'post_town'", blocking the whole cert.
`post_town` is address metadata that the SAP cascade never reads, so
defaulting it to "" is inert for the calculation while keeping the
cert mappable. The schema dataclass can't simply give the field a
default it is a plain (non-kw_only) dataclass with 57 required
fields after `post_town`, so a mid-list default would break field
ordering; pre-processing here mirrors `_normalize_shower_outlets`.
Mutates a shallow copy so the caller's dict is untouched."""
if "post_town" in data:
return data
return {**data, "post_town": ""}
def _count_shower_outlets_by_type(
schema_shower_outlets: Any, target_type: int,
) -> Optional[int]:
@ -2090,6 +2238,10 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
"SG": 1, # Stone: granite or whinstone (cert 000565 Ext1) — the
# granite-specific Elmhurst variant of "ST"; same SAP10
# WALL_STONE_GRANITE=1 cascade entry.
"SS": 2, # Stone: sandstone or limestone (simulated case 5 / cert
# 0240 archetype) — SAP10 WALL_STONE_SANDSTONE=2. The
# sandstone-specific Elmhurst variant; the API path lodges
# the same wall as integer wall_construction=2.
"SB": 3, # Solid brick (cohort cert lodgement)
"SO": 3, # Solid brick (newer Elmhurst PDF variant — same SAP10
# mapping; cert 9501 lodges "SO Solid Brick" where the
@ -2097,16 +2249,16 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
"CA": 4, # Cavity
"TF": 5, # Timber frame
"TI": 5, # Timber frame (Elmhurst's alt-wall code; same SAP10 mapping)
"SY": 6, # System build
"B": 6, # Basement wall (cert 000565 Ext3+Ext4) — routes to the
# `BASEMENT_WALL_CONSTRUCTION_CODE=6` canonical signal so
# the cascade's `part.main_wall_is_basement` triggers the
# RdSAP 10 §5.17 / Table 23 `u_basement_wall` override
# (heat_transmission.py:640). Collides numerically with
# "SY" System build — the cascade's basement check
# precedes `u_wall(construction=6)` so SY would be
# silently mis-routed to u_basement_wall today; no cohort
# fixture exercises SY yet so the conflict is dormant.
"SY": 6, # System build — canonical RdSAP10 WALL_SYSTEM_BUILT=6.
"B": 6, # Basement wall (cert 000565 Ext3+Ext4). Numerically
# collides with "SY" System build on code 6, so the
# basement vs system-built distinction is carried by the
# explicit `is_basement` / `wall_is_basement` flag (set
# via `_elmhurst_wall_is_basement`) rather than the code:
# only "B" triggers the cascade's `main_wall_is_basement`
# → RdSAP 10 §5.17 / Table 23 `u_basement_wall` override.
# "SY" sets the flag False so it routes through the normal
# `u_wall(construction=6)` system-built table instead.
"CO": 7, # Cob
"PH": 8, # Park home
"CW": 9, # Curtain wall
@ -2171,6 +2323,40 @@ def _elmhurst_dwelling_type(
return f"{position} flat"
# Elmhurst roof-type codes → SAP10 roof_construction integer, matching the
# gov-EPC API codes in `_API_ROOF_CONSTRUCTION_TO_STR` so the two
# front-ends populate the same field. Harvested from the committed
# Elmhurst Summary fixtures (corpus + cohort): F/PN/PA/PS/S/A. Vaulted (5)
# and thatched (6) are omitted until a fixture surfaces their Elmhurst
# codes. NR ("Non-residential space above") is intentionally left
# unmapped — the gov enum's code 7 is specifically "dwelling above".
_ELMHURST_ROOF_CODE_TO_SAP10: Dict[str, int] = {
"F": 1, # Flat
"PN": 3, # Pitched (slates/tiles), no access (to loft)
"PA": 4, # Pitched (slates/tiles), access to loft
"PS": 8, # Pitched, sloping ceiling
"S": 7, # Same dwelling above
"A": 7, # Another dwelling above
}
def _elmhurst_roof_construction_int(coded: Optional[str]) -> Optional[int]:
"""Map an Elmhurst roof_type string ('PA Pitched (slates/tiles),
access to loft') to the SAP10 `roof_construction` integer the gov-EPC
API lodges (4), so the site-notes and API front-ends populate the
same field (cross-mapper structural parity).
Returns None for an absent or unmapped code and, unlike
`_elmhurst_wall_construction_int`, does NOT raise. `roof_construction`
(int) is not read by the SAP cascade (which reads the string
`roof_construction_type`, populated on both paths), so an unmapped
roof code stays None the pre-existing Elmhurst behaviour rather
than blocking the cert."""
if not coded:
return None
return _ELMHURST_ROOF_CODE_TO_SAP10.get(_leading_code(coded))
def _elmhurst_wall_construction_int(coded: str) -> Optional[int]:
"""Map an Elmhurst wall_type string ('CA Cavity') to the SAP10
integer code (4). Returns None when the lodging is absent (empty
@ -2188,6 +2374,32 @@ def _elmhurst_wall_construction_int(coded: str) -> Optional[int]:
return _ELMHURST_WALL_CODE_TO_SAP10[code]
# Elmhurst wall codes that both resolve to SAP10 wall_construction=6 but
# carry opposite basement meaning: "B" Basement wall vs "SY" System build
# (see `_ELMHURST_WALL_CODE_TO_SAP10`). RdSAP10 code 6 is canonically
# WALL_SYSTEM_BUILT; the explicit basement flag lets the cascade route a
# genuine basement to RdSAP §5.17 `u_basement_wall` without mis-flagging
# a system-built wall.
_ELMHURST_BASEMENT_WALL_CODE: Final[str] = "B"
_ELMHURST_SYSTEM_BUILT_WALL_CODE: Final[str] = "SY"
def _elmhurst_wall_is_basement(coded: str) -> Optional[bool]:
"""Disambiguate the SAP10 code-6 collision from the Elmhurst wall_type
string. Returns True for "B Basement wall", False for "SY System
build", and None for every other code (so the SapBuildingPart /
SapAlternativeWall properties fall back to the gov-EPC API code-6
heuristic unchanged for the API path). Empty lodging None."""
code = _leading_code(coded)
if not code:
return None
if code == _ELMHURST_BASEMENT_WALL_CODE:
return True
if code == _ELMHURST_SYSTEM_BUILT_WALL_CODE:
return False
return None
# Elmhurst Party Wall Type codes — distinct category-set from the Wall
# Type field; the codes describe construction class for `u_party_wall`
# (Table 4 / RdSAP §S.3.2) rather than a specific SAP10 wall-type. Maps
@ -2319,9 +2531,33 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i
# distinguishes "Suspended" vs everything-else (the solid-branch is
# the default fall-through), so the additional code maps to the same
# "Solid" string as code 1.
_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = {
# Code 3 = "suspended, not timber" (e.g. beam-and-block / suspended
# concrete). RdSAP 10 field 3-1 "Floor construction" enumerates the
# lowest-floor construction as solid / suspended timber / suspended,
# not timber. The timber/not-timber split is load-bearing: the spec's
# "Suspended not timber (structural infiltration 0)" means only
# "Suspended timber" triggers the §5 (12) 0.1/0.2 floor-infiltration
# adjustment (see `_has_suspended_timber_floor_per_spec`), while a
# not-timber suspended floor is infiltration 0. Mapping to the canonical
# "Suspended, not timber" string (also used by the site-notes mapper)
# takes the suspended U-value branch via the "Suspended" prefix yet
# correctly fails the exact-match timber gate. Observed on 53/1000 of a
# random 2026 API sample (was raising UnmappedApiCode, blocking the cert).
#
# Code 0 = "not recorded / not applicable" → None. It pairs
# overwhelmingly with floor_heat_loss=6 ("another dwelling below" — an
# upper-floor flat with no ground floor to describe) but also appears
# with mixed Solid / unheated-space descriptions, so there is no single
# construction to assert. None defers to RdSAP 10 Table 19 ("where floor
# construction is unknown" → age-band default), exactly as an unlodged
# floor_construction does. Empirically inert: floor W/K is identical to
# any explicit construction across all 37 code-0 certs in the 2026
# sample (the heat loss is governed by floor_heat_loss, not this field).
_API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = {
0: None,
1: "Solid",
2: "Suspended timber",
3: "Suspended, not timber",
4: "Solid",
}
@ -2334,11 +2570,28 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, str] = {
# are observed on cert 001479; the wider RdSAP10 roof-construction
# enum (1=Flat, 3=Pitched no-access, 5=Vaulted, etc.) is mapped as
# best-effort against SAP10 nomenclature.
_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, str] = {
#
# Codes 6 and 7 → None. This field is read ONLY for the sloping-ceiling
# inclined factor; the base roof U-value comes from the global
# roofs[].description, so a non-sloping code carries no information the
# cascade consumes here, and None correctly avoids the cos(30°) false-
# trigger:
# 6 = "Thatched, with additional insulation" — its U is set by the
# global description; not a sloping ceiling.
# 7 = "(same dwelling above)" / "(another dwelling above)" — an
# internal ceiling with no roof heat loss (the roof-side analogue
# of floor_construction code 0). Heat loss is governed by the
# roof_heat_loss / description path, not this field.
# Empirically inert: roof W/K is identical whether 6/7 map to None or to
# an explicit pitched string across all code-6/7 certs in the 2026
# sample (were raising UnmappedApiCode, blocking the cert).
_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = {
1: "Flat",
3: "Pitched (slates/tiles), no access to loft",
4: "Pitched (slates/tiles), access to loft",
5: "Pitched (vaulted ceiling)",
6: None,
7: None,
8: "Pitched, sloping ceiling",
}
@ -2627,6 +2880,50 @@ def _api_secondary_fuel_type(
return lodged_fuel_type
# RdSAP API `window_wall_type` code 4 = roof window ("Roof of Room"
# rooflight / inclined glazing). Codes 1=main wall, 2=alt wall 1, 3=alt
# wall 2 (see `_window_on_alt_wall`). A roof window is billed on worksheet
# (27a) at the Table 6e Note 2 inclination-adjusted U and draws 45°-
# inclined solar gains, NOT on (27) as vertical glazing. Cert 0240's 6
# "Roof of Room" windows lodge this code; the simulated-case-6 worksheet
# confirms the (27a) treatment at U_eff 2.1062.
_API_WINDOW_WALL_TYPE_ROOF: Final[int] = 4
def _api_is_roof_window(w: Any) -> bool:
"""True when an API sap_windows entry is a roof window (rooflight),
keyed on `window_wall_type == 4`. `window_type` is NOT the signal
certs 0390 / 7536 lodge `window_type=2` on ordinary main-wall
(wall_type=1) windows."""
return w.window_wall_type == _API_WINDOW_WALL_TYPE_ROOF
def _api_sap_roof_window(w: Any) -> SapRoofWindow:
"""Build a `SapRoofWindow` from one API roof-window entry
(`window_wall_type=4`). The lodged glazing type gives the vertical
U / g / frame-factor via the same SAP 10.2 Table 24 lookup the
vertical-window path uses; the U is then raised by the SAP 10.2
Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to
the inclined-position value the worksheet bills on (27a). Mirror of
the site-notes `_map_elmhurst_roof_window`."""
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap)
vertical_u = transmission[0] if transmission is not None else 2.0
g_perp = transmission[1] if transmission is not None else 0.76
frame_factor = w.frame_factor
if frame_factor is None:
frame_factor = transmission[2] if transmission is not None else 0.70
return SapRoofWindow(
area_m2=_measurement_value(w.window_width) * _measurement_value(w.window_height),
u_value_raw=vertical_u + _ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_M2K,
orientation=w.orientation,
pitch_deg=45.0,
g_perpendicular=g_perp,
frame_factor=frame_factor,
glazing_type=_api_cascade_glazing_type(w.glazing_type),
window_location=w.window_location,
)
def _api_sap_window(w: Any) -> SapWindow:
"""Build a `SapWindow` from one API schema sap_windows entry,
routing the glazing-type + glazing-gap pair through the spec
@ -2710,20 +3007,84 @@ def _api_build_sap_floor_dimensions(
return out
# RdSAP 10 §3.9.1 (PDF p.21): a Simplified Type 1 RR gable has no measured
# height — the worksheet applies the default RR storey height of 2.45 m, so
# the gable area is L × 2.45 (cert 6035 Summary lodges H=2.45 explicitly,
# matching this default; gable area 4.65 × 2.45 = 11.39 m²).
_RIR_TYPE_1_GABLE_HEIGHT_M: Final[float] = 2.45
# GOV.UK API `room_in_roof_type_1.gable_wall_type_*` integer → the
# `SapRoomInRoofSurface.kind` the cascade's Detailed-RR branch routes by
# U-value. Established from cert 6035's Summary (gable_wall_type_1=1 ↔
# "Exposed" U=0.29; gable_wall_type_2=0 ↔ "Party" U=0.25):
# 0 = Party → `gable_wall` (Table 4 p.22 row 2, U=0.25)
# 1 = Exposed → `gable_wall_external` (Table 4 p.22 row 1, "as common wall")
_API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = {
0: "gable_wall",
1: "gable_wall_external",
}
def _api_type_1_gable_kind(gable_type: Optional[int]) -> str:
"""Map a `gable_wall_type_*` code to the cascade's RR surface kind.
`None` (type unlodged) defaults to `gable_wall` (party) the modal
RR gable and the conservative choice (party billing at U=0.25 vs the
main-wall U). A lodged integer outside the known set raises
`UnmappedApiCode` so a new gable variant forces an explicit mapping
rather than silently mis-routing its U-value (mirror of the strict-
raise pattern on the other API helpers)."""
if gable_type is None:
return "gable_wall"
if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND:
raise UnmappedApiCode("gable_wall_type", gable_type)
return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type]
def _api_type_1_gable_surfaces(
type_1: Any,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the Simplified Type 1 scalar gable fields into the
per-surface list the cascade's Detailed-RR branch consumes.
Gable area = L × the §3.9.1 default RR storey height (2.45 m); the
`gable_wall_type_*` code routes the kind (Exposed vs Party). U-values
are left to the cascade (Exposed falls through to the main-wall U;
Party uses the fixed 0.25). Returns None when no gable length is
lodged so the cascade keeps its existing residual-only behaviour."""
surfaces: List[SapRoomInRoofSurface] = []
for gable_type, length in (
(type_1.gable_wall_type_1, type_1.gable_wall_length_1),
(type_1.gable_wall_type_2, type_1.gable_wall_length_2),
):
if length is None or length <= 0:
continue
surfaces.append(
SapRoomInRoofSurface(
kind=_api_type_1_gable_kind(gable_type),
area_m2=_round_half_up_2dp(
float(length), _RIR_TYPE_1_GABLE_HEIGHT_M
),
)
)
return surfaces or None
def _api_build_room_in_roof(
bp_rir: Any, *, is_flat: bool = False,
) -> Optional[SapRoomInRoof]:
"""Build `SapRoomInRoof` from the API schema's per-bp RR block. Two
real-API shapes coexist:
- `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1
Simplified Type 1 gable lengths only, cascade applies the
2.45 m default storey height.
Simplified Type 1 gable lengths only (no heights). Built into
`detailed_surfaces` using the 2.45 m default RR storey height so
the cascade's residual deducts the gables from the A_RR shell.
- `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR
per-surface lengths + heights + flat-ceiling detail.
When the Detailed block is present, build `detailed_surfaces` so the
cascade's per-surface RR branch (heat_transmission.py:629) picks
up exact gable + flat-ceiling areas instead of falling through to
the Table 18 col(4) "all elements" default U.
For BOTH shapes we build `detailed_surfaces` so the cascade's
per-surface RR branch picks up exact gable + flat-ceiling areas and
the §3.9.1(e) residual roof (A_RR shell Σ gables), instead of
billing the whole shell at the Table 18 col(4) "all elements" U.
"""
if bp_rir is None:
return None
@ -2733,10 +3094,20 @@ def _api_build_room_in_roof(
)
type_1 = getattr(bp_rir, "room_in_roof_type_1", None)
if type_1 is not None:
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights —
# the cascade applies the 2.45 m default storey height).
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights).
rir.gable_1_length_m = type_1.gable_wall_length_1
rir.gable_2_length_m = type_1.gable_wall_length_2
# Route the gables through `detailed_surfaces` so the cascade's
# Detailed-RR residual deducts each gable from the A_RR shell
# (RdSAP 10 §3.9.1(e) p.21: A_RR_final = 12.5√(A_RR_floor/1.5)
# Σ gables) — the same path the site-notes mapper builds. The
# scalar `gable_*_length_m` fields alone can't trigger this: the
# cascade's `_part_geometry` gable formula needs a height and
# silently drops height-less gables, billing the WHOLE shell as
# roof (a ~52 W/K over-count on cert 6035). Gable area = L × the
# §3.9.1 default RR storey height (2.45 m); the type code routes
# the U-value (Exposed → main-wall U, Party → 0.25).
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1)
details = getattr(bp_rir, "room_in_roof_details", None)
if details is not None:
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
@ -2798,6 +3169,31 @@ def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]:
return int(m.group(1)) if m else None
def _api_resolve_wall_insulation_thickness(
wall_insulation_thickness: Union[str, int, None],
wall_insulation_thickness_measured: Union[str, int, None],
) -> Union[str, int, None]:
"""Resolve the wall insulation thickness for the cascade.
When the cert lodges `wall_insulation_thickness == "measured"` the
actual value sits in the separate `wall_insulation_thickness_measured`
field (mm). RdSAP 10 §5.7/Table 8 use the measured thickness to pick
the insulated-wall U-value row; without it the cascade falls back to
the 50 mm "insulation present, unknown thickness" default (e.g. cert
2130 Ext1: solid brick band B + internal insulation lodged 100 mm
Table 8 U=0.32, not the 50 mm default 0.55).
Any other lodgement (numeric string, "NI", None) passes through
unchanged."""
if (
isinstance(wall_insulation_thickness, str)
and wall_insulation_thickness.strip().lower() == "measured"
and wall_insulation_thickness_measured is not None
):
return wall_insulation_thickness_measured
return wall_insulation_thickness
def _api_resolve_sloping_ceiling_thickness(
roof_construction: Optional[int],
roof_insulation_thickness: Union[str, int, None],
@ -3126,6 +3522,7 @@ def _map_elmhurst_building_part(
identifier=identifier,
construction_age_band=age_code,
wall_construction=_elmhurst_wall_construction_int(walls.wall_type),
wall_is_basement=_elmhurst_wall_is_basement(walls.wall_type),
wall_insulation_type=_elmhurst_wall_insulation_int(walls.insulation),
wall_thickness_measured=not walls.thickness_unknown,
party_wall_construction=_elmhurst_party_wall_construction_int(walls.party_wall_type),
@ -3140,6 +3537,7 @@ def _map_elmhurst_building_part(
if walls.insulation_thickness_mm is not None
else None
),
roof_construction=_elmhurst_roof_construction_int(roof.roof_type),
roof_construction_type=_strip_code(roof.roof_type),
roof_insulation_location=_strip_code(roof.insulation),
roof_insulation_thickness=_resolve_sloping_ceiling_thickness(
@ -3204,6 +3602,7 @@ def _map_elmhurst_alternative_wall(
wall_thickness_measured="Y" if not a.thickness_unknown else "N",
wall_insulation_thickness=None,
wall_thickness_mm=measured_thickness_mm,
is_basement=_elmhurst_wall_is_basement(a.wall_type),
)
@ -3416,6 +3815,25 @@ def _map_elmhurst_rir_surface(
if prefix is None:
return None
kind = _RIR_KIND_FROM_NAME_PREFIX[prefix]
# RdSAP 10 §3.9.1 (PDF p.21) Simplified assessment: the roof-going
# surfaces (slope / flat ceiling / stud wall) are NOT measured — the
# Summary lodges placeholder Length/Height cells (e.g. a 40 m ceiling
# height, a 32 m slope on a 4.65 m-wide gable). The spec instead
# derives one timber-framed "remaining area" from the floor area:
# A_RR = 12.5 × √(A_RR_floor / 1.5) §3.9.1(d)
# A_RR_final = A_RR ΣA_RR_gable/other §3.9.1(e)
# The cascade computes A_RR_final itself (heat_transmission.py — the
# `12.5 × √(A_RR_floor / 1.5) rr_walls_in_a_rr_area` residual),
# but ONLY when `detailed_surfaces` carries no roof-going kind
# (`has_roof_lodgement` gate). Emitting these placeholder rows flips
# that gate and bills their raw L×H as explicit roof area (a 7.5×
# heat-loss explosion). Drop them for Simplified so the cascade's
# residual formula fires — matching how the API path already handles
# the same Simplified RR (scalar gable fields, no roof-going
# detailed_surfaces; cert 6035) and the gables-only cert 000565.
# Detailed (§3.10) assessments DO measure these surfaces — keep them.
if is_simplified and kind in ("slope", "flat_ceiling", "stud_wall"):
return None
u_value_override: Optional[float] = None
if kind == "gable_wall" and surface.gable_type == "Sheltered":
kind = "gable_wall_external"
@ -3688,6 +4106,11 @@ def _is_elmhurst_roof_window(
"""
if w.glazing_type.startswith("Single"):
return False
# Explicit "Roof of Room" location lodging (simulated case 6): the
# surveyor placed the window on the room-in-roof, so it's a rooflight
# regardless of BP roof type or U-value.
if "roof of room" in (w.location or "").lower():
return True
bp_roof_type = _elmhurst_bp_roof_type(w, survey)
if bp_roof_type is not None and bp_roof_type.startswith(
_ELMHURST_BP_ROOF_TYPES_WITH_ROOFLIGHTS
@ -3703,6 +4126,12 @@ def _is_elmhurst_roof_window(
# worksheet's (27a) line. The cohort exercises only "Double pre 2002".
_ELMHURST_ROOF_WINDOW_U_BY_GLAZING: Dict[str, float] = {
"Double pre 2002": 3.4,
# Simulated case 6 rooflights: the Summary lodges the already-inclined
# roof-window U=2.30 for DG-2002-2021 glazing (vs 2.00 vertical for the
# same glazing on a wall) — the worksheet bills it on (27a) at U_eff
# 2.1062 (= 2.30 with the §3.2 R=0.04 curtain transform). Keyed here so
# the inclination adjustment isn't double-applied.
"Double between 2002 and 2021": 2.30,
}
@ -4421,8 +4850,14 @@ _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10: Dict[str, int] = {
# which SAP 10.2 Table 2 Note 2 treats as factory-applied PU foam).
# Other labels (Loose Jacket, None) raise `UnmappedElmhurstLabel`
# until a fixture exercises them.
# SAP10 cylinder_insulation_type enum: 1 = factory-applied foam,
# 2 = loose jacket (matching the GOV.UK API codes). SAP 10.2 Table 2
# Note 1 gives loose jacket a separate, ~2× higher storage-loss factor;
# the cascade's loose-jacket branch is wired (S0380.224), so "Jacket"
# resolves to 2 for cross-mapper parity with the API path.
_ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10: Dict[str, int] = {
"Foam": 1,
"Jacket": 2,
}
@ -4644,6 +5079,36 @@ _ELMHURST_GLAZING_LABEL_TO_SAP10: Dict[str, int] = {
# solar_transmittance=0.72 overrides per worksheet-pinned value.
"Triple between 2002 and 2021": 9,
"Secondary": 7,
# Elmhurst §11 lodges the full "Secondary glazing" phrase as well as
# the bare "Secondary" form — both are SAP 10.2 Table 6b "window with
# secondary glazing" (cascade code 7, g_L=0.80, g⊥=0.76). The
# RdSAP-21 schema splits secondary glazing by pane emissivity; the
# "Normal emissivity" variant is its own code 11 (g_L=0.80, identical
# cascade output to 7, kept distinct for the strict-raise audit
# trail). Surfaced on the double_glazing/before recommendation
# fixture (Summary_001431 §11 Window 9).
"Secondary glazing": 7,
"Secondary glazing - Normal emissivity": 11,
# RdSAP-21 glazed_type 12 "secondary glazing, low emissivity" — the
# low-E sibling of code 11. Cascade code 12 carries g_L=0.80 / g⊥=0.76
# (identical daylight/solar bucket to 7 and 11; the lodged U/g drive
# §3/§6). Mapped symmetrically with the "Normal emissivity" variant so
# the whole secondary-glazing family is covered.
"Secondary glazing - Low emissivity": 12,
# RdSAP-21 row "triple glazing, installed pre-2002" (cascade code 10,
# g_L=0.70, g⊥=0.68 — same triple-glazed daylight/solar bucket as the
# other triple variants {6, 8, 9, 14}). The lodged manufacturer
# U-value / solar_transmittance drive §6; the code only sets g_L.
"Triple pre 2002": 10,
# Triple glazing of unknown install date → the generic SAP 10.2
# Table 6b "triple glazed" row (cascade code 6, g_L=0.70). No
# dedicated "triple, unknown date" enum exists; 6 is the correct
# triple-glazed bucket.
"Triple with unknown install date": 6,
# RdSAP-21 row "single glazing, known data" (cascade code 15,
# g_L=0.90, g⊥=0.85 — same as plain single glazing). Manufacturer
# U/g lodged on WindowTransmissionDetails drive §6.
"Single glazing, known data": 15,
}
_ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE: Final[re.Pattern[str]] = re.compile(
@ -4652,6 +5117,18 @@ _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE: Final[re.Pattern[str]] = re.compile(
_ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE: Final[re.Pattern[str]] = re.compile(
r"\s+Summary Information$|\s+Alternative wall.*$"
)
# Fallback only: pdftotext wraps the §11 glazing-GAP column ("6 mm" /
# "12 mm" / "16 mm or more") onto the glazing-TYPE token on hand-entered
# worksheets, e.g. "Double between 2002 and 2021 16 mm or [1st]". When the
# lightly-cleaned label isn't a known key, strip the trailing gap
# descriptor (and any building-part fragment after it) and retry. Applied
# AFTER the direct lookup so explicitly-mapped interleaved variants (e.g.
# "Double with unknown 16 mm or install date more") are unaffected. The
# gap drives the API-path U-value lookup, not the site-notes glazing-type
# enum, so dropping it here is loss-free for the cascade.
_ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE: Final[re.Pattern[str]] = re.compile(
r"\s+\d+\s*mm\b.*$"
)
def _elmhurst_glazing_type_code(label: Optional[str]) -> int:
@ -4668,9 +5145,15 @@ def _elmhurst_glazing_type_code(label: Optional[str]) -> int:
cleaned = _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE.sub("", label)
cleaned = _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE.sub("", cleaned).strip()
code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(cleaned)
if code is None:
raise UnmappedElmhurstLabel("glazing_type", label)
return code
if code is not None:
return code
# Fallback: strip a trailing wrapped glazing-gap descriptor and retry.
degapped = _ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE.sub("", cleaned).strip()
if degapped != cleaned:
code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(degapped)
if code is not None:
return code
raise UnmappedElmhurstLabel("glazing_type", label)
def _elmhurst_main_heating_category(
@ -4701,6 +5184,10 @@ def _elmhurst_main_heating_category(
def _map_elmhurst_main_heating_2(
mh2: Optional[ElmhurstMainHeating2],
*,
fallback_fuel_type: Union[int, str, None] = None,
main_floor: Optional[ElmhurstFloorDetails] = None,
main_age_band: Optional[str] = None,
) -> Optional[MainHeatingDetail]:
"""Build a `MainHeatingDetail` from the Elmhurst §14.1 Main Heating2
block. Returns None when no Main 2 is lodged (extractor convention:
@ -4736,25 +5223,51 @@ def _map_elmhurst_main_heating_2(
and mh2.main_heating_sap_code in _ELECTRIC_SAP_MAIN_HEATING_CODES
):
main_fuel_int = _STANDARD_ELECTRICITY_FUEL_CODE
# §14.1 Main Heating2 often omits the "Fuel Type" cell when the
# second main system shares Main 1's fuel (simulated case 6: one oil
# boiler feeding radiators + underfloor, so the Summary lodges the
# fuel once on §14.0). Inherit Main 1's resolved fuel so the §9a
# two-main split (213)m can apply system 2's own efficiency.
resolved_fuel: Union[int, str] = (
main_fuel_int
if main_fuel_int is not None
else (
fallback_fuel_type
if (not mh2.fuel_type and fallback_fuel_type is not None)
else mh2.fuel_type
)
)
category: Optional[int] = None
if pcdb_index is not None and heat_pump_record(pcdb_index) is not None:
category = _ELMHURST_HEATING_CATEGORY_HEAT_PUMP
elif pcdb_index is not None and mh2.fuel_type in _ELMHURST_GAS_BOILER_FUEL_TYPES:
category = _ELMHURST_HEATING_CATEGORY_GAS_BOILER
# §14.1 lodges Main 2's own "Heat Emitter" + "Main Heating Controls
# Sap" when the two systems heat different parts of the dwelling
# (simulated case 6: Main 1 radiators / 2106, Main 2 underfloor /
# 2110). Map them through the same helpers as Main 1 so the SAP 10.2
# p.186 two-systems-different-parts MIT can read system 2's
# responsiveness (underfloor → emitter 2 → R=0.75) + control type.
# Empty-string sentinels preserved for the legacy DHW-only Main 2
# (cert 000565: §14.1 omits emitter/control → consumers key off
# Main 1).
emitter_int = _elmhurst_heat_emitter_int(
mh2.heat_emitter, main_floor=main_floor, main_age_band=main_age_band
)
control_int = _elmhurst_sap_control_code(mh2.heating_controls_sap)
return MainHeatingDetail(
# Main 2 doesn't carry its own FGHRS lodgement in §14.1; the
# cert-level renewables block is the single source of truth and
# is already wired into Main 1.
has_fghrs=False,
main_fuel_type=main_fuel_int if main_fuel_int is not None else mh2.fuel_type,
# §14.1 doesn't lodge a heat emitter (the emitter is Main 1's
# radiator/UFH); leave as empty-string sentinel for cascade
# consumers that key off Main 1's emitter.
heat_emitter_type="",
main_fuel_type=resolved_fuel,
heat_emitter_type=(
emitter_int if emitter_int is not None else mh2.heat_emitter
),
emitter_temperature="",
fan_flue_present=mh2.fan_assisted_flue,
boiler_flue_type=_elmhurst_flue_type_int(mh2.flue_type),
main_heating_control="",
main_heating_control=control_int if control_int is not None else "",
main_heating_category=category,
main_heating_number=2,
main_heating_fraction=mh2.percentage_of_heat,
@ -4950,7 +5463,12 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
# services DHW via `Water Heating SapCode 914` ("from second main
# system") while Main 1 handles space heat. None when the §14.1
# block is absent or lodges only placeholder zeros.
main_2_detail = _map_elmhurst_main_heating_2(mh.main_heating_2)
main_2_detail = _map_elmhurst_main_heating_2(
mh.main_heating_2,
fallback_fuel_type=main_1_detail.main_fuel_type,
main_floor=survey.floor,
main_age_band=survey.construction_age_band,
)
main_heating_details = (
[main_1_detail, main_2_detail]
if main_2_detail is not None

View file

@ -697,3 +697,246 @@ class TestFromRdSapSchema21_0_1:
assert rhi.impact_of_cavity_insulation_kwh == -122.0
assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0
# ---------------------------------------------------------------------------
# Measured wall insulation thickness (`wall_insulation_thickness == "measured"`)
# ---------------------------------------------------------------------------
class TestApiResolveWallInsulationThickness:
"""`wall_insulation_thickness == "measured"` resolves to the separate
`wall_insulation_thickness_measured` field (previously dropped by
`from_dict`, leaving the cascade on the 50 mm unknown-thickness
default). Cert 2130 Ext1 lodges solid brick band B + internal
insulation "measured"/100 mm RdSAP 10 Table 8 U=0.32, not 0.55."""
def test_measured_string_resolves_to_measured_value(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import (
_api_resolve_wall_insulation_thickness,
)
lodged_thickness = "measured"
measured_value_mm = 100
# Act
resolved = _api_resolve_wall_insulation_thickness(
lodged_thickness, measured_value_mm
)
# Assert
assert resolved == measured_value_mm
def test_non_measured_lodgement_passes_through_unchanged(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import (
_api_resolve_wall_insulation_thickness,
)
ni_lodgement = "NI"
measured_value_mm = 100
# Act
ni: object = _api_resolve_wall_insulation_thickness(
ni_lodgement, measured_value_mm
)
none_thk: object = _api_resolve_wall_insulation_thickness(None, None)
# Assert
assert ni == ni_lodgement
assert none_thk is None
def test_measured_without_value_passes_through(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import (
_api_resolve_wall_insulation_thickness,
)
lodged_thickness = "measured"
measured_value_mm = None
# Act
resolved: object = _api_resolve_wall_insulation_thickness(
lodged_thickness, measured_value_mm
)
# Assert
assert resolved == lodged_thickness
# ---------------------------------------------------------------------------
# Glazing-type label cleaning — pdftotext gap-column wrap
# ---------------------------------------------------------------------------
class TestElmhurstGlazingTypeWrappedGap:
"""When a hand-entered Elmhurst worksheet is dumped via pdftotext, the
glazing-GAP column ("16 mm or more") wraps onto the glazing-TYPE token,
yielding labels like "Double between 2002 and 2021 16 mm or" (plus a
trailing building-part fragment). The extractor must strip the trailing
gap descriptor and map the clean type, not raise UnmappedElmhurstLabel."""
def test_trailing_gap_descriptor_stripped(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code
# Act
code = _elmhurst_glazing_type_code(
"Double between 2002 and 2021 16 mm or"
)
# Assert — clean "Double between 2002 and 2021" → SAP10 code 3
assert code == 3
def test_trailing_gap_plus_building_part_fragment_stripped(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code
# Act
code = _elmhurst_glazing_type_code(
"Double between 2002 and 2021 16 mm or 1st"
)
# Assert
assert code == 3
def test_clean_label_still_maps(self) -> None:
# Arrange — regression guard: an un-wrapped label is unaffected.
from datatypes.epc.domain.mapper import _elmhurst_glazing_type_code
# Act
code = _elmhurst_glazing_type_code("Double pre 2002")
# Assert
assert code == 2
class TestApiFloorConstructionCode:
"""`_api_floor_construction_str` maps the GOV.UK API integer
floor_construction code to the description string the cascade's
`u_floor` + the §5 (12) infiltration rule read. RdSAP 10 field 3-1
"Floor construction" enumerates the lowest-floor construction as one
of: solid / suspended timber / suspended, not timber. The spec's
"Suspended not timber (structural infiltration 0)" makes the
timber/not-timber split load-bearing: only "Suspended timber"
triggers the §5 (12) 0.1/0.2 floor-infiltration adjustment;
"suspended, not timber" is structural-infiltration 0."""
def test_code_3_maps_to_suspended_not_timber(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_construction_str(3)
# Assert — suspended U-value branch fires (starts "Suspended"),
# but the exact-match "Suspended timber" (12) rule does NOT —
# per RdSAP 10 "suspended not timber (structural infiltration 0)".
# Same canonical string the site-notes mapper already uses.
assert result == "Suspended, not timber"
def test_code_2_still_maps_to_suspended_timber(self) -> None:
# Arrange — regression guard: the timber code is unchanged.
from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_construction_str(2)
# Assert
assert result == "Suspended timber"
def test_code_0_maps_to_none_unknown_construction(self) -> None:
# Arrange — code 0 is the "not recorded / not applicable"
# sentinel: it pairs overwhelmingly with floor_heat_loss=6
# ("another dwelling below", an upper-floor flat with no ground
# floor to describe), but also appears with mixed Solid / unheated
# descriptions. There is no single construction to assert, so it
# maps to None — RdSAP 10 Table 19 ("where floor construction is
# unknown" → age-band default), the same treatment as an unlodged
# floor_construction. Empirically inert: floor W/K is identical to
# any explicit construction string across all observed code-0
# certs (the heat loss is governed by floor_heat_loss, not this).
from datatypes.epc.domain.mapper import _api_floor_construction_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_floor_construction_str(0)
# Assert — no raise; None defers to the cascade's Table 19 default.
assert result is None
class TestDefaultMissingPostTown:
"""`_default_missing_post_town` keeps a cert mappable when the
register omits the required `post_town` field (observed on a 2026
cert whose town sits only in address_line_3). post_town is address
metadata the SAP cascade never reads, so defaulting it to "" before
`from_dict` is inert for the calculation."""
def test_absent_post_town_is_defaulted_to_empty_string(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage]
doc: dict[str, object] = {"postcode": "EX31 2LE"}
# Act
result = _default_missing_post_town(doc)
# Assert
assert result["post_town"] == ""
def test_present_post_town_is_untouched(self) -> None:
# Arrange — regression guard: a lodged town passes through.
from datatypes.epc.domain.mapper import _default_missing_post_town # pyright: ignore[reportPrivateUsage]
doc: dict[str, object] = {"post_town": "BARNSTAPLE"}
# Act
result = _default_missing_post_town(doc)
# Assert
assert result["post_town"] == "BARNSTAPLE"
class TestApiRoofConstructionCode:
"""`_api_roof_construction_str` maps the GOV.UK API integer
roof_construction code to the string the cascade reads ONLY for the
"sloping ceiling" cos(30°) inclined-surface factor (Slice 89). Codes
6 and 7 are neither sloping ceilings nor base-U drivers (the roof
U-value comes from the global roofs[].description), so both map to
None: code 6 = "Thatched" (its U is set by the description, not this
field) and code 7 = "(same/another dwelling above)" an internal
ceiling with no roof heat loss, the roof-side analogue of
floor_construction code 0. Empirically inert: roof W/K is identical
whether 6/7 map to None or to an explicit pitched string across all
code-6/7 certs in the 2026 sample."""
def test_code_7_same_dwelling_above_maps_to_none(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_roof_construction_str(7)
# Assert — None: no sloping-ceiling signal (avoids the cos(30°)
# false-trigger); the internal ceiling has no roof heat loss.
assert result is None
def test_code_6_thatched_maps_to_none(self) -> None:
# Arrange
from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_roof_construction_str(6)
# Assert — None: thatched is not a sloping ceiling; its U-value is
# carried by the global roof description, not roof_construction_type.
assert result is None
def test_code_8_still_maps_to_sloping_ceiling(self) -> None:
# Arrange — regression guard: the sloping-ceiling code is unchanged.
from datatypes.epc.domain.mapper import _api_roof_construction_str # pyright: ignore[reportPrivateUsage]
# Act
result = _api_roof_construction_str(8)
# Assert
assert result == "Pitched, sloping ceiling"

View file

@ -133,6 +133,7 @@ class SapEnergySource:
wind_turbines_terrain_type: int
electricity_smart_meter_present: str
pv_batteries: Optional[PvBatteries] = None
pv_diverter: Optional[str] = None
@dataclass
@ -175,10 +176,11 @@ class SapFloorDimension:
class RoomInRoofType1:
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
full enum not yet mapped). `gable_wall_length_*` is the run of the
external gable in metres. Heights are NOT lodged here the cascade
applies the §3.9.1 default storey height (2.45 m)."""
`gable_wall_type_*` is the Table 4 gable variant (0 = Party,
1 = Exposed established from cert 6035's Summary; other variants
not yet seen). `gable_wall_length_*` is the run of the gable in
metres. Heights are NOT lodged here the mapper applies the §3.9.1
default RR storey height (2.45 m)."""
gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None
@ -240,6 +242,16 @@ class SapBuildingPart:
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
wall_thickness: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
# Lodged measured insulation thickness (mm) backing a
# `wall_insulation_thickness == "measured"` lodgement. Previously
# undeclared, so `from_dict` silently dropped it and the cascade fell
# back to the 50 mm "insulation present, unknown thickness" default.
wall_insulation_thickness_measured: Optional[Union[str, int]] = None
# Lodged thermal-conductivity code for measured wall insulation
# (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared
# → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence
# R-value path when a measured wall thickness is also lodged.
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None

View file

@ -161,6 +161,7 @@ class SapEnergySource:
wind_turbines_terrain_type: int
electricity_smart_meter_present: str
pv_battery_count: Optional[int] = None
pv_diverter: Optional[str] = None
wind_turbine_details: Optional[WindTurbineDetails] = None
pv_batteries: Optional[Union[PvBatteries, List[PvBatteries]]] = None
@ -207,10 +208,11 @@ class SapFloorDimension:
class RoomInRoofType1:
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
full enum not yet mapped). `gable_wall_length_*` is the run of the
external gable in metres. Heights are NOT lodged here the cascade
applies the §3.9.1 default storey height (2.45 m)."""
`gable_wall_type_*` is the Table 4 gable variant (0 = Party,
1 = Exposed established from cert 6035's Summary; other variants
not yet seen). `gable_wall_length_*` is the run of the gable in
metres. Heights are NOT lodged here the mapper applies the §3.9.1
default RR storey height (2.45 m)."""
gable_wall_type_1: Optional[int] = None
gable_wall_type_2: Optional[int] = None
gable_wall_length_1: Optional[float] = None
@ -278,6 +280,16 @@ class SapBuildingPart:
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
wall_thickness: Optional[int] = None
wall_insulation_thickness: Optional[str] = None
# Lodged measured insulation thickness (mm) backing a
# `wall_insulation_thickness == "measured"` lodgement. Previously
# undeclared, so `from_dict` silently dropped it and the cascade fell
# back to the 50 mm "insulation present, unknown thickness" default.
wall_insulation_thickness_measured: Optional[Union[str, int]] = None
# Lodged thermal-conductivity code for measured wall insulation
# (RdSAP 10 §5.8: λ = 0.04 / 0.03 / 0.025 W/m·K). Previously undeclared
# → dropped by `from_dict`. Consumed by `u_wall`'s documentary-evidence
# R-value path when a measured wall thickness is also lodged.
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
floor_insulation_thickness: Optional[str] = None
flat_roof_insulation_thickness: Optional[Union[str, int]] = None

View file

@ -244,6 +244,15 @@ class MainHeating2:
fan_assisted_flue: bool = False
percentage_of_heat: int = 0
main_heating_sap_code: Optional[int] = None
# §14.1 "Heat Emitter" (e.g. "Underfloor Heating") + "Main Heating
# Controls Sap" (e.g. "SAP code 2110, ..."). Lodged when the two main
# systems serve different parts of the dwelling with their own
# emitter + control (simulated case 6: Main 1 radiators / control
# 2106, Main 2 underfloor / control 2110). Needed for the SAP 10.2
# p.186 two-systems-different-parts MIT (weighted responsiveness +
# elsewhere two-control blend).
heat_emitter: str = ""
heating_controls_sap: str = ""
@dataclass
@ -409,6 +418,11 @@ class Renewables:
solar_hw_collector_orientation: Optional[str] = None
solar_hw_collector_pitch_deg: Optional[int] = None
solar_hw_overshading: Optional[str] = None
# Summary §19.0 "Diverter present" — a PV diverter routes surplus PV
# generation to an immersion heater in the hot-water cylinder
# (SAP 10.2 Appendix G4). Drives worksheet (63b)m. Defaults False
# when the cert lodges no PV or "Diverter present = No".
pv_diverter_present: bool = False
@dataclass

View file

@ -0,0 +1,62 @@
# PR note — SY system-built vs B basement wall (issue #1177)
For the reviewer / the `feature/bill-derivation` session. Fold the relevant
parts into the PR description; delete this file before/at merge.
## What this branch changed (commit `bd25a3c7`)
`wall_construction == 6` (`WALL_SYSTEM_BUILT`) is the canonical **wall type**
for system-built — and it stays there. The basement signal, which had hijacked
code 6, is moved onto a dedicated flag:
- `SapBuildingPart.wall_is_basement: Optional[bool]` and
`SapAlternativeWall.is_basement: Optional[bool]`.
- `main_wall_is_basement` / `is_basement_wall` honour the flag when set, else
fall back to the legacy `wall_construction == 6` heuristic (so untouched API
basements and the cert 000565 "B" cohort are unchanged).
- Elmhurst: `_elmhurst_wall_is_basement` sets the flag from the distinct
"SY"/"B" labels (SY→False, B→True, other→None).
- gov-EPC API: `from_api_response` post-processes via
`_clear_basement_flag_when_system_built` — when the cert `addendum.system_build`
is True, the code-6 basement heuristic is cleared.
- `EpcPropertyData.system_build` is a **derived `@property`** (not a stored
field): the MAIN wall is system-built iff `wall_construction == 6` and it is
not flagged basement. (Per the call: "system build is the wall type and
should be on `wall_construction`.")
Acceptance verified: system-built main wall → `wall_construction == 6`,
`main_wall_is_basement is False`, `system_build is True`; genuine basement main
wall → `main_wall_is_basement is True`, `system_build is False`. Full §4 suite
green (2404 passed), zero new pyright errors.
## ⚠️ Merge collision: `system_build` field (yours) vs property (this branch)
The `#1177` prompt referenced `EpcPropertyData.system_build` as an existing
**field** on `feature/bill-derivation`. This branch adds `system_build` as a
**`@property`**. They share the name but live in different regions of the class,
so:
- **Git will likely merge them silently** (no textual conflict).
- **Python will NOT raise at import** — the class defines fine.
- It raises `AttributeError: property 'system_build' ... has no setter` at the
**first `EpcPropertyData(...)` instantiation** — i.e. the first mapper call,
so the test suite fails immediately. (Reproduced.)
So the collision is caught fast but is a landmine, not a clean signal. **Resolve
it deliberately at merge** — pick ONE representation:
- **Recommended (matches the agreed model):** drop the stored field; keep the
derived property (system-built is the wall type). If your code currently
*assigns* `epc.system_build = …`, replace those writes with setting the
underlying wall type / basement flag, or add a setter.
- **Or** keep your stored field and delete this branch's property — but then
populate the field consistently with the wall type on BOTH paths (API addendum
*and* Elmhurst "SY"), so `system_build` and `wall_construction`/the basement
flag never disagree.
## Tripwire you own
`tests/domain/modelling/test_elmhurst_cascade_pins.py::test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after`
is a strict-xfail on your branch (fixtures `system_built_{ewi,iwi}_001431_{before,after}.pdf`
are committed there, not here). With this fix the behaviour it guards is
satisfied, so it should flip xfail→xpass — delete the marker when it does.

View file

@ -32,8 +32,9 @@ and **still require the matching DB migration** wherever the physical tables liv
`heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection`;
`epc_main_heating_detail`: `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`,
`main_heating_control`; `epc_building_part`: `wall_construction`, `wall_insulation_type`,
`party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`,
`roof_insulation_thickness`; `epc_window`: `glazing_gap`, `orientation`, `window_type`,
`party_wall_construction`, `wall_insulation_thickness`, `flat_roof_insulation_thickness`,
`roof_insulation_location`, `roof_insulation_thickness`; `epc_window`: `glazing_gap`,
`orientation`, `window_type`,
`glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`,
`permanent_shutters_present`, `transmission_data_source`).
- **New scalar columns**`epc_property`: `heating_number_baths`, `heating_number_baths_wwhrs`,
@ -68,7 +69,7 @@ in the forward mapper so the Python type round-trips exactly (JSON scalars prese
|---|---|
| `epc_property` | `heating_cylinder_size`, `heating_immersion_heating_type`, `heating_cylinder_insulation_type`, `heating_secondary_heating_type`, `heating_shower_outlet_type`, `energy_pv_connection` |
| `epc_main_heating_detail` | `main_fuel_type`, `heat_emitter_type`, `emitter_temperature`, `main_heating_control` |
| `epc_building_part` | `wall_construction`, `wall_insulation_type`, `party_wall_construction`, `flat_roof_insulation_thickness`, `roof_insulation_location`, `roof_insulation_thickness` |
| `epc_building_part` | `wall_construction`, `wall_insulation_type`, `party_wall_construction`, `wall_insulation_thickness`, `flat_roof_insulation_thickness`, `roof_insulation_location`, `roof_insulation_thickness` |
| `epc_window` | `glazing_gap`, `orientation`, `window_type`, `glazing_type`, `window_location`, `window_wall_type`, `draught_proofed`, `permanent_shutters_present` |
(`energy_meter_type` and `energy_wind_turbines_terrain_type` are `str` in the domain — leave as

View file

@ -425,9 +425,15 @@ def _solve_month(
# SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh`
# (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly.
q_heat = inputs.space_heating_monthly_kwh[month - 1]
# SAP 10.2 §9a — (211)m/(215)m precomputed upstream by
# SAP 10.2 §9a — (211)m/(213)m/(215)m precomputed upstream by
# `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline.
fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1]
# `main_heating_fuel_kwh` aggregates both main systems (213)m is zero
# for single-main certs, so this is the per-system sum for dual-main
# dwellings (cert 0240 / simulated case 6) and main-1 alone otherwise.
fuel_main = (
inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1]
+ inputs.energy_requirements.main_2_fuel_monthly_kwh[month - 1]
)
fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1]
# SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh`

View file

@ -0,0 +1,212 @@
# Handover — closing golden cert 0240-0200-5706-2365-8010 to 1e-4
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology,
accuracy bar, and pipeline. This records the state of cert **0240** and the
concrete path to driving its residual to zero.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `7344f600` (S0380.207). Confirm with `git rev-parse HEAD`.
- **Baseline:** `2372 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command).
---
## What the last session shipped (S0380.201207)
Closed **simulated case 6** (`001431_case6`) to 1e-4 on the full SapResult and
promoted it to an e2e fixture. It is the worksheet-backed proxy for 0240's
**dual-oil, different-parts** archetype (Main 1 rads/2106 + Main 2 UFH/2110,
51/49, 6 "Roof of Room" rooflights, no boiler interlock). Slices:
| slice | spec | what |
|---|---|---|
| S0380.201 | Table 4f note c) | 2nd-main circulation pump → (231) |
| S0380.202 | Table 5a note a) | 2nd-main pump **gain** → (70) |
| S0380.203 | RdSAP §3.7 | "Roof of Room" rooflights deduct from the §3.10.1 RR residual → (30) |
| S0380.204 | extractor/mapper | capture Main 2's §14.1 emitter + control |
| S0380.205 | SAP 10.2 **p.186** | two-systems-different-parts MIT: weighted R + elsewhere two-control blend → (87)/(90)/(98c) |
| S0380.206 | Appendix D Eq D1 | Q_space = DHW boiler's own (204) share, not (202) → (219) |
| S0380.207 | test | promote case 6 to a full e2e fixture |
**0240 was re-pinned at each step** (it shares the archetype) and its residual
improved on PE/CO2 but its SAP integer dropped 73→72. The boiler-interlock 5pp
the *previous* handover called the priority was already implemented — see
[[project_case6_interlock_already_done]].
---
## The 0240 problem — and why case 6 did NOT close it
### ⚠️ Critical: 0240 is API-only and its register target is INTEGER-rounded
0240 has **no worksheet**. The golden test pins the cascade against the lodged
EPC register:
- `energy_consumption_current = 122`**an integer** (1 kWh/m² resolution).
- `co2_emissions_current = 6.0`**1 d.p.** (tonnes).
- `current_energy_efficiency = None` — the SAP isn't even in the JSON
(`actual_sap=73` in the test was carried from the original lodgement).
**You cannot drive 0240 to "0 residual" at 1e-4 against these.** The register
rounds PE to the nearest whole kWh/m², so any cascade value in `[121.5, 122.5)`
*is* 122, and the true (unrounded) Elmhurst value could sit anywhere in that band
— or itself carry residual vs the rounded lodgement. Matching a rounded integer
to 1e-4 is not a well-posed target. **The only 1e-4 ground truth is a worksheet**
(the per-line `(1)..(286)` Elmhurst output), which is exactly why case 5/6 were
generated.
Current 0240 cascade vs lodged: **PE 123.8687 vs 122 (resid +1.8687)**, **CO2
6.0907 vs 6.0 (+0.0907)**, SAP cont 72.39 (integer 72 vs lodged 73, resid 1).
### Why case 6 didn't close 0240
Case 6 validated the dual-main **structure** (MIT p.186, pumps, rooflights,
Eq-D1 fraction). But 0240 differs in cert-specific features case 6 does **not**
exercise:
| feature | case 6 (worksheet-validated) | **0240** (unvalidated) |
|---|---|---|
| SAP code | 127 regular oil boiler | **130 condensing oil combi** (Table 4b 82/73) |
| DHW path | regular boiler **+ 110 L cylinder** → primary/storage loss | **combi, NO cylinder** → Table 3a keep-hot **600 kWh** (`combi_loss`), primary_loss 0 |
| TFA | (case-6 dwelling) | **201.53 m²** (different fabric/dimensions) |
| PV | none | **none** (the golden note's "+ PV" is STALE — `solar_water_heating=N`, no PV field) |
So 0240's *remaining* residual lives in the parts case 6 never touched —
**the condensing-combi (130) + no-cylinder HW path** and the cert's own fabric.
The combi Eq-D1 / Table 3a keep-hot path has never been pinned against a
worksheet in the dual-main context.
### Partial ground truth already in the 0240 JSON
The lodged `renewable_heat_incentive` block gives two deemed-demand figures:
- `water_heating = 2842.82`**exactly equals** the cascade's §4 HW output
`(64)` (2842.82). So the **HW demand is right**; any HW residual is in the
*efficiency* (Eq D1 combi blend), not the demand.
- `space_heating_existing_dwelling = 13254.52` vs cascade `(98c)` 12760.9 —
differ ~494 kWh (~3.7%). RHI uses its own deemed methodology so this is **not**
a clean 1e-4 check, but it's a hint the space-heat demand or the combi figures
are worth scrutinising.
---
## What to do next — generate the right example
To close 0240 properly you need a **worksheet** that exercises its
combi-HW path. Two options, best first:
1. **Exact 0240 replica worksheet (gold standard).** Re-enter 0240's lodged data
into Elmhurst and export the worksheet PDF. Then build a mapper-driven fixture
(mirror `_elmhurst_worksheet_001431_case6.py`) and pin every line `(1)..(286)`
at 1e-4. The first diverging line localises the residual exactly. This is the
only way to get a true 1e-4 target for 0240.
2. **"Case 7" — case 6 with 0240's combi swapped in.** If generating an exact
0240 replica is hard, generate a `001431` variant that changes case 6's
heating to **0240's**:
- **Condensing oil combi, SAP code 130** (not 127 regular boiler).
- **NO hot water cylinder** — combi instantaneous DHW → WHC 901, Table 3a/3b
combi keep-hot loss, no primary/storage loss.
- Keep the validated dual-main rads(2106)+UFH(2110) 51/49 + RR rooflights.
This pins the **combi Eq-D1 + Table 3 keep-hot** path (the biggest unvalidated
piece) against a worksheet. Whatever it reveals applies directly to 0240.
**The single most important differentiator to change vs case 6: regular
boiler + cylinder → condensing combi (130) with no cylinder.** That is the one
HW path 0240 uses that has never seen a worksheet in this archetype.
### Reframing the goal
If a worksheet is genuinely unavailable, "0 residual vs the lodged register" is
not achievable at 1e-4 (integer rounding). The realistic target then is the
**±0.5 SAP fallback** (AGENT_GUIDE §1) — and 0240's continuous SAP 72.39 vs
lodged 73 is ~0.6 off, just outside it. Closing that last 0.6 still requires
knowing the true (worksheet) value, so the worksheet is the unblocker either way.
---
## 0240 worksheet input specification (build THIS in Elmhurst)
Reproduce cert **0240-0200-5706-2365-8010** as closely as possible so the
worksheet is a valid 1e-4 ground truth for the cascade. All values are from the
fixture JSON (`tests/.../fixtures/golden/0240-0200-5706-2365-8010.json`). Match
the **U-values / W-per-K targets** below — those are what the cascade actually
consumes, so hitting them matters more than the exact construction wording.
**Dwelling**
- Detached house, **construction age band J**, England. Postcode **LE15 9LB**
(drives the demand/EPC climate). 1 extension. 7 habitable rooms.
- Storey height **2.28 m**. Total floor area **≈202 m²** =
Main ground floor **97.72** + Extension 1 ground floor **20.61** + the
room-in-roof floor **83.2**.
**Heating — the load-bearing difference vs case 6.** TWO main systems, both a
**condensing oil combi, SAP main heating code 130** (Table 4b winter **82** /
summer **73**), oil fuel, **balanced flue** (not fan-assisted), efficiency from
the **SAP table** (no PCDB boiler index), central-heating pump age **unknown**.
They heat **different parts** (so the p.186 MIT applies, already implemented):
- **Main 1****radiators**, control **2106** (programmer + room thermostat +
TRVs), **51%**, serves the living area.
- **Main 2****underfloor heating in screed** (R=0.75), control **2110** (time +
temperature zone control), **49%**, serves elsewhere.
**Domestic hot water — the other key difference vs case 6.** Heated **from the
main system** (WHC 901), oil. It is a **COMBI with NO hot-water cylinder**
instantaneous DHW → SAP 10.2 **Table 3a keep-hot loss 600 kWh/yr** (`combi_loss`
600, `primary_loss` 0). 3 **mixer** showers + 1 bath; no effective WWHRS.
NB the lodged RHI `water_heating = 2842.82` already equals the cascade HW output
exactly, so get the DHW *demand* inputs right and any residual is in the combi
*efficiency* (Eq D1 winter/summer blend).
- **Boiler interlock: YES** for 0240 (combi + room thermostat 2106, no cylinder)
**no 5pp penalty**, both systems run at base eff 82/73. (This is the
OPPOSITE of case 6, which had a regular boiler + cylinder with no cylinder stat
5pp. Get this right or the efficiencies — hence everything — will be off.)
**Fabric — target W/K (cascade values to reproduce; total external area 328.97 m²):**
| element | W/K | notes |
|---|---|---|
| Walls (29a) | 24.45 | age J, **uninsulated** (NI), not dry-lined, not measured |
| Roof (30) | 32.331 | Main = pitched, **access to loft**, insulation at ceiling, **400 mm+** ; Ext1 = pitched **vaulted ceiling**, **no insulation (NI)** |
| Floor (28) | 29.4297 | **solid**; Main heat-loss perimeter 36.45, Ext1 13.45 |
| Party/gable (32) | 7.84 | RR gables billed as party at U=0.25 |
| Windows (27) | 22.7407 | see below |
| Roof windows (27a) | 12.6374 | see below |
| Doors (26) | 11.1 | **2 doors, uninsulated** |
| Thermal bridging (36) | 36.1867 | = 0.11 × 328.97 |
| **(33) fabric total** | **140.5288** | |
| **(37)+vent feeds (39)** | total transmission **176.7155** | |
**Room-in-roof** (Main only): floor area **83.2 m²**, **two gables L = 6.40 m**
— one **Exposed**, one **Party** (per the case-5/6 sandstone replica convention),
age J. This is the same Simplified/detailed-gable RR structure case 6 validated.
**Windows** (all **double glazed, PVC frame**, glazing "DG 2002+", U≈2.0, g=0.72):
- 5 **vertical** wall windows: 1.4×1.3, 1.2×1.3 (orient N), 1.6×1.3, 2.5×2.0
(orient E), 1.4×1.3 (orient S, on Extension 1).
- 6 **"Roof of Room" rooflights** (window_wall_type 4): all **1.0×1.0**, at 45°,
3 orient N + 3 orient S. These bill on (27a) and deduct from the RR residual
(S0380.203) — keep them as roof-of-room, not vertical glazing.
**Ventilation / lighting / other**
- Natural ventilation; **no** mechanical ventilation, **no** extract fans, **no**
chimneys/flues. 85% draught-proofed.
- Lighting: **8 LED bulbs, 100% low-energy** (no CFL/incandescent).
- **No PV**, no solar thermal, **no secondary heating**, no air-conditioning.
- Electricity meter type 3 (standard), smart meter present, not export-capable.
### The three things that MUST differ from case 6 (or you've just rebuilt case 6)
1. **Condensing oil combi, SAP code 130** (case 6 = regular oil boiler 127).
2. **Combi, NO cylinder** → Table 3a keep-hot 600 kWh (case 6 = boiler + 110 L
cylinder → primary/storage loss).
3. **Boiler interlock PRESENT → no 5pp** (case 6 = no interlock → 5pp). Driven
automatically by "combi + room thermostat, no cylinder", but verify the
worksheet shows base eff 82/73, not 77/68.
Everything else (dual-main different-parts MIT, two pumps, rooflight→RR, Eq-D1
(204) share) is already implemented and validated by case 6 — the new worksheet
just confirms the combi-HW path on top of that closed structure.
---
## Pointers
- Golden pin + full slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py` (cert `0240-0200-5706-2365-8010`, line ~83).
- Case-6 fixture to mirror: `tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case6.py` + its e2e pins in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`.
- Memories: [[project_case6_mit_two_system_rootcause]] (the p.186 MIT, now CLOSED), [[project_case6_interlock_already_done]], [[feedback-worksheet-not-api-reference]] (API matches the worksheet, not the lodged register), [[feedback-software-no-special-handling]].
- Repro 0240: `EpcPropertyDataMapper.from_api_response(json.load(...0240.json))``cert_to_inputs` / `cert_to_demand_inputs``calculate_sap_from_inputs`. The §2.4 section helpers are UNFAITHFUL (skip the interlock penalty + two-system MIT params) — diagnose against the real `cert_to_inputs` cascade.
- Process: one slice = one commit, spec citation (page+line), `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. SAP 10.2 only. No tolerance widening. mapper.py + cert_to_inputs.py each carry 32 pre-existing pyright errors (baseline-compare with `git stash`).

View file

@ -0,0 +1,318 @@
# Handover — wide-scale API accuracy study + next steps
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, the
1e-4 bar, the per-line debugging loop, the section helpers, and the suite command.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `9521d524`. Next SAP slice: **S0380.235**.
- **Baseline (§4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/`
→ green (2412 passed, 1 skipped). Pre-existing out-of-scope failures unchanged
(stone-§5.6 in `domain/sap10_ml/tests/`; `test_from_rdsap_schema.py::...test_total_floor_area`).
## Shipped this session (S0380.232-234 — the case-19 PV closure)
The PV diverter (the prior handover's S0380.232 ask) needed two prerequisite
spec bugs fixed first; all three landed:
| slice | commit | spec | what |
|---|---|---|---|
| **S0380.232** | `212b0c92` | App M1 §3a (p.93, l.5470-5476) | D_PV excludes the LOW-rate portion of an off-peak electric main: `(211)` is only PV-eligible where its §10a code ∈ {30,32,34,35,38}. Storage heaters on 7-hr charge wholly at low rate → fraction 0.0 → excluded. β_Jan 0.894→0.792 (ws 0.791). New `_main_space_heating_high_rate_fraction`. |
| **S0380.233** | `d4a8c02b` | App M1 §6 (p.94, l.5510-5513) | PV-used-in-dwelling credited at the Table 12a ALL_OTHER_USES **weighted** rate (7-hr 14.311 p/kWh), not the bare low rate (5.50). Was under-crediting onsite PV on every off-peak PV cert. Delegates to `_other_fuel_cost_gbp_per_kwh`; STANDARD unchanged. |
| **S0380.234** | `9521d524` | Appendix G4 (p.72-73) | The PV diverter. 3 layers: extractor `Diverter present` + schema `pv_diverter``pv_diverter_present` flag (Elmhurst + API mappers) → `_pv_diverter_monthly_kwh` (SPV = export×0.8×0.9, clamp ≤ (62)+(63a), → (63b)m); `cert_to_inputs` recomputes (219) + PV export, β fixed pre-diverter. |
**Case 19 now: SAP cont 50.33 → 51.34** (ws 51.2221; both round to lodged **51**),
cost (255) 1847.5→1812.3 (ws 1816.6), CO2 3331→3120 (ws 3126), (233a) dwelling
1280.6 (ws **1280.4** — the β fix pins it). The diverter formula is **exact in
summer** (Jun SPV 186.07 = export×0.72, matches ws (63b)).
**The remaining +0.11 SAP on case 19 = two separate, still-open causes:**
1. **Winter Appendix-M monthly EPV shape.** Our annual EPV (2684.17) matches the
worksheet exactly and Jun-Sep match per-month exactly, but Jan-May/Oct-Dec our
EPV is ~9-11% LOW (worksheet Jan 68.2 vs ours 62.5). Back-solve: ws EPV_m =
|(233a)_m| + |(63b)_m|/0.72. This under-diverts in winter → export (233b) 280.7
vs ws 184.2, and (219) 3322 vs ws 3188. **A two-array PV apportionment issue
(case 19 has SE + NW arrays with different overshading) — chase in §M / Appendix U
monthly radiation, NOT the diverter (which is validated).**
2. **Fabric (33) +1.0 W/K** (ours 305.04 vs ws 304.04) — a single element off by
exactly 1.0; floor=25.000 is suspiciously round. Walk the per-element §3 breakdown.
The **eval headline is flat** (42.9→43.0% <0.5; cat-7 5.254.93) expected: the
diverter is rare and the β/price effects are small on the rounded SAP. The value
was pinning the worksheet-validated case 19 + fixing three real spec bugs that the
curated cohort masked.
## Headline now (1,000-cert 2026 API sample, HEAD `f326e4eb`)
| metric | value | was (handover baseline `9c0a373f`) |
|---|---|---|
| computed | 882 / 1000 | 882 |
| **% \|err\| < 0.5** | **42.9%** | 41.8% |
| % < 1 / < 2 / < 5 | 56.7% / 74.6% / 90.1% | 54.9 / 71.9 / 87.8 |
| median / mean \|err\| | 0.73 / **2.04** | 0.79 / ~2.4 |
| mean signed | 0.41 | +0.2 |
**Error by heating cluster** (the load-bearing cut — re-run `analyse_api_sap_clusters.py`):
| cluster | n | mean \|err\| | %<0.5 | note |
|---|---|---|---|---|
| cat 2 gas boiler + PCDB | 639 | 1.27 | 49.6% | well-trodden |
| cat 2 gas, NO PCDB idx | 91 | 3.18 | 35.2% | non-PCDB Table-4b boilers |
| cat 6 community | 45 | 2.59 | 31.1% | known-hard |
| cat 7 electric storage | 40 | **5.25** | 10.0% | was 7.33 → S0380.227-229 |
| cat 10 electric room heaters | 48 | **5.26** | 16.7% | was 9.49 → S0380.230-231 (bias gone) |
| cat 4 HP + PCDB | 8 | 6.11 | 12.5% | small n, APM |
| Flats (any) | 282 | 2.57 | 30.5% | geometry / communal |
| real PV | 45 | 3.90 | 26.7% | Appendix M |
**Worst individual offenders** (the long tail — `eval` TOP 40): `2100-5421-0922-1622-3463`
(60.8, our SAP **negative** 24.8 vs lodged 36 — a flat, 2 bps, cat-2; the single worst, likely
a geometry/communal blow-up — START a per-cert dig here), `2958-8008` (+32, age 6=tiny),
`9836-5829` (29.5, cat-10 tail), several cat-7/cat-10 in the 20s.
## Work shipped (this session — S0380.227-231 + 3 mapper commits)
| commit | what |
|---|---|
| **S0380.227** | dedicated DHW-only system (WHS 911) is NOT separately timed → no Table 2b ×0.9; TF (53) 0.54→0.60, (59) h=3→5 |
| **S0380.228** | electric SECONDARY on off-peak bills at Table 12a `OTHER_DIRECT_ACTING_ELECTRIC` (1.00 high-frac), not 100% low |
| **S0380.229** | dedicated water boiler/circulator (WHC 911-931) feeds cylinder via primary loop → Table 3 primary loss applies |
| **S0380.230** | electric room heaters (cat 10) on off-peak → `OTHER_DIRECT_ACTING_ELECTRIC` (mirror of .228 for the MAIN). cat-10 9.49→7.11 |
| **S0380.231** | Dual-meter electric room heaters → 10-hour tariff (RdSAP §12 Rule 3; codes 691-694,699). cat-10 7.11→5.26, bias +5.08→0.86 |
| `bd25a3c7` | SY system-built vs B basement: code 6 stays system-built; basement → explicit `wall_is_basement`/`is_basement` flag. `system_build` is a derived property (wall type). API path post-processes via addendum. (issue #1177 — see `docs/PR_NOTE_system_built_basement_1177.md`: field-vs-property merge landmine) |
| `f326e4eb` | Elmhurst path now populates `roof_construction` (int) via `_elmhurst_roof_construction_int` for cross-mapper parity (API set it, Elmhurst didn't) |
- **Toolkit (committed):** `scripts/fetch_2026_epc_sample.py`,
`scripts/eval_api_sap_accuracy.py`, `scripts/analyse_api_sap_clusters.py`. The 1,000 cached
JSONs live in `/tmp/epc_2026_sample/` (gitignored scratch — re-fetch with the sampler;
`EPC_SAMPLE_CACHE` overrides the dir). Re-run the eval after any mapper/calculator change to
watch the headline move.
---
## What this study did
Fetched a **random 1,000-cert sample of domestic EPCs lodged JanMay 2026** from the
GOV.UK EPB register (the `/api/domestic/search` date-windowed endpoint to enumerate cert
numbers across random pages → `/api/certificate` per cert for the full schema-21 JSON), ran
each through the **API path** (`from_api_response → cert_to_inputs → continuous SAP`), and
compared to the lodged rounded `energy_rating_current`.
**This is the first measurement of raw-API behaviour on an unbiased population** — the curated
golden cohort (~exact) masked it.
### Reproduce
- Sampler/fetcher: `/tmp/sample_fetch_2026.py` → caches JSONs to `/tmp/epc_2026_sample/`.
- Evaluator: `/tmp/eval_sap_accuracy.py` → per-cert CSV + summary (`% <0.5`, buckets, worst-40,
raise breakdown). Cluster analysis: `/tmp/analyze2.py`. (Token in `backend/.env`
`OPEN_EPC_API_TOKEN`; `date_end` must be < today.)
- **These scripts are uncommitted (in /tmp).** Worth promoting to `scripts/` if this becomes
a recurring measurement.
---
## Headline (at HEAD `9c0a373f`)
| metric | value |
|---|---|
| computed | **882 / 1000** (100 unsupported pre-21 schema; 18 still raise) |
| **% \|err\| < 0.5** (of computed) | **41.8%** |
| % < 1.0 / < 2.0 / < 5.0 | 54.9% / 71.9% / 87.8% |
| median / mean \|err\| | 0.79 / ~2.4 |
| mean signed err | +0.2 (slight over-rate) |
**Accuracy is dominated by heating type** (the load-bearing cut):
| main_heating_category | n | mean \|err\| | %<0.5 | status |
|---|---|---|---|---|
| 2 = gas boiler (PCDB-indexed) | 579 | 1.30 | 48% | the well-trodden path |
| **7 = electric storage heaters** | 39 | **7.33** | **3%** | **broken — #1 lever** |
| **10 = electric room heaters** | 43 | **10.26** | **9%** | **broken — #2 lever** |
| 6 = community scheme | 38 | 2.28 | 34% | known-hard |
| Flats (any heating) | 242 | 3.19 | 29% | geometry + communal |
---
## Work shipped this session (S0380.219225)
Coverage unblocked **788 → 882 computed (+94)**; one real accuracy bug fixed (+22 certs).
| slice | fix | certs |
|---|---|---|
| S0380.219 | floor_construction 3 → "Suspended, not timber" (RdSAP 10 field 3-1) | ~44 |
| S0380.220 | floor_construction 0 → None (Table 19 unknown; proven inert) | 37 |
| S0380.221 | default missing `post_town` (unused metadata) | 1 |
| S0380.222 | roof_construction 6 (thatched) + 7 (dwelling above) → None (inert) | 5 |
| S0380.223 | `_part_geometry` early-return key contract (RR KeyError) | 5 |
| **S0380.224** | **loose-jacket cylinder storage loss (Table 2 Note 1)** — was None'd out → zero loss | **22** (mean err +2.29 → +0.45) |
| S0380.225 | §10.7 no-water-heating default A-F → 12mm loose jacket | 2 |
| S0380.226 | Elmhurst "Jacket" cylinder insulation → loose-jacket code 2 (Summary path) | (unblocked case 19) |
Headline at HEAD: **882 / 1000 computed, 41.8% < 0.5** (re-run the eval to refresh).
---
## ★ Active worksheet: simulated case 19 — the electric-storage-heater debug
The user generated `sap worksheets/golden fixture debugging/simulated case 19/`
(`Summary_001431 (2).pdf` + `P960-0001-001431 - 2026-06-04T174437.228.pdf`), purpose-built to
hit the #1 cluster. It exercises **electric storage heaters** (SAP code 402, control 2402
auto-charge, 7-hr off-peak tariff) + a **loose-jacket 210 L cylinder** + **WHS 911** (gas
boiler for water only) + **room-in-roof gables (Party + Exposed) + an alternative wall +
exposed floor + electric secondary**.
**S0380.226 unblocked extraction** (the "Jacket" label was raising). The worksheet has FOUR
blocks: **block 1 = rating** (UK-avg region 0; cost (255)=1816.58, SAP (258)=51, TF (53)=0.60,
(51)=0.0330), **block 2 = demand** (postcode; CO2 (272)=3125.85, PE (286)=30271.76), blocks 3/4
= the potential/improved variants. Pin the rating block for SAP/cost, the demand block for
PE/CO2. Worksheet header line 116 lodges **"Separate Time Control: No"** (NOT in the Summary §15
PDF — only in the P960 header).
**Three slices shipped (S0380.227229)** — closed the +9 cluster signature; SAP cont
60.2 → **50.33** (worksheet ~51.22):
| slice | line ref | fix | SAP cont |
|---|---|---|---|
| **S0380.227** | TF (53) 0.54→**0.60**; (59) h=3→**h=5** | dedicated DHW-only system (WHS 911) is NOT separately timed → no Table 2b ×0.9 (RdSAP 10 §10.5.1). `_separately_timed_dhw` gated on WHC ∈ {901,902,914}. Worksheet-pins S0380.224's loose-jacket (51)=0.0330/(53)=0.60/(55)=3.4531/(56-57)Jan=107.0456 at 1e-4. | 60.2→60.1 |
| **S0380.228** | cost (255) | electric SECONDARY on off-peak bills at Table 12a `OTHER_DIRECT_ACTING_ELECTRIC` (7-hr high-frac **1.00** = £0.1529), not the flat off-peak low (£0.0550). Worksheet (242): "1.00*15.29 + 0.00*5.50". THE primary cost driver (340). | 60.1→**50.67** |
| **S0380.229** | (62) 2493.30→**3169.98** | dedicated water-heating boiler/circulator (WHC 911-931) feeds the cylinder via a primary loop → Table 3 row 1 primary loss applies (keyed off `water_heating_code`, since `_water_heating_main` returns the electric SPACE main). Restored the missing (59)=676.68 kWh/yr. | 50.67→50.33 |
**The ONE remaining case-19 cause — the PV diverter (63b) — is S0380.232.** Worksheet
header line 124 "Diverter = Yes"; Summary §19 "Diverter present: Yes". Per **SAP 10.2 Appendix
G4 (PDF p.72-73)** surplus PV is diverted to the cylinder immersion:
`S_PV,diverter,m = EPV,m × (1 βm) × 0.8 × 0.9`, clamped to ≤ (62)m + (63a)m, entered as a
NEGATIVE (63b)m. (64)m = (62)m + (63a)m + (63b)m + … → (219)m = (64)m / eff. All four G4
inclusion conditions are met (PV connected to dwelling; cylinder 210 L > (43)=74.24; no solar
HW; no battery). Worksheet (63b) annual ≈ 1097.67 kWh → (64) drops 3169.98 → 2072.31, (219)
4876.9 → 3188.17. It ALSO changes the PV β-split (export drops: worksheet dwelling 1280.39 /
exported 184.16 vs our 1496.20 / 1187.98 with no diverter). This is a 3-layer feature
(extractor `Diverter present` → mapper flag → calculator (63b) + β-split interaction) —
implement as one focused slice. Spec note p.5485: for the β calc, (219)m must EXCLUDE the
diverter saving.
Smaller residuals after the diverter lands: main fuel (211) ours 20250.22 vs ws 19910.30
(+340), secondary (215) 3573.57 vs 3513.58 (+60), fabric (33) +1.0 (gable/alt-wall). Current
demand block: CO2 (272) 3331.04 vs 3125.85, PE (286) 31653.23 vs 30271.76 — both will drop with
the diverter (less grid import).
**Debug recipe** (reuse the `/tmp/case19*.py` throwaways or rebuild):
```python
pages → ElmhurstSiteNotesExtractor(...).extract() → from_elmhurst_site_notes
→ cert_to_inputs / cert_to_demand_inputs → calculate_sap_from_inputs
# CI._cylinder_storage_loss_override(epc, main) → (57)m; CI._primary_loss_override(epc, age) → (59)m
# CI._water_heating_worksheet_and_gains(epc=…, water_efficiency_pct=0.65, is_instantaneous=False,
# primary_age=<band>, pcdb_record=None) → wh_result with (45)/(46)/(57)/(59)/(62)/(64)
```
---
## Remaining work, prioritised
### A. Accuracy clusters (highest value)
1. **PV diverter (S0380.232)** — closes case 19 to 1e-4 AND helps the real-PV cluster (45 certs,
mean 3.90). Fully spec'd in the case-19 section above (Appendix G4). **Has a worksheet**
1e-4 bar. Do this first: it's the one open cause on a validated worksheet.
2. **Electric storage heaters (cat 7, 40 certs, mean 5.25).** S0380.227-229 took it 7.33→5.25;
the case-19 PV diverter will help further. Beyond that the tail is per-cert — a **dedicated
cat-7 worksheet** (no PV, no diverter) would let you pin charge-control / responsiveness at
1e-4 instead of the ±0.5 lodged fallback.
3. **Electric room heaters (cat 10, 48 certs, mean 5.26).** S0380.230-231 fixed the systematic
tariff bias (mean 9.49→5.26, signed +5.08→0.86); the residual is now scattered per-cert
(e.g. `9836-5829` 29.5, an under-rater). A **cat-10 worksheet** pins the tail at 1e-4.
4. **Non-PCDB gas boilers (cat 2, no idx, 91 certs, mean 3.18)** and **Flats (282, mean 2.57)**
the next volume levers once the electric clusters are worksheet-pinned. Flats = geometry /
communal; start with the worst (`2100-5421` negative SAP).
- **`2100-5421-0922-1622-3463` diagnosed (S0380.234 session):** NOT a flat — `property_type 0`,
a **352 m² 2-storey uninsulated solid-wall** dwelling (wall_constr 3 / wall_ins 4 as-built;
roof_type 4, no roof insulation). Our space-heating demand is **71,084 kWh/yr** → (37)=995.93
W/K → SAP 24.8 (lodged 36), cost £14,045. This is the **`as-built insulated-assumed`**
U-value front ([[project_as_built_insulated_assumed_bug]]; S0380.209 fixed walls, "roof next"):
the uninsulated-roof / as-built U over-estimates demand on big old dwellings. API-only (no
worksheet → ±0.5 lodged fallback only); needs a generated worksheet or a roof-U spec audit to
pin. It is one outlier, not a cluster-wide flats bug.
### B. Remaining raises (16 certs — all U-value / heat-loss-sensitive, NOT enum guesses)
- **`gable_wall_type` 2 & 3 (14 certs).** RdSAP 10 **Table 4** RR walls: 0=Party (U=0.25),
1=Exposed (U=common wall), 2/3 = **Sheltered (U=external×R0.5)** + **Adjacent-to-heated
(U=0)**, code↔type order unconfirmed (schema says "not yet seen"). Needs (i) a worksheet to
pin which code is which + the U-values, and (ii) **calculator support** — the cascade only
has `gable_wall`/`gable_wall_external` kinds; Sheltered (R=0.5) and Adjacent (U=0) are new.
Best real example: `2818-3053-3203-2655-9204` lodges BOTH gable 2 and 3.
- **`main_heating_category: 9` = warm air, mains gas (1 cert).** Needs §9 warm-air dispatch.
- **`wall_insulation_thermal_conductivity` 3 (1 cert).** Verified it shifts wall U
(53.96→51.61 across λ) → worksheet-backed (the resolver's own discipline).
- **`floor_heat_loss` 8 (2 certs).** Semantically unconfirmed; inert for the 2 observed
(non-Main bp) but potentially "heated space below" (→ should exclude the floor, a calculator
change). Don't guess.
The clean mapper-enum raises are **exhausted** — every remaining raise changes the answer, which
is what the strict-raise guard exists to prevent.
---
## ★ Additional worksheets that would help most (the user will generate these on request)
The two electric clusters are now systematic-bias-free (S0380.227-231) but their TAILS sit at
the ±0.5-vs-lodged fallback bar because **no worksheet validates them at 1e-4**. The three
highest-value worksheets to ask the user for:
1. **An electric ROOM-heater dwelling** (SAP code ~691, control 2601/2602/2603, Dual meter) —
pins the cat-10 tail (48 certs, mean 5.26) at 1e-4. Make it PV-free + cylinder-free to
isolate the space-heat path from the diverter/HW.
2. **An electric STORAGE-heater dwelling distinct from case 19** (no PV, no WHS-911) — pins the
cat-7 tail (40 certs, mean 5.25): charge control (2401/2402), 7-hr vs 24-hr, responsiveness.
3. **A room-in-roof with a SHELTERED gable and an ADJACENT-TO-HEATED gable** (Table 4 types
beyond Party/Exposed) — closes the `gable_wall_type` 2/3 raise (14 certs) and pins the
Sheltered (U=ext×R0.5) / Adjacent (U=0) U-values the calculator must add.
Per worksheet send BOTH the **Summary PDF** (input) and the **P960/dr87 worksheet PDF** (the
`(1)..(286)` ground truth). Drop them in `sap worksheets/golden fixture debugging/<name>/` and
run the case-19 debug recipe.
The original "design one property" guidance (kept below for reference) is what case 19 was
built from.
## What to generate — the single most productive worksheet (reference)
Heating is one-per-property, so one worksheet can't cover all four broken heating types. But
**fabric is independent of heating**, so the highest-ROI single artifact bundles the #1
accuracy cluster with the fabric that closes the gable raises and pins the loose-jacket fix.
**Build (in Elmhurst, a simulated case is fine — same as the existing `simulated case N`
worksheets) ONE property:**
> **A house heated by ELECTRIC STORAGE HEATERS, with a room-in-roof and a hot-water cylinder:**
> - **Heating:** electric storage heaters (off-peak / Economy-7 tariff), with a clear control
> type. *This is the load-bearing choice — it validates the 39-cert cat-7 cluster.*
> - **Hot water:** a cylinder with a **loose-jacket** insulation (not factory foam), a stated
> jacket thickness, and a cylinder thermostat. *Pins S0380.224's loose-jacket storage loss
> (56)m at 1e-4 — currently only direction-validated.*
> - **Room-in-roof** with **two gable walls of different types** — ideally one **"Sheltered"**
> and one **"Adjacent to another heated space"** (plus, if the tool allows, a Party and an
> Exposed gable). *Gives the Table 4 U-values for gable_wall_type 2 & 3 and disambiguates the
> code order — closes the 14-cert raise.*
> - **An extension (2nd building part)** with a different floor exposure (e.g. over unheated
> space or "to external air"). *Exercises multi-bp geometry + floor-exposure handling.*
From that single worksheet I can pin, at 1e-4: the electric-storage space-heating lines
((210)/(211)/space-heat), the loose-jacket storage loss (56)m, the RR gable U-values (30)/(32),
and the multi-bp fabric (27)(37). That's **one cluster + one fix-validation + the biggest
raise + fabric**, all in one document.
**If you'd rather do two:** add a second worksheet that is identical but with **electric room
heaters** instead of storage heaters — together they cover cat 7 + cat 10 (≈ 82 certs, the
two worst clusters). A third for a **community-heating flat** would cover cat 6 + the flat
geometry cluster.
### Then send me, per worksheet
The **Summary PDF** (the Elmhurst input/site-notes) + the **worksheet PDF** (the `(1)..(286)`
ground truth). With those I run both front-ends through the cascade and pin each line ref at
1e-4, exactly as for the `with api 3` pair (S0380.218).
---
## Conventions (unchanged)
One cause = one slice = one commit; spec citation (page+line) in the message; AAA tests
(`# Arrange / # Act / # Assert`); `abs(x - y) <= tol` (not `pytest.approx`); SAP 10.2 only; no
tolerance widening / xfail / rel-tol. New code passes pyright strict with ZERO NEW errors
(baseline-compare with `git stash`; mapper.py / cert_to_inputs.py / heat_transmission.py carry
pre-existing errors — compare counts). Stage files by name (the tree has unrelated
`pytest.ini`/`scripts/` changes that must NOT be staged).
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.

View file

@ -0,0 +1,160 @@
# Handover — fresh-API cross-comparison + flagged-cert debugging
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology, the
1e-4 bar, the per-line debugging loop, the section helpers, and the suite command.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `6d9ef114` (S0380.218). Confirm with `git rev-parse HEAD`.
- **Baseline (AGENT_GUIDE §4 suite):** `tests/domain/sap10_calculator/ backend/documents_parser/tests/`
→ green (2392 passed, 1 skipped at HEAD; the golden + worksheet pins all pass).
- **Next slice number:** **S0380.219**.
> **S0380.218 (DONE) — Part 1 closed for the "with api 3" pair.** The two
> certs the user dropped under `sap worksheets/with api 3/`
> `0340-2467-9260-2006-6521` (Summary_000922 / dr87 000922) and
> `5500-5070-0822-0201-3663` (Summary_000920 / dr87 000920) — are **clean**.
> Fetched fresh, run through BOTH front-ends, both paths agree to <1e-4 on
> SAP/cost/CO2/PE AND reproduce the worksheet (255)/(272)/(286)/(33)/(37)
> exactly. SAP integer = lodged (resid +0) on both. **No mapper/calculator
> bug surfaced.** Dropped-field audit clean (only `created_at` +
> `_normalize_shower_outlets`-handled shower keys). Locked in as golden
> fixtures: 2 JSONs under `fixtures/golden/` + entries in `_EXPECTATIONS`
> and `_WORKSHEET_PE_CO2` (test_golden_fixtures.py). The Summary path was
> validated manually but is NOT pinned in a committed test (would need the
> Summary PDFs copied into `backend/documents_parser/tests/fixtures/` + a
> textract-preprocessed chain test) — a cheap follow-up if cross-mapper
> parity wants a standing regression guard beyond the API-path golden pin.
- **Pre-existing failures (NOT yours, out of scope):**
- `domain/sap10_ml/tests/test_rdsap_uvalues.py` — 2 stone-§5.6 thin-wall failures
(granite + sandstone band A, 3.7408 vs Table-6 1.7 cap). Run this suite when you touch
`rdsap_uvalues.py`.
- `datatypes/epc/domain/tests/test_from_rdsap_schema.py::TestFromRdSapSchema21_0_1::test_total_floor_area`
(145.82 vs 45.82) — fails at original HEAD `ec64c39d` too. This file is NOT in the §4
suite command.
---
## ★ THE TASK — fetch fresh from the EPC API and debug, with worksheet cross-comparison
The previous session drove the **golden-fixtures cascade** (`cert_to_inputs`
`calculate_sap_from_inputs`) and concluded that the three then-flagged certs (7536, 2130,
0240) are "0240-like" — API-only residuals not reproducible from the register JSON. The
user pushed back ("going around in circles"), and the right next move is **fresh raw-API
data + worksheet triples**, not more simulated worksheets.
### Part 1 — two NEW certs with API + Summary + worksheet (cross-comparison)
The user has **two certs that have all three artifacts**: the GOV.UK API JSON, the Elmhurst
**Summary** PDF (site notes / input), and the Elmhurst **worksheet** PDF (the `(1)..(286)`
ground truth). These are gold — they let you run BOTH front-ends (`from_api_response` and
`from_elmhurst_site_notes`) through the same cascade and pin **both** against the worksheet
at 1e-4. The user will provide the cert numbers + drop the PDFs. For each:
1. Fetch the API JSON (see **Fetching** below).
2. Run API path → cascade; run Summary path → cascade; pin **both** vs the worksheet line
refs (`pdftotext -layout` the worksheet; compare `(27)/(28a)/(29a)/(30)/(33)/(36)/(45)m/
(62)/(233a)/(233b)/(258)…`). Cross-mapper parity: the two paths must agree to 1e-4 AND
match the worksheet (memory `feedback_cross_mapper_parity_via_cascade`).
3. The **first diverging line ref localises the bug** (AGENT_GUIDE §3): value present in
worksheet but cascade 0/wrong → calculator; input field absent in `epc` → mapper or
extractor. Fix one cause = one slice.
### Part 2 — (secondary) re-check the previously-flagged certs on THIS branch
A dashboard once flagged six certs (0240, 0390-2954-3640, 2130, 6035, 7536, 9390). **Those
numbers are STALE — they came from a branch WITHOUT this branch's fixes** (the user confirmed
this). Do not chase them. On THIS branch the picture is different and mostly settled:
- 7536 (68.924, +1), 2130 (83.78, +2), 0240 (1) — concluded **0240-like** (API-only
residuals; see per-cert notes below). 0390-2954-3640 pins at **+0** (exact).
- 6035 (+2.19) and 9390 (community, 2) carry documented open residuals (see notes) but are
lower-priority and not worksheet-backed.
So Part 2 is only worth touching if a **fresh fetch differs from the committed fixture**
(curated/hand-corrected fixtures can mask raw-API mapper behaviour) — `diff` fresh vs fixture
and debug the delta. Otherwise these are done; the real new work is **Part 1**.
---
## Fetching from the EPC API
Token lives in `backend/.env` as `OPEN_EPC_API_TOKEN` (also `EPC_AUTH_TOKEN`). The exact
mechanism (from `scripts/fetch_cohort2_api_jsons.py`):
```python
import httpx, os
from dotenv import load_dotenv
from infrastructure.epc_client.epc_client_service import EpcClientService
load_dotenv("backend/.env")
token = os.environ["OPEN_EPC_API_TOKEN"]
resp = httpx.get(
f"{EpcClientService.BASE_URL}/api/certificate",
params={"certificate_number": "<CERT>"},
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
timeout=EpcClientService.REQUEST_TIMEOUT,
)
payload = resp.json()["data"] # <- this is the schema-21 JSON the mapper consumes
```
`EpcPropertyDataMapper.from_api_response(payload)` only supports `schema_type`
`RdSAP-Schema-21.0.0` / `21.0.1`; it raises for others. The persisted golden fixture IS this
`data` payload. So `diff <(fresh)` vs the committed fixture is apples-to-apples.
---
## Per-cert notes carried from the previous session (verify against FRESH data)
- **7536 (+1)** — roof bug fixed (S0380.214: as-built sloping ceiling → Table 18 col 3).
Every per-element U matches Elmhurst (cases 15-17 worksheets). Concluded 0240-like; cont
68.924.
- **2130 (+2)** — dropped measured wall insulation captured (S0380.215 → Table 8 U=0.32),
which **exposed** the true residual (the +1 was two offsetting bugs). PV β-split **proven
exact** vs simulated case 18 worksheet (onsite 970.77 / export 1713.40 to the decimal).
Gas PE factor exact (1.13). Concluded 0240-like; cont 83.78.
- **0240 (1)** — export-dropped 2013+ circulation-pump age (115 vs 41 kWh); WWHRS confirmed
inert (`shower_wwhrs=1` is the universal default across all 47 certs). User previously
decided NOT to re-pin. Concluded 0240-like.
- **0390-2954-3640** — pinned +0 (oil combi, Table 3a row 1). The user's 6.85 flag is the
reconciliation mystery above — START HERE; it's the clearest signal of a fresh-vs-fixture
or different-engine gap.
- **6035** — see memory `project_golden_coverage_state`: a user-simulated 6035 worksheet
closed to 1e-4, but "6035 remaining +19 PE needs its own worksheet"; flagged +2.19 SAP.
- **9390** — community heat-network (S0380.212/.213 fixed the fuel-code collision + standing
charge); left at SAP 2 with a documented ~7% demand over-count (heat-source-eff default?).
Unpinned/retired. The user's 4.24 may be the same demand over-count on fresh data.
---
## What this session shipped (commits `ec64c39d..f895dd3a`)
| slice | what |
|---|---|
| **S0380.214** | As-built "Pitched, sloping ceiling" (code 8) roof → RdSAP 10 Table 18 col (3) (band F 0.40→0.68, L 0.16→0.18) per §5.11 item 5-5 + note (b). Code-5 vaulted stays col (1) (cohort). Worksheet-validated (sim case 15). Re-pinned 7536. |
| **S0380.215** | Captured dropped `wall_insulation_thickness_measured` (schema 21 didn't declare it → `from_dict` dropped it). 2130 Ext1 "measured"/100 mm → RdSAP Table 8 U=0.32 (was 0.55 default). Exposed 2130's true +2 residual. |
| **S0380.216** | Extractor: handle pdftotext wrapping the §11 glazing-GAP column onto the glazing-TYPE token ("…16 mm or [1st]"). Fallback strip AFTER the direct lookup (preserves explicit interleaved keys). Unblocked running the cascade on hand-entered worksheet Summaries. |
| **S0380.217** | Captured dropped `wall_insulation_thermal_conductivity` (schema → domain → mapper) and wired it into `u_wall`'s §5.8 λ resolver. Code 1 = default 0.04; unmapped codes raise. Zero cascade effect today (2130's §5.8 path doesn't fire). |
| 3× docs | finalised 7536 / 2130 as 0240-like; corrected diagnoses. |
**Audit method that found the dropped fields** (reuse it on the fresh certs): recursively
compare raw JSON keys against the parsed schema dataclass fields — anything in the JSON but
not a declared field is silently dropped by `from_dict`. The two real drops (2130's measured
wall insulation + thermal conductivity) came from this. Re-run it on the fresh fetches; new
certs may surface new dropped fields.
---
## Conventions (unchanged)
One cause = one slice = one commit; spec citation (page + line) in the message; AAA tests
(`# Arrange / # Act / # Assert`); assert with `abs(x - y) <= tol` (not `pytest.approx`);
SAP 10.2 only; no tolerance widening / xfail / rel-tol. New code passes pyright strict with
ZERO NEW errors — baseline-compare with `git stash` + `PYRIGHT_PYTHON_FORCE_VERSION=latest`
(mapper.py / cert_to_inputs.py / heat_transmission.py / rdsap_uvalues.py carry pre-existing
errors; compare counts). Stage files by name — the working tree has pre-existing unrelated
changes to `pytest.ini` / `scripts/` that must NOT be staged.
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
When you re-pin a golden cert, update `expected_sap_resid` (±0), `expected_pe_resid_kwh_per_m2`
(±0.01) and `expected_co2_resid_tonnes_per_yr` (±0.001) to the exact post-fix values and
append a slice note to the cert's `notes:` explaining the cause + spec/worksheet citation.
Run the full §4 suite as the blast-radius check after any fabric/factor change.

View file

@ -0,0 +1,94 @@
# Handover — double_glazing fixture: glazing done, window-extraction open
Point-in-time note for the agent owning the Elmhurst window extractor /
mapper. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for the 1e-4
worksheet-pin methodology and the cascade pipeline.
- **Branch:** `feature/per-cert-mapper-validation`. HEAD `8133521c`.
- **Fixture:** the double_glazing "before" recommendation pair —
`sap worksheets/Recommendations Elmhurst Files/double_glazing/before/`
(`Summary_001431 (1).pdf` + `P960-0001-001431 - 2026-06-02T115533.961.pdf`).
The Summary is also committed as a test fixture:
`backend/documents_parser/tests/fixtures/Summary_001431_double_glazing.pdf`.
- **Worksheet block to pin** (rating = block 1, region 0; demand = block 2,
postcode): SAP cont **57.2415**, cost (255) **1423.0955**, fabric (33)
**158.4548** / (37) **197.8463**; demand CO2 (272) **3486.0799**, PE (286)
**16796.5617**. (Blocks 3+ add PV/diverter — ignore for the "before" pin.)
## Done this session (S0380.235-237) — DON'T redo
| slice | what |
|---|---|
| **S0380.235** `3e45b7fa` | 5 missing Elmhurst §11 glazing labels → SAP10 Table 6b (`Secondary glazing`→7, `…- Normal emissivity`→11, `Triple pre 2002`→10, `Triple with unknown install date`→6, `Single glazing, known data`→15). |
| **S0380.236** `ea35bed2` | Extension party-wall type read independently of "As Main Wall" (`_extract_extensions`): Main `CU`→0.5, Ext `U Unable to determine`→0.25. Worksheet **(32) party heat loss now exact** (32.573 vs 32.5725). |
| **S0380.237** `8133521c` | `Secondary glazing - Low emissivity`→12. Double {1,2,3,7,13} + secondary {4,11,12} families now fully mapped. |
**Confirmed already correct — do not touch:**
- The calculator's window **U-adjustment `1/(1/U + 0.04)`** (SAP §3.2 curtain
correction) is exact: lodged 4.80→4.0268, 3.10→2.7580, 1.40→1.3258, all
match the worksheet to 1e-4. Our 14 extracted windows sum to **exactly
56.090**. The 1e-4 gap is NOT in the calculator.
- Glazing label→code mapping (g_L is the only cascade effect; lodged U/g
drive §3/§6 via `_g_perpendicular` preferring the lodged value).
## Open — current residual **SAP +1.13** (ours 58.37 vs ws 57.24), all in WINDOWS
The Summary §11 lodges **17 physical window rows**; we end up with **14**
`sap_windows`. Three windows are lost, in two distinct ways:
### Cause 1 (HARD — read before touching `_is_elmhurst_roof_window`)
The mapper routes the two `Double pre 2002` windows (lodged U 3.1 / 3.4) to
**roof** windows via the `U > 3.0` backstop in
`_is_elmhurst_roof_window` (`datatypes/epc/domain/mapper.py`, the final
`return w.u_value > _ELMHURST_ROOF_WINDOW_U_THRESHOLD`). This fixture's
worksheet bills them as **wall** windows (27).
**The trap:** cohort cert **000516** has a window that is *byte-identical*
in every extractable Summary field — `Double pre 2002`, U=3.1,
`location="External wall"`, bp `Main`, orient `North-East` — and *its*
worksheet bills it as a **roof** window (27a). Verified: gating the U>3
rule on `location == "External wall"` makes this fixture pass but
**breaks both 000516 pins** (`test_summary_000516_full_chain_…` and
`test_from_elmhurst_site_notes_matches_hand_built_000516`).
So identical Summary inputs are classified oppositely by the two
worksheets. **No rule keyed on the fields we currently extract can satisfy
both.** Resolving this needs a NEW disambiguating signal — likely a
roof/wall or rooflight field Elmhurst lodges in §11 (or the BP roof
structure) that the extractor doesn't yet capture. Do NOT flip the U>3
heuristic to fix this fixture; it silently regresses 000516.
### Cause 2 (tractable — a plain parsing miss)
The extractor produces **16 windows from 17 §11 rows** — it drops the
`Double glazing, known data` row (BFRC, lodged U=1.00 → adjusted 0.9615,
1st Extension, area 1.00; worksheet "Windows 12" on Ext1). The label maps
fine (→3); the physical row just isn't extracted. Fixing this alone won't
pin the fixture (Cause 1 still blocks) but it's a real, isolatable
extractor bug.
## Tracer recipe (rebuild — the throwaway lived in /tmp)
```python
# from repo root, PYTHONPATH=/workspaces/model
import re, subprocess; from pathlib import Path
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES, cert_to_inputs, cert_to_demand_inputs,
heat_transmission_section_from_cert)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
def pages(pdf):
n=int(re.search(r"Pages:\s+(\d+)",subprocess.run(["pdfinfo",str(pdf)],
capture_output=True,text=True).stdout).group(1)); out=[]
for i in range(1,n+1):
L=subprocess.run(["pdftotext","-layout","-f",str(i),"-l",str(i),str(pdf),"-"],
capture_output=True,text=True).stdout
out.append("\n".join(tok for ln in L.splitlines()
for tok in re.split(r"\s{2,}",ln.strip()) if tok))
return out
D=Path("sap worksheets/Recommendations Elmhurst Files/double_glazing/before")
sn=ElmhurstSiteNotesExtractor(pages(D/"Summary_001431 (1).pdf")).extract()
epc=EpcPropertyDataMapper.from_elmhurst_site_notes(sn)
# len(sn.windows)==16 (should be 17); len(epc.sap_windows)==14 (2 → roof, 1 dropped)
```
Per-window A×U on the worksheet uses the ADJUSTED U `1/(1/U_lodged+0.04)`;
sum the §3 `(27)` lines to 60.5577 (we get 56.090 from 14 windows).

View file

@ -0,0 +1,264 @@
# Handover — golden-cert mapper/cascade bugs (post-0240 wall fix)
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology,
the 1e-4 bar, the per-line debugging loop, and the suite command. This records the
state after closing the 0240 investigation and fixing the first of several
API-mapper/cascade bugs the audit surfaced.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `b9bbcecb` (docs after S0380.213). Confirm with `git rev-parse HEAD`.
- **Baseline:** `2386 passed, 1 skipped, 0 failed` (AGENT_GUIDE §4 suite command).
ALSO run `domain/sap10_ml/tests/` when touching `rdsap_uvalues.py` — 2 PRE-EXISTING
stone-formula failures there, see Thread 1.
- **Next slice number:** **S0380.214**.
---
## ★ CURRENT PRIORITY — drive golden-fixture SAP residuals to ZERO
`_EXPECTATIONS` in `test_golden_fixtures.py` holds **53 pinned golden certs**. The suite
is GREEN. **Only 3 have a non-zero SAP integer residual**, and they split into two kinds:
| cert | lodged | cont SAP | resid | nature (from the cert's `notes:`) |
|---|---|---|---|---|
| **7536-3827-0600** | 68 | **69.071** | **+1** | +0.57 over — multi-age bps (Main D / Ext1 L / Ext2 F); **glazing U** (S0380.97 set glazing_type=2 → Table 24 spec U=2.0, but the cert's lodged U "appears higher than the spec default") |
| **2130-1033-4050** | 82 | **83.349** | **+1** | +0.85 over — end-terrace + 1 ext, gas combi PCDB 17505, **2× PV arrays**; SAP +1 came from the **cohort PV-β cascade interaction** (S0380.45/.49), not a pinpointed fabric line; PE residual 8.22 sits in gas-combi PE + secondary credit |
**These are TWO DIFFERENT root causes — not a shared one** (an earlier audit label calling
both "multi-part wall" was wrong; trust the `notes:` above).
- **7536** is the more tractable: a clear **glazing-U** hypothesis. S0380.97 forced
`glazing_type=2` to the Table 24 default U=2.0; the note suspects the cert's true per-bp
glazing U is higher (multi-age D/L/F geometry). Walk §3 window `(27)` per-bp vs the lodged
register window rating; the lever is likely the glazing U for one of the extensions. ASK
the user for a simulated Elmhurst worksheet mirroring 7536's glazing (double-glazed,
multi-age bps) to pin the true `(27)` U rather than guess.
- **2130** is harder: the SAP +1 is a PV-β / cohort *cascade interaction*, not a single
fabric line. Its PE residual (8.22) is a documented gas-combi-PE + secondary-credit
deferral. Decompose which metric drives the integer flip (cost/EI vs PE) before touching
anything; this one may need the PV/secondary path, not fabric.
Both certs are API-only (no worksheet) → bar is ±0.5 SAP vs lodged; the goal is the integer
flip (69→68 / 83→82), i.e. shave ~0.57 / ~0.85 off the continuous SAP. Per the session
methodology lesson, ASK for a worksheet rather than guess a U-value or factor.
**0240 (1) is NOT driveable from the JSON and the user has decided NOT to re-pin it —
document the cause only.** Continuous SAP is 72.462; the true SAP is **72**. The lodged
**73 requires a "2013 or later" circulation pump (41 kWh)**; 0240's open-data API lodges
`central_heating_pump_age=0` = **Unknown → 115 kWh**. The encoding was proven across 13
API+Summary pairs (`0`=Unknown, `2`=2013+). The export did not preserve the pump age that
produced the lodged 73, so 73 is unreachable without inventing data. Both fabric bugs that
masked it are now fixed (wall S0380.209 + roof S0380.211 → cont 72.462). **Leave the pin at
`actual_sap=73, expected_sap_resid=-1`; the notes already record this.** Driving it to zero
would mean fudging the pump age — don't.
Latent (lower priority): **9390** (community, 2, **unpinned**/retired) ~7% demand
over-count — see Thread 2.
---
## What this session shipped
| slice | what |
|---|---|
| **S0380.208** | Promoted **simulated case 7** (combi swap of case 6) to an e2e fixture. PROVED the condensing-oil-combi (SAP code 130, no cylinder, combi instantaneous DHW, Eq D1, Table 3a keep-hot) path is **exact at 1e-4** with zero calculator changes → exonerated the heating as the source of 0240's residual. |
| **S0380.209** | Fixed the **API-path wall U** "as built, insulated (assumed)" bug — routes to the as-built age-band row, not the 50 mm retrofit bucket. New `_described_as_retrofit_insulated` in `heat_transmission.py`. Worksheet-validated by case 9 (sandstone J → 0.35) + case 10 (solid brick J → 0.35). Re-pinned 0240 PE +1.8687 → +5.5044, CO2 +0.0907 → +0.2757 (SAP integer 72 unchanged). |
| **S0380.210** | **CLOSED cert 0390** (Thread 3). Cavity wall "as built, **partial** insulation (assumed)" (type 4) was mis-routed to the Table 6 "Filled cavity" row (band F 0.40) → should be "Cavity as built" (band F 1.0). New `_cavity_described_as_filled` in `rdsap_uvalues.py` excludes "partial insulation" from the filled trigger (keeps "insulated (assumed)" → filled). SAP +7 → +0, PE 27.97 → +0.53, CO2 2.71 → 0.12. Mirrors S0380.209 on the cavity path. |
| **S0380.211** | **CLOSED Thread 1 (roof).** 0240 Ext1 vaulted (code 5) NI roof returned 0.68 (§5.11.4 50 mm) → should be Table 18 col (1) age-band (band J 0.16), matching 33 cohort-2 `ND` vaulted roofs. New `u_roof(is_sloping_ceiling=...)` flag threaded from heat_transmission (codes 5/8). 0240 PE +5.50 → +1.52, CO2 +0.28 → +0.07 (SAP 72). Also corrected the S0380.210 cavity unit test in `domain/sap10_ml/tests/` (suite-command gap — see Thread 1). |
| **S0380.212** | **Thread 2 CO2/PE collision FIXED.** EPC fuel 20 = "mains gas (community)" collided with Table-12 biomass code 20 → community CO2 6.4× low. New `_heat_network_factor_fuel_code` translates 20→51 for heat-network mains only (5 sites: SH+HW CO2/PE/price). 9390 CO2 0.44→3.03 t (lodged 2.8), PE 204→220. Case-14-validated ((367) 0.2100 / (467) 1.1300). Cost +4 tail open. |
| **S0380.213** | **Thread 2 cost +4 FIXED** via the heat-network standing charge. API community fuel 20 isn't a Table-32 gas code → `_is_gas_code` False → £0 standing (vs SAP 10.2 Table 12 note (l) £120; case 14 `(351)`=£120). New `_heat_network_standing_charge_gbp` (£120 full / £60 DHW-only, §C3.2) REPLACES the fuel standing for heat-network mains (no double-count; CH corpus stays £120). 9390 SAP +4 → -2 (exposes a ~7% demand over-count — follow-up). |
Both also carry a memory: [[project_case7_combi_exonerated]], [[project_as_built_insulated_assumed_bug]].
---
## The 0240 verdict — RESOLVED, not closable to 73
**0240's true SAP is 72**, proven three independent ways:
- Elmhurst **case 8** (pump=Unknown, 0240's actual lodged value) → worksheet SAP **72**.
- Elmhurst **case 9** (correct sandstone wall 0.35 + sloping roof 0.25) → SAP **72**.
- Our cascade with **both** the wall (done) and roof (pending) bugs fixed → SAP cont **72.31**.
The register's **73 requires a "2013 or later" circulation pump (41 kWh)** — Elmhurst
**case 7** with that pump = 73. But 0240's API lodges `central_heating_pump_age=0`
= **Unknown** → 115 kWh. The encoding was **proven from 13 API+Summary pairs**:
`0`="Unknown" (9 pairs), `2`="2013 or later" (4 pairs). The open-data export did not
preserve the pump age that produced the lodged 73. **It is genuinely unreachable from
the JSON.** Do not chase 0240 to 73; re-pin to its correct 72 once the roof lands.
`pump_age` enum (verified): `0`=Unknown→115, `1`=Pre-2013→165, `2`=2013+→41
(`_TABLE_4F_CIRCULATION_PUMP_KWH_BY_AGE` in `cert_to_inputs.py`).
---
## THE METHODOLOGY LESSON FROM THIS SESSION (read this)
The 0240 baseline (cont 72.39) was **two offsetting bugs**: a wall U **under**-count
(0.25 vs 0.35, less loss) masking an Ext1 roof U **over**-count (0.68 vs ~0.25, more
loss). Fixing one alone moves the residual the "wrong" way — fix both
([[feedback_software_no_special_handling]]). And: **Elmhurst is the arbiter, not the
spec text** — twice this session a confident spec/first-principles read was wrong and
a generated worksheet settled it (`NI`=not-indicated not "none"; pump `0`=Unknown).
**Generate a worksheet rather than guess a U-value or factor.** The user generates
Elmhurst worksheets readily (cases 710 done) — ask for one.
---
## THREAD 1 — Roof fix — **CLOSED (S0380.211)**
0240's Ext1 (BP2) lodges `roof_construction=5` (vaulted), `NI` thickness, "Pitched,
insulated (assumed)", band J → the cascade hit `u_roof`'s
`insulation_thickness_mm==0 and _described_as_insulated` override → **0.68** (the
§5.11.4 retrofit-50 mm joist row). A vaulted/sloping ceiling has no joist void, so per
RdSAP 10 §5.11 Table 18 (p.45) it takes the **column (1) age-band default (band J =
0.16)**, NOT 0.68.
**The arbiter was the cohort, not case 11 — a methodology trap avoided.** The handover
above guessed the value was col-3 **0.25** (→ cont 72.31), citing case 9. That was
WRONG. The decisive evidence: **33 cohort-2 certs lodge `ND` (thickness None) vaulted
roofs** (`roof_construction=5`, band D) that already pin to their dr87 worksheets at
**0.40 = Table 18 col (1)**. 0240's only difference is the `NI` sentinel (insulation
present, unknown thickness), which uniquely hit the 0.68 override. So the spec-correct
value is **col (1) 0.16**, and 0240 lands at **cont 72.4617**, integer 72 — NOT 72.31.
A first broad attempt (route sloping → col-3 `_FLAT_ROOF_BY_AGE`) broke all 33 cohort
certs (band D col-3 = 2.30 vs worksheet 0.40) — that failure is what revealed the
col-1 answer. Lesson: when a U-value change moves worksheet-pinned cohort certs off
their pins, the change is wrong; the cohort worksheets are ground truth.
**Implementation:** new `u_roof(is_sloping_ceiling=...)` flag, threaded from
`heat_transmission` for `roof_construction_type` containing "sloping ceiling" (code 8)
or "vaulted" (code 5). Fires only on the `NI` case (thickness 0 + "insulated
(assumed)") → col (1); the `ND`/None path is untouched (already col 1) and a normal
pitched-with-loft roof still takes the §5.11.4 50 mm row (flag defaults False). 0240
PE +5.5044 → +1.5181, CO2 +0.2757 → +0.0728 (SAP 72 unchanged). Re-pinned in
`test_golden_fixtures.py`.
**⚠ Suite-command gap discovered:** the AGENT_GUIDE §4 suite command does NOT run
`domain/sap10_ml/tests/`, where `u_roof`/`u_wall` unit tests live. S0380.210 shipped a
broken `test_u_wall_cavity_..._filled_cavity_row` there unnoticed; S0380.211 corrected
it (→ `..._as_built_row`). **When touching `rdsap_uvalues.py`, also run
`domain/sap10_ml/tests/`.** Two PRE-EXISTING failures live there (stone §5.6 thin-wall
formula 3.7408 vs Table-6 1.7 cap, granite + sandstone band A) — they fail at HEAD
`58ff7d88` too, unrelated to this branch.
---
## THREAD 2 — Community fuel-code collision (cert 9390) — **CO2/PE FIXED (S0380.212); cost +4 open**
Cert **9390-2722-3520** (community mains-gas boiler, `sap_main_heating_code=301`,
`main_fuel_type=20`). Authoritative: `datatypes/epc/domain/epc_codes.csv`
(RdSAP-Schema-17.0) `main_fuel,20,mains gas (community)`.
**Root cause (the collision):** the EPC `main_fuel_type` enum and the SAP Table 12 /
Table 32 numbering overlap in **1825** — EPC 20='mains gas (community)' but Table-12
code 20 is solid biomass (CO2 0.028). `co2_factor_kg_per_kwh`/`primary_energy_factor`/
`unit_price_p_per_kwh` check the Table-12 dict FIRST, so the EPC community fuel got the
biomass factor instead of translating 20→51 (community mains gas: CO2 0.210, PE 1.130).
**S0380.212 fix:** new `_heat_network_factor_fuel_code(main)` translates the EPC community
fuel → Table-12 code via `API_FUEL_TO_TABLE_12`, but ONLY for heat-network mains
(`_is_heat_network_main`) so a genuine biomass boiler keeps its raw factor. Applied at
**five** sites — space-heating CO2/PE/unit-price + water-heating (WHC 901) CO2/PE (9390's
HW is ALSO community gas, so both paths needed it). Worksheet-validated by **case 14**
(community boilers + mains gas, code 301): `(367)` CO2 0.2100, `(467)` PE 1.1300 = the
Table-12 code-51 values. 9390 CO2 **0.44 → 3.03 t** (lodged 2.8 — spec-correct factor over
the API-only register residual; 9390 is unpinned, retired P2.2 per ADR-0010 §10), PE
**204 → 220** (the prior 204≈205 was the collision coinciding with the register residual).
Summary path uses code 1 (no collision) → CH1-6 corpus untouched. Locked by 2 unit tests
in `test_cert_to_inputs.py`.
**Cost +4 — FIXED (S0380.213), via the standing charge (NOT cost scaling).** The earlier
"missing 1/heat_source_eff cost scaling" hypothesis was WRONG: case 14's 10b block shows
the heat price (`(340)`/`(307)` = 4.24 p/kWh) is applied to delivered heat, NOT scaled —
and Table-32 code 51 already = 4.24 p/kWh (the price collision was harmless, 4.23≈4.24).
The real gap was the **£120 heat-network standing charge** (SAP 10.2 Table 12 note (l) +
§C3.2; case 14 `(351)` = £120): the API community fuel (20) isn't a Table-32 gas code so
`_is_gas_code` returned False → £0 standing (the Summary path masks it via code 1). New
`_heat_network_standing_charge_gbp` REPLACES the fuel standing for heat-network mains
(£120 full / £60 DHW-only) — not additive, so the CH corpus (already £120 via the gas
branch) isn't double-counted to £240. 9390 SAP +4 → **-2**.
**STILL OPEN — 9390 ~7% demand over-count (SAP -2):** the standing fix EXPOSED it — PE 220
vs lodged 205, CO2 3.03 vs 2.8 all run ~7% high. Likely the heat-source-efficiency default
(`_HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY[301]=0.80`) being too low for 9390's actual scheme,
or a fabric/demand difference. 9390 is API-only (no worksheet) + unpinned, so this is a
low-priority residual; needs a 9390-specific efficiency/fabric investigation.
---
## THREAD 3 — Cert 0390 +7 — **CLOSED (S0380.210)**
**0390-2954-3640** (detached, TFA 360, age F). The boiler was correctly resolved
(PCDB 9005 Firebird S, 86.4% winter); the gap was a single fabric mis-route. Walking
the §3 cascade localised it to the Main **cavity wall**: lodged `wall_construction=4`,
`wall_insulation_type=4` (as-built/assumed), description "Cavity wall, as built,
**partial insulation** (assumed)". The cascade routed it to the Table 6 **"Filled
cavity"** row (band F = 0.40) because `_described_as_insulated` matches the "partial
insulation" substring. Per **RdSAP 10 Table 6 (England)** an as-built partial-fill
cavity uses **"Cavity as built"** (band F = **1.0**), not filled — a genuine fill
lodges the distinct "Cavity wall, filled cavity" (`wall_insulation_type=2`). This
mirrors the worksheet-validated solid-brick rule from S0380.209 (cases 9/10).
Fix: new `_cavity_described_as_filled` predicate, used **only** in u_wall's cavity
filled-row branch, excludes "partial insulation" while keeping "insulated (assumed)"
→ filled. Wall HLC +53.6 W/K lifted all four metrics together: **SAP +7 → +0**,
PE 27.9745 → +0.5281, CO2 2.7134 → 0.1189. Bands I-M coincide († footnote) so
0535(M)/7536(L) are unaffected. Re-pinned in `test_golden_fixtures.py`.
**Diagnosis lesson / latent follow-up:** the fix collided with an existing test,
`test_cavity_as_built_insulated_assumed_uses_filled_cavity_row` (heat_transmission
tests). That test (from early "slice S-B25") asserts a cavity **"insulated (assumed)"**
→ filled row, citing only an *assumption* ("the assessor has determined the cavity is
filled"), **never worksheet-validated** — and it is the OPPOSITE conclusion from the
worksheet-backed solid-brick sibling. The narrow S0380.210 fix leaves it untouched
(no current cert exercises it at a band where as-built ≠ filled). **Open question for a
future worksheet:** does a cavity lodged "as built, insulated (assumed)" (type 4)
belong on the filled row (0.7 at E) or the as-built row (1.5 at E)? If a worksheet
says as-built, fold "insulated (assumed)" into the as-built routing too and retire
that test.
---
## Full audit — all golden certs with non-zero SAP residual
| cert | SAP resid | diagnosis |
|---|---|---|
| 0390-2954-3640 | ~~+7~~ **+0** | **CLOSED S0380.210** — cavity partial-insulation → as-built row |
| 9390-2722-3520 | 2 (unpinned) | **CO2/PE collision FIXED S0380.212** + **standing charge S0380.213** (SAP +4→2); remaining ~7% demand over-count (heat-source-eff default?) |
| 0240-0200-5706 | 1 | NOT a bug — unpreserved 2013+ pump; true SAP 72. Roof PE-pin tightened by **S0380.211** (PE +5.50 → +1.52) |
| 2130-1033-4050 | +1 | minor fabric precision (multi-part solid-brick wall); low value |
| 7536-3827-0600 | +1 | minor fabric precision (multi-bp D/L/F cavity); low value |
All others pin at residual 0.
---
## Worksheets
- **Available** (user-generated, `sap worksheets/golden fixture debugging/simulated case N/`):
case 7 (combi), case 8 (unknown pump), case 9 (sandstone wall + sloping roof),
case 10 (solid-brick wall), **case 11** (001431 sloping-ceiling Unknown roof — used to
scope Thread 1; the cohort `ND` certs were the real arbiter), **case 12** (community
CHP **coal**, code 302), **case 13** (community CHP **mains gas**, code 302). Case 7's
Summary is the only one mirrored into tracked fixtures
(`backend/.../Summary_001431_case7.pdf`, used by the e2e pin).
- **Still needed (Thread 2):** a **code-301** (community boilers, NOT CHP) + **mains gas**
worksheet to pin 9390's exact PE/CO2/cost. case 13 is code-302 CHP-gas: it confirms the
community-gas direction (heat-network `(386)` CO2 0.2456) but the CHP heat-power split
differs from 9390's boiler scheme. **API `main_fuel_type=20` = community/district
heating from mains gas → SAP Table 12 code 51** (CO2 0.210, PEF 1.130).
## Pointers
- Golden pins + slice history: `tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py`.
- Wall fix: `domain/sap10_calculator/worksheet/heat_transmission.py`
(`_described_as_retrofit_insulated`, the `wall_ins_present` gate) +
`tests/.../worksheet/test_heat_transmission.py`.
- Roof code: `domain/sap10_ml/rdsap_uvalues.py` `u_roof` (L708 + Table 18 dicts L637-668).
- Community/heat-network CO2/PE/cost: `cert_to_inputs.py` ~L2749-2880, L1837
(`_main_fuel_code`), `tables/table_12.py` + `table_32.py` (`co2_factor_kg_per_kwh`,
`API_FUEL_TO_TABLE_12/32`).
- Process: one slice = one commit, spec citation (page+line), AAA tests, `abs(x-y)<=tol`
not `pytest.approx`, `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
SAP 10.2 only. No tolerance widening. mapper.py + cert_to_inputs.py each carry 32
pre-existing pyright errors; heat_transmission.py + rdsap_uvalues.py carry their own —
baseline-compare with `git stash` for net-zero NEW.

View file

@ -0,0 +1,147 @@
# Handover — post S0380.195 (gas-combi site-notes + RR/floor bugs; 6035 OPEN)
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology,
accuracy bar, and pipeline — this records *what this session did* and *what is open*.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `4a21717d` (S0380.195)
- **Baseline:** `2341 passed, 1 skipped, 0 failed`. Verify with the §4 suite command.
---
## What this session shipped (S0380.190195 + 1 extractor fix)
| Slice | What | Spec |
|---|---|---|
| **.190** | Gas-combi site-notes `main_fuel_type` derivation. Newer Elmhurst export lodges §14.0 Fuel Type EMPTY + SAP code 104 → `MissingMainFuelType` blocked ALL gas-combi Summary certs. Derive carrier from §15.0 Water Heating Fuel Type for Table 4b gas-boiler codes 101119 (NOT "104→mains gas" — Table 4b gas codes span mains gas/LPG/biogas; §15.0 disambiguates). `_elmhurst_gas_boiler_main_fuel`. | SAP 10.2 Table 4b p.168 |
| extractor fix | Windows-table header remnant ("value value Proofed Shutters") leaked into the FIRST window's `glazing_type` (layout `before_start=0` reaches the wrapped header). Trim prefix to the glazing-start word. | — |
| **.191** | Promoted sim case 1 (single-part gas combi) to e2e harness `001431`, 11 pins @1e-4. Resolved the handover's "+0.0007 SAP" as a display-rounded-ECF target artifact. | — |
| **.192** | **Simplified room-in-roof bug.** A Simplified RR (assessment "Simplified") lodges PLACEHOLDER slope/ceiling L×H (40 m ceiling height, 32 m slope). Spec derives one timber-framed remaining area `A_RR = 12.5√(A_floor/1.5) Σgables`. The cascade already computes this (`has_roof_lodgement` gate in heat_transmission.py) but `_map_elmhurst_rir_surface` emitted the placeholder slope/ceiling → 1024+160 m² roof → **7.5× heat-loss explosion (SAP 14.6)**. Fix: drop roof-going surfaces for Simplified. API path (6035) already correct via scalar gable fields. | RdSAP 10 §3.9.1 p.21 |
| **.193** | **Suspended-floor (12) sealed rule.** Rule (a) "floor U<0.5 sealed 0.1" applies only when a U-value is SUPPLIED; an as-built/default U falls to (b)→unsealed 0.2. Cascade fed the computed default U into (a) wrongly sealed ~450 kWh space-heat understatement. Fix: gate (a) on `floor_u_value_known`. | RdSAP 10 §5 p.29 |
| **.194** | Sim case 3 (8 windows, symmetric HLP) → e2e `001431_rr8`, 11 pins @1e-4. | — |
| **.195** | Sim case 4 (6035 floor geometry: Main ground HLP 15.99 + first 8.32) → e2e `001431_6035`, 11 pins @1e-4. | — |
Net: 4 new Elmhurst-only e2e fixtures (cases 14 of cert 001431), all @1e-4. The
worksheet Summaries are mirrored into `backend/documents_parser/tests/fixtures/`
(`Summary_001431_gas_combi.pdf`, `_rr_ext`, `_rr8w`, `_6035`); source Summary +
P960 worksheet tracked under `sap worksheets/golden fixture debugging/simulated
case {1..4}/`.
**The .195 commit message and the `_elmhurst_worksheet_001431_6035.py` docstring
claim 6035's +19 PE is "lodged divergence." THAT CLAIM IS RETRACTED — see below.**
---
## OPEN (the priority) — golden cert 6035 residual is REAL, not divergence
`tests/.../test_golden_fixtures.py` pins cert `6035-7729-2309-0879-2296`:
`actual_sap=70, expected_sap_resid=-2, expected_pe_resid=+19.16,
expected_co2_resid=+0.42 t`. **All three exceed the ±0.5 SAP / small-CO2 fallback
bar.** A 2 SAP is not rounding. 6035 was lodged **2025-11-11** under
RdSAP-Schema-21.0.1 / SAP 10.2 (software `5.02r0328`) — the SAME methodology we
target, so it is NOT a version artifact.
### What we know
- **The cascade reproduces Elmhurst's WORKSHEET engine** for this archetype: sim
cases 14 (Main+Ext+RR+suspended-floor+gas-combi-104) all pin @1e-4 on all 11
Block-1 line refs.
- **Case 4 ≈ 6035.** Identical: 2 BPs, age A, solid-brick walls (Main ins, Ext
as-built), RR floor 29.75, floor areas, **Main floors HLP 15.99/8.32**, doors,
heating (104/control 2106), 8 windows by **area + BP + 7/8 orientations**.
Remaining input diffs case4-vs-6035:
1. the **3.82 m² window**: North in case 4, **South** in 6035 (only window diff);
2. **lighting bulbs**: case 4 cascade lighting 262 vs 6035's **364** (6035 lodges
9 low-energy + 2 incandescent; case 4's Summary lighting parsed as None);
3. **meter type** "Dual" (case4) vs API **2** (6035);
4. 6035 lodges `cylinder_size=1` (case 4 none) — appears immaterial (HW matches).
- **Controlled test:** flipping case 4's 3.82 window N→S raises SAP only **+0.25**
(68.19→68.44). Nowhere near +2. So orientation does NOT explain the gap.
- **The energy/demand model looks ~right per-end-use.** Cascade DEMAND
(postcode) costs ≈ 6035's lodged costs: heating £1278 vs lodged £1285, HW £225
vs £217, lighting £103 vs £103. So the 2 SAP lives in the **RATING block**
(UK-avg): cascade rating cost 948.59 → ECF 2.31 → SAP 67.81; register implies
cost ~£886 / ECF 2.15 / SAP 70. **Plus the CO2 (+0.42 t) is unexplained.**
- Neither bug fixed this session touches 6035 (its RR uses the API scalar-field
path, already correct; its floor U=0.63 ≥ 0.5 was already "unsealed").
### The contradiction to resolve
Elmhurst-worksheet-for-case4-inputs = **68**, 6035-register = **70**, same
methodology, inputs nearly identical, and the known diffs explain only ~+0.25.
Either (a) 6035's register was produced from inputs materially different from the
golden JSON in a rating-relevant way we can't see, or (b) there's a real cascade
bug only 6035's exact combination triggers (the simulated cases didn't hit it).
### ★ BREAKTHROUGH LEAD (end of session) — API-mapper roof/RR over-count
The user's hypothesis ("something missing from the API mapper") is CONFIRMED.
Diffing **6035 (API path) vs case 4 (site-notes path)** at the SECTION level
(`heat_transmission_section_from_cert`) — with near-identical fabric — exposes a
cross-mapper parity break that should not exist:
| §3 line | case4 (site-notes) | 6035 (API) | Δ |
|---|---|---|---|
| **roof W/K** | **78.33** | **130.73** | **+52.39** |
| party W/K | 36.86 | 0.00 | 36.86 |
| (33) fabric heat loss | 290.72 | 304.66 | +13.94 |
| (31) total ext area | 231.02 | 242.74 | +11.72 |
| walls / floor / windows / doors | — | — | ≈0 |
**The roof +52 W/K is the prime suspect for the whole 6035 residual** (52 W/K of
spurious heat loss ≈ the 2 SAP / +19 PE / +0.42 t CO2). Root cause is the RR/roof
representation feeding two DIFFERENT cascade paths:
- **case 4 (site-notes):** `sap_room_in_roof.detailed_surfaces=[gable_wall_external,
gable_wall]`, scalar gable lengths = None, `roof_construction=None` → cascade's
Detailed-loop residual path (`12.5√(A_floor/1.5) Σwalls`) → roof 78.33. ✓
(pins to case-4 worksheet @1e-4).
- **6035 (API):** `detailed_surfaces=None`, scalar `gable_1/2_length_m=4.65`,
**`roof_construction=4`** → cascade's SCALAR RR path (heat_transmission.py
~363-460 + ~853-875) AND a separate `roof_construction=4` main-roof element →
roof 130.73. Likely DOUBLE-COUNTS the main roof over the full footprint with the
RR, or the scalar A_RR path over-states the area.
Hand-check: for 6035 the correct roof ≈ RR remaining (12.5√(29.75/1.5) 2×11.39
= 32.88 × 2.30 = 75.6) + main-loft residual (41.7329.75=11.98 × 0.14 = 1.68) +
ext roof (7.21 × 0.14 = 1.01) ≈ **78.3** (matches case 4). The API path's 130.73 is
~52 too high.
**START HERE:** instrument the API RR/roof path for 6035. Compare
`_api_build_room_in_roof` (mapper.py ~2713) output + `roof_construction=4`
handling vs the site-notes detailed_surfaces path. Find where the extra ~52 W/K
roof comes from (main-roof-area double count with RR, or scalar A_RR over-state).
Fix so the API path matches the site-notes path (cross-mapper parity), then re-pin
6035's golden residual (should collapse toward 0). The party=0 (party_wall_
construction=3) is secondary — verify 3=solid U=0 is correct first.
This is a CALCULATOR/MAPPER bug, not lodged divergence — the byte-exact-worksheet
plan below is now a fallback only.
### Fallback — byte-exact 6035 worksheet ("simulated case 5")
Ask the user to generate case 5 = case 4 with EVERY remaining input matched to
6035: **3.82 m² window → South**, **lighting = 9 low-energy + 2 incandescent**,
**meter type matched**, **cylinder matched**. Then:
- If the worksheet SAP = **70** → real cascade bug. Diff cascade vs worksheet
line-by-line (start §6 solar gains (74)(83) for the south window, §8 lighting
(232)/Appendix L, then §10a/§12 rating cost/ECF and §12/§13 CO2).
- If the worksheet SAP = **68** → the register's 70 is the anomaly (lodged from
different inputs); 6035 becomes a documented register-vs-worksheet divergence.
Parallel angle worth a look NOW (no new worksheet needed): the **lighting energy**
(cascade 364 for 9 LE + 2 inc, TFA 128) — verify against SAP 10.2 Appendix L; and
the **CO2 (+0.42 t)** decomposition by carrier (the demand-cost match suggests the
energy is right, so a CO2-FACTOR or rating-block issue is implicated).
---
## Carry-over (lower priority, from the prior handover)
- `transform.py:973` treats `wall_construction in (5,6)` as timber-frame for the
ventilation structural-ACH split, but 6 = system-built (masonry); only 5/7/8 are
timber/cob/park. Possible latent ventilation-ACH bug — verify before touching.
- Summary-path `main_fuel_type` for non-gas/non-104 boilers (only 101119 + the
existing liquid/solid/electric/community branches are covered).
## Process notes
- One slice = one commit, spec citation in the message, `Co-Authored-By: Claude
Opus 4.8` trailer. AAA tests, `abs(x-y) <= tol` (not `pytest.approx`).
- The 4 sim-case e2e fixtures pin Block 1 (UK-avg rating) via
`Sap10Calculator().calculate(epc)` — NOT the postcode demand block.
- Window ORIENTATION does NOT change the SAP rating much (+0.25 for 3.82 m²) — do
not over-attribute the 6035 gap to it.

View file

@ -0,0 +1,186 @@
# Handover — post S0380.200 (dual-main split done; boiler-interlock 5pp OPEN)
Point-in-time note. Start from [`AGENT_GUIDE.md`](AGENT_GUIDE.md) for methodology,
accuracy bar, and pipeline — this records *what this session did* and *what is open*.
- **Branch:** `feature/per-cert-mapper-validation`
- **HEAD:** `8ae978a6` (S0380.200)
- **Baseline:** `2355 passed, 1 skipped, 0 failed`. Verify with the AGENT_GUIDE §4 suite command.
---
## What this session shipped (S0380.196200)
The through-line: **golden certs 6035 and 0240 were both closed to SAP-exact**
by finding real API-mapper bugs (not "lodged divergence"), each confirmed
against a user-generated Elmhurst worksheet ("simulated case 5/6").
| Slice | What | Spec |
|---|---|---|
| **.196** | API Simplified Type 1 room-in-roof: `room_in_roof_type_1` gables (length-only, no height) weren't deducted from the A_RR shell → whole shell billed as roof at U_RR=2.30 (+52 W/K). Route them through `detailed_surfaces` (gable area = L × 2.45 default RR storey height). **6035 SAP 2→+0 exact**, PE +19.16→+1.84. | RdSAP 10 §3.9.1(e) p.21; Table 4 p.22 |
| **.197** | Promoted "simulated case 5" (detached sandstone RR) to e2e fixture (`001431_case5`, 11 pins @1e-4). Fixed sandstone wall label `"SS"`→2 (`_ELMHURST_WALL_CODE_TO_SAP10`) + `_parse_thickness_mm` for "400+ mm" roof insulation (trailing `+` was dropped → u_roof fell to age default). | — |
| **.198** | **API `window_wall_type=4` → roof window.** These are roof-of-room rooflights; the mapper flattened them into `sap_windows` (vertical, (27), U=2.0) instead of `sap_roof_windows` ((27a), inclined U=2.30 + 45° solar). The inclined solar dominates → **0240 SAP 1→+0 exact**, PE +3.91→+1.95; 6035 PE +1.84→+1.37. Discriminator is `wall_type==4` NOT `window_type==2` (0390/7536 lodge window_type=2 on main walls). | SAP 10.2 §3 (27a); Table 6e Note 2 |
| **.199** | Site-notes mirror of .198: extractor parses "Roof of Room" window rows (`_parse_window_from_anchors`); `_is_elmhurst_roof_window` location branch; `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double between 2002 and 2021"]=2.30`. Case 6 pinned on §3 windows (`test_section_3_roof_windows_case6_match_pdf`): (27)=22.7408, (27a)=13.0375 exact. | RdSAP 10 §3.7 |
| **.200** | **SAP 10.2 §9a two-main-heating split** (203)/(204)/(205)/(207)/(213). The cascade lumped a 2-main dwelling into one system. Now `space_heating_fuel_monthly_kwh` splits demand (204) to sys1 @ (206) + (205) to sys2 @ (207); `_solve_month` sums main_1+main_2; `_main_heating_detail_efficiency` (new, the per-detail core of `_main_heating_efficiency`) gives each system its own efficiency. Site-notes: `_map_elmhurst_main_heating_2` inherits Main 1's fuel when §14.1 omits Fuel Type. Cost/CO2/PE main_2 paths were already wired. 0240 unchanged (identical Eq-D1 systems). | SAP 10.2 §9a |
Two new e2e fixtures: `001431_case5` (full SapResult, S0380.197) and
`001431_case6` (§3 windows only, S0380.199 — see why below). Source PDFs
tracked under `sap worksheets/golden fixture debugging/simulated case {5,6}/`;
Summaries mirrored to `backend/documents_parser/tests/fixtures/Summary_001431_case{5,6}.pdf`.
---
## ⚠️ CORRECTION (post S0380.201) — the interlock priority was ALREADY DONE
The "priority" below was **misdiagnosed**. At HEAD the cascade already
produces case 6 (206) sys-1 eff = **79.0** and (207) sys-2 eff = **84.0**,
matching the worksheet exactly. The cylinder-thermostat interlock path
(`no_stored_hw_interlock = has_cylinder and cylinder_thermostat != "Y"`)
has existed since **S0380.141**; the room-thermostat path since S0380.177.
`no_interlock = no_room_thermostat OR no_stored_hw_interlock` — it does NOT
only catch 2101/2102. Toggling case 6 `cylinder_thermostat` N→Y flips eff
0.79→0.84, confirming the 5pp fires. Golden **0240 is a combi**
(`has_hot_water_cylinder=False`) → correctly NOT penalised; its predicted
re-pin from the interlock is void. The misread came from
`energy_requirements_section_from_cert` (a §2.4 debug helper using raw
`_main_heating_efficiency`, which reports 84 — the real `cert_to_inputs`
cascade applies the 5pp at ~line 6071). See [[feedback-verify-handover-claims]].
**S0380.201 landed the SECONDARY item** (dual-system aux pumps): SAP 10.2
Table 4f note c) second main-system circulation pump, gated on a lodged
`main_heating_fraction > 0`. Case 6 (231) 241 → **356** EXACT (= 41 Main-1
pump + 115 Main-2 pump + 200 oil aux). 0240 re-pinned (pumps 315 → 430,
integer 73 → 72, resid +0 → -1, PE +2.8092, CO2 +0.1385) — anticipated
and authorised below. 000565 protected by the fraction>0 gate (its Main 2
is a DHW-only combi, fraction 0).
**Remaining case-6 gaps for full-SapResult promotion** (vs P960-0001-001431):
- (98c) space demand cascade **12145.31** vs ws **11991.96** (+1.28%) —
living-area MIT (87) ~0.3 °C low in winter; multi-causal (gains/heat-loss).
- (219) hot water cascade **4824.74** vs ws **4902.86** (1.6%) — §4 walk needed.
Once both close, promote case 6 to a full SapResult e2e fixture (pin grid below).
---
## OPEN (was the priority, now DONE) — boiler-interlock 5pp efficiency adjustment, per main system
**Goal:** a RdSAP-10/SAP-10.2 **spec-accurate** implementation of the boiler
interlock efficiency adjustment, applied **per main heating system**, done in
the established pattern of this domain (per-line walk → cite spec → TDD →
re-pin). This is the last gap blocking full closure of simulated **case 6**,
and it will also re-pin golden **0240**.
### The evidence (simulated case 6)
`sap worksheets/golden fixture debugging/simulated case 6/` — detached, dual
**oil** boiler (both SAP code **127**, base seasonal eff **84%**), radiators 51%
(control **2106**) + underfloor 49% (control **2110**). Its P960 worksheet:
| line | worksheet | meaning |
|---|---|---|
| (206) main sys-1 eff | **79.0** | 84 **5pp** |
| (207) main sys-2 eff | **84.0** | base, no penalty |
| (216) water-heater eff | **72.0** | also penalised (DHW leg of the 5pp) |
| "Temperature adjustment" | 0.0000 | **flow temp has NO effect** — this is NOT a flow-temperature feature |
Summary §14 lodges it explicitly: system 1 **"Boiler Interlock: No"**, system 2
**"Boiler Interlock: Yes"**. The 84→79 is the SAP 10.2 **Table 4c(2)** "no boiler
interlock" 5pp **Space + DHW** adjustment (same mechanism as the AGENT_GUIDE
"oil 6" worked example, S0380.177 — but that one fired off control 2101).
### Why control 2106 (which HAS a room thermostat) is "no interlock"
Per RdSAP 10 boiler-interlock rules (find + cite the exact §; the existing
`_NO_INTERLOCK_CONTROLS = {2101, 2102}` block in `cert_to_inputs.py` ~line 1238
quotes "RdSAP 10 §3 p.57: boiler interlock is assumed present if there is a room
thermostat and [time control], AND — when there is a hot-water cylinder — a
cylinder thermostat; otherwise not interlocked"): system 1 serves the **DHW
cylinder**, the cylinder is present (`Hot Water Cylinder Present: Yes`) but
**`Cylinder Thermostat: No`** → interlock **not** present → 5pp, *despite* the
room thermostat. System 2 (underfloor, separate part, no cylinder interaction)
keeps interlock via its zone control → no penalty.
So the determination is **not** "control ∈ {2101,2102}". It is, per system:
`interlock present` ⇔ (room thermostat present, from the control code) AND
(time/programmer control) AND (cylinder absent OR cylinder thermostat present).
The current cascade only catches the "no room thermostat" path (2101/2102); it
misses the "room thermostat present but no cylinder thermostat" path that 2106
hits here.
### This single root cause explains BOTH remaining case-6 deltas
- space heating: sys-1 eff 79 not 84 → main fuel cascade 14925 vs ws **14736.96**
- hot water: the 5pp DHW leg → cascade HW 4824 vs ws **4902.86** (lower cascade
fuel ⇒ cascade eff too high ⇒ missing the penalty)
### 0240 will shift — and that is correct (apply the spec uniformly)
Golden **0240** has the SAME controls (sys1 2106 / sys2 2110) AND the same
`cylinder_thermostat = "N"` with a cylinder present. So the spec-correct rule
applies the 5pp to 0240's system 1 too. 0240 is currently SAP-exact (continuous
72.55) **without** the penalty — that is an offsetting coincidence (it's API-only,
±0.5 bar, no worksheet). Per [[feedback-software-no-special-handling]] +
[[feedback-spec-floor-skepticism]]: implement the spec rule, let 0240 shift, and
**re-pin** it with a documented note. Expect 0240 continuous SAP to drop ~0.30.5
(may take the integer 73→72; if so the golden `expected_sap_resid` moves 1 and
that is the new truth). Measure precisely and re-pin PE/CO2 too.
### Where to implement (per-line walk first, then TDD)
1. **Interlock determination.** Add a per-`MainHeatingDetail` helper, e.g.
`_boiler_interlock_present(main, epc) -> bool`, encoding the RdSAP 10 rule
above (room thermostat from control code + cylinder-thermostat gate when a
cylinder is present). `epc.sap_heating.cylinder_thermostat` ("Y"/"N") and
`cylinder_size`/`hot_water_cylinder_present` are the cylinder signals. The
site-notes path already lodges `cylinder_thermostat` (mapper.py ~5183, string
"Y"/"N"); the API path lodges it on `sap_heating.cylinder_thermostat` (0240 =
"N").
2. **Apply Table 4c(2) 5pp per system.** The existing 5pp lives near the
`_NO_INTERLOCK_CONTROLS` block — find how it currently adjusts the seasonal
efficiency for 2101/2102 and generalise it to fire on
`not _boiler_interlock_present(main, epc)`, applied inside
`_main_heating_detail_efficiency` so **each** main system gets its own
adjustment (sys1 5, sys2 0). Confirm the DHW leg (water-heater efficiency
(216)) is penalised too — the §4 water-heating cascade reads
`_main_heating_efficiency`; verify the 5pp flows there (case 6 (216)=72
is the check).
3. **Verify combi vs regular rows of Table 4c(2).** The "no interlock" 5pp has a
combi row (Space 5 / DHW 0) and a regular-boiler row (Space 5 / DHW 5);
the DHW leg is gated on cylinder presence. Case 6 is a regular oil boiler with
a cylinder → DHW 5 applies (hence (216)=72). Read the table; don't assume.
### Validation target
After the fix, **promote case 6 to a full SapResult e2e fixture** (it's currently
§3-windows-only because the lumped efficiency made (211)/(219)/(231) non-
comparable). Case 6 worksheet Block-1 pin grid (P960-0001-001431):
- SAP 72 (258), continuous **71.6597**, ECF **2.0316** (257)
- total fuel cost **1162.5374** (255), CO2 **5953.6679** (272)
- (211) main sys-1 fuel **7741.6458**, (213) main sys-2 fuel **6995.3106**
(SapResult.main_heating_fuel_kwh_per_yr should be the sum **14736.9564**)
- hot water **4902.8601** (219), lighting **357.6571** (232)
- pumps/fans **356.0** (231) — **see the SECOND open item below**
### SECONDARY open item — dual-system auxiliary pumps (Table 4f)
Case 6 (231) = **356** = (230c) central-heating pump 156 + (230d) oil-boiler pump
200. Cascade gives **241**. Two boilers → two pump contributions per Table 4f
(note c: "where there are two main heating systems include two figures from this
table" — same note already used for the 0240 oil-pump in S0380.148). Needs the
per-system pump aggregation. Smaller than the interlock fix; do it after, then
case 6's (231) pin closes and the full e2e fixture lands.
---
## Process notes
- One slice = one commit, spec citation (page + line) in the message,
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer.
- AAA tests (`# Arrange/# Act/# Assert`), `abs(x-y) <= tol` (not `pytest.approx`).
- New code passes `pyright` strict, 0 errors. (mapper.py + cert_to_inputs.py each
carry **32 pre-existing** errors — don't add to them; check with a `git stash`
baseline comparison.)
- The Elmhurst worksheet is ground truth at abs=1e-4. 0240 is API-only (±0.5
fallback) — case 6 is its worksheet-backed proxy for the heating archetype, but
differs from 0240 on the boiler SAP code (127 vs 0240's 130 condensing combi),
so pin case 6 to ITS OWN worksheet, not 0240's register.
- Suite command + section/e2e harness layout: AGENT_GUIDE §2.6 + §4.

View file

@ -164,7 +164,10 @@ from domain.sap10_calculator.worksheet.energy_requirements import (
from domain.sap10_calculator.worksheet.fabric_energy_efficiency import (
fabric_energy_efficiency_kwh_per_m2_yr,
)
from domain.sap10_calculator.worksheet.photovoltaic import pv_split_monthly
from domain.sap10_calculator.worksheet.photovoltaic import (
PhotovoltaicSplit,
pv_split_monthly,
)
from domain.sap10_calculator.worksheet.space_cooling import (
SpaceCoolingResult,
space_cooling_monthly_kwh,
@ -659,6 +662,19 @@ _INSTANTANEOUS_WATER_CODES: Final[frozenset[int]] = frozenset({907, 909})
# zero-loss list, so primary loss is zero whenever this code is lodged.
_WHC_ELECTRIC_IMMERSION: Final[int] = 903
# Water-heating codes for a dedicated "boiler/circulator for water
# heating only" — SAP 10.2 Table 4a hot-water section (PDF p.166):
# 911 gas, 912 liquid fuel, 913 solid fuel boiler/circulator; 921-931
# range cooker with boiler for water heating only. Each is a heat
# generator feeding the cylinder through a primary loop, so SAP 10.2
# Table 3 (PDF p.160) row 1 primary circuit loss applies — independent
# of the space-heating system (which for these certs is a separate main,
# e.g. electric storage heaters). 941 (electric HP for water only) is
# excluded: HP DHW vessels follow the Table 3 integral-vessel rules.
_WATER_HEATING_BOILER_CIRCULATOR_CODES: Final[frozenset[int]] = frozenset(
{911, 912, 913} | set(range(921, 932))
)
# SAP 10.2 Appendix M equation (M1): EPV = 0.8 × kWp × S × ZPV, summed
# per array. The module efficiency constant (0.8), orientation-dependent
@ -883,6 +899,12 @@ _HEAT_NETWORK_HEAT_SOURCE_EFFICIENCY: Final[dict[int, float]] = {
304: 3.00,
}
# SAP 10.2 Table 12 (PDF p.191) "Heat networks" standing charge row =
# £120/yr (note (k)). Note (l): "Include half this value if only DHW is
# provided by a heat network." §C3.2 (PDF p.58): the full charge applies
# when the space heating is also on the heat network.
_HEAT_NETWORK_STANDING_CHARGE_GBP: Final[float] = 120.0
def _is_heat_network_main(main: Optional[MainHeatingDetail]) -> bool:
"""True when the cert's main heating is a heat network — either by
@ -1479,6 +1501,35 @@ def _water_heating_main(
return details[0]
def _water_heating_main_space_fraction(
epc: EpcPropertyData, secondary_fraction: float
) -> float:
"""Fraction of TOTAL space heating provided by the DHW boiler — the
SAP 10.2 Appendix D §D2.1(2) Equation D1 Q_space weight.
Eq D1's monthly water-heater efficiency blends η_winter / η_summer by
the ratio of the boiler's space-heating load to its water load. On a
single-main / WHC-901 cert that load is the whole main share,
(202) = 1 (201). On a dual-main cert the DHW boiler does ONLY its
own share (204) for Main 1, (205) for Main 2 so feeding it the
dwelling total over-weights η_winter and under-states HW fuel
(simulated case 6: Main 1 serves DHW + 51% of space heat; using 100%
of demand gave HW 78 kWh vs the worksheet)."""
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
main_fraction = 1.0 - secondary_fraction # (202)
if len(details) < 2:
return main_fraction
main_2 = details[1]
main_2_of_main = (
main_2.main_heating_fraction / 100.0
if main_2.main_heating_fraction is not None
else 0.0
)
if _water_heating_main(epc) is details[1]:
return main_fraction * main_2_of_main # (205) — DHW from Main 2
return main_fraction * (1.0 - main_2_of_main) # (204) — DHW from Main 1
def _rdsap_tariff(epc: EpcPropertyData) -> Tariff:
"""Resolve the cert's Table 12a tariff column via RdSAP 10 §12
Rules 1-4 (page 62). Consults BOTH main heating systems §12
@ -1563,6 +1614,35 @@ def _is_community_heating_hw_from_main(epc: EpcPropertyData) -> bool:
)
def _heat_network_standing_charge_gbp(
epc: EpcPropertyData, main: Optional[MainHeatingDetail]
) -> Optional[float]:
"""SAP 10.2 Table 12 note (l) + §C3.2 heat-network standing charge, or
None when the dwelling is not on a heat network (caller then falls back
to the fuel-based `additional_standing_charges_gbp`).
A heat network carries the Table 12 £120/yr standing charge regardless
of the network fuel full when the SPACE heating is on the network
(§C3.2 "the total standing charge is the normal heat network standing
charge"), halved to £60 when ONLY DHW is provided by the heat network
(note (l)). This REPLACES the fuel-based gas/off-peak standing for a
heat-network main, so it must not be added on top of
`additional_standing_charges_gbp` (which would double-count: a
Summary-path community-gas main lodges Table-32 code 1 and already
draws the £120 gas standing). Worksheet-validated: simulated case 14
(community boilers + mains gas, space + water) (351) = £120.
The API path under-counted this: an EPC community fuel (e.g. 20 = mains
gas community) is not a Table-32 gas code, so `_is_gas_code` returned
False and the standing came out £0 cert 9390 lost the whole £120.
"""
if _is_heat_network_main(main):
return _HEAT_NETWORK_STANDING_CHARGE_GBP
if _is_community_heating_hw_from_main(epc):
return _HEAT_NETWORK_STANDING_CHARGE_GBP / 2.0
return None
def _main_heating_efficiency(epc: EpcPropertyData) -> float:
"""SAP 10.2 (206) main system 1 efficiency as a 0..1 fraction.
@ -1570,7 +1650,16 @@ def _main_heating_efficiency(epc: EpcPropertyData) -> float:
seasonal efficiency heat-network 1/DLF override. Used by §4 (water
heating cascade) and §9a (per-system fuel kWh) both must see the
same value, so this single helper is the single source of truth."""
main = _first_main_heating(epc)
return _main_heating_detail_efficiency(_first_main_heating(epc), epc)
def _main_heating_detail_efficiency(
main: Optional[MainHeatingDetail], epc: EpcPropertyData
) -> float:
"""SAP 10.2 (206)/(207) efficiency (0..1) for a SPECIFIC main heating
detail the per-detail core of `_main_heating_efficiency`. Used for
both main system 1 (206) and main system 2 (207) on dual-main certs
(cert 0240 / simulated case 6)."""
main_code = main.sap_main_heating_code if main is not None else None
main_category = main.main_heating_category if main is not None else None
main_fuel = _main_fuel_code(main)
@ -1817,6 +1906,37 @@ def _main_fuel_code(main: Optional[MainHeatingDetail]) -> Optional[int]:
raise MissingMainFuelType(fuel, main.sap_main_heating_code)
def _heat_network_factor_fuel_code(
main: Optional[MainHeatingDetail],
) -> Optional[int]:
"""Fuel code to feed the Table 12 / Table 32 factor lookups, with the
EPCTable-12 translation applied for heat-network (community) mains.
The EPC `main_fuel_type` enum and the SAP Table 12 / Table 32 fuel-code
numbering COLLIDE in the 18-25 range: `epc_codes.csv` lists
20='mains gas (community)', 21='LPG (community)', 22='oil (community)',
..., whereas Table 12/32 code 20-25 are solid biomass fuels. The factor
lookups (`co2_factor_kg_per_kwh` / `primary_energy_factor` /
`unit_price_p_per_kwh`) check the Table-12/32 dict FIRST, so an EPC
community fuel 20 silently returns the biomass factor (CO2 0.028, PE
1.046, wood-logs price) instead of community mains gas (CO2 0.210, PE
1.130, mains-gas price + £120 standing charge).
Resolution: for a heat-network main, translate the EPC community fuel to
its Table-12 code via `API_FUEL_TO_TABLE_12` (20->51) so the lookups hit
the heat-network row. NON-heat-network mains are returned unchanged so a
genuine biomass boiler (EPC 6 wood logs / 12 biomass, etc.) keeps its raw
Table-12 factor. The Summary path is unaffected it maps
"Mains gas - community" to code 1 (no collision). Worksheet-validated:
simulated case 14 (community boilers + mains gas, SAP code 301)
(367) CO2 factor 0.2100, (467) PE factor 1.1300.
"""
fuel = _main_fuel_code(main)
if fuel is None or not _is_heat_network_main(main):
return fuel
return API_FUEL_TO_TABLE_12.get(fuel, fuel)
def _fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail], prices: PriceTable
) -> float:
@ -1844,7 +1964,10 @@ def _fuel_cost_gbp_per_kwh(
)
blended_p = chp_frac * chp_price + (1.0 - chp_frac) * boiler_price
return blended_p * _PENCE_TO_GBP
return prices.unit_price_p_per_kwh(_main_fuel_code(main)) * _PENCE_TO_GBP
return (
prices.unit_price_p_per_kwh(_heat_network_factor_fuel_code(main))
* _PENCE_TO_GBP
)
# RdSAP energy_tariff enum (per datatypes/epc/domain/epc_codes.csv):
@ -1962,23 +2085,6 @@ def _off_peak_low_rate_gbp_per_kwh(tariff: Tariff) -> float:
return low * _PENCE_TO_GBP
def _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type: object) -> float:
"""Off-peak low-rate £/kWh for callsites that detect off-peak via the
`_is_off_peak_meter` heuristic (RdSAP meter code 3 = Unknown is
treated as off-peak for electric end-uses; see _is_off_peak_meter
docstring). When the meter resolves to a known off-peak tariff
(codes 1/4/5), bills at that tariff's Table 32 low rate; when the
meter resolves to STANDARD (codes 2 = Single, 3 = Unknown), falls
back to the SEVEN_HOUR rate (5.50, Table 32 code 31). Codifies the
heuristic that pre-S0380.138 was baked into the literal
`prices.e7_low_rate_p_per_kwh` constant."""
tariff = tariff_from_meter_type(meter_type)
if tariff is Tariff.STANDARD:
_high, low = _tariff_high_low_rates_p_per_kwh(Tariff.SEVEN_HOUR)
return low * _PENCE_TO_GBP
return _off_peak_low_rate_gbp_per_kwh(tariff)
# Tariff → (high_rate_fuel_code, low_rate_fuel_code) for the SAP 10.2
# Table 12d (CO2) / Table 12e (PE) monthly factors. Mirror of the
# Table 32 cost-rates dict above: 7-hour and 10-hour tariffs split into
@ -2018,6 +2124,16 @@ def _table_12a_system_for_main(
main.main_heating_index_number is not None
and heat_pump_record(main.main_heating_index_number) is not None
)
# Electric room heaters (RdSAP main_heating_category 10) are direct-
# acting electric → SAP 10.2 Table 12a Grid 1 (PDF p.191) "Other
# systems including direct-acting electric" row (7-hour high-rate
# fraction 1.00, 10-hour 0.50). Distinct from electric STORAGE
# heaters (category 7), which charge off-peak and correctly fall
# through to None here (→ 100% low rate). Gated on `_is_electric_main`
# so a non-electric room heater (gas / solid-fuel cat 10) is excluded;
# all callers already pre-gate on electric, this is belt-and-braces.
if main.main_heating_category == 10 and _is_electric_main(main):
return Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC
# ASHP — Table 4a rows 211-217 (earlier generations) + 221-227
# (2013+) cover the air-source space. Warm-air ASHPs are 521-524.
if code is not None and (
@ -2053,6 +2169,41 @@ def _space_heating_fuel_cost_gbp_per_kwh(
return blended * _PENCE_TO_GBP
def _main_space_heating_high_rate_fraction(
main: Optional[MainHeatingDetail],
tariff: Tariff,
) -> float:
"""SAP 10.2 Appendix M1 §3a (PDF p.93) — the fraction of the main
space-heating fuel that is billed at the HIGH rate in Section 10a,
i.e. carries an "electricity not at the low-rate" fuel code (30, 32,
34, 35 or 38). Only this high-rate portion of E_space,m may enter the
PV-eligible demand D_PV,m; the low-rate portion (code 31/33/36/37/39)
is excluded.
Mirrors `_space_heating_fuel_cost_gbp_per_kwh`'s rate split exactly so
the D_PV inclusion and the §10a billing stay consistent:
- non-electric main, or STANDARD tariff 1.0 (no off-peak split;
the eligible-code gate in `_pv_eligible_demand_monthly_kwh`
already excludes non-electric fuels, and a STANDARD-tariff
electric main bills 100% at code 30).
- electric main on an off-peak tariff whose Table 12a Grid 1 SH row
is wired the published high-rate fraction. Electric STORAGE
heaters (Table 12a `_table_12a_system_for_main` None, charged
wholly off-peak) and any system whose Grid 1 SH row is not yet
wired bill 100% at the low rate fraction 0.0, so E_space,m is
excluded from D_PV entirely (worksheet (240) high-rate cost = 0).
"""
if not _is_electric_main(main) or tariff is Tariff.STANDARD:
return 1.0
system = _table_12a_system_for_main(main)
if system is None:
return 0.0
try:
return space_heating_high_rate_fraction(system, tariff)
except NotImplementedError:
return 0.0
def _hot_water_fuel_cost_gbp_per_kwh(
water_heating_fuel: Optional[int],
main: Optional[MainHeatingDetail],
@ -2155,6 +2306,35 @@ def _secondary_efficiency(
return seasonal_efficiency(code, None, None)
def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float:
"""SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric
secondary heater on an off-peak tariff. The secondary is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on
the "Other systems including direct-acting electric" row high-rate
fraction 1.00 for 7-hour, 0.50 for 10-hour. NOT the 100%-low-rate of
storage-charging: a room heater runs on demand, mostly at the high
rate. Worksheet evidence simulated case 19 (242): "Space heating -
secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate.
Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to
a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls
through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_
heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting
row) fall back to the tariff's Table 32 low rate."""
tariff = tariff_from_meter_type(meter_type)
if tariff is Tariff.STANDARD:
tariff = Tariff.SEVEN_HOUR
try:
high_frac = space_heating_high_rate_fraction(
Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, tariff,
)
except NotImplementedError:
return _off_peak_low_rate_gbp_per_kwh(tariff)
high_rate, low_rate = _tariff_high_low_rates_p_per_kwh(tariff)
blended = high_frac * high_rate + (1.0 - high_frac) * low_rate
return blended * _PENCE_TO_GBP
def _secondary_fuel_cost_gbp_per_kwh(
sap_heating,
main: Optional[MainHeatingDetail],
@ -2170,13 +2350,13 @@ def _secondary_fuel_cost_gbp_per_kwh(
# Default to electricity since the default secondary system is
# portable electric heaters (code 693).
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
# When secondary_fuel_type is electricity, apply off-peak if applicable.
if _is_electric_water(sec_fuel) and _is_off_peak_meter(
meter_type, fuel_is_electric=True
):
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return prices.unit_price_p_per_kwh(sec_fuel) * _PENCE_TO_GBP
@ -2315,6 +2495,7 @@ def _pv_eligible_demand_monthly_kwh(
main_fuel_code_table_12: Optional[int],
secondary_fuel_code_table_12: Optional[int],
water_heating_fuel_code_table_12: Optional[int],
main_space_high_rate_fraction: float = 1.0,
) -> tuple[float, ...]:
"""SAP 10.2 Appendix M1 §3a (p.93) — monthly PV-eligible demand
D_PV,m. Always includes lighting + appliances + cooking + electric
@ -2323,6 +2504,18 @@ def _pv_eligible_demand_monthly_kwh(
(codes 30, 32, 34, 35, 38 per spec). Includes E_water,m only when
the water heating fuel code is 30 (standard electricity) per spec.
`main_space_high_rate_fraction` scales the main-heating contribution
by the portion billed at the HIGH rate (code 30) in Section 10a.
Per the §3a inclusion rule "(211) should be included only where the
fuel code applied to it in Section 10a is 30, 32, 34, 35 or 38 (i.e.
electricity not at the low-rate)", off-peak electric mains (e.g.
storage heaters charged wholly at the low rate, fraction 0.0) must
NOT add their (211) to D_PV. Defaults to 1.0 unchanged for
STANDARD-tariff electric mains and the gas-main / electric-secondary
cohort. Without this, off-peak storage-heater dwellings over-counted
D_PV by the full (211) in winter, inflating R_PV,m β the onsite
PV split (case 19: β_Jan 0.894 0.792, matching worksheet 0.791).
Secondary space heating is included on the same footing as main:
Appendix M1 §3a counts E_space,m as the dwelling's total electric
space-heating demand, which for a gas-main / electric-secondary
@ -2332,9 +2525,13 @@ def _pv_eligible_demand_monthly_kwh(
worksheet (233a) gap localised on the cohort-2 gas+PV certs:
cert 3136 onsite 726.9 790.3 vs worksheet 792.1).
The off-peak immersion × (243) Ewater branch and the Appendix G4
PV diverter adjustment are deferred current cohort fixtures
don't exercise them."""
The off-peak immersion × (243) Ewater branch is deferred. The
Appendix G4 PV-diverter saving is intentionally NOT reflected here:
per the §3a note (PDF p.93, lines 5485-5486) "If there is a PV
diverter, then for the purposes of this β factor calculation (219)m
should not include the diverter savings" — so D_PV uses the
pre-diverter (219), and the diverter (63b)m is applied afterwards in
`_pv_diverter_monthly_kwh`."""
include_main_space = (
main_fuel_code_table_12 is not None
and main_fuel_code_table_12 in _PV_ELIGIBLE_SPACE_HEATING_FUEL_CODES
@ -2357,7 +2554,7 @@ def _pv_eligible_demand_monthly_kwh(
+ pumps_fans_monthly_kwh[m]
)
if include_main_space:
d += main_1_fuel_monthly_kwh[m]
d += main_space_high_rate_fraction * main_1_fuel_monthly_kwh[m]
if include_secondary_space:
d += secondary_fuel_monthly_kwh[m]
if include_water:
@ -2366,6 +2563,70 @@ def _pv_eligible_demand_monthly_kwh(
return tuple(monthly)
# SAP 10.2 Appendix G4 step 4 (PDF p.73) — correction factors applied to
# the surplus PV available to the diverter: 0.8 for the cylinder's
# ability to accept the heat, and fPV,diverter,storageloss = 0.9 for the
# increased cylinder losses from storing water at a higher temperature.
_PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR: Final[float] = 0.8
_PV_DIVERTER_STORAGE_LOSS_FACTOR: Final[float] = 0.9
def _pv_diverter_monthly_kwh(
*,
epc: EpcPropertyData,
pv_export_monthly_kwh: tuple[float, ...],
water_demand_monthly_kwh: tuple[float, ...],
avg_daily_hot_water_l: float,
battery_capacity_kwh: float,
pv_generation_kwh: float,
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 Appendix G4 (PDF p.72-73) — monthly PV-diverter water-
heating input SPV,diverter,m (positive kWh), entered as the negative
worksheet (63b)m.
`pv_export_monthly_kwh` is the pre-diverter surplus EPV,m × (1 βm)
the portion of PV generation not consumed by the dwelling's
instantaneous demand, which would otherwise be exported. Per G4 step
4:
SPV,diverter,m = EPV,m × (1 βm) × 0.8 × fPV,diverter,storageloss
clamped to (62)m + (63a)m (`water_demand_monthly_kwh`; (63a) the
WWHRS reduction, 0 here) so the diverter never supplies more than the
water-heating demand.
Returns None diverter disregarded by software (G4 step 1) unless
ALL four inclusion conditions hold:
a. a PV system connected to the dwelling supply (EPV > 0);
b. a cylinder whose volume exceeds (43) the average daily hot-water
use;
c. no solar water heating present;
d. no battery storage present.
`pv_diverter_present` (Summary §19 / API `pv_diverter`) gates the
whole calculation: an absent diverter returns None immediately.
"""
if not epc.sap_energy_source.pv_diverter_present:
return None
# a. PV connected to the dwelling (case "a" Appendix M1 step 2).
if pv_generation_kwh <= 0.0:
return None
# b. Cylinder volume (litres) must exceed (43) average daily HW use.
cylinder_volume_l = _hot_water_cylinder_volume_l(epc)
if cylinder_volume_l is None or cylinder_volume_l <= avg_daily_hot_water_l:
return None
# c. No solar water heating. d. No battery storage.
if epc.solar_water_heating or battery_capacity_kwh > 0.0:
return None
correction = (
_PV_DIVERTER_CYLINDER_ACCEPTANCE_FACTOR
* _PV_DIVERTER_STORAGE_LOSS_FACTOR
)
return tuple(
min(pv_export_monthly_kwh[m] * correction, water_demand_monthly_kwh[m])
for m in range(12)
)
# 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
@ -2453,30 +2714,25 @@ def _pv_export_credit_gbp_per_kwh() -> float:
def _pv_dwelling_import_price_gbp_per_kwh(
meter_type: object, prices: PriceTable
tariff: Tariff, prices: PriceTable
) -> float:
"""PV dwelling-consumption price per kWh per SAP 10.2 Appendix M1 §6
(p.94): "apply the normal import electricity price to PV energy used
within the dwelling". Onsite-consumed PV displaces grid IMPORTS, so
it bills at the standard electricity import tariff (Table 32 code 30
under the RdSAP10 amendment per ADR-0010 §10 = 13.19 p/kWh the
same rate `_fuel_cost`'s `other_uses_p_per_kwh` already pays for
lighting/pumps/fans, and crucially the same rate Table 32 code 60
pays for the EXPORT credit. In Table 32 these collapse to a single
13.19 p value, so the IMPORT/EXPORT split is mathematically
equivalent to the legacy single-rate-EXPORT credit but the
distinction matters when an off-peak tariff lands: §6 then directs
a weighted Table 12a high/low rate, deferred until the first off-
peak cost cert ships."""
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
# Off-peak weighted Table 12a rate (deferred — `_fuel_cost`
# short-circuits Tariff != STANDARD before reaching this path).
# Routes through the meter-heuristic helper so an Unknown-meter
# cert (code 3 = "treat as off-peak for electric end-uses" per
# _is_off_peak_meter) falls back to the SEVEN_HOUR low rate
# rather than raising on STANDARD.
return _off_peak_low_rate_gbp_per_kwh_via_meter_heuristic(meter_type)
return table_32_unit_price_p_per_kwh(30) * _PENCE_TO_GBP
(PDF p.94, lines 5510-5513): "apply the normal import electricity
price to PV energy used within the dwelling In the case of the
former, use a weighted average of high and low rates (Table 12a)."
Onsite-consumed PV displaces the dwelling's "all other uses"
electricity (lighting / appliances / pumps), so it bills at the same
Table 12a Grid 2 ALL_OTHER_USES rate `_other_fuel_cost_gbp_per_kwh`
derives a STANDARD-tariff dwelling pays the flat Table 32 code 30
13.19 p/kWh (unchanged from the legacy single-rate path), while an
off-peak dwelling pays the weighted high/low blend (7-hour:
0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh, matching worksheet
(252)/(269) "PV used in dwelling" on case 19).
Pre-S0380.233 the off-peak branch returned the bare low rate
(5.50 p/kWh), under-crediting onsite PV on every off-peak cert."""
return _other_fuel_cost_gbp_per_kwh(tariff, prices)
def _other_fuel_cost_gbp_per_kwh(
@ -2778,7 +3034,7 @@ def _main_heating_co2_factor_kg_per_kwh(
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_co2_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -2857,7 +3113,7 @@ def _main_heating_primary_factor(
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(fuel) * scaling
return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling
if tariff is Tariff.STANDARD:
monthly = _effective_monthly_pe_factor(
main_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE,
@ -3133,7 +3389,7 @@ def _hot_water_co2_factor_kg_per_kwh(
)
if monthly is not None:
return monthly * scaling
return _co2_factor_kg_per_kwh(main) * scaling
return co2_factor_kg_per_kwh(_heat_network_factor_fuel_code(main)) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_CO2_KG_PER_KWH
@ -3197,7 +3453,7 @@ def _hot_water_primary_factor(
)
if monthly is not None:
return monthly * scaling
return primary_energy_factor(_main_fuel_code(main)) * scaling
return primary_energy_factor(_heat_network_factor_fuel_code(main)) * scaling
fuel = _water_heating_fuel_code(epc)
if fuel is None:
return _DEFAULT_PEF
@ -3956,11 +4212,25 @@ def energy_requirements_section_from_cert(
if secondary_fraction_value > 0.0 else 0.0
)
eff = _main_heating_efficiency(epc)
# SAP 10.2 §9a two-main split (203)/(207): when a second main heating
# system is lodged, (203) = its `main_heating_fraction` (% of main
# heating it supplies) and (207) = its own seasonal efficiency. Cert
# 0240 (2× oil code 130, 51/49) + simulated case 6 (oil code 127,
# rads 51% + underfloor 49%) exercise this.
details = epc.sap_heating.main_heating_details if epc.sap_heating else []
main_2 = details[1] if len(details) >= 2 else None
main_2_of_main_fraction = 0.0
main_2_efficiency_value = 0.0
if main_2 is not None and main_2.main_heating_fraction is not None:
main_2_of_main_fraction = main_2.main_heating_fraction / 100.0
main_2_efficiency_value = _main_heating_detail_efficiency(main_2, epc)
return space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=sh.total_space_heating_monthly_kwh,
secondary_heating_fraction=secondary_fraction_value,
main_heating_efficiency_pct=eff * 100.0,
secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0,
main_2_of_main_fraction=main_2_of_main_fraction,
main_2_efficiency_pct=main_2_efficiency_value * 100.0,
)
@ -4103,13 +4373,27 @@ def _has_suspended_timber_floor_per_spec(
if age in _AGE_BANDS_F_TO_M:
return True, True # sealed
if age in _AGE_BANDS_A_TO_E:
# (a) U-value < 0.5 → sealed
main_floor_u = _main_floor_u_value(epc)
if main_floor_u is not None and main_floor_u < _FLOOR_U_SEALED_THRESHOLD:
return True, True
# (b) retro-fitted insulation + no U-value supplied → sealed
ins_type_str = (main.floor_insulation_type_str or "").strip().lower()
u_value_known = bool(getattr(main, "floor_u_value_known", False))
# (a) a SUPPLIED floor U-value < 0.5 → sealed. RdSAP 10 §5 (PDF
# p.29) splits (a)/(b) on whether a U-value is supplied: (a) is
# the "U-value supplied" branch, (b) the "no U-value is supplied"
# branch. A computed default U (an assumed / as-built uninsulated
# floor) is NOT a supplied value, so it must NOT trigger (a) — it
# falls through to (b). Without this gate the cascade marked an
# as-built suspended-timber floor with default U=0.43 "sealed"
# (0.1) where Elmhurst uses "unsealed" (0.2) — cert 001431 sim
# case 2 worksheet (12)=0.2, dropping (25) effective ACH and
# understating space heating ~450 kWh.
main_floor_u = _main_floor_u_value(epc)
if (
u_value_known
and main_floor_u is not None
and main_floor_u < _FLOOR_U_SEALED_THRESHOLD
):
return True, True
# (b) no U-value supplied: retro-fitted insulation → sealed;
# otherwise unsealed.
ins_type_str = (main.floor_insulation_type_str or "").strip().lower()
if "retro" in ins_type_str and not u_value_known:
return True, True
# otherwise → unsealed
@ -4447,6 +4731,27 @@ _CYLINDER_SIZE_CODE_TO_LITRES: Final[dict[int, float]] = {
# from the ASHP cohort (all 7 certs lodge code 1, worksheet shows
# "Foam" → factory-applied per SAP 10.2 Table 2 Note 2).
_CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
# RdSAP 10 field 7-11 (cylinder insulation type) — code 2 = loose jacket,
# which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor
# L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4).
_CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2
def _cylinder_storage_loss_insulation_label(
insulation_type: "int | str | None",
) -> Optional[Literal["factory_insulated", "loose_jacket"]]:
"""Map the lodged cylinder_insulation_type code to the SAP 10.2
Table 2 loss-factor branch. Code 1 factory-insulated, code 2
loose jacket. Any other value (None / 0 / unknown) None so the
caller keeps the conservative no-storage-loss default rather than
guessing a loss branch. Accepts the int / digit-string / None shapes
`cylinder_insulation_type` arrives in across the two front-ends."""
code = _int_or_none(insulation_type)
if code == _CYLINDER_INSULATION_TYPE_FACTORY:
return "factory_insulated"
if code == _CYLINDER_INSULATION_TYPE_LOOSE_JACKET:
return "loose_jacket"
return None
# RdSAP 10 §10.7 (PDF p.55) "No water heating system": SAP water-heating
# code 999 (Elmhurst §15.0 "NON") signals that no DHW system was
@ -4460,11 +4765,25 @@ _WHC_NO_WATER_HEATING_SYSTEM: Final[int] = 999
# "Immersion Heater Type: Single" so the single-immersion path is used.
_CYLINDER_SIZE_CODE_NORMAL_110L: Final[int] = 2
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
# accessible" — the §10.7 default cylinder uses the age-band insulation,
# same rule as the inaccessible-cylinder path: A-F → 12 mm loose jacket
# (not yet plumbed — strict-raise), G/H → 25 mm foam, I-M → 38 mm foam.
_TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE: Final[dict[str, int]] = {
"G": 25, "H": 25, "I": 38, "J": 38, "K": 38, "L": 38, "M": 38,
# accessible" — the §10.7 default cylinder uses the age-band insulation:
# "Age band of main property A to F: 12 mm loose jacket", G/H → 25 mm
# foam, I-M → 38 mm foam. Each entry is (cylinder_insulation_type,
# thickness_mm); the loose-jacket branch is now plumbed (S0380.224) so
# A-F resolves instead of raising.
_TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE: Final[dict[str, tuple[int, int]]] = {
"A": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"B": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"C": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"D": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"E": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"F": (_CYLINDER_INSULATION_TYPE_LOOSE_JACKET, 12),
"G": (_CYLINDER_INSULATION_TYPE_FACTORY, 25),
"H": (_CYLINDER_INSULATION_TYPE_FACTORY, 25),
"I": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"J": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"K": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"L": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
"M": (_CYLINDER_INSULATION_TYPE_FACTORY, 38),
}
@ -4487,9 +4806,8 @@ def _apply_rdsap_no_water_heating_system_default(
Elmhurst engine's worksheet header for the corpus "no system" cert
(WHS 903, Single immersion, 110 L cylinder, 25 mm foam at age G).
Raises `UnmappedSapCode` for age bands A-F (12 mm loose jacket)
no corpus member exercises that combination and the SAP 10.2 Table 2
loss-factor dispatch only has the factory-foam path plumbed.
Raises `UnmappedSapCode` only when the main dwelling's age band is
absent / outside A-M (no Table 29 row to apply).
"""
if epc.sap_heating.water_heating_code != _WHC_NO_WATER_HEATING_SYSTEM:
return epc
@ -4498,17 +4816,18 @@ def _apply_rdsap_no_water_heating_system_default(
if epc.sap_building_parts else None
)
band = (age_band or "")[:1].upper()
thickness_mm = _TABLE_29_DEFAULT_CYLINDER_INSULATION_MM_BY_AGE.get(band)
if thickness_mm is None:
default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band)
if default is None:
raise UnmappedSapCode(
"rdsap_10_7_default_cylinder_insulation_age_band", age_band
)
insulation_type_code, thickness_mm = default
sap_heating = replace(
epc.sap_heating,
water_heating_code=_WHC_ELECTRIC_IMMERSION,
water_heating_fuel=_STANDARD_ELECTRICITY_FUEL_CODE,
cylinder_size=_CYLINDER_SIZE_CODE_NORMAL_110L,
cylinder_insulation_type=_CYLINDER_INSULATION_TYPE_FACTORY,
cylinder_insulation_type=insulation_type_code,
cylinder_insulation_thickness_mm=thickness_mm,
cylinder_thermostat="Y",
)
@ -4594,6 +4913,20 @@ def _separately_timed_dhw(
return False
if main.sap_main_heating_code in _TABLE_4A_SOLID_FUEL_BOILER_CODES:
return False
# SAP 10.2 Table 2b note b + RdSAP 10 §10.5.1 (PDF p.55): the ×0.9
# reduction reflects DHW timed separately from space heating on a
# SHARED heat generator. When DHW is from a separate dedicated
# water-heating-only system (water-heating code not "from main /
# 2nd-main system" — e.g. 911 "Gas boiler/circulator for water
# heating only") there is no shared timer to apply the ×0.9 against,
# so the multiplier must not fire — the same principle as the WHC
# 903 electric-immersion carve-out above. Simulated case 19 (electric
# storage main + WHS 911 + 210 L loose-jacket cylinder) is the
# worksheet case: (53) Temperature factor 0.6000 (not 0.54) and
# (59)m primary loss h=5 (Jan 64.5792, not 43.31) both confirm the
# DHW is not separately timed.
if epc.sap_heating.water_heating_code not in _WATER_INHERIT_FROM_MAIN_CODES:
return False
return bool(epc.has_hot_water_cylinder)
@ -4897,6 +5230,18 @@ def _primary_loss_applies(
# kWh/yr primary loss to a system with no primary circuit at all.
if water_heating_code == _WHC_ELECTRIC_IMMERSION:
return False
# SAP 10.2 Table 3 (PDF p.160) row 1 — a dedicated "boiler/circulator
# for water heating only" (WHC 911 gas / 912 liquid / 913 solid /
# 921-931 range cooker with boiler) is a heat generator feeding the
# cylinder through a primary loop, so the loss applies regardless of
# the space-heating main. Checked off `water_heating_code` (not
# `main`) because for these certs the resolved DHW `main` is the
# SPACE main (e.g. an electric storage heater, SAP code 402) — the
# gas/oil water boiler isn't a `main_heating_detail`. Simulated case
# 19 (storage main + WHS 911 + 210 L cylinder): worksheet (59) = 676.68
# kWh/yr — zero before this branch.
if water_heating_code in _WATER_HEATING_BOILER_CIRCULATOR_CODES:
return True
if main.main_heating_category == 4:
if hp_record is None:
# No PCDB record → assume separate-vessel (conservative; the
@ -5421,14 +5766,17 @@ def _cylinder_storage_loss_override(
volume_l = _CYLINDER_SIZE_CODE_TO_LITRES.get(size_code)
if volume_l is None:
return None
if sh.cylinder_insulation_type != _CYLINDER_INSULATION_TYPE_FACTORY:
insulation_label = _cylinder_storage_loss_insulation_label(
sh.cylinder_insulation_type
)
if insulation_label is None:
return None
thickness_mm = sh.cylinder_insulation_thickness_mm
if thickness_mm is None:
return None
storage_56m = cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
insulation_type="factory_insulated",
insulation_type=insulation_label,
thickness_mm=float(thickness_mm),
has_cylinder_thermostat=sh.cylinder_thermostat == "Y",
# SAP 10.2 Table 2b note b (PDF p.159) verbatim restricts the
@ -5785,10 +6133,15 @@ def _fuel_cost(
table_32_unit_price_p_per_kwh(60) * _PENCE_TO_GBP
)
standing = additional_standing_charges_gbp(
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
heat_network_standing = _heat_network_standing_charge_gbp(epc, main)
standing = (
heat_network_standing
if heat_network_standing is not None
else additional_standing_charges_gbp(
main_fuel_code=main_fuel_code,
water_heating_fuel_code=water_heating_fuel_code,
tariff=tariff,
)
)
# Worksheet display convention: when a row's kWh is zero (no main 2, no
@ -5833,9 +6186,7 @@ def _fuel_cost(
pv_dwelling_kwh_per_yr=pv_dwelling_kwh_per_yr,
pv_exported_kwh_per_yr=pv_exported_kwh_per_yr,
pv_dwelling_import_price_gbp_per_kwh=(
_pv_dwelling_import_price_gbp_per_kwh(
epc.sap_energy_source.meter_type, prices
)
_pv_dwelling_import_price_gbp_per_kwh(_rdsap_tariff(epc), prices)
),
)
@ -5887,6 +6238,27 @@ def cert_to_inputs(
has_balanced_mv=_has_balanced_mechanical_ventilation(epc),
)
)
# SAP 10.2 Table 4f note c) (PDF p.175): "Where there are two main
# heating systems include two figures from this table." A genuine
# second SPACE-heating main therefore contributes its own circulation
# pump alongside Main 1's. The "second main heating system" test is the
# same one §9a uses to split space-heating demand: a lodged
# `main_heating_fraction > 0`. This excludes DHW-only second mains
# (e.g. cert 000565 Main 2 = gas combi via WHC 914, fraction 0 — water
# heating only, no space-heating circulation pump). Simulated case 6
# (dual oil boiler, 51% rads + 49% underfloor) lodges Main 1 "2013 or
# later" (41 kWh) + Main 2 unknown-date (115 kWh) → worksheet (230c)
# central-heating pump = 41 + 115 = 156. The Main 2 oil-boiler aux
# (230d) is already summed in `_table_4f_additive_components`; this
# adds only the circulation pump.
_pumps_main_details = (
epc.sap_heating.main_heating_details if epc.sap_heating else []
)
if len(_pumps_main_details) >= 2:
_pumps_main_2 = _pumps_main_details[1]
_pumps_main_2_fraction = _pumps_main_2.main_heating_fraction
if _pumps_main_2_fraction is not None and _pumps_main_2_fraction > 0:
pumps_fans_kwh += _table_4f_circulation_pump_kwh(_pumps_main_2)
pumps_fans_kwh += _table_4f_additive_components(epc)
# Track the MEV/MVHR-fan portion separately so the cost cascade can
# apply Table 12a Grid 2 `FANS_FOR_MECH_VENT` (0.58 high-frac on
@ -6192,12 +6564,33 @@ def cert_to_inputs(
# = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0
# for the Elmhurst corpus (cert-side mapping is a future slice).
control_type_value = _control_type(main)
responsiveness_value = _responsiveness(
main, tariff=tariff_from_meter_type(epc.sap_energy_source.meter_type),
)
_mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
responsiveness_value = _responsiveness(main, tariff=_mit_tariff)
living_area_fraction_value = _living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
)
# SAP 10.2 Table 9b weighted R + p.186 two-systems-different-parts MIT.
# A genuine second main (main_heating_fraction > 0 = (203)) contributes
# its own responsiveness (Table 9b weighted average) and, when it
# carries a different control type, its own rest-of-dwelling control
# schedule. `_first_main_heating` is system 1 (living area); the second
# detail is system 2. Single-main / DHW-only second mains (frac 0) pass
# the None/0 defaults → unchanged single-system MIT.
_mit_details = epc.sap_heating.main_heating_details if epc.sap_heating else []
_mit_main_2 = _mit_details[1] if len(_mit_details) >= 2 else None
main_2_control_type_value: Optional[int] = None
main_2_fraction_value = 0.0
main_2_responsiveness_value = 1.0
if (
_mit_main_2 is not None
and _mit_main_2.main_heating_fraction is not None
and _mit_main_2.main_heating_fraction > 0
):
main_2_control_type_value = _control_type(_mit_main_2)
main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0
main_2_responsiveness_value = _responsiveness(
_mit_main_2, tariff=_mit_tariff
)
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
)
@ -6223,6 +6616,9 @@ def cert_to_inputs(
responsiveness=responsiveness_value,
living_area_fraction=living_area_fraction_value,
control_temperature_adjustment_c=_control_temperature_adjustment_c(main),
main_2_control_type=main_2_control_type_value,
main_2_fraction=main_2_fraction_value,
main_2_responsiveness=main_2_responsiveness_value,
extended_heating_days_per_month=extended_heating_days,
)
@ -6246,9 +6642,16 @@ def cert_to_inputs(
# the scalar `water_eff` (Table 4a/4b boilers, legacy fallback).
# Q_space (kWh/month) per spec = (98c)m × (204) = (98c)m × (1
# sec_frac) for single-main fixtures.
space_heating_monthly_useful_kwh: tuple[float, ...] = (0.0,) * 12
if wh_result is not None:
# Eq D1 Q_space is the DHW boiler's OWN space-heating load — its
# (204)/(205) share of total — not the dwelling total (202). See
# `_water_heating_main_space_fraction`.
water_main_space_fraction = _water_heating_main_space_fraction(
epc, secondary_fraction_value
)
space_heating_monthly_useful_kwh = tuple(
q * (1.0 - secondary_fraction_value)
q * water_main_space_fraction
for q in space_heating_result.total_space_heating_monthly_kwh
)
hw_kwh = _apply_water_efficiency(
@ -6337,11 +6740,22 @@ def cert_to_inputs(
secondary_efficiency_value = _secondary_efficiency(
epc.sap_heating, main_code, main_fuel
)
# SAP 10.2 §9a two-main split (203)/(207) — see the section helper
# `energy_requirements_section_from_cert` for the rationale.
_main_details = epc.sap_heating.main_heating_details if epc.sap_heating else []
_main_2 = _main_details[1] if len(_main_details) >= 2 else None
main_2_of_main_fraction = 0.0
main_2_efficiency_value = 0.0
if _main_2 is not None and _main_2.main_heating_fraction is not None:
main_2_of_main_fraction = _main_2.main_heating_fraction / 100.0
main_2_efficiency_value = _main_heating_detail_efficiency(_main_2, epc)
energy_requirements_result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
secondary_heating_fraction=secondary_fraction_value,
main_heating_efficiency_pct=eff * 100.0,
secondary_heating_efficiency_pct=secondary_efficiency_value * 100.0,
main_2_of_main_fraction=main_2_of_main_fraction,
main_2_efficiency_pct=main_2_efficiency_value * 100.0,
)
# SAP 10.2 Appendix M1 §3-4 (p.93-94): split monthly PV generation
@ -6403,6 +6817,12 @@ def cert_to_inputs(
)
if epc.sap_heating.water_heating_fuel is not None else None
),
# SAP 10.2 Appendix M1 §3a — exclude the low-rate portion of an
# off-peak electric main from D_PV (the §10a high/low split that
# `_space_heating_fuel_cost_gbp_per_kwh` already bills).
main_space_high_rate_fraction=_main_space_heating_high_rate_fraction(
main, _rdsap_tariff(epc),
),
)
pv_split = pv_split_monthly(
epv_monthly_kwh=pv_monthly_kwh,
@ -6410,17 +6830,70 @@ def cert_to_inputs(
battery_capacity_kwh=_pv_battery_capacity_kwh(epc),
)
# SAP 10.2 Appendix G4 (PDF p.72-73) — PV diverter. The β factor above
# is computed on the PRE-diverter (219) per the §3a note; now apply
# the diverter saving. SPV,diverter,m diverts the surplus PV (the
# would-be export EPV,m × (1 βm)) into the cylinder immersion:
# - (63b)m = SPV,diverter,m reduces the §4 output (64)m → less main-
# system water-heating fuel (219);
# - the export drops to EPV,ex,m = EPV,m(1 βm) + (63b)m / 0.9 (the
# diverted energy is no longer exported); the onsite dwelling
# portion EPV,dw,m = EPV,m × βm is unchanged (the β is fixed).
hw_output_monthly_for_factors = (
wh_result.output_monthly_kwh if wh_result is not None else (0.0,) * 12
)
pv_diverter_monthly_kwh = _pv_diverter_monthly_kwh(
epc=epc,
pv_export_monthly_kwh=pv_split.epv_exported_monthly_kwh,
water_demand_monthly_kwh=(
wh_result.total_demand_monthly_kwh if wh_result is not None
else (0.0,) * 12
),
avg_daily_hot_water_l=(
wh_result.annual_avg_hot_water_l_per_day if wh_result is not None
else 0.0
),
battery_capacity_kwh=_pv_battery_capacity_kwh(epc),
pv_generation_kwh=sum(pv_monthly_kwh),
)
if pv_diverter_monthly_kwh is not None and wh_result is not None:
pv63b_monthly_kwh = tuple(-s for s in pv_diverter_monthly_kwh)
# (64)m = (62)m + (63a)m + (63b)m — reduce the §4 output by the
# diverter input, then recompute (219) from the reduced output.
hw_output_monthly_for_factors = tuple(
max(0.0, wh_result.output_monthly_kwh[m] + pv63b_monthly_kwh[m])
for m in range(12)
)
if section_12_4_4_blend is None:
hw_kwh = _apply_water_efficiency(
wh_output_monthly_kwh=hw_output_monthly_for_factors,
wh_output_annual_kwh=sum(hw_output_monthly_for_factors),
water_efficiency_pct=water_eff,
eq_d1_winter_summer_pct=eq_d1_winter_summer_pct,
space_heating_monthly_useful_kwh=space_heating_monthly_useful_kwh,
interlock_penalty_pp=eq_d1_interlock_penalty_pp,
)
# EPV,ex,m = EPV,m(1 βm) + (63b)m / fPV,diverter,storageloss.
adjusted_export_monthly_kwh = tuple(
pv_split.epv_exported_monthly_kwh[m]
+ pv63b_monthly_kwh[m] / _PV_DIVERTER_STORAGE_LOSS_FACTOR
for m in range(12)
)
pv_split = PhotovoltaicSplit(
beta_monthly=pv_split.beta_monthly,
epv_dwelling_monthly_kwh=pv_split.epv_dwelling_monthly_kwh,
epv_exported_monthly_kwh=adjusted_export_monthly_kwh,
)
# SAP 10.2 §12.4.4 overrides — when summer immersion applies (back-
# boiler combo + cylinder + WHC from main heating), the HW cost /
# CO2 / PE factors are kWh-weighted blends of the winter boiler fuel
# + summer electric immersion. The standing-charges line adds the
# off-peak electric standing because the cylinder is heated by an
# off-peak immersion Jun-Sep. When the rule does NOT apply, the
# locals fall back to the existing single-fuel HW helpers.
hw_monthly_kwh_for_factors = (
wh_result.output_monthly_kwh if wh_result is not None
else (0.0,) * 12
)
# locals fall back to the existing single-fuel HW helpers. The HW
# factors weight by the diverter-adjusted (64)m output.
hw_monthly_kwh_for_factors = hw_output_monthly_for_factors
if section_12_4_4_blend is not None:
(
_hw_total_unused,
@ -6448,10 +6921,15 @@ def cert_to_inputs(
epc, hw_monthly_kwh_for_factors, _rdsap_tariff(epc),
)
_hw_extra_standing = 0.0
standing_charges_total = additional_standing_charges_gbp(
main_fuel_code=_main_fuel_code(main),
water_heating_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
_heat_network_standing = _heat_network_standing_charge_gbp(epc, main)
standing_charges_total = (
_heat_network_standing
if _heat_network_standing is not None
else additional_standing_charges_gbp(
main_fuel_code=_main_fuel_code(main),
water_heating_fuel_code=_water_heating_fuel_code(epc),
tariff=_rdsap_tariff(epc),
)
) + _hw_extra_standing
# SAP 10.2 Appendix C §C3.2 (PDF p.51) — heat-network distribution
@ -6599,7 +7077,7 @@ def cert_to_inputs(
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc, climate),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(),
pv_dwelling_import_price_gbp_per_kwh=_pv_dwelling_import_price_gbp_per_kwh(
epc.sap_energy_source.meter_type, prices
_rdsap_tariff(epc), prices
),
# SAP 10.2 Appendix M1 §3-4 PV split — the cascade applies
# IMPORT PEF (Table 12) to the onsite portion and EXPORT PEF

View file

@ -250,13 +250,16 @@ _RULE_2_STORAGE_CODES: Final[frozenset[int]] = frozenset(
# Rule 3: direct-acting electric + heat pumps + electric room heaters
# → 10-hour. §12 lists "heat pump (211 to 224, 521 to 524, or
# database)" — the "database" branch fires when the cert lodges a
# PCDB Table 362 heat-pump index regardless of SAP code.
# PCDB Table 362 heat-pump index regardless of SAP code. §12 also
# names "electric room heaters" verbatim (RdSAP 10 PDF p.62) — Table 4a
# electric room-heater codes 691 (panel/convector/radiant), 692 (fan),
# 693 (portable), 694 (water-/oil-filled), 699 (assumed). Without these
# a Dual-meter room-heater cert fell through to Rule 4 (7-hour default).
_RULE_3_TEN_HOUR_CODES: Final[frozenset[int]] = frozenset(
[191] # direct-acting electric boiler
+ list(range(211, 225)) # heat pumps 211-224
+ list(range(521, 525)) # warm-air heat pumps 521-524
# TODO: electric room heater codes (SAP Table 4a row 6xx for
# electric panel / radiant heaters) when a fixture surfaces them.
+ [691, 692, 693, 694, 699] # electric room heaters (Table 4a)
)

View file

@ -11,8 +11,10 @@ where (204) = (202) × (1 (203)) and (202) = 1 (201). Single-main
case ((203) = 0) collapses (204) to (202), so (211)m = (98c)m × (202) ×
100 / (206). Same shape for secondary (215)m and main 2 (213)m.
Two-main split ((203) > 0) and cooling-fuel (209)/(221) are zero-branch
placeholders in scope A populated once first cert exercises them.
Two-main split ((203) > 0) is implemented: (211)m = (98c)m × (204) ×
100 / (206) for system 1 and (213)m = (98c)m × (205) × 100 / (207) for
system 2, where (204) = (202) × (1 (203)) and (205) = (202) × (203).
Cooling-fuel (209)/(221) remains a zero-branch placeholder.
Reference: SAP 10.2 specification (14-03-2025) §9a (lines 7909-7953).
"""
@ -26,10 +28,9 @@ from dataclasses import dataclass
class EnergyRequirementsResult:
"""SAP 10.2 §9a worksheet line refs (201)..(221).
Scope-A populated lines: (201), (202), (204), (206), (208), (211)m,
(211), (215)m, (215). Two-main and cooling-fuel line refs ((203),
(205), (207), (209), (213)m, (213), (221)) are zero-branch
placeholders until the first multi-main / fixed-AC cert lands.
Populated lines: (201)-(208), (211)m/(211), (213)m/(213) (two-main
split), (215)m/(215). Cooling-fuel line refs ((209), (221)) are
zero-branch placeholders until the first fixed-AC cert lands.
"""
# Fractions (Table 11)
@ -60,26 +61,37 @@ def space_heating_fuel_monthly_kwh(
secondary_heating_fraction: float,
main_heating_efficiency_pct: float,
secondary_heating_efficiency_pct: float,
main_2_of_main_fraction: float = 0.0,
main_2_efficiency_pct: float = 0.0,
) -> EnergyRequirementsResult:
"""SAP 10.2 §9a orchestrator — produce (201)..(221) line refs.
Scope A: single-main + secondary only. Two-main ((203) > 0) and
cooling-fuel (Table 10c SEER) populate the zero-branch placeholder
fields with computed values when their respective slices land.
Single-main certs leave `main_2_of_main_fraction` = 0, collapsing
(204) to (202) and zeroing (213)m. Dual-main certs (cert 0240 /
simulated case 6) pass (203) = fraction of main heating from main
system 2 and (207) = main system 2 efficiency; the §8 space-heat
demand then splits (204)=(202)×(1(203)) to system 1 and
(205)=(202)×(203) to system 2, each at its own efficiency. Cooling-
fuel (Table 10c SEER) remains a zero-branch placeholder.
"""
fraction_201 = secondary_heating_fraction
fraction_202 = 1.0 - fraction_201
fraction_203 = 0.0 # scope A: no main 2
fraction_203 = main_2_of_main_fraction
fraction_204 = fraction_202 * (1.0 - fraction_203)
fraction_205 = fraction_202 * fraction_203
main_1_eff = main_heating_efficiency_pct
main_2_eff = main_2_efficiency_pct
secondary_eff = secondary_heating_efficiency_pct
main_1_fuel_monthly = tuple(
q * fraction_204 * 100.0 / main_1_eff if main_1_eff > 0 else 0.0
for q in space_heating_monthly_kwh
)
main_2_fuel_monthly = tuple(
q * fraction_205 * 100.0 / main_2_eff if main_2_eff > 0 else 0.0
for q in space_heating_monthly_kwh
)
secondary_fuel_monthly = tuple(
q * fraction_201 * 100.0 / secondary_eff if secondary_eff > 0 else 0.0
for q in space_heating_monthly_kwh
@ -92,14 +104,14 @@ def space_heating_fuel_monthly_kwh(
main_1_of_total_fraction=fraction_204,
main_2_of_total_fraction=fraction_205,
main_1_efficiency_pct=main_1_eff,
main_2_efficiency_pct=0.0,
main_2_efficiency_pct=main_2_eff,
secondary_efficiency_pct=secondary_eff,
cooling_seer=0.0,
main_1_fuel_monthly_kwh=main_1_fuel_monthly,
main_2_fuel_monthly_kwh=(0.0,) * 12,
main_2_fuel_monthly_kwh=main_2_fuel_monthly,
secondary_fuel_monthly_kwh=secondary_fuel_monthly,
main_1_fuel_kwh_per_yr=sum(main_1_fuel_monthly),
main_2_fuel_kwh_per_yr=0.0,
main_2_fuel_kwh_per_yr=sum(main_2_fuel_monthly),
secondary_fuel_kwh_per_yr=sum(secondary_fuel_monthly),
cooling_fuel_kwh_per_yr=0.0,
)

View file

@ -300,6 +300,36 @@ def _parse_thickness_mm(value: Any) -> Optional[int]:
return int(digits) if digits else None
def _described_as_retrofit_insulated(description: Optional[str]) -> bool:
"""True only when the description asserts insulation KNOWN to have
been added subsequently i.e. genuine retrofit, not the age-band
as-built assumption.
RdSAP 10 Table 8/9 footnote routes a wall to the 50 mm "insulation
of unknown thickness" row ONLY when insulation is "known to have been
increased subsequently (otherwise 'as built' applies)". A description
rendered as "as built ... insulated (assumed)" is the EPC's age-band
assumption it renders only on recent age bands where as-built
construction already includes insulation (an old band renders "no
insulation (assumed)"). For those the spec uses the as-built age-band
U-value, NOT the 50 mm retrofit row.
Worksheet evidence: simulated case 9 (sandstone, band J, As Built
U 0.35) and case 10 (solid brick, band J, As Built U 0.35); both
Elmhurst worksheets return the as-built row, not the 50 mm bucket
(which gives ~0.25). Genuine retrofit is signalled by
`wall_insulation_type` (External/Internal/Filled), checked
independently by the `wall_ins_present` gate so excluding the
"as built"/"(assumed)" description here loses no real retrofit signal.
"""
if description is None:
return False
if not _described_as_insulated(description):
return False
desc = description.lower()
return "as built" not in desc and "assumed" not in desc
def _joined_descriptions(elements: list[Any]) -> Optional[str]:
if not elements:
return None
@ -311,6 +341,13 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]:
def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
if not part.sap_floor_dimensions:
# A part with no floor dimensions has no derivable RR shell or
# cantilever geometry, but the early return must still expose the
# SAME keys as the full return below: the §3.9 RR block reads
# geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] /
# ["cantilever_floor_area_m2"] for every part, so omitting them
# here raised KeyError on multi-part certs whose first bp lodges
# no sap_floor_dimensions (5 certs in a 2026 API sample).
return {
"ground_floor_area_m2": 0.0,
"top_floor_area_m2": 0.0,
@ -318,6 +355,9 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
"party_wall_area_m2": 0.0,
"rr_floor_area_m2": 0.0,
"rr_simplified_a_rr_m2": 0.0,
"rr_common_wall_area_m2": 0.0,
"rr_gable_area_m2": 0.0,
"cantilever_floor_area_m2": 0.0,
}
fds = list(part.sap_floor_dimensions)
ground = next((fd for fd in fds if fd.floor == 0), fds[0])
@ -450,6 +490,45 @@ def _part_geometry(part: SapBuildingPart) -> dict[str, float]:
}
_RR_ROOF_LODGEMENT_KINDS: Final[frozenset[str]] = frozenset(
{"slope", "flat_ceiling", "stud_wall"}
)
def _bp_rr_roof_absorbs_rooflight(
part: SapBuildingPart, geom: dict[str, Any]
) -> bool:
"""Whether a rooflight on this building part pierces the room-in-roof
sloped ceiling (so it deducts from the RR roof contribution) rather
than a flat external roof.
True ONLY for a Detailed RR (§3.10) lodging wall surfaces but no roof
surfaces (gable / common / connected, no slope / flat_ceiling /
stud_wall): the §3.10.1 residual roof fires and the rooflight deducts
from it (simulated case 6: the 6 "Roof of Room" rooflights deduct from
"Roof room Main remaining" net 55.54 = gross 61.73 6.19).
False otherwise:
- Simplified Type 1/2 RR (geom A_RR > 0, certs 6035 / 0240): the
rooflight pierces the regular loft roof at U_roof, NOT the A_RR
shell its area deducts from `roof_area` (the test
`test_6035_api_room_in_roof_gables_deduct_from_roof` pins this).
- Detailed RR lodging explicit roof surfaces (cert 000565 Ext2 stud
walls / 000516 slopes): the rooflight pierces the regular roof.
Both keep the pre-S0380.203 §3.7 "deduct from the host roof" behaviour.
"""
if geom["rr_simplified_a_rr_m2"] > 0:
return False
rir = part.sap_room_in_roof
if rir is None or not rir.detailed_surfaces:
return False
if float(rir.floor_area) <= 0.0:
return False
return not any(
s.kind in _RR_ROOF_LODGEMENT_KINDS for s in rir.detailed_surfaces
)
def heat_transmission_from_cert(
epc: EpcPropertyData,
*,
@ -626,14 +705,21 @@ def heat_transmission_from_cert(
wall_construction = _int_or_none(part.wall_construction)
wall_ins_type = _int_or_none(part.wall_insulation_type)
wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness)
# Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)"
# or "partial insulation (assumed)" in its description has retrofit
# insulation the assessor hasn't measured the thickness of — even
# when wall_insulation_type=4 ("as-built / assumed"). Treat as
# present so the 50 mm bucket routes correctly.
# RdSAP 10 Table 8/9 footnote: the 50 mm "insulation of unknown
# thickness" row applies only when insulation is "known to have
# been increased subsequently (otherwise 'as built' applies)".
# Genuine retrofit is signalled by `wall_insulation_type`
# (External/Internal/Filled ≠ NONE). An "as built ... insulated
# (assumed)" description is the EPC age-band assumption (it only
# renders on recent bands where as-built already includes
# insulation) → use the as-built age-band row, NOT 50 mm.
# Worksheet-validated by simulated case 9 (sandstone J → 0.35)
# and case 10 (solid brick J → 0.35), both As Built. So the
# description signal is restricted to genuine (non-assumed)
# retrofit via `_described_as_retrofit_insulated`.
wall_ins_present = (
(wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE)
or _described_as_insulated(wall_description)
or _described_as_retrofit_insulated(wall_description)
)
party_construction = _int_or_none(part.party_wall_construction)
raw_roof_thickness = getattr(part, "roof_insulation_thickness", None)
@ -674,6 +760,10 @@ def heat_transmission_from_cert(
# insulation_type combination doesn't match the formula
# path's preconditions.
wall_thickness_mm=part.wall_thickness_mm,
# RdSAP 10 §5.8 — lodged insulation thermal-conductivity
# code feeds the documentary-evidence R-value calc when a
# measured wall thickness is also present (else ignored).
wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity,
)
# When the per-bp `roof_insulation_thickness` is explicitly lodged
# as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling
@ -707,7 +797,21 @@ def heat_transmission_from_cert(
# spec value is 2.30 (A-D) / 1.50 (E) / 0.68 (F) / 0.40 (G).
roof_type_lower = (part.roof_construction_type or "").lower()
is_flat_roof = "flat" in roof_type_lower
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof)
# RdSAP 10 §5.11 Table 18 — a pitched roof whose ceiling follows the
# slope ("Pitched, sloping ceiling" code 8 / "Pitched (vaulted
# ceiling)" code 5) has no loft void, so an unknown-thickness
# lodgement takes the column (3) "Flat roof / sloping ceiling"
# age-band default rather than the §5.11.4 retrofit-50 mm joist row.
is_sloping_ceiling = (
"sloping ceiling" in roof_type_lower or "vaulted" in roof_type_lower
)
# RdSAP 10 Table 18 col (3) routing for an AS-BUILT "Pitched,
# sloping ceiling" (code 8). Narrower than `is_sloping_ceiling`
# (which also covers code-5 vaulted): vaulted ceilings stay on
# col (1) per the cohort, so only the literal "sloping ceiling"
# string triggers the col (3) age-band default in `u_roof`.
is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling)
# Floor U-value routing (in priority order):
# 1. Basement floor — Table 23 F-column override (whole floor=0).
# 2. Exposed/semi-exposed upper floor — Table 20 lookup; no
@ -811,7 +915,23 @@ def heat_transmission_from_cert(
if "sloping ceiling" in roof_type:
top_floor_area = top_floor_area / _COS_30_DEG
gross_roof_area = _round_half_up(top_floor_area, _AREA_ROUND_DP)
roof_area = max(0.0, gross_roof_area - rw_area_part)
# RdSAP 10 §3.7 — a rooflight deducts from the gross roof of the
# element it physically pierces. A "Roof of Room" rooflight sits on
# the room-in-roof sloped ceiling (the §3.9/§3.10 A_RR shell), not a
# flat external roof, so its area deducts from the RR roof
# contribution (simplified A_RR_final or the §3.10.1 detailed
# residual) rather than `roof_area` — but ONLY when the BP's RR
# actually contributes such a shell/residual. Where the BP lodges
# explicit roof surfaces (cert 000565 Ext2 stud walls / 000516
# slopes), the rooflight pierces those (the regular roof) and
# deducts there per §3.7 (current behaviour). Simulated case 6
# worksheet: "Roof room Main remaining area" net 55.54 = gross 61.73
# 6.19 rooflights, while "External roof Main" 14.52 carries no
# opening.
rw_area_on_rr = (
rw_area_part if _bp_rr_roof_absorbs_rooflight(part, geom) else 0.0
)
roof_area = max(0.0, gross_roof_area - (rw_area_part - rw_area_on_rr))
floor_area_total = _round_half_up(
geom["ground_floor_area_m2"] if exposure.has_exposed_floor else 0.0,
_AREA_ROUND_DP,
@ -860,19 +980,28 @@ def heat_transmission_from_cert(
# U_main_wall per spec page 23 ("Common wall U-value is inferred
# from the U-value of the main wall in the building part below";
# gables fall under the same Table 4 rule).
rr_a_rr = geom["rr_simplified_a_rr_m2"]
rr_common = geom["rr_common_wall_area_m2"]
rr_gable = geom["rr_gable_area_m2"]
# `rr_roof_area` is the worksheet's simplified A_RR (the notional
# room-in-roof roof area, RdSAP 10 §3.10.1). Its perimeter common
# walls + gables are billed to walls; the leftover residual is the
# roof-going area that takes the roof U-value.
rr_roof_area = geom["rr_simplified_a_rr_m2"]
rr_common_wall_area = geom["rr_common_wall_area_m2"]
rr_gable_area = geom["rr_gable_area_m2"]
rr_detailed_area = 0.0
if rr_a_rr > 0:
if rr_roof_area > 0:
rir = part.sap_room_in_roof
assert rir is not None # rr_a_rr > 0 ⇒ rir present per _part_geometry
walls += uw * (rr_common + rr_gable)
a_rr_final = max(0.0, rr_a_rr - rr_common - rr_gable)
assert rir is not None # rr_roof_area > 0 ⇒ rir present per _part_geometry
walls += uw * (rr_common_wall_area + rr_gable_area)
# Deduct any "Roof of Room" rooflights piercing the RR shell
# (see `rw_area_on_rr` rationale at the gross-roof block).
rr_residual_roof_area = max(
0.0,
rr_roof_area - rr_common_wall_area - rr_gable_area - rw_area_on_rr,
)
u_rr = u_rr_default_all_elements(
country=country, age_band=rir.construction_age_band,
)
roof += u_rr * a_rr_final
roof += u_rr * rr_residual_roof_area
elif part.sap_room_in_roof is not None and part.sap_room_in_roof.detailed_surfaces:
# RdSAP10 §3.10 Detailed RR — iterate per-surface lodgement.
# Slope / flat_ceiling / stud_wall route to roof (worksheet
@ -885,7 +1014,8 @@ def heat_transmission_from_cert(
# band". Wall-going RIR surfaces (gable_wall, gable_wall_
# external, common_wall) deduct from the simplified A_RR
# to leave the residual area, mirroring the Simplified
# branch's `a_rr_final = rr_a_rr - rr_common - rr_gable`.
# branch's `rr_residual_roof_area = rr_roof_area -
# rr_common_wall_area - rr_gable_area`.
# Roof-going surfaces (slope / flat_ceiling / stud_wall)
# do NOT deduct — they sit inside the RR shell rather than
# forming its perimeter walls.
@ -1003,7 +1133,13 @@ def heat_transmission_from_cert(
a_rr_shell = _round_half_up(
12.5 * sqrt(rr_floor_for_a_rr / 1.5), _AREA_ROUND_DP,
)
residual_area = max(0.0, a_rr_shell - rr_walls_in_a_rr_area)
# Deduct any "Roof of Room" rooflights piercing the RR
# residual (see `rw_area_on_rr` rationale at the gross-roof
# block) — case 6: 93.09 shell 31.36 gables 6.19
# rooflights = 55.54 net = worksheet "Roof room remaining".
residual_area = max(
0.0, a_rr_shell - rr_walls_in_a_rr_area - rw_area_on_rr
)
if residual_area > 0.0:
rr_detailed_area += residual_area
roof += residual_area * u_rr_default_all_elements(
@ -1051,7 +1187,7 @@ def heat_transmission_from_cert(
main_wall_area
+ (alt_walls_total_area - alt_window_area)
+ roof_area + floor_area_total
+ w_area + d_area + rw_area_part + rr_a_rr + rr_detailed_area
+ w_area + d_area + rw_area_part + rr_roof_area + rr_detailed_area
+ cantilever_area
)
total_external_area += part_external_area
@ -1109,7 +1245,7 @@ def _alt_wall_w_per_k(
alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness)
alt_insulation_present = (
alt_wall.wall_insulation_type != _WALL_INSULATION_NONE
or _described_as_insulated(wall_description)
or _described_as_retrofit_insulated(wall_description)
)
alt_u = u_wall(
country=country,

View file

@ -27,9 +27,13 @@ from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from math import cos, exp, pi
from typing import Final
from typing import Final, Optional
from datatypes.epc.domain.epc_property_data import EpcPropertyData, SapWindow
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
SapWindow,
)
def _decimal_window_area_2dp(width: float, height: float) -> float:
@ -634,15 +638,15 @@ def _daylight_factor_from_cert(
return 52.2 * g_l * g_l - 9.94 * g_l + 1.433
def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
"""Map first main-heating detail's central_heating_pump_age_str to a
def _pump_date_category_for_detail(
detail: Optional[MainHeatingDetail],
) -> PumpDateCategory:
"""Map a `MainHeatingDetail`'s central_heating_pump_age_str to a
Table 5a bucket. Elmhurst lodges "Pre 2013" / "Post 2013" / "Unknown"
/ None on each `MainHeatingDetail` (nested under `epc.sap_heating`)."""
sap_heating = getattr(epc, "sap_heating", None)
details = getattr(sap_heating, "main_heating_details", None) or []
/ None on each detail."""
age_str = ""
if details:
age_str = (details[0].central_heating_pump_age_str or "").lower()
if detail is not None:
age_str = (detail.central_heating_pump_age_str or "").lower()
if "post" in age_str or "2013 or later" in age_str:
return PumpDateCategory.NEW_2013_OR_LATER
if "pre" in age_str or "2012" in age_str:
@ -650,6 +654,14 @@ def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
return PumpDateCategory.UNKNOWN
def _pump_date_category_from_cert(epc: EpcPropertyData) -> PumpDateCategory:
"""Table 5a date bucket for Main 1 (the dwelling's first circulation
pump). Delegates to `_pump_date_category_for_detail`."""
sap_heating = getattr(epc, "sap_heating", None)
details = getattr(sap_heating, "main_heating_details", None) or []
return _pump_date_category_for_detail(details[0] if details else None)
# SAP 10.2 Table 5a Note a) (PDF p.177): "Not applicable for electric
# heat pumps from database." The pump GAIN (worksheet line 70) is
# omitted only for HP-category systems. Where the cert lodges a
@ -730,33 +742,69 @@ def _any_main_system_has_central_heating_pump(epc: EpcPropertyData) -> bool:
details = epc.sap_heating.main_heating_details
if not details:
return False
for d in details:
if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY:
# PCDB Table 362 record → pump electricity AND gain are
# embedded in COP (Appendix N1.2.1); no separate gain row.
if d.main_heating_index_number is not None:
continue
# Cat 5 warm-air HP (codes 521/523-527) → no water pump.
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
continue
# Cat 4 HP, Table 4a default cascade → apply Table 5a
# pump gain per Appendix N3.1.
return True
code = d.sap_main_heating_code
if code is not None and any(
code in r for r in _WET_BOILER_SAP_CODE_RANGES
):
return True
return any(_main_detail_has_central_heating_pump(d) for d in details)
def _main_detail_has_central_heating_pump(d: MainHeatingDetail) -> bool:
"""Whether a single `MainHeatingDetail` carries a Table 5a central-
heating-pump gain the per-detail core of
`_any_main_system_has_central_heating_pump` (see that docstring for
the wet-main identification + HP rules)."""
if d.main_heating_category == _HEAT_PUMP_MAIN_HEATING_CATEGORY:
# PCDB Table 362 record → pump electricity AND gain are
# embedded in COP (Appendix N1.2.1); no separate gain row.
if d.main_heating_index_number is not None:
return True
if d.main_heating_category in {1, 2}:
return True
if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES:
return True
return False
# Cat 5 warm-air HP (codes 521/523-527) → no water pump.
code = d.sap_main_heating_code
if code is not None and code in _TABLE_4A_WARM_AIR_SAP_CODES:
return False
# Cat 4 HP, Table 4a default cascade → apply Table 5a
# pump gain per Appendix N3.1.
return True
code = d.sap_main_heating_code
if code is not None and any(code in r for r in _WET_BOILER_SAP_CODE_RANGES):
return True
if d.main_heating_index_number is not None:
return True
if d.main_heating_category in {1, 2}:
return True
if d.heat_emitter_type in _WET_HEAT_EMITTER_TYPES:
return True
return False
def _second_main_central_heating_pump_gain_w(epc: EpcPropertyData) -> float:
"""SAP 10.2 Table 5a note a) (PDF p.177): "Where there are two main
heating systems serving different parts of the dwelling, assume each
has its own circulation pump and therefore include two figures from
this table. ... Where two main systems serve the same space a single
pump is assumed."
Returns the SECOND main system's central-heating-pump gain (W,
heating-season) when a genuine second SPACE-heating main is lodged
detected by `main_heating_fraction > 0`, the same gate
`cert_to_inputs` uses to split §9a space-heating demand and to add
the Table 4f note c) second circulation pump (S0380.201). Excludes
DHW-only second mains (fraction 0, e.g. cert 000565 Main 2 combi via
WHC 914). The gain uses the SECOND main's own pump-age bucket — for
simulated case 6 (dual oil, Main 2 unknown date) that is 7 W, giving
worksheet (70) = 3 (Main 1) + 7 (Main 2) = 10.
"""
details = epc.sap_heating.main_heating_details
if len(details) < 2:
return 0.0
second = details[1]
fraction = second.main_heating_fraction
if fraction is None or fraction <= 0:
return 0.0
if not _main_detail_has_central_heating_pump(second):
return 0.0
return central_heating_pump_w(
date_category=_pump_date_category_for_detail(second)
)
# SAP 10.2 Table 4a (PDF p.165-166) warm-air heating SAP codes. The
# Table 5a "Warm air heating system fans" gain (and Table 4f
# electricity row) fire for these mains:
@ -881,6 +929,11 @@ def internal_gains_from_cert(
pump_w = central_heating_pump_w(
date_category=_pump_date_category_from_cert(epc)
)
# SAP 10.2 Table 5a note a) — a second main heating system serving
# a different part of the dwelling has its own circulation pump
# (two figures from the table). Simulated case 6 (dual oil, rads +
# underfloor) → Main 1 3 W + Main 2 7 W = worksheet (70) 10 W.
pump_w += _second_main_central_heating_pump_gain_w(epc)
else:
pump_w = 0.0
# SAP 10.2 Table 5a row "Warm air heating system fans a) c)" (PDF

View file

@ -321,6 +321,9 @@ def mean_internal_temperature_monthly(
control_temperature_adjustment_c: float = 0.0,
secondary_fraction: float = 0.0,
secondary_responsiveness: float = 1.0,
main_2_control_type: Optional[int] = None,
main_2_fraction: float = 0.0,
main_2_responsiveness: float = 1.0,
extended_heating_days_per_month: Optional[tuple[tuple[int, int], ...]] = None,
) -> MeanInternalTemperatureResult:
"""SAP 10.2 §7 orchestrator — chain Table 9c steps 19 for all 12 months.
@ -354,13 +357,36 @@ def mean_internal_temperature_monthly(
standard SAP heating schedule applies: T_zone
= T_bimodal directly.
"""
# SAP 10.2 Table 9b (PDF p.183) — "where there are two main systems R
# is a weighted average ... R = (203)·R_system2 + [1 (203)]·R_system1".
# (203) = `main_2_fraction`. Applied before the secondary-heating blend.
main_responsiveness = responsiveness
if main_2_control_type is not None and main_2_fraction > 0.0:
main_responsiveness = (
(1.0 - main_2_fraction) * responsiveness
+ main_2_fraction * main_2_responsiveness
)
effective_responsiveness = (
(1.0 - secondary_fraction) * responsiveness
(1.0 - secondary_fraction) * main_responsiveness
+ secondary_fraction * secondary_responsiveness
)
elsewhere_off_hours = (
_ELSEWHERE_OFF_HOURS_TYPE_3 if control_type == 3 else _ELSEWHERE_OFF_HOURS_TYPE_12
)
# SAP 10.2 p.186 "two systems heat different parts of the house": when
# the two mains carry different controls, the rest-of-dwelling (90)m is
# the weighted average of T2 computed under EACH system's control. The
# elsewhere off-hours for main system 2's control:
two_main_different_parts = (
main_2_control_type is not None
and main_2_fraction > 0.0
and main_2_control_type != control_type
)
elsewhere_off_hours_main_2 = (
_ELSEWHERE_OFF_HOURS_TYPE_3
if main_2_control_type == 3
else _ELSEWHERE_OFF_HOURS_TYPE_12
)
eta_living: list[float] = []
t_1: list[float] = []
@ -408,6 +434,34 @@ def mean_internal_temperature_monthly(
)
eta_elsewhere.append(eta_e)
# SAP 10.2 p.186 part 2 — two systems heat different parts: blend
# the rest-of-dwelling temperature computed under each system's
# control. Th2 + η are identical for control types 2/3 (Table 9
# uses the same Th2 formula); only the off-hours differ, so the
# second computation reuses t_h2_m and shares η. Weights:
# sys2 control: (203) / [1 (91)]
# sys1 control: [1 (203) (91)] / [1 (91)]
# If (203) ≥ rest-of-house area [1 (91)], use sys2's control
# alone for elsewhere (per the spec's threshold clause).
if two_main_different_parts:
rest_of_house = 1.0 - living_area_fraction
_, t_e_main_2 = _zone_mean_temp_with_per_zone_eta(
heating_temperature_c=t_h2_m,
off_hours_first=elsewhere_off_hours_main_2[0],
off_hours_second=elsewhere_off_hours_main_2[1],
external_temp_c=ext, responsiveness=effective_responsiveness,
total_gains_w=gains, heat_transfer_coefficient_w_per_k=h,
time_constant_h=tau,
)
if rest_of_house <= 0.0 or main_2_fraction >= rest_of_house:
t_e_bimodal = t_e_main_2
else:
w_main_2 = main_2_fraction / rest_of_house
w_main_1 = (
rest_of_house - main_2_fraction
) / rest_of_house
t_e_bimodal = w_main_1 * t_e_bimodal + w_main_2 * t_e_main_2
# SAP 10.2 Appendix N3.5 Equation N5 — when the caller provides
# per-month (N24,9, N16,9) day allocations, blend Th / T_unimodal
# / T_bimodal for each zone. T_unimodal applies one 8-hour off

View file

@ -52,14 +52,11 @@ def _described_as_insulated(description: Optional[str]) -> bool:
otherwise. Looks for "insulated" or "partial insulation" substrings,
with "no insulation" taking precedence as a hard negation.
Two consumers:
- `u_wall` uses this to route cavity walls to the Filled-cavity row
of Table 6 (in lieu of the bucketed cascade).
- `heat_transmission_from_cert` uses this to set `wall_ins_present`
for non-cavity walls so the 50 mm bucket routing fires per the
RdSAP 10 Table 6 footnote ("If a wall is known to have additional
insulation but the insulation thickness is unknown, use the row
in the table for 50 mm insulation").
Consumer: `u_wall` uses this to route cavity walls to the Filled-
cavity row of Table 6 (in lieu of the bucketed cascade). For the
non-cavity `wall_ins_present` gate, `heat_transmission_from_cert`
further restricts this to genuine (non-assumed) retrofit via its
local `_described_as_retrofit_insulated`.
"""
if description is None:
return False
@ -69,6 +66,41 @@ def _described_as_insulated(description: Optional[str]) -> bool:
return "insulated" in desc or "partial insulation" in desc
def _cavity_described_as_filled(description: Optional[str]) -> bool:
"""True when an as-built cavity wall's description asserts the cavity is
insulated/filled, routing it to the Table 6 "Filled cavity" row.
Distinguishes the three as-built cavity states the EPC renders by age
band when wall_insulation_type=4 ("as-built / assumed"):
- "...insulated (assumed)" Filled cavity (assessor judges
the cavity filled but lodges no
thickness)
- "...partial insulation (assumed)" "Cavity as built" row (the
as-built partial fill of the age
band, NOT a retrofit cavity fill)
- "...no insulation (assumed)" "Cavity as built" row
Narrower than `_described_as_insulated`: it excludes the "partial
insulation" substring so a "partial insulation (assumed)" cavity stays on
the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F =
1.0 vs "Filled cavity" band F = 0.40 for an as-built band-F cavity the
filled row understates heat loss by 2.5x. A genuine retrofit fill is
lodged distinctly as "Cavity wall, filled cavity"
(wall_insulation_type=2), handled by the explicit-code branch.
Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity
type 4, "partial insulation (assumed)") closes all four SAP metrics on
the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/.
"""
if description is None:
return False
desc = description.lower()
if "no insulation" in desc:
return False
return "insulated" in desc
# ---------------------------------------------------------------------------
# Country
# ---------------------------------------------------------------------------
@ -145,6 +177,43 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7
# (cavity + external/internal insulation).
_WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04
# RdSAP 10 §5.8 (page 41) — when documentary evidence lodges the insulation
# thermal conductivity, the R-value calc uses it instead of the 0.04 default.
# The spec offers three λ: 0.04 (mineral wool / EPS, the default), 0.03 (XPS),
# 0.025 (PUR / PIR / phenolic). The GOV.UK API surfaces a coded value
# (`wall_insulation_thermal_conductivity`); code 1 = the default 0.04 (the
# only code observed — cert 2130 Ext1, whose documentary-evidence path does
# not fire as no wall thickness is lodged, so the value is captured but
# unused there). Other codes raise until a worksheet-backed fixture confirms
# their λ — the same incremental-coverage discipline as the glazing-type map.
_WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA: Final[dict[int, float]] = {
1: 0.04,
}
def _resolve_wall_insulation_lambda_w_per_mk(
conductivity: "str | int | None",
) -> float:
"""Resolve the insulation λ (W/m·K) for the §5.8 documentary-evidence
R-value calc. Absent / "Unknown" the 0.04 default; a mapped integer
code its λ; an unmapped integer code raises so the enum is confirmed
against a worksheet rather than silently mis-factored."""
if conductivity is None:
return _WALL_INSULATION_LAMBDA_W_PER_MK
if isinstance(conductivity, str):
text = conductivity.strip()
if not text or text.lower() == "unknown" or not text.isdigit():
return _WALL_INSULATION_LAMBDA_W_PER_MK
conductivity = int(text)
lam = _WALL_INSULATION_CONDUCTIVITY_CODE_TO_LAMBDA.get(conductivity)
if lam is None:
raise ValueError(
"unmapped wall_insulation_thermal_conductivity code "
f"{conductivity!r}; add its RdSAP 10 §5.8 λ "
"(0.04 / 0.03 / 0.025 W/m·K) once a worksheet confirms it"
)
return lam
# RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including
# laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to
# the base U-value of an otherwise-uninsulated wall when the cert lodges
@ -457,6 +526,7 @@ def u_wall(
dry_lined: bool = False,
curtain_wall_age: Optional[str] = None,
wall_thickness_mm: Optional[int] = None,
wall_insulation_thermal_conductivity: "str | int | None" = None,
) -> float:
"""RdSAP10 wall U-value in W/m^2K, never null.
@ -569,7 +639,10 @@ def u_wall(
):
u0 = _u_brick_thin_wall_age_a_to_e(wall_thickness_mm)
r_ins = _r_insulation_table_14(
insulation_thickness_mm, _WALL_INSULATION_LAMBDA_W_PER_MK,
insulation_thickness_mm,
_resolve_wall_insulation_lambda_w_per_mk(
wall_insulation_thermal_conductivity
),
)
u_unrounded = 1.0 / (1.0 / u0 + r_ins)
return float(
@ -591,7 +664,9 @@ def u_wall(
# for column alignment). Cascade-internal HLC then uses the
# rounded U so net wall HLC matches `A × U_2dp` exactly.
u_filled = _CAVITY_FILLED_ENG[age_idx]
r_ins = (insulation_thickness_mm / 1000.0) / _WALL_INSULATION_LAMBDA_W_PER_MK
r_ins = (insulation_thickness_mm / 1000.0) / _resolve_wall_insulation_lambda_w_per_mk(
wall_insulation_thermal_conductivity
)
u_unrounded = 1.0 / (1.0 / u_filled + r_ins)
# Half-up 2-d.p. round so 0.2545 → 0.25, matching the dr87
# worksheet's column-display behaviour (used downstream in A×U).
@ -600,7 +675,7 @@ def u_wall(
)
if wall_type == WALL_CAVITY and (
wall_insulation_type == WALL_INSULATION_FILLED_CAVITY
or _described_as_insulated(description)
or _cavity_described_as_filled(description)
):
return _CAVITY_FILLED_ENG[age_idx]
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)
@ -688,6 +763,8 @@ def u_roof(
insulation_thickness_mm: Optional[int],
description: Optional[str] = None,
is_flat_roof: bool = False,
is_sloping_ceiling: bool = False,
is_pitched_sloping_ceiling: bool = False,
) -> float:
"""RdSAP10 roof U-value in W/m^2K, never null.
@ -701,6 +778,29 @@ def u_roof(
3. Table 18 age-band default column (1) "Pitched, insulation between
joists" by default; column (3) "Flat roof" when `is_flat_roof=True`.
Spec §5.11 Table 18 page 45.
`is_sloping_ceiling` flags a pitched roof whose ceiling follows the
slope (a "Pitched, sloping ceiling" or "Pitched (vaulted ceiling)"
construction RdSAP roof_construction codes 8 and 5). Such a roof has
no loft / ceiling-joist void, so an "NI" lodgement (parsed to 0) +
"insulated (assumed)" description means unknown-thickness-with-insulation,
NOT the §5.11.4 retrofit-50 mm joist row (0.68) a normal pitched-with-
loft roof would take. It instead takes the Table 18 column (1) age-band
default (band J = 0.16) the same value a vaulted roof lodged "ND"
(thickness None) already reaches by falling through. The 33 cohort-2
"ND" vaulted certs (code 5, band D 0.40 = col 1) are the evidence.
`is_pitched_sloping_ceiling` is the narrower code-8 ("Pitched, sloping
ceiling") signal for the AS-BUILT case (insulation lodged "As Built",
parsed to thickness None distinct from the "NI"/"ND" unknown case
above). Per RdSAP 10 roof-input item 5-5 ("Sloping ceiling insulation
... as built Table 18") and Table 18 note (b) ("applies also to roof
with sloping ceiling"), an as-built sloping ceiling takes the column
(3) age-band default (band F = 0.68, band L = 0.18), NOT the column (1)
loft-joist default (band F = 0.40, band L = 0.16). Vaulted ceilings
(code 5) are deliberately excluded they stay on column (1) per the
cohort evidence above. Worksheet-validated by simulated case 15 (the
7536 replica): Ext1 band L 0.18, Ext2 band F 0.68.
"""
measured = _measured_u_from_description(description)
if measured is not None:
@ -708,6 +808,20 @@ def u_roof(
# ("Average thermal transmittance X W/m²K"); spec §5.11 opening
# clause defers to the assessor's value when present.
return measured
if (
is_sloping_ceiling
and age_band is not None
and insulation_thickness_mm == 0
and _described_as_insulated(description)
):
# RdSAP 10 §5.11 Table 18 page 45 — a vaulted/sloping ceiling has no
# ceiling-joist void, so the "NI" sentinel (parsed to 0) +
# "insulated (assumed)" is unknown-thickness-with-insulation, not
# 0 mm uninsulated. It must NOT fall to the §5.11.4 retrofit-50 mm
# joist row (0.68) below; it takes the column (1) age-band default
# (band J = 0.16), matching the cohort's "ND" (thickness None)
# vaulted roofs which already reach col (1) by falling through.
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)
if insulation_thickness_mm == 0 and _described_as_insulated(description):
# Spec §5.11.4 (page 44 footnote): "If retrofit insulation
# present of unknown thickness use 50 mm". The cert encodes
@ -731,6 +845,15 @@ def u_roof(
return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row)
if age_band is None:
return 0.4
if is_pitched_sloping_ceiling:
# RdSAP 10 §5.11 Table 18 page 45 column (3) + roof-input item 5-5:
# an as-built "Pitched, sloping ceiling" (code 8) with no measured
# thickness takes the column (3) age-band default, not the column
# (1) loft-joist default. Note (b): column (3) "applies also to
# roof with sloping ceiling". (Pre-1950 bands reach the same value
# via the mapper's thickness=0 → Table 16 row-0 2.30 override, so
# this branch carries the post-1950 bands where col 1 ≠ col 3.)
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
if is_flat_roof:
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)

View file

@ -165,18 +165,28 @@ def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_ro
assert result == pytest.approx(1.5, abs=0.001)
def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row() -> None:
# Arrange — 147 corpus certs lodge "Cavity wall, as built, partial
# insulation (assumed)" with wall_insulation_type=4. The legacy
# production map (recommendations/rdsap_tables.py:753) routes these
# to "Filled cavity" — same destination as the "insulated (assumed)"
# case. We match that interpretation for parity with the cert
# assessor and the production recommendation engine.
def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> None:
# Arrange — a cavity lodged "Cavity wall, as built, partial insulation
# (assumed)" with wall_insulation_type=4 is in its AS-BUILT state (the
# partial fill of the age band), NOT a retrofit cavity fill. Per
# RdSAP 10 Table 6 (England) it uses the "Cavity as built" row, not
# "Filled cavity": band D = 1.5 (as built) vs 0.7 (filled). A genuine
# fill lodges the distinct "Cavity wall, filled cavity"
# (wall_insulation_type=2), caught by the explicit-code branch.
#
# Slice S0380.210 corrected this: the prior routing to "Filled cavity"
# mirrored a legacy production map, but golden cert 0390-2954-3640
# (band F, cavity type 4, "partial insulation (assumed)") closes all
# four SAP metrics on the as-built row (band F = 1.0) and under-counts
# PE by ~28 kWh/m² on the filled row — the legacy parity was a latent
# bug at bands A-H (bands I-M coincide per the Table 6 † footnote).
# The "insulated (assumed)" variant still routes to filled (see the
# heat_transmission `_cavity_described_as_filled` sibling test).
# Act
result = u_wall(
country=Country.ENG,
age_band="D", # 1950-1966 — typical partial-fill retrofit cohort
age_band="D", # 1950-1966 — as-built ≠ filled at this band
construction=WALL_CAVITY,
insulation_thickness_mm=None,
insulation_present=False,
@ -184,8 +194,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_filled_cavity_row()
description="Cavity wall, as built, partial insulation (assumed)",
)
# Assert — Filled-cavity row at band D = 0.7 W/m²K.
assert result == pytest.approx(0.7, abs=0.001)
# Assert — Cavity-as-built row at band D = 1.5 W/m²K (not filled 0.7).
assert abs(result - 1.5) <= 0.001
def test_u_wall_description_without_transmittance_phrase_routes_through_cascade() -> None:
@ -841,6 +851,54 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
assert result == pytest.approx(0.4, abs=0.001)
def test_u_roof_vaulted_ni_unknown_band_j_uses_col1_age_band_not_50mm() -> None:
# Arrange — a pitched roof with a vaulted/sloping ceiling (no joist
# void) lodged with insulation thickness "NI" (Not Indicated, parsed
# to 0) + an "insulated (assumed)" description. For a NORMAL pitched
# roof this hits the §5.11.4 "retrofit 50 mm" override (U=0.68, the
# Table 16 joist row) — but a vaulted/sloping ceiling has no joist
# void, so RdSAP 10 Table 18 routes it to the column (1) age-band
# default: band J = 0.16 W/m²K (NOT 0.68). This is the same value a
# vaulted roof lodged "ND" (thickness None) already reaches by falling
# through to the age-band default.
#
# Cohort-validated: 33 cohort-2 certs lodge "ND" vaulted roofs
# (roof_construction=5, band D) that pin to worksheet U=0.40 = col (1).
# Closes golden cert 0240's Ext1 vaulted roof (code 5, NI, band J)
# which the cascade returned at 0.68 (offsetting the wall under-count
# fixed in S0380.209).
# Act
result = u_roof(
country=Country.ENG,
age_band="J",
insulation_thickness_mm=0, # parsed from "NI"
description="Pitched, insulated (assumed)",
is_sloping_ceiling=True,
)
# Assert
assert abs(result - 0.16) <= 1e-4
def test_u_roof_normal_pitched_ni_insulated_still_returns_50mm_per_5_11_4() -> None:
# Arrange — regression guard: the is_sloping_ceiling flag defaults
# False, so a NORMAL pitched roof (with loft) lodged NI + "insulated
# (assumed)" must STILL hit the §5.11.4 retrofit-50 mm row (U=0.68).
# Same inputs as the sloping test above minus is_sloping_ceiling.
# Act
result = u_roof(
country=Country.ENG,
age_band="J",
insulation_thickness_mm=0,
description="Pitched, insulated (assumed)",
)
# Assert
assert abs(result - 0.68) <= 1e-4
def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None:
# Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof":
# age band D, thickness unknown → U = 2.30 W/m²K. Column (1)
@ -887,6 +945,66 @@ def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None:
assert abs(result - 0.18) <= 1e-4
def test_u_roof_pitched_sloping_ceiling_as_built_band_f_uses_col3() -> None:
# Arrange — RdSAP 10 §5.11 Table 18 page 45 + roof-input item 5-5
# ("Sloping ceiling insulation ... unknown / as built → Table 18").
# A "Pitched, sloping ceiling" roof (roof_construction code 8) with an
# "As Built" insulation lodgement (no measured thickness → None) takes
# the Table 18 column (3) age-band default, NOT the column (1)
# "insulation between joists" default. Note (b) on column (3) states it
# "applies also to roof with sloping ceiling". For age band F the
# column (3) value is 0.68 W/m²K (vs column (1) 0.40 — the loft-joist
# assumption that is wrong for a sloping ceiling with no joist void).
#
# Worksheet-validated: simulated case 15 (7536 replica) lodges Ext2 as
# band F "PS Pitched, sloping ceiling, As Built"; its P960 worksheet
# pins `External roof Ext2 … 0.68`, and the full-cascade roof HLC and
# SAP match Elmhurst exactly only with column (3).
# Act
result = u_roof(
country=Country.ENG, age_band="F", insulation_thickness_mm=None,
is_pitched_sloping_ceiling=True,
)
# Assert
assert abs(result - 0.68) <= 1e-4
def test_u_roof_pitched_sloping_ceiling_as_built_band_l_uses_col3() -> None:
# Arrange — same rule at band L (2012-2022): Table 18 column (3) gives
# 0.18 W/m²K, where columns (2)/(3) coincide. Simulated case 15's Ext1
# (band L PS sloping ceiling, As Built) pins worksheet U=0.18 (vs the
# column (1) value 0.16 the cascade returned pre-fix).
# Act
result = u_roof(
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
is_pitched_sloping_ceiling=True,
)
# Assert
assert abs(result - 0.18) <= 1e-4
def test_u_roof_vaulted_nd_unknown_band_d_still_col1_not_col3() -> None:
# Arrange — regression guard for the discriminator: a code-5 "vaulted"
# roof lodged "ND" (thickness None) is the UNKNOWN-insulation case and
# must stay on Table 18 column (1) — band D = 0.40 — per the 33
# cohort-2 vaulted certs (S0380.211). The col (3) routing fires only
# for code-8 "Pitched, sloping ceiling" (is_pitched_sloping_ceiling),
# NOT for vaulted ceilings, so this defaults False here and resolves
# to column (1) 0.40, NOT column (3) 2.30.
# Act
result = u_roof(
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
)
# Assert
assert abs(result - 0.40) <= 1e-4
def test_u_roof_description_no_insulation_overrides_age_band_default() -> None:
# Arrange — surveyor description on a Victorian roof says uninsulated;
# Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm
@ -1752,3 +1870,59 @@ def test_u_floor_matches_section_5_12_formula_for_cohort_geometry(
# Assert
assert abs(u - expected_u) < 1e-4
def test_resolve_wall_insulation_lambda_absent_uses_default() -> None:
# Arrange — no lodged conductivity → RdSAP 10 §5.8 default 0.04 W/m·K.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam = _resolve_wall_insulation_lambda_w_per_mk(None)
# Assert
assert abs(lam - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_unknown_string_uses_default() -> None:
# Arrange — a non-numeric "Unknown" lodgement defers to the default.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam = _resolve_wall_insulation_lambda_w_per_mk("Unknown")
# Assert
assert abs(lam - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_code_1_is_default_mineral_wool() -> None:
# Arrange — code 1 = the §5.8 default λ=0.04 (mineral wool / EPS);
# cert 2130 Ext1 lodges this. Numeric-string form resolves identically.
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act
lam_int = _resolve_wall_insulation_lambda_w_per_mk(1)
lam_str = _resolve_wall_insulation_lambda_w_per_mk("1")
# Assert
assert abs(lam_int - 0.04) <= 1e-9
assert abs(lam_str - 0.04) <= 1e-9
def test_resolve_wall_insulation_lambda_unmapped_code_raises() -> None:
# Arrange — an unmapped code must raise (incremental-coverage gate)
# rather than silently mis-factor the R-value.
import pytest as _pytest
from domain.sap10_ml.rdsap_uvalues import (
_resolve_wall_insulation_lambda_w_per_mk,
)
# Act / Assert
with _pytest.raises(ValueError):
_resolve_wall_insulation_lambda_w_per_mk(2)

View file

@ -549,7 +549,15 @@ class EpcBuildingPartModel(SQLModel, table=True):
building_part_number: Optional[int] = Field(default=None)
wall_dry_lined: Optional[bool] = Field(default=None)
wall_thickness_mm: Optional[int] = Field(default=None)
wall_insulation_thickness: Optional[str] = Field(default=None)
# Union[str, int] — int mm when the API lodges
# `wall_insulation_thickness == "measured"` (resolved by
# `_api_resolve_wall_insulation_thickness`), else the lodged string
# ("NI", a numeric string, ...). JSONB to preserve int vs str on
# round-trip, exactly like the sibling `roof_insulation_thickness` /
# `flat_roof_insulation_thickness`.
wall_insulation_thickness: Optional[Union[str, int]] = Field(
default=None, sa_column=Column(JSONB, nullable=True)
)
floor_heat_loss: Optional[int] = Field(default=None)
floor_insulation_thickness: Optional[str] = Field(default=None)
flat_roof_insulation_thickness: Optional[Union[str, int]] = Field(

View file

@ -0,0 +1,102 @@
"""Group API-path SAP error by property + heating type to find clusters.
WHAT THIS IS FOR
----------------
The headline number from `eval_api_sap_accuracy.py` tells you HOW accurate the
API path is; this tells you WHERE the error lives so you can prioritise. It
buckets the cached sample's per-cert SAP error (continuous vs lodged) by:
- property type (house / flat / bungalow / maisonette / park home),
- real PV presence,
- heating identity (main_heating_category + whether a PCDB index is lodged),
and prints n / mean|err| / %<0.5 per group, plus red flags (negative or
extreme-low SAP). The load-bearing cut is heating: e.g. electric storage
heaters (cat 7) and room heaters (cat 10) are the worst clusters, which points
the next worksheet-backed fix at those systems.
USAGE
-----
PYTHONPATH=/workspaces/model python scripts/analyse_api_sap_clusters.py
Reads the cache written by `fetch_2026_epc_sample.py` (default
`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`).
"""
import os
import json
import math
from collections import defaultdict
from pathlib import Path
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample"))
PROP = {"0": "House", "1": "Bungalow", "2": "Flat", "3": "Maisonette", "4": "Park home"}
def real_pv(doc):
"""True only for a genuine PV array — `none_or_no_details` / 0% is not PV."""
es = doc.get("sap_energy_source", {}) or {}
pv = es.get("photovoltaic_supply")
if not isinstance(pv, dict):
return False
if set(pv.keys()) <= {"none_or_no_details"}:
nod = pv.get("none_or_no_details") or {}
return bool(nod.get("percent_roof_area"))
return True
def heat_identity(doc):
h = doc.get("sap_heating", {}) or {}
mh = (h.get("main_heating_details") or [{}])
m0 = mh[0] if mh else {}
return m0.get("main_heating_index_number"), m0.get("main_heating_category")
def main():
rows = []
for f in sorted(CACHE.glob("????-????-????-????-????.json")):
doc = json.loads(f.read_text())
lodged = doc.get("energy_rating_current")
try:
epc = EpcPropertyDataMapper.from_api_response(doc)
cont = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
).sap_score_continuous
except Exception:
continue
if lodged is None or not math.isfinite(cont):
continue
idx, cat = heat_identity(doc)
rows.append(dict(
cert=f.stem, ae=abs(cont - lodged), cont=cont, lodged=lodged,
prop=PROP.get(str(doc.get("property_type")), str(doc.get("property_type"))),
pv=real_pv(doc), idx=idx, cat=cat,
neg=(cont < 0), low_lodged=(lodged <= 20),
))
n = len(rows)
def grp(keyfn, label):
g = defaultdict(list)
for r in rows:
g[keyfn(r)].append(r["ae"])
print(f"\n-- mean|err| by {label} (n, mean|err|, %<0.5) --")
for k, v in sorted(g.items(), key=lambda kv: -sum(kv[1]) / len(kv[1])):
if len(v) < 5:
continue
p = 100 * sum(1 for x in v if x < 0.5) / len(v)
print(f" {str(k):28s} n={len(v):4d} mean={sum(v) / len(v):6.2f} <0.5={p:4.1f}%")
print(f"computed n={n}")
grp(lambda r: r["prop"], "property type")
grp(lambda r: "PV" if r["pv"] else "no-PV", "real PV presence")
grp(lambda r: f"cat={r['cat']},idx={'Y' if r['idx'] else '-'}", "heating identity")
neg = [r for r in rows if r["neg"]]
loww = [r for r in rows if r["low_lodged"]]
print(f"\nRED FLAGS: negative continuous SAP: {len(neg)} | lodged<=20 (extreme): {len(loww)}")
print(" negative-SAP certs:", [r["cert"] for r in neg][:15])
if __name__ == "__main__":
main()

View file

@ -0,0 +1,169 @@
"""Score the SAP10 calculator's API path against a cached EPC sample.
WHAT THIS IS FOR
----------------
Measures how well the API front-end (`from_api_response` `cert_to_inputs`
continuous SAP) reproduces each cert's lodged rounded SAP
(`energy_rating_current`) across the sample built by
`fetch_2026_epc_sample.py`. This is the headline accuracy gauge for raw-API
behaviour on an unbiased population.
Each cert lands in one bucket:
- computed ran end-to-end; SAP error recorded.
- unsupported_schema pre-21 schema the mapper doesn't support (skip).
- raise:<Exc> mapper raised (UnmappedApiCode etc.) a gap to fix.
- calc_raise:<Exc> calculator raised (UnmappedSapCode etc.) a gap.
OUTPUT
------
- Category counts + the raise breakdown with example certs (what to fix).
- For computed certs: % within 0.5 / 1 / 2 / 5 SAP, median/mean/p90/p99/max
|err|, the signed mean (over- vs under-rating), abs-err histogram.
- The 40 worst offenders with diagnostic columns (to prioritise).
- A full per-cert CSV at <cache>/_results.csv for ad-hoc slicing.
USAGE
-----
PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py
Reads the cache written by `fetch_2026_epc_sample.py` (default
`/tmp/epc_2026_sample`, overridable via `EPC_SAMPLE_CACHE`).
"""
import os
import json
import csv
import math
from collections import Counter, defaultdict
from pathlib import Path
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample"))
def diag(doc):
"""A few raw-JSON fields that help explain a cert's error at a glance."""
es = doc.get("sap_energy_source", {}) or {}
h = doc.get("sap_heating", {}) or {}
mh = (h.get("main_heating_details") or [{}])
mh0 = mh[0] if mh else {}
pv = es.get("photovoltaic_supply")
return {
"schema": doc.get("schema_type"),
"prop_type": doc.get("property_type"),
"built_form": doc.get("built_form"),
"age_band": doc.get("construction_age_band"),
"mains_gas": es.get("mains_gas"),
"main_heat_cat": mh0.get("main_heating_category"),
"main_heat_idx": mh0.get("main_heating_index_number"),
"n_bps": len(doc.get("sap_building_parts") or []),
"lodged_band": doc.get("current_energy_efficiency_band"),
}
def main():
files = sorted(CACHE.glob("????-????-????-????-????.json"))
rows = []
cat = Counter()
exc_examples = defaultdict(list)
for f in files:
cert = f.stem
try:
doc = json.loads(f.read_text())
except Exception:
cat["bad_json"] += 1
continue
lodged = doc.get("energy_rating_current")
try:
epc = EpcPropertyDataMapper.from_api_response(doc)
except ValueError as e:
if "Unsupported EPC schema" in str(e):
cat["unsupported_schema"] += 1
else:
cat["raise:ValueError"] += 1
exc_examples["ValueError:" + str(e)[:60]].append(cert)
continue
except Exception as e:
ename = type(e).__name__
cat[f"raise:{ename}"] += 1
exc_examples[f"{ename}:{str(e)[:60]}"].append(cert)
continue
if lodged is None:
cat["no_lodged_sap"] += 1
continue
try:
cont = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
).sap_score_continuous
except Exception as e:
ename = type(e).__name__
cat[f"calc_raise:{ename}"] += 1
exc_examples[f"calc:{ename}:{str(e)[:50]}"].append(cert)
continue
if not math.isfinite(cont):
cat["non_finite"] += 1
continue
err = cont - lodged
cat["computed"] += 1
rows.append({
"cert": cert, "our_cont": round(cont, 4), "lodged": lodged,
"err": round(err, 4), "abs_err": round(abs(err), 4), **diag(doc),
})
if rows:
keys = list(rows[0].keys())
with open(CACHE / "_results.csv", "w", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=keys)
w.writeheader()
w.writerows(rows)
n = len(rows)
print("=" * 70)
print(f"SAMPLE: {len(files)} cached certs | categories:")
for k, v in cat.most_common():
print(f" {k:28s} {v}")
if n == 0:
return
abs_errs = sorted(r["abs_err"] for r in rows)
def pct(thr):
return 100.0 * sum(1 for r in rows if r["abs_err"] < thr) / n
print("=" * 70)
print(f"COMPUTED: {n} certs (continuous SAP vs lodged rounded)")
print(f" % |err| < 0.5 : {pct(0.5):.1f}% <-- headline")
print(f" % |err| < 1.0 : {pct(1.0):.1f}%")
print(f" % |err| < 2.0 : {pct(2.0):.1f}%")
print(f" % |err| < 5.0 : {pct(5.0):.1f}%")
print(f" median |err| : {abs_errs[n // 2]:.3f}")
print(f" mean |err| : {sum(abs_errs) / n:.3f}")
print(f" p90 |err| : {abs_errs[int(n * 0.90)]:.3f}")
print(f" p99 |err| : {abs_errs[int(n * 0.99)]:.3f}")
print(f" max |err| : {abs_errs[-1]:.3f}")
signed = [r["err"] for r in rows]
print(f" mean signed err: {sum(signed) / n:+.3f} (we - lodged; +ve = we over-rate)")
print(" abs-err buckets:")
for lo, hi in [(0, 0.5), (0.5, 1), (1, 2), (2, 5), (5, 10), (10, 1e9)]:
c = sum(1 for r in rows if lo <= r["abs_err"] < hi)
print(f" [{lo:>4}, {hi:>4}) : {c:4d} ({100 * c / n:4.1f}%)")
print("=" * 70)
print("TOP 40 LARGEST |err| (prioritise these):")
worst = sorted(rows, key=lambda r: -r["abs_err"])[:40]
print(f" {'cert':22s} {'err':>7s} {'our':>6s} {'lodg':>4s} prop bf age gas cat/idx bps")
for r in worst:
print(f" {r['cert']:22s} {r['err']:+7.2f} {r['our_cont']:6.1f} {r['lodged']:4d} "
f"{str(r['prop_type']):>4s} {str(r['built_form']):>2s} {str(r['age_band'])[:3]:>3s} "
f"{str(r['mains_gas']):>3s} {str(r['main_heat_cat']):>3s}/{str(r['main_heat_idx']):>6s} "
f"{r['n_bps']}")
if exc_examples:
print("=" * 70)
print("RAISE/ERROR EXAMPLES (mapper/calculator gaps — also prioritise):")
for k, v in sorted(exc_examples.items(), key=lambda kv: -len(kv[1]))[:20]:
print(f" [{len(v):3d}] {k} e.g. {v[0]}")
print(f"\nFull per-cert CSV -> {CACHE / '_results.csv'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,145 @@
"""Fetch a random sample of domestic EPC JSONs from the GOV.UK EPB register.
WHAT THIS IS FOR
----------------
Wide-scale accuracy testing of the SAP10 calculator's API front-end against
real-world certificates (not the curated golden cohort, which masks raw-API
behaviour). This script builds the *input corpus*: it samples certificate
numbers uniformly at random across a date window, then downloads each cert's
full schema-21 ``data`` payload (the exact shape
``EpcPropertyDataMapper.from_api_response`` consumes) into a local cache.
Pair it with:
- ``eval_api_sap_accuracy.py`` % within 0.5 SAP, worst offenders, raises.
- ``analyse_api_sap_clusters.py`` error grouped by heating type / property.
HOW THE SAMPLE IS DRAWN
-----------------------
The register's ``/api/domestic/search`` endpoint is date-windowed and paged
(``date_start``/``date_end``/``current_page``/``page_size``); results are
ordered by registration date, so picking random PAGES across the whole window
gives an unbiased spread over dates, regions and property types. Each chosen
cert number is then resolved to its full JSON via ``/api/certificate``.
USAGE
-----
PYTHONPATH=/workspaces/model python scripts/fetch_2026_epc_sample.py
Resumable re-running skips certs already cached, so it's safe to interrupt.
Token is read from ``backend/.env`` (``OPEN_EPC_API_TOKEN``). NB the register
rejects a ``date_end`` that includes today, so keep the window in the past.
Tune the constants below (window, page count, target size, seed). The cache
dir defaults to ``/tmp/epc_2026_sample`` and can be overridden with the
``EPC_SAMPLE_CACHE`` env var.
"""
import os
import json
import time
import random
import threading
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
import httpx
from dotenv import load_dotenv
load_dotenv("backend/.env")
TOKEN = os.environ["OPEN_EPC_API_TOKEN"]
BASE = "https://api.get-energy-performance-data.communities.gov.uk"
H = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"}
CACHE = Path(os.environ.get("EPC_SAMPLE_CACHE", "/tmp/epc_2026_sample"))
CACHE.mkdir(parents=True, exist_ok=True)
# Sampling window + size. `date_end` must be strictly before today (the
# register rejects "the date cannot include today"). TOTAL_PAGES is the
# `totalPages` the search returns for this window at page_size=100 — re-probe
# it if you change the window (it only needs to be an upper bound for the
# random page draw; out-of-range pages just return fewer rows).
WINDOW = {"date_start": "2026-01-01", "date_end": "2026-05-31"}
TOTAL_PAGES = 7402
N_PAGES = 14 # random pages to pull → N_PAGES * 100 candidate certs
TARGET = 1200 # cap on how many full JSONs to fetch
random.seed(2026) # reproducible page draw
def _get(url, params, timeout=20.0, tries=5):
"""GET with retry/backoff on 429 + 5xx (honours Retry-After)."""
r = None
for i in range(tries):
try:
r = httpx.get(url, params=params, headers=H, timeout=timeout)
except httpx.HTTPError:
time.sleep(1.5 * (i + 1))
continue
if r.status_code == 429 or r.status_code >= 500:
ra = r.headers.get("Retry-After")
time.sleep(float(ra) if ra else 1.5 * (i + 1))
continue
return r
return r
def sample_cert_numbers():
pages = sorted(random.sample(range(1, TOTAL_PAGES + 1), N_PAGES))
certs = {}
for p in pages:
r = _get(f"{BASE}/api/domestic/search", {**WINDOW, "current_page": p, "page_size": 100})
if r is None or not r.is_success:
print(f" search page {p} -> {getattr(r, 'status_code', 'ERR')}")
continue
for row in r.json().get("data", []):
certs[row["certificateNumber"]] = row.get("registrationDate")
print(f" page {p}: cumulative {len(certs)} certs")
return certs
_lock = threading.Lock()
_done = {"ok": 0, "404": 0, "err": 0}
def fetch_one(cert):
out = CACHE / f"{cert}.json"
if out.exists():
with _lock:
_done["ok"] += 1
return
r = _get(f"{BASE}/api/certificate", {"certificate_number": cert})
if r is not None and r.status_code == 404:
with _lock:
_done["404"] += 1
return
if r is None or not r.is_success:
with _lock:
_done["err"] += 1
return
try:
payload = r.json()["data"]
except Exception:
with _lock:
_done["err"] += 1
return
out.write_text(json.dumps(payload))
with _lock:
_done["ok"] += 1
if _done["ok"] % 100 == 0:
print(f" fetched {_done['ok']} (404={_done['404']} err={_done['err']})")
def main():
print("sampling cert numbers...")
certs = sample_cert_numbers()
cert_list = list(certs)[:TARGET]
(CACHE / "_manifest.json").write_text(
json.dumps({"certs": cert_list, "window": WINDOW}, indent=2)
)
print(f"fetching {len(cert_list)} cert JSONs into {CACHE} ...")
t0 = time.time()
with ThreadPoolExecutor(max_workers=8) as ex:
list(as_completed([ex.submit(fetch_one, c) for c in cert_list]))
print(f"DONE in {time.time() - t0:.0f}s: ok={_done['ok']} 404={_done['404']} err={_done['err']}")
print(f"cached JSON files: {len(list(CACHE.glob('????-????-????-????-????.json')))}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,516 @@
{
"uprn": 77048251,
"roofs": [
{
"description": "Pitched, 300 mm loft insulation",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
}
],
"walls": [
{
"description": "Cavity wall, filled cavity",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 2,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
"addendum": {
"addendum_numbers": [
15
]
},
"lighting": {
"description": "Good lighting efficiency",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"postcode": "M22 1UR",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "MANCHESTER",
"built_form": 2,
"created_at": "2026-06-03 14:45:36",
"door_count": 2,
"region_code": 19,
"report_type": 2,
"sap_heating": {
"number_baths": 0,
"cylinder_size": 1,
"shower_outlets": [
{
"shower_wwhrs": 1,
"shower_outlet_type": 1
}
],
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 26,
"secondary_fuel_type": 29,
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 15709
}
],
"immersion_heating_type": "NA",
"secondary_heating_type": 691,
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.48,
"window_height": 0.92,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.57,
"window_height": 1.12,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.44,
"window_height": 1.21,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.54,
"window_height": 1.11,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 8,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.48,
"window_height": 0.92,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.61,
"window_height": 1.05,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 1,
"window_height": 1.24,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 1,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.83,
"window_height": 1.07,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 7,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.56,
"window_height": 1.13,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.02,
"window_height": 1.17,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.93,
"window_height": 1.21,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.81,
"window_height": 1.29,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 4,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.54,
"window_height": 1.32,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.93,
"window_height": 1.21,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.93,
"window_height": 1.13,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.99,
"window_height": 1.05,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 5,
"window_type": 1,
"glazing_type": 2,
"window_width": 2,
"window_height": 1.21,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "ENG",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Semi-detached house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "49 Cotefield Road",
"assessment_type": "RdSAP",
"completion_date": "2026-06-03",
"inspection_date": "2026-06-03",
"extensions_count": 0,
"measurement_type": 1,
"total_floor_area": 107,
"transaction_type": 5,
"conservatory_type": 1,
"heated_room_count": 6,
"registration_date": "2026-06-03",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"pv_connection": 0,
"photovoltaic_supply": {
"none_or_no_details": {
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "false",
"wind_turbines_terrain_type": 2,
"electricity_smart_meter_present": "false"
},
"secondary_heating": {
"description": "Room heaters, electric",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"extract_fans_count": 2,
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 300,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.4,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 53.63,
"quantity": "square metres"
},
"party_wall_length": {
"value": 5.63,
"quantity": "metres"
},
"floor_construction": 1,
"heat_loss_perimeter": {
"value": 26.13,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.45,
"quantity": "metres"
},
"total_floor_area": {
"value": 53.63,
"quantity": "square metres"
},
"party_wall_length": {
"value": 5.63,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 21.78,
"quantity": "metres"
}
}
],
"wall_insulation_type": 2,
"construction_age_band": "D",
"party_wall_construction": 0,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "300mm",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
}
],
"solar_water_heating": "N",
"habitable_room_count": 6,
"heating_cost_current": {
"value": 1131,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.9,
"energy_rating_average": 60,
"energy_rating_current": 70,
"lighting_cost_current": {
"value": 63,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 1031,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 218,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 100,
"currency": "GBP"
},
"indicative_cost": "\u00a35,000 - \u00a310,000",
"improvement_type": "W2",
"improvement_details": {
"improvement_number": 58
},
"improvement_category": 5,
"energy_performance_rating": 72,
"environmental_impact_rating": 75
},
{
"sequence": 2,
"typical_saving": {
"value": 223,
"currency": "GBP"
},
"indicative_cost": "\u00a38,000 - \u00a310,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 76,
"environmental_impact_rating": 76
}
],
"co2_emissions_potential": 2.5,
"energy_rating_potential": 76,
"lighting_cost_potential": {
"value": 63,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"hot_water_cost_potential": {
"value": 218,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2512.47,
"space_heating_existing_dwelling": 9580.15
},
"draughtproofed_door_count": 2,
"energy_consumption_current": 154,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0344",
"energy_consumption_potential": 130,
"environmental_impact_current": 73,
"current_energy_efficiency_band": "C",
"environmental_impact_potential": 76,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 27,
"low_energy_fixed_lighting_bulbs_count": 14,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -0,0 +1,523 @@
{
"uprn": 77079925,
"roofs": [
{
"description": "Pitched, 100 mm loft insulation",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
{
"description": "Pitched, insulated (assumed)",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"walls": [
{
"description": "Cavity wall, filled cavity",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": "Suspended, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 2,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
},
"lighting": {
"description": "Good lighting efficiency",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"postcode": "M22 4FA",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "MANCHESTER",
"built_form": 2,
"created_at": "2026-06-03 10:48:51",
"door_count": 2,
"region_code": 19,
"report_type": 2,
"sap_heating": {
"number_baths": 1,
"cylinder_size": 1,
"shower_outlets": [
{
"shower_wwhrs": 1,
"shower_outlet_type": 2
}
],
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 26,
"secondary_fuel_type": 29,
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"emitter_temperature": 0,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 17741
}
],
"immersion_heating_type": "NA",
"secondary_heating_type": 691,
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.98,
"window_height": 0.93,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.4,
"window_height": 0.74,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.49,
"window_height": 1.99,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.13,
"window_height": 1.06,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.17,
"window_height": 0.75,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 6,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.92,
"window_height": 0.94,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 8,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.01,
"window_height": 1.21,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 8,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.41,
"window_height": 0.85,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.03,
"window_height": 1.26,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.12,
"window_height": 0.92,
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 1.63,
"window_height": 0.98,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 2.64,
"window_height": 1.2,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"orientation": 2,
"window_type": 1,
"glazing_type": 2,
"window_width": 0.55,
"window_height": 1.22,
"draught_proofed": "true",
"window_location": 1,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
}
],
"schema_type": "RdSAP-Schema-21.0.1",
"uprn_source": "Energy Assessor",
"country_code": "ENG",
"main_heating": [
{
"description": "Boiler and radiators, mains gas",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"air_tightness": {
"description": "(not tested)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"dwelling_type": "Semi-detached house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "75 Kenworthy Lane",
"assessment_type": "RdSAP",
"completion_date": "2026-06-03",
"inspection_date": "2026-06-03",
"extensions_count": 1,
"measurement_type": 1,
"total_floor_area": 83,
"transaction_type": 5,
"conservatory_type": 1,
"heated_room_count": 5,
"registration_date": "2026-06-03",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"pv_connection": 0,
"photovoltaic_supply": {
"none_or_no_details": {
"percent_roof_area": 0
}
},
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "false",
"wind_turbines_terrain_type": 2,
"electricity_smart_meter_present": "false"
},
"secondary_heating": {
"description": "Room heaters, electric",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"extract_fans_count": 2,
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 300,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 4,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.43,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 35.07,
"quantity": "square metres"
},
"party_wall_length": {
"value": 4.29,
"quantity": "metres"
},
"floor_construction": 2,
"heat_loss_perimeter": {
"value": 11.12,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.45,
"quantity": "metres"
},
"total_floor_area": {
"value": 35.67,
"quantity": "square metres"
},
"party_wall_length": {
"value": 4.84,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 12.21,
"quantity": "metres"
}
}
],
"wall_insulation_type": 2,
"construction_age_band": "C",
"party_wall_construction": 0,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "100mm",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
},
{
"identifier": "Extension 1",
"wall_dry_lined": "N",
"wall_thickness": 300,
"floor_heat_loss": 7,
"roof_construction": 5,
"wall_construction": 4,
"building_part_number": 2,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": {
"value": 2.43,
"quantity": "metres"
},
"floor_insulation": 1,
"total_floor_area": {
"value": 6.07,
"quantity": "square metres"
},
"party_wall_length": {
"value": 1.2,
"quantity": "metres"
},
"floor_construction": 2,
"heat_loss_perimeter": {
"value": 6.26,
"quantity": "metres"
}
},
{
"floor": 1,
"room_height": {
"value": 2.45,
"quantity": "metres"
},
"total_floor_area": {
"value": 6.07,
"quantity": "square metres"
},
"party_wall_length": {
"value": 1.2,
"quantity": "metres"
},
"heat_loss_perimeter": {
"value": 6.26,
"quantity": "metres"
}
}
],
"wall_insulation_type": 2,
"construction_age_band": "C",
"party_wall_construction": 0,
"wall_thickness_measured": "Y",
"roof_insulation_location": 4,
"roof_insulation_thickness": "ND",
"wall_insulation_thickness": "NI",
"floor_insulation_thickness": "NI"
}
],
"solar_water_heating": "N",
"habitable_room_count": 5,
"heating_cost_current": {
"value": 1013,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.4,
"energy_rating_average": 60,
"energy_rating_current": 66,
"lighting_cost_current": {
"value": 53,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 926,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 292,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": {
"value": 87,
"currency": "GBP"
},
"indicative_cost": "\u00a35,000 - \u00a310,000",
"improvement_type": "W1",
"improvement_details": {
"improvement_number": 57
},
"improvement_category": 5,
"energy_performance_rating": 68,
"environmental_impact_rating": 75
},
{
"sequence": 2,
"typical_saving": {
"value": 224,
"currency": "GBP"
},
"indicative_cost": "\u00a38,000 - \u00a310,000",
"improvement_type": "U",
"improvement_details": {
"improvement_number": 34
},
"improvement_category": 5,
"energy_performance_rating": 73,
"environmental_impact_rating": 76
}
],
"co2_emissions_potential": 2.1,
"energy_rating_potential": 73,
"lighting_cost_potential": {
"value": 53,
"currency": "GBP"
},
"schema_version_original": "21.0.1",
"hot_water_cost_potential": {
"value": 292,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2105.06,
"space_heating_existing_dwelling": 8424.88
},
"draughtproofed_door_count": 2,
"energy_consumption_current": 174,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "5.02r0344",
"energy_consumption_potential": 146,
"environmental_impact_current": 73,
"current_energy_efficiency_band": "D",
"environmental_impact_potential": 76,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "C",
"co2_emissions_current_per_floor_area": 29,
"low_energy_fixed_lighting_bulbs_count": 9,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -14,10 +14,18 @@ area fraction); SAP 10.3 specification (13-01-2026) Tables 4a/4e/12.
from __future__ import annotations
from typing import Final
from typing import Final, Optional
import pytest
from datatypes.epc.domain.mapper import (
_map_elmhurst_room_in_roof, # pyright: ignore[reportPrivateUsage]
)
from datatypes.epc.surveys.elmhurst_site_notes import (
RoomInRoof as ElmhurstRoomInRoof,
RoomInRoofSurface as ElmhurstRoomInRoofSurface,
)
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
MainHeatingDetail,
@ -44,16 +52,24 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_heat_network_code_302_effective_factor, # pyright: ignore[reportPrivateUsage]
_heat_network_distribution_electricity, # pyright: ignore[reportPrivateUsage]
_heat_network_dlf, # pyright: ignore[reportPrivateUsage]
_heat_network_factor_fuel_code, # pyright: ignore[reportPrivateUsage]
_heat_network_standing_charge_gbp, # pyright: ignore[reportPrivateUsage]
_is_electric_main, # pyright: ignore[reportPrivateUsage]
_is_heat_network_electric_main, # pyright: ignore[reportPrivateUsage]
_is_electric_water, # pyright: ignore[reportPrivateUsage]
_is_off_peak_meter, # pyright: ignore[reportPrivateUsage]
_main_floor_u_value, # pyright: ignore[reportPrivateUsage]
_main_space_heating_high_rate_fraction, # pyright: ignore[reportPrivateUsage]
_other_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_pv_diverter_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_pv_dwelling_import_price_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_primary_loss_applies, # pyright: ignore[reportPrivateUsage]
_rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage]
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage]
_secondary_fuel_cost_gbp_per_kwh, # pyright: ignore[reportPrivateUsage]
_secondary_heating_fraction_for_category, # pyright: ignore[reportPrivateUsage]
_section_12_4_4_summer_immersion_applies, # pyright: ignore[reportPrivateUsage]
_separately_timed_dhw, # pyright: ignore[reportPrivateUsage]
@ -1754,6 +1770,209 @@ def test_other_fuel_cost_for_18_hour_tariff_uses_18_hour_high_rate() -> None:
)
def test_main_space_high_rate_fraction_zero_for_off_peak_storage_heaters() -> None:
# Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93): E_space,m (211) is
# included in D_PV "only where the fuel code applied to it in Section
# 10a is 30, 32, 34, 35 or 38 (i.e. electricity not at the low-rate)".
# Electric STORAGE heaters (code 402) on a 7-hour off-peak tariff are
# charged wholly at the low rate (Table 12a Grid 1 SH fraction 0.00 /
# `_table_12a_system_for_main` → None) — worksheet (240) high-rate
# cost = 0 — so none of (211) may enter D_PV.
from domain.sap10_calculator.tables.table_12a import Tariff
storage_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29, # electricity
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2402,
main_heating_category=7,
sap_main_heating_code=402,
)
# Act
off_peak = _main_space_heating_high_rate_fraction(
storage_main, Tariff.SEVEN_HOUR
)
standard = _main_space_heating_high_rate_fraction(
storage_main, Tariff.STANDARD
)
gas = _main_space_heating_high_rate_fraction(
_gas_boiler_detail(sap_main_heating_code=102), Tariff.SEVEN_HOUR
)
# Assert
assert abs(off_peak - 0.0) <= 1e-9
# STANDARD tariff has no high/low split → 100% high rate.
assert abs(standard - 1.0) <= 1e-9
# Non-electric main never carries an off-peak split.
assert abs(gas - 1.0) <= 1e-9
def test_pv_eligible_demand_excludes_low_rate_main_space_heating() -> None:
# Arrange — SAP 10.2 Appendix M1 §3a (PDF p.93). A main billed wholly
# at the low rate (high-rate fraction 0.0) must contribute zero to
# D_PV even though its Table-12 code (30) is in the eligible set; the
# secondary (also code 30) at its full high-rate fraction stays in.
main_1 = tuple(float(100 + m) for m in range(12))
secondary = tuple(float(10 + m) for m in range(12))
base = tuple(float(5) for _ in range(12)) # lighting et al.
# Act
excluded = _pv_eligible_demand_monthly_kwh(
lighting_monthly_kwh=base,
appliances_monthly_kwh=(0.0,) * 12,
cooking_monthly_kwh=(0.0,) * 12,
electric_shower_monthly_kwh=(0.0,) * 12,
pumps_fans_monthly_kwh=(0.0,) * 12,
main_1_fuel_monthly_kwh=main_1,
secondary_fuel_monthly_kwh=secondary,
hot_water_monthly_kwh=(0.0,) * 12,
main_fuel_code_table_12=30,
secondary_fuel_code_table_12=30,
water_heating_fuel_code_table_12=26, # gas → no E_water
main_space_high_rate_fraction=0.0,
)
included = _pv_eligible_demand_monthly_kwh(
lighting_monthly_kwh=base,
appliances_monthly_kwh=(0.0,) * 12,
cooking_monthly_kwh=(0.0,) * 12,
electric_shower_monthly_kwh=(0.0,) * 12,
pumps_fans_monthly_kwh=(0.0,) * 12,
main_1_fuel_monthly_kwh=main_1,
secondary_fuel_monthly_kwh=secondary,
hot_water_monthly_kwh=(0.0,) * 12,
main_fuel_code_table_12=30,
secondary_fuel_code_table_12=30,
water_heating_fuel_code_table_12=26,
main_space_high_rate_fraction=1.0,
)
# Assert — excluded drops the full (211); secondary stays in both.
for m in range(12):
assert abs(excluded[m] - (base[m] + secondary[m])) <= 1e-9
assert abs(included[m] - (base[m] + secondary[m] + main_1[m])) <= 1e-9
def test_pv_dwelling_import_price_blends_high_low_on_off_peak() -> None:
# Arrange — SAP 10.2 Appendix M1 §6 (PDF p.94, lines 5510-5513): PV
# used in the dwelling is credited at "a weighted average of high and
# low rates (Table 12a)". On a 7-hour tariff the ALL_OTHER_USES blend
# is 0.90 × 15.29 + 0.10 × 5.50 = 14.311 p/kWh (worksheet case 19
# (252) "PV used in dwelling" = 14.3110). STANDARD tariff has no
# split → flat Table 32 code 30 = 13.19 p/kWh (unchanged).
from domain.sap10_calculator.tables.table_12a import Tariff
# Act
off_peak = _pv_dwelling_import_price_gbp_per_kwh(
Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
standard = _pv_dwelling_import_price_gbp_per_kwh(
Tariff.STANDARD, SAP_10_2_SPEC_PRICES
)
# Assert
assert abs(off_peak - 0.14311) <= 1e-6
assert abs(standard - 0.1319) <= 1e-6
def _pv_diverter_epc():
"""A minimal dwelling that satisfies every Appendix G4 inclusion
condition: a 210 L cylinder (code 4), no solar HW, no battery, with
`pv_diverter_present` set on the energy source."""
from dataclasses import replace
epc = make_minimal_sap10_epc(
total_floor_area_m2=90.0,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
solar_water_heating=False,
sap_heating=make_sap_heating(
main_heating_details=[_gas_boiler_detail(sap_main_heating_code=102)],
cylinder_size=4, # RdSAP Table 28 code 4 → 210 L
),
)
return replace(
epc,
sap_energy_source=replace(
epc.sap_energy_source, pv_diverter_present=True
),
)
def test_pv_diverter_monthly_applies_g4_correction_and_clamp() -> None:
# Arrange — SAP 10.2 Appendix G4 step 4 (PDF p.73): SPV,diverter,m =
# EPV,m(1 βm) × 0.8 × 0.9, clamped to ≤ (62)m + (63a)m. With a
# 100-kWh monthly surplus the uncapped diverter input is 72 kWh; a
# month whose water demand is only 50 kWh clamps it to 50.
epc = _pv_diverter_epc()
export = tuple(100.0 for _ in range(12))
demand = tuple(50.0 if m < 6 else 1000.0 for m in range(12))
# Act
out = _pv_diverter_monthly_kwh(
epc=epc,
pv_export_monthly_kwh=export,
water_demand_monthly_kwh=demand,
avg_daily_hot_water_l=120.0, # < 210 L cylinder
battery_capacity_kwh=0.0,
pv_generation_kwh=1200.0,
)
# Assert
assert out is not None
for m in range(12):
expected = min(100.0 * 0.8 * 0.9, demand[m])
assert abs(out[m] - expected) <= 1e-9
def test_pv_diverter_disregarded_when_any_g4_condition_fails() -> None:
# Arrange — SAP 10.2 Appendix G4 step 1: if a PV system / large-enough
# cylinder / no-solar-HW / no-battery condition is not met, software
# disregards the diverter (returns None).
from dataclasses import replace
epc = _pv_diverter_epc()
export: tuple[float, ...] = (100.0,) * 12
demand: tuple[float, ...] = (1000.0,) * 12
def divert(
e: object, avg_l: float = 120.0, battery: float = 0.0, pv_gen: float = 1200.0
) -> Optional[tuple[float, ...]]:
return _pv_diverter_monthly_kwh(
epc=e, # pyright: ignore[reportArgumentType]
pv_export_monthly_kwh=export,
water_demand_monthly_kwh=demand,
avg_daily_hot_water_l=avg_l,
battery_capacity_kwh=battery,
pv_generation_kwh=pv_gen,
)
# Act / Assert — sanity: all conditions met → not None.
assert divert(epc) is not None
# Diverter not present.
assert (
divert(
replace(
epc,
sap_energy_source=replace(
epc.sap_energy_source, pv_diverter_present=False
),
)
)
is None
)
# No PV generation (condition a).
assert divert(epc, pv_gen=0.0) is None
# Cylinder not larger than (43) average daily HW use (condition b).
assert divert(epc, avg_l=9999.0) is None
# Battery present (condition d).
assert divert(epc, battery=5.0) is None
# Solar water heating present (condition c).
assert divert(replace(epc, solar_water_heating=True)) is None
def test_is_off_peak_meter_recognises_bare_18_hour_lodging() -> None:
# Arrange — RdSAP 10 §17 page 85 row 10-2 lodges 18-hour meter
# as the bare "18-hour" or "18 Hour" form (Elmhurst Summary §14.2
@ -1854,6 +2073,137 @@ def test_separately_timed_dhw_excludes_electric_immersion_per_table_2b_note_b()
assert sep_immersion is False
def test_separately_timed_dhw_excludes_dedicated_water_heater_per_table_2b_note_b() -> None:
# Arrange — SAP 10.2 Table 2b note b) (PDF p.159) applies the ×0.9
# temperature-factor reduction only when DHW "is separately timed"
# relative to space heating on a SHARED heat generator ("boiler
# systems, warm air systems and heat pump systems"). Per RdSAP 10
# §10.5.1 (PDF p.55) a separate boiler/circulator providing DHW only
# (water-heating code 911 = "Gas boiler/circulator for water heating
# only") is NOT the main space-heating system — here space is by
# electric storage heaters (SAP code 402). With no shared generator
# there is no separate DHW timer to apply the ×0.9 against, so the
# multiplier must not fire — the same principle as the WHC 903
# electric-immersion carve-out above. Simulated case 19's worksheet
# confirms it: cylinder thermostat present + "Separate Time Control:
# No" → (53) Temperature factor 0.6000 (base, not 0.54 = 0.6 × 0.9)
# AND (59)m primary loss h=5 (winter Jan 64.5792), not h=3 (43.31).
storage_heater_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # electricity
heat_emitter_type="",
emitter_temperature=1,
main_heating_control=2402, # storage-heater auto-charge control
main_heating_category=None,
sap_main_heating_code=402, # electric storage heaters
)
dedicated_gas_water_heater_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_heating=make_sap_heating(
main_heating_details=[storage_heater_main],
water_heating_fuel=26, # mains gas (dedicated WHS boiler)
water_heating_code=911, # gas boiler/circulator, water only
cylinder_size=4,
cylinder_insulation_type=2, # loose jacket
cylinder_insulation_thickness_mm=50,
),
)
# Act
separately_timed = _separately_timed_dhw(
dedicated_gas_water_heater_epc, storage_heater_main,
)
# Assert — dedicated water-heating-only system → not separately timed.
assert separately_timed is False
def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate() -> None:
# Arrange — SAP 10.2 Table 12a Grid 1 (PDF p.191): secondary heating
# is a direct-acting electric room heater (RdSAP 10 §A.2.2 default),
# which sits on the "Other systems including direct-acting electric"
# row. For a 7-hour (Economy-7) tariff that row's high-rate fraction
# is 1.00 — ALL secondary consumption bills at the high rate, NOT the
# off-peak low rate that storage-heater charging earns. Simulated
# case 19's worksheet (242) is the evidence: "Space heating -
# secondary (1.00*15.29 + 0.00*5.50)" → 15.29 p/kWh = £0.1529. Pre-
# slice `_secondary_fuel_cost_gbp_per_kwh` returned the 7-hour low
# rate 5.50 p (£0.0550) for every off-peak electric secondary,
# under-charging by 9.79 p/kWh × the secondary kWh (~£340 on case 19).
storage_heater_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # electricity
heat_emitter_type="",
emitter_temperature=1,
main_heating_control=2402,
main_heating_category=None,
sap_main_heating_code=402, # electric storage heaters
)
dual_meter_off_peak_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_heating=make_sap_heating(
main_heating_details=[storage_heater_main],
# secondary_fuel_type omitted → §A.2.2 portable electric default
),
)
# Act
secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh(
dual_meter_off_peak_epc.sap_heating,
storage_heater_main,
1, # Dual meter → 7-hour off-peak tariff
SAP_10_2_SPEC_PRICES,
)
# Assert — 1.00 × 15.29 p + 0.00 × 5.50 p = 15.29 p/kWh = £0.1529.
assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6
def test_sap_table_3_primary_loss_applies_to_dedicated_water_heating_boiler_circulator() -> None:
# Arrange — SAP 10.2 Table 3 (PDF p.160) row 1: primary circuit loss
# applies when "hot water is heated by a heat generator (e.g. boiler)
# connected to a hot water storage vessel via insulated or
# uninsulated pipes". The dedicated "boiler/circulator for water
# heating only" water-heating codes (Table 4a hot-water section, PDF
# p.166): 911 gas, 912 liquid fuel, 913 solid fuel, 921-931 range
# cooker with boiler — each is a boiler feeding the cylinder through a
# primary loop, so the loss applies regardless of what the SPACE
# heating system is. Simulated case 19 pairs electric storage heaters
# (SAP code 402) for space with a WHS 911 gas boiler/circulator for
# water: `_water_heating_main` resolves to the code-402 storage main
# (electric, no primary loop), so before this slice every dedicated-
# boiler branch missed the cylinder's primary circuit and (59)m went
# to zero — dropping the worksheet's 676.68 kWh/yr (59) and inflating
# HW fuel (219).
storage_heater_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # electricity
heat_emitter_type="",
emitter_temperature=1,
main_heating_control=2402,
main_heating_category=None,
sap_main_heating_code=402, # electric storage heaters (space)
)
# Act
applies_with_cylinder = _primary_loss_applies(
storage_heater_main, True, None, water_heating_code=911,
)
applies_without_cylinder = _primary_loss_applies(
storage_heater_main, False, None, water_heating_code=911,
)
# Assert — WHS 911 + cylinder → primary loss applies; no cylinder →
# no primary circuit, no loss.
assert applies_with_cylinder is True
assert applies_without_cylinder is False
def test_water_efficiency_uses_table_4a_water_column_for_heat_pumps_per_sap_10_2() -> None:
# Arrange — SAP 10.2 Table 4a (PDF p.163-164) gives heat pumps two
# efficiency columns: "space" and "water". For low-temperature
@ -2120,6 +2470,70 @@ def test_elmhurst_main_fuel_to_sap10_maps_bio_liquid_water_heating_labels() -> N
assert _ELMHURST_MAIN_FUEL_TO_SAP10["Bio-liquid HVO from used cooking oil"] == 71
def _placeholder_rir_surfaces() -> "list[ElmhurstRoomInRoofSurface]":
# A §3.9.1 Simplified Room-in-Roof lodges the roof-going Length/Height
# cells as placeholders (a 40 m flat-ceiling height, a 32 m slope on a
# 4.65 m gable) — Elmhurst ignores them and derives the area from the
# floor area. Gables ARE measured (4.65 × 2.45 = 11.39).
def surf(
name: str, length: float, height: float,
gable_type: Optional[str] = None, default_u: Optional[float] = None,
) -> "ElmhurstRoomInRoofSurface":
return ElmhurstRoomInRoofSurface(
name=name, length_m=length, height_m=height, insulation="",
insulation_type=None, gable_type=gable_type,
default_u_value=default_u, u_value_known=False, u_value=0.0,
)
return [
surf("Flat Ceiling 1", 4.00, 40.00), # placeholder
surf("Slope 1", 32.00, 32.00), # placeholder
surf("Gable Wall 1", 4.65, 2.45, gable_type="Exposed", default_u=0.29),
surf("Gable Wall 2", 4.65, 2.45, gable_type="Party", default_u=0.25),
]
def test_elmhurst_simplified_rir_drops_placeholder_roof_surfaces() -> None:
# Arrange — RdSAP 10 §3.9.1 (PDF p.21): a Simplified RR's slope /
# flat ceiling / stud wall are not measured; emitting their
# placeholder L×H as `detailed_surfaces` makes the cascade bill them
# as explicit roof area (7.5× heat-loss explosion) instead of firing
# the spec's `A_RR = 12.5√(A_floor/1.5) Σwalls` residual formula.
rir = ElmhurstRoomInRoof(
floor_area_m2=29.75, construction_age_band="A",
assessment="Simplified Type 1", surfaces=_placeholder_rir_surfaces(),
)
# Act
mapped = _map_elmhurst_room_in_roof(rir)
# Assert — roof-going surfaces dropped, both gables retained.
assert mapped is not None
kinds = sorted(s.kind for s in (mapped.detailed_surfaces or []))
assert "slope" not in kinds
assert "flat_ceiling" not in kinds
assert kinds == ["gable_wall", "gable_wall_external"]
def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None:
# Arrange — a Detailed (§3.10) assessment DOES measure slope / flat
# ceiling, so they must be retained (regression guard so the
# Simplified drop doesn't bleed into Detailed lodgements).
rir = ElmhurstRoomInRoof(
floor_area_m2=29.75, construction_age_band="A",
assessment="Detailed", surfaces=_placeholder_rir_surfaces(),
)
# Act
mapped = _map_elmhurst_room_in_roof(rir)
# Assert — slope + flat ceiling retained under the Detailed path.
assert mapped is not None
kinds = sorted(s.kind for s in (mapped.detailed_surfaces or []))
assert "slope" in kinds
assert "flat_ceiling" in kinds
def test_elmhurst_gas_boiler_main_fuel_derives_carrier_from_water_heating() -> None:
# Arrange — SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas
# boilers (including mains gas, LPG and biogas)". The code identifies
@ -2731,6 +3145,55 @@ def test_space_heating_off_peak_fallback_uses_actual_tariff_low_rate_not_e7() ->
assert abs(cost_eighteen_hour - 0.0741) <= 1e-6
def test_space_heating_electric_room_heater_off_peak_bills_at_direct_acting_high_rate() -> None:
# Arrange — an ELECTRIC room heater (RdSAP main_heating_category 10,
# e.g. SAP code 691) is direct-acting electric, so SAP 10.2 Table 12a
# Grid 1 (PDF p.191) puts it on the "Other systems including direct-
# acting electric" row: 7-hour high-rate fraction 1.00, 10-hour 0.50.
# Unlike STORAGE heaters (category 7), which charge off-peak and so
# correctly bill 100% at the low rate, a room heater runs on demand —
# mostly at the HIGH rate. `_table_12a_system_for_main` only mapped
# ASHP, so a room heater fell through to the "100% low-rate" fallback
# (5.50 p, £0.0550), under-charging space heating by ~9.79 p/kWh and
# systematically OVER-rating the cat-10 cluster (1,000-cert API sample:
# 48 certs, mean |err| 9.49, signed +5.08). The fix maps electric
# cat-10 mains to OTHER_DIRECT_ACTING_ELECTRIC. Mirror of S0380.228
# (which fixed the same fallback for electric SECONDARY heating).
from domain.sap10_calculator.tables.table_12a import Tariff
electric_room_heater_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=30, # standard electricity
heat_emitter_type=2,
emitter_temperature=1,
main_heating_control=2602,
main_heating_category=10, # electric room heaters
sap_main_heating_code=691,
)
gas_room_heater_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=1, # mains gas — NOT electric
heat_emitter_type=2,
emitter_temperature=1,
main_heating_control=2602,
main_heating_category=10, # gas room heater (also cat 10)
sap_main_heating_code=631,
)
# Act — 7-hour off-peak tariff.
electric_rate = _space_heating_fuel_cost_gbp_per_kwh(
electric_room_heater_main, Tariff.SEVEN_HOUR, prices=SAP_10_2_SPEC_PRICES,
)
gas_rate = _space_heating_fuel_cost_gbp_per_kwh(
gas_room_heater_main, Tariff.SEVEN_HOUR, prices=SAP_10_2_SPEC_PRICES,
)
# Assert — electric room heater: 1.00 × 15.29 p = £0.1529 (high rate).
# Gas room heater is unaffected (non-electric → single Table 32 rate,
# not the off-peak electric split).
assert abs(electric_rate - 0.1529) <= 1e-6
assert abs(gas_rate - 0.0550) > 1e-6
def test_heat_network_dlf_full_table_12c_age_band_coverage() -> None:
# Arrange — SAP 10.2 Table 12c (page 193) heat-network Distribution
# Loss Factor by dwelling age band A..M. None → K-or-newer
@ -3886,6 +4349,110 @@ def test_air_source_heat_pump_pcdb_104568_derives_apm_efficiencies_per_sap_app_n
)
def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_branch() -> None:
"""SAP 10.2 Table 2 (PDF p.158) Note 1 gives a SEPARATE storage loss
factor for a loose-jacket cylinder: L = 0.005 + 1.76 / (t + 12.8),
~2× the factory-insulated L = 0.005 + 0.55 / (t + 4.0) at the same
thickness. The EPB API lodges cylinder_insulation_type=2 = loose
jacket (1 = factory-applied). Before this fix
`_cylinder_storage_loss_override` returned None for every non-factory
type, so a loose-jacket cylinder fell to the zero-storage-loss combi
default a systematic HW under-count (a 2026 register sample of 22
such certs over-predicted SAP by +2.29 mean). The override must route
insulation_type=2 to the Table 2 loose-jacket branch.
"""
# Arrange — identical to the factory storage-loss test but
# cylinder_insulation_type=2 (loose jacket) instead of 1.
from domain.sap10_calculator.worksheet.water_heating import (
cylinder_storage_loss_factor_table_2,
cylinder_temperature_factor_table_2b,
cylinder_volume_factor_table_2a,
)
hp_main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=29,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2206,
main_heating_category=4,
sap_main_heating_code=None,
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
has_hot_water_cylinder=True,
sap_building_parts=[make_building_part()],
sap_heating=make_sap_heating(
main_heating_details=[hp_main],
water_heating_code=901,
cylinder_size=3, # Medium → 160 L
cylinder_insulation_type=2, # loose jacket
cylinder_insulation_thickness_mm=50,
cylinder_thermostat="Y",
),
)
# Expected (56)m Jan from the Table 2 loose-jacket branch (same V /
# VF / TF as the factory test — only the loss factor L differs).
loss_factor = cylinder_storage_loss_factor_table_2(
insulation_type="loose_jacket", thickness_mm=50.0
)
vol_factor = cylinder_volume_factor_table_2a(160.0)
temp_factor = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=True, separately_timed_dhw=True
)
expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31
# Act
wh_result, _ = _water_heating_worksheet_and_gains(
epc=epc,
water_efficiency_pct=1.7,
is_instantaneous=False,
primary_age="D",
pcdb_record=None,
)
# Assert — non-None (was the zero-loss default) and equal to the
# loose-jacket branch, distinctly larger than the factory 36.9530.
assert wh_result is not None
got_jan_kwh = wh_result.solar_storage_monthly_kwh[0]
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4
assert got_jan_kwh > 36.9530 # loose jacket loses more than factory
def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None:
"""RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating
system is lodged, the default cylinder takes the age-band insulation,
and "Age band of main property A to F: 12 mm loose jacket". Bands
A-F previously raised UnmappedSapCode because the loose-jacket storage
loss branch wasn't plumbed (now it is, S0380.224). A band-B cert must
resolve to a 12 mm loose-jacket cylinder; band G stays 25 mm factory.
"""
from domain.sap10_calculator.rdsap.cert_to_inputs import _apply_rdsap_no_water_heating_system_default # pyright: ignore[reportPrivateUsage]
def _no_dhw_epc(age_band: str) -> EpcPropertyData:
return make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
country_code="ENG",
sap_building_parts=[make_building_part(construction_age_band=age_band)],
sap_heating=make_sap_heating(water_heating_code=999),
)
# Act — band B (A-F band) + band G (factory band, regression guard).
band_b = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("B"))
band_g = _apply_rdsap_no_water_heating_system_default(_no_dhw_epc("G"))
# Assert — band B → 12 mm loose jacket (type 2); band G → 25 mm
# factory (type 1). Both gain the immersion + 110 L cylinder default.
assert band_b.has_hot_water_cylinder is True
assert band_b.sap_heating.cylinder_insulation_type == 2 # loose jacket
assert band_b.sap_heating.cylinder_insulation_thickness_mm == 12
assert band_g.sap_heating.cylinder_insulation_type == 1 # factory
assert band_g.sap_heating.cylinder_insulation_thickness_mm == 25
def test_cert_with_hot_water_cylinder_computes_primary_loss_59m_from_sap_table_3() -> None:
"""SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) define the primary
circuit loss for an indirect cylinder:
@ -5744,3 +6311,105 @@ def test_sap_appendix_d_eq_d1_water_efficiency_monthly_for_non_pcdb_table_4b_boi
f"want {expected_hw_fuel!r} per SAP 10.2 Appendix D §D2.1 (2) "
f"Equation D1 with Table 4b code 127 (winter 84%, summer 72%)"
)
def test_heat_network_community_gas_fuel_translates_epc_20_to_table12_51() -> None:
# Arrange — a community mains-gas BOILER main (SAP code 301) lodges
# main_fuel_type=20. Per epc_codes.csv (RdSAP-Schema-17.0) EPC fuel 20
# is "mains gas (community)", but the SAP Table 12 / Table 32 numbering
# uses 20 for a solid biomass fuel — a collision. The factor lookups
# check the Table-12 dict first, so co2_factor_kg_per_kwh(20) returns
# the biomass 0.028 instead of community mains gas 0.210. The
# heat-network fuel-code translator must route EPC 20 → Table 12 51.
from domain.sap10_calculator.tables.table_12 import (
co2_factor_kg_per_kwh,
primary_energy_factor,
)
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20, # EPC "mains gas (community)"
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6, # heat network
sap_main_heating_code=301, # community boilers
)
# Act
code = _heat_network_factor_fuel_code(main)
# Assert — translates to Table 12 code 51 (community mains gas), and the
# factor lookups then return the worksheet-validated case-14 values
# ((367) CO2 0.2100, (467) PE 1.1300), NOT the collided biomass factors.
assert code == 51
assert abs(co2_factor_kg_per_kwh(code) - 0.210) <= 1e-9
assert abs(primary_energy_factor(code) - 1.130) <= 1e-9
assert abs(co2_factor_kg_per_kwh(20) - 0.028) <= 1e-9 # the collided value
def test_non_heat_network_biomass_fuel_not_translated() -> None:
# Arrange — a NON-heat-network main lodging the same integer fuel code
# must NOT be translated: a genuine biomass boiler keeps its raw
# Table-12 factor. The translator only fires for heat-network mains.
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20,
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2, # ordinary boiler, NOT heat network
sap_main_heating_code=102,
)
# Act
code = _heat_network_factor_fuel_code(main)
# Assert — unchanged (raw code, biomass factor preserved).
assert code == 20
def test_heat_network_space_and_water_standing_charge_is_full_120() -> None:
# Arrange — a heat-network SPACE main (SAP code 301) carries the full
# Table 12 (PDF p.191) heat-network standing charge of £120/yr per
# §C3.2 ("the total standing charge is the normal heat network standing
# charge" when space heating is on the network). Worksheet-validated:
# case 14 (community boilers + mains gas, space + water) → (351) £120.
# The epc is not consulted on this branch (heat-network space main wins
# first), so a minimal epc suffices.
epc = _typical_semi_detached_epc()
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=20, # EPC mains gas (community)
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2306,
main_heating_category=6,
sap_main_heating_code=301,
)
# Act
standing = _heat_network_standing_charge_gbp(epc, main)
# Assert
assert standing is not None
assert abs(standing - 120.0) <= 1e-9
def test_non_heat_network_main_returns_none_so_caller_uses_fuel_standing() -> None:
# Arrange — a non-heat-network gas-boiler main must NOT draw the
# heat-network standing branch; the helper returns None so the caller
# falls back to the fuel-based `additional_standing_charges_gbp`.
epc = _typical_semi_detached_epc()
main = MainHeatingDetail(
has_fghrs=False,
main_fuel_type=26, # mains gas (not community)
heat_emitter_type=1,
emitter_temperature=1,
main_heating_control=2106,
main_heating_category=2,
sap_main_heating_code=102,
)
# Act / Assert
assert _heat_network_standing_charge_gbp(epc, main) is None

View file

@ -41,6 +41,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
cert_to_demand_inputs,
cert_to_inputs,
heat_transmission_section_from_cert,
)
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden"
@ -82,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-1,
expected_pe_resid_kwh_per_m2=+5.8007,
expected_co2_resid_tonnes_per_yr=+0.3173,
expected_pe_resid_kwh_per_m2=+1.5181,
expected_co2_resid_tonnes_per_yr=+0.0728,
notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
@ -120,7 +121,103 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"extract-fans default (age J, 4 hab rooms → 2 fans). "
"Cascade ventilation HLC rises ~0.07 ACH × volume → SH "
"demand rises proportionally; PE +2.5225 → +5.8007, CO2 "
"+0.1395 → +0.3173. SAP integer unchanged at 72."
"+0.1395 → +0.3173. SAP integer unchanged at 72. "
"Slice S0380.196 (the 6035 RR fix) also applies here: this "
"cert's `room_in_roof_type_1` lodges two gables (L=6.4, both "
"Party) with no height, previously dropped → roof over-count. "
"Routing them through `detailed_surfaces` deducts 2×(6.4×2.45) "
"from the A_RR shell → roof drops, tightening PE +5.8007 → "
"+3.9138, CO2 +0.3173 → +0.2213. SAP integer unchanged at 72. "
"Slice S0380.198 CLOSED the SAP: this cert lodges 6 windows "
"with `window_wall_type=4` = roof windows ('Roof of Room' "
"rooflights). The API mapper had flattened them into "
"`sap_windows` (vertical glazing, (27), U=2.0); they belong on "
"(27a) at the Table 6e Note 2 inclination-adjusted U=2.30 with "
"45°-inclined solar gains. Validated against the simulated-"
"case-6 worksheet ((27a) U_eff 2.1062). The inclined solar "
"gain dominates → SAP cont 72.14 → 72.55 (resid -1 → +0 "
"EXACT), PE +3.9138 → +1.9459, CO2 +0.2213 → +0.1226. "
"Slice S0380.201 added the SECOND main heating system's "
"circulation pump per SAP 10.2 Table 4f note c) (PDF p.175) "
"\"Where there are two main heating systems include two "
"figures from this table\" — gated on a lodged "
"main_heating_fraction > 0 (a genuine second SPACE-heating "
"main, excluding DHW-only second mains). This cert is dual-"
"main oil combi 51%/49%; Main 2 pump_age unknown (115 kWh) "
"joins Main 1's 115 → cascade pumps_fans 315 → 430 (+115 "
"kWh/yr). The fix was validated against the simulated-case-6 "
"worksheet, whose (231) = 356 decomposes as (230c) central-"
"heating pump 156 (= Main 1 41 + Main 2 115) + (230d) oil "
"boiler pump 200 — proving the two-pump treatment is spec-"
"correct. Cascade SAP cont 72.55 → 72.18 (integer 73 → 72, "
"resid +0 → -1), PE +1.9459 → +2.8092, CO2 +0.1226 → "
"+0.1385. The lodged 73 carries Elmhurst's own rounding/"
"residual (this cert is API-only with no worksheet); the "
"worksheet-backed case 6 is the spec authority for the "
"archetype per [[feedback-worksheet-not-api-reference]]. "
"Slice S0380.202 added the SECOND main's central-heating-pump "
"GAIN per SAP 10.2 Table 5a note a) (PDF p.177) \"two main "
"heating systems serving different parts ... include two "
"figures\" — the §5 (70) mirror of S0380.201's Table 4f (230c). "
"Both Main 1 + Main 2 unknown-date → (70) 7 → 14 W. The extra "
"internal gain lowers space-heating demand → SAP cont 72.18 → "
"72.24 (integer 72 unchanged), PE +2.8092 → +2.5812, CO2 "
"+0.1385 → +0.1269 (both closer to zero). Validated against "
"case 6 worksheet (70) = 10 (= 3 Main 1 + 7 Main 2). "
"Slice S0380.203 routed this cert's 6 'Roof of Room' rooflights "
"(window_wall_type=4) to deduct from the §3.10.1 RR residual "
"instead of the regular roof (the case-6 worksheet rule). 0240 "
"is detailed-RR gables-only like case 6 → roof drops → space-"
"heating demand falls → PE +2.5812 → +2.1519, CO2 +0.1269 → "
"+0.1051 (both closer to zero; SAP integer 72 unchanged). "
"Slice S0380.205 applied the SAP 10.2 p.186 two-systems-"
"different-parts MIT (Main 1 2106 type 2 / Main 2 2110 type 3, "
"emitter 2 R=0.75): weighted responsiveness 0.8775 + elsewhere "
"two-control blend. Lowers MIT ~0.037 °C → space-heating demand "
"falls → PE +2.1519 → +1.6893, CO2 +0.1051 → +0.0815 (both "
"closer to zero; SAP integer 72 unchanged). Verified 1e-4 "
"against the case-6 worksheet (87)/(90)/(98c). "
"Slice S0380.206 fed Eq D1 the DHW boiler's OWN (204) space "
"share (Main 1 = 51%) instead of the dwelling total (202) — "
"the worksheet-validated case-6 fix that lands its (219) HW "
"exact. For 0240 this raises HW fuel slightly → PE +1.6893 → "
"+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged "
"73 carries Elmhurst's own residual; case 6 is the spec "
"authority per [[feedback-worksheet-not-api-reference]]. "
"Slice S0380.209 fixed the API-path wall U: the EPC renders "
"this cert's sandstone (band J, As Built) wall as 'insulated "
"(assumed)', which the cascade wrongly routed to the 50 mm "
"retrofit row (U 0.25). Per RdSAP 10 Table 8/9 footnote the "
"50 mm row is only for insulation 'known to have been "
"increased subsequently'; an 'as built ... (assumed)' "
"description is the age-band assumption (renders only on "
"recent bands) → as-built row U 0.35. Worksheet-validated by "
"simulated case 9 (sandstone J → 0.35) + case 10 (solid brick "
"J → 0.35). walls 24.45 → 34.23 W/K → PE +1.8687 → +5.5044, "
"CO2 +0.0907 → +0.2757 (SAP 72 unchanged). This spec-correct "
"fix REMOVED the wall under-count that was masking the Ext1 "
"vaulted-roof over-count (cascade U 0.68 via the same "
"'insulated (assumed)' description vs case-9 sloping-ceiling "
"0.25) — that roof over-count is the next slice; fixing both "
"lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 "
"requires a 2013+ pump (case 7); 0240's API lodges the pump "
"as Unknown (code 0 → 115, proven 0=Unknown across 9 API+"
"Summary pairs), so 73 is unreachable from the lodged inputs. "
"Slice S0380.211 fixed the Ext1 vaulted-roof over-count S0380.209 "
"exposed: BP2 lodges roof_construction=5 (vaulted), NI thickness, "
"'Pitched, insulated (assumed)', band J → the cascade returned "
"U 0.68 (the §5.11.4 retrofit-50 mm joist row). A vaulted ceiling "
"has no joist void, so per RdSAP 10 Table 18 it takes the column "
"(1) age-band default (band J = 0.16) — the SAME value the 33 "
"cohort-2 'ND' vaulted roofs (code 5, band D → 0.40 = col 1) "
"reach by falling through. New u_roof `is_sloping_ceiling` flag "
"(threaded from heat_transmission for codes 5/8) routes the 'NI' "
"variant to col (1) too. roof 76.93 → ~68 W/K → PE +5.5044 → "
"+1.5181, CO2 +0.2757 → +0.0728 (SAP integer 72 unchanged — the "
"true value; the lodged 73 needs the unpreserved 2013+ pump). "
"NB the S0380.209 note's predicted 'cont ≈ 72.31 (case 9, U 0.25 "
"col 3)' was an unconfirmed guess; the cohort's 'ND' vaulted "
"roofs are the arbiter and use col (1) 0.16 → cont 72.4617."
),
),
_GoldenExpectation(
@ -154,9 +251,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0390-2954-3640-2196-4175",
actual_sap=60,
expected_sap_resid=+7,
expected_pe_resid_kwh_per_m2=-27.9745,
expected_co2_resid_tonnes_per_yr=-2.7134,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=+0.5281,
expected_co2_resid_tonnes_per_yr=-0.1189,
notes=(
"Detached, TFA 360, age F, Firebird oil combi PCDF 9005 "
"(winter eff 86.4%). PCDB record lodges separate_dhw_tests=0 + "
@ -187,15 +284,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"Slice S0380.151 wired RdSAP 10 §4.1 Table 5 (PDF p.28) "
"extract-fans default (age F → 1 fan). Cascade ventilation "
"HLC rises ~0.03 ACH × volume; PE -28.0830 → -27.9745 "
"(closer to zero), CO2 -2.7342 → -2.7134."
"(closer to zero), CO2 -2.7342 → -2.7134. "
"Slice S0380.210 CLOSED the residual: the Main cavity wall lodges "
"wall_insulation_type=4 (as-built/assumed) + description "
"'Cavity wall, as built, partial insulation (assumed)'. The "
"cascade mis-routed it to the Table 6 'Filled cavity' row "
"(band F = 0.40) via the 'partial insulation' substring; "
"RdSAP 10 Table 6 (England) routes an as-built partial-fill "
"cavity to the 'Cavity as built' row (band F = 1.0). New "
"`_cavity_described_as_filled` excludes 'partial insulation' "
"(keeping 'insulated (assumed)' → filled). Wall HLC +53.6 W/K "
"(0.40 → 1.0 over 268 m²) lifted all four metrics together: "
"SAP +7 → +0, PE -27.9745 → +0.5281, CO2 -2.7134 → -0.1189."
),
),
_GoldenExpectation(
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=-2,
expected_pe_resid_kwh_per_m2=+19.1566,
expected_co2_resid_tonnes_per_yr=+0.4211,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.1357,
expected_co2_resid_tonnes_per_yr=-0.0362,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"S0380.189 fixed the dominant driver: walls are solid brick "
@ -223,15 +331,47 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"WHC=901 + main code 104). Eq D1 monthly blend (mean ~80%) "
"produces ~150 kWh/yr more HW fuel than the pre-slice flat-"
"winter calc → PE residual +46.0952 → +47.2928, CO2 +1.0495 "
"→ +1.0779."
"→ +1.0779. "
"Slice S0380.196 CLOSED the residual (the prior 'lodged "
"divergence' claim is RETRACTED — it was a real API-mapper "
"bug). The API `room_in_roof_type_1` block lodges two gable "
"walls (L=4.65 each) but no heights; the mapper carried only "
"the scalar lengths, and the cascade's `_part_geometry` gable "
"formula silently drops height-less gables → the whole "
"55.67 m² A_RR shell billed as roof at U_RR=2.30 instead of "
"the §3.9.1(e) residual `12.5√(29.75/1.5) 2×11.39 = 32.89`. "
"That over-counted roof by 22.78 m² × 2.30 = +52.4 W/K (roof "
"130.73 → 78.33, matching the site-notes case-4 replica at "
"1e-4). Fix: route the Type 1 gables through `detailed_"
"surfaces` (gable area = L × the §3.9.1 default RR storey "
"height 2.45 m; Exposed → gable_wall_external, Party → "
"gable_wall) so the cascade's Detailed-RR residual fires. "
"SAP resid -2 → +0 (exact), PE +19.16 → +1.84, CO2 "
"+0.42 → +0.01. "
"Slice S0380.198 (the 0240 roof-window fix) also applies: "
"6035 lodges 2 windows with `window_wall_type=4` (room-in-roof "
"rooflights) which were billed as vertical glazing; routing "
"them to roof windows (27a) at inclined U=2.30 + 45° solar "
"tightened PE +1.84 → +1.37 and CO2 +0.01 → -0.0004 (SAP still "
"exact). "
"Slice S0380.203 CLOSED the remaining +1.37 PE (it was NOT "
"'unrelated gains/HW'): the 2 'Roof of Room' rooflights pierce "
"the room-in-roof sloped ceiling, so their 1.92 m² deducts from "
"the §3.10.1 RR residual (uninsulated U_RR=2.30) — not the "
"insulated loft (U=0.14) the S0380.198 assumption used. Roof "
"78.0648 → 73.9176 (4.42 W/K); space-heating demand drops → "
"PE +1.37 → -0.14, CO2 -0.0004 → -0.0362 (SAP still exact 70). "
"Validated against the simulated-case-6 worksheet, the only "
"worksheet evidence for 'Roof of Room' rooflight deduction "
"(6035's site-notes case-4 replica lodges no rooflights)."
),
),
_GoldenExpectation(
cert_number="7536-3827-0600-0600-0276",
actual_sap=68,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-7.0776,
expected_co2_resid_tonnes_per_yr=-0.1875,
expected_pe_resid_kwh_per_m2=-6.1952,
expected_co2_resid_tonnes_per_yr=-0.1639,
notes=(
"Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, "
"Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and "
@ -239,10 +379,31 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"age band, not per-bp) jointly tightened: SAP +4 → +3, PE "
"-27.17 → -22.53, CO2 -0.72 → -0.60. Slice 97 added "
"glazing_type=2 (Table 24 spec U=2.0): SAP 0 → +1, PE/CO2 "
"widened. The cert's actual lodged U for glazing_type=2 "
"appears higher than the spec's table default — multi-age "
"geometry probably surfaces a per-bp U-value the spec table "
"doesn't capture exactly."
"widened. Slice S0380.214 fixed the as-built sloping-ceiling "
"roof U: Ext1 (band L) and Ext2 (band F) lodge "
"roof_construction=8 'Pitched, sloping ceiling' + 'As Built', "
"which take RdSAP 10 Table 18 col (3) (L=0.18, F=0.68) not "
"col (1) (0.16/0.40) — per item 5-5 + note (b). Roof HLC "
"26.77 → 29.17 W/K; cont SAP 69.071 → 68.924, PE -7.0776 → "
"-6.1952, CO2 -0.1875 → -0.1639 (SAP integer still 68 vs "
"lodged → resid +1). Worksheet-validated by simulated case 15 "
"(the 7536 replica): our cascade on its Summary matches the "
"P960 worksheet exactly (roof 29.17, SAP 65.04 vs 65). The "
"glazing hypothesis from the prior handover was wrong — maxing "
"the glazing U past spec can't flip 69→68, and every per-bp "
"fabric U is spec-plausible. CONCLUSION (cases 15/16/17, the "
"last faithful on windows 16.98/13.59/1.89 + ground floors): "
"every per-element value matches Elmhurst — walls 0.70/0.28/"
"0.40, roofs 0.40/0.18/0.68, window U-eff 2.4368/1.8519, "
"ground floors Main 0.97 / Ext1 0.26 / Ext2 1.12. The only "
"worksheet divergences were manual-entry artifacts (case 16 "
"floor-order inversion; case 17 spurious 'to external air' "
"exposed floors auto-derived from the small-ground/big-upper "
"geometry — real 7536 lodges floor_heat_loss 2/7/3 = unheated-"
"space/ground, NOT code 1 exposed). The residual +0.92 cont "
"SAP is therefore 0240-like: an Elmhurst register-rounding "
"residual not reproducible from the API-only JSON. DO NOT "
"chase further — leave at resid +1."
),
),
_GoldenExpectation(
@ -273,9 +434,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+1,
expected_pe_resid_kwh_per_m2=-7.5579,
expected_co2_resid_tonnes_per_yr=-0.0454,
expected_sap_resid=+2,
expected_pe_resid_kwh_per_m2=-11.7236,
expected_co2_resid_tonnes_per_yr=-0.0947,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
@ -284,9 +445,28 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"from -38.63 to -9.70. Slice S0380.49 wired effective monthly "
"Table 12e PE factor (vs annual 1.501/0.501) into the PV "
"split: residual closed -9.70 → -8.22. SAP integer shifted "
"+1 (82 → 83) via the cohort cascade interaction. Remaining "
"-8.22 residual sits in gas combi PE under-count + secondary "
"heating credit (deferred)."
"+1 (82 → 83) via the cohort cascade interaction. "
"Slice S0380.215 fixed the dropped measured wall insulation: "
"Ext1 lodges solid-brick band B + INTERNAL insulation "
"`wall_insulation_thickness='measured'` with the actual 100 mm "
"in the separate `wall_insulation_thickness_measured` field "
"that the schema didn't declare, so `from_dict` discarded it "
"and the cascade fell back to the 50 mm unknown-thickness "
"default (U=0.55). Wiring it through → RdSAP 10 Table 8 U=0.32 "
"(less wall loss). This SPEC-CORRECT fix EXPOSED the offsetting "
"PV-β / gas-combi-PE under-count it had been masking: cont SAP "
"83.35 → 83.78 (resid +1 → +2), PE -7.56 → -11.72, CO2 -0.045 "
"→ -0.095. INVESTIGATED the exposed -11.72 PE (~-746 kWh/yr) "
"against simulated case 18 (a TFA-64 base + 2130's exact PV: 2× "
"2.04 kWp SE/NW, overshading 1/2): our cascade reproduces the "
"P960 worksheet's PV split EXACTLY — gen 2684.17, (233a) onsite "
"970.77, (233b) export 1713.40 to the decimal. So the Appendix "
"M1 β-split is NOT the bug; the gas PE factor is also exact "
"(Table 12 mains gas 1.13). 2130's residual is therefore the "
"irreducible API-only lodged gap (Elmhurst's own residual), "
"0240-like — NOT a closable calculator bug. The +2/-11.72 is "
"the spec-correct state once the masking wall bug is removed. "
"Leave it; do not chase."
),
),
_GoldenExpectation(
@ -486,6 +666,58 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(cert_number="9421-3045-3205-1646-6200", actual_sap=87, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3541, expected_co2_resid_tonnes_per_yr=-0.0046, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9796-3058-6205-0346-9200", actual_sap=90, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.3533, expected_co2_resid_tonnes_per_yr=-0.0013, notes="Cohort-2 baseline pin captured by S0380.69."),
_GoldenExpectation(cert_number="9836-7525-9500-0575-1202", actual_sap=75, expected_sap_resid=+0, expected_pe_resid_kwh_per_m2=-0.1132, expected_co2_resid_tonnes_per_yr=+0.0011, notes="Cohort-2 baseline pin captured by S0380.69."),
# ------------------------------------------------------------------
# "with api 3" — 2 fresh API+Summary+worksheet triples cross-validated
# by S0380.218. Both fetched fresh from the GOV.UK EPB register, run
# through BOTH front-ends (`from_api_response` + `from_elmhurst_site_
# notes`), and pinned against the dr87 worksheet. The two paths agree
# to <1e-4 on all four metrics (cross-mapper parity per
# [[feedback-cross-mapper-parity-via-cascade]]) AND reproduce the
# worksheet's (255) cost / (272) CO2 / (286) PE exactly — see the
# +0.0000 worksheet pins in `_WORKSHEET_PE_CO2`. The dropped-field
# audit on both fresh JSONs surfaced no new silently-dropped schema
# fields (only `created_at` metadata + the shower keys handled by
# `_normalize_shower_outlets`). The PE/CO2 residuals below are vs the
# integer-rounded lodged register (`energy_consumption_current` /
# `co2_emissions_current`); the worksheet pins are the load-bearing
# 1e-4 check.
# ------------------------------------------------------------------
_GoldenExpectation(
cert_number="0340-2467-9260-2006-6521",
actual_sap=70,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.4054,
expected_co2_resid_tonnes_per_yr=-0.0250,
notes=(
"Semi-detached house, TFA 107.26, mains-gas PCDB-listed boiler "
"(index 15709, no Table 4b code), no PV, 1 building part, 17 "
"windows. S0380.218 cross-validation: API path ≡ Summary path "
"to <1e-4 on SAP/cost/CO2/PE; cascade reproduces the dr87 "
"worksheet (255) cost 776.4295 / (272) CO2 2875.0498 / (286) PE "
"16474.5616 exactly (see `_WORKSHEET_PE_CO2`). Cascade SAP "
"integer 70 = lodged (resid +0); cont 70.1228. PE/CO2 resids "
"below are vs the integer-rounded lodged register."
),
),
_GoldenExpectation(
cert_number="5500-5070-0822-0201-3663",
actual_sap=66,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.2909,
expected_co2_resid_tonnes_per_yr=+0.0235,
notes=(
"Semi-detached house + 1 extension, TFA 82.88, mains-gas "
"PCDB-listed boiler (index 17741, no Table 4b code), no PV, 2 "
"building parts, 13 windows. S0380.218 cross-validation: API "
"path ≡ Summary path to <1e-4 on SAP/cost/CO2/PE; cascade "
"reproduces the dr87 worksheet (255) cost 751.8295 / (272) CO2 "
"2423.4547 / (286) PE 14397.0118 exactly (see "
"`_WORKSHEET_PE_CO2`). Cascade SAP integer 66 = lodged (resid "
"+0); cont 65.5539. PE/CO2 resids below are vs the integer-"
"rounded lodged register."
),
),
)
@ -517,7 +749,8 @@ class _WorksheetPin:
expected_co2_resid_kg: float
# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). calc ≡
# The 49 worksheet-validated certs (9 ASHP + 38 cohort-2 + 2 "with api
# 3", the last pair added by S0380.218). calc ≡
# worksheet on BOTH PE and CO2 at <1e-4 across the ENTIRE cohort
# (every expected_*_resid below is 0.0000) — the SAP 10.2 1e-4
# convergence target, met. Closed over two slices:
@ -542,6 +775,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = (
_WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0340-2467-9260-2006-6521", ws_pe_kwh_per_m2=153.5946, ws_co2_kg_per_yr=2875.0498, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
@ -567,6 +801,7 @@ _WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = (
_WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="5500-5070-0822-0201-3663", ws_pe_kwh_per_m2=173.7091, ws_co2_kg_per_yr=2423.4547, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
_WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000),
@ -731,3 +966,75 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number(
assert main.main_heating_index_number == expected_pcdb_id
if expected_winter_eff is not None:
assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3
def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None:
"""Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP
API code for a roof window ("Roof of Room" rooflight / inclined
glazing), distinct from main-wall (1) and alternative-wall (2/3)
windows. They belong on worksheet line (27a) Roof Windows at the
Table 6e Note 2 inclination-adjusted U (DG 2002+ vertical 2.0 + 0.30
= 2.30 W/m²K), with 45°-inclined solar gains NOT on (27) as vertical
wall windows at U=2.0.
Before the fix the API mapper flattened all windows into
`sap_windows`, so these 6 billed as vertical glazing (wrong U *and*
wrong solar). Validated against the simulated-case-6 worksheet, which
bills the identical 6 windows on (27a) at U_eff 2.1062 (= 2.30 with
the §3.2 R=0.04 curtain transform).
"""
# Arrange
doc = _load_cert("0240-0200-5706-2365-8010")
# Act
epc = EpcPropertyDataMapper.from_api_response(doc)
# Assert — the 6 wall_type=4 windows route to roof windows; the other
# 5 (wall_type=1, main wall) stay vertical.
assert epc.sap_roof_windows is not None
assert len(epc.sap_roof_windows) == 6
assert len(epc.sap_windows) == 5
assert all(abs(rw.u_value_raw - 2.30) <= 1e-9 for rw in epc.sap_roof_windows)
def test_6035_api_room_in_roof_gables_deduct_from_roof() -> None:
"""Cert 6035 lodges a Simplified Type 1 room-in-roof (`room_in_roof_
type_1`) with two gable walls (L=4.65 each). Per RdSAP 10 §3.9.1(e)
(PDF p.21) the gable areas deduct from the A_RR shell the residual
roof area is `12.5(A_RR_floor/1.5) Σ gables`, NOT the full shell.
The API mapper must route these scalar gables through
`detailed_surfaces` (gable area = L × the §3.9.1 default RR storey
height 2.45 m) so the cascade's Detailed-RR residual fires, exactly
as the site-notes path does. Before the fix the gables (no lodged
height) were silently dropped the whole 55.67 shell billed at
U_RR=2.30, a +52 W/K roof over-count and the entire 6035 residual.
Cross-mapper parity: this is the value the site-notes case-4 replica
(`worksheet/_elmhurst_worksheet_001431_6035.py`) pins to its
worksheet at 1e-4:
loft (41.7329.75=11.98) × U_roof(300 mm) = 1.6772
ext (7.21) × U_roof(300 mm) = 1.0094
RR residual (55.67 2×11.39 = 32.89) × U_RR(age A=2.30) = 75.647
78.3336 W/K
"""
# Arrange
doc = _load_cert("6035-7729-2309-0879-2296")
epc = EpcPropertyDataMapper.from_api_response(doc)
# Act
roof_w_per_k = heat_transmission_section_from_cert(epc).roof_w_per_k
# Assert — 78.3336 (gable-deducted residual + loft + ext roof). The 2
# room-in-roof rooflights (window_wall_type=4 = "Roof of Room", 1.92 m²)
# pierce the RR sloped ceiling, so per S0380.203 their area deducts from
# the §3.10.1 residual (at the uninsulated U_RR=2.30) — NOT the insulated
# loft at U_roof=0.14 as the unvalidated S0380.198 assumption had it.
# 78.3336 1.92 × 2.30 = 78.3336 4.416 = 73.9176. The rooflights' own
# A×U stays on roof_windows_w_per_k. This matches the simulated-case-6
# worksheet, where the only worksheet evidence for "Roof of Room"
# rooflight deduction shows them billed against "Roof room remaining"
# (the RR residual), not the flat/loft roof. Cert 6035 is API-only and
# its site-notes case-4 worksheet replica lodges no rooflights, so the
# case-6 worksheet is the spec authority for this archetype.
assert abs(roof_w_per_k - 73.9176) <= 1e-4

View file

@ -17,12 +17,35 @@ from domain.sap10_calculator.tables.table_12a import (
Table12aSystem,
Tariff,
other_use_high_rate_fraction,
rdsap_tariff_for_cert,
space_heating_high_rate_fraction,
tariff_from_meter_type,
water_heating_high_rate_fraction,
)
def test_dual_meter_electric_room_heater_resolves_to_ten_hour_tariff() -> None:
# Arrange — RdSAP 10 §12 (PDF p.62) Dual-meter tariff dispatch: for a
# Dual meter the choice between 7-hour and 10-hour is made by the main
# heating type. Rule 3 verbatim: "if the main system ... is a direct-
# acting electric boiler (191), or electric room heaters ... it is
# 10-hour tariff." The electric room-heater codes are Table 4a 691
# (panel/convector/radiant), 692 (fan), 693 (portable), 694 (water-/
# oil-filled), 699 (assumed). Pre-slice these fell through to Rule 4
# (7-hour default), so a Dual-meter room-heater cert was billed at the
# 7-hour rates with Table 12a high-rate fraction 1.00 instead of the
# 10-hour rates with fraction 0.50 — over-charging direct-acting heat
# once S0380.230 routed it to OTHER_DIRECT_ACTING_ELECTRIC.
# Act / Assert — every electric room-heater code on a Dual meter → 10-hour.
for code in (691, 692, 693, 694, 699):
assert rdsap_tariff_for_cert(1, main_1_sap_code=code) is Tariff.TEN_HOUR
# Storage heaters (Rule 2) stay 7-hour; a gas room heater (non-Rule-3
# code) keeps the Rule 4 Dual default of 7-hour.
assert rdsap_tariff_for_cert(1, main_1_sap_code=401) is Tariff.SEVEN_HOUR
assert rdsap_tariff_for_cert(1, main_1_sap_code=601) is Tariff.SEVEN_HOUR
def test_tariff_enum_has_five_members() -> None:
"""Table 12a columns: standard (no off-peak split), 7-hour, 10-hour,
18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for

View file

@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -98,6 +100,8 @@ def build_epc() -> EpcPropertyData:
)
extension_1 = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -130,6 +134,8 @@ def build_epc() -> EpcPropertyData:
)
extension_2 = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_2,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=3,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -63,6 +63,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -64,6 +64,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -133,6 +135,8 @@ def build_epc() -> EpcPropertyData:
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -60,6 +60,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4, # "A As Built"
@ -130,6 +132,8 @@ def build_epc() -> EpcPropertyData:
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -65,6 +65,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,
@ -97,6 +99,8 @@ def build_epc() -> EpcPropertyData:
)
extension = SapBuildingPart(
identifier=BuildingPartIdentifier.EXTENSION_1,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=4,
construction_age_band="B",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -69,6 +69,8 @@ def build_epc() -> EpcPropertyData:
"""
main = SapBuildingPart(
identifier=BuildingPartIdentifier.MAIN,
# API parity: roof_construction int mirrors the gov-EPC mapper
roof_construction=3,
construction_age_band="A",
wall_construction=_WC_CAVITY,
wall_insulation_type=4,

View file

@ -0,0 +1,114 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 4" worksheet a near-exact replica of golden cert
6035 (Main + Extension + Simplified room-in-roof, 8 windows).
Like 000565 / sim case 1 / sim case 2, this fixture does NOT hand-build
the EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result
pin grid exercises the WHOLE extractor + mapper + calculator pipeline.
Purpose: prove the calculator is spec-correct for the 6035 archetype
(after S0380.192 Simplified-RR + S0380.193 suspended-floor fixes). This
cert reproduces 6035's full floor geometry — Main ground-floor HLP
15.99 m AND first-floor HLP 8.32 m (the asymmetric upper-storey
perimeter) plus 6035's 8 windows (≈14.15 m²). Two minor inputs still
differ from 6035 (the largest window's orientation is N here vs S in
6035; meter type "Dual" vs API code 2), accounting for a residual ~50
kWh / £11 cascade delta both are lodged inputs, not calculator
behaviour. All 11 Block-1 line refs pin at abs=1e-4 against this cert's
OWN worksheet, confirming the cascade reproduces the spec engine exactly
for 6035's geometry — so 6035's residual +19 PE vs the lodged register
is lodged-register divergence, not a cascade gap.
Cert shape: Main + Extension 1, both solid brick WITH internal
insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof
on the Main (floor 29.75 , exposed + party gables), suspended
uninsulated ground floors (Main ground HLP 15.99 / first 8.32),
gas-combi SAP code 104, 8 windows, no PV.
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 4/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_6035.pdf`
(distinct name the corpus reuses cert 001431).
Worksheet pin targets (P960-0001-001431, Block 1 energy rating):
- SAP rating 68 (line 258), ECF 2.2802 (line 257)
- Total fuel cost £937.2341 (line 255)
- CO2 4682.3494 kg/year (line 272)
- Space heating 15745.3260 kWh/year (Σ monthly (98))
- Main 1 fuel 18744.4357 kWh/year (line 211)
- Secondary fuel 0.0 (line 215)
- Hot water fuel 3307.8383 kWh/year (line 219)
- Lighting 262.0885 kWh/year (line 232)
- Pumps/fans 86.0 kWh/year (line 231)
Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation-
philosophy]]: pins are abs=1e-4 against the worksheet PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_6035.pdf"
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label\\nvalue sequences).
Mirror of the helper in `test_summary_pdf_mapper_chain.py` /
`_elmhurst_worksheet_000565.py`.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-2 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. Exercises the S0380.192 Simplified-RR fix and the
S0380.193 suspended-floor sealed-rule fix.
"""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -0,0 +1,122 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 5" worksheet a DETACHED, SANDSTONE-walled cousin of
golden cert 0240 (Main + Extension + room-in-roof, age band J).
Like the other 001431 cases, this fixture does NOT hand-build the
EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result
pin grid exercises the WHOLE extractor + mapper + calculator pipeline.
Purpose: prove the calculator is spec-correct for a DETACHED room-in-roof
with one Exposed + one Party gable, validating S0380.196 (API Simplified
Type 1 RR gables deduct from the A_RR shell) against a real worksheet.
The worksheet prints the exact routing the cascade implements:
Roof room Main Gable Wall 1 15.68 U=0.35 (29a) Exposed walls @ main-wall U
Roof room Main remaining area 61.73 U=0.30 (30) A_RR shell Σ gables (residual)
External roof Main 14.52 U=0.11 (30) loft residual
Roof room Main Gable Wall 2 15.68 U=0.25 (32) Party party @ 0.25
gable area = 6.40 × 2.45 = 15.68 (the §3.9.1 default RR storey height).
A_RR remaining = 12.5(83.2/1.5) 2×15.68 = 93.09 31.36 = 61.73.
This case surfaced two extractor/mapper gaps fixed in the same slice
(S0380.197):
- the sandstone wall label "SS Stone: sandstone or limestone" had no
`_ELMHURST_WALL_CODE_TO_SAP10` entry ( WALL_STONE_SANDSTONE=2, matching
0240's API `wall_construction=2`);
- the roof "Insulation Thickness 400+ mm" was silently dropped by the
extractor's `.split()[0].isdigit()` thickness parse (the trailing "+"),
so u_roof fell back to the age-J default 0.16 instead of 0.11
(`_parse_thickness_mm` now strips to leading digits).
Cert shape: Detached house, Main + Extension 1, sandstone insulated walls,
2 storeys + room-in-roof on the Main (floor 83.2 , one Exposed + one
Party gable, L=6.40 each), oil community/boiler (SAP code 901 combi route,
control 2106), no PV, 20 low-energy lighting bulbs.
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 5/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case5.pdf`.
Worksheet pin targets (P960-0001-001431, Block 1 energy rating):
- SAP rating 61 (line 258), ECF 2.7724 (line 257)
- Total fuel cost £1586.4549 (line 255)
- CO2 8387.6229 kg/year (line 272)
- Space heating 12838.6489 kWh/year (Σ monthly (98))
- Main 1 fuel 21397.7480 kWh/year (line 211)
- Secondary fuel 0.0 (line 215)
- Hot water fuel 6498.2518 kWh/year (line 219)
- Lighting 381.4601 kWh/year (line 232)
- Pumps/fans 141.0 kWh/year (line 231)
Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation-
philosophy]]: pins are abs=1e-4 against the worksheet PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case5.pdf"
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label\\nvalue sequences).
Mirror of the helper in `test_summary_pdf_mapper_chain.py` /
`_elmhurst_worksheet_000565.py`.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-5 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. Exercises the S0380.196 RR-gable deduction, the
S0380.197 sandstone-wall-label + "400+ mm" roof-thickness fixes.
"""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -0,0 +1,156 @@
"""Mapper-driven cascade fixture for the Elmhurst P960-0001-001431
"simulated case 6" worksheet a DETACHED, dual-oil cousin of golden
cert 0240 carrying ROOM-IN-ROOF WINDOWS (rooflights).
Routes the Summary PDF through ElmhurstSiteNotesExtractor +
from_elmhurst_site_notes (no hand-built EpcPropertyData) so the pin
exercises the whole extractor + mapper + calculator pipeline.
Purpose: validate S0380.198/199 ROOF-WINDOW handling against a real
worksheet. Case 6 lodges 6 windows on the room-in-roof ("Roof of Room"
location); the worksheet bills them on line (27a) Roof Windows at
U_eff 2.1062 (= inclined 2.30 with the §3.2 R=0.04 curtain transform),
NOT on (27) as vertical glazing. This is the site-notes mirror of
0240's API `window_wall_type=4` roof windows (S0380.198).
This cert surfaced two site-notes gaps fixed in S0380.199:
- the extractor mangled the "Roof of Room in Roof" window-location cell
into the glazing-type phrase ("Double between 2002 Roof of Room and
2021 in Roof" → UnmappedElmhurstLabel); `_parse_window_from_anchors`
now detects + strips those tokens and marks the window roof-of-room;
- `_is_elmhurst_roof_window` gained a "Roof of Room" location branch,
and `_ELMHURST_ROOF_WINDOW_U_BY_GLAZING` an entry for the
already-inclined "Double between 2002 and 2021" 2.30 (so the
inclination adjustment isn't double-applied).
SCOPE: promoted to a FULL SapResult e2e fixture (S0380.207) once the dual
main heating system was fully modelled. Case 6 has Main 1 radiators (51%,
control 2106) + Main 2 underfloor (49%, control 2110) heating different
parts. Closing every line ref took: Table 4f note c) two circulation
pumps (231) S0380.201; Table 5a note a) two pump gains (70) S0380.202;
RdSAP §3.7 "Roof of Room" rooflights §3.10.1 RR residual (30) S0380.203;
SAP 10.2 p.186 two-systems-different-parts MIT weighted responsiveness
0.8775 + elsewhere two-control blend (87)/(90)/(98c) S0380.205 (with
the Main 2 emitter/control extractor fix S0380.204); and Eq D1 per-boiler
(204) space share (219) S0380.206. SapResult pins (Block 1 energy rating)
live in `test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case6"]`;
`main_heating_fuel_kwh_per_yr` is the (211)+(213) two-system sum. Heating
is SAP code 127 (vs 0240's 130 condensing combi) — case 6 pins to its OWN
worksheet, the spec authority for this dual-oil archetype.
The §3 window line refs (27)/(27a)/(31), the roof (30), the pumps (231),
the pump gains (70), the per-system fuel (211)/(213), and HW (219) also
have dedicated section pins in `test_section_cascade_pins.py`.
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 6/`. Summary mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case6.pdf`.
Worksheet §3 window pin targets (P960-0001-001431, Block 1):
- (27) Windows = 19.3704 (Main) + 3.3704 (Ext1) = 22.7408 W/K
- (27a) Roof Windows = 6.19 × 2.1062 = 13.0375 W/K (the 6 rooflights)
- (31) Total external element area = 336.13
Per [[feedback-zero-error-strict]]: pins are abs=1e-4 against the PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case6.pdf"
)
# Worksheet §3 window line refs (Block 1 — energy rating).
LINE_27_WINDOWS_W_PER_K: Final[float] = 22.7408
LINE_27A_ROOF_WINDOWS_W_PER_K: Final[float] = 13.0375
LINE_31_TOTAL_EXTERNAL_AREA_M2: Final[float] = 336.13
# Worksheet (30) Roof total W/K = RR remaining (net of the 6.19 m² roof
# windows) 55.54 × 0.30 = 16.6620 + External roof Main 14.52 × 0.11 =
# 1.5972 + External roof Ext1 7.21 × 0.11 = 0.7931 → 19.0523. The 6 "Roof
# of Room" rooflights pierce the room-in-roof sloped ceiling, so their
# area deducts from the RR residual area, NOT the external flat roof.
LINE_30_ROOF_W_PER_K: Final[float] = 19.0523
# Worksheet (231) "Total electricity for the above, kWh/year" (Block 1).
# Decomposes as (230c) central heating pump 156 + (230d) oil boiler pump
# 200. (230c) = 41 (Main 1 circ pump, "2013 or later") + 115 (Main 2 circ
# pump, unknown date) — the two-main-system circulation-pump pair per
# SAP 10.2 Table 4f note c. (230d) = 2 × 100 oil-boiler aux (already
# wired in `_table_4f_additive_components`).
LINE_231_PUMPS_FANS_KWH: Final[float] = 356.0
# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). The dual
# oil boiler heats different parts (Main 1 radiators/2106 living 51%, Main
# 2 underfloor/2110 elsewhere 49%) — the SAP 10.2 p.186 two-systems-
# different-parts MIT (weighted R 0.8775 + elsewhere two-control blend)
# lands (98c) demand 11991.96 exact, so the per-system fuels pin.
LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7741.6458
LINE_213_MAIN_2_FUEL_KWH: Final[float] = 6995.3106
# Worksheet (219) water-heating fuel (kWh/yr). The DHW boiler is Main 1
# (WHC 901), which provides only 51% of space heating, so SAP 10.2
# Appendix D Eq D1 weights η_winter by Main 1's (204) share — not the
# dwelling total — when blending the monthly water-heater efficiency.
LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 4902.8601
# Worksheet (70) "Pumps, fans" internal-gain (W), heating-season only
# (Jun-Sep = 0). = 10 W = the two-main-system central-heating-pump pair
# per SAP 10.2 Table 5a note a): Main 1 ("2013 or later" → 3 W) + Main 2
# (unknown date → 7 W). The pre-S0380.202 cascade billed a single Main 1
# pump (3 W).
LINE_70_PUMPS_FANS_GAINS_W: Final[tuple[float, ...]] = (
10.0, 10.0, 10.0, 10.0, 10.0, 0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0,
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (mirror of the case-5 helper)."""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-6 Summary through extractor + mapper."""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -0,0 +1,134 @@
"""Mapper-driven cascade fixture for the Elmhurst P960-0001-001431
"simulated case 7" worksheet the CONDENSING-OIL-COMBI variant of
[[case 6]], generated to validate the combi HW + space efficiency path
that golden cert 0240-0200-5706-2365-8010 exercises.
Routes the Summary PDF through ElmhurstSiteNotesExtractor +
from_elmhurst_site_notes (no hand-built EpcPropertyData) so the pin
exercises the whole extractor + mapper + calculator pipeline.
WHY THIS FIXTURE EXISTS
-----------------------
Case 6 is SAP code 127 ("Condensing oil *boiler*", regular) + a 110 L
cylinder so it never exercised the COMBI instantaneous-DHW efficiency
path. 0240 is SAP code 130 ("Condensing combi oil boiler") with NO
cylinder. Case 7 is case 6 with that single difference swapped in:
- both mains SAP code 130 (Table 4b winter 82 / summer 73);
- NO hot-water cylinder combi instantaneous DHW (WHC 901), Table 3a
keep-hot combi loss (61), no primary/storage loss;
- boiler interlock PRESENT (combi + room thermostat 2106, no cylinder)
NO 5pp penalty, base eff 82/73 the OPPOSITE of case 6.
The dual-main rads(2106, 51%) + UFH(2110, 49%) different-parts structure,
the 6 "Roof of Room" rooflights, and the fabric are unchanged from case 6.
WHAT IT PROVED
--------------
The cascade reproduces the case-7 worksheet EXACTLY at abs=1e-4 on every
top-level output with ZERO calculator changes the condensing-combi
(130) + no-cylinder + dual-main + Appendix D Eq D1 path is already
correct. This fixture is a regression lock on that path; it did NOT
require a fix. (It also exonerates the combi mechanism as the source of
0240's API-path residual — see docs/HANDOVER_0240_CLOSURE.md.)
Combi-path worksheet line refs (P960-0001-001431, Block 1):
- (206)/(207) main space-heating efficiency = 82.0000 / 82.0000 (base,
interlock present, no 5pp).
- (216) water-heater efficiency (summer base) = 73.0000.
- (217)m water-heater monthly efficiency = combi blend 73.00 80.18.
- (61)m combi loss = 50.9589 (Jan) = 600 kWh/yr flat (Table 3a
keep-hot, daily HW volume > 100 L every month so the "no keep-hot"
fu-scaling collapses to 1.0).
- (59)m primary loss = 0 and storage loss = 0 (combi, no cylinder).
- (211) space-heating fuel main 1 = 7865.4304.
- (213) space-heating fuel main 2 = 7556.9821.
- (219) water-heating fuel = 3496.8121.
- (64) HW demand total = 2712.0619 (smaller dwelling than 0240's
2842.82 case 7 validates the combi *mechanism*, not 0240's absolute
demand).
Per [[feedback-zero-error-strict]]: e2e pins are abs=1e-4 against the PDF
(see test_e2e_elmhurst_sap_score.py::_FIXTURE_PINS["001431_case7"]).
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 7/`. Summary mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_case7.pdf`.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_case7.pdf"
)
# Worksheet (211)/(213) per-system space-heating fuel (kWh/yr). Both mains
# are condensing oil combis (SAP code 130, Table 4b 82/73) at base
# efficiency — interlock present (combi + room thermostat, no cylinder),
# so NO 5pp penalty (the case-6 boiler+cylinder had no cylinder stat → a
# 5pp penalty; the combi removes it).
LINE_211_MAIN_1_FUEL_KWH: Final[float] = 7865.4304
LINE_213_MAIN_2_FUEL_KWH: Final[float] = 7556.9821
# Worksheet (219) water-heating fuel (kWh/yr). Combi instantaneous DHW
# (WHC 901) — SAP 10.2 Appendix D Eq D1 blends the monthly water-heater
# efficiency (217)m by the DHW boiler's (204) space share; Table 3a
# keep-hot combi loss (61) = 600 kWh/yr; no primary/storage loss.
LINE_219_HOT_WATER_FUEL_KWH: Final[float] = 3496.8121
# Worksheet (206)/(207) main space-heating efficiency — base 82, no
# 5pp (interlock present). Watch these if the pin ever regresses: a
# silent interlock flip drops them to 77/68.
LINE_206_MAIN_1_EFFICIENCY_PCT: Final[float] = 82.0
LINE_207_MAIN_2_EFFICIENCY_PCT: Final[float] = 82.0
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (mirror of the case-6 helper)."""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-7 Summary through extractor + mapper."""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -0,0 +1,124 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 2" worksheet a Main + Extension dwelling with a
Simplified room-in-roof (the 6035 archetype, more complete than sim
case 1).
Like 000565 / sim case 1, this fixture does NOT hand-build the
EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result
pin grid exercises the WHOLE extractor + mapper + calculator pipeline.
This cert surfaced two real cascade bugs (both fixed; this fixture pins
them end-to-end at 1e-4):
S0380.192 Simplified room-in-roof. The Summary lodges placeholder
slope/ceiling Length/Height cells (a 40 m ceiling height, a 32 m
slope on a 4.65 m gable). RdSAP 10 §3.9.1 derives one timber-framed
"remaining area" from the floor area instead
(A_RR = 12.5(A_floor/1.5) Σgables = 32.89 ). Emitting the
placeholders as detailed_surfaces billed 1024 + 160 of explicit
roof area a 7.5× fabric-heat-loss explosion (SAP 14.6). Fixed by
dropping roof-going surfaces for Simplified assessments so the
cascade's residual formula fires.
S0380.193 Suspended-timber-floor "sealed/unsealed" infiltration.
RdSAP 10 §5 (PDF p.29) line (12): rule (a) ("U-value < 0.5 → sealed
0.1") applies only when a floor U-value is SUPPLIED. This cert's
floor is as-built/uninsulated (default U=0.43, not supplied), so it
falls to rule (b) unsealed 0.2. The cascade was feeding the
computed default U into rule (a) sealed 0.1 (25) effective ACH
dropped space heating understated ~450 kWh.
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 2/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_rr_ext.pdf`
(distinct name the corpus reuses cert 001431; sim case 1 is the
single-part gas-combi variant) so the test runs without depending on
the unstaged workspace.
Cert shape: Main + Extension 1, both solid brick WITH internal
insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof
on the Main (floor 29.75 , exposed + party gables), suspended
uninsulated ground floors, gas-combi SAP code 104, no PV.
Worksheet pin targets (P960-0001-001431, Block 1 energy rating):
- SAP rating 69 (line 258), ECF 2.2395 (line 257)
- Total fuel cost £920.5046 (line 255)
- CO2 4566.7090 kg/year (line 272)
- Space heating 15269.8593 kWh/year (Σ monthly (98))
- Main 1 fuel 18178.4039 kWh/year (line 211)
- Secondary fuel 0.0 (line 215)
- Hot water fuel 3308.6172 kWh/year (line 219)
- Lighting 282.6414 kWh/year (line 232)
- Pumps/fans 86.0 kWh/year (line 231)
Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation-
philosophy]]: pins are abs=1e-4 against the worksheet PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_rr_ext.pdf"
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label\\nvalue sequences).
Mirror of the helper in `test_summary_pdf_mapper_chain.py` /
`_elmhurst_worksheet_000565.py`.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-2 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. Exercises the S0380.192 Simplified-RR fix and the
S0380.193 suspended-floor sealed-rule fix.
"""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -0,0 +1,112 @@
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
"simulated case 3" worksheet a near-exact replica of golden cert
6035 (Main + Extension + Simplified room-in-roof, 8 windows).
Like 000565 / sim case 1 / sim case 2, this fixture does NOT hand-build
the EpcPropertyData: it routes the Summary PDF through
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the SAP-result
pin grid exercises the WHOLE extractor + mapper + calculator pipeline.
Purpose: prove the calculator is spec-correct for the 6035 archetype
(after S0380.192 Simplified-RR + S0380.193 suspended-floor fixes). This
cert reproduces 6035's 8 windows (≈14.15 m²) and Main ground-floor
heat-loss perimeter (15.99 m). It still differs from 6035 in ONE input:
the Main FIRST-floor HLP is 15.99 here vs 6035's 8.32 (6035's upper
storey has less exposed perimeter), so it is not yet byte-identical to
6035. All 11 Block-1 line refs nonetheless pin at abs=1e-4 against this
cert's OWN worksheet, confirming the cascade reproduces the spec engine
exactly for this Main+Ext+RR+suspended-floor+gas-combi shape so 6035's
residual +19 PE vs the lodged register is lodged-register divergence,
not a cascade gap.
Cert shape: Main + Extension 1, both solid brick WITH internal
insulation (Main) / as-built (Ext1), 3 storeys, Simplified room-in-roof
on the Main (floor 29.75 , exposed + party gables), suspended
uninsulated ground floors, gas-combi SAP code 104, 8 windows, no PV.
Source: user-simulated PDFs at `sap worksheets/golden fixture
debugging/simulated case 3/`. The Summary is mirrored into the tracked
`backend/documents_parser/tests/fixtures/Summary_001431_rr8w.pdf`
(distinct name the corpus reuses cert 001431).
Worksheet pin targets (P960-0001-001431, Block 1 energy rating):
- SAP rating 68 (line 258), ECF 2.3146 (line 257)
- Total fuel cost £951.3425 (line 255)
- CO2 4767.4862 kg/year (line 272)
- Space heating 16086.3557 kWh/year (Σ monthly (98))
- Main 1 fuel 19150.4235 kWh/year (line 211)
- Secondary fuel 0.0 (line 215)
- Hot water fuel 3307.2639 kWh/year (line 219)
- Lighting 262.0885 kWh/year (line 232)
- Pumps/fans 86.0 kWh/year (line 231)
Per [[feedback-zero-error-strict]] + [[feedback-e2e-validation-
philosophy]]: pins are abs=1e-4 against the worksheet PDF.
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
from typing import Final
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
# [4]=repo root.
_SUMMARY_PDF: Final[Path] = (
Path(__file__).resolve().parents[4]
/ "backend" / "documents_parser" / "tests" / "fixtures"
/ "Summary_001431_rr8w.pdf"
)
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
"""Convert a Summary PDF into the per-page text format the
ElmhurstSiteNotesExtractor expects (label\\nvalue sequences).
Mirror of the helper in `test_summary_pdf_mapper_chain.py` /
`_elmhurst_worksheet_000565.py`.
"""
info = subprocess.run(
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
).stdout
m = re.search(r"Pages:\s+(\d+)", info)
if m is None:
raise RuntimeError(f"Could not parse page count from {pdf_path}")
page_count = int(m.group(1))
pages: list[str] = []
for i in range(1, page_count + 1):
layout = subprocess.run(
[
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
str(pdf_path), "-",
],
capture_output=True, text=True, check=True,
).stdout
tokens: list[str] = []
for line in layout.splitlines():
if not line.strip():
tokens.append("")
continue
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
tokens.extend(parts)
pages.append("\n".join(tokens))
return pages
def build_epc() -> EpcPropertyData:
"""Route the simulated case-2 Summary through extractor + mapper.
No hand-built EpcPropertyData the extractor and mapper are part of
the test target. Exercises the S0380.192 Simplified-RR fix and the
S0380.193 suspended-floor sealed-rule fix.
"""
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)

View file

@ -38,6 +38,12 @@ from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000516 as _w000516,
_elmhurst_worksheet_000565 as _w000565,
_elmhurst_worksheet_001431 as _w001431,
_elmhurst_worksheet_001431_rr as _w001431_rr,
_elmhurst_worksheet_001431_rr8 as _w001431_rr8,
_elmhurst_worksheet_001431_6035 as _w001431_6035,
_elmhurst_worksheet_001431_case5 as _w001431_case5,
_elmhurst_worksheet_001431_case6 as _w001431_case6,
_elmhurst_worksheet_001431_case7 as _w001431_case7,
)
from tests.domain.sap10_calculator.worksheet._elmhurst_fixtures import (
ALL_FIXTURES as _ELMHURST_FIXTURES,
@ -167,6 +173,111 @@ _FIXTURE_PINS: Final[dict[str, FixtureCascadePins]] = {
lighting_kwh_per_yr=283.2229,
pumps_fans_kwh_per_yr=86.0,
),
# Mapper-driven cohort entry — Summary_001431_rr_ext.pdf → extractor
# → mapper → calculator. Main + Extension, Simplified room-in-roof,
# suspended uninsulated floors (the 6035 archetype). Surfaced + pins
# S0380.192 (Simplified-RR remaining area) and S0380.193 (suspended-
# floor sealed/unsealed rule). Pins are worksheet Block 1 line refs.
"001431_rr": FixtureCascadePins(
sap_score=69, sap_score_continuous=68.7584, ecf=2.2395,
total_fuel_cost_gbp=920.5046, co2_kg_per_yr=4566.7090,
space_heating_kwh_per_yr=15269.8593,
main_heating_fuel_kwh_per_yr=18178.4039,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=3308.6172,
lighting_kwh_per_yr=282.6414,
pumps_fans_kwh_per_yr=86.0,
),
# Mapper-driven cohort entry — Summary_001431_rr8w.pdf → extractor →
# mapper → calculator. Near-exact 6035 replica: Main + Extension +
# Simplified room-in-roof, 8 windows (≈14.15 m², matching 6035),
# suspended uninsulated floors. Differs from 6035 only in the Main
# first-floor HLP (15.99 here vs 6035's 8.32). Pins at 1e-4 confirm
# the cascade is spec-correct for the archetype → 6035's +19 PE vs
# the lodged register is lodged-register divergence, not a calc gap.
"001431_rr8": FixtureCascadePins(
sap_score=68, sap_score_continuous=67.7118, ecf=2.3146,
total_fuel_cost_gbp=951.3425, co2_kg_per_yr=4767.4862,
space_heating_kwh_per_yr=16086.3557,
main_heating_fuel_kwh_per_yr=19150.4235,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=3307.2639,
lighting_kwh_per_yr=262.0885,
pumps_fans_kwh_per_yr=86.0,
),
# Mapper-driven cohort entry — Summary_001431_6035.pdf → extractor →
# mapper → calculator. Reproduces 6035's full floor geometry (Main
# ground HLP 15.99 + first 8.32, asymmetric) and 8 windows. Residual
# vs 6035 is two lodged inputs only (largest window orientation,
# meter type). Pins at 1e-4 → 6035's +19 PE is lodged divergence.
"001431_6035": FixtureCascadePins(
sap_score=68, sap_score_continuous=68.1906, ecf=2.2802,
total_fuel_cost_gbp=937.2341, co2_kg_per_yr=4682.3494,
space_heating_kwh_per_yr=15745.3260,
main_heating_fuel_kwh_per_yr=18744.4357,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=3307.8383,
lighting_kwh_per_yr=262.0885,
pumps_fans_kwh_per_yr=86.0,
),
# Mapper-driven cohort entry — Summary_001431_case5.pdf → extractor →
# mapper → calculator. DETACHED, SANDSTONE-walled cousin of cert 0240:
# Main + Extension + room-in-roof (floor 83.2 m², one Exposed + one
# Party gable L=6.40), age J, oil combi (SAP 901), no PV. Validates
# S0380.196 (RR gable deduction) against a real worksheet — the
# worksheet prints Gable 1 (Exposed) at (29a) U=0.35, Gable 2 (Party)
# at (32) U=0.25, remaining area = shell Σ gables at (30). Also pins
# the S0380.197 sandstone "SS" wall label + "400+ mm" roof-thickness
# extractor fixes (without the latter, roof U fell to 0.16 not 0.11).
"001431_case5": FixtureCascadePins(
sap_score=61, sap_score_continuous=61.3255, ecf=2.7724,
total_fuel_cost_gbp=1586.4549, co2_kg_per_yr=8387.6229,
space_heating_kwh_per_yr=12838.6489,
main_heating_fuel_kwh_per_yr=21397.7480,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=6498.2518,
lighting_kwh_per_yr=381.4601,
pumps_fans_kwh_per_yr=141.0,
),
# Mapper-driven cohort entry — Summary_001431_case6.pdf → extractor →
# mapper → calculator. DETACHED dual-oil cousin of case 5: Main 1
# radiators (control 2106) + Main 2 underfloor (control 2110) heating
# DIFFERENT parts (51% / 49%), 6 "Roof of Room" rooflights, no boiler
# interlock (cyl stat No → 5pp on Main 1). Promoted to a full
# SapResult fixture once S0380.201-206 closed every line ref: Table 4f
# note c) two-pump electricity (231), Table 5a note a) two-pump gain
# (70), §3.7 rooflight→RR-residual (30), SAP 10.2 p.186 two-systems-
# different-parts MIT (87)/(90)/(98c), and Eq D1 per-boiler (204)
# space share (219). Pins are worksheet Block 1 (energy rating) line
# refs; main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum.
"001431_case6": FixtureCascadePins(
sap_score=72, sap_score_continuous=71.6597, ecf=2.0316,
total_fuel_cost_gbp=1162.5374, co2_kg_per_yr=5953.6679,
space_heating_kwh_per_yr=11991.9611,
main_heating_fuel_kwh_per_yr=14736.9564,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=4902.8601,
lighting_kwh_per_yr=357.6571,
pumps_fans_kwh_per_yr=356.0,
),
# Mapper-driven cohort entry — Summary_001431_case7.pdf → extractor →
# mapper → calculator. Case 6 with the heating swapped to a CONDENSING
# OIL COMBI (SAP code 130, Table 4b 82/73) with NO cylinder — combi
# instantaneous DHW (WHC 901), Table 3a keep-hot combi loss (61), no
# primary/storage loss, boiler interlock PRESENT (no 5pp). Validates
# the combi HW + space efficiency path that golden cert 0240 uses;
# reproduces every line ref EXACTLY with no calculator change.
# main_heating_fuel_kwh_per_yr is the (211)+(213) two-system sum.
"001431_case7": FixtureCascadePins(
sap_score=73, sap_score_continuous=72.6153, ecf=1.9631,
total_fuel_cost_gbp=1123.3372, co2_kg_per_yr=5738.9315,
space_heating_kwh_per_yr=12646.3783,
main_heating_fuel_kwh_per_yr=15422.4125,
secondary_heating_fuel_kwh_per_yr=0.0,
hot_water_kwh_per_yr=3496.8121,
lighting_kwh_per_yr=357.6571,
pumps_fans_kwh_per_yr=356.0,
),
}
@ -179,6 +290,12 @@ _FIXTURE_MODULES: Final[dict[str, ModuleType]] = {
"000516": _w000516,
"000565": _w000565,
"001431": _w001431,
"001431_rr": _w001431_rr,
"001431_rr8": _w001431_rr8,
"001431_6035": _w001431_6035,
"001431_case5": _w001431_case5,
"001431_case6": _w001431_case6,
"001431_case7": _w001431_case7,
}

View file

@ -60,6 +60,40 @@ def test_table_11_secondary_fraction_splits_q_heat_between_main_and_secondary()
assert result.secondary_fuel_kwh_per_yr == 550.0
def test_two_main_systems_split_q_heat_by_fraction_203_at_own_efficiencies() -> None:
"""Spec §9a (203)/(204)/(205) two-main split: when a second main
system supplies (203) of the main heating, (204)=(202)×(1(203)) goes
to system 1 at (206) and (205)=(202)×(203) to system 2 at (207). With
no secondary ((202)=1), (203)=0.49, eff1=79%, eff2=84%, Σ(98c)=4400:
Σ(211) = 4400 × 0.51 × 100/79 = 2840.5063 kWh
Σ(213) = 4400 × 0.49 × 100/84 = 2566.6667 kWh
Mirrors simulated case 6 (oil boiler, radiators 51% + underfloor 49%)
and cert 0240 (identical-efficiency systems collapse to the single-
main total)."""
# Arrange
monthly_space_heating = (
1000.0, 800.0, 600.0, 400.0, 200.0, 0.0,
0.0, 0.0, 0.0, 200.0, 400.0, 800.0,
)
# Act
result = space_heating_fuel_monthly_kwh(
space_heating_monthly_kwh=monthly_space_heating,
secondary_heating_fraction=0.0,
main_heating_efficiency_pct=79.0,
secondary_heating_efficiency_pct=0.0,
main_2_of_main_fraction=0.49,
main_2_efficiency_pct=84.0,
)
# Assert
assert abs(result.main_2_of_main_fraction - 0.49) <= 1e-12
assert abs(result.main_1_of_total_fraction - 0.51) <= 1e-12
assert abs(result.main_2_of_total_fraction - 0.49) <= 1e-12
assert abs(result.main_1_fuel_kwh_per_yr - 4400.0 * 0.51 * 100.0 / 79.0) <= 1e-9
assert abs(result.main_2_fuel_kwh_per_yr - 4400.0 * 0.49 * 100.0 / 84.0) <= 1e-9
def test_per_month_fuel_preserves_summer_clamp_zeros_from_98c() -> None:
"""The §8 Table 9c summer clamp zeros (98c)m for Jun..Sep. §9a's per-
month (211)m / (215)m tuples are linear in (98c)m so they inherit the

View file

@ -36,11 +36,36 @@ from domain.sap10_calculator.worksheet.heat_transmission import (
heat_transmission_from_cert,
)
from domain.sap10_calculator.worksheet.heat_transmission import (
_part_geometry, # pyright: ignore[reportPrivateUsage]
_round_half_up, # pyright: ignore[reportPrivateUsage]
_window_bp_index, # pyright: ignore[reportPrivateUsage]
)
def test_part_geometry_floorless_part_honours_full_key_contract() -> None:
# Arrange — a building part lodged with NO sap_floor_dimensions (e.g.
# a party-wall-only or RR-only extension; observed on 5 certs in a
# 2026 API sample). `_part_geometry`'s early return must expose the
# same dict keys as its full return: the §3.9 RR contribution block
# reads geom["rr_common_wall_area_m2"] / ["rr_gable_area_m2"] for
# EVERY part, so a missing key raises KeyError and blocks the cert.
floorless = make_building_part(floor_dimensions=[])
with_floors = make_building_part(
floor_dimensions=[make_floor_dimension(total_floor_area_m2=50.0)]
)
# Act
early = _part_geometry(floorless)
full = _part_geometry(with_floors)
# Assert — identical key contract; the RR/cantilever geometry is 0.0
# for a floorless part (no floor area ⇒ no RR shell or cantilever).
assert set(early.keys()) == set(full.keys())
assert early["rr_common_wall_area_m2"] == 0.0
assert early["rr_gable_area_m2"] == 0.0
assert early["cantilever_floor_area_m2"] == 0.0
def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4() -> None:
# Arrange — 346 corpus certs lodge roof_insulation_thickness="NI"
# with descriptions like "Pitched, insulated (assumed)". The
@ -168,21 +193,26 @@ def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnot
assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0)
def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None:
# Arrange — 128 corpus certs lodge solid-brick walls with
# wall_insulation_type=4 ("as-built / assumed") AND description
# "Solid brick, as built, insulated (assumed)". The description
# signals retrofit insulation that the assessor hasn't measured the
# thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm
# row. Without the description signal, type=4 alone would set
# wall_ins_present=False and the cascade would return the as-built
# U=1.7. With it, U = 0.55 at band B.
def test_solid_brick_as_built_insulated_assumed_uses_as_built_row_per_table9_footnote() -> None:
# Arrange — an "as built, insulated (assumed)" description only renders
# on RECENT age bands (where as-built construction already includes
# insulation per Building Regs); an old band renders "no insulation
# (assumed)". RdSAP 10 Table 8/9 footnote routes to the 50 mm row only
# when insulation is "known to have been increased subsequently
# (otherwise 'as built' applies)" — an age-band assumption is NOT
# known retrofit, so the as-built row applies.
#
# Worksheet-validated: simulated case 9 (sandstone, band J, As Built
# → U 0.35) and case 10 (solid brick, band J, As Built → U 0.35) both
# return the as-built row, NOT the 50 mm bucket (which would give
# U=0.25). This was previously asserted at 55 W/K via an IMPOSSIBLE
# band-B + "insulated (assumed)" combination.
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100
# = 55 W/K.
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.35 × 100
# = 35 W/K.
main = make_building_part(
identifier="Main Dwelling",
construction_age_band="B",
construction_age_band="J",
wall_construction=3,
wall_insulation_type=4,
party_wall_construction=1,
@ -211,7 +241,7 @@ def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnot
result = heat_transmission_from_cert(epc)
# Assert
assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0)
assert result.walls_w_per_k == pytest.approx(35.0, abs=1.0)
def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None:
@ -302,6 +332,58 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None:
assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0)
def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None:
# Arrange — the EPC renders a cavity wall lodged wall_insulation_type=4
# (as-built / assumed) with description "Cavity wall, as built, partial
# insulation (assumed)" for age bands where the as-built construction
# carries only partial cavity fill. "Partial insulation" is the as-built
# thermal state of the age band, NOT a retrofit cavity fill — the spec
# routes it to the "Cavity as built" row, not "Filled cavity":
# RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs
# "Filled cavity" band F = 0.40. A genuine fill renders the distinct
# "Cavity wall, filled cavity" description (wall_insulation_type=2),
# caught separately. Contrast the "insulated (assumed)" variant above,
# which the assessor judges as filled.
#
# Real-cert evidence: golden cert 0390-2954-3640 (detached, band F,
# cavity type 4, "partial insulation (assumed)") closes all four SAP
# metrics (PE/SAP/CO2/cost) on the as-built 1.0 row — at the filled
# 0.40 row its PE under-counts by ~28 kWh/m².
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey
# → gross_wall = 100 m². walls_w_per_k expected = 1.0 × 100 = 100 W/K.
main = make_building_part(
construction_age_band="F",
wall_construction=4,
wall_insulation_type=4,
party_wall_construction=1,
roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=100.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
),
],
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0,
country_code="ENG",
sap_building_parts=[main],
)
epc.walls = [
EnergyElement(
description="Cavity wall, as built, partial insulation (assumed)",
energy_efficiency_rating=3,
environmental_efficiency_rating=3,
),
]
# Act
result = heat_transmission_from_cert(epc)
# Assert — U=1.0 × 100 m² gross wall = 100 W/K (as-built, not filled 70).
assert abs(result.walls_w_per_k - 100.0) <= 1.0
def test_walls_description_measured_transmittance_overrides_construction_cascade() -> None:
# Arrange — a full-SAP (not RdSAP) cert lodges the wall U-value
# directly in walls[i].description ("Average thermal transmittance
@ -1140,6 +1222,138 @@ def test_sap_building_part_has_basement_detects_main_wall_and_alt_wall_codes() -
assert alt_is_basement.main_wall_is_basement is False # main is still wc=4
def test_explicit_wall_is_basement_flag_disambiguates_system_built_from_basement() -> None:
"""RdSAP10 `wall_construction == 6` is canonically SYSTEM-BUILT
(`WALL_SYSTEM_BUILT`), but the gov-EPC basement heuristic hijacked it
(Elmhurst lodges both "SY System build" and "B Basement wall" as
code 6). The explicit `wall_is_basement` flag set by the Elmhurst
mapper from the distinct "SY"/"B" codes disambiguates:
- flag True basement (drives §5.17 u_basement_wall)
- flag False system-built (drives the u_wall code-6 table)
- flag None fall back to the gov-EPC API code-6 heuristic
so the API path (which lodges basement as integer 6 with no flag) is
unchanged."""
from dataclasses import replace
# Arrange — three parts, all wall_construction=6, differing only in flag.
plain = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=6, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
system_built = replace(plain, wall_is_basement=False)
basement = replace(plain, wall_is_basement=True)
api_code_6 = replace(plain, wall_is_basement=None)
# Act / Assert
assert system_built.main_wall_is_basement is False
assert basement.main_wall_is_basement is True
assert api_code_6.main_wall_is_basement is True # API heuristic preserved
# Alt-wall mirror — same Optional disambiguation on SapAlternativeWall.
alt_system_built = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y", is_basement=False,
)
alt_basement = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y", is_basement=True,
)
alt_api_code_6 = SapAlternativeWall(
wall_area=14.24, wall_dry_lined="N", wall_construction=6,
wall_insulation_type=4, wall_thickness_measured="Y",
)
assert alt_system_built.is_basement_wall is False
assert alt_basement.is_basement_wall is True
assert alt_api_code_6.is_basement_wall is True
def test_system_build_property_derives_from_main_wall_construction_type() -> None:
# Arrange — system-built is a WALL TYPE: RdSAP10 WALL_SYSTEM_BUILT=6
# on the MAIN wall. It shares the integer with basement, so a code-6
# main wall is system-built only when it is NOT flagged basement. The
# `system_build` property reads the wall type (wall_construction) + the
# dedicated basement flag — it does not need a separate dwelling-level
# field.
from dataclasses import replace
base_main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=6, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
system_built = make_minimal_sap10_epc(
sap_building_parts=[replace(base_main, wall_is_basement=False)],
)
basement = make_minimal_sap10_epc(
sap_building_parts=[replace(base_main, wall_is_basement=True)],
)
cavity = make_minimal_sap10_epc(
sap_building_parts=[replace(base_main, wall_construction=4)],
)
no_main = make_minimal_sap10_epc(sap_building_parts=[])
# Act / Assert — code 6 + not basement → system-built; code 6 + basement
# → not system-built; a non-6 wall type → not system-built; no main → None.
assert system_built.system_build is True
assert basement.system_build is False
assert cavity.system_build is False
assert no_main.system_build is None
def test_system_built_addendum_clears_basement_on_code_6_walls_api_path() -> None:
# Arrange — gov-EPC API system-built cert: the per-wall code 6 can't be
# told from a basement at lodging time, so once the cert addendum marks
# the dwelling system-built, `from_api_response` clears the code-6
# basement heuristic. A genuine basement (no addendum signal) keeps it.
from dataclasses import replace
from datatypes.epc.domain.epc_property_data import Addendum
from datatypes.epc.domain.mapper import _clear_basement_flag_when_system_built # pyright: ignore[reportPrivateUsage]
code_6_main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,
construction_age_band="G",
wall_construction=6, wall_insulation_type=4,
party_wall_construction=1, roof_construction=4,
floor_dimensions=[
make_floor_dimension(
total_floor_area_m2=80.0, room_height_m=2.5,
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
),
],
)
system_built_cert = replace(
make_minimal_sap10_epc(sap_building_parts=[code_6_main]),
addendum=Addendum(system_build=True),
)
genuine_basement_cert = make_minimal_sap10_epc(sap_building_parts=[code_6_main])
# Act
cleared = _clear_basement_flag_when_system_built(system_built_cert)
untouched = _clear_basement_flag_when_system_built(genuine_basement_cert)
# Assert — system-built cert: code-6 main wall is no longer basement,
# and the wall-type-derived system_build reads True. Genuine basement
# (no addendum) is unchanged → still basement.
assert cleared.sap_building_parts[0].main_wall_is_basement is False
assert cleared.system_build is True
assert untouched.sap_building_parts[0].main_wall_is_basement is True
assert untouched.system_build is False
def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None:
"""RdSAP §5.17 / Table 23 governs basement-wall U-values: 0.7 for age
A-F, 0.6 for G-H, 0.45 for I, 0.35 for J, ..., 0.26 for M. The

View file

@ -42,6 +42,7 @@ from tests.domain.sap10_calculator.worksheet import (
_elmhurst_worksheet_000487 as _w000487,
_elmhurst_worksheet_000490 as _w000490,
_elmhurst_worksheet_000516 as _w000516,
_elmhurst_worksheet_001431_case6 as _w001431_case6,
)
@ -248,6 +249,152 @@ def test_section_3_line_refs_match_pdf(
_pin(actual, expected, f"§3 {fixture_attr} {fixture_name}")
def test_section_3_roof_windows_case6_match_pdf() -> None:
"""§3 (27a) roof-window pin for simulated case 6 — the 6 room-in-roof
rooflights (window_wall_type=4 on the API side / "Roof of Room"
location on the site-notes side) must bill on (27a) at U_eff 2.1062,
not on (27) as vertical glazing. Validates the S0380.198/199 roof-
window routing against a real worksheet. Case 6 is pinned only on the
§3 window line refs (not added to `_FIXTURES`) because its dual main
heating system makes the §10/§12 per-system lines non-comparable
see the fixture module docstring."""
# Arrange
epc = _w001431_case6.build_epc()
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
_pin(ht.windows_w_per_k, _w001431_case6.LINE_27_WINDOWS_W_PER_K, "§3 (27) case6")
_pin(
ht.roof_windows_w_per_k,
_w001431_case6.LINE_27A_ROOF_WINDOWS_W_PER_K,
"§3 (27a) case6",
)
_pin(
ht.total_external_element_area_m2,
_w001431_case6.LINE_31_TOTAL_EXTERNAL_AREA_M2,
"§3 (31) case6",
)
_pin(
ht.roof_w_per_k,
_w001431_case6.LINE_30_ROOF_W_PER_K,
"§3 (30) case6",
)
def test_case6_main_2_emitter_and_control_extracted() -> None:
"""Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter
("Underfloor Heating") and control ("SAP code 2110, ...") the two
main systems heat different parts (Main 1 radiators/2106 living, Main
2 underfloor/2110 elsewhere). Pre-S0380.204 the extractor + mapper
dropped both (emitter='' / control=''), so the SAP 10.2 p.186 two-
systems-different-parts MIT could not read system 2's responsiveness
(underfloor emitter 2 R=0.75) or control type (2110 type 3)."""
# Arrange / Act
epc = _w001431_case6.build_epc()
main_2 = epc.sap_heating.main_heating_details[1]
# Assert — emitter 2 (underfloor in screed → Table 4d R=0.75) +
# control 2110 (Table 4e type 3 zone control).
assert main_2.heat_emitter_type == 2
assert main_2.main_heating_control == 2110
def test_section_4_hot_water_fuel_case6_match_pdf() -> None:
"""(219) water-heating fuel for simulated case 6. The DHW boiler (Main
1, WHC 901) provides only 51% of space heating, so SAP 10.2 Appendix D
§D2.1(2) Equation D1 must weight η_winter by Main 1's (204) share, not
the dwelling total (202). Pre-S0380.206 the cascade fed Eq D1 the full
dwelling space load over-weighted η_winter HW 78 kWh."""
# Arrange / Act — real cascade (the §2.4 helper skips the cylinder gate).
ci = cert_to_inputs(_w001431_case6.build_epc())
# Assert
_pin(
ci.hot_water_kwh_per_yr,
_w001431_case6.LINE_219_HOT_WATER_FUEL_KWH,
"§4 (219) case6",
)
def test_section_9a_per_system_fuel_case6_match_pdf() -> None:
"""(211)/(213) per-system space-heating fuel for simulated case 6. The
dual oil boiler heats different parts (Main 1 radiators/2106 living,
Main 2 underfloor/2110 elsewhere), so SAP 10.2 p.186 applies the
two-systems-different-parts MIT: weighted responsiveness R = 0.51·1.0
+ 0.49·0.75 = 0.8775 (Table 9b) and a rest-of-dwelling temperature
blended from each system's control schedule. That lands (98c) demand
11991.96 exact, so the per-system fuels pin. Pre-S0380.205 the cascade
used Main 1's control + R=1.0 for the whole dwelling → MIT +0.037 °C →
demand +61 kWh both legs ~+1.3 % high."""
# Arrange / Act — pin the REAL cascade (the §2.4 section helper skips
# the interlock penalty + two-system MIT params, so use cert_to_inputs).
er = cert_to_inputs(_w001431_case6.build_epc()).energy_requirements
# Assert
_pin(
er.main_1_fuel_kwh_per_yr,
_w001431_case6.LINE_211_MAIN_1_FUEL_KWH,
"§9a (211) case6",
)
_pin(
er.main_2_fuel_kwh_per_yr,
_w001431_case6.LINE_213_MAIN_2_FUEL_KWH,
"§9a (213) case6",
)
def test_section_4f_pumps_fans_case6_match_pdf() -> None:
"""(231) pumps/fans pin for simulated case 6 — a DUAL-oil-boiler
detached dwelling. Worksheet (231) = 356 = (230c) central heating
pump 156 + (230d) oil boiler pump 200. (230c) is itself the two-
main-system circulation-pump pair per SAP 10.2 Table 4f note c
("Where there are two main heating systems include two figures from
this table"): Main 1 41 kWh (pump age "2013 or later") + Main 2 115
kWh (pump age unknown). The pre-S0380.201 cascade summed only Main 1's
circulation pump (41) and gave (231) = 241."""
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
# Arrange
epc = _w001431_case6.build_epc()
# Act
result = calculate_sap_from_inputs(cert_to_inputs(epc))
# Assert
_pin(
result.pumps_fans_kwh_per_yr,
_w001431_case6.LINE_231_PUMPS_FANS_KWH,
"§4f (231) case6",
)
def test_section_5_pumps_fans_gains_case6_match_pdf() -> None:
"""(70) pumps/fans internal-gain pin for simulated case 6. The dual oil
boiler serves different parts (51% radiators + 49% underfloor), so SAP
10.2 Table 5a note a) ("Where there are two main heating systems serving
different parts of the dwelling, assume each has its own circulation
pump and therefore include two figures from this table") bills TWO
central-heating-pump gains: Main 1 "2013 or later" (3 W) + Main 2
unknown date (7 W) = 10 W in the 8 heating months. The pre-S0380.202
cascade billed a single Main 1 pump (3 W)."""
# Arrange
epc = _w001431_case6.build_epc()
# Act
ig = internal_gains_section_from_cert(epc)
# Assert
assert ig is not None
for m in range(12):
_pin(
ig.pumps_fans_monthly_w[m],
_w001431_case6.LINE_70_PUMPS_FANS_GAINS_W[m],
f"§5 (70) case6 month {m + 1}",
)
# ============================================================================
# §4 Water heating — LINE_42..LINE_65 scalar + monthly tuples
# ============================================================================

View file

@ -48,3 +48,37 @@ def test_epc_property_data_round_trips(schema_dir: str, db_engine: Engine) -> No
# Assert
assert reloaded == original
def test_building_part_wall_insulation_thickness_preserves_int(
db_engine: Engine,
) -> None:
# SAP 10.2 §5.7/Table 8: when the API lodges
# `wall_insulation_thickness == "measured"`, the mapper resolves the
# value to an int mm. The `epc_building_part.wall_insulation_thickness`
# column must therefore preserve int vs str on round-trip (JSONB), like
# its `roof_insulation_thickness` sibling — a plain str column would
# round-trip the int 100 back as "100" and corrupt the Table 8 lookup.
from dataclasses import replace
# Arrange — take a green fixture and force the measured-int case.
original = _load_epc("RdSAP-Schema-21.0.0")
assert original.sap_building_parts, "fixture must have a building part"
bp0 = replace(original.sap_building_parts[0], wall_insulation_thickness=100)
original = replace(
original,
sap_building_parts=[bp0, *original.sap_building_parts[1:]],
)
# Act
with Session(db_engine) as session:
epc_property_id = EpcPostgresRepository(session).save(original)
session.commit()
with Session(db_engine) as session:
reloaded = EpcPostgresRepository(session).get(epc_property_id)
# Assert — the int survives as an int, not the string "100".
assert reloaded is not None
value = reloaded.sap_building_parts[0].wall_insulation_thickness
assert value == 100
assert isinstance(value, int)