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." ), ), )