mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
943f83ed01
commit
71b378b9e5
2 changed files with 96 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue