fix(sap): (203)-weight the dual-main Table 4e MIT adjustment

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-28 09:22:59 +00:00
parent 138f5688e0
commit 4d9dd9aaf3
2 changed files with 55 additions and 4 deletions

View file

@ -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.581.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:

View file

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