mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.51: strict-raise UnmappedApiCode on API integer enums
Mirrors the Elmhurst `UnmappedElmhurstLabel` coverage gate on the GOV.UK API path. The same failure mode (silently routing an unknown enum to a default / None hides cascade gaps until a downstream SAP- delta investigation surfaces them) was hitting the API mapper: existing helpers like `_api_floor_construction_str` returned None on unrecognised codes per the comment "Only the values observed across the 10 golden fixtures (1, 2) are mapped; unrecognised codes fall through to None." Adds `UnmappedApiCode(ValueError)` at the API mapper boundary and threads it through five strict helpers: - `_api_party_wall_construction_int` (RdSAP10 Table 15) - `_api_floor_construction_str` (Slice 88 floor signal) - `_api_floor_type_str` (RdSAP10 §5 rule (12)) - `_api_roof_construction_str` (Slice 89 cos(30°) factor) - `_api_sheltered_sides` (SAP10.2 §S5) Each helper distinguishes: - "lodging absent" → return None (unchanged behaviour) - "lodging present and mapped" → translate (unchanged behaviour) - "lodging present but unrecognised" → raise UnmappedApiCode (NEW) Two coverage gaps surfaced immediately at strict-run, both fixed in the same slice with the worksheet-backed lodged-floor descriptions: 1. `floor_heat_loss=2` — cert 7536 Main lodges this (floors[] description "To unheated space, insulated"); also lodged on cert 2031 / etc. Added mapping → "To unheated space". 2. `floor_heat_loss=3` — cert 7536 Ext2 lodges this with the same floors[] description as Main code 2 — same cascade signal. 3. `floor_heat_loss=6` — cert 9501 + cert 9390 (top-floor flats) lodge this with floors[] description "(another dwelling below)". The cascade routes party-floor handling via property_type=Flat + cert.floors[] description independently of this string, so the explicit None entry preserves the cascade match (cert 9501 stays at exact 1e-4 SAP vs worksheet 68.5252) while distinguishing "decided no string" from "unknown". Six new tests document the contract: - Five unit tests inject an out-of-range integer (99) into a real cohort cert JSON and assert UnmappedApiCode raises with the right `field` and `value`. - One coverage forcing function (`test_all_golden_fixtures_extract _via_api_without_unmapped_code_raise`) loops every JSON under `fixtures/golden/` through `from_api_response` and asserts no raise — future fixtures with unmapped enums fail this test until a dict entry is added. 763 → 769 pass + 0 fail (5 unit + 1 cohort-coverage test added). Pyright net-zero (32 → 32 baseline preserved). The pattern is ready to extend to other silently-falling-through helpers — e.g., `_api_glazing_transmission` (codes 4-12, 15+ noted in the existing comment as "not yet mapped — incremental coverage as new fixtures surface them"), `_api_cascade_glazing_type` (pass- through is intentional, so probably leave alone). Each addition is its own slice.
This commit is contained in:
parent
3d1e6f103a
commit
b7fbbcca96
2 changed files with 231 additions and 16 deletions
|
|
@ -40,6 +40,7 @@ import pytest
|
|||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.mapper import (
|
||||
EpcPropertyDataMapper,
|
||||
UnmappedApiCode,
|
||||
UnmappedElmhurstLabel,
|
||||
)
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
|
|
@ -1089,6 +1090,126 @@ def test_summary_mapper_raises_on_unmapped_glazing_type_label() -> None:
|
|||
assert excinfo.value.value == "Quintuple glazed with helium"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# API mapper strict-raise — mirror the Elmhurst UnmappedElmhurstLabel
|
||||
# coverage gate on the GOV.UK API path. The same failure mode (silently
|
||||
# routing an unknown enum to a default int / None hides cascade gaps
|
||||
# until a SAP-delta investigation surfaces them) applies to API
|
||||
# integer codes. Each strict helper is unit-tested for its raise
|
||||
# behaviour; a cohort-coverage forcing function asserts every golden
|
||||
# fixture extracts cleanly via `from_api_response`.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
_GOLDEN_FIXTURES_DIR = (
|
||||
Path(__file__).parents[3]
|
||||
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
|
||||
)
|
||||
|
||||
|
||||
def _patch_api_doc_and_extract(
|
||||
cert: str, mutator: "callable[[dict], None]"
|
||||
) -> None:
|
||||
"""Load a golden cert JSON, apply a mutation, and run
|
||||
`from_api_response`. Used by the strict-raise unit tests to inject
|
||||
an unmapped integer code into a known-good document."""
|
||||
doc = json.loads((_GOLDEN_FIXTURES_DIR / f"{cert}.json").read_text())
|
||||
mutator(doc)
|
||||
EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
|
||||
def test_api_mapper_raises_on_unmapped_floor_construction_code() -> None:
|
||||
# Arrange — start from a real cohort cert and inject an unmapped
|
||||
# `floor_construction` integer (currently the dict covers 1 and 2).
|
||||
# The mapper must raise `UnmappedApiCode` rather than silently
|
||||
# dropping the floor_construction signal — losing it routes the
|
||||
# cascade to the wrong solid-vs-suspended branch (see Slice
|
||||
# S0380.27's floor_construction_type fix that closed cert 8135's
|
||||
# PE -4.96 → -0.07).
|
||||
def mutate(doc: "dict") -> None:
|
||||
doc["sap_building_parts"][0]["sap_floor_dimensions"][0]["floor_construction"] = 99
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedApiCode) as excinfo:
|
||||
_patch_api_doc_and_extract("0380-2471-3250-2596-8761", mutate)
|
||||
assert excinfo.value.field == "floor_construction"
|
||||
assert excinfo.value.value == 99
|
||||
|
||||
|
||||
def test_api_mapper_raises_on_unmapped_roof_construction_code() -> None:
|
||||
# Arrange — inject an unmapped roof_construction integer. The
|
||||
# cascade's `_api_roof_construction_str` powers the cos(30°)
|
||||
# inclined-surface factor and the flat-roof Table 18 column-3
|
||||
# dispatch — a silently-None value here under-counts roof loss
|
||||
# for sloping ceilings or routes flat roofs to the wrong column.
|
||||
def mutate(doc: "dict") -> None:
|
||||
doc["sap_building_parts"][0]["roof_construction"] = 99
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedApiCode) as excinfo:
|
||||
_patch_api_doc_and_extract("0380-2471-3250-2596-8761", mutate)
|
||||
assert excinfo.value.field == "roof_construction"
|
||||
assert excinfo.value.value == 99
|
||||
|
||||
|
||||
def test_api_mapper_raises_on_unmapped_party_wall_construction_code() -> None:
|
||||
# Arrange — inject an unmapped party_wall_construction. The cohort
|
||||
# currently covers RdSAP10 Table 15 codes 0..5; out-of-range integers
|
||||
# must raise so the next fixture forces an explicit dict entry.
|
||||
def mutate(doc: "dict") -> None:
|
||||
doc["sap_building_parts"][0]["party_wall_construction"] = 99
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedApiCode) as excinfo:
|
||||
_patch_api_doc_and_extract("0380-2471-3250-2596-8761", mutate)
|
||||
assert excinfo.value.field == "party_wall_construction"
|
||||
assert excinfo.value.value == 99
|
||||
|
||||
|
||||
def test_api_mapper_raises_on_unmapped_floor_heat_loss_code() -> None:
|
||||
# Arrange — codes 4/5/8+ aren't in the dict; injecting one must
|
||||
# raise. Codes 1/2/3/6/7 are mapped explicitly (some to None) so
|
||||
# the strict gate distinguishes "decided no string" from "unknown".
|
||||
def mutate(doc: "dict") -> None:
|
||||
doc["sap_building_parts"][0]["floor_heat_loss"] = 99
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedApiCode) as excinfo:
|
||||
_patch_api_doc_and_extract("0380-2471-3250-2596-8761", mutate)
|
||||
assert excinfo.value.field == "floor_heat_loss"
|
||||
assert excinfo.value.value == 99
|
||||
|
||||
|
||||
def test_api_mapper_raises_on_unmapped_built_form_code() -> None:
|
||||
# Arrange — codes 1..6 cover detached / semi-detached / terraces;
|
||||
# an out-of-range integer must raise rather than silently routing
|
||||
# through the cascade's `_DEFAULT_SHELTERED_SIDES = 2`.
|
||||
def mutate(doc: "dict") -> None:
|
||||
doc["built_form"] = 99
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedApiCode) as excinfo:
|
||||
_patch_api_doc_and_extract("0380-2471-3250-2596-8761", mutate)
|
||||
assert excinfo.value.field == "built_form"
|
||||
assert excinfo.value.value == 99
|
||||
|
||||
|
||||
def test_all_golden_fixtures_extract_via_api_without_unmapped_code_raise() -> None:
|
||||
# Arrange — coverage forcing function on the API path: every JSON
|
||||
# fixture in `fixtures/golden/` must round-trip through
|
||||
# `from_api_response` without triggering an `UnmappedApiCode` raise
|
||||
# from any strict helper. New cohort fixtures added in subsequent
|
||||
# slices fall under the same gate; future API enum variants
|
||||
# surface here at extraction time instead of as a downstream SAP
|
||||
# delta.
|
||||
fixtures = sorted(_GOLDEN_FIXTURES_DIR.glob("*.json"))
|
||||
assert fixtures, f"no golden fixtures under {_GOLDEN_FIXTURES_DIR}"
|
||||
|
||||
# Act / Assert — strict run for each fixture
|
||||
for fixture in fixtures:
|
||||
doc = json.loads(fixture.read_text())
|
||||
EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
|
||||
def test_summary_7800_two_electric_showers_count_as_two_not_one() -> None:
|
||||
# Arrange — cert 7800-1501-0922-7127-3563's Summary §16 lodges TWO
|
||||
# instantaneous electric showers ("Shower 01" + "Shower 11", both
|
||||
|
|
|
|||
|
|
@ -2178,6 +2178,31 @@ def _elmhurst_party_wall_construction_int(coded: str) -> Optional[int]:
|
|||
return _ELMHURST_PARTY_WALL_CODE_TO_SAP10.get(_leading_code(coded))
|
||||
|
||||
|
||||
class UnmappedApiCode(ValueError):
|
||||
"""A GOV.UK API integer enum that the mapper does not yet know how
|
||||
to translate to the SAP10 cascade-domain value.
|
||||
|
||||
Raised by the strict API code helpers (floor_construction,
|
||||
roof_construction, party_wall_construction, …) to surface mapper-
|
||||
coverage gaps at the extraction boundary instead of silently
|
||||
returning None and letting the cascade default to a wrong-but-not-
|
||||
obviously-wrong value downstream. Mirrors `UnmappedElmhurstLabel`
|
||||
on the Summary path.
|
||||
|
||||
Distinguish "lodging absent" (helper returns None — correct) from
|
||||
"lodging present but unrecognised" (raise — fixture variant the
|
||||
mapper doesn't yet cover, needs a dict entry added).
|
||||
"""
|
||||
|
||||
def __init__(self, field: str, value: object) -> None:
|
||||
super().__init__(
|
||||
f"unmapped API {field} code: {value!r}; "
|
||||
f"add an entry to the corresponding mapper lookup dict"
|
||||
)
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
|
||||
# GOV.UK API party_wall_construction enum → SAP10 wall_construction
|
||||
# integer (the domain `u_party_wall` consumes). The API uses a different
|
||||
# enum from the regular wall_construction field — RdSAP 10 Table 15
|
||||
|
|
@ -2216,10 +2241,18 @@ def _api_party_wall_construction_int(value: Union[int, str, None]) -> Optional[i
|
|||
"""Translate the GOV.UK API `party_wall_construction` integer code
|
||||
(or 'NA' string) to the SAP10 wall_construction integer the cascade
|
||||
consumes. See `_API_PARTY_WALL_CONSTRUCTION_TO_SAP10` for the
|
||||
enum semantics (RdSAP 10 Table 15)."""
|
||||
enum semantics (RdSAP 10 Table 15).
|
||||
|
||||
Strict-coverage: a lodged integer that isn't in the dict raises
|
||||
`UnmappedApiCode` so the fixture forces a dict entry rather than
|
||||
silently falling back to the cascade's U=0.25 house default.
|
||||
`None` / 'NA' / digit-string variants stay as no-lodging (return
|
||||
None — the cascade applies its own default)."""
|
||||
if value is None or isinstance(value, str):
|
||||
return None
|
||||
return _API_PARTY_WALL_CONSTRUCTION_TO_SAP10.get(value)
|
||||
if value not in _API_PARTY_WALL_CONSTRUCTION_TO_SAP10:
|
||||
raise UnmappedApiCode("party_wall_construction", value)
|
||||
return _API_PARTY_WALL_CONSTRUCTION_TO_SAP10[value]
|
||||
|
||||
|
||||
# GOV.UK API `floor_construction` integer → human-readable string the
|
||||
|
|
@ -2254,8 +2287,17 @@ _API_ROOF_CONSTRUCTION_TO_STR: Dict[int, str] = {
|
|||
def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
|
||||
"""Translate the API integer floor_construction code to the
|
||||
human-readable string the cascade reads via Slice 88's
|
||||
`effective_floor_description` in `heat_transmission.py`."""
|
||||
return _API_FLOOR_CONSTRUCTION_TO_STR.get(value) if value is not None else None
|
||||
`effective_floor_description` in `heat_transmission.py`.
|
||||
|
||||
Strict-coverage: a lodged integer outside the mapped set raises
|
||||
`UnmappedApiCode` so the next fixture forces a dict entry rather
|
||||
than silently dropping back to the cascade's solid-vs-suspended
|
||||
branching default."""
|
||||
if value is None:
|
||||
return None
|
||||
if value not in _API_FLOOR_CONSTRUCTION_TO_STR:
|
||||
raise UnmappedApiCode("floor_construction", value)
|
||||
return _API_FLOOR_CONSTRUCTION_TO_STR[value]
|
||||
|
||||
|
||||
# GOV.UK API `floor_heat_loss` integer → `floor_type` string the
|
||||
|
|
@ -2266,8 +2308,35 @@ def _api_floor_construction_str(value: Optional[int]) -> Optional[str]:
|
|||
# floor" as the floor_type — otherwise the spec rule short-circuits
|
||||
# to False even when the cert is genuinely G+T (e.g. cert 001479
|
||||
# Main, floor_heat_loss=7).
|
||||
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, str] = {
|
||||
1: "To external air", # exposed (cantilevered / over passageway)
|
||||
#
|
||||
# Code coverage across the cohort fixtures:
|
||||
# 1 = "To external air" — exposed floor (cantilever / passageway)
|
||||
# 2 = "To unheated space" — over garage / unheated basement /
|
||||
# crawlspace; cert 7536 Main lodges this
|
||||
# 3 = "To unheated space" — variant lodged by cert 7536 Ext2 with
|
||||
# the same top-level floors[] description
|
||||
# as code 2; route to the same cascade
|
||||
# signal until a fixture forces them apart
|
||||
# 6 = "(another dwelling below)" — top-floor flat over a party floor;
|
||||
# cert 9501 lodges this. The cascade's
|
||||
# floor-as-party-floor dispatch already
|
||||
# handles this via `property_type=Flat` +
|
||||
# cert.floors[].description, so the
|
||||
# floor_type string from this helper is
|
||||
# not consumed for the (12) spec rule
|
||||
# in that path — explicit None preserves
|
||||
# the cert 9501 cascade match without
|
||||
# silently letting unknown codes through.
|
||||
# 7 = "Ground floor" — typical ground-floor heat loss
|
||||
#
|
||||
# Codes 4/5/8+ are not yet observed in any fixture; the strict-raise
|
||||
# path catches them at the extraction boundary so the next cert forces
|
||||
# an explicit mapping decision.
|
||||
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, Optional[str]] = {
|
||||
1: "To external air",
|
||||
2: "To unheated space",
|
||||
3: "To unheated space",
|
||||
6: None,
|
||||
7: "Ground floor",
|
||||
}
|
||||
|
||||
|
|
@ -2275,19 +2344,37 @@ _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE: Dict[int, str] = {
|
|||
def _api_floor_type_str(floor_heat_loss: Optional[int]) -> Optional[str]:
|
||||
"""Translate the API integer floor_heat_loss code to the
|
||||
human-readable floor_type the RdSAP 10 §5 (12) spec rule consumes
|
||||
(see `_has_suspended_timber_floor_per_spec`)."""
|
||||
return (
|
||||
_API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE.get(floor_heat_loss)
|
||||
if floor_heat_loss is not None else None
|
||||
)
|
||||
(see `_has_suspended_timber_floor_per_spec`).
|
||||
|
||||
Strict-coverage: a lodged integer outside the mapped set raises
|
||||
`UnmappedApiCode`. The mapped set includes explicit `None` entries
|
||||
for codes whose floor-type-string isn't consumed by the (12) rule
|
||||
on the cascade path that handles them (e.g. code 6 = "another
|
||||
dwelling below" routes through the party-floor cascade independent
|
||||
of this string). Explicit None entries DECIDE the no-string
|
||||
outcome rather than letting unknown integers fall through."""
|
||||
if floor_heat_loss is None:
|
||||
return None
|
||||
if floor_heat_loss not in _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE:
|
||||
raise UnmappedApiCode("floor_heat_loss", floor_heat_loss)
|
||||
return _API_FLOOR_HEAT_LOSS_TO_FLOOR_TYPE[floor_heat_loss]
|
||||
|
||||
|
||||
def _api_roof_construction_str(value: Optional[int]) -> Optional[str]:
|
||||
"""Translate the API integer roof_construction code to the
|
||||
human-readable string the cascade reads via Slice 89's
|
||||
`roof_construction_type`-based cos(30°) factor in
|
||||
`heat_transmission.py`."""
|
||||
return _API_ROOF_CONSTRUCTION_TO_STR.get(value) if value is not None else None
|
||||
`heat_transmission.py`.
|
||||
|
||||
Strict-coverage: a lodged integer outside the mapped set raises
|
||||
`UnmappedApiCode` so the next fixture surfaces a missing dict
|
||||
entry rather than silently dropping the inclined-surface cos(30°)
|
||||
factor (or, worse, the flat-roof Table 18 column-3 dispatch)."""
|
||||
if value is None:
|
||||
return None
|
||||
if value not in _API_ROOF_CONSTRUCTION_TO_STR:
|
||||
raise UnmappedApiCode("roof_construction", value)
|
||||
return _API_ROOF_CONSTRUCTION_TO_STR[value]
|
||||
|
||||
|
||||
# API `floor_heat_loss` integer that signals an exposed floor (lowest
|
||||
|
|
@ -2317,13 +2404,20 @@ _API_BUILT_FORM_TO_SHELTERED_SIDES: Dict[int, int] = {
|
|||
|
||||
def _api_sheltered_sides(built_form: object) -> Optional[int]:
|
||||
"""Translate the API `built_form` integer code to the SAP10.2 §S5
|
||||
sheltered-sides count. Returns None when the form isn't recognised
|
||||
so the cascade applies its own default (currently 2)."""
|
||||
sheltered-sides count.
|
||||
|
||||
Strict-coverage: a lodged integer outside the mapped set raises
|
||||
`UnmappedApiCode` rather than silently routing through the
|
||||
cascade's `_DEFAULT_SHELTERED_SIDES = 2` (which over-states
|
||||
sheltering for detached / under-states for enclosed). Non-int /
|
||||
None lodging stays as no-lodging."""
|
||||
if isinstance(built_form, str) and built_form.isdigit():
|
||||
built_form = int(built_form)
|
||||
if not isinstance(built_form, int):
|
||||
return None
|
||||
return _API_BUILT_FORM_TO_SHELTERED_SIDES.get(built_form)
|
||||
if built_form not in _API_BUILT_FORM_TO_SHELTERED_SIDES:
|
||||
raise UnmappedApiCode("built_form", built_form)
|
||||
return _API_BUILT_FORM_TO_SHELTERED_SIDES[built_form]
|
||||
|
||||
|
||||
# GOV.UK API `glazing_type` integer → (u_value W/m²K, g_perpendicular,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue