Slice 102f-prep.8: API mapper resolves shower_outlets=None → 0 mixers

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 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-27 15:54:25 +00:00 committed by Jun-te Kim
parent 5f9978ca33
commit dfe2f2ce6e
3 changed files with 54 additions and 13 deletions

View file

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

View file

@ -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 certinputs cascade's `_mixer_shower_flow_rates_from_cert`
still keeps a None1 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:

View file

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