From bf99b1c7dfdb222f45460d2b2236a8b9cdfeaf7b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 28 May 2026 19:17:02 +0000 Subject: [PATCH] Slice S0380.48: surface real-API pv_batteries[].battery_capacity (5 kWh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 7-cert ASHP+battery PE cluster was overshooting by +2.7..+8.1 kWh/m² after the PE β-split landed in S0380.45. The handover hypothesised an E_PV magnitude bug ("cascade thinks 2570 kWh/yr vs worksheet 831"). The worksheet PDF for cert 0380 (dr87-0001-000899.pdf line 233) was verified to show **-2563.3692** kWh/yr — matching our cascade. The real bug was different: the **5-kWh battery wasn't reaching the cascade**, so β-coefficients used the no-battery branch (C1=1.61, β≈0.36) instead of the 5-kWh branch (C1=1.12, β≈0.75). Per SAP 10.2 Appendix M1 §3c-d (p.94): "C_bat is the usable capacity of the battery in kWh, limited to a maximum value of 15 kWh. C_bat=0 if no battery present." Cert 0380 lodges `pv_battery_count: 1` and `pv_batteries: [{"battery_capacity": 5}]` — but the schema's `PvBatteries` dataclass had only `pv_battery: Optional[PvBattery]`, matching the older synthetic fixture shape (nested `{"pv_battery": {"battery_capacity": 5}}`). The real-API payload's flat `battery_capacity: 5` was silently dropped during `from_dict`. Two surgical changes: - `datatypes/epc/schema/rdsap_schema_21_0_1.py`: add `battery_capacity: Optional[float] = None` as a sibling to `pv_battery` on `PvBatteries`. Synthetic-shape certs continue to populate the nested form; real-API certs now populate the flat form. - `datatypes/epc/domain/mapper.py:_first_pv_battery`: prefer nested when present, fall back to the flat lifted field. Domain still exposes a single uniform `PvBatteries(pv_battery=PvBattery(...))` shape downstream. Cohort impact (PE residual kWh/m² vs worksheet): | Cert | Pre-S0380.48 | Post-S0380.48 | |---|---:|---:| | 0350 | +2.73 | -3.58 | | 0380 | +8.09 | -4.01 | | 2225 | +4.48 | -4.50 | | 2636 | +3.42 | -4.14 | | 3800 | +3.58 | -4.01 | | 9285 | +3.20 | -3.46 | | 9418 | +4.67 | -3.76 | Cluster magnitude dropped from +2.7..+8.1 to -3.5..-4.5 — the cascade now over-credits PV by ~4 kWh/m² (vs previously under-crediting by ~5 kWh/m²). The residual flipped sign because cascade β=0.75-0.81 slightly exceeds worksheet β=0.74 (read from page-3 line 233a/233b ratio 1903.39/2563.37 = 0.7426). The remaining ~4 kWh/m² under-shoot traces to two structural factors deferred until a fresh closure slice ships: 1. The synthetic-default `pv_export_primary_factor = 0.501` is the annual Table 12 code-60 value. The worksheet uses the effective monthly Table 12e factor weighted by E_PV,ex,m (cert 0380: 0.4268 = -0.074 differential). The cascade's `_effective_monthly_pe_ factor` already computes the same weighting for PV — but the calculator's PV PE credit reads `inputs.other_primary_factor` (=1.501) and `inputs.pv_export_primary_factor` (=0.501) directly, bypassing the per-end-use effective-monthly cascade. 2. Cascade β slightly higher than worksheet (0.751 vs 0.7426 on cert 0380) — likely a monthly-distribution detail in D_PV. SAP scores remain exact across the cohort (residual +0 every cert). CO2 residuals all <0.11 t/yr (well within the 0.001-tolerance pin range after re-pin). 9501 (PV no battery) preserved at +0.255 PE / -0.047 CO2 — no regression. Re-pins all 7 golden fixtures in the same slice per [[feedback-commit-per-slice]]. Pyright net-zero on touched files (32 errors before, 32 after). --- datatypes/epc/domain/mapper.py | 20 +++++- datatypes/epc/schema/rdsap_schema_21_0_1.py | 9 +++ .../rdsap/tests/test_golden_fixtures.py | 69 ++++++++++--------- 3 files changed, 61 insertions(+), 37 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b37c7f77..3b5539e2 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1916,7 +1916,13 @@ def _first_pv_battery( schema_pv_batteries: Any, ) -> Optional[PvBatteries]: """SapEnergySource.pv_batteries is a list in real-API certs and a single - dataclass in the older synthetic fixture. Pick the first battery if any.""" + dataclass in the older synthetic fixture. Pick the first battery if any. + + Real-API certs lift `battery_capacity` onto the item itself + (`[{"battery_capacity": 5}]`); the synthetic fixture wraps it under + `pv_battery` (`{"pv_battery": {"battery_capacity": 5}}`). Schema-level + `PvBatteries` exposes both: prefer nested when present, fall back to + the lifted flat value.""" if schema_pv_batteries is None: return None if isinstance(schema_pv_batteries, list): @@ -1925,9 +1931,17 @@ def _first_pv_battery( first = schema_pv_batteries[0] else: first = schema_pv_batteries - if first.pv_battery is None: + if first.pv_battery is not None: + return PvBatteries( + pv_battery=PvBattery(battery_capacity=first.pv_battery.battery_capacity) + ) + flat_capacity = cast( + Optional[float], + first.battery_capacity, # pyright: ignore[reportUnknownMemberType] + ) + if flat_capacity is None: return None - return PvBatteries(pv_battery=PvBattery(battery_capacity=first.pv_battery.battery_capacity)) + return PvBatteries(pv_battery=PvBattery(battery_capacity=flat_capacity)) def _first_shower_outlet( diff --git a/datatypes/epc/schema/rdsap_schema_21_0_1.py b/datatypes/epc/schema/rdsap_schema_21_0_1.py index d3adb1b9..e8925863 100644 --- a/datatypes/epc/schema/rdsap_schema_21_0_1.py +++ b/datatypes/epc/schema/rdsap_schema_21_0_1.py @@ -92,7 +92,16 @@ class PvBattery: class PvBatteries: # Real-API certs carry pv_batteries as a list (similar to shower_outlets); # the older synthetic fixture used a single-object wrapper. + # + # Two payload shapes coexist: + # real API : [{"battery_capacity": 5}] — flat, lifted + # synthetic: {"pv_battery": {"battery_capacity": 5}} — nested + # `battery_capacity` is the lifted-flat field for the real-API shape; + # `pv_battery` retains the legacy nested form for synthetic certs. + # `_first_pv_battery` in the mapper prefers nested when present and + # falls back to flat — covers both shapes without divergence. pv_battery: Optional[PvBattery] = None + battery_capacity: Optional[float] = None @dataclass diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index a9700b50..de06cd7e 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -256,93 +256,94 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = ( cert_number="0380-2471-3250-2596-8761", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+8.0916, - expected_co2_resid_tonnes_per_yr=-0.0540, + expected_pe_resid_kwh_per_m2=-4.0121, + expected_co2_resid_tonnes_per_yr=-0.0549, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, semi-detached bungalow " "TFA 60.43 age D, PV 3 kWp + 5 kWh battery. Worksheet SAP " - "88.5104. Slice S0380.45 wired SAP 10.2 Appendix M1 §3-4 " - "β-split into the PE cascade: residual flipped from -14.60 " - "to +8.09. The remaining +8 over-shoot points to the EPV " - "magnitude bug (cascade thinks 2570 kWh PV / yr vs worksheet " - "831 kWh / yr — 3× over-estimate), which keeps R_PV high and " - "β low. Slice S0380.46 audits the EPV cascade — kWp " - "interpretation, S lookup, or ZPV mapping." + "88.5104. Slice S0380.48 surfaced the 5-kWh battery to the " + "domain mapper: real-API certs lodge `pv_batteries` as " + "`[{\"battery_capacity\": 5}]` (flat list) but the schema " + "expected a nested `{\"pv_battery\": {\"battery_capacity\": " + "5}}`. With battery surfaced, β rises 0.36 → 0.75 (vs " + "worksheet 0.74), flipping PE residual +8.09 → -4.01. The " + "remaining -4 under-shoot traces to the export PE factor " + "(synthetic default 0.501 vs worksheet's effective monthly " + "Table 12e ~0.427) plus a small β fine-tuning gap." ), ), _GoldenExpectation( cert_number="0350-2968-2650-2796-5255", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+2.7315, - expected_co2_resid_tonnes_per_yr=-0.0841, + expected_pe_resid_kwh_per_m2=-3.5819, + expected_co2_resid_tonnes_per_yr=-0.0865, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " - "PV + 5 kWh battery. Worksheet SAP 84.1367. Slice S0380.45 " - "shifted PE residual -7.78 → +2.73 via Appendix M1 β-split. " - "Same EPV-magnitude shape as cert 0380 (see notes there)." + "PV + 5 kWh battery. Worksheet SAP 84.1367. Slice S0380.48 " + "battery-surface fix shifted PE residual +2.73 → -3.58." ), ), _GoldenExpectation( cert_number="2225-3062-8205-2856-7204", actual_sap=89, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+4.4804, - expected_co2_resid_tonnes_per_yr=-0.0711, + expected_pe_resid_kwh_per_m2=-4.5030, + expected_co2_resid_tonnes_per_yr=-0.0729, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " - "PV + 5 kWh battery. Worksheet SAP 88.7921. Slice S0380.45 " - "shifted PE residual -11.77 → +4.48 via Appendix M1 β-split." + "PV + 5 kWh battery. Worksheet SAP 88.7921. Slice S0380.48 " + "battery-surface fix shifted PE residual +4.48 → -4.50." ), ), _GoldenExpectation( cert_number="2636-0525-2600-0401-2296", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+3.4216, - expected_co2_resid_tonnes_per_yr=-0.0581, + expected_pe_resid_kwh_per_m2=-4.1432, + expected_co2_resid_tonnes_per_yr=-0.0603, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " "PV + 5 kWh battery + 3.74 m² cantilever + 12.76 m² alt wall. " - "Worksheet SAP 86.2641. Slice S0380.45 shifted PE residual " - "-9.65 → +3.42 via Appendix M1 β-split." + "Worksheet SAP 86.2641. Slice S0380.48 battery-surface fix " + "shifted PE residual +3.42 → -4.14." ), ), _GoldenExpectation( cert_number="3800-8515-0922-3398-3563", actual_sap=86, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+3.5809, - expected_co2_resid_tonnes_per_yr=-0.0142, + expected_pe_resid_kwh_per_m2=-4.0132, + expected_co2_resid_tonnes_per_yr=-0.0166, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " - "PV + 5 kWh battery. Worksheet SAP 86.1458. Slice S0380.45 " - "shifted PE residual -9.61 → +3.58 via Appendix M1 β-split." + "PV + 5 kWh battery. Worksheet SAP 86.1458. Slice S0380.48 " + "battery-surface fix shifted PE residual +3.58 → -4.01." ), ), _GoldenExpectation( cert_number="9285-3062-0205-7766-7200", actual_sap=84, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+3.1982, - expected_co2_resid_tonnes_per_yr=-0.0979, + expected_pe_resid_kwh_per_m2=-3.4619, + expected_co2_resid_tonnes_per_yr=-0.1003, notes=( "Mitsubishi PUZ-WM50VHA PCDB 104568, ASHP cohort cert with " - "PV + 5 kWh battery. Worksheet SAP 84.1369. Slice S0380.45 " - "shifted PE residual -7.96 → +3.20 via Appendix M1 β-split." + "PV + 5 kWh battery. Worksheet SAP 84.1369. Slice S0380.48 " + "battery-surface fix shifted PE residual +3.20 → -3.46." ), ), _GoldenExpectation( cert_number="9418-3062-8205-3566-7200", actual_sap=85, expected_sap_resid=+0, - expected_pe_resid_kwh_per_m2=+4.6681, - expected_co2_resid_tonnes_per_yr=-0.0463, + expected_pe_resid_kwh_per_m2=-3.7627, + expected_co2_resid_tonnes_per_yr=-0.0485, notes=( "Daikin Altherma EDLQ05CAV3 PCDB 102421 (heating_duration " "code '24' — continuous, all days at Th) + 5 kWh battery. " - "Worksheet SAP 84.6305. Slice S0380.45 shifted PE residual " - "-7.30 → +4.67 via Appendix M1 β-split." + "Worksheet SAP 84.6305. Slice S0380.48 battery-surface fix " + "shifted PE residual +4.67 → -3.76." ), ), )