fix(ventilation): map API mechanical_ventilation enum to §2 MV-kind dispatch

The profiler flagged `mechanical_ventilation=2` as a clean systematic
over-rate: 20 certs, signed +1.90 SAP, only 5% within 0.5 (every one
positive). Root cause: the API path (`from_api_response`) dropped the
doc-level `mechanical_ventilation` field, so `sap_ventilation.
mechanical_ventilation_kind` was always None and the §2 cascade
defaulted to NATURAL — under-stating the ventilation air-change rate
(and hence heat loss) for every mechanical system. (Only the Elmhurst/
Summary path mapped it, via `_ELMHURST_MV_TYPE_TO_KIND`.)

RdSAP-Schema-21 `mechanical_ventilation` enum (epc_codes.csv) →
MechanicalVentilationKind picking the SAP 10.2 §2 (24a..d) effective-ach
formula:
  0 natural                      -> NATURAL (24d)
  1 MV (no heat recovery)        -> MV (24b)
  2 mechanical extract, dc (MEV) -> EXTRACT_OR_PIV_OUTSIDE (24c)
  3 mechanical extract, c (MEV)  -> EXTRACT_OR_PIV_OUTSIDE (24c)
  5 positive input from loft     -> NATURAL (loft-sourced PIV adds no
                                    system air change per RdSAP 10 §2.6)
  6 positive input from outside  -> EXTRACT_OR_PIV_OUTSIDE (24c)
Code 4 (MVHR, 24a) is DEFERRED — its formula needs the lodged
heat-recovery efficiency (PCDB Table 326) the API→cascade path doesn't
yet plumb; mapping it to MVHR with a null efficiency would mis-model it
as MV, so it stays NATURAL (3 scattered certs, accurate at the median).
Unmapped integers raise `UnmappedApiCode` (mirror of `_api_sheltered_
sides` / `_api_type_1_gable_kind`).

Eval: the extract cohort (mech_vent 2/3/6) moved +1.90 -> +0.9 median
(within-0.5 5% -> 35%); 20 improved / 3 regressed (offsetting). Headline
within-0.5 54.24% -> 55.01%, within-1.0 69.64% -> 70.08%, mean|err|
1.248 -> 1.233, 909 computed / 0 raises. The +0.9 residual on MEV is the
fan electricity (§2.6.4 SFP, PCDB Table 322) — a separate follow-up.
2 AAA tests; goldens + full calc/epc/parser regression green; pyright
net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-08 23:02:04 +00:00
parent 943f83ed01
commit 71b378b9e5
2 changed files with 96 additions and 0 deletions

View file

@ -1877,6 +1877,14 @@ class EpcPropertyDataMapper:
# default 2 → over-counted shelter factor → -2.42
# ACH/month infiltration shortfall).
sheltered_sides=_api_sheltered_sides(schema.built_form),
# RdSAP-Schema-21 `mechanical_ventilation` enum → §2 (24a..d)
# MV-kind dispatch. Without this an MEV / PIV-from-outside
# dwelling defaulted to NATURAL and under-stated its
# ventilation heat loss (+1.90 SAP over-rate on the n=20
# MEV cohort).
mechanical_ventilation_kind=_api_mechanical_ventilation_kind(
schema.mechanical_ventilation
),
),
)
@ -2780,6 +2788,55 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]:
return _API_BUILT_FORM_TO_SHELTERED_SIDES[built_form]
# GOV.UK API `mechanical_ventilation` integer (RdSAP-Schema-21 enum) →
# `MechanicalVentilationKind` enum name picking the SAP 10.2 §2 (24a..d)
# effective-air-change formula. Mirrors the Elmhurst-path
# `_ELMHURST_MV_TYPE_TO_KIND` so both source paths converge on the same
# cascade dispatch.
# 0 natural → None (NATURAL, 24d)
# 1 mechanical ventilation, no HR (MV) → MV (24b)
# 2 mechanical extract, decentralised (MEVdc)→ EXTRACT_OR_PIV_OUTSIDE (24c)
# 3 mechanical extract, centralised (MEV c) → EXTRACT_OR_PIV_OUTSIDE (24c)
# 5 positive input from loft → None — RdSAP 10 §2.6 treats
# loft-sourced PIV as natural
# (no added system air change)
# 6 positive input from outside → EXTRACT_OR_PIV_OUTSIDE (24c)
# Code 4 (MVHR, 24a) is DEFERRED: its (24a)m formula needs the lodged
# heat-recovery efficiency (`mvhr_efficiency_pct`, PCDB Table 326) which the
# API→cascade path does not yet plumb; mapping it to MVHR with a null
# efficiency would mis-model it as MV (no recovery), so it stays NATURAL
# until the efficiency is wired. The extract systems (2/3/6) carry no
# efficiency, so this slice closes the clean +1.90 SAP over-rate on the
# MEV/PIV-outside cohort (n=20, 5% within 0.5) — they were silently
# defaulting to NATURAL, under-stating ventilation heat loss.
_API_MECHANICAL_VENTILATION_TO_KIND: Final[dict[int, Optional[str]]] = {
0: None,
1: "MV",
2: "EXTRACT_OR_PIV_OUTSIDE",
3: "EXTRACT_OR_PIV_OUTSIDE",
4: None, # MVHR — efficiency plumbing deferred; treat as natural
5: None,
6: "EXTRACT_OR_PIV_OUTSIDE",
}
def _api_mechanical_ventilation_kind(mechanical_ventilation: object) -> Optional[str]:
"""Translate the API `mechanical_ventilation` integer to a
`MechanicalVentilationKind` enum name for the §2 cascade dispatch.
Strict-coverage: a lodged integer outside the mapped set raises
`UnmappedApiCode` rather than silently defaulting to NATURAL (which
under-states ventilation heat loss for any mechanical system).
Non-int / None lodging stays as no-lodging (NATURAL)."""
if isinstance(mechanical_ventilation, str) and mechanical_ventilation.isdigit():
mechanical_ventilation = int(mechanical_ventilation)
if not isinstance(mechanical_ventilation, int):
return None
if mechanical_ventilation not in _API_MECHANICAL_VENTILATION_TO_KIND:
raise UnmappedApiCode("mechanical_ventilation", mechanical_ventilation)
return _API_MECHANICAL_VENTILATION_TO_KIND[mechanical_ventilation]
# GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular,
# frame_factor) lookup the cascade reads via `window_transmission_
# details` for per-window cascade fidelity. The cascade defaults to a

