mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Merge branch 'main' into feature/deploy-sharepoint-renamer
This commit is contained in:
commit
1af9d84f94
46 changed files with 5052 additions and 333 deletions
|
|
@ -340,6 +340,12 @@ class ElmhurstSiteNotesExtractor:
|
|||
dry_lined=self._local_bool(
|
||||
lines, f"Alternative Wall {n} Dry-lining"
|
||||
),
|
||||
# RdSAP 10 Table 4 (p.22): a sheltered alt sub-area
|
||||
# (adjacent to an unheated buffer, e.g. a flat corridor
|
||||
# wall) adds R=0.5 m²K/W → U = 1/(1/U + 0.5).
|
||||
sheltered=self._local_bool(
|
||||
lines, f"Alternative Wall {n} Sheltered Wall"
|
||||
),
|
||||
))
|
||||
return result
|
||||
|
||||
|
|
@ -1001,7 +1007,17 @@ class ElmhurstSiteNotesExtractor:
|
|||
joined to the data line (no separate prefix line exists), so
|
||||
the only signal of window-transition is the orientation tokens
|
||||
rotating: orient_suffix(k) → orient_prefix(k+1). Falls through
|
||||
to `next_data_idx` when neither marker is present."""
|
||||
to `next_data_idx` when neither marker is present.
|
||||
|
||||
(c) A standalone wall-location line ("Alternative wall", "External
|
||||
wall", "Party wall") in the gap belongs to the NEXT window's
|
||||
prefix — it is that window's §11 Location cell, wrapped above its
|
||||
W×H×A data row. When the next window is single-glazed its prefix
|
||||
line carries no glazing-type word (branch a never fires), so
|
||||
without this the "Alternative wall" line is swallowed into the
|
||||
current window's suffix and the next window defaults to "External
|
||||
wall" (simulated case 34: 2 of 4 single-glazed alt-wall windows
|
||||
mis-allocated → wrong corridor-wall net area)."""
|
||||
scan_start = manuf_idx + 4
|
||||
seen_orient = False
|
||||
for j in range(scan_start, next_data_idx):
|
||||
|
|
@ -1009,6 +1025,8 @@ class ElmhurstSiteNotesExtractor:
|
|||
first_word = stripped.split(" ", 1)[0]
|
||||
if first_word in self._GLAZING_TYPE_PREFIX_WORDS:
|
||||
return j
|
||||
if "wall" in stripped.lower():
|
||||
return j
|
||||
if stripped in self._ORIENTATION_TOKENS:
|
||||
if seen_orient:
|
||||
return j
|
||||
|
|
@ -1531,6 +1549,9 @@ class ElmhurstSiteNotesExtractor:
|
|||
if cylinder_thermostat is None:
|
||||
if "Cylinder thermostat (Already installed)" in self._lines:
|
||||
cylinder_thermostat = True
|
||||
# §15.1 "Immersion Heater" lodging ("Dual" / "Single"). Scoped to
|
||||
# the §15.1..§15.2 slice so the lookup can't collide elsewhere.
|
||||
immersion_type = self._local_val(cylinder_lines, "Immersion Heater")
|
||||
return WaterHeating(
|
||||
water_heating_code=self._str_val("Water Heating Code"),
|
||||
water_heating_sap_code=self._int_val("Water Heating SapCode"),
|
||||
|
|
@ -1540,6 +1561,7 @@ class ElmhurstSiteNotesExtractor:
|
|||
cylinder_insulation_label=cylinder_insulation_label,
|
||||
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
|
||||
cylinder_thermostat=cylinder_thermostat,
|
||||
immersion_type=immersion_type,
|
||||
)
|
||||
|
||||
def _extract_baths_and_showers(self) -> BathsAndShowers:
|
||||
|
|
@ -1727,6 +1749,25 @@ class ElmhurstSiteNotesExtractor:
|
|||
))
|
||||
return arrays
|
||||
|
||||
def _extract_main_age_band(self) -> str:
|
||||
"""Read the Main Property age band from the §3.0 Date Built block.
|
||||
|
||||
"Main Property" also heads the §4 Dimensions table, so a global
|
||||
`_str_val("Main Property")` collides with it: when the layout
|
||||
glues "3.0 Date Built:" onto the "Main Property" row token (the
|
||||
recommendation worksheets do), the first standalone "Main
|
||||
Property" match is the dimensions header — yielding its next
|
||||
token ("Floor") instead of the age band. Scope the read to the
|
||||
Date-Built block and take the first age row — a line beginning
|
||||
with a single A-M band letter + space (e.g. "C 1930-1949",
|
||||
"A before 1900", "J 2003-2006"). Building-part name rows
|
||||
("Main Property", "1st Extension", "Main Prop. Room(s) in
|
||||
Roof") never start that way, and the Main row precedes any
|
||||
extension / room-in-roof rows."""
|
||||
block = self._between("3.0 Date Built", "4.0 Dimensions")
|
||||
m = re.search(r"^([A-M] .+)$", block, re.MULTILINE)
|
||||
return " ".join(m.group(1).split()) if m else ""
|
||||
|
||||
def extract(self) -> ElmhurstSiteNotes:
|
||||
emissions_raw = self._next_val("Emissions (t/year)")
|
||||
co2 = float(emissions_raw.split()[0]) if emissions_raw else 0.0
|
||||
|
|
@ -1744,7 +1785,7 @@ class ElmhurstSiteNotesExtractor:
|
|||
number_of_storeys=self._int_val("Storeys"),
|
||||
habitable_rooms=self._int_val("Habitable Rooms"),
|
||||
heated_habitable_rooms=self._int_val("Heated Habitable Rooms"),
|
||||
construction_age_band=self._str_val("Main Property"),
|
||||
construction_age_band=self._extract_main_age_band(),
|
||||
dimensions=self._extract_dimensions(),
|
||||
has_conservatory=self._bool_val("Is there a conservatory?"),
|
||||
walls=self._extract_walls(),
|
||||
|
|
|
|||
BIN
backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_001431_lpg_boiler.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001431_lpg_boiler.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_001431_topfloor_flat.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001431_topfloor_flat.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -961,6 +961,24 @@ def test_heating_systems_corpus_residual_matches_pin(
|
|||
)
|
||||
|
||||
|
||||
def test_solid_fuel_5_captures_section_15_1_dual_immersion() -> None:
|
||||
# Arrange — solid fuel 5 (cert 001431: House Coal main SAP 153, WHC 903
|
||||
# electric immersion HW, 18-hour meter, 110 L Normal cylinder). The
|
||||
# Elmhurst Summary §15.1 "Hot Water Cylinder" block lodges "Immersion
|
||||
# Heater: Dual". The extractor must surface it and the mapper map it to
|
||||
# the SAP10 `immersion_heating_type` code 1 (dual) per RdSAP 10 §10.5.
|
||||
summary_pdf, _p960 = _variant_paths('solid fuel 5')
|
||||
pages = _summary_pdf_to_textract_style_pages(summary_pdf)
|
||||
|
||||
# Act
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert site_notes.water_heating.immersion_type == "Dual"
|
||||
assert epc.sap_heating.immersion_heating_type == 1
|
||||
|
||||
|
||||
def test_oil_6_no_room_thermostat_applies_table_4c2_minus_5pp_space_efficiency() -> None:
|
||||
# Arrange — oil 6 (B30K standard liquid-fuel boiler, Table 4b code
|
||||
# 126 winter 80 / summer 68) lodges "Main Heating Controls Sap: SAP
|
||||
|
|
|
|||
|
|
@ -42,7 +42,15 @@ from datatypes.epc.domain.mapper import (
|
|||
EpcPropertyDataMapper,
|
||||
UnmappedApiCode,
|
||||
UnmappedElmhurstLabel,
|
||||
_elmhurst_dwelling_type, # pyright: ignore[reportPrivateUsage]
|
||||
_elmhurst_glazing_type_code, # pyright: ignore[reportPrivateUsage]
|
||||
_elmhurst_immersion_type_code, # pyright: ignore[reportPrivateUsage]
|
||||
_map_elmhurst_alternative_wall, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
from datatypes.epc.surveys.elmhurst_site_notes import (
|
||||
AlternativeWall as ElmhurstAlternativeWall,
|
||||
FloorDetails as ElmhurstFloorDetails,
|
||||
RoofDetails as ElmhurstRoofDetails,
|
||||
)
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
|
|
@ -78,12 +86,19 @@ _SUMMARY_000904_PDF = _FIXTURES / "Summary_000904.pdf" # cert 9285
|
|||
_SUMMARY_000900_PDF = _FIXTURES / "Summary_000900.pdf" # cert 2225
|
||||
_SUMMARY_000898_PDF = _FIXTURES / "Summary_000898.pdf" # cert 2636
|
||||
_SUMMARY_000902_PDF = _FIXTURES / "Summary_000902.pdf" # cert 9418
|
||||
# simulated case 34 (cert 001431 reconfigured as a slimline electric-storage
|
||||
# flat with an unheated corridor / sheltered alternative wall + 4 alt-wall
|
||||
# windows). Regression net for the flat-roof, sheltered-wall, and §11
|
||||
# alt-wall-window-allocation fixes.
|
||||
_SUMMARY_CASE34_PDF = _FIXTURES / "Summary_case34_storage_flat.pdf"
|
||||
_SUMMARY_000889_PDF = _FIXTURES / "Summary_000889.pdf" # cert 2536 (Normal cylinder)
|
||||
_SUMMARY_000884_PDF = _FIXTURES / "Summary_000884.pdf" # cert 9421 (Normal cylinder)
|
||||
_SUMMARY_000910_PDF = _FIXTURES / "Summary_000910.pdf" # cert 0036 (Flat, party wall U=0)
|
||||
_SUMMARY_000890_PDF = _FIXTURES / "Summary_000890.pdf" # cert 7800 (two electric showers)
|
||||
_SUMMARY_000565_PDF = _FIXTURES / "Summary_000565.pdf" # cert 000565 (5-bp Elmhurst-only)
|
||||
_SUMMARY_001431_CASE20_PDF = _FIXTURES / "Summary_001431_case20.pdf" # sim case 20 (storage heaters + RR type-2 + wrapped "Double between 2002 and 2021" glazing)
|
||||
_SUMMARY_001431_TOPFLOOR_PDF = _FIXTURES / "Summary_001431_topfloor_flat.pdf" # gas-boiler-upgrade recommendation "after" — top-floor flat, PS sloping roof; exercises the Date-Built age-band + flat-position layout regressions
|
||||
_SUMMARY_001431_LPG_PDF = _FIXTURES / "Summary_001431_lpg_boiler.pdf" # lpg-boiler recommendation "before" — §14 SAP code 115, §15 "Bottled gas"; exercises the bottled-LPG main-fuel mapping
|
||||
|
||||
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
|
||||
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
|
||||
|
|
@ -163,6 +178,105 @@ def test_summary_001431_case20_fabric_heat_loss_matches_worksheet_line_33() -> N
|
|||
assert abs(ht.fabric_heat_loss_w_per_k - 285.9847) <= 1e-4
|
||||
|
||||
|
||||
def test_summary_001431_topfloor_extracts_main_property_age_band() -> None:
|
||||
# Arrange — the gas-boiler-upgrade recommendation "after" Summary
|
||||
# renders "3.0 Date Built:" glued to its "Main Property" row header
|
||||
# on one layout line, so the FIRST standalone "Main Property" token
|
||||
# is the §4 dimensions-table header (followed by "Floor"). The
|
||||
# extractor must read the age band from the Date-Built block, not the
|
||||
# first global "Main Property" match — the worksheet lodges age band
|
||||
# C (1930-1949).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF)
|
||||
|
||||
# Act
|
||||
survey = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Assert
|
||||
assert survey.construction_age_band == "C 1930-1949"
|
||||
|
||||
|
||||
def test_summary_001431_lpg_boiler_maps_main_fuel_to_bottled_lpg() -> None:
|
||||
# Arrange — the lpg-boiler recommendation "before" Summary lodges
|
||||
# §14.0 SAP code 115 (a Table 4b gas-family boiler row), §15.0
|
||||
# "Water Heating Fuel Type: Bottled gas", and §14.2 "Main gas: Yes".
|
||||
# The boiler burns bottled LPG, not mains gas; the mapper must
|
||||
# resolve the carrier from the "Bottled gas" label, NOT default to
|
||||
# mains gas via the (contradictory) meter flag. Table-route code 3 =
|
||||
# bottled LPG main heating (Table 32/12 10.30/9.46 p/kWh) — NOT code
|
||||
# 5, which collides with anthracite (`canonical_fuel_code`).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
main = epc.sap_heating.main_heating_details[0]
|
||||
assert main.main_fuel_type == 3
|
||||
assert epc.sap_heating.water_heating_fuel == 3
|
||||
|
||||
|
||||
def test_summary_001431_lpg_boiler_full_chain_sap_matches_worksheet_pdf() -> None:
|
||||
# Arrange — the lpg-boiler "before" worksheet (P960-0001-001431):
|
||||
# pre-1998 LPG boiler (SAP code 115, eff 61%) + 210 L cylinder, NO
|
||||
# cylinder thermostat, control 2113 (room thermostat + TRVs, no
|
||||
# programmer). RdSAP 10 §10.5 (p.57) "Hot water separately timed":
|
||||
# a no-programmer + pre-1998 boiler is NOT separately timed, so the
|
||||
# Table 2b temperature factor (53) is 0.78 (= 0.60 × 1.3), not
|
||||
# 0.702 (× 0.9). Worksheet §11a lodges unrounded SAP -6.6499.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_LPG_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
# Assert
|
||||
worksheet_unrounded_sap = -6.6499
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_summary_001431_topfloor_flat_classified_as_top_floor() -> None:
|
||||
# Arrange — the recommendation "after" Summary lodges §6.0 "Position
|
||||
# of flat in block of flats: Top Floor": floor "A Another dwelling
|
||||
# below" (party) + roof "PS Pitched, sloping ceiling" (an exposed
|
||||
# external roof, NOT a room-in-roof). The mapper must classify it
|
||||
# Top-floor (roof exposed) — not Mid-floor — so the cascade charges
|
||||
# the roof heat-loss term.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.dwelling_type == "Top-floor flat"
|
||||
|
||||
|
||||
def test_summary_001431_topfloor_full_chain_sap_matches_worksheet_pdf() -> None:
|
||||
# Arrange — gas-boiler-upgrade-with-cylinder recommendation "after"
|
||||
# worksheet (P960-0001-001431). Top-floor flat, PS sloping roof at
|
||||
# U=2.3 (age C, uninsulated) → (30) roof 241.68 W/K, (33) fabric
|
||||
# 320.06, (37) HLC 348.76. Worksheet §11a lodges unrounded SAP
|
||||
# 56.3649. Exercises both upstream fixes: the Date-Built age band
|
||||
# (roof U 2.3 not 0.4) and the top-floor flat classification (roof
|
||||
# not dropped).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001431_TOPFLOOR_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
# Assert
|
||||
worksheet_unrounded_sap = 56.3649
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_summary_000474_mapper_produces_three_building_parts() -> None:
|
||||
# Arrange — cert U985-0001-000474 is a mid-terrace with 3 building
|
||||
# parts (Main + 2 extensions) per the hand-built worksheet fixture
|
||||
|
|
@ -1439,6 +1553,164 @@ def test_summary_mapper_raises_on_unmapped_cylinder_insulation_label() -> None:
|
|||
assert excinfo.value.value == "Polyester wool"
|
||||
|
||||
|
||||
def test_case34_alt_wall_windows_all_allocated_to_alternative_wall() -> None:
|
||||
# Arrange — simulated case 34 lodges 4 windows on "Alternative wall 1"
|
||||
# (0.70 + 1.75 + 1.18 + 1.00 = 4.63 m²) and 6 on the external wall. The
|
||||
# §11 layout interleaves the wrapped "Alternative wall / 1" Location cell
|
||||
# around each window's data row; for single-glazed alt windows the
|
||||
# location line carries no glazing-type word, so the partition swallowed
|
||||
# it into the previous window's suffix and the window defaulted to
|
||||
# "External wall" — mis-deducting its opening from the wrong wall.
|
||||
from domain.sap10_calculator.worksheet.heat_transmission import (
|
||||
_window_on_alt_wall, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_CASE34_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Act
|
||||
alt_area = sum(
|
||||
round(float(w.window_width) * float(w.window_height), 2)
|
||||
for w in (epc.sap_windows or [])
|
||||
if _window_on_alt_wall(w)
|
||||
)
|
||||
alt_count = sum(1 for w in (epc.sap_windows or []) if _window_on_alt_wall(w))
|
||||
|
||||
# Assert — all 4 alt-wall windows recovered (worksheet alt openings 4.63).
|
||||
assert alt_count == 4
|
||||
assert abs(alt_area - 4.63) <= 0.01
|
||||
|
||||
|
||||
def test_map_elmhurst_alternative_wall_carries_sheltered_flag() -> None:
|
||||
# Arrange — Elmhurst Summary §7 lodges "Alternative Wall N Sheltered
|
||||
# Wall: Yes" for a sub-area adjacent to an unheated buffer (e.g. a flat's
|
||||
# corridor wall). RdSAP 10 Table 4 (p.22) gives a sheltered wall an added
|
||||
# R=0.5 m²K/W → U=1/(1/U+0.5). The cascade applies this via
|
||||
# SapAlternativeWall.is_sheltered (the API path already wires it); the
|
||||
# Elmhurst path must surface it too. Surfaced by simulated case 34 (cert
|
||||
# 001431 flat: 6.02 m² corridor wall billed at full U=1.50 instead of
|
||||
# the sheltered 0.86 → +3.85 W/K, -1.61 SAP).
|
||||
sheltered = ElmhurstAlternativeWall(
|
||||
area_m2=12.5, wall_type="CA Cavity", insulation="A As Built",
|
||||
thickness_unknown=False, thickness_mm=250, u_value_known=False,
|
||||
dry_lined=False, sheltered=True,
|
||||
)
|
||||
plain = ElmhurstAlternativeWall(
|
||||
area_m2=12.5, wall_type="CA Cavity", insulation="A As Built",
|
||||
thickness_unknown=False, thickness_mm=250, u_value_known=False,
|
||||
dry_lined=False, sheltered=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
mapped_sheltered = _map_elmhurst_alternative_wall(sheltered)
|
||||
mapped_plain = _map_elmhurst_alternative_wall(plain)
|
||||
|
||||
# Assert
|
||||
assert mapped_sheltered.is_sheltered is True
|
||||
assert mapped_plain.is_sheltered is False
|
||||
|
||||
|
||||
def test_elmhurst_dwelling_type_single_storey_flat_with_exposed_roof_is_top_floor() -> None:
|
||||
# Arrange — a single-storey flat exposed BOTH top (external pitched
|
||||
# roof, access to loft) AND bottom (floor over partially-heated space,
|
||||
# not another dwelling) — simulated case 34 (cert 001431). Pre-fix the
|
||||
# absence of a "dwelling below" routed it to "Ground-floor flat", which
|
||||
# the cascade's `_dwelling_exposure` maps to has_exposed_roof=False,
|
||||
# dropping the 91.95 W/K external roof (+21.76 SAP over-prediction). An
|
||||
# exposed (non-party) roof means the flat is on the top storey →
|
||||
# "Top-floor flat"; its exposed floor is recovered downstream by the
|
||||
# per-BP is_above_partially_heated_space override.
|
||||
roof = ElmhurstRoofDetails(
|
||||
roof_type="PA Pitched (slates/tiles), access to loft",
|
||||
insulation="N None", u_value_known=False,
|
||||
)
|
||||
floor = ElmhurstFloorDetails(
|
||||
location="P Above partially heated space",
|
||||
floor_type="", insulation="A As built", u_value_known=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = _elmhurst_dwelling_type(
|
||||
built_form="Mid-Terrace", property_type="Flat",
|
||||
floor=floor, roof=roof, room_in_roof=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == "Top-floor flat"
|
||||
|
||||
|
||||
def test_elmhurst_dwelling_type_party_roof_flat_stays_ground_floor() -> None:
|
||||
# Arrange — a genuine ground-floor flat with a dwelling ABOVE lodges a
|
||||
# party roof ("A Another dwelling above") + a real ground floor. It must
|
||||
# stay "Ground-floor flat" (roof party, floor exposed) — the fix must
|
||||
# not over-promote party-roof flats to top-floor.
|
||||
roof = ElmhurstRoofDetails(
|
||||
roof_type="A Another dwelling above",
|
||||
insulation="N None", u_value_known=False,
|
||||
)
|
||||
floor = ElmhurstFloorDetails(
|
||||
location="G Ground floor",
|
||||
floor_type="", insulation="A As built", u_value_known=False,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = _elmhurst_dwelling_type(
|
||||
built_form="Mid-Terrace", property_type="Flat",
|
||||
floor=floor, roof=roof, room_in_roof=None,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == "Ground-floor flat"
|
||||
|
||||
|
||||
def test_elmhurst_glazing_type_code_strips_interleaved_alternative_wall() -> None:
|
||||
# Arrange — when a property lodges an Alternative Wall (cert 001431
|
||||
# storage-heater variants, "simulated case 34"), pdftotext interleaves
|
||||
# the §11 "Alternative wall 1" location column into the wrapped
|
||||
# glazing-type cell, e.g. "Double between 2002 Alternative wall and 2021
|
||||
# 1 Alternative wall". The wall-location fragments are not part of the
|
||||
# glazing type — the helper must recover "Double between 2002 and 2021".
|
||||
|
||||
# Act
|
||||
code = _elmhurst_glazing_type_code(
|
||||
"Double between 2002 Alternative wall and 2021 1 Alternative wall"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert code == _elmhurst_glazing_type_code("Double between 2002 and 2021")
|
||||
|
||||
|
||||
def test_elmhurst_immersion_type_code_maps_dual_and_single() -> None:
|
||||
# Arrange — Elmhurst Summary §15.1 "Immersion Heater" lodges "Dual"
|
||||
# or "Single". RdSAP 10 §10.5 (PDF p.54): an immersion is "assumed
|
||||
# dual" on a dual/off-peak meter; the SAP10 cascade code is 1 = dual,
|
||||
# 2 = single (cert_to_inputs `_IMMERSION_TYPE_DUAL`).
|
||||
|
||||
# Act
|
||||
dual = _elmhurst_immersion_type_code("Dual", cylinder_present=True)
|
||||
single = _elmhurst_immersion_type_code("Single", cylinder_present=True)
|
||||
no_cylinder = _elmhurst_immersion_type_code("Dual", cylinder_present=False)
|
||||
absent = _elmhurst_immersion_type_code(None, cylinder_present=True)
|
||||
|
||||
# Assert
|
||||
assert dual == 1
|
||||
assert single == 2
|
||||
assert no_cylinder is None
|
||||
assert absent is None
|
||||
|
||||
|
||||
def test_elmhurst_immersion_type_code_raises_on_unmapped_label() -> None:
|
||||
# Arrange — a lodged §15.1 "Immersion Heater" label outside the
|
||||
# {Dual, Single} set must strict-raise (mirror of the cylinder-size /
|
||||
# cylinder-insulation helpers) rather than silently drop the field.
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedElmhurstLabel) as excinfo:
|
||||
_elmhurst_immersion_type_code("Triple", cylinder_present=True)
|
||||
assert excinfo.value.field == "immersion_type"
|
||||
assert excinfo.value.value == "Triple"
|
||||
|
||||
|
||||
def test_all_seven_ashp_cohort_certs_extract_without_unmapped_label_raise() -> None:
|
||||
# Arrange — coverage forcing function: every cohort cert must
|
||||
# extract through `from_elmhurst_site_notes` without triggering an
|
||||
|
|
@ -1539,6 +1811,20 @@ def test_elmhurst_glazing_label_full_coverage_per_sap10_table_6b() -> None:
|
|||
)
|
||||
|
||||
|
||||
def test_elmhurst_glazing_label_strips_wrapped_building_part_fragment() -> None:
|
||||
# Arrange — pdftotext wraps the §11 building-part column (e.g. "1st"
|
||||
# for the 1st Extension) onto the glazing-TYPE token even when no
|
||||
# glazing-GAP descriptor ("16 mm") sits between them, so the lodged
|
||||
# label reads "Double between 2002 and 2021 1st". The fragment is a
|
||||
# building-part marker, not part of the glazing type — it must be
|
||||
# stripped so the label resolves to its base code. Worksheet
|
||||
# `simulated case 33` (direct-acting electric boiler + immersion)
|
||||
# surfaced this.
|
||||
# Act / Assert — base "Double between 2002 and 2021" → code 3.
|
||||
assert _elmhurst_glazing_type_code("Double between 2002 and 2021 1st") == 3
|
||||
assert _elmhurst_glazing_type_code("Single glazing 2nd") == 1
|
||||
|
||||
|
||||
def test_extension_party_wall_type_read_independently_of_as_main_wall() -> None:
|
||||
# Arrange — RdSAP 10 §3.3: "As Main Wall: Yes" inherits only the
|
||||
# external wall CONSTRUCTION; the party wall type is lodged
|
||||
|
|
|
|||
|
|
@ -138,6 +138,10 @@ class SapHeating:
|
|||
cylinder_size: Optional[Union[int, str]] = (
|
||||
None # int code from API; str (e.g. "Normal (90-130 litres)") from site notes
|
||||
)
|
||||
# RdSAP 10 §10.5 Table 28 — the lodged measured cylinder volume in
|
||||
# litres, present only when `cylinder_size` is the "Exact" descriptor
|
||||
# (gov-API code 6, field `cylinder_size_measured`). None otherwise.
|
||||
cylinder_volume_measured_l: Optional[int] = None
|
||||
water_heating_code: Optional[int] = None # TODO: make enum?
|
||||
water_heating_fuel: Optional[int] = None # TODO: make enum?
|
||||
immersion_heating_type: Optional[Union[int, str]] = None # TODO: make enum?
|
||||
|
|
@ -441,6 +445,14 @@ class SapAlternativeWall:
|
|||
# Mirrors `SapBuildingPart.wall_thickness_mm` per the
|
||||
# [[feedback-no-misleading-insulation-type]] convention.
|
||||
wall_thickness_mm: Optional[int] = None
|
||||
# RdSAP 10 Table 4 (p.22) "Sheltered" wall: a sub-area adjacent to
|
||||
# an unheated buffer space (stair core, adjoining structure) carries
|
||||
# an added external surface resistance of R=0.5 m²K/W, so its U is
|
||||
# reduced to U_sheltered = 1/(1/U + 0.5) — the same adjustment the
|
||||
# main wall applies for `gable_wall_type=2`. The gov-EPC API lodges
|
||||
# this per alt-wall as `sheltered_wall="Y"`; the Summary/Elmhurst path
|
||||
# leaves it False (sheltering rides through the lodged U-value there).
|
||||
is_sheltered: bool = False
|
||||
# Explicit basement determination. RdSAP10 `wall_construction == 6` is
|
||||
# canonically SYSTEM-BUILT (`WALL_SYSTEM_BUILT`) — the basement
|
||||
# heuristic hijacked it because Elmhurst lodges both "SY System build"
|
||||
|
|
@ -498,10 +510,19 @@ class SapBuildingPart:
|
|||
# (λ = 0.04 / 0.03 / 0.025 W/m·K). Used by the documentary-evidence
|
||||
# R-value path when a measured wall thickness is lodged alongside it.
|
||||
wall_insulation_thermal_conductivity: Optional[Union[str, int]] = None
|
||||
# RdSAP 10 §5.1 — the assessor's lodged main-wall U-value (W/m²K), surfaced
|
||||
# by the gov-EPC API as `wall_u_value`. Authoritative when the open data
|
||||
# redacts the backing insulation; overrides the §5.6/§5.7 construction-
|
||||
# default cascade for the main wall (alt-wall sub-areas keep their own U).
|
||||
wall_u_value: Optional[float] = None
|
||||
sap_alternative_wall_1: Optional[SapAlternativeWall] = None
|
||||
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
|
||||
|
||||
floor_heat_loss: Optional[int] = None
|
||||
# RdSAP 10 §5.1 — the assessor's lodged ground-floor U-value (W/m²K),
|
||||
# surfaced by the gov-EPC API as `floor_u_value`. Overrides the BS EN ISO
|
||||
# 13370 / Table 19 ground-floor cascade when present.
|
||||
floor_u_value: Optional[float] = None
|
||||
floor_insulation_thickness: Optional[str] = None
|
||||
flat_roof_insulation_thickness: Optional[Union[str, int]] = (
|
||||
None # TODO: make enum/mapping?
|
||||
|
|
@ -519,6 +540,12 @@ class SapBuildingPart:
|
|||
roof_construction_type: Optional[str] = (
|
||||
None # str from site notes e.g. "PS Pitched, sloping ceiling"
|
||||
)
|
||||
# RdSAP 10 §5.1 — the assessor's lodged roof U-value (W/m²K). The gov-EPC
|
||||
# API surfaces it as `roof_u_value`; it is the RdSAP-assessed output for
|
||||
# the roof and overrides the §5.11 construction-default cascade in
|
||||
# `heat_transmission` (the open data can redact the backing insulation
|
||||
# thickness, so the cascade otherwise mis-derives an uninsulated U).
|
||||
roof_u_value: Optional[float] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = (
|
||||
None # TODO: make enum/mapping?
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import re
|
|||
from dataclasses import replace
|
||||
from datetime import date
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any, Dict, Final, List, Optional, Sequence, Union, cast
|
||||
from typing import Any, Dict, Final, List, Optional, Sequence, TypeVar, Union, cast
|
||||
from datatypes.epc.schema.helpers import from_dict
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
|
|
@ -309,6 +309,7 @@ class EpcPropertyDataMapper:
|
|||
built_form=built_form,
|
||||
property_type=property_type,
|
||||
floor=survey.floor,
|
||||
roof=survey.roof,
|
||||
room_in_roof=survey.room_in_roof,
|
||||
)
|
||||
|
||||
|
|
@ -588,7 +589,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -779,7 +780,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -978,7 +979,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -1175,7 +1176,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -1410,7 +1411,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -1571,6 +1572,7 @@ class EpcPropertyDataMapper:
|
|||
has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning
|
||||
== "true",
|
||||
cylinder_size=schema.sap_heating.cylinder_size,
|
||||
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
|
||||
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,
|
||||
|
|
@ -1643,7 +1645,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -1703,6 +1705,12 @@ class EpcPropertyDataMapper:
|
|||
bp.construction_age_band,
|
||||
bp.sloping_ceiling_insulation_thickness,
|
||||
),
|
||||
# RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U
|
||||
# overrides the §5.6/§5.7/§5.11 construction-default cascade
|
||||
# (gov open data can redact the backing insulation).
|
||||
roof_u_value=bp.roof_u_value,
|
||||
wall_u_value=bp.wall_u_value,
|
||||
floor_u_value=bp.floor_u_value,
|
||||
sap_room_in_roof=_api_build_room_in_roof(
|
||||
bp.sap_room_in_roof,
|
||||
is_flat=schema.property_type == 2,
|
||||
|
|
@ -1711,10 +1719,11 @@ class EpcPropertyDataMapper:
|
|||
SapAlternativeWall(
|
||||
wall_area=bp.sap_alternative_wall_1.wall_area,
|
||||
wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined,
|
||||
wall_construction=bp.sap_alternative_wall_1.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction),
|
||||
wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type,
|
||||
wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured,
|
||||
wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness,
|
||||
is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y",
|
||||
)
|
||||
if bp.sap_alternative_wall_1
|
||||
else None
|
||||
|
|
@ -1723,10 +1732,11 @@ class EpcPropertyDataMapper:
|
|||
SapAlternativeWall(
|
||||
wall_area=bp.sap_alternative_wall_2.wall_area,
|
||||
wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined,
|
||||
wall_construction=bp.sap_alternative_wall_2.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction),
|
||||
wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type,
|
||||
wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured,
|
||||
wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness,
|
||||
is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y",
|
||||
)
|
||||
if bp.sap_alternative_wall_2
|
||||
else None
|
||||
|
|
@ -1810,6 +1820,18 @@ class EpcPropertyDataMapper:
|
|||
open_chimneys_count=schema.open_chimneys_count,
|
||||
insulated_door_count=schema.insulated_door_count,
|
||||
draughtproofed_door_count=schema.draughtproofed_door_count,
|
||||
# Mechanical ventilation PCDB plumbing — feeds the §5 Table 4f
|
||||
# line (230a) decentralised-MEV fan electricity
|
||||
# (`SFPav × 1.22 × V`, PCDB Tables 322/329). Without the index
|
||||
# the cascade's `_mev_decentralised_kwh_per_yr_from_cert`
|
||||
# short-circuits to 0, leaving the MEV fan running cost off the
|
||||
# bill (the +0.9 SAP over-rate residual on the MEV cohort once
|
||||
# the §2 ventilation heat-loss was fixed). Duct type selects the
|
||||
# Table 329 in-use factor (1=Flexible / 2=Rigid).
|
||||
mechanical_ventilation_index_number=(
|
||||
schema.mechanical_ventilation_index_number
|
||||
),
|
||||
mechanical_vent_duct_type=schema.mechanical_vent_duct_type,
|
||||
# Lighting
|
||||
led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count,
|
||||
cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count,
|
||||
|
|
@ -1860,6 +1882,7 @@ class EpcPropertyDataMapper:
|
|||
has_fixed_air_conditioning=schema.sap_heating.has_fixed_air_conditioning
|
||||
== "true",
|
||||
cylinder_size=schema.sap_heating.cylinder_size,
|
||||
cylinder_volume_measured_l=schema.sap_heating.cylinder_size_measured,
|
||||
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,
|
||||
|
|
@ -1931,7 +1954,7 @@ class EpcPropertyDataMapper:
|
|||
SapBuildingPart(
|
||||
identifier=BuildingPartIdentifier.from_api_string(bp.identifier),
|
||||
construction_age_band=bp.construction_age_band,
|
||||
wall_construction=bp.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.wall_construction),
|
||||
wall_insulation_type=bp.wall_insulation_type,
|
||||
wall_thickness_measured=bp.wall_thickness_measured == "Y",
|
||||
party_wall_construction=_api_party_wall_construction_int(
|
||||
|
|
@ -1991,6 +2014,12 @@ class EpcPropertyDataMapper:
|
|||
bp.construction_age_band,
|
||||
bp.sloping_ceiling_insulation_thickness,
|
||||
),
|
||||
# RdSAP 10 §5.1 — the assessor's lodged roof/wall/floor U
|
||||
# overrides the §5.6/§5.7/§5.11 construction-default cascade
|
||||
# (gov open data can redact the backing insulation).
|
||||
roof_u_value=bp.roof_u_value,
|
||||
wall_u_value=bp.wall_u_value,
|
||||
floor_u_value=bp.floor_u_value,
|
||||
sap_room_in_roof=_api_build_room_in_roof(
|
||||
bp.sap_room_in_roof,
|
||||
is_flat=schema.property_type == 2,
|
||||
|
|
@ -1999,10 +2028,11 @@ class EpcPropertyDataMapper:
|
|||
SapAlternativeWall(
|
||||
wall_area=bp.sap_alternative_wall_1.wall_area,
|
||||
wall_dry_lined=bp.sap_alternative_wall_1.wall_dry_lined,
|
||||
wall_construction=bp.sap_alternative_wall_1.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_1.wall_construction),
|
||||
wall_insulation_type=bp.sap_alternative_wall_1.wall_insulation_type,
|
||||
wall_thickness_measured=bp.sap_alternative_wall_1.wall_thickness_measured,
|
||||
wall_insulation_thickness=bp.sap_alternative_wall_1.wall_insulation_thickness,
|
||||
is_sheltered=bp.sap_alternative_wall_1.sheltered_wall == "Y",
|
||||
)
|
||||
if bp.sap_alternative_wall_1
|
||||
else None
|
||||
|
|
@ -2011,10 +2041,11 @@ class EpcPropertyDataMapper:
|
|||
SapAlternativeWall(
|
||||
wall_area=bp.sap_alternative_wall_2.wall_area,
|
||||
wall_dry_lined=bp.sap_alternative_wall_2.wall_dry_lined,
|
||||
wall_construction=bp.sap_alternative_wall_2.wall_construction,
|
||||
wall_construction=_api_wall_construction_code(bp.sap_alternative_wall_2.wall_construction),
|
||||
wall_insulation_type=bp.sap_alternative_wall_2.wall_insulation_type,
|
||||
wall_thickness_measured=bp.sap_alternative_wall_2.wall_thickness_measured,
|
||||
wall_insulation_thickness=bp.sap_alternative_wall_2.wall_insulation_thickness,
|
||||
is_sheltered=bp.sap_alternative_wall_2.sheltered_wall == "Y",
|
||||
)
|
||||
if bp.sap_alternative_wall_2
|
||||
else None
|
||||
|
|
@ -2148,6 +2179,14 @@ class EpcPropertyDataMapper:
|
|||
# default 2 → over-counted shelter factor → -2.42
|
||||
# ACH/month infiltration shortfall).
|
||||
sheltered_sides=_api_sheltered_sides(schema.built_form),
|
||||
# RdSAP-Schema-21 `mechanical_ventilation` enum → §2 (24a..d)
|
||||
# MV-kind dispatch. Without this an MEV / PIV-from-outside
|
||||
# dwelling defaulted to NATURAL and under-stated its
|
||||
# ventilation heat loss (+1.90 SAP over-rate on the n=20
|
||||
# MEV cohort).
|
||||
mechanical_ventilation_kind=_api_mechanical_ventilation_kind(
|
||||
schema.mechanical_ventilation
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -2594,6 +2633,33 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
|
|||
}
|
||||
|
||||
|
||||
_WallConstructionCode = TypeVar("_WallConstructionCode")
|
||||
|
||||
|
||||
def _api_wall_construction_code(
|
||||
code: _WallConstructionCode,
|
||||
) -> _WallConstructionCode:
|
||||
"""Translate the gov-EPC API `wall_construction` enum to the
|
||||
calculator's WALL_* code-space where the two DIVERGE.
|
||||
|
||||
The gov enum (confirmed by the description-vs-code audit) is
|
||||
1=granite, 2=sandstone, 3=solid brick, 4=cavity, 5=timber frame (all
|
||||
ALIGN with the WALL_* constants), 6=basement, 8=system built, 9=cob.
|
||||
Only code 9 needs remapping HERE: it collides with the calculator's
|
||||
`WALL_CURTAIN`=9 (which the Summary path's "CW" mapping legitimately
|
||||
uses, paired with a `curtain_wall_age`). The gov API has no curtain
|
||||
code, so an API `wall_construction` of 9 is unambiguously cob → remap to
|
||||
`WALL_COB`=7 at this boundary, before it can hit the curtain dispatch in
|
||||
`u_wall`. Gov 8 (system built) is left as-is — `u_wall` resolves it and
|
||||
calc `WALL_PARK_HOME`=8 is never dispatched; gov 6 (basement) is left to
|
||||
the basement machinery. Non-int values pass through unchanged; the input
|
||||
type is preserved so each call site's typed `wall_construction` field
|
||||
stays satisfied."""
|
||||
if code == 9:
|
||||
return cast(_WallConstructionCode, 7)
|
||||
return code
|
||||
|
||||
|
||||
# Elmhurst wall-insulation-type codes mapped to the SAP10 integer enum
|
||||
# documented at domain.sap10_ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY.
|
||||
# Two-letter dual codes ("FE", "FI") encode a cavity-wall that received a
|
||||
|
|
@ -2614,26 +2680,53 @@ _ELMHURST_INSULATION_CODE_TO_SAP10: Dict[str, int] = {
|
|||
}
|
||||
|
||||
|
||||
# Elmhurst roof codes that denote a party ceiling (another/same dwelling
|
||||
# or non-residential space ABOVE), so the flat's roof is NOT a heat-loss
|
||||
# surface: S (Same dwelling above), A (Another dwelling above), NR
|
||||
# (Non-residential space above). Every other roof code (F / PN / PA / PS)
|
||||
# is an exposed external roof — the dwelling is on the top storey.
|
||||
_ELMHURST_PARTY_ROOF_CODES: frozenset[str] = frozenset({"S", "A", "NR"})
|
||||
|
||||
|
||||
def _elmhurst_roof_is_exposed(roof: Optional[ElmhurstRoofDetails]) -> bool:
|
||||
"""Whether a flat's roof is an exposed (heat-loss) external roof.
|
||||
|
||||
The dual of the floor's "Another dwelling below" signal: the roof is
|
||||
a party ceiling only when its Elmhurst code is S / A / NR (a dwelling
|
||||
or non-residential space above). A plain external roof — including a
|
||||
"PS Pitched, sloping ceiling" with no room-in-roof — is exposed, so
|
||||
the flat sits on the top storey."""
|
||||
if roof is None:
|
||||
return False
|
||||
return _leading_code(roof.roof_type) not in _ELMHURST_PARTY_ROOF_CODES
|
||||
|
||||
|
||||
def _elmhurst_dwelling_type(
|
||||
*,
|
||||
built_form: str,
|
||||
property_type: str,
|
||||
floor: Optional[ElmhurstFloorDetails],
|
||||
roof: Optional[ElmhurstRoofDetails],
|
||||
room_in_roof: Optional[ElmhurstRoomInRoof],
|
||||
) -> str:
|
||||
"""Compose `EpcPropertyData.dwelling_type` from the Elmhurst Summary's
|
||||
property-type + attachment + floor-location + RR presence.
|
||||
property-type + attachment + floor-location + roof-type + RR presence.
|
||||
|
||||
For HOUSES: returns `f"{built_form} {property_type.lower()}"` — the
|
||||
historical contract ("Mid-Terrace house", "Detached house").
|
||||
|
||||
For FLATS: derives the floor-position prefix ("Top-floor",
|
||||
"Mid-floor", "Ground-floor") from `floor.location` + RR presence:
|
||||
- floor lodges "dwelling below" → roof exposed (RR present or
|
||||
external roof) → Top-floor; roof party (no RR/external) →
|
||||
Mid-floor;
|
||||
"Mid-floor", "Ground-floor") from `floor.location` + roof exposure:
|
||||
- floor lodges "dwelling below" → roof exposed (RR present OR an
|
||||
external roof type, per `_elmhurst_roof_is_exposed`) → Top-floor;
|
||||
roof party (dwelling above, no RR) → Mid-floor;
|
||||
- floor not over another dwelling → Ground-floor.
|
||||
|
||||
Reading the roof TYPE (not just room-in-roof presence) is the dual of
|
||||
reading the floor location: a top-floor flat can have a plain external
|
||||
sloping ceiling and no room-in-roof, which the RR-only test wrongly
|
||||
routed to Mid-floor (dropping the roof heat-loss term).
|
||||
|
||||
The cascade's `_dwelling_exposure` (cert_to_inputs.py) is prefix-
|
||||
matched on the lowercase result; correct flat-prefix detection is
|
||||
the gate for floor / roof party-surface routing (RdSAP 10 §5).
|
||||
|
|
@ -2642,8 +2735,17 @@ def _elmhurst_dwelling_type(
|
|||
return f"{built_form} {property_type.lower()}".strip()
|
||||
floor_loc = (floor.location if floor is not None else "") or ""
|
||||
has_dwelling_below = "dwelling below" in floor_loc.lower()
|
||||
has_exposed_roof = room_in_roof is not None
|
||||
if has_dwelling_below and has_exposed_roof:
|
||||
has_exposed_roof = room_in_roof is not None or _elmhurst_roof_is_exposed(roof)
|
||||
# An exposed (non-party) roof puts the flat on the TOP storey, whether
|
||||
# or not a dwelling sits below it. A single-storey flat exposed both top
|
||||
# (external roof) and bottom (floor over partially-heated space, no
|
||||
# dwelling below) is still top-floor for roof purposes — its exposed
|
||||
# floor is recovered by the per-BP is_above_partially_heated_space /
|
||||
# is_exposed_floor override in §3. Keying "Top-floor" on
|
||||
# has_dwelling_below dropped that roof, routing such flats to
|
||||
# "Ground-floor flat" → has_exposed_roof=False → no roof heat loss
|
||||
# (simulated case 34, cert 001431: 91.95 W/K roof dropped, +21.76 SAP).
|
||||
if has_exposed_roof:
|
||||
position = "Top-floor"
|
||||
elif has_dwelling_below:
|
||||
position = "Mid-floor"
|
||||
|
|
@ -3103,6 +3205,55 @@ def _api_sheltered_sides(built_form: object) -> Optional[int]:
|
|||
return _API_BUILT_FORM_TO_SHELTERED_SIDES[built_form]
|
||||
|
||||
|
||||
# GOV.UK API `mechanical_ventilation` integer (RdSAP-Schema-21 enum) →
|
||||
# `MechanicalVentilationKind` enum name picking the SAP 10.2 §2 (24a..d)
|
||||
# effective-air-change formula. Mirrors the Elmhurst-path
|
||||
# `_ELMHURST_MV_TYPE_TO_KIND` so both source paths converge on the same
|
||||
# cascade dispatch.
|
||||
# 0 natural → None (NATURAL, 24d)
|
||||
# 1 mechanical ventilation, no HR (MV) → MV (24b)
|
||||
# 2 mechanical extract, decentralised (MEVdc)→ EXTRACT_OR_PIV_OUTSIDE (24c)
|
||||
# 3 mechanical extract, centralised (MEV c) → EXTRACT_OR_PIV_OUTSIDE (24c)
|
||||
# 5 positive input from loft → None — RdSAP 10 §2.6 treats
|
||||
# loft-sourced PIV as natural
|
||||
# (no added system air change)
|
||||
# 6 positive input from outside → EXTRACT_OR_PIV_OUTSIDE (24c)
|
||||
# Code 4 (MVHR, 24a) is DEFERRED: its (24a)m formula needs the lodged
|
||||
# heat-recovery efficiency (`mvhr_efficiency_pct`, PCDB Table 326) which the
|
||||
# API→cascade path does not yet plumb; mapping it to MVHR with a null
|
||||
# efficiency would mis-model it as MV (no recovery), so it stays NATURAL
|
||||
# until the efficiency is wired. The extract systems (2/3/6) carry no
|
||||
# efficiency, so this slice closes the clean +1.90 SAP over-rate on the
|
||||
# MEV/PIV-outside cohort (n=20, 5% within 0.5) — they were silently
|
||||
# defaulting to NATURAL, under-stating ventilation heat loss.
|
||||
_API_MECHANICAL_VENTILATION_TO_KIND: Final[dict[int, Optional[str]]] = {
|
||||
0: None,
|
||||
1: "MV",
|
||||
2: "EXTRACT_OR_PIV_OUTSIDE",
|
||||
3: "EXTRACT_OR_PIV_OUTSIDE",
|
||||
4: None, # MVHR — efficiency plumbing deferred; treat as natural
|
||||
5: None,
|
||||
6: "EXTRACT_OR_PIV_OUTSIDE",
|
||||
}
|
||||
|
||||
|
||||
def _api_mechanical_ventilation_kind(mechanical_ventilation: object) -> Optional[str]:
|
||||
"""Translate the API `mechanical_ventilation` integer to a
|
||||
`MechanicalVentilationKind` enum name for the §2 cascade dispatch.
|
||||
|
||||
Strict-coverage: a lodged integer outside the mapped set raises
|
||||
`UnmappedApiCode` rather than silently defaulting to NATURAL (which
|
||||
under-states ventilation heat loss for any mechanical system).
|
||||
Non-int / None lodging stays as no-lodging (NATURAL)."""
|
||||
if isinstance(mechanical_ventilation, str) and mechanical_ventilation.isdigit():
|
||||
mechanical_ventilation = int(mechanical_ventilation)
|
||||
if not isinstance(mechanical_ventilation, int):
|
||||
return None
|
||||
if mechanical_ventilation not in _API_MECHANICAL_VENTILATION_TO_KIND:
|
||||
raise UnmappedApiCode("mechanical_ventilation", mechanical_ventilation)
|
||||
return _API_MECHANICAL_VENTILATION_TO_KIND[mechanical_ventilation]
|
||||
|
||||
|
||||
# ADR-0027 (Reduced-Field Synthesis): RdSAP 20.0.0 lodges a glazed_area *band* +
|
||||
# floor area, never window m². Synthesised total glazing = ratio x TFA; the ratio
|
||||
# is the MEDIAN glazing-area/floor-area of the 1000 real 21.0.1 certs (mean 0.155;
|
||||
|
|
@ -3168,14 +3319,28 @@ def _synthesise_reduced_field_windows(
|
|||
]
|
||||
|
||||
|
||||
# ADR-0028: multiple_glazing_type "ND" (Not Defined) → the DG-modal
|
||||
# default (cascade code 2 → daylight g_L 0.80), as for 18.0/19.0/17.x.
|
||||
_RDSAP20_ND_GLAZING_TYPE: int = 2
|
||||
|
||||
|
||||
def _synthesise_20_0_0_sap_windows(schema: RdSapSchema20_0_0) -> List[SapWindow]:
|
||||
"""ADR-0027/0028 seam: 20.0.0 glazed_type codes 1-8+ND are identical to
|
||||
21.0.1's, so route multiple_glazing_type through the verified cascade (fixes
|
||||
code 1 "DG pre-2002" read as single), then call the shared core."""
|
||||
code 1 "DG pre-2002" read as single), then call the shared core. The "ND"
|
||||
string (no cascade mapping) falls back to the DG-modal default, mirroring
|
||||
`_synthesise_18_0_sap_windows` — without this the cascade raised
|
||||
UnmappedApiCode on every ND-glazed 20.0.0 cert."""
|
||||
mgt = schema.multiple_glazing_type
|
||||
glazing_type = (
|
||||
_api_cascade_glazing_type(mgt)
|
||||
if isinstance(mgt, int)
|
||||
else _RDSAP20_ND_GLAZING_TYPE
|
||||
)
|
||||
return _synthesise_reduced_field_windows(
|
||||
schema.glazed_area,
|
||||
schema.total_floor_area,
|
||||
_api_cascade_glazing_type(schema.multiple_glazing_type),
|
||||
glazing_type,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -3298,9 +3463,10 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]:
|
|||
# "Fully double glazed" with a worksheet-resolved U=2.7. Per Table 24
|
||||
# row 2 (DG pre-2002, gap 16+, PVC/wooden) the spec answer is U=2.7,
|
||||
# so GOV.UK API code 1 is a schema sibling of code 3 (both alias the
|
||||
# "DG pre-2002 / unknown install date" row). The wider SAP10.2
|
||||
# glazing-type enum (4-12, 15+) is not yet mapped — incremental
|
||||
# coverage as new fixtures surface them.
|
||||
# "DG pre-2002 / unknown install date" row). The full RdSAP-21
|
||||
# glazing-type enum (single / secondary / triple, codes 4-12 + 15) is
|
||||
# now mapped from Table 24 below — single-glazed windows previously fell
|
||||
# through to the cascade default U=2.5 instead of their spec 4.8.
|
||||
#
|
||||
# Spec source: RdSAP 10 Table 24 "Window characteristics" page 49 —
|
||||
# DG pre-2002 spec U varies by gap (6mm=3.1, 12mm=2.8, 16+=2.7); the
|
||||
|
|
@ -3308,20 +3474,48 @@ def _synthesise_17_1_sap_windows(schema: RdSapSchema17_1) -> List[SapWindow]:
|
|||
# is lodged, falling back to the type-only default for missing gaps.
|
||||
_API_GLAZING_TYPE_TO_TRANSMISSION: Dict[int, tuple[float, float, float]] = {
|
||||
# (u_value, solar_transmittance/g_⊥, frame_factor)
|
||||
1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install
|
||||
# date — Table 24 row 2 (PVC/wooden), 12mm
|
||||
# gap default. Schema sibling of code 3.
|
||||
2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022)
|
||||
3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default)
|
||||
13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022
|
||||
14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022
|
||||
# (Table 24 last row: 2022+ E/W / 2023+ Sc
|
||||
# / 2022+ NI — same U/g as code 13 per
|
||||
# spec; the integer codes 13/14 are
|
||||
# schema siblings within the post-2022
|
||||
# product family. Cert 0380 lodges code
|
||||
# 14 on all windows; worksheet uses U=1.4
|
||||
# = post-curtain 1.3258.)
|
||||
1: (2.8, 0.76, 0.70), # Double glazed, pre-2002 / unknown install
|
||||
# date — Table 24 row 2 (PVC/wooden), 12mm
|
||||
# gap default. Schema sibling of code 3.
|
||||
2: (2.0, 0.72, 0.70), # Double glazed, England/Wales 2002+ (pre-2022)
|
||||
3: (2.8, 0.76, 0.70), # Double glazed, pre-2002 (12mm gap default)
|
||||
13: (1.4, 0.72, 0.70), # Double glazed, Argon-filled post-2022
|
||||
14: (1.4, 0.72, 0.70), # Double or triple glazed, post-2022
|
||||
# (Table 24 last row: 2022+ E/W / 2023+ Sc
|
||||
# / 2022+ NI — same U/g as code 13 per
|
||||
# spec; the integer codes 13/14 are
|
||||
# schema siblings within the post-2022
|
||||
# product family. Cert 0380 lodges code
|
||||
# 14 on all windows; worksheet uses U=1.4
|
||||
# = post-curtain 1.3258.)
|
||||
#
|
||||
# SINGLE / SECONDARY / TRIPLE glazing — RdSAP 10 Table 24 (spec p.50).
|
||||
# Previously unmapped (`_api_glazing_transmission` returned None) so the
|
||||
# cascade silently routed e.g. a single-glazed window to the u_window
|
||||
# all-None default 2.5 instead of its true 4.8 — over-rating single-
|
||||
# glazed dwellings (cert 0370-2933, 7 single windows, +17 SAP). Codes
|
||||
# per datatypes/epc/domain/epc_codes.csv `glazed_type` (RdSAP-Schema-21).
|
||||
4: (2.9, 0.85, 0.70), # Secondary glazing, unknown data → Table 24
|
||||
# Secondary "Normal emissivity" default (2.9).
|
||||
5: (4.8, 0.85, 0.70), # Single glazing — Table 24 "Single / Any
|
||||
# period" (PVC/wooden 4.8, g 0.85).
|
||||
6: (2.1, 0.68, 0.70), # Triple glazed, unknown install date — Table
|
||||
# 24 Triple pre-2002 12mm-gap default (2.1).
|
||||
7: (2.8, 0.76, 0.70), # Double glazed, known data — no measured U on
|
||||
# the reduced-data path → double pre-2002 /
|
||||
# unknown-date family default (2.8), as code 3.
|
||||
8: (2.1, 0.68, 0.70), # Triple glazed, known data → triple unknown-
|
||||
# date family default (2.1), as code 6.
|
||||
9: (2.0, 0.72, 0.70), # Triple glazed, 2002-2022 — Table 24 "Double
|
||||
# or triple, 2002+ (pre-2022), any gap" (2.0).
|
||||
10: (2.1, 0.68, 0.70), # Triple glazed, pre-2002 — Table 24 Triple
|
||||
# pre-2002 12mm-gap default (2.1).
|
||||
11: (2.9, 0.85, 0.70), # Secondary glazing, normal emissivity —
|
||||
# Table 24 Secondary "Normal emissivity" (2.9).
|
||||
12: (2.2, 0.85, 0.70), # Secondary glazing, low emissivity — Table 24
|
||||
# Secondary "Low emissivity" (2.2).
|
||||
15: (4.8, 0.85, 0.70), # Single glazing, known data → Single row
|
||||
# (4.8) when no measured U is lodged, as code 5.
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3341,6 +3535,22 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[
|
|||
(3, 6): (3.1, 0.76, 0.70),
|
||||
(3, 12): (2.8, 0.76, 0.70),
|
||||
(3, "16+"): (2.7, 0.76, 0.70),
|
||||
# Double glazed, known data (code 7) — aliases the double pre-2002 /
|
||||
# unknown-date Table 24 row (same as codes 1/3) when no measured U.
|
||||
(7, 6): (3.1, 0.76, 0.70),
|
||||
(7, 12): (2.8, 0.76, 0.70),
|
||||
(7, "16+"): (2.7, 0.76, 0.70),
|
||||
# Triple glazed pre-2002 / unknown / known-data (codes 6/8/10) —
|
||||
# Table 24 Triple pre-2002 row varies by gap (6mm=2.4, 12mm=2.1, 16+=2.0).
|
||||
(6, 6): (2.4, 0.68, 0.70),
|
||||
(6, 12): (2.1, 0.68, 0.70),
|
||||
(6, "16+"): (2.0, 0.68, 0.70),
|
||||
(8, 6): (2.4, 0.68, 0.70),
|
||||
(8, 12): (2.1, 0.68, 0.70),
|
||||
(8, "16+"): (2.0, 0.68, 0.70),
|
||||
(10, 6): (2.4, 0.68, 0.70),
|
||||
(10, 12): (2.1, 0.68, 0.70),
|
||||
(10, "16+"): (2.0, 0.68, 0.70),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -3348,15 +3558,20 @@ def _api_glazing_transmission(
|
|||
glazing_type: Optional[int],
|
||||
glazing_gap: object,
|
||||
) -> Optional[tuple[float, float, float]]:
|
||||
"""Resolve (U, g, frame_factor) for an API window. Per-gap override
|
||||
takes precedence over the type-only default; returns None when the
|
||||
glazing_type isn't yet in the lookup."""
|
||||
"""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."""
|
||||
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]
|
||||
return _API_GLAZING_TYPE_TO_TRANSMISSION.get(glazing_type)
|
||||
if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION:
|
||||
return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type]
|
||||
raise UnmappedApiCode("glazing_type", glazing_type)
|
||||
|
||||
|
||||
# GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade
|
||||
|
|
@ -3377,13 +3592,29 @@ def _api_glazing_transmission(
|
|||
# remap — incremental coverage as new fixtures surface them.
|
||||
_API_TO_SAP10_CASCADE_GLAZING_CODE: Dict[int, int] = {
|
||||
1: 2, # RdSAP 21 DG pre-2002 → cascade DG (g_L=0.80, not single 0.90)
|
||||
# RdSAP-21 codes 4 and 5 DIVERGE from the cascade enum in the 1-6 range
|
||||
# (cascade 4 = double low-E soft-coat, 5 = secondary). Without these the
|
||||
# g-tables read the wrong slot: a single-glazed window (RdSAP-21 5) took
|
||||
# the cascade-5 secondary g (g⊥ 0.76 / g_L 0.80) for solar + daylight
|
||||
# gains instead of single (0.85 / 0.90), and a secondary-glazed window
|
||||
# (RdSAP-21 4) took cascade-4 double-low-E (0.63). Correctness fix
|
||||
# (gains are second-order — negligible SAP impact; the dominant single-
|
||||
# glazing error was the U-value, closed in the Table 24 transmission map).
|
||||
4: 5, # RdSAP 21 secondary glazing → cascade secondary slot (0.76/0.80)
|
||||
5: 1, # RdSAP 21 single glazing → cascade single slot (0.85/0.90)
|
||||
}
|
||||
|
||||
|
||||
def _api_cascade_glazing_type(api_glazing_type: int) -> int:
|
||||
"""Canonicalise an API-lodged RdSAP 21 glazing-type code to the SAP
|
||||
10.2 Table 6b cascade enum that `_G_LIGHT_BY_GLAZING_CODE` reads.
|
||||
Pass-through for codes already coincident with the cascade table."""
|
||||
10.2 Table 6b cascade enum the g-value tables (`_G_LIGHT_BY_GLAZING_CODE`
|
||||
/ `_G_PERPENDICULAR_BY_GLAZING_TYPE`) read. Divergent codes are remapped;
|
||||
coincident codes pass through. An unknown glazing code raises
|
||||
`UnmappedApiCode` (mirrors `_api_glazing_transmission`) so a new code
|
||||
surfaces rather than silently reading a wrong g-value slot. The known
|
||||
set is the RdSAP-21 glazing enum the transmission table already covers."""
|
||||
if api_glazing_type not in _API_GLAZING_TYPE_TO_TRANSMISSION:
|
||||
raise UnmappedApiCode("glazing_type (cascade g)", api_glazing_type)
|
||||
return _API_TO_SAP10_CASCADE_GLAZING_CODE.get(api_glazing_type, api_glazing_type)
|
||||
|
||||
|
||||
|
|
@ -4210,6 +4441,11 @@ def _map_elmhurst_alternative_wall(
|
|||
wall_insulation_thickness=None,
|
||||
wall_thickness_mm=measured_thickness_mm,
|
||||
is_basement=_elmhurst_wall_is_basement(a.wall_type),
|
||||
# Summary §7 "Alternative Wall N Sheltered Wall: Yes" → RdSAP 10
|
||||
# Table 4 (p.22) sheltered U = 1/(1/U + 0.5), applied by the
|
||||
# cascade's `_alt_wall_w_per_k`. Mirror of the API path's
|
||||
# `sheltered_wall == "Y"` wiring.
|
||||
is_sheltered=a.sheltered,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -4924,6 +5160,17 @@ _ELMHURST_MAIN_FUEL_TO_SAP10: Dict[str, int] = {
|
|||
# existing oddity as "Oil" → 8; both labels are unused by any live
|
||||
# fixture). Live form on Elmhurst worksheets is "Bulk LPG".
|
||||
"Bulk LPG": 27,
|
||||
# Elmhurst Summary §14.0 / §15.0 lodging form for BOTTLED LPG
|
||||
# (cylinders) — the recommendation worksheets lodge "Bottled gas" as
|
||||
# the §15.0 "Water Heating Fuel Type" for an SAP-code-115 boiler.
|
||||
# 3 = API/epc-codes `main_fuel` code for bottled LPG main heating,
|
||||
# which routes via `API_FUEL_TO_TABLE_32`/`API_FUEL_TO_TABLE_12` →
|
||||
# Table-code 3 (bottled LPG main heating, 10.30 / 9.46 p/kWh). NOT
|
||||
# the legacy "LPG bottled": 5 above — API code 5 = anthracite, and
|
||||
# `canonical_fuel_code` resolves the same-valued Table-32 code 5 to
|
||||
# anthracite (3.64 p/kWh), so a 5 here would mis-price the dwelling
|
||||
# as cheap solid fuel (the cohort-2100 -61-SAP collision class).
|
||||
"Bottled gas": 3,
|
||||
# Elmhurst Summary §15.0 "Water Heating Fuel Type" labels for the
|
||||
# bio-liquid fuels added to the EES dict above. Values are Table 32
|
||||
# codes verbatim (no API enum collision). Spec: SAP 10.2 Table 12
|
||||
|
|
@ -5099,6 +5346,35 @@ def _elmhurst_pump_age_int(age_str: Optional[str]) -> Optional[int]:
|
|||
return 2
|
||||
|
||||
|
||||
# SAP 10.2 Table 4a "Room heaters" section — the secondary-heating SAP
|
||||
# code → FUEL CATEGORY map. Per RdSAP 10 §10.4.1 + SAP 10.2 §12 (PDF
|
||||
# p.34, "Secondary heating systems and applicable fuel types are taken
|
||||
# from the room heaters section of Table 4a") each code carries an
|
||||
# appliance type whose APPLICABLE FUELS are a category (gas / liquid /
|
||||
# solid / electric); the SPECIFIC sub-fuel within that category (mains
|
||||
# gas vs LPG vs biogas) is lodged SEPARATELY and is NOT recoverable from
|
||||
# the Summary, which exports only the code. We therefore resolve each
|
||||
# code to its category's MODAL fuel. Codes mirror the Table 4a efficiency
|
||||
# rows in `domain.sap10_ml.sap_efficiencies` (601-613 gas, 621-625
|
||||
# liquid, 631-636 solid, 691-694/699/701 electric).
|
||||
_ELMHURST_SECONDARY_GAS_CODES: Final[frozenset[int]] = frozenset(
|
||||
{601, 602, 603, 604, 605, 606, 607, 609, 610, 611, 612, 613}
|
||||
)
|
||||
_ELMHURST_SECONDARY_LIQUID_CODES: Final[frozenset[int]] = frozenset(
|
||||
{621, 622, 623, 624, 625}
|
||||
)
|
||||
_ELMHURST_SECONDARY_SOLID_CODES: Final[frozenset[int]] = frozenset(
|
||||
{631, 632, 633, 634, 635, 636}
|
||||
)
|
||||
_ELMHURST_SECONDARY_ELECTRIC_CODES: Final[frozenset[int]] = frozenset(
|
||||
{691, 692, 693, 694, 699, 701}
|
||||
)
|
||||
# Modal Table 32 / `_ELMHURST_MAIN_FUEL_TO_SAP10` fuel code per category.
|
||||
_SECONDARY_FUEL_MAINS_GAS: Final[int] = 26 # Table 32 code 1, 3.48 p/kWh
|
||||
_SECONDARY_FUEL_HEATING_OIL: Final[int] = 28 # → Table 32 code 4, 5.44 p/kWh
|
||||
_SECONDARY_FUEL_HOUSE_COAL: Final[int] = 11 # Table 32 code 11, 3.67 p/kWh
|
||||
|
||||
|
||||
def _elmhurst_secondary_fuel_from_sap_code(
|
||||
sap_code: Optional[int],
|
||||
) -> Optional[int]:
|
||||
|
|
@ -5109,29 +5385,44 @@ def _elmhurst_secondary_fuel_from_sap_code(
|
|||
electricity when `secondary_fuel_type` is None — correct for the
|
||||
portable-electric default but wrong for fuel-fired room heaters.
|
||||
|
||||
SAP 10.2 Table 4a Category 10 ("Room heaters") code blocks:
|
||||
601-613: Gas (mains gas / LPG / biogas) — column A is mains gas;
|
||||
column B for LPG. Cohort default is mains gas
|
||||
(`_ELMHURST_MAIN_FUEL_TO_SAP10["Mains gas"] = 26`).
|
||||
621-625: Liquid fuel room heaters (oil / bioethanol). Cohort
|
||||
not yet exercised; deferred until a fixture surfaces.
|
||||
631-634: Solid fuel room heaters (open fire, closed room
|
||||
heater with/without boiler). House coal is the modal
|
||||
default per Table 12 secondary rate (3.67 p/kWh).
|
||||
691-699: Electric room heaters. Cascade default (None) routes
|
||||
to standard electricity (13.19 p/kWh).
|
||||
Each code resolves to its SAP 10.2 Table 4a fuel CATEGORY's modal
|
||||
fuel (per RdSAP 10 §10.4.1 — the specific sub-fuel is a separate
|
||||
lodgement the Summary omits):
|
||||
- gas room heaters (601-613) → mains gas (26)
|
||||
- liquid room heaters (621-625) → heating oil (28)
|
||||
- solid room heaters (631-636) → house coal (11)
|
||||
- electric room heaters (691-699, → None (cascade electricity
|
||||
701) default; electricity IS the fuel)
|
||||
|
||||
A code in the room-heater range (601-701) that is NOT a recognised
|
||||
Table 4a row RAISES `UnmappedElmhurstLabel` rather than silently
|
||||
mis-fuelling — per the strict-raise pattern. Codes outside the range
|
||||
(e.g. None / no secondary) return None.
|
||||
|
||||
Cohort cert 2102-3018-0205-7886-5204 surfaces the 631 ("Open fire
|
||||
in grate") path — pre-slice the cascade defaulted to electricity
|
||||
at 13.19 p/kWh, over-charging secondary by ~£340/yr and pushing
|
||||
SAP -15.81 below the worksheet's 63.87.
|
||||
in grate") path — pre-fix the cascade defaulted to electricity at
|
||||
13.19 p/kWh, over-charging secondary by ~£340/yr and pushing SAP
|
||||
-15.81 below the worksheet's 63.87.
|
||||
|
||||
SUB-FUEL CAVEAT: the gas block 601-613 resolves to the modal mains
|
||||
gas; an LPG or biogas live-effect fire (worksheet "simulated case 37"
|
||||
lodged biogas at 7.60 p/kWh vs mains gas 3.48 p/kWh) is
|
||||
indistinguishable here — the sub-fuel is not in the Summary export.
|
||||
"""
|
||||
if sap_code is None:
|
||||
return None
|
||||
if 601 <= sap_code <= 613:
|
||||
return 26 # Mains gas, matching `_ELMHURST_MAIN_FUEL_TO_SAP10`
|
||||
if 631 <= sap_code <= 634:
|
||||
return 11 # House coal (Coal in `_ELMHURST_MAIN_FUEL_TO_SAP10`)
|
||||
if sap_code in _ELMHURST_SECONDARY_GAS_CODES:
|
||||
return _SECONDARY_FUEL_MAINS_GAS
|
||||
if sap_code in _ELMHURST_SECONDARY_LIQUID_CODES:
|
||||
return _SECONDARY_FUEL_HEATING_OIL
|
||||
if sap_code in _ELMHURST_SECONDARY_SOLID_CODES:
|
||||
return _SECONDARY_FUEL_HOUSE_COAL
|
||||
if sap_code in _ELMHURST_SECONDARY_ELECTRIC_CODES:
|
||||
return None # Electric room heaters → cascade electricity default
|
||||
if 601 <= sap_code <= 701:
|
||||
# A room-heater-range code we don't recognise — raise rather than
|
||||
# let the cascade silently bill it as electricity.
|
||||
raise UnmappedElmhurstLabel("secondary_heating.sap_code", str(sap_code))
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -5261,7 +5552,12 @@ _GAS_BOILER_SAP_MAIN_HEATING_CODES: Final[frozenset[int]] = frozenset(range(101,
|
|||
# of these so it can't mis-assign electricity from a separate immersion
|
||||
# (where §15.0 lodges the immersion's fuel, not the boiler's) — that
|
||||
# case still strict-raises `MissingMainFuelType` to force a mapper fix.
|
||||
_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 5, 6, 7, 26, 27})
|
||||
# 3 = bottled LPG main heating ("Bottled gas" label); the other LPG
|
||||
# carriers are the legacy API LPG codes (5/6/7) + the live "Bulk LPG"
|
||||
# (27). All count as a gas/LPG carrier so `_elmhurst_gas_boiler_main_fuel`
|
||||
# adopts a §15.0-lodged bottled-LPG water fuel for the boiler's space-
|
||||
# heating carrier instead of falling through to the mains-gas meter flag.
|
||||
_GAS_LPG_MAIN_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 3, 5, 6, 7, 26, 27})
|
||||
|
||||
# SAP10 main-fuel code for mains gas (`_ELMHURST_MAIN_FUEL_TO_SAP10`
|
||||
# "Mains gas"). Used when a Table 4b gas boiler's carrier can't be read
|
||||
|
|
@ -5552,6 +5848,15 @@ _ELMHURST_CYLINDER_NO_INSULATION_LABELS: frozenset[str] = frozenset(
|
|||
)
|
||||
|
||||
|
||||
# Elmhurst §15.1 "Immersion Heater" label → SAP10 `immersion_heating_type`
|
||||
# cascade code. RdSAP 10 §10.5 (PDF p.54) + cert_to_inputs
|
||||
# `_IMMERSION_TYPE_DUAL`/`_IMMERSION_TYPE_SINGLE`: 1 = dual, 2 = single.
|
||||
_ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10: Dict[str, int] = {
|
||||
"Dual": 1,
|
||||
"Single": 2,
|
||||
}
|
||||
|
||||
|
||||
# Elmhurst §15.0 "Water Heating Fuel Type" labels that route to solid-
|
||||
# fuel Table 32 codes (Anthracite, House coal, Wood logs/pellets, etc.).
|
||||
# Used by `_resolve_elmhurst_inaccessible_cylinder_size` to detect the
|
||||
|
|
@ -5675,6 +5980,23 @@ def _elmhurst_cylinder_insulation_code(
|
|||
return code
|
||||
|
||||
|
||||
def _elmhurst_immersion_type_code(
|
||||
immersion_type_label: Optional[str], cylinder_present: bool,
|
||||
) -> Optional[int]:
|
||||
"""Map an Elmhurst §15.1 "Immersion Heater" label ("Dual" / "Single")
|
||||
to the SAP10 `immersion_heating_type` cascade code (1 = dual, 2 =
|
||||
single per RdSAP 10 §10.5 p.54). Returns None when no cylinder is
|
||||
present or the label is genuinely absent. Raises `UnmappedElmhurstLabel`
|
||||
when the label IS lodged but isn't in the mapping dict — same
|
||||
strict-fallback as `_elmhurst_cylinder_insulation_code`."""
|
||||
if not cylinder_present or immersion_type_label is None:
|
||||
return None
|
||||
code = _ELMHURST_IMMERSION_TYPE_LABEL_TO_SAP10.get(immersion_type_label)
|
||||
if code is None:
|
||||
raise UnmappedElmhurstLabel("immersion_type", immersion_type_label)
|
||||
return code
|
||||
|
||||
|
||||
def _resolve_elmhurst_inaccessible_cylinder_insulation(
|
||||
age_band: str,
|
||||
) -> tuple[int, int]:
|
||||
|
|
@ -5828,6 +6150,30 @@ _ELMHURST_GLAZING_LABEL_NOISE_SUFFIX_RE: Final[re.Pattern[str]] = re.compile(
|
|||
_ELMHURST_GLAZING_LABEL_TRAILING_GAP_RE: Final[re.Pattern[str]] = re.compile(
|
||||
r"\s+\d+\s*mm\b.*$"
|
||||
)
|
||||
# Fallback only: pdftotext can wrap the §11 building-part column onto the
|
||||
# glazing-TYPE token WITHOUT an intervening glazing-gap descriptor, e.g.
|
||||
# "Double between 2002 and 2021 1st" (the "1st" marks the 1st Extension).
|
||||
# The ordinal / "Main" fragment is a building-part marker, not part of the
|
||||
# glazing type — strip it and retry. No glazing-type key ends in an ordinal
|
||||
# or "Main", so this is loss-free. Surfaced by `simulated case 33`.
|
||||
_ELMHURST_GLAZING_LABEL_TRAILING_BP_RE: Final[re.Pattern[str]] = re.compile(
|
||||
r"\s+(?:\d+(?:st|nd|rd|th)|Main)$"
|
||||
)
|
||||
# Fallback only: when a property lodges an Alternative Wall, pdftotext
|
||||
# INTERLEAVES the §11 location column ("Alternative wall 1") into the
|
||||
# wrapped glazing-TYPE cell, e.g. "Double between 2002 Alternative wall
|
||||
# and 2021 1 Alternative wall" (cert 001431 storage-heater variants,
|
||||
# `simulated case 34`). The greedy trailing-suffix strip truncates at the
|
||||
# first "Alternative wall" (losing "and 2021"), so remove EVERY
|
||||
# wall-location fragment + any stray 1-2 digit location index globally and
|
||||
# retry. Loss-free: no glazing-type key contains a wall-location phrase or
|
||||
# a bare 1-2 digit number (install-date years are 4 digits).
|
||||
_ELMHURST_GLAZING_LABEL_EMBEDDED_WALL_RE: Final[re.Pattern[str]] = re.compile(
|
||||
r"\s*(?:External|Alternative|Party)\s+wall(?:\s+\d+)?"
|
||||
)
|
||||
_ELMHURST_GLAZING_LABEL_STRAY_LOCATION_DIGIT_RE: Final[re.Pattern[str]] = re.compile(
|
||||
r"\b\d{1,2}\b"
|
||||
)
|
||||
|
||||
|
||||
def _elmhurst_glazing_type_code(label: Optional[str]) -> int:
|
||||
|
|
@ -5852,6 +6198,25 @@ def _elmhurst_glazing_type_code(label: Optional[str]) -> int:
|
|||
code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(degapped)
|
||||
if code is not None:
|
||||
return code
|
||||
# Fallback: strip a trailing wrapped building-part fragment (ordinal /
|
||||
# "Main") and retry.
|
||||
debp = _ELMHURST_GLAZING_LABEL_TRAILING_BP_RE.sub("", cleaned).strip()
|
||||
if debp != cleaned:
|
||||
code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(debp)
|
||||
if code is not None:
|
||||
return code
|
||||
# Fallback: remove INTERLEAVED wall-location fragments from the raw
|
||||
# label (Alternative/External/Party wall + stray location index) and
|
||||
# collapse whitespace. Operates on `label`, not the greedily-truncated
|
||||
# `cleaned`, so "Double between 2002 ... and 2021" survives.
|
||||
dewall = _ELMHURST_GLAZING_LABEL_EMBEDDED_WALL_RE.sub(" ", label)
|
||||
dewall = _ELMHURST_GLAZING_LABEL_STRAY_LOCATION_DIGIT_RE.sub(" ", dewall)
|
||||
dewall = _ELMHURST_GLAZING_LABEL_NOISE_PREFIX_RE.sub("", dewall)
|
||||
dewall = re.sub(r"\s+", " ", dewall).strip()
|
||||
if dewall != cleaned:
|
||||
code = _ELMHURST_GLAZING_LABEL_TO_SAP10.get(dewall)
|
||||
if code is not None:
|
||||
return code
|
||||
raise UnmappedElmhurstLabel("glazing_type", label)
|
||||
|
||||
|
||||
|
|
@ -6231,6 +6596,14 @@ def _map_elmhurst_sap_heating(survey: ElmhurstSiteNotes) -> SapHeating:
|
|||
and survey.water_heating.cylinder_thermostat is not None
|
||||
else None
|
||||
),
|
||||
# §15.1 "Immersion Heater" (Dual / Single) → SAP10
|
||||
# `immersion_heating_type` code (1 = dual, 2 = single). Drives the
|
||||
# Table 13 high-rate-fraction split for WHC-903 electric immersion
|
||||
# DHW on a 7-/10-hour off-peak tariff (18-/24-hour bill 100% low).
|
||||
immersion_heating_type=_elmhurst_immersion_type_code(
|
||||
survey.water_heating.immersion_type,
|
||||
survey.water_heating.hot_water_cylinder_present,
|
||||
),
|
||||
water_heating_code=survey.water_heating.water_heating_sap_code,
|
||||
water_heating_fuel=water_heating_fuel,
|
||||
secondary_heating_type=mh.secondary_heating_sap_code,
|
||||
|
|
|
|||
|
|
@ -734,6 +734,36 @@ class TestFromRdSapSchema21_0_1:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestApiWallConstructionCode:
|
||||
"""The gov-EPC API `wall_construction` enum diverges from the
|
||||
calculator's WALL_* code-space at code 9: gov 9 = "Cob" but calc
|
||||
`WALL_CURTAIN` = 9 (set by the Summary path's "CW" mapping). The gov
|
||||
API has no curtain code, so an API code 9 must be remapped to
|
||||
`WALL_COB` = 7 before it reaches `u_wall`'s curtain dispatch (which
|
||||
would otherwise bill the wall at the curtain default 2.0 W/m²K)."""
|
||||
|
||||
def test_gov_api_cob_code_9_remaps_to_wall_cob_7(self) -> None:
|
||||
# Arrange
|
||||
from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_wall_construction_code(9)
|
||||
|
||||
# Assert — 9 (gov cob) → 7 (WALL_COB), dodging WALL_CURTAIN=9.
|
||||
assert result == 7
|
||||
|
||||
def test_aligned_and_system_built_codes_pass_through_unchanged(self) -> None:
|
||||
# Arrange — codes 1-5 already align; gov 8 (system built) is left
|
||||
# for u_wall to resolve (calc WALL_PARK_HOME=8 is never dispatched);
|
||||
# gov 6 (basement) is left to the basement machinery.
|
||||
from datatypes.epc.domain.mapper import _api_wall_construction_code # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act / Assert
|
||||
for code in (1, 2, 3, 4, 5, 6, 8):
|
||||
assert _api_wall_construction_code(code) == code
|
||||
assert _api_wall_construction_code(None) is None
|
||||
|
||||
|
||||
class TestApiResolveWallInsulationThickness:
|
||||
"""`wall_insulation_thickness == "measured"` resolves to the separate
|
||||
`wall_insulation_thickness_measured` field (previously dropped by
|
||||
|
|
@ -1133,6 +1163,144 @@ class TestApiRoofConstructionCode:
|
|||
assert result == "Pitched, sloping ceiling"
|
||||
|
||||
|
||||
class TestApiGlazingTransmissionTable24:
|
||||
"""`_api_glazing_transmission` must resolve the SINGLE / SECONDARY /
|
||||
TRIPLE glazing RdSAP-21 enum codes to their RdSAP 10 Table 24 (spec
|
||||
page 50) (U, g, frame-factor) — not leave them unmapped (None), which
|
||||
silently routed single-glazed windows to the cascade default U=2.5
|
||||
instead of their true 4.8, over-rating single-glazed dwellings (cert
|
||||
0370-2933, 7 single-glazed windows, +17 SAP)."""
|
||||
|
||||
def test_single_glazing_code_5_is_table_24_u_4p8(self) -> None:
|
||||
# Arrange — RdSAP 21 glazing_type 5 = "single glazing"; Table 24
|
||||
# row "Single / Any period" → U 4.8 (PVC/wooden), g 0.85.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(5, None)
|
||||
|
||||
# Assert
|
||||
assert result == (4.8, 0.85, 0.70)
|
||||
|
||||
def test_single_glazing_known_data_code_15_is_table_24_u_4p8(self) -> None:
|
||||
# Arrange — code 15 = "single glazing, known data"; same Table 24
|
||||
# Single row when no measured U is lodged on the reduced-data path.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(15, None)
|
||||
|
||||
# Assert
|
||||
assert result == (4.8, 0.85, 0.70)
|
||||
|
||||
def test_secondary_glazing_normal_emissivity_code_11_is_u_2p9(self) -> None:
|
||||
# Arrange — code 11 = "secondary glazing, normal emissivity";
|
||||
# Table 24 Secondary "Normal emissivity" row → U 2.9, g 0.85.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(11, None)
|
||||
|
||||
# Assert
|
||||
assert result == (2.9, 0.85, 0.70)
|
||||
|
||||
def test_secondary_glazing_low_emissivity_code_12_is_u_2p2(self) -> None:
|
||||
# Arrange — code 12 = "secondary glazing, low emissivity"; Table 24
|
||||
# Secondary "Low emissivity" row → U 2.2, g 0.85.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(12, None)
|
||||
|
||||
# Assert
|
||||
assert result == (2.2, 0.85, 0.70)
|
||||
|
||||
def test_triple_glazing_2002_to_2022_code_9_is_u_2p0(self) -> None:
|
||||
# Arrange — code 9 = "triple glazing, installed 2002-2022"; Table 24
|
||||
# "Double or triple, 2002+ (pre-2022), any gap" → U 2.0, g 0.72.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(9, None)
|
||||
|
||||
# Assert
|
||||
assert result == (2.0, 0.72, 0.70)
|
||||
|
||||
def test_triple_glazing_pre_2002_code_10_default_gap_is_u_2p1(self) -> None:
|
||||
# Arrange — code 10 = "triple glazing, pre-2002"; Table 24 Triple
|
||||
# pre-2002 12mm-gap default → U 2.1, g 0.68.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(10, None)
|
||||
|
||||
# Assert
|
||||
assert result == (2.1, 0.68, 0.70)
|
||||
|
||||
def test_double_glazing_2002_plus_code_2_unchanged(self) -> None:
|
||||
# Arrange — regression guard: the already-mapped double-glazing
|
||||
# 2002+ entry (U 2.0, g 0.72) is untouched by the single/secondary/
|
||||
# triple extension.
|
||||
from datatypes.epc.domain.mapper import _api_glazing_transmission # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_glazing_transmission(2, None)
|
||||
|
||||
# Assert
|
||||
assert result == (2.0, 0.72, 0.70)
|
||||
|
||||
|
||||
class TestApiCascadeGlazingCodeDivergentRemap:
|
||||
"""`_api_cascade_glazing_type` must translate the RdSAP-21 glazing codes
|
||||
that DIVERGE from the SAP 10.2 Table 6b cascade enum the g-value tables
|
||||
(`_G_PERPENDICULAR_BY_GLAZING_TYPE` / `_G_LIGHT_BY_GLAZING_CODE`) are
|
||||
keyed on. Only code 1 was ever remapped; codes 4 and 5 sit in the 1-6
|
||||
range where the two enums disagree — RdSAP-21 4=secondary / 5=single,
|
||||
cascade 4=double-low-E / 5=secondary — so an API single-glazed window
|
||||
(5) read the cascade-5 (secondary) g slot for solar + daylight gains."""
|
||||
|
||||
def test_single_glazing_code_5_remaps_to_cascade_single_slot_1(self) -> None:
|
||||
# Arrange — RdSAP-21 code 5 = single glazing; cascade single slot
|
||||
# is 1 (g⊥ 0.85, g_L 0.90), not cascade 5 (secondary, 0.76/0.80).
|
||||
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_cascade_glazing_type(5)
|
||||
|
||||
# Assert
|
||||
assert result == 1
|
||||
|
||||
def test_secondary_glazing_code_4_remaps_to_cascade_secondary_slot_5(self) -> None:
|
||||
# Arrange — RdSAP-21 code 4 = secondary glazing; cascade secondary
|
||||
# slot is 5 (g⊥ 0.76, g_L 0.80), not cascade 4 (double low-E 0.63).
|
||||
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_cascade_glazing_type(4)
|
||||
|
||||
# Assert
|
||||
assert result == 5
|
||||
|
||||
def test_double_pre_2002_code_1_remap_unchanged(self) -> None:
|
||||
# Arrange — regression guard: the existing code-1 remap (→2) stands.
|
||||
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act
|
||||
result = _api_cascade_glazing_type(1)
|
||||
|
||||
# Assert
|
||||
assert result == 2
|
||||
|
||||
def test_rdsap21_native_codes_pass_through(self) -> None:
|
||||
# Arrange — codes 9-15 already coincide with the g-table's RdSAP-21
|
||||
# extension slots, so they must pass through untranslated.
|
||||
from datatypes.epc.domain.mapper import _api_cascade_glazing_type # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Act / Assert
|
||||
assert _api_cascade_glazing_type(14) == 14
|
||||
assert _api_cascade_glazing_type(9) == 9
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema 20.0.0 — Reduced-Field Synthesis (ADR-0027)
|
||||
#
|
||||
|
|
|
|||
|
|
@ -713,3 +713,78 @@ class TestUnmeasurableWallThickness:
|
|||
|
||||
def test_wall_thickness_mm_is_none(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].wall_thickness_mm is None
|
||||
|
||||
|
||||
class TestElmhurstSecondaryFuelFromSapCode:
|
||||
"""`_elmhurst_secondary_fuel_from_sap_code` must RAISE on an unmapped
|
||||
fuel-fired Table 4a Category-10 room-heater code rather than return
|
||||
None and let the cascade silently bill it as electricity (13.19
|
||||
p/kWh). The Summary lodges only the secondary SAP code, not its fuel.
|
||||
"""
|
||||
|
||||
def test_gas_room_heater_resolves_to_mains_gas_modal_default(self) -> None:
|
||||
# Arrange — SAP code 605 (flush-fitting live-effect gas fire), in
|
||||
# the 601-613 gas block. The Summary cannot distinguish mains gas
|
||||
# from LPG/biogas, so the modal default is mains gas (26).
|
||||
from datatypes.epc.domain.mapper import (
|
||||
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# Act
|
||||
fuel = _elmhurst_secondary_fuel_from_sap_code(605)
|
||||
|
||||
# Assert
|
||||
assert fuel == 26
|
||||
|
||||
def test_electric_room_heater_returns_none_for_electricity_default(self) -> None:
|
||||
# Arrange — SAP code 693 (electric room heater); electricity IS its
|
||||
# fuel, so None (→ cascade electricity default) is correct, NOT a
|
||||
# silent mis-fuel.
|
||||
from datatypes.epc.domain.mapper import (
|
||||
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# Act
|
||||
fuel = _elmhurst_secondary_fuel_from_sap_code(693)
|
||||
|
||||
# Assert
|
||||
assert fuel is None
|
||||
|
||||
def test_liquid_fuel_room_heater_resolves_to_heating_oil(self) -> None:
|
||||
# Arrange — SAP code 621 (liquid-fuel room heater) → its Table 4a
|
||||
# category's modal fuel, heating oil (28 → Table 32 code 4), NOT a
|
||||
# silent electricity fallback.
|
||||
from datatypes.epc.domain.mapper import (
|
||||
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# Act
|
||||
fuel = _elmhurst_secondary_fuel_from_sap_code(621)
|
||||
|
||||
# Assert
|
||||
assert fuel == 28
|
||||
|
||||
def test_solid_fuel_room_heater_resolves_to_house_coal(self) -> None:
|
||||
# Arrange — SAP code 631 (open fire in grate) → house coal (11).
|
||||
from datatypes.epc.domain.mapper import (
|
||||
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# Act
|
||||
fuel = _elmhurst_secondary_fuel_from_sap_code(631)
|
||||
|
||||
# Assert
|
||||
assert fuel == 11
|
||||
|
||||
def test_unrecognised_room_heater_range_code_raises(self) -> None:
|
||||
# Arrange — SAP code 620 sits in the room-heater range (601-701) but
|
||||
# is not a recognised Table 4a row; rather than silently mis-fuel it
|
||||
# must raise.
|
||||
from datatypes.epc.domain.mapper import (
|
||||
UnmappedElmhurstLabel,
|
||||
_elmhurst_secondary_fuel_from_sap_code, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedElmhurstLabel):
|
||||
_elmhurst_secondary_fuel_from_sap_code(620)
|
||||
|
|
|
|||
|
|
@ -267,7 +267,9 @@ class RdSapSchema20_0_0:
|
|||
energy_rating_current: int
|
||||
lighting_cost_current: float
|
||||
main_heating_controls: List[EnergyElement]
|
||||
multiple_glazing_type: int
|
||||
# "ND" (Not Defined) appears in the corpus alongside the 1-8 integer
|
||||
# codes — type as Union to match the data (mirrors RdSAP-Schema-18.0).
|
||||
multiple_glazing_type: Union[int, str]
|
||||
open_fireplaces_count: int
|
||||
heating_cost_potential: float
|
||||
hot_water_cost_current: float
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ class SapHeating:
|
|||
secondary_fuel_type: Optional[int] = None
|
||||
secondary_heating_type: Optional[int] = None
|
||||
cylinder_insulation_thickness: Optional[int] = None
|
||||
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
|
||||
# only when `cylinder_size` is the "Exact" descriptor (code 6).
|
||||
cylinder_size_measured: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -220,6 +223,7 @@ class SapAlternativeWall:
|
|||
wall_insulation_type: int
|
||||
wall_thickness_measured: str
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
sheltered_wall: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -261,6 +265,16 @@ class SapBuildingPart:
|
|||
# the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by
|
||||
# `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a).
|
||||
sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None
|
||||
# Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U,
|
||||
# authoritative when the open data redacts the backing insulation
|
||||
# thickness. Consumed by `heat_transmission` as a §5.1 documentary-evidence
|
||||
# override. Previously undeclared → dropped by `from_dict`.
|
||||
roof_u_value: Optional[float] = None
|
||||
# Lodged main-wall / ground-floor U-values (W/m²K) — same §5.1 documentary-
|
||||
# evidence override; authoritative when the open data redacts the backing
|
||||
# insulation. Previously undeclared → dropped by `from_dict`.
|
||||
wall_u_value: Optional[float] = None
|
||||
floor_u_value: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ class SapHeating:
|
|||
secondary_fuel_type: Optional[int] = None
|
||||
secondary_heating_type: Optional[int] = None
|
||||
cylinder_insulation_thickness: Optional[int] = None
|
||||
# RdSAP 10 §10.5 Table 28 — measured cylinder volume (litres), lodged
|
||||
# only when `cylinder_size` is the "Exact" descriptor (code 6).
|
||||
cylinder_size_measured: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -258,6 +261,7 @@ class SapAlternativeWall:
|
|||
wall_insulation_type: int
|
||||
wall_thickness_measured: str
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
sheltered_wall: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -299,6 +303,17 @@ class SapBuildingPart:
|
|||
# the slope as uninsulated (Table 16 / Table 18 fallback). Consumed by
|
||||
# `_api_resolve_sloping_ceiling_thickness` → Table 17 column (1a).
|
||||
sloping_ceiling_insulation_thickness: Optional[Union[str, int]] = None
|
||||
# Lodged roof U-value (W/m²K) — the assessor's RdSAP-assessed roof U. The
|
||||
# gov open data can redact the backing insulation thickness, so this is the
|
||||
# authoritative per-element value; consumed by `heat_transmission` as a
|
||||
# §5.1 documentary-evidence override. Previously undeclared → dropped by
|
||||
# `from_dict` (cert 7921-0052-0940-5007-0663 lodges roof_u_value=0.2).
|
||||
roof_u_value: Optional[float] = None
|
||||
# Lodged main-wall / ground-floor U-values (W/m²K) — same §5.1 documentary-
|
||||
# evidence override as roof_u_value; authoritative when the open data
|
||||
# redacts the backing insulation. Previously undeclared → dropped.
|
||||
wall_u_value: Optional[float] = None
|
||||
floor_u_value: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -71,6 +71,11 @@ class AlternativeWall:
|
|||
thickness_mm: Optional[int]
|
||||
u_value_known: bool
|
||||
dry_lined: bool = False
|
||||
# Summary §7 "Alternative Wall N Sheltered Wall: Yes/No". RdSAP 10
|
||||
# Table 4 (p.22): a sheltered sub-area (adjacent to an unheated buffer
|
||||
# such as a flat's corridor) carries an added R=0.5 m²K/W → U =
|
||||
# 1/(1/U + 0.5). Drives SapAlternativeWall.is_sheltered.
|
||||
sheltered: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -367,6 +372,11 @@ class WaterHeating:
|
|||
# §15.1 "Cylinder Thermostat" lodging (Yes / No). False or absent
|
||||
# keeps the cascade's no-thermostat Table 2b temperature factor.
|
||||
cylinder_thermostat: Optional[bool] = None
|
||||
# §15.1 "Immersion Heater" lodging ("Dual" / "Single"). Drives the
|
||||
# SAP10 `immersion_heating_type` code (1 = dual, 2 = single) used by
|
||||
# the Table 13 high-rate-fraction DHW-cost split. None when no
|
||||
# cylinder is present or the line is absent.
|
||||
immersion_type: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# Handover — API SAP accuracy (session 3): raises cleared, now profile-driven
|
||||
|
||||
> **➡️ SESSION 10 STARTS HERE: `docs/HANDOVER_SUMMARY_AUDIT.md`.** HEAD `872bc585`, **56.8%
|
||||
> within-0.5** (909 computed / 0 raises). Session 9 ran FIVE data-driven audit angles — all
|
||||
> converged on "remaining gap is diffuse" — and shipped the glazing Table-24 win (+16 certs) +
|
||||
> HW-only heat-network DLF. The data-driven seam is mined out; **session 10 switches to the
|
||||
> summary-report-based per-cert worksheet audit.** Read that doc first.
|
||||
|
||||
**Branch:** `feature/per-cert-mapper-validation` (long-lived working branch — **NEVER PR to
|
||||
main**; the user pushes/PRs when ready). **HEAD `a8e5563a`+** (the profiler commit), local-only
|
||||
ahead of origin.
|
||||
|
|
@ -13,17 +19,216 @@ deproven approaches + the meter/shower data-fidelity findings), and the earlier
|
|||
`energy_rating_current`. Headline gauge:
|
||||
`PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py`.
|
||||
|
||||
| metric | now (`a8e5563a`) |
|
||||
|--------|------------------|
|
||||
| **% \|err\| < 0.5** | **45.1%** |
|
||||
| % \|err\| < 1.0 | 59.4% |
|
||||
| mean \|err\| | 1.702 |
|
||||
| mean signed | −0.006 (balanced) |
|
||||
| computed / raises | **909 / 0** |
|
||||
| unsupported_schema | 100 (deferred — see below) |
|
||||
| metric | session-7 (`3e05c95e`) | session-8 (`71b378b9`) | session-8b (`e7af6fda`) | **session-8c (`e6dda705`)** |
|
||||
|--------|------------------|------------------|------------------|------------------|
|
||||
| **% \|err\| < 0.5** | 54.24% | 55.01% | 55.12% | **54.90%** ⚠ |
|
||||
| % \|err\| < 1.0 | 69.64% | 70.08% | 70.08% | **70.19%** |
|
||||
| % \|err\| < 2.0 | 83.50% | ~83.6% | ~83.6% | **84.60%** |
|
||||
| mean \|err\| | 1.248 | 1.233 | 1.232 | **1.224** |
|
||||
| median \|err\| | 0.457 | 0.448 | 0.446 | **0.448** |
|
||||
| computed / raises | 909 / 0 | 909 / 0 | 909 / 0 | **909 / 0** |
|
||||
| unsupported_schema | 100 (deferred) | 100 (deferred) | 100 (deferred) | 100 (deferred) |
|
||||
|
||||
45% is still poor. The systematic bias is gone; remaining error is per-cert scatter + the
|
||||
profile-surfaced buckets below.
|
||||
### SESSION-8c — Table 4g default SFP for index-less MEV (HEADLINE TRADE-OFF), `e6dda705`
|
||||
The 11 index-less MEV certs (mostly GAS houses, `mechanical_ventilation=2`, no PCDB
|
||||
index) billed ZERO fan electricity → +2.2 over-rate (signed +1.23, median +2.19).
|
||||
SAP 10.2 §2.6.3 / Table 4g note 1 (p.176): default SFP **0.8 W/(l/s)** used directly
|
||||
as SFPav in (230a). `_mev_decentralised_kwh_per_yr_from_cert` now falls back to it
|
||||
for a mechanical-EXTRACT system when no Table 322 record resolves. Cohort closed
|
||||
+1.23 → +0.18 signed. **TRADE-OFF: within-0.5 55.12% → 54.90% (−2)** — the fan energy
|
||||
is only ~half each cert's over-rate, so the cohort lands at ~+1.0 (still outside 0.5)
|
||||
while 2 borderline offsetting-error certs cross out; within-1.0/within-2.0/mean|err|
|
||||
all improve. Spec-correct, applied uniformly per the determinism principle. **NEXT:
|
||||
the unmasked ~+1.0 residual on gas-house MEV — the OTHER half (not electric-flat
|
||||
fabric, since these are gas houses).** Goldens green, pyright net-zero.
|
||||
|
||||
### SESSION-8b — MEV fan electricity (PCDB index plumbed), `e7af6fda`
|
||||
Follow-up to 8: the +0.9 residual on MEV after the §2 heat-loss fix was the FAN
|
||||
electricity (§5 Table 4f (230a) `SFPav × 1.22 × V`, PCDB Tables 322/329).
|
||||
`_mev_decentralised_kwh_per_yr_from_cert` already composes it but reads
|
||||
`epc.mechanical_ventilation_index_number` + `mechanical_vent_duct_type`, which the
|
||||
API builder never set → `pcdf_id is None` zeroed the fan energy on every API cert.
|
||||
Wired both through the 21.0.1 construction. The 9 MEV certs with a PCDB index
|
||||
closed +0.90 → +0.13; the **11 index-less MEV certs still sit at +1.36** — they
|
||||
need the SAP Table 4h DEFAULT specific fan power (no PCDB record), a clean next
|
||||
slice (verify the default SFP value against spec first). New e2e test + golden
|
||||
fixture (cert 1300, dMEV index 500777). NB the 21.0.0 API builder didn't get the
|
||||
8/8b ventilation edits (only 21.0.1, the corpus schema).
|
||||
|
||||
### SESSION-8 — API `mechanical_ventilation` enum never mapped, `71b378b9`
|
||||
Re-profiling after the sheltering fix surfaced `mechanical_ventilation=2` as a
|
||||
clean systematic over-rate (n=20, signed +1.90, 5% within 0.5, every cert
|
||||
positive). ROOT: `from_api_response` DROPPED the doc-level
|
||||
`mechanical_ventilation` field, so `mechanical_ventilation_kind` was always None
|
||||
and the §2 (24a..d) cascade defaulted to NATURAL — under-stating the air-change
|
||||
rate for every mechanical system (only the Elmhurst path mapped it). New
|
||||
`_api_mechanical_ventilation_kind` maps the RdSAP-Schema-21 enum →
|
||||
MechanicalVentilationKind: 0→NATURAL, 1 MV→"MV", 2/3 MEV + 6 PIV-outside →
|
||||
"EXTRACT_OR_PIV_OUTSIDE", 5 PIV-loft→NATURAL; **code 4 (MVHR) DEFERRED** (needs
|
||||
the lodged HR efficiency, PCDB Table 326, the API path doesn't plumb — stays
|
||||
NATURAL rather than mis-modelling as MV). Unmapped → `UnmappedApiCode`. Extract
|
||||
cohort +1.90→+0.9 median (within-0.5 5%→35%), 20 improved / 3 regressed. The
|
||||
**+0.9 residual is the MEV FAN ELECTRICITY** (§2.6.4 SFP, PCDB Table 322
|
||||
decentralised-MEV — 9 of 20 carry `mechanical_ventilation_index_number`): a
|
||||
clean next slice. Goldens + regression green, pyright net-zero.
|
||||
|
||||
### SESSION-7 — sheltered alternative walls (RdSAP Table 4 R=0.5), `3e05c95e`
|
||||
The headline-moving audit. User: 53% is poor enough to indicate a MAJOR error
|
||||
— audit again. The decisive diagnostic CHAIN (reusable):
|
||||
1. **Error by `dwelling_type`** → flats are the drag (houses 60-66% within 0.5,
|
||||
flats 28-47%; top-floor flat −1.19, mid-floor 28% within 0.5).
|
||||
2. **Split flats by `mains_gas`** → ELECTRIC flats are the killer (gas flats
|
||||
~45%, electric flats 13-19%; top-floor electric mean|err| 3.62).
|
||||
3. **Invert the SAP equation** (ECF = 0.42·cost/(TFA+45) → `sap_rating`) to get
|
||||
`our_cost / lodged_cost` → electric flats over-cost ~3% median (houses 1.00),
|
||||
amplified by the 4× electric price + steep low-band log curve (+3% ≈ +1.5 SAP
|
||||
at band 40).
|
||||
4. **Under-rate tracks space-heating kWh/m² precisely** — accurate certs 14-110,
|
||||
under-rating certs 130-289 (cert 2021: 11 275 kWh for 39 m²) → over-stated
|
||||
FABRIC, not tariff (storage flats on the correct 5.5p rate under-rate just as
|
||||
much as room-heater flats at 13p).
|
||||
5. **Field-by-field on the worst** → cert 0340-2976 (band-A flat) computed wall
|
||||
128 W/K though its main wall is a FILLED cavity (U 0.7); the excess was a
|
||||
SECOND `u_wall` call — a `sap_alternative_wall_1` timber-frame sub-area at
|
||||
U=2.5 lodging **`sheltered_wall="Y"`**.
|
||||
|
||||
ROOT: the gov-EPC API lodges `sheltered_wall="Y"` per alt-wall, but it was
|
||||
DROPPED by the schema + domain dataclasses, so `_alt_wall_w_per_k` billed the
|
||||
sub-area at its full exposed U. RdSAP 10 Table 4 (PDF p.22) "Sheltered": added
|
||||
external resistance R=0.5 m²K/W → U_sheltered = 1/(1/U + 0.5) — the SAME
|
||||
adjustment the MAIN wall already applies for `gable_wall_type=2`
|
||||
(`gable_wall_sheltered`, `_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). Threaded
|
||||
end-to-end: schema (21.0.0/21.0.1) + domain `SapAlternativeWall.is_sheltered`
|
||||
(default False → Summary/Elmhurst path unchanged, goldens untouched) +
|
||||
`from_api_response` `"Y"→True` + `_alt_wall_w_per_k` applies the 0.5 resistance
|
||||
(lodged-U + basement alts return before it). 140 certs (15% of corpus) carry a
|
||||
sheltered alt-wall; they under-rated at median −0.82 / signed −1.33 / 23% within
|
||||
0.5. Eval: 102 improved / 38 regressed (offsetting-error cases — applied
|
||||
spec-uniformly per the determinism principle); +10 net within-0.5. Goldens +
|
||||
full regression green, pyright net-zero. **OPEN audit leftovers: electric-flat
|
||||
tail persists (2021 −3.9 genuine uninsulated solid brick = data-fidelity;
|
||||
top-floor "Flat, limited insulation" roofs); detached houses 45% (balanced
|
||||
bidirectional scatter); the 38 sheltering regressions = offsetting errors
|
||||
elsewhere; main-wall (non-gable) sheltering not audited; multi-element wall
|
||||
description joined-string-to-all-BPs (113 certs, mild).**
|
||||
|
||||
### SESSION-6 — community fuels 30/31/32 collide with electricity codes, `a7761ea8`
|
||||
Picked up the deferred "fuel-collision part 2". The profiler's strongly-biased
|
||||
`main_control=2306` bucket (n=11, signed −3.75, nearly uniform) was a PROXY: every
|
||||
cert in it is `sap_main_heating_code=301` (community heating). ROOT: gov-API
|
||||
`main_fuel_type` codes **30=waste-combustion / 31=biomass / 32=biogas** (all
|
||||
"(community)" in `epc_codes.csv`) collide in VALUE with the Table-32 electricity
|
||||
codes 30 (standard) / 31 (7h-low) / 32 (7h-high). All three sit in
|
||||
`_ELECTRIC_FUEL_CODES`, so `is_electric_fuel_code` flagged a community-scheme main as
|
||||
electric and `_is_electric_main` routed its cost through the off-peak electricity
|
||||
branch — BYPASSING `_heat_network_factor_fuel_code`. Cert 8536 (biomass community)
|
||||
billed 5.5 p grid electricity instead of the 4.24 p heat-network rate → −17.2 SAP.
|
||||
Per SAP 10.2 Table 12 the community waste/biomass/biogas rows are 42/43/44 (the same
|
||||
rows the backwards-compat enum codes 11/12/13 already map to). Added 30→42, 31→43,
|
||||
32→44 to `API_FUEL_TO_TABLE_12` + `API_FUEL_TO_TABLE_32`.
|
||||
**CRITICAL — the remap is GATED, NOT global.** A prototype putting 30/31/32 in
|
||||
`canonical_fuel_code` regressed certs 2211 (+16 SAP) and 3420 (+7): the cascade uses
|
||||
the bare Table-32 code 30 internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (the RdSAP
|
||||
no-water-heating whc=999 immersion default writes `water_heating_fuel=30`), so a
|
||||
blanket remap mis-prices genuine grid electricity as community waste. New
|
||||
`_heat_network_community_fuel_code(fuel, main)` translates only when
|
||||
`_is_heat_network_main(main)` is true, wired into `_main_fuel_code` AND
|
||||
`_water_heating_fuel_code` (water gated via `_water_heating_main(epc)`).
|
||||
**STRICT-RAISE (user-requested):** a heat-network main lodging a colliding community
|
||||
fuel the table doesn't cover raises `UnmappedSapCode` rather than silently falling
|
||||
through to the same-numbered electricity code (currently can't fire — all of
|
||||
{30,31,32} are mapped — but guards future community fuels 56/57/58/99). Cert 8536
|
||||
−17.25 → −6.51 (residual now flat fabric), 5036 −6.29 → +1.36; mean|err|
|
||||
1.329→1.312, within-1.0/2.0 up, within-0.5 held. 3 AAA tests, regression green
|
||||
(only pre-existing 2 stone-wall U + a flaky historic-epc ordering test), pyright
|
||||
net-zero. **STILL DEFERRED: dual-fuel API-9; the per-cert FABRIC tail (8536's −6.5
|
||||
is now flat fabric, 2100 demand 110905); the 100 unsupported-schema certs.**
|
||||
|
||||
### SESSION-5 — fuel-code collision (anthracite/coal), `19235d11`
|
||||
The re-audit traced the cohort's WORST cert (2100 anthracite, −61) + the −20/−21 coal cluster to
|
||||
the **fuel-code collision** (`reference_fuel_code_collision`): the shared price/CO2/PE lookups check
|
||||
Table-32/12-code membership BEFORE translating the gov-API fuel enum, so API-5 (anthracite) priced at
|
||||
the bulk-LPG rate (12.19 p) and API-33 (coal) at the electricity-10h rate (7.5 p). KEY constraint
|
||||
(goldens caught it): code 33 is ALSO the electricity-10h TARIFF code used by the dual-rate CO2/PE
|
||||
split — so the fix CANNOT live in the shared table functions (breaks golden 000565). Instead
|
||||
`canonical_fuel_code` (table_32) normalises the colliding SOLID-fuel enums at the **fuel-TYPE
|
||||
boundary** (`_main_fuel_code`/`_water_heating_fuel_code`): 5→15 anthracite 3.64 p, 33→11 house coal
|
||||
3.67 p; also fixes `is_electric_fuel_code(33)` mis-flagging coal as electric. Scoped to {5, 33}
|
||||
(unambiguous large mispricings). mean|err| 1.424→1.329 (2100 −61→−11, residual now FABRIC: 110 905
|
||||
kWh demand = a separate area over-statement); within-0.5 flat at 53.1%. **DEFERRED follow-ups:**
|
||||
dual-fuel API-9 (0.45 p delta, net-neutral, shifts certs in an un-understood direction — needs its own
|
||||
look); community API-20/25/31 (route through the heat-network standing/CO2 path, NOT `unit_price` —
|
||||
cert 8536 fuel-31 still mis-prices at 5.5 p electricity → −17). Method: the `decompose_api_cost_error.py`
|
||||
heat:high/low tail + field-by-field audit of the worst certs surfaced it; `is_electric_fuel_code`
|
||||
collisions are the tell.
|
||||
|
||||
### THE UNIFYING PRINCIPLE (user, load-bearing) — "unknown insulation → as-built, NOT uninsulated"
|
||||
An EPC insulation field that is UNDETERMINED (thickness `'ND'`/`'AB'`/absent → parsed None, or
|
||||
description "as built / (assumed)") must map to the **age-band default** ("as built"), which is
|
||||
INSULATED at newer bands — never to the uninsulated row. The recurring bug shape: a fixed
|
||||
uninsulated U (cavity Filled-row, roof Table-16 2.30) is MASKED at old bands (where the age
|
||||
default coincides with uninsulated) and only diverges (catastrophic under-rate) at newer bands.
|
||||
All three session-5 fixes are instances. Review status across elements (all now conform):
|
||||
- **Flat roofs** — FIXED `58cff932` (this slice). **Pitched roofs** — "unknown"→Table 18 (`a64e857b`);
|
||||
"no insulation" only appears at bands A/B where 2.30 IS the age default (verified, no new-band bug).
|
||||
- **Cavity walls** — FIXED `2e466ed1`. **System/timber/solid walls** — already on the as-built age
|
||||
row (verified: bidirectional scatter, not a one-cause under-rate). **Floors** — undetermined
|
||||
thickness already routes to the Table 19 age default (I=25/J=75/K=100 mm; verified).
|
||||
- CAVEAT: do NOT broadly reroute PITCHED `'ND'`/`'NI'`→Table 18 (the parsed-0 `'NI'` case) — that
|
||||
was empirically net-negative (pitched "no insulation" lodgements genuinely use 2.30 even at newer
|
||||
bands; the description is load-bearing for pitched lofts). The principle holds for flat roofs,
|
||||
cavity, floors; pitched lofts are the documented exception.
|
||||
|
||||
## SESSION-5 UPDATE (HEAD `2e466ed1`) — whc=903 immersion HW + as-built cavity-U both closed
|
||||
|
||||
**Shipped (47.6 → 52.1%, two spec-grounded fixes):**
|
||||
|
||||
**(2) `2e466ed1` as-built "insulated (assumed)" cavity → Cavity-as-built row, not Filled cavity
|
||||
(48.6 → 52.1%, the bigger win).** Robust-sweep lead: `wall_desc="Cavity wall, as built, insulated
|
||||
(assumed)"` median +0.26, n=145, but split by age band it was a CLEAN G/H signal (G +1.38 n37,
|
||||
H +1.61 n18; I-L neutral). RdSAP 10 Table 6 (England, p.41) "Filled cavity" row carries the † footnote
|
||||
("assumed as built") ONLY at bands I-M, where it equals "Cavity as built"; at A-H the filled row is a
|
||||
GENUINE fill. An as-built cavity (type 4) must use "Cavity as built" at all bands (G/H 0.60 not 0.35).
|
||||
This was the SAME latent A-H bug slice S0380.210 fixed for "partial insulation (assumed)" but left for
|
||||
"insulated (assumed)" by a legacy convention. Retired `_cavity_described_as_filled`; genuine fills
|
||||
(wall_insulation_type=2) still hit the filled row. Per-band confirmation: I-M unchanged, G/H corrected
|
||||
exactly. Bucket within-0.5 47% → 66%; eval +32 net (36 improved, 4 regressed — offsetting-error
|
||||
electric-storage flats). 3 tests updated to the corrected behaviour (the legacy tests literally said
|
||||
"we follow the legacy convention for parity").
|
||||
|
||||
**(1) `43d4c67d` WHC-903 electric immersion off-peak HW → SAP 10.2 Table 13 high-rate fraction
|
||||
(47.6 → 48.6%).** The session-4 robust-sweep `whc=903`
|
||||
lead (median +0.87, n=84).
|
||||
- `43d4c67d` **WHC-903 electric immersion off-peak HW → SAP 10.2 Table 13 high-rate fraction.**
|
||||
Was billing 100% at the off-peak low rate; Table 12a "Immersion water heater" row (p.191) routes
|
||||
the WH column to Table 13 (p.197). New `tables/table_13.py` evaluates the Note-2 equations (f of
|
||||
cylinder volume V, occupancy N, single/dual immersion), clamped [0,1]; 18-/24-hour use the 10-hour
|
||||
column (Note 1). Wired into `_hot_water_fuel_cost_gbp_per_kwh` (threads V / N / immersion-single
|
||||
from the caller; absent any → old 100%-low fallback, no regression). Off-peak WHC-903 cohort
|
||||
(n=57): within-0.5 16% → 33%, median |err| 1.56 → 0.86.
|
||||
- **IMMERSION CODE MAPPING CORRECTED: `immersion_heating_type` 1 = DUAL, 2 = SINGLE.** The
|
||||
session-3 handover lead #3's "(1=single, 2=dual)" was UNVERIFIED and BACKWARDS. Confirmed via
|
||||
RdSAP 10 §10.5 (p.54 — immersion "assumed dual" on a dual/off-peak meter) cross-checked with the
|
||||
cohort: code 1 sits 3.6:1 on dual meters (40 vs 11), code 2 on single meters (22 vs 16). Dual
|
||||
carries Table 13's small fraction → matches the over-rating direction; the single mapping
|
||||
overshot in a prototype (cohort within-0.5 16% → 14%). The description-vs-code-audit lesson
|
||||
again: skeptical of unverified handover code-semantics claims.
|
||||
- **Next robust leads (post-BOTH-fixes sweep, ranked by net directional skew + MEDIAN):** every
|
||||
top bucket is now an UNDER-rate cluster (negative median = fabric/flat scatter, per-cert not one-bug):
|
||||
`property_type=2` flats med −0.39 (n=283, netDir +75), roof "(another dwelling above)" −0.46 (n=182),
|
||||
`wall_desc="Solid brick, as built, no insulation"` −0.22 (n=114). No clean OVER-rate single-cause
|
||||
bucket remains (cavity-insulated dropped to −0.13, main_heat_cat=7 to −0.31, whc=903 off the top —
|
||||
all addressed). The flats under-rate is the biggest front but DIFFUSE (fabric/tariff per-cert) — likely
|
||||
needs worksheets, not one rule. The 100 unsupported-schema certs remain the deferred big ticket.
|
||||
METHOD NOTE: the cavity win came from splitting the +0.26 bucket BY AGE BAND — the mild median hid a
|
||||
sharp G/H spike. When a description bucket has a modest median but a plausible single mechanism,
|
||||
re-split by age band / sub-field before dismissing it as scatter.
|
||||
|
||||
**SESSION-4 shipped (45.1 → 47.6%):** four spec-grounded fixes + closed one false lead.
|
||||
See the `## SESSION-4 …` blocks below and the auto-memory for full detail. The systematic bias
|
||||
is gone; the winning method this session was the **description-vs-code audit** + an
|
||||
**outlier-robust categorical sweep** (rank by net directional skew + MEDIAN, not mean — the
|
||||
mean-based metric is fooled by multi-cause outliers). 47.6% is still the target's halfway point.
|
||||
|
||||
## WHAT SHIPPED THIS SESSION (7 slices, all green, pyright net-zero)
|
||||
1. `e41a0bc0` **PCDB heat pump w/o SAP code → Table 12a ASHP_APP_N SH split** (0.80 high-rate).
|
||||
|
|
@ -39,7 +244,42 @@ profile-surfaced buckets below.
|
|||
(4)(5)(6) cleared **all 4 raises** — eval now has zero raises.
|
||||
7. `(profiler)` **`scripts/profile_api_error.py`** — the new diagnostic (below).
|
||||
|
||||
## SESSION-4 UPDATE (HEAD `8741fbdf`) — read before re-working the leads below
|
||||
## SESSION-4 UPDATE (HEAD `faf29942`) — read before re-working the leads below
|
||||
|
||||
### Shipped this session (45.1 → 47.6%)
|
||||
1. `b40e0f67` **exposed-floor-on-flats** (floor_heat_loss=1) — §3.12; per-BP override of the
|
||||
dwelling-level flat suppression.
|
||||
2. `8741fbdf` **floor_heat_loss=3 → above partially heated space, U=0.7** (§3.12/§5.14) +
|
||||
re-pinned golden 7536 (its "irreducible residual" was THIS bug).
|
||||
3. `5e7ef5c7` **boiler interlock for TRVs+bypass controls 2107/2111** (§9.4.11) — biggest single
|
||||
win (+1.6pts). The no-interlock set was keyed off the wrong signal (the "+0.6 °C" annotation);
|
||||
2107/2111 lack a room thermostat → −5pp + Table 4f ×1.3 pump.
|
||||
4. `faf29942` **description-lodged secondary heating** (§A.2.2/Table 11) — gas/oil boilers with an
|
||||
API-description-only secondary ("Portable electric heaters (assumed)", code field None)
|
||||
dropped the secondary (sec_kWh=0); now `_has_lodged_secondary_description` fires Table 11.
|
||||
Also added cat-8 (electric underfloor) Table-11 fraction 0.10.
|
||||
- `560c912c`/`d0f57a0e` docs: **roof_construction=8 lead CLOSED as data-fidelity** (not a bug — see
|
||||
the roof-8 section below; user worksheet sim-case-29 proved we ≡ Elmhurst).
|
||||
|
||||
### The two methods that worked (reuse these)
|
||||
- **Description-vs-code audit:** join each int code (`floor_heat_loss`, `roof_construction`,
|
||||
`wall_construction`, secondary type) to its authoritative `…[].description`, **on single-element
|
||||
certs only** (multi-element `[]` arrays are LOSSY). Mis-maps fall out (floor-3, secondary).
|
||||
- **Outlier-robust categorical sweep** (`/tmp/cat_audit2.py`): rank field-values by **net
|
||||
directional skew** (#under−0.5 minus #over+0.5) + **MEDIAN** error. The mean-based directionality
|
||||
metric (`/tmp/cat_audit.py`) gets FOOLED by multi-cause outliers (e.g. "Solid brick no insulation"
|
||||
looked systematic at mean −1.07 but median is −0.22 = scatter; 2100 −61/RR drove it).
|
||||
|
||||
### Open robust leads (verify with `/tmp/cat_audit2.py` — they shift; check MEDIAN not mean)
|
||||
- `whc=903` electric-immersion HW: **median +0.87, n=84** — likely off-peak immersion handling
|
||||
(the handover noted WHC 903 raises NotImplementedError on the Table-12a off-peak-immersion row).
|
||||
- `main_heat_cat=7` electric storage: median +1.05, n=41 — over-rate (tariff/cost; partly artifact).
|
||||
- `immersion_type=2` dual: +1.50, n=43 — we OVER-credit (so §12 dual→off-peak would worsen it).
|
||||
- `dwelling_type=Top-floor flat`: median −1.24, n=99 — under-rate, mostly fabric scatter/artifacts.
|
||||
- **Low-dir = SCATTER, do NOT single-fix:** non-PCDB main / data_source=2 (n=242, 28% within-0.5),
|
||||
mains_gas=N electric (n=145), most flats. These are per-cert/data-fidelity, not one bug.
|
||||
|
||||
### Resolved/closed this session (don't re-chase)
|
||||
- **Lead #1 `floor_codes=3` RESOLVED — the code IS authoritative.** The diagnostic that cracked
|
||||
it: join each **single-BP** cert's `floor_heat_loss` code to its independent
|
||||
`floors[].description` (the multi-BP tally was contaminated because a cert's `floors[]` summary
|
||||
|
|
@ -65,6 +305,31 @@ profile-surfaced buckets below.
|
|||
`roof_codes=1` broad bucket is mean −0.15 (the −1.78 was top-floor-electric-flat outliers
|
||||
−29/−25). Remaining gains need per-cert worksheets (start code-3) or the unsupported-schema ticket.
|
||||
|
||||
## SESSION-4 AUDIT — description-vs-code cross-check (other element types)
|
||||
After floor-3, audited every API field that carries BOTH an integer code AND an
|
||||
independent `…[].description` (roofs, walls, floors, main_heating, controls), by joining
|
||||
code↔description on **single-element certs** (the multi-element `[]` summary is lossy).
|
||||
- **Walls / heating / controls: CLEAN** — codes map 1:1 to their description families;
|
||||
residual error is per-cert scatter, not mis-mapping.
|
||||
- **Roof `roof_construction=8`: investigated hard, NOT a bug — DO NOT re-chase.** It looked
|
||||
like a huge lead (n=57, ~189 |err|, mean −2.43) because code-8 sloping ceilings described
|
||||
"Pitched, insulated" with no numeric thickness compute U=2.30 (e.g. 7921-0052 roof_w 241
|
||||
W/K, SAP 56.75 vs lodged 80). **User worksheet (simulated case 29) settled it: at band C,
|
||||
RdSAP/Elmhurst assumes UNINSULATED for an "insulated (assumed)" roof+wall with no measured
|
||||
thickness → Elmhurst SAP 55, which MATCHES our 56.75.** The lodged 80 needed real insulation
|
||||
the API record doesn't carry = data-fidelity artifact (meter-3 class). KEY LESSON (user):
|
||||
**"insulated (assumed)" = the age-band DEFAULT insulation level, NOT "well insulated"** — at
|
||||
old bands that default is ~uninsulated. Re-audit by band confirmed: old-band no-numeric =
|
||||
artifacts (we ≡ Elmhurst); newer-band code-8 already gets correct insulated U (2031 band I
|
||||
U≈0.18, 1436 band E 0.17, 0536 band B 0.09) — their under-rates are elsewhere; numeric-
|
||||
thickness = accurate (9884 +0.06). So the force-uninsulated-at-pre-1950 rule is CORRECT.
|
||||
A roof-8 "fix" would push us AWAY from Elmhurst's faithful 55 toward the unreproducible 80.
|
||||
- **Roof `roof_construction=3`: latent mis-map (inert).** gov desc "(another dwelling above)"
|
||||
(party roof) but we map to "Pitched, no access to loft"; masked by dwelling exposure
|
||||
(mean −0.06). Correct for robustness only if touching the roof mapper; not worth chasing.
|
||||
- **Worksheet-gen constraints (user, for future repros):** Elmhurst no longer lets you pick
|
||||
build form for a flat; and a band-C repro defaults to uninsulated walls+roof.
|
||||
|
||||
## KEY INSIGHT (load-bearing, from the user)
|
||||
**The gov EPC API JSON is the published OUTPUT of RdSAP software (Elmhurst), not its input.**
|
||||
So any API field Elmhurst doesn't expose as an *input* is register metadata the RdSAP10 method
|
||||
|
|
@ -122,6 +387,113 @@ bug, not noise):
|
|||
on unsupported schema 19.1.0; type 4 already accurate). `shower_wwhrs` 1/2/3/4 = none / inst-
|
||||
WWHRS-1 / inst-WWHRS-2 / storage. Low headline value — not worth pursuing.
|
||||
|
||||
### SESSION-9 — profile sweep, six candidates ruled out (no shipped fix)
|
||||
Re-ran `profile_api_error.py --min-n 10` at HEAD `da094feb` and chased every
|
||||
biased/error-carrying bucket field-by-field. All resolved to proxy / already-deproven /
|
||||
already-fixed — **no clean systematic bug found at this resolution**:
|
||||
- **roof_construction code 5/8 (vaulted/sloping) under-rate** (gas: `5`−0.67, `4,5`−0.59,
|
||||
`4,8`−0.85): a PROXY. `u_roof` returns the SAME value for code 4 and code 5 at a given
|
||||
age/thickness (band G ND → 0.40 both; verified in `rdsap_uvalues.u_roof`). Code-5 certs
|
||||
correlate with electric HW / flats / multi-part dwellings — those are the cause, not the roof.
|
||||
- **age_band dropped on the roof path:** NOT a bug. Doc-level `construction_age_band` is None for
|
||||
ALL 909 certs (age lives per-building-part); the mapper reads the bp-level band correctly
|
||||
(`heat_transmission.py:735 part.construction_age_band`; cert 2270 → bp0 age=G mapped fine).
|
||||
- **roof description "(same dwelling above)"** (a heated dwelling above → should be zero-loss
|
||||
internal element): only 5 certs carry it and 4 are already within 0.5. Cert 0700 (−6.37) is a
|
||||
messy 4-building-part data-fidelity cert (mixed Flat-no-insulation + Roof-room parts), not a
|
||||
"same dwelling above" bug. Not systematic.
|
||||
- **index-less MEV gas residual (the post-e6dda705 "next lead"):** RESOLVED by e6dda705. The 17
|
||||
index-less-MEV gas certs are now signed +0.09 / median +0.43 / 41% within-0.5 — centred scatter,
|
||||
not a systematic over-rate. The ~6 certs at +1.0..+1.9 are per-cert, no common cause.
|
||||
- **wit=4 (as-built cavity) gas −0.25 over 478 certs:** tail-driven, not a uniform Table-6 shift.
|
||||
Splitting by age band, the worst bands (B −0.54, F −0.60, G −1.00) still hold 70–77% within-0.5
|
||||
— a few big-negative outliers drag the mean; correcting a Table-6 value would over-shift the
|
||||
majority that are already accurate.
|
||||
- **community-main + whc=903 electric-immersion HW under-cost (−6.3, n=3):** = the deproven
|
||||
**meter_type=3** data-fidelity issue. All three (0380/2270/2673) carry meter_type=3; the HW
|
||||
electricity tariff ambiguity (lodged HW ≈17.3 p/kWh vs our standard ≈22.36 p) is exactly the
|
||||
Unknown-meter artifact already on this list.
|
||||
- **The biggest +32 outlier 2958** (fuel=0 / sapcode=699): per-cert, NOT a class bug — cert 3420
|
||||
has an identical heating profile and sits at +0.18. 2958's error is fabric/geometry-specific.
|
||||
Method note: `decompose_api_cost_error.py` cluster table = heat:high 311 (47% within) /
|
||||
heat:low 206 / hw:low 173 / hw:high 125 / balanced 94 — i.e. the residual is a broad per-cert
|
||||
fabric+HW tail, not one lever. The clean systematic wins (sheltered walls, MEV, fuel collisions)
|
||||
are harvested; remaining headway is per-cert worksheet grind or the 100-cert schema big-ticket.
|
||||
|
||||
### SESSION-9 (cont.) — silent-fallback audit + Tier-1 strict-raise SHIPPED, `7878a969`
|
||||
User pivoted from accuracy-chasing to a robustness audit: "where do we map codes but NOT raise
|
||||
on an unmapped code (silent fallback)?" Four parallel Explore agents swept the API mapper,
|
||||
`cert_to_inputs`, the U-value/table lookups, and the worksheet dispatch. Findings triaged into
|
||||
tiers (full list in the session report I wrote inline). **Tier 1 (shipped):** the fuel-type
|
||||
helpers fed Table 12/32 cost/CO2/PE via a silent `API_FUEL_TO_TABLE_12.get(fuel, fuel)`
|
||||
passthrough at 5 sites → an unmapped/colliding fuel hit the **mains-gas default** in
|
||||
`unit_price_p_per_kwh`/`co2_factor`/`primary_energy_factor` (the cert-8536 −17-SAP class). New
|
||||
`_table_12_factor_fuel_code` is byte-identical to `.get(fuel,fuel)` for every recognised input
|
||||
and raises `UnmappedSapCode("table_12_factor_fuel", …)` only when the resolved code is in NO
|
||||
table. **Verified 0/909 corpus raises; eval unchanged 54.9%/909/0** — pure future-proofing.
|
||||
2 AAA tests, goldens + gate green, pyright net-zero (44=44).
|
||||
**Tiers NOT actioned (user chose Tier-1 only — candidates for a future robustness slice):**
|
||||
- Tier 2: `_api_glazing_transmission` ([mapper.py:2925]) maps only glazing codes [1,2,3,13,14];
|
||||
4–12/15+ silently default U≈2.5 (the `reference_unmapped_api_code` "pending" gap). Window
|
||||
orientation drop ([solar_gains.py:391]) silently drops a window from solar gains on an unmapped
|
||||
orientation. Both would make currently-computed certs RAISE → need the codes MAPPED first, not
|
||||
just a guard added (coverage tradeoff).
|
||||
- Tier 3 (low impact, bands are schema-bounded A–M): `.get(band, DEFAULT)` swallows a non-None
|
||||
unrecognised age band in `u_door` (→3.0), `u_basement_wall` (→0.7), `u_basement_floor` (→0.50),
|
||||
`u_roof` (→0.4). The `age_band is None` branches above each are justified absent-data defaults;
|
||||
only a typo/unknown band silently passing is the gap.
|
||||
|
||||
### SESSION-9 (cont. 2) — glazing single/secondary/triple per RdSAP 10 Table 24, `a0432977` (BIGGEST WIN)
|
||||
Chasing Tier-2 turned up the session's biggest lever. `_API_GLAZING_TYPE_TO_TRANSMISSION` mapped
|
||||
only the DOUBLE-glazing codes [1,2,3,13,14]; single (5/15), secondary (4/11/12), triple
|
||||
(6/8/9/10) returned None → the cascade routed them to the `u_window` all-None default **U=2.5**
|
||||
instead of their Table 24 value. **Single glazing (U=4.8) modelled at half its heat loss** was
|
||||
the killer — 79 corpus certs carry code 5; the 94 certs with any unmapped glazing code sat at
|
||||
**32% within-0.5 vs 54.9% baseline**. Extended the transmission + gap-override tables with the
|
||||
RdSAP 10 Table 24 (spec p.50) (U, g, FF) rows for every RdSAP-21 glazing code. **Eval 54.90% →
|
||||
56.66% within-0.5** (net +16: 22 in, 6 offsetting-error out), within-1.0 70.2→71.9, mean|err|
|
||||
1.224→1.203, 909/0. 7 AAA tests, goldens + gate green, pyright net-zero. **Method that found it:
|
||||
profile by `sap_windows[].glazing_type`, split known vs unmapped codes, decode against
|
||||
`epc_codes.csv` `glazed_type`.**
|
||||
|
||||
### OPEN — KNOWN bugs still on the board (ranked)
|
||||
1. **Glazing SOLAR/LIGHT g mis-keyed** (the natural follow-on): `_G_PERPENDICULAR_BY_GLAZING_TYPE`
|
||||
(solar_gains.py) + `_G_LIGHT_BY_GLAZING_CODE` (internal_gains.py) are keyed on the SAP-10.2
|
||||
Table-6b cascade ordering (1=single…) but `_api_cascade_glazing_type` only translates {1:2} —
|
||||
every OTHER API code passes through UNTRANSLATED, so API code 5 (single) reads the cascade-5
|
||||
slot (secondary/DG-low-E g) for solar+daylight gains. Smaller than the U effect (gains are
|
||||
second-order) but a real systematic mis-map. Fix = complete `_api_cascade_glazing_type` →
|
||||
Table-6b, OR feed the transmission-tuple g instead. Verify magnitude first.
|
||||
2. **Window orientation silently dropped** (solar_gains.py:391 `if orientation is None: continue`)
|
||||
— an unmapped orientation code loses the whole window's solar gain. HIGH-confidence data-loss.
|
||||
3. **Tier-2 glazing strict-raise** — now that codes 1-15 are mapped, make `_api_glazing_transmission`
|
||||
raise `UnmappedApiCode` on a present-but-unmapped (>15) code (zero current regression).
|
||||
4. Tier-3 age-band swallows (low impact). MVHR (mech_vent=4) deferred. 21.0.0 builder missing the
|
||||
8/8b/8c ventilation + (now) glazing edits — extend if a 21.0.0 cert surfaces.
|
||||
5. Per-cert: cert 0370-2933 still +15 after glazing (7 single windows but the over-rate is
|
||||
non-window — separate fabric/heating cause). The 100 unsupported-schema certs (big ticket).
|
||||
|
||||
### SESSION-9 (cont. 3) — glazing g remap (correctness, 0 impact) + post-glazing re-profile
|
||||
- **Glazing g remap `49fb6c1b`:** completed the design's divergent-code remap —
|
||||
`_API_TO_SAP10_CASCADE_GLAZING_CODE` gained {4:5, 5:1} so API single (5) / secondary (4) read
|
||||
the right cascade g-slot (single 0.85/0.90, not secondary 0.76/0.80). CORRECTNESS ONLY: solar/
|
||||
daylight gains are second-order → 0 certs flip, eval unchanged 56.66%. The single-glazing
|
||||
*U-value* (a0432977) was the whole accuracy effect.
|
||||
- **Re-profiled at 56.7%.** Remaining biased buckets ruled out as deproven / spec-faithful /
|
||||
tail-driven (verified, do NOT re-chase):
|
||||
- `wall_construction=3` solid brick gas (n=197, signed −0.52, survives gas-split): **spec-faithful,
|
||||
NOT a bug.** `u_wall` applies RdSAP §5.7 Table 13 thickness (≤200→2.5, 200-280→1.7, 280-420→1.4,
|
||||
>420→1.1); the API plumbs `wall_thickness` and the mapper passes it; wit=4 "insulated (assumed)"
|
||||
correctly takes the as-built U (no §5.8 reduction). Direction is also wrong for a thickness gap
|
||||
(using thickness LOWERS U → over-rate). Residual = old solid-brick houses outperform as-built.
|
||||
- `main_control=2113` (n=26, −0.76 but 77% within-0.5): tail-driven by messy multi-part certs
|
||||
(0700/9092), not uniform. `wall_construction=5` (timber, n=35): tail-driven (7921 −23, 0370 +15).
|
||||
- worst remaining under-rater **7921 (−23)**: roof code-8 sloping-ceiling "Pitched, insulated"
|
||||
thick=None → uninsulated 2.3 (roof 241 W/K). On the DEPROVEN list (= data-fidelity, we ≡ Elmhurst
|
||||
at uninsulated). Don't chase.
|
||||
- NOT yet run down (genuine open candidates): `main_heat_cat=4` heat pumps (n=20, −0.78),
|
||||
`water_fuel=20` community HW over-rate (n=40, +0.54, distinct direction — all sapcode 301).
|
||||
|
||||
## THE 100 unsupported_schema CERTS (deferred — bigger ticket)
|
||||
SAP-Schema-19.1.0 (and other pre-21). The user is planning a separate big piece: map old schemas
|
||||
→ new + **predict missing fields from similar-looking properties** (needs an EPC-prediction
|
||||
|
|
|
|||
145
docs/HANDOVER_SUMMARY_AUDIT.md
Normal file
145
docs/HANDOVER_SUMMARY_AUDIT.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Handover — API SAP accuracy, SESSION 10: summary-report-based per-cert audit
|
||||
|
||||
You're continuing API→SAP accuracy work on branch **`feature/per-cert-mapper-validation`** in
|
||||
`/workspaces/model`, **HEAD `872bc585`**. This is a **long-lived working branch — NEVER PR to
|
||||
main**; the user pushes/PRs when ready. 31 commits ahead of `origin`, unpushed.
|
||||
|
||||
## THE GOAL (measurable, unchanged)
|
||||
100% of API records with a lodged SAP compute **within 0.5 SAP** of the API's
|
||||
`energy_rating_current`. Headline gauge:
|
||||
```
|
||||
PYTHONPATH=/workspaces/model python scripts/eval_api_sap_accuracy.py
|
||||
```
|
||||
**Current: 56.8% within-0.5** (within-1.0 72.2%, within-2.0 84.8%, mean|err| 1.197, median 0.438,
|
||||
signed −0.229, **909 computed / 0 raises**, 100 unsupported_schema). Writes `_results.csv` to the
|
||||
cache. Re-profile with `scripts/profile_api_error.py --min-n 12`; component decomposition with
|
||||
`scripts/decompose_api_cost_error.py`. ~1009 cached API JSONs at `/tmp/epc_2026_sample`
|
||||
(`EPC_SAMPLE_CACHE` overrides).
|
||||
|
||||
## ⚠️ THE PIVOT — why this session is DIFFERENT
|
||||
The previous session (9) ran **FIVE independent data-driven audit angles**, all of which converged
|
||||
on the same conclusion — the clean systematic levers are harvested and the remaining gap is
|
||||
**diffuse** (data-fidelity matching the reference software, data-composition, per-cert scatter):
|
||||
1. **Error-bucket profiler** → scatter, no clean residual bias.
|
||||
2. **Dropped-field scan** (raw-JSON field present but mapped-None) → every field plumbed.
|
||||
3. **CO2/PE reconciliation** vs lodged → systematic +15% but it's a **factor-basis** difference
|
||||
(our SAP 10.2 Table 12 vs the lodged EPC's published basis), NOT demand (cost-SAP matches),
|
||||
NOT scope (our CO2/PE correctly exclude appliances/cooking per spec line 326). **Off-goal** —
|
||||
CO2/PE don't feed the cost-based SAP rating.
|
||||
4. **Cross-provider parity** (LIG-21.0 vs 21.0.1, same builder): LIG under-rates −0.59, cleanest in
|
||||
cavity (LIG −0.45 vs standard −0.01) — but the wall U computes CORRECTLY; the cause is diffuse
|
||||
(composition: more solid-brick/system-built/non-PCDB mains, all under-rating in BOTH datasets;
|
||||
plus per-cert scatter). No recoverable LIG-specific mapping bug.
|
||||
5. **HW-demand reconciliation** vs lodged HW cost → median residual ≈ £0, well-calibrated. The
|
||||
high-HW/m² certs are small flats (SAP HW floor effect) and are ACCURATE.
|
||||
|
||||
**The data-driven seam is mined out.** The user (correctly — they drove the glazing find) wants to
|
||||
switch to **worksheet-level ground truth**: the **summary-report-based audit**. Do NOT re-run the
|
||||
five angles above expecting a new clean bug; pursue per-cert worksheet pins instead.
|
||||
|
||||
## THE METHOD — summary-report-based audit (this session's loop)
|
||||
For a chosen cert, the **user generates two Elmhurst worksheets from the cert's OWN API JSON**
|
||||
(`/tmp/epc_2026_sample/<cert>.json`): the **P960** (full SAP worksheet, line refs `(1a)..(486)`)
|
||||
and the **Summary**. Your loop:
|
||||
1. **Describe the cert field-by-field FIRST** (so the user can reproduce it in Elmhurst): dwelling
|
||||
type, TFA, age band, every building part (wall/roof/floor construction + insulation + thickness),
|
||||
windows, the heating system (sap_main_heating_code, category, control, emitter, fuel, PCDB index),
|
||||
water heating (whc, fuel, cylinder), ventilation, PV. Use the mapper to dump the *mapped*
|
||||
`EpcPropertyData` so the description matches what we actually compute on.
|
||||
2. **Pin the cascade to the worksheet line refs at abs=1e-4** — `heat_transmission_section_from_cert`
|
||||
for §3 (26)..(37), the water-heating/§4, §9a/§10a etc. Localise the divergence to a specific
|
||||
line ref → extractor / mapper / calculator gap.
|
||||
3. **VALIDATE BEHAVIOUR against the LODGED SAP, not blindly against the user's repro.** The user's
|
||||
Elmhurst repros are APPROXIMATE (they often pick a slightly-wrong system / inputs). Confirm the
|
||||
repro's continuous SAP ≈ the lodged `energy_rating_current` BEFORE trusting its line refs; if the
|
||||
repro diverges from lodged, the repro is the problem, not our cascade. (See
|
||||
`reference_elmhurst_only_test_pattern` + the `_elmhurst_worksheet_000565` prototype for the
|
||||
mapper-driven cascade-fixture shape.)
|
||||
4. One confirmed cause = one TDD slice = one commit (conventions below).
|
||||
|
||||
### Starter candidate certs (clean gas, single-building-part, schema 21.0.1 — NOT electric-fabric
|
||||
### tail, NOT LIG, NOT deproven). |err| in 0.7–6, good worksheet targets:
|
||||
```
|
||||
8700-1771-0622-8501-3963 -5.77 gas cat2 whc=903 (electric immersion HW on a gas main — odd)
|
||||
2135-2729-0509-0142-6226 +5.29 sapcode 119
|
||||
4700-6865-0122-1501-3963 -5.19 detached gas boiler
|
||||
0700-6754-0922-3505-3963 +4.35 whc=911 (gas boiler/circulator for water only)
|
||||
9093-3060-2207-6506-0204 +3.60 sapcode 502 cat9 (WARM AIR main) + whc=950 — see SESSION-9 below
|
||||
0330-2817-5590-2096-7831 -2.95 gas cat2 mid-terrace
|
||||
```
|
||||
The full list (81 clean candidates) regenerates from the profiler/`_results.csv`. The user may pick
|
||||
their own certs from domain knowledge — let them drive selection; your job is the field-by-field
|
||||
description + the line-ref pin.
|
||||
|
||||
## WHAT SHIPPED IN SESSION 9 (don't redo) — 54.90% → 56.8%
|
||||
- **`a0432977` glazing single/secondary/triple per RdSAP 10 Table 24 (THE BIG WIN, +16 certs).**
|
||||
`_API_GLAZING_TYPE_TO_TRANSMISSION` only mapped double-glazing [1,2,3,13,14]; single (5/15, U 4.8),
|
||||
secondary (4/11/12), triple (6/8/9/10) returned None → silent u_window default U=2.5. Single glazing
|
||||
at half its real heat loss was the killer. Method that found it: profile `sap_windows[].glazing_type`,
|
||||
decode vs `epc_codes.csv` `glazed_type`.
|
||||
- **`872bc585` HW-only heat-network DLF (whc 950/951/952).** The Table 12c distribution loss fired
|
||||
only for `_is_heat_network_main AND whc∈{901,902,914}`; HW-only heat networks missed it entirely.
|
||||
Added a whc-gated branch `water_eff = plant_eff / DLF` (RdSAP §10, spec p.36). All 3 corpus whc=950
|
||||
certs improved in |err|; cert **9093 still +3.60** — its residual is the **warm-air main (sapcode
|
||||
502, cat 9)**, a SEPARATE cause and a good worksheet candidate.
|
||||
- **`7878a969` fuel strict-raise** at the Table-12 factor boundary (the cert-8536 collision class).
|
||||
- **`49fb6c1b` glazing g remap** (codes 4/5 → correct cascade g-slots) — correctness, 0 SAP impact.
|
||||
- **`a7990edb` ROBUSTNESS GUARDS** (forcing functions): `_api_glazing_transmission` +
|
||||
`_api_cascade_glazing_type` raise `UnmappedApiCode` on present-but-unmapped glazing;
|
||||
`seasonal_efficiency` + `water_heating_efficiency` raise `UnmappedSapCode` on present-but-unmapped
|
||||
codes (was the blind 0.80/0.78 default). **0 current-corpus impact (tables complete) — these are
|
||||
guards.** KEY for this session: **if a worksheet-audit cert RAISES, that's the guard surfacing a
|
||||
real gap — map the code.** Also re-verify: efficiency table already covers WHC 908 (multi-point
|
||||
gas) / 950 (HW heat network) — those are NOT unmapped bugs.
|
||||
|
||||
## RULED OUT — do NOT re-chase (verified this session + DEPROVEN list in HANDOVER_API_PROFILING.md)
|
||||
- **The 100 `unsupported_schema` certs are full-SAP NEW BUILDS** (`assessment_type="SAP"`, mean
|
||||
rating 86, transaction_type 6 = new dwelling). Structurally different (sap_walls/sap_roofs/
|
||||
sap_openings with measured U-values, DER, construction_year). **Out of scope for a retrofit
|
||||
product — do NOT build a parallel pipeline.** They're already excluded from the 56.8%.
|
||||
- **Solid brick** (gas, −0.52): spec-faithful — `u_wall` applies RdSAP §5.7 Table 13 thickness;
|
||||
direction wrong for a thickness gap. Data-fidelity (old houses outperform as-built).
|
||||
- **Roof code-8 sloping-ceiling "insulated"-no-thickness** (cert 7921 −23): data-fidelity, we ≡
|
||||
Elmhurst at uninsulated. **meter_type=3** (Unknown meter): data-fidelity. Orientation code-9 drop:
|
||||
the East/West "fix" HURTS the gauge; conservatory-only spec rule; leave it.
|
||||
- LIG-21.0 divergence, CO2/PE +15%, HW-demand over-estimate: all diffuse / off-goal (see THE PIVOT).
|
||||
|
||||
## CONVENTIONS (non-negotiable)
|
||||
One cause = one slice = one commit; **spec citation (page+line) in the message** (the user
|
||||
explicitly asks us to confirm against the SAP 10.2 / RdSAP 10 PDFs in `domain/sap10_calculator/
|
||||
docs/specs/` before claiming a fix — see `feedback_spec_citation_in_commits`); AAA test headers
|
||||
(`# Arrange / # Act / # Assert`); **`abs(x-y)<=tol` not `pytest.approx`** (strict-pyright);
|
||||
private-symbol test imports single-line with `# pyright: ignore[reportPrivateUsage]`; **SAP 10.2
|
||||
only** (ignore the 10.3 PDF); no tolerance-widening / xfail; RdSAP is deterministic — every fix is a
|
||||
spec rule, apply uniformly even when it unmasks offsetting errors, **but flag any within-0.5
|
||||
regression to the user**; **pyright strict net-zero** (baseline-compare via `git stash`; avoid
|
||||
`**dict` unpacking into `make_minimal_sap10_epc` — explodes pyright); **stage files BY NAME** (the
|
||||
tree carries unrelated `scripts/` + `sap worksheets/` changes — never `git add -A`); end commit
|
||||
messages with `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
|
||||
|
||||
**Regression gate** after any calc/mapper change (goldens esp. 6035 + 000565 are the gate):
|
||||
```
|
||||
PYTHONPATH=/workspaces/model python -m pytest tests/domain/sap10_calculator/ \
|
||||
domain/sap10_ml/tests/ datatypes/epc/ backend/documents_parser/tests/ -q
|
||||
```
|
||||
**IGNORE these pre-existing fails** (not yours): `test_total_floor_area`, the 2 stone-wall U tests
|
||||
in `test_rdsap_uvalues.py`, the flaky `test_other_client_error_propagates` (passes in isolation).
|
||||
|
||||
## ARCHITECTURE (quick map)
|
||||
API path = `EpcPropertyDataMapper.from_api_response(doc)` → `from_rdsap_schema_21_0_1` →
|
||||
`cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)` → `calculate_sap_from_inputs(...)`. Fabric U via
|
||||
`domain/sap10_ml/rdsap_uvalues.py` (`u_wall/u_roof/u_floor/u_window`) feeding
|
||||
`worksheet/heat_transmission.py` (per-BP loop). HW in `cert_to_inputs` §4 + `worksheet/water_heating.py`.
|
||||
Efficiency in `domain/sap10_ml/sap_efficiencies.py` (`seasonal_efficiency` / `water_heating_efficiency`,
|
||||
now strict-raising). Fuel cost/CO2/PE: `tables/table_12.py` + `tables/table_32.py`. SAP equation:
|
||||
`worksheet/rating.py` (ECF = 0.42·cost/(TFA+45)). The §3 breakdown helper for pins:
|
||||
`cert_to_inputs.heat_transmission_section_from_cert(epc)` → `HeatTransmission` (every (26)..(37) line
|
||||
ref). **KEY INSIGHT: the gov-API JSON is the published OUTPUT of RdSAP software, not its input —
|
||||
route fields Elmhurst doesn't consume to the spec default.**
|
||||
|
||||
## READ ALSO
|
||||
- `docs/HANDOVER_API_PROFILING.md` — the full SESSION-3..9 log + the load-bearing **DEPROVEN** list.
|
||||
- Auto-memories: `project_per_cert_mapper_validation_state`, `reference_unmapped_sap_code`,
|
||||
`reference_unmapped_api_code`, `reference_fuel_code_collision`, `feedback_software_no_special_handling`,
|
||||
`feedback_spec_citation_in_commits`, `feedback_worksheet_not_api_reference`,
|
||||
`reference_elmhurst_only_test_pattern`.
|
||||
|
|
@ -64,6 +64,10 @@ _HHR_STORAGE_OVERLAY = HeatingOverlay(
|
|||
cylinder_insulation_type=1,
|
||||
cylinder_insulation_thickness_mm=120,
|
||||
cylinder_thermostat="Y",
|
||||
# Single off-peak electric immersion — drives the SAP 10.2 Table 13 HW
|
||||
# high-rate split (matches the relodged after-cert; without it the HW
|
||||
# bills 100% at the low rate, +1.26 SAP over the reference).
|
||||
immersion_heating_type=1,
|
||||
has_hot_water_cylinder=True,
|
||||
meter_type="Dual",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ _SAP_HEATING_FIELDS: tuple[str, ...] = (
|
|||
"cylinder_insulation_type",
|
||||
"cylinder_insulation_thickness_mm",
|
||||
"cylinder_thermostat",
|
||||
"immersion_heating_type",
|
||||
)
|
||||
_ENERGY_SOURCE_FIELDS: tuple[str, ...] = ("meter_type", "mains_gas")
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,11 @@ class HeatingOverlay:
|
|||
cylinder_insulation_type: Optional[Union[int, str]] = None
|
||||
cylinder_insulation_thickness_mm: Optional[int] = None
|
||||
cylinder_thermostat: Optional[str] = None
|
||||
# The cylinder's immersion-heater arrangement (e.g. single off-peak
|
||||
# immersion = 1). Drives the SAP 10.2 Table 13 HW high-rate fraction on
|
||||
# off-peak tariffs — without it the calculator cannot resolve
|
||||
# `immersion_single` and bills HW 100% at the low rate.
|
||||
immersion_heating_type: Optional[Union[int, str]] = None
|
||||
# EpcPropertyData (top-level)
|
||||
has_hot_water_cylinder: Optional[bool] = None
|
||||
# sap_energy_source
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -214,7 +214,7 @@ API_FUEL_TO_TABLE_12: Final[dict[int, int]] = {
|
|||
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
|
||||
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
|
||||
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
|
||||
26: 1, 27: 2, 28: 4, 29: 30,
|
||||
26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
88
domain/sap10_calculator/tables/table_13.py
Normal file
88
domain/sap10_calculator/tables/table_13.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""SAP 10.2 Table 13 — high-rate fraction for electric DHW heating.
|
||||
|
||||
Sourced verbatim from `domain/sap10_calculator/docs/specs/sap-10-2-full-
|
||||
specification-2025-03-14.pdf`, page 197 (Table 13). RdSAP10 §10.5 (PDF
|
||||
p.54) routes electric immersion water heating here via Table 12a's
|
||||
"Immersion water heater" row, whose Water-heating column reads "Fraction
|
||||
from Table 13".
|
||||
|
||||
The table gives the fraction of DHW electricity consumed at the HIGH
|
||||
rate for a cylinder with a single or dual immersion heater on an
|
||||
off-peak tariff; the remainder is at the low rate. Note 2 of the table
|
||||
supplies exact equations equivalent to the tabulated (rounded) grid —
|
||||
this module evaluates those equations, so no floor-area interpolation
|
||||
is needed:
|
||||
|
||||
7-hour tariff (>= 7 hours/day at the low rate)
|
||||
Dual: [(6.8 - 0.024 V) N + 14 - 0.07 V] / 100
|
||||
Single: [(14530 - 762 N) / V - 80 + 10 N] / 100
|
||||
|
||||
10-hour tariff (>= 10 hours/day at the low rate)
|
||||
Dual: [(6.8 - 0.036 V) N + 14 - 0.105 V] / 100
|
||||
Single: [(14530 - 762 N) / (1.5 V) - 80 + 10 N] / 100
|
||||
|
||||
where V is the cylinder volume (litres) and N is the assumed occupancy
|
||||
(Appendix J Table 1b). Per Note 2 the result is clamped to [0, 1].
|
||||
|
||||
Table 12a (PDF p.191) — whose title reads "High-rate fractions for
|
||||
systems using 7-hour and 10-hour tariffs" — routes the "Immersion water
|
||||
heater" row to Table 13 for the tariff "7-hour or 10-hour" ONLY. An
|
||||
18-hour or 24-hour tariff is outside Table 12a/13's scope: it provides
|
||||
at least 18 hours/day at the low rate, more than enough to heat any
|
||||
immersion cylinder off-peak, so the high-rate fraction is 0 (all DHW
|
||||
billed at the low rate). The Elmhurst dr87 worksheet for solid fuel 5
|
||||
(cert 001431: 18-hour meter, 110 L dual immersion, WHC 903) confirms
|
||||
this — HW (245) high-rate = 0.0 kWh, (246) low-rate = 100%. (An earlier
|
||||
reading mapped 18-/24-hour to the 10-hour column via Note 1's "at least
|
||||
10 hours"; the worksheet refutes it — Table 12a's title bounds the table
|
||||
to the two named tariffs.)
|
||||
|
||||
Heat pumps providing water heating only are treated as dual immersion
|
||||
(Note 1) — out of scope of this helper (callers route those via Table
|
||||
12a).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
|
||||
|
||||
def electric_dhw_high_rate_fraction(
|
||||
*,
|
||||
cylinder_volume_l: float,
|
||||
occupancy_n: float,
|
||||
single_immersion: bool,
|
||||
tariff: Tariff,
|
||||
) -> float:
|
||||
"""SAP 10.2 Table 13 (PDF p.197) high-rate fraction for an electric
|
||||
immersion DHW cylinder on an off-peak tariff.
|
||||
|
||||
`single_immersion` selects the single- vs dual-immersion equation
|
||||
(RdSAP10 §10.5 p.54: an immersion is assumed dual on a dual meter).
|
||||
The 7-hour tariff uses the 7-hour equations; the 10-hour tariff uses
|
||||
the 10-hour equations. The 18-hour and 24-hour tariffs are outside
|
||||
Table 12a/13's "7-hour and 10-hour" scope (PDF p.191 title) — they
|
||||
provide >= 18 hours/day at the low rate, so the high-rate fraction is
|
||||
0. STANDARD has no off-peak split and is rejected — callers must
|
||||
early-return before this fires.
|
||||
"""
|
||||
if tariff is Tariff.STANDARD:
|
||||
raise ValueError("Table 13 high-rate fraction is undefined for STANDARD")
|
||||
if tariff in (Tariff.EIGHTEEN_HOUR, Tariff.TWENTY_FOUR_HOUR):
|
||||
# Outside Table 12a's 7-hour/10-hour scope — >= 18 h/day low rate
|
||||
# heats the cylinder entirely off-peak (high-rate fraction 0).
|
||||
return 0.0
|
||||
v = cylinder_volume_l
|
||||
n = occupancy_n
|
||||
if tariff is Tariff.SEVEN_HOUR:
|
||||
if single_immersion:
|
||||
fraction = ((14530 - 762 * n) / v - 80 + 10 * n) / 100
|
||||
else:
|
||||
fraction = ((6.8 - 0.024 * v) * n + 14 - 0.07 * v) / 100
|
||||
else:
|
||||
# 10-hour tariff (18-/24-hour handled above as out-of-scope).
|
||||
if single_immersion:
|
||||
fraction = ((14530 - 762 * n) / (1.5 * v) - 80 + 10 * n) / 100
|
||||
else:
|
||||
fraction = ((6.8 - 0.036 * v) * n + 14 - 0.105 * v) / 100
|
||||
return max(0.0, min(1.0, fraction))
|
||||
|
|
@ -105,9 +105,53 @@ API_FUEL_TO_TABLE_32: Final[dict[int, int]] = {
|
|||
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
|
||||
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
|
||||
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
|
||||
26: 1, 27: 2, 28: 4, 29: 30,
|
||||
26: 1, 27: 2, 28: 4, 29: 30, 30: 42, 31: 43, 32: 44, 33: 11,
|
||||
}
|
||||
|
||||
# Gov-API `main_fuel_type` enum codes whose value COLLIDES with a
|
||||
# same-valued Table 32 code of a DIFFERENT fuel. The gov EPC register
|
||||
# always lodges the API enum, so for these the API translation is
|
||||
# authoritative and must win over the direct same-value Table-code
|
||||
# lookup (which otherwise mis-prices solid fuel at the colliding code's
|
||||
# rate). Confirmed by the description-vs-code audit on
|
||||
# `main_heating[].description`:
|
||||
# 5 = anthracite — Table-32 code 5 is bulk LPG (secondary), 12.19 p
|
||||
# vs anthracite 3.64 p. Drove the cohort's worst cert (2100,
|
||||
# -61 SAP at the LPG rate).
|
||||
# 33 = coal — Table-32 code 33 is the electricity 10-hour low rate
|
||||
# 7.5 p vs house coal 3.67 p (and `is_electric_fuel_code(33)`
|
||||
# wrongly classified the coal main as electric).
|
||||
# DEFERRED (not included): API 9 = dual fuel (mineral + wood) is also a
|
||||
# collision (Table-32 9 = LPG SC11F 3.48 p vs dual fuel 3.99 p) but the
|
||||
# 0.45 p delta nets neutral-to-negative on the (outlier-dominated)
|
||||
# dual-fuel certs and shifts them in a direction not yet understood —
|
||||
# investigate separately.
|
||||
#
|
||||
# COMMUNITY FUELS (handled elsewhere, NOT here): API 30 (waste
|
||||
# combustion), 31 (biomass) and 32 (biogas) — all "(community)" in the
|
||||
# enum — collide in VALUE with the Table-32 electricity codes 30 (standard
|
||||
# rate), 31 (7-hour low) and 32 (7-hour high). They must NOT be
|
||||
# canonicalised globally: the cascade uses the bare Table-32 code 30
|
||||
# internally as `_STANDARD_ELECTRICITY_FUEL_CODE` (e.g. the RdSAP
|
||||
# no-water-heating immersion default writes `water_heating_fuel=30`), so a
|
||||
# blanket remap would mis-price genuine grid electricity as community
|
||||
# waste. The translation is therefore done at the fuel-TYPE boundary
|
||||
# GATED on heat-network context (`_heat_network_community_fuel_code` in
|
||||
# cert_to_inputs), where the community meaning is unambiguous. Community
|
||||
# fuels 20/25 do not collide with an electricity code, so they resolve
|
||||
# correctly through the heat-network path without any special handling.
|
||||
_GOV_API_COLLISION_FUELS: Final[frozenset[int]] = frozenset({5, 33})
|
||||
|
||||
|
||||
def canonical_fuel_code(fuel_code: Optional[int]) -> Optional[int]:
|
||||
"""Normalise a colliding gov-API fuel enum (see
|
||||
`_GOV_API_COLLISION_FUELS`) to its canonical Table 32 code so the
|
||||
same-value collision can't mis-resolve it. Non-colliding codes and
|
||||
already-canonical Table codes pass through unchanged."""
|
||||
if fuel_code in _GOV_API_COLLISION_FUELS:
|
||||
return API_FUEL_TO_TABLE_32.get(fuel_code, fuel_code)
|
||||
return fuel_code
|
||||
|
||||
|
||||
# RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel
|
||||
# code. Only fuels with a published standing charge appear here;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ def _decimal_round_half_up_product(a: float, b: float, dp: int) -> float:
|
|||
|
||||
_WALL_INSULATION_NONE: Final[int] = 4
|
||||
_DEFAULT_DOOR_AREA_M2: Final[float] = 1.85
|
||||
# RdSAP 10 Table 26 (PDF p.51): a door opening to an unheated corridor or
|
||||
# stairwell takes U=1.4 W/m²K for any age band (vs 3.0 for an external door
|
||||
# A-J). The door sits on the sheltered wall (RdSAP §3.7 p.18) and its area
|
||||
# deducts from that wall, not the main wall.
|
||||
_CORRIDOR_DOOR_U_W_PER_M2K: Final[float] = 1.4
|
||||
_DEFAULT_STOREY_HEIGHT_M: Final[float] = 2.5
|
||||
# SAP10.2 §3.2 curtain/blind thermal resistance applied to windows (and
|
||||
# roof windows) — turns raw window U into the worksheet's (27) effective U.
|
||||
|
|
@ -569,6 +574,7 @@ def heat_transmission_from_cert(
|
|||
insulated_door_count: int = 0,
|
||||
insulated_door_u_value: Optional[float] = None,
|
||||
exposure: Optional[DwellingExposure] = None,
|
||||
corridor_door_count: int = 0,
|
||||
) -> HeatTransmission:
|
||||
"""Conduction HLC + thermal-bridging contribution, summed across every
|
||||
sap_building_part in the cert. Windows and doors are apportioned to the
|
||||
|
|
@ -590,7 +596,18 @@ def heat_transmission_from_cert(
|
|||
floor_description = _joined_descriptions(epc.floors)
|
||||
|
||||
# RdSAP10 §15 — door area rounds to 2 d.p. before entering the calc.
|
||||
door_area = _round_half_up(max(0, door_count) * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP)
|
||||
# A door to an unheated corridor (RdSAP Table 26 / §3.7) is billed at
|
||||
# U=1.4 and its area deducts from the sheltered wall, not the main wall.
|
||||
# Only the remaining EXTERNAL doors stay on the main wall at the
|
||||
# age-default U; the corridor door area is tracked separately and
|
||||
# assigned to the first sheltered alt wall in the BP loop below.
|
||||
corridor_door_count = max(0, min(corridor_door_count, door_count))
|
||||
external_door_count = max(0, door_count - corridor_door_count)
|
||||
door_area = _round_half_up(external_door_count * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP)
|
||||
corridor_door_area = _round_half_up(
|
||||
corridor_door_count * _DEFAULT_DOOR_AREA_M2, _AREA_ROUND_DP,
|
||||
)
|
||||
corridor_door_area_remaining = corridor_door_area
|
||||
# SAP10.2 §3.2: effective window U includes the 0.04 m²K/W curtain
|
||||
# resistance — `(27)` worksheet column applies it per-window. When
|
||||
# sap_windows have per-window U lodgements (mixed glazing types in
|
||||
|
|
@ -795,7 +812,21 @@ def heat_transmission_from_cert(
|
|||
# code feeds the documentary-evidence R-value calc when a
|
||||
# measured wall thickness is also present (else ignored).
|
||||
wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity,
|
||||
# RdSAP 10 §5.7/§5.8 (PDF p.40-41), Table 14 — a dry-lined
|
||||
# (incl. lath-and-plaster) uninsulated wall adds R=0.17.
|
||||
# The alt-wall path already passes this; the main wall must
|
||||
# too, else every lodged `wall_dry_lined=Y` main wall is
|
||||
# billed at the un-adjusted U.
|
||||
dry_lined=bool(part.wall_dry_lined),
|
||||
)
|
||||
# RdSAP 10 §5.1 — a lodged/known main-wall U-value (the assessor's
|
||||
# RdSAP output, surfaced by the gov-EPC API as `wall_u_value`)
|
||||
# overrides the §5.6/§5.7 construction-default cascade for the MAIN
|
||||
# wall. Alt-wall sub-areas keep their own per-area U (handled below),
|
||||
# so this only replaces the primary `uw`.
|
||||
lodged_wall_u = getattr(part, "wall_u_value", None)
|
||||
if lodged_wall_u is not None:
|
||||
uw = lodged_wall_u
|
||||
# When the per-bp `roof_insulation_thickness` is explicitly lodged
|
||||
# as 0 (uninsulated — e.g. cert 001479 Ext2 PS sloping ceiling
|
||||
# age C from Slice 91's `_api_resolve_sloping_ceiling_thickness`,
|
||||
|
|
@ -843,6 +874,15 @@ def heat_transmission_from_cert(
|
|||
# string triggers the col (3) age-band default in `u_roof`.
|
||||
is_pitched_sloping_ceiling = "sloping ceiling" in roof_type_lower
|
||||
ur = u_roof(country=country, age_band=age_band, insulation_thickness_mm=roof_thickness, description=effective_roof_description, is_flat_roof=is_flat_roof, is_sloping_ceiling=is_sloping_ceiling, is_pitched_sloping_ceiling=is_pitched_sloping_ceiling)
|
||||
# RdSAP 10 §5.1 — a lodged/known roof U-value (the assessor's RdSAP
|
||||
# output, surfaced by the gov-EPC API as `roof_u_value`) is used
|
||||
# directly in place of the §5.11 construction-default cascade. The gov
|
||||
# open data can redact the backing insulation thickness, so the
|
||||
# cascade otherwise mis-derives an uninsulated U for an insulated roof
|
||||
# (cert 7921-0052-0940-5007-0663: lodged 0.2 vs cascade 2.30 → -23 SAP).
|
||||
lodged_roof_u = getattr(part, "roof_u_value", None)
|
||||
if lodged_roof_u is not None:
|
||||
ur = lodged_roof_u
|
||||
# Floor U-value routing (in priority order):
|
||||
# 1. Basement floor — Table 23 F-column override (whole floor=0).
|
||||
# 2. Exposed/semi-exposed upper floor — Table 20 lookup; no
|
||||
|
|
@ -887,6 +927,12 @@ def heat_transmission_from_cert(
|
|||
wall_thickness_mm=part.wall_thickness_mm,
|
||||
description=effective_floor_description,
|
||||
)
|
||||
# RdSAP 10 §5.1 — a lodged/known ground-floor U-value (surfaced by the
|
||||
# gov-EPC API as `floor_u_value`) overrides the BS EN ISO 13370 /
|
||||
# Table 19 cascade for this part's floor=0.
|
||||
lodged_floor_u = getattr(part, "floor_u_value", None)
|
||||
if lodged_floor_u is not None:
|
||||
uf = lodged_floor_u
|
||||
# RdSAP 10 Table 15 footnote * — flats/maisonettes with unknown
|
||||
# party-wall construction default to U=0.0 (both sides heated),
|
||||
# not the U=0.25 house default. Cert 0036-6325-1100-0063-1226
|
||||
|
|
@ -1009,12 +1055,20 @@ def heat_transmission_from_cert(
|
|||
continue
|
||||
# RdSAP10 §15 — alt wall area rounded to 2 d.p.
|
||||
alt_walls_total_area += _round_half_up(alt_wall.wall_area, _AREA_ROUND_DP)
|
||||
alt_opening = alt_window_area if idx == 0 else 0.0
|
||||
# RdSAP §3.7 (p.18): the door to an unheated corridor deducts
|
||||
# from the SHELTERED wall area. Attach the corridor door to the
|
||||
# first sheltered alt wall (its U=1.4 contribution is billed in
|
||||
# the door channel below, not here).
|
||||
if alt_wall.is_sheltered and corridor_door_area_remaining > 0.0:
|
||||
alt_opening += corridor_door_area_remaining
|
||||
corridor_door_area_remaining = 0.0
|
||||
alt_walls_contribution += _alt_wall_w_per_k(
|
||||
alt_wall=alt_wall,
|
||||
country=country,
|
||||
age_band=age_band,
|
||||
wall_description=wall_description,
|
||||
opening_area_m2=alt_window_area if idx == 0 else 0.0,
|
||||
opening_area_m2=alt_opening,
|
||||
)
|
||||
# Main wall net adds back the alt-wall windows that were initially
|
||||
# deducted from the BP's total gross — those openings should have
|
||||
|
|
@ -1263,6 +1317,13 @@ def heat_transmission_from_cert(
|
|||
total_external_area += part_external_area
|
||||
bridging += y * part_external_area
|
||||
|
||||
# RdSAP Table 26 — the unheated-corridor door's heat loss (U=1.4). Its
|
||||
# area was deducted from the sheltered alt wall above, so the alt wall
|
||||
# net (and hence its W/K) already excludes it; bill it here at the
|
||||
# corridor U. (31) is unaffected: the door area stays counted in the
|
||||
# alt-wall gross contribution, equivalent to the worksheet's separate
|
||||
# door line.
|
||||
doors += _CORRIDOR_DOOR_U_W_PER_M2K * corridor_door_area
|
||||
roof_windows_w_per_k = roof_windows_w_per_k_total
|
||||
fabric_heat_loss = (
|
||||
walls + roof + floor + party + windows + roof_windows_w_per_k + doors # (33)
|
||||
|
|
@ -1339,4 +1400,16 @@ def _alt_wall_w_per_k(
|
|||
# dry-lined) → U=2.34 via §5.6 + §5.8 chain.
|
||||
wall_thickness_mm=alt_wall.wall_thickness_mm,
|
||||
)
|
||||
if alt_wall.is_sheltered:
|
||||
# RdSAP 10 Table 4 (p.22) "Sheltered" wall: an alt sub-area
|
||||
# adjacent to an unheated buffer (stair core, adjoining
|
||||
# structure) carries an added external surface resistance of
|
||||
# R=0.5 m²K/W → U_sheltered = 1/(1/U + 0.5). Mirrors the main
|
||||
# wall's `gable_wall_sheltered` branch
|
||||
# (`_SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W`). The gov-EPC API
|
||||
# lodges this per alt-wall as `sheltered_wall="Y"`; without it a
|
||||
# sheltered timber/cavity alt sub-area billed its full exposed U
|
||||
# (cert 0340-2976: a 42 m² sheltered timber-frame alt at U=2.5
|
||||
# over-stated the wall channel by ~58 W/K → -5 SAP under-rate).
|
||||
alt_u = 1.0 / (1.0 / alt_u + _SHELTERED_GABLE_ADDED_RESISTANCE_M2K_W)
|
||||
return alt_u * net_alt_area
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ def hot_water_mixer_showers_monthly_l_per_day(
|
|||
has_bath: bool,
|
||||
mixer_shower_flow_rates_l_per_min: tuple[float, ...],
|
||||
cold_water_temps_c: tuple[float, ...],
|
||||
n_electric_showers: int = 0,
|
||||
) -> tuple[float, ...]:
|
||||
"""SAP 10.2 §4 line (42a)m via Appendix J equations J1, J2, J3.
|
||||
|
||||
|
|
@ -145,14 +146,23 @@ def hot_water_mixer_showers_monthly_l_per_day(
|
|||
Each outlet's warm-water draw for a month is the flow rate × 6 min ×
|
||||
Table J5 fbeh. The hot fraction is (41 − Tcold[m])/(52 − Tcold[m]).
|
||||
Per-outlet daily warm water is scaled by N_shower / N_outlets, then
|
||||
summed across outlets to give (42a)m.
|
||||
summed across the MIXER outlets to give (42a)m.
|
||||
|
||||
Instantaneous electric showers belong to worksheet (64a)m, not (42a)m
|
||||
— those should be excluded from `mixer_shower_flow_rates_l_per_min`.
|
||||
N_outlets is the dwelling's TOTAL shower-outlet count: SAP 10.2
|
||||
Appendix J step 1a is explicit that the count INCLUDES "any
|
||||
instantaneous electric showers" (which then bill their own energy via
|
||||
(64a)m). So `mixer_shower_flow_rates_l_per_min` lists only the mixer
|
||||
outlets (iterated for the warm-water draw), but `n_electric_showers`
|
||||
must be added to the denominator — otherwise a dwelling with both a
|
||||
mixer and an electric shower assigns the FULL N_shower to the mixer
|
||||
system AND bills the electric shower on top, double-counting shower
|
||||
demand (over-counts main HW → under-rates the dwelling).
|
||||
"""
|
||||
n_outlets = len(mixer_shower_flow_rates_l_per_min)
|
||||
if n_outlets == 0:
|
||||
n_mixer_outlets = len(mixer_shower_flow_rates_l_per_min)
|
||||
if n_mixer_outlets == 0:
|
||||
return tuple(0.0 for _ in range(12))
|
||||
# Appendix J step 1a: Noutlets includes instantaneous electric showers.
|
||||
n_outlets = n_mixer_outlets + n_electric_showers
|
||||
if has_bath:
|
||||
n_shower = 0.45 * n_occupants + 0.65
|
||||
else:
|
||||
|
|
@ -550,13 +560,21 @@ def primary_circuit_hours_per_day_table_3(
|
|||
*,
|
||||
has_cylinder_thermostat: bool,
|
||||
separately_timed_dhw: bool,
|
||||
heat_network: bool = False,
|
||||
) -> tuple[float, float]:
|
||||
"""SAP 10.2 Table 3 (PDF p.159) — hours of primary circulation per
|
||||
day, returned as `(winter_hours, summer_hours)`:
|
||||
"""SAP 10.2 Table 3 (PDF p.159-160) — hours of primary circulation
|
||||
per day, returned as `(winter_hours, summer_hours)`:
|
||||
no thermostat → (11, 3)
|
||||
thermostat, not separately timed → ( 5, 3)
|
||||
thermostat, separately timed → ( 3, 3)
|
||||
|
||||
SAP 10.2 Table 3 (PDF p.160) verbatim: "For heat networks apply the
|
||||
formula above with p = 1.0 and h = 3 for all months." So a heat-
|
||||
network main uses h=3 winter and summer regardless of the cylinder-
|
||||
thermostat / separate-timing lodgement.
|
||||
"""
|
||||
if heat_network:
|
||||
return (3.0, 3.0)
|
||||
if not has_cylinder_thermostat:
|
||||
return (11.0, 3.0)
|
||||
if separately_timed_dhw:
|
||||
|
|
@ -569,6 +587,7 @@ def primary_loss_monthly_kwh(
|
|||
pipework_insulation_fraction: float,
|
||||
has_cylinder_thermostat: bool,
|
||||
separately_timed_dhw: bool,
|
||||
heat_network: bool = False,
|
||||
) -> tuple[float, ...]:
|
||||
"""SAP 10.2 §4 line (59)m via Table 3 (PDF p.159):
|
||||
(59)m = n_m × 14 × [{0.0091 × p + 0.0245 × (1 − p)} × h + 0.0263]
|
||||
|
|
@ -576,6 +595,10 @@ def primary_loss_monthly_kwh(
|
|||
hours of primary circulation per day (winter / summer split per
|
||||
`primary_circuit_hours_per_day_table_3`).
|
||||
|
||||
`heat_network=True` selects the SAP 10.2 Table 3 (PDF p.160) heat-
|
||||
network row — h=3 for all months — regardless of the cylinder-
|
||||
thermostat / separate-timing lodgement.
|
||||
|
||||
Returns 12 monthly values in calendar order Jan..Dec. Callers must
|
||||
gate this helper on the spec's zero-loss configurations
|
||||
(combi boilers, integral-vessel HPs, CPSUs, thermal stores ≤ 1.5 m
|
||||
|
|
@ -587,6 +610,7 @@ def primary_loss_monthly_kwh(
|
|||
winter_h, summer_h = primary_circuit_hours_per_day_table_3(
|
||||
has_cylinder_thermostat=has_cylinder_thermostat,
|
||||
separately_timed_dhw=separately_timed_dhw,
|
||||
heat_network=heat_network,
|
||||
)
|
||||
return tuple(
|
||||
n * 14.0 * (
|
||||
|
|
@ -880,6 +904,8 @@ def water_heating_from_cert(
|
|||
has_bath=has_bath,
|
||||
mixer_shower_flow_rates_l_per_min=mixer_shower_flow_rates_l_per_min,
|
||||
cold_water_temps_c=cold_water_temps_c,
|
||||
# SAP 10.2 Appendix J step 1a — Noutlets includes electric showers.
|
||||
n_electric_showers=electric_shower_count if has_electric_shower else 0,
|
||||
)
|
||||
has_shower = (
|
||||
len(mixer_shower_flow_rates_l_per_min) > 0
|
||||
|
|
|
|||
|
|
@ -66,39 +66,26 @@ def _described_as_insulated(description: Optional[str]) -> bool:
|
|||
return "insulated" in desc or "partial insulation" in desc
|
||||
|
||||
|
||||
def _cavity_described_as_filled(description: Optional[str]) -> bool:
|
||||
"""True when an as-built cavity wall's description asserts the cavity is
|
||||
insulated/filled, routing it to the Table 6 "Filled cavity" row.
|
||||
|
||||
Distinguishes the three as-built cavity states the EPC renders by age
|
||||
band when wall_insulation_type=4 ("as-built / assumed"):
|
||||
|
||||
- "...insulated (assumed)" → Filled cavity (assessor judges
|
||||
the cavity filled but lodges no
|
||||
thickness)
|
||||
- "...partial insulation (assumed)" → "Cavity as built" row (the
|
||||
as-built partial fill of the age
|
||||
band, NOT a retrofit cavity fill)
|
||||
- "...no insulation (assumed)" → "Cavity as built" row
|
||||
|
||||
Narrower than `_described_as_insulated`: it excludes the "partial
|
||||
insulation" substring so a "partial insulation (assumed)" cavity stays on
|
||||
the as-built row. RdSAP 10 Table 6 (England) "Cavity as built" band F =
|
||||
1.0 vs "Filled cavity" band F = 0.40 — for an as-built band-F cavity the
|
||||
filled row understates heat loss by 2.5x. A genuine retrofit fill is
|
||||
lodged distinctly as "Cavity wall, filled cavity"
|
||||
(wall_insulation_type=2), handled by the explicit-code branch.
|
||||
|
||||
Real-cert evidence: golden cert 0390-2954-3640 (detached, band F, cavity
|
||||
type 4, "partial insulation (assumed)") closes all four SAP metrics on
|
||||
the as-built 1.0 row; the filled 0.40 row under-counts PE by ~28 kWh/m².
|
||||
"""
|
||||
if description is None:
|
||||
return False
|
||||
desc = description.lower()
|
||||
if "no insulation" in desc:
|
||||
return False
|
||||
return "insulated" in desc
|
||||
# An AS-BUILT cavity wall (wall_insulation_type=4 / "as-built / assumed",
|
||||
# however the EPC renders the insulation adjective — "insulated", "partial
|
||||
# insulation" or "no insulation" "(assumed)") routes to Table 6's "Cavity
|
||||
# as built" row via the bucketed cascade, NOT the "Filled cavity" row. Per
|
||||
# RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote ("assumed
|
||||
# as built") applies only at age bands I-M, where the two rows are
|
||||
# numerically identical — so at bands A-H the Filled cavity row represents a
|
||||
# GENUINE fill, not the as-built assumption. A genuine retrofit fill is
|
||||
# lodged distinctly as "Cavity wall, filled cavity" (wall_insulation_type=2),
|
||||
# caught by the explicit-code branch in `u_wall`.
|
||||
#
|
||||
# Slice S0380.210 first corrected this for "partial insulation (assumed)"
|
||||
# (golden 0390-2954-3640, band F → as-built 1.0); the "insulated (assumed)"
|
||||
# variant was left on the filled row by a legacy production convention. That
|
||||
# was the SAME latent A-H bug: the API SAP-accuracy cohort over-rated
|
||||
# "Cavity wall, as built, insulated (assumed)" certs at bands G/H by a clean
|
||||
# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60), while bands I-M
|
||||
# were unaffected (rows coincide). The `_cavity_described_as_filled`
|
||||
# description sniffer is therefore retired — as-built cavities always use the
|
||||
# as-built row regardless of the rendered insulation adjective.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -472,6 +459,29 @@ _DEFAULT_WALL_BY_AGE: Final[dict[str, int]] = {
|
|||
"L": WALL_CAVITY, "M": WALL_CAVITY,
|
||||
}
|
||||
|
||||
# Gov-EPC API `wall_construction` codes that DIVERGE from the calculator's
|
||||
# internal WALL_* code-space. The gov enum (confirmed by the description-vs-
|
||||
# code audit across the corpus) is 1=granite, 2=sandstone, 3=solid brick,
|
||||
# 4=cavity, 5=timber frame (all ALIGN with the WALL_* constants), but
|
||||
# 6=basement, 8=system built, 9=cob — whereas the calc constants are
|
||||
# WALL_SYSTEM_BUILT=6, WALL_COB=7, WALL_PARK_HOME=8, WALL_CURTAIN=9.
|
||||
#
|
||||
# Code 8 (system built) falls OUT of `known_types` and previously resolved
|
||||
# only via the `walls[].description` fallback; a cert lodging no description
|
||||
# silently defaulted to cavity. Translate it here so the int code alone
|
||||
# resolves. `WALL_PARK_HOME` (calc 8) is never dispatched, so reusing API
|
||||
# code 8 for system built is collision-free at this layer.
|
||||
#
|
||||
# Code 9 (cob) is NOT handled here: calc `WALL_CURTAIN`=9 (set by the
|
||||
# Summary path's "CW" mapping, with a `curtain_wall_age`) intercepts it in
|
||||
# the `construction == WALL_CURTAIN` branch above. The gov-API cob code is
|
||||
# therefore translated to `WALL_COB` upstream in the API mapper (where the
|
||||
# source is unambiguously the gov enum), so it never reaches this collision.
|
||||
# Code 6 (basement) is left to the mapper's basement machinery.
|
||||
_GOV_API_WALL_CODE_TO_TYPE: Final[dict[int, int]] = {
|
||||
8: WALL_SYSTEM_BUILT, # gov API "System built"
|
||||
}
|
||||
|
||||
|
||||
# Surveyor-text -> wall-construction code, evaluated in priority order so that
|
||||
# "sandstone" beats "stone", "solid brick" beats "brick", etc. Used only as a
|
||||
|
|
@ -598,6 +608,11 @@ def u_wall(
|
|||
}
|
||||
if construction in known_types:
|
||||
wall_type = construction
|
||||
elif construction in _GOV_API_WALL_CODE_TO_TYPE:
|
||||
# A gov-API construction code (system built / cob) that diverges from
|
||||
# the WALL_* code-space — resolve it directly, independent of the
|
||||
# surveyor description (RdSAP 10 §5.7 Table 6 rows still apply).
|
||||
wall_type = _GOV_API_WALL_CODE_TO_TYPE[construction]
|
||||
else:
|
||||
wall_type = _wall_type_from_description(description) or _DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)
|
||||
# RdSAP 10 §5.7 Table 13 + §5.8 (PDF p.41-42) — uninsulated solid
|
||||
|
|
@ -689,7 +704,6 @@ def u_wall(
|
|||
)
|
||||
if wall_type == WALL_CAVITY and (
|
||||
wall_insulation_type == WALL_INSULATION_FILLED_CAVITY
|
||||
or _cavity_described_as_filled(description)
|
||||
):
|
||||
return _CAVITY_FILLED_ENG[age_idx]
|
||||
bucket = _insulation_bucket(insulation_thickness_mm, insulation_present)
|
||||
|
|
@ -871,9 +885,30 @@ def u_roof(
|
|||
return u
|
||||
if description is not None:
|
||||
desc = description.lower()
|
||||
if any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS):
|
||||
no_insulation = any(marker in desc for marker in _ROOF_NO_INSULATION_MARKERS)
|
||||
limited_insulation = any(
|
||||
marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS
|
||||
)
|
||||
if (no_insulation or limited_insulation) and is_flat_roof and age_band is not None:
|
||||
# FLAT roof reached here only when insulation_thickness_mm is None
|
||||
# — the lodged thickness is undetermined ('ND'/'AB'/absent). Per
|
||||
# RdSAP 10 §5.11.4 (PDF p.44) "U-values in Table 18 are used when
|
||||
# thickness of insulation cannot be determined", so the column (3)
|
||||
# flat-roof age-band default applies — NOT the uninsulated 2.30.
|
||||
# The "no/limited insulation" text is RdSAP's as-built rendering:
|
||||
# at old bands (A-D) the column (3) default IS 2.30 (so those certs
|
||||
# are unchanged), but a newer-band flat roof carries the age-band
|
||||
# insulation as built (band H = 0.35, F = 0.68, not 2.30). The
|
||||
# roof's deterministic energy_efficiency_rating confirms it (e.g.
|
||||
# cert 0390-2753 band H lodges rating 3 = moderate U, not the
|
||||
# rating-1 that 2.30 implies). PITCHED roofs are deliberately NOT
|
||||
# routed here — their "no insulation" text is load-bearing (the
|
||||
# broad 'ND'→Table-18 reroute was empirically net-negative for
|
||||
# pitched lofts).
|
||||
return _FLAT_ROOF_BY_AGE.get(age_band.upper(), 0.4)
|
||||
if no_insulation:
|
||||
return _ROOF_BY_THICKNESS[0][1] # 2.30 W/m^2K
|
||||
if any(marker in desc for marker in _ROOF_LIMITED_INSULATION_MARKERS):
|
||||
if limited_insulation:
|
||||
return _ROOF_BY_THICKNESS[1][1] # 1.50 W/m^2K (12mm row)
|
||||
if age_band is None:
|
||||
return 0.4
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import Final, Optional
|
||||
|
||||
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table 4a + Table 4b — space-heating seasonal efficiency by code
|
||||
|
|
@ -60,7 +62,13 @@ _SPACE_EFF_BY_CODE: Final[dict[int, float]] = {
|
|||
607: 0.45, 609: 0.58, 610: 0.72, 611: 0.85, 612: 0.20, 613: 0.90,
|
||||
# Room heaters — liquid.
|
||||
621: 0.55, 622: 0.65, 623: 0.60, 624: 0.70, 625: 0.94,
|
||||
# Room heaters — solid (column B non-HETAS).
|
||||
# Room heaters — solid. SAP 10.2 Table 4a (p.167): column (A) is the
|
||||
# minimum for HETAS-approved appliances, column (B) for other
|
||||
# appliances. RdSAP defaults to column (B) when approval is not
|
||||
# lodged (Elmhurst worksheet "solid fuel 9" code 636 → (206)=70 = B),
|
||||
# so these are the column-(B) space efficiencies: 631 open fire 32,
|
||||
# 632 +back boiler 50, 633 closed room heater 60, 634 +boiler 65,
|
||||
# 635 pellet stove 65, 636 +boiler 70.
|
||||
631: 0.32, 632: 0.50, 633: 0.60, 634: 0.65, 635: 0.65, 636: 0.70,
|
||||
# Room heaters — electric.
|
||||
691: 1.00, 692: 1.00, 693: 1.00, 694: 1.00,
|
||||
|
|
@ -148,6 +156,15 @@ def seasonal_efficiency(
|
|||
eff = _CATEGORY_FALLBACK_EFF.get(main_heating_category)
|
||||
if eff is not None:
|
||||
return eff
|
||||
# Reached when neither code nor category resolved. If SOMETHING was
|
||||
# lodged but unmapped, surface it (the blind 0.80 gas-boiler default
|
||||
# catastrophically misrates heat pumps / storage). Only a genuinely
|
||||
# data-free call (both absent) keeps the 0.80 "no data" default.
|
||||
if sap_main_heating_code is not None or main_heating_category is not None:
|
||||
raise UnmappedSapCode(
|
||||
"seasonal_efficiency (code/category)",
|
||||
(sap_main_heating_code, main_heating_category),
|
||||
)
|
||||
return 0.80
|
||||
|
||||
|
||||
|
|
@ -159,13 +176,15 @@ def water_heating_efficiency(
|
|||
|
||||
Codes 901/914 ("from main / from second main") inherit the main code's
|
||||
seasonal efficiency. Code 902 ("from secondary") falls back to typical.
|
||||
Unknown -> 0.78 (gas-combi typical).
|
||||
Absent (None) -> 0.78 (gas-combi typical). A code PRESENT but in no SAP
|
||||
10.2 Table 4a HW row raises `UnmappedSapCode` rather than silently taking
|
||||
the 0.78 default — the forcing function that surfaces a new HW code.
|
||||
"""
|
||||
if water_heating_code is None:
|
||||
return 0.78
|
||||
eff = _WATER_EFF_BY_CODE.get(water_heating_code)
|
||||
if eff is None:
|
||||
return 0.78
|
||||
raise UnmappedSapCode("water_heating_code (efficiency)", water_heating_code)
|
||||
if eff == 0.0: # sentinel for "inherit"
|
||||
return seasonal_efficiency(main_heating_code)
|
||||
return eff
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ def make_sap_heating(
|
|||
water_heating_code: Optional[int] = 901,
|
||||
water_heating_fuel: Optional[int] = 26,
|
||||
cylinder_size: Optional[Union[int, str]] = None,
|
||||
cylinder_volume_measured_l: Optional[int] = None,
|
||||
immersion_heating_type: Optional[Union[int, str]] = None,
|
||||
cylinder_insulation_type: Optional[int] = None,
|
||||
cylinder_insulation_thickness_mm: Optional[int] = None,
|
||||
cylinder_thermostat: Optional[str] = None,
|
||||
|
|
@ -115,6 +117,8 @@ def make_sap_heating(
|
|||
water_heating_code=water_heating_code,
|
||||
water_heating_fuel=water_heating_fuel,
|
||||
cylinder_size=cylinder_size,
|
||||
cylinder_volume_measured_l=cylinder_volume_measured_l,
|
||||
immersion_heating_type=immersion_heating_type,
|
||||
cylinder_insulation_type=cylinder_insulation_type,
|
||||
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
|
||||
cylinder_thermostat=cylinder_thermostat,
|
||||
|
|
|
|||
|
|
@ -114,20 +114,26 @@ def test_u_wall_solid_brick_with_ni_thickness_uses_50mm_row_per_table6_footnote(
|
|||
assert result == pytest.approx(0.55, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row() -> None:
|
||||
# Arrange — 1 171 corpus certs (~4% of scanned bulk) lodge
|
||||
# wall_insulation_type=4 ("as-built / assumed") together with the
|
||||
# description "Cavity wall, as built, insulated (assumed)". The
|
||||
# assessor is saying: this cavity is filled, but I haven't measured
|
||||
# the thickness. Spec footnote on Table 6 covers this: "If a wall
|
||||
# is known to have additional insulation but the insulation thickness
|
||||
# is unknown, use the row in the table for 50 mm insulation" — but
|
||||
# legacy convention (used by the production recommendation engine)
|
||||
# is to route this to the Filled-cavity row, U = 0.7 at A-E. We
|
||||
# follow the legacy convention here for parity with the cert assessor.
|
||||
def test_u_wall_cavity_as_built_insulated_assumed_routes_to_as_built_row() -> None:
|
||||
# Arrange — a cavity lodged "Cavity wall, as built, insulated (assumed)"
|
||||
# with wall_insulation_type=4 is in its AS-BUILT state, NOT a retrofit
|
||||
# cavity fill. Per RdSAP 10 Table 6 (England) the "Filled cavity" row's
|
||||
# † footnote ("assumed as built") applies only at bands I-M, where it
|
||||
# coincides with "Cavity as built"; at bands A-H the filled row is for a
|
||||
# GENUINE fill. So an as-built cavity uses the "Cavity as built" row:
|
||||
# band E = 1.5, NOT the filled 0.7.
|
||||
#
|
||||
# Slice S0380.210 corrected this for the "partial insulation (assumed)"
|
||||
# variant but left "insulated (assumed)" on the filled row by a legacy
|
||||
# production convention — the SAME latent A-H bug. The API SAP-accuracy
|
||||
# cohort over-rated band-G/H "insulated (assumed)" cavities by a clean
|
||||
# +1.4 / +1.6 SAP median (filled 0.35 vs as-built 0.60); bands I-M were
|
||||
# unaffected (rows coincide). A genuine fill lodges the distinct "Cavity
|
||||
# wall, filled cavity" (wall_insulation_type=2), caught by the
|
||||
# explicit-code branch.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
result_e = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="E",
|
||||
construction=WALL_CAVITY,
|
||||
|
|
@ -136,19 +142,30 @@ def test_u_wall_cavity_as_built_insulated_assumed_routes_to_filled_cavity_row()
|
|||
wall_insulation_type=4,
|
||||
description="Cavity wall, as built, insulated (assumed)",
|
||||
)
|
||||
# Band I: "Cavity as built" and "Filled cavity" rows coincide (0.45),
|
||||
# so the routing change is a no-op there — the corpus-confirmed pivot.
|
||||
result_i = u_wall(
|
||||
country=Country.ENG,
|
||||
age_band="I",
|
||||
construction=WALL_CAVITY,
|
||||
insulation_thickness_mm=None,
|
||||
insulation_present=False,
|
||||
wall_insulation_type=4,
|
||||
description="Cavity wall, as built, insulated (assumed)",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(0.7, abs=0.001)
|
||||
# Assert — band E → as-built 1.5 (not filled 0.7); band I → 0.45 (rows coincide).
|
||||
assert abs(result_e - 1.5) <= 0.001
|
||||
assert abs(result_i - 0.45) <= 0.001
|
||||
|
||||
|
||||
def test_u_wall_cavity_as_built_no_insulation_stays_at_table6_cavity_as_built_row() -> None:
|
||||
# Arrange — the same wall_insulation_type=4 ("as-built / assumed")
|
||||
# cert population also contains 686 "Cavity wall, as built, no
|
||||
# insulation (assumed)" entries which must continue to route to the
|
||||
# Cavity-as-built row of Table 6 (U=1.5 at band E). The "no
|
||||
# insulation" substring marker takes precedence over the
|
||||
# "insulated"-substring filled-cavity rule, so this case is
|
||||
# disambiguated from "Cavity wall, as built, insulated (assumed)".
|
||||
# insulation (assumed)" entries which route to the Cavity-as-built row
|
||||
# of Table 6 (U=1.5 at band E) — as do ALL as-built cavity variants
|
||||
# ("insulated" / "partial insulation" / "no insulation") now that the
|
||||
# as-built path no longer special-cases the insulation adjective.
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
|
|
@ -180,8 +197,8 @@ def test_u_wall_cavity_as_built_partial_insulation_routes_to_as_built_row() -> N
|
|||
# four SAP metrics on the as-built row (band F = 1.0) and under-counts
|
||||
# PE by ~28 kWh/m² on the filled row — the legacy parity was a latent
|
||||
# bug at bands A-H (bands I-M coincide per the Table 6 † footnote).
|
||||
# The "insulated (assumed)" variant still routes to filled (see the
|
||||
# heat_transmission `_cavity_described_as_filled` sibling test).
|
||||
# A later slice extended the same fix to the "insulated (assumed)"
|
||||
# variant (see the as-built-insulated sibling test above).
|
||||
|
||||
# Act
|
||||
result = u_wall(
|
||||
|
|
@ -434,6 +451,29 @@ def test_u_wall_falls_back_to_age_band_default_when_construction_unknown() -> No
|
|||
assert result == pytest.approx(0.60, abs=0.001)
|
||||
|
||||
|
||||
def test_u_wall_gov_api_system_built_code_8_resolves_without_description() -> None:
|
||||
# Arrange — the gov-EPC API `wall_construction` enum diverges from the
|
||||
# calculator's internal WALL_* code-space: API 8 = "System built" (calc
|
||||
# WALL_SYSTEM_BUILT = 6; calc 8 = park home). The 43-cert system-built
|
||||
# cohort currently resolves only via the `walls[].description` fallback;
|
||||
# with no description, code 8 silently defaulted to cavity (1.5) instead
|
||||
# of the system-built U (band E as-built = 1.7).
|
||||
|
||||
# Act — code 8, NO description.
|
||||
result = u_wall(
|
||||
country=Country.ENG, age_band="E", construction=8,
|
||||
insulation_thickness_mm=0, description=None,
|
||||
)
|
||||
reference = u_wall(
|
||||
country=Country.ENG, age_band="E", construction=WALL_SYSTEM_BUILT,
|
||||
insulation_thickness_mm=0,
|
||||
)
|
||||
|
||||
# Assert — code 8 is system-built (1.7), not the cavity default (1.5).
|
||||
assert abs(result - reference) <= 1e-9
|
||||
assert abs(result - 1.7) <= 1e-9
|
||||
|
||||
|
||||
def test_u_wall_falls_back_to_mid_range_default_when_everything_unknown() -> None:
|
||||
# Arrange — no signal at all.
|
||||
|
||||
|
|
@ -861,6 +901,46 @@ def test_u_roof_unknown_flat_insulation_uses_table18_flat_column() -> None:
|
|||
assert abs(result - 0.35) <= 0.01
|
||||
|
||||
|
||||
def test_u_roof_flat_no_insulation_undetermined_thickness_uses_table18_by_age() -> None:
|
||||
# Arrange — a flat roof lodged "Flat, no insulation" / "Flat, limited
|
||||
# insulation" with an UNDETERMINED thickness (parsed to None from
|
||||
# 'ND'/'AB') must take the Table 18 column (3) flat-roof age-band
|
||||
# default per RdSAP 10 §5.11.4 (PDF p.44), NOT the uninsulated 2.30.
|
||||
# The "no/limited insulation" text is RdSAP's as-built rendering — at
|
||||
# old bands the column (3) default IS 2.30 (so they're unchanged), but
|
||||
# a newer-band flat roof carries the age-band insulation as built.
|
||||
# Cert 0390-2753 (top-floor flat, band H, "Flat, no insulation",
|
||||
# thickness 'ND', roof rating 3 = moderate) drove a -31.78 SAP error at
|
||||
# the 2.30 value; band H column (3) = 0.35.
|
||||
|
||||
# Act — band H "no insulation" → 0.35; band F "limited insulation" → 0.68;
|
||||
# band C "no insulation" → unchanged 2.30 (column (3) default at C).
|
||||
band_h = u_roof(
|
||||
country=Country.ENG, age_band="H", insulation_thickness_mm=None,
|
||||
description="Flat, no insulation", is_flat_roof=True,
|
||||
)
|
||||
band_f = u_roof(
|
||||
country=Country.ENG, age_band="F", insulation_thickness_mm=None,
|
||||
description="Flat, limited insulation", is_flat_roof=True,
|
||||
)
|
||||
band_c = u_roof(
|
||||
country=Country.ENG, age_band="C", insulation_thickness_mm=None,
|
||||
description="Flat, no insulation", is_flat_roof=True,
|
||||
)
|
||||
# A PITCHED roof "no insulation" with undetermined thickness is NOT
|
||||
# rerouted — its text is load-bearing (2.30 stays).
|
||||
pitched = u_roof(
|
||||
country=Country.ENG, age_band="H", insulation_thickness_mm=None,
|
||||
description="Pitched, no insulation", is_flat_roof=False,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(band_h - 0.35) <= 0.01
|
||||
assert abs(band_f - 0.68) <= 0.01
|
||||
assert abs(band_c - 2.30) <= 0.01
|
||||
assert abs(pitched - 2.30) <= 0.01
|
||||
|
||||
|
||||
def test_u_roof_age_band_j_pitched_returns_table18_value() -> None:
|
||||
# Arrange — Table 18, pitched insulation between joists, age J -> 0.16 W/m^2K.
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,22 @@ def test_seasonal_efficiency_ground_source_heat_pump_returns_table4a_value() ->
|
|||
assert result == pytest.approx(2.30, abs=0.005)
|
||||
|
||||
|
||||
def test_seasonal_efficiency_solid_fuel_room_heaters_use_table4a_column_b() -> None:
|
||||
# Arrange — SAP 10.2 Table 4a (p.167) solid-fuel room heaters give
|
||||
# column (A) for HETAS-approved appliances and column (B) for other
|
||||
# appliances. RdSAP defaults to column (B) when HETAS approval is not
|
||||
# lodged (Elmhurst worksheet "solid fuel 9" code 636 → (206)=70 = B):
|
||||
# 631 open fire 32, 633 closed room heater 60, 634 with boiler 65,
|
||||
# 635 pellet stove 65, 636 with boiler 70.
|
||||
|
||||
# Act / Assert
|
||||
assert seasonal_efficiency(sap_main_heating_code=631) == 0.32
|
||||
assert seasonal_efficiency(sap_main_heating_code=633) == 0.60
|
||||
assert seasonal_efficiency(sap_main_heating_code=634) == 0.65
|
||||
assert seasonal_efficiency(sap_main_heating_code=635) == 0.65
|
||||
assert seasonal_efficiency(sap_main_heating_code=636) == 0.70
|
||||
|
||||
|
||||
def test_seasonal_efficiency_oil_boiler_returns_table4b_value() -> None:
|
||||
# Arrange — Table 4b, code 126 standard oil 1998+ -> 80% winter.
|
||||
|
||||
|
|
@ -275,3 +291,48 @@ def test_fuel_unit_price_recognises_api_code_29_electricity_not_community() -> N
|
|||
def test_fuel_unit_price_recognises_api_code_27_lpg_not_community() -> None:
|
||||
# Arrange / Act — gov API code 27 = LPG not community -> bulk LPG 7.60 p/kWh.
|
||||
assert fuel_unit_price_p_per_kwh(fuel_code=27) == pytest.approx(7.60, abs=0.01)
|
||||
|
||||
|
||||
# ----- Robustness: strict-raise on a lodged-but-unmapped code -----
|
||||
|
||||
|
||||
def test_seasonal_efficiency_raises_on_present_unmapped_code_and_category() -> None:
|
||||
# Arrange — a main-heating SAP code AND category that resolve in NEITHER
|
||||
# _SPACE_EFF_BY_CODE nor the category/room-heater fallbacks. The blind
|
||||
# 0.80 gas-boiler default "catastrophically misrates heat pumps and
|
||||
# storage" (per the table comment), so a lodged-but-unmapped pairing must
|
||||
# surface as UnmappedSapCode, not silently rate as a gas boiler.
|
||||
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedSapCode):
|
||||
seasonal_efficiency(sap_main_heating_code=8888, main_heating_category=99)
|
||||
|
||||
|
||||
def test_seasonal_efficiency_no_data_still_defaults_to_gas_boiler() -> None:
|
||||
# Arrange — when NOTHING is lodged (code and category both absent), the
|
||||
# 0.80 typical-gas default is the correct "no data" fallback, not a raise.
|
||||
# Act
|
||||
result = seasonal_efficiency(sap_main_heating_code=None, main_heating_category=None)
|
||||
|
||||
# Assert
|
||||
assert result == 0.80
|
||||
|
||||
|
||||
def test_water_heating_efficiency_raises_on_present_unmapped_code() -> None:
|
||||
# Arrange — a water-heating code that exists in NO SAP 10.2 Table 4a HW
|
||||
# row must surface rather than silently take the 0.78 gas-combi default.
|
||||
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedSapCode):
|
||||
water_heating_efficiency(water_heating_code=9999, main_heating_code=None)
|
||||
|
||||
|
||||
def test_water_heating_efficiency_absent_code_still_defaults() -> None:
|
||||
# Arrange — no water-heating code lodged (None) keeps the typical default.
|
||||
# Act
|
||||
result = water_heating_efficiency(water_heating_code=None, main_heating_code=None)
|
||||
|
||||
# Assert
|
||||
assert result == 0.78
|
||||
|
|
|
|||
124
scripts/decompose_co2_pe_error.py
Normal file
124
scripts/decompose_co2_pe_error.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"""Decompose the API-path CO2/PE over-estimate against lodged EPC figures.
|
||||
|
||||
The corpus integration test surfaced a systematic +5-10% over-estimate on
|
||||
CO2 and PE while cost/SAP is well-calibrated. This script profiles the
|
||||
structure of that bias: multiplicative vs additive, per-component shares,
|
||||
and segmentation by fuel / PV / heating type — to localise the cause.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import statistics as stats
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_inputs,
|
||||
)
|
||||
|
||||
CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
docs = [
|
||||
json.loads(line)
|
||||
for line in CORPUS.read_text().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for doc in docs:
|
||||
lodged_sap = doc.get("energy_rating_current")
|
||||
lodged_co2 = doc.get("co2_emissions_current") # t/yr
|
||||
lodged_pe = doc.get("energy_consumption_current") # kWh/m2/yr
|
||||
if lodged_pe is None or lodged_co2 is None:
|
||||
continue
|
||||
try:
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
res = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
tfa = res.intermediate["tfa_m2"]
|
||||
im = res.intermediate
|
||||
rows.append({
|
||||
"cert": doc.get("rrn") or "",
|
||||
"sap_err": res.sap_score_continuous - (lodged_sap or 0),
|
||||
"our_pe": res.primary_energy_kwh_per_m2,
|
||||
"lodged_pe": lodged_pe,
|
||||
"pe_diff": res.primary_energy_kwh_per_m2 - lodged_pe,
|
||||
"pe_ratio": res.primary_energy_kwh_per_m2 / lodged_pe if lodged_pe else 0,
|
||||
"our_co2": res.co2_kg_per_yr / 1000.0,
|
||||
"lodged_co2": lodged_co2,
|
||||
"co2_diff": res.co2_kg_per_yr / 1000.0 - lodged_co2,
|
||||
"co2_ratio": (res.co2_kg_per_yr / 1000.0) / lodged_co2 if lodged_co2 else 0,
|
||||
# PE components per m2
|
||||
"space_pe": im["space_heating_pe_kwh_per_m2"],
|
||||
"hw_pe": im["hot_water_pe_kwh_per_m2"],
|
||||
"other_pe": im["other_pe_kwh_per_m2"],
|
||||
"pv_pe": im["pv_pe_offset_kwh_per_m2"],
|
||||
"mains_gas": (doc.get("sap_energy_source") or {}).get("mains_gas"),
|
||||
"has_pv": bool((doc.get("sap_energy_source") or {}).get("photovoltaic_supply")),
|
||||
"tfa": tfa,
|
||||
})
|
||||
|
||||
n = len(rows)
|
||||
print(f"decomposed {n} certs\n" + "=" * 70)
|
||||
|
||||
def summ(key: str) -> str:
|
||||
vals = [r[key] for r in rows]
|
||||
return (f"median={stats.median(vals):+.3f} mean={stats.mean(vals):+.3f} "
|
||||
f"p10={sorted(vals)[n//10]:+.3f} p90={sorted(vals)[n*9//10]:+.3f}")
|
||||
|
||||
print(f"PE diff (kWh/m2): {summ('pe_diff')}")
|
||||
print(f"PE ratio (our/lod): {summ('pe_ratio')}")
|
||||
print(f"CO2 diff (t/yr) : {summ('co2_diff')}")
|
||||
print(f"CO2 ratio (our/lod): {summ('co2_ratio')}")
|
||||
print("=" * 70)
|
||||
|
||||
# multiplicative vs additive: correlate pe_diff with lodged_pe magnitude
|
||||
lod = [r["lodged_pe"] for r in rows]
|
||||
dif = [r["pe_diff"] for r in rows]
|
||||
mean_lod, mean_dif = stats.mean(lod), stats.mean(dif)
|
||||
cov = sum((l - mean_lod) * (d - mean_dif) for l, d in zip(lod, dif)) / n
|
||||
var = sum((l - mean_lod) ** 2 for l in lod) / n
|
||||
slope = cov / var
|
||||
print(f"PE: regress pe_diff ~ lodged_pe -> slope={slope:+.4f} intercept={mean_dif - slope*mean_lod:+.3f}")
|
||||
print(" (slope~0 => additive constant; slope>0 => multiplicative)")
|
||||
print("=" * 70)
|
||||
|
||||
# segment
|
||||
def seg(name: str, pred: Any) -> None:
|
||||
s = [r for r in rows if pred(r)]
|
||||
if not s:
|
||||
return
|
||||
print(f" {name:28s} n={len(s):4d} PEdiff_med={stats.median(r['pe_diff'] for r in s):+6.2f} "
|
||||
f"PEratio_med={stats.median(r['pe_ratio'] for r in s):.3f} "
|
||||
f"CO2diff_med={stats.median(r['co2_diff'] for r in s):+.3f} "
|
||||
f"SAPerr_med={stats.median(r['sap_err'] for r in s):+.2f}")
|
||||
|
||||
print("SEGMENTS:")
|
||||
seg("mains_gas=Y", lambda r: r["mains_gas"] in (True, 1, "Y"))
|
||||
seg("mains_gas=N", lambda r: r["mains_gas"] not in (True, 1, "Y"))
|
||||
seg("ALL", lambda r: True)
|
||||
print("=" * 70)
|
||||
print("PE COMPONENT MEANS (kWh/m2):")
|
||||
for k in ("space_pe", "hw_pe", "other_pe", "pv_pe", "our_pe", "lodged_pe"):
|
||||
print(f" {k:12s} mean={stats.mean(r[k] for r in rows):7.2f} "
|
||||
f"median={stats.median(r[k] for r in rows):7.2f}")
|
||||
print("=" * 70)
|
||||
# Well-calibrated-SAP certs: energy is right, so PE diff isolates factor/scope.
|
||||
cal = [r for r in rows if abs(r["sap_err"]) < 0.3]
|
||||
print(f"WELL-CALIBRATED-SAP (|sap_err|<0.3) n={len(cal)}:")
|
||||
print(f" PE diff median={stats.median(r['pe_diff'] for r in cal):+.2f} "
|
||||
f"ratio={stats.median(r['pe_ratio'] for r in cal):.3f}")
|
||||
print(f" CO2 diff median={stats.median(r['co2_diff'] for r in cal):+.3f} "
|
||||
f"ratio={stats.median(r['co2_ratio'] for r in cal):.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
95
scripts/profile_case34.py
Normal file
95
scripts/profile_case34.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""Decompose simulated case 34 (electric-storage corridor flat) vs its
|
||||
dr87/P960 worksheet, channel by channel.
|
||||
|
||||
Routes the tracked fixture
|
||||
`backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf`
|
||||
through extractor -> mapper -> calculator (Summary path) and prints the §3
|
||||
heat-transmission breakdown + the space-heating demand / ventilation /
|
||||
gains / MIT intermediates against the worksheet line refs.
|
||||
|
||||
The fabric (33), bridging (36), (31) and the door channel are EXACT after
|
||||
HEAD c10881ae. The +46.3 kWh/yr (+0.41%) space-heating over-count was the
|
||||
§2 (13) draught lobby: a corridor flat assumes a lobby (0.0), not the 0.05
|
||||
no-lobby penalty (RdSAP 10 spec p.30) — closed at HEAD 450e33e1, which lands
|
||||
effective air change (25)m on the worksheet (avg 0.6024) and SAP at 35.3130
|
||||
vs ws 35.3094 (Δ +0.0036, sub-2dp-rounding). The residual now sits at the
|
||||
worksheet's own 2dp display floor (walls -0.017 W/K). Use this to audit.
|
||||
|
||||
PYTHONPATH=/workspaces/model python scripts/profile_case34.py
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_inputs,
|
||||
heat_transmission_section_from_cert,
|
||||
)
|
||||
|
||||
_FIXTURE = (
|
||||
Path(__file__).parent.parent
|
||||
/ "backend/documents_parser/tests/fixtures/Summary_case34_storage_flat.pdf"
|
||||
)
|
||||
|
||||
|
||||
def _pages(pdf: Path) -> list[str]:
|
||||
info = subprocess.run(
|
||||
["pdfinfo", str(pdf)], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
pc = int(re.search(r"Pages:\s+(\d+)", info).group(1)) # type: ignore[union-attr]
|
||||
pages: list[str] = []
|
||||
for i in range(1, pc + 1):
|
||||
layout = subprocess.run(
|
||||
["pdftotext", "-layout", "-f", str(i), "-l", str(i), str(pdf), "-"],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
toks: list[str] = []
|
||||
for line in layout.splitlines():
|
||||
if not line.strip():
|
||||
toks.append("")
|
||||
continue
|
||||
toks.extend([p for p in re.split(r"\s{2,}", line.strip()) if p])
|
||||
pages.append("\n".join(toks))
|
||||
return pages
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sn = ElmhurstSiteNotesExtractor(_pages(_FIXTURE)).extract()
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(sn)
|
||||
ht = heat_transmission_section_from_cert(epc)
|
||||
print("== §3 HEAT TRANSMISSION (all EXACT at c10881ae) ==")
|
||||
print(f"(31) ext elem area = {ht.total_external_element_area_m2:.4f} | ws 120.369")
|
||||
print(f"(29a) walls = {ht.walls_w_per_k:.4f} | ws 30.902")
|
||||
print(f"(30) roof = {ht.roof_w_per_k:.4f} | ws 91.954")
|
||||
print(f"(28a) floor = {ht.floor_w_per_k:.4f} | ws 27.986")
|
||||
print(f"(26) doors = {ht.doors_w_per_k:.4f} | ws 8.14 (corridor 1.4 + ext 3.0)")
|
||||
print(f"(33) fabric = {ht.fabric_heat_loss_w_per_k:.4f} | ws 207.484")
|
||||
print(f"(36) bridging = {ht.thermal_bridging_w_per_k:.4f} | ws 18.054")
|
||||
print(f"(37) total fabric = {ht.total_w_per_k:.4f} | ws 225.538")
|
||||
|
||||
res = calculate_sap_from_inputs(cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES))
|
||||
im = res.intermediate
|
||||
print("\n== RESIDUAL CLOSED at 450e33e1: was +46.3 kWh, now -0.96 kWh ==")
|
||||
keys = [
|
||||
"useful_space_heating_kwh_per_yr", # ws (98) = 11357.24
|
||||
"infiltration_ach", "infiltration_w_per_k",
|
||||
"internal_gains_annual_avg_w", # ws (73)/(84)
|
||||
"mean_internal_temp_annual_avg_c", # ws (85)-(93)
|
||||
]
|
||||
for k in keys:
|
||||
if k in im:
|
||||
print(f" {k} = {round(im[k], 4)}")
|
||||
print(
|
||||
"\nworksheet targets: (98) space heat=11357.24 | (35) TMP=250 |"
|
||||
" (25)m eff ach 0.46-0.64 | (20) shelter=0.85 | (19) sheltered sides=2"
|
||||
)
|
||||
print(f"\nSAP cont = {res.sap_score_continuous:.4f} | ws 35.3094 | "
|
||||
f"Δ = {res.sap_score_continuous - 35.3094:+.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -67,6 +67,7 @@ def test_electric_storage_dwelling_yields_an_hhr_storage_bundle() -> None:
|
|||
cylinder_insulation_type=1,
|
||||
cylinder_insulation_thickness_mm=120,
|
||||
cylinder_thermostat="Y",
|
||||
immersion_heating_type=1,
|
||||
has_hot_water_cylinder=True,
|
||||
meter_type="Dual",
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -224,8 +224,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
cert_number="0300-2747-7640-2526-2135",
|
||||
actual_sap=78,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=+0.9264,
|
||||
expected_co2_resid_tonnes_per_yr=+0.2495,
|
||||
expected_pe_resid_kwh_per_m2=-0.1033,
|
||||
expected_co2_resid_tonnes_per_yr=+0.1488,
|
||||
notes=(
|
||||
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
|
||||
"(no Table 4b code). Cert lodges open_flues_count=1 + "
|
||||
|
|
@ -245,7 +245,11 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
|||
"CO2 / PE factors through the cert's mains-gas "
|
||||
"`secondary_fuel_type` (mirroring the cost-side Slice 58 "
|
||||
"fix), closing PE +8.28 → +0.93 and CO2 −0.25 → +0.25 — "
|
||||
"second-biggest cohort PE closure to date."
|
||||
"second-biggest cohort PE closure to date. Slice S0380.xx "
|
||||
"(SAP 10.2 Appendix J step 1a — Noutlets includes electric "
|
||||
"showers) halved this cert's mixer-shower HW (1 mixer + 1 "
|
||||
"electric → /2 not /1), removing the double-counted shower "
|
||||
"demand: PE +0.93 → -0.10, CO2 +0.25 → +0.15 (both toward 0)."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
|
|
@ -983,6 +987,31 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number(
|
|||
assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3
|
||||
|
||||
|
||||
def test_api_mapper_preserves_mechanical_ventilation_pcdb_index_for_mev_fan_energy() -> None:
|
||||
# Arrange — cert 1300 is a decentralised-MEV dwelling
|
||||
# (mechanical_ventilation=2) lodging a PCDB index (500777) + duct type
|
||||
# (1=Flexible). The API path previously DROPPED
|
||||
# `mechanical_ventilation_index_number`, so the §5 Table 4f line (230a)
|
||||
# MEV fan electricity (`SFPav × 1.22 × V`, PCDB Tables 322/329)
|
||||
# short-circuited to 0 in `_mev_decentralised_kwh_per_yr_from_cert` —
|
||||
# leaving the fan running cost off the bill (+0.9 SAP over-rate residual
|
||||
# on the MEV cohort once the §2 ventilation heat loss was fixed).
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
_mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage]
|
||||
)
|
||||
|
||||
doc = _load_cert("1300-7634-0922-3203-3563")
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Assert — the PCDB pointer + duct type survive the API mapping, and the
|
||||
# (230a) MEV fan electricity resolves to a positive contribution.
|
||||
assert epc.mechanical_ventilation_index_number == 500777
|
||||
assert epc.mechanical_vent_duct_type == 1
|
||||
assert _mev_decentralised_kwh_per_yr_from_cert(epc) > 0.0
|
||||
|
||||
|
||||
def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None:
|
||||
"""Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP
|
||||
API code for a roof window ("Roof of Room" rooflight / inclined
|
||||
|
|
|
|||
110
tests/domain/sap10_calculator/test_table_13.py
Normal file
110
tests/domain/sap10_calculator/test_table_13.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""SAP 10.2 Table 13 — high-rate fraction for electric DHW heating.
|
||||
|
||||
Locks `electric_dhw_high_rate_fraction` against the published table grid
|
||||
and the Note-2 clamp at
|
||||
`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`,
|
||||
page 197. Table 12a's "Immersion water heater" row (PDF p.191) routes
|
||||
electric immersion DHW here.
|
||||
|
||||
The helper evaluates the Note-2 equations, which the spec offers as an
|
||||
exact alternative to the rounded grid — the pins below check that the
|
||||
equations reproduce the published 2-dp cells.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
from domain.sap10_calculator.tables.table_13 import (
|
||||
electric_dhw_high_rate_fraction,
|
||||
)
|
||||
|
||||
# Appendix J Table 1b occupancy N at a few total floor areas (m²) — the
|
||||
# anchor for the V/N grid cells below. Computed from the same piecewise
|
||||
# formula the §4 worksheet uses (water_heating.assumed_occupancy).
|
||||
_N_AT_TFA_100 = 2.7395 # N(100 m²)
|
||||
_N_AT_TFA_60 = 1.9816 # N(60 m²)
|
||||
|
||||
# Table 13 high-rate-fraction grid cells (PDF p.197), keyed by (floor
|
||||
# area row, cylinder litres column, tariff, single?) → published value.
|
||||
_GRID_TOL = 0.005 # the published grid is rounded to 2 dp
|
||||
|
||||
|
||||
def test_table_13_dual_immersion_matches_published_grid() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 (PDF p.197), 110 L cylinder, dual
|
||||
# immersion. Floor area 100 m² row: 7-hour = 0.18, 10-hour = 0.10.
|
||||
|
||||
# Act
|
||||
seven = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.SEVEN_HOUR,
|
||||
)
|
||||
ten = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(seven - 0.18) <= _GRID_TOL
|
||||
assert abs(ten - 0.10) <= _GRID_TOL
|
||||
|
||||
|
||||
def test_table_13_single_immersion_matches_published_grid() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 (PDF p.197), 110 L cylinder, single
|
||||
# immersion. Floor area 100 m² row: 7-hour = 0.61, 10-hour = 0.23.
|
||||
# Single immersion carries a much larger high-rate fraction than dual.
|
||||
|
||||
# Act
|
||||
seven = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=True, tariff=Tariff.SEVEN_HOUR,
|
||||
)
|
||||
ten = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=True, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert abs(seven - 0.61) <= _GRID_TOL
|
||||
assert abs(ten - 0.23) <= _GRID_TOL
|
||||
|
||||
|
||||
def test_table_13_large_cylinder_single_immersion_clamps_to_zero() -> None:
|
||||
# Arrange — SAP 10.2 Table 13 Note 2 (PDF p.197): "If these formulae
|
||||
# give a value less than zero, set the high-rate fraction to zero." A
|
||||
# 210 L cylinder with single immersion on a 10-hour tariff falls below
|
||||
# zero (the published 210 L 10-hour column is 0), so the helper clamps.
|
||||
|
||||
# Act
|
||||
fraction = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=210.0, occupancy_n=_N_AT_TFA_60,
|
||||
single_immersion=True, tariff=Tariff.TEN_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert fraction == 0.0
|
||||
|
||||
|
||||
def test_table_13_eighteen_and_twenty_four_hour_bill_full_low_rate() -> None:
|
||||
# Arrange — SAP 10.2 Table 12a (PDF p.191) is titled "High-rate
|
||||
# fractions for systems using 7-hour and 10-hour tariffs"; its
|
||||
# "Immersion water heater" row lists the tariff as "7-hour or 10-hour"
|
||||
# only, routing to Table 13. An 18-hour / 24-hour tariff is OUTSIDE the
|
||||
# table's scope: it provides at least 18 hours/day at the low rate, more
|
||||
# than enough to heat any immersion cylinder off-peak, so the high-rate
|
||||
# fraction is 0 (100% billed at the low rate). The Elmhurst dr87
|
||||
# worksheet for solid fuel 5 (cert 001431: 18-hour meter, 110 L dual
|
||||
# immersion, WHC 903) bills HW (245) high-rate = 0.0 kWh, (246) low-rate
|
||||
# = 100% — confirming high_frac = 0 for an 18-hour immersion DHW.
|
||||
|
||||
# Act
|
||||
eighteen = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.EIGHTEEN_HOUR,
|
||||
)
|
||||
twenty_four = electric_dhw_high_rate_fraction(
|
||||
cylinder_volume_l=110.0, occupancy_n=_N_AT_TFA_100,
|
||||
single_immersion=False, tariff=Tariff.TWENTY_FOUR_HOUR,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert eighteen == 0.0
|
||||
assert twenty_four == 0.0
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"""Mapper-driven cascade pin against the Elmhurst P960-0001-001431
|
||||
"simulated case 38" worksheet — a mains-gas dwelling with a code-117
|
||||
regular boiler (1979-1997, winter 66%), control 2102 (programmer, no room
|
||||
thermostat → −5pp interlock → (206)=61%), and a **mains-gas condensing gas
|
||||
fire secondary** (SAP code 611).
|
||||
|
||||
This is the realistic re-generation of "simulated case 37": case 37 lodged
|
||||
the same dwelling's code-605 gas fire on BIOGAS (7.60 p/kWh), which the
|
||||
Elmhurst Summary export cannot carry (it lodges only the secondary SAP
|
||||
code, not its sub-fuel — see `_elmhurst_secondary_fuel_from_sap_code`), so
|
||||
the mains-gas modal default left a +7 SAP gap that was purely the biogas
|
||||
sub-fuel. With a mains-gas secondary the whole cascade reproduces the
|
||||
worksheet EXACTLY, confirming the boiler-efficiency / control-2102 /
|
||||
secondary handling is all correct.
|
||||
|
||||
Like 000565 / the _rr cases / case 20 / 21, this fixture does NOT hand-
|
||||
build the EpcPropertyData: it routes the Summary PDF through
|
||||
ElmhurstSiteNotesExtractor + from_elmhurst_site_notes so the pin exercises
|
||||
the WHOLE extractor + mapper + calculator pipeline.
|
||||
|
||||
Source: user-simulated PDFs at `sap worksheets/golden fixture debugging/
|
||||
simulated case 38/`. The Summary is mirrored into the tracked
|
||||
`backend/documents_parser/tests/fixtures/Summary_001431_case38.pdf` so the
|
||||
test runs without depending on the unstaged workspace.
|
||||
|
||||
Worksheet pin targets (P960-0001-001431, "11a. SAP rating" block):
|
||||
- SAP value (un-rounded, before (258) integer rounding) = 60.9152
|
||||
- (272) Total CO2, kg/year = 5801.0770
|
||||
|
||||
Per [[feedback-zero-error-strict]] + [[feedback-continuous-sap-tolerance]]:
|
||||
pins are abs <= 1e-3 against the worksheet PDF (the worksheet prints the
|
||||
SAP value to 4 dp).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
|
||||
|
||||
# parents[0]=worksheet/, [1]=sap10_calculator/, [2]=domain/, [3]=tests/,
|
||||
# [4]=repo root.
|
||||
_SUMMARY_PDF: Final[Path] = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "backend" / "documents_parser" / "tests" / "fixtures"
|
||||
/ "Summary_001431_case38.pdf"
|
||||
)
|
||||
|
||||
LINE_258_SAP_VALUE_CONTINUOUS: Final[float] = 60.9152
|
||||
LINE_272_TOTAL_CO2_KG_PER_YR: Final[float] = 5801.0770
|
||||
_PIN_ABS: Final[float] = 1e-3
|
||||
|
||||
|
||||
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
|
||||
"""Convert a Summary PDF into the per-page text format the
|
||||
ElmhurstSiteNotesExtractor expects (label/value token sequences).
|
||||
Mirror of the helper in the other `_elmhurst_worksheet_*` fixtures.
|
||||
"""
|
||||
info = subprocess.run(
|
||||
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
m = re.search(r"Pages:\s+(\d+)", info)
|
||||
if m is None:
|
||||
raise RuntimeError(f"Could not parse page count from {pdf_path}")
|
||||
page_count = int(m.group(1))
|
||||
pages: list[str] = []
|
||||
for i in range(1, page_count + 1):
|
||||
layout = subprocess.run(
|
||||
[
|
||||
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
|
||||
str(pdf_path), "-",
|
||||
],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
tokens: list[str] = []
|
||||
for line in layout.splitlines():
|
||||
if not line.strip():
|
||||
tokens.append("")
|
||||
continue
|
||||
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
|
||||
tokens.extend(parts)
|
||||
pages.append("\n".join(tokens))
|
||||
return pages
|
||||
|
||||
|
||||
def build_epc() -> EpcPropertyData:
|
||||
"""Route the simulated case-38 Summary through extractor + mapper.
|
||||
No hand-built EpcPropertyData — the extractor and mapper are part of
|
||||
the test target."""
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
return EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
|
||||
def test_case38_mains_gas_secondary_reproduces_the_worksheet_sap_and_co2() -> None:
|
||||
# Arrange — the full extractor -> mapper -> calculator pipeline on the
|
||||
# simulated case-38 Summary (mains-gas boiler 117 + mains-gas
|
||||
# condensing gas-fire secondary 611).
|
||||
epc = build_epc()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(cert_to_inputs(epc))
|
||||
|
||||
# Assert — the SAP-rating block reproduces the worksheet exactly.
|
||||
assert (
|
||||
abs(result.sap_score_continuous - LINE_258_SAP_VALUE_CONTINUOUS)
|
||||
<= _PIN_ABS
|
||||
)
|
||||
assert abs(result.co2_kg_per_yr - LINE_272_TOTAL_CO2_KG_PER_YR) <= _PIN_ABS
|
||||
|
|
@ -36,6 +36,7 @@ from domain.sap10_calculator.worksheet.heat_transmission import (
|
|||
heat_transmission_from_cert,
|
||||
)
|
||||
from domain.sap10_calculator.worksheet.heat_transmission import (
|
||||
_alt_wall_w_per_k, # pyright: ignore[reportPrivateUsage]
|
||||
_joined_main_roof_descriptions, # pyright: ignore[reportPrivateUsage]
|
||||
_part_geometry, # pyright: ignore[reportPrivateUsage]
|
||||
_round_half_up, # pyright: ignore[reportPrivateUsage]
|
||||
|
|
@ -150,6 +151,112 @@ def test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_section_5_11_4()
|
|||
assert result.roof_w_per_k == pytest.approx(68.0, abs=2.0)
|
||||
|
||||
|
||||
def test_lodged_roof_u_value_overrides_construction_default() -> None:
|
||||
# Arrange — RdSAP 10 §5.1: where an element's U-value is known from the
|
||||
# assessment (documentary evidence / the lodged RdSAP output) it is used
|
||||
# directly in place of the §5.11 construction-default cascade. The gov-EPC
|
||||
# API surfaces the assessor's roof U as `roof_u_value`; the gov open data
|
||||
# can redact the backing insulation thickness, leaving the §5.11 cascade
|
||||
# to mis-derive an uninsulated U. Cert 7921-0052-0940-5007-0663 lodges a
|
||||
# "Pitched, sloping ceiling" (rc=8) age-C roof with no thickness — the
|
||||
# cascade returns the uninsulated 2.30 W/m²K (→ -23 SAP) where the lodged
|
||||
# roof_u_value is 0.2. Geometry: 100 m² plan → sloped roof area =
|
||||
# 100 / cos(30°) = 115.47 m². roof_w_per_k must follow the lodged 0.2
|
||||
# (0.2 × 115.47 = 23.094 W/K), NOT the 2.30 × 115.47 = 265 W/K default.
|
||||
main = make_building_part(
|
||||
construction_age_band="C",
|
||||
wall_construction=3,
|
||||
wall_insulation_type=4,
|
||||
party_wall_construction=1,
|
||||
roof_construction=8,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=100.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
main.roof_construction_type = "Pitched, sloping ceiling"
|
||||
main.roof_u_value = 0.2
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=100.0,
|
||||
country_code="ENG",
|
||||
sap_building_parts=[main],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = heat_transmission_from_cert(epc)
|
||||
|
||||
# Assert — 0.2 × (100 / cos(30°)) = 0.2 × 115.470 = 23.094 W/K.
|
||||
assert abs(result.roof_w_per_k - 23.094) <= 1e-3
|
||||
|
||||
|
||||
def test_lodged_wall_u_value_overrides_construction_default() -> None:
|
||||
# Arrange — RdSAP 10 §5.1: a lodged main-wall U-value (the gov-EPC API
|
||||
# `wall_u_value`, the assessor's RdSAP output) overrides the §5.6/§5.7
|
||||
# construction-default cascade. Cohort certs 2021/7505 lodge solid-brick
|
||||
# (rc=3) walls with the open data's insulation redacted, where the lodged
|
||||
# wall_u_value (0.34) is far below the cascade's uninsulated default → the
|
||||
# cascade under-rates by ~-2.6 SAP. Geometry chosen so the gross wall area
|
||||
# (no openings) is 80 m²: net wall U×A must follow 0.34, NOT the default.
|
||||
main = make_building_part(
|
||||
construction_age_band="C",
|
||||
wall_construction=3,
|
||||
wall_insulation_type=4,
|
||||
party_wall_construction=1,
|
||||
roof_construction=4,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=100.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
main.wall_u_value = 0.34
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=100.0,
|
||||
country_code="ENG",
|
||||
sap_building_parts=[main],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = heat_transmission_from_cert(epc)
|
||||
|
||||
# Assert — gross wall = perimeter 40 m × height 2.5 m = 100 m²; no windows
|
||||
# or doors in this minimal cert → net wall area 100 m². 0.34 × 100 = 34.0.
|
||||
assert abs(result.walls_w_per_k - 34.0) <= 1e-6
|
||||
|
||||
|
||||
def test_lodged_floor_u_value_overrides_iso_13370_cascade() -> None:
|
||||
# Arrange — RdSAP 10 §5.1: a lodged ground-floor U-value (the gov-EPC API
|
||||
# `floor_u_value`) overrides the BS EN ISO 13370 / Table 19 cascade.
|
||||
main = make_building_part(
|
||||
construction_age_band="C",
|
||||
wall_construction=3,
|
||||
wall_insulation_type=4,
|
||||
party_wall_construction=1,
|
||||
roof_construction=4,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=100.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=40.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
main.floor_u_value = 0.12
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=100.0,
|
||||
country_code="ENG",
|
||||
sap_building_parts=[main],
|
||||
)
|
||||
|
||||
# Act
|
||||
result = heat_transmission_from_cert(epc)
|
||||
|
||||
# Assert — 0.12 × 100 m² floor = 12.0 W/K.
|
||||
assert abs(result.floor_w_per_k - 12.0) <= 1e-6
|
||||
|
||||
|
||||
def test_exposed_timber_floor_age_b_uses_table_20_u_120_not_iso_13370() -> None:
|
||||
# Arrange — RdSAP10 §5.13 Table 20: a part whose lowest floor sits
|
||||
# over outside air (or unheated space) rather than soil takes its
|
||||
|
|
@ -327,16 +434,22 @@ def test_solid_brick_as_built_no_insulation_assumed_stays_at_table6_as_built_row
|
|||
assert result.walls_w_per_k == pytest.approx(170.0, abs=1.0)
|
||||
|
||||
|
||||
def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None:
|
||||
# Arrange — the modal RdSAP encoding for a retrofitted-cavity dwelling:
|
||||
# wall_construction=4 (cavity), wall_insulation_type=4 (as-built /
|
||||
# assumed), and walls[0].description = "Cavity wall, as built,
|
||||
# insulated (assumed)". The assessor has determined the cavity is
|
||||
# filled but hasn't lodged a thickness. Without the description-based
|
||||
# dispatcher, the cascade would return U=1.5; with it, the Filled-
|
||||
# cavity row of Table 6 applies: U=0.7 at band E.
|
||||
def test_cavity_as_built_insulated_assumed_uses_as_built_row() -> None:
|
||||
# Arrange — wall_construction=4 (cavity), wall_insulation_type=4
|
||||
# (as-built / assumed), walls[0].description = "Cavity wall, as built,
|
||||
# insulated (assumed)". This is the AS-BUILT state, not a retrofit fill:
|
||||
# per RdSAP 10 Table 6 (England) the "Filled cavity" row's † footnote
|
||||
# ("assumed as built") applies only at bands I-M, where it coincides
|
||||
# with "Cavity as built"; at bands A-H the filled row is for a genuine
|
||||
# fill. So band E uses the "Cavity as built" row U=1.5, NOT filled 0.7.
|
||||
#
|
||||
# Prior code special-cased the "insulated" adjective to the filled row
|
||||
# (legacy convention); the API SAP-accuracy cohort over-rated band-G/H
|
||||
# "insulated (assumed)" cavities by +1.4 / +1.6 SAP median (filled 0.35
|
||||
# vs as-built 0.60). A genuine fill renders the distinct "Cavity wall,
|
||||
# filled cavity" (wall_insulation_type=2), caught separately.
|
||||
# Geometry: 100 m² floor, 40 m perimeter, 2.5 m height, single storey
|
||||
# → gross_wall = 100 m². walls_w_per_k expected = 0.7 × 100 = 70 W/K.
|
||||
# → gross_wall = 100 m². walls_w_per_k expected = 1.5 × 100 = 150 W/K.
|
||||
main = make_building_part(
|
||||
identifier="Main Dwelling",
|
||||
construction_age_band="E",
|
||||
|
|
@ -367,8 +480,8 @@ def test_cavity_as_built_insulated_assumed_uses_filled_cavity_row() -> None:
|
|||
# Act
|
||||
result = heat_transmission_from_cert(epc)
|
||||
|
||||
# Assert
|
||||
assert result.walls_w_per_k == pytest.approx(70.0, abs=1.0)
|
||||
# Assert — Cavity-as-built row at band E = 1.5 W/m²K (not filled 0.7).
|
||||
assert result.walls_w_per_k == pytest.approx(150.0, abs=1.0)
|
||||
|
||||
|
||||
def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None:
|
||||
|
|
@ -381,8 +494,8 @@ def test_cavity_as_built_partial_insulation_assumed_uses_as_built_row() -> None:
|
|||
# RdSAP 10 Table 6 (England) "Cavity as built" band F = 1.0 vs
|
||||
# "Filled cavity" band F = 0.40. A genuine fill renders the distinct
|
||||
# "Cavity wall, filled cavity" description (wall_insulation_type=2),
|
||||
# caught separately. Contrast the "insulated (assumed)" variant above,
|
||||
# which the assessor judges as filled.
|
||||
# caught separately. The "insulated (assumed)" variant above now routes
|
||||
# to the same as-built row (all as-built adjectives coincide at A-H).
|
||||
#
|
||||
# Real-cert evidence: golden cert 0390-2954-3640 (detached, band F,
|
||||
# cavity type 4, "partial insulation (assumed)") closes all four SAP
|
||||
|
|
@ -1187,6 +1300,131 @@ def test_alternative_wall_uses_own_construction_and_deducts_from_main_wall_area(
|
|||
)
|
||||
|
||||
|
||||
def test_sheltered_alternative_wall_applies_table4_0p5_resistance() -> None:
|
||||
# Arrange — RdSAP 10 Table 4 (PDF p.22) "Sheltered" wall: a sub-area
|
||||
# adjacent to an unheated buffer carries an added external surface
|
||||
# resistance R=0.5 m²K/W, so its U reduces to 1/(1/U + 0.5). The gov
|
||||
# EPC API lodges this per alt-wall as `sheltered_wall="Y"` (mapped to
|
||||
# `is_sheltered`). Two identical timber-frame as-built alt sub-areas
|
||||
# (cavity-band-A → uninsulated cascade U), one exposed, one sheltered.
|
||||
from dataclasses import replace
|
||||
|
||||
from domain.sap10_ml.rdsap_uvalues import Country
|
||||
|
||||
area = 42.0
|
||||
exposed = SapAlternativeWall(
|
||||
wall_area=area, wall_dry_lined="N", wall_construction=5,
|
||||
wall_insulation_type=4, wall_thickness_measured="Y",
|
||||
wall_insulation_thickness="NI", is_sheltered=False,
|
||||
)
|
||||
sheltered = replace(exposed, is_sheltered=True)
|
||||
|
||||
# Act
|
||||
exposed_wpk = _alt_wall_w_per_k(
|
||||
alt_wall=exposed, country=Country.ENG, age_band="A", wall_description=None,
|
||||
)
|
||||
sheltered_wpk = _alt_wall_w_per_k(
|
||||
alt_wall=sheltered, country=Country.ENG, age_band="A", wall_description=None,
|
||||
)
|
||||
|
||||
# Assert — the sheltered U is the exposed U with R=0.5 added.
|
||||
exposed_u = exposed_wpk / area
|
||||
expected_sheltered_u = 1.0 / (1.0 / exposed_u + 0.5)
|
||||
assert abs(sheltered_wpk - expected_sheltered_u * area) <= 1e-9
|
||||
assert sheltered_wpk < exposed_wpk
|
||||
|
||||
|
||||
def test_corridor_door_on_sheltered_alt_wall_uses_table26_u_1p4() -> None:
|
||||
# Arrange — RdSAP 10 Table 26 (PDF p.51): a door opening to an unheated
|
||||
# corridor/stairwell has U=1.4 (any age), versus 3.0 for an external
|
||||
# door (age A-J). RdSAP §3.7 (p.18): "the door of a flat/maisonette to
|
||||
# an unheated stairwell or corridor ... is deducted from the sheltered
|
||||
# wall area" — i.e. the corridor door sits on the sheltered alt wall,
|
||||
# not the main wall. Simulated case 34: a flat with a sheltered alt
|
||||
# (corridor) wall + 2 doors → 1 corridor door (U=1.4 on the alt wall) +
|
||||
# 1 external door (U=3.0 on the main wall).
|
||||
from dataclasses import replace
|
||||
|
||||
main = replace(
|
||||
make_building_part(
|
||||
construction_age_band="B",
|
||||
wall_construction=4, wall_insulation_type=4,
|
||||
party_wall_construction=1, roof_construction=4,
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0,
|
||||
),
|
||||
],
|
||||
),
|
||||
sap_alternative_wall_1=SapAlternativeWall(
|
||||
wall_area=12.5, wall_dry_lined="N", wall_construction=4,
|
||||
wall_insulation_type=4, wall_thickness_measured="Y",
|
||||
wall_insulation_thickness="NI", is_sheltered=True,
|
||||
),
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[main],
|
||||
)
|
||||
|
||||
# Act
|
||||
no_corridor = heat_transmission_from_cert(epc, door_count=2, corridor_door_count=0)
|
||||
with_corridor = heat_transmission_from_cert(epc, door_count=2, corridor_door_count=1)
|
||||
|
||||
# Assert — no-corridor: both doors external at the age-default U.
|
||||
door_u = no_corridor.doors_w_per_k / (2 * 1.85)
|
||||
# with-corridor: 1 external @door_u + 1 corridor @1.4 (both 1.85 m²).
|
||||
assert abs(with_corridor.doors_w_per_k - (1.85 * door_u + 1.85 * 1.4)) <= 0.02
|
||||
# The corridor door (U=1.4) is cheaper than an external door (U≈3.0) and
|
||||
# its area moves off the main wall onto the sheltered alt wall, so the
|
||||
# net fabric heat loss drops.
|
||||
assert with_corridor.fabric_heat_loss_w_per_k < no_corridor.fabric_heat_loss_w_per_k
|
||||
|
||||
|
||||
def test_main_wall_dry_lining_applies_table_14_resistance() -> None:
|
||||
# Arrange — RdSAP 10 §5.7/§5.8 (PDF p.40-41), Table 14: a dry-lined
|
||||
# (including lath-and-plaster) uninsulated wall adds R=0.17 m²K/W:
|
||||
# U_adj = 1/(1/U₀ + 0.17). A solid-brick (construction 3) age-A wall
|
||||
# with a measured 230 mm thickness has U₀=1.70 (Table 13, 200-280 mm
|
||||
# band) → dry-lined U=1/(1/1.70+0.17)=1.32 (2 d.p.). The alt-wall path
|
||||
# already applies this; the MAIN wall dropped the `dry_lined` kwarg, so
|
||||
# every lodged `wall_dry_lined=Y` main wall was billed at the un-adjusted
|
||||
# U — under-rating solid-brick stock (API wall_construction=3 cohort:
|
||||
# 48 dry-lined certs at 10% within-0.5, signed -1.33).
|
||||
from dataclasses import replace
|
||||
|
||||
base_part = make_building_part(
|
||||
construction_age_band="A",
|
||||
wall_construction=3, # solid brick
|
||||
wall_insulation_type=0, # uninsulated (as-built)
|
||||
floor_dimensions=[
|
||||
make_floor_dimension(
|
||||
total_floor_area_m2=50.0, room_height_m=2.5,
|
||||
party_wall_length_m=0.0, heat_loss_perimeter_m=28.0, floor=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
not_dry = replace(base_part, wall_thickness_mm=230, wall_dry_lined=False)
|
||||
dry = replace(base_part, wall_thickness_mm=230, wall_dry_lined=True)
|
||||
epc_not_dry = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[not_dry],
|
||||
)
|
||||
epc_dry = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=50.0, country_code="ENG", sap_building_parts=[dry],
|
||||
)
|
||||
|
||||
# Act
|
||||
ht_not_dry = heat_transmission_from_cert(epc_not_dry, door_count=0)
|
||||
ht_dry = heat_transmission_from_cert(epc_dry, door_count=0)
|
||||
|
||||
# Assert — same net wall area, so the W/K ratio is the U ratio: the
|
||||
# dry-lined wall is 1.32/1.70 = 0.776× the as-built wall.
|
||||
assert ht_not_dry.walls_w_per_k > 0.0
|
||||
expected_ratio = 1.32 / 1.70
|
||||
assert abs(ht_dry.walls_w_per_k / ht_not_dry.walls_w_per_k - expected_ratio) <= 0.005
|
||||
assert ht_dry.fabric_heat_loss_w_per_k < ht_not_dry.fabric_heat_loss_w_per_k
|
||||
|
||||
|
||||
def test_window_uses_effective_u_value_with_curtain_resistance_per_sap10_2_section_3_2() -> None:
|
||||
"""SAP10.2 §3.2: the window U-value used for heat-transmission is the
|
||||
effective form `U_eff = 1/(1/U_raw + 0.04)` — the 0.04 m²K/W is the
|
||||
|
|
|
|||
|
|
@ -344,6 +344,34 @@ def test_hot_water_mixer_showers_two_outlets_distributes_shower_count_evenly() -
|
|||
assert t == pytest.approx(s, abs=1e-9), f"month {m+1}"
|
||||
|
||||
|
||||
def test_hot_water_mixer_showers_counts_electric_showers_in_noutlets() -> None:
|
||||
# Arrange — SAP 10.2 Appendix J step 1a (PDF p.~): "Establish how many
|
||||
# shower outlets are present in the dwelling, Noutlets (INCLUDING in the
|
||||
# count any instantaneous electric showers)". The mixer (42a) demand
|
||||
# divides N_shower by this TOTAL outlet count, so a coexisting electric
|
||||
# shower reduces each mixer outlet's share. Adding one electric shower to
|
||||
# a single-mixer dwelling makes Noutlets=2 → the mixer demand halves
|
||||
# (vs. counting only the mixer outlet, which over-counts main HW and
|
||||
# under-rates the dwelling).
|
||||
mixer_only = hot_water_mixer_showers_monthly_l_per_day(
|
||||
n_occupants=2.0, has_bath=True,
|
||||
mixer_shower_flow_rates_l_per_min=(8.0,),
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
)
|
||||
|
||||
# Act — same single mixer outlet, but one electric shower also present.
|
||||
with_electric = hot_water_mixer_showers_monthly_l_per_day(
|
||||
n_occupants=2.0, has_bath=True,
|
||||
mixer_shower_flow_rates_l_per_min=(8.0,),
|
||||
cold_water_temps_c=TABLE_J1_TCOLD_FROM_MAINS_C,
|
||||
n_electric_showers=1,
|
||||
)
|
||||
|
||||
# Assert — N_shower / 2 instead of / 1 → exactly half the mixer demand.
|
||||
for m, (only, both) in enumerate(zip(mixer_only, with_electric)):
|
||||
assert abs(both - only / 2.0) <= 1e-9, f"month {m+1}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ALL_FIXTURES, ids=[fixture_id(f) for f in ALL_FIXTURES])
|
||||
def test_total_hot_water_monthly_matches_elmhurst_line_44(fixture: ModuleType) -> None:
|
||||
"""SAP10.2 §4 line (44)m via Appendix J equation J13:
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ def test_run_one_returns_a_plan_and_prints_the_table(
|
|||
assert len(plan.measures) >= 1
|
||||
printed: str = capsys.readouterr().out
|
||||
assert "Plan SAP" in printed
|
||||
assert "cavity_wall_insulation" in printed
|
||||
assert "air_source_heat_pump" in printed
|
||||
|
||||
|
||||
def test_run_modelling_inspects_a_plan_without_baseline_or_lodged_performance() -> None:
|
||||
|
|
|
|||
|
|
@ -80,35 +80,28 @@ def test_each_fired_measure_carries_the_attributes_that_triggered_it() -> None:
|
|||
# Assert — the Plan ran and every fired measure names its trigger fields.
|
||||
assert report.plan is not None
|
||||
assert report.plan_error is None
|
||||
# This gas dwelling lodges an electric secondary heater (SAP 691) on a
|
||||
# category-2 main, so secondary-heating removal (ADR-0028) is a very cheap
|
||||
# SAP lever (\£250); the Optimiser reaches the target band via the fabric
|
||||
# stack + that removal, leaving the \£12k ASHP unselected (it owns the
|
||||
# economics — ADR-0024).
|
||||
# The gain-maximising package: the efficient representative ASHP (ADR-0025)
|
||||
# plus solid-floor insulation. The cavity wall + its forced mechanical
|
||||
# ventilation (ADR-0016) are NOT selected — the wall earns +SAP alone but
|
||||
# the forced-ventilation penalty makes the pair net-negative, so the
|
||||
# Optimiser correctly leaves them out (see test_measure_dependency /
|
||||
# test_optimiser for the forced-edge unit coverage; cavity_wall +
|
||||
# mechanical_ventilation trigger fields are exercised in
|
||||
# test_cavity_wall_recommendation / the ventilation generator tests).
|
||||
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
|
||||
assert set(triggers) == {
|
||||
"cavity_wall_insulation",
|
||||
"mechanical_ventilation",
|
||||
"solid_floor_insulation",
|
||||
"secondary_heating_removal",
|
||||
}
|
||||
# Cavity-fill fired off an uninsulated cavity wall; its dependent MEV fired
|
||||
# because no mechanical ventilation is lodged.
|
||||
assert triggers["cavity_wall_insulation"].triggers == {
|
||||
"wall_construction": 4,
|
||||
"wall_insulation_type": 4,
|
||||
}
|
||||
assert triggers["mechanical_ventilation"].triggers == {
|
||||
"mechanical_ventilation_kind": None,
|
||||
"air_source_heat_pump",
|
||||
}
|
||||
# Solid-floor insulation fired off an uninsulated solid ground floor.
|
||||
assert triggers["solid_floor_insulation"].triggers == {
|
||||
"floor_insulation_thickness": None,
|
||||
"floor_construction_type": "Solid",
|
||||
}
|
||||
# Secondary-heating removal fired off the lodged secondary (SAP code 691).
|
||||
assert triggers["secondary_heating_removal"].triggers == {
|
||||
"secondary_heating_type": 691,
|
||||
# The ASHP bundle fired off the gas-dwelling main it replaces.
|
||||
assert triggers["air_source_heat_pump"].triggers == {
|
||||
"property_type": "0",
|
||||
"main_heating_category": 2,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -174,18 +167,20 @@ def test_few_measure_cert_surfaces_only_its_fired_measures_triggers() -> None:
|
|||
# Act
|
||||
report: PropertyReport = build_property_report(path)
|
||||
|
||||
# Assert — 0036 reaches the target band with solid-floor insulation plus
|
||||
# secondary-heating removal (it lodges an electric secondary, SAP 691, on a
|
||||
# gas main — a cheap SAP lever, ADR-0028), and nothing else. The cheaper-to-
|
||||
# target pair displaces the LED upgrade the Optimiser used to add.
|
||||
# Assert — 0036's gain-maximising package is solid-floor insulation plus the
|
||||
# low-energy-lighting upgrade (it lodges 7 low-energy + 0 incandescent fixed
|
||||
# bulbs, so the LED top-up is a cheap positive-SAP lever, ADR-0023), and
|
||||
# nothing else.
|
||||
triggers: dict[str, MeasureTrigger] = _triggers_by_measure(report)
|
||||
assert set(triggers) == {"solid_floor_insulation", "secondary_heating_removal"}
|
||||
assert set(triggers) == {"solid_floor_insulation", "low_energy_lighting"}
|
||||
assert triggers["solid_floor_insulation"].triggers == {
|
||||
"floor_insulation_thickness": None,
|
||||
"floor_construction_type": "Solid",
|
||||
}
|
||||
assert triggers["secondary_heating_removal"].triggers == {
|
||||
"secondary_heating_type": 691,
|
||||
assert triggers["low_energy_lighting"].triggers == {
|
||||
"incandescent_fixed_lighting_bulbs_count": 0,
|
||||
"cfl_fixed_lighting_bulbs_count": None,
|
||||
"low_energy_fixed_lighting_bulbs_count": 7,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,165 @@
|
|||
"""Regression pin: a PV battery is SAP-cost-NEUTRAL on an export-capable
|
||||
STANDARD-tariff dwelling, but still moves primary energy / CO2 and DOES
|
||||
help the rating on an off-peak tariff.
|
||||
|
||||
SPEC EVIDENCE — the user-simulated Elmhurst P960 worksheet pair at
|
||||
`sap worksheets/golden fixture debugging/simulated case 35 (without
|
||||
battery)` and `simulated case 36 (with battery)` (cert 001431, 5 kWh,
|
||||
export-capable, mains-gas, standard tariff). The official SAP rating uses
|
||||
"10a. Fuel costs - using Table 12 prices", where PV used-in-dwelling and
|
||||
PV exported are BOTH priced at 13.19 p/kWh (export code 60 == import code
|
||||
30, ADR-0010). A battery only redistributes PV between those two equally-
|
||||
priced lines (worksheet split 1128/2326 -> 1951/1504 kWh), so the PV
|
||||
credit (252) = -455.6458 and the SAP (258) = 88.0859 are IDENTICAL with
|
||||
and without the battery: ΔSAP = exactly 0. The battery DOES move the EPC's
|
||||
consumer £-bill (a SEPARATE "10a ... using BEDF prices (595)" block at
|
||||
import 27.67 / export 5.81, £696 -> £515/yr) — but that block never feeds
|
||||
(258). It also moves CO2 (272) / PE (286), since used vs exported carry
|
||||
different factors.
|
||||
|
||||
This guards two things against silent regression:
|
||||
1. The export price == import price (13.19) that makes the credit split-
|
||||
invariant on standard tariff — if someone changes table_32 code 60,
|
||||
the standard-tariff ΔSAP would stop being 0 and Test A fails.
|
||||
2. The Appendix M1 §3c battery β-split is actually wired — if a refactor
|
||||
dropped the battery, PE would stop responding (Test A's PE assertion)
|
||||
AND the off-peak benefit would vanish (Test B).
|
||||
|
||||
The β-coefficient formula itself is unit-tested in
|
||||
tests/domain/sap10_calculator/worksheet/test_photovoltaic.py; this is the
|
||||
end-to-end API-path (`from_api_response` -> cert_to_inputs -> calculator)
|
||||
counterpart over the committed RdSAP-21.0.1 corpus.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_inputs,
|
||||
)
|
||||
|
||||
_CORPUS = Path("backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl")
|
||||
|
||||
# RdSAP meter_type 2 = Single (standard tariff). Anything else (1 Dual / 4
|
||||
# Dual-24h / 5 18-hour) is an off-peak tariff where self-consumed PV
|
||||
# displaces a higher unit rate than the export credit — see _rdsap_tariff.
|
||||
_STANDARD_METER_TYPE = 2
|
||||
|
||||
|
||||
def _load_corpus() -> list[dict[str, Any]]:
|
||||
if not _CORPUS.exists():
|
||||
return []
|
||||
return [
|
||||
json.loads(line)
|
||||
for line in _CORPUS.read_text().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def _energy_source(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
es = doc.get("sap_energy_source")
|
||||
return cast("dict[str, Any]", es) if isinstance(es, dict) else {}
|
||||
|
||||
|
||||
def _has_real_pv_and_battery(doc: dict[str, Any]) -> bool:
|
||||
es = _energy_source(doc)
|
||||
if not es.get("is_dwelling_export_capable"):
|
||||
return False
|
||||
if (es.get("pv_battery_count") or 0) < 1:
|
||||
return False
|
||||
pv = es.get("photovoltaic_supply")
|
||||
# A bare "no details" array / "none_or_no_details" dict carries no PV.
|
||||
if isinstance(pv, dict) and "none_or_no_details" in pv:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _sap_pe(doc: dict[str, Any]) -> tuple[float, float]:
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
return result.sap_score_continuous, result.primary_energy_kwh_per_m2
|
||||
|
||||
|
||||
def _with_battery_zeroed(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
zeroed = deepcopy(doc)
|
||||
zeroed["sap_energy_source"]["pv_battery_count"] = 0
|
||||
return zeroed
|
||||
|
||||
|
||||
def test_battery_is_sap_cost_neutral_on_export_capable_standard_tariff() -> None:
|
||||
# Arrange — every export-capable, standard-tariff (meter 2) corpus cert
|
||||
# that carries both a real PV array and a battery (the worksheet case
|
||||
# 35/36 profile: mains-gas, single-rate meter).
|
||||
corpus = _load_corpus()
|
||||
if not corpus:
|
||||
pytest.skip(f"no corpus at {_CORPUS}")
|
||||
standard_battery_certs = [
|
||||
doc
|
||||
for doc in corpus
|
||||
if _has_real_pv_and_battery(doc)
|
||||
and _energy_source(doc).get("meter_type") == _STANDARD_METER_TYPE
|
||||
]
|
||||
assert standard_battery_certs, (
|
||||
"no export-capable standard-tariff PV+battery cert in the corpus — "
|
||||
"this regression pin has nothing to guard"
|
||||
)
|
||||
|
||||
# Act / Assert — toggling the battery off leaves the continuous SAP
|
||||
# EXACTLY unchanged (export price == import price 13.19 makes the
|
||||
# onsite/export split cost-irrelevant), while at least one cert's
|
||||
# primary energy DOES respond (proving the battery is modelled, not
|
||||
# silently dropped).
|
||||
pe_deltas: list[float] = []
|
||||
for doc in standard_battery_certs:
|
||||
sap_on, pe_on = _sap_pe(doc)
|
||||
sap_off, pe_off = _sap_pe(_with_battery_zeroed(doc))
|
||||
assert abs(sap_on - sap_off) <= 1e-9, (
|
||||
"battery changed SAP on a standard-tariff export-capable cert: "
|
||||
f"{sap_on} vs {sap_off}"
|
||||
)
|
||||
pe_deltas.append(pe_on - pe_off)
|
||||
|
||||
assert min(pe_deltas) < -1e-6, (
|
||||
"no standard-tariff battery cert showed a primary-energy effect — "
|
||||
"the App-M1 §3c battery β-split may have been dropped"
|
||||
)
|
||||
|
||||
|
||||
def test_battery_improves_sap_on_export_capable_off_peak_tariff() -> None:
|
||||
# Arrange — export-capable PV+battery certs on an OFF-PEAK tariff
|
||||
# (meter_type != 2). Here self-consumed PV displaces high-rate import
|
||||
# (e.g. 7-hour high 15.29 p/kWh) which exceeds the 13.19 export credit,
|
||||
# so a battery (more self-consumption) lowers cost.
|
||||
corpus = _load_corpus()
|
||||
if not corpus:
|
||||
pytest.skip(f"no corpus at {_CORPUS}")
|
||||
off_peak_battery_certs = [
|
||||
doc
|
||||
for doc in corpus
|
||||
if _has_real_pv_and_battery(doc)
|
||||
and _energy_source(doc).get("meter_type") != _STANDARD_METER_TYPE
|
||||
]
|
||||
if not off_peak_battery_certs:
|
||||
pytest.skip("no export-capable off-peak PV+battery cert in the corpus")
|
||||
|
||||
# Act / Assert — the battery STRICTLY raises the SAP, confirming the
|
||||
# standard-tariff neutrality above is a price coincidence (import ==
|
||||
# export) and not a dropped-battery no-op.
|
||||
for doc in off_peak_battery_certs:
|
||||
sap_on, _ = _sap_pe(doc)
|
||||
sap_off, _ = _sap_pe(_with_battery_zeroed(doc))
|
||||
assert sap_on - sap_off > 1e-6, (
|
||||
"battery did not improve SAP on an off-peak export-capable cert: "
|
||||
f"{sap_on} vs {sap_off}"
|
||||
)
|
||||
155
tests/infrastructure/epc_client/test_sap_accuracy_corpus.py
Normal file
155
tests/infrastructure/epc_client/test_sap_accuracy_corpus.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""End-to-end SAP-accuracy gauge over the committed RdSAP-21.0.1 corpus.
|
||||
|
||||
Drives the full API path — raw gov-EPC response → ``from_api_response`` →
|
||||
``cert_to_inputs`` → ``calculate_sap_from_inputs`` — across all 1000 certs in
|
||||
``backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl`` and pins the
|
||||
aggregate accuracy of our continuous SAP (and CO2 / PE) against each cert's
|
||||
lodged figures. This is the committed regression guard for the headline
|
||||
"% within 0.5 SAP of the lodged rating" gauge that the per-cert mapper work
|
||||
optimises (mirrors scripts/eval_api_sap_accuracy.py, but on the in-repo
|
||||
corpus so it runs in CI without the /tmp sample).
|
||||
|
||||
SCOPE — RdSAP-21.0.1 ONLY. It is the RdSAP 10 / SAP 10.2-era schema, so its
|
||||
lodged ``energy_rating_current`` was produced by the same SAP methodology we
|
||||
compute, making it a fair accuracy target. The pre-SAP10 schemas (17.x-20.0.0)
|
||||
lodge SAP 2012 ratings — a different underlying calculation — so they are NOT
|
||||
expected to match and are excluded here (their mapper coverage is guarded by
|
||||
test_mapper_corpus.py instead).
|
||||
|
||||
The asserted thresholds are deterministic floors/ceilings over the fixed
|
||||
corpus: tighten them whenever a slice improves the gauge (ratchet, never
|
||||
loosen). Run ``pytest -s`` to see the live metrics line.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_inputs,
|
||||
)
|
||||
|
||||
_CORPUS = Path(
|
||||
"backend/epc_api/json_samples/RdSAP-Schema-21.0.1/corpus.jsonl"
|
||||
)
|
||||
|
||||
# Measured floors/ceilings over the fixed corpus at HEAD (1000 certs, 0 skips).
|
||||
# Current: SAP within-0.5 = 66.9%, SAP MAE = 1.039 (SAP 10.2 Appendix M1
|
||||
# EPV,ex=0 for non-export-capable PV, this slice: 7 Wybourn +19.1 -> +6.5,
|
||||
# 4 Lime Ave +11.1 -> +0.4, 8 Hatherleigh +7.6 -> -0.2 — PE/CO2 matched
|
||||
# lodged but the export cost credit alone was over-rating). Prior slices:
|
||||
# unsized-cylinder Table 28 110 L; Table 12a code-408 0.20 storage fraction;
|
||||
# heat-network Table 4c(3) flat-rate charging factor.
|
||||
# CO2 MAE = 0.28 t/yr (signed +0.17 — a systematic over-estimate, see below).
|
||||
# PE MAE = 14.7 kWh/m2/yr (signed +9.1).
|
||||
#
|
||||
# The SAP (cost) gauge is the optimised target — its floor/ceiling are TIGHT.
|
||||
# CO2 and PE are reported + LOOSELY guarded: both run ~+5% high vs the lodged
|
||||
# figures. INVESTIGATED (see scripts/decompose_co2_pe_error.py): this is NOT a
|
||||
# CO2/PE-factor or scope bug. Table 12 factors are spec-exact (SAP 10.2 p.188:
|
||||
# mains gas PEF 1.130 / CO2 0.210, electricity 1.501 / 0.136) and worksheet
|
||||
# (286)/(272) scope is identical to ours. Two real causes:
|
||||
# 1. Per-cert mapper/demand fidelity. corr(SAP-err, PE-diff) = -0.54: when we
|
||||
# over-estimate demand the cert under-rates on SAP AND over-estimates PE.
|
||||
# Golden cert 0300-2747 (matched data) hits register PE to -0.10 kWh/m2,
|
||||
# proving the engine is correct — the corpus bias is the aggregate of the
|
||||
# same unclosed mapper gaps the SAP gauge chases, seen through a more
|
||||
# sensitive (linear, no standing-charge/deflator damping) lens.
|
||||
# 2. SAP cost is genuinely calibrated, NOT energy hiding behind it: a +5% gas
|
||||
# space-heating bump moves SAP -0.6 (would show as a -0.6 corpus bias if
|
||||
# energy were 5% high; actual SAP bias is +0.145).
|
||||
# So closing demand over-estimates lifts BOTH the SAP gauge and PE/CO2; there is
|
||||
# no one-slice factor fix. RATCHET any ceiling up when a slice tightens it.
|
||||
_MIN_WITHIN_HALF_SAP = 0.65
|
||||
_MAX_SAP_MAE = 1.08
|
||||
_MAX_CO2_MAE_TONNES = 0.35 # t CO2 / yr vs co2_emissions_current
|
||||
_MAX_PE_PER_M2_MAE = 16.0 # kWh / m2 / yr vs energy_consumption_current
|
||||
|
||||
|
||||
def _load_corpus() -> list[dict[str, Any]]:
|
||||
if not _CORPUS.exists():
|
||||
return []
|
||||
return [
|
||||
json.loads(line)
|
||||
for line in _CORPUS.read_text().splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
|
||||
|
||||
def test_api_path_sap_accuracy_on_rdsap_21_0_1_corpus(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
# Arrange — the full in-repo 21.0.1 corpus.
|
||||
corpus = _load_corpus()
|
||||
if not corpus:
|
||||
pytest.skip(f"no corpus at {_CORPUS}")
|
||||
|
||||
sap_abs_errs: list[float] = []
|
||||
co2_signed_errs_t: list[float] = [] # our − lodged, tonnes/yr
|
||||
pe_signed_errs: list[float] = [] # our − lodged, kWh/m²/yr
|
||||
skipped = 0
|
||||
|
||||
# Act — run the API → EpcPropertyData → calculator pipeline per cert.
|
||||
for doc in corpus:
|
||||
lodged_sap = doc.get("energy_rating_current")
|
||||
if lodged_sap is None:
|
||||
skipped += 1
|
||||
continue
|
||||
try:
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
except Exception:
|
||||
# A mapper / calculator raise is a coverage gap tracked elsewhere
|
||||
# (eval_api_sap_accuracy.py); here we gauge the certs that compute.
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
sap_abs_errs.append(abs(result.sap_score_continuous - lodged_sap))
|
||||
|
||||
lodged_co2_t = doc.get("co2_emissions_current") # tonnes/yr
|
||||
if lodged_co2_t is not None:
|
||||
co2_signed_errs_t.append(result.co2_kg_per_yr / 1000.0 - lodged_co2_t)
|
||||
lodged_pe_per_m2 = doc.get("energy_consumption_current") # kWh/m²/yr (primary)
|
||||
if lodged_pe_per_m2 is not None:
|
||||
pe_signed_errs.append(result.primary_energy_kwh_per_m2 - lodged_pe_per_m2)
|
||||
|
||||
n = len(sap_abs_errs)
|
||||
within_half = sum(1 for e in sap_abs_errs if e < 0.5) / n
|
||||
sap_mae = sum(sap_abs_errs) / n
|
||||
co2_mae = sum(abs(e) for e in co2_signed_errs_t) / len(co2_signed_errs_t)
|
||||
co2_bias = sum(co2_signed_errs_t) / len(co2_signed_errs_t)
|
||||
pe_mae = sum(abs(e) for e in pe_signed_errs) / len(pe_signed_errs)
|
||||
pe_bias = sum(pe_signed_errs) / len(pe_signed_errs)
|
||||
|
||||
with capsys.disabled():
|
||||
print(
|
||||
f"\n[RdSAP-21.0.1 corpus | {n} computed / {skipped} skipped]"
|
||||
f"\n SAP within-0.5 = {within_half:.1%} MAE = {sap_mae:.3f}"
|
||||
f"\n CO2 MAE = {co2_mae:.2f} t/yr (bias {co2_bias:+.2f} t/yr)"
|
||||
f"\n PE MAE = {pe_mae:.1f} kWh/m2/yr (bias {pe_bias:+.1f})"
|
||||
)
|
||||
|
||||
# Assert — SAP (cost) is the optimised gauge: tight floor/ceiling. CO2/PE
|
||||
# are loose "don't regress" guards (see module + threshold notes).
|
||||
assert within_half >= _MIN_WITHIN_HALF_SAP, (
|
||||
f"SAP within-0.5 {within_half:.1%} fell below floor "
|
||||
f"{_MIN_WITHIN_HALF_SAP:.0%}"
|
||||
)
|
||||
assert sap_mae <= _MAX_SAP_MAE, (
|
||||
f"SAP MAE {sap_mae:.3f} exceeded ceiling {_MAX_SAP_MAE}"
|
||||
)
|
||||
assert co2_mae <= _MAX_CO2_MAE_TONNES, (
|
||||
f"CO2 MAE {co2_mae:.2f} t/yr exceeded ceiling {_MAX_CO2_MAE_TONNES}"
|
||||
)
|
||||
assert pe_mae <= _MAX_PE_PER_M2_MAE, (
|
||||
f"PE MAE {pe_mae:.1f} kWh/m2/yr exceeded ceiling {_MAX_PE_PER_M2_MAE}"
|
||||
)
|
||||
|
|
@ -384,29 +384,29 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
|
|||
assert plan.energy_consumption_savings > 0.0
|
||||
|
||||
by_type = {rec.type: rec for rec in rec_rows}
|
||||
# The gain-maximising package: the efficient representative heat pump
|
||||
# (Vaillant aroTHERM plus 5 kW, ADR-0025) now raises SAP even on this gas
|
||||
# dwelling, plus the cheap positive-SAP fabric/lighting/secondary measures.
|
||||
# CAVITY-WALL INSULATION is NOT selected: it earns +2.9 SAP alone, but the
|
||||
# fabric→ventilation forced dependency (ADR-0016) drags the wall+ventilation
|
||||
# pair to a NET −1.8 SAP (−0.9 on top of the ASHP package), so the Optimiser
|
||||
# correctly leaves the wall — and therefore its forced ventilation — out.
|
||||
# (The forced wall→ventilation edge itself is covered by
|
||||
# test_measure_dependency / test_optimiser; here we prove the end-to-end
|
||||
# optimise→persist→telescope pipeline on the package the Optimiser keeps.)
|
||||
# The sample EPC lodges 8 low-energy-unknown bulbs (LED upgrade, ADR-0023)
|
||||
# and an electric secondary heater (SAP 691, removal offered per ADR-0028).
|
||||
assert set(by_type) == {
|
||||
"cavity_wall_insulation",
|
||||
"suspended_floor_insulation",
|
||||
"mechanical_ventilation",
|
||||
# The sample EPC lodges 8 low-energy-unknown bulbs, so the LED upgrade is
|
||||
# a cheap positive-SAP candidate the Optimiser also keeps (ADR-0023).
|
||||
"low_energy_lighting",
|
||||
# The efficient representative heat pump (Vaillant aroTHERM plus 5 kW,
|
||||
# ADR-0025) now raises SAP even on this gas dwelling, so the Optimiser
|
||||
# also keeps the ASHP bundle in the least-cost-to-band package (ADR-0024).
|
||||
"air_source_heat_pump",
|
||||
# The sample lodges an electric secondary (SAP 691), so removal is offered
|
||||
# (ADR-0028); the Optimiser keeps it in its all-beneficial-measures package
|
||||
# — its SAP gain is 0 once the ASHP (category 4) ignores the secondary, but
|
||||
# the heater is still physically removed at its own cost.
|
||||
"secondary_heating_removal",
|
||||
}
|
||||
# Each persisted measure carries the catalogue id of the Product it installs
|
||||
# (the MaterialRow ids seeded above), replacing the retired
|
||||
# recommendation_materials BOM with a single material_id on the row.
|
||||
assert by_type["cavity_wall_insulation"].material_id == 1
|
||||
assert by_type["air_source_heat_pump"].material_id == 5
|
||||
assert by_type["suspended_floor_insulation"].material_id == 2
|
||||
assert by_type["mechanical_ventilation"].material_id == 3
|
||||
assert by_type["low_energy_lighting"].material_id == 4
|
||||
assert by_type["secondary_heating_removal"].material_id == 9
|
||||
for rec in rec_rows:
|
||||
|
|
@ -414,27 +414,12 @@ def test_modelling_optimises_and_persists_a_multi_measure_plan(
|
|||
assert rec.already_installed is False
|
||||
assert rec.sap_points is not None
|
||||
assert rec.estimated_cost is not None
|
||||
# The forced ventilation costs two £450 units and is priced even though it
|
||||
# was never a free choice in the pool.
|
||||
vent_cost: float | None = by_type["mechanical_ventilation"].estimated_cost
|
||||
assert vent_cost is not None
|
||||
assert abs(vent_cost - 900.0) <= 1e-6
|
||||
# The insulation measures earn positive SAP; ventilation's contribution is
|
||||
# not positive (it only ever costs SAP — ADR-0016).
|
||||
wall_sap: float | None = by_type["cavity_wall_insulation"].sap_points
|
||||
vent_sap: float | None = by_type["mechanical_ventilation"].sap_points
|
||||
assert wall_sap is not None and vent_sap is not None
|
||||
assert wall_sap > 0.0
|
||||
assert vent_sap <= 0.0
|
||||
# Per-measure bill savings (telescoping cascade, ADR-0014 amendment): each
|
||||
# measure carries its delivered-kWh and £ saving, and they telescope exactly
|
||||
# to the Plan's headline savings. Ventilation increases energy, so its
|
||||
# savings are negative — and the telescoping still holds.
|
||||
# to the Plan's headline savings.
|
||||
for rec in rec_rows:
|
||||
assert rec.kwh_savings is not None
|
||||
assert rec.energy_cost_savings is not None
|
||||
vent_kwh: float | None = by_type["mechanical_ventilation"].kwh_savings
|
||||
assert vent_kwh is not None and vent_kwh < 0.0
|
||||
kwh_total: float = sum(rec.kwh_savings or 0.0 for rec in rec_rows)
|
||||
cost_total: float = sum(rec.energy_cost_savings or 0.0 for rec in rec_rows)
|
||||
assert plan.energy_consumption_savings is not None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue