Merge pull request #1685 from Hestia-Homes/feat/1639-pashub-lrha-wave3-residual

PasHub LRHA Wave 3: close residual mapper/calculator gaps (#1639)
This commit is contained in:
KhalimCK 2026-07-27 14:01:29 +01:00 committed by GitHub
commit f4932569ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1090 additions and 127 deletions

View file

@ -377,12 +377,53 @@ class PasHubRdSapSiteNotesExtractor:
def extract_heating_and_hot_water(self) -> HeatingAndHotWater:
hhw_section = self._section("Heating & Hot Water", "Ventilation")
main_1, main_2 = self._main_heating_slices(hhw_section)
return HeatingAndHotWater(
main_heating=self._parse_main_heating(hhw_section),
main_heating=self._parse_main_heating(main_1),
main_heating_2=(
self._parse_main_heating(main_2) if main_2 is not None else None
),
main_heating_1_percent=self._main_heating_1_percent(main_1),
secondary_heating=self._parse_secondary_heating(hhw_section),
water_heating=self._parse_water_heating(hhw_section),
)
def _main_heating_slices(
self, section: List[str]
) -> tuple[List[str], Optional[List[str]]]:
"""Split the §Heating "Main Heating Systems" region into per-system token
slices. A dwelling with two main systems lodges a "Main Heating 2"
marker; system 1 is [Main Heating 1 .. Main Heating 2), system 2 is
[Main Heating 2 .. Secondary Heating System). A single-main dwelling (no
"Main Heating 2") yields the whole [Main Heating 1 .. Secondary) slice
and None byte-identical to the pre-split parse for every single-main
cert (only the two dual-main certs' system-1 parse changes, dropping the
system-2 field bleed)."""
try:
start = section.index("Main Heating 1")
except ValueError:
return section, None
end = len(section)
if "Secondary Heating System" in section[start:]:
end = section.index("Secondary Heating System", start)
if "Main Heating 2" in section[start:end]:
split = section.index("Main Heating 2", start)
return section[start:split], section[split:end]
return section[start:end], None
def _main_heating_1_percent(self, main_1_slice: List[str]) -> Optional[int]:
"""Main 1's "Percentage of space heating provided by Main Heating 1"
split, which PasHub wraps across lines ("...by Main" / "Heating 1:" /
"50"); the value token follows the "Heating 1:" continuation. None when
the dwelling lodges a single main (no split surveyed)."""
raw = self._get_in(main_1_slice, "Heating 1:")
if raw is None:
return None
try:
return int(raw.split()[0])
except (ValueError, IndexError):
return None
def extract_ventilation(self) -> Ventilation:
v_section = self._section("Ventilation", "Conservatories")
return Ventilation(
@ -605,6 +646,26 @@ class PasHubRdSapSiteNotesExtractor:
),
)
def _controls_value(self, data: List[str]) -> str:
"""The main-heating "Controls:" value, re-joining a line-wrapped
continuation. PasHub wraps a long controls string across two PDF lines
(e.g. "...room thermostat and" / "TRVs"); `_get_in` reads only the first
token, truncating the label so the mapper misses its Table 4e control
code (#1639 block B — the community-heating charging controls). A value
ending in a conjunction or comma is grammatically incomplete, so the
next token is its continuation; a value ending on a noun is complete and
left untouched. Scoped to "Controls:" the shared `_get_in` single-token
read is unchanged (a wider truncation audit is tracked separately)."""
try:
idx = data.index("Controls:")
except ValueError:
return ""
value = data[idx + 1].strip() if idx + 1 < len(data) else ""
continuation = data[idx + 2].strip() if idx + 2 < len(data) else ""
if continuation and value.endswith((" and", " or", ",")):
value = f"{value} {continuation}"
return value
def _parse_main_heating(self, data: List[str]) -> MainHeating:
return MainHeating(
selection_method=self._get_in(
@ -635,7 +696,7 @@ class PasHubRdSapSiteNotesExtractor:
status=self._get_in(data, "Status") or "",
central_heating_pump_age=self._get_in(data, "Central heating pump age:")
or "",
controls=self._get_in(data, "Controls:") or "",
controls=self._controls_value(data),
flue_gas_heat_recovery_system=self._bool_in(
data, "Does the boiler have a Flue Gas Heat Recover", offset=2
),
@ -660,10 +721,16 @@ class PasHubRdSapSiteNotesExtractor:
def _parse_water_heating(self, data: List[str]) -> WaterHeating:
thickness_raw = self._get_in(data, "Insulation Thickness (mm):") or self._get_in(data, "Thickness:")
thickness_mm = int(thickness_raw.split()[0]) if thickness_raw else None
# A measured cylinder lodges its litres on a separate "Exact cylinder
# volume:" line ("166 litres" → 166), distinct from the "Cylinder Size:"
# band value. Same token-split pattern as the thickness lines above.
volume_raw = self._get_in(data, "Exact cylinder volume:")
cylinder_volume_measured_l = int(volume_raw.split()[0]) if volume_raw else None
return WaterHeating(
type=self._get_in(data, "Water Heating Type:") or "",
system=self._get_in(data, "Water Heating System:") or "",
cylinder_size=self._get_in(data, "Cylinder Size:") or "",
cylinder_volume_measured_l=cylinder_volume_measured_l,
cylinder_measured_heat_loss=self._get_in(
data, "Cylinder Measured Heat Loss:"
),

View file

@ -537,6 +537,119 @@ class TestWaterHeatingCylinderThickness:
assert hhw.water_heating.cylinder_size == "Normal (90-130 litres)"
class TestExactCylinderVolume:
"""When the surveyor measures the cylinder, PasHub lodges the litres on a
SEPARATE "Exact cylinder volume:" line (the "Cylinder Size:" value is the
band label "Exact cylinder volume"). Token order confirmed against the real
extractor on fixture 461178278096: "Cylinder Size:" -> "Exact cylinder
volume", then "Exact cylinder volume:" -> "166 litres". The numeric value
is what the calculator reads for SAP cylinder-size code 6 (RdSAP 10 §10.5)."""
@staticmethod
def _parse(section: list[str]) -> WaterHeating:
return PasHubRdSapSiteNotesExtractor([])._parse_water_heating(section)
def test_exact_cylinder_volume_parsed(self) -> None:
wh = self._parse(
[
"Water Heating System:",
"Electric immersion",
"Cylinder Size:",
"Exact cylinder volume",
"Exact cylinder volume:",
"166 litres",
]
)
assert wh.cylinder_size == "Exact cylinder volume"
assert wh.cylinder_volume_measured_l == 166
def test_exact_cylinder_volume_absent_on_banded_cylinder(self) -> None:
wh = self._parse(["Cylinder Size:", "Normal (90-130 litres)"])
assert wh.cylinder_volume_measured_l is None
class TestDualMainHeating:
"""A dwelling with two main heating systems lodges a "Main Heating 2" block
and Main 1's percentage split. The extractor must capture the second system
and the split rather than reading only Main 1 (and bleeding Main 2's fields
into Main 1 via the shared-section first-match read)."""
@staticmethod
def _hhw(tokens: list[str]) -> HeatingAndHotWater:
return PasHubRdSapSiteNotesExtractor(tokens).extract_heating_and_hot_water()
def test_second_main_and_split_captured(self) -> None:
hhw = self._hhw(
[
"Heating & Hot Water", "Main Heating Systems",
"Main Heating 1",
"Percentage of space heating provided by Main", "Heating 1:", "20",
"System type:", "Electric storage heaters",
"Heating System (Other):", "Fan storage heater",
"Controls:", "Automatic charge control",
"Main Heating 2",
"System type:", "Electric storage heaters",
"Heating System (Other):", "Modern slimline storage heater",
"Controls:", "Manual charge control",
"Secondary Heating System", "Secondary Fuel", "No Secondary Heating",
"Ventilation",
]
)
# Main 1 does not pick up Main 2's slimline label.
assert hhw.main_heating.community_heat_source == "Fan storage heater"
assert hhw.main_heating_2 is not None
assert (
hhw.main_heating_2.community_heat_source == "Modern slimline storage heater"
)
assert hhw.main_heating_1_percent == 20
def test_single_main_has_no_second_system(self) -> None:
hhw = self._hhw(
[
"Heating & Hot Water", "Main Heating Systems", "Main Heating 1",
"System type:", "Boiler with radiators or underfloor heating",
"Secondary Heating System", "Ventilation",
]
)
assert hhw.main_heating_2 is None
assert hhw.main_heating_1_percent is None
class TestControlsLineWrap:
"""PasHub wraps a long main-heating "Controls:" value across two PDF lines
(e.g. "...room thermostat and" / "TRVs"); `_get_in` reads only the first,
truncating the label so the mapper misses its heat-network control code
(#1639 block B). A value ending in a conjunction/comma is grammatically
incomplete the next token is its continuation, so re-join it. A complete
value (ends on a noun) is left alone."""
@staticmethod
def _controls(section: list[str]) -> str:
return PasHubRdSapSiteNotesExtractor([])._parse_main_heating(section).controls
def test_wrapped_community_controls_rejoined(self) -> None:
section = [
"System type:",
"Community heating system",
"Controls:",
"Charging system linked to use of community heating, room thermostat and",
"TRVs",
"Secondary Heating System",
]
assert self._controls(section) == (
"Charging system linked to use of community heating, "
"room thermostat and TRVs"
)
def test_complete_controls_not_joined(self) -> None:
section = [
"Controls:",
"Programmer, room thermostat and TRVs",
"Does the boiler have a Flue Gas Heat Recover",
]
assert self._controls(section) == "Programmer, room thermostat and TRVs"
class TestImmersionType:
@pytest.fixture
def hhw(self) -> HeatingAndHotWater:

View file

@ -26,10 +26,20 @@ import pytest
from backend.documents_parser.parser import parse_site_notes_pdf
from datatypes.epc.domain.mapper import UnmappedPasHubLabel
from domain.sap10_calculator.calculator import Sap10Calculator
from domain.sap10_calculator.exceptions import UnmappedSapCode
from domain.sap10_calculator.exceptions import MissingMainFuelType, UnmappedSapCode
# Same known in-progress main-heating-control mapper gap as the Guinness module.
_KNOWN_GAPS: tuple[type[Exception], ...] = (UnmappedSapCode, UnmappedPasHubLabel)
# Known, separately-tracked gaps that block a fixture rather than mis-rate it.
# `MissingMainFuelType`: one LRHA WAVE 3 fixture (461178278096) lodges NO main
# heating system at all — only an electric-immersion cylinder — so there is no
# fuel to rate. Modelling a "no main heating" dwelling is a policy decision
# tracked in its own follow-up; until then it fails loud at the boundary rather
# than inventing a heating system (ADR-0015). Surfaced once #1639 block A
# unmasked it (the fixture previously blocked on the cylinder label).
_KNOWN_GAPS: tuple[type[Exception], ...] = (
UnmappedSapCode,
UnmappedPasHubLabel,
MissingMainFuelType,
)
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "pashub_accuracy_lrha_wave3"
_MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
@ -61,11 +71,65 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
# controls label ×6 (extractor fix, tracked separately), heat_network_age_band
# '2007 - 2011' ×4 (calculator cascade dict, surfaced behind the biomass
# fuel), room-in-roof gable 'None' ×1.
# Treat the constants as a regression tripwire, NOT a cohort accuracy figure;
# re-baseline (coverage growth is not loosening) as further mapper fixes unblock
# fixtures.
_MIN_WITHIN_HALF: float = 0.41
_MAX_SAP_MAE: float = 3.0
# 2026-07-24 (#1639): closed the residual non-table-add blocks. Block A (exact
# cylinder volume — extractor parse of "Exact cylinder volume:" + dataclass
# field + SAP cylinder-size code 6) and block B (re-join the line-wrapped
# "Controls:" continuation so "…room thermostat and TRVs" matches the existing
# code 2314) unblocked their fixtures. The heat_network_age_band '2007 - 2011'
# block turned out to be one facet of a broader latent bug: PasHub lodges the
# construction age band in mixed templates (letter-prefixed "K: …", tight
# "1991-1995", space-padded "2007 - 2011"), and only the letter form was being
# normalised — the bare year ranges flowed through un-normalised and were
# SILENTLY MIS-RATED by the age-band-keyed U-value/DLF cascades. Normalising
# every template to its RdSAP letter in `_extract_age_band` both unblocked the
# heat-network dwellings AND corrected ~30 already-computing fixtures →
# **103 computed / 1 blocked** (within-0.5 42.9% → 49.5%, MAE 3.05 → 1.02 — a
# real accuracy correction, not tuning). The 1 residual block is fixture
# 461178278096, which lodges no main heating at all (`MissingMainFuelType`,
# tolerated above pending a modelling decision). Floor/ceiling re-baselined to
# the corrected cohort.
# 2026-07-24 (accuracy tail audit): the secondary-heating COST path resolved its
# tariff from the bare meter string (a Dual meter defaulting to 7-hour, all
# high-rate) instead of the dwelling's SAP §12 tariff, so an electric room/panel-
# heater dwelling (10-hour by §12) billed its secondary at 15.29 p/kWh instead of
# the 11.09 p its main heating bills — under-rating SAP by ~1 point across the
# code-691 dwellings. Routed the resolved `_rdsap_tariff` into the secondary cost
# path (mirroring the CO2 path, which already did this) → within-0.5 49.5% →
# 52.4%, MAE 0.977. Storage-heater dwellings (7-hour by §12) are unchanged.
# 2026-07-24 (accuracy tail audit): a Manual-Entry electric "Direct acting"
# boiler was left uncoded (Table 4b covers only gas/liquid), so it took the 0.80
# gas-boiler default AND, on a Dual meter, 100%-off-peak billing → ±6-9 mis-rate.
# Coded it to SAP Table 4a 191 (direct-acting electric boiler, eff 1.00): fixture
# 497712825571 +9.5 → +0.6, 461386632387 -6.2 → +0.4 → within-0.5 52.4% → 53.4%,
# MAE 0.835.
# 2026-07-24 (accuracy tail audit): a dwelling with two main heating systems
# lodges a "Main Heating 2" block + a split, but the survey model held one main
# and the extractor read only Main 1 (100% of it), dropping the second system
# and its share — over-rating the 6 dual-main dwellings. The survey model now
# holds `main_heating_2` + `main_heating_1_percent`, the extractor slices per
# system, and the mapper emits two MainHeatingDetails with the fraction → 5 of 6
# converge (within-0.5 53.4% → 55.3%, MAE 0.799). The 6th (497655371974,
# storage+panel) still missed because the calculator billed an electric Main 2
# at Main 1's rate (deferred §10a slice) — the correct mapping exposed that gap.
# 2026-07-27 (#1684): the calculator now bills an electric Main 2 at its OWN
# Table 12a Grid 1 SH rate (not Main 1's), so the storage+panel Main 2's on-peak
# half is costed correctly: fixture 497655371974 +15.3 → -2.5. within-0.5 holds
# at 55.3% (still >0.5 vs PasHub's hybrid figure — genuine divergence, not a
# bug), MAE 0.799 → 0.675. Homogeneous off-peak cohorts (storage+storage) are a
# no-op. Treat the constants as a regression tripwire, NOT a cohort accuracy
# figure; re-baseline (coverage growth is not loosening) as further mapper fixes
# unblock fixtures.
# 2026-07-27 (#A1): an oil STORAGE combi (Worcester Greenstar Heatslave II,
# PCDB 18415, store_type 1) zeroed its internal-store water-heating loss —
# `pcdb_combi_loss_override` defers on store_type∈{1,2,3} and a combi has no
# cylinder — under-costing hot water and over-rating SAP. SAP 10.2 Table 2
# note b now includes the store loss from the PCDB store volume + insulation
# (Table 2/2a × Table 2b storage-combi TF), matching the accredited Elmhurst
# P960 worksheet exactly (5.4175 kWh/day). The 5 oil storage-combis collapse
# from +2.0..+4.9 into the ±1.5 spread: within-0.5 55.3% → 58.3%, MAE
# 0.675 → 0.531.
_MIN_WITHIN_HALF: float = 0.58
_MAX_SAP_MAE: float = 0.54
_KNOWN_GAP_REASON = (
"pashub `from_site_notes` mapper does not int-code a main-heating "
@ -126,6 +190,89 @@ def _evaluate(deal_id: str) -> Outcome:
)
@pytest.mark.skipif(
"461029305556" not in _BY_ID, reason="dual-main fixture not in manifest"
)
def test_dual_main_heating_models_both_systems() -> None:
"""Regression: a dwelling with two main heating systems (a "Main Heating 2"
block + a surveyed split) must model BOTH, not 100% of Main 1. Fixture
461029305556 lodges 20% fan + 80% modern-slimline storage heaters; modelling
it as 100% of the 20% fan heater over-rated it +1.3. Emitting the second
MainHeatingDetail with the split (Main 2 fraction = 100 - Main 1 %) closes it
to 0.0.
NB the sibling storage+PANEL dual-main (497655371974) is NOT pinned here.
Its Main 2 on-peak panel half is now costed correctly (#1684:
`_main_2_space_heating_fuel_cost_gbp_per_kwh` bills an electric Main 2 at its
OWN Table 12a rate), moving it +15.3 -2.5 vs PasHub. The residual is
PasHub-vs-Elmhurst divergence (>0.5), not a bug a ground-truth Elmhurst
pin is tracked separately, so it is left out of the within-0.5 assertion."""
outcome = _evaluate("461029305556")
assert outcome.diff is not None
assert outcome.diff < 0.5, f"dual-main not modelled: diff {outcome.diff:.2f}"
@pytest.mark.skipif(
"461996268736" not in _BY_ID, reason="oil storage-combi fixture not in manifest"
)
def test_oil_storage_combi_applies_pcdb_store_loss() -> None:
"""Regression (#A1): an oil STORAGE combi (Worcester Greenstar Heatslave
II, PCDB 18415, store_type 1) must incur its internal-store water-heating
loss. `pcdb_combi_loss_override` defers on store_type{1,2,3} and the
combi has no cylinder, so the store loss (56) was ZEROED hot water
under-costed SAP over-rated +4.9. SAP 10.2 Table 2 note b now includes
the store loss from the PCDB store volume (63 L) + insulation (25 mm):
Table 2 × 2a × Table 2b storage-combi TF 2.8946 5.4175 kWh/day
1977 kWh/yr, matching the accredited Elmhurst P960 worksheet for this
boiler exactly. Fixture 461996268736 +4.93 +0.13.
The five oil storage-combis (461996268736, 461989738731, 497712315613,
497602074839, 462051619031) all collapse from +2..+4.9 into the cohort's
normal ±1.5 spread; only this one is pinned (the others' residuals are
PasHub-vs-Elmhurst divergence, not the store-loss bug)."""
outcome = _evaluate("461996268736")
assert outcome.diff is not None
assert outcome.diff < 0.5, f"oil storage-combi store loss not applied: diff {outcome.diff:.2f}"
@pytest.mark.skipif(
"497712825571" not in _BY_ID or "461386632387" not in _BY_ID,
reason="direct-acting electric boiler fixtures not in manifest",
)
@pytest.mark.parametrize("deal_id", ["497712825571", "461386632387"])
def test_direct_acting_electric_boiler_codes_to_191(deal_id: str) -> None:
"""Regression: a Manual-Entry electric "Direct acting" boiler is a
direct-acting electric wet system SAP Table 4a code 191 (efficiency 1.00,
§12 Rule 3 tariff). Left uncoded it took the generic 0.80 gas-boiler default
AND, on a Dual meter, billed its whole load at the off-peak low rate two
errors that pull opposite ways (fixture 497712825571 over-rated +9.5 on a
Dual meter; 461386632387 under-rated -6.2 on a Single meter). Coding it 191
fixes both (the code drives the efficiency table, the Table 12a pricing row
and the §12 tariff together): 497712825571 +9.5 +0.6, 461386632387
-6.2 +0.4. The bound is 1.0 (not 0.5) the fix removes the ±6-9 mis-code,
leaving only sub-point residuals within the cohort's normal spread."""
outcome = _evaluate(deal_id)
assert outcome.diff is not None
assert outcome.diff < 1.0, f"direct-acting electric boiler mis-coded: diff {outcome.diff:.2f}"
@pytest.mark.skipif(
"462024626384" not in _BY_ID, reason="fixture 462024626384 not in manifest"
)
def test_electric_panel_secondary_prices_at_dwelling_tariff() -> None:
"""Regression: an electric room/panel-heater dwelling (main SAP code 691) on
a dual meter must bill its SECONDARY electric heating at the dwelling's SAP
§12 tariff (10-hour, Table 12a SH high-rate fraction 0.50 ~11.09 p/kWh)
the same rate as its main heating not the 7-hour all-high-rate (~15.29
p/kWh) that the bare meter string defaulted to. The mispriced secondary
under-rated SAP by ~1.4 (fixture 462024626384 scored 44.5 vs PasHub's 46);
the CO2 path already resolves the tariff via `_rdsap_tariff`, the cost path
now matches it."""
outcome = _evaluate("462024626384")
assert outcome.diff is not None
assert outcome.diff < 0.5, f"secondary mis-priced: diff {outcome.diff:.2f}"
@pytest.mark.skipif(not _FIXTURES, reason="no pashub_accuracy_lrha_wave3 fixtures/manifest")
@pytest.mark.parametrize("deal_id", _IDS)
def test_pashub_fixture_computes(deal_id: str) -> None:

View file

@ -6340,9 +6340,43 @@ def _is_floor_above_partially_heated_space(location: Optional[str]) -> bool:
return "above partially heated" in location.lower()
# RdSAP 10 Table S1 England & Wales construction-age bands → letter code. Keyed
# by the year range with spaces around the dash collapsed and lower-cased, so
# both PasHub templates ("2007 - 2011" space-padded, "1991-1995" tight) resolve.
_AGE_RANGE_TO_BAND_LETTER: Dict[str, str] = {
"before 1900": "A",
"1900-1929": "B",
"1930-1949": "C",
"1950-1966": "D",
"1967-1975": "E",
"1976-1982": "F",
"1983-1990": "G",
"1991-1995": "H",
"1996-2002": "I",
"2003-2006": "J",
"2007-2011": "K",
"2012-2021": "L",
"2012 onwards": "L",
"2022 onwards": "M",
"2023 onwards": "M",
}
def _extract_age_band(age_range: str) -> str:
"""Return the letter code from a site-notes age range, e.g. 'I: 1996 - 2002''I'."""
return age_range.split(":")[0].strip()
"""Return the RdSAP letter code from a site-notes age range.
Two PasHub templates occur: the letter-prefixed "K: 2007 - 2011" (take the
letter) and a bare year range either space-padded "2007 - 2011" or tight
"1991-1995" which is normalised to its letter via the RdSAP band table.
The calculator keys its age-band cascades (heat-network DLF, U-values) on
the letter, so a stray year range strict-raises deep in the calculator
(#1639 — heat_network_age_band '2007 - 2011'). An unrecognised value is
returned unchanged (preserving the prior fall-through)."""
head = age_range.split(":")[0].strip()
if len(head) == 1 and head.isalpha():
return head.upper()
key = re.sub(r"\s*-\s*", "-", head).lower()
return _AGE_RANGE_TO_BAND_LETTER.get(key, head)
def _map_floor_dimensions(
@ -6773,6 +6807,74 @@ def _map_sap_window(window: Window) -> SapWindow:
_PASHUB_ELECTRIC_SHOWER_LABEL: Final[str] = "Electric Shower"
def _pashub_main_heating_detail(
main: PasHubMainHeating, main_heating_fraction: Optional[int]
) -> MainHeatingDetail:
"""Build one `MainHeatingDetail` from a PasHub main-heating system.
`main_heating_fraction` is the system's percentage of space heat, set on the
SECOND of two mains the calculator reads `details[1].main_heating_fraction`
as Main 2's share and gives Main 1 the remainder (`_normalised_...`,
cert_to_inputs.py:1879). None on a single main."""
_ELECTRIC_SYSTEM_TYPES = {"electric storage heaters", "electric underfloor heating"}
_raw_fuel = main.fuel.split(", ")[0] if main.fuel else ""
fuel_type = (
_raw_fuel
if _raw_fuel
else (
"Electricity"
if main.system_type.lower() in _ELECTRIC_SYSTEM_TYPES
else _raw_fuel
)
)
# Heat networks lodge the Table 4a code + Table 12 community fuel from the
# community heat-source label; every other system resolves the plain Table 32
# fuel (issue #1590 follow-up, fixture 507644414148).
community_sap_code: Optional[int] = None
main_fuel: Union[int, str]
if main.system_type.lower() in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES:
community_sap_code, community_fuel = _pashub_community_heating(
main.community_heat_source, fuel_type
)
main_fuel = community_fuel if community_fuel is not None else fuel_type
else:
main_fuel = _pashub_main_fuel_code(fuel_type)
# Table 4a system code: heat networks resolved theirs above; storage/room
# heaters resolve theirs from the surveyed type; boilers stay None.
sap_main_heating_code: Optional[int] = (
community_sap_code
if community_sap_code is not None
else _pashub_main_heating_system_code(main)
)
main_heating_control = _pashub_resolve_storage_control_coherence(
_pashub_main_heating_control_code(main.controls, main.system_type),
sap_main_heating_code,
)
return MainHeatingDetail(
has_fghrs=main.flue_gas_heat_recovery_system,
main_fuel_type=main_fuel,
sap_main_heating_code=sap_main_heating_code,
main_heating_category=(
_PASHUB_HEATING_CATEGORY_HEAT_PUMP
if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES
else None
),
heat_emitter_type=_pashub_heat_emitter_code(main.emitter),
emitter_temperature=main.emitter_temperature,
fan_flue_present=main.fan_assist,
main_heating_control=main_heating_control,
condensing=main.condensing,
weather_compensator=main.weather_compensator,
central_heating_pump_age_str=main.central_heating_pump_age,
# PCDB product id → the appliance's SEDBUK efficiency source
# (`gas_oil_boiler_record`); 0 means no product lodged (#1563).
main_heating_index_number=(
main.product_id if main.product_id > 0 else None
),
main_heating_fraction=main_heating_fraction,
)
def _map_sap_heating(
heating: HeatingAndHotWater, ventilation: Ventilation, water_use: WaterUse
) -> SapHeating:
@ -6806,18 +6908,6 @@ def _map_sap_heating(
1 for s in water_use.showers if s.outlet_type != _PASHUB_ELECTRIC_SHOWER_LABEL
)
_ELECTRIC_SYSTEM_TYPES = {"electric storage heaters", "electric underfloor heating"}
_raw_fuel = main.fuel.split(", ")[0] if main.fuel else ""
fuel_type = (
_raw_fuel
if _raw_fuel
else (
"Electricity"
if main.system_type.lower() in _ELECTRIC_SYSTEM_TYPES
else _raw_fuel
)
)
# Water heating is only typed for cylinder dwellings; a combi leaves the WHC
# None so HW inherits the main system (issue #1565). A `Water Heating Type:
# None` lodgement is the RdSAP 10 §10.7 "no water heating system" signal and
@ -6836,57 +6926,27 @@ def _map_sap_heating(
else None
)
# Heat networks lodge the Table 4a code + Table 12 community fuel from
# the community heat-source label; every other system resolves the plain
# Table 32 fuel (issue #1590 follow-up, fixture 507644414148).
community_sap_code: Optional[int] = None
main_fuel: Union[int, str]
if main.system_type.lower() in _PASHUB_HEAT_NETWORK_SYSTEM_TYPES:
community_sap_code, community_fuel = _pashub_community_heating(
main.community_heat_source, fuel_type
# One MainHeatingDetail per surveyed main system. A dwelling with two mains
# lodges a second "Main Heating 2" block: the calculator reads Main 2's share
# from `details[1].main_heating_fraction` and gives Main 1 the remainder
# (cert_to_inputs.py:1879). PasHub lodges Main 1's percentage, so Main 2
# takes `100 - it` — an even split when the percentage is not surveyed.
# Without the second detail the surveyed split collapsed to 100% of Main 1
# (e.g. a 50% off-peak storage heater modelled as the whole main, dropping
# the on-peak room-heater half and over-rating SAP).
main_details = [_pashub_main_heating_detail(main, None)]
if heating.main_heating_2 is not None:
main_1_percent = heating.main_heating_1_percent
main_2_fraction = (
100 - main_1_percent if main_1_percent is not None else 50
)
main_details.append(
_pashub_main_heating_detail(heating.main_heating_2, main_2_fraction)
)
main_fuel = community_fuel if community_fuel is not None else fuel_type
else:
main_fuel = _pashub_main_fuel_code(fuel_type)
# Table 4a system code: heat networks resolved theirs above; storage/room
# heaters resolve theirs from the surveyed type; boilers stay None.
sap_main_heating_code: Optional[int] = (
community_sap_code
if community_sap_code is not None
else _pashub_main_heating_system_code(main)
)
main_heating_control = _pashub_resolve_storage_control_coherence(
_pashub_main_heating_control_code(main.controls, main.system_type),
sap_main_heating_code,
)
return SapHeating(
instantaneous_wwhrs=InstantaneousWwhrs(),
main_heating_details=[
MainHeatingDetail(
has_fghrs=main.flue_gas_heat_recovery_system,
main_fuel_type=main_fuel,
sap_main_heating_code=sap_main_heating_code,
main_heating_category=(
_PASHUB_HEATING_CATEGORY_HEAT_PUMP
if main.system_type.lower() in _PASHUB_HEAT_PUMP_SYSTEM_TYPES
else None
),
heat_emitter_type=_pashub_heat_emitter_code(main.emitter),
emitter_temperature=main.emitter_temperature,
fan_flue_present=main.fan_assist,
main_heating_control=main_heating_control,
condensing=main.condensing,
weather_compensator=main.weather_compensator,
central_heating_pump_age_str=main.central_heating_pump_age,
# PCDB product id → the appliance's SEDBUK efficiency source
# (`gas_oil_boiler_record`); 0 means no product lodged (#1563).
main_heating_index_number=(
main.product_id if main.product_id > 0 else None
),
)
],
main_heating_details=main_details,
has_fixed_air_conditioning=ventilation.has_fixed_air_conditioning,
secondary_fuel_type=secondary_fuel_type,
secondary_heating_type=_pashub_secondary_heating_type_code(
@ -6904,6 +6964,14 @@ def _map_sap_heating(
if cylinder_present
else None
),
# Measured litres for the "Exact cylinder volume" band (code 6); the
# calculator reads it only when `cylinder_size == 6`. Mirrors the
# gov-API path (`cylinder_volume_measured_l=...cylinder_size_measured`).
cylinder_volume_measured_l=(
heating.water_heating.cylinder_volume_measured_l
if cylinder_present
else None
),
cylinder_insulation_type=_pashub_cylinder_insulation_type_code(
heating.water_heating.insulation_type, cylinder_present
),
@ -7020,6 +7088,9 @@ _PASHUB_CYLINDER_SIZE_TO_SAP10: Dict[str, int] = {
"Normal (90-130 litres)": 2,
"Medium (131-170 litres)": 3, # Table 28 Medium → fixed 160 L
"Large (>170 litres)": 4,
# Surveyor measured the cylinder: code 6 (Exact) reads the litres carried
# on `WaterHeating.cylinder_volume_measured_l`, not a Table 28 band volume.
"Exact cylinder volume": 6,
}
@ -8533,14 +8604,24 @@ def _pashub_main_heating_control_code(
return code
# SAP 10.2 Table 4a code 191 = "direct-acting electric boiler" (efficiency 1.00,
# §12 Rule 3 tariff dispatch). A Manual-Entry electric boiler lodges "Direct
# acting" in its "Heating System (Boiler):" age-band cell; Table 4b (gas/liquid
# only) does not cover it, so without an explicit code it stayed uncoded → billed
# at the generic 0.80 gas default AND, on a Dual meter, 100% off-peak low-rate — a
# large mis-rate in BOTH directions (over-rate on Dual, under-rate on Single).
_PASHUB_DIRECT_ACTING_ELECTRIC_BOILER_AGE_BAND: Final[str] = "Direct acting"
_SAP_DIRECT_ACTING_ELECTRIC_BOILER_CODE: Final[int] = 191
# PasHub Manual-Entry boiler descriptor → SAP 10.2 Table 4b sub-row code
# (`table_4b.py`), keyed on the surveyed "Heating System (Boiler):" cell
# (`boiler_age_band`), which carries both the boiler type and the pre/post-1998
# age band. A Manual-Entry boiler has no PCDB `product_id`, so its efficiency
# must come from Table 4b, not the generic 0.80 gas default (#1649 A). Only the
# cohort's observed gas/liquid descriptors are mapped; an unmapped descriptor
# (or an electric "Direct acting" boiler, which Table 4b does not cover) resolves
# to None, preserving the pre-existing behaviour (no block, no regression).
# cohort's observed gas/liquid descriptors are mapped; an unmapped gas/liquid
# descriptor resolves to None, preserving the pre-existing behaviour (no block,
# no regression). The electric "Direct acting" boiler is handled separately
# (Table 4a code 191, above) since it is not a Table 4b row.
_PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B: Dict[str, int] = {
# Gas boilers 1998 or later (Table 4b 101-109).
"1998 or later - Condensing combi": 104, # 84/75 winter/summer
@ -8554,14 +8635,22 @@ _PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B: Dict[str, int] = {
}
def _pashub_manual_boiler_table_4b_code(main: PasHubMainHeating) -> Optional[int]:
def _pashub_manual_boiler_code(main: PasHubMainHeating) -> Optional[int]:
"""Resolve a Manual-Entry boiler's surveyed descriptor to its SAP 10.2
Table 4b code (#1649 A). Returns None for a PCDF-Search boiler (product_id
lodged efficiency is the SEDBUK lookup) and for any descriptor the Table
4b map does not cover (e.g. an electric boiler), leaving the calculator's
existing cascade untouched for those."""
main-heating code. Returns None for a PCDF-Search boiler (product_id lodged
efficiency is the SEDBUK lookup) and for any gas/liquid descriptor the
Table 4b map does not cover, leaving the calculator's existing cascade
untouched. An electric "Direct acting" boiler is a direct-acting electric
wet system Table 4a code 191 (efficiency 1.00), NOT a Table 4b gas/liquid
row; without this it stayed uncoded and was billed at the 0.80 gas default
and (on a Dual meter) the off-peak low rate."""
if main.product_id > 0:
return None
if (
main.boiler_age_band == _PASHUB_DIRECT_ACTING_ELECTRIC_BOILER_AGE_BAND
and main.fuel == "Electricity"
):
return _SAP_DIRECT_ACTING_ELECTRIC_BOILER_CODE
return _PASHUB_MANUAL_BOILER_AGE_BAND_TO_TABLE_4B.get(main.boiler_age_band)
@ -8600,9 +8689,10 @@ def _pashub_main_heating_system_code(main: PasHubMainHeating) -> Optional[int]:
return code
if system in _PASHUB_BOILER_SYSTEM_TYPES:
# PCDF-Search boiler → None (SEDBUK product efficiency); Manual-Entry
# boiler → its Table 4b code (or None for an uncovered/non-Table-4b
# descriptor, keeping the pre-existing cascade) (#1649 A).
return _pashub_manual_boiler_table_4b_code(main)
# boiler → its Table 4b code, Table 4a 191 for an electric direct-acting
# boiler, or None for an uncovered descriptor (keeping the pre-existing
# cascade) (#1649 A).
return _pashub_manual_boiler_code(main)
return None

View file

@ -20,7 +20,11 @@ from datatypes.epc.domain.epc_property_data import (
ShowerOutlet,
ShowerOutlets,
)
from datatypes.epc.domain.mapper import EpcPropertyDataMapper, UnmappedPasHubLabel
from datatypes.epc.domain.mapper import (
EpcPropertyDataMapper,
UnmappedPasHubLabel,
_extract_age_band,
)
from datatypes.epc.schema.tests.helpers import from_dict
from datatypes.epc.surveys.pashub_rdsap_site_notes import PasHubRdSapSiteNotes
@ -838,6 +842,31 @@ class TestSystemBuildWallIsNotBasement:
assert part.main_wall_is_basement is False
class TestAgeBandNormalisation:
"""`_extract_age_band` must yield an RdSAP letter for every PasHub age-range
template. The letter-prefixed form ("K: 2007 - 2011") takes its letter; a
bare year range ("2007 - 2011" or the no-space "1991-1995") is normalised to
its letter via the RdSAP band table. The calculator keys age-band cascades
(heat-network DLF, U-values) on the letter, so a stray year range strict-
raises deep in the calculator (#1639 — heat_network_age_band '2007 - 2011')."""
@pytest.mark.parametrize(
"age_range, expected_letter",
[
("K: 2007 - 2011", "K"), # letter-prefixed form
("2007 - 2011", "K"), # bare, space-padded dash (the blocking form)
("1991-1995", "H"), # bare, no-space dash
("1996-2002", "I"),
("before 1900", "A"),
("H", "H"), # already a letter
],
)
def test_normalises_to_letter(
self, age_range: str, expected_letter: str
) -> None:
assert _extract_age_band(age_range) == expected_letter
class TestCylinderSizeCoding:
"""The surveyed §Water-Heating "Cylinder Size" label must resolve to the
SAP10 cylinder-size enum (`_cylinder_volume_l_from_code` int-or-nones a raw
@ -860,6 +889,7 @@ class TestCylinderSizeCoding:
("Medium (131-170 litres)", 3), # Table 28 Medium → fixed 160 L
("Large (>170 litres)", 4),
("No Access", 2), # fed from the (gas) main → Table 28 "otherwise"
("Exact cylinder volume", 6), # Table 28 code 6 → measured litres
],
)
def test_label_maps_to_sap_code(self, label: str, expected_code: int) -> None:
@ -872,6 +902,24 @@ class TestCylinderSizeCoding:
# Assert
assert result.sap_heating.cylinder_size == expected_code
def test_exact_volume_threads_measured_litres(self) -> None:
# Arrange — a measured cylinder: band "Exact cylinder volume" (→ code 6)
# plus the surveyed litres. `_cylinder_volume_l_from_code` reads the
# measured value only when the code is 6 (RdSAP 10 §10.5).
data = self._with_cylinder("Exact cylinder volume")
data["heating_and_hot_water"]["water_heating"][
"cylinder_volume_measured_l"
] = 166
# Act
result = EpcPropertyDataMapper.from_site_notes(
from_dict(PasHubRdSapSiteNotes, data)
)
# Assert
assert result.sap_heating.cylinder_size == 6
assert result.sap_heating.cylinder_volume_measured_l == 166
def test_no_cylinder_stays_absent(self) -> None:
# Arrange — a combi dwelling: no cylinder lodged.
survey = from_dict(

View file

@ -255,6 +255,9 @@ class WaterHeating:
type: str
system: str
cylinder_size: str
# Litres from the surveyor's "Exact cylinder volume:" line — read by the
# calculator only when `cylinder_size` codes to 6 (Exact, RdSAP 10 §10.5).
cylinder_volume_measured_l: Optional[int] = None
cylinder_measured_heat_loss: Optional[str] = None
insulation_type: Optional[str] = None
insulation_thickness_mm: Optional[int] = None
@ -267,6 +270,13 @@ class HeatingAndHotWater:
main_heating: MainHeating
secondary_heating: SecondaryHeating
water_heating: WaterHeating
# A dwelling with two main heating systems lodges a second "Main Heating 2"
# block and a "Percentage of space heating provided by Main Heating 1" split
# (e.g. 50/50 storage + panel heaters). `main_heating_2` is None for the
# common single-main dwelling; `main_heating_1_percent` is Main 1's share
# (Main 2 takes the remainder), None when only one system is lodged.
main_heating_2: Optional[MainHeating] = None
main_heating_1_percent: Optional[int] = None
@dataclass

View file

@ -0,0 +1,130 @@
# Elmhurst RdSAP input sheet — fixture 497655371974 (4 Edmund Close)
Purpose: reproduce LRHA WAVE 3 fixture **497655371974** in the Elmhurst RdSAP 10
entry tool, download its **Input Summary + SAP Worksheets**, and give us the
**accredited SAP score** + the **space/water-heating fuel-cost worksheet lines**.
This anchors #1684 to accredited ground truth (per the A5 discipline: Elmhurst,
not PasHub's hybrid `pre_sap`), so we can pin it as a `RealCertExpectation`
instead of relying on the PasHub-relative residual.
The #1684 fix is already shipped and green; this worksheet is the **confirmatory
ground-truth anchor**, not a blocker.
## What this cert is (and why it matters for #1684)
A detached house with **two electric main heating systems on Economy 7**:
- **Main 1** — modern-slimline electric **storage** heaters (charged off-peak,
~5.5 p/kWh low rate).
- **Main 2** — electric **direct-acting panel/convector** heaters (on-peak,
~15.29 p/kWh high rate), 50% split.
The bug (#1684) billed the on-peak panel Main 2 at Main 1's off-peak storage
rate → over-rated SAP by **+15.3 vs PasHub**. The fix bills Main 2 at its own
Table 12a rate → **SAP 40.5** (2.5 vs PasHub 43). **The load-bearing worksheet
lines to check are the space-heating fuel-cost rows: Main 2's kWh must bill at
the HIGH (on-peak) rate, not Main 1's low rate.**
## Our engine's output (to compare against Elmhurst)
| Quantity | Our engine |
|---|---|
| SAP score (continuous / band) | **40.49** (E) |
| Space heating demand | 9 166 kWh/yr |
| Main 1 (storage) fuel | 7 791 kWh/yr |
| Main 2 (panel) fuel | 3 896 kWh/yr |
| Hot water | 1 459 kWh/yr |
| CO₂ | 1 555 kg/yr |
If Elmhurst lands materially away from ~40.5, that is the signal to dig — but
the direction (panel billed on-peak) is not in doubt (SAP 10.2 Table 12a Grid 1,
`OTHER_DIRECT_ACTING_ELECTRIC` = 100% high rate on a 7-hour tariff).
---
## Page-by-page inputs
### Property / general
- Address: **4 Edmund Close, Gainsborough, DN21 5RN**
- Property type: **House** · Built form: **Detached**
- Dwelling type: Detached house · Tenure: Rented (social)
- Transaction type: None of the above
- Inspection date: 2026-04-30
- Age band: **F** (England & Wales 19761982)
- Storeys: **2** · Habitable rooms: **5** · Heated habitable rooms: 5
- Total floor area: **78.3 m²** (ground 38.24 + first 38.24 + extension 1.82)
- Extensions: **1**
- Country: England
### Dimensions
| Level | Storey height (m) | Floor area (m²) | Heat-loss perimeter (m) |
|---|---|---|---|
| Ground (main) | 2.62 | 38.24 | 25.64 |
| First (main) | 2.30 | 38.24 | 25.64 |
| Extension (ground) | 2.30 | 1.82 | 4.02 |
Party-wall length: 0 (detached).
### Walls
- Construction: **Cavity**
- Insulation: **Filled cavity** (retrofit CWI)
- Thickness: **300 mm** (measured)
- Dry-lined: No
### Roof
- Main: **Pitched, slates/tiles, access to loft** · Insulation at **joists**,
**250 mm**
- Extension: Pitched, slates/tiles, **no access** · Insulation: unknown
(RdSAP default for age band)
### Floor
- **Solid** ground floor · Insulation: **as built** (none assumed) · U unknown
### Windows / doors
- Glazing / low-E: RdSAP defaults for age band F (no override surveyed)
- Doors: **3** (all draught-proofed) · Insulated doors: 0
- Draught-proofing: **100%**
### Ventilation
- **Natural** ventilation · No pressure test
- **Draught lobby: present**
- Open chimneys: 0 · Blocked chimneys: **1** · Open/closed/boiler/other flues: 0
- Extract fans: 0 · Passive vents: 0 · Flueless gas fires: 0
- Sheltered sides: 0
### Lighting
- Low-energy fixed lighting: **11** bulbs (of 11 → 100% low-energy)
### Electricity meter / tariff
- Meter type: **Dual (Economy 7 / 7-hour off-peak)** ← load-bearing for #1684
- Gas connection: **No** (all-electric dwelling)
- PV / wind / battery: none · Export-capable: yes (no generation)
### Main heating system 1
- **Electric storage heaters — modern slimline** (SAP Table 4a **402**)
- Fuel: **Electricity** · Charge control: **Manual** (SAP control 2401)
- Fraction: **50%**
### Main heating system 2
- **Electric direct-acting — panel, convector or radiant heaters**
(SAP Table 4a **691**)
- Fuel: **Electricity (on-peak)** · Control: **Appliance thermostats** (2602)
- Fraction: **50%**
### Secondary heating
- **None**
### Water heating
- **Electric immersion** cylinder (SAP WHC **903**), fuel electricity
- Immersion: **single** · on the Economy-7 (off-peak) meter
- Cylinder size: **Small band ≈110 L** (RdSAP Table 28 code 2)
- Insulation: **factory-fitted foam, 50 mm** · Cylinder thermostat: **No**
- Electric shower ×1 · Bath ×1 · Mixer shower ×0
- Solar water heating: No · WWHRS: None
---
## What to send back
1. **SAP score** (continuous + band) from the Elmhurst Input Summary.
2. The **SAP Worksheet** pages covering the **§10a/§12 fuel-cost lines** — the
space-heating cost split showing Main 1 (storage, low rate) vs Main 2 (panel,
high rate), and the per-fuel p/kWh applied.
3. (Nice to have) the per-end-use kWh (space heating, water) to pin alongside the
score as `space_heating_kwh_per_yr` / `hot_water_kwh_per_yr`.

View file

@ -210,6 +210,7 @@ from domain.sap10_calculator.worksheet.water_heating import (
cylinder_storage_loss_monthly_kwh,
cylinder_volume_factor_table_2a,
primary_loss_monthly_kwh,
storage_combi_temperature_factor_table_2b,
water_efficiency_monthly_via_equation_d1,
water_heating_from_cert,
)
@ -2883,24 +2884,25 @@ def _main_2_space_heating_fuel_cost_gbp_per_kwh(
prices: PriceTable,
) -> float:
"""Main heating system 2's space-heating fuel rate (£/kWh) for the
legacy/off-peak scalar cost path. Keyed on main 2's OWN fuel via the same
`_space_heating_fuel_cost_gbp_per_kwh` logic (off-peak Table 12a split when
main 2 is electric, flat Table 32 rate otherwise) so a wood-log or other
non-electric second main is not billed at main 1's electric rate. Returns
0.0 when no second main is lodged (multiplied by main 2's 0 kWh).
legacy/off-peak scalar cost path. Keyed on main 2's OWN system via the same
`_space_heating_fuel_cost_gbp_per_kwh` logic Main 1 uses (off-peak Table 12a
Grid 1 SH high/low split when main 2 is electric, flat Table 32 rate
otherwise). Returns 0.0 when no second main is lodged (multiplied by main
2's 0 kWh).
Scoped to a NON-electric second main (the unambiguous correction): a wood/
oil/coal second main billed at main 1's rate is plainly wrong. An ELECTRIC
second main keeps main 1's space-heating scalar — its off-peak Table 12a
high/low split is the deferred §10a off-peak slice, and applying the split
per-system here regresses the off-peak electric cohort (certs 13 Parkers
Hill / 34 Dunley Road)."""
An ELECTRIC main 2 is billed at its OWN Table 12a Grid 1 SH row, not main
1's rate (#1684). On a HOMOGENEOUS off-peak cohort (storage+storage,
direct+direct) main 2's own rate already equals main 1's, so this is a
no-op; it only corrects the HETEROGENEOUS case e.g. off-peak STORAGE main
1 (5.5 p/kWh low) + on-peak DIRECT-ACTING panel main 2 (15.29 p/kWh high),
where inheriting main 1's storage low rate under-costs the on-peak panel
half and over-rates SAP (LRHA WAVE 3 fixture 497655371974 read +15.3). This
keeps the scalar cost consistent with `_main_2_high_rate_fraction`, which
already resolves per-system."""
details = epc.sap_heating.main_heating_details if epc.sap_heating else None
if not details or len(details) < 2:
return 0.0
main_2 = details[1]
if _is_electric_main(main_2):
return _space_heating_fuel_cost_gbp_per_kwh(details[0], tariff, prices)
return _space_heating_fuel_cost_gbp_per_kwh(main_2, tariff, prices)
@ -3237,7 +3239,7 @@ def _secondary_efficiency(
return seasonal_efficiency(code, None, None)
def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float:
def _secondary_off_peak_rate_gbp_per_kwh(tariff: Tariff) -> float:
"""SAP 10.2 Table 12a Grid 1 (PDF p.191) blended rate for an electric
secondary heater on an off-peak tariff. The secondary is a direct-
acting electric room heater (RdSAP 10 §A.2.2 default), so it sits on
@ -3247,12 +3249,14 @@ def _secondary_off_peak_rate_gbp_per_kwh(meter_type: object) -> float:
rate. Worksheet evidence simulated case 19 (242): "Space heating -
secondary (1.00*15.29 + 0.00*5.50)" → all at the 7-hour HIGH rate.
Mirrors `_space_heating_fuel_cost_gbp_per_kwh`: the meter resolves to
a tariff (the `_is_off_peak_meter` Unknown-code-3 heuristic falls
through to 7-hour, as in `_off_peak_low_rate_gbp_per_kwh_via_meter_
heuristic`); 18-/24-hour tariffs (absent from the Grid 1 direct-acting
row) fall back to the tariff's Table 32 low rate."""
tariff = tariff_from_meter_type(meter_type)
Takes the dwelling's resolved SAP §12 tariff (`_rdsap_tariff`), the
SAME tariff the main heating bills on a room-heater dwelling on a
Dual meter is 10-hour by §12 Rule 3, so its secondary must blend at
0.50 (11.09 p) like the main, not 1.00 (15.29 p). Resolving from the
bare meter string instead defaulted a Dual meter to 7-hour and
over-priced the secondary (mirrors the CO2 path, which already keys
on `_rdsap_tariff`). 18-/24-hour tariffs (absent from the Grid 1
direct-acting row) fall back to the tariff's Table 32 low rate."""
if tariff is Tariff.STANDARD:
tariff = Tariff.SEVEN_HOUR
try:
@ -3271,23 +3275,27 @@ def _secondary_fuel_cost_gbp_per_kwh(
main: Optional[MainHeatingDetail],
meter_type: object,
prices: PriceTable,
tariff: Tariff,
) -> float:
"""Secondary fuel cost. When secondary_fuel_type is missing, default
to portable-electric (code 30 standard electricity, or off-peak
under E7-eligible meter). The cert's secondary is an electric room
heater per the §A.2.2 default."""
heater per the §A.2.2 default. `tariff` is the dwelling's resolved
SAP §12 tariff, so an off-peak secondary bills at the same tariff as
the main heating rather than a meter-string default (see
`_secondary_off_peak_rate_gbp_per_kwh`)."""
sec_fuel = sap_heating.secondary_fuel_type
if sec_fuel is None:
# Default to electricity since the default secondary system is
# portable electric heaters (code 693).
if _is_off_peak_meter(meter_type, fuel_is_electric=True):
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return _secondary_off_peak_rate_gbp_per_kwh(tariff)
return prices.standard_electricity_p_per_kwh * _PENCE_TO_GBP
# When secondary_fuel_type is electricity, apply off-peak if applicable.
if _is_electric_water(sec_fuel) and _is_off_peak_meter(
meter_type, fuel_is_electric=True
):
return _secondary_off_peak_rate_gbp_per_kwh(meter_type)
return _secondary_off_peak_rate_gbp_per_kwh(tariff)
# Normalise colliding gov-API enum codes (e.g. 9 dual fuel, whose
# value collides with Table-32 9 = LPG SC11F) before the price lookup,
# exactly as the main-fuel boundary does — otherwise the same-value
@ -6194,6 +6202,11 @@ def _apply_rdsap_no_water_heating_system_default(
# cylinder of 110 litres and a factory insulation thickness of 50 mm".
_HEAT_NETWORK_HIU_DEFAULT_INSULATION_MM: Final[int] = 50
# SAP 10.2 Table 2 note c) (PDF p.158): when a storage combi's factory
# insulation thickness is unknown, a 13 mm default applies. Used by
# `_storage_combi_store_loss_override` when the PCDB record omits it.
_STORE_COMBI_DEFAULT_INSULATION_MM: Final[float] = 13.0
def _apply_heat_network_hiu_default_store(
epc: EpcPropertyData,
@ -7125,6 +7138,14 @@ def _water_heating_worksheet_and_gains(
# combi systems, so the override is only built when the cert explicitly
# lodges a cylinder.
storage_loss_override = _cylinder_storage_loss_override(epc, main)
if storage_loss_override is None:
# SAP 10.2 Table 2 note b) — a storage combi with no separate cylinder
# still incurs its internal store loss (56)m. `pcdb_record` is the
# same boiler record `pcdb_combi_loss_override` used above, so the
# store loss and the combi loss (61)=0 stay on the one heat generator.
storage_loss_override = _storage_combi_store_loss_override(
epc, pcdb_record
)
# SAP 10.2 §4 line 7700 + Table 3 (PDF p.159) — primary circuit loss
# (59)m. Only fires for indirect cylinders; HPs with integral
# vessels and combi boilers are in the spec's zero list. The gate
@ -7408,6 +7429,62 @@ def _cylinder_storage_loss_override(
return tuple(s * factor for s in storage_56m)
def _storage_combi_store_loss_override(
epc: EpcPropertyData,
pcdb_record: Optional[GasOilBoilerRecord],
) -> Optional[tuple[float, ...]]:
"""SAP 10.2 §4 (56)m for the internal water store of a STORAGE COMBI
boiler the loss a lodged-cylinder dwelling gets from
`_cylinder_storage_loss_override`, but which a combi (no cylinder)
otherwise drops to zero, over-rating the dwelling.
Table 2 note b) (PDF p.158): "the loss is to be included for a storage
combination boiler if its efficiency ... is obtained from the PCDB (in
which case its insulation thickness and volume are also ... obtained
from the PCDB), using the loss factor for a factory insulated cylinder."
So when a PCDB Table 105 record with `store_type {1, 2}` (primary /
secondary water store) drives the combi's efficiency, its store volume
(field 42) and insulation thickness (field 44) size a Table 2 store loss
with the Table 2b storage-combi temperature factor (much larger than a
cylinder's 0.60 — the store is kept hot continuously).
Returns None (leaving the existing zero default) when there is a lodged
cylinder (that path already runs), when the main is not a PCDB storage
combi, or when the PCDB store volume is unknown (Table 2 note c defaults
deferred no cohort fixture yet). Verified against the Elmhurst P960
worksheet for pcdb_id 18415 (Worcester Greenstar Heatslave II): store
volume 63 L / insulation 25 mm (55) 5.4175 kWh/day, (56) Jan 167.94."""
if epc.has_hot_water_cylinder:
return None
if pcdb_record is None or pcdb_record.store_type not in (1, 2):
return None
volume_l = pcdb_record.store_boiler_volume_l
if volume_l is None:
return None
thickness_mm = pcdb_record.store_insulation_thickness_mm
if thickness_mm is None:
# Table 2 note c) default store insulation (13 mm) when the PCDB
# thickness is unlodged — kept explicit so the loss is still applied
# rather than dropped, mirroring the cylinder Table 29 fallback.
thickness_mm = _STORE_COMBI_DEFAULT_INSULATION_MM
temperature_factor = storage_combi_temperature_factor_table_2b(
store_type=pcdb_record.store_type, volume_l=volume_l,
)
return cylinder_storage_loss_monthly_kwh(
volume_l=volume_l,
# SAP 10.2 Table 2 note 2: the water store in a storage combi uses
# the factory-insulated cylinder loss factor regardless of material.
insulation_type="factory_insulated",
thickness_mm=thickness_mm,
# The Table 2b storage-combi temperature factor replaces the cylinder
# 0.60 base; the thermostat / separate-timing multipliers do not
# apply, so these two flags are inert (override supplied).
has_cylinder_thermostat=True,
separately_timed_dhw=False,
temperature_factor_override=temperature_factor,
)
def _apply_water_efficiency(
*,
wh_output_monthly_kwh: tuple[float, ...],
@ -8943,7 +9020,8 @@ def cert_to_inputs(
secondary_heating_efficiency=secondary_efficiency_value,
energy_requirements=energy_requirements_result,
secondary_heating_fuel_cost_gbp_per_kwh=_secondary_fuel_cost_gbp_per_kwh(
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices
epc.sap_heating, main, epc.sap_energy_source.meter_type, prices,
_rdsap_tariff(epc),
),
space_heating_primary_factor=_main_heating_primary_factor(
main, _rdsap_tariff(epc),

View file

@ -75,6 +75,29 @@ _TABLE_362_JSONL: Final[Path] = (
)
# PCDF Spec Rev 6b (0-idx) raw-row positions for the storage-combi store
# fields not surfaced as top-level NDJSON keys. Parsed from `raw` at load
# time so no NDJSON rebuild is needed. Field 42 = store boiler volume (L),
# field 44 = store insulation thickness (mm).
_RAW_STORE_VOLUME_IDX: Final[int] = 41
_RAW_STORE_INSULATION_IDX: Final[int] = 43
def _raw_float(raw: list[str], idx: int) -> Optional[float]:
"""Parse a float from the raw PCDB row at `idx`; None if the row is too
short, the cell is blank, or it does not parse (mirrors the blank-store
convention for non-storage combis)."""
if idx >= len(raw):
return None
value = raw[idx].strip()
if not value:
return None
try:
return float(value)
except ValueError:
return None
def _load_table_105() -> dict[int, GasOilBoilerRecord]:
"""Read the Table 105 NDJSON at import time and build a by-pcdb-id
dict. ~5MB / ~4000 rows; one-off ~50ms cost. The Python runtime
@ -98,6 +121,12 @@ def _load_table_105() -> dict[int, GasOilBoilerRecord]:
final_year_of_manufacture=data["final_year_of_manufacture"],
subsidiary_type=data.get("subsidiary_type"),
store_type=data.get("store_type"),
store_boiler_volume_l=_raw_float(
data["raw"], _RAW_STORE_VOLUME_IDX
),
store_insulation_thickness_mm=_raw_float(
data["raw"], _RAW_STORE_INSULATION_IDX
),
separate_dhw_tests=data.get("separate_dhw_tests"),
rejected_energy_proportion_r1=data.get("rejected_energy_proportion_r1"),
loss_factor_f1_kwh_per_day=data.get("loss_factor_f1_kwh_per_day"),

View file

@ -81,8 +81,20 @@ class GasOilBoilerRecord:
subsidiary_type: Optional[int]
# PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary
# water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in
# Table 3b/3c (deferred until a fixture exercises).
# Table 3b/3c and the SAP 10.2 Table 2 store-loss path below.
store_type: Optional[int]
# PCDF Spec Rev 6b field 42 (0-idx 41): "Store boiler volume" — the water
# volume (litres) of the internal hot-water store heatable by the boiler
# (total store less the store solar volume, field 43). Blank / None for a
# non-storage combi. PCDF Spec Rev 6b field 44 (0-idx 43): "Store
# insulation" — the store insulation thickness in mm. Together they drive
# the SAP 10.2 Table 2 store-loss factor (factory-insulated formula, Note
# 2) for a storage combi whose efficiency is from the PCDB (Table 2 note
# b). Verified against the Elmhurst P960 worksheet for pcdb_id 18415
# (Worcester Greenstar Heatslave II): 63 L / 25 mm → L 0.0240, store loss
# (55) 5.4175 kWh/day.
store_boiler_volume_l: Optional[float]
store_insulation_thickness_mm: Optional[float]
separate_dhw_tests: Optional[int]
rejected_energy_proportion_r1: Optional[float]
loss_factor_f1_kwh_per_day: Optional[float]
@ -466,6 +478,12 @@ def parse_table_105_row(row: str) -> GasOilBoilerRecord:
comparative_hot_water_efficiency_pct=_parse_optional_float(fields[28]),
subsidiary_type=_parse_optional_int(fields[15]),
store_type=_parse_optional_int(fields[38]),
store_boiler_volume_l=(
_parse_optional_float(fields[41]) if len(fields) > 41 else None
),
store_insulation_thickness_mm=(
_parse_optional_float(fields[43]) if len(fields) > 43 else None
),
separate_dhw_tests=_parse_optional_int(fields[47]),
rejected_energy_proportion_r1=_parse_optional_float(fields[50]),
loss_factor_f1_kwh_per_day=_parse_optional_float(fields[51]),

View file

@ -537,6 +537,38 @@ def cylinder_temperature_factor_table_2b(
return factor
def storage_combi_temperature_factor_table_2b(
*,
store_type: int,
volume_l: float,
) -> float:
"""SAP 10.2 Table 2b (PDF p.159), "loss from Table 2" column, for a
storage combi boiler's internal water store (as opposed to a cylinder):
primary store (store_type 1): Vc >= 115 2.54
Vc < 115 2.54 + 0.00682 × (115 Vc)
secondary store (store_type 2): Vc >= 115 1.86
Vc < 115 1.86 + 0.00496 × (115 Vc)
These are much larger than the 0.60 cylinder base the store in a combi
is kept hot continuously, so SAP applies a heavier standing-loss factor.
Verified against the accredited Elmhurst P960 worksheet for pcdb_id 18415
(Worcester Greenstar Heatslave II, primary store 63 L 2.8946).
store_type 3 (CPSU) is a distinct Table 2b row (integrated thermal store /
CPSU) with its own airing-cupboard / separate-timer notes; no fixture
exercises it yet, so it strict-raises rather than silently mis-rate."""
if store_type == 1:
return 2.54 if volume_l >= 115.0 else 2.54 + 0.00682 * (115.0 - volume_l)
if store_type == 2:
return 1.86 if volume_l >= 115.0 else 1.86 + 0.00496 * (115.0 - volume_l)
raise NotImplementedError(
f"Table 2b storage-combi temperature factor for store_type={store_type} "
"(CPSU / integrated thermal store) is not yet wired — no fixture "
"exercises it (SAP 10.2 Table 2b, PDF p.159)."
)
# SAP 10.2 Table 3 (PDF p.159) — primary circuit loss for boilers and
# heat pumps connected to a hot water cylinder via insulated or
# uninsulated primary pipework. The spec lists the zero-loss
@ -629,6 +661,7 @@ def cylinder_storage_loss_monthly_kwh(
has_cylinder_thermostat: bool,
separately_timed_dhw: bool,
declared_loss_kwh_per_day: Optional[float] = None,
temperature_factor_override: Optional[float] = None,
) -> tuple[float, ...]:
"""SAP 10.2 §4 line (56)m water storage loss per spec (PDF p.136).
@ -649,9 +682,13 @@ def cylinder_storage_loss_monthly_kwh(
solar storage is present in the vessel callers handling solar
storage must adjust further per `(57)m = (56)m × [(47) - Vs] / (47)`.
"""
TF = cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
TF = (
temperature_factor_override
if temperature_factor_override is not None
else cylinder_temperature_factor_table_2b(
has_cylinder_thermostat=has_cylinder_thermostat,
separately_timed_dhw=separately_timed_dhw,
)
)
if declared_loss_kwh_per_day is not None:
# SAP 10.2 §4 (PDF p.136) branch a) — the lodged manufacturer's

View file

@ -89,6 +89,7 @@ from domain.sap10_calculator.rdsap.cert_to_inputs import (
_pv_eligible_demand_monthly_kwh, # pyright: ignore[reportPrivateUsage]
_primary_loss_applies, # pyright: ignore[reportPrivateUsage]
_rdsap_extract_fans_default, # pyright: ignore[reportPrivateUsage]
_rdsap_tariff, # pyright: ignore[reportPrivateUsage]
_pv_overshading_factor, # pyright: ignore[reportPrivateUsage]
_pv_pitch_deg, # pyright: ignore[reportPrivateUsage]
_responsiveness, # pyright: ignore[reportPrivateUsage]
@ -619,31 +620,81 @@ def test_off_peak_main_2_non_electric_priced_at_own_fuel() -> None:
assert abs(rate - 0.0423) <= 1e-9
def test_off_peak_main_2_electric_keeps_main_1_scalar() -> None:
# Arrange — both mains electric on an off-peak tariff. The electric
# second-main off-peak Table 12a split is the deferred §10a slice, so the
# helper keeps main 1's space-heating scalar (no per-system split here) to
# avoid regressing the off-peak electric cohort.
def test_off_peak_main_2_electric_billed_at_own_table_12a_rate() -> None:
# Arrange — HETEROGENEOUS electric mains on an off-peak (7-hour) tariff:
# Main 1 = modern-slimline STORAGE heaters (SAP 402, Table 12a
# OTHER_STORAGE_HEATERS → charged wholly off-peak → 5.5 p/kWh low rate);
# Main 2 = DIRECT-ACTING panel/convector heaters (SAP 691, Table 12a
# OTHER_DIRECT_ACTING_ELECTRIC → 100% high rate on 7-hour → 15.29 p/kWh).
# An electric Main 2 must be billed at its OWN Table 12a Grid 1 SH row —
# the on-peak panel half cannot ride Main 1's off-peak storage rate (§1684).
# This is the same per-system resolution Main 1 and a non-electric Main 2
# already use; the earlier "keep Main 1's scalar" deferral was a no-op on
# the HOMOGENEOUS cohort (storage+storage, direct+direct → equal rates) and
# only mis-billed the heterogeneous case, over-rating SAP (LRHA WAVE 3
# fixture 497655371974 storage+panel read +15.3 vs PasHub).
from domain.sap10_calculator.tables.table_12a import Tariff
main_1 = MainHeatingDetail(
has_fghrs=False, main_fuel_type=29, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2106,
main_heating_category=10, sap_main_heating_code=691,
main_1_storage = MainHeatingDetail(
has_fghrs=False, main_fuel_type=30, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2401,
main_heating_category=10, sap_main_heating_code=402,
main_heating_fraction=50,
)
main_2 = MainHeatingDetail(
has_fghrs=False, main_fuel_type=29, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2106,
main_2_direct = MainHeatingDetail(
has_fghrs=False, main_fuel_type=30, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2602,
main_heating_category=10, sap_main_heating_code=691,
main_heating_fraction=50,
)
epc = make_minimal_sap10_epc(
sap_building_parts=[make_building_part(construction_age_band="D")],
sap_heating=make_sap_heating(
main_heating_details=[main_1_storage, main_2_direct]
),
)
# Act
main_2_rate = _main_2_space_heating_fuel_cost_gbp_per_kwh(
epc, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
main_2_own_rate = _space_heating_fuel_cost_gbp_per_kwh(
main_2_direct, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
main_1_storage_rate = _space_heating_fuel_cost_gbp_per_kwh(
main_1_storage, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
# Assert — Main 2 bills at its own direct-acting on-peak rate (15.29 p),
# NOT Main 1's storage low rate (5.5 p).
assert abs(main_2_rate - main_2_own_rate) <= 1e-9
assert abs(main_2_rate - 0.1529) <= 1e-4
assert main_2_rate > main_1_storage_rate + 0.09
def test_off_peak_main_2_electric_homogeneous_storage_unchanged() -> None:
# Arrange — HOMOGENEOUS storage+storage on a 7-hour tariff. Per-system
# billing is a no-op here (both mains resolve to the same off-peak storage
# rate), which is why flipping the deferral (§1684) leaves the off-peak
# electric cohort untouched. Locks that invariant.
from domain.sap10_calculator.tables.table_12a import Tariff
def _storage() -> MainHeatingDetail:
return MainHeatingDetail(
has_fghrs=False, main_fuel_type=30, heat_emitter_type=0,
emitter_temperature="NA", main_heating_control=2401,
main_heating_category=10, sap_main_heating_code=402,
main_heating_fraction=50,
)
main_1 = _storage()
main_2 = _storage()
epc = make_minimal_sap10_epc(
sap_building_parts=[make_building_part(construction_age_band="D")],
sap_heating=make_sap_heating(main_heating_details=[main_1, main_2]),
)
# Act — main 2's rate equals main 1's space-heating scalar.
# Act
main_2_rate = _main_2_space_heating_fuel_cost_gbp_per_kwh(
epc, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
@ -651,7 +702,7 @@ def test_off_peak_main_2_electric_keeps_main_1_scalar() -> None:
main_1, Tariff.SEVEN_HOUR, SAP_10_2_SPEC_PRICES
)
# Assert
# Assert — identical storage rates; the flip does not disturb this cohort.
assert abs(main_2_rate - main_1_rate) <= 1e-9
@ -815,6 +866,74 @@ def test_cylinder_size_not_determined_still_incurs_storage_loss() -> None:
assert sum(storage_56m) > 0.0
def test_storage_combi_store_loss_matches_elmhurst_worksheet_18415() -> None:
# Arrange — SAP 10.2 Table 2 note b): a storage combi whose efficiency is
# from the PCDB includes its internal-store loss (56)m, which a combi
# (no cylinder) otherwise zeroes. Worcester Greenstar Heatslave II
# (pcdb_id 18415, store_type 1 primary, store volume 63 L, insulation
# 25 mm) → Table 2 L 0.0240 × Table 2a VF (120/63)^⅓ × Table 2b storage-
# combi TF 2.8946 = (55) 5.4175 kWh/day. Pinned to the accredited Elmhurst
# P960 worksheet for this boiler ((47) 63, (55) 5.4175, (56) Jan 167.94).
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_storage_combi_store_loss_override, # pyright: ignore[reportPrivateUsage]
)
record = gas_oil_boiler_record(18415)
assert record is not None
combi_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
country_code="ENG",
has_hot_water_cylinder=False,
sap_building_parts=[make_building_part(construction_age_band="F")],
sap_heating=make_sap_heating(
main_heating_details=[
MainHeatingDetail(
has_fghrs=False, main_fuel_type=28, heat_emitter_type=1,
emitter_temperature="Unknown", main_heating_control=2106,
main_heating_index_number=18415,
),
],
),
)
# Act
storage_56m = _storage_combi_store_loss_override(combi_epc, record)
# Assert — (56) Jan (31 days) = 5.4175 × 31 = 167.94; annual ≈ 1977 kWh.
assert storage_56m is not None
assert abs(storage_56m[0] / 31.0 - 5.4175) <= 1e-3
assert abs(storage_56m[0] - 167.94) <= 5e-2
assert abs(sum(storage_56m) - 1977.4) <= 1.0
def test_storage_combi_store_loss_none_without_pcdb_store() -> None:
# Arrange — the override must NOT fire for (a) a lodged cylinder (the
# cylinder path handles it), (b) a non-storage combi (store_type 0), or
# (c) a None PCDB record. Each returns None so the cascade keeps its
# existing behaviour.
from domain.sap10_calculator.rdsap.cert_to_inputs import (
_storage_combi_store_loss_override, # pyright: ignore[reportPrivateUsage]
)
storage_record = gas_oil_boiler_record(18415)
non_storage_record = gas_oil_boiler_record(18204) # store_type 0
combi_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
has_hot_water_cylinder=False,
sap_building_parts=[make_building_part(construction_age_band="F")],
sap_heating=make_sap_heating(main_heating_details=[]),
)
cyl_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2, country_code="ENG",
has_hot_water_cylinder=True,
sap_building_parts=[make_building_part(construction_age_band="F")],
sap_heating=make_sap_heating(main_heating_details=[]),
)
# Act / Assert
assert _storage_combi_store_loss_override(cyl_epc, storage_record) is None
assert _storage_combi_store_loss_override(combi_epc, non_storage_record) is None
assert _storage_combi_store_loss_override(combi_epc, None) is None
def test_cylinder_size_inaccessible_code_5_solid_fuel_boiler_uses_160l() -> None:
# Arrange — RdSAP 10 §10.5 Table 28: an "Inaccessible" cylinder (code 5)
# heated "from a solid fuel boiler" uses 160 litres.
@ -3185,6 +3304,7 @@ def test_dual_fuel_secondary_api_enum_9_prices_as_dual_fuel_not_lpg() -> None:
gas_boiler_main,
2, # standard (single-rate) meter
SAP_10_2_SPEC_PRICES,
_rdsap_tariff(dual_fuel_secondary_epc),
)
secondary_factor_code = _secondary_fuel_code(dual_fuel_secondary_epc)
@ -4080,6 +4200,8 @@ def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate(
main_heating_category=None,
sap_main_heating_code=402, # electric storage heaters
)
from dataclasses import replace
dual_meter_off_peak_epc = make_minimal_sap10_epc(
total_floor_area_m2=_TYPICAL_TFA_M2,
habitable_rooms_count=4,
@ -4089,16 +4211,28 @@ def test_secondary_electric_off_peak_bills_at_table_12a_direct_acting_high_rate(
# secondary_fuel_type omitted → §A.2.2 portable electric default
),
)
# A storage-heater dwelling on a Dual meter resolves to the 7-hour tariff
# by SAP §12; set it on the cert so the cost path reads the dwelling's
# tariff (`_rdsap_tariff`), the same source the main heating bills on —
# NOT the bare meter string. (A room/panel-heater dwelling on a Dual meter
# is instead 10-hour, so its secondary blends 0.50 → 11.09 p, not 15.29.)
dual_meter_off_peak_epc = replace(
dual_meter_off_peak_epc,
sap_energy_source=replace(
dual_meter_off_peak_epc.sap_energy_source, meter_type="1"
),
)
# Act
secondary_rate_gbp_per_kwh = _secondary_fuel_cost_gbp_per_kwh(
dual_meter_off_peak_epc.sap_heating,
storage_heater_main,
1, # Dual meter → 7-hour off-peak tariff
dual_meter_off_peak_epc.sap_energy_source.meter_type,
SAP_10_2_SPEC_PRICES,
_rdsap_tariff(dual_meter_off_peak_epc),
)
# Assert — 1.00 × 15.29 p + 0.00 × 5.50 p = 15.29 p/kWh = £0.1529.
# Assert — storage main → 7-hour: 1.00 × 15.29 p + 0.00 × 5.50 p = £0.1529.
assert abs(secondary_rate_gbp_per_kwh - 0.1529) <= 1e-6
@ -6655,6 +6789,8 @@ def test_pcdb_combi_loss_override_returns_none_or_raises_for_untested_or_storage
final_year_of_manufacture=None,
subsidiary_type=0,
store_type=0,
store_boiler_volume_l=None,
store_insulation_thickness_mm=None,
separate_dhw_tests=2,
rejected_energy_proportion_r1=0.015,
loss_factor_f1_kwh_per_day=0.5,

View file

@ -30,6 +30,38 @@ def test_gas_oil_boiler_record_returns_verified_baxi_98() -> None:
assert record.summer_efficiency_pct == 56.0
def test_gas_oil_boiler_record_surfaces_storage_combi_store_fields() -> None:
"""Worcester GREENSTAR HEATSLAVE II (pcdb_id 18415) is an oil storage
combi: PCDF field 42 (store boiler volume) = 63 L, field 44 (store
insulation thickness) = 25 mm. These drive the SAP 10.2 Table 2 store
loss (note b: included when combi efficiency is from the PCDB). Verified
against the accredited Elmhurst P960 worksheet for this boiler (store
volume 63 L, loss factor 0.0240 = factory-insulated 25 mm)."""
# Arrange
# Act
record = gas_oil_boiler_record(18415)
# Assert
assert record is not None
assert record.store_type == 1 # primary water store
assert record.store_boiler_volume_l == 63.0
assert record.store_insulation_thickness_mm == 25.0
def test_gas_oil_boiler_record_store_fields_none_for_non_storage_combi() -> None:
"""A non-storage combi (store_type 0) leaves the store volume /
insulation fields blank None, so the store-loss path is not triggered."""
# Arrange
# Act
record = gas_oil_boiler_record(18204)
# Assert
assert record is not None
assert record.store_type == 0
assert record.store_boiler_volume_l is None
assert record.store_insulation_thickness_mm is None
def test_gas_oil_boiler_record_returns_none_for_unknown_pcdb_id() -> None:
"""`main_heating_index_number` values not in Table 105 return None so
`cert_to_inputs` can fall back to the Table 4a/4b category default."""

View file

@ -676,6 +676,34 @@ def test_water_efficiency_monthly_via_equation_d1_weights_winter_summer_per_mont
assert monthly[0] == pytest.approx(num / denom, abs=1e-6)
def test_storage_combi_temperature_factor_table_2b_primary_store() -> None:
# Arrange — SAP 10.2 Table 2b (PDF p.159), "Storage combi boiler,
# primary store", loss-from-Table-2 column:
# Vc >= 115 L → 2.54
# Vc < 115 L → 2.54 + 0.00682 × (115 Vc)
# Verified against the accredited Elmhurst P960 worksheet for pcdb_id
# 18415 (Worcester Greenstar Heatslave II): Vc 63 L → 2.8946.
from domain.sap10_calculator.worksheet.water_heating import (
storage_combi_temperature_factor_table_2b,
)
# Act / Assert — Vc 63 L primary store reproduces the worksheet's (53).
assert abs(
storage_combi_temperature_factor_table_2b(store_type=1, volume_l=63.0)
- 2.8946
) <= 1e-4
# Vc >= 115 L flattens to the 2.54 floor.
assert abs(
storage_combi_temperature_factor_table_2b(store_type=1, volume_l=150.0)
- 2.54
) <= 1e-9
# Secondary store (store_type 2): 1.86 + 0.00496 × (115 Vc).
assert abs(
storage_combi_temperature_factor_table_2b(store_type=2, volume_l=63.0)
- (1.86 + 0.00496 * 52)
) <= 1e-9
def test_cylinder_storage_loss_uses_declared_loss_factor_times_temp_factor() -> None:
# Arrange — SAP 10.2 §4 branch a) (PDF p.136): when the manufacturer's
# declared cylinder loss factor (kWh/day) is lodged, storage loss