diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index a699be4e..380192d3 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 11cecf73..fb859bf6 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index cd146109..8227764b 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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 diff --git a/domain/sap10_calculator/worksheet/heat_transmission.py b/domain/sap10_calculator/worksheet/heat_transmission.py index 80c65aac..a0d773d9 100644 --- a/domain/sap10_calculator/worksheet/heat_transmission.py +++ b/domain/sap10_calculator/worksheet/heat_transmission.py @@ -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 diff --git a/domain/sap10_ml/rdsap_uvalues.py b/domain/sap10_ml/rdsap_uvalues.py index 33b9741c..6687491f 100644 --- a/domain/sap10_ml/rdsap_uvalues.py +++ b/domain/sap10_ml/rdsap_uvalues.py @@ -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 # --------------------------------------------------------------------------- diff --git a/domain/sap10_ml/tests/test_rdsap_uvalues.py b/domain/sap10_ml/tests/test_rdsap_uvalues.py index 38efc93c..4fb51f42 100644 --- a/domain/sap10_ml/tests/test_rdsap_uvalues.py +++ b/domain/sap10_ml/tests/test_rdsap_uvalues.py @@ -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. diff --git a/tests/datatypes/epc/domain/test_mapper_glazing_label.py b/tests/datatypes/epc/domain/test_mapper_glazing_label.py new file mode 100644 index 00000000..8430d76e --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_glazing_label.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index d8934c16..e1ca3f7e 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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 diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index dba25409..5444a140 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -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 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index ef933adb..df64ed59 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -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]]: