Normalize RdSAP ventilation mapping on the degraded 17.0/19.0 paths

PRD #1385, ventilation family. The 17.0 and 19.0 API mappers dropped lodged
ventilation fields that the reference-complete 17.1/18.0/20.0 paths read:

  * open_fireplaces_count -> open_chimneys_count: hardcoded 0 on both
    (understating §2 infiltration).
  * percent_draughtproofed: omitted on both.
  * 17.0 additionally passed no sap_ventilation block, so the §2 cascade fell
    back to NATURAL + its default sheltered_sides=2. Restored from the lodged
    mechanical_ventilation + built_form via the existing _api_* helpers.

Semantic parity verified (not just type-equality): built_form and
mechanical_ventilation use the same gov code lists the 17.1/18.0/19.0/20.0/21.x
paths already map through these helpers; all 244 RdSAP fixtures resolve with zero
UnmappedApiCode, and the strict-coverage guard raises on any future divergence.
For the current corpus the 17.0 score-mover is sheltered_sides from built_form
(every 17.0 cert lodges mechanical_ventilation=0 -> NATURAL).

Adds a regression test asserting both paths carry the fields through
from_api_response on real certs.

Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jun-te Kim 2026-07-01 12:17:54 +00:00
parent 7dcead2e51
commit e05f873282
2 changed files with 90 additions and 2 deletions

View file

@ -596,9 +596,13 @@ class EpcPropertyDataMapper:
heated_rooms_count=schema.heated_room_count,
wet_rooms_count=0,
extensions_count=schema.extensions_count,
open_chimneys_count=0,
# Both lodged by the schema and read by the 17.1/18.0/20.0 paths.
# open_chimneys_count was hardcoded 0 (understating infiltration);
# percent_draughtproofed was omitted entirely.
open_chimneys_count=schema.open_fireplaces_count,
insulated_door_count=schema.insulated_door_count,
draughtproofed_door_count=None,
percent_draughtproofed=schema.percent_draughtproofed,
led_fixed_lighting_bulbs_count=0,
cfl_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=0,
@ -728,6 +732,19 @@ class EpcPropertyDataMapper:
if getattr(bp, "glazed_perimeter", None) is None
],
sap_conservatory=_api_sap_conservatory(schema.sap_building_parts),
# 17.0 lodges `mechanical_ventilation` + `built_form` but passed no
# sap_ventilation, so the §2 cascade fell back to NATURAL and its
# default sheltered_sides=2. Mirror the 19.0/17.1 path exactly (same
# gov code lists — verified in-set across the corpus, strict-coverage
# raises on any divergence). For the current corpus the score-mover
# is sheltered_sides from built_form (every 17.0 cert lodges
# mechanical_ventilation=0 → NATURAL).
sap_ventilation=SapVentilation(
sheltered_sides=_api_sheltered_sides(schema.built_form),
mechanical_ventilation_kind=_api_mechanical_ventilation_kind(
schema.mechanical_ventilation
),
),
)
@staticmethod
@ -1540,9 +1557,13 @@ class EpcPropertyDataMapper:
heated_rooms_count=schema.heated_room_count,
wet_rooms_count=0,
extensions_count=schema.extensions_count,
open_chimneys_count=0,
# Both lodged by the schema and read by the 17.1/18.0/20.0 paths.
# open_chimneys_count was hardcoded 0 (understating infiltration);
# percent_draughtproofed was omitted entirely.
open_chimneys_count=schema.open_fireplaces_count,
insulated_door_count=schema.insulated_door_count,
draughtproofed_door_count=None,
percent_draughtproofed=schema.percent_draughtproofed,
led_fixed_lighting_bulbs_count=0,
cfl_fixed_lighting_bulbs_count=0,
incandescent_fixed_lighting_bulbs_count=0,

View file

@ -0,0 +1,67 @@
"""Regression: the degraded RdSAP paths (17.0, 19.0) must carry the lodged
ventilation fields, like the reference-complete 17.1/18.0/20.0 paths.
PRD #1385 (mapper normalization). Both paths dropped ventilation lodgements the
schema carries and the SAP §2 cascade reads:
* `open_fireplaces_count` `open_chimneys_count` was hardcoded 0 on both,
understating infiltration.
* `percent_draughtproofed` was omitted on both.
* 17.0 additionally passed no `sap_ventilation` block at all, so the cascade
fell back to NATURAL + its default `sheltered_sides=2`. Restored from the
lodged `mechanical_ventilation` + `built_form` (same gov code lists the
19.0/17.1 paths already use; strict-coverage raises on any divergence). 19.0
already carried `sap_ventilation`, so only its two counts are restored here.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
# 17.0 cert: open_fireplaces=1, percent_draughtproofed=100, built_form=2
# (Semi-Detached → sheltered_sides=1, i.e. NOT the cascade default of 2),
# mechanical_ventilation=0 (natural → kind None).
_FIXTURE_17_0 = Path("tests/fixtures/epc_prediction/BD24JG/cert-f326c2524ab3.json")
# 19.0 cert: open_fireplaces=1, percent_draughtproofed=100.
_FIXTURE_19_0 = Path("tests/fixtures/epc_prediction/RM143YU/cert-720d5771bfd7.json")
def _load(path: Path, schema_type: str) -> dict[str, Any]:
data: dict[str, Any] = json.loads(path.read_text())
assert data["schema_type"] == schema_type
return data
def test_17_0_path_carries_ventilation_lodgements() -> None:
epc = EpcPropertyDataMapper.from_api_response(
_load(_FIXTURE_17_0, "RdSAP-Schema-17.0")
)
# Counts that were hardcoded 0 / omitted.
assert epc.open_chimneys_count == 1
assert epc.percent_draughtproofed == 100
# sap_ventilation block was absent entirely — now built from built_form +
# mechanical_ventilation. built_form 2 (Semi-Detached) → sheltered_sides 1,
# which differs from the cascade's default of 2 (the real score-mover here).
assert epc.sap_ventilation is not None
assert epc.sap_ventilation.sheltered_sides == 1
# mechanical_ventilation 0 is natural → no mechanical kind.
assert epc.sap_ventilation.mechanical_ventilation_kind is None
def test_19_0_path_carries_ventilation_counts() -> None:
epc = EpcPropertyDataMapper.from_api_response(
_load(_FIXTURE_19_0, "RdSAP-Schema-19.0")
)
# The two counts 19.0 previously dropped.
assert epc.open_chimneys_count == 1
assert epc.percent_draughtproofed == 100
# 19.0 already carried sap_ventilation; confirm it still resolves built_form.
assert epc.sap_ventilation is not None
assert epc.sap_ventilation.sheltered_sides == 1