Slice S0380.48: surface real-API pv_batteries[].battery_capacity (5 kWh)

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).
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-28 19:17:02 +00:00
parent 42ed38f77d
commit bf99b1c7df
3 changed files with 61 additions and 37 deletions

View file

@ -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(

View file

@ -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

View file

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