S0380.214: as-built sloping-ceiling roof → Table 18 col (3)

A "Pitched, sloping ceiling" roof (roof_construction code 8) lodged with
"As Built" insulation (no measured thickness → None) was wrongly routed to
RdSAP 10 Table 18 column (1) "insulation between joists or unknown". A
sloping ceiling has no joist void, so per RdSAP 10 §5.11 roof-input item
5-5 ("Sloping ceiling insulation … unknown / as built → Table 18") and
Table 18 note (b) ("Applies also to roof with sloping ceiling") it takes
column (3) — band F = 0.68, band L = 0.18 (vs col 1 0.40 / 0.16).

Discriminator is the code-8 "sloping ceiling" string only: code-5 vaulted
ceilings stay on column (1) per the 33 cohort-2 "ND" vaulted certs
(S0380.211), and the "NI"/"ND" unknown case is untouched. New
`is_pitched_sloping_ceiling` flag threaded from heat_transmission to
`u_roof`; pre-1950 bands already reach the same col (3) value (2.30) via
the mapper's thickness=0 → Table 16 row-0 override, so the new branch
carries the post-1950 bands where col 1 ≠ col 3.

Worksheet-validated by simulated case 15 (the 7536 replica): our cascade
on its Summary matches the P960 worksheet exactly — roof HLC 29.17 W/K,
cont SAP 65.04 vs 65. Re-pins golden cert 7536: roof 26.77 → 29.17, cont
SAP 69.071 → 68.924, PE -7.0776 → -6.1952, CO2 -0.1875 → -0.1639 (SAP
integer 68, resid +1 unchanged — the remaining +0.92 is a diffuse demand
under-count needing a fully-faithful worksheet). Blast radius: 7536 only.

Suite: 2388 passed, 1 skipped (main); sap10_ml 233 passed + 2 pre-existing
stone-formula failures (out of scope). Zero new pyright errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-04 08:58:37 +00:00
parent ec64c39d74
commit ac7f510ccb
4 changed files with 108 additions and 7 deletions

View file

@ -791,7 +791,13 @@ def heat_transmission_from_cert(
is_sloping_ceiling = (
"sloping ceiling" in roof_type_lower or "vaulted" 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)
# 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

View file

@ -721,6 +721,7 @@ def u_roof(
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.
@ -745,6 +746,18 @@ def u_roof(
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:
@ -789,6 +802,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

@ -945,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

View file

@ -370,8 +370,8 @@ _EXPECTATIONS: tuple[_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 "
@ -379,10 +379,23 @@ _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. The residual +0.92 cont SAP is a "
"diffuse demand under-count (window split + Main suspended-"
"timber floor) not capturable from the API-only JSON; needs a "
"fully-faithful 7536 worksheet (in progress) to localise or "
"conclude it is 0240-like."
),
),
_GoldenExpectation(