Slice 31: §11a SAP rating cascade pin (24/24)

Adds `sap_rating_section_from_cert(epc) -> SapRatingSection`. Composes
§1 TFA + §10a (255) total fuel cost via `fuel_cost_section_from_cert`,
then runs the SAP rating equations (`energy_cost_factor`, `sap_rating`,
`sap_rating_integer`).

Pins (256) deflator, (257) ECF, SAP continuous, (258) SAP integer for
all 6 fixtures — 24/24 PASS.

Existing e2e pins on `ecf`, `sap_score_continuous`, `sap_score`
already verified these outputs; cascade pins formalise §11a for the
worksheet-conformance test surface.

Cascade scoreboard: 660/660 → 684/684 (§7..§11a closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 08:06:52 +00:00
parent 74bfac049a
commit 2bfecad272
8 changed files with 129 additions and 0 deletions

View file

@ -69,6 +69,12 @@ from domain.sap.tables.table_32 import (
unit_price_p_per_kwh as table_32_unit_price_p_per_kwh,
)
from domain.sap.worksheet.fuel_cost import FuelCostResult, fuel_cost
from domain.sap.worksheet.rating import (
ENERGY_COST_DEFLATOR,
energy_cost_factor,
sap_rating,
sap_rating_integer,
)
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
@ -1042,6 +1048,40 @@ def fabric_energy_efficiency_from_cert(epc: EpcPropertyData) -> Optional[float]:
)
@dataclass(frozen=True)
class SapRatingSection:
"""SAP 10.2 §11a worksheet line refs (256)..(258) — Energy Cost Factor
and SAP rating. Returned by `sap_rating_section_from_cert`."""
energy_cost_deflator: float # (256) — Table 12 constant 0.42
energy_cost_factor: float # (257) — (255) × (256) / ((4) + 45)
sap_continuous: float # SAP value (un-rounded)
sap_integer: int # (258) — round half-up to nearest int
def sap_rating_section_from_cert(
epc: EpcPropertyData,
) -> Optional[SapRatingSection]:
"""SAP 10.2 §11a cert→inputs cascade. Composes §10a (255) + §1 TFA via
`_fuel_cost` + `dimensions_from_cert`, then runs the SAP rating equations
(`energy_cost_factor`, `sap_rating`, `sap_rating_integer`). Returns the
full `SapRatingSection`; None when TFA missing."""
if epc.total_floor_area_m2 is None:
return None
dim = dimensions_from_cert(epc)
fc = fuel_cost_section_from_cert(epc)
assert fc is not None, "fuel_cost None despite TFA present"
ecf = energy_cost_factor(
total_cost_gbp=fc.total_cost_gbp, total_floor_area_m2=dim.total_floor_area_m2
)
return SapRatingSection(
energy_cost_deflator=ENERGY_COST_DEFLATOR,
energy_cost_factor=ecf,
sap_continuous=sap_rating(ecf=ecf),
sap_integer=sap_rating_integer(ecf=ecf),
)
def fuel_cost_section_from_cert(
epc: EpcPropertyData,
) -> Optional[FuelCostResult]:

View file

@ -488,3 +488,11 @@ LINE_252_PV_CREDIT: float = 0.0000
LINE_253_APPENDIX_Q_SAVED: float = 0.0000
LINE_254_APPENDIX_Q_USED: float = 0.0000
LINE_255_TOTAL_COST: float = 655.6949
# ============================================================================
# §11a SAP rating — line refs (256)..(258)
# ============================================================================
LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200
LINE_257_ECF: float = 2.7055
SAP_VALUE_CONTINUOUS: float = 62.2584
LINE_258_SAP_RATING_INTEGER: int = 62

View file

@ -455,3 +455,11 @@ LINE_252_PV_CREDIT: float = 0.0000
LINE_253_APPENDIX_Q_SAVED: float = 0.0000
LINE_254_APPENDIX_Q_USED: float = 0.0000
LINE_255_TOTAL_COST: float = 732.1396
# ============================================================================
# §11a SAP rating — line refs (256)..(258)
# ============================================================================
LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200
LINE_257_ECF: float = 2.5086
SAP_VALUE_CONTINUOUS: float = 65.0057
LINE_258_SAP_RATING_INTEGER: int = 65

View file

@ -497,3 +497,11 @@ LINE_252_PV_CREDIT: float = 0.0000
LINE_253_APPENDIX_Q_SAVED: float = 0.0000
LINE_254_APPENDIX_Q_USED: float = 0.0000
LINE_255_TOTAL_COST: float = 854.8139
# ============================================================================
# §11a SAP rating — line refs (256)..(258)
# ============================================================================
LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200
LINE_257_ECF: float = 2.7743
SAP_VALUE_CONTINUOUS: float = 61.2986
LINE_258_SAP_RATING_INTEGER: int = 61

View file

@ -521,3 +521,11 @@ LINE_252_PV_CREDIT: float = 0.0000
LINE_253_APPENDIX_Q_SAVED: float = 0.0000
LINE_254_APPENDIX_Q_USED: float = 0.0000
LINE_255_TOTAL_COST: float = 828.6119
# ============================================================================
# §11a SAP rating — line refs (256)..(258)
# ============================================================================
LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200
LINE_257_ECF: float = 2.7496
SAP_VALUE_CONTINUOUS: float = 61.6431
LINE_258_SAP_RATING_INTEGER: int = 62

View file

@ -471,3 +471,11 @@ LINE_252_PV_CREDIT: float = 0.0000
LINE_253_APPENDIX_Q_SAVED: float = 0.0000
LINE_254_APPENDIX_Q_USED: float = 0.0000
LINE_255_TOTAL_COST: float = 807.5421
# ============================================================================
# §11a SAP rating — line refs (256)..(258)
# ============================================================================
LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200
LINE_257_ECF: float = 3.0539
SAP_VALUE_CONTINUOUS: float = 57.3979
LINE_258_SAP_RATING_INTEGER: int = 57

View file

@ -497,3 +497,11 @@ LINE_252_PV_CREDIT: float = 0.0000
LINE_253_APPENDIX_Q_SAVED: float = 0.0000
LINE_254_APPENDIX_Q_USED: float = 0.0000
LINE_255_TOTAL_COST: float = 860.7162
# ============================================================================
# §11a SAP rating — line refs (256)..(258)
# ============================================================================
LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200
LINE_257_ECF: float = 2.6671
SAP_VALUE_CONTINUOUS: float = 62.7937
LINE_258_SAP_RATING_INTEGER: int = 63

View file

@ -24,6 +24,7 @@ from domain.sap.rdsap.cert_to_inputs import (
heat_transmission_section_from_cert,
internal_gains_section_from_cert,
mean_internal_temperature_section_from_cert,
sap_rating_section_from_cert,
solar_gains_section_from_cert,
space_cooling_section_from_cert,
space_heating_section_from_cert,
@ -850,3 +851,43 @@ def test_section_10a_line_refs_match_pdf(
# Assert
_pin(actual, expected, f"§10a {fixture_attr} {fixture_name}")
# ============================================================================
# §11a SAP rating — LINE_256..LINE_258
# ============================================================================
_SECTION_11A_PINS: Final[tuple[tuple[str, str], ...]] = (
("LINE_256_ENERGY_COST_DEFLATOR", "energy_cost_deflator"),
("LINE_257_ECF", "energy_cost_factor"),
("SAP_VALUE_CONTINUOUS", "sap_continuous"),
("LINE_258_SAP_RATING_INTEGER", "sap_integer"),
)
@pytest.mark.parametrize(
"fixture_name,fixture_attr,result_attr",
[
(fix, line, attr)
for fix in _FIXTURES
for line, attr in _SECTION_11A_PINS
],
ids=lambda x: x if isinstance(x, str) else None,
)
def test_section_11a_line_refs_match_pdf(
fixture_name: str, fixture_attr: str, result_attr: str
) -> None:
"""§11a pins — (256) deflator, (257) ECF, SAP continuous, (258) SAP
integer match the U985 PDF to abs=1e-4."""
# Arrange
mod = _FIXTURES[fixture_name]
epc = mod.build_epc() # type: ignore[attr-defined]
expected = getattr(mod, fixture_attr)
# Act
sr = sap_rating_section_from_cert(epc)
assert sr is not None, f"{fixture_name}: sap_rating_from_cert returned None"
actual = getattr(sr, result_attr)
# Assert
_pin(actual, expected, f"§11a {fixture_attr} {fixture_name}")