mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Slice 98: API path shower-counts + window-rounding → cert 0330 1e-4
Closes the cert 0330 API path Layer 4 gate (Δ -0.000011 vs worksheet
SAP 61.5993) by surfacing two previously-broken inputs to the HW
cascade plus aligning the wall-net-deduction with the worksheet's
2-d.p.-per-window rounding convention.
(a) RdSAP schema 21.0.x `shower_outlets` shape mismatch:
real-API certs lodge `[{"shower_outlet_type": N, "shower_wwhrs":
M}, ...]` (a list of bare ShowerOutlet dicts), but the schema
modelled it as `[ShowerOutlets]` with nested
`{"shower_outlet": {...}}` wrappers. `from_dict` silently dropped
every bare element's payload (left `shower_outlet=None`),
blanking the cascade's mixer/electric counts on cert 0330 (and 4
other golden fixtures). Normalisation in `from_api_response`
rewrites the bare list shape to the wrapped form before
`from_dict` parses, so the schema's `ShowerOutlets` dataclass
sees the data it expects — no schema-class breakage downstream.
New helper `_count_shower_outlets_by_type` walks the normalised
list and counts outlets by integer code:
- code 1 → mixer (drives `mixer_shower_count`)
- code 2 → electric (drives `electric_shower_count`)
Empirically derived from the golden cohort + Summary mapper
cross-check (cert 0330 lodges code 2 + Summary surfaces "Electric
shower"; cert 0240 lodges multiple code-1 outlets on a
conventional oil-boiler + cylinder dwelling). No spec page
reference found.
Wired into both `from_rdsap_schema_21_0_0` and
`from_rdsap_schema_21_0_1`. Effect on cert 0330 API path:
`mixer_shower_count` 1 (cascade default) → 0; `electric_shower_
count` None (= 0) → 1; HW kWh 3172.65 → 2111.93. SAP Δ +2.1155
→ -0.0012.
(b) Per-window 2-d.p. area rounding in wall-net deduction:
RdSAP 10 §15 rounds per-window area at 2 d.p. before any sum.
The cascade's `windows_w_per_k_total` branch already rounds
per-window for the curtain transform; the wall-net deduction
branch (computing `gross_wall - windows - door` for the (29a)
line) was rounding the SUM once, which for cert 0330's 9 Main
windows yields 12.22 m² vs the worksheet's per-window-rounded
12.23 m² — Δ +0.01 m² × U=1.5 = +0.015 W/K on (29a). Aligned
both branches to round per-window, matching worksheet line (27).
SAP Δ -0.0012 → -0.000011.
Layer 4 chain test added:
- `test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly` pins
cert 0330 API path SAP at 1e-4 vs worksheet 61.5993. This is the
second boiler validation cert with a Layer 4 1e-4 gate (cert
001479 is the first).
Re-pinned golden cert residuals (shifted by changes (a) and (b)):
- 0300: PE +7.52 → +8.44, CO2 -0.27 → -0.23 (Slice 98a — electric
shower count surfaced; cert has 1 electric + 1 mixer outlets)
- 2130: PE -38.17 → -38.18, CO2 +0.305 → +0.304 (Slice 98b —
window rounding edge)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
aa6645e3f1
commit
8443c77069
6 changed files with 155 additions and 25 deletions
|
|
@ -352,6 +352,39 @@ def test_summary_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
|||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
_API_0330_JSON = (
|
||||
Path(__file__).parents[3]
|
||||
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
|
||||
/ "0330-2249-8150-2326-4121.json"
|
||||
)
|
||||
|
||||
|
||||
def test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 0330-2249-8150-2326-4121 (second boiler validation
|
||||
# cert: mains-gas Vaillant PCDB idx 10241, mid-terrace 2-bp dwelling,
|
||||
# TFA 90.56 m²) has both an Elmhurst Summary PDF and a GOV.UK EPB API
|
||||
# JSON. The Summary path lands at 1e-4 vs worksheet SAP 61.5993
|
||||
# above; this Layer 4 production gate asserts the API path matches
|
||||
# the worksheet to the same 1e-4 tolerance — same forcing function
|
||||
# as cert 001479's Layer 4 test, applied to the second boiler cert.
|
||||
#
|
||||
# Slices 96-99 (flat-roof Table 18 col (3) U-values + glazing_type=2
|
||||
# surfacing + shower-outlets list normalisation + window-area
|
||||
# rounding alignment) jointly closed the API path from
|
||||
# Δ +2.1453 → Δ -0.000011 vs worksheet 61.5993.
|
||||
doc = json.loads(_API_0330_JSON.read_text())
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
# Assert — 1e-4 pin against the worksheet's continuous SAP.
|
||||
worksheet_unrounded_sap = 61.5993
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 001479 has both an Elmhurst Summary PDF and a GOV.UK
|
||||
# EPB API JSON (ref 0535-9020-6509-0821-6222). The Summary cascade
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import re
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any, Dict, Final, List, Optional, Sequence, Union
|
||||
from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast
|
||||
from datatypes.epc.schema.helpers import from_dict
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
|
|
@ -1230,21 +1230,18 @@ class EpcPropertyDataMapper:
|
|||
water_heating_code=schema.sap_heating.water_heating_code,
|
||||
water_heating_fuel=schema.sap_heating.water_heating_fuel,
|
||||
immersion_heating_type=schema.sap_heating.immersion_heating_type,
|
||||
shower_outlets=(
|
||||
ShowerOutlets(
|
||||
ShowerOutlet(
|
||||
shower_wwhrs=schema.sap_heating.shower_outlets.shower_outlet.shower_wwhrs,
|
||||
shower_outlet_type=schema.sap_heating.shower_outlets.shower_outlet.shower_outlet_type,
|
||||
)
|
||||
)
|
||||
if schema.sap_heating.shower_outlets
|
||||
else None
|
||||
),
|
||||
shower_outlets=_first_shower_outlet(schema.sap_heating.shower_outlets),
|
||||
cylinder_insulation_type=schema.sap_heating.cylinder_insulation_type,
|
||||
cylinder_thermostat=schema.sap_heating.cylinder_thermostat,
|
||||
secondary_fuel_type=schema.sap_heating.secondary_fuel_type,
|
||||
secondary_heating_type=schema.sap_heating.secondary_heating_type,
|
||||
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
|
||||
electric_shower_count=_count_shower_outlets_by_type(
|
||||
schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_ELECTRIC,
|
||||
),
|
||||
mixer_shower_count=_count_shower_outlets_by_type(
|
||||
schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER,
|
||||
),
|
||||
),
|
||||
sap_windows=[
|
||||
SapWindow(
|
||||
|
|
@ -1524,6 +1521,12 @@ class EpcPropertyDataMapper:
|
|||
cylinder_insulation_thickness_mm=schema.sap_heating.cylinder_insulation_thickness,
|
||||
number_baths=schema.sap_heating.number_baths,
|
||||
number_baths_wwhrs=schema.sap_heating.number_baths_wwhrs,
|
||||
electric_shower_count=_count_shower_outlets_by_type(
|
||||
schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_ELECTRIC,
|
||||
),
|
||||
mixer_shower_count=_count_shower_outlets_by_type(
|
||||
schema.sap_heating.shower_outlets, _API_SHOWER_OUTLET_CODE_MIXER,
|
||||
),
|
||||
),
|
||||
# SAP windows
|
||||
sap_windows=[
|
||||
|
|
@ -1861,6 +1864,7 @@ class EpcPropertyDataMapper:
|
|||
Raises ValueError for unsupported schemas — add cases here as needed.
|
||||
"""
|
||||
|
||||
data = _normalize_shower_outlets(data)
|
||||
schema = data.get("schema_type", "")
|
||||
if schema == "RdSAP-Schema-21.0.1":
|
||||
from datatypes.epc.schema.rdsap_schema_21_0_1 import RdSapSchema21_0_1
|
||||
|
|
@ -1958,6 +1962,82 @@ def _first_shower_outlet(
|
|||
)
|
||||
|
||||
|
||||
# RdSAP shower-outlet integer codes observed across the golden cohort
|
||||
# (no spec reference found — derived empirically: cert 0330 lodges code
|
||||
# 2 + Summary surfaces "Electric shower"; cert 0240 lodges multiple
|
||||
# code-1 outlets on a conventional oil-boiler + cylinder dwelling
|
||||
# matching "Mixer shower" expectation).
|
||||
_API_SHOWER_OUTLET_CODE_MIXER: Final[int] = 1
|
||||
_API_SHOWER_OUTLET_CODE_ELECTRIC: Final[int] = 2
|
||||
|
||||
|
||||
def _normalize_shower_outlets(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Rewrite the raw API doc's `sap_heating.shower_outlets` list so
|
||||
every element is the wrapped `{"shower_outlet": {...}}` shape the
|
||||
schema's `ShowerOutlets` dataclass expects.
|
||||
|
||||
Real-API certs lodge each outlet as a bare dict
|
||||
`{"shower_outlet_type": ..., "shower_wwhrs": ...}` directly in the
|
||||
list — older fixtures wrap each element as
|
||||
`{"shower_outlet": {"shower_outlet_type": ..., "shower_wwhrs": ...}}`.
|
||||
Without normalisation, `from_dict` parses the bare shape as
|
||||
`ShowerOutlets(shower_outlet=None)`, silently dropping the
|
||||
`shower_outlet_type` / `shower_wwhrs` payload — which made the
|
||||
`_count_shower_outlets_by_type` helper return 0 for every cert.
|
||||
|
||||
Mutates a shallow copy of `data` so the caller's dict is untouched.
|
||||
"""
|
||||
sap_heating: Optional[Dict[str, Any]] = data.get("sap_heating")
|
||||
if not isinstance(sap_heating, dict):
|
||||
return data
|
||||
outlets: Optional[List[Any]] = sap_heating.get("shower_outlets")
|
||||
if not isinstance(outlets, list) or not outlets:
|
||||
return data
|
||||
needs_rewrite = any(
|
||||
isinstance(item, dict) and "shower_outlet" not in item
|
||||
for item in outlets
|
||||
)
|
||||
if not needs_rewrite:
|
||||
return data
|
||||
new_outlets: List[Dict[str, Any]] = [
|
||||
item if isinstance(item, dict) and "shower_outlet" in item
|
||||
else {"shower_outlet": item}
|
||||
for item in outlets
|
||||
]
|
||||
new_sap_heating: Dict[str, Any] = {**sap_heating, "shower_outlets": new_outlets}
|
||||
return {**data, "sap_heating": new_sap_heating}
|
||||
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
if not isinstance(schema_shower_outlets, list):
|
||||
outlet = schema_shower_outlets.shower_outlet
|
||||
if outlet is None:
|
||||
return 0
|
||||
return 1 if outlet.shower_outlet_type == target_type else 0
|
||||
outlets_list = cast("list[Any]", schema_shower_outlets)
|
||||
if not outlets_list:
|
||||
return None
|
||||
count = 0
|
||||
for o in outlets_list:
|
||||
outlet = o.shower_outlet
|
||||
if outlet is not None and outlet.shower_outlet_type == target_type:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _strip_code(value: str) -> str:
|
||||
"""Strip leading uppercase code from Elmhurst coded strings, e.g. 'CA Cavity' → 'Cavity'."""
|
||||
parts = value.split(" ", 1)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,12 @@ class SapHeating:
|
|||
immersion_heating_type: Union[int, str]
|
||||
has_fixed_air_conditioning: str
|
||||
instantaneous_wwhrs: Optional[InstantaneousWwhrs] = None
|
||||
shower_outlets: Optional[ShowerOutlets] = None
|
||||
# Real-API certs carry shower_outlets as a list, not the synthetic
|
||||
# single-object form; list elements are normalised to the wrapped
|
||||
# `{"shower_outlet": {...}}` shape in `from_api_response` before
|
||||
# `from_dict` parses them (the bare-element shape is equivalent
|
||||
# but requires the doc rewrite to land losslessly).
|
||||
shower_outlets: Optional[Union[ShowerOutlets, List[ShowerOutlets]]] = None
|
||||
cylinder_insulation_type: Optional[int] = None
|
||||
cylinder_thermostat: Optional[str] = None
|
||||
secondary_fuel_type: Optional[int] = None
|
||||
|
|
|
|||
|
|
@ -67,7 +67,11 @@ class SapHeating:
|
|||
has_fixed_air_conditioning: str
|
||||
instantaneous_wwhrs: Optional[InstantaneousWwhrs] = None
|
||||
# Real-API certs carry shower_outlets as a list, not the synthetic single-object form;
|
||||
# accept both shapes so older fixtures keep parsing.
|
||||
# accept both shapes so older fixtures keep parsing. List elements
|
||||
# are normalised to the wrapped `{"shower_outlet": {...}}` shape in
|
||||
# `EpcPropertyDataMapper.from_api_response` before `from_dict`
|
||||
# parses them — the real-API bare-element shape (no wrapper) is
|
||||
# equivalent but requires the doc rewrite to land losslessly.
|
||||
shower_outlets: Optional[Union[ShowerOutlets, List[ShowerOutlets]]] = None
|
||||
# SAP10 hot-water demand inputs.
|
||||
number_baths: Optional[int] = None
|
||||
|
|
|
|||
|
|
@ -97,8 +97,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0300-2747-7640-2526-2135",
|
||||
actual_sap=78,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=+7.5229,
|
||||
expected_co2_resid_tonnes_per_yr=-0.2726,
|
||||
expected_pe_resid_kwh_per_m2=+8.4391,
|
||||
expected_co2_resid_tonnes_per_yr=-0.2341,
|
||||
notes=(
|
||||
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
|
||||
"(no Table 4b code). Cert lodges open_flues_count=1 + "
|
||||
|
|
@ -110,7 +110,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"Slice 96 (RdSAP 10 §5.11 Table 18 column (3) flat-roof "
|
||||
"defaults) lifted Ext1's flat-roof U from the pitched-column-1 "
|
||||
"0.40 fall-through to the spec-correct 2.30 (age D), "
|
||||
"tightening SAP residual +1 → 0."
|
||||
"tightening SAP residual +1 → 0. Slice 98 (schema 21.0.x "
|
||||
"shower_outlets list normalisation + explicit electric/"
|
||||
"mixer counts) surfaces this cert's 1 electric + 1 mixer "
|
||||
"outlets vs the previous default 0+1: PE +7.52 → +8.44, "
|
||||
"CO2 -0.27 → -0.23."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
@ -179,8 +183,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="2130-1033-4050-5007-8395",
|
||||
actual_sap=82,
|
||||
expected_sap_resid=+1,
|
||||
expected_pe_resid_kwh_per_m2=-38.1666,
|
||||
expected_co2_resid_tonnes_per_yr=+0.3047,
|
||||
expected_pe_resid_kwh_per_m2=-38.1790,
|
||||
expected_co2_resid_tonnes_per_yr=+0.3046,
|
||||
notes=(
|
||||
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
|
||||
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
|
||||
|
|
|
|||
|
|
@ -428,16 +428,20 @@ def heat_transmission_from_cert(
|
|||
# single-bp test contract.
|
||||
window_area_by_bp = [0.0] * len(parts)
|
||||
if epc.sap_windows:
|
||||
window_area_by_bp_unrounded = [0.0] * len(parts)
|
||||
# RdSAP 10 §15: per-window area enters the SAP calc at 2 d.p.
|
||||
# The worksheet's line (27) Σ-area column sums the per-window-
|
||||
# rounded values (12.23 = 2.48 + 0.79 + ... rounded per row),
|
||||
# NOT the unrounded total rounded once (which yields 12.22 for
|
||||
# cert 0330 — Δ +0.015 W/K on net wall area = +0.0012 SAP).
|
||||
# The cascade's windows_w_per_k_total branch already rounds
|
||||
# per-window; align the wall-net-deduction branch with it for
|
||||
# cascade-internal consistency.
|
||||
for w in epc.sap_windows:
|
||||
idx = _window_bp_index(w.window_location, len(parts))
|
||||
window_area_by_bp_unrounded[idx] += (
|
||||
float(w.window_width) * float(w.window_height)
|
||||
window_area_by_bp[idx] += _round_half_up(
|
||||
float(w.window_width) * float(w.window_height),
|
||||
_AREA_ROUND_DP,
|
||||
)
|
||||
window_area_by_bp = [
|
||||
_round_half_up(a, _AREA_ROUND_DP)
|
||||
for a in window_area_by_bp_unrounded
|
||||
]
|
||||
elif window_total_area_m2 > 0.0:
|
||||
window_area_by_bp[0] = _round_half_up(
|
||||
window_total_area_m2, _AREA_ROUND_DP,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue