Merge pull request #1672 from Hestia-Homes/fix/sap10-accuracy-calc-batch

SAP 10.2 calculator accuracy batch: #1666 / #1667 / #1669 / #1668
This commit is contained in:
KhalimCK 2026-07-23 10:04:16 +01:00 committed by GitHub
commit 09c2b93251
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 797 additions and 36 deletions

View file

@ -1,5 +1,6 @@
import copy
import logging
import math
import re
from dataclasses import replace
from datetime import date
@ -212,6 +213,35 @@ def _sap_opening_area_m2(width: Any, height: Any) -> float:
)
def _normalised_main_heating_fraction(
raw: Optional[Union[int, float]],
all_fractions: Sequence[Optional[Union[int, float]]],
) -> Optional[int]:
"""Return `raw` as a canonical PERCENT (RdSAP 10 item 7-5, spec p.81).
The gov-EPC API lodges a two-main dwelling's `main_heating_fraction` pair in
two incompatible encodings a DECIMAL fraction summing to ~1.0
(`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`) and every
consumer divides by 100 unconditionally. A decimal pair therefore collapses
the second system's share to ~1% of its true value, billing almost the whole
load to the first system's fuel and efficiency (#1666).
Discriminate on the pair sum: a genuine two-main split summing nearer 1.0
than 100 is decimal-encoded and scaled to percent; a percent-encoded pair
(sum ~100) and any single main are left exactly as lodged, so no
percent-encoded cert regresses. Returned as an int to match the domain
invariant (`MainHeatingDetail.main_heating_fraction` is a 0-100 percent).
"""
if raw is None:
return None
present = [float(f) for f in all_fractions if f is not None]
if len(present) >= 2:
pair_sum = sum(present)
if abs(pair_sum - 1.0) < abs(pair_sum - 100.0):
return round(float(raw) * 100.0)
return round(float(raw))
def _pv_array_field(array: Any, name: str) -> Any:
"""Read a PV-array field from either a `PhotovoltaicArray` dataclass (21.0.x,
where the schema Union parsed the nested list) or a raw dict (older schemas
@ -753,7 +783,13 @@ class EpcPropertyDataMapper:
main_heating_number=d.main_heating_number,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
@ -1385,7 +1421,13 @@ class EpcPropertyDataMapper:
main_heating_number=d.main_heating_number,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=None,
main_heating_data_source=d.main_heating_data_source,
@ -1573,7 +1615,13 @@ class EpcPropertyDataMapper:
main_heating_number=d.main_heating_number,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
@ -1799,7 +1847,13 @@ class EpcPropertyDataMapper:
main_heating_number=d.main_heating_number,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
@ -2052,7 +2106,13 @@ class EpcPropertyDataMapper:
main_heating_number=d.main_heating_number,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
@ -2285,7 +2345,13 @@ class EpcPropertyDataMapper:
boiler_ignition_type=d.boiler_ignition_type,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
@ -2634,7 +2700,13 @@ class EpcPropertyDataMapper:
boiler_ignition_type=d.boiler_ignition_type,
main_heating_control=d.main_heating_control,
main_heating_category=d.main_heating_category,
main_heating_fraction=d.main_heating_fraction,
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[
x.main_heating_fraction
for x in schema.sap_heating.main_heating_details
],
),
sap_main_heating_code=d.sap_main_heating_code,
central_heating_pump_age=d.central_heating_pump_age,
main_heating_data_source=d.main_heating_data_source,
@ -3130,13 +3202,16 @@ class EpcPropertyDataMapper:
)
return None
return _clear_basement_flag_when_system_built(
_with_renewable_heat_incentive(
_with_recorded_performance(
_drop_building_parts_without_age_band(mapped), data
),
data,
)
return _with_internal_dimensions(
_clear_basement_flag_when_system_built(
_with_renewable_heat_incentive(
_with_recorded_performance(
_drop_building_parts_without_age_band(mapped), data
),
data,
)
),
data,
)
@ -3323,10 +3398,12 @@ def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating:
main_heating_index_number=d.main_heating_index_number,
main_heating_number=d.main_heating_number,
main_heating_category=d.main_heating_category,
main_heating_fraction=(
int(d.main_heating_fraction)
if d.main_heating_fraction is not None
else None
# #1666: normalise the encoding (and fix the old `int()` floor
# that silently deleted a decimal-lodged second main, e.g.
# int(0.35) -> 0) via the shared pair-sum discriminator.
main_heating_fraction=_normalised_main_heating_fraction(
d.main_heating_fraction,
[x.main_heating_fraction for x in sh.main_heating_details],
),
main_heating_data_source=d.main_heating_data_source,
)
@ -3351,6 +3428,181 @@ def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
)
# ---------------------------------------------------------------------------
# RdSAP 10 §3.4 conversion of external to internal dimensions (#1669)
# ---------------------------------------------------------------------------
# `measurement_type` lodged value: 1 = internal dimensions, 2 = external.
_EXTERNAL_MEASUREMENT: Final[int] = 2
def _table_3_row(*vals: int) -> Dict[str, int]:
"""Expand a Table 3 wall-thickness row's 10 spec columns to the 13 age
bands: bands I/J/K share the 9th column and L/M share the 10th."""
a, b, c, d, e, f, g, h, ijk, lm = vals
return {
"A": a, "B": b, "C": c, "D": d, "E": e, "F": f, "G": g, "H": h,
"I": ijk, "J": ijk, "K": ijk, "L": lm, "M": lm,
}
# RdSAP 10 Table 3 (PDF p.19): default main-dwelling wall thickness (mm) by wall
# construction row and age band, used only when the thickness is not lodged.
_TABLE_3_WALL_THICKNESS_MM: Final[Dict[str, Dict[str, int]]] = {
"solid": _table_3_row(220, 220, 220, 220, 240, 250, 270, 270, 300, 300),
"cavity": _table_3_row(250, 250, 250, 250, 250, 260, 270, 270, 300, 300),
"timber": _table_3_row(150, 150, 150, 250, 270, 270, 270, 270, 300, 300),
"cob": _table_3_row(540, 540, 540, 540, 540, 540, 560, 560, 590, 590),
"system": _table_3_row(250, 250, 250, 250, 250, 300, 300, 300, 300, 300),
}
# `wall_construction` code → Table 3 row. Table 3 has no stone row (stone is
# normally measured); stone (1/2) and any non-masonry/unknown fall back to the
# solid-masonry column.
_WALL_CONSTRUCTION_TO_TABLE_3_ROW: Final[Dict[int, str]] = {
1: "solid", 2: "solid", 3: "solid", # stone/granite, sandstone, solid brick
4: "cavity", 5: "timber", 6: "system", 7: "cob",
}
def _main_wall_thickness_m(epc: EpcPropertyData) -> Optional[float]:
"""The main dwelling's wall thickness in metres (RdSAP 10 §3.4 `w`): the
lodged value if present, else the Table 3 default for the main part's wall
construction + age band. None when neither is derivable."""
parts = epc.sap_building_parts
if not parts:
return None
main = parts[0]
if main.wall_thickness_mm is not None:
return main.wall_thickness_mm / 1000.0
construction = main.wall_construction
row = (
_WALL_CONSTRUCTION_TO_TABLE_3_ROW.get(construction, "solid")
if isinstance(construction, int)
else "solid"
)
band = (main.construction_age_band or "").strip().upper()[:1]
mm = _TABLE_3_WALL_THICKNESS_MM[row].get(band)
return mm / 1000.0 if mm is not None else None
def _table_2_ratios(
built_form: Optional[str], area_ext: float, perim_ext: float, w: float
) -> tuple[float, float]:
"""RdSAP 10 Table 2 (PDF p.18) whole-dwelling external→internal
(area_ratio, perimeter_ratio) by built form (1 detached, 2 semi, 3
end-terrace, 4 mid-terrace, 5 enclosed end-terrace, 6 enclosed mid-terrace).
`area_ext`/`perim_ext` are the ground-floor FOOTPRINT (§3.4 Note 4: one ratio
for the whole dwelling). Returns (1, 1) no conversion for an unrecognised
form or a degenerate footprint."""
if area_ext <= 0.0 or perim_ext <= 0.0:
return (1.0, 1.0)
bf = (built_form or "").strip()
if bf == "1": # detached
perim_int = perim_ext - 8.0 * w
area_int = area_ext - w * perim_int - 4.0 * w * w
elif bf in ("2", "3"): # semi-detached / end-terrace
if perim_ext**2 >= 8.0 * area_ext:
perim_int = perim_ext - 5.0 * w
a = 0.5 * (perim_ext - math.sqrt(perim_ext**2 - 8.0 * area_ext))
area_int = area_ext - w * (perim_ext + 0.5 * a) + 3.0 * w * w
else:
perim_int = perim_ext - 3.0 * w
area_int = area_ext - w * perim_ext + 3.0 * w * w
elif bf == "4": # mid-terrace
perim_int = perim_ext - 2.0 * w
area_int = area_ext - w * (perim_ext + 2.0 * area_ext / perim_ext) + 2.0 * w * w
elif bf == "5": # enclosed end-terrace
perim_int = perim_ext - 3.0 * w
area_int = area_ext - 1.5 * w * perim_ext + 2.25 * w * w
elif bf == "6": # enclosed mid-terrace
perim_int = perim_ext - w
area_int = area_ext - w * (area_ext / perim_ext + 1.5 * perim_ext) + 1.5 * w * w
else:
return (1.0, 1.0)
if area_int <= 0.0 or perim_int <= 0.0:
return (1.0, 1.0)
return (area_int / area_ext, perim_int / perim_ext)
def _domain_total_floor_area_m2(building_parts: List[SapBuildingPart]) -> float:
"""Sum the domain floor-dimension areas (plus each part's always-internal
room-in-roof floor area) used to refresh `total_floor_area_m2` after a
§3.4 conversion, since `water_heating_from_cert` reads that scalar for
occupancy N."""
total = 0.0
for bp in building_parts:
for fd in bp.sap_floor_dimensions:
total += fd.total_floor_area_m2 or 0.0
rir = bp.sap_room_in_roof
if rir is not None and rir.floor_area:
total += rir.floor_area
return total
def _with_internal_dimensions(
epc: EpcPropertyData, data: Dict[str, Any]
) -> EpcPropertyData:
"""Plumb the lodged `measurement_type` onto `epc` and, when the horizontal
dimensions were measured externally (== 2), convert them to internal
dimensions per RdSAP 10 §3.4 + Table 2 before they reach the cascade (#1669).
§3.4 Note 4: ONE whole-dwelling area/perimeter ratio (from the ground-floor
footprint) is applied to every storey of every part not a per-storey
conversion, which over-corrects multi-part mid-terraces. Room-in-roof floor
area is always measured internally (§3.1) and is left untouched; party wall
lengths are reduced by 2w (Table 2 Note 5). Internal certs are unchanged.
Scope: the conversion is applied only to the RdSAP-Schema reduced-data family
(17.0-21.x), where per-storey external dimensions are the lodgement standard
and the fix is corpus-validated (RdSAP-21.0.1). `measurement_type` is still
plumbed for every schema, but the legacy SAP-Schema-15.0/16.x certs whose
per-storey areas do not reconcile to the lodged TFA are left unconverted."""
epc.measurement_type = data.get("measurement_type")
schema_type = data.get("schema_type", "")
if (
epc.measurement_type != _EXTERNAL_MEASUREMENT
or not isinstance(schema_type, str)
or not schema_type.startswith("RdSAP-Schema-")
or not epc.sap_building_parts
):
return epc
w = _main_wall_thickness_m(epc)
if w is None:
return epc
ground_levels = [
fd.floor
for bp in epc.sap_building_parts
for fd in bp.sap_floor_dimensions
if fd.floor is not None
]
if not ground_levels:
return epc
ground = min(ground_levels)
area_ext = sum(
fd.total_floor_area_m2 or 0.0
for bp in epc.sap_building_parts
for fd in bp.sap_floor_dimensions
if fd.floor == ground
)
perim_ext = sum(
fd.heat_loss_perimeter_m or 0.0
for bp in epc.sap_building_parts
for fd in bp.sap_floor_dimensions
if fd.floor == ground
)
area_ratio, perim_ratio = _table_2_ratios(epc.built_form, area_ext, perim_ext, w)
if area_ratio == 1.0 and perim_ratio == 1.0:
return epc
for bp in epc.sap_building_parts:
for fd in bp.sap_floor_dimensions:
fd.total_floor_area_m2 *= area_ratio
fd.heat_loss_perimeter_m *= perim_ratio
if fd.party_wall_length_m:
fd.party_wall_length_m = max(0.0, fd.party_wall_length_m - 2.0 * w)
epc.total_floor_area_m2 = _domain_total_floor_area_m2(epc.sap_building_parts)
return epc
def _with_recorded_performance(
epc: EpcPropertyData, data: Dict[str, Any]
) -> EpcPropertyData:
@ -5269,24 +5521,67 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[
}
# RdSAP 10 Table 24 METAL-frame U-column (PDF p.50-51) — #1667. Only the U
# differs from the PVC/wooden column above (the glazing g is frame-independent);
# the frame factor becomes 0.8 (SAP 10.2 Table 6c) instead of 0.70. Secondary
# glazing (types 4/11/12) has no separate metal U — its combined U is
# frame-independent — so it is absent here and keeps the PVC-column U. Values
# mirror `u_window`'s metal column (`rdsap_uvalues.py`): single 5.7; DG pre-2002
# 6/12/16 = 3.7/3.4/3.3; triple pre-2002 6/12/16 = 2.9/2.6/2.5; DG/triple 2002+
# = 2.2; 2022+ = 1.6.
_API_GLAZING_TYPE_TO_METAL_U: Dict[int, float] = {
1: 3.4, 3: 3.4, 7: 3.4, # DG pre-2002 / unknown-date, 12mm default
2: 2.2, 9: 2.2, # DG (2) / triple (9) 2002+ (pre-2022)
13: 1.6, 14: 1.6, # DG / DG-or-triple 2022+
5: 5.7, 15: 5.7, # single glazing
6: 2.6, 8: 2.6, 10: 2.6, # triple pre-2002 / unknown / known, 12mm default
}
_API_GLAZING_TYPE_GAP_TO_METAL_U: Dict[tuple[int, object], float] = {
(1, 6): 3.7, (1, 12): 3.4, (1, "16+"): 3.3,
(3, 6): 3.7, (3, 12): 3.4, (3, "16+"): 3.3,
(7, 6): 3.7, (7, 12): 3.4, (7, "16+"): 3.3,
(6, 6): 2.9, (6, 12): 2.6, (6, "16+"): 2.5,
(8, 6): 2.9, (8, 12): 2.6, (8, "16+"): 2.5,
(10, 6): 2.9, (10, 12): 2.6, (10, "16+"): 2.5,
}
# SAP 10.2 Table 6c window frame factor: 0.8 for metal, 0.70 for PVC/wooden.
_METAL_FRAME_FACTOR: Final[float] = 0.8
def _api_glazing_transmission(
glazing_type: Optional[int],
glazing_gap: object,
pvc_frame: object = None,
) -> Optional[tuple[float, float, float]]:
"""Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24.
Per-gap override takes precedence over the type-only default. Returns None
only when `glazing_type` is absent (None cascade default). A glazing_type
PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing
the window to the u_window all-None default U=2.5 the forcing function
that surfaced single-glazing (code 5 4.8) instead of letting it hide."""
that surfaced single-glazing (code 5 4.8) instead of letting it hide.
`pvc_frame` is the lodged RdSAP input 6-5 boolean (spec p.80): "true" =
wooden/PVC (grouped in Table 24), "false" = metal. Only an explicit "false"
routes the window to the Table 24 metal U-column + FF 0.8 (#1667); "true",
None or any other value keeps the PVC/wooden column + FF 0.70, so no
PVC-lodged cert regresses."""
if glazing_type is None:
return None
gap_key = (glazing_type, glazing_gap)
if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION:
return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key]
if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION:
return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type]
raise UnmappedApiCode("glazing_type", glazing_type)
base = _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key]
elif glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION:
base = _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type]
else:
raise UnmappedApiCode("glazing_type", glazing_type)
if pvc_frame != "false":
return base
u_pvc, g_perp, _pvc_ff = base
metal_u = _API_GLAZING_TYPE_GAP_TO_METAL_U.get(
gap_key, _API_GLAZING_TYPE_TO_METAL_U.get(glazing_type, u_pvc)
)
return (metal_u, g_perp, _METAL_FRAME_FACTOR)
# GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade
@ -5405,7 +5700,7 @@ def _api_sap_roof_window(w: Any) -> SapRoofWindow:
Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to
the inclined-position value the worksheet bills on (27a). Mirror of
the site-notes `_map_elmhurst_roof_window`."""
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap)
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame)
vertical_u = transmission[0] if transmission is not None else 2.0
g_perp = transmission[1] if transmission is not None else 0.76
frame_factor = w.frame_factor
@ -5475,7 +5770,7 @@ def _api_sap_window(w: Any) -> SapWindow:
to the SAP 10.2 Table 6b cascade enum so the cascade's daylight g_L
lookup picks the spec-correct value (e.g. RdSAP 21 code 1 = DG
pre-2002, cascade g_L=0.80, not single-glazed 0.90)."""
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap)
transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame)
frame_factor: Optional[float] = w.frame_factor
if frame_factor is None and transmission is not None:
frame_factor = transmission[2]

