mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice 100a: API path — surface Detailed-RR per-surface areas
Two RR shapes coexist in real-API JSON: cohort certs (6035, 0240,
schema test 21_0_1.json) lodge `room_in_roof_type_1` (RdSAP §3.9.1
Simplified Type 1 — gable lengths only, cascade applies the 2.45 m
default storey height); cert 9501 lodges `room_in_roof_details`
(RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-
ceiling detail). The schema only modelled the Simplified-Type-1
wrapper, so `from_dict` parsed cert 9501's Detailed-RR block as
None and the API mapper built `SapRoomInRoof` with `detailed_
surfaces=None`. The cascade then defaulted to Simplified Type 2
"all elements" (RR floor area × Table 18 col(4) age-B U=2.30) for
the whole RR → roof HLC 149.43 W/K vs worksheet 18.10 (Δ +131.32).
Changes:
- Add `RoomInRoofDetails` dataclass to both schema 21.0.0 and 21.0.1
with the 10 fields the JSON lodges: gable_wall_type_{1,2} +
gable_wall_length_{1,2} + gable_wall_height_{1,2} + flat_ceiling_
length_1 + flat_ceiling_height_1 + flat_ceiling_insulation_
type_1 + flat_ceiling_insulation_thickness_1. `SapRoomInRoof`
gains a sibling `room_in_roof_details` field next to the legacy
`room_in_roof_type_1`; both shapes are now lossless.
- Extract `_api_build_room_in_roof` mapper helper that reads from
whichever block is present and populates
`SapRoomInRoof.detailed_surfaces` from the Detailed-RR block.
Gables route to `gable_wall_external` for flats (top-floor flats
with RR sit at the end of the building, no neighbour above) and
to `gable_wall` (party at U=0.25) otherwise — mirrors the Summary
mapper's `_map_elmhurst_rir_surface` heuristic.
- Replace both inline `SapRoomInRoof(...)` builds in
`from_rdsap_schema_21_0_0` and `from_rdsap_schema_21_0_1` with
the helper.
Effect on cert 9501 API path:
- roof HLC 149.43 → 18.10 (= worksheet 18.10 exact)
- walls HLC 168.74 → 218.81 (= worksheet 218.81 exact)
- (37) total HLC 382.19 → 297.54 (worksheet 296.68; Δ +0.86)
- sap_continuous still -9.27 vs worksheet because TFA on the API
path is still 81.28 (missing the 31.8 m² RR floor area) — next
slice closes that.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
0735c7e81c
commit
7d46018386
4 changed files with 172 additions and 40 deletions
|
|
@ -497,6 +497,43 @@ _API_0330_JSON = (
|
|||
/ "0330-2249-8150-2326-4121.json"
|
||||
)
|
||||
|
||||
_API_9501_JSON = (
|
||||
Path(__file__).parents[3]
|
||||
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
|
||||
/ "9501-3059-8202-7356-0204.json"
|
||||
)
|
||||
|
||||
|
||||
def test_api_9501_room_in_roof_surfaces_populated() -> None:
|
||||
# Arrange — cert 9501's API JSON lodges measured RR detail under
|
||||
# `sap_room_in_roof.room_in_roof_details`: two gable walls
|
||||
# (5.51 m × 2.45 m + 6.51 m × 2.45 m) and a flat ceiling (5.5 m ×
|
||||
# 1.0 m, 300 mm insulation). The schema's `SapRoomInRoof` dataclass
|
||||
# exposed the inner block under the wrong field name
|
||||
# `room_in_roof_type_1` (the legacy Simplified Type 1 wrapper),
|
||||
# so `from_dict` parsed the inner block as None — the API mapper
|
||||
# then built `SapRoomInRoof` with no per-surface area data, and
|
||||
# the cascade defaulted to the Simplified Type 2 "all elements"
|
||||
# branch (RR floor_area × Table 18 col(4) age-B U=2.30) for the
|
||||
# whole RR → roof HLC 149.43 vs worksheet 18.10 (Δ +131).
|
||||
doc = json.loads(_API_9501_JSON.read_text())
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Assert — RR surfaces present and match worksheet element table:
|
||||
# Gable Wall 1 = 13.50 m², Gable Wall 2 = 15.95 m², Flat Ceiling 1
|
||||
# = 5.50 m² (per worksheet §3 element table).
|
||||
rir = epc.sap_building_parts[0].sap_room_in_roof
|
||||
assert rir is not None
|
||||
assert rir.detailed_surfaces is not None
|
||||
kinds_by_area = sorted((s.kind, s.area_m2) for s in rir.detailed_surfaces)
|
||||
assert kinds_by_area == [
|
||||
("flat_ceiling", 5.5),
|
||||
("gable_wall_external", 13.50),
|
||||
("gable_wall_external", 15.95),
|
||||
]
|
||||
|
||||
|
||||
def test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 0330-2249-8150-2326-4121 (second boiler validation
|
||||
|
|
|
|||
|
|
@ -1354,26 +1354,9 @@ class EpcPropertyDataMapper:
|
|||
bp.roof_insulation_thickness,
|
||||
bp.construction_age_band,
|
||||
),
|
||||
sap_room_in_roof=(
|
||||
SapRoomInRoof(
|
||||
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
|
||||
construction_age_band=bp.sap_room_in_roof.construction_age_band,
|
||||
# RdSAP §3.9.1 Simplified Type 1: gable lengths
|
||||
# only (no heights — the cascade applies the
|
||||
# 2.45 m default storey height per §3.9.1).
|
||||
gable_1_length_m=(
|
||||
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1
|
||||
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
|
||||
else None
|
||||
),
|
||||
gable_2_length_m=(
|
||||
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2
|
||||
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
if bp.sap_room_in_roof
|
||||
else None
|
||||
sap_room_in_roof=_api_build_room_in_roof(
|
||||
bp.sap_room_in_roof,
|
||||
is_flat=schema.property_type == 2,
|
||||
),
|
||||
sap_alternative_wall_1=(
|
||||
SapAlternativeWall(
|
||||
|
|
@ -1668,26 +1651,9 @@ class EpcPropertyDataMapper:
|
|||
bp.roof_insulation_thickness,
|
||||
bp.construction_age_band,
|
||||
),
|
||||
sap_room_in_roof=(
|
||||
SapRoomInRoof(
|
||||
floor_area=_measurement_value(bp.sap_room_in_roof.floor_area),
|
||||
construction_age_band=bp.sap_room_in_roof.construction_age_band,
|
||||
# RdSAP §3.9.1 Simplified Type 1: gable lengths
|
||||
# only (no heights — the cascade applies the
|
||||
# 2.45 m default storey height per §3.9.1).
|
||||
gable_1_length_m=(
|
||||
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_1
|
||||
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
|
||||
else None
|
||||
),
|
||||
gable_2_length_m=(
|
||||
bp.sap_room_in_roof.room_in_roof_type_1.gable_wall_length_2
|
||||
if bp.sap_room_in_roof.room_in_roof_type_1 is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
if bp.sap_room_in_roof
|
||||
else None
|
||||
sap_room_in_roof=_api_build_room_in_roof(
|
||||
bp.sap_room_in_roof,
|
||||
is_flat=schema.property_type == 2,
|
||||
),
|
||||
sap_alternative_wall_1=(
|
||||
SapAlternativeWall(
|
||||
|
|
@ -2376,6 +2342,94 @@ def _api_build_sap_floor_dimensions(
|
|||
return out
|
||||
|
||||
|
||||
def _api_build_room_in_roof(
|
||||
bp_rir: Any, *, is_flat: bool = False,
|
||||
) -> Optional[SapRoomInRoof]:
|
||||
"""Build `SapRoomInRoof` from the API schema's per-bp RR block. Two
|
||||
real-API shapes coexist:
|
||||
- `room_in_roof_type_1` (cohort certs 6035, 0240): RdSAP §3.9.1
|
||||
Simplified Type 1 — gable lengths only, cascade applies the
|
||||
2.45 m default storey height.
|
||||
- `room_in_roof_details` (cert 9501): RdSAP §3.9 Detailed RR —
|
||||
per-surface lengths + heights + flat-ceiling detail.
|
||||
When the Detailed block is present, build `detailed_surfaces` so the
|
||||
cascade's per-surface RR branch (heat_transmission.py:629) picks
|
||||
up exact gable + flat-ceiling areas instead of falling through to
|
||||
the Table 18 col(4) "all elements" default U.
|
||||
"""
|
||||
if bp_rir is None:
|
||||
return None
|
||||
rir = SapRoomInRoof(
|
||||
floor_area=_measurement_value(bp_rir.floor_area),
|
||||
construction_age_band=bp_rir.construction_age_band,
|
||||
)
|
||||
type_1 = getattr(bp_rir, "room_in_roof_type_1", None)
|
||||
if type_1 is not None:
|
||||
# RdSAP §3.9.1 Simplified Type 1: gable lengths only (no heights —
|
||||
# the cascade applies the 2.45 m default storey height).
|
||||
rir.gable_1_length_m = type_1.gable_wall_length_1
|
||||
rir.gable_2_length_m = type_1.gable_wall_length_2
|
||||
details = getattr(bp_rir, "room_in_roof_details", None)
|
||||
if details is not None:
|
||||
rir.detailed_surfaces = _api_rir_detailed_surfaces(details, is_flat=is_flat)
|
||||
return rir
|
||||
|
||||
|
||||
def _api_rir_detailed_surfaces(
|
||||
details: Any, *, is_flat: bool,
|
||||
) -> Optional[List[SapRoomInRoofSurface]]:
|
||||
"""Translate the API `room_in_roof_details` block into the per-surface
|
||||
list the cascade's Detailed-RR branch consumes.
|
||||
|
||||
Gable walls route to `gable_wall_external` when `is_flat=True` (top-
|
||||
floor flats with RR sit at the end of their building, no neighbour
|
||||
above) and to `gable_wall` (party at U=0.25) otherwise. Mirrors the
|
||||
Summary mapper's `_map_elmhurst_rir_surface` heuristic.
|
||||
"""
|
||||
surfaces: List[SapRoomInRoofSurface] = []
|
||||
gable_specs = (
|
||||
(details.gable_wall_length_1, details.gable_wall_height_1),
|
||||
(details.gable_wall_length_2, details.gable_wall_height_2),
|
||||
)
|
||||
gable_kind = "gable_wall_external" if is_flat else "gable_wall"
|
||||
for length, height in gable_specs:
|
||||
if length is not None and height is not None and length > 0 and height > 0:
|
||||
area = _round_half_up_2dp(float(length), float(height))
|
||||
surfaces.append(SapRoomInRoofSurface(kind=gable_kind, area_m2=area))
|
||||
if (
|
||||
details.flat_ceiling_length_1 is not None
|
||||
and details.flat_ceiling_height_1 is not None
|
||||
and details.flat_ceiling_length_1 > 0
|
||||
and details.flat_ceiling_height_1 > 0
|
||||
):
|
||||
area = _round_half_up_2dp(
|
||||
float(details.flat_ceiling_length_1),
|
||||
float(details.flat_ceiling_height_1),
|
||||
)
|
||||
thickness = _parse_rir_insulation_thickness_mm(
|
||||
details.flat_ceiling_insulation_thickness_1
|
||||
)
|
||||
surfaces.append(
|
||||
SapRoomInRoofSurface(
|
||||
kind="flat_ceiling",
|
||||
area_m2=area,
|
||||
insulation_thickness_mm=thickness,
|
||||
)
|
||||
)
|
||||
return surfaces or None
|
||||
|
||||
|
||||
def _parse_rir_insulation_thickness_mm(value: Any) -> Optional[int]:
|
||||
"""Parse the API's `flat_ceiling_insulation_thickness_*` string
|
||||
(e.g. "300mm") to an integer mm. Returns None when missing or
|
||||
unparseable so the cascade defers to the spec default."""
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
m = re.match(r"^(\d+)\s*mm$", s)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _api_resolve_sloping_ceiling_thickness(
|
||||
roof_construction: Optional[int],
|
||||
roof_insulation_thickness: Union[str, int, None],
|
||||
|
|
|
|||
|
|
@ -185,12 +185,29 @@ class RoomInRoofType1:
|
|||
gable_wall_length_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofDetails:
|
||||
"""RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling
|
||||
detail. See `rdsap_schema_21_0_1.RoomInRoofDetails`."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
gable_wall_length_2: Optional[float] = None
|
||||
gable_wall_height_1: Optional[float] = None
|
||||
gable_wall_height_2: Optional[float] = None
|
||||
flat_ceiling_length_1: Optional[float] = None
|
||||
flat_ceiling_height_1: Optional[float] = None
|
||||
flat_ceiling_insulation_type_1: Optional[int] = None
|
||||
flat_ceiling_insulation_thickness_1: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
room_in_roof_type_1: Optional[RoomInRoofType1] = None
|
||||
room_in_roof_details: Optional[RoomInRoofDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -194,11 +194,35 @@ class RoomInRoofType1:
|
|||
gable_wall_length_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofDetails:
|
||||
"""RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-ceiling
|
||||
detail. Newer cert vintages lodge full per-surface measured detail under
|
||||
`room_in_roof_details` instead of the Simplified Type 1 wrapper. Used
|
||||
by `EpcPropertyDataMapper.from_api_response` to populate
|
||||
`SapRoomInRoof.detailed_surfaces` with `gable_wall_external` /
|
||||
`flat_ceiling` entries the cascade's Detailed-RR branch consumes."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
gable_wall_length_2: Optional[float] = None
|
||||
gable_wall_height_1: Optional[float] = None
|
||||
gable_wall_height_2: Optional[float] = None
|
||||
flat_ceiling_length_1: Optional[float] = None
|
||||
flat_ceiling_height_1: Optional[float] = None
|
||||
flat_ceiling_insulation_type_1: Optional[int] = None
|
||||
flat_ceiling_insulation_thickness_1: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
# Two real-API shapes coexist: older certs (cohort 6035, 0240, test
|
||||
# fixture 21_0_1.json) lodge the Simplified Type 1 wrapper; newer
|
||||
# certs (9501) lodge the Detailed-RR block. Accept both.
|
||||
room_in_roof_type_1: Optional[RoomInRoofType1] = None
|
||||
room_in_roof_details: Optional[RoomInRoofDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue