From ac7f510ccb2f1bc4c891835d9e63f526cb49612e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 4 Jun 2026 08:58:37 +0000 Subject: [PATCH] =?UTF-8?q?S0380.214:=20as-built=20sloping-ceiling=20roof?= =?UTF-8?q?=20=E2=86=92=20Table=2018=20col=20(3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../worksheet/heat_transmission.py | 8 ++- domain/sap10_ml/rdsap_uvalues.py | 22 +++++++ domain/sap10_ml/tests/test_rdsap_uvalues.py | 60 +++++++++++++++++++ .../rdsap/test_golden_fixtures.py | 25 ++++++-- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 9ddce6c6..2a4d7088 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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 diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 0e39f8a7..529b2fcb 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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) diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index a864600f..85dbd489 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index e9918716..49f66e24 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -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(