View file

@ -2141,9 +2141,52 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int:
raise UnmappedSapCode("main_heating_control", code)
# RdSAP 10 Table 29 (PDF p.56-57): a solid floor in age bands A-E carries no
# insulation layer, so wet underfloor pipes sit in a bare concrete slab (SAP 10.2
# Table 4d R=0.25). F-M solid floors carry insulation → screed above (0.75). #1668
_CONCRETE_SLAB_UNDERFLOOR_BANDS: Final[frozenset[str]] = frozenset("ABCDE")
# Lumped gov-EPC "underfloor" heat-emitter code — carries no screed/timber/slab
# subtype; the subtype is derived from floor construction + age band per Table 29.
_UNDERFLOOR_EMITTER_CODE: Final[int] = 2
def _underfloor_responsiveness(
floor_description: Optional[str], age_band: Optional[str]
) -> float:
"""SAP 10.2 Table 4d underfloor responsiveness selected via RdSAP 10 Table 29
from the main part's floor construction + age band (#1668):
- suspended TIMBER floor 1.0 (fully responsive timber-floor UFH)
- solid floor, age A-E 0.25 (bare concrete slab, no insulation)
- solid F-M / suspended-not-timber / unknown / no ground floor
0.75 (screed above insulation the prior
default, so ambiguous certs do not move).
"""
desc = (floor_description or "").lower()
is_timber = "timber" in desc and "not timber" not in desc
if is_timber:
return 1.0
band = (age_band or "").strip().upper()[:1]
if "solid" in desc and band in _CONCRETE_SLAB_UNDERFLOOR_BANDS:
return 0.25
return 0.75
def _main_underfloor_floor_context(
epc: Optional[EpcPropertyData],
) -> tuple[Optional[str], Optional[str]]:
"""(floor construction description, age band) of the main building part —
the Table 29 discriminator for the underfloor subtype in `_responsiveness`."""
if epc is None or not epc.sap_building_parts:
return (None, None)
main_bp = epc.sap_building_parts[0]
return (_effective_floor_description(epc, main_bp), main_bp.construction_age_band)
def _responsiveness(
main: Optional[MainHeatingDetail],
tariff: Optional[Tariff] = None,
epc: Optional[EpcPropertyData] = None,
) -> float:
"""SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271:
@ -2183,9 +2226,10 @@ def _responsiveness(
4 = Warm air R = 1.0
5 = Fan coils R = 1.0
"Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry
yet that variant would need a new mapper code before the cascade
can dispatch it.
The lumped underfloor code 2 carries no screed/timber/concrete-slab
subtype; its Table 4d responsiveness (0.25 / 0.75 / 1.0) is derived
from the main part's floor construction + age band per RdSAP 10
Table 29 via `_underfloor_responsiveness` (needs `epc`; #1668).
Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging
(None / 0 / "") returns modal default R=1.0 (radiators); lodging
@ -2210,6 +2254,9 @@ def _responsiveness(
emitter = main.heat_emitter_type
if not emitter:
return 1.0
if emitter == _UNDERFLOOR_EMITTER_CODE:
floor_description, age_band = _main_underfloor_floor_context(epc)
return _underfloor_responsiveness(floor_description, age_band)
if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE:
return _RESPONSIVENESS_BY_EMITTER_CODE[emitter]
raise UnmappedSapCode("heat_emitter_type", emitter)
@ -4958,7 +5005,7 @@ def mean_internal_temperature_section_from_cert(
thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc),
total_floor_area_m2=dim.total_floor_area_m2,
control_type=_control_type(main),
responsiveness=_responsiveness(main, tariff=tariff),
responsiveness=_responsiveness(main, tariff=tariff, epc=epc),
living_area_fraction=_living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
),
@ -8255,7 +8302,7 @@ def cert_to_inputs(
# for the Elmhurst corpus (cert-side mapping is a future slice).
control_type_value = _control_type(main)
_mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type)
responsiveness_value = _responsiveness(main, tariff=_mit_tariff)
responsiveness_value = _responsiveness(main, tariff=_mit_tariff, epc=epc)
living_area_fraction_value = _living_area_fraction(
epc.habitable_rooms_count, dim.total_floor_area_m2
)
@ -8279,7 +8326,7 @@ def cert_to_inputs(
main_2_control_type_value = _control_type(_mit_main_2)
main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0
main_2_responsiveness_value = _responsiveness(
_mit_main_2, tariff=_mit_tariff
_mit_main_2, tariff=_mit_tariff, epc=epc
)
monthly_total_gains_w = tuple(
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)

View file

@ -0,0 +1,93 @@
"""#1669 — when a cert lodges its horizontal dimensions measured externally
(`measurement_type == 2`), RdSAP 10 §3.4 + Table 2 (spec p.17-18) require the
external floor area and exposed perimeter to be converted to INTERNAL dimensions
before any SAP use. `measurement_type` was read nowhere, so external certs
carried a TFA ~11-15% too large corrupting occupancy, reported floor area and
every per- output. The mapper must plumb `measurement_type` through and, for
external certs, apply the Table 2 whole-dwelling ratio (§3.4 Note 4) before the
values reach the cascade, while leaving internal (`== 1`) certs verbatim.
"""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
def _corpus_cert(uprn: str) -> dict[str, Any]:
for line in _CORPUS.read_text().splitlines():
if uprn in line:
cert: dict[str, Any] = json.loads(line)
return cert
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
def _with_measurement_type(payload: dict[str, Any], value: int) -> dict[str, Any]:
twin = deepcopy(payload)
twin["measurement_type"] = value
return twin
def _mapped_tfa(payload: dict[str, Any]) -> float:
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
assert epc.total_floor_area_m2 is not None
return epc.total_floor_area_m2
def test_measurement_type_is_plumbed_onto_the_domain_object() -> None:
# Arrange — cert 100091435353 lodges measurement_type 2; the field was
# dropped to None by every API mapper (`# What is this?`).
payload = _corpus_cert("100091435353")
# Act
epc = EpcPropertyDataMapper.from_api_response(payload)
# Assert
assert epc is not None
assert epc.measurement_type == 2
def test_external_cert_tfa_is_converted_to_internal_dimensions() -> None:
# Arrange — 100091435353 (detached, w=270mm) lodges a 128.7 m^2 EXTERNAL
# floor-area sum; its lodged internal TFA is 112.
payload = _corpus_cert("100091435353")
# Act
tfa = _mapped_tfa(payload)
# Assert — Table 2 detached conversion recovers the internal TFA to <0.5 m^2,
# not the raw external 128.7.
assert abs(tfa - 112.0) <= 0.5
def test_internal_cert_dimensions_are_left_verbatim() -> None:
# Arrange — the same cert forced to measurement_type 1 (internal); §3.4 does
# not apply, so the floor areas must be used exactly as lodged (128.7).
payload = _with_measurement_type(_corpus_cert("100091435353"), 1)
# Act
tfa = _mapped_tfa(payload)
# Assert — no conversion; the external sum is passed through unchanged.
assert abs(tfa - 128.7) <= 1e-6
def test_external_conversion_moves_sap_toward_lodged() -> None:
# Arrange — 100091435353 over-rated at 66.23 on HEAD (external dims), lodged 65.
payload = _corpus_cert("100091435353")
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
# Act
sap = Sap10Calculator().calculate(epc).sap_score_continuous
# Assert — the internal-dimension conversion pulls it to ~65.33, toward lodged.
assert abs(sap - 65.33) <= 0.05

View file

@ -0,0 +1,116 @@
"""#1666 — a two-main dwelling's `main_heating_fraction` pair is lodged by the
gov-EPC API in two incompatible encodings: a DECIMAL fraction summing to ~1.0
(`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`). Every consumer
divides by 100 unconditionally, so a decimal-encoded pair collapses the second
main's share to ~1% of its true value and bills almost the whole load to the
first system's fuel/efficiency. The mapper must normalise both encodings to the
spec-canonical percent (RdSAP 10 item 7-5, spec p.81) so the encoding is
invisible downstream, without regressing the percent-encoded certs.
"""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any, Optional, cast
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
_RDSAP_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
_SAP_17_1_CORPUS = Path("backend/epc_api/json_samples/SAP-Schema-17.1/corpus.jsonl")
def _corpus_cert(corpus: Path, uprn: str) -> dict[str, Any]:
for line in corpus.read_text().splitlines():
if uprn in line:
cert: dict[str, Any] = json.loads(line)
return cert
raise AssertionError(f"uprn {uprn} not found in {corpus.name}")
def _mapped_fractions(payload: dict[str, Any]) -> list[Optional[int]]:
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
return [m.main_heating_fraction for m in epc.sap_heating.main_heating_details if m]
def _continuous_sap(payload: dict[str, Any]) -> float:
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
return Sap10Calculator().calculate(epc).sap_score_continuous
def _scaled_fractions(payload: dict[str, Any], factor: float) -> dict[str, Any]:
"""Deep-copy `payload`, multiplying every lodged `main_heating_fraction` by
`factor` used to build a percent-encoded twin of a decimal-encoded cert."""
twin = deepcopy(payload)
def walk(node: object) -> None:
if isinstance(node, dict):
node_dict = cast("dict[str, Any]", node)
for key, value in node_dict.items():
if key == "main_heating_fraction" and value is not None:
node_dict[key] = value * factor
else:
walk(value)
elif isinstance(node, list):
for value in cast("list[Any]", node):
walk(value)
walk(twin)
return twin
def test_decimal_encoded_fraction_pair_is_normalised_to_percent() -> None:
# Arrange — corpus cert 10070086972 (semi-detached, lodged SAP 18) lodges its
# two mains as a DECIMAL pair [0.8, 0.2] summing to 1.0; the /100 consumers
# would otherwise bill the second main at 0.2% of the load, not 20%.
payload = _corpus_cert(_RDSAP_CORPUS, "10070086972")
# Act
fractions = _mapped_fractions(payload)
# Assert — scaled to the spec-canonical percent (RdSAP 10 item 7-5, p.81).
assert fractions == [80, 20]
def test_percent_encoded_fraction_pair_is_left_unchanged() -> None:
# Arrange — corpus cert 40037847 lodges a PERCENT pair [10, 90] summing to
# ~100; the pair-sum discriminator must leave it exactly as lodged so the 3
# percent-encoded corpus certs do not regress.
payload = _corpus_cert(_RDSAP_CORPUS, "40037847")
# Act
fractions = _mapped_fractions(payload)
# Assert
assert fractions == [10, 90]
def test_encoding_is_invisible_to_the_continuous_sap() -> None:
# Arrange — the decimal cert and a PERCENT twin built by scaling its lodged
# fractions x100 ([0.8, 0.2] -> [80, 20]); the encoding must not change SAP.
decimal_payload = _corpus_cert(_RDSAP_CORPUS, "10070086972")
percent_payload = _scaled_fractions(decimal_payload, 100)
# Act
sap_decimal = _continuous_sap(decimal_payload)
sap_percent = _continuous_sap(percent_payload)
# Assert — the two encodings must produce the same continuous SAP.
assert abs(sap_decimal - sap_percent) <= 1e-9
def test_sap_17_1_decimal_second_main_is_not_floored_to_zero() -> None:
# Arrange — SAP-Schema-17.1 cert 38334849 lodges two mains as a DECIMAL pair
# [0.5, 0.5]. The old `int(fraction)` floor mapped BOTH to 0, silently
# deleting the split; the shared normaliser must scale to percent instead.
payload = _corpus_cert(_SAP_17_1_CORPUS, "38334849")
# Act
fractions = _mapped_fractions(payload)
# Assert
assert fractions == [50, 50]

View file

@ -0,0 +1,100 @@
"""#1667 — on the gov-EPC API path a window's metal frame was unrepresentable:
the transmission lookup had no frame dimension, so every window was billed at
the PVC/wooden U-column and frame factor 0.70 even where the cert lodges a metal
frame (`pvc_frame:"false"`). RdSAP 10 Table 24 gives metal a higher U (+0.2 to
+0.9 W/m^2K) and SAP 10.2 Table 6c a frame factor of 0.8, so we over-rated
metal-framed dwellings. The mapper must route a metal-lodged window to the
Table 24 metal column + FF 0.8, leaving PVC-lodged windows unchanged.
"""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any, cast
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
def _corpus_cert(uprn: str) -> dict[str, Any]:
for line in _CORPUS.read_text().splitlines():
if uprn in line:
cert: dict[str, Any] = json.loads(line)
return cert
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
def _forced_pvc_frame(payload: dict[str, Any], value: str) -> dict[str, Any]:
"""Deep-copy `payload`, setting every lodged `pvc_frame` to `value` — used to
build a PVC-framed twin of a metal-framed cert."""
twin = deepcopy(payload)
def walk(node: object) -> None:
if isinstance(node, dict):
node_dict = cast("dict[str, Any]", node)
for key, child in node_dict.items():
if key == "pvc_frame" and child is not None:
node_dict[key] = value
else:
walk(child)
elif isinstance(node, list):
for child in cast("list[Any]", node):
walk(child)
walk(twin)
return twin
def _window_u_and_ff(payload: dict[str, Any]) -> tuple[set[float], set[float]]:
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
u_values = {
w.window_transmission_details.u_value
for w in epc.sap_windows
if w.window_transmission_details is not None
}
frame_factors = {w.frame_factor for w in epc.sap_windows if w.frame_factor is not None}
return u_values, frame_factors
def test_metal_frame_window_bills_table_24_metal_u_and_frame_factor_0p8() -> None:
# Arrange — cert 21067296 lodges six pvc_frame:"false" (metal) double-glazed
# (type 3, 12 mm) windows; HEAD billed them on the PVC column (U 2.8, FF 0.70).
payload = _corpus_cert("21067296")
# Act
u_values, frame_factors = _window_u_and_ff(payload)
# Assert — Table 24 metal DG-12mm column (3.4) and SAP 10.2 Table 6c FF 0.8.
assert u_values == {3.4}
assert frame_factors == {0.8}
def test_pvc_frame_window_stays_on_the_pvc_column() -> None:
# Arrange — the same cert forced to pvc_frame:"true"; the fix must not
# regress PVC/wooden-framed windows (the discriminator is the boolean).
payload = _forced_pvc_frame(_corpus_cert("21067296"), "true")
# Act
u_values, frame_factors = _window_u_and_ff(payload)
# Assert — PVC DG-12mm column (2.8) and FF 0.70, exactly as before.
assert u_values == {2.8}
assert frame_factors == {0.7}
def test_metal_frame_fix_moves_cert_toward_lodged() -> None:
# Arrange — 21067296 scored 68.60 on HEAD (PVC assumption), lodged 67.
payload = _corpus_cert("21067296")
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
# Act
sap = Sap10Calculator().calculate(epc).sap_score_continuous
# Assert — the metal U-penalty pulls it to ~66.96, i.e. toward lodged 67.
assert abs(sap - 66.96) <= 0.05

View file

@ -124,11 +124,20 @@ _RATE_FLOORS: dict[str, float] = {
# dimensional predictions); floor_area loosened 12.0378 -> 12.0586 as the one
# physical residual that fell (1-2 targets picking a new-build donor). See the
# _RATE_FLOORS note above.
# total_window_area 3.7184 -> 3.7484 and door_count 0.3333 -> 0.3737 re-baselined
# when the external-measurement conversion landed (#1669: measurement_type=2 certs
# now have their floor area + exposed perimeter converted external->internal per
# RdSAP 10 §3.4 + Table 2). The leave-one-out scorer re-derives the *actual*
# EpcPropertyData through the same mapper, so the sharper (internal) dimensions of
# the fixture's external certs shift the geo-proximity-weighted donor mode — a
# ground-truth-method change, not a prediction-logic loosening (spec-correct,
# corpus MAE 0.571 -> 0.570 and the real-cert pin held). The move is two
# single-target donor tips on the n=36 fixture. Tighten-only resumes from here.
_RESIDUAL_CEILINGS: dict[str, float] = {
"floor_area": 12.0586,
"total_window_area": 3.7184,
"total_window_area": 3.7484,
"building_parts": 0.1212,
"door_count": 0.3333,
"door_count": 0.3737,
}
_TOLERANCE = 1e-3

View file

@ -55,6 +55,15 @@ _FIXTURE = (
# 28-scoreable fixture) and residual ceilings — the measured values; tighten,
# never loosen. The general (unconditioned) prediction floors live in
# test_component_accuracy_gate.py; this gate guards the CONDITIONING path.
#
# has_pv re-baselined 0.8929 -> 0.8571 when the external-measurement conversion
# landed (#1669: measurement_type=2 certs converted external->internal per RdSAP
# 10 §3.4 + Table 2). This gate re-derives the *actual* EpcPropertyData through
# the same mapper, so the sharper internal dimensions of the fixture's external
# certs shift the conditioned donor mode and one pair's has_pv agreement tips
# 25/28 -> 24/28 — a ground-truth-method change, not a prediction-logic loosening
# (spec-correct; corpus MAE improved and the real-cert pin held). Tighten-only
# resumes from here.
_CLASSIFICATION_FLOORS: dict[str, float] = {
"construction_age_band": 0.5357,
"construction_age_band_pm1": 0.8214,
@ -62,7 +71,7 @@ _CLASSIFICATION_FLOORS: dict[str, float] = {
"floor_construction": 0.8636,
"floor_insulation": 1.0000,
"has_hot_water_cylinder": 0.8214,
"has_pv": 0.8929,
"has_pv": 0.8571,
"has_room_in_roof": 0.8571,
"heating_main_category": 1.0000,
"heating_main_control": 0.6071,

View file

@ -0,0 +1,78 @@
"""#1668 — wet underfloor responsiveness was pinned at R=0.75 for the lumped
gov-EPC emitter code 2, regardless of floor construction. SAP 10.2 Table 4d
splits it three ways and RdSAP 10 Table 29 (p.56-57) selects which from floor
construction + age band: insulated timber floor R=1.0, screed above insulation
R=0.75, concrete slab R=0.25. A concrete-slab system (solid floor, age A-E) was
billed as screed (0.75), over-stating responsiveness and over-rating the
dwelling. The calculator must derive the Table 29 subtype from the main part's
floor construction + age band.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import Sap10Calculator
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_underfloor_responsiveness, # pyright: ignore[reportPrivateUsage]
)
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
def _corpus_cert(uprn: str) -> dict[str, Any]:
for line in _CORPUS.read_text().splitlines():
if uprn in line:
cert: dict[str, Any] = json.loads(line)
return cert
raise AssertionError(f"uprn {uprn} not found in the RdSAP-21.0.1 corpus")
def test_solid_floor_age_a_to_e_is_a_concrete_slab_r_0p25() -> None:
# Arrange / Act / Assert — RdSAP 10 Table 29: a solid floor in age bands A-E
# has no insulation layer, so the underfloor pipes sit in a concrete slab.
assert _underfloor_responsiveness("Solid", "B") == 0.25
assert _underfloor_responsiveness("Solid", "E") == 0.25
def test_solid_floor_age_f_onwards_is_screed_r_0p75() -> None:
# Arrange / Act / Assert — newer solid floors carry insulation, so the pipes
# are in screed above it (0.75), not a bare slab.
assert _underfloor_responsiveness("Solid", "F") == 0.75
assert _underfloor_responsiveness("Solid", "M") == 0.75
def test_suspended_timber_floor_is_r_1p0() -> None:
# Arrange / Act / Assert — a timber-floor underfloor system is fully
# responsive (Table 4d timber floor = 1.0).
assert _underfloor_responsiveness("Suspended timber", "D") == 1.0
def test_suspended_not_timber_is_screed_r_0p75_not_timber() -> None:
# Arrange / Act / Assert — "Suspended, not timber" must NOT be read as timber
# (the substring trap); it falls to the screed default 0.75.
assert _underfloor_responsiveness("Suspended, not timber", "C") == 0.75
def test_unknown_or_absent_floor_defaults_to_screed_r_0p75() -> None:
# Arrange / Act / Assert — a main part with no ground floor / unlodged
# construction keeps the prior 0.75, so ambiguous certs do not move.
assert _underfloor_responsiveness(None, "A") == 0.75
assert _underfloor_responsiveness("", None) == 0.75
def test_concrete_slab_cert_corrects_to_lodged() -> None:
# Arrange — cert 100100137438 (solid floor, band B, wet underfloor) over-rated
# at 72 on HEAD via the pinned 0.75; lodged 69.
payload = _corpus_cert("100100137438")
epc = EpcPropertyDataMapper.from_api_response(payload)
assert epc is not None
# Act
sap = Sap10Calculator().calculate(epc).sap_score
# Assert — Table 29 concrete-slab R=0.25 corrects it to exactly lodged 69.
assert sap == 69

View file

@ -280,7 +280,18 @@ _CORPUS = Path(
# (U=0), previously mislabelled a pitched roof so the party override never fired.
# Combined with main's gable fix: within-0.5 79.5% -> 80.3%, MAE 0.599 -> 0.584,
# PE 2.71 -> 2.5. Ratcheted.
_MIN_WITHIN_HALF_SAP = 0.803
# SAP-10.2 CALCULATOR ACCURACY BATCH (#1656 backlog: #1666/#1667/#1669/#1668,
# all stacked on the merged 80.3% baseline above):
# #1666 normalise dual-encoded main_heating_fraction (decimal vs percent) ->
# 80.3% -> 80.6%, MAE 0.584 -> 0.578;
# #1667 bill metal-framed API windows on the Table 24 metal U-column + FF 0.8 ->
# 80.6% -> 81.0%, MAE 0.578 -> 0.571;
# #1669 convert measurement_type=2 external dims to internal (RdSAP §3.4/Table 2)
# -> 81.0% -> 81.2%, MAE 0.571 -> 0.570, PE 2.5 -> 2.4;
# #1668 derive wet-underfloor Table 29 subtype (concrete-slab R=0.25) ->
# 81.2%, MAE 0.570 -> 0.565.
# Measured within-0.5 = 0.812000, MAE = 0.565082. Ratcheted.
_MIN_WITHIN_HALF_SAP = 0.812
# 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,
@ -415,7 +426,10 @@ _MIN_WITHIN_HALF_SAP = 0.803
# ceiling roof-code-3 slice stacking on main's #1662 floor-to-unheated-space
# fix: merged-corpus MAE measured 0.583761 (within-0.5 80.3%), rounded up to
# 3 d.p. so the `<=` assert holds.
_MAX_SAP_MAE = 0.584
# Ratcheted 0.584 -> 0.566 by the #1666/#1667/#1669/#1668 calculator-accuracy
# batch (see the _MIN_WITHIN_HALF_SAP note above): merged-corpus MAE measured
# 0.565082, rounded up to 3 d.p.
_MAX_SAP_MAE = 0.566
_MAX_CO2_MAE_TONNES = 0.068 # t CO2 / yr vs co2_emissions_current
_MAX_PE_PER_M2_MAE = 2.72 # kWh / m2 / yr vs energy_consumption_current