Treat a lodged party ceiling as U=0 instead of a pitched roof 🟩

RdSAP 10 item 2-3(h) "another dwelling above" is U=0 (spec p.45: "There is no
heat loss through the roof of a building part that has the same dwelling above
or another dwelling above"). gov-API `roof_construction = 3` is that code, but
the mapper labelled it "Pitched (slates/tiles), no access to loft" (which is
code 5), so the per-part party override at heat_transmission.py:1172 never
matched and the part fell through to the dwelling_type-label-only
has_exposed_roof heuristic.

190 of the 200 corpus code-3 parts sit at index 0 and are already suppressed by
`_cert_lodges_exposed_roof` off the roof_insulation_location == "ND" signal. The
unmitigated set is the 10 parts at index > 0, where extensions default to
exposed and were billed a phantom pitched roof. Also removes a latent PV defect:
the bogus "Pitched" prefix satisfied `is_pitched` and divided PV array area by
cos(35 deg), inflating kWp.

Shipped with the stairwell fix so the corpus pin moves once: within-0.5
78.8% -> 79.6%, SAP MAE 0.626 -> 0.611, PE MAE 2.9 -> 2.7.

The roof change alone trades 4 certs out of the +-0.5 band for 2 in (one moving
4.4 SAP closer). Those 4 were investigated and are NOT a masked bug: the corpus
error distribution is symmetric (mean +0.076, median +0.03, sigma 1.606; 112
certs above +0.5 vs 102 below), ground-floor flats show 7 positive / 8 negative
with no excess over the null, and closing them needs HLC changes of 2.3-5.6% —
below the ~8.8% noise floor of the RHI anchor, calibrated on the 76 certs whose
SAP is exact (space-heating ratio 1.062 +/- 0.088).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-21 22:23:52 +00:00
parent 3f6129afc6
commit da993b5359
3 changed files with 97 additions and 3 deletions

View file

@ -4607,7 +4607,23 @@ _API_FLOOR_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = {
# here too rather than left half-fixed.
_API_ROOF_CONSTRUCTION_TO_STR: Dict[int, Optional[str]] = {
1: "Flat",
3: "Pitched (slates/tiles), no access to loft",
# RdSAP 10 item 2-3(h) "another dwelling above" — U=0 (spec p.45: "There is
# no heat loss through the roof of a building part that has the same
# dwelling above or another dwelling above"). Previously mislabelled
# "Pitched (slates/tiles), no access to loft" (that is code 5), so the
# per-part party override at `heat_transmission.py:1172` never matched.
# 190 of the 200 corpus code-3 parts sit at index 0 and are already
# suppressed by `_cert_lodges_exposed_roof` off the `roof_insulation_location
# == "ND"` signal; the unmitigated set is the 10 parts at index > 0, where
# extensions default to exposed and were billed a phantom pitched roof.
# Also removes a latent PV defect: the bogus "Pitched" prefix satisfied
# `is_pitched` (cert_to_inputs.py:3559) and divided PV array area by
# cos(35°), inflating kWp. Corpus evidence (1000 certs, 200 code-3 parts):
# 200/200 lodge roof_insulation_location "ND", 186/200 lodge the roof
# description literally "(another dwelling above)", zero lodge a "no access
# to loft" description, and no top-floor dwelling ever lodges it. Codes 7
# and 9 were corrected by an earlier slice; 3 was left behind.
3: "(another dwelling above)",
4: "Pitched (slates/tiles), access to loft",
5: "Pitched (vaulted ceiling)",
6: None,

View file

@ -0,0 +1,78 @@
"""Regression: gov-API `roof_construction = 3` is "another dwelling above"
(RdSAP 10 item 2-3(h), U=0), NOT a pitched roof.
RdSAP 10 item 2-3 "Above the building part" (spec PDF p.72, printed p.71) lists
the codes and their treatment; (g) "same dwelling above" and (h) "another
dwelling above" are both U=0, and p.45 states it plainly: "There is no heat loss
through the roof of a building part that has the same dwelling above or another
dwelling above."
The mapper labelled code 3 "Pitched (slates/tiles), no access to loft", so the
per-part party override in `heat_transmission.py` which matches on the literal
string "another dwelling above" in `roof_construction_type` never fired. The
building part then fell through to the dwelling-level `has_exposed_roof`
heuristic, which is keyed only on the `dwelling_type` LABEL. Where that label
says top-floor (or the part is an extension, billed exposed unconditionally), we
invented an entire pitched roof over a lodged party ceiling.
Evidence that code 3 is a party ceiling, from the committed RdSAP-21.0.1 corpus
(1000 certs, 200 code-3 building parts):
- 200/200 lodge `roof_insulation_location == "ND"` (not determined there is
no insulation to determine on a party ceiling);
- 186/200 lodge the cert's roof description as literally
"(another dwelling above)";
- dwelling types are exclusively non-top-floor (Mid-floor flat 104,
Ground-floor flat 86, Ground-floor maisonette 7, Mid-floor maisonette 1,
Mid-terrace house 2) no top-floor dwelling ever lodges it.
Codes 7 and 9 were corrected to the party string by an earlier slice; code 3 was
left behind.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
_FIXTURE = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/epc.json")
_PARTY_CEILING = "another dwelling above"
def _load_with_roof_construction(code: int) -> dict[str, Any]:
data: dict[str, Any] = json.loads(_FIXTURE.read_text())
for part in data["sap_building_parts"]:
part["roof_construction"] = code
return data
def test_roof_construction_3_maps_to_a_party_ceiling() -> None:
# Arrange — the lodged part has another dwelling above it (API code 3).
data = _load_with_roof_construction(3)
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — carries the party-ceiling marker the per-part override matches on,
# so the roof is billed at U=0 rather than as an invented pitched roof.
assert epc is not None
assert epc.sap_building_parts
for part in epc.sap_building_parts:
assert _PARTY_CEILING in (part.roof_construction_type or "").lower()
def test_roof_construction_4_remains_a_real_pitched_roof() -> None:
# Arrange — code 4 is 2-3(a) "pitched roof, access to loft", a REAL roof.
data = _load_with_roof_construction(4)
# Act
epc = EpcPropertyDataMapper.from_api_response(data)
# Assert — must not be swept up by the code-3 correction.
assert epc is not None
assert epc.sap_building_parts
for part in epc.sap_building_parts:
assert _PARTY_CEILING not in (part.roof_construction_type or "").lower()

View file

@ -267,7 +267,7 @@ _CORPUS = Path(
# unheated corridor, 3 = unheated stairwell), which was read NOWHERE under
# domain/, so every sheltered wall took the corridor 0.5 and every stairwell
# flat over-billed its sheltered wall. 23 corpus certs; within-0.5 +1.0pp.
_MIN_WITHIN_HALF_SAP = 0.798
_MIN_WITHIN_HALF_SAP = 0.796
# 0.793 -> 0.789 via the §12 Unknown-meter + dual-electric-immersion off-peak
# trigger (RdSAP 10 PDF p.62): Apartment 241 (main 691 + 903 dual immersion)
# -5.38 -> -1.05. Worksheet-validated on "simulated case 48" (Elmhurst SAP 57,
@ -393,7 +393,7 @@ _MIN_WITHIN_HALF_SAP = 0.798
# The -0.672 overshoot is expected to move again when the C4 plant-
# efficiency and C3.2 pumping siblings land. Unit-pinned in
# test_cert_to_inputs.
_MAX_SAP_MAE = 0.614
_MAX_SAP_MAE = 0.611
_MAX_CO2_MAE_TONNES = 0.072 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 3.0 # kWh / m2 / yr vs energy_consumption_current