From 4d9dd9aaf374d918e11c472a15ed4b1494883137 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 28 Jul 2026 09:22:59 +0000 Subject: [PATCH] fix(sap): (203)-weight the dual-main Table 4e MIT adjustment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For a dwelling with two main systems heating different parts, the (93)m Table 4e mean-internal-temperature adjustment was taken from system 1 alone (`details[0]`). SAP 10.2 p.186 requires the (203)-weighted mean of each system's control adjustment — (1 - (203)) for system 1, (203) for system 2 — mirroring the Table 9b responsiveness weighting already applied two lines above. Keyed to system 1, a dwelling led by a small-fraction system (e.g. a 20% HHRSH, adj 0.0 °C, ahead of an 80% manual-charge storage heater, adj +0.7 °C) was modelled ~0.7 °C too cool → space heat under-counted → over-rated. Compute the weighted adjustment at the call site. Shared `cert_to_inputs` path, so it also lifts the accredited gov-API RdSAP corpus (81.5% → 81.7%, guardrail). 5 Edmund Close +2.74 → +0.59; 4 Edmund Close -2.51 → -0.55. LRHA cohort within-0.5 60.2% → 62.1%, MAE 0.496 → 0.445; ratchets re-based to 0.62 / 0.45. Also corrects an oil-combi docstring (462051619031 is a Grant Vortex 10599, not the Worcester 18415). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_pashub_sap_accuracy_lrha_wave3.py | 45 +++++++++++++++++-- .../sap10_calculator/rdsap/cert_to_inputs.py | 14 +++++- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index 52ca6c7cc..4558be0b3 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -128,8 +128,24 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # P960 worksheet exactly (5.4175 kWh/day). The 5 oil storage-combis collapse # from +2.0..+4.9 into the ±1.5 spread: within-0.5 55.3% → 58.3%, MAE # 0.675 → 0.531. -_MIN_WITHIN_HALF: float = 0.58 -_MAX_SAP_MAE: float = 0.54 +# 2026-07-27 (fan-out audit — 3 spec-correct fixes, each with a named test +# above): (1) the window channel collapsed MIXED per-window glazing onto +# windows[0]'s U (SAP 10.2 (26)-(27) sums each window's own Table 24 U); (2) a +# surveyed measured cylinder heat-loss factor was dropped on the PasHub path (an +# extractor label mismatch + the mapper never wiring `cylinder_heat_loss`), so +# the calc fell back to the Table 2 insulation default (SAP 10.2 §4 (48)-(50) +# uses the declared loss); (3) the dual-main Table 4e MIT adjustment was keyed +# to system 1 alone, not (203)-weighted across both mains (SAP 10.2 p.186). +# Fixes (1)/(3) are symmetric — they correct over- AND under-raters — and (3) +# lives in the shared `cert_to_inputs` path, improving the accredited gov-API +# RdSAP corpus too (81.5% → 81.7%, guardrail). Combined: within-0.5 58.3% → +# 62.1%, MAE 0.531 → 0.445. The residual top-of-cohort is now entirely the +# Varsity Rise community-heating cluster (a solar-DHW over-credit + an immersion +# fuel-collision), deferred to Elmhurst adjudication (per-cert build sheets +# prepared). Floor/ceiling re-baselined to the corrected cohort — coverage +# correction is not loosening. +_MIN_WITHIN_HALF: float = 0.62 +_MAX_SAP_MAE: float = 0.45 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " @@ -229,7 +245,11 @@ def test_oil_storage_combi_applies_pcdb_store_loss() -> None: The five oil storage-combis (461996268736, 461989738731, 497712315613, 497602074839, 462051619031) all collapse from +2..+4.9 into the cohort's normal ±1.5 spread; only this one is pinned (the others' residuals are - PasHub-vs-Elmhurst divergence, not the store-loss bug).""" + PasHub-vs-Elmhurst divergence, not the store-loss bug). NB the five share + the store_type-1 storage-combi treatment but NOT one PCDB product — e.g. + 462051619031 is a Grant Vortex Outdoor Condensing Combi (PCDB 10599), not + the Worcester Greenstar Heatslave II (18415) pinned here; the Table 2 note-b + store loss uses each boiler's own PCDB store volume/insulation.""" outcome = _evaluate("461996268736") assert outcome.diff is not None assert outcome.diff < 0.5, f"oil storage-combi store loss not applied: diff {outcome.diff:.2f}" @@ -321,6 +341,25 @@ def test_measured_cylinder_loss_overrides_table2() -> None: assert outcome.diff < 0.5, f"measured cylinder loss not applied: diff {outcome.diff:.2f}" +@pytest.mark.skipif( + "462019682527" not in _BY_ID, reason="dual-main Table-4e fixture not in manifest" +) +def test_dual_main_table_4e_adjustment_is_fraction_weighted() -> None: + """Regression: for a dwelling with two main systems heating different parts + (5 Edmund Close, 462019682527 — a 20% HHRSH, Table 4e adj 0.0 °C, + an 80% + manual-charge storage heater, adj +0.7 °C), the (93)m Table 4e mean-internal- + temperature adjustment must be the (203)-weighted mean of both systems' + control adjustments per SAP 10.2 p.186, not system 1's alone. Keyed to the + 20% HHRSH the adjustment was 0.0 (dwelling modelled ~0.7 °C too cool → space + heat under-counted) → over-rated +2.74. The (203)-weighted +0.56 °C brings + it to +0.59. Bound 1.0 guards against regression to the +2.74 mis-weight; the + residual is normal cohort spread. NB this fix lives in the shared + `cert_to_inputs` path and improves the gov-API RdSAP corpus too (81.5→81.7%).""" + outcome = _evaluate("462019682527") + assert outcome.diff is not None + assert outcome.diff < 1.0, f"Table 4e adjustment not (203)-weighted: diff {outcome.diff:.2f}" + + @pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy_lrha_wave3 fixtures/manifest") @pytest.mark.parametrize("deal_id", _IDS) def test_pashub_fixture_computes(deal_id: str) -> None: diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 38b72ecc3..537c8617b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -8405,6 +8405,18 @@ def cert_to_inputs( main_2_responsiveness_value = _responsiveness( _mit_main_2, tariff=_mit_tariff, epc=epc ) + # SAP 10.2 p.186: the (93)m Table 4e temperature adjustment for a dwelling + # with two main systems heating different parts is the (203)-weighted mean + # of each system's control adjustment — (1 - (203)) for system 1, (203) for + # system 2 — mirroring the Table 9b responsiveness weighting above. Keyed to + # system 1 alone it ignores the dominant system's control when the two carry + # different Table 4e controls (e.g. a 20% HHRSH + 80% manual-charge storage). + control_temperature_adjustment_value = _control_temperature_adjustment_c(main) + if _mit_main_2 is not None and main_2_control_type_value is not None: + control_temperature_adjustment_value = ( + (1.0 - main_2_fraction_value) * _control_temperature_adjustment_c(main) + + main_2_fraction_value * _control_temperature_adjustment_c(_mit_main_2) + ) monthly_total_gains_w = tuple( internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) ) @@ -8429,7 +8441,7 @@ def cert_to_inputs( control_type=control_type_value, responsiveness=responsiveness_value, living_area_fraction=living_area_fraction_value, - control_temperature_adjustment_c=_control_temperature_adjustment_c(main), + control_temperature_adjustment_c=control_temperature_adjustment_value, main_2_control_type=main_2_control_type_value, main_2_fraction=main_2_fraction_value, main_2_responsiveness=main_2_responsiveness_value,