diff --git a/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf b/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf new file mode 100644 index 00000000..48a5273e Binary files /dev/null and b/backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf differ diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index f7099f18..11cecf73 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -42,7 +42,7 @@ Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors. from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from typing import Final, Optional, TYPE_CHECKING from domain.sap10_calculator.climate.appendix_u import external_temperature_c @@ -863,6 +863,25 @@ class Sap10Calculator(SapCalculator): """ def calculate(self, epc: "EpcPropertyData") -> SapResult: - from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs + # SAP 10.2 Appendix U paragraph 1 (p.124): the SAP and EI ratings are + # computed on UK-average climate (so ratings are nationally + # comparable), but "other calculations (such as for energy use and + # costs on EPCs) are done using local weather" — the EPC-displayed + # CO2 emissions and primary energy use postcode-district weather from + # the PCDB. So we run two climate cascades and graft the demand + # cascade's CO2/PE onto the rating cascade's SAP result. (Worked + # example: simulated case 45 — rating SAP 60.53/CO2 692.13 on + # UK-average; demand CO2 626.78/PE 6581.59 on the W6 postcode.) + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + cert_to_demand_inputs, + cert_to_inputs, + ) - return calculate_sap_from_inputs(cert_to_inputs(epc)) + rating = calculate_sap_from_inputs(cert_to_inputs(epc)) + demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) + return replace( + rating, + co2_kg_per_yr=demand.co2_kg_per_yr, + primary_energy_kwh_per_yr=demand.primary_energy_kwh_per_yr, + primary_energy_kwh_per_m2=demand.primary_energy_kwh_per_m2, + ) diff --git a/tests/domain/modelling/test_elmhurst_cascade_pins.py b/tests/domain/modelling/test_elmhurst_cascade_pins.py index 0e6832b7..19186eae 100644 --- a/tests/domain/modelling/test_elmhurst_cascade_pins.py +++ b/tests/domain/modelling/test_elmhurst_cascade_pins.py @@ -686,12 +686,15 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None: # dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot # of the Vaillant overlay's own output, validated transitively by the # system-boiler pin below (which reproduces a real Vaillant cert at delta 0). + # CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator. + # calculate` computes EPC emissions/PE on local weather (SAP 10.2 + # Appendix U p.124); SAP is unchanged (UK-average rating cascade). _assert_overlay_scores( before, option.overlay, sap=51.99820176096402, - co2=1268.4645083243888, - pe=13080.20756425629, + co2=1065.7593506066496, + pe=10995.781557709413, ) @@ -715,12 +718,14 @@ def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw # boiler-1 pin above); the same merge also resolved this cert's main-fuel # mapper gap (§14.2 mains-gas derivation), so its raw before now baselines — # see `test_gas_boiler_instant_hw_before_baselines`. + # CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above); + # SAP is unchanged (UK-average rating cascade). _assert_overlay_scores( before, option.overlay, sap=39.00740809309464, - co2=2248.6089062232704, - pe=23094.10189037302, + co2=1845.8588018295509, + pe=18944.42568846759, ) diff --git a/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py new file mode 100644 index 00000000..7d9ab95f --- /dev/null +++ b/tests/domain/sap10_calculator/worksheet/_elmhurst_worksheet_001431_case45.py @@ -0,0 +1,107 @@ +"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431 +"simulated case 45" worksheet — a ~47 m² GROUND-FLOOR FLAT heated by an +air-source HEAT PUMP (PCDB 100053 ECODAN, radiators, MCS=No) with a +WHC-903 electric-immersion DHW and a 110 L cylinder, postcode W6 9BF +(SAP Region "Thames Valley"). + +Case 45 is the 1e-4 oracle for the SAP 10.2 Appendix U (PDF p.124) TWO- +CLIMATE-CASCADE split. The P960 prints the current dwelling TWICE: + + * Block 1 — "11a. SAP rating / 12a. CO2" — computed on UK-AVERAGE + weather (Appendix U Tables U1-U3 region 0). Drives the SAP/EI rating. + Space-heat demand (98c) = 7333.79; SAP value (258) = 60.5318 (-> 61); + total CO2 (272) = 692.13. + * Block 2 — "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY" — + computed on POSTCODE-DISTRICT weather (PCDB Table 172, W6). Drives the + EPC-displayed figures. Space-heat demand (98c) = 5921.05; total CO2 + (272) = 626.78; total primary energy (286) = 6581.59. + +Per Appendix U paragraph 1: "Other calculations (such as for energy use +and costs on EPCs) are done using local weather." `Sap10Calculator. +calculate` therefore runs both cascades and grafts the demand cascade's +CO2/PE onto the rating cascade's SAP — this fixture pins BOTH. + +Like the other `_elmhurst_worksheet_001431_case*` fixtures it does NOT +hand-build the EpcPropertyData: it routes the Summary PDF through +ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises +the WHOLE extractor + mapper + calculator pipeline. + +Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/ +simulated case 45/`. The Summary is mirrored into the tracked +`backend/documents_parser/tests/fixtures/Summary_001431_case45.pdf` so the +test runs without depending on the unstaged workspace. + +Per [[feedback-zero-error-strict]]: pins are abs <= 1e-4 against the PDF. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path +from typing import Final + +from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor +from datatypes.epc.domain.epc_property_data import EpcPropertyData +from datatypes.epc.domain.mapper import EpcPropertyDataMapper + +# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/, +# [4]=repo root. +_SUMMARY_PDF: Final[Path] = ( + Path(__file__).resolve().parents[4] + / "backend" / "documents_parser" / "tests" / "fixtures" + / "Summary_001431_case45.pdf" +) + +# Block 1 — UK-average RATING cascade (`cert_to_inputs`). +RATING_SPACE_HEATING_KWH: Final[float] = 7333.7892 # (98c) +RATING_SAP_CONTINUOUS: Final[float] = 60.5318 # (258) un-rounded +RATING_SAP_INTEGER: Final[int] = 61 # (258) +RATING_CO2_KG_PER_YR: Final[float] = 692.1287 # (272) + +# Block 2 — POSTCODE-district DEMAND cascade (`cert_to_demand_inputs`). +DEMAND_SPACE_HEATING_KWH: Final[float] = 5921.0486 # (98c) +DEMAND_CO2_KG_PER_YR: Final[float] = 626.7797 # (272) +DEMAND_PRIMARY_ENERGY_KWH: Final[float] = 6581.5936 # (286) + + +def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]: + """Convert a Summary PDF into the per-page text format the + ElmhurstSiteNotesExtractor expects (label/value token sequences). + Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures. + """ + info = subprocess.run( + ["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True, + ).stdout + m = re.search(r"Pages:\s+(\d+)", info) + if m is None: + raise RuntimeError(f"Could not parse page count from {pdf_path}") + page_count = int(m.group(1)) + pages: list[str] = [] + for i in range(1, page_count + 1): + layout = subprocess.run( + [ + "pdftotext", "-layout", "-f", str(i), "-l", str(i), + str(pdf_path), "-", + ], + capture_output=True, text=True, check=True, + ).stdout + tokens: list[str] = [] + for line in layout.splitlines(): + if not line.strip(): + tokens.append("") + continue + parts = [p for p in re.split(r"\s{2,}", line.strip()) if p] + tokens.extend(parts) + pages.append("\n".join(tokens)) + return pages + + +def build_epc() -> EpcPropertyData: + """Route the simulated case-45 Summary through extractor + mapper. + No hand-built EpcPropertyData — the extractor and mapper are part of + the test target. This module is a pin PROVIDER (build_epc + constants); + the collected assertions live in `test_section_cascade_pins`.""" + pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF) + site_notes = ElmhurstSiteNotesExtractor(pages).extract() + return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes) diff --git a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py index 1637f281..1625bcbc 100644 --- a/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py +++ b/tests/domain/sap10_calculator/worksheet/test_e2e_elmhurst_sap_score.py @@ -24,7 +24,10 @@ from typing import Final import pytest -from domain.sap10_calculator.calculator import Sap10Calculator +from domain.sap10_calculator.calculator import ( + Sap10Calculator, + calculate_sap_from_inputs, +) from domain.sap10_calculator.rdsap.cert_to_inputs import ( cert_to_inputs, water_heating_section_from_cert, @@ -338,8 +341,13 @@ def test_sap_result_pin(fixture_name: str, field_name: str) -> None: epc = _FIXTURE_MODULES[fixture_name].build_epc() expected = getattr(pin, field_name) - # Act - result = Sap10Calculator().calculate(epc) + # Act — these pins are the worksheet's Block-1 (energy-rating) line refs, + # i.e. the UK-average RATING cascade. `Sap10Calculator.calculate` now + # grafts the postcode DEMAND cascade's CO2/PE onto the result (SAP 10.2 + # Appendix U p.124), so the rating-cascade fields are pinned via + # `cert_to_inputs` directly; the demand cascade is pinned separately + # (corpus gauge + simulated case 45 Block-2 pins). + result = calculate_sap_from_inputs(cert_to_inputs(epc)) actual = getattr(result, field_name) # Assert diff --git a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py index 6547a585..ce93e21c 100644 --- a/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py +++ b/tests/domain/sap10_calculator/worksheet/test_section_cascade_pins.py @@ -46,6 +46,7 @@ from tests.domain.sap10_calculator.worksheet import ( _elmhurst_worksheet_001431_case21 as _w001431_case21, _elmhurst_worksheet_001431_case43 as _w001431_case43, _elmhurst_worksheet_001431_case44 as _w001431_case44, + _elmhurst_worksheet_001431_case45 as _w001431_case45, ) @@ -491,6 +492,67 @@ def test_case44_blower_door_pressure_test_matches_pdf() -> None: _pin(vent.effective_monthly_ach[0], 0.5812, "§2 (25) Jan case44") +def test_case45_heat_pump_two_climate_cascade_matches_pdf() -> None: + """Simulated case 45 (heat-pump ground-floor flat, postcode W6) is the + 1e-4 oracle for the SAP 10.2 Appendix U (p.124) two-climate-cascade + split. The P960 prints the current dwelling twice: + + * Block 1 ("11a SAP rating / 12a CO2") on UK-AVERAGE weather (region + 0): space heat (98c) 7333.79, SAP (258) 60.5318, CO2 (272) 692.13. + * Block 2 ("EPC COSTS, EMISSIONS AND PRIMARY ENERGY") on POSTCODE + weather (PCDB Table 172, W6): space heat (98c) 5921.05, CO2 (272) + 626.78, primary energy (286) 6581.59. + + The SAP/EI rating reads the rating cascade; the EPC-displayed CO2/PE + read the demand cascade. Pins both ends at abs=1e-4.""" + # Arrange + from domain.sap10_calculator.calculator import calculate_sap_from_inputs + from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_demand_inputs + + epc = _w001431_case45.build_epc() + # The split only exists because the postcode resolves to local weather. + assert local_climate_for_cert(epc) is not None + + # Act — both climate cascades from the one cert. + rating = calculate_sap_from_inputs(cert_to_inputs(epc)) + demand = calculate_sap_from_inputs(cert_to_demand_inputs(epc)) + + # Assert — Block 1 (UK-average rating cascade). + _pin( + rating.space_heating_kwh_per_yr, + _w001431_case45.RATING_SPACE_HEATING_KWH, + "(98c) rating case45", + ) + _pin( + rating.sap_score_continuous, + _w001431_case45.RATING_SAP_CONTINUOUS, + "(258) rating case45", + ) + assert rating.sap_score == _w001431_case45.RATING_SAP_INTEGER + _pin( + rating.co2_kg_per_yr, + _w001431_case45.RATING_CO2_KG_PER_YR, + "(272) rating case45", + ) + + # Assert — Block 2 (postcode demand cascade). + _pin( + demand.space_heating_kwh_per_yr, + _w001431_case45.DEMAND_SPACE_HEATING_KWH, + "(98c) demand case45", + ) + _pin( + demand.co2_kg_per_yr, + _w001431_case45.DEMAND_CO2_KG_PER_YR, + "(272) demand case45", + ) + _pin( + demand.primary_energy_kwh_per_yr, + _w001431_case45.DEMAND_PRIMARY_ENERGY_KWH, + "(286) demand case45", + ) + + def test_case6_main_2_emitter_and_control_extracted() -> None: """Simulated case 6's §14.1 Main Heating2 lodges its OWN emitter ("Underfloor Heating") and control ("SAP code 2110, ...") — the two diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index 7b510ab3..ca4681e4 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -30,11 +30,7 @@ from typing import Any import pytest from datatypes.epc.domain.mapper import EpcPropertyDataMapper -from domain.sap10_calculator.calculator import calculate_sap_from_inputs -from domain.sap10_calculator.rdsap.cert_to_inputs import ( - SAP_10_2_SPEC_PRICES, - cert_to_inputs, -) +from domain.sap10_calculator.calculator import Sap10Calculator _CORPUS = Path( "backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl" @@ -127,10 +123,25 @@ _CORPUS = Path( # "ground floor" floor_type as exposed (worksheet-validated to 1e-4 on simulated # case 45: floor (28a) 0 -> 25.38 W/K, fabric (33) 75.6 -> 101.01) -> 69.5% -> # 69.7% (MAE 0.859 -> 0.854). Pinned in test_heat_transmission. +# POSTCODE DEMAND CASCADE (SAP 10.2 Appendix U paragraph 1, p.124): the +# CO2/PE over-estimate diagnosed above as "per-cert mapper/demand fidelity" +# was largely a CLIMATE-cascade bug. The SAP/EI rating is computed on +# UK-average weather (Tables U1-U3 region 0), but EPC-displayed energy use, +# CO2 emissions and primary energy use POSTCODE-DISTRICT weather from PCDB +# Table 172 — "other calculations (such as for energy use and costs on EPCs) +# are done using local weather". We were feeding the UK-average demand to all +# three outputs, so warm-region certs (most of England, warmer than the +# UK-average) over-counted heating demand → CO2/PE high. `Sap10Calculator. +# calculate` now grafts the demand cascade's CO2/PE onto the rating cascade's +# SAP. Across the corpus this moved CO2 MAE 0.26 -> 0.12 t/yr (bias +0.18 -> +# +0.04) and PE MAE 13.6 -> 3.8 kWh/m2/yr (bias +9.0 -> +0.24); SAP unchanged +# (rating cascade). Worksheet-validated to 1e-4 on simulated case 45 (rating +# CO2 692.13; demand CO2 626.78, PE 6581.59). The residual PE/CO2 spread is +# now the genuine per-cert mapper-fidelity tail. _MIN_WITHIN_HALF_SAP = 0.695 _MAX_SAP_MAE = 0.86 -_MAX_CO2_MAE_TONNES = 0.30 # t CO2 / yr vs co2_emissions_current -_MAX_PE_PER_M2_MAE = 14.0 # kWh / m2 / yr vs energy_consumption_current +_MAX_CO2_MAE_TONNES = 0.13 # t CO2 / yr vs co2_emissions_current +_MAX_PE_PER_M2_MAE = 4.2 # kWh / m2 / yr vs energy_consumption_current def _load_corpus() -> list[dict[str, Any]]: @@ -155,8 +166,12 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( co2_signed_errs_t: list[float] = [] # our − lodged, tonnes/yr pe_signed_errs: list[float] = [] # our − lodged, kWh/m²/yr skipped = 0 + _calculator = Sap10Calculator() # Act — run the API → EpcPropertyData → calculator pipeline per cert. + # `Sap10Calculator.calculate` runs both climate cascades (SAP 10.2 + # Appendix U p.124): the SAP rating on UK-average weather, CO2/PE on + # postcode-district weather — exactly the two figures the EPC lodges. for doc in corpus: lodged_sap = doc.get("energy_rating_current") if lodged_sap is None: @@ -164,9 +179,7 @@ def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus( continue try: epc = EpcPropertyDataMapper.from_api_response(doc) - result = calculate_sap_from_inputs( - cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) - ) + result = _calculator.calculate(epc) except Exception: # A mapper / calculator raise is a coverage gap tracked elsewhere # (eval_api_sap_accuracy.py); here we gauge the certs that compute.