mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
0fdb15ad65
4 changed files with 135 additions and 14 deletions
|
|
@ -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 m² × 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":
|
||||
|
|
|
|||
65
tests/datatypes/epc/domain/test_mapper_rir_gable_terraced.py
Normal file
65
tests/datatypes/epc/domain/test_mapper_rir_gable_terraced.py
Normal 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 m² × 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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue