fix(roof): bill at-rafters insulation on RdSAP 10 Table 16/18 column (2)

`u_roof` only implemented the joist column, so roofs lodged insulated at
rafters (`roof_insulation_location == 1`) were mis-billed at the joist U
on both the API and Summary paths — under-stating loss, over-rating SAP.

RdSAP 10 §5.11.2 Table 16 (spec p.42-43) gives a distinct "insulation at
rafters" column (2): the rafter cavity is shallower than a loft void, so
the same depth yields a higher U (200 mm: rafters 0.29 vs joists 0.21).
§5.11 Table 18 (p.45) likewise carries a rafters column (2) for unknown /
as-built thickness (footnote (1): "The value from the table applies for
unknown and as built") — band A-D = 2.30, E = 1.50, F = 0.68, diverging
from the joist column's 100 mm-equivalent 0.40 default (footnote (4)).

- add `_ROOF_RAFTERS_BY_THICKNESS` (Table 16 col 2) + `_ROOF_RAFTERS_BY_AGE`
  (Table 18 col 2) to rdsap_uvalues; `u_roof` selects them via a new
  `insulation_at_rafters` flag (ignored for flat / sloping-ceiling roofs).
- `heat_transmission` derives the flag PER BUILDING PART from
  `roof_insulation_location` (gov-API int 1 / Summary "R Rafters"), which
  also fixes the multi-part dedup-roof-join problem: each part's own
  location now drives its U, replacing the unattributable joined
  `epc.roofs[]` description.

Worksheet-validated to 1e-4: simulated case 41 (4-bp — Ext1 rafters 200mm
→ 0.29, Ext3 rafters As-Built band F → 0.68; roof total 24.8350) and case
42 (6 variants — rafters 50mm → 0.88, rafters unknown band C → 2.30,
joists/none unchanged). Case 40 stays exact (roof 35.340, total 441.1606);
worksheet harness 47/47.

Corpus within-0.5 66.9% → 66.5% (gates 0.65/1.08 hold) — a spec-correct
shift, NOT a regression: all 15 corpus rafter certs carry redacted (None)
thickness yet lodge roof EER 2-4 (insulated), so the open API blanked a
specified thickness and the spec's unknown-rafter 2.30 default correctly
over-states them. Recovery needs a roof-EER→thickness inference on the
API path (follow-up), not a change to the U-table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-16 04:42:44 +00:00
parent b2b6f8e954
commit 5d556faf86
9 changed files with 272 additions and 7 deletions

View file

@ -99,6 +99,9 @@ _SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmh
_SUMMARY_001431_CASE20_PDF = _FIXTURES / "Summary_001431_case20.pdf" # sim case 20 (storage heaters + RR type-2 + wrapped "Double between 2002 and 2021" glazing)
_SUMMARY_001431_TOPFLOOR_PDF = _FIXTURES / "Summary_001431_topfloor_flat.pdf" # gas-boiler-upgrade recommendation "after" — top-floor flat, PS sloping roof; exercises the Date-Built age-band + flat-position layout regressions
_SUMMARY_001431_LPG_PDF = _FIXTURES / "Summary_001431_lpg_boiler.pdf" # lpg-boiler recommendation "before" — §14 SAP code 115, §15 "Bottled gas"; exercises the bottled-LPG main-fuel mapping
_SUMMARY_001431_CASE41_RAFTERS_PDF = _FIXTURES / "Summary_001431_case41_rafters.pdf" # sim case 41 — 4-bp roof: Main joists 200mm, Ext1 rafters 200mm, Ext2 joists unknown, Ext3 rafters As Built (RdSAP 10 §5.11.2 Table 16 col 2 + Table 18 col 2)
_SUMMARY_001431_CASE42_50MM_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_50mm_rafters.pdf" # sim case 42 — single-bp roof: rafters 50mm (Table 16 col 2 → 0.88)
_SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF = _FIXTURES / "Summary_001431_case42_unknown_rafters.pdf" # sim case 42 — single-bp roof: rafters unknown thickness (Table 18 col 2 band C → 2.30)
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
@ -178,6 +181,69 @@ def test_summary_001431_case20_fabric_heat_loss_matches_worksheet_line_33() -> N
assert abs(ht.fabric_heat_loss_w_per_k - 285.9847) <= 1e-4
def test_summary_001431_case41_roof_drives_rafters_column_per_part() -> None:
# Arrange — sim case 41's 4 building parts exercise both RdSAP 10 roof
# columns per-part (RdSAP 10 §5.11.2 Table 16 + §5.11 Table 18,
# PDF p.42-45). The P960 §3 line (30) "External roof" A×U per part:
# Main joists 200mm → 0.21 × 59.5 = 12.4950 (Table 16 col 1)
# Ext1 rafters 200mm → 0.29 × 10.0 = 2.9000 (Table 16 col 2)
# Ext2 joists unknown→ 0.40 × 10.0 = 4.0000 (Table 18 col 1, band E)
# Ext3 rafters AsBlt → 0.68 × 8.0 = 5.4400 (Table 18 col 2, band F)
# Total (sum of (30)) = 24.8350 W/K. Before the rafters column the two
# rafter parts were mis-billed at the joists U (Ext1 0.21, Ext3 0.40).
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_CASE41_RAFTERS_PDF)
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
assert abs(ht.roof_w_per_k - 24.8350) <= 1e-4
def test_summary_001431_case42_rafters_50mm_uses_table16_column_2() -> None:
# Arrange — sim case 42's single-bp roof lodged "R Rafters" + 50 mm.
# RdSAP 10 §5.11.2 Table 16 (PDF p.43) column (2) "insulation at
# rafters" 50 mm → U=0.88 (vs the joists column (1) 0.68). P960 §3 (30)
# = 0.88 × 59.5 = 52.3600 W/K.
pages = _summary_pdf_to_textract_style_pages(
_SUMMARY_001431_CASE42_50MM_RAFTERS_PDF
)
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
assert abs(ht.roof_w_per_k - 52.3600) <= 1e-4
def test_summary_001431_case42_rafters_unknown_uses_table18_column_2() -> None:
# Arrange — sim case 42's single-bp roof lodged "R Rafters" with an
# unknown thickness, age band C. RdSAP 10 §5.11 Table 18 (PDF p.45)
# column (2) "insulation at rafters" applies "for unknown and as built"
# (footnote 1) → band A-D = 2.30 (NOT the joists column (1) 100 mm
# default 0.40, which only applies to the "between joists or unknown"
# column). Worksheet-confirmed by the case-42 variant set. P960 §3 (30)
# = 2.30 × 59.5 = 136.8500 W/K.
pages = _summary_pdf_to_textract_style_pages(
_SUMMARY_001431_CASE42_UNKNOWN_RAFTERS_PDF
)
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(
ElmhurstSiteNotesExtractor(pages).extract()
)
# Act
ht = heat_transmission_section_from_cert(epc)
# Assert
assert abs(ht.roof_w_per_k - 136.8500) <= 1e-4
def test_summary_001431_topfloor_extracts_main_property_age_band() -> None:
# Arrange — the gas-boiler-upgrade recommendation "after" Summary
# renders "3.0 Date Built:" glued to its "Main Property" row header

View file

@ -41,7 +41,7 @@ from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Final, Optional
from typing import Any, Final, Optional, Union
from datatypes.epc.domain.epc_property_data import (
EpcPropertyData,
@ -349,6 +349,27 @@ def _joined_descriptions(elements: list[Any]) -> Optional[str]:
return " | ".join(parts)
def _roof_insulation_at_rafters(location: Optional[Union[int, str]]) -> bool:
"""True when a building part's roof insulation sits at the rafters
(the sloping side of the roof) rather than between the ceiling joists.
`roof_insulation_location` is the authoritative per-part signal it
carries the gov-EPC API integer code (1 = Rafters, per the empirically
watertight single-roof corpus map) on the API path and the stripped
Elmhurst Summary label ("Rafters" from "R Rafters") on the Summary
path. Both resolve here so `u_roof` selects the RdSAP 10 §5.11.2
Table 16 column (2) / Table 18 rafters column instead of the loft-
joist column (1). The flat deduplicated `epc.roofs[]` description list
cannot give this per-part 190/329 multi-part certs have
len(roofs) != len(parts) so the per-part location is the only
reliable discriminator (worksheet-validated by simulated case 41)."""
if location is None:
return False
if isinstance(location, int):
return location == 1
return "rafter" in location.strip().lower()
def _joined_main_roof_descriptions(roofs: list[Any]) -> Optional[str]:
"""Join roof descriptions for the MAIN (non-RR) roof U-value, dropping
"Roof room(s)" entries.
@ -873,7 +894,17 @@ def heat_transmission_from_cert(
# 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)
# RdSAP 10 §5.11.2 Table 16 column (2) / Table 18 rafters column —
# a roof lodged insulated AT RAFTERS (per-part
# `roof_insulation_location` == 1 / "R Rafters") sits on the
# shallower sloping side, so the same insulation depth yields a
# higher U than the loft-joist column (1). Driven per-part because
# the deduplicated `epc.roofs[]` description list cannot attribute
# a location to each building part.
insulation_at_rafters = _roof_insulation_at_rafters(
getattr(part, "roof_insulation_location", None)
)
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, insulation_at_rafters=insulation_at_rafters)
# RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP
# output, surfaced by the gov-EPC API as `roof_u_value`) is used
# directly in place of the §5.11 construction-default cascade. The gov

View file

@ -747,6 +747,34 @@ _ROOF_BY_AGE: Final[dict[str, float]] = {
"K": 0.16, "L": 0.16, "M": 0.15,
}
# Table 16 column (2): insulation AT RAFTERS (sloping side of the roof,
# rather than between the ceiling joists). RdSAP 10 §5.11.2 Table 16
# (PDF p.42-43). The rafter cavity is shallower than a loft void, so the
# same insulation depth yields a HIGHER U than the column (1) joist row
# (e.g. 200 mm: rafters 0.29 vs joists 0.21). Thickness mm -> U.
_ROOF_RAFTERS_BY_THICKNESS: Final[list[tuple[int, float]]] = [
(0, 2.30), (12, 1.75), (25, 1.30), (50, 0.88), (75, 0.67),
(100, 0.54), (125, 0.45), (150, 0.39), (175, 0.32), (200, 0.29),
(225, 0.25), (250, 0.23), (270, 0.21), (300, 0.19), (350, 0.16),
(400, 0.14),
]
# Table 18 rafters column: pitched-roof "insulation at rafters" default U
# by age band when the thickness cannot be determined. RdSAP 10 §5.11
# Table 18 (PDF p.45). Identical to the joist column (1) for bands A-G
# (2.30 → 0.40), then diverges higher (H 0.35 vs 0.30, I 0.35 vs 0.26,
# J/K 0.20 vs 0.16, L 0.18 vs 0.16). Unlike the loft-joist default this
# does NOT collapse to the optimistic 0.40 "assume modern retrofit" floor
# at old bands — a rafter cavity cannot be topped up from the loft, so an
# unknown-thickness rafter roof keeps the as-built age-band U (band F
# 0.68, band E 1.50, A-D 2.30). Worksheet-validated by simulated case 41
# Ext3 (band F, R Rafters, As Built → P960 §3 (30) U=0.68).
_ROOF_RAFTERS_BY_AGE: Final[dict[str, float]] = {
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50,
"F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.20,
"K": 0.20, "L": 0.18, "M": 0.18,
}
# Table 18 column (3): flat-roof default U by age band when thickness unknown.
# RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults
# bottom out at 0.40 because "between joists insulation" is the implicit
@ -793,6 +821,7 @@ def u_roof(
is_flat_roof: bool = False,
is_sloping_ceiling: bool = False,
is_pitched_sloping_ceiling: bool = False,
insulation_at_rafters: bool = False,
) -> float:
"""RdSAP10 roof U-value in W/m^2K, never null.
@ -829,7 +858,27 @@ def u_roof(
(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.
`insulation_at_rafters` selects the RdSAP 10 §5.11.2 Table 16 column
(2) thickness ladder and the Table 18 rafters age-band column instead
of the loft-joist column (1). A roof lodged insulated AT RAFTERS
(`roof_insulation_location == 1` on the API path, "R Rafters" on the
Summary path) sits on the sloping side of the roof a shallower
cavity than a loft void, so the same insulation depth yields a higher
U (200 mm: 0.29 vs the joists 0.21). Ignored for flat / sloping-
ceiling roofs (the rafter distinction is a pitched-with-loft concept).
Worksheet-validated by simulated case 41 Ext1 (band C, R Rafters,
200 mm 0.29) and Ext3 (band F, R Rafters, As Built 0.68).
"""
# RdSAP 10 §5.11.2 Table 16 / §5.11 Table 18 — pick the rafters
# column when the insulation sits at the rafters rather than the
# loft joists. Flat / sloping-ceiling geometries keep their own
# dedicated tables (rafters is meaningless there).
use_rafters = insulation_at_rafters and not (is_flat_roof or is_sloping_ceiling)
roof_by_thickness = (
_ROOF_RAFTERS_BY_THICKNESS if use_rafters else _ROOF_BY_THICKNESS
)
roof_by_age = _ROOF_RAFTERS_BY_AGE if use_rafters else _ROOF_BY_AGE
measured = _measured_u_from_description(description)
if measured is not None:
# Full-SAP cert lodges a measured roof U-value in the description
@ -852,7 +901,7 @@ def u_roof(
# genuine "no insulation" lodgement, which keeps 2.30 (below). The
# discriminator is the deterministic "Unknown" text RdSAP renders
# for an undetermined-thickness observation.
table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else _ROOF_BY_AGE
table_18 = _FLAT_ROOF_BY_AGE if is_flat_roof else roof_by_age
return table_18.get(age_band.upper(), 0.4)
if (
is_sloping_ceiling
@ -877,9 +926,10 @@ def u_roof(
# uninsulated 2.30 W/m²K.
return 0.68 # Table 16 row 50, "Insulation at joists at ceiling level"
if insulation_thickness_mm is not None:
# nearest tabulated thickness <= supplied
u = _ROOF_BY_THICKNESS[0][1]
for t, val in _ROOF_BY_THICKNESS:
# nearest tabulated thickness <= supplied (Table 16 column (1)
# joists or column (2) rafters per `insulation_at_rafters`)
u = roof_by_thickness[0][1]
for t, val in roof_by_thickness:
if insulation_thickness_mm >= t:
u = val
return u
@ -923,7 +973,7 @@ def u_roof(
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)
return roof_by_age.get(age_band.upper(), 0.4)
# RdSAP10 Table 17 — U-values for rooms in roof where insulation thickness

View file

@ -961,6 +961,69 @@ def test_u_roof_with_explicit_insulation_thickness_uses_table16() -> None:
assert result == pytest.approx(0.21, abs=0.001)
def test_u_roof_at_rafters_explicit_thickness_uses_table16_column_2() -> None:
# Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2)
# "insulation at rafters". A roof lodged insulated AT RAFTERS
# (roof_insulation_location == 1, "R Rafters" on the Summary path)
# takes the rafters thickness ladder, NOT the column (1) joist row:
# at 200 mm the rafters U is 0.29 W/m²K vs the joists 0.21 — a ~38%
# heat-loss understatement when the joists column is mis-used. The
# joists column (1) stays 0.21 for the same thickness.
# Act
at_rafters = u_roof(
country=Country.ENG, age_band="C", insulation_thickness_mm=200,
insulation_at_rafters=True,
)
at_joists = u_roof(
country=Country.ENG, age_band="C", insulation_thickness_mm=200,
insulation_at_rafters=False,
)
# Assert
assert abs(at_rafters - 0.29) <= 0.001
assert abs(at_joists - 0.21) <= 0.001
def test_u_roof_at_rafters_thickness_ladder_matches_table16_column_2() -> None:
# Arrange — RdSAP 10 §5.11.2 Table 16 (PDF p.42-43) column (2) rows:
# 50 mm → 0.88, 100 mm → 0.54, 150 mm → 0.39, 270 mm → 0.21. Each is
# higher than the joists column (1) value at the same thickness (the
# rafter cavity is shallower so the same insulation depth yields a
# higher U).
# Act / Assert
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=50, insulation_at_rafters=True) - 0.88) <= 0.001
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=100, insulation_at_rafters=True) - 0.54) <= 0.001
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=150, insulation_at_rafters=True) - 0.39) <= 0.001
assert abs(u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=270, insulation_at_rafters=True) - 0.21) <= 0.001
def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() -> None:
# Arrange — RdSAP 10 §5.11 Table 18 (PDF p.45) rafters age-band
# column. A rafter-insulated roof with no determinable thickness
# ("R Rafters" + "As Built" → thickness None) takes the rafters
# age-band default. Band F → 0.68 (== the joists value at F), band H
# → 0.35 (vs joists 0.30), band J → 0.20 (vs joists 0.16). Unlike a
# loft-joist roof the rafter cavity cannot be topped up, so the
# optimistic 0.40 "assume modern retrofit" joist floor does NOT apply
# at old bands — band C stays 2.30 (vs the joists-unknown 0.40).
# Worksheet-validated by simulated case 41 Ext3 (band F, R Rafters,
# As Built → P960 §3 (30) U=0.68).
# Act
band_f = u_roof(country=Country.ENG, age_band="F", insulation_thickness_mm=None, insulation_at_rafters=True)
band_h = u_roof(country=Country.ENG, age_band="H", insulation_thickness_mm=None, insulation_at_rafters=True)
band_j = u_roof(country=Country.ENG, age_band="J", insulation_thickness_mm=None, insulation_at_rafters=True)
band_c = u_roof(country=Country.ENG, age_band="C", insulation_thickness_mm=None, insulation_at_rafters=True)
# Assert
assert abs(band_f - 0.68) <= 0.001
assert abs(band_h - 0.35) <= 0.001
assert abs(band_j - 0.20) <= 0.001
assert abs(band_c - 2.30) <= 0.001
def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
# Arrange — nothing known.

View file

@ -151,6 +151,44 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4()
assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0)
def test_roof_insulation_location_rafters_drives_table16_column_2_api_int_path() -> None:
# Arrange — the gov-EPC API lodges roof_insulation_location as an
# integer (1 = Rafters per the empirically watertight corpus map). A
# roof insulated AT RAFTERS with 200 mm takes RdSAP 10 §5.11.2 Table 16
# (PDF p.43) column (2) → U=0.29, NOT the joists column (1) 0.21 — the
# rafter cavity is shallower so the same depth yields a higher U. The
# per-part location is the authoritative signal (the deduplicated
# epc.roofs[] list cannot attribute a location per building part).
# Geometry: 100 m² plan → roof area 100 m². rafters: 0.29 × 100 = 29
# W/K (vs the joists 0.21 × 100 = 21 W/K).
main = make_building_part(
construction_age_band="C",
wall_construction=3,
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,
),
],
)
main.roof_insulation_location = 1 # gov-API int: Rafters
main.roof_insulation_thickness = "200mm"
epc = make_minimal_sap10_epc(
total_floor_area_m2=100.0,
country_code="ENG",
sap_building_parts=[main],
)
# Act
result = heat_transmission_from_cert(epc)
# Assert
assert abs(result.roof_w_per_k - 29.0) <= 1e-4
def test_lodged_roof_u_value_overrides_construction_default() -> None:
# Arrange — RdSAP 10 §5.1: where an element's U-value is known from the
# assessment (documentary evidence / the lodged RdSAP output) it is used

View file

@ -67,6 +67,23 @@ _CORPUS = Path(
# energy were 5% high; actual SAP bias is +0.145).
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
#
# RAFTERS ROOF U-TABLE (RdSAP 10 §5.11.2 Table 16 col 2 + §5.11 Table 18 col 2):
# this slice billed roofs lodged insulated AT RAFTERS (roof_insulation_location
# == 1) on the spec rafters column instead of the joists column. Within-0.5 went
# 66.9% -> 66.5% (MAE 1.039 -> 1.064) — a SPEC-CORRECT move, NOT a regression to
# chase. The calculator is worksheet-validated to 1e-4 on simulated case 41
# (4-bp: measured rafters 200mm -> 0.29; rafters As-Built band F -> 0.68) and
# case 42 (6 variants: rafters 50mm -> 0.88; rafters unknown band C -> 2.30 per
# Table 18 footnote 1 "applies for unknown and as built"). The dip is a gov
# open-data REDACTION artifact: all 15 corpus rafter certs carry NO thickness
# (blanked to None) yet lodge roof energy_efficiency_rating 2-4 (insulated),
# proving they had a SPECIFIED thickness the open API redacted. With the
# thickness gone the spec's unknown-rafter default (2.30) correctly fires but
# over-states those certs' real (insulated) roof. Recovering them needs a
# roof-EER -> assumed-thickness inference on the API path (future slice), NOT a
# change to the spec-correct U-table. Do NOT revert the rafters column to "fix"
# the gauge.
_MIN_WITHIN_HALF_SAP = 0.65
_MAX_SAP_MAE = 1.08
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current