View file

@ -2715,6 +2715,45 @@ def test_api_type_1_gable_kind_maps_sheltered_and_connected_codes() -> None:
assert _api_type_1_gable_kind(3) == "connected_wall"
def test_api_mechanical_ventilation_maps_extract_systems_to_cascade_kind() -> None:
# Arrange — RdSAP-Schema-21 `mechanical_ventilation` enum → the SAP
# 10.2 §2 (24a..d) MechanicalVentilationKind dispatch. The mapper
# previously dropped this field on the API path, so every mechanical
# system defaulted to NATURAL and under-stated its ventilation heat
# loss (the MEV-dc cohort, code 2, over-rated +1.90 SAP, n=20).
from datatypes.epc.domain.mapper import (
_api_mechanical_ventilation_kind, # pyright: ignore[reportPrivateUsage]
)
# Act / Assert — extract / positive-input-from-outside systems carry
# the (24c) kind; MV-no-HR carries (24b); natural and PIV-from-loft
# stay NATURAL; MVHR (4) is deferred (efficiency not yet plumbed) and
# stays NATURAL rather than mis-modelling as MV.
assert _api_mechanical_ventilation_kind(0) is None # natural
assert _api_mechanical_ventilation_kind(1) == "MV" # MV, no HR
assert _api_mechanical_ventilation_kind(2) == "EXTRACT_OR_PIV_OUTSIDE" # MEV dc
assert _api_mechanical_ventilation_kind(3) == "EXTRACT_OR_PIV_OUTSIDE" # MEV c
assert _api_mechanical_ventilation_kind(4) is None # MVHR (deferred)
assert _api_mechanical_ventilation_kind(5) is None # PIV from loft
assert _api_mechanical_ventilation_kind(6) == "EXTRACT_OR_PIV_OUTSIDE" # PIV outside
assert _api_mechanical_ventilation_kind(None) is None
def test_api_mechanical_ventilation_unmapped_code_raises() -> None:
# Arrange — an out-of-range `mechanical_ventilation` integer is a
# spec-coverage gap: raise rather than silently default to NATURAL
# (which would under-state ventilation heat loss). Mirror of the
# `_api_sheltered_sides` / `_api_type_1_gable_kind` strict-raise.
from datatypes.epc.domain.mapper import (
UnmappedApiCode, # pyright: ignore[reportPrivateUsage]
_api_mechanical_ventilation_kind, # pyright: ignore[reportPrivateUsage]
)
# Act / Assert
with pytest.raises(UnmappedApiCode):
_api_mechanical_ventilation_kind(7)
def test_elmhurst_detailed_rir_keeps_roof_surfaces() -> None:
# Arrange — a Detailed (§3.10) assessment DOES measure slope / flat
# ceiling, so they must be retained (regression guard so the