mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice S0380.26: RdSAP10 §5.8 dry-lining adjustment on alt walls — closes cert 7700 -0.44 → +5e-5
Per RdSAP10 §5.8 final note + Table 14 page 41:
"For drylining including laths and plaster use Rinsulation = 0.17 m²K/W."
Applied additively to the base U-value of an otherwise-uninsulated wall:
U_adjusted = 1 / (1/U_base + 0.17) — rounded to 2 d.p. half-up.
Closed form for the cohort fixture (cavity-as-built age C, U_base=1.5):
1 / (1/1.5 + 0.17) = 1.19522... → 1.20 ✓ matches worksheet
Cert 7700-3362-0922-7022-3563 (Summary_000905.pdf / dr87-0001-000905.pdf)
is an End-Terrace house age C lodging:
- Main wall: CavityWallDensePlasterDenseBlock, Filled Cavity, U=0.70
- Alt wall 1: 14.44 m² Cavity As-Built, Dry-lining: Yes (worksheet
`CavityWallPlasterOnDabsDenseBlock`, U=1.20)
Pre-slice the Elmhurst alt-wall mapper hard-coded `wall_dry_lined="N"`
and the cascade ignored the field everywhere — alt-wall U routed to the
cavity-as-built default (1.50), giving fabric (33) 148.72 W/K vs
worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet "SAP value" line
lodges unrounded SAP 63.4425.
Implementation:
1. `AlternativeWall.dry_lined: bool = False` on the Elmhurst surveys
dataclass.
2. Elmhurst extractor reads "Alternative Wall N Dry-lining: Yes/No"
into the new field.
3. `_map_elmhurst_alternative_wall` propagates `wall_dry_lined="Y"`
instead of the hard-coded "N".
4. `u_wall` gains a `dry_lined: bool = False` kwarg and a single
§5.8 adjustment site at the as-built bucket (bucket=0). Insulated
buckets already absorb the dry-lining R via Table 14.
5. `_alt_wall_w_per_k` passes `dry_lined=alt_wall.wall_dry_lined == "Y"`.
Scope is the alt-wall path only — main BPs in the corpus all lodge
`wall_dry_lined="N"` (or the Summary PDF omits the field for the main
wall), so the main-wall call site is untouched. Conservative regression
posture per the user's strict cohort-pin convention.
Cohort-2 outcome (38 certs, Summary path):
exact (<1e-4): 22 → **23** (+1: cert 7700 -0.44 → +4.87e-05)
0.07..0.5: 1 → **0** (-1: cert 7700 closes out)
0.5..1: 1 → 1 (cert 9796 unchanged — MIT precision floor)
RAISES: 0 → 0
Cohort-1 ASHP cohort untouched: all certs lodge wall_dry_lined="N", so
the alt-wall call site short-circuits to the original cascade. Verified
no regressions across the 22 previously-exact cohort-2 certs either.
Pyright net-zero on all 8 touched files (183 → 183).
Tests: 704 → 708 pass (+4 new: u_wall §5.8 adjustment fires
correctly; cavity-as-built unchanged without flag; insulated bucket
unaffected by flag; heat_transmission alt-wall delta = 14.44 × 0.30
W/K; cert 7700 full chain hits worksheet 63.4425 at < 1e-4),
10 expected fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
73fedc0ecd
commit
c144d444e2
8 changed files with 209 additions and 8 deletions
|
|
@ -294,6 +294,13 @@ class ElmhurstSiteNotesExtractor:
|
|||
u_value_known=self._local_bool(
|
||||
lines, f"Alternative Wall {n} U-value Known"
|
||||
),
|
||||
# RdSAP10 §5.8 + Table 14: dry-lined uninsulated wall adds
|
||||
# R = 0.17 m²K/W to base U. Cohort fixture: cert 7700
|
||||
# Alt 1 "CavityWallPlasterOnDabs" lodges Dry-lining: Yes →
|
||||
# U = 1/(1/1.5 + 0.17) ≈ 1.20.
|
||||
dry_lined=self._local_bool(
|
||||
lines, f"Alternative Wall {n} Dry-lining"
|
||||
),
|
||||
))
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -340,6 +340,38 @@ def test_summary_2102_secondary_heating_routes_house_coal_for_open_fire() -> Non
|
|||
assert epc.sap_heating.secondary_fuel_type == 11
|
||||
|
||||
|
||||
def test_summary_7700_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cohort-2 cert 7700-3362-0922-7022-3563 (Summary_000905.pdf
|
||||
# / dr87-0001-000905.pdf) is the first cohort fixture to exercise
|
||||
# the alt-wall dry-lining adjustment. End-Terrace house age C, main
|
||||
# wall filled cavity (CavityWallDensePlasterDenseBlock, U=0.70),
|
||||
# alt wall 14.44 m² Cavity As-Built, Dry-lining: Yes
|
||||
# (CavityWallPlasterOnDabsDenseBlock, worksheet U=1.20).
|
||||
#
|
||||
# Per RdSAP10 §5.8 + Table 14 page 41: dry-lining adds R = 0.17
|
||||
# m²K/W → U = 1/(1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20.
|
||||
# Pre-slice the alt sub-area's `wall_dry_lined="N"` hard-code routed
|
||||
# to the cavity-as-built default (U=1.50), giving fabric (33)
|
||||
# 148.72 W/K vs worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet
|
||||
# "SAP value" line lodges unrounded SAP **63.4425**.
|
||||
cert_dir = Path(
|
||||
"sap worksheets/additional with api 2/7700-3362-0922-7022-3563"
|
||||
)
|
||||
summary_pdf = next(cert_dir.glob("Summary_*.pdf"))
|
||||
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
# Assert
|
||||
worksheet_unrounded_sap = 63.4425
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_summary_9501_flat_has_no_built_form_in_summary_pdf() -> None:
|
||||
# Arrange — cert 9501 (Summary_000784.pdf) is a flat. The Elmhurst
|
||||
# Summary's §1.0 "Property type" section lodges the built-form
|
||||
|
|
|
|||
|
|
@ -2875,7 +2875,7 @@ def _map_elmhurst_alternative_wall(
|
|||
case, matching the full-cert-text "TimberWallOneLayer" lodgement)."""
|
||||
return SapAlternativeWall(
|
||||
wall_area=a.area_m2,
|
||||
wall_dry_lined="N",
|
||||
wall_dry_lined="Y" if a.dry_lined else "N",
|
||||
wall_construction=_elmhurst_wall_construction_int(a.wall_type) or 0,
|
||||
wall_insulation_type=_elmhurst_wall_insulation_int(a.insulation) or 4,
|
||||
wall_thickness_measured="Y" if not a.thickness_unknown else "N",
|
||||
|
|
|
|||
|
|
@ -57,7 +57,12 @@ class AlternativeWall:
|
|||
gross wall that has a different construction (e.g. a small 1.43 m²
|
||||
timber-frame panel on an otherwise cavity-walled extension). Up to
|
||||
two alternative walls per bp; Elmhurst lodges them in §7's "1st/2nd
|
||||
Extension" subsection under the "Alternative Wall N <field>" prefix."""
|
||||
Extension" subsection under the "Alternative Wall N <field>" prefix.
|
||||
|
||||
`dry_lined` carries Summary §7 "Alternative Wall N Dry-lining: Yes/No".
|
||||
RdSAP10 §5.8 + Table 14: a dry-lined uninsulated wall adds R = 0.17
|
||||
m²K/W to the base U-value (cavity-as-built age C: U = 1/(1/1.5 + 0.17)
|
||||
≈ 1.20). Cohort fixture: cert 7700 alt-wall (CavityWallPlasterOnDabs)."""
|
||||
|
||||
area_m2: float
|
||||
wall_type: str # e.g. "TI Timber Frame"
|
||||
|
|
@ -65,6 +70,7 @@ class AlternativeWall:
|
|||
thickness_unknown: bool
|
||||
thickness_mm: Optional[int]
|
||||
u_value_known: bool
|
||||
dry_lined: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -921,5 +921,9 @@ def _alt_wall_w_per_k(
|
|||
insulation_present=alt_insulation_present,
|
||||
description=wall_description,
|
||||
wall_insulation_type=alt_wall.wall_insulation_type,
|
||||
# RdSAP10 §5.8 + Table 14: dry-lined alt-wall adds R = 0.17 m²K/W
|
||||
# when no insulation thickness is lodged. Cohort fixture: cert
|
||||
# 7700 Alt 1 (Cavity, As-Built, Dry-lined) → 1.50 → 1.20.
|
||||
dry_lined=alt_wall.wall_dry_lined == "Y",
|
||||
)
|
||||
return alt_u * net_alt_area
|
||||
|
|
|
|||
|
|
@ -1119,6 +1119,65 @@ def test_basement_alt_wall_uses_table_23_u_value_not_cascade() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_dry_lined_alt_wall_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None:
|
||||
"""RdSAP10 §5.8 + Table 14 page 41: dry-lined uninsulated alt-wall
|
||||
adds R = 0.17 m²K/W. Cohort fixture: cert 7700-3362-0922-7022-3563
|
||||
Alt 1 lodges 14.44 m² Cavity, As-Built, Dry-lining: Yes, age C —
|
||||
worksheet `CavityWallPlasterOnDabsDenseBlock` row (29a) U=1.20,
|
||||
A×U = 14.44 × 1.20 = 17.3280 W/K. Without dry-lining the cascade
|
||||
would route to the cavity-as-built default (U=1.50, A×U=21.66).
|
||||
Difference: 4.33 W/K → ~+0.44 SAP — the entire cert 7700 residual."""
|
||||
from dataclasses import replace
|
||||
# Arrange — age C single-bp dwelling, main wall filled-cavity (U=0.70,
|
||||
# so the difference we isolate sits entirely on the alt sub-area).
|
||||
main_age_c = make_building_part(
|
||||
identifier=BuildingPartIdentifier.MAIN,
|
||||
construction_age_band="C",
|
||||
wall_construction=4,
|
||||
wall_insulation_type=2, # filled cavity → main wall U=0.70
|
||||
party_wall_construction=1, roof_construction=4,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=80.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=35.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
with_dry_lined_alt = replace(
|
||||
main_age_c,
|
||||
sap_alternative_wall_1=SapAlternativeWall(
|
||||
wall_area=14.44, wall_dry_lined="Y",
|
||||
wall_construction=4, wall_insulation_type=4,
|
||||
wall_thickness_measured="N",
|
||||
),
|
||||
)
|
||||
without_dry_lined_alt = replace(
|
||||
main_age_c,
|
||||
sap_alternative_wall_1=SapAlternativeWall(
|
||||
wall_area=14.44, wall_dry_lined="N",
|
||||
wall_construction=4, wall_insulation_type=4,
|
||||
wall_thickness_measured="N",
|
||||
),
|
||||
)
|
||||
|
||||
# Act
|
||||
epc_dry_lined = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=80.0, country_code="ENG",
|
||||
sap_building_parts=[with_dry_lined_alt],
|
||||
)
|
||||
epc_plain = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=80.0, country_code="ENG",
|
||||
sap_building_parts=[without_dry_lined_alt],
|
||||
)
|
||||
result_dry_lined = heat_transmission_from_cert(epc_dry_lined)
|
||||
result_plain = heat_transmission_from_cert(epc_plain)
|
||||
|
||||
# Assert — the alt-wall A×U delta is exactly 14.44 × (1.50 - 1.20)
|
||||
# = 4.3320 W/K. Closed form: 1/(1/1.5 + 0.17) = 1.19522... → 2 d.p. = 1.20.
|
||||
delta = result_plain.walls_w_per_k - result_dry_lined.walls_w_per_k
|
||||
assert abs(delta - (14.44 * (1.50 - 1.20))) <= 1e-9
|
||||
|
||||
|
||||
def test_basement_floor_uses_table_23_u_value_for_whole_floor_when_basement_detected() -> None:
|
||||
"""User-confirmed convention: when a part has a basement, the WHOLE
|
||||
floor=0 is the basement floor. Table 23 F-column overrides the
|
||||
|
|
|
|||
|
|
@ -137,6 +137,12 @@ WALL_INSULATION_CAVITY_PLUS_INTERNAL: Final[int] = 7
|
|||
# (cavity + external/internal insulation).
|
||||
_WALL_INSULATION_LAMBDA_W_PER_MK: Final[float] = 0.04
|
||||
|
||||
# RdSAP10 §5.8 final note + Table 14 page 41: "For drylining including
|
||||
# laths and plaster use Rinsulation = 0.17 m²K/W." Applied additively to
|
||||
# the base U-value of an otherwise-uninsulated wall when the cert lodges
|
||||
# `wall_dry_lined = "Y"` — see `u_wall(dry_lined=True)`.
|
||||
_DRY_LINING_RESISTANCE_M2K_PER_W: Final[float] = 0.17
|
||||
|
||||
|
||||
_AGE_BANDS: Final[tuple[str, ...]] = tuple("ABCDEFGHIJKLM")
|
||||
|
||||
|
|
@ -329,6 +335,7 @@ def u_wall(
|
|||
insulation_present: bool = False,
|
||||
description: Optional[str] = None,
|
||||
wall_insulation_type: Optional[int] = None,
|
||||
dry_lined: bool = False,
|
||||
) -> float:
|
||||
"""RdSAP10 wall U-value in W/m^2K, never null.
|
||||
|
||||
|
|
@ -344,6 +351,16 @@ def u_wall(
|
|||
dedicated "Filled cavity" row is used in preference to the
|
||||
thickness-bucketed cascade — the two encode different things (filled-
|
||||
cavity is a construction state, not an added-insulation thickness).
|
||||
|
||||
`dry_lined` triggers the RdSAP10 §5.8 + Table 14 adjustment:
|
||||
U_adjusted = 1 / (1/U_base + R_dryline) with R_dryline = 0.17 m²K/W.
|
||||
The adjustment is applied only when the base U comes from the
|
||||
uninsulated bucket (no measured insulation thickness, no filled-cavity
|
||||
branch, no surveyor "described as insulated" override) — for those
|
||||
branches the dry-lining R is already absorbed into the assumed
|
||||
insulation stack. Cohort fixture: cert 7700 Alt 1 cavity-as-built
|
||||
age C with Dry-lining: Yes — base U=1.5 → adjusted U=1.20 (2 d.p.,
|
||||
matching worksheet `CavityWallPlasterOnDabsDenseBlock`).
|
||||
"""
|
||||
measured = _measured_u_from_description(description)
|
||||
if measured is not None:
|
||||
|
|
@ -394,12 +411,21 @@ def u_wall(
|
|||
# Country override first.
|
||||
overrides = _COUNTRY_KLM_OVERRIDES.get(ctry, {}).get((wall_type, bucket), {})
|
||||
if band in overrides:
|
||||
return overrides[band]
|
||||
|
||||
base = _ENG_WALL.get((wall_type, bucket))
|
||||
if base is None:
|
||||
return 1.5
|
||||
return base[age_idx]
|
||||
u_base = overrides[band]
|
||||
else:
|
||||
base = _ENG_WALL.get((wall_type, bucket))
|
||||
u_base = base[age_idx] if base is not None else 1.5
|
||||
# RdSAP10 §5.8 + Table 14 page 41 — dry-lining (including lath and
|
||||
# plaster) adds R = 0.17 m²K/W to an otherwise-uninsulated wall:
|
||||
# U_adjusted = 1 / (1/U_base + 0.17), rounded to 2 d.p. half-up.
|
||||
# Only the as-built uninsulated bucket triggers the adjustment;
|
||||
# insulated buckets already incorporate the dry-lining R via Table 14.
|
||||
if dry_lined and bucket == 0:
|
||||
u_unrounded = 1.0 / (1.0 / u_base + _DRY_LINING_RESISTANCE_M2K_PER_W)
|
||||
return float(
|
||||
Decimal(str(u_unrounded)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
)
|
||||
return u_base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -278,6 +278,73 @@ def test_u_wall_unfilled_cavity_england_age_band_e_unchanged_at_1_5() -> None:
|
|||
assert result == pytest.approx(1.5, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_dry_lined_cavity_as_built_age_c_applies_rdsap_5_8_r_0_17_adjustment() -> None:
|
||||
# Arrange — RdSAP10 §5.8 final note + Table 14 page 41: "For drylining
|
||||
# including laths and plaster use Rinsulation = 0.17 m²K/W." Applied
|
||||
# additively to the base U-value of an otherwise-uninsulated wall.
|
||||
# Cohort fixture: cert 7700-3362-0922-7022-3563 Alt 1 lodges Cavity,
|
||||
# As-Built, Dry-lining: Yes, age band C → worksheet
|
||||
# `CavityWallPlasterOnDabsDenseBlock` U-value = 1.20 W/m²K.
|
||||
# Closed form: 1 / (1/1.5 + 0.17) = 1.19522... → 2 d.p. half-up = 1.20.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="C",
|
||||
construction=WALL_CAVITY,
|
||||
insulation_thickness_mm=None,
|
||||
insulation_present=False,
|
||||
wall_insulation_type=4,
|
||||
dry_lined=True,
|
||||
)
|
||||
|
||||
# Assert — adjusted U is rounded to 2 d.p. matching the dr87 worksheet's
|
||||
# `UValueFinal` column for this construction.
|
||||
assert abs(result - 1.20) <= 1e-9
|
||||
|
||||
|
||||
def test_u_wall_not_dry_lined_cavity_as_built_age_c_returns_unadjusted_1_5() -> None:
|
||||
# Arrange — same age + construction as the dry-lined case above but
|
||||
# without the dry-lining flag. Cascade must return the bare Table 6
|
||||
# "Cavity as built" row value (no R = 0.17 added).
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="C",
|
||||
construction=WALL_CAVITY,
|
||||
insulation_thickness_mm=None,
|
||||
insulation_present=False,
|
||||
wall_insulation_type=4,
|
||||
dry_lined=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(result - 1.50) <= 1e-9
|
||||
|
||||
|
||||
def test_u_wall_dry_lined_with_measured_insulation_thickness_no_adjustment() -> None:
|
||||
# Arrange — once a measured insulation thickness is lodged, Table 6's
|
||||
# insulated buckets already incorporate the dry-lining R via Table 14.
|
||||
# Applying R = 0.17 on top would double-count. Cavity + 100 mm
|
||||
# insulation, age band E → Table 6 cavity-100mm row = 0.32 W/m²K
|
||||
# regardless of the dry-lining flag.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="E",
|
||||
construction=WALL_CAVITY,
|
||||
insulation_thickness_mm=100,
|
||||
insulation_present=True,
|
||||
wall_insulation_type=4,
|
||||
dry_lined=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(result - 0.32) <= 1e-9
|
||||
|
||||
|
||||
def test_u_wall_cavity_as_built_england_age_band_g_returns_table6_value() -> None:
|
||||
# Arrange — Table 6, England, Cavity as built, age band G -> 0.60 W/m^2K.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue