From fc5f10ea92e12de78f327ed186a547961326bfb4 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 19 Jun 2026 14:03:13 +0000 Subject: [PATCH 01/12] =?UTF-8?q?fix(rating):=20floor=20the=20continuous?= =?UTF-8?q?=20SAP=20score=20at=201=20(SAP=2010.2=20=C2=A713=20/=20RdSAP=20?= =?UTF-8?q?10=20=C2=A713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SAP rating is spec-floored at 1 ("if the result of the calculation is less than 1, the rating is 1"). `sap_rating_integer` already clamps, but the continuous `sap_score_continuous` did not — so a degenerate dwelling could emit a physically impossible negative SAP. Apply the same max(1, …) floor to the continuous value (the un-rounded part is for sensitivity near real ratings, not for negative ratings). Removes a -12.3 accuracy outlier on the committed corpus (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 70.2% -> 70.3%, MAE 0.845 -> 0.833. Ratcheted the corpus MAE ceiling to 0.84. Unit-pinned in test_calculator. pyright not installed in this codespace (strict gate not run locally). Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_calculator/calculator.py | 8 ++++++- .../sap10_calculator/test_calculator.py | 22 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 8 ++++++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 11cecf73..fb859bf6 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -617,7 +617,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: ) ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa) sap_int = sap_rating_integer(ecf=ecf) - sap_cont = sap_rating(ecf=ecf) + # SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1 ("if the + # result of the calculation is less than 1, the rating is 1"). Apply the + # same floor to the continuous value so it stays a valid rating — the + # un-rounded part is for sensitivity NEAR real ratings, not for emitting + # a physically impossible negative SAP on a degenerate dwelling (e.g. a + # cert lodged at the floor of 1). Mirrors `sap_rating_integer`'s max(1,…). + sap_cont = max(1.0, sap_rating(ecf=ecf)) co2_factor = inputs.co2_factor_kg_per_kwh # Per-end-use effective CO2 factors (Table 12d monthly cascade for # electricity, annual for gas). cert_to_inputs supplies these from diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index dba25409..5444a140 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -270,6 +270,28 @@ def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> ) +def test_sap_score_continuous_floored_at_1_for_degenerate_high_cost() -> None: + # Arrange — SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1 + # ("if the result of the calculation is less than 1, the rating is 1"). + # Drive the cost so high that the raw ECF formula returns a negative SAP + # (a degenerate dwelling, e.g. a cert lodged at the floor of 1); both the + # integer AND the continuous score must clamp to 1 rather than emit a + # physically impossible negative rating. + inputs = replace( + _baseline_inputs(), + space_heating_fuel_cost_gbp_per_kwh=5.0, + hot_water_fuel_cost_gbp_per_kwh=5.0, + other_fuel_cost_gbp_per_kwh=5.0, + ) + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert — raw SAP would be < 1 here; the floor holds on both outputs. + assert result.sap_score == 1 + assert abs(result.sap_score_continuous - 1.0) <= 1e-9 + + def test_calculate_exposes_dimensions_intermediates() -> None: # Arrange — P5 trace mode: `result.intermediate` must surface the # worksheet-named dimensions variables for per-section diffing diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index ef933adb..7b3524f5 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -150,8 +150,14 @@ _CORPUS = Path( # MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). A prior session deferred enum 9 # ("direction not understood") while the PE/CO2 lens was confounded by the # climate-cascade bug (fc7c4d2d); the corrected lens shows the over-rate. +# SAP RATING FLOOR (SAP 10.2 §13 / RdSAP 10 §13): the rating is floored at 1 +# ("if the result is less than 1, the rating is 1"). `calculate_sap_from_inputs` +# now applies that floor to the CONTINUOUS score too (was integer-only), so a +# degenerate dwelling no longer emits a negative SAP. Removed a -12.3 outlier +# (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 +# 70.2% -> 70.3%, MAE 0.845 -> 0.833. _MIN_WITHIN_HALF_SAP = 0.70 -_MAX_SAP_MAE = 0.85 +_MAX_SAP_MAE = 0.84 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current From 034d4b7cd53af4e5d15700020046ebd339141288 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 19 Jun 2026 14:32:31 +0000 Subject: [PATCH 02/12] =?UTF-8?q?fix(uvalues):=20bill=20known-insulation?= =?UTF-8?q?=20stone=20walls=20by=20the=20=C2=A75.6=20thickness=20formula,?= =?UTF-8?q?=20uncapped=20(RdSAP=2010=20=C2=A75.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An uninsulated stone wall of lodged thickness, age bands A-E, is billed by the RdSAP 10 §5.6 Table-12 formula on its measured thickness — sandstone / limestone U = 54.876·W^-0.561, granite / whinstone U = 45.315·W^-0.513. Two bugs suppressed it: 1. CAP: the as-built branch capped the formula result at the Table-6 typical-thickness default (`if u0 >= 1.7: return 1.7`). But the formula only dips below 1.7 past ~488 mm (sandstone) / ~640 mm (granite), so the cap nullified §5.6 for essentially every real-thickness stone wall, under-counting fabric loss and over-rating. A measured 400 mm sandstone age-B wall is 1.90 (Elmhurst-confirmed), not 1.70. 2. NO INSULATION-STATE GATE: the branch fired for any stone wall with a lodged thickness, including ones whose insulation is "Unknown". RdSAP treats an unknown-insulation wall via the Table-6 typical-thickness default, NOT as bare stone of the lodged thickness — so a 50 mm granite wall with Unknown insulation must read 1.70 (worksheet), not the formula's 6.09. Gated on `wall_insulation_type is not None`. Fixes the 2 long-standing stone-U unit tests (granite 120 mm → 3.8871, sandstone 120 mm → 3.7408 — they were correct red tests, not "known fails"). Corpus within-0.5 70.3% -> 71.6% (MAE 0.833 -> 0.822); ratcheted floors to 0.71 / 0.83. Worksheet fixture 000565 (granite 50 mm Unknown → 1.70) still passes via the insulation gate. The two stone groups (sandstone/limestone vs granite/whinstone) keep their distinct §5.6 coefficients. pyright not installed in this codespace (strict gate not run locally). Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 43 +++++++++++-------- .../epc_client/test_sap_accuracy_corpus.py | 17 +++++++- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 33b9741c..26e4f611 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -567,23 +567,25 @@ def u_wall( ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] - # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall thin-wall - # formula, age bands A-E. Fires only when a documentary wall - # thickness is lodged (per §5.3 documentary-evidence rule). - # §5.8 + Table 14 dry-line adjustment applies on top. - # - # Table 6 footnote (a) (PDF p.34): "Or from equations in 5.6 if - # the calculated U-value is less than 1.7." The cap applies only - # to the AS-BUILT (no insulation, no dry-line) Table 6 row — for - # thin walls where §5.6 gives U ≥ 1.7 (e.g. granite at W=50 mm - # yields 6.09 → use Table 6 default 1.7 instead). When the wall - # is dry-lined or insulated, the raw §5.6 result feeds the §5.8 - # chain as the input U₀ — the Table 6 footnote doesn't cap that - # path (verified empirically against cert 000565 Main alt_wall_1: - # granite W=120 mm dry-lined → U₀=3.88 raw + dry-line → 2.34 - # matches worksheet, NOT 1.7 + dry-line → 1.32). + # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall formula, age + # bands A-E. Fires only when (a) a documentary wall thickness is lodged + # (per §5.3 documentary-evidence rule) AND (b) the insulation STATE is + # known (`wall_insulation_type` not None — As Built / external / internal). + # When either is absent the cascade falls through to the Table-6 + # typical-thickness default (1.7) below: an "insulation Unknown" lodgement + # is NOT treated as bare stone of the lodged thickness (cert 000565 Ext1: + # granite 50 mm + insulation Unknown → Table-6 1.70 in the worksheet, NOT + # the §5.6 formula's 6.09), and the footnote (a) "use §5.6 if the + # calculated U-value is less than 1.7" clause governs the unknown path. When the thickness IS + # lodged the raw §5.6 U is the spec target — it is NOT capped at 1.7, + # because a thin/standard solid stone wall genuinely loses more than the + # typical-thickness default (sandstone 400 mm → 1.90, granite 120 mm → + # 3.89). §5.8 + Table 14 insulation / dry-line adjustments apply on top + # of the raw §5.6 U₀ (cert 000565 Main alt_wall_1: granite W=120 mm + # dry-lined → U₀=3.88 + dry-line → 2.34, matches worksheet). if ( wall_thickness_mm is not None + and wall_insulation_type is not None and band in _STONE_AGE_A_TO_E and construction in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE) ): @@ -628,8 +630,15 @@ def u_wall( Decimal("0.01"), rounding=ROUND_HALF_UP ) ) - if u0 >= 1.7: - return 1.7 # Table-6 row cap per footnote (a) + # As-built (uninsulated, not dry-lined) stone wall of KNOWN + # thickness, age A-E: return the raw §5.6 result. The Table-6 + # footnote (a) "< 1.7" clause governs the UNKNOWN-thickness path + # (which falls through to the Table-6 typical 1.7 default below) — + # NOT a documentary-thickness lodgement. A thin solid stone wall + # genuinely has U > 1.7 (e.g. sandstone 400 mm = 1.90, granite + # 120 mm = 3.89); capping it to 1.7 under-counts fabric loss and + # over-rates. Confirmed against Elmhurst (age-B sandstone 400 mm + # → 1.9) and the §5.6 Table-12 formula tests. return u0 known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 7b3524f5..2f454359 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -150,14 +150,27 @@ _CORPUS = Path( # MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). A prior session deferred enum 9 # ("direction not understood") while the PE/CO2 lens was confounded by the # climate-cascade bug (fc7c4d2d); the corrected lens shows the over-rate. +# UNINSULATED STONE WALL §5.6 FORMULA (RdSAP 10 §5.6 Table 12, PDF p.40): a +# stone wall of KNOWN thickness whose insulation STATE is known (As Built / +# external / internal) is billed by the §5.6 formula on its lodged thickness +# (sandstone/limestone U = 54.876·W^-0.561, granite/whinstone 45.315·W^-0.513), +# NOT capped at the Table-6 typical-thickness 1.7. The old `if u0>=1.7: 1.7` +# cap nullified the formula for every real-thickness stone wall (it only dips +# below 1.7 past ~488 mm sandstone / ~640 mm granite) and under-counted fabric +# loss → over-rate. Gated on `wall_insulation_type is not None` so an +# "insulation Unknown" wall still falls to the Table-6 default (cert 000565 +# Ext1: granite 50 mm + Unknown → worksheet 1.70, not the formula's 6.09). +# Took within-0.5 70.3% -> 71.6% (MAE 0.833 -> 0.822); fixed the 2 stone-U +# unit tests; worksheet-validated (Elmhurst age-B sandstone 400 mm → 1.90). +# # SAP RATING FLOOR (SAP 10.2 §13 / RdSAP 10 §13): the rating is floored at 1 # ("if the result is less than 1, the rating is 1"). `calculate_sap_from_inputs` # now applies that floor to the CONTINUOUS score too (was integer-only), so a # degenerate dwelling no longer emits a negative SAP. Removed a -12.3 outlier # (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 # 70.2% -> 70.3%, MAE 0.845 -> 0.833. -_MIN_WITHIN_HALF_SAP = 0.70 -_MAX_SAP_MAE = 0.84 +_MIN_WITHIN_HALF_SAP = 0.71 +_MAX_SAP_MAE = 0.83 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current From 7e187078b9ef4e313804f5818110a1d2def0b5cb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 13:24:31 +0000 Subject: [PATCH 03/12] fix(uvalues): cap as-built stone U at age E only, not A-E; drop insulation-state gate (RdSAP 10 Tables 6-7 footnote a, PDF p.33-35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 034d4b7c corrected two stone cases but via the wrong mechanism: it removed the 1.7 cap for ALL age bands and gated the §5.6 formula on `wall_insulation_type is not None`. Both diverge from RdSAP 10 Tables 6-10: - The stone rows read "According to 5.6" for bands A-D (uncapped formula) and "1.7 a" at band E, where footnote (a) = "Or from equations in 5.6 if the calculated U-value is less than 1.7" → U_E = min(formula, 1.7). The cap belongs ONLY at age E. Removing it for E let an as-built 50 mm granite age-E wall return the formula's 6.09 instead of 1.70. - Scotland sandstone/limestone age E defaults to 1.5 (Table 7, PDF p.35); granite/whinstone stays 1.7. - The insulation-state gate is not a spec rule. It sent age-A-D stone with "insulation Unknown" (wall_insulation_type None) to the flat-1.7 table instead of the §5.6 formula; it only "worked" for fixture 000565 Ext1 because that wall is age E, where 1.7 is correct anyway. Removed: the age-E cap, not the gate, is what produces 000565 Ext1's worksheet 1.70. Bands A-D stay uncapped (sandstone 400 mm → 1.90, granite 120 mm → 3.89). Unknown-thickness handling unchanged in this commit (still flat-table; the §3.5 Table-3 default follows separately). pyright not installed in this container — strict type gate not run locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 62 ++++++++------ domain/sap10_ml/tests/test_rdsap_uvalues.py | 90 +++++++++++++++++++++ 2 files changed, 126 insertions(+), 26 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 26e4f611..fae4a9ff 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -567,30 +567,43 @@ def u_wall( ctry = country if country is not None else Country.ENG age_idx = _age_index(age_band) band = _AGE_BANDS[age_idx] - # RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall formula, age - # bands A-E. Fires only when (a) a documentary wall thickness is lodged - # (per §5.3 documentary-evidence rule) AND (b) the insulation STATE is - # known (`wall_insulation_type` not None — As Built / external / internal). - # When either is absent the cascade falls through to the Table-6 - # typical-thickness default (1.7) below: an "insulation Unknown" lodgement - # is NOT treated as bare stone of the lodged thickness (cert 000565 Ext1: - # granite 50 mm + insulation Unknown → Table-6 1.70 in the worksheet, NOT - # the §5.6 formula's 6.09), and the footnote (a) "use §5.6 if the - # calculated U-value is less than 1.7" clause governs the unknown path. When the thickness IS - # lodged the raw §5.6 U is the spec target — it is NOT capped at 1.7, - # because a thin/standard solid stone wall genuinely loses more than the - # typical-thickness default (sandstone 400 mm → 1.90, granite 120 mm → - # 3.89). §5.8 + Table 14 insulation / dry-line adjustments apply on top - # of the raw §5.6 U₀ (cert 000565 Main alt_wall_1: granite W=120 mm - # dry-lined → U₀=3.88 + dry-line → 2.34, matches worksheet). + # RdSAP 10 Tables 6-10 stone rows + footnote (a) (PDF p.33-39), §5.6 + # formula (PDF p.40), §5.8 + Table 14 (PDF p.41-42). A documentary wall + # thickness (per §5.3) routes stone in age bands A-E off the §5.6 formula, + # NOT the flat Table-6 typical-thickness default: + # - Bands A-D: pure §5.6 formula, UNCAPPED. The stone rows read + # "According to 5.6" with NO 1.7 entry, because a thin/standard solid + # stone wall genuinely loses more than the typical default (sandstone + # 400 mm → 1.90, granite 120 mm → 3.89). + # - Band E: the stone row reads "1.7 a"; footnote (a) = "Or from + # equations in 5.6 if the calculated U-value is less than 1.7" → + # U_E = min(formula, 1.7). Scotland sandstone/limestone age E defaults + # to 1.5 (Table 7), granite/whinstone stays 1.7. The 1.7 (1.5) cap + # belongs ONLY at age E, never A-D. + # The insulation STATE is NOT a gate: an "as built / insulation Unknown" + # lodgement (`wall_insulation_type` None or 4) takes the formula too. Cert + # 000565 Ext1 (granite 50 mm, age E, insulation Unknown) → min(6.09, 1.7) + # = 1.70, matching the U985 worksheet WITHOUT a flat-table detour — the + # age-E cap, not an insulation gate, is what produces the 1.70. if ( wall_thickness_mm is not None - and wall_insulation_type is not None and band in _STONE_AGE_A_TO_E and construction in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE) ): u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm) if u0 is not None: + # Footnote (a) cap is age-E only: clamp the as-built U to the + # Table-6/7 age-E default (1.7, or 1.5 for Scotland sandstone/ + # limestone) when the §5.6 formula exceeds it. A-D stay uncapped. + if band == "E": + e_default = ( + 1.5 + if ctry == Country.SCT + and construction == WALL_STONE_SANDSTONE + else 1.7 + ) + if u0 >= e_default: + u0 = e_default # RdSAP 10 §5.8 + Table 14 (PDF p.41-42) — added External/Internal # insulation on a stone wall: U = 1/(1/U₀ + R_ins), with U₀ the # RAW §5.6 stone result (the Table-6 footnote (a) 1.7 cap does NOT @@ -630,15 +643,12 @@ def u_wall( Decimal("0.01"), rounding=ROUND_HALF_UP ) ) - # As-built (uninsulated, not dry-lined) stone wall of KNOWN - # thickness, age A-E: return the raw §5.6 result. The Table-6 - # footnote (a) "< 1.7" clause governs the UNKNOWN-thickness path - # (which falls through to the Table-6 typical 1.7 default below) — - # NOT a documentary-thickness lodgement. A thin solid stone wall - # genuinely has U > 1.7 (e.g. sandstone 400 mm = 1.90, granite - # 120 mm = 3.89); capping it to 1.7 under-counts fabric loss and - # over-rates. Confirmed against Elmhurst (age-B sandstone 400 mm - # → 1.9) and the §5.6 Table-12 formula tests. + # As-built (uninsulated, not dry-lined) stone wall, age A-E: + # return the §5.6 result — uncapped for A-D, age-E-capped above. + # A thin solid stone wall genuinely has U > 1.7 (sandstone 400 mm + # = 1.90, granite 120 mm = 3.89); capping A-D to 1.7 under-counts + # fabric loss and over-rates. Confirmed against Elmhurst (age-B + # sandstone 400 mm → 1.90) and the §5.6 Table-12 formula tests. return u0 known_types = { WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY, diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 38efc93c..14599a33 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -849,6 +849,96 @@ def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a assert abs(result - 1.7) <= 1e-3 +def test_u_wall_stone_granite_age_e_50mm_caps_at_table6_default_1_7() -> None: + # Arrange — RdSAP 10 Table 6 (England, PDF p.33-34) stone row reads + # "1.7 a" at age E, footnote (a) = "Or from equations in 5.6 if the + # calculated U-value is less than 1.7". A 50 mm granite wall's §5.6 + # formula gives U = 45.315 × 50^(-0.513) = 6.09 (> 1.7), so the age-E + # default 1.7 stands → min(6.09, 1.7) = 1.70. The cap is age-E ONLY: + # bands A-D are uncapped (120 mm age-A granite = 3.89). Insulation is + # Unknown (wall_insulation_type None) — no longer a gate. Cert 000565 + # BP Ext1 is this fixture (U985 worksheet U = 1.70). + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=None, + dry_lined=False, + wall_thickness_mm=50, + ) + + # Assert + assert abs(result - 1.70) <= 1e-3 + + +def test_u_wall_stone_sandstone_age_e_thick_wall_uses_5_6_formula_below_1_7() -> None: + # Arrange — footnote (a) at age E: use the §5.6 formula when it gives + # < 1.7. A 600 mm sandstone wall → U = 54.876 × 600^(-0.561) = 1.5165 + # (< 1.7), so the formula value is used, NOT the 1.7 default. + + # Act + result = u_wall( + country=Country.ENG, + age_band="E", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=None, + dry_lined=False, + wall_thickness_mm=600, + ) + + # Assert + assert abs(result - 1.5165) <= 1e-3 + + +def test_u_wall_stone_sandstone_scotland_age_e_caps_at_1_5_not_1_7() -> None: + # Arrange — RdSAP 10 Table 7 (Scotland, PDF p.35) sandstone/limestone + # age E default is "1.5 a" (granite/whinstone stays 1.7). A 500 mm + # sandstone wall's §5.6 formula = 54.876 × 500^(-0.561) = 1.68 (> 1.5), + # so the Scotland age-E default 1.5 stands → min(1.68, 1.5) = 1.50. + + # Act + result = u_wall( + country=Country.SCT, + age_band="E", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=None, + dry_lined=False, + wall_thickness_mm=500, + ) + + # Assert + assert abs(result - 1.50) <= 1e-3 + + +def test_u_wall_stone_granite_scotland_age_e_50mm_stays_capped_at_1_7() -> None: + # Arrange — Scotland granite/whinstone age E default is 1.7 (only + # sandstone/limestone drops to 1.5, Table 7 PDF p.35). A 50 mm granite + # wall's formula 6.09 (> 1.7) → min(6.09, 1.7) = 1.70, NOT 1.5. + + # Act + result = u_wall( + country=Country.SCT, + age_band="E", + construction=WALL_STONE_GRANITE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=None, + dry_lined=False, + wall_thickness_mm=50, + ) + + # Assert + assert abs(result - 1.70) <= 1e-3 + + def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None: # Arrange — when the cert lodges `Type: CW Curtain Wall` but no # `Curtain Wall Age` line (older Elmhurst Summary PDFs, or API EPCs From 54ae05d04b056b4ea2654b552572de4a0389ccdf Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 13:26:36 +0000 Subject: [PATCH 04/12] =?UTF-8?q?fix(uvalues):=20default=20unknown-thickne?= =?UTF-8?q?ss=20stone=20to=20=C2=A73.5=20Table=203=20thickness,=20not=20fl?= =?UTF-8?q?at=201.7=20(RdSAP=2010=20Table=203,=20PDF=20p.20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a stone wall lodges no documentary thickness, RdSAP 10 §3.5 Table 3 supplies the default thickness to feed the §5.6 formula — it does NOT fall to a flat 1.7. Table 3 stone row: A-D = 500 mm, E = 450, F-H = 420; Scotland footnote (*) adds 200 mm for bands A,B and 100 mm otherwise. This matches accredited Elmhurst: an England age-B granite/whinstone wall, as built, unknown thickness defaults to 500 mm → U = 45.315 × 500^(-0.513) = 1.87 (sandstone → 1.68). The previous flat-1.7 fallback was a setup error — Table 6 reads "According to 5.6" for bands A-D with no 1.7 entry, so the formula (not 1.7) is the spec target. The age-E footnote-(a) cap still applies on top. Adds `_table_3_stone_thickness(band, country)`. Updated the stale `..._returns_table_6_age_a_default` pin (was 1.7 → now 1.87 via Table-3) and added sandstone (1.68) + Scotland-+200mm (700 mm → 1.39) pins. Corpus: within-0.5 71.6% unchanged, SAP MAE 0.821 → 0.819, PE MAE 3.7 → 3.6. pyright not installed in this container — strict type gate not run locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 36 +++++++++++-- domain/sap10_ml/tests/test_rdsap_uvalues.py | 59 +++++++++++++++++---- 2 files changed, 83 insertions(+), 12 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index fae4a9ff..b75b17a4 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -226,6 +226,26 @@ def _u_stone_thin_wall_age_a_to_e( return None +def _table_3_stone_thickness(band: str, country: Country) -> int: + """RdSAP 10 §3.5 Table 3 (PDF p.20) — default stone wall thickness (mm) + used "only when the wall thickness could not be measured". + + Stone row: A-D = 500, E = 450, F-H = 420, I+ = 450. + Scotland footnote (*): add 200 mm for bands A and B, 100 mm for other + bands. Only A-E reach this helper (the §5.6 formula gate), so the F+ + branches are defensive. + """ + if band in ("A", "B", "C", "D"): + base = 500 + elif band == "E": + base = 450 + else: + base = 420 + if country == Country.SCT: + base += 200 if band in ("A", "B") else 100 + return base + + def _u_brick_thin_wall_age_a_to_e(wall_thickness_mm: int) -> float: """RdSAP 10 §5.7 Table 13 (PDF p.41) — default U-value for an uninsulated solid brick wall by lodged thickness, age bands A-E. @@ -585,12 +605,22 @@ def u_wall( # 000565 Ext1 (granite 50 mm, age E, insulation Unknown) → min(6.09, 1.7) # = 1.70, matching the U985 worksheet WITHOUT a flat-table detour — the # age-E cap, not an insulation gate, is what produces the 1.70. + # + # When no documentary thickness is lodged, §3.5 Table 3 (PDF p.20) gives + # the default thickness to feed the formula (stone A-D = 500 mm, E = 450, + # Scotland +200/+100) — NOT a flat 1.7. This matches Elmhurst: an age-B + # granite as-built wall with unknown thickness defaults to 500 mm → + # 45.315 × 500^(-0.513) = 1.87 (sandstone → 1.68). if ( - wall_thickness_mm is not None - and band in _STONE_AGE_A_TO_E + band in _STONE_AGE_A_TO_E and construction in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE) ): - u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm) + w = ( + wall_thickness_mm + if wall_thickness_mm is not None + else _table_3_stone_thickness(band, ctry) + ) + u0 = _u_stone_thin_wall_age_a_to_e(construction, w) if u0 is not None: # Footnote (a) cap is age-E only: clamp the as-built U to the # Table-6/7 age-E default (1.7, or 1.5 for Scotland sandstone/ diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 14599a33..210b97bf 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -826,13 +826,14 @@ def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_ assert abs(result - 0.60) <= 1e-3 -def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a_default() -> None: - # Arrange — §5.6 formula only fires when a wall thickness is - # lodged. Without documentary wall-thickness evidence, fall back - # to the Table 6 row (which represents typical thickness). For - # age A stone granite without thickness, the cascade preserves - # its existing "as-built typical" U value rather than the formula - # extrapolation. +def test_u_wall_stone_granite_age_a_without_wall_thickness_uses_table_3_default_500mm() -> None: + # Arrange — when no documentary wall thickness is lodged, RdSAP 10 §3.5 + # Table 3 (PDF p.20) supplies the default stone thickness (A-D = 500 mm), + # which feeds the §5.6 formula — NOT a flat 1.7. This matches Elmhurst: + # an as-built granite/whinstone wall with unknown thickness defaults to + # 500 mm → U = 45.315 × 500^(-0.513) = 1.8693. (The earlier 1.7 + # expectation was a setup error: Table 6 reads "According to 5.6" for + # bands A-D, with no 1.7 entry.) # Act result = u_wall( @@ -845,8 +846,48 @@ def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a wall_thickness_mm=None, ) - # Assert — _TYPICAL_STONE_UNINSULATED at age A = 1.7 (cohort default). - assert abs(result - 1.7) <= 1e-3 + # Assert — §5.6 formula at the Table-3 default 500 mm. + assert abs(result - 1.8693) <= 1e-3 + + +def test_u_wall_stone_sandstone_age_b_without_wall_thickness_uses_table_3_default_500mm() -> None: + # Arrange — sandstone/limestone variant of the Table-3 default: age B, + # unknown thickness → 500 mm → 54.876 × 500^(-0.561) = 1.6798. This is + # the "500 mm → sandstone 1.68" Elmhurst default. + + # Act + result = u_wall( + country=Country.ENG, + age_band="B", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + wall_thickness_mm=None, + ) + + # Assert + assert abs(result - 1.6798) <= 1e-3 + + +def test_u_wall_stone_sandstone_scotland_age_a_without_thickness_adds_200mm_per_table_3() -> None: + # Arrange — Table 3 Scotland footnote (*): add 200 mm for bands A and B. + # Age-A Scotland sandstone unknown thickness → 500 + 200 = 700 mm → + # 54.876 × 700^(-0.561) = 1.3909 (< 1.7, no age-E cap at band A). + + # Act + result = u_wall( + country=Country.SCT, + age_band="A", + construction=WALL_STONE_SANDSTONE, + insulation_thickness_mm=None, + insulation_present=False, + wall_insulation_type=4, + wall_thickness_mm=None, + ) + + # Assert + assert abs(result - 1.3909) <= 1e-3 def test_u_wall_stone_granite_age_e_50mm_caps_at_table6_default_1_7() -> None: From 381ecfda4c87a994ba1e3a0a591ab0384dcd8949 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 13:29:54 +0000 Subject: [PATCH 05/12] fix(uvalues): add missing Scotland band-J 0.30 wall override for all 7 as-built types (RdSAP 10 Table 7, PDF p.35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full code-vs-spec sweep of unknown-thickness as-built U-values across all seven wall types found exactly one divergence: Scotland age band J. Table 7 (Scotland) gives 0.30 for every uninsulated wall type, but the England base (Table 6) is 0.35 and the _COUNTRY_KLM_OVERRIDES[SCT] dicts listed H/K/L/M while omitting J — so a Scotland band-J wall wrongly returned England's 0.35. Added 'J': 0.30 to all 7 SCT as-built entries (granite, sandstone, solid brick, cavity, timber frame, system built, cob). The audit's companion finding — Scotland sandstone age E = 1.5 — needs NO table override: the §5.6 stone branch intercepts all sandstone bands A-E and already caps age E at 1.5 for Scotland (e_default in the prior commit), so a table-E override would be dead code. England base tables and Isle of Man (= England) are confirmed correct by the same sweep. Corpus unchanged (England-only corpus has no Scotland band-J certs). pyright not installed in this container — strict type gate not run locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 18 ++++++----- domain/sap10_ml/tests/test_rdsap_uvalues.py | 36 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index b75b17a4..725c7726 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -439,14 +439,16 @@ _CAVITY_FILLED_ENG: Final[list[float]] = [ # entries that differ from the England base. _COUNTRY_KLM_OVERRIDES: Final[dict[Country, dict[tuple[int, int], dict[str, float]]]] = { Country.SCT: { - # Scotland Cavity-as-built K-M: 0.25, 0.22, 0.17 (vs ENG 0.30, 0.28, 0.26). - (WALL_CAVITY, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17}, - (WALL_STONE_GRANITE, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17}, - (WALL_STONE_SANDSTONE, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17}, - (WALL_SOLID_BRICK, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17}, - (WALL_TIMBER_FRAME, 0): {"K": 0.25, "L": 0.22, "M": 0.17}, - (WALL_SYSTEM_BUILT, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17}, - (WALL_COB, 0): {"K": 0.25, "L": 0.22, "M": 0.17}, + # Scotland (Table 7, PDF p.35) as-built bands that diverge from the + # England base: H 0.60→0.45 (not timber/cob, which are already 0.40/ + # 0.60), J 0.35→0.30 (ALL types), K 0.30→0.25, L 0.28→0.22, M 0.26→0.17. + (WALL_CAVITY, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, + (WALL_STONE_GRANITE, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, + (WALL_STONE_SANDSTONE, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, + (WALL_SOLID_BRICK, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, + (WALL_TIMBER_FRAME, 0): {"J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, + (WALL_SYSTEM_BUILT, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, + (WALL_COB, 0): {"J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17}, }, Country.NIR: { (WALL_CAVITY, 0): {"M": 0.18}, diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 210b97bf..8d670a2f 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -421,6 +421,42 @@ def test_u_wall_scotland_age_band_m_returns_country_specific_table7_value() -> N assert result == pytest.approx(0.17, abs=0.001) +def test_u_wall_scotland_age_band_j_returns_0_30_not_england_0_35_per_table7() -> None: + # Arrange — RdSAP 10 Table 7 (Scotland, PDF p.35) as-built band J is + # 0.30 for every uninsulated wall type, vs the England base 0.35 + # (Table 6). The _COUNTRY_KLM_OVERRIDES[SCT] dicts previously listed + # H/K/L/M but omitted J, so a Scotland band-J cavity wrongly fell + # through to England's 0.35. + + # Act + result = u_wall( + country=Country.SCT, + age_band="J", + construction=WALL_CAVITY, + insulation_thickness_mm=0, + ) + + # Assert + assert result == pytest.approx(0.30, abs=0.001) + + +def test_u_wall_scotland_age_band_j_timber_frame_returns_0_30_per_table7() -> None: + # Arrange — the J=0.30 override applies to all 7 as-built wall types, + # including timber frame (England base J = 0.35). Guards the type whose + # Scotland override has no H entry (timber H already 0.40 in England). + + # Act + result = u_wall( + country=Country.SCT, + age_band="J", + construction=WALL_TIMBER_FRAME, + insulation_thickness_mm=0, + ) + + # Assert + assert result == pytest.approx(0.30, abs=0.001) + + def test_u_wall_timber_frame_as_built_age_band_a_returns_table6_value() -> None: # Arrange — Timber frame as built, age A, England -> 2.5 W/m^2K. From 104e3725b8631dd43b464189eb3438f84ddb0c0d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 13:34:31 +0000 Subject: [PATCH 06/12] test(corpus): ratchet SAP MAE 0.83->0.82 and PE 4.0->3.7 floors after stone-mechanism correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corrected stone branch (age-E-only cap, §3.5 Table-3 unknown-thickness default, Scotland band-J override) improved the corpus gauge to SAP MAE 0.819 and PE MAE 3.6 kWh/m2/yr. Lock the gains in and record the corrected mechanism in the provenance log; within-0.5 (71.6%) and CO2 (0.08) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../epc_client/test_sap_accuracy_corpus.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 2f454359..079f4e01 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -163,6 +163,20 @@ _CORPUS = Path( # Took within-0.5 70.3% -> 71.6% (MAE 0.833 -> 0.822); fixed the 2 stone-U # unit tests; worksheet-validated (Elmhurst age-B sandstone 400 mm → 1.90). # +# STONE MECHANISM CORRECTED (RdSAP 10 Tables 6-7 footnote a + §3.5 Table 3): +# the commit above (034d4b7c) got the right numbers for two cases but the +# wrong mechanism — it dropped the 1.7 cap for ALL age bands and gated on +# `wall_insulation_type is not None`. Per Tables 6-10: bands A-D = uncapped +# §5.6 formula, band E = min(formula, 1.7) (Scotland sandstone 1.5); the cap +# is age-E ONLY. The insulation-state gate is not a spec rule (it sent +# age-A-D "insulation Unknown" stone to the flat 1.7 table). Unknown +# thickness now feeds the §3.5 Table-3 default thickness (stone A-D 500 mm, +# E 450; Scotland +200/+100) into the formula — Elmhurst defaults an England +# age-B granite as-built unknown-thickness wall to 500 mm → 1.87 (sandstone +# 1.68), NOT a flat 1.7. Also added the missing Scotland band-J 0.30 override +# (Table 7) for all 7 as-built wall types. MAE 0.822 -> 0.819, PE 3.7 -> 3.6; +# within-0.5 and CO2 unchanged. Unit-pinned in test_rdsap_uvalues. +# # SAP RATING FLOOR (SAP 10.2 §13 / RdSAP 10 §13): the rating is floored at 1 # ("if the result is less than 1, the rating is 1"). `calculate_sap_from_inputs` # now applies that floor to the CONTINUOUS score too (was integer-only), so a @@ -170,9 +184,9 @@ _CORPUS = Path( # (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 # 70.2% -> 70.3%, MAE 0.845 -> 0.833. _MIN_WITHIN_HALF_SAP = 0.71 -_MAX_SAP_MAE = 0.83 +_MAX_SAP_MAE = 0.82 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current +_MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: From b22b27c0ff8ac200a252674e29ca43ba2e7a8537 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 13:57:29 +0000 Subject: [PATCH 07/12] fix(uvalues): correct rafter-roof age-M default U 0.18->0.15 (RdSAP 10 Table 18 col 2, PDF p.46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A full row-by-row audit of the roof U-value tables (16, 17, 18) and floor tables (19, 20) against the PDF found one numeric error: Table 18 column (2) "Pitched, insulation at rafters" band M is 0.15 W/m²K (footnote (1) only — no country variation; the whole M row converges to 0.15), but _ROOF_RAFTERS_BY_AGE carried 0.18. The rafters column diverges above the joist column at H-L (0.35/0.35/0.20/0.20/0.18) and rejoins it at M=0.15. Everything else in the roof/floor tables is exact: Table 16 joist + rafter thickness ladders, Table 17 room-in-roof (all 6 columns), Table 18 cols (1) joists / (3) flat / (4) room-in-roof, Table 19 (England & Wales) floor insulation defaults, and Table 20 (England) exposed-floor U-values. Known remaining gaps (NOT fixed — zero England-corpus reach, would need a new roof country-override mechanism): Scotland Table 18 footnote (2) K=0.20 (flat/ RR/thatch) and (3) joists-L=0.15; Scotland/NI Table 19 thicknesses; Scotland/ Wales Table 20 L/M overrides. Logged for a future country pass. Corpus unchanged (band-M rafter roofs essentially absent from the 1000-cert England corpus). pyright not installed in this container. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 21 +++++++++++---------- domain/sap10_ml/tests/test_rdsap_uvalues.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 725c7726..6b1e35fc 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -839,20 +839,21 @@ _ROOF_RAFTERS_BY_THICKNESS: Final[list[tuple[int, float]]] = [ (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 +# Table 18 column (2) "Pitched, insulation at rafters": pitched-roof default +# U by age band when the thickness cannot be determined. RdSAP 10 §5.11 +# Table 18 (PDF p.46). 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). +# J/K 0.20 vs 0.16, L 0.18 vs 0.16) before converging to 0.15 at band M. +# 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, + "K": 0.20, "L": 0.18, "M": 0.15, } # Table 18 column (3): flat-roof default U by age band when thickness unknown. diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 8d670a2f..77de58eb 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -1265,6 +1265,25 @@ def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() -> assert abs(band_c - 2.30) <= 0.001 +def test_u_roof_at_rafters_unknown_thickness_age_m_returns_0_15_per_table18() -> None: + # Arrange — RdSAP 10 Table 18 column (2) "Pitched, insulation at + # rafters" (PDF p.46): band M = 0.15 (footnote (1) only, no country + # variation — the whole M row converges to 0.15). The rafters column + # diverges above the joist column at H-L (0.35/0.35/0.20/0.20/0.18) + # but rejoins it at M = 0.15; the table previously carried 0.18 here. + + # Act + band_m = u_roof( + country=Country.ENG, + age_band="M", + insulation_thickness_mm=None, + insulation_at_rafters=True, + ) + + # Assert + assert abs(band_m - 0.15) <= 0.001 + + def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None: # Arrange — nothing known. From 600684f5dfaaedf2a08e7e85b40fd02b96538072 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 14:09:21 +0000 Subject: [PATCH 08/12] fix(uvalues): apply metal-frame U 1.6 for 2022-or-later windows (RdSAP 10 Table 24, PDF p.51) Table 24 "Double or triple glazed, 2022 or later" row gives U = 1.4 for PVC/wooden frames and 1.6 for metal frames. `u_window` returned 1.4 for both, ignoring the metal-frame variant and under-counting metal-frame heat loss on post-2022 windows. The 2002-2021 and pre-2002 rows already split PVC/metal correctly; this aligns the 2022+ row. Spec-pinned in test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4. pyright not installed in this container. Co-Authored-By: Claude Opus 4.8 (1M context) --- domain/sap10_ml/rdsap_uvalues.py | 3 ++- domain/sap10_ml/tests/test_rdsap_uvalues.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 6b1e35fc..b4ab5de3 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -1419,7 +1419,8 @@ def u_window( # double/triple glazing — period bands. if installed_year is not None and installed_year >= 2022: - return 1.4 + # Table 24 "2022 or later" row: PVC/wood 1.4, metal 1.6. + return 1.6 if metal else 1.4 if installed_year is not None and installed_year >= 2002: return 2.2 if metal else 2.0 # pre-2002 double/triple default to 12mm gap row. diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 77de58eb..522ccd52 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -1748,6 +1748,18 @@ def test_u_window_post_2022_pvc_returns_low_table24_value() -> None: assert result == pytest.approx(1.4, abs=0.001) +def test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4() -> None: + # Arrange — Table 24 "2022 or later" row (PDF p.51): PVC/wooden frame + # 1.4, METAL frame 1.6. The metal frame variant was previously ignored + # (1.4 returned for both), under-counting metal-frame heat loss. + + # Act + result = u_window(installed_year=2023, glazing_type="double", frame_type="metal") + + # Assert + assert result == pytest.approx(1.6, abs=0.001) + + def test_u_window_falls_back_to_mid_range_when_unknown() -> None: # Arrange — nothing known. From d7a60efcdf2eb2f0491123a0f8c9c8a7364a0e3b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sat, 20 Jun 2026 14:14:31 +0000 Subject: [PATCH 09/12] fix(uvalues): thread glazing gap into pre-2002 window U fallback (RdSAP 10 Table 24, PDF p.50) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `u_window` hard-coded the 12 mm gap row for pre-2002 double/triple glazing (double 2.8, triple 2.1), ignoring the lodged glazing gap. Table 24 splits the pre-2002 rows by gap: double 6mm=3.1 / 12mm=2.8 / 16mm+=2.7; triple 6mm=2.4 / 12mm=2.1 / 16mm+=2.0 (PVC/wooden), with a metal-frame column (+0.5/+0.5/+0.5 ish). Added a `glazing_gap` parameter + `_glazing_gap_row` helper and wired `w.glazing_gap` through the synthesised-window caller in heat_transmission. Corpus impact nil by design: the gov-API mapper already resolves per-window U gap-aware via `_API_GLAZING_TYPE_GAP_TO_TRANSMISSION` (e.g. code 3 + gap "16+" → 2.7), so corpus certs use that lodged per-window U, not this fallback. This aligns the reduced-field / worksheet fallback path with the mapper and Table 24. Unknown gap still defaults to the 12 mm row. (Metal frames are not distinguishable on the gov-API path — only a `pvc_frame` boolean exists and Table 24 groups PVC+wooden — so the PVC/wooden U stands there; the metal column applies only where frame material is lodged.) Spec-pinned: pre-2002 double + triple gap-row tests. pyright not installed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../worksheet/heat_transmission.py | 7 ++- domain/sap10_ml/rdsap_uvalues.py | 50 +++++++++++++++++-- domain/sap10_ml/tests/test_rdsap_uvalues.py | 24 +++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 80c65aac..a0d773d9 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -171,7 +171,12 @@ def _synthesised_window_u_raw(windows: Optional[Sequence[SapWindow]]) -> float: if isinstance(code, int) else ("double", None) ) - return u_window(installed_year=year, glazing_type=glaze, frame_type=w.frame_material) + return u_window( + installed_year=year, + glazing_type=glaze, + frame_type=w.frame_material, + glazing_gap=w.glazing_gap, + ) # RdSAP10 §15 "Rounding of data" (p.66): "All element areas (gross) # including window areas and conservatory wall area: 2 d.p." plus # "U-values: 2 d.p.". This is the data-passed-to-SAP-calculator diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index b4ab5de3..6687491f 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -1401,12 +1401,51 @@ def u_exposed_floor( # --------------------------------------------------------------------------- +# RdSAP 10 Table 24 (PDF p.50-51) — pre-2002 (Scotland pre-2003 / NI pre-2006) +# double and triple glazing split by glazing gap between panes: 6 mm, 12 mm, +# and 16 mm or more, each with a PVC/wooden and a metal-frame U-value. The +# 2002+ and 2022+ rows are gap-independent ("any" gap). (pvc, metal) per gap: +_PRE_2002_DOUBLE_U_BY_GAP: Final[dict[str, tuple[float, float]]] = { + "6": (3.1, 3.7), "12": (2.8, 3.4), "16+": (2.7, 3.3), +} +_PRE_2002_TRIPLE_U_BY_GAP: Final[dict[str, tuple[float, float]]] = { + "6": (2.4, 2.9), "12": (2.1, 2.6), "16+": (2.0, 2.5), +} + + +def _glazing_gap_row(glazing_gap: "str | int | None") -> str: + """Map a lodged glazing gap to its Table 24 row key ("6" / "12" / "16+"). + + The cert lodges discrete gaps as the int 6 or 12 or the string "16+" + (RdSAP-Schema `glazing_gap`). Unknown gap (None) defaults to the 12 mm + row — the spec's typical pre-2002 sealed unit. Robust to intermediate + integers: <=8 → 6 mm, >=15 → 16 mm-or-more, else 12 mm.""" + if glazing_gap is None: + return "12" + if isinstance(glazing_gap, str): + s = glazing_gap.strip().lower() + if "16" in s or "+" in s: + return "16+" + try: + g = int(float(s)) + except ValueError: + return "12" + else: + g = int(glazing_gap) + if g <= 8: + return "6" + if g >= 15: + return "16+" + return "12" + + def u_window( installed_year: Optional[int], glazing_type: Optional[str], frame_type: Optional[str], + glazing_gap: "str | int | None" = None, ) -> float: - """RdSAP10 window U-value in W/m^2K, never null.""" + """RdSAP10 window U-value in W/m^2K, never null (RdSAP 10 Table 24).""" if glazing_type is None and installed_year is None and frame_type is None: return 2.5 glaze = (glazing_type or "double").lower() @@ -1423,10 +1462,11 @@ def u_window( return 1.6 if metal else 1.4 if installed_year is not None and installed_year >= 2002: return 2.2 if metal else 2.0 - # pre-2002 double/triple default to 12mm gap row. - if glaze == "triple": - return 2.6 if metal else 2.1 - return 3.4 if metal else 2.8 + # pre-2002 double/triple — Table 24 splits by glazing gap (6/12/16+ mm). + gap_row = _glazing_gap_row(glazing_gap) + table = _PRE_2002_TRIPLE_U_BY_GAP if glaze == "triple" else _PRE_2002_DOUBLE_U_BY_GAP + pvc_u, metal_u = table[gap_row] + return metal_u if metal else pvc_u # --------------------------------------------------------------------------- diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 522ccd52..4fb51f42 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -1760,6 +1760,30 @@ def test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4() -> None: assert result == pytest.approx(1.6, abs=0.001) +def test_u_window_pre_2002_double_glazing_gap_selects_table24_row() -> None: + # Arrange — RdSAP 10 Table 24 (PDF p.50) pre-2002 double glazing splits + # by glazing gap (PVC/wooden frame): 6 mm → 3.1, 12 mm → 2.8, 16 mm or + # more → 2.7. The cert lodges the gap as the int 6/12 or the string + # "16+"; unknown gap defaults to the 12 mm row. + + # Act / Assert + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=6) == pytest.approx(3.1, abs=0.001) + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=12) == pytest.approx(2.8, abs=0.001) + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.7, abs=0.001) + assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=None) == pytest.approx(2.8, abs=0.001) + + +def test_u_window_pre_2002_triple_glazing_gap_and_metal_frame_select_table24_row() -> None: + # Arrange — Table 24 pre-2002 triple glazing: 6 mm → 2.4, 12 mm → 2.1, + # 16 mm+ → 2.0 (PVC); metal frame adds +0.5 per the metal column + # (6 → 2.9, 12 → 2.6, 16+ → 2.5). + + # Act / Assert + assert u_window(installed_year=None, glazing_type="triple", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.0, abs=0.001) + assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap=6) == pytest.approx(2.9, abs=0.001) + assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap="16+") == pytest.approx(2.5, abs=0.001) + + def test_u_window_falls_back_to_mid_range_when_unknown() -> None: # Arrange — nothing known. From d05fdbe6f2f70a018770b0cd4f315429cf6cc4aa Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 21 Jun 2026 06:15:25 +0000 Subject: [PATCH 10/12] fix(mapper): map truncated Elmhurst glazing label "Double between 2002" (RdSAP 10 Table 24 code 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full RdSAP-Schema-21 label is "Double between 2002 and 2021" (double glazing installed 2002-2021, SAP 10.2 Table 24 code 3). When the Elmhurst Summary PDF wraps the trailing "and 2021" into an adjacent table cell that the extractor joins away, the surfaced label truncates to "Double between 2002" — the same artifact already handled for "Triple post or during". `_elmhurst_glazing_type_code` raised UnmappedElmhurstLabel on it, blocking the whole Summary (surfaced on the simulated-case-46 multi-attribute worksheet). Added the truncated form as a code-3 alias. pyright not installed in this container. Co-Authored-By: Claude Opus 4.8 (1M context) --- datatypes/epc/domain/mapper.py | 6 +++++ .../epc/domain/test_mapper_glazing_label.py | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/datatypes/epc/domain/test_mapper_glazing_label.py diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index a699be4e..380192d3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -6942,6 +6942,12 @@ _ELMHURST_GLAZING_LABEL_TO_SAP10: Dict[str, int] = { "Single glazing": 1, "Double pre 2002": 2, "Double between 2002 and 2021": 3, + # Year-truncated form of "Double between 2002 and 2021": the trailing + # "and 2021" wraps to an adjacent PDF table cell that the extractor + # joins away (same artifact as "Triple post or during" below). Same + # SAP 10.2 code 3 (DG 2002-2021) — surfaced on the simulated-case-46 + # multi-attribute worksheet. + "Double between 2002": 3, "Double with unknown install date": 3, "Double with unknown 16 mm or install date more": 3, # Elmhurst §11 lodgement of RdSAP-21 schema row 7 "double, known diff --git a/tests/datatypes/epc/domain/test_mapper_glazing_label.py b/tests/datatypes/epc/domain/test_mapper_glazing_label.py new file mode 100644 index 00000000..8430d76e --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_glazing_label.py @@ -0,0 +1,26 @@ +"""Mapper boundary: the Elmhurst §11 "Double between 2002" glazing label. + +The full RdSAP-Schema-21 label is "Double between 2002 and 2021" (SAP 10.2 +Table 24 code 3 — double glazing installed 2002-2021). When the Elmhurst +Summary PDF wraps the trailing "and 2021" into an adjacent table cell the +extractor joins away, the surfaced label truncates to "Double between 2002" +(the same artifact already handled for "Triple post or during"). Before this +was mapped the truncated form raised `UnmappedElmhurstLabel`, blocking the +whole Summary (surfaced on the simulated-case-46 multi-attribute worksheet). +""" + +from datatypes.epc.domain.mapper import ( + _elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage] +) + + +def test_truncated_double_between_2002_maps_to_code_3() -> None: + # Arrange — the year-truncated form of "Double between 2002 and 2021". + + # Act + code = _elmhurst_glazing_type_code("Double between 2002") + full = _elmhurst_glazing_type_code("Double between 2002 and 2021") + + # Assert — both resolve to SAP 10.2 Table 24 code 3 (DG 2002-2021). + assert code == 3 + assert full == 3 From 34e52a893c1e15b79245f97e69f58aea3b4255cd Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 21 Jun 2026 06:15:37 +0000 Subject: [PATCH 11/12] fix(heating): assume portable-electric secondary for unheated habitable rooms (SAP 10.2 Appendix A.2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the main heating system does not heat every habitable room (heated rooms < habitable rooms), SAP 10.2 Appendix A.2.2 assumes the unheated rooms are served by a portable-electric secondary heater, so the Table 11 secondary fraction (0.10 for a boiler main) must be costed at the electricity tariff — even when the cert lodges no explicit secondary system. `_secondary_fraction` previously returned 0 unless a secondary was lodged or the main was a forced-secondary electric-storage code, dropping the assumed secondary and billing 100% of space heat to the (cheaper) main fuel — an over-rate. Added an `unheated_habitable_rooms` trigger plus `_has_unheated_habitable_rooms(epc)`, which prefers the lodged `any_unheated_rooms` flag and guards the gov-API `heated_rooms_count == 0` "not provided" sentinel. The secondary fuel/efficiency cascade already defaults to portable electric (code 693) when no secondary code is lodged. Worksheet-validated on simulated case 46 (heated 4 < habitable 7, no lodged secondary): the assumed 10% electric secondary (2289 kWh, ~£260) lifted our SAP 39 -> 29.35 vs accredited Elmhurst 30 (cost £1502 vs £1493, within 0.6%). Corpus UNCHANGED (71.6% / MAE 0.819): all 17 corpus certs with heated < habitable already lodge an explicit secondary description, so the gov-API path was already costing it; this only adds the assumed secondary where none is lodged (Elmhurst / reduced-field path). pyright not installed locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 30 ++++++++++- .../rdsap/test_cert_to_inputs.py | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index cd146109..41c1415d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2631,6 +2631,7 @@ def _secondary_fraction( main: Optional[MainHeatingDetail], secondary_heating_type: object, secondary_lodged: bool = False, + unheated_habitable_rooms: bool = False, ) -> float: """SAP 10.2 Table 11 lookup by main heating category, applied only when (a) the cert has a secondary system lodged OR (b) the main @@ -2672,7 +2673,12 @@ def _secondary_fraction( code = main.sap_main_heating_code has_lodged_secondary = secondary_heating_type is not None or secondary_lodged force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES - if not has_lodged_secondary and not force: + # SAP 10.2 Appendix A.2.2 — when the main system does not heat every + # habitable room, the unheated rooms are assumed to be served by a + # portable-electric secondary heater, so the Table 11 fraction is costed + # even with no lodged secondary (the secondary fuel/efficiency cascade + # already defaults to portable electric, code 693, when no code lodged). + if not has_lodged_secondary and not force and not unheated_habitable_rooms: return 0.0 if ( code is not None @@ -2682,6 +2688,26 @@ def _secondary_fraction( return _secondary_heating_fraction_for_category(main.main_heating_category) +def _has_unheated_habitable_rooms(epc: EpcPropertyData) -> bool: + """SAP 10.2 Appendix A.2.2 — the main heating system does not heat every + habitable room (heated rooms < habitable rooms), so the unheated rooms + take an assumed portable-electric secondary heater. + + Prefers the lodged `any_unheated_rooms` flag (set on both the gov-API and + Elmhurst paths). Falls back to the heated/habitable room-count comparison + only when the heated count is a real positive value — a lodged + `heated_rooms_count == 0` is the "not provided" sentinel on the gov-API + path, not literally zero heated rooms, so it must not spuriously trigger + the assumed secondary.""" + if epc.any_unheated_rooms is not None: + return epc.any_unheated_rooms + return ( + epc.heated_rooms_count > 0 + and epc.habitable_rooms_count > 0 + and epc.heated_rooms_count < epc.habitable_rooms_count + ) + + def _has_lodged_secondary_description(epc: EpcPropertyData) -> bool: """True when the cert lodges a secondary-heating DESCRIPTION (the gov-API path surfaces the secondary as `secondary_heating.description`, @@ -4735,6 +4761,7 @@ def energy_requirements_section_from_cert( main, epc.sap_heating.secondary_heating_type if epc.sap_heating else None, secondary_lodged=_has_lodged_secondary_description(epc), + unheated_habitable_rooms=_has_unheated_habitable_rooms(epc), ) # When no secondary system is lodged the worksheet displays (208) = 0; # the per-system fuel formula already collapses to 0 via fraction_201 = 0 @@ -7272,6 +7299,7 @@ def cert_to_inputs( main, epc.sap_heating.secondary_heating_type, secondary_lodged=_has_lodged_secondary_description(epc), + unheated_habitable_rooms=_has_unheated_habitable_rooms(epc), ) # SAP10.2 §4 — compute the worksheet (45..65) values now (they only # depend on the cert dwelling shape, not on water_efficiency). The diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index d8934c16..4b4951bc 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1104,6 +1104,59 @@ def test_secondary_fraction_fires_when_secondary_lodged_via_description_only() - assert abs(description_lodged - 0.10) <= 1e-9 +def test_secondary_fraction_fires_for_unheated_habitable_rooms_per_appendix_a22() -> None: + # Arrange — SAP 10.2 Appendix A.2.2: when the main system does not heat + # every habitable room (heated rooms < habitable rooms), the unheated + # rooms take an assumed portable-electric secondary heater, so the Table + # 11 0.10 fraction is costed EVEN WITH no lodged secondary. A gas boiler + # main (cat 2, not forced-secondary) with no secondary lodged returns 0.0 + # normally, but 0.10 once `unheated_habitable_rooms=True`. Worksheet- + # validated on simulated case 46 (heated 4 < habitable 7): the assumed + # secondary lifted our SAP from 39 to 29 (Elmhurst 30). + from domain.sap10_calculator.rdsap.cert_to_inputs import _secondary_fraction # pyright: ignore[reportPrivateUsage] + + main = _gas_boiler_detail() # cat 2, code 102 — not forced-secondary + + # Act + all_rooms_heated = _secondary_fraction(main, None, unheated_habitable_rooms=False) + has_unheated = _secondary_fraction(main, None, unheated_habitable_rooms=True) + + # Assert + assert all_rooms_heated == 0.0 + assert abs(has_unheated - 0.10) <= 1e-9 + + +def test_has_unheated_habitable_rooms_prefers_flag_and_guards_zero_sentinel() -> None: + # Arrange — `_has_unheated_habitable_rooms` prefers the lodged + # `any_unheated_rooms` flag; its room-count fallback must NOT trigger on a + # `heated_rooms_count == 0` "not provided" sentinel (gov-API), only on a + # real positive heated count below the habitable count. + import dataclasses + + from domain.sap10_calculator.rdsap.cert_to_inputs import ( # pyright: ignore[reportPrivateUsage] + _has_unheated_habitable_rooms, + ) + + base = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG", + ) + + flag_true = dataclasses.replace(base, any_unheated_rooms=True) + flag_false = dataclasses.replace(base, any_unheated_rooms=False) + count_unheated = dataclasses.replace( + base, any_unheated_rooms=None, heated_rooms_count=4, habitable_rooms_count=7 + ) + zero_sentinel = dataclasses.replace( + base, any_unheated_rooms=None, heated_rooms_count=0, habitable_rooms_count=5 + ) + + # Act / Assert + assert _has_unheated_habitable_rooms(flag_true) is True + assert _has_unheated_habitable_rooms(flag_false) is False + assert _has_unheated_habitable_rooms(count_unheated) is True + assert _has_unheated_habitable_rooms(zero_sentinel) is False + + def test_main_heating_fraction_missing_falls_back_to_table11_default() -> None: # Arrange — when main_heating_fraction isn't lodged AND the cert # has a secondary system lodged, Table 11's 0.10 default still From a9632937d5012bb9a978345063e65b367fe3a3c1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 21 Jun 2026 06:22:26 +0000 Subject: [PATCH 12/12] =?UTF-8?q?fix(ventilation):=20use=20lodged=20extrac?= =?UTF-8?q?t-fan=20count=20when=20known,=20not=20max(lodged,=20age=20defau?= =?UTF-8?q?lt)=20(RdSAP=2010=20=C2=A74.1=20Table=205,=20PDF=20p.28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table 5 reads "Number of extract fans if known; if number is unknown: [age-band default]" — the default is an UNKNOWN-fallback, NOT a floor. The cascade applied `max(lodged, table_5_default)`, flooring a genuinely-lodged count up to the age-band minimum: e.g. an age H-M dwelling lodging 2 extract fans was billed at the 6-8-room default of 3, over-counting ventilation line (8) and the heat-loss coefficient. Fixed to `lodged if lodged > 0 else default` (a lodged 0 is the Elmhurst/RdSAP "unknown" form → default; any positive count is taken literally). Surfaced by Khalim's Elmhurst stress worksheet (simulated case 46): this was its last ventilation residual — our Jan effective ACH 9.14 -> 9.0748 (exact match to the accredited worksheet), SAP 29 -> 30 = Elmhurst, cost £1496 vs £1493. Corpus IMPROVED: within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815 (the max-flooring over-counted ventilation on every cert lodging fans below its age default). Floor ratcheted 0.71 -> 0.72. pyright not installed locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sap10_calculator/rdsap/cert_to_inputs.py | 17 +++++++--- .../rdsap/test_cert_to_inputs.py | 34 +++++++++++++++++++ .../epc_client/test_sap_accuracy_corpus.py | 12 ++++++- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 41c1415d..8227764b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -4987,16 +4987,23 @@ def ventilation_from_cert( storeys = max(1, dim.storey_count) vc = _ventilation_counts(epc.sap_ventilation) sv = epc.sap_ventilation - # RdSAP 10 §4.1 Table 5 (PDF p.28) — extract-fans default when the - # lodged count is below the age-band minimum. The Elmhurst Summary - # renders "0" as the form for unknown; the worksheet applies the - # default via `max(lodged, table_5_default)`. + # RdSAP 10 §4.1 Table 5 (PDF p.28) — extract fans: "Number of extract + # fans if known; if number is unknown: [age-band default]." The default + # is an UNKNOWN-fallback, NOT a floor: a genuinely-lodged count is used + # as-is even when it is below the age-band default (e.g. a band H-M + # dwelling lodging 2 fans is NOT bumped to the 3-fan default). The + # Elmhurst Summary / RdSAP convention renders "0" as the form for + # unknown, so a lodged 0 falls back to the default; any positive count + # is taken literally. (Was `max(lodged, default)`, which over-applied + # the default as a minimum and over-counted ventilation.) age_band = _dwelling_age_band(epc) or "" is_park_home = (epc.property_type or "").strip().lower() == "park home" table_5_fan_default = _rdsap_extract_fans_default( age_band, epc.habitable_rooms_count, is_park_home=is_park_home, ) - intermittent_fans = max(vc.intermittent_fans, table_5_fan_default) + intermittent_fans = ( + vc.intermittent_fans if vc.intermittent_fans > 0 else table_5_fan_default + ) wind_kwargs: dict[str, tuple[float, ...]] = ( {"monthly_wind_speed_m_s": postcode_climate.monthly_wind_speed_m_per_s} if postcode_climate is not None else {} diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 4b4951bc..e1ca3f7e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -1609,6 +1609,40 @@ def test_ventilation_from_cert_applies_table_5_default_when_lodged_zero() -> Non ) +def test_ventilation_from_cert_uses_lodged_fans_below_age_default_not_floored() -> None: + # Arrange — RdSAP 10 §4.1 Table 5 (PDF p.28): "Number of extract fans if + # known; if unknown: [age-band default]." The default is an UNKNOWN- + # fallback, NOT a floor — a genuinely-lodged positive count is used + # as-is even when below the age default. An age-H 6-habitable-room + # dwelling has a 3-fan default, but a cert lodging 2 fans must use 2, + # not be floored up to 3. (Was `max(lodged, default)` → 3, over-counting + # ventilation; surfaced on simulated case 46 where it inflated (8) by + # one fan = 0.055 ACH and pushed SAP 30 → 29.) + age_h_part = make_building_part( + floor_dimensions=[ + make_floor_dimension(total_floor_area_m2=45.0, floor=0), + make_floor_dimension(total_floor_area_m2=45.0, floor=1), + ], + construction_age_band='H', + ) + epc = make_minimal_sap10_epc( + total_floor_area_m2=_TYPICAL_TFA_M2, + habitable_rooms_count=6, # age H-M, 6-8 rooms → Table 5 default 3 + region_code="1", + sap_building_parts=[age_h_part], + sap_ventilation=SapVentilation(extract_fans_count=2), # lodged 2 < 3 + ) + + # Act + v = ventilation_from_cert(epc) + + # Assert — (8) openings ACH uses the lodged 2 fans (20 m³/h), not 3. + from domain.sap10_calculator.rdsap.cert_to_inputs import dimensions_from_cert + vol = dimensions_from_cert(epc).volume_m3 + assert abs(v.openings_ach - 20.0 / vol) <= 1e-6 + assert abs(v.openings_ach - 30.0 / vol) > 1e-6 + + def test_ventilation_from_cert_passes_lodged_ap4_to_pressure_test_ach_per_sap_10_2_section_2_line_18() -> None: # Arrange — SAP 10.2 §2 line (17a)/(18) "Air permeability value, AP4 # (m³/h/m²)": when a Pulse pressure test is lodged the cascade must diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 079f4e01..df64ed59 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -183,7 +183,17 @@ _CORPUS = Path( # degenerate dwelling no longer emits a negative SAP. Removed a -12.3 outlier # (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 # 70.2% -> 70.3%, MAE 0.845 -> 0.833. -_MIN_WITHIN_HALF_SAP = 0.71 +# EXTRACT-FAN DEFAULT IS UNKNOWN-FALLBACK, NOT A FLOOR (RdSAP 10 §4.1 Table 5, +# PDF p.28). Table 5 reads "Number of extract fans if known; if unknown: +# [age-band default]". The cascade applied `max(lodged, age_default)`, flooring +# a genuinely-lodged count up to the age-band minimum (e.g. an age H-M dwelling +# lodging 2 fans billed at the 3-fan default), over-counting ventilation line +# (8) and the HLC. Fixed to `lodged if lodged > 0 else default` (a lodged 0 is +# the Elmhurst/RdSAP "unknown" form → default; any positive count is literal). +# within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst +# stress worksheet (simulated case 46): closed its last ventilation residual +# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst). +_MIN_WITHIN_HALF_SAP = 0.72 _MAX_SAP_MAE = 0.82 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current