fix(ventilation): dMEV takes lodged 0 intermittent fans, not the Table 5 default (SAP 10.2 §2)

Chasing the space-heating demand gap on "simulated case 48" (main 691 + Unknown
meter + 903 dual immersion): our SAP 55 vs Elmhurst 57. Every §10a cost line
already matched to the penny; the residual was demand — our space-heating
energy 3849.8 kWh vs Elmhurst 3513.8 (+9.6%). Traced through the worksheet: our
ventilation heat loss (38) ran ~35.5 W/K vs Elmhurst 27.76 — we were adding 20
m3/h of intermittent extract fans (the Table 5 age-band default) on a dwelling
with a decentralised mechanical extract (dMEV) system that lodges 0 fans.

SAP 10.2 §2 (PDF p.13): a whole-house mechanical EXTRACT system provides
extraction via the (23a) 0.5 system air-change rate; the lodged intermittent
extract-fan count (7a) is then explicit — a lodged 0 means 0 (the dMEV is the
ventilation), NOT "unknown". The Table 5 default is an unknown-fallback for
NATURALLY ventilated dwellings only, so it must not be substituted here.

Fix: for EXTRACT_OR_PIV_OUTSIDE, take vc.intermittent_fans as-is (no age-band
default). Worksheet-proven on two dMEV builds of cert 000565: "case 48" lodges
(7a)=0 -> our SAP 55 -> 57 EXACT; the original 000565 fixture lodges (7a)=2 and
keeps 2 (its e2e pins are unchanged). An earlier draft that forced fans=0 broke
000565 (which legitimately has 2) — corrected to "lodged as-is".

within-0.5 72.5% -> 72.6%, MAE 0.789 -> 0.788; CO2/PE unchanged. The fix also
reduces a systematic under-rating bias in the 21-cert dMEV cohort (median dSAP
-0.22 -> -0.08). Scoped to EXTRACT_OR_PIV_OUTSIDE; balanced MVHR/MV kinds left
untouched pending their own worksheet. SAP-schema regression
test_18_0_0 pin 80 -> 81 (closer to its lodged 84, same cause). Spec-pinned in
test_cert_to_inputs (dMEV-lodged-0 vs natural-default). pyright not installed
in this container -- strict type gate not run locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 11:33:07 +00:00
parent fd0530159f
commit 4db05e843c
4 changed files with 69 additions and 2 deletions

View file

@ -506,7 +506,10 @@ class TestFromSapSchema16_2:
epc = EpcPropertyDataMapper.from_api_response(load("sap_18_0_0.json"))
assert isinstance(epc, EpcPropertyData)
assert epc.uprn == 10094601287
assert Sap10Calculator().calculate(epc).sap_score == 80 # lodged 84
# 80 -> 81 (closer to lodged 84) after the dMEV intermittent-fan fix:
# this cert is EXTRACT_OR_PIV_OUTSIDE with a lodged 0 fans, so the
# Table 5 age-band default is no longer substituted (SAP 10.2 §2 (7a)).
assert Sap10Calculator().calculate(epc).sap_score == 81 # lodged 84
def test_16_0_dispatches_via_16_x_path_with_tenure_default(self) -> None:
# SAP-Schema-16.0 is the same reduced-field 16.x shape; it omits the

View file

@ -5090,6 +5090,17 @@ def ventilation_from_cert(
# can override via a future plumbing slice; the spec default
# is what every MEV / MV / MVHR cohort cert lodges today.
mv_system_ach = 0.5
# For a whole-house mechanical EXTRACT system (MEV / dMEV) the
# lodged intermittent extract-fan count (7a) is taken AS-IS — the
# Table 5 age-band default must NOT be substituted for a lodged 0.
# On a mechanically-ventilated dwelling the fan count is explicit
# (the dMEV is the ventilation), so 0 means 0, not "unknown".
# Worksheet-proven on two dMEV builds of 000565: "case 48" lodges
# (7a)=0 → SAP 57 exact, while the original 000565 fixture lodges
# (7a)=2 → unchanged. Scoped to EXTRACT_OR_PIV_OUTSIDE; balanced
# MVHR/MV kinds are left untouched pending their own worksheet.
if mv_kind is MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE:
intermittent_fans = vc.intermittent_fans
return ventilation_from_inputs(
volume_m3=vol,
storey_count=storeys,

View file

@ -1762,6 +1762,56 @@ def test_ventilation_from_cert_applies_table_5_default_when_lodged_zero() -> Non
)
def test_ventilation_from_cert_dmev_takes_lodged_zero_fans_not_table_5_default() -> None:
# Arrange — on a dwelling with a whole-house mechanical EXTRACT system
# (dMEV → EXTRACT_OR_PIV_OUTSIDE) the lodged intermittent extract-fan
# count (7a) is explicit: a lodged 0 means 0 (the dMEV is the
# ventilation), NOT "unknown". The SAP 10.2 §2 Table 5 age-band default
# must NOT be substituted as it would on a NATURALLY ventilated dwelling.
# Worksheet-proven: two dMEV builds of cert 000565 — "simulated case 48"
# lodges (7a)=0 and Elmhurst's worksheet uses 0 (→ SAP 57 exact), while
# the original 000565 fixture lodges (7a)=2 and Elmhurst uses 2. The same
# Table 5 default that this age-G NATURAL case applies (1 fan above) must
# be suppressed here.
from domain.sap10_calculator.worksheet.ventilation import (
MechanicalVentilationKind,
)
age_g_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='G',
)
base = _typical_semi_detached_epc()
epc_dmev = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
region_code="1",
sap_building_parts=[age_g_part],
sap_windows=base.sap_windows,
sap_heating=base.sap_heating,
sap_ventilation=SapVentilation(
extract_fans_count=0,
mechanical_ventilation_kind=(
MechanicalVentilationKind.EXTRACT_OR_PIV_OUTSIDE.name
),
),
)
# Act
v = ventilation_from_cert(epc_dmev)
# Assert — 0 intermittent fans contribute 0 to (8), so openings ACH = 0
# (no Table 5 age-G default fan); the mechanical system is applied via
# the separate (23a) 0.5 system air-change rate instead.
assert abs(v.openings_ach) <= 1e-9, (
f"openings ACH {v.openings_ach:.6f} should be 0 for a dMEV cert "
f"lodging 0 fans (no Table 5 default substituted)"
)
assert v.mv_system_ach == 0.5
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-

View file

@ -197,7 +197,10 @@ _MIN_WITHIN_HALF_SAP = 0.72
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
# 10-Hour Off Peak). within-0.5 holds 72.5%.
# 10-Hour Off Peak). Then 0.789 -> 0.788 (within-0.5 72.5% -> 72.6%) via the
# dMEV intermittent-fan fix: an EXTRACT_OR_PIV_OUTSIDE cert lodging 0 fans now
# takes 0 (not the Table 5 age-band default) — same "case 48" worksheet closes
# its space-heating demand to land SAP 57 exact.
_MAX_SAP_MAE = 0.79
_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