mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-30 13:10:47 +00:00
Map full-SAP heating systems onto the domain SapHeating model 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
125ff6f4dd
commit
cb4d080da2
5 changed files with 162 additions and 11 deletions
24
CONTEXT.md
24
CONTEXT.md
|
|
@ -350,6 +350,30 @@ _Avoid_: new API, current API
|
|||
The auth credential required by the New EPC API; stored in the `EPC_AUTH_TOKEN` environment variable.
|
||||
_Avoid_: API key, auth token, secret
|
||||
|
||||
## Team
|
||||
|
||||
Who's who on the project, so commit authorship and review history read
|
||||
correctly. The git author names below map to people as follows.
|
||||
|
||||
**Khalim Conn-Kowlessar**:
|
||||
CTO and the technical decision-maker — the boss. Treat his calls as
|
||||
authoritative when guidance conflicts. Git author: `Khalim
|
||||
Conn-Kowlessar <kconnkowlessar@gmail.com>` (also commits as `KhalimCK`,
|
||||
same email).
|
||||
|
||||
**Daniel Roth ("Dan")**:
|
||||
Software engineer. Git author: `Daniel Roth <daniel_roth@hotmail.co.uk>`
|
||||
(also `Daniel Roth <36244509+dancafc@users.noreply.github.com>`).
|
||||
|
||||
**Jun-te Kim ("Junte")**:
|
||||
Software engineer. Git author: `Jun-te Kim <junte.kim@mealcraft.com>`
|
||||
(also `<juntekim@googlemail.com>` and `<39764191+kimjunte@users.noreply.github.com>`).
|
||||
|
||||
**Michael Duong**:
|
||||
Contractor (software). Git author: `Michael Duong <michael123ster@gmail.com>`
|
||||
(also commits as `<michaelduong22@gmail.com>` and from local machine
|
||||
addresses `<michaelduong@Michaels-MacBook-Pro.local>`, `<michael@11s-MacBook.local>`).
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Property** represents a single physical dwelling for modelling; identified by `(portfolio_id, UPRN)` or `(portfolio_id, landlord_property_id)`.
|
||||
|
|
|
|||
|
|
@ -710,11 +710,10 @@ class EpcPropertyDataMapper:
|
|||
_sap_17_1_building_part(bp, i)
|
||||
for i, bp in enumerate(schema.sap_building_parts)
|
||||
],
|
||||
sap_heating=SapHeating(
|
||||
instantaneous_wwhrs=InstantaneousWwhrs(),
|
||||
main_heating_details=[],
|
||||
has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true",
|
||||
),
|
||||
# D6: full-SAP heating — translate the differing field names onto
|
||||
# the domain SapHeating the calculator consumes (PCDB efficiency via
|
||||
# main_heating_index_number; water cascade via water_heating_code).
|
||||
sap_heating=_sap_17_1_heating(schema),
|
||||
sap_energy_source=SapEnergySource(
|
||||
mains_gas=False,
|
||||
meter_type="",
|
||||
|
|
@ -2505,6 +2504,42 @@ _SAP_LIVING_AREA_FRACTION_BY_ROOMS: Final[Dict[int, float]] = {
|
|||
}
|
||||
|
||||
|
||||
def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating:
|
||||
"""D6: map full-SAP `sap_heating` onto the domain `SapHeating`. Field names
|
||||
differ from RdSAP — `is_flue_fan_present`→`fan_flue_present`,
|
||||
`main_heating_flue_type`→`boiler_flue_type`, `water_fuel_type`→
|
||||
`water_heating_fuel`; has_fghrs isn't lodged (default False)."""
|
||||
sh = schema.sap_heating
|
||||
return SapHeating(
|
||||
instantaneous_wwhrs=InstantaneousWwhrs(),
|
||||
has_fixed_air_conditioning=sh.has_fixed_air_conditioning == "true",
|
||||
water_heating_code=sh.water_heating_code,
|
||||
water_heating_fuel=sh.water_fuel_type,
|
||||
main_heating_details=[
|
||||
MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=d.main_fuel_type,
|
||||
heat_emitter_type=d.heat_emitter_type,
|
||||
emitter_temperature=d.emitter_temperature,
|
||||
main_heating_control=d.main_heating_control,
|
||||
fan_flue_present=d.is_flue_fan_present == "true",
|
||||
boiler_flue_type=d.main_heating_flue_type,
|
||||
central_heating_pump_age=d.central_heating_pump_age,
|
||||
main_heating_index_number=d.main_heating_index_number,
|
||||
main_heating_number=d.main_heating_number,
|
||||
main_heating_category=d.main_heating_category,
|
||||
main_heating_fraction=(
|
||||
int(d.main_heating_fraction)
|
||||
if d.main_heating_fraction is not None
|
||||
else None
|
||||
),
|
||||
main_heating_data_source=d.main_heating_data_source,
|
||||
)
|
||||
for d in sh.main_heating_details
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
|
||||
"""D3: pick the habitable-room count whose Table 27 fraction is closest to
|
||||
the measured living_area/total_floor_area, so the engine's Table-27 path
|
||||
|
|
|
|||
|
|
@ -232,6 +232,31 @@ class TestFromSapSchema17_1LivingArea:
|
|||
assert self._map("sap_17_1_flat.json").habitable_rooms_count == 3
|
||||
|
||||
|
||||
class TestFromSapSchema17_1Heating:
|
||||
"""Slice D6: full-SAP sap_heating (differing field names) maps onto the
|
||||
domain SapHeating + MainHeatingDetail the calculator consumes."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample(self) -> EpcPropertyData:
|
||||
schema = from_dict(SapSchema17_1, load("sap_17_1.json"))
|
||||
return EpcPropertyDataMapper.from_sap_schema_17_1(schema)
|
||||
|
||||
def test_main_heating_detail_mapped(self, sample: EpcPropertyData) -> None:
|
||||
details = sample.sap_heating.main_heating_details
|
||||
assert len(details) == 1
|
||||
assert details[0].main_fuel_type == 1
|
||||
# PCDB boiler index drives the efficiency lookup — must survive.
|
||||
assert details[0].main_heating_index_number == 17929
|
||||
|
||||
def test_flue_fan_field_renamed(self, sample: EpcPropertyData) -> None:
|
||||
# full SAP lodges is_flue_fan_present; domain field is fan_flue_present.
|
||||
assert sample.sap_heating.main_heating_details[0].fan_flue_present is True
|
||||
|
||||
def test_water_heating_carried(self, sample: EpcPropertyData) -> None:
|
||||
assert sample.sap_heating.water_heating_code == 901
|
||||
assert sample.sap_heating.water_heating_fuel == 1
|
||||
|
||||
|
||||
class TestFromSapSchema17_1Perimeter:
|
||||
"""Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the
|
||||
measured exposed-wall areas (wall_type 1/2/3) ÷ Σ storey-heights, with party
|
||||
|
|
|
|||
|
|
@ -88,6 +88,41 @@ class SapBuildingPart:
|
|||
building_part_number: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapMainHeatingDetail:
|
||||
"""One main-heating system. Field names differ from RdSAP (e.g.
|
||||
`is_flue_fan_present` vs `fan_flue_present`, `main_heating_flue_type` vs
|
||||
`boiler_flue_type`); the mapper translates them."""
|
||||
|
||||
main_fuel_type: int
|
||||
heat_emitter_type: int
|
||||
emitter_temperature: Union[int, str]
|
||||
main_heating_control: int
|
||||
main_heating_number: Optional[int] = None
|
||||
main_heating_category: Optional[int] = None
|
||||
main_heating_fraction: Optional[Union[int, float]] = None
|
||||
main_heating_data_source: Optional[int] = None
|
||||
main_heating_index_number: Optional[int] = None
|
||||
main_heating_flue_type: Optional[int] = None
|
||||
is_flue_fan_present: Optional[str] = None
|
||||
central_heating_pump_age: Optional[int] = None
|
||||
load_or_weather_compensation: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapHeating:
|
||||
"""Heating + hot-water systems. `water_fuel_type`/`water_heating_code` drive
|
||||
the hot-water cascade; `main_heating_index_number` keys the PCDB efficiency
|
||||
lookup."""
|
||||
|
||||
main_heating_details: List[SapMainHeatingDetail] = field(default_factory=list)
|
||||
water_fuel_type: Optional[int] = None
|
||||
water_heating_code: Optional[int] = None
|
||||
has_hot_water_cylinder: Optional[str] = None
|
||||
has_fixed_air_conditioning: Optional[str] = None
|
||||
secondary_heating_category: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnergyElement:
|
||||
"""A fabric/system element with its lodged description. On full SAP the
|
||||
|
|
@ -121,6 +156,7 @@ class SapSchema17_1:
|
|||
floors: List[EnergyElement]
|
||||
sap_opening_types: List[SapOpeningType]
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
sap_heating: SapHeating
|
||||
# measured living-room area (m²); the engine consumes it via a back-solved
|
||||
# habitable_rooms_count (Table 27). Optional — 100% present in the corpus.
|
||||
living_area: Optional[Union[int, float]] = None
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ class RealCertExpectation:
|
|||
`unsupported_schema=True` marks a cert whose schema the mapper can't
|
||||
yet consume (full SAP vs RdSAP). Those cases are expected to FAIL at
|
||||
the mapper until support lands — see `test_real_cert_sap_score`.
|
||||
|
||||
`known_bug_xfail` marks a cert whose `sap_score` is the verified
|
||||
ground truth (e.g. reproduced in Elmhurst on identical inputs) but
|
||||
which the engine doesn't yet hit because of a localised, documented
|
||||
calculator bug. Strict xfail: when the bug is fixed the test flips to
|
||||
a failure, prompting removal of the marker.
|
||||
"""
|
||||
|
||||
schema: str
|
||||
|
|
@ -63,6 +69,7 @@ class RealCertExpectation:
|
|||
hot_water_kwh_per_yr: Optional[float] = None
|
||||
co2_kg_per_yr: Optional[float] = None
|
||||
unsupported_schema: bool = False
|
||||
known_bug_xfail: Optional[str] = None
|
||||
|
||||
|
||||
# Absolute tolerance for float pins — matches the Elmhurst cohort.
|
||||
|
|
@ -93,6 +100,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
|
|||
sap_score=83,
|
||||
unsupported_schema=True,
|
||||
),
|
||||
# UPRN 10002468137 → cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1,
|
||||
# all-electric high-heat-retention storage heaters on Economy 7, solid-
|
||||
# brick uninsulated end-terrace. Ground truth is Elmhurst RdSAP10 = 60,
|
||||
# reproduced on identical inputs (summary + full SAP 10.2 worksheet saved
|
||||
# alongside: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The engine
|
||||
# produces 62 — a +2 over-rating localised to OFF-PEAK WATER HEATING:
|
||||
# the worksheet (lines 243-246) prices the 7-hour off-peak immersion at a
|
||||
# Table 13 split (19.36% @ 15.29p high + 80.64% @ 5.5p low), but the engine
|
||||
# prices 100% at the 5.5p low rate, under-costing the bill (£595.68 vs
|
||||
# £629.67) → lower ECF (2.69 vs 2.84) → SAP 62 not 60. (Space heating 100%
|
||||
# off-peak IS correct for storage heaters — the worksheet agrees.) Strict
|
||||
# xfail until the off-peak water-heating rate split is implemented.
|
||||
RealCertExpectation(
|
||||
schema="RdSAP-Schema-17.1",
|
||||
sample="uprn_10002468137",
|
||||
cert_num="0215-2818-7357-9703-2145",
|
||||
sap_score=60,
|
||||
known_bug_xfail=(
|
||||
"off-peak (7-hour) water-heating high/low rate split not applied — "
|
||||
"engine prices 100% at the low rate; see elmhurst_worksheet.pdf (243-246)"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -100,17 +129,19 @@ def _as_param(exp: RealCertExpectation) -> object:
|
|||
"""Wrap a case as a pytest param, marking unsupported-schema certs as
|
||||
strict xfails (they raise `ValueError` at the mapper until full-SAP
|
||||
support exists; strict so the marker can't silently outlive the gap)."""
|
||||
marks = (
|
||||
[
|
||||
marks = []
|
||||
if exp.unsupported_schema:
|
||||
marks.append(
|
||||
pytest.mark.xfail(
|
||||
reason="full-SAP (non-RdSAP) schema not yet supported by the mapper",
|
||||
raises=ValueError,
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
if exp.unsupported_schema
|
||||
else []
|
||||
)
|
||||
)
|
||||
elif exp.known_bug_xfail is not None:
|
||||
marks.append(
|
||||
pytest.mark.xfail(reason=exp.known_bug_xfail, strict=True)
|
||||
)
|
||||
return pytest.param(exp, id=exp.sample, marks=marks)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue