mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
S0380.238: single-point instantaneous water heaters incur no distribution loss
Water heating SAP code 909 (electric instantaneous) and 907 (single-point
gas) heat water at the point of use, serving one outlet with no
distribution pipework. Per SAP 10.2 §4 (p.23, l.1416): "'Single-point'
heaters, which are located at the point of use and serve only one outlet,
do not have distribution losses either." So worksheet (46)m = 0 and the
heat-required line collapses to SAP 10.2 worksheet l.7704
(62)m = 0.85 × (45)m + (46)m + (57)m + (59)m + (61)m
= 0.85 × (45)m (all loss terms zero for a no-cylinder system).
`distribution_loss_monthly_kwh` already supported the
`is_instantaneous_at_point_of_use` flag (and its docstring already named
codes 907/909), but `water_heating_from_cert` hard-coded it to False, so
the cascade applied (46)m = 0.15 × (45)m to single-point heaters. That
0.15 distribution loss exactly cancelled the 0.85 reduction, leaving
(62)m = (45)m. On the cat-10 room-heater fixture (ref 001431, code 909)
that over-stated the water fuel (219) as 2082.6250 instead of the
worksheet's 1770.2313, and inflated the (65)m heat gains (692.47 vs
worksheet 442.55) which in turn suppressed space-heating demand.
Thread the cert's existing instantaneous flag (`_INSTANTANEOUS_WATER_CODES`
= {907, 909}) through `_water_heating_worksheet_and_gains` into both the
demand-pass and final `water_heating_from_cert` calls.
Pins (219) water fuel = 1770.2313 at abs 1e-4 via the extractor → mapper →
rating cascade. §4 suite green (2414 passed, 1 skipped); no existing
fixture exercised the 907/909 path. The residual space-heating fuel gap
((211) 11158.59 vs worksheet 11563.17) this exposes is a separate cause —
next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
97f44b5364
commit
cbdee9ec3c
5 changed files with 105 additions and 1 deletions
|
|
@ -0,0 +1,101 @@
|
||||||
|
"""Worksheet pins for the cat-10 electric-room-heater dwelling (ref 001431).
|
||||||
|
|
||||||
|
Fixture: `sap worksheets/Recommendations Elmhurst Files/main heating/high
|
||||||
|
heat retention storage heaters/electric room heaters/before/` — Summary
|
||||||
|
(site-notes input) + P960 (the `(1)..(286)` worksheet ground truth). The
|
||||||
|
dwelling lodges main `sap_main_heating_code=691` (electric room heaters),
|
||||||
|
control `2601`, an `18 Hour` meter, and water heating `sap_code=909`
|
||||||
|
(electric instantaneous, single-point at the point of use — NO cylinder,
|
||||||
|
NO solar, NO WWHRS).
|
||||||
|
|
||||||
|
Per [[feedback-worksheet-not-api-reference]] + [[feedback-zero-error-strict]]
|
||||||
|
the worksheet PDF is the 1e-4 target. Each pin below is a P960 line ref
|
||||||
|
transcribed to 4 d.p. and asserted via `abs(x - y) <= 1e-4` against the
|
||||||
|
extractor → mapper → cascade output.
|
||||||
|
|
||||||
|
Because the SAP 10.2 worksheet computes the rating block (UK-average
|
||||||
|
climate, Table 12 regulated prices) separately from the EPC block
|
||||||
|
(postcode climate, Table 32 prices), the rating-mode cascade
|
||||||
|
(`cert_to_inputs`) is pinned against the rating block and the demand-mode
|
||||||
|
cascade (`cert_to_demand_inputs`) against the EPC block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||||
|
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||||
|
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
|
||||||
|
from domain.sap10_calculator.rdsap.cert_to_inputs import (
|
||||||
|
SAP_10_2_SPEC_PRICES,
|
||||||
|
cert_to_inputs,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FIXTURE_DIR = (
|
||||||
|
Path(__file__).parents[3]
|
||||||
|
/ "sap worksheets/Recommendations Elmhurst Files/main heating"
|
||||||
|
/ "high heat retention storage heaters/electric room heaters/before"
|
||||||
|
)
|
||||||
|
|
||||||
|
# P960 line ref (219) "Water heating fuel used" — rating block. The water
|
||||||
|
# heater is electric (efficiency (216) = 100 %), so (219) == (64) output.
|
||||||
|
_WORKSHEET_LINE_219_WATER_FUEL_KWH = 1770.2313
|
||||||
|
|
||||||
|
_ABS_TOLERANCE = 0.0001
|
||||||
|
|
||||||
|
|
||||||
|
def _summary_pdf_to_pages(pdf: Path) -> list[str]:
|
||||||
|
"""Summary PDF → one Textract-style token string per page (the same
|
||||||
|
`pdftotext -layout` → whitespace-split preprocessing the rest of the
|
||||||
|
documents_parser chain tests use)."""
|
||||||
|
page_count_text = subprocess.run(
|
||||||
|
["pdfinfo", str(pdf)], capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
page_count_match = re.search(r"Pages:\s+(\d+)", page_count_text)
|
||||||
|
assert page_count_match is not None, f"no page count in {pdf}"
|
||||||
|
page_count = int(page_count_match.group(1))
|
||||||
|
pages: list[str] = []
|
||||||
|
for page_index in range(1, page_count + 1):
|
||||||
|
layout = subprocess.run(
|
||||||
|
[
|
||||||
|
"pdftotext", "-layout",
|
||||||
|
"-f", str(page_index), "-l", str(page_index),
|
||||||
|
str(pdf), "-",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
).stdout
|
||||||
|
pages.append(
|
||||||
|
"\n".join(
|
||||||
|
token
|
||||||
|
for line in layout.splitlines()
|
||||||
|
for token in re.split(r"\s{2,}", line.strip())
|
||||||
|
if token
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def test_electric_room_heater_water_fuel_matches_worksheet_line_219() -> None:
|
||||||
|
# Arrange — route the before/ Summary through the full extractor →
|
||||||
|
# mapper → rating cascade. Water heating SAP code 909 is a single-
|
||||||
|
# point electric instantaneous heater at the point of use, so per
|
||||||
|
# SAP 10.2 §4 (p.23, l.1416) it has NO distribution loss: worksheet
|
||||||
|
# (46)m = 0 and (62)m = 0.85 × (45)m collapses to the (219) fuel.
|
||||||
|
summary_pdf = next(_FIXTURE_DIR.glob("Summary_*.pdf"))
|
||||||
|
pages = _summary_pdf_to_pages(summary_pdf)
|
||||||
|
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||||
|
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||||
|
expected_water_fuel_kwh = _WORKSHEET_LINE_219_WATER_FUEL_KWH
|
||||||
|
|
||||||
|
# Act
|
||||||
|
rating = calculate_sap_from_inputs(
|
||||||
|
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES),
|
||||||
|
)
|
||||||
|
actual_water_fuel_kwh = rating.hot_water_kwh_per_yr
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert abs(actual_water_fuel_kwh - expected_water_fuel_kwh) <= _ABS_TOLERANCE
|
||||||
|
|
@ -5653,6 +5653,7 @@ def _water_heating_worksheet_and_gains(
|
||||||
primary_loss_monthly_kwh_override=primary_loss_override,
|
primary_loss_monthly_kwh_override=primary_loss_override,
|
||||||
has_electric_shower=has_electric_shower,
|
has_electric_shower=has_electric_shower,
|
||||||
electric_shower_count=electric_shower_count,
|
electric_shower_count=electric_shower_count,
|
||||||
|
is_instantaneous_at_point_of_use=is_instantaneous,
|
||||||
)
|
)
|
||||||
solar_hw_override = _solar_hw_monthly_override(
|
solar_hw_override = _solar_hw_monthly_override(
|
||||||
epc=epc,
|
epc=epc,
|
||||||
|
|
@ -5670,6 +5671,7 @@ def _water_heating_worksheet_and_gains(
|
||||||
solar_water_heating_monthly_kwh_override=solar_hw_override,
|
solar_water_heating_monthly_kwh_override=solar_hw_override,
|
||||||
has_electric_shower=has_electric_shower,
|
has_electric_shower=has_electric_shower,
|
||||||
electric_shower_count=electric_shower_count,
|
electric_shower_count=electric_shower_count,
|
||||||
|
is_instantaneous_at_point_of_use=is_instantaneous,
|
||||||
)
|
)
|
||||||
return wh_result, wh_result.heat_gains_monthly_kwh
|
return wh_result, wh_result.heat_gains_monthly_kwh
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -843,6 +843,7 @@ def water_heating_from_cert(
|
||||||
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
electric_shower_monthly_kwh_override: Optional[tuple[float, ...]] = None,
|
||||||
has_electric_shower: bool = False,
|
has_electric_shower: bool = False,
|
||||||
electric_shower_count: int = 0,
|
electric_shower_count: int = 0,
|
||||||
|
is_instantaneous_at_point_of_use: bool = False,
|
||||||
) -> WaterHeatingResult:
|
) -> WaterHeatingResult:
|
||||||
"""SAP 10.2 §4 orchestrator — chain every line ref from (42) through
|
"""SAP 10.2 §4 orchestrator — chain every line ref from (42) through
|
||||||
(65) for a combi-gas dwelling with optional PCDB-backed combi loss.
|
(65) for a combi-gas dwelling with optional PCDB-backed combi loss.
|
||||||
|
|
@ -912,7 +913,7 @@ def water_heating_from_cert(
|
||||||
)
|
)
|
||||||
distribution = distribution_loss_monthly_kwh(
|
distribution = distribution_loss_monthly_kwh(
|
||||||
monthly_energy_content_kwh=energy_content,
|
monthly_energy_content_kwh=energy_content,
|
||||||
is_instantaneous_at_point_of_use=False,
|
is_instantaneous_at_point_of_use=is_instantaneous_at_point_of_use,
|
||||||
)
|
)
|
||||||
combi = (
|
combi = (
|
||||||
combi_loss_monthly_kwh_override
|
combi_loss_monthly_kwh_override
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Loading…
Add table
Reference in a new issue