mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
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:
parent
c035d17f2b
commit
345154c6b7
4 changed files with 107 additions and 8 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue