diff --git a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py index c8ccc34e4..cfdcd54fd 100644 --- a/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py +++ b/backend/documents_parser/tests/test_pashub_sap_accuracy_lrha_wave3.py @@ -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", diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 448c00c29..38b72ecc3 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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, ...], diff --git a/domain/sap10_calculator/tables/pcdb/__init__.py b/domain/sap10_calculator/tables/pcdb/__init__.py index 6fd2991e8..7cf315b48 100644 --- a/domain/sap10_calculator/tables/pcdb/__init__.py +++ b/domain/sap10_calculator/tables/pcdb/__init__.py @@ -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"), diff --git a/domain/sap10_calculator/tables/pcdb/parser.py b/domain/sap10_calculator/tables/pcdb/parser.py index 27e1b3d1a..9b39ce597 100644 --- a/domain/sap10_calculator/tables/pcdb/parser.py +++ b/domain/sap10_calculator/tables/pcdb/parser.py @@ -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]), diff --git a/domain/sap10_calculator/worksheet/water_heating.py b/domain/sap10_calculator/worksheet/water_heating.py index dad601e22..126989ab6 100644 --- a/domain/sap10_calculator/worksheet/water_heating.py +++ b/domain/sap10_calculator/worksheet/water_heating.py @@ -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 diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 95cd1e887..d20230c18 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -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, diff --git a/tests/domain/sap10_calculator/test_pcdb_lookup.py b/tests/domain/sap10_calculator/test_pcdb_lookup.py index e2d289595..ba78a2567 100644 --- a/tests/domain/sap10_calculator/test_pcdb_lookup.py +++ b/tests/domain/sap10_calculator/test_pcdb_lookup.py @@ -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.""" diff --git a/tests/domain/sap10_calculator/worksheet/test_water_heating.py b/tests/domain/sap10_calculator/worksheet/test_water_heating.py index c7a2db79a..3360cd4b2 100644 --- a/tests/domain/sap10_calculator/worksheet/test_water_heating.py +++ b/tests/domain/sap10_calculator/worksheet/test_water_heating.py @@ -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