Merge pull request #1645 from Hestia-Homes/fix/terraced-rir-gable-party

Reclassify terraced room-in-roof gables as party, not external
This commit is contained in:
KhalimCK 2026-07-20 12:14:08 +01:00 committed by GitHub
commit 0fdb15ad65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 135 additions and 14 deletions

View file

@ -2454,6 +2454,7 @@ class EpcPropertyDataMapper:
sap_room_in_roof=_api_build_room_in_roof(
bp.sap_room_in_roof,
is_flat=schema.property_type == 2,
built_form=schema.built_form,
),
sap_alternative_wall_1=(
SapAlternativeWall(
@ -2790,6 +2791,7 @@ class EpcPropertyDataMapper:
sap_room_in_roof=_api_build_room_in_roof(
bp.sap_room_in_roof,
is_flat=schema.property_type == 2,
built_form=schema.built_form,
),
sap_alternative_wall_1=(
SapAlternativeWall(
@ -5561,8 +5563,16 @@ _API_TYPE_1_GABLE_TYPE_TO_KIND: Dict[int, str] = {
3: "connected_wall",
}
# RdSAP 10 §1.1 (p.7-8): built forms whose BOTH gable ends are party walls, so
# neither can be a genuinely exposed gable. 4 = Mid-Terrace, 6 = Enclosed
# Mid-Terrace. End-terrace {3, 5}, semi-detached {2} and detached {1} each have
# at least one legitimately exposed gable and are left untouched.
_BOTH_ENDS_PARTY_BUILT_FORMS: Final[frozenset[int]] = frozenset({4, 6})
def _api_type_1_gable_kind(gable_type: Optional[int]) -> str:
def _api_type_1_gable_kind(
gable_type: Optional[int], *, built_form: Optional[int] = None
) -> str:
"""Map a `gable_wall_type_*` code to the cascade's RR surface kind.
`None` (type unlodged) defaults to `gable_wall` (party) the modal
@ -5570,16 +5580,35 @@ def _api_type_1_gable_kind(gable_type: Optional[int]) -> str:
main-wall U). A lodged integer outside the known set raises
`UnmappedApiCode` so a new gable variant forces an explicit mapping
rather than silently mis-routing its U-value (mirror of the strict-
raise pattern on the other API helpers)."""
raise pattern on the other API helpers).
`built_form` consistency guard: a mid-terrace / enclosed-mid-terrace has
external walls on its two opposite (front/rear) faces only (RdSAP 10 §1.1,
p.7-8); both end/gable elevations and their room-in-roof continuations
abut a neighbour and are party (§1.4.3, p.9). So an "Exposed" RR gable
lodged on such a form is itself the data anomaly: reclassify it to a Party
gable (Table 4, p.22 U=0.25) rather than billing it at the main-wall U.
Corroborated by S10TP-05 Appendix A (a mid-terrace's roof-gable junction
length is zero) and by three accredited Elmhurst mid-terrace worksheets
(000477/000480/000516), which all lodge their RR gables as Party. The API
path lodges no per-gable U-value, so this only ever overrides a cascade
fallback, never an assessor-measured U. Surfaced by corpus cert
100040550095 (mid-terrace, Exposed RR gable billed 19.32 × 2.5 =
48.3 W/K SAP -10.13; reclassified to Party SAP 40, matching lodged)."""
if gable_type is None:
return "gable_wall"
if gable_type not in _API_TYPE_1_GABLE_TYPE_TO_KIND:
raise UnmappedApiCode("gable_wall_type", gable_type)
return _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type]
kind = _API_TYPE_1_GABLE_TYPE_TO_KIND[gable_type]
if kind == "gable_wall_external" and built_form in _BOTH_ENDS_PARTY_BUILT_FORMS:
return "gable_wall"
return kind
def _api_type_1_gable_surfaces(
type_1: Any,
*,
built_form: Optional[int] = None,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the Simplified Type 1 scalar gable fields into the
per-surface list the cascade's Detailed-RR branch consumes.
@ -5598,7 +5627,7 @@ def _api_type_1_gable_surfaces(
continue
surfaces.append(
SapRoomInRoofSurface(
kind=_api_type_1_gable_kind(gable_type),
kind=_api_type_1_gable_kind(gable_type, built_form=built_form),
area_m2=_round_half_up_2dp(float(length), _RIR_TYPE_1_GABLE_HEIGHT_M),
)
)
@ -5609,6 +5638,7 @@ def _api_build_room_in_roof(
bp_rir: Any,
*,
is_flat: bool = False,
built_form: Optional[int] = None,
) -> Optional[SapRoomInRoof]:
"""Build `SapRoomInRoof` from the API schema's per-bp RR block. Two
real-API shapes coexist:
@ -5644,10 +5674,12 @@ def _api_build_room_in_roof(
# roof (a ~52 W/K over-count on cert 6035). Gable area = L × the
# §3.9.1 default RR storey height (2.45 m); the type code routes
# the U-value (Exposed → main-wall U, Party → 0.25).
rir.detailed_surfaces = _api_type_1_gable_surfaces(type_1)
rir.detailed_surfaces = _api_type_1_gable_surfaces(
type_1, built_form=built_form
)
type_2 = getattr(bp_rir, "room_in_roof_type_2", None)
if type_2 is not None:
rir.detailed_surfaces = _api_type_2_surfaces(type_2)
rir.detailed_surfaces = _api_type_2_surfaces(type_2, built_form=built_form)
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)
@ -5656,6 +5688,8 @@ def _api_build_room_in_roof(
def _api_type_2_surfaces(
type_2: Any,
*,
built_form: Optional[int] = None,
) -> Optional[List[SapRoomInRoofSurface]]:
"""Translate the §3.9.2 Simplified Type 2 block into the per-surface
list the cascade's Detailed-RR branch consumes — MIRRORING the
@ -5704,7 +5738,7 @@ def _api_type_2_surfaces(
continue
if height <= 0 and not cw_heights:
continue
kind = _api_type_1_gable_kind(gable_type)
kind = _api_type_1_gable_kind(gable_type, built_form=built_form)
length_m, height_m = float(length), float(height)
if cw_heights:
if kind == "connected_wall":

View file

@ -0,0 +1,65 @@
"""Mapper boundary: a room-in-roof gable lodged "Exposed" on a both-ends-party
terraced form must be reclassified to a Party gable (U=0.25), not billed as an
external wall (main-wall U ~2.5).
RdSAP 10 §1.1 (p.7-8): a mid-terrace has external walls on its two *opposite*
(front/rear) faces only; both end/gable elevations and their room-in-roof
continuations abut the neighbours and are party walls (§1.4.3 p.9). Table 4
(p.22) bills a Party RR gable at U=0.25; an Exposed gable "as common wall".
S10TP-05 Appendix A independently tabulates the roof-gable junction length as
zero for a mid-terrace (Detached 15.6 m, Semi 7.2 m, **Mid-terrace 0**), and
three accredited Elmhurst mid-terrace worksheets (000477/000480/000516) lodge
their gables as Party. So a mid/enclosed-mid terrace lodging an Exposed RR
gable is itself the data anomaly.
Surfaced by corpus cert 100040550095 (mid-terrace, built_form 4): its Type-2
RR `gable_wall_type_1 = 1` (Exposed) was billed at the masonry main-wall U
(19.32 × 2.5 = 48.3 W/K), over-counting fabric SAP 29.87 vs lodged 40
(-10.13). Reclassifying it to a Party gable (0.25 × 19.32 = 4.83 W/K) lands
SAP 40 / PE 189, matching lodged. The correction is scoped to the both-ends-
party forms {4 Mid-Terrace, 6 Enclosed Mid-Terrace}; end-terrace / semi /
detached forms keep their genuine Exposed gable.
"""
from datatypes.epc.domain.mapper import (
_api_type_1_gable_kind, # pyright: ignore[reportPrivateUsage]
)
_EXPOSED = 1
_PARTY = 0
class TestApiRirGableTerraced:
def test_mid_terrace_exposed_gable_reclassified_to_party(self) -> None:
# built_form 4 = Mid-Terrace: both gable ends are party.
assert _api_type_1_gable_kind(_EXPOSED, built_form=4) == "gable_wall"
def test_enclosed_mid_terrace_exposed_gable_reclassified_to_party(self) -> None:
# built_form 6 = Enclosed Mid-Terrace: both gable ends party.
assert _api_type_1_gable_kind(_EXPOSED, built_form=6) == "gable_wall"
def test_end_terrace_exposed_gable_stays_external(self) -> None:
# built_form 3 = End-Terrace: the outer gable is genuinely exposed.
assert _api_type_1_gable_kind(_EXPOSED, built_form=3) == "gable_wall_external"
def test_enclosed_end_terrace_exposed_gable_stays_external(self) -> None:
# built_form 5 = Enclosed End-Terrace: one genuine exposed gable.
assert _api_type_1_gable_kind(_EXPOSED, built_form=5) == "gable_wall_external"
def test_semi_detached_exposed_gable_stays_external(self) -> None:
assert _api_type_1_gable_kind(_EXPOSED, built_form=2) == "gable_wall_external"
def test_detached_exposed_gable_stays_external(self) -> None:
assert _api_type_1_gable_kind(_EXPOSED, built_form=1) == "gable_wall_external"
def test_unknown_built_form_exposed_gable_unchanged(self) -> None:
# No built form → conservative: keep the lodged Exposed classification.
assert _api_type_1_gable_kind(_EXPOSED, built_form=None) == "gable_wall_external"
def test_party_gable_unchanged_on_mid_terrace(self) -> None:
# A lodged Party gable stays Party regardless of built form.
assert _api_type_1_gable_kind(_PARTY, built_form=4) == "gable_wall"
def test_default_call_without_built_form_backwards_compatible(self) -> None:
# The pre-existing single-arg contract still resolves Exposed → external.
assert _api_type_1_gable_kind(_EXPOSED) == "gable_wall_external"

View file

@ -306,8 +306,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=+0,
expected_pe_resid_kwh_per_m2=-0.1357,
expected_co2_resid_tonnes_per_yr=-0.0362,
expected_pe_resid_kwh_per_m2=-0.9246,
expected_co2_resid_tonnes_per_yr=-0.0549,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"S0380.189 fixed the dominant driver: walls are solid brick "
@ -367,7 +367,16 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
"PE +1.37 → -0.14, CO2 -0.0004 → -0.0362 (SAP still exact 70). "
"Validated against the simulated-case-6 worksheet, the only "
"worksheet evidence for 'Roof of Room' rooflight deduction "
"(6035's site-notes case-4 replica lodges no rooflights)."
"(6035's site-notes case-4 replica lodges no rooflights). "
"This PR (terraced RR-gable reclassification) re-pins PE/CO2: "
"6035 is built_form 4 (Mid-Terrace), so both RR gables are "
"party (RdSAP 10 §1.4.3) — the mapper now routes its Exposed "
"Type-1 gable to gable_wall (U=0.25) instead of the main-wall "
"U. Heat loss drops → PE -0.14 → -0.92, CO2 -0.0362 → -0.0549 "
"(SAP still exact 70). Residuals stay well within noise (PE "
"<0.5% of lodged 191); corroborated by three accredited "
"Elmhurst mid-terrace worksheets (000477/000480/000516) that "
"all lodge RR gables as Party."
),
),
_GoldenExpectation(

View file

@ -260,7 +260,20 @@ _CORPUS = Path(
# the plant efficiency C4 reserves for CO2/PE is a known follow-up. Spec-
# verified against the SAP 10.2 PDF, not the ticket prose; unit-pinned in
# test_cert_to_inputs (950/951/952 must bill identical DHW fuel).
_MIN_WITHIN_HALF_SAP = 0.788
# 78.8% -> 79.5% (MAE 0.625 -> 0.599, PE 2.85 -> 2.71, CO2 0.070 -> 0.068) via
# the terraced room-in-roof gable fix (RdSAP 10 §1.1 p.7-8 + Table 4 p.22): a
# mid-terrace / enclosed-mid-terrace (built_form 4/6) has external walls on its
# two opposite faces only, so both gable ends are party — yet the gov API lodges
# an "Exposed" RR gable (`gable_wall_type=1`) that the built_form-blind mapper
# billed at the masonry main-wall U (main-wall U ~2.5 vs the party 0.25). The
# `_api_type_1_gable_kind` built_form guard reclassifies an Exposed RR gable to
# Party (0.25) on forms {4,6} only; end-terrace/semi/detached keep their genuine
# exposed gable. Corroborated by S10TP-05 App A (mid-terrace roof-gable junction
# length = 0) and three accredited Elmhurst mid-terrace worksheets (000477/
# 000480/000516 lodge Party gables). Cert 100040550095 -10.13 -> +0.37; the
# 18-cert terraced-external-gable cohort mean -1.94 -> +0.11; end-terrace
# untouched. Unit-pinned in test_mapper_rir_gable_terraced.
_MIN_WITHIN_HALF_SAP = 0.795
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
@ -391,9 +404,9 @@ _MIN_WITHIN_HALF_SAP = 0.788
# efficiency / interlock -5pp — SAP Table 4a immersion = 100%): observed
# within-0.5 78.9%, MAE 0.622. A handful of corpus certs with a gas-boiler
# space main + separate electric immersion moved closer to accredited.
_MAX_SAP_MAE = 0.625
_MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current
_MAX_SAP_MAE = 0.599
_MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: