diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 435df408..ed509c99 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -54,6 +54,13 @@ _SAP_ABS_TOLERANCE = 0 _PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01 _CO2_ABS_TOLERANCE_TONNES = 0.001 +# Worksheet-pin tolerances (calc − Elmhurst dr87 worksheet, full precision). +# These are deterministic so the tolerances are tight; they lock the +# current residual against the worksheet's full-precision (286)/(272) +# rather than the integer-rounded lodged register values. +_WS_PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01 +_WS_CO2_ABS_TOLERANCE_KG = 0.01 + @dataclass(frozen=True) class _GoldenExpectation: @@ -473,6 +480,96 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( ) +@dataclass(frozen=True) +class _WorksheetPin: + """Full-precision PE / CO2 targets read from a cert's Elmhurst dr87 + worksheet (the "CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY + ENERGY" block of the *current* dwelling), plus the recorded calc + residual against them. + + Unlike `_GoldenExpectation` — which compares against the integer- + rounded lodged register values (`energy_consumption_current` / + `co2_emissions_current`) — these pin against the worksheet's + unrounded `(286)` primary energy and `(272)` CO2. That makes the + residual a *calculator-vs-Elmhurst* signal, free of register + rounding: a non-zero `expected_pe_resid` here is a genuine calc gap, + not lodged noise. + + `ws_pe_kwh_per_m2` = worksheet (286) / worksheet (4) total floor area + (the worksheet's own decimal TFA, not the JSON's integer); the + calculator uses the same decimal TFA, so the comparison is + apples-to-apples. `ws_co2_kg_per_yr` = worksheet (272) total CO2. + """ + + cert_number: str + ws_pe_kwh_per_m2: float + ws_co2_kg_per_yr: float + expected_pe_resid: float + expected_co2_resid_kg: float + + +# The 47 worksheet-validated certs (9 ASHP + 38 cohort-2). Findings at +# capture (HEAD post-S0380.185), calc − worksheet: +# - CO2: exact on 37/47 (<0.02 kg); the 10 higher-consumption gas certs +# carry a small −0.5..−1.1 kg under-count. +# - PE : exact on 37/47 (<0.05 kWh/m²); the SAME 10 carry a +0.5..+1.5 +# kWh/m² over-count. +# PE-over + CO2-under on the same certs is the fingerprint of a small +# gas→electricity fuel-split difference (electricity PE 1.51 > gas 1.13, +# but electricity CO2 0.136 < gas 0.21), not a factor-value error — the +# next slice candidate. Values frozen from the dr87 PDFs (untracked, so +# not parsed at test time) per the worksheet_unrounded_sap convention. +_WORKSHEET_PE_CO2: tuple[_WorksheetPin, ...] = ( + _WorksheetPin(cert_number="0036-6325-1100-0063-1226", ws_pe_kwh_per_m2=213.4019, ws_co2_kg_per_yr=2125.4851, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0100-5141-0522-4696-3463", ws_pe_kwh_per_m2=53.4939, ws_co2_kg_per_yr=427.6895, expected_pe_resid=+0.0235, expected_co2_resid_kg=+0.0195), + _WorksheetPin(cert_number="0200-3155-0122-2602-3563", ws_pe_kwh_per_m2=192.4660, ws_co2_kg_per_yr=2191.4589, expected_pe_resid=+1.1381, expected_co2_resid_kg=-1.0649), + _WorksheetPin(cert_number="0300-2403-2650-2206-0235", ws_pe_kwh_per_m2=224.9069, ws_co2_kg_per_yr=2445.3496, expected_pe_resid=+1.2239, expected_co2_resid_kg=-1.0351), + _WorksheetPin(cert_number="0310-2763-5450-2506-3501", ws_pe_kwh_per_m2=233.8452, ws_co2_kg_per_yr=1715.8602, expected_pe_resid=+1.4339, expected_co2_resid_kg=-0.8667), + _WorksheetPin(cert_number="0320-2126-2150-2326-6161", ws_pe_kwh_per_m2=177.7940, ws_co2_kg_per_yr=2312.8161, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0320-2756-8640-2296-1101", ws_pe_kwh_per_m2=45.7367, ws_co2_kg_per_yr=430.2596, expected_pe_resid=+0.0263, expected_co2_resid_kg=+0.0247), + _WorksheetPin(cert_number="0330-2249-8150-2326-4121", ws_pe_kwh_per_m2=199.4413, ws_co2_kg_per_yr=3066.3286, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0330-2257-3640-2196-3145", ws_pe_kwh_per_m2=66.2620, ws_co2_kg_per_yr=435.0043, expected_pe_resid=+0.0189, expected_co2_resid_kg=+0.0093), + _WorksheetPin(cert_number="0350-2968-2650-2796-5255", ws_pe_kwh_per_m2=55.7024, ws_co2_kg_per_yr=470.7988, expected_pe_resid=+0.0164, expected_co2_resid_kg=+0.0146), + _WorksheetPin(cert_number="0360-2266-5650-2106-8285", ws_pe_kwh_per_m2=162.9804, ws_co2_kg_per_yr=2183.7720, expected_pe_resid=+0.6841, expected_co2_resid_kg=-0.7413), + _WorksheetPin(cert_number="0380-2471-3250-2596-8761", ws_pe_kwh_per_m2=56.4872, ws_co2_kg_per_yr=292.5490, expected_pe_resid=+0.0387, expected_co2_resid_kg=+0.0199), + _WorksheetPin(cert_number="0380-2530-6150-2326-4161", ws_pe_kwh_per_m2=174.9107, ws_co2_kg_per_yr=2368.5251, expected_pe_resid=+0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="0390-2066-4250-2026-4555", ws_pe_kwh_per_m2=176.7478, ws_co2_kg_per_yr=2500.4581, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="0464-3032-0205-4276-3204", ws_pe_kwh_per_m2=179.2365, ws_co2_kg_per_yr=1845.9475, expected_pe_resid=+0.9242, expected_co2_resid_kg=-0.8342), + _WorksheetPin(cert_number="0652-3022-1205-2826-1200", ws_pe_kwh_per_m2=251.0214, ws_co2_kg_per_yr=2828.3691, expected_pe_resid=+0.9740, expected_co2_resid_kg=-0.7228), + _WorksheetPin(cert_number="1536-9325-5100-0433-1226", ws_pe_kwh_per_m2=180.8432, ws_co2_kg_per_yr=2054.3609, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2007-3011-9205-8136-3204", ws_pe_kwh_per_m2=172.6227, ws_co2_kg_per_yr=2567.5298, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2031-3007-0205-1296-3204", ws_pe_kwh_per_m2=191.4198, ws_co2_kg_per_yr=2257.9561, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2102-3018-0205-7886-5204", ws_pe_kwh_per_m2=228.1961, ws_co2_kg_per_yr=4104.7798, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2130-3018-4205-4686-5204", ws_pe_kwh_per_m2=181.4083, ws_co2_kg_per_yr=2364.3480, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2225-3062-8205-2856-7204", ws_pe_kwh_per_m2=52.6750, ws_co2_kg_per_yr=389.8819, expected_pe_resid=+0.0272, expected_co2_resid_kg=+0.0209), + _WorksheetPin(cert_number="2336-3124-3600-0517-1292", ws_pe_kwh_per_m2=68.1077, ws_co2_kg_per_yr=458.6131, expected_pe_resid=+0.0171, expected_co2_resid_kg=+0.0085), + _WorksheetPin(cert_number="2536-2525-0600-0788-2292", ws_pe_kwh_per_m2=87.5683, ws_co2_kg_per_yr=375.6003, expected_pe_resid=+0.0107, expected_co2_resid_kg=+0.0045), + _WorksheetPin(cert_number="2590-3025-7205-9066-0200", ws_pe_kwh_per_m2=171.8691, ws_co2_kg_per_yr=2396.4327, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="2636-0525-2600-0401-2296", ws_pe_kwh_per_m2=52.5660, ws_co2_kg_per_yr=395.4880, expected_pe_resid=+0.0212, expected_co2_resid_kg=+0.0168), + _WorksheetPin(cert_number="2699-3025-5205-8066-0200", ws_pe_kwh_per_m2=168.4755, ws_co2_kg_per_yr=2498.3764, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="2800-7999-0322-4594-3563", ws_pe_kwh_per_m2=89.2727, ws_co2_kg_per_yr=395.0757, expected_pe_resid=+0.0141, expected_co2_resid_kg=+0.0067), + _WorksheetPin(cert_number="3136-7925-4500-0246-6202", ws_pe_kwh_per_m2=238.6376, ws_co2_kg_per_yr=1752.3516, expected_pe_resid=+1.4560, expected_co2_resid_kg=-0.8858), + _WorksheetPin(cert_number="3336-2825-9400-0512-8292", ws_pe_kwh_per_m2=84.7840, ws_co2_kg_per_yr=458.0332, expected_pe_resid=+0.0099, expected_co2_resid_kg=+0.0058), + _WorksheetPin(cert_number="3800-8515-0922-3398-3563", ws_pe_kwh_per_m2=58.7712, ws_co2_kg_per_yr=440.6740, expected_pe_resid=+0.0195, expected_co2_resid_kg=+0.0156), + _WorksheetPin(cert_number="4536-5424-8600-0109-1226", ws_pe_kwh_per_m2=63.9133, ws_co2_kg_per_yr=494.6357, expected_pe_resid=+0.0207, expected_co2_resid_kg=+0.0176), + _WorksheetPin(cert_number="4536-8325-3100-0409-1222", ws_pe_kwh_per_m2=181.7206, ws_co2_kg_per_yr=2109.2633, expected_pe_resid=-0.0000, expected_co2_resid_kg=+0.0000), + _WorksheetPin(cert_number="4800-3992-0422-0599-3563", ws_pe_kwh_per_m2=66.4814, ws_co2_kg_per_yr=259.3652, expected_pe_resid=+0.0417, expected_co2_resid_kg=+0.0188), + _WorksheetPin(cert_number="6835-3920-2509-0933-5226", ws_pe_kwh_per_m2=224.4924, ws_co2_kg_per_yr=1476.3032, expected_pe_resid=+0.0360, expected_co2_resid_kg=-0.0013), + _WorksheetPin(cert_number="7700-3362-0922-7022-3563", ws_pe_kwh_per_m2=196.5859, ws_co2_kg_per_yr=2321.5875, expected_pe_resid=+0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="7800-1501-0922-7127-3563", ws_pe_kwh_per_m2=172.9406, ws_co2_kg_per_yr=3144.0259, expected_pe_resid=-0.0000, expected_co2_resid_kg=-0.0000), + _WorksheetPin(cert_number="7836-3125-0600-0526-2202", ws_pe_kwh_per_m2=183.0794, ws_co2_kg_per_yr=1817.2248, expected_pe_resid=+0.8789, expected_co2_resid_kg=-0.7461), + _WorksheetPin(cert_number="9036-0824-3500-0420-8222", ws_pe_kwh_per_m2=56.7016, ws_co2_kg_per_yr=433.6372, expected_pe_resid=+0.0192, expected_co2_resid_kg=+0.0155), + _WorksheetPin(cert_number="9285-3062-0205-7766-7200", ws_pe_kwh_per_m2=56.9079, ws_co2_kg_per_yr=454.7771, expected_pe_resid=+0.0185, expected_co2_resid_kg=+0.0156), + _WorksheetPin(cert_number="9370-3060-1205-3546-4204", ws_pe_kwh_per_m2=51.9889, ws_co2_kg_per_yr=494.0023, expected_pe_resid=+0.0242, expected_co2_resid_kg=+0.0229), + _WorksheetPin(cert_number="9380-2957-7490-2595-3141", ws_pe_kwh_per_m2=207.1976, ws_co2_kg_per_yr=2176.1656, expected_pe_resid=+0.7198, expected_co2_resid_kg=-0.5344), + _WorksheetPin(cert_number="9418-3062-8205-3566-7200", ws_pe_kwh_per_m2=58.5508, ws_co2_kg_per_yr=394.3858, expected_pe_resid=+0.0201, expected_co2_resid_kg=+0.0124), + _WorksheetPin(cert_number="9421-3045-3205-1646-6200", ws_pe_kwh_per_m2=59.6459, ws_co2_kg_per_yr=295.3567, expected_pe_resid=+0.0288, expected_co2_resid_kg=+0.0145), + _WorksheetPin(cert_number="9501-3059-8202-7356-0204", ws_pe_kwh_per_m2=182.3673, ws_co2_kg_per_yr=3554.1642, expected_pe_resid=+0.4570, expected_co2_resid_kg=-0.7517), + _WorksheetPin(cert_number="9796-3058-6205-0346-9200", ws_pe_kwh_per_m2=53.6467, ws_co2_kg_per_yr=198.7122, expected_pe_resid=+0.0432, expected_co2_resid_kg=+0.0183), + _WorksheetPin(cert_number="9836-7525-9500-0575-1202", ws_pe_kwh_per_m2=253.8868, ws_co2_kg_per_yr=3101.1029, expected_pe_resid=+0.0366, expected_co2_resid_kg=+0.0026), +) + + def _load_cert(cert_number: str) -> dict[str, Any]: """Load one frozen cert document from the fixtures directory.""" path = _FIXTURES_DIR / f"{cert_number}.json" @@ -530,6 +627,47 @@ def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> No ) +@pytest.mark.parametrize( + "pin", + _WORKSHEET_PE_CO2, + ids=lambda p: p.cert_number, +) +def test_golden_cert_pe_co2_matches_worksheet(pin: _WorksheetPin) -> None: + """Pin the demand cascade's PE / CO2 against the cert's Elmhurst dr87 + worksheet at full precision — the calculator-vs-Elmhurst signal that + the lodged-register residual (`test_golden_cert_residual_matches_pin`) + can't give, because lodged values are integer-rounded. + + The worksheet's published *Current* PE `(286)` and CO2 `(272)` come + from its postcode-climate "CALCULATION OF EPC COSTS, EMISSIONS AND + PRIMARY ENERGY" block — so we drive the same `cert_to_demand_inputs` + (postcode climate) cascade the EPC publishes, not the UK-average SAP + cascade. + """ + # Arrange + doc = _load_cert(pin.cert_number) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + demand = calculate_sap_from_inputs( + cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + ) + pe_resid = demand.primary_energy_kwh_per_m2 - pin.ws_pe_kwh_per_m2 + co2_resid_kg = demand.co2_kg_per_yr - pin.ws_co2_kg_per_yr + + # Assert + assert abs(pe_resid - pin.expected_pe_resid) <= _WS_PE_ABS_TOLERANCE_KWH_PER_M2, ( + f"PE residual vs worksheet {pe_resid:+.4f} kWh/m² drifted from pin " + f"{pin.expected_pe_resid:+.4f} (tolerance " + f"±{_WS_PE_ABS_TOLERANCE_KWH_PER_M2})." + ) + assert abs(co2_resid_kg - pin.expected_co2_resid_kg) <= _WS_CO2_ABS_TOLERANCE_KG, ( + f"CO2 residual vs worksheet {co2_resid_kg:+.4f} kg/yr drifted from " + f"pin {pin.expected_co2_resid_kg:+.4f} (tolerance " + f"±{_WS_CO2_ABS_TOLERANCE_KG})." + ) + + # Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number # 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain # must surface that PCDB winter efficiency on `inputs.main_heating_efficiency` @@ -579,6 +717,4 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( main = epc.sap_heating.main_heating_details[0] assert main.main_heating_index_number == expected_pcdb_id if expected_winter_eff is not None: - assert inputs.main_heating_efficiency == pytest.approx( - expected_winter_eff, abs=1e-3 - ) + assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3