diff --git a/domain/sap10_calculator/calculator.py b/domain/sap10_calculator/calculator.py index 11cecf73..fb859bf6 100644 --- a/domain/sap10_calculator/calculator.py +++ b/domain/sap10_calculator/calculator.py @@ -617,7 +617,13 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: ) ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa) sap_int = sap_rating_integer(ecf=ecf) - sap_cont = sap_rating(ecf=ecf) + # SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1 ("if the + # result of the calculation is less than 1, the rating is 1"). Apply the + # same floor to the continuous value so it stays a valid rating — the + # un-rounded part is for sensitivity NEAR real ratings, not for emitting + # a physically impossible negative SAP on a degenerate dwelling (e.g. a + # cert lodged at the floor of 1). Mirrors `sap_rating_integer`'s max(1,…). + sap_cont = max(1.0, sap_rating(ecf=ecf)) co2_factor = inputs.co2_factor_kg_per_kwh # Per-end-use effective CO2 factors (Table 12d monthly cascade for # electricity, annual for gas). cert_to_inputs supplies these from diff --git a/tests/domain/sap10_calculator/test_calculator.py b/tests/domain/sap10_calculator/test_calculator.py index dba25409..5444a140 100644 --- a/tests/domain/sap10_calculator/test_calculator.py +++ b/tests/domain/sap10_calculator/test_calculator.py @@ -270,6 +270,28 @@ def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> ) +def test_sap_score_continuous_floored_at_1_for_degenerate_high_cost() -> None: + # Arrange — SAP 10.2 §13 / RdSAP 10 §13: the SAP rating is floored at 1 + # ("if the result of the calculation is less than 1, the rating is 1"). + # Drive the cost so high that the raw ECF formula returns a negative SAP + # (a degenerate dwelling, e.g. a cert lodged at the floor of 1); both the + # integer AND the continuous score must clamp to 1 rather than emit a + # physically impossible negative rating. + inputs = replace( + _baseline_inputs(), + space_heating_fuel_cost_gbp_per_kwh=5.0, + hot_water_fuel_cost_gbp_per_kwh=5.0, + other_fuel_cost_gbp_per_kwh=5.0, + ) + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert — raw SAP would be < 1 here; the floor holds on both outputs. + assert result.sap_score == 1 + assert abs(result.sap_score_continuous - 1.0) <= 1e-9 + + def test_calculate_exposes_dimensions_intermediates() -> None: # Arrange — P5 trace mode: `result.intermediate` must surface the # worksheet-named dimensions variables for per-section diffing diff --git a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py index ef933adb..7b3524f5 100644 --- a/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py +++ b/tests/infrastructure/epc_client/test_sap_accuracy_corpus.py @@ -150,8 +150,14 @@ _CORPUS = Path( # MAE 0.12 -> 0.08 t/yr (bias +0.04 -> 0.00). A prior session deferred enum 9 # ("direction not understood") while the PE/CO2 lens was confounded by the # climate-cascade bug (fc7c4d2d); the corrected lens shows the over-rate. +# SAP RATING FLOOR (SAP 10.2 §13 / RdSAP 10 §13): the rating is floored at 1 +# ("if the result is less than 1, the rating is 1"). `calculate_sap_from_inputs` +# now applies that floor to the CONTINUOUS score too (was integer-only), so a +# degenerate dwelling no longer emits a negative SAP. Removed a -12.3 outlier +# (cert 422000111926, lodged at the floor of 1, was computing -11.3): within-0.5 +# 70.2% -> 70.3%, MAE 0.845 -> 0.833. _MIN_WITHIN_HALF_SAP = 0.70 -_MAX_SAP_MAE = 0.85 +_MAX_SAP_MAE = 0.84 _MAX_CO2_MAE_TONNES = 0.09 # t CO2 / yr vs co2_emissions_current _MAX_PE_PER_M2_MAE = 4.0 # kWh / m2 / yr vs energy_consumption_current