mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice 96: flat-roof U-value defaults — RdSAP 10 §5.11 Table 18 col (3)
Cert 0330 (mid-terrace boiler, Summary_000897.pdf) Summary path was at
Δ +0.4667 SAP vs worksheet 61.5993 because Ext1's flat roof fell through
`_ROOF_BY_AGE` (Table 18 column (1), pitched-roof "between joists"
defaults) to 0.40 W/m²K for age D — the spec value is 2.30 W/m²K from
column (3) "Flat roof" (RdSAP 10 spec page 45).
RdSAP 10 §5.11 Table 18 column (3) verbatim:
Age A,B,C,D → 2.30; E → 1.50; F → 0.68; G → 0.40; H,I → 0.35;
J,K → 0.25; L → 0.18; M → 0.15.
Footnote (a): "If the roof insulation is 'none' use U = 2.3 (all roof
types, except for thatched roofs)" — confirms the col-3 entries for
old ages are the uninsulated row, applied because cert 0330's Ext1
lodges "Flat" construction with no measured insulation thickness.
Changes:
- `_FLAT_ROOF_BY_AGE` added in rdsap_uvalues.py
- `u_roof` gains `is_flat_roof: bool = False` parameter
- `heat_transmission_from_cert` detects flat roofs from
`part.roof_construction_type` ("flat" substring) and routes through
the new column.
Effect on baseline:
- cert 0330 Summary chain test: RED Δ+0.4667 → GREEN at 1e-4 (worksheet
total fabric heat loss 237.7549 W/K matches cascade to 4 d.p.)
- cert 001479 Layer 4 chain test: unchanged (Main pitched, no flat
components)
- cohort certs 000477/000516: unchanged (no flat roofs)
- golden cert 0300-2747-7640-2526-2135: SAP residual +1 → 0 (improved),
Ext1 is genuinely flat; pe/co2 residuals re-pinned. The dwelling has
the same Main-pitched + Ext1-flat shape as cert 0330; same fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
17646c8ae9
commit
da5e7196c4
5 changed files with 111 additions and 6 deletions
|
|
@ -56,6 +56,7 @@ _SUMMARY_000487_PDF = _FIXTURES / "Summary_000487.pdf"
|
|||
_SUMMARY_000490_PDF = _FIXTURES / "Summary_000490.pdf"
|
||||
_SUMMARY_000516_PDF = _FIXTURES / "Summary_000516.pdf"
|
||||
_SUMMARY_001479_PDF = _FIXTURES / "Summary_001479.pdf"
|
||||
_SUMMARY_000897_PDF = _FIXTURES / "Summary_000897.pdf"
|
||||
|
||||
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
|
||||
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
|
||||
|
|
@ -325,6 +326,32 @@ def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
|||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_summary_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 0330-2249-8150-2326-4121 (Summary_000897.pdf /
|
||||
# dr87-0001-000897.pdf) is the second boiler cert under per-cert
|
||||
# mapper validation: mains-gas boiler (PCDB idx 10241), mid-terrace
|
||||
# 2-bp dwelling, TFA 69.14 m². Worksheet PDF "SAP value" line lodges
|
||||
# unrounded SAP **61.5993**. Same load-bearing role as cert 001479
|
||||
# (the first boiler) — Summary path proves itself against the
|
||||
# worksheet, then becomes the canonical reference for the API path.
|
||||
# Expected RED at Δ +0.4667 at handover-baseline (Summary mapper
|
||||
# cascade SAP 62.0660); mapper gaps to close are §11 glazing_type=14
|
||||
# (windows HLC +6.71 W/K) and the §4 hot-water cascade (kWh +1060).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000897_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 — 1e-4 pin, no widening, no xfail (project memory
|
||||
# `feedback_zero_error_strict`).
|
||||
worksheet_unrounded_sap = 61.5993
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 001479 has both an Elmhurst Summary PDF and a GOV.UK
|
||||
# EPB API JSON (ref 0535-9020-6509-0821-6222). The Summary cascade
|
||||
|
|
|
|||
|
|
@ -98,9 +98,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
_GoldenExpectation(
|
||||
cert_number="0300-2747-7640-2526-2135",
|
||||
actual_sap=78,
|
||||
expected_sap_resid=+1,
|
||||
expected_pe_resid_kwh_per_m2=+1.0093,
|
||||
expected_co2_resid_tonnes_per_yr=-0.8321,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=+7.7553,
|
||||
expected_co2_resid_tonnes_per_yr=-0.2526,
|
||||
notes=(
|
||||
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
|
||||
"(no Table 4b code). Cert lodges open_flues_count=1 + "
|
||||
|
|
@ -108,7 +108,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"fuel 26). Slice 58 cascade routed secondary fuel cost through "
|
||||
"the lodged fuel_type (rather than hardcoding the electric "
|
||||
"tariff), tightening this cert's SAP residual −7 → +2 — the "
|
||||
"biggest single SAP improvement on the golden cohort to date."
|
||||
"biggest single SAP improvement on the golden cohort to date. "
|
||||
"Slice 96 (RdSAP 10 §5.11 Table 18 column (3) flat-roof "
|
||||
"defaults) lifted Ext1's flat-roof U from the pitched-column-1 "
|
||||
"0.40 fall-through to the spec-correct 2.30 (age D), "
|
||||
"tightening SAP residual +1 → 0."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
|
|||
|
|
@ -496,7 +496,15 @@ def heat_transmission_from_cert(
|
|||
effective_roof_description = (
|
||||
None if roof_thickness == 0 else roof_description
|
||||
)
|
||||
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description)
|
||||
# RdSAP 10 §5.11 Table 18 page 45: column (3) "Flat roof" applies
|
||||
# when the per-bp roof construction lodges as a flat roof and the
|
||||
# cert doesn't supply an insulation thickness. The pitched-roof
|
||||
# column (1) age-band fallback (0.40 for ages A-G) catastrophically
|
||||
# under-states a flat roof's heat loss for old age bands where the
|
||||
# spec value is 2.30 (A-D) / 1.50 (E) / 0.68 (F) / 0.40 (G).
|
||||
roof_type_lower = (part.roof_construction_type or "").lower()
|
||||
is_flat_roof = "flat" in roof_type_lower
|
||||
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof)
|
||||
# Floor U-value routing (in priority order):
|
||||
# 1. Basement floor — Table 23 F-column override (whole floor=0).
|
||||
# 2. Exposed/semi-exposed upper floor — Table 20 lookup; no
|
||||
|
|
|
|||
|
|
@ -390,6 +390,21 @@ _ROOF_BY_AGE: Final[dict[str, float]] = {
|
|||
"K": 0.16, "L": 0.16, "M": 0.15,
|
||||
}
|
||||
|
||||
# Table 18 column (3): flat-roof default U by age band when thickness unknown.
|
||||
# RdSAP 10 §5.11 Table 18 page 45 — the pitched-roof column (1) defaults
|
||||
# bottom out at 0.40 because "between joists insulation" is the implicit
|
||||
# Table-16 reference; the flat-roof column (3) drops directly to the
|
||||
# Table 16 row-0 / "no insulation" value (2.30) for old age bands and
|
||||
# follows Table 16's thickness ladder for modern ones. A flat roof
|
||||
# without a measured insulation thickness lodgement therefore cannot
|
||||
# share the pitched-roof age-band fallback — for an age D dwelling the
|
||||
# spec value is 2.30, not 0.40 (5.75x understatement of heat loss).
|
||||
_FLAT_ROOF_BY_AGE: Final[dict[str, float]] = {
|
||||
"A": 2.30, "B": 2.30, "C": 2.30, "D": 2.30, "E": 1.50,
|
||||
"F": 0.68, "G": 0.40, "H": 0.35, "I": 0.35, "J": 0.25,
|
||||
"K": 0.25, "L": 0.18, "M": 0.15,
|
||||
}
|
||||
|
||||
# Table 18 column (4): "Room-in-roof, all elements" default U by age band
|
||||
# when no detailed RR lodgement is available. Footnote (1) on each entry
|
||||
# confirms "value from the table applies for unknown and as built".
|
||||
|
|
@ -418,6 +433,7 @@ def u_roof(
|
|||
age_band: Optional[str],
|
||||
insulation_thickness_mm: Optional[int],
|
||||
description: Optional[str] = None,
|
||||
is_flat_roof: bool = False,
|
||||
) -> float:
|
||||
"""RdSAP10 roof U-value in W/m^2K, never null.
|
||||
|
||||
|
|
@ -428,7 +444,9 @@ def u_roof(
|
|||
Table 18 age-band defaults assume joist insulation ≥100 mm, which is
|
||||
wrong for catastrophic heritage roofs the EPC explicitly describes
|
||||
as uninsulated.
|
||||
3. Table 18 age-band default.
|
||||
3. Table 18 age-band default — column (1) "Pitched, insulation between
|
||||
joists" by default; column (3) "Flat roof" when `is_flat_roof=True`.
|
||||
Spec §5.11 Table 18 page 45.
|
||||
"""
|
||||
measured = _measured_u_from_description(description)
|
||||
if measured is not None:
|
||||
|
|
@ -459,6 +477,8 @@ def u_roof(
|
|||
return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row)
|
||||
if age_band is None:
|
||||
return 0.4
|
||||
if is_flat_roof:
|
||||
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
|
||||
return _ROOF_BY_AGE.get(age_band.upper(), 0.4)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -565,6 +565,52 @@ def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
|
|||
assert result == pytest.approx(0.4, abs=0.001)
|
||||
|
||||
|
||||
def test_u_roof_flat_age_band_d_returns_table18_col3_value() -> None:
|
||||
# Arrange — RdSAP 10 §5.11 Table 18 page 45 column (3) "Flat roof":
|
||||
# age band D, thickness unknown → U = 2.30 W/m²K. Column (1)
|
||||
# (pitched-between-joists default) returns 0.40 for the same age
|
||||
# band; routing must pick column (3) when the per-bp roof
|
||||
# construction lodges as flat.
|
||||
|
||||
# Act
|
||||
result = u_roof(
|
||||
country=Country.ENG, age_band="D", insulation_thickness_mm=None,
|
||||
is_flat_roof=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(result - 2.30) <= 1e-4
|
||||
|
||||
|
||||
def test_u_roof_flat_age_band_g_returns_table18_col3_value() -> None:
|
||||
# Arrange — Table 18 column (3) flat-roof default is 0.40 for age G,
|
||||
# the cross-over point where the flat-roof and pitched-roof columns
|
||||
# agree. Confirms the dict is populated across the full age range.
|
||||
|
||||
# Act
|
||||
result = u_roof(
|
||||
country=Country.ENG, age_band="G", insulation_thickness_mm=None,
|
||||
is_flat_roof=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(result - 0.40) <= 1e-4
|
||||
|
||||
|
||||
def test_u_roof_flat_age_band_l_returns_table18_col3_value() -> None:
|
||||
# Arrange — Table 18 column (3) flat-roof default is 0.18 for age L,
|
||||
# the modern band where both columns agree.
|
||||
|
||||
# Act
|
||||
result = u_roof(
|
||||
country=Country.ENG, age_band="L", insulation_thickness_mm=None,
|
||||
is_flat_roof=True,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(result - 0.18) <= 1e-4
|
||||
|
||||
|
||||
def test_u_roof_description_no_insulation_overrides_age_band_default() -> None:
|
||||
# Arrange — surveyor description on a Victorian roof says uninsulated;
|
||||
# Table 18 age-B default (0.40) is far too optimistic. Table 16 row 0mm
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue