fix(rating): floor the continuous SAP score at 1 (SAP 10.2 §13 / RdSAP 10 §13)

The SAP rating is spec-floored at 1 ("if the result of the calculation is
less than 1, the rating is 1"). `sap_rating_integer` already clamps, but the
continuous `sap_score_continuous` did not — so a degenerate dwelling could
emit a physically impossible negative SAP. Apply the same max(1, …) floor to
the continuous value (the un-rounded part is for sensitivity near real
ratings, not for negative ratings).

Removes a -12.3 accuracy outlier on the committed corpus (cert 422000111926,
lodged at the floor of 1, was computing -11.3): within-0.5 70.2% -> 70.3%,
MAE 0.845 -> 0.833. Ratcheted the corpus MAE ceiling to 0.84. Unit-pinned in
test_calculator.

pyright not installed in this codespace (strict gate not run locally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-19 14:03:13 +00:00
parent 2e416a0221
commit fc5f10ea92
3 changed files with 36 additions and 2 deletions

View file

@ -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

View file

@ -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

View file

@ -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