Model/tests/domain/modelling/test_elmhurst_cascade_pins.py
Khalim Conn-Kowlessar 2f3b1dbd3f test(modelling): Solar PV Elmhurst cascade pins + battery tripwire
Slice 9 of the Solar PV Recommendation Generator (ADR-0026). Pins the
overlay→calculator PV cascade against Elmhurst's before/after re-lodgements of
cert 001431, across the orientation/pitch/overshading config space (the certs
lodge synthetic 1.00 kWp test vectors):
 - SE/SW, shaded (overshading 3/2), pitch 30°/45°
 - E/W, unshaded, pitch 60°/45°
 - NW/N, unshaded, pitch 60°/45° (the low-yield orientations)
Each hand-built SolarOverlay reproduces the relodged after at abs ≤ 1e-4 on
SAP / CO2 / primary energy.

Battery tripwire (per user): the "with battery" cert lodges a §19 5 kWh battery
the current extractor does NOT parse, so it scores identically to its
no-battery twin — the no-battery overlay reproduces it today, and the pin will
fail (alerting us to switch to the with-battery overlay) once the extractor
parses the battery. A companion test pins that the calculator already models
the 5 kWh battery (it raises SAP), so the fix target is established.

All five certs share an EES 'WGK' / SAP-code-502 main-heating lodgement the
mapper doesn't yet derive a fuel for; the pins patch the shared fuel (mains gas
26) identically on before+after to isolate the PV delta (the solar overlay
never touches heating), and `test_solar_before_baselines` xfails as the
forcing-function tripwire for that separate mapper-front gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 12:42:24 +00:00

941 lines
38 KiB
Python

"""Elmhurst before/after cascade pins for the Recommendation Generators.
Each measure has an Elmhurst `before` Summary (baseline cert) and an `after`
Summary (the same cert re-lodged with the measure applied). The pin drives the
matching generator on the parsed `before`, scores its Option's overlay through
the `PackageScorer`, and asserts the result equals the calculator's score on
the parsed `after` at `abs(diff) <= 1e-4` for SAP / CO2 / primary energy.
This is the real cert→generator→overlay→calculator cascade, not a per-section
isolation test (see `[[feedback-cascade-pin-methodology]]`): a non-zero delta
is a named generator/overlay/calculator gap to fix, never a tolerance to widen
(`[[feedback-zero-error-strict]]`).
"""
from __future__ import annotations
from dataclasses import replace
from typing import Final
import pytest
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
PhotovoltaicArray,
PvBatteries,
PvBattery,
)
from domain.modelling.scoring.package_scorer import PackageScorer, Score
from domain.modelling.product import Product
from domain.modelling.recommendation import Recommendation
from domain.modelling.generators.floor_recommendation import recommend_floor_insulation
from domain.modelling.generators.roof_recommendation import (
recommend_roof_insulation,
)
from domain.modelling.simulation import (
BuildingPartOverlay,
EpcSimulation,
SolarOverlay,
)
from domain.modelling.generators.wall_recommendation import recommend_cavity_wall
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.modelling.generators.solid_wall_recommendation import (
recommend_solid_wall,
)
from domain.modelling.generators.glazing_recommendation import recommend_glazing
from domain.modelling.generators.lighting_recommendation import recommend_lighting
from domain.modelling.generators.heating_recommendation import recommend_heating
from domain.modelling.scoring.overlay_applicator import apply_simulations
from domain.modelling.recommendation import MeasureOption
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from repositories.product.product_repository import ProductRepository
from tests.domain.modelling._elmhurst_recommendation import (
parse_recommendation_summary,
)
# Pin tolerance: the Summary PDFs are deterministic test vectors, so the
# overlay must reproduce the re-lodged cert exactly. Matches the worksheet
# e2e tolerance.
_PIN_ABS: Final[float] = 1e-4
# RdSAP wall_insulation_type codes for solid-wall insulation (Elmhurst
# Summary "E External" / "I Internal"); cf. domain/sap10_ml/rdsap_uvalues.py.
_WALL_INSULATION_EXTERNAL: Final[int] = 1
_WALL_INSULATION_INTERNAL: Final[int] = 3
# Recommended solid-wall insulation depth (mm); the calculator's λ default
# (0.04 W/m·K) matches Elmhurst's lodged thermal conductivity.
_SOLID_WALL_INSULATION_MM: Final[int] = 100
class _AnyProduct(ProductRepository):
"""In-memory ProductRepository returning a fixed Product for any Measure
Type. The pins assert the SAP cascade, not Cost, so the unit cost is
immaterial — only the generator's overlay is exercised."""
def get(self, measure_type: str) -> Product:
return Product(
measure_type=measure_type, unit_cost_per_m2=1.0, contingency_rate=0.0
)
def _assert_overlay_reproduces_after(
before: EpcPropertyData, after: EpcPropertyData, overlay: EpcSimulation
) -> None:
"""Score ``overlay`` on ``before`` and assert it matches the calculator's
score on the re-lodged ``after`` across all three metrics."""
calculator = Sap10Calculator()
relodged: SapResult = calculator.calculate(after)
scored: Score = PackageScorer(calculator).score(before, [overlay])
assert abs(scored.sap_continuous - relodged.sap_score_continuous) <= _PIN_ABS
assert abs(scored.co2_kg_per_yr - relodged.co2_kg_per_yr) <= _PIN_ABS
assert (
abs(scored.primary_energy_kwh_per_yr - relodged.primary_energy_kwh_per_yr)
<= _PIN_ABS
)
def _assert_overlay_scores(
before: EpcPropertyData,
overlay: EpcSimulation,
*,
sap: float,
co2: float,
pe: float,
) -> None:
"""Score ``overlay`` on ``before`` and assert it matches the given snapshot
of SAP / CO2 / primary energy. Used where the relodged after-cert predates
the Vaillant product swap (it lodges the old heat-pump index): the snapshot
is taken as correct because the same overlay reproduces the corrected
Vaillant cert at delta 0 in the boiler-3 pin (ADR-0025)."""
scored: Score = PackageScorer(Sap10Calculator()).score(before, [overlay])
assert abs(scored.sap_continuous - sap) <= _PIN_ABS
assert abs(scored.co2_kg_per_yr - co2) <= _PIN_ABS
assert abs(scored.primary_energy_kwh_per_yr - pe) <= _PIN_ABS
def test_cavity_wall_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"cavity_wall_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"cavity_wall_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_cavity_wall(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_solid_brick_ewi_overlay_reproduces_the_relodged_after() -> None:
# Arrange — 100 mm external wall insulation on a solid-brick main wall.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_after.pdf"
)
overlay = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
wall_insulation_type=_WALL_INSULATION_EXTERNAL,
wall_insulation_thickness=_SOLID_WALL_INSULATION_MM,
)
}
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solid_brick_iwi_overlay_reproduces_the_relodged_after() -> None:
# Arrange — 100 mm internal wall insulation on a solid-brick main wall
# (also lowers the thermal-mass parameter, unlike EWI).
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_iwi_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_iwi_001431_after.pdf"
)
overlay = EpcSimulation(
building_parts={
BuildingPartIdentifier.MAIN: BuildingPartOverlay(
wall_insulation_type=_WALL_INSULATION_INTERNAL,
wall_insulation_thickness=_SOLID_WALL_INSULATION_MM,
)
}
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solid_brick_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None:
# Arrange — one uninsulated solid-brick before, two re-lodged afters.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
ewi_after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_after.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"solid_brick_iwi_001431_after.pdf"
)
# Act — solid brick is suitable for both, unrestricted.
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — both Options offered, and each Option's overlay reproduces its
# own re-lodged after at the pin tolerance.
assert set(options) == {"external_wall_insulation", "internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, ewi_after, options["external_wall_insulation"].overlay
)
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_system_built_generator_offers_ewi_and_iwi_each_pinning_its_after() -> None:
# Arrange — system-built (precast concrete) takes both Options like solid
# brick (ADR-0019): one uninsulated before, two re-lodged afters.
before: EpcPropertyData = parse_recommendation_summary(
"system_built_ewi_001431_before.pdf"
)
ewi_after: EpcPropertyData = parse_recommendation_summary(
"system_built_ewi_001431_after.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"system_built_iwi_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — both Options offered, each reproducing its own re-lodged after.
assert set(options) == {"external_wall_insulation", "internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, ewi_after, options["external_wall_insulation"].overlay
)
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_timber_frame_generator_offers_iwi_only_pinning_its_after() -> None:
# Arrange — timber frame takes IWI but EWI is not constructable (ADR-0019).
before: EpcPropertyData = parse_recommendation_summary(
"timber_frame_iwi_001431_before.pdf"
)
iwi_after: EpcPropertyData = parse_recommendation_summary(
"timber_frame_iwi_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(before, _AnyProduct())
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — IWI only, and it reproduces the re-lodged after.
assert set(options) == {"internal_wall_insulation"}
_assert_overlay_reproduces_after(
before, iwi_after, options["internal_wall_insulation"].overlay
)
def test_conservation_area_drops_ewi_but_keeps_iwi() -> None:
# Arrange — a conservation area blocks the external-appearance change only.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
# Act
recommendation: Recommendation | None = recommend_solid_wall(
before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True)
)
# Assert — IWI survives, EWI is gone.
assert recommendation is not None
assert {option.measure_type for option in recommendation.options} == {
"internal_wall_insulation"
}
def test_listed_building_blocks_all_solid_wall_insulation() -> None:
# Arrange — listed/heritage protect the fabric, so both EWI and IWI go.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
# Act / Assert
assert (
recommend_solid_wall(
before, _AnyProduct(), PlanningRestrictions(is_listed=True)
)
is None
)
def test_flat_drops_ewi_but_keeps_iwi() -> None:
# Arrange — a flat can take IWI to its own unit, but EWI needs whole-block
# coordination (ADR-0019). property_type "Flat" is the Elmhurst name form.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
flat: EpcPropertyData = replace(before, property_type="Flat")
# Act
recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct())
# Assert
assert recommendation is not None
assert {option.measure_type for option in recommendation.options} == {
"internal_wall_insulation"
}
def test_flat_detected_from_api_property_type_code() -> None:
# Arrange — the API path lodges property_type as a stringified code
# ("2" = Flat per PROPERTY_TYPE_LOOKUP), not the name.
before: EpcPropertyData = parse_recommendation_summary(
"solid_brick_ewi_001431_before.pdf"
)
flat: EpcPropertyData = replace(before, property_type="2")
# Act
recommendation: Recommendation | None = recommend_solid_wall(flat, _AnyProduct())
# Assert — same gate fires regardless of representation.
assert recommendation is not None
assert {option.measure_type for option in recommendation.options} == {
"internal_wall_insulation"
}
def test_cavity_wall_gets_no_solid_wall_recommendation() -> None:
# Arrange — a cavity wall is handled by recommend_cavity_wall, never here.
before: EpcPropertyData = parse_recommendation_summary(
"cavity_wall_001431_before.pdf"
)
# Act / Assert
assert recommend_solid_wall(before, _AnyProduct()) is None
def test_loft_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"loft_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"loft_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_roof_generator_insulates_a_sloping_ceiling_pinning_its_after() -> None:
# Arrange — a pitched roof with an uninsulated sloping ceiling; the re-lodged
# after raises its insulation from As Built to 100 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"sloping_ceiling_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"sloping_ceiling_001431_after.pdf"
)
# Act — the dispatcher detects "sloping ceiling" and offers the sloping
# measure (not loft).
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one sloping-ceiling Option whose overlay reproduces the after.
assert set(options) == {"sloping_ceiling_insulation"}
_assert_overlay_reproduces_after(
before, after, options["sloping_ceiling_insulation"].overlay
)
def test_roof_generator_insulates_a_thatched_roof_as_loft_pinning_its_after() -> None:
# Arrange — a thatched pitched roof. Thatch is NOT excluded: the covering
# doesn't block insulating the loft floor, so it takes loft (joist)
# insulation, re-lodged None → 300 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"loft_thatched_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"loft_thatched_001431_after.pdf"
)
# Act — the dispatcher routes a thatched roof to the loft branch.
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one loft Option whose overlay reproduces the after.
assert set(options) == {"loft_insulation"}
_assert_overlay_reproduces_after(
before, after, options["loft_insulation"].overlay
)
def test_roof_generator_insulates_a_flat_roof_pinning_its_after() -> None:
# Arrange — a flat roof, uninsulated (As Built → None on the Elmhurst path);
# the re-lodged after raises it to 200 mm (ADR-0021).
before: EpcPropertyData = parse_recommendation_summary(
"flat_roof_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"flat_roof_001431_after.pdf"
)
# Act
recommendation: Recommendation | None = recommend_roof_insulation(
before, _AnyProduct()
)
assert recommendation is not None
options: dict[str, MeasureOption] = {
option.measure_type: option for option in recommendation.options
}
# Assert — one flat-roof Option whose overlay reproduces the after.
assert set(options) == {"flat_roof_insulation"}
_assert_overlay_reproduces_after(
before, after, options["flat_roof_insulation"].overlay
)
def test_solid_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"solid_floor_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"solid_floor_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_floor_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_suspended_floor_overlay_reproduces_the_relodged_after() -> None:
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"suspended_floor_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"suspended_floor_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_floor_insulation(
before, _AnyProduct()
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_double_glazing_overlay_reproduces_the_relodged_after_windows() -> None:
# The full-SAP pin below is xfail (draught-proofing coupling), but the
# overlay's actual job — turning every single-glazed window into the
# relodged spec — is deterministic and must hold exactly: it proves the
# generator detects BOTH single-glazing codes (1 and 15) on the real cert.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct())
assert recommendation is not None
# Act — apply the overlay to the parsed before.
applied: EpcPropertyData = apply_simulations(
before, [recommendation.options[0].overlay]
)
# Assert — every window's glazing_type + lodged U/g matches the after.
def _window_spec(epc: EpcPropertyData) -> list[tuple[object, object, object]]:
specs: list[tuple[object, object, object]] = []
for window in epc.sap_windows:
details = window.window_transmission_details
specs.append(
(
window.glazing_type,
details.u_value if details is not None else None,
details.solar_transmittance if details is not None else None,
)
)
return specs
assert _window_spec(applied) == _window_spec(after)
_GLAZING_DRAUGHT_COUPLING_REASON: Final[str] = (
"Blocked on the glazing measure's draught-proofing coupling. The window "
"U/g overlay reproduces the after's 14 windows EXACTLY (all four single-"
"glazed panes — codes 1 and 15 — become the relodged double/secondary "
"spec). The residual ~0.7 SAP is a secondary effect the overlay does not "
"model: replacing the single-glazed (lodged draught_proofed=No) windows "
"with sealed units re-lodges percent_draughtproofed 84->100 (~0.3 SAP) and "
"lowers fabric heat loss by ~+150 kWh space heating (~0.4 SAP) not yet "
"isolated. Flips green once the glazing overlay propagates draught-proofing "
"(and the residual fabric coupling is modelled)."
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_double_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — cert 001431 lodges four single-glazed windows (codes 1 and 15,
# "single glazing, known data"); the after re-lodges every one as double
# (gt=5, U=1.40, g=0.72).
before: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"double_glazing_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_glazing(before, _AnyProduct())
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
@pytest.mark.xfail(strict=True, reason=_GLAZING_DRAUGHT_COUPLING_REASON)
def test_secondary_glazing_overlay_reproduces_the_relodged_after() -> None:
# Arrange — a planning protection forces secondary glazing; the after
# re-lodges every single-glazed window as secondary (gt=11, U=2.90, g=0.85).
before: EpcPropertyData = parse_recommendation_summary(
"secondary_glazing_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"secondary_glazing_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_glazing(
before, _AnyProduct(), PlanningRestrictions(in_conservation_area=True)
)
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_lighting_overlay_reproduces_the_relodged_after_zero_existing_leds() -> None:
# Arrange — a dwelling with no LEDs (20 incandescent); the after re-lodges
# all 20 as LED. Lighting only changes bulb counts → Appendix L (232), with
# no fabric coupling, so the full-SAP pin closes cleanly.
before: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_zero_leds_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_zero_leds_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_lighting(before, _AnyProduct())
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_lighting_overlay_reproduces_the_relodged_after_some_existing_leds() -> None:
# Arrange — a dwelling with some LEDs already (5 LED + 15 incandescent); the
# after re-lodges all 20 as LED. Exercises the partial-upgrade path: the
# overlay tops led up to the total rather than starting from zero.
before: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_some_leds_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"low_energy_lighting_some_leds_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_lighting(before, _AnyProduct())
assert recommendation is not None
# Act / Assert
_assert_overlay_reproduces_after(
before, after, recommendation.options[0].overlay
)
def test_hhr_storage_overlay_reproduces_the_relodged_after_from_electric_storage() -> None:
# Arrange — an existing electric storage system re-lodged as high-heat-
# retention storage (Table 4a 402 -> 409, control 2401 -> 2404), gaining an
# off-peak immersion cylinder and a dual meter (ADR-0024).
before: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_from_electric_storage_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o
for o in recommendation.options
if o.measure_type == "high_heat_retention_storage_heaters"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_hhr_storage_overlay_reproduces_the_relodged_after_from_no_system() -> None:
# Arrange — a "no system present" electric dwelling re-lodged as HHR storage;
# the same absolute-target overlay must reproduce the common after.
before: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_from_no_system_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"hhr_storage_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o
for o in recommendation.options
if o.measure_type == "high_heat_retention_storage_heaters"
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, option.overlay)
def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler() -> None:
# Arrange — a typical mains-gas combi house re-cast as an air-source heat
# pump (fuel 26 -> 30, SAP code 104 -> Vaillant aroTHERM plus 5 kW index
# 110257 + category 4, control 2106 -> 2210), off mains gas, gaining a heat-
# pump cylinder (ADR-0024). The boiler-1 after-cert predates the Vaillant
# swap (it lodges the old index 101413), so this snapshots the Vaillant
# overlay's own output rather than re-pinning a stale relodged PDF — taken as
# correct because the same overlay reproduces the corrected Vaillant cert at
# delta 0 in the system-boiler pin below.
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_001431_before.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Act / Assert
_assert_overlay_scores(
before,
option.overlay,
sap=47.65139515167728,
co2=1376.9759827175776,
pe=14216.05717899134,
)
def test_ashp_overlay_scores_the_vaillant_end_state_from_a_gas_boiler_instant_hw() -> None:
# Arrange — a gas boiler whose hot water is electric/instantaneous (water-
# heating SAP code 909, no cylinder) re-cast as an ASHP. Exercises the
# overlay's water_heating_code reset (909 -> 901, "from the heat pump") that
# boiler-1 didn't (its HW was already 901). Snapshots the Vaillant overlay's
# output (the after-cert predates the Vaillant swap), validated transitively
# by the system-boiler pin below.
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_instant_hw_001431_before.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Act / Assert
_assert_overlay_scores(
before,
option.overlay,
sap=40.41821395541584,
co2=2181.1871765820424,
pe=22453.205759768087,
)
def test_ashp_overlay_reproduces_the_relodged_after_from_a_system_boiler_with_cylinder() -> None:
# Arrange — a mains-gas *regular/system* boiler (SAP code 101, not a combi)
# that already heats its own hot-water cylinder (size 2 / insulation type 2 /
# 80 mm) re-lodged as an ASHP. This exercises the cylinder OVERWRITE path that
# boiler-1/boiler-2 didn't: those added a cylinder where none existed, whereas
# here the overlay must overwrite the existing cylinder to the fixed heat-pump
# cylinder (size 4 / insulation type 1 / 50 mm). The dwelling also goes off
# mains gas (fuel 26 -> 30, code 101 -> Vaillant aroTHERM plus 5 kW index
# 110257 + category 4, control 2113 -> 2210). After-cert re-lodged with the
# Vaillant: ASHP raises this dwelling's SAP 63.85 -> 72.30.
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_before.pdf"
)
after: EpcPropertyData = parse_recommendation_summary(
"ashp_from_system_boiler_with_cylinder_001431_after.pdf"
)
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
assert recommendation is not None
option = next(
o for o in recommendation.options if o.measure_type == "air_source_heat_pump"
)
# Act / Assert — the absolute overlay overwrites the existing cylinder and
# reproduces the after exactly.
_assert_overlay_reproduces_after(before, after, option.overlay)
_BOILER_INSTANT_HW_FUEL_REASON: Final[str] = (
"Blocked on the Elmhurst mapper deriving main_fuel_type for a gas boiler "
"lodged with EES code 'BGB' / Main Heating SAP code 102: it currently maps "
"to '' (empty), so Sap10Calculator raises MissingMainFuelType when "
"baselining the raw before. (boiler-1's 'RGE' resolves to mains gas 26; "
"'BGB' has no mapping.) The ASHP overlay still reproduces the after exactly "
"because it overwrites main_fuel_type -> 30, so the cascade pin above passes "
"— only baselining the unmodified before is blocked. Flips green once the "
"mapper derives mains gas (26) from the gas-boiler SAP code. Owner: "
"mapper/extractor front."
)
@pytest.mark.xfail(strict=True, reason=_BOILER_INSTANT_HW_FUEL_REASON)
def test_gas_boiler_instant_hw_before_baselines() -> None:
# The Modelling pipeline baselines the dwelling before modelling it, so the
# before must be scorable on its own. This one is not yet: its main fuel is
# unresolved (see reason). A failing tripwire for the separate mapper fix.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"ashp_from_gas_boiler_instant_hw_001431_before.pdf"
)
# Act / Assert — currently raises MissingMainFuelType.
Sap10Calculator().calculate(before)
# --- Solar PV cascade pins (ADR-0026) -------------------------------------
#
# The solar before/after Summaries lodge *synthetic* PV arrays (each 1.00 kWp,
# varied orientation/pitch/overshading) — deterministic test vectors chosen to
# exercise the overlay -> calculator PV path across the config space, NOT the
# Google-derived production arrays. So these pins hand-build the SolarOverlay
# matching each after-cert's lodged arrays (the generator's own overlay is
# Google-sourced and validated separately in test_solar_recommendation /
# test_solar_overshading); the cascade proves `_fold_solar` + the calculator
# reproduce Elmhurst's PV re-lodgement exactly.
#
# All five certs share one main-heating system lodged with EES code 'WGK' /
# Main Heating SAP code 502, which the Elmhurst mapper does not yet derive a
# main_fuel_type for (it maps to '' -> MissingMainFuelType). The solar overlay
# never touches heating, so the pins patch the shared fuel to mains gas (26) on
# both before and after identically — the heating contribution is then equal on
# both sides and the delta isolates the PV change. The unresolved raw baseline
# is a separate mapper-front gap, tripwired by `test_solar_before_baselines`.
_SOLAR_MAINS_GAS_FUEL: Final[int] = 26
def _parse_solar(fixture_name: str) -> EpcPropertyData:
"""Parse a solar before/after Summary, patching the shared main-heating
fuel to mains gas (the EES 'WGK' / SAP code 502 mapper-front gap — see the
section note). Applied identically on before + after, so it cancels in the
PV delta."""
epc: EpcPropertyData = parse_recommendation_summary(fixture_name)
main = epc.sap_heating.main_heating_details[0]
if not main.main_fuel_type:
main.main_fuel_type = _SOLAR_MAINS_GAS_FUEL
return epc
def test_solar_overlay_reproduces_relodged_after_se_sw_shaded() -> None:
# Arrange — two shaded planes: SE (octant 4) at 30° pitch under significant
# shading (code 3), and SW (octant 6) at 45° pitch under modest shading
# (code 2); each a 1.00 kWp array. Exercises the overshading + orientation
# spread.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_1.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=2, orientation=4, overshading=3),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=6, overshading=2),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solar_overlay_reproduces_relodged_after_e_w_unshaded() -> None:
# Arrange — E (octant 3) at 60° pitch and W (octant 7) at 45° pitch, both
# unshaded (code 1). Exercises the steeper pitches with no shading.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_2.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=3, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=7, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_solar_overlay_reproduces_relodged_after_nw_n_unshaded() -> None:
# Arrange — NW (octant 8) at 60° pitch and N (octant 1) at 45° pitch, both
# unshaded. The least-productive orientations (the N plane in particular)
# exercise the low-yield end of the SAP Appendix M output.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_no_battery_001431_after_3.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_battery_cert_currently_reproduced_by_the_no_battery_overlay() -> None:
# Tripwire (user-requested): the "with battery" cert lodges a §19 5 kWh
# battery, but the current Elmhurst extractor does NOT parse it (the parsed
# EpcPropertyData has pv_batteries=None). So the cert currently scores
# identically to its no-battery twin, and the *no-battery* overlay (same NW/N
# arrays) reproduces it exactly. When the extractor learns to parse the §19
# Batteries block, the after-cert will gain ~+1.1 SAP from the 5 kWh battery
# and THIS PIN WILL FAIL — the fix is then to switch to the with-battery
# overlay below (which the calculator already models, see the next test).
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
after: EpcPropertyData = _parse_solar("solar_pv_with_battery_001431_after.pdf")
overlay = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=[
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
],
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
# Act / Assert
_assert_overlay_reproduces_after(before, after, overlay)
def test_battery_overlay_raises_sap_above_its_no_battery_twin() -> None:
# The calculator DOES model a PV battery (App M monthly self-consumption), so
# the recommendation's battery variant is a meaningful, higher-SAP Option —
# even though the example cert's battery is not yet parsed. This pins the fix
# target for the tripwire above: once the extractor parses the §19 battery,
# the with-battery overlay should reproduce the (then battery-bearing) cert.
before: EpcPropertyData = _parse_solar("solar_pv_001431_before.pdf")
arrays = [
PhotovoltaicArray(peak_power=1.0, pitch=4, orientation=8, overshading=1),
PhotovoltaicArray(peak_power=1.0, pitch=3, orientation=1, overshading=1),
]
no_battery = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=arrays,
pv_diverter_present=True,
is_dwelling_export_capable=True,
)
)
with_battery = EpcSimulation(
solar=SolarOverlay(
photovoltaic_arrays=arrays,
pv_diverter_present=True,
is_dwelling_export_capable=True,
pv_batteries=PvBatteries(pv_battery=PvBattery(battery_capacity=5.0)),
)
)
# Act
scorer = PackageScorer(Sap10Calculator())
sap_no_battery: float = scorer.score(before, [no_battery]).sap_continuous
sap_with_battery: float = scorer.score(before, [with_battery]).sap_continuous
# Assert — the 5 kWh battery raises SAP by a meaningful margin.
assert sap_with_battery > sap_no_battery + 1e-3
_SOLAR_FUEL_GAP_REASON: Final[str] = (
"Blocked on the Elmhurst mapper deriving main_fuel_type for the main heating "
"lodged with EES code 'WGK' / Main Heating SAP code 502: it currently maps to "
"'' (empty), so Sap10Calculator raises MissingMainFuelType when baselining the "
"raw solar before. The solar overlay never touches heating, so the cascade "
"pins above patch the shared fuel to mains gas (26) on before + after to "
"isolate the PV delta — only baselining the unmodified before is blocked. "
"Flips green once the mapper derives mains gas from the WGK/502 lodgement. "
"Owner: mapper/extractor front."
)
@pytest.mark.xfail(strict=True, reason=_SOLAR_FUEL_GAP_REASON)
def test_solar_before_baselines() -> None:
# The Modelling pipeline baselines the dwelling before modelling it, so the
# before must be scorable on its own. This solar cert is not yet: its main
# fuel is unresolved (see reason). A failing tripwire for the mapper fix.
# Arrange
before: EpcPropertyData = parse_recommendation_summary(
"solar_pv_001431_before.pdf"
)
# Act / Assert — currently raises MissingMainFuelType.
Sap10Calculator().calculate(before)