mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +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.
|
The auth credential required by the New EPC API; stored in the `EPC_AUTH_TOKEN` environment variable.
|
||||||
_Avoid_: API key, auth token, secret
|
_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
|
## Relationships
|
||||||
|
|
||||||
- A **Property** represents a single physical dwelling for modelling; identified by `(portfolio_id, UPRN)` or `(portfolio_id, landlord_property_id)`.
|
- 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)
|
_sap_17_1_building_part(bp, i)
|
||||||
for i, bp in enumerate(schema.sap_building_parts)
|
for i, bp in enumerate(schema.sap_building_parts)
|
||||||
],
|
],
|
||||||
sap_heating=SapHeating(
|
# D6: full-SAP heating — translate the differing field names onto
|
||||||
instantaneous_wwhrs=InstantaneousWwhrs(),
|
# the domain SapHeating the calculator consumes (PCDB efficiency via
|
||||||
main_heating_details=[],
|
# main_heating_index_number; water cascade via water_heating_code).
|
||||||
has_fixed_air_conditioning=schema.has_fixed_air_conditioning == "true",
|
sap_heating=_sap_17_1_heating(schema),
|
||||||
),
|
|
||||||
sap_energy_source=SapEnergySource(
|
sap_energy_source=SapEnergySource(
|
||||||
mains_gas=False,
|
mains_gas=False,
|
||||||
meter_type="",
|
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:
|
def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int:
|
||||||
"""D3: pick the habitable-room count whose Table 27 fraction is closest to
|
"""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
|
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
|
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:
|
class TestFromSapSchema17_1Perimeter:
|
||||||
"""Slice 5 (D1): full SAP lodges no heat-loss perimeter; derive it from the
|
"""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
|
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
|
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
|
@dataclass
|
||||||
class EnergyElement:
|
class EnergyElement:
|
||||||
"""A fabric/system element with its lodged description. On full SAP the
|
"""A fabric/system element with its lodged description. On full SAP the
|
||||||
|
|
@ -121,6 +156,7 @@ class SapSchema17_1:
|
||||||
floors: List[EnergyElement]
|
floors: List[EnergyElement]
|
||||||
sap_opening_types: List[SapOpeningType]
|
sap_opening_types: List[SapOpeningType]
|
||||||
sap_building_parts: List[SapBuildingPart]
|
sap_building_parts: List[SapBuildingPart]
|
||||||
|
sap_heating: SapHeating
|
||||||
# measured living-room area (m²); the engine consumes it via a back-solved
|
# measured living-room area (m²); the engine consumes it via a back-solved
|
||||||
# habitable_rooms_count (Table 27). Optional — 100% present in the corpus.
|
# habitable_rooms_count (Table 27). Optional — 100% present in the corpus.
|
||||||
living_area: Optional[Union[int, float]] = None
|
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
|
`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
|
yet consume (full SAP vs RdSAP). Those cases are expected to FAIL at
|
||||||
the mapper until support lands — see `test_real_cert_sap_score`.
|
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
|
schema: str
|
||||||
|
|
@ -63,6 +69,7 @@ class RealCertExpectation:
|
||||||
hot_water_kwh_per_yr: Optional[float] = None
|
hot_water_kwh_per_yr: Optional[float] = None
|
||||||
co2_kg_per_yr: Optional[float] = None
|
co2_kg_per_yr: Optional[float] = None
|
||||||
unsupported_schema: bool = False
|
unsupported_schema: bool = False
|
||||||
|
known_bug_xfail: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Absolute tolerance for float pins — matches the Elmhurst cohort.
|
# Absolute tolerance for float pins — matches the Elmhurst cohort.
|
||||||
|
|
@ -93,6 +100,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
|
||||||
sap_score=83,
|
sap_score=83,
|
||||||
unsupported_schema=True,
|
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
|
"""Wrap a case as a pytest param, marking unsupported-schema certs as
|
||||||
strict xfails (they raise `ValueError` at the mapper until full-SAP
|
strict xfails (they raise `ValueError` at the mapper until full-SAP
|
||||||
support exists; strict so the marker can't silently outlive the gap)."""
|
support exists; strict so the marker can't silently outlive the gap)."""
|
||||||
marks = (
|
marks = []
|
||||||
[
|
if exp.unsupported_schema:
|
||||||
|
marks.append(
|
||||||
pytest.mark.xfail(
|
pytest.mark.xfail(
|
||||||
reason="full-SAP (non-RdSAP) schema not yet supported by the mapper",
|
reason="full-SAP (non-RdSAP) schema not yet supported by the mapper",
|
||||||
raises=ValueError,
|
raises=ValueError,
|
||||||
strict=True,
|
strict=True,
|
||||||
)
|
)
|
||||||
]
|
)
|
||||||
if exp.unsupported_schema
|
elif exp.known_bug_xfail is not None:
|
||||||
else []
|
marks.append(
|
||||||
)
|
pytest.mark.xfail(reason=exp.known_bug_xfail, strict=True)
|
||||||
|
)
|
||||||
return pytest.param(exp, id=exp.sample, marks=marks)
|
return pytest.param(exp, id=exp.sample, marks=marks)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue