mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge pull request #1252 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
This commit is contained in:
commit
eeec3972fe
10 changed files with 608 additions and 63 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
26
tests/datatypes/epc/domain/test_mapper_glazing_label.py
Normal file
26
tests/datatypes/epc/domain/test_mapper_glazing_label.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue