mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Fitting sealed glazing units changes two things beyond the pane's U/g
that the cascade reads, which the overlay didn't model — leaving the
double/secondary before→after pins ~0.7 SAP short (xfail):
1. Draught-proofing (RdSAP 10 §8.1). Sealed units draught-proof the panes
they replace, re-lodging the dwelling-level `percent_draughtproofed`
(cert 001431: 84 → 100). The §2 cascade reads that dwelling-level
value, so the overlay now carries it. `_recompute_percent_draughtproofed`
anchors on the lodged before-% — `after = round((round(before%/100 × N)
+ flips) / N × 100)`, N = openable windows (vertical + roof) + doors,
flips = upgraded panes that were not draught-proofed — so it's robust
to incomplete window extraction (unchanged openings are already in the
aggregate). ~0.3 SAP.
2. Frame factor (§6 solar gains). A replacement unit re-lodges its own
FF=0.70, overriding the pane it replaced — the two "single glazing,
known data" panes lodge FF 1.00 / 0.50 (one is 6.6 m²), so leaving them
unchanged understated solar gains by ~+150 kWh space heating. `WindowOverlay`
now carries `frame_factor`, written flat onto the window. ~0.4 SAP.
Wiring: `EpcSimulation.percent_draughtproofed` + `WindowOverlay.frame_factor`
new fields; `apply_simulations` / `_fold_window` write them; the glazing
generator computes both from the upgraded set and cert 001431's after.
Un-xfails `test_{double,secondary}_glazing_overlay_reproduces_the_relodged_after`
— both now pin SAP/CO2/PE to the relodged after within tolerance. Updates
the two `test_glazing_recommendation` overlay expectations for the new
`frame_factor`. 96 modelling tests pass; zero new pyright errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1188 lines
50 KiB
Python
1188 lines
50 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
|
|
|
|
import copy
|
|
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.generators.secondary_heating_recommendation import (
|
|
recommend_secondary_heating_removal,
|
|
)
|
|
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,
|
|
)
|
|
from tests.domain.sap10_calculator.worksheet._elmhurst_worksheet_001431 import (
|
|
build_epc as build_001431_epc,
|
|
)
|
|
|
|
# RdSAP §A.2.2 forces a secondary system for electric-storage mains; SAP code
|
|
# 402 (slimline storage) is in that set. Code 104 (a gas combi boiler) is not.
|
|
_ELECTRIC_STORAGE_MAIN_CODE: Final[int] = 402
|
|
_STANDARD_ELECTRICITY_FUEL: Final[int] = 30
|
|
# SAP 10.2 Table 4a code 691 — electric panel/convector/radiant heaters, the
|
|
# fixed secondary the user's example cert lodges.
|
|
_SECONDARY_ELECTRIC_PANEL_CODE: Final[int] = 691
|
|
|
|
# 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 now passes (the draught-proofing + frame-factor
|
|
# coupling is modelled), but this narrower pin isolates the overlay's actual
|
|
# job — turning every single-glazed window into the relodged spec — which 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)
|
|
|
|
|
|
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). The overlay now also models the two secondary
|
|
# effects of fitting sealed units: RdSAP 10 §8.1 draught-proofing
|
|
# 84 → 100 (`percent_draughtproofed`) and the re-lodged FF=0.70 on the
|
|
# panes that lodged FF 1.00 / 0.50 — so the full-SAP pin closes.
|
|
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
|
|
)
|
|
|
|
|
|
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 — re-pinned after merging main's fabric fixes (roof "Unknown"
|
|
# U → Table 18 default, Room-in-Roof U leak), which shift this 001431
|
|
# dwelling's baseline fabric and so the ASHP end-state SAP. Still a snapshot
|
|
# of the Vaillant overlay's own output, validated transitively by the
|
|
# system-boiler pin below (which reproduces a real Vaillant cert at delta 0).
|
|
# CO2/PE are the postcode DEMAND cascade now that `Sap10Calculator.
|
|
# calculate` computes EPC emissions/PE on local weather (SAP 10.2
|
|
# Appendix U p.124); SAP is unchanged (UK-average rating cascade).
|
|
_assert_overlay_scores(
|
|
before,
|
|
option.overlay,
|
|
sap=51.99820176096402,
|
|
co2=1065.7593506066496,
|
|
pe=10995.781557709413,
|
|
)
|
|
|
|
|
|
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 — re-pinned after merging main's fabric fixes (see the
|
|
# boiler-1 pin above); the same merge also resolved this cert's main-fuel
|
|
# mapper gap (§14.2 mains-gas derivation), so its raw before now baselines —
|
|
# see `test_gas_boiler_instant_hw_before_baselines`.
|
|
# CO2/PE are the postcode DEMAND cascade now (see the boiler-1 pin above);
|
|
# SAP is unchanged (UK-average rating cascade).
|
|
_assert_overlay_scores(
|
|
before,
|
|
option.overlay,
|
|
sap=39.00740809309464,
|
|
co2=1845.8588018295509,
|
|
pe=18944.42568846759,
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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 was previously blocked: the gas
|
|
# boiler lodged with EES 'BGB' / SAP code 102 derived no main_fuel_type, so
|
|
# Sap10Calculator raised MissingMainFuelType. Merging main's mapper fix
|
|
# (resolve gas-boiler main fuel from the §14.2 mains-gas meter) closed that
|
|
# gap, so the raw before now baselines.
|
|
# Arrange
|
|
before: EpcPropertyData = parse_recommendation_summary(
|
|
"ashp_from_gas_boiler_instant_hw_001431_before.pdf"
|
|
)
|
|
|
|
# Act
|
|
result: SapResult = Sap10Calculator().calculate(before)
|
|
|
|
# Assert — it baselines without raising, on mains gas.
|
|
assert result.sap_score_continuous > 0.0
|
|
|
|
|
|
def test_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
|
# Arrange — a mains-gas wet boiler (SAP code 114) heating an uninsulated
|
|
# hot-water cylinder (no insulation, no thermostat) re-lodged as a new gas
|
|
# condensing boiler with a cylinder (SAP code 102, fanned flue), the cylinder
|
|
# jacketed (insulation type 2 / 80 mm) and given a thermostat. The boiler
|
|
# upgrade leaves the (already adequate) controls + cylinder size + meter
|
|
# unchanged. Validates the boiler-with-cylinder option end-state at delta 0.
|
|
#
|
|
# NB the absolute SAP on this dwelling is subject to a separate Summary-path
|
|
# mapper roof-fidelity gap (our calculator reads the roof better-insulated
|
|
# than Elmhurst, so it scores ~75 where Elmhurst prints 56); the gap is
|
|
# identical on before + after (the boiler measure never touches the roof), so
|
|
# it cancels and this pin still proves the overlay applies Elmhurst's exact
|
|
# heating field-delta. Tracked on the calculator branch, not here.
|
|
before: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_gas_001431_before.pdf"
|
|
)
|
|
# The cert lodges code 114 (already condensing), which the efficiency gate
|
|
# excludes from a like-for-like swap; recast to a pre-1998 non-condensing
|
|
# boiler (110) so the upgrade is offered. The overlay overwrites the code to
|
|
# 102 regardless, so this changes only eligibility, not the validated result.
|
|
before.sap_heating.main_heating_details[0].sap_main_heating_code = 110
|
|
after: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_gas_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 == "gas_boiler_upgrade"
|
|
)
|
|
|
|
# Act / Assert
|
|
_assert_overlay_reproduces_after(before, after, option.overlay)
|
|
|
|
|
|
def test_boiler_combi_overlay_reproduces_the_relodged_after() -> None:
|
|
# Arrange — a mains-gas combi (SAP code 112, no cylinder) with inadequate
|
|
# controls (2111 "TRVs and bypass" — no room thermostat, so no boiler
|
|
# interlock) re-lodged as a new gas condensing combi (code 104, fanned flue)
|
|
# with full programmer + room thermostat + TRV controls (2106). No cylinder,
|
|
# so no cylinder components. Validates the combi end-state + the controls-
|
|
# when-inadequate upgrade at delta 0. (Same Summary-path roof gap as the
|
|
# with-cylinder pin — it cancels across before/after.)
|
|
before: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_combi_gas_001431_before.pdf"
|
|
)
|
|
after: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_combi_gas_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 == "gas_boiler_upgrade"
|
|
)
|
|
|
|
# Act / Assert
|
|
_assert_overlay_reproduces_after(before, after, option.overlay)
|
|
|
|
|
|
def test_oil_combi_overlay_reproduces_the_relodged_after() -> None:
|
|
# Arrange — an OIL combi (fuel 28, SAP code 130, no cylinder) on a mains-gas
|
|
# street re-lodged as a gas condensing combi (fuel 28->26, code 104, fanned
|
|
# flue). Validates the non-gas -> gas conversion: the upgrade targets gas
|
|
# because a mains-gas connection is present (ADR-0024 revised). Controls are
|
|
# already adequate (2106), so they are unchanged.
|
|
before: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_combi_oil_001431_before.pdf"
|
|
)
|
|
after: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_combi_oil_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 == "gas_boiler_upgrade"
|
|
)
|
|
|
|
# Act / Assert
|
|
_assert_overlay_reproduces_after(before, after, option.overlay)
|
|
|
|
|
|
def test_boiler_with_already_insulated_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
|
# Arrange — a gas boiler heating an ALREADY-jacketed cylinder (insulation
|
|
# type 2 / 80 mm) with no thermostat, re-lodged as a new gas condensing
|
|
# boiler (code 102) with a cylinder thermostat added. Validates the cylinder
|
|
# path's skip-jacket branch (the 80 mm jacket is not re-applied) while the
|
|
# thermostat is still added. (Sourced from an LPG re-lodgement; the Summary
|
|
# mapper reads its fuel as mains gas — fuel 26 — so this exercises the gas
|
|
# cylinder path, not a true LPG conversion. The LPG fuel-mapping gap is a
|
|
# separate mapper-front concern.)
|
|
before: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_lpg_001431_before.pdf"
|
|
)
|
|
after: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_lpg_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 == "gas_boiler_upgrade"
|
|
)
|
|
|
|
# Act / Assert
|
|
_assert_overlay_reproduces_after(before, after, option.overlay)
|
|
|
|
|
|
def test_coal_boiler_with_cylinder_overlay_reproduces_the_relodged_after() -> None:
|
|
# Arrange — a SOLID-FUEL (coal) boiler (fuel 11, SAP code 153) heating a
|
|
# cylinder, on a mains-gas street, re-lodged as a gas condensing boiler
|
|
# (fuel 11->26, code 102, fanned flue + boiler flue type 2). Exercises the
|
|
# non-gas -> gas conversion for a solid-fuel boiler AND the new
|
|
# `boiler_flue_type` end-state (coal's before lodged none; every other cert
|
|
# already lodged flue type 2). The cylinder is already 80 mm insulated so the
|
|
# jacket is skipped; only the thermostat is added.
|
|
#
|
|
# The relodged after predates the user-locked "always add a cylinder
|
|
# thermostat when absent" rule, so it stale-lodged thermostat 'N'; the test
|
|
# corrects it to the rule's end-state 'Y' (the same correction the gas
|
|
# with-cylinder after received by re-lodging). Controls are already adequate
|
|
# (2106), so they are unchanged.
|
|
before: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_coal_001431_before.pdf"
|
|
)
|
|
after: EpcPropertyData = parse_recommendation_summary(
|
|
"boiler_cyl_coal_001431_after.pdf"
|
|
)
|
|
after.sap_heating.cylinder_thermostat = "Y"
|
|
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
|
assert recommendation is not None
|
|
option = next(
|
|
o for o in recommendation.options if o.measure_type == "gas_boiler_upgrade"
|
|
)
|
|
|
|
# Act / Assert
|
|
_assert_overlay_reproduces_after(before, after, option.overlay)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"before_fixture, after_fixture, measure_type",
|
|
[
|
|
# The system tune-up keeps the existing boiler and forces the heating
|
|
# controls to a fixed end-state (standard 2106 / zone 2110) ABSOLUTELY —
|
|
# proven by reproducing each common after from two different starting
|
|
# controls (2101 "no control" and 2113 "room thermostat and TRVs") — plus
|
|
# the conditional cylinder jacket + thermostat (both befores are
|
|
# uninsulated / un-thermostatted, so both fire).
|
|
(
|
|
"tune_up_from_2101_001431_before.pdf",
|
|
"tune_up_standard_001431_after.pdf",
|
|
"system_tune_up",
|
|
),
|
|
(
|
|
"tune_up_from_2113_001431_before.pdf",
|
|
"tune_up_standard_001431_after.pdf",
|
|
"system_tune_up",
|
|
),
|
|
(
|
|
"tune_up_from_2101_001431_before.pdf",
|
|
"tune_up_zoned_001431_after.pdf",
|
|
"system_tune_up_zoned",
|
|
),
|
|
(
|
|
"tune_up_from_2113_001431_before.pdf",
|
|
"tune_up_zoned_001431_after.pdf",
|
|
"system_tune_up_zoned",
|
|
),
|
|
],
|
|
)
|
|
def test_system_tune_up_overlay_reproduces_the_relodged_after(
|
|
before_fixture: str, after_fixture: str, measure_type: str
|
|
) -> None:
|
|
# Arrange
|
|
before: EpcPropertyData = parse_recommendation_summary(before_fixture)
|
|
after: EpcPropertyData = parse_recommendation_summary(after_fixture)
|
|
recommendation: Recommendation | None = recommend_heating(before, _AnyProduct())
|
|
assert recommendation is not None
|
|
option = next(
|
|
o for o in recommendation.options if o.measure_type == measure_type
|
|
)
|
|
|
|
# Act / Assert
|
|
_assert_overlay_reproduces_after(before, after, option.overlay)
|
|
|
|
|
|
# --- 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)
|
|
|
|
|
|
# --- Secondary Heating Removal (ADR-0028) ----------------------------------
|
|
# The user's Elmhurst before/after Summary for this measure (cert 001431,
|
|
# electric-storage main + secondary 691) cannot be parsed — that PDF export
|
|
# trips the documented 001431 Summary window-extraction bug. So these pins use
|
|
# the worksheet-pinned `build_epc()` (a validated real-001431 representation,
|
|
# the repo's sanctioned 001431 baseline) with the secondary configuration set on
|
|
# it, exercising the real generator → overlay → calculator cascade.
|
|
|
|
|
|
def test_secondary_removal_on_an_electric_storage_main_is_a_no_op() -> None:
|
|
# Arrange — 001431 recast to an electric-storage main (SAP code 402, fuel 30)
|
|
# with a lodged secondary (691). RdSAP §A.2.2 forces a default secondary back
|
|
# on storage mains, so removal reproduces the after at delta 0 — exactly why
|
|
# the user's before/after Summaries both print SAP F35.
|
|
before: EpcPropertyData = build_001431_epc()
|
|
main = before.sap_heating.main_heating_details[0]
|
|
main.sap_main_heating_code = _ELECTRIC_STORAGE_MAIN_CODE
|
|
main.main_fuel_type = _STANDARD_ELECTRICITY_FUEL
|
|
main.main_heating_index_number = None
|
|
before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE
|
|
after: EpcPropertyData = copy.deepcopy(before)
|
|
after.sap_heating.secondary_heating_type = None
|
|
after.sap_heating.secondary_fuel_type = None
|
|
recommendation: Recommendation | None = recommend_secondary_heating_removal(
|
|
before, _AnyProduct()
|
|
)
|
|
assert recommendation is not None
|
|
|
|
# Act / Assert — the overlay reproduces the secondary-removed cert at delta 0.
|
|
_assert_overlay_reproduces_after(
|
|
before, after, recommendation.options[0].overlay
|
|
)
|
|
|
|
|
|
def test_secondary_removal_on_a_non_forced_main_raises_sap() -> None:
|
|
# Arrange — 001431's lodged gas combi (SAP code 104, NOT a forced-secondary
|
|
# main) with an added electric secondary (691). Removing it reallocates the
|
|
# Table 11 secondary fraction to the cheaper gas main, so cost-based SAP rises
|
|
# (the value path the forced-secondary example can't exercise).
|
|
before: EpcPropertyData = build_001431_epc()
|
|
before.sap_heating.secondary_heating_type = _SECONDARY_ELECTRIC_PANEL_CODE
|
|
recommendation: Recommendation | None = recommend_secondary_heating_removal(
|
|
before, _AnyProduct()
|
|
)
|
|
assert recommendation is not None
|
|
scorer = PackageScorer(Sap10Calculator())
|
|
|
|
# Act
|
|
with_secondary: Score = scorer.score(before, [])
|
|
removed: Score = scorer.score(before, [recommendation.options[0].overlay])
|
|
|
|
# Assert — removal strictly raises SAP (delta well above the pin tolerance).
|
|
assert removed.sap_continuous - with_secondary.sap_continuous > _PIN_ABS
|