From 2bfecad27266d9348e3537f698c2cbe69a2ad099 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 08:06:52 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2031:=20=C2=A711a=20SAP=20rating=20cascad?= =?UTF-8?q?e=20pin=20(24/24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/domain/sap/rdsap/cert_to_inputs.py | 40 ++++++++++++++++++ .../tests/_elmhurst_worksheet_000474.py | 8 ++++ .../tests/_elmhurst_worksheet_000477.py | 8 ++++ .../tests/_elmhurst_worksheet_000480.py | 8 ++++ .../tests/_elmhurst_worksheet_000487.py | 8 ++++ .../tests/_elmhurst_worksheet_000490.py | 8 ++++ .../tests/_elmhurst_worksheet_000516.py | 8 ++++ .../tests/test_section_cascade_pins.py | 41 +++++++++++++++++++ 8 files changed, 129 insertions(+) diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index 8a344348..6e06c895 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -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]: diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py index 9f263704..4997a70b 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py index fef51471..028b7518 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py index 52fdbc5f..e22ef452 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py index c44416dc..da027d5c 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py index 6618be85..40cb5c91 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py index 96ebbba1..b084d298 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py @@ -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 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index aef2cab5..f1bfe4b7 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -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}")