diff --git a/backend/documents_parser/extractor.py b/backend/documents_parser/extractor.py index fde4530e9..d06017dba 100644 --- a/backend/documents_parser/extractor.py +++ b/backend/documents_parser/extractor.py @@ -518,6 +518,15 @@ class PasHubRdSapSiteNotesExtractor: number_of_fixed_cfl_bulbs=int( self._get_in(rce_section, "Number of fixed CFL bulbs:") or 0 ), + number_of_fixed_low_energy_lights=( + int(low_energy_raw) + if ( + low_energy_raw := self._get_in( + rce_section, "Number of fixed low energy lights?" + ) + ) + else None + ), waste_water_heat_recovery=self._get_in( rce_section, "Are there any waste water heat recovery systems?" ) diff --git a/backend/documents_parser/tests/test_end_to_end.py b/backend/documents_parser/tests/test_end_to_end.py index b0858ddc6..6052e581c 100644 --- a/backend/documents_parser/tests/test_end_to_end.py +++ b/backend/documents_parser/tests/test_end_to_end.py @@ -279,6 +279,7 @@ class TestPdfToEpcPropertyData: sap_ventilation=SapVentilation( ventilation_type="Mechanical Extract - Decentralised", draught_lobby=False, + has_draught_lobby=False, pressure_test="No test", open_flues_count=0, closed_flues_count=0, diff --git a/backend/documents_parser/tests/test_extractor.py b/backend/documents_parser/tests/test_extractor.py index 0666f0d7c..3a57fcb56 100644 --- a/backend/documents_parser/tests/test_extractor.py +++ b/backend/documents_parser/tests/test_extractor.py @@ -1181,6 +1181,36 @@ class TestRoomCountElements: waste_water_heat_recovery="None", ) + def test_low_energy_lights_absent_when_exact_counts_known( + self, rce: RoomCountElements + ) -> None: + # Base fixture lodges the exact LED/CFL counts, so the aggregate + # "low energy lights" line is absent and stays None. + assert rce.number_of_fixed_low_energy_lights is None + + def test_low_energy_lights_parsed_when_exact_counts_unknown(self) -> None: + # Arrange — splice a survey that answers "No" to the exact-count + # question and lodges an aggregate low-energy count instead (the shape + # of ~6% of the PAS Hub cohort, e.g. 5 Bailey Street / 58 Hackle St). + lines = load_text_fixture() + idx = lines.index("Is the exact number of LED and CFL bulbs known?") + assert lines[idx + 1] == "Yes" + cfl_idx = lines.index("Number of fixed CFL bulbs:") + spliced = ( + lines[: idx + 1] + + ["No"] + + lines[idx + 2 : cfl_idx + 2] + + ["Number of fixed low energy lights?", "11"] + + lines[cfl_idx + 2 :] + ) + + # Act + rce = PasHubRdSapSiteNotesExtractor(spliced).extract_room_count_elements() + + # Assert + assert rce.exact_led_cfl_count_known is False + assert rce.number_of_fixed_low_energy_lights == 11 + class TestWaterUse: def test_full_water_use(self) -> None: diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy.py b/backend/documents_parser/tests/test_pashub_sap_accuracy.py index 09299d1cd..66524f6bd 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy.py @@ -75,8 +75,10 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # 0.5. MAE rises 2.560 -> 2.701 because two of those four are large, isolated # divergences NOT caused by the mappings (which land the others at d=-1.7/+7.6): # - 499584755922 d=-33.0 Bulk-LPG main + house-coal secondary (34.0 vs pre_sap 67) -# - 507644414148 d=+18.2 community heating — pashub path maps only the control -# code, not the full heat-network fuel/flags (70.2 vs 52) +# - 507644414148 d=+18.2 community heating (70.2 vs 52) at the time. Since +# RESOLVED: fuel/flags threaded (#1590 follow-up) and the +# control code corrected 2306->2308 (2026-07-15); now +# +2.0, the SAP-10.2-engine-vs-lodged offset (not a gap). # Both are tracked for verification (pre_sap correctness vs a deeper modelling # gap) — see docs/HANDOVER_PASHUB_838_PROBLEM_PROPERTIES.md. Re-baselined to the # full-coverage aggregate; ratchet back DOWN as those two are resolved. @@ -98,11 +100,22 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json" # ~58 vs 59). Ratcheted 0.50 -> 0.51 by #1602 (semi-exposed / exposed floor # exposure flags -> RdSAP §5.14 U=0.7 / §5.13 Table 20) + #1603 (alternative- # wall sub-areas + extension floor construction now threaded): observed 51.2% -# (105/205). #1601 (upper-floor +0.25 m) RESOLVED as keep-raw — adjudicated -# against verified truth it over-corrects every verified dwelling (raw beats -# +0.25 on all 7); pashub surveys internal dims and its upper-ground height -# gap is modal 0.00, so the joist void must not be added. -_MIN_WITHIN_HALF: float = 0.51 +# (105/205). Ratcheted 0.51 -> 0.58 by the 2026-07-15 levers (low-energy-lights +# + has_draught_lobby threading in from_site_notes + heat-network control code +# 2306->2308): observed 58.5%. +# Ratcheted 0.58 -> 0.78 by the 2026-07-15c INFILTRATION bundle: from_site_notes +# uniquely dropped THREE §2 inputs the gov-API/Elmhurst mappers set — +# `percent_draughtproofed` (window §2(15) pinned at the 0.25-ACH worst case), +# the +0.25 m upper-storey joist void, and built-form `sheltered_sides` +# (defaulted to a flat 2, over-sheltering end/semi/detached). The three are +# NON-ADDITIVE — each overshoots alone (which is why #1601 upper-floor +0.25 +# was previously RESOLVED as keep-raw: that call was confounded by the +# then-present draught-proofing drop suppressing every dwelling) — but together +# they land all 7 verified dwellings toward truth (none regress) and every +# built form near zero. Observed 79.5% here; vs the DB SAP-10.2 oracle +# (property_baseline_performance.effective_sap_score, portfolio 838) the cohort +# moves 62.1% -> 83.3% within-0.5. gov-API RdSAP corpus unchanged (78.9%). +_MIN_WITHIN_HALF: float = 0.78 # Ratcheted 2.71 -> 2.64 by #1590 bug 2 (roof "Insulation At: None" -> explicit # zero thickness): observed MAE 2.632 — the two ~7-SAP roof over-raters # (507665533138 / 507670893756) correcting is exactly 14/205 ≈ 0.07 of MAE. @@ -167,9 +180,14 @@ _MIN_WITHIN_HALF: float = 0.51 # Bingley still +6.818). The three silent-drop fixes the gov-API/Elmhurst paths # already apply; the electric-shower drop (100/205) was the dominant lever. # Ratcheted 0.653 -> 0.625 by #1602 (floor exposure flags) + #1603 (alternative -# walls + extension floor construction): observed MAE 0.624. #1601 (upper-floor -# +0.25 m) RESOLVED as keep-raw — see the within-0.5 note above. -_MAX_SAP_MAE: float = 0.625 +# walls + extension floor construction): observed MAE 0.624. +# Ratcheted 0.625 -> 0.521 by the 2026-07-15 levers (low-energy-lights + +# has_draught_lobby + heat-network control 2306->2308): observed MAE 0.520. +# Ratcheted 0.521 -> 0.40 by the 2026-07-15c infiltration bundle +# (percent_draughtproofed + upper-floor +0.25 m + built-form sheltered_sides; +# #1601 upper-floor RE-ADJUDICATED, see the within-0.5 note above): observed +# MAE 0.389. +_MAX_SAP_MAE: float = 0.40 _KNOWN_GAP_REASON = ( "pashub `from_site_notes` mapper does not int-code a main-heating " diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 62fe48069..3998b57eb 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -352,6 +352,31 @@ class EpcPropertyDataMapper: 2, ) # TODO: verify that is the correct approach + # §2 (15) draught-proofing of windows/doors. The gov-API and Elmhurst + # mappers set `percent_draughtproofed` (from their lodged percentage); + # from_site_notes uniquely dropped it → the calculator read 0 and pinned + # the window-infiltration term at its 0.25-ACH worst case (0.25 − 0.2 × + # pct/100) on EVERY pashub dwelling — a uniform cohort-wide under-rate. + # PAS Hub surveys draught-proofing per window (SapWindow.draught_proofed; + # 198/205 dwellings are fully draught-proofed, consistent with sealed + # double-glazed units), so reconstruct the area-weighted percentage the + # sibling mappers lodge directly. See the #1601 note in + # `_map_floor_dimensions` for the non-additive height/shelter partners. + sap_windows = [_map_sap_window(w) for w in survey.windows] + _dp_total_area = sum( + (w.window_width or 0.0) * (w.window_height or 0.0) for w in sap_windows + ) + _dp_proofed_area = sum( + (w.window_width or 0.0) * (w.window_height or 0.0) + for w in sap_windows + if w.draught_proofed + ) + percent_draughtproofed = ( + int(round(100.0 * _dp_proofed_area / _dp_total_area)) + if _dp_total_area > 0 + else None + ) + return EpcPropertyData( uprn=uprn, dwelling_type=f"{general.detachment_type} {general.property_type.lower()}", @@ -371,7 +396,8 @@ class EpcPropertyDataMapper: main_heating=[], door_count=room_counts.number_of_external_doors, sap_heating=_map_sap_heating(heating, ventilation, survey.water_use), - sap_windows=[_map_sap_window(w) for w in survey.windows], + sap_windows=sap_windows, + percent_draughtproofed=percent_draughtproofed, sap_energy_source=SapEnergySource( gas_connection_available=general.mains_gas_available, meter_type=general.electric_meter_type, @@ -409,6 +435,16 @@ class EpcPropertyDataMapper: cfl_fixed_lighting_bulbs_count=room_counts.number_of_fixed_cfl_bulbs, led_fixed_lighting_bulbs_count=room_counts.number_of_fixed_led_bulbs, incandescent_fixed_lighting_bulbs_count=room_counts.number_of_fixed_incandescent_bulbs, + # When the assessor did not enumerate exact LED/CFL bulbs, PAS Hub + # records an aggregate "Number of fixed low energy lights?" instead; + # thread it so the §L calculation uses the surveyed low-energy count + # rather than falling back to the pessimistic no-data default. Mirrors + # the Elmhurst path (from_elmhurst_site_notes). + low_energy_fixed_lighting_bulbs_count=( + room_counts.number_of_fixed_low_energy_lights + if not room_counts.exact_led_cfl_count_known + else None + ), total_floor_area_m2=total_floor_area, built_form=general.detachment_type, property_type=general.property_type, @@ -424,7 +460,9 @@ class EpcPropertyDataMapper: waste_water_heat_recovery=room_counts.waste_water_heat_recovery, hydro=renewables.hydro, photovoltaic_array=renewables.photovoltaic_array, - sap_ventilation=_map_sap_ventilation(ventilation), + sap_ventilation=_map_sap_ventilation( + ventilation, built_form=general.detachment_type + ), ) @staticmethod @@ -5964,17 +6002,19 @@ def _extract_age_band(age_range: str) -> str: def _map_floor_dimensions( floors: List[FloorMeasurement], floor_type: str ) -> List[SapFloorDimension]: - # NOTE (#1601, RESOLVED — keep raw): the gov-API/Elmhurst mappers add a - # 0.25 m joist-void to UPPER-storey heights (their lodged height is a CLEAR - # internal ceiling). Adjudicated against VERIFIED truth (the reliable oracle, - # not pre_sap), pashub must NOT add it: RAW beats +0.25 on all 7 verified - # dwellings (16 Stillwater 75.6 vs +0.25's 75.2 against verified 76; every - # verified property is already slightly under truth and +0.25 only lowers - # SAP). pashub surveys internal dimensions (202/205 "Measurements Location: - # Internal") and its upper−ground height difference is modal at 0.00 — the - # joist void is not differentially present, and the absolute basis is - # inconsistent (≈half clear-ceiling ~2.3, ≈half already storey-tall >2.6), - # so a blanket +0.25 is unsafe. Left as the raw surveyed value. + # #1601 RE-ADJUDICATED — add the 0.25 m upper-storey joist void, matching + # the gov-API/Elmhurst mappers (whose lodged upper height is a CLEAR internal + # ceiling; `_UPPER_FLOOR_HEIGHT_ADD_M` at mapper.py:5492 / :6697). The earlier + # "keep raw" call was made while from_site_notes still dropped + # `percent_draughtproofed` — that pinned the §2 (15) window-infiltration term + # at its 0.25-ACH worst case on every dwelling, suppressing the whole cohort, + # so omitting +0.25 (which lowers SAP) spuriously looked correct on the 7 + # verified dwellings. With draught-proofing restored the +0.25 is required: + # draught-proofing + upper-floor height + built-form sheltered_sides together + # move all 7 verified dwellings toward truth (none regress) and lift the + # cohort 62.1%→83.3% within-0.5 / MAE 0.507→0.377 vs the SAP-10.2 oracle + # (property_baseline_performance.effective_sap_score, portfolio 838). The + # three are non-additive — each overshoots alone — and MUST land together. is_exposed, is_above_partial = _pashub_floor_exposure(floor_type) dimensions: List[SapFloorDimension] = [] for floor in floors: @@ -5984,9 +6024,14 @@ def _map_floor_dimensions( # over heated space (#1602). Mirrors the gov-API/Elmhurst siblings, # which only flag the ground SapFloorDimension. is_ground = floor_number == 0 + room_height = ( + floor.height_m + if is_ground + else floor.height_m + _UPPER_FLOOR_HEIGHT_ADD_M + ) dimensions.append( SapFloorDimension( - room_height_m=floor.height_m, + room_height_m=room_height, total_floor_area_m2=floor.area_m2, party_wall_length_m=floor.pwl_m, heat_loss_perimeter_m=floor.heat_loss_perimeter_m, @@ -6625,13 +6670,38 @@ def _pashub_cylinder_insulation_type_code( return code -def _map_sap_ventilation(ventilation: Ventilation) -> SapVentilation: +# PAS Hub `detachment_type` label → RdSAP §S5 sheltered-sides count (mirrors the +# gov-API `_API_BUILT_FORM_TO_SHELTERED_SIDES` int map). from_site_notes left +# `sheltered_sides` None → the calculator's flat default of 2, which over-shelters +# end/semi/detached (spec 1/1/0) — an over-credit that masked the cohort-wide +# under-rate. Keyed case-insensitively; an unmapped label falls through to None +# (calculator default 2), the prior behaviour, rather than raising. +_PASHUB_BUILT_FORM_TO_SHELTERED_SIDES: Dict[str, int] = { + "detached": 0, + "semi-detached": 1, + "end-terrace": 1, + "mid-terrace": 2, + "enclosed end-terrace": 2, + "enclosed mid-terrace": 3, +} + + +def _map_sap_ventilation( + ventilation: Ventilation, built_form: Optional[str] = None +) -> SapVentilation: return SapVentilation( ventilation_type=ventilation.ventilation_type, mechanical_ventilation_kind=_pashub_mechanical_ventilation_kind( ventilation.ventilation_type ), + sheltered_sides=_PASHUB_BUILT_FORM_TO_SHELTERED_SIDES.get( + (built_form or "").strip().lower() + ), draught_lobby=ventilation.draught_lobby, + # `has_draught_lobby` is the canonical §2 (13) gate the cascade reads in + # preference to the legacy `draught_lobby` field; the site-notes path + # previously left it None, dropping the surveyed lobby. Mirror Elmhurst. + has_draught_lobby=ventilation.draught_lobby, pressure_test=ventilation.pressure_test, open_flues_count=ventilation.number_of_open_flues, closed_flues_count=ventilation.number_of_closed_flues, @@ -7817,16 +7887,26 @@ _PASHUB_HEAT_NETWORK_SYSTEM_TYPES: frozenset[str] = frozenset({ # PasHub Site-Notes main-heating "Controls" labels for a Table 4e Group 3 heat -# network → their SAP code. All four "charging system linked to use of community -# heating" codes (2306/2310/2312/2314) are calculation-identical — control_type -# 3, DLF 1.00/1.00, temperature_adjustment 0.0 (`cert_to_inputs.py` -# `_CONTROL_TYPE_BY_CODE` / DLF / temp-adjustment maps) — so the cohort's -# "...room thermostat only" resolves to 2306, the domain's modal heat-network -# control code (`domain/epc/property_overlays/main_heating_system_overlay.py`, -# `HANDOVER_POST_S0380_176`). Every code here is in `_CONTROL_TYPE_BY_CODE`. +# network → their SAP code. Table 4e Group 3 has two independent axes: +# * charging: flat-rate (DHW factor 1.05) vs linked-to-use (DHW factor 1.00); +# * control kit: no thermostat / room thermostat only (no TRVs) / thermostat +# + TRVs → control_type 1 / 2 / 3 and space-charging factor 1.10 / 1.05 / 1.00. +# The label "charging system linked to use of community heating, room thermostat +# only" fixes BOTH axes: linked-to-use (DHW 1.00) AND thermostat-only/no-TRVs +# (type 2, space 1.05) → code 2308/2309 (calculation-identical). It is NOT 2306, +# which is the linked-to-use WITH-TRVs code (type 3, space 1.00) — that asserts +# TRVs the label excludes and over-rated 16 Bingley (56.89 vs pashub 52; 2308 → +# 54.01, the residual +2.0 being the documented SAP-10.2-engine-vs-lodged offset, +# NOT a fuel/flags gap — those were threaded by the #1590 follow-up). It is also +# NOT 2303, a FLAT-RATE code (DHW 1.05, type 1) whose oracle match (52.28) is a +# coincidence of two offsetting spec violations. Codes verified against +# `cert_to_inputs.py` `_CONTROL_TYPE_BY_CODE` (2308→2), space-charging (2308→1.05) +# and DHW (2308→1.00) maps + the RdSAP label vocab in +# `etl/epc_clean/epc_attributes/MainheatControlAttributes.py`. (Supersedes the +# earlier 2306 mapping and the ADR-0053 / HANDOVER_838 "fuel/flags" attribution.) _PASHUB_HEAT_NETWORK_CONTROL_TO_SAP10: Dict[str, int] = { "Charging system linked to use of community heating, room thermostat only": ( - 2306 + 2308 ), } diff --git a/datatypes/epc/domain/tests/test_from_site_notes.py b/datatypes/epc/domain/tests/test_from_site_notes.py index de16a931c..5a21a6db5 100644 --- a/datatypes/epc/domain/tests/test_from_site_notes.py +++ b/datatypes/epc/domain/tests/test_from_site_notes.py @@ -1,5 +1,6 @@ import json import os +from dataclasses import replace from datetime import date from typing import Any, Dict, Optional @@ -1677,9 +1678,11 @@ class TestFromSiteNotesExample1: ) def test_floor_height(self, result: EpcPropertyData) -> None: - # floors[0].height_m: 2.37 (Floor 1, upper) + # floors[0].height_m: 2.37 (Floor 1, upper) + 0.25 m joist-void = 2.62. + # The +0.25 m upper-storey add now mirrors the gov-API/Elmhurst mappers + # (see _map_floor_dimensions #1601 re-adjudication). assert ( - result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.37 + result.sap_building_parts[0].sap_floor_dimensions[0].room_height_m == 2.62 ) def test_heat_loss_perimeter(self, result: EpcPropertyData) -> None: @@ -1913,7 +1916,7 @@ class TestFromSiteNotesExample1: party_wall_construction=4, # "Cavity Masonry, Unfilled" → SAP10 code 4 (U=0.5) sap_floor_dimensions=[ SapFloorDimension( - room_height_m=2.37, + room_height_m=2.62, # 2.37 raw + 0.25 m upper-storey joist void total_floor_area_m2=24.78, party_wall_length_m=6.15, heat_loss_perimeter_m=14.21, @@ -1957,6 +1960,9 @@ class TestFromSiteNotesExample1: has_conservatory=False, blocked_chimneys_count=0, draughtproofed_door_count=2, + # §2 (15) draught-proofing: area-weighted % of draught-proofed + # windows (all 4 windows draught-proofed → 100). Previously dropped. + percent_draughtproofed=100, report_reference="49D422A9-0779-44DD-9665-464D35DFF1A8", number_of_storeys=2, any_unheated_rooms=True, @@ -1974,11 +1980,139 @@ class TestFromSiteNotesExample1: flueless_gas_fires_count=0, pressure_test="No test", draught_lobby=False, + has_draught_lobby=False, + # RdSAP §S5 sheltered sides from built form: Mid-terrace → 2. + # Previously left None (calculator default 2); now set explicitly + # so end/semi/detached get their correct 1/1/0 instead of 2. + sheltered_sides=2, ), ) assert result == expected +class TestFromSiteNotesInfiltrationFixes: + """Focused coverage for the three §2 infiltration inputs that + `from_site_notes` previously dropped — `percent_draughtproofed`, the + +0.25 m upper-storey joist void, and built-form `sheltered_sides`. All + three are set by the gov-API/Elmhurst sibling mappers; restoring them + together corrects a cohort-wide SAP under-rate vs the accredited SAP-10.2 + oracle (62.1%→83.3% within-0.5). Fixture example1 is a Mid-terrace house + with 4 all-draught-proofed windows and raw storey heights 2.35 (ground) / + 2.37 (upper). + """ + + @pytest.fixture + def survey(self) -> PasHubRdSapSiteNotes: + return from_dict( + PasHubRdSapSiteNotes, load("pashub_rdsap_site_notes_example1.json") + ) + + # --- percent_draughtproofed (§2 (15)) --- + + def test_all_windows_proofed_gives_100( + self, survey: PasHubRdSapSiteNotes + ) -> None: + result = EpcPropertyDataMapper.from_site_notes(survey) + assert result.percent_draughtproofed == 100 + + def test_percent_is_area_weighted_not_count_based( + self, survey: PasHubRdSapSiteNotes + ) -> None: + # Un-proof only the LARGEST window; the percentage must fall by that + # window's AREA share, which differs from a naive (N-1)/N count. + windows = survey.windows + big = max( + range(len(windows)), + key=lambda i: windows[i].width_m * windows[i].height_m, + ) + s = replace( + survey, + windows=[ + replace(w, draught_proofed=False) if i == big else w + for i, w in enumerate(windows) + ], + ) + result = EpcPropertyDataMapper.from_site_notes(s) + + total = sum(w.width_m * w.height_m for w in windows) + proofed = sum( + w.width_m * w.height_m for i, w in enumerate(windows) if i != big + ) + expected_area_pct = int(round(100.0 * proofed / total)) + count_pct = int(round(100.0 * (len(windows) - 1) / len(windows))) + assert result.percent_draughtproofed is not None + assert result.percent_draughtproofed == expected_area_pct + assert result.percent_draughtproofed != count_pct # proves AREA weighting + assert result.percent_draughtproofed < 100 + + def test_no_windows_proofed_gives_0( + self, survey: PasHubRdSapSiteNotes + ) -> None: + s = replace( + survey, windows=[replace(w, draught_proofed=False) for w in survey.windows] + ) + result = EpcPropertyDataMapper.from_site_notes(s) + assert result.percent_draughtproofed == 0 + + def test_no_windows_gives_none(self, survey: PasHubRdSapSiteNotes) -> None: + # No openings to weight → None (not 0), so the calculator applies its + # own default rather than a spurious 0% draught-proofing. + s = replace(survey, windows=[]) + result = EpcPropertyDataMapper.from_site_notes(s) + assert result.percent_draughtproofed is None + + # --- +0.25 m upper-storey joist void --- + + def test_upper_floor_gets_joist_void_ground_does_not( + self, survey: PasHubRdSapSiteNotes + ) -> None: + result = EpcPropertyDataMapper.from_site_notes(survey) + parts = result.sap_building_parts + assert parts is not None + heights = {fd.floor: fd.room_height_m for fd in parts[0].sap_floor_dimensions} + assert heights[0] == 2.35 # ground floor: raw surveyed height + assert heights[1] == 2.62 # upper: 2.37 raw + 0.25 m joist void + + # --- built-form sheltered_sides (RdSAP §S5) --- + + @pytest.mark.parametrize( + "detachment_type, expected_sides", + [ + ("Detached", 0), + ("Semi-Detached", 1), + ("End-terrace", 1), + ("Mid-terrace", 2), + ("Enclosed End-terrace", 2), + ("Enclosed Mid-terrace", 3), + ], + ) + def test_sheltered_sides_from_built_form( + self, + survey: PasHubRdSapSiteNotes, + detachment_type: str, + expected_sides: int, + ) -> None: + s = replace( + survey, + general=replace(survey.general, detachment_type=detachment_type), + ) + result = EpcPropertyDataMapper.from_site_notes(s) + assert result.sap_ventilation is not None + assert result.sap_ventilation.sheltered_sides == expected_sides + + def test_unknown_built_form_leaves_sheltered_sides_none( + self, survey: PasHubRdSapSiteNotes + ) -> None: + # An unmapped label must fall through to None (calculator default 2), + # never silently mis-map to a wrong shelter count. + s = replace( + survey, general=replace(survey.general, detachment_type="Park home") + ) + result = EpcPropertyDataMapper.from_site_notes(s) + assert result.sap_ventilation is not None + assert result.sap_ventilation.sheltered_sides is None + + class TestFromSiteNotesVentilation: """ Fixture: pashub_rdsap_site_notes_example1.json @@ -2428,8 +2562,9 @@ class TestPasHubMainHeatingControlCoding: def test_heat_network_control_maps_to_group3_code(self) -> None: # Arrange — a Table 4e Group 3 heat network (`system_type = # "Community heating system"`). Its control label carries a Group 3 - # (23xx) code, not the boiler 21xx; the accredited Elmhurst certs for - # this same cohort lodge 2306 for it (HANDOVER_POST_S0380_176). + # (23xx) code, not the boiler 21xx. "charging linked to use … room + # thermostat only" is linked-to-use (DHW 1.00) + thermostat-only/no-TRVs + # → code 2308, control type 2 (NOT 2306, the with-TRVs type-3 code). from domain.sap10_calculator.rdsap.cert_to_inputs import ( _control_type, # pyright: ignore[reportPrivateUsage] ) @@ -2452,9 +2587,10 @@ class TestPasHubMainHeatingControlCoding: survey ).sap_heating.main_heating_details[0] - # Assert — the Group 3 code, and its spec control type (3). - assert main.main_heating_control == 2306 - assert _control_type(main) == 3 + # Assert — the Group 3 linked-to-use, thermostat-only code and its + # spec control type (2). + assert main.main_heating_control == 2308 + assert _control_type(main) == 2 def test_heat_network_unmapped_control_label_strict_raises(self) -> None: # A community-system control label the Group 3 lookup does not cover @@ -2472,6 +2608,54 @@ class TestPasHubMainHeatingControlCoding: EpcPropertyDataMapper.from_site_notes(survey) +class TestPasHubLowEnergyLighting: + """When the assessor does not enumerate exact LED/CFL bulbs, PAS Hub lodges + an aggregate "Number of fixed low energy lights?" count. `from_site_notes` + must thread it into `low_energy_fixed_lighting_bulbs_count` (mirroring the + Elmhurst path) so §L uses the surveyed low-energy count rather than the + pessimistic no-lighting-data default that under-rates SAP.""" + + def test_aggregate_count_threaded_when_exact_unknown(self) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["room_count_elements"]["exact_led_cfl_count_known"] = False + data["room_count_elements"]["number_of_fixed_low_energy_lights"] = 9 + survey = from_dict(PasHubRdSapSiteNotes, data) + + result = EpcPropertyDataMapper.from_site_notes(survey) + + assert result.low_energy_fixed_lighting_bulbs_count == 9 + + def test_aggregate_count_ignored_when_exact_counts_known(self) -> None: + # When exact LED/CFL counts are lodged, the per-type counts are + # authoritative and the aggregate field must not shadow them. + data = load("pashub_rdsap_site_notes_example1.json") + data["room_count_elements"]["exact_led_cfl_count_known"] = True + data["room_count_elements"]["number_of_fixed_low_energy_lights"] = 9 + survey = from_dict(PasHubRdSapSiteNotes, data) + + result = EpcPropertyDataMapper.from_site_notes(survey) + + assert result.low_energy_fixed_lighting_bulbs_count is None + + +class TestPasHubDraughtLobby: + """`from_site_notes` must set the canonical §2 (13) gate `has_draught_lobby` + (the cascade reads it in preference to the legacy `draught_lobby` field); + leaving it None dropped the surveyed lobby and over-stated infiltration.""" + + @pytest.mark.parametrize("surveyed, expected", [(True, True), (False, False)]) + def test_has_draught_lobby_threaded_from_survey( + self, surveyed: bool, expected: bool + ) -> None: + data = load("pashub_rdsap_site_notes_example1.json") + data["ventilation"]["draught_lobby"] = surveyed + survey = from_dict(PasHubRdSapSiteNotes, data) + + result = EpcPropertyDataMapper.from_site_notes(survey) + + assert result.sap_ventilation.has_draught_lobby is expected + + class TestPasHubUnmappedHeatEmitter: """A PasHub survey whose surveyed main-heating emitter label the mapper does not yet cover must strict-raise `UnmappedPasHubLabel` at the boundary, diff --git a/datatypes/epc/surveys/pashub_rdsap_site_notes.py b/datatypes/epc/surveys/pashub_rdsap_site_notes.py index 6b8c77de2..1a1135c8a 100644 --- a/datatypes/epc/surveys/pashub_rdsap_site_notes.py +++ b/datatypes/epc/surveys/pashub_rdsap_site_notes.py @@ -317,6 +317,7 @@ class RoomCountElements: number_of_fixed_cfl_bulbs: int waste_water_heat_recovery: str number_of_heated_rooms: Optional[int] = None + number_of_fixed_low_energy_lights: Optional[int] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 1fc568e03..cd68d5099 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7910,7 +7910,26 @@ def cert_to_inputs( if water_main is not None and water_main.main_heating_index_number is not None else None ) - if water_pcdb_main is not None and water_pcdb_main.summer_efficiency_pct is not None: + # DHW from a SEPARATE electric immersion heater (WHC 903) is not served by + # any boiler — SAP 10.2 Table 4a lists the immersion at 100% efficiency and + # SAP §9.4.11 / Appendix D2.1 Eq D1 (the boiler seasonal-efficiency cascade) + # applies only when "the boiler provides both space and water heating". But + # `_water_heating_main` resolves to the SPACE main (a gas/oil combi still has + # a PCDB record), so without this gate both the scalar `water_eff` and the + # Eq-D1 pair below would bill electric-immersion DHW at the boiler's ~87% + # summer efficiency instead of 100% (mongrel: electric fuel price × gas-boiler + # efficiency). Fires for #1600's no-water-heating default (WHC 999 → 903) with + # a gas-boiler main; the immersion path (`_water_efficiency_with_category_ + # inherit` → 1.0) is the correct fallback, mirroring the Table 3 zero-primary- + # loss gate already applied for WHC 903. + dhw_is_electric_immersion = ( + epc.sap_heating.water_heating_code == _WHC_ELECTRIC_IMMERSION + ) + if ( + water_pcdb_main is not None + and water_pcdb_main.summer_efficiency_pct is not None + and not dhw_is_electric_immersion + ): water_eff = water_pcdb_main.summer_efficiency_pct / 100.0 else: water_eff = _water_efficiency_with_category_inherit( @@ -7972,6 +7991,10 @@ def cert_to_inputs( no_interlock and water_pcdb_main is not None and epc.has_hot_water_cylinder + # Not for a separate electric immersion (WHC 903): the -5pp boiler + # interlock adjustment has no meaning for a 100%-efficient immersion + # element (see the electric-immersion note above). + and not dhw_is_electric_immersion ): water_eff -= 0.05 # Resolve the (winter, summer) seasonal efficiency pair that feeds @@ -7993,6 +8016,10 @@ def cert_to_inputs( pcdb_main is not None and pcdb_main.winter_efficiency_pct is not None and pcdb_main.summer_efficiency_pct is not None + # Not when DHW is a separate electric immersion (WHC 903): the boiler + # does not provide water heating, so Eq D1 must not apply its seasonal + # efficiency to the immersion-heated cylinder (see the note above). + and not dhw_is_electric_immersion ): eq_d1_winter_summer_pct = ( pcdb_main.winter_efficiency_pct, 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 578cb67de..a59aef297 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -962,6 +962,61 @@ def test_off_peak_immersion_unlodged_type_defaults_to_dual_table13_blend() -> No assert inputs.hot_water_high_rate_fraction == pytest.approx(_frac_from_cost) +def test_electric_immersion_dhw_not_billed_at_gas_boiler_efficiency() -> None: + # Arrange — a separate electric immersion heater (WHC 903) is 100% efficient + # (SAP 10.2 Table 4a) and the boiler provides no water heating, so Appendix + # D2.1 Eq D1 (the boiler seasonal-efficiency cascade) does not apply. But + # `_water_heating_main` resolves to the SPACE main — a gas combi keeps its + # PCDB record — so pre-fix both the scalar water efficiency and the Eq-D1 + # winter/summer pair billed the immersion-heated cylinder at the boiler's + # ~87% summer efficiency (a mongrel: electric fuel price × gas-boiler + # efficiency). Fires for #1600's no-water-heating default (WHC 999 -> 903) + # on a gas-boiler main — 58 Hackle St M11 4WU (SAP 55.30 -> 57.67). + from dataclasses import replace + + pcdb_gas_boiler = replace( + _gas_boiler_detail(sap_main_heating_code=102), + # PCDB gas combi (winter 88.7% / summer 87.0%) — a real record so the + # buggy branches would have summer-efficiency data to (wrongly) apply. + main_heating_index_number=16839, + ) + boiler_epc = _cylinder_epc(cylinder_size=2, main=pcdb_gas_boiler) + boiler_epc = replace( + boiler_epc, + sap_heating=replace( + boiler_epc.sap_heating, water_heating_code=903, water_heating_fuel=29 + ), + ) + + # A non-boiler electric main (SAP code 401, no PCDB record) is the reference + # for a correctly-billed 100%-efficient immersion: Eq D1 cannot engage. + electric_main = MainHeatingDetail( + has_fghrs=False, + main_fuel_type=29, + heat_emitter_type=0, + emitter_temperature=1, + main_heating_control=2401, + main_heating_category=7, + sap_main_heating_code=401, + ) + electric_epc = replace( + boiler_epc, + sap_heating=replace(boiler_epc.sap_heating, main_heating_details=[electric_main]), + ) + + # Act + boiler_inputs = cert_to_inputs(boiler_epc) + electric_inputs = cert_to_inputs(electric_epc) + + # Assert — the immersion DHW energy is identical whether the SPACE main is a + # PCDB gas boiler or a non-boiler electric system: the boiler's efficiency + # never touches the immersion-heated cylinder. Pre-fix the boiler variant was + # inflated by ~1/0.878. + assert boiler_inputs.hot_water_kwh_per_yr == pytest.approx( + electric_inputs.hot_water_kwh_per_yr + ) + + def test_non_integrated_storage_heater_bills_100_percent_low_rate() -> None: # Arrange — same off-peak storage cert but SAP code 401 ("other storage # heaters"): Table 12a Grid 1 gives a 0.00 high-rate fraction → the heat diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index a0a45a31e..8e90dac15 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -386,7 +386,12 @@ _MIN_WITHIN_HALF_SAP = 0.788 # The -0.672 overshoot is expected to move again when the C4 plant- # efficiency and C3.2 pumping siblings land. Unit-pinned in # test_cert_to_inputs. -_MAX_SAP_MAE = 0.626 +# Ratcheted 0.626 -> 0.625 by the electric-immersion water-efficiency fix +# (WHC 903 DHW no longer billed at the space boiler's PCDB seasonal +# efficiency / interlock -5pp — SAP Table 4a immersion = 100%): observed +# within-0.5 78.9%, MAE 0.622. A handful of corpus certs with a gas-boiler +# space main + separate electric immersion moved closer to accredited. +_MAX_SAP_MAE = 0.625 _MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current