diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b506995c4..a487f10de 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,5 +1,6 @@ import copy import logging +import math import re from dataclasses import replace from datetime import date @@ -212,6 +213,35 @@ def _sap_opening_area_m2(width: Any, height: Any) -> float: ) +def _normalised_main_heating_fraction( + raw: Optional[Union[int, float]], + all_fractions: Sequence[Optional[Union[int, float]]], +) -> Optional[int]: + """Return `raw` as a canonical PERCENT (RdSAP 10 item 7-5, spec p.81). + + The gov-EPC API lodges a two-main dwelling's `main_heating_fraction` pair in + two incompatible encodings — a DECIMAL fraction summing to ~1.0 + (`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`) — and every + consumer divides by 100 unconditionally. A decimal pair therefore collapses + the second system's share to ~1% of its true value, billing almost the whole + load to the first system's fuel and efficiency (#1666). + + Discriminate on the pair sum: a genuine two-main split summing nearer 1.0 + than 100 is decimal-encoded and scaled to percent; a percent-encoded pair + (sum ~100) and any single main are left exactly as lodged, so no + percent-encoded cert regresses. Returned as an int to match the domain + invariant (`MainHeatingDetail.main_heating_fraction` is a 0-100 percent). + """ + if raw is None: + return None + present = [float(f) for f in all_fractions if f is not None] + if len(present) >= 2: + pair_sum = sum(present) + if abs(pair_sum - 1.0) < abs(pair_sum - 100.0): + return round(float(raw) * 100.0) + return round(float(raw)) + + def _pv_array_field(array: Any, name: str) -> Any: """Read a PV-array field from either a `PhotovoltaicArray` dataclass (21.0.x, where the schema Union parsed the nested list) or a raw dict (older schemas @@ -753,7 +783,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -1385,7 +1421,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=None, main_heating_data_source=d.main_heating_data_source, @@ -1573,7 +1615,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -1799,7 +1847,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -2052,7 +2106,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -2285,7 +2345,13 @@ class EpcPropertyDataMapper: boiler_ignition_type=d.boiler_ignition_type, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -2634,7 +2700,13 @@ class EpcPropertyDataMapper: boiler_ignition_type=d.boiler_ignition_type, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -3130,13 +3202,16 @@ class EpcPropertyDataMapper: ) return None - return _clear_basement_flag_when_system_built( - _with_renewable_heat_incentive( - _with_recorded_performance( - _drop_building_parts_without_age_band(mapped), data - ), - data, - ) + return _with_internal_dimensions( + _clear_basement_flag_when_system_built( + _with_renewable_heat_incentive( + _with_recorded_performance( + _drop_building_parts_without_age_band(mapped), data + ), + data, + ) + ), + data, ) @@ -3323,10 +3398,12 @@ def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating: main_heating_index_number=d.main_heating_index_number, main_heating_number=d.main_heating_number, main_heating_category=d.main_heating_category, - main_heating_fraction=( - int(d.main_heating_fraction) - if d.main_heating_fraction is not None - else None + # #1666: normalise the encoding (and fix the old `int()` floor + # that silently deleted a decimal-lodged second main, e.g. + # int(0.35) -> 0) via the shared pair-sum discriminator. + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [x.main_heating_fraction for x in sh.main_heating_details], ), main_heating_data_source=d.main_heating_data_source, ) @@ -3351,6 +3428,181 @@ def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int: ) +# --------------------------------------------------------------------------- +# RdSAP 10 §3.4 conversion of external to internal dimensions (#1669) +# --------------------------------------------------------------------------- + +# `measurement_type` lodged value: 1 = internal dimensions, 2 = external. +_EXTERNAL_MEASUREMENT: Final[int] = 2 + + +def _table_3_row(*vals: int) -> Dict[str, int]: + """Expand a Table 3 wall-thickness row's 10 spec columns to the 13 age + bands: bands I/J/K share the 9th column and L/M share the 10th.""" + a, b, c, d, e, f, g, h, ijk, lm = vals + return { + "A": a, "B": b, "C": c, "D": d, "E": e, "F": f, "G": g, "H": h, + "I": ijk, "J": ijk, "K": ijk, "L": lm, "M": lm, + } + + +# RdSAP 10 Table 3 (PDF p.19): default main-dwelling wall thickness (mm) by wall +# construction row and age band, used only when the thickness is not lodged. +_TABLE_3_WALL_THICKNESS_MM: Final[Dict[str, Dict[str, int]]] = { + "solid": _table_3_row(220, 220, 220, 220, 240, 250, 270, 270, 300, 300), + "cavity": _table_3_row(250, 250, 250, 250, 250, 260, 270, 270, 300, 300), + "timber": _table_3_row(150, 150, 150, 250, 270, 270, 270, 270, 300, 300), + "cob": _table_3_row(540, 540, 540, 540, 540, 540, 560, 560, 590, 590), + "system": _table_3_row(250, 250, 250, 250, 250, 300, 300, 300, 300, 300), +} +# `wall_construction` code → Table 3 row. Table 3 has no stone row (stone is +# normally measured); stone (1/2) and any non-masonry/unknown fall back to the +# solid-masonry column. +_WALL_CONSTRUCTION_TO_TABLE_3_ROW: Final[Dict[int, str]] = { + 1: "solid", 2: "solid", 3: "solid", # stone/granite, sandstone, solid brick + 4: "cavity", 5: "timber", 6: "system", 7: "cob", +} + + +def _main_wall_thickness_m(epc: EpcPropertyData) -> Optional[float]: + """The main dwelling's wall thickness in metres (RdSAP 10 §3.4 `w`): the + lodged value if present, else the Table 3 default for the main part's wall + construction + age band. None when neither is derivable.""" + parts = epc.sap_building_parts + if not parts: + return None + main = parts[0] + if main.wall_thickness_mm is not None: + return main.wall_thickness_mm / 1000.0 + construction = main.wall_construction + row = ( + _WALL_CONSTRUCTION_TO_TABLE_3_ROW.get(construction, "solid") + if isinstance(construction, int) + else "solid" + ) + band = (main.construction_age_band or "").strip().upper()[:1] + mm = _TABLE_3_WALL_THICKNESS_MM[row].get(band) + return mm / 1000.0 if mm is not None else None + + +def _table_2_ratios( + built_form: Optional[str], area_ext: float, perim_ext: float, w: float +) -> tuple[float, float]: + """RdSAP 10 Table 2 (PDF p.18) whole-dwelling external→internal + (area_ratio, perimeter_ratio) by built form (1 detached, 2 semi, 3 + end-terrace, 4 mid-terrace, 5 enclosed end-terrace, 6 enclosed mid-terrace). + `area_ext`/`perim_ext` are the ground-floor FOOTPRINT (§3.4 Note 4: one ratio + for the whole dwelling). Returns (1, 1) — no conversion — for an unrecognised + form or a degenerate footprint.""" + if area_ext <= 0.0 or perim_ext <= 0.0: + return (1.0, 1.0) + bf = (built_form or "").strip() + if bf == "1": # detached + perim_int = perim_ext - 8.0 * w + area_int = area_ext - w * perim_int - 4.0 * w * w + elif bf in ("2", "3"): # semi-detached / end-terrace + if perim_ext**2 >= 8.0 * area_ext: + perim_int = perim_ext - 5.0 * w + a = 0.5 * (perim_ext - math.sqrt(perim_ext**2 - 8.0 * area_ext)) + area_int = area_ext - w * (perim_ext + 0.5 * a) + 3.0 * w * w + else: + perim_int = perim_ext - 3.0 * w + area_int = area_ext - w * perim_ext + 3.0 * w * w + elif bf == "4": # mid-terrace + perim_int = perim_ext - 2.0 * w + area_int = area_ext - w * (perim_ext + 2.0 * area_ext / perim_ext) + 2.0 * w * w + elif bf == "5": # enclosed end-terrace + perim_int = perim_ext - 3.0 * w + area_int = area_ext - 1.5 * w * perim_ext + 2.25 * w * w + elif bf == "6": # enclosed mid-terrace + perim_int = perim_ext - w + area_int = area_ext - w * (area_ext / perim_ext + 1.5 * perim_ext) + 1.5 * w * w + else: + return (1.0, 1.0) + if area_int <= 0.0 or perim_int <= 0.0: + return (1.0, 1.0) + return (area_int / area_ext, perim_int / perim_ext) + + +def _domain_total_floor_area_m2(building_parts: List[SapBuildingPart]) -> float: + """Sum the domain floor-dimension areas (plus each part's always-internal + room-in-roof floor area) — used to refresh `total_floor_area_m2` after a + §3.4 conversion, since `water_heating_from_cert` reads that scalar for + occupancy N.""" + total = 0.0 + for bp in building_parts: + for fd in bp.sap_floor_dimensions: + total += fd.total_floor_area_m2 or 0.0 + rir = bp.sap_room_in_roof + if rir is not None and rir.floor_area: + total += rir.floor_area + return total + + +def _with_internal_dimensions( + epc: EpcPropertyData, data: Dict[str, Any] +) -> EpcPropertyData: + """Plumb the lodged `measurement_type` onto `epc` and, when the horizontal + dimensions were measured externally (== 2), convert them to internal + dimensions per RdSAP 10 §3.4 + Table 2 before they reach the cascade (#1669). + + §3.4 Note 4: ONE whole-dwelling area/perimeter ratio (from the ground-floor + footprint) is applied to every storey of every part — not a per-storey + conversion, which over-corrects multi-part mid-terraces. Room-in-roof floor + area is always measured internally (§3.1) and is left untouched; party wall + lengths are reduced by 2w (Table 2 Note 5). Internal certs are unchanged. + + Scope: the conversion is applied only to the RdSAP-Schema reduced-data family + (17.0-21.x), where per-storey external dimensions are the lodgement standard + and the fix is corpus-validated (RdSAP-21.0.1). `measurement_type` is still + plumbed for every schema, but the legacy SAP-Schema-15.0/16.x certs — whose + per-storey areas do not reconcile to the lodged TFA — are left unconverted.""" + epc.measurement_type = data.get("measurement_type") + schema_type = data.get("schema_type", "") + if ( + epc.measurement_type != _EXTERNAL_MEASUREMENT + or not isinstance(schema_type, str) + or not schema_type.startswith("RdSAP-Schema-") + or not epc.sap_building_parts + ): + return epc + w = _main_wall_thickness_m(epc) + if w is None: + return epc + ground_levels = [ + fd.floor + for bp in epc.sap_building_parts + for fd in bp.sap_floor_dimensions + if fd.floor is not None + ] + if not ground_levels: + return epc + ground = min(ground_levels) + area_ext = sum( + fd.total_floor_area_m2 or 0.0 + for bp in epc.sap_building_parts + for fd in bp.sap_floor_dimensions + if fd.floor == ground + ) + perim_ext = sum( + fd.heat_loss_perimeter_m or 0.0 + for bp in epc.sap_building_parts + for fd in bp.sap_floor_dimensions + if fd.floor == ground + ) + area_ratio, perim_ratio = _table_2_ratios(epc.built_form, area_ext, perim_ext, w) + if area_ratio == 1.0 and perim_ratio == 1.0: + return epc + for bp in epc.sap_building_parts: + for fd in bp.sap_floor_dimensions: + fd.total_floor_area_m2 *= area_ratio + fd.heat_loss_perimeter_m *= perim_ratio + if fd.party_wall_length_m: + fd.party_wall_length_m = max(0.0, fd.party_wall_length_m - 2.0 * w) + epc.total_floor_area_m2 = _domain_total_floor_area_m2(epc.sap_building_parts) + return epc + + def _with_recorded_performance( epc: EpcPropertyData, data: Dict[str, Any] ) -> EpcPropertyData: @@ -5269,24 +5521,67 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ } +# RdSAP 10 Table 24 METAL-frame U-column (PDF p.50-51) — #1667. Only the U +# differs from the PVC/wooden column above (the glazing g is frame-independent); +# the frame factor becomes 0.8 (SAP 10.2 Table 6c) instead of 0.70. Secondary +# glazing (types 4/11/12) has no separate metal U — its combined U is +# frame-independent — so it is absent here and keeps the PVC-column U. Values +# mirror `u_window`'s metal column (`rdsap_uvalues.py`): single 5.7; DG pre-2002 +# 6/12/16 = 3.7/3.4/3.3; triple pre-2002 6/12/16 = 2.9/2.6/2.5; DG/triple 2002+ +# = 2.2; 2022+ = 1.6. +_API_GLAZING_TYPE_TO_METAL_U: Dict[int, float] = { + 1: 3.4, 3: 3.4, 7: 3.4, # DG pre-2002 / unknown-date, 12mm default + 2: 2.2, 9: 2.2, # DG (2) / triple (9) 2002+ (pre-2022) + 13: 1.6, 14: 1.6, # DG / DG-or-triple 2022+ + 5: 5.7, 15: 5.7, # single glazing + 6: 2.6, 8: 2.6, 10: 2.6, # triple pre-2002 / unknown / known, 12mm default +} +_API_GLAZING_TYPE_GAP_TO_METAL_U: Dict[tuple[int, object], float] = { + (1, 6): 3.7, (1, 12): 3.4, (1, "16+"): 3.3, + (3, 6): 3.7, (3, 12): 3.4, (3, "16+"): 3.3, + (7, 6): 3.7, (7, 12): 3.4, (7, "16+"): 3.3, + (6, 6): 2.9, (6, 12): 2.6, (6, "16+"): 2.5, + (8, 6): 2.9, (8, 12): 2.6, (8, "16+"): 2.5, + (10, 6): 2.9, (10, 12): 2.6, (10, "16+"): 2.5, +} + +# SAP 10.2 Table 6c window frame factor: 0.8 for metal, 0.70 for PVC/wooden. +_METAL_FRAME_FACTOR: Final[float] = 0.8 + + def _api_glazing_transmission( glazing_type: Optional[int], glazing_gap: object, + pvc_frame: object = None, ) -> Optional[tuple[float, float, float]]: """Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24. Per-gap override takes precedence over the type-only default. Returns None only when `glazing_type` is absent (None → cascade default). A glazing_type PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing the window to the u_window all-None default U=2.5 — the forcing function - that surfaced single-glazing (code 5 → 4.8) instead of letting it hide.""" + that surfaced single-glazing (code 5 → 4.8) instead of letting it hide. + + `pvc_frame` is the lodged RdSAP input 6-5 boolean (spec p.80): "true" = + wooden/PVC (grouped in Table 24), "false" = metal. Only an explicit "false" + routes the window to the Table 24 metal U-column + FF 0.8 (#1667); "true", + None or any other value keeps the PVC/wooden column + FF 0.70, so no + PVC-lodged cert regresses.""" if glazing_type is None: return None gap_key = (glazing_type, glazing_gap) if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: - return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] - if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: - return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] - raise UnmappedApiCode("glazing_type", glazing_type) + base = _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] + elif glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: + base = _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] + else: + raise UnmappedApiCode("glazing_type", glazing_type) + if pvc_frame != "false": + return base + u_pvc, g_perp, _pvc_ff = base + metal_u = _API_GLAZING_TYPE_GAP_TO_METAL_U.get( + gap_key, _API_GLAZING_TYPE_TO_METAL_U.get(glazing_type, u_pvc) + ) + return (metal_u, g_perp, _METAL_FRAME_FACTOR) # GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade @@ -5405,7 +5700,7 @@ def _api_sap_roof_window(w: Any) -> SapRoofWindow: Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to the inclined-position value the worksheet bills on (27a). Mirror of the site-notes `_map_elmhurst_roof_window`.""" - transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame) vertical_u = transmission[0] if transmission is not None else 2.0 g_perp = transmission[1] if transmission is not None else 0.76 frame_factor = w.frame_factor @@ -5475,7 +5770,7 @@ def _api_sap_window(w: Any) -> SapWindow: to the SAP 10.2 Table 6b cascade enum so the cascade's daylight g_L lookup picks the spec-correct value (e.g. RdSAP 21 code 1 = DG pre-2002, cascade g_L=0.80, not single-glazed 0.90).""" - transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame) frame_factor: Optional[float] = w.frame_factor if frame_factor is None and transmission is not None: frame_factor = transmission[2] diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index ecb72917c..0f9df93ef 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2141,9 +2141,52 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int: raise UnmappedSapCode("main_heating_control", code) +# RdSAP 10 Table 29 (PDF p.56-57): a solid floor in age bands A-E carries no +# insulation layer, so wet underfloor pipes sit in a bare concrete slab (SAP 10.2 +# Table 4d R=0.25). F-M solid floors carry insulation → screed above (0.75). #1668 +_CONCRETE_SLAB_UNDERFLOOR_BANDS: Final[frozenset[str]] = frozenset("ABCDE") +# Lumped gov-EPC "underfloor" heat-emitter code — carries no screed/timber/slab +# subtype; the subtype is derived from floor construction + age band per Table 29. +_UNDERFLOOR_EMITTER_CODE: Final[int] = 2 + + +def _underfloor_responsiveness( + floor_description: Optional[str], age_band: Optional[str] +) -> float: + """SAP 10.2 Table 4d underfloor responsiveness selected via RdSAP 10 Table 29 + from the main part's floor construction + age band (#1668): + + - suspended TIMBER floor → 1.0 (fully responsive timber-floor UFH) + - solid floor, age A-E → 0.25 (bare concrete slab, no insulation) + - solid F-M / suspended-not-timber / unknown / no ground floor + → 0.75 (screed above insulation — the prior + default, so ambiguous certs do not move). + """ + desc = (floor_description or "").lower() + is_timber = "timber" in desc and "not timber" not in desc + if is_timber: + return 1.0 + band = (age_band or "").strip().upper()[:1] + if "solid" in desc and band in _CONCRETE_SLAB_UNDERFLOOR_BANDS: + return 0.25 + return 0.75 + + +def _main_underfloor_floor_context( + epc: Optional[EpcPropertyData], +) -> tuple[Optional[str], Optional[str]]: + """(floor construction description, age band) of the main building part — + the Table 29 discriminator for the underfloor subtype in `_responsiveness`.""" + if epc is None or not epc.sap_building_parts: + return (None, None) + main_bp = epc.sap_building_parts[0] + return (_effective_floor_description(epc, main_bp), main_bp.construction_age_band) + + def _responsiveness( main: Optional[MainHeatingDetail], tariff: Optional[Tariff] = None, + epc: Optional[EpcPropertyData] = None, ) -> float: """SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271: @@ -2183,9 +2226,10 @@ def _responsiveness( 4 = Warm air → R = 1.0 5 = Fan coils → R = 1.0 - "Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry - yet — that variant would need a new mapper code before the cascade - can dispatch it. + The lumped underfloor code 2 carries no screed/timber/concrete-slab + subtype; its Table 4d responsiveness (0.25 / 0.75 / 1.0) is derived + from the main part's floor construction + age band per RdSAP 10 + Table 29 via `_underfloor_responsiveness` (needs `epc`; #1668). Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging (None / 0 / "") returns modal default R=1.0 (radiators); lodging @@ -2210,6 +2254,9 @@ def _responsiveness( emitter = main.heat_emitter_type if not emitter: return 1.0 + if emitter == _UNDERFLOOR_EMITTER_CODE: + floor_description, age_band = _main_underfloor_floor_context(epc) + return _underfloor_responsiveness(floor_description, age_band) if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE: return _RESPONSIVENESS_BY_EMITTER_CODE[emitter] raise UnmappedSapCode("heat_emitter_type", emitter) @@ -4958,7 +5005,7 @@ def mean_internal_temperature_section_from_cert( thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=_control_type(main), - responsiveness=_responsiveness(main, tariff=tariff), + responsiveness=_responsiveness(main, tariff=tariff, epc=epc), living_area_fraction=_living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ), @@ -8255,7 +8302,7 @@ def cert_to_inputs( # for the Elmhurst corpus (cert-side mapping is a future slice). control_type_value = _control_type(main) _mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) - responsiveness_value = _responsiveness(main, tariff=_mit_tariff) + responsiveness_value = _responsiveness(main, tariff=_mit_tariff, epc=epc) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) @@ -8279,7 +8326,7 @@ def cert_to_inputs( main_2_control_type_value = _control_type(_mit_main_2) main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0 main_2_responsiveness_value = _responsiveness( - _mit_main_2, tariff=_mit_tariff + _mit_main_2, tariff=_mit_tariff, epc=epc ) monthly_total_gains_w = tuple( internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) diff --git a/tests/datatypes/epc/domain/test_mapper_external_measurement_conversion.py b/tests/datatypes/epc/domain/test_mapper_external_measurement_conversion.py new file mode 100644 index 000000000..0c6cb8d8c --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_external_measurement_conversion.py @@ -0,0 +1,93 @@ +"""#1669 — when a cert lodges its horizontal dimensions measured externally +(`measurement_type == 2`), RdSAP 10 §3.4 + Table 2 (spec p.17-18) require the +external floor area and exposed perimeter to be converted to INTERNAL dimensions +before any SAP use. `measurement_type` was read nowhere, so external certs +carried a TFA ~11-15% too large — corrupting occupancy, reported floor area and +every per-m² output. The mapper must plumb `measurement_type` through and, for +external certs, apply the Table 2 whole-dwelling ratio (§3.4 Note 4) before the +values reach the cascade, while leaving internal (`== 1`) certs verbatim. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +def _corpus_cert(uprn: str) -> dict[str, Any]: + for line in _CORPUS.read_text().splitlines(): + if uprn in line: + cert: dict[str, Any] = json.loads(line) + return cert + raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus") + + +def _with_measurement_type(payload: dict[str, Any], value: int) -> dict[str, Any]: + twin = deepcopy(payload) + twin["measurement_type"] = value + return twin + + +def _mapped_tfa(payload: dict[str, Any]) -> float: + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + assert epc.total_floor_area_m2 is not None + return epc.total_floor_area_m2 + + +def test_measurement_type_is_plumbed_onto_the_domain_object() -> None: + # Arrange — cert 100091435353 lodges measurement_type 2; the field was + # dropped to None by every API mapper (`# What is this?`). + payload = _corpus_cert("100091435353") + + # Act + epc = EpcPropertyDataMapper.from_api_response(payload) + + # Assert + assert epc is not None + assert epc.measurement_type == 2 + + +def test_external_cert_tfa_is_converted_to_internal_dimensions() -> None: + # Arrange — 100091435353 (detached, w=270mm) lodges a 128.7 m^2 EXTERNAL + # floor-area sum; its lodged internal TFA is 112. + payload = _corpus_cert("100091435353") + + # Act + tfa = _mapped_tfa(payload) + + # Assert — Table 2 detached conversion recovers the internal TFA to <0.5 m^2, + # not the raw external 128.7. + assert abs(tfa - 112.0) <= 0.5 + + +def test_internal_cert_dimensions_are_left_verbatim() -> None: + # Arrange — the same cert forced to measurement_type 1 (internal); §3.4 does + # not apply, so the floor areas must be used exactly as lodged (128.7). + payload = _with_measurement_type(_corpus_cert("100091435353"), 1) + + # Act + tfa = _mapped_tfa(payload) + + # Assert — no conversion; the external sum is passed through unchanged. + assert abs(tfa - 128.7) <= 1e-6 + + +def test_external_conversion_moves_sap_toward_lodged() -> None: + # Arrange — 100091435353 over-rated at 66.23 on HEAD (external dims), lodged 65. + payload = _corpus_cert("100091435353") + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + + # Act + sap = Sap10Calculator().calculate(epc).sap_score_continuous + + # Assert — the internal-dimension conversion pulls it to ~65.33, toward lodged. + assert abs(sap - 65.33) <= 0.05 diff --git a/tests/datatypes/epc/domain/test_mapper_main_heating_fraction.py b/tests/datatypes/epc/domain/test_mapper_main_heating_fraction.py new file mode 100644 index 000000000..4cb336d91 --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_main_heating_fraction.py @@ -0,0 +1,116 @@ +"""#1666 — a two-main dwelling's `main_heating_fraction` pair is lodged by the +gov-EPC API in two incompatible encodings: a DECIMAL fraction summing to ~1.0 +(`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`). Every consumer +divides by 100 unconditionally, so a decimal-encoded pair collapses the second +main's share to ~1% of its true value and bills almost the whole load to the +first system's fuel/efficiency. The mapper must normalise both encodings to the +spec-canonical percent (RdSAP 10 item 7-5, spec p.81) so the encoding is +invisible downstream, without regressing the percent-encoded certs. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, Optional, cast + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator + +_RDSAP_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") +_SAP_17_1_CORPUS = Path("backend/epc_api/json_samples/SAP-Schema-17.1/corpus.jsonl") + + +def _corpus_cert(corpus: Path, uprn: str) -> dict[str, Any]: + for line in corpus.read_text().splitlines(): + if uprn in line: + cert: dict[str, Any] = json.loads(line) + return cert + raise AssertionError(f"uprn {uprn} not found in {corpus.name}") + + +def _mapped_fractions(payload: dict[str, Any]) -> list[Optional[int]]: + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + return [m.main_heating_fraction for m in epc.sap_heating.main_heating_details if m] + + +def _continuous_sap(payload: dict[str, Any]) -> float: + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + return Sap10Calculator().calculate(epc).sap_score_continuous + + +def _scaled_fractions(payload: dict[str, Any], factor: float) -> dict[str, Any]: + """Deep-copy `payload`, multiplying every lodged `main_heating_fraction` by + `factor` — used to build a percent-encoded twin of a decimal-encoded cert.""" + twin = deepcopy(payload) + + def walk(node: object) -> None: + if isinstance(node, dict): + node_dict = cast("dict[str, Any]", node) + for key, value in node_dict.items(): + if key == "main_heating_fraction" and value is not None: + node_dict[key] = value * factor + else: + walk(value) + elif isinstance(node, list): + for value in cast("list[Any]", node): + walk(value) + + walk(twin) + return twin + + +def test_decimal_encoded_fraction_pair_is_normalised_to_percent() -> None: + # Arrange — corpus cert 10070086972 (semi-detached, lodged SAP 18) lodges its + # two mains as a DECIMAL pair [0.8, 0.2] summing to 1.0; the /100 consumers + # would otherwise bill the second main at 0.2% of the load, not 20%. + payload = _corpus_cert(_RDSAP_CORPUS, "10070086972") + + # Act + fractions = _mapped_fractions(payload) + + # Assert — scaled to the spec-canonical percent (RdSAP 10 item 7-5, p.81). + assert fractions == [80, 20] + + +def test_percent_encoded_fraction_pair_is_left_unchanged() -> None: + # Arrange — corpus cert 40037847 lodges a PERCENT pair [10, 90] summing to + # ~100; the pair-sum discriminator must leave it exactly as lodged so the 3 + # percent-encoded corpus certs do not regress. + payload = _corpus_cert(_RDSAP_CORPUS, "40037847") + + # Act + fractions = _mapped_fractions(payload) + + # Assert + assert fractions == [10, 90] + + +def test_encoding_is_invisible_to_the_continuous_sap() -> None: + # Arrange — the decimal cert and a PERCENT twin built by scaling its lodged + # fractions x100 ([0.8, 0.2] -> [80, 20]); the encoding must not change SAP. + decimal_payload = _corpus_cert(_RDSAP_CORPUS, "10070086972") + percent_payload = _scaled_fractions(decimal_payload, 100) + + # Act + sap_decimal = _continuous_sap(decimal_payload) + sap_percent = _continuous_sap(percent_payload) + + # Assert — the two encodings must produce the same continuous SAP. + assert abs(sap_decimal - sap_percent) <= 1e-9 + + +def test_sap_17_1_decimal_second_main_is_not_floored_to_zero() -> None: + # Arrange — SAP-Schema-17.1 cert 38334849 lodges two mains as a DECIMAL pair + # [0.5, 0.5]. The old `int(fraction)` floor mapped BOTH to 0, silently + # deleting the split; the shared normaliser must scale to percent instead. + payload = _corpus_cert(_SAP_17_1_CORPUS, "38334849") + + # Act + fractions = _mapped_fractions(payload) + + # Assert + assert fractions == [50, 50] diff --git a/tests/datatypes/epc/domain/test_mapper_metal_window_frame.py b/tests/datatypes/epc/domain/test_mapper_metal_window_frame.py new file mode 100644 index 000000000..6681a4e7b --- /dev/null +++ b/tests/datatypes/epc/domain/test_mapper_metal_window_frame.py @@ -0,0 +1,100 @@ +"""#1667 — on the gov-EPC API path a window's metal frame was unrepresentable: +the transmission lookup had no frame dimension, so every window was billed at +the PVC/wooden U-column and frame factor 0.70 even where the cert lodges a metal +frame (`pvc_frame:"false"`). RdSAP 10 Table 24 gives metal a higher U (+0.2 to ++0.9 W/m^2K) and SAP 10.2 Table 6c a frame factor of 0.8, so we over-rated +metal-framed dwellings. The mapper must route a metal-lodged window to the +Table 24 metal column + FF 0.8, leaving PVC-lodged windows unchanged. +""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any, cast + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +def _corpus_cert(uprn: str) -> dict[str, Any]: + for line in _CORPUS.read_text().splitlines(): + if uprn in line: + cert: dict[str, Any] = json.loads(line) + return cert + raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus") + + +def _forced_pvc_frame(payload: dict[str, Any], value: str) -> dict[str, Any]: + """Deep-copy `payload`, setting every lodged `pvc_frame` to `value` — used to + build a PVC-framed twin of a metal-framed cert.""" + twin = deepcopy(payload) + + def walk(node: object) -> None: + if isinstance(node, dict): + node_dict = cast("dict[str, Any]", node) + for key, child in node_dict.items(): + if key == "pvc_frame" and child is not None: + node_dict[key] = value + else: + walk(child) + elif isinstance(node, list): + for child in cast("list[Any]", node): + walk(child) + + walk(twin) + return twin + + +def _window_u_and_ff(payload: dict[str, Any]) -> tuple[set[float], set[float]]: + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + u_values = { + w.window_transmission_details.u_value + for w in epc.sap_windows + if w.window_transmission_details is not None + } + frame_factors = {w.frame_factor for w in epc.sap_windows if w.frame_factor is not None} + return u_values, frame_factors + + +def test_metal_frame_window_bills_table_24_metal_u_and_frame_factor_0p8() -> None: + # Arrange — cert 21067296 lodges six pvc_frame:"false" (metal) double-glazed + # (type 3, 12 mm) windows; HEAD billed them on the PVC column (U 2.8, FF 0.70). + payload = _corpus_cert("21067296") + + # Act + u_values, frame_factors = _window_u_and_ff(payload) + + # Assert — Table 24 metal DG-12mm column (3.4) and SAP 10.2 Table 6c FF 0.8. + assert u_values == {3.4} + assert frame_factors == {0.8} + + +def test_pvc_frame_window_stays_on_the_pvc_column() -> None: + # Arrange — the same cert forced to pvc_frame:"true"; the fix must not + # regress PVC/wooden-framed windows (the discriminator is the boolean). + payload = _forced_pvc_frame(_corpus_cert("21067296"), "true") + + # Act + u_values, frame_factors = _window_u_and_ff(payload) + + # Assert — PVC DG-12mm column (2.8) and FF 0.70, exactly as before. + assert u_values == {2.8} + assert frame_factors == {0.7} + + +def test_metal_frame_fix_moves_cert_toward_lodged() -> None: + # Arrange — 21067296 scored 68.60 on HEAD (PVC assumption), lodged 67. + payload = _corpus_cert("21067296") + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + + # Act + sap = Sap10Calculator().calculate(epc).sap_score_continuous + + # Assert — the metal U-penalty pulls it to ~66.96, i.e. toward lodged 67. + assert abs(sap - 66.96) <= 0.05 diff --git a/tests/domain/epc_prediction/test_component_accuracy_gate.py b/tests/domain/epc_prediction/test_component_accuracy_gate.py index e4bcd41f6..a9065c926 100644 --- a/tests/domain/epc_prediction/test_component_accuracy_gate.py +++ b/tests/domain/epc_prediction/test_component_accuracy_gate.py @@ -124,11 +124,20 @@ _RATE_FLOORS: dict[str, float] = { # dimensional predictions); floor_area loosened 12.0378 -> 12.0586 as the one # physical residual that fell (1-2 targets picking a new-build donor). See the # _RATE_FLOORS note above. +# total_window_area 3.7184 -> 3.7484 and door_count 0.3333 -> 0.3737 re-baselined +# when the external-measurement conversion landed (#1669: measurement_type=2 certs +# now have their floor area + exposed perimeter converted external->internal per +# RdSAP 10 §3.4 + Table 2). The leave-one-out scorer re-derives the *actual* +# EpcPropertyData through the same mapper, so the sharper (internal) dimensions of +# the fixture's external certs shift the geo-proximity-weighted donor mode — a +# ground-truth-method change, not a prediction-logic loosening (spec-correct, +# corpus MAE 0.571 -> 0.570 and the real-cert pin held). The move is two +# single-target donor tips on the n=36 fixture. Tighten-only resumes from here. _RESIDUAL_CEILINGS: dict[str, float] = { "floor_area": 12.0586, - "total_window_area": 3.7184, + "total_window_area": 3.7484, "building_parts": 0.1212, - "door_count": 0.3333, + "door_count": 0.3737, } _TOLERANCE = 1e-3 diff --git a/tests/domain/epc_prediction/test_expired_pairs_gate.py b/tests/domain/epc_prediction/test_expired_pairs_gate.py index ed037815c..93abebb8b 100644 --- a/tests/domain/epc_prediction/test_expired_pairs_gate.py +++ b/tests/domain/epc_prediction/test_expired_pairs_gate.py @@ -55,6 +55,15 @@ _FIXTURE = ( # 28-scoreable fixture) and residual ceilings — the measured values; tighten, # never loosen. The general (unconditioned) prediction floors live in # test_component_accuracy_gate.py; this gate guards the CONDITIONING path. +# +# has_pv re-baselined 0.8929 -> 0.8571 when the external-measurement conversion +# landed (#1669: measurement_type=2 certs converted external->internal per RdSAP +# 10 §3.4 + Table 2). This gate re-derives the *actual* EpcPropertyData through +# the same mapper, so the sharper internal dimensions of the fixture's external +# certs shift the conditioned donor mode and one pair's has_pv agreement tips +# 25/28 -> 24/28 — a ground-truth-method change, not a prediction-logic loosening +# (spec-correct; corpus MAE improved and the real-cert pin held). Tighten-only +# resumes from here. _CLASSIFICATION_FLOORS: dict[str, float] = { "construction_age_band": 0.5357, "construction_age_band_pm1": 0.8214, @@ -62,7 +71,7 @@ _CLASSIFICATION_FLOORS: dict[str, float] = { "floor_construction": 0.8636, "floor_insulation": 1.0000, "has_hot_water_cylinder": 0.8214, - "has_pv": 0.8929, + "has_pv": 0.8571, "has_room_in_roof": 0.8571, "heating_main_category": 1.0000, "heating_main_control": 0.6071, diff --git a/tests/domain/sap10_calculator/rdsap/test_underfloor_responsiveness.py b/tests/domain/sap10_calculator/rdsap/test_underfloor_responsiveness.py new file mode 100644 index 000000000..ba1ea77f6 --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/test_underfloor_responsiveness.py @@ -0,0 +1,78 @@ +"""#1668 — wet underfloor responsiveness was pinned at R=0.75 for the lumped +gov-EPC emitter code 2, regardless of floor construction. SAP 10.2 Table 4d +splits it three ways and RdSAP 10 Table 29 (p.56-57) selects which from floor +construction + age band: insulated timber floor R=1.0, screed above insulation +R=0.75, concrete slab R=0.25. A concrete-slab system (solid floor, age A-E) was +billed as screed (0.75), over-stating responsiveness and over-rating the +dwelling. The calculator must derive the Table 29 subtype from the main part's +floor construction + age band. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from datatypes.epc.domain.mapper import EpcPropertyDataMapper +from domain.sap10_calculator.calculator import Sap10Calculator +from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _underfloor_responsiveness, # pyright: ignore[reportPrivateUsage] +) + +_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl") + + +def _corpus_cert(uprn: str) -> dict[str, Any]: + for line in _CORPUS.read_text().splitlines(): + if uprn in line: + cert: dict[str, Any] = json.loads(line) + return cert + raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus") + + +def test_solid_floor_age_a_to_e_is_a_concrete_slab_r_0p25() -> None: + # Arrange / Act / Assert — RdSAP 10 Table 29: a solid floor in age bands A-E + # has no insulation layer, so the underfloor pipes sit in a concrete slab. + assert _underfloor_responsiveness("Solid", "B") == 0.25 + assert _underfloor_responsiveness("Solid", "E") == 0.25 + + +def test_solid_floor_age_f_onwards_is_screed_r_0p75() -> None: + # Arrange / Act / Assert — newer solid floors carry insulation, so the pipes + # are in screed above it (0.75), not a bare slab. + assert _underfloor_responsiveness("Solid", "F") == 0.75 + assert _underfloor_responsiveness("Solid", "M") == 0.75 + + +def test_suspended_timber_floor_is_r_1p0() -> None: + # Arrange / Act / Assert — a timber-floor underfloor system is fully + # responsive (Table 4d timber floor = 1.0). + assert _underfloor_responsiveness("Suspended timber", "D") == 1.0 + + +def test_suspended_not_timber_is_screed_r_0p75_not_timber() -> None: + # Arrange / Act / Assert — "Suspended, not timber" must NOT be read as timber + # (the substring trap); it falls to the screed default 0.75. + assert _underfloor_responsiveness("Suspended, not timber", "C") == 0.75 + + +def test_unknown_or_absent_floor_defaults_to_screed_r_0p75() -> None: + # Arrange / Act / Assert — a main part with no ground floor / unlodged + # construction keeps the prior 0.75, so ambiguous certs do not move. + assert _underfloor_responsiveness(None, "A") == 0.75 + assert _underfloor_responsiveness("", None) == 0.75 + + +def test_concrete_slab_cert_corrects_to_lodged() -> None: + # Arrange — cert 100100137438 (solid floor, band B, wet underfloor) over-rated + # at 72 on HEAD via the pinned 0.75; lodged 69. + payload = _corpus_cert("100100137438") + epc = EpcPropertyDataMapper.from_api_response(payload) + assert epc is not None + + # Act + sap = Sap10Calculator().calculate(epc).sap_score + + # Assert — Table 29 concrete-slab R=0.25 corrects it to exactly lodged 69. + assert sap == 69 diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 5d74b143c..83bec44d7 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -280,7 +280,18 @@ _CORPUS = Path( # (U=0), previously mislabelled a pitched roof so the party override never fired. # Combined with main's gable fix: within-0.5 79.5% -> 80.3%, MAE 0.599 -> 0.584, # PE 2.71 -> 2.5. Ratcheted. -_MIN_WITHIN_HALF_SAP = 0.803 +# SAP-10.2 CALCULATOR ACCURACY BATCH (#1656 backlog: #1666/#1667/#1669/#1668, +# all stacked on the merged 80.3% baseline above): +# #1666 normalise dual-encoded main_heating_fraction (decimal vs percent) -> +# 80.3% -> 80.6%, MAE 0.584 -> 0.578; +# #1667 bill metal-framed API windows on the Table 24 metal U-column + FF 0.8 -> +# 80.6% -> 81.0%, MAE 0.578 -> 0.571; +# #1669 convert measurement_type=2 external dims to internal (RdSAP §3.4/Table 2) +# -> 81.0% -> 81.2%, MAE 0.571 -> 0.570, PE 2.5 -> 2.4; +# #1668 derive wet-underfloor Table 29 subtype (concrete-slab R=0.25) -> +# 81.2%, MAE 0.570 -> 0.565. +# Measured within-0.5 = 0.812000, MAE = 0.565082. Ratcheted. +_MIN_WITHIN_HALF_SAP = 0.812 # 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak # trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion) # -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57, @@ -415,7 +426,10 @@ _MIN_WITHIN_HALF_SAP = 0.803 # ceiling roof-code-3 slice stacking on main's #1662 floor-to-unheated-space # fix: merged-corpus MAE measured 0.583761 (within-0.5 80.3%), rounded up to # 3 d.p. so the `<=` assert holds. -_MAX_SAP_MAE = 0.584 +# Ratcheted 0.584 -> 0.566 by the #1666/#1667/#1669/#1668 calculator-accuracy +# batch (see the _MIN_WITHIN_HALF_SAP note above): merged-corpus MAE measured +# 0.565082, rounded up to 3 d.p. +_MAX_SAP_MAE = 0.566 _MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current