Merge pull request #1252 from Hestia-Homes/feature/per-cert-mapper-validation

Feature/per cert mapper validation
This commit is contained in:
Jun-te Kim 2026-06-22 09:38:27 +01:00 committed by GitHub
commit eeec3972fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 608 additions and 63 deletions

View file

@ -6942,6 +6942,12 @@ _ELMHURST_GLAZING_LABEL_TO_SAP10: Dict[str, int] = {
"Single glazing": 1,
"Double pre 2002": 2,
"Double between 2002 and 2021": 3,
# Year-truncated form of "Double between 2002 and 2021": the trailing
# "and 2021" wraps to an adjacent PDF table cell that the extractor
# joins away (same artifact as "Triple post or during" below). Same
# SAP 10.2 code 3 (DG 2002-2021) — surfaced on the simulated-case-46
# multi-attribute worksheet.
"Double between 2002": 3,
"Double with unknown install date": 3,
"Double with unknown 16 mm or install date more": 3,
# Elmhurst §11 lodgement of RdSAP-21 schema row 7 "double, known

View file

@ -617,7 +617,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
)
ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa)
sap_int = sap_rating_integer(ecf=ecf)
sap_cont = sap_rating(ecf=ecf)
# SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1 ("if the
# result of the calculation is less than 1, the rating is 1"). Apply the
# same floor to the continuous value so it stays a valid rating — the
# un-rounded part is for sensitivity NEAR real ratings, not for emitting
# a physically impossible negative SAP on a degenerate dwelling (e.g. a
# cert lodged at the floor of 1). Mirrors `sap_rating_integer`'s max(1,…).
sap_cont = max(1.0, sap_rating(ecf=ecf))
co2_factor = inputs.co2_factor_kg_per_kwh
# Per-end-use effective CO2 factors (Table 12d monthly cascade for
# electricity, annual for gas). cert_to_inputs supplies these from

View file

@ -2631,6 +2631,7 @@ def _secondary_fraction(
main: Optional[MainHeatingDetail],
secondary_heating_type: object,
secondary_lodged: bool = False,
unheated_habitable_rooms: bool = False,
) -> float:
"""SAP 10.2 Table 11 lookup by main heating category, applied only
when (a) the cert has a secondary system lodged OR (b) the main
@ -2672,7 +2673,12 @@ def _secondary_fraction(
code = main.sap_main_heating_code
has_lodged_secondary = secondary_heating_type is not None or secondary_lodged
force = code is not None and code in _FORCE_SECONDARY_FOR_MAIN_CODES
if not has_lodged_secondary and not force:
# SAP 10.2 Appendix A.2.2 — when the main system does not heat every
# habitable room, the unheated rooms are assumed to be served by a
# portable-electric secondary heater, so the Table 11 fraction is costed
# even with no lodged secondary (the secondary fuel/efficiency cascade
# already defaults to portable electric, code 693, when no code lodged).
if not has_lodged_secondary and not force and not unheated_habitable_rooms:
return 0.0
if (
code is not None
@ -2682,6 +2688,26 @@ def _secondary_fraction(
return _secondary_heating_fraction_for_category(main.main_heating_category)
def _has_unheated_habitable_rooms(epc: EpcPropertyData) -> bool:
"""SAP 10.2 Appendix A.2.2 — the main heating system does not heat every
habitable room (heated rooms < habitable rooms), so the unheated rooms
take an assumed portable-electric secondary heater.
Prefers the lodged `any_unheated_rooms` flag (set on both the gov-API and
Elmhurst paths). Falls back to the heated/habitable room-count comparison
only when the heated count is a real positive value a lodged
`heated_rooms_count == 0` is the "not provided" sentinel on the gov-API
path, not literally zero heated rooms, so it must not spuriously trigger
the assumed secondary."""
if epc.any_unheated_rooms is not None:
return epc.any_unheated_rooms
return (
epc.heated_rooms_count > 0
and epc.habitable_rooms_count > 0
and epc.heated_rooms_count < epc.habitable_rooms_count
)
def _has_lodged_secondary_description(epc: EpcPropertyData) -> bool:
"""True when the cert lodges a secondary-heating DESCRIPTION (the
gov-API path surfaces the secondary as `secondary_heating.description`,
@ -4735,6 +4761,7 @@ def energy_requirements_section_from_cert(
main,
epc.sap_heating.secondary_heating_type if epc.sap_heating else None,
secondary_lodged=_has_lodged_secondary_description(epc),
unheated_habitable_rooms=_has_unheated_habitable_rooms(epc),
)
# When no secondary system is lodged the worksheet displays (208) = 0;
# the per-system fuel formula already collapses to 0 via fraction_201 = 0
@ -4960,16 +4987,23 @@ def ventilation_from_cert(
storeys = max(1, dim.storey_count)
vc = _ventilation_counts(epc.sap_ventilation)
sv = epc.sap_ventilation
# RdSAP 10 §4.1 Table 5 (PDF p.28) — extract-fans default when the
# lodged count is below the age-band minimum. The Elmhurst Summary
# renders "0" as the form for unknown; the worksheet applies the
# default via `max(lodged, table_5_default)`.
# RdSAP 10 §4.1 Table 5 (PDF p.28) — extract fans: "Number of extract
# fans if known; if number is unknown: [age-band default]." The default
# is an UNKNOWN-fallback, NOT a floor: a genuinely-lodged count is used
# as-is even when it is below the age-band default (e.g. a band H-M
# dwelling lodging 2 fans is NOT bumped to the 3-fan default). The
# Elmhurst Summary / RdSAP convention renders "0" as the form for
# unknown, so a lodged 0 falls back to the default; any positive count
# is taken literally. (Was `max(lodged, default)`, which over-applied
# the default as a minimum and over-counted ventilation.)
age_band = _dwelling_age_band(epc) or ""
is_park_home = (epc.property_type or "").strip().lower() == "park home"
table_5_fan_default = _rdsap_extract_fans_default(
age_band, epc.habitable_rooms_count, is_park_home=is_park_home,
)
intermittent_fans = max(vc.intermittent_fans, table_5_fan_default)
intermittent_fans = (
vc.intermittent_fans if vc.intermittent_fans > 0 else table_5_fan_default
)
wind_kwargs: dict[str, tuple[float, ...]] = (
{"monthly_wind_speed_m_s": postcode_climate.monthly_wind_speed_m_per_s}
if postcode_climate is not None else {}
@ -7272,6 +7306,7 @@ def cert_to_inputs(
main,
epc.sap_heating.secondary_heating_type,
secondary_lodged=_has_lodged_secondary_description(epc),
unheated_habitable_rooms=_has_unheated_habitable_rooms(epc),
)
# SAP10.2 §4 — compute the worksheet (45..65) values now (they only
# depend on the cert dwelling shape, not on water_efficiency). The

View file

@ -171,7 +171,12 @@ def _synthesised_window_u_raw(windows: Optional[Sequence[SapWindow]]) -> float:
if isinstance(code, int)
else ("double", None)
)
return u_window(installed_year=year, glazing_type=glaze, frame_type=w.frame_material)
return u_window(
installed_year=year,
glazing_type=glaze,
frame_type=w.frame_material,
glazing_gap=w.glazing_gap,
)
# RdSAP10 §15 "Rounding of data" (p.66): "All element areas (gross)
# including window areas and conservatory wall area: 2 d.p." plus
# "U-values: 2 d.p.". This is the data-passed-to-SAP-calculator

View file

@ -226,6 +226,26 @@ def _u_stone_thin_wall_age_a_to_e(
return None
def _table_3_stone_thickness(band: str, country: Country) -> int:
"""RdSAP 10 §3.5 Table 3 (PDF p.20) — default stone wall thickness (mm)
used "only when the wall thickness could not be measured".
Stone row: A-D = 500, E = 450, F-H = 420, I+ = 450.
Scotland footnote (*): add 200 mm for bands A and B, 100 mm for other
bands. Only A-E reach this helper (the §5.6 formula gate), so the F+
branches are defensive.
"""
if band in ("A", "B", "C", "D"):
base = 500
elif band == "E":
base = 450
else:
base = 420
if country == Country.SCT:
base += 200 if band in ("A", "B") else 100
return base
def _u_brick_thin_wall_age_a_to_e(wall_thickness_mm: int) -> float:
"""RdSAP 10 §5.7 Table 13 (PDF p.41) — default U-value for an
uninsulated solid brick wall by lodged thickness, age bands A-E.
@ -419,14 +439,16 @@ _CAVITY_FILLED_ENG: Final[list[float]] = [
# entries that differ from the England base.
_COUNTRY_KLM_OVERRIDES: Final[dict[Country, dict[tuple[int, int], dict[str, float]]]] = {
Country.SCT: {
# Scotland Cavity-as-built K-M: 0.25, 0.22, 0.17 (vs ENG 0.30, 0.28, 0.26).
(WALL_CAVITY, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_STONE_GRANITE, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_STONE_SANDSTONE, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_SOLID_BRICK, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_TIMBER_FRAME, 0): {"K": 0.25, "L": 0.22, "M": 0.17},
(WALL_SYSTEM_BUILT, 0): {"H": 0.45, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_COB, 0): {"K": 0.25, "L": 0.22, "M": 0.17},
# Scotland (Table 7, PDF p.35) as-built bands that diverge from the
# England base: H 0.60→0.45 (not timber/cob, which are already 0.40/
# 0.60), J 0.35→0.30 (ALL types), K 0.30→0.25, L 0.28→0.22, M 0.26→0.17.
(WALL_CAVITY, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_STONE_GRANITE, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_STONE_SANDSTONE, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_SOLID_BRICK, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_TIMBER_FRAME, 0): {"J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_SYSTEM_BUILT, 0): {"H": 0.45, "J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
(WALL_COB, 0): {"J": 0.30, "K": 0.25, "L": 0.22, "M": 0.17},
},
Country.NIR: {
(WALL_CAVITY, 0): {"M": 0.18},
@ -567,28 +589,53 @@ def u_wall(
ctry = country if country is not None else Country.ENG
age_idx = _age_index(age_band)
band = _AGE_BANDS[age_idx]
# RdSAP 10 §5.6 (PDF p.40) — uninsulated stone wall thin-wall
# formula, age bands A-E. Fires only when a documentary wall
# thickness is lodged (per §5.3 documentary-evidence rule).
# §5.8 + Table 14 dry-line adjustment applies on top.
# RdSAP 10 Tables 6-10 stone rows + footnote (a) (PDF p.33-39), §5.6
# formula (PDF p.40), §5.8 + Table 14 (PDF p.41-42). A documentary wall
# thickness (per §5.3) routes stone in age bands A-E off the §5.6 formula,
# NOT the flat Table-6 typical-thickness default:
# - Bands A-D: pure §5.6 formula, UNCAPPED. The stone rows read
# "According to 5.6" with NO 1.7 entry, because a thin/standard solid
# stone wall genuinely loses more than the typical default (sandstone
# 400 mm → 1.90, granite 120 mm → 3.89).
# - Band E: the stone row reads "1.7 a"; footnote (a) = "Or from
# equations in 5.6 if the calculated U-value is less than 1.7" →
# U_E = min(formula, 1.7). Scotland sandstone/limestone age E defaults
# to 1.5 (Table 7), granite/whinstone stays 1.7. The 1.7 (1.5) cap
# belongs ONLY at age E, never A-D.
# The insulation STATE is NOT a gate: an "as built / insulation Unknown"
# lodgement (`wall_insulation_type` None or 4) takes the formula too. Cert
# 000565 Ext1 (granite 50 mm, age E, insulation Unknown) → min(6.09, 1.7)
# = 1.70, matching the U985 worksheet WITHOUT a flat-table detour — the
# age-E cap, not an insulation gate, is what produces the 1.70.
#
# Table 6 footnote (a) (PDF p.34): "Or from equations in 5.6 if
# the calculated U-value is less than 1.7." The cap applies only
# to the AS-BUILT (no insulation, no dry-line) Table 6 row — for
# thin walls where §5.6 gives U ≥ 1.7 (e.g. granite at W=50 mm
# yields 6.09 → use Table 6 default 1.7 instead). When the wall
# is dry-lined or insulated, the raw §5.6 result feeds the §5.8
# chain as the input U₀ — the Table 6 footnote doesn't cap that
# path (verified empirically against cert 000565 Main alt_wall_1:
# granite W=120 mm dry-lined → U₀=3.88 raw + dry-line → 2.34
# matches worksheet, NOT 1.7 + dry-line → 1.32).
# When no documentary thickness is lodged, §3.5 Table 3 (PDF p.20) gives
# the default thickness to feed the formula (stone A-D = 500 mm, E = 450,
# Scotland +200/+100) — NOT a flat 1.7. This matches Elmhurst: an age-B
# granite as-built wall with unknown thickness defaults to 500 mm →
# 45.315 × 500^(-0.513) = 1.87 (sandstone → 1.68).
if (
wall_thickness_mm is not None
and band in _STONE_AGE_A_TO_E
band in _STONE_AGE_A_TO_E
and construction in (WALL_STONE_GRANITE, WALL_STONE_SANDSTONE)
):
u0 = _u_stone_thin_wall_age_a_to_e(construction, wall_thickness_mm)
w = (
wall_thickness_mm
if wall_thickness_mm is not None
else _table_3_stone_thickness(band, ctry)
)
u0 = _u_stone_thin_wall_age_a_to_e(construction, w)
if u0 is not None:
# Footnote (a) cap is age-E only: clamp the as-built U to the
# Table-6/7 age-E default (1.7, or 1.5 for Scotland sandstone/
# limestone) when the §5.6 formula exceeds it. A-D stay uncapped.
if band == "E":
e_default = (
1.5
if ctry == Country.SCT
and construction == WALL_STONE_SANDSTONE
else 1.7
)
if u0 >= e_default:
u0 = e_default
# RdSAP 10 §5.8 + Table 14 (PDF p.41-42) — added External/Internal
# insulation on a stone wall: U = 1/(1/U₀ + R_ins), with U₀ the
# RAW §5.6 stone result (the Table-6 footnote (a) 1.7 cap does NOT
@ -628,8 +675,12 @@ def u_wall(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
)
if u0 >= 1.7:
return 1.7 # Table-6 row cap per footnote (a)
# As-built (uninsulated, not dry-lined) stone wall, age A-E:
# return the §5.6 result — uncapped for A-D, age-E-capped above.
# A thin solid stone wall genuinely has U > 1.7 (sandstone 400 mm
# = 1.90, granite 120 mm = 3.89); capping A-D to 1.7 under-counts
# fabric loss and over-rates. Confirmed against Elmhurst (age-B
# sandstone 400 mm → 1.90) and the §5.6 Table-12 formula tests.
return u0
known_types = {
WALL_STONE_GRANITE, WALL_STONE_SANDSTONE, WALL_SOLID_BRICK, WALL_CAVITY,
@ -788,20 +839,21 @@ _ROOF_RAFTERS_BY_THICKNESS: Final[list[tuple[int, float]]] = [
(400, 0.14),
]
# Table 18 rafters column: pitched-roof "insulation at rafters" default U
# by age band when the thickness cannot be determined. RdSAP 10 §5.11
# Table 18 (PDF p.45). Identical to the joist column (1) for bands A-G
# Table 18 column (2) "Pitched, insulation at rafters": pitched-roof default
# U by age band when the thickness cannot be determined. RdSAP 10 §5.11
# Table 18 (PDF p.46). Identical to the joist column (1) for bands A-G
# (2.30 → 0.40), then diverges higher (H 0.35 vs 0.30, I 0.35 vs 0.26,
# J/K 0.20 vs 0.16, L 0.18 vs 0.16). Unlike the loft-joist default this
# does NOT collapse to the optimistic 0.40 "assume modern retrofit" floor
# at old bands — a rafter cavity cannot be topped up from the loft, so an
# unknown-thickness rafter roof keeps the as-built age-band U (band F
# 0.68, band E 1.50, A-D 2.30). Worksheet-validated by simulated case 41
# Ext3 (band F, R Rafters, As Built → P960 §3 (30) U=0.68).
# J/K 0.20 vs 0.16, L 0.18 vs 0.16) before converging to 0.15 at band M.
# Unlike the loft-joist default this does NOT collapse to the optimistic
# 0.40 "assume modern retrofit" floor at old bands — a rafter cavity cannot
# be topped up from the loft, so an unknown-thickness rafter roof keeps the
# as-built age-band U (band F 0.68, band E 1.50, A-D 2.30). Worksheet-
# validated by simulated case 41 Ext3 (band F, R Rafters, As Built → P960
# §3 (30) U=0.68).
_ROOF_RAFTERS_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.20,
"K": 0.20, "L": 0.18, "M": 0.18,
"K": 0.20, "L": 0.18, "M": 0.15,
}
# Table 18 column (3): flat-roof default U by age band when thickness unknown.
@ -1349,12 +1401,51 @@ def u_exposed_floor(
# ---------------------------------------------------------------------------
# RdSAP 10 Table 24 (PDF p.50-51) — pre-2002 (Scotland pre-2003 / NI pre-2006)
# double and triple glazing split by glazing gap between panes: 6 mm, 12 mm,
# and 16 mm or more, each with a PVC/wooden and a metal-frame U-value. The
# 2002+ and 2022+ rows are gap-independent ("any" gap). (pvc, metal) per gap:
_PRE_2002_DOUBLE_U_BY_GAP: Final[dict[str, tuple[float, float]]] = {
"6": (3.1, 3.7), "12": (2.8, 3.4), "16+": (2.7, 3.3),
}
_PRE_2002_TRIPLE_U_BY_GAP: Final[dict[str, tuple[float, float]]] = {
"6": (2.4, 2.9), "12": (2.1, 2.6), "16+": (2.0, 2.5),
}
def _glazing_gap_row(glazing_gap: "str | int | None") -> str:
"""Map a lodged glazing gap to its Table 24 row key ("6" / "12" / "16+").
The cert lodges discrete gaps as the int 6 or 12 or the string "16+"
(RdSAP-Schema `glazing_gap`). Unknown gap (None) defaults to the 12 mm
row the spec's typical pre-2002 sealed unit. Robust to intermediate
integers: <=8 6 mm, >=15 16 mm-or-more, else 12 mm."""
if glazing_gap is None:
return "12"
if isinstance(glazing_gap, str):
s = glazing_gap.strip().lower()
if "16" in s or "+" in s:
return "16+"
try:
g = int(float(s))
except ValueError:
return "12"
else:
g = int(glazing_gap)
if g <= 8:
return "6"
if g >= 15:
return "16+"
return "12"
def u_window(
installed_year: Optional[int],
glazing_type: Optional[str],
frame_type: Optional[str],
glazing_gap: "str | int | None" = None,
) -> float:
"""RdSAP10 window U-value in W/m^2K, never null."""
"""RdSAP10 window U-value in W/m^2K, never null (RdSAP 10 Table 24)."""
if glazing_type is None and installed_year is None and frame_type is None:
return 2.5
glaze = (glazing_type or "double").lower()
@ -1367,13 +1458,15 @@ def u_window(
# double/triple glazing — period bands.
if installed_year is not None and installed_year >= 2022:
return 1.4
# Table 24 "2022 or later" row: PVC/wood 1.4, metal 1.6.
return 1.6 if metal else 1.4
if installed_year is not None and installed_year >= 2002:
return 2.2 if metal else 2.0
# pre-2002 double/triple default to 12mm gap row.
if glaze == "triple":
return 2.6 if metal else 2.1
return 3.4 if metal else 2.8
# pre-2002 double/triple — Table 24 splits by glazing gap (6/12/16+ mm).
gap_row = _glazing_gap_row(glazing_gap)
table = _PRE_2002_TRIPLE_U_BY_GAP if glaze == "triple" else _PRE_2002_DOUBLE_U_BY_GAP
pvc_u, metal_u = table[gap_row]
return metal_u if metal else pvc_u
# ---------------------------------------------------------------------------

View file

@ -421,6 +421,42 @@ def test_u_wall_scotland_age_band_m_returns_country_specific_table7_value() -> N
assert result == pytest.approx(0.17, abs=0.001)
def test_u_wall_scotland_age_band_j_returns_0_30_not_england_0_35_per_table7() -> None:
# Arrange — RdSAP 10 Table 7 (Scotland, PDF p.35) as-built band J is
# 0.30 for every uninsulated wall type, vs the England base 0.35
# (Table 6). The _COUNTRY_KLM_OVERRIDES[SCT] dicts previously listed
# H/K/L/M but omitted J, so a Scotland band-J cavity wrongly fell
# through to England's 0.35.
# Act
result = u_wall(
country=Country.SCT,
age_band="J",
construction=WALL_CAVITY,
insulation_thickness_mm=0,
)
# Assert
assert result == pytest.approx(0.30, abs=0.001)
def test_u_wall_scotland_age_band_j_timber_frame_returns_0_30_per_table7() -> None:
# Arrange — the J=0.30 override applies to all 7 as-built wall types,
# including timber frame (England base J = 0.35). Guards the type whose
# Scotland override has no H entry (timber H already 0.40 in England).
# Act
result = u_wall(
country=Country.SCT,
age_band="J",
construction=WALL_TIMBER_FRAME,
insulation_thickness_mm=0,
)
# Assert
assert result == pytest.approx(0.30, abs=0.001)
def test_u_wall_timber_frame_as_built_age_band_a_returns_table6_value() -> None:
# Arrange — Timber frame as built, age A, England -> 2.5 W/m^2K.
@ -826,13 +862,14 @@ def test_u_wall_stone_granite_age_g_with_wall_thickness_ignores_5_6_formula_per_
assert abs(result - 0.60) <= 1e-3
def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a_default() -> None:
# Arrange — §5.6 formula only fires when a wall thickness is
# lodged. Without documentary wall-thickness evidence, fall back
# to the Table 6 row (which represents typical thickness). For
# age A stone granite without thickness, the cascade preserves
# its existing "as-built typical" U value rather than the formula
# extrapolation.
def test_u_wall_stone_granite_age_a_without_wall_thickness_uses_table_3_default_500mm() -> None:
# Arrange — when no documentary wall thickness is lodged, RdSAP 10 §3.5
# Table 3 (PDF p.20) supplies the default stone thickness (A-D = 500 mm),
# which feeds the §5.6 formula — NOT a flat 1.7. This matches Elmhurst:
# an as-built granite/whinstone wall with unknown thickness defaults to
# 500 mm → U = 45.315 × 500^(-0.513) = 1.8693. (The earlier 1.7
# expectation was a setup error: Table 6 reads "According to 5.6" for
# bands A-D, with no 1.7 entry.)
# Act
result = u_wall(
@ -845,8 +882,138 @@ def test_u_wall_stone_granite_age_a_without_wall_thickness_returns_table_6_age_a
wall_thickness_mm=None,
)
# Assert — _TYPICAL_STONE_UNINSULATED at age A = 1.7 (cohort default).
assert abs(result - 1.7) <= 1e-3
# Assert — §5.6 formula at the Table-3 default 500 mm.
assert abs(result - 1.8693) <= 1e-3
def test_u_wall_stone_sandstone_age_b_without_wall_thickness_uses_table_3_default_500mm() -> None:
# Arrange — sandstone/limestone variant of the Table-3 default: age B,
# unknown thickness → 500 mm → 54.876 × 500^(-0.561) = 1.6798. This is
# the "500 mm → sandstone 1.68" Elmhurst default.
# Act
result = u_wall(
country=Country.ENG,
age_band="B",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
wall_thickness_mm=None,
)
# Assert
assert abs(result - 1.6798) <= 1e-3
def test_u_wall_stone_sandstone_scotland_age_a_without_thickness_adds_200mm_per_table_3() -> None:
# Arrange — Table 3 Scotland footnote (*): add 200 mm for bands A and B.
# Age-A Scotland sandstone unknown thickness → 500 + 200 = 700 mm →
# 54.876 × 700^(-0.561) = 1.3909 (< 1.7, no age-E cap at band A).
# Act
result = u_wall(
country=Country.SCT,
age_band="A",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=4,
wall_thickness_mm=None,
)
# Assert
assert abs(result - 1.3909) <= 1e-3
def test_u_wall_stone_granite_age_e_50mm_caps_at_table6_default_1_7() -> None:
# Arrange — RdSAP 10 Table 6 (England, PDF p.33-34) stone row reads
# "1.7 a" at age E, footnote (a) = "Or from equations in 5.6 if the
# calculated U-value is less than 1.7". A 50 mm granite wall's §5.6
# formula gives U = 45.315 × 50^(-0.513) = 6.09 (> 1.7), so the age-E
# default 1.7 stands → min(6.09, 1.7) = 1.70. The cap is age-E ONLY:
# bands A-D are uncapped (120 mm age-A granite = 3.89). Insulation is
# Unknown (wall_insulation_type None) — no longer a gate. Cert 000565
# BP Ext1 is this fixture (U985 worksheet U = 1.70).
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=None,
dry_lined=False,
wall_thickness_mm=50,
)
# Assert
assert abs(result - 1.70) <= 1e-3
def test_u_wall_stone_sandstone_age_e_thick_wall_uses_5_6_formula_below_1_7() -> None:
# Arrange — footnote (a) at age E: use the §5.6 formula when it gives
# < 1.7. A 600 mm sandstone wall → U = 54.876 × 600^(-0.561) = 1.5165
# (< 1.7), so the formula value is used, NOT the 1.7 default.
# Act
result = u_wall(
country=Country.ENG,
age_band="E",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=None,
dry_lined=False,
wall_thickness_mm=600,
)
# Assert
assert abs(result - 1.5165) <= 1e-3
def test_u_wall_stone_sandstone_scotland_age_e_caps_at_1_5_not_1_7() -> None:
# Arrange — RdSAP 10 Table 7 (Scotland, PDF p.35) sandstone/limestone
# age E default is "1.5 a" (granite/whinstone stays 1.7). A 500 mm
# sandstone wall's §5.6 formula = 54.876 × 500^(-0.561) = 1.68 (> 1.5),
# so the Scotland age-E default 1.5 stands → min(1.68, 1.5) = 1.50.
# Act
result = u_wall(
country=Country.SCT,
age_band="E",
construction=WALL_STONE_SANDSTONE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=None,
dry_lined=False,
wall_thickness_mm=500,
)
# Assert
assert abs(result - 1.50) <= 1e-3
def test_u_wall_stone_granite_scotland_age_e_50mm_stays_capped_at_1_7() -> None:
# Arrange — Scotland granite/whinstone age E default is 1.7 (only
# sandstone/limestone drops to 1.5, Table 7 PDF p.35). A 50 mm granite
# wall's formula 6.09 (> 1.7) → min(6.09, 1.7) = 1.70, NOT 1.5.
# Act
result = u_wall(
country=Country.SCT,
age_band="E",
construction=WALL_STONE_GRANITE,
insulation_thickness_mm=None,
insulation_present=False,
wall_insulation_type=None,
dry_lined=False,
wall_thickness_mm=50,
)
# Assert
assert abs(result - 1.70) <= 1e-3
def test_u_wall_curtain_wall_missing_age_lodgement_defaults_to_pre_2023_u_2p0_per_rdsap_5_18() -> None:
@ -1098,6 +1265,25 @@ def test_u_roof_at_rafters_unknown_thickness_uses_table18_rafters_age_band() ->
assert abs(band_c - 2.30) <= 0.001
def test_u_roof_at_rafters_unknown_thickness_age_m_returns_0_15_per_table18() -> None:
# Arrange — RdSAP 10 Table 18 column (2) "Pitched, insulation at
# rafters" (PDF p.46): band M = 0.15 (footnote (1) only, no country
# variation — the whole M row converges to 0.15). The rafters column
# diverges above the joist column at H-L (0.35/0.35/0.20/0.20/0.18)
# but rejoins it at M = 0.15; the table previously carried 0.18 here.
# Act
band_m = u_roof(
country=Country.ENG,
age_band="M",
insulation_thickness_mm=None,
insulation_at_rafters=True,
)
# Assert
assert abs(band_m - 0.15) <= 0.001
def test_u_roof_unknown_age_band_falls_back_to_mid_range() -> None:
# Arrange — nothing known.
@ -1562,6 +1748,42 @@ def test_u_window_post_2022_pvc_returns_low_table24_value() -> None:
assert result == pytest.approx(1.4, abs=0.001)
def test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4() -> None:
# Arrange — Table 24 "2022 or later" row (PDF p.51): PVC/wooden frame
# 1.4, METAL frame 1.6. The metal frame variant was previously ignored
# (1.4 returned for both), under-counting metal-frame heat loss.
# Act
result = u_window(installed_year=2023, glazing_type="double", frame_type="metal")
# Assert
assert result == pytest.approx(1.6, abs=0.001)
def test_u_window_pre_2002_double_glazing_gap_selects_table24_row() -> None:
# Arrange — RdSAP 10 Table 24 (PDF p.50) pre-2002 double glazing splits
# by glazing gap (PVC/wooden frame): 6 mm → 3.1, 12 mm → 2.8, 16 mm or
# more → 2.7. The cert lodges the gap as the int 6/12 or the string
# "16+"; unknown gap defaults to the 12 mm row.
# Act / Assert
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=6) == pytest.approx(3.1, abs=0.001)
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=12) == pytest.approx(2.8, abs=0.001)
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.7, abs=0.001)
assert u_window(installed_year=None, glazing_type="double", frame_type="pvc", glazing_gap=None) == pytest.approx(2.8, abs=0.001)
def test_u_window_pre_2002_triple_glazing_gap_and_metal_frame_select_table24_row() -> None:
# Arrange — Table 24 pre-2002 triple glazing: 6 mm → 2.4, 12 mm → 2.1,
# 16 mm+ → 2.0 (PVC); metal frame adds +0.5 per the metal column
# (6 → 2.9, 12 → 2.6, 16+ → 2.5).
# Act / Assert
assert u_window(installed_year=None, glazing_type="triple", frame_type="pvc", glazing_gap="16+") == pytest.approx(2.0, abs=0.001)
assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap=6) == pytest.approx(2.9, abs=0.001)
assert u_window(installed_year=None, glazing_type="triple", frame_type="metal", glazing_gap="16+") == pytest.approx(2.5, abs=0.001)
def test_u_window_falls_back_to_mid_range_when_unknown() -> None:
# Arrange — nothing known.

View file

@ -0,0 +1,26 @@
"""Mapper boundary: the Elmhurst §11 "Double between 2002" glazing label.
The full RdSAP-Schema-21 label is "Double between 2002 and 2021" (SAP 10.2
Table 24 code 3 double glazing installed 2002-2021). When the Elmhurst
Summary PDF wraps the trailing "and 2021" into an adjacent table cell the
extractor joins away, the surfaced label truncates to "Double between 2002"
(the same artifact already handled for "Triple post or during"). Before this
was mapped the truncated form raised `UnmappedElmhurstLabel`, blocking the
whole Summary (surfaced on the simulated-case-46 multi-attribute worksheet).
"""
from datatypes.epc.domain.mapper import (
_elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage]
)
def test_truncated_double_between_2002_maps_to_code_3() -> None:
# Arrange — the year-truncated form of "Double between 2002 and 2021".
# Act
code = _elmhurst_glazing_type_code("Double between 2002")
full = _elmhurst_glazing_type_code("Double between 2002 and 2021")
# Assert — both resolve to SAP 10.2 Table 24 code 3 (DG 2002-2021).
assert code == 3
assert full == 3

View file

@ -1104,6 +1104,59 @@ def test_secondary_fraction_fires_when_secondary_lodged_via_description_only() -
assert abs(description_lodged - 0.10) <= 1e-9
def test_secondary_fraction_fires_for_unheated_habitable_rooms_per_appendix_a22() -> None:
# Arrange — SAP 10.2 Appendix A.2.2: when the main system does not heat
# every habitable room (heated rooms < habitable rooms), the unheated
# rooms take an assumed portable-electric secondary heater, so the Table
# 11 0.10 fraction is costed EVEN WITH no lodged secondary. A gas boiler
# main (cat 2, not forced-secondary) with no secondary lodged returns 0.0
# normally, but 0.10 once `unheated_habitable_rooms=True`. Worksheet-
# validated on simulated case 46 (heated 4 < habitable 7): the assumed
# secondary lifted our SAP from 39 to 29 (Elmhurst 30).
from domain.sap10_calculator.rdsap.cert_to_inputs import _secondary_fraction # pyright: ignore[reportPrivateUsage]
main = _gas_boiler_detail() # cat 2, code 102 — not forced-secondary
# Act
all_rooms_heated = _secondary_fraction(main, None, unheated_habitable_rooms=False)
has_unheated = _secondary_fraction(main, None, unheated_habitable_rooms=True)
# Assert
assert all_rooms_heated == 0.0
assert abs(has_unheated - 0.10) <= 1e-9
def test_has_unheated_habitable_rooms_prefers_flag_and_guards_zero_sentinel() -> None:
# Arrange — `_has_unheated_habitable_rooms` prefers the lodged
# `any_unheated_rooms` flag; its room-count fallback must NOT trigger on a
# `heated_rooms_count == 0` "not provided" sentinel (gov-API), only on a
# real positive heated count below the habitable count.
import dataclasses
from domain.sap10_calculator.rdsap.cert_to_inputs import ( # pyright: ignore[reportPrivateUsage]
_has_unheated_habitable_rooms,
)
base = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
)
flag_true = dataclasses.replace(base, any_unheated_rooms=True)
flag_false = dataclasses.replace(base, any_unheated_rooms=False)
count_unheated = dataclasses.replace(
base, any_unheated_rooms=None, heated_rooms_count=4, habitable_rooms_count=7
)
zero_sentinel = dataclasses.replace(
base, any_unheated_rooms=None, heated_rooms_count=0, habitable_rooms_count=5
)
# Act / Assert
assert _has_unheated_habitable_rooms(flag_true) is True
assert _has_unheated_habitable_rooms(flag_false) is False
assert _has_unheated_habitable_rooms(count_unheated) is True
assert _has_unheated_habitable_rooms(zero_sentinel) is False
def test_main_heating_fraction_missing_falls_back_to_table11_default() -> None:
# Arrange — when main_heating_fraction isn't lodged AND the cert
# has a secondary system lodged, Table 11's 0.10 default still
@ -1556,6 +1609,40 @@ def test_ventilation_from_cert_applies_table_5_default_when_lodged_zero() -> Non
)
def test_ventilation_from_cert_uses_lodged_fans_below_age_default_not_floored() -> None:
# Arrange — RdSAP 10 §4.1 Table 5 (PDF p.28): "Number of extract fans if
# known; if unknown: [age-band default]." The default is an UNKNOWN-
# fallback, NOT a floor — a genuinely-lodged positive count is used
# as-is even when below the age default. An age-H 6-habitable-room
# dwelling has a 3-fan default, but a cert lodging 2 fans must use 2,
# not be floored up to 3. (Was `max(lodged, default)` → 3, over-counting
# ventilation; surfaced on simulated case 46 where it inflated (8) by
# one fan = 0.055 ACH and pushed SAP 30 → 29.)
age_h_part = make_building_part(
floor_dimensions=[
make_floor_dimension(total_floor_area_m2=45.0, floor=0),
make_floor_dimension(total_floor_area_m2=45.0, floor=1),
],
construction_age_band='H',
)
epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=6, # age H-M, 6-8 rooms → Table 5 default 3
region_code="1",
sap_building_parts=[age_h_part],
sap_ventilation=SapVentilation(extract_fans_count=2), # lodged 2 < 3
)
# Act
v = ventilation_from_cert(epc)
# Assert — (8) openings ACH uses the lodged 2 fans (20 m³/h), not 3.
from domain.sap10_calculator.rdsap.cert_to_inputs import dimensions_from_cert
vol = dimensions_from_cert(epc).volume_m3
assert abs(v.openings_ach - 20.0 / vol) <= 1e-6
assert abs(v.openings_ach - 30.0 / vol) > 1e-6
def test_ventilation_from_cert_passes_lodged_ap4_to_pressure_test_ach_per_sap_10_2_section_2_line_18() -> None:
# Arrange — SAP 10.2 §2 line (17a)/(18) "Air permeability value, AP4
# (m³/h/m²)": when a Pulse pressure test is lodged the cascade must

View file

@ -270,6 +270,28 @@ def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() ->
)
def test_sap_score_continuous_floored_at_1_for_degenerate_high_cost() -> None:
# Arrange — SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1
# ("if the result of the calculation is less than 1, the rating is 1").
# Drive the cost so high that the raw ECF formula returns a negative SAP
# (a degenerate dwelling, e.g. a cert lodged at the floor of 1); both the
# integer AND the continuous score must clamp to 1 rather than emit a
# physically impossible negative rating.
inputs = replace(
_baseline_inputs(),
space_heating_fuel_cost_gbp_per_kwh=5.0,
hot_water_fuel_cost_gbp_per_kwh=5.0,
other_fuel_cost_gbp_per_kwh=5.0,
)
# Act
result = calculate_sap_from_inputs(inputs)
# Assert — raw SAP would be < 1 here; the floor holds on both outputs.
assert result.sap_score == 1
assert abs(result.sap_score_continuous - 1.0) <= 1e-9
def test_calculate_exposes_dimensions_intermediates() -> None:
# Arrange — P5 trace mode: `result.intermediate` must surface the
# worksheet-named dimensions variables for per-section diffing

View file

@ -150,10 +150,53 @@ _CORPUS = Path(
# MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). A prior session deferred enum 9
# ("direction not understood") while the PE/CO2 lens was confounded by the
# climate-cascade bug (fc7c4d2d); the corrected lens shows the over-rate.
_MIN_WITHIN_HALF_SAP = 0.70
_MAX_SAP_MAE = 0.85
# UNINSULATED STONE WALL §5.6 FORMULA (RdSAP 10 §5.6 Table 12, PDF p.40): a
# stone wall of KNOWN thickness whose insulation STATE is known (As Built /
# external / internal) is billed by the §5.6 formula on its lodged thickness
# (sandstone/limestone U = 54.876·W^-0.561, granite/whinstone 45.315·W^-0.513),
# NOT capped at the Table-6 typical-thickness 1.7. The old `if u0>=1.7: 1.7`
# cap nullified the formula for every real-thickness stone wall (it only dips
# below 1.7 past ~488 mm sandstone / ~640 mm granite) and under-counted fabric
# loss → over-rate. Gated on `wall_insulation_type is not None` so an
# "insulation Unknown" wall still falls to the Table-6 default (cert 000565
# Ext1: granite 50 mm + Unknown → worksheet 1.70, not the formula's 6.09).
# Took within-0.5 70.3% -> 71.6% (MAE 0.833 -> 0.822); fixed the 2 stone-U
# unit tests; worksheet-validated (Elmhurst age-B sandstone 400 mm → 1.90).
#
# STONE MECHANISM CORRECTED (RdSAP 10 Tables 6-7 footnote a + §3.5 Table 3):
# the commit above (034d4b7c) got the right numbers for two cases but the
# wrong mechanism — it dropped the 1.7 cap for ALL age bands and gated on
# `wall_insulation_type is not None`. Per Tables 6-10: bands A-D = uncapped
# §5.6 formula, band E = min(formula, 1.7) (Scotland sandstone 1.5); the cap
# is age-E ONLY. The insulation-state gate is not a spec rule (it sent
# age-A-D "insulation Unknown" stone to the flat 1.7 table). Unknown
# thickness now feeds the §3.5 Table-3 default thickness (stone A-D 500 mm,
# E 450; Scotland +200/+100) into the formula — Elmhurst defaults an England
# age-B granite as-built unknown-thickness wall to 500 mm → 1.87 (sandstone
# 1.68), NOT a flat 1.7. Also added the missing Scotland band-J 0.30 override
# (Table 7) for all 7 as-built wall types. MAE 0.822 -> 0.819, PE 3.7 -> 3.6;
# within-0.5 and CO2 unchanged. Unit-pinned in test_rdsap_uvalues.
#
# SAP RATING FLOOR (SAP 10.2 §13 / RdSAP 10 §13): the rating is floored at 1
# ("if the result is less than 1, the rating is 1"). `calculate_sap_from_inputs`
# now applies that floor to the CONTINUOUS score too (was integer-only), so a
# degenerate dwelling no longer emits a negative SAP. Removed a -12.3 outlier
# (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5
# 70.2% -> 70.3%, MAE 0.845 -> 0.833.
# EXTRACT-FAN DEFAULT IS UNKNOWN-FALLBACK, NOT A FLOOR (RdSAP 10 §4.1 Table 5,
# PDF p.28). Table 5 reads "Number of extract fans if known; if unknown:
# [age-band default]". The cascade applied `max(lodged, age_default)`, flooring
# a genuinely-lodged count up to the age-band minimum (e.g. an age H-M dwelling
# lodging 2 fans billed at the 3-fan default), over-counting ventilation line
# (8) and the HLC. Fixed to `lodged if lodged > 0 else default` (a lodged 0 is
# the Elmhurst/RdSAP "unknown" form → default; any positive count is literal).
# within-0.5 71.6% -> 72.5%, MAE 0.819 -> 0.815. Surfaced by Khalim's Elmhurst
# stress worksheet (simulated case 46): closed its last ventilation residual
# (our Jan ACH 9.14 -> 9.0748 exact; SAP 29 -> 30 = accredited Elmhurst).
_MIN_WITHIN_HALF_SAP = 0.72
_MAX_SAP_MAE = 0.82
_MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current
_MAX_PE_PER_M2_MAE = 3.7 # kWh / m2 / yr vs energy_consumption_current
def _load_corpus() -> list[dict[str, Any]]: