Map full-SAP measured ventilation: air permeability, MV kind, sheltered sides 🟩

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-06-15 14:37:52 +00:00
parent c035d17f2b
commit 345154c6b7
4 changed files with 107 additions and 8 deletions

View file

@ -64,6 +64,21 @@ _SAP_KNOWN_WALL_TYPES: Final[frozenset[int]] = frozenset({1, 2, 3, 4, 5})
# isn't parseable from a wall description, the engine derives from the newest
# RdSAP age band M. Used only for that fallback + secondary age-band logic.
_SAP_DEFAULT_AGE_BAND: Final[str] = "M"
# full-SAP standard `ventilation_type` code → MechanicalVentilationKind name
# (None = natural). MVHR (7) is deferred to natural — like the RdSAP API path
# (_API_MECHANICAL_VENTILATION_TO_KIND code 4), its (24a) formula needs the
# PCDB heat-recovery efficiency, not yet plumbed; mapping to MVHR with a null
# efficiency would mis-model it as MV. None of the corpus lodges 7.
_SAP_VENTILATION_TYPE_TO_MV_KIND: Final[Dict[int, Optional[str]]] = {
1: None, # natural (with intermittent extract fans)
2: None, # passive stack — treated as natural
3: None, # positive input from loft → natural
4: "EXTRACT_OR_PIV_OUTSIDE", # positive input from outside
5: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, centralised (MEV c)
6: "EXTRACT_OR_PIV_OUTSIDE", # mechanical extract, decentralised (MEV dc)
7: None, # MVHR — deferred (efficiency not plumbed)
8: "MV", # balanced mechanical, no heat recovery
}
# rdsap_uvalues WALL_CAVITY = 4 (D7 fallback; U comes from the description).
_SAP_DEFAULT_WALL_CONSTRUCTION: Final[int] = 4
# SAP-typical glazing solar transmittance when an opening-type omits it.
@ -675,7 +690,6 @@ class EpcPropertyDataMapper:
has_hot_water_cylinder=schema.has_hot_water_cylinder == "true",
has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true",
solar_water_heating=False,
wet_rooms_count=0,
extensions_count=0,
heated_rooms_count=0,
open_chimneys_count=0,
@ -683,6 +697,13 @@ class EpcPropertyDataMapper:
# habitable_rooms_count (Table 27). Back-solve the count whose
# Table-27 fraction best matches the measured living_area/TFA.
habitable_rooms_count=_sap_back_solved_habitable_rooms(schema),
# D5-vent: measured ventilation (air permeability AP4, MV kind,
# sheltered sides, wet rooms, MEV PCDB index).
wet_rooms_count=schema.sap_ventilation.wet_rooms_count or 0,
sap_ventilation=_sap_17_1_ventilation(schema),
mechanical_ventilation_index_number=(
schema.sap_ventilation.mechanical_vent_system_index_number
),
# D2: door openings (1/2/3) → counts + area-weighted U. New-build
# doors are treated insulated, so insulated_door_count == door_count.
door_count=door_count,
@ -2523,6 +2544,28 @@ _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = {
}
def _sap_17_1_ventilation(schema: SapSchema17_1) -> SapVentilation:
"""D5-vent: map full-SAP `sap_ventilation` onto the domain `SapVentilation`.
The measured `air_permeability` feeds the engine's AP4 path directly (vs the
RdSAP age-band default); `ventilation_type` MechanicalVentilationKind name
(unknown code fails loud)."""
sv = schema.sap_ventilation
mv_kind: Optional[str] = None
if sv.ventilation_type is not None:
if sv.ventilation_type not in _SAP_VENTILATION_TYPE_TO_MV_KIND:
raise UnmappedApiCode("ventilation_type", sv.ventilation_type)
mv_kind = _SAP_VENTILATION_TYPE_TO_MV_KIND[sv.ventilation_type]
return SapVentilation(
air_permeability_ap4_m3_h_m2=sv.air_permeability,
mechanical_ventilation_kind=mv_kind,
sheltered_sides=sv.sheltered_sides_count,
pressure_test=str(sv.pressure_test) if sv.pressure_test is not None else None,
extract_fans_count=sv.extract_fans_count,
open_flues_count=sv.open_flues_count,
flueless_gas_fires_count=sv.flueless_gas_fires_count,
)
# SAP main_fuel_type code for mains gas.
_SAP_MAINS_GAS_FUEL_CODE: Final[int] = 1

View file

@ -254,6 +254,41 @@ class TestFromSapSchema17_1EnergySource:
assert sample.sap_energy_source.wind_turbines_count == 0
class TestFromSapSchema17_1Ventilation:
"""Slice D5-vent: full-SAP sap_ventilation → measured air permeability (AP4),
ventilation_type MechanicalVentilationKind, sheltered sides, wet rooms and
the MEV PCDB index."""
@pytest.fixture
def sample(self) -> EpcPropertyData:
schema = from_dict(SapSchema17_1, load("sap_17_1.json"))
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
def test_measured_air_permeability_fed_as_ap4(self, sample: EpcPropertyData) -> None:
assert sample.sap_ventilation is not None
assert sample.sap_ventilation.air_permeability_ap4_m3_h_m2 == 2.6
def test_ventilation_type_6_is_extract(self, sample: EpcPropertyData) -> None:
# ventilation_type 6 = MEV decentralised → EXTRACT_OR_PIV_OUTSIDE.
assert sample.sap_ventilation is not None
assert sample.sap_ventilation.mechanical_ventilation_kind == "EXTRACT_OR_PIV_OUTSIDE"
def test_sheltered_sides_and_wet_rooms(self, sample: EpcPropertyData) -> None:
assert sample.sap_ventilation is not None
assert sample.sap_ventilation.sheltered_sides == 1
assert sample.wet_rooms_count == 2
def test_mev_index_for_pcdb_lookup(self, sample: EpcPropertyData) -> None:
assert sample.mechanical_ventilation_index_number == 500229
def test_unknown_ventilation_type_fails_loud(self) -> None:
data = load("sap_17_1.json")
data["sap_ventilation"]["ventilation_type"] = 99
schema = from_dict(SapSchema17_1, data)
with pytest.raises(UnmappedApiCode):
EpcPropertyDataMapper.from_sap_schema_17_1(schema)
class TestFromSapSchema17_1Heating:
"""Slice D6: full-SAP sap_heating (differing field names) maps onto the
domain SapHeating + MainHeatingDetail the calculator consumes."""

View file

@ -88,6 +88,25 @@ class SapBuildingPart:
building_part_number: Optional[int] = None
@dataclass
class SapVentilation:
"""Measured ventilation. `air_permeability` is the as-tested AP4 value the
engine consumes directly (vs RdSAP's age-band default). `ventilation_type`
is the standard SAP code (1 natural 5/6 MEV, 7 MVHR, 8 MV)."""
air_permeability: Optional[float] = None
pressure_test: Optional[int] = None
ventilation_type: Optional[int] = None
sheltered_sides_count: Optional[int] = None
wet_rooms_count: Optional[int] = None
extract_fans_count: Optional[int] = None
open_flues_count: Optional[int] = None
flueless_gas_fires_count: Optional[int] = None
open_fireplaces_count: Optional[int] = None
mechanical_vent_system_index_number: Optional[int] = None
mechanical_vent_duct_type: Optional[int] = None
@dataclass
class SapEnergySource:
"""Electricity tariff, on-site generation and lighting. Lighting outlet
@ -171,6 +190,7 @@ class SapSchema17_1:
sap_building_parts: List[SapBuildingPart]
sap_heating: SapHeating
sap_energy_source: SapEnergySource = field(default_factory=SapEnergySource)
sap_ventilation: SapVentilation = field(default_factory=SapVentilation)
# measured living-room area (m²); the engine consumes it via a back-solved
# habitable_rooms_count (Table 27). Optional — 100% present in the corpus.
living_area: Optional[Union[int, float]] = None

View file

@ -92,17 +92,18 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
# support has landed (datatypes/epc/domain/mapper.py from_sap_schema_17_1;
# design: scripts/hyde/mapping_decisions.md), so the chain now runs through
# the RdSAP SAP-10 engine end-to-end. Lodged rating is 83; the engine
# produces 81 (2) — a small, expected residual: full SAP carries measured
# fabric the RdSAP engine partly re-derives, plus the mapper fabricates
# RdSAP proxies absent from full SAP (age band, habitable-room count back-
# solved from the measured living area). PINNED TO THE OBSERVED 81, not the
# lodged 83 — the mapping is deliberately not tuned to hit the lodged value;
# the 2 is to be reconciled with the domain expert against a worksheet.
# produces 77 (6). The mapper faithfully feeds the cert's measured
# decentralised MEV (ventilation_type 6 → EXTRACT_OR_PIV_OUTSIDE), which the
# engine prices as added extract-ventilation loss (the 4 driver isolated;
# measured air permeability / fabric drive the rest). PINNED TO THE OBSERVED
# 77, not the lodged 83 — the mapping is deliberately not tuned; the 6 (and
# the engine's full-SAP MEV treatment) is for the separate SAP-calc
# verification task to reconcile against a worksheet.
RealCertExpectation(
schema="SAP-Schema-17.1",
sample="uprn_10092973954",
cert_num="0862-3892-7875-2690-2325",
sap_score=81,
sap_score=77,
),
# UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1,
# all-electric high-heat-retention storage heaters on Economy 7, solid-