From dfe2f2ce6ee840e78467dd37d27429494e53809d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 27 May 2026 15:54:25 +0000 Subject: [PATCH] =?UTF-8?q?Slice=20102f-prep.8:=20API=20mapper=20resolves?= =?UTF-8?q?=20shower=5Foutlets=3DNone=20=E2=86=92=200=20mixers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cert 2225 (Mitsubishi PUZ-WM50VHA, semi-detached 2-bp, TFA 82.49) lodges `sap_heating.shower_outlets = None` in the Open EPC API JSON. The worksheet (42a) "Hot water usage for mixer showers" reads 0 every month — Elmhurst's convention is "absent ⇒ no shower". Pre-fix the API mapper returned `mixer_shower_count = None`, deferring to the cert→inputs cascade's "RdSAP modal lodging" default of 1 vented mixer. That added ~7 L/day to (44) daily HW use, ~113 kWh/yr to (62) HW demand, and shifted cert 2225's SAP residual from -0.31 → +0.04 (now aligned with the cohort's +0.03..+0.06 cluster) once the mapper returns 0. `_count_shower_outlets_by_type` now treats None as 0 (the API mapper-only path). The cert→inputs cascade's `_mixer_shower_flow_rates_from_cert` keeps the None→1 default for the Elmhurst hand-built fixture path that doesn't route through this helper. Cohort impact: 6 of 7 ASHP certs now cluster at SAP Δ +0.03 to +0.06 (vs worksheet); only cert 2636 remains an outlier (+0.49). Golden cert PE/CO2 pins re-pinned for 6035, 8135, 0390 (the three certs that previously lodged shower_outlets=None and consumed the spurious 1-mixer default). Co-Authored-By: Claude Opus 4.7 --- .../tests/test_summary_pdf_mapper_chain.py | 28 +++++++++++++++++++ datatypes/epc/domain/mapper.py | 14 +++++++--- .../rdsap/tests/test_golden_fixtures.py | 25 +++++++++++------ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py index 5d3d8683..fa8dbe6e 100644 --- a/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py +++ b/backend/documents_parser/tests/test_summary_pdf_mapper_chain.py @@ -685,6 +685,34 @@ _API_9418_JSON = ( ) +_API_2225_JSON = ( + Path(__file__).parents[3] + / "domain/sap10_calculator/rdsap/tests/fixtures/golden" + / "2225-3062-8205-2856-7204.json" +) + + +def test_api_2225_no_mixer_lodged_uses_zero_showers_per_worksheet() -> None: + # Arrange — cert 2225 lodges `mixer_shower_count = None` (the field + # is unlodged in the API JSON, not "0"). The worksheet (42a) "Hot + # water usage for mixer showers" shows 0.0000 every month — the + # Elmhurst convention is "absent ⇒ no shower". Cascade previously + # defaulted to a single 7 L/min vented mixer when unlodged, which + # raised (44) daily HW use from 122.89 → 130.56 l/day (Jan) and + # added ~113 kWh/yr to (62) HW demand. The cohort-modal lodging + # is 0 (5/7 certs lodge mixer=0 explicitly). + doc = json.loads(_API_2225_JSON.read_text()) + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Act + inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES) + + # Assert — HW fuel kWh tracks worksheet (247) 1634.04 at 1e-1 + # (η_water = 172.85 implies demand 2824.44; fuel = demand / η). + worksheet_hw_fuel_kwh = 1634.04 + assert abs(inputs.hot_water_kwh_per_yr - worksheet_hw_fuel_kwh) <= 0.1 + + def test_api_9418_daikin_24h_duration_mean_internal_temp_matches_worksheet_92() -> None: # Arrange — cert 9418 (Daikin Altherma EDLQ05CAV3, PCDB 102421) # lodges `heating_duration_code = "24"`. Per SAP 10.2 Table N4 (PDF diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 28493ebe..2be18112 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1971,16 +1971,22 @@ def _count_shower_outlets_by_type( schema_shower_outlets: Any, target_type: int, ) -> Optional[int]: """Count how many outlets in the schema list lodge the given - `shower_outlet_type` integer. Returns None when the schema field - is None or empty (the cascade reads None as "use the spec default" - rather than 0 — RdSAP modal lodging assumption). + `shower_outlet_type` integer. Returns 0 when the schema field is + None — the Open EPC API convention is "no shower_outlets entry + means no showers". Cert 2225 confirms: API lodges `shower_outlets + = None` and the worksheet (42a) "Hot water usage for mixer + showers" reads 0 for every month. + + (The cert→inputs cascade's `_mixer_shower_flow_rates_from_cert` + still keeps a None→1 default for the Elmhurst hand-built fixture + path, which doesn't route through this helper.) Assumes the input has been passed through `_normalize_shower_outlets` first — every list element is the wrapped `ShowerOutlets(shower_outlet=ShowerOutlet)` shape. """ if schema_shower_outlets is None: - return None + return 0 if not isinstance(schema_shower_outlets, list): outlet = schema_shower_outlets.shower_outlet if outlet is None: diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index a4bee41d..23a3a8a2 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -140,14 +140,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="6035-7729-2309-0879-2296", actual_sap=70, expected_sap_resid=-6, - expected_pe_resid_kwh_per_m2=+47.8483, - expected_co2_resid_tonnes_per_yr=+1.0911, + expected_pe_resid_kwh_per_m2=+46.7562, + expected_co2_resid_tonnes_per_yr=+1.0652, notes=( "Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. " "Slice 59 per-bp window apportionment tightens all 3 " "residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → " "+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs " - "Main ins_type 3, lowering Ext1's net wall U-loss)." + "Main ins_type 3, lowering Ext1's net wall U-loss). Slice " + "102f-prep.8 mapper fix: shower_outlets=None now resolves to " + "0 mixers (was 1) — drops daily HW by ~7 l/day → PE +47.85 " + "→ +46.76, CO2 +1.09 → +1.07." ), ), _GoldenExpectation( @@ -173,15 +176,17 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="8135-1728-8500-0511-3296", actual_sap=72, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=-3.6590, - expected_co2_resid_tonnes_per_yr=-0.0432, + expected_pe_resid_kwh_per_m2=-4.9611, + expected_co2_resid_tonnes_per_yr=-0.0678, notes=( "Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges " "blocked_chimneys_count=1. Slice 59 per-bp window apportionment " "tightens PE -16.98 → -16.51 and CO2 -0.30 → -0.29; SAP " "residual unchanged at +1. Slice 97 added glazing_type=2 — " "SAP residual unchanged (cert rounds to 72 either way); PE " - "-2.41 → -5.31 and CO2 -0.02 → -0.07." + "-2.41 → -5.31 and CO2 -0.02 → -0.07. Slice 102f-prep.8: " + "shower_outlets=None → 0 mixers shifts PE -3.66 → -4.96, " + "CO2 -0.04 → -0.07." ), ), _GoldenExpectation( @@ -208,8 +213,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0390-2254-6420-2126-5561", actual_sap=65, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+1.6962, - expected_co2_resid_tonnes_per_yr=+0.0639, + expected_pe_resid_kwh_per_m2=+0.1521, + expected_co2_resid_tonnes_per_yr=+0.0409, notes=( "End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, " "no PV, no secondary, postcode LN12 (PCDB Table 172 match). " @@ -219,7 +224,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( "infiltration vs the pre-fix zero default). PE / CO2 residuals " "are now small enough that the remaining drivers are likely " "lighting efficacy (schema-21 doesn't carry led_/cfl bulb " - "counts for this cert) + boiler PCDB winter efficiency lookup." + "counts for this cert) + boiler PCDB winter efficiency lookup. " + "Slice 102f-prep.8: shower_outlets=None → 0 mixers tightens " + "PE +1.70 → +0.15 and CO2 +0.06 → +0.04." ), ), # Retired early at P2.2: 9390-2722-3520-2105-8715 (mid-floor flat,