mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-27 23:35:01 +00:00
Charge an oil storage-combi its PCDB internal-store water-heating loss 🟩
An oil STORAGE combi (Worcester Greenstar Heatslave II, PCDB 18415,
store_type 1) zeroed its internal-store loss: pcdb_combi_loss_override
defers on store_type in {1,2,3} and a combi has no cylinder, so line
(56) dropped to zero — under-costing hot water and over-rating SAP by
+2.0..+4.9 across the five LRHA WAVE 3 oil combis.
SAP 10.2 Table 2 note b) now includes the store loss when the combi's
efficiency is from the PCDB: the PCDB store volume (field 42) and
insulation thickness (field 44) feed Table 2/2a with the Table 2b
storage-combi temperature factor, reproducing the accredited Elmhurst
P960 worksheet exactly (5.4175 kWh/day, Jan 167.94, ~1977 kWh/yr).
Cohort: within-0.5 55.3% -> 58.3%, MAE 0.675 -> 0.531; ratchet
tightened accordingly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a21d2cc579
commit
ca27f0b9d8
8 changed files with 322 additions and 6 deletions
|
|
@ -119,8 +119,17 @@ _MANIFEST_PATH = _FIXTURES_DIR / "manifest.json"
|
|||
# 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.
|
||||
_MIN_WITHIN_HALF: float = 0.55
|
||||
_MAX_SAP_MAE: float = 0.68
|
||||
# 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 "
|
||||
|
|
@ -203,6 +212,29 @@ def test_dual_main_heating_models_both_systems() -> 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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -6201,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,
|
||||
|
|
@ -7132,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
|
||||
|
|
@ -7415,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, ...],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -866,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.
|
||||
|
|
@ -6721,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