mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
S0380.209: API-path wall U — as-built "insulated (assumed)" uses age-band row, not 50mm
The EPC renders a recent-band as-built wall as "<material>, as built, insulated (assumed)". The API mapper populates epc.walls with that string, and heat_transmission's wall_ins_present gate keyed off the "insulated" substring → routed the wall to the RdSAP 50 mm "insulation of unknown thickness" bucket (e.g. sandstone band J U=0.25) instead of the as-built age-band row (U=0.35). Per RdSAP 10 Table 8/9 footnote the 50 mm row applies ONLY when insulation is "known to have been increased subsequently (otherwise 'as built' applies)". An "as built ... (assumed)" description is the EPC's age-band assumption — it only renders on RECENT bands (an old band renders "no insulation (assumed)"), so the as-built row applies. Genuine retrofit is signalled by wall_insulation_type (External/Internal/Filled), which the gate still checks independently. Worksheet-validated by two new Elmhurst worksheets, both As Built band J: - simulated case 9: sandstone → (29a) U 0.35 - simulated case 10: solid brick → (29a) U 0.35 both the as-built row, NOT 50 mm (0.25). Fix: restrict the description-based gate to genuine retrofit via the new local `_described_as_retrofit_insulated` (excludes "as built"/"(assumed)"). The cavity filled-row routing inside `u_wall` (which uses `_described_as_insulated` directly) is untouched — the 3 cavity API certs (0390/0535/7536) are unaffected. test_heat_transmission: the old `..._uses_50mm_row` test asserted 50 mm via an IMPOSSIBLE band-B + "insulated (assumed)" combination; corrected to a valid recent-band (J) scenario asserting the as-built row (35 W/K). Golden 0240: walls 24.45 → 34.23 W/K (U 0.25 → 0.35). SAP integer 72 unchanged; PE residual re-pinned +1.8687 → +5.5044, CO2 +0.0907 → +0.2757. This spec-correct fix REMOVED the wall under-count that was masking the Ext1 vaulted-roof over-count (cascade U 0.68 via the same "insulated (assumed)" description vs case-9 sloping-ceiling 0.25) — that roof over-count is the next slice; fixing both lands SAP cont ≈ 72.31 (= Elmhurst case 9). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
fe59c4d8a2
commit
844fc22f67
4 changed files with 89 additions and 31 deletions
|
|
@ -300,6 +300,36 @@ def _parse_thickness_mm(value: Any) -> Optional[int]:
|
|||
return int(digits) if digits else None
|
||||
|
||||
|
||||
def _described_as_retrofit_insulated(description: Optional[str]) -> bool:
|
||||
"""True only when the description asserts insulation KNOWN to have
|
||||
been added subsequently — i.e. genuine retrofit, not the age-band
|
||||
as-built assumption.
|
||||
|
||||
RdSAP 10 Table 8/9 footnote routes a wall to the 50 mm "insulation
|
||||
of unknown thickness" row ONLY when insulation is "known to have been
|
||||
increased subsequently (otherwise 'as built' applies)". A description
|
||||
rendered as "as built ... insulated (assumed)" is the EPC's age-band
|
||||
assumption — it renders only on recent age bands where as-built
|
||||
construction already includes insulation (an old band renders "no
|
||||
insulation (assumed)"). For those the spec uses the as-built age-band
|
||||
U-value, NOT the 50 mm retrofit row.
|
||||
|
||||
Worksheet evidence: simulated case 9 (sandstone, band J, As Built →
|
||||
U 0.35) and case 10 (solid brick, band J, As Built → U 0.35); both
|
||||
Elmhurst worksheets return the as-built row, not the 50 mm bucket
|
||||
(which gives ~0.25). Genuine retrofit is signalled by
|
||||
`wall_insulation_type` (External/Internal/Filled), checked
|
||||
independently by the `wall_ins_present` gate — so excluding the
|
||||
"as built"/"(assumed)" description here loses no real retrofit signal.
|
||||
"""
|
||||
if description is None:
|
||||
return False
|
||||
if not _described_as_insulated(description):
|
||||
return False
|
||||
desc = description.lower()
|
||||
return "as built" not in desc and "assumed" not in desc
|
||||
|
||||
|
||||
def _joined_descriptions(elements: list[Any]) -> Optional[str]:
|
||||
if not elements:
|
||||
return None
|
||||
|
|
@ -665,14 +695,21 @@ def heat_transmission_from_cert(
|
|||
wall_construction = _int_or_none(part.wall_construction)
|
||||
wall_ins_type = _int_or_none(part.wall_insulation_type)
|
||||
wall_ins_thickness = _parse_thickness_mm(part.wall_insulation_thickness)
|
||||
# Per RdSAP 10 Table 6 footnote, a wall with "insulated (assumed)"
|
||||
# or "partial insulation (assumed)" in its description has retrofit
|
||||
# insulation the assessor hasn't measured the thickness of — even
|
||||
# when wall_insulation_type=4 ("as-built / assumed"). Treat as
|
||||
# present so the 50 mm bucket routes correctly.
|
||||
# RdSAP 10 Table 8/9 footnote: the 50 mm "insulation of unknown
|
||||
# thickness" row applies only when insulation is "known to have
|
||||
# been increased subsequently (otherwise 'as built' applies)".
|
||||
# Genuine retrofit is signalled by `wall_insulation_type`
|
||||
# (External/Internal/Filled ≠ NONE). An "as built ... insulated
|
||||
# (assumed)" description is the EPC age-band assumption (it only
|
||||
# renders on recent bands where as-built already includes
|
||||
# insulation) → use the as-built age-band row, NOT 50 mm.
|
||||
# Worksheet-validated by simulated case 9 (sandstone J → 0.35)
|
||||
# and case 10 (solid brick J → 0.35), both As Built. So the
|
||||
# description signal is restricted to genuine (non-assumed)
|
||||
# retrofit via `_described_as_retrofit_insulated`.
|
||||
wall_ins_present = (
|
||||
(wall_ins_type is not None and wall_ins_type != _WALL_INSULATION_NONE)
|
||||
or _described_as_insulated(wall_description)
|
||||
or _described_as_retrofit_insulated(wall_description)
|
||||
)
|
||||
party_construction = _int_or_none(part.party_wall_construction)
|
||||
raw_roof_thickness = getattr(part, "roof_insulation_thickness", None)
|
||||
|
|
@ -1172,7 +1209,7 @@ def _alt_wall_w_per_k(
|
|||
alt_thickness = _parse_thickness_mm(alt_wall.wall_insulation_thickness)
|
||||
alt_insulation_present = (
|
||||
alt_wall.wall_insulation_type != _WALL_INSULATION_NONE
|
||||
or _described_as_insulated(wall_description)
|
||||
or _described_as_retrofit_insulated(wall_description)
|
||||
)
|
||||
alt_u = u_wall(
|
||||
country=country,
|
||||
|
|
|
|||
|
|
@ -52,14 +52,11 @@ def _described_as_insulated(description: Optional[str]) -> bool:
|
|||
otherwise. Looks for "insulated" or "partial insulation" substrings,
|
||||
with "no insulation" taking precedence as a hard negation.
|
||||
|
||||
Two consumers:
|
||||
- `u_wall` uses this to route cavity walls to the Filled-cavity row
|
||||
of Table 6 (in lieu of the bucketed cascade).
|
||||
- `heat_transmission_from_cert` uses this to set `wall_ins_present`
|
||||
for non-cavity walls so the 50 mm bucket routing fires per the
|
||||
RdSAP 10 Table 6 footnote ("If a wall is known to have additional
|
||||
insulation but the insulation thickness is unknown, use the row
|
||||
in the table for 50 mm insulation").
|
||||
Consumer: `u_wall` uses this to route cavity walls to the Filled-
|
||||
cavity row of Table 6 (in lieu of the bucketed cascade). For the
|
||||
non-cavity `wall_ins_present` gate, `heat_transmission_from_cert`
|
||||
further restricts this to genuine (non-assumed) retrofit via its
|
||||
local `_described_as_retrofit_insulated`.
|
||||
"""
|
||||
if description is None:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -83,8 +83,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0240-0200-5706-2365-8010",
|
||||
actual_sap=73,
|
||||
expected_sap_resid=-1,
|
||||
expected_pe_resid_kwh_per_m2=+1.8687,
|
||||
expected_co2_resid_tonnes_per_yr=+0.0907,
|
||||
expected_pe_resid_kwh_per_m2=+5.5044,
|
||||
expected_co2_resid_tonnes_per_yr=+0.2757,
|
||||
notes=(
|
||||
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
|
||||
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
|
||||
|
|
@ -183,7 +183,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"exact. For 0240 this raises HW fuel slightly → PE +1.6893 → "
|
||||
"+1.8687, CO2 +0.0815 → +0.0907 (SAP 72 unchanged). The lodged "
|
||||
"73 carries Elmhurst's own residual; case 6 is the spec "
|
||||
"authority per [[feedback-worksheet-not-api-reference]]."
|
||||
"authority per [[feedback-worksheet-not-api-reference]]. "
|
||||
"Slice S0380.209 fixed the API-path wall U: the EPC renders "
|
||||
"this cert's sandstone (band J, As Built) wall as 'insulated "
|
||||
"(assumed)', which the cascade wrongly routed to the 50 mm "
|
||||
"retrofit row (U 0.25). Per RdSAP 10 Table 8/9 footnote the "
|
||||
"50 mm row is only for insulation 'known to have been "
|
||||
"increased subsequently'; an 'as built ... (assumed)' "
|
||||
"description is the age-band assumption (renders only on "
|
||||
"recent bands) → as-built row U 0.35. Worksheet-validated by "
|
||||
"simulated case 9 (sandstone J → 0.35) + case 10 (solid brick "
|
||||
"J → 0.35). walls 24.45 → 34.23 W/K → PE +1.8687 → +5.5044, "
|
||||
"CO2 +0.0907 → +0.2757 (SAP 72 unchanged). This spec-correct "
|
||||
"fix REMOVED the wall under-count that was masking the Ext1 "
|
||||
"vaulted-roof over-count (cascade U 0.68 via the same "
|
||||
"'insulated (assumed)' description vs case-9 sloping-ceiling "
|
||||
"0.25) — that roof over-count is the next slice; fixing both "
|
||||
"lands SAP cont ≈ 72.31 (= Elmhurst case 9). The lodged 73 "
|
||||
"requires a 2013+ pump (case 7); 0240's API lodges the pump "
|
||||
"as Unknown (code 0 → 115, proven 0=Unknown across 9 API+"
|
||||
"Summary pairs), so 73 is unreachable from the lodged inputs."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
|
|||
|
|
@ -168,21 +168,26 @@ def test_floor_insulated_assumed_with_ni_thickness_uses_50mm_per_table19_footnot
|
|||
assert result.floor_w_per_k == pytest.approx(31.0, abs=2.0)
|
||||
|
||||
|
||||
def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnote() -> None:
|
||||
# Arrange — 128 corpus certs lodge solid-brick walls with
|
||||
# wall_insulation_type=4 ("as-built / assumed") AND description
|
||||
# "Solid brick, as built, insulated (assumed)". The description
|
||||
# signals retrofit insulation that the assessor hasn't measured the
|
||||
# thickness of; RdSAP 10 Table 6 footnote routes this to the 50 mm
|
||||
# row. Without the description signal, type=4 alone would set
|
||||
# wall_ins_present=False and the cascade would return the as-built
|
||||
# U=1.7. With it, U = 0.55 at band B.
|
||||
def test_solid_brick_as_built_insulated_assumed_uses_as_built_row_per_table9_footnote() -> None:
|
||||
# Arrange — an "as built, insulated (assumed)" description only renders
|
||||
# on RECENT age bands (where as-built construction already includes
|
||||
# insulation per Building Regs); an old band renders "no insulation
|
||||
# (assumed)". RdSAP 10 Table 8/9 footnote routes to the 50 mm row only
|
||||
# when insulation is "known to have been increased subsequently
|
||||
# (otherwise 'as built' applies)" — an age-band assumption is NOT
|
||||
# known retrofit, so the as-built row applies.
|
||||
#
|
||||
# Worksheet-validated: simulated case 9 (sandstone, band J, As Built
|
||||
# → U 0.35) and case 10 (solid brick, band J, As Built → U 0.35) both
|
||||
# return the as-built row, NOT the 50 mm bucket (which would give
|
||||
# U=0.25). This was previously asserted at 55 W/K via an IMPOSSIBLE
|
||||
# band-B + "insulated (assumed)" combination.
|
||||
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single
|
||||
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.55 × 100
|
||||
# = 55 W/K.
|
||||
# storey → gross_wall = 100 m². walls_w_per_k expected = 0.35 × 100
|
||||
# = 35 W/K.
|
||||
main = make_building_part(
|
||||
identifier="Main Dwelling",
|
||||
construction_age_band="B",
|
||||
construction_age_band="J",
|
||||
wall_construction=3,
|
||||
wall_insulation_type=4,
|
||||
party_wall_construction=1,
|
||||
|
|
@ -211,7 +216,7 @@ def test_solid_brick_as_built_insulated_assumed_uses_50mm_row_per_table6_footnot
|
|||
result = heat_transmission_from_cert(epc)
|
||||
|
||||
# Assert
|
||||
assert result.walls_w_per_k == pytest.approx(55.0, abs=1.0)
|
||||
assert result.walls_w_per_k == pytest.approx(35.0, abs=1.0)
|
||||
|
||||
|
||||
def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row() -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue