mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
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:
commit
f4932569ba
14 changed files with 1090 additions and 127 deletions
|
|
@ -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:"
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
130
docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md
Normal file
130
docs/HANDOVER_1684_ELMHURST_INPUTS_497655371974.md
Normal 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 1976–1982)
|
||||
- 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`.
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue