Model/tests/domain/sap10_calculator/worksheet/test_rating.py
Khalim Conn-Kowlessar d7d5084f90 Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.

Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
  identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
  test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
  golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
  tests.domain.sap10_calculator.worksheet (21 files incl. the external
  importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
  scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
  moved with the rdsap tests).

load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.

Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:58:00 +00:00

142 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for SAP 10.2 §13 Energy Cost Rating and §14 Environmental Impact
Rating formulas.
(7) ECF = deflator × total_cost / (TFA + 45)
(8) if ECF ≥ 3.5, SAP10 = 117 121 × log10(ECF)
(9) if ECF < 3.5, SAP10 = 100 13.95 × ECF
(10) CF = CO2_emissions / (TFA + 45)
(11) if CF ≥ 28.3, EI = 200 95 × log10(CF)
(12) if CF < 28.3, EI = 100 1.34 × CF
Energy Cost Deflator = 0.42 per Table 12 (page 191). SAP and EI ratings
are rounded to nearest integer; if the result is less than 1 it is quoted
as 1.
Reference: SAP 10.2 specification (14-03-2025) §13 + §14 (pages 38-39),
Table 12 Energy Cost Deflator (page 191). Per ADR-0010, active spec
target is SAP 10.2.
"""
import pytest
from domain.sap10_calculator.worksheet.rating import (
energy_cost_factor,
environmental_impact_rating,
sap_rating,
sap_rating_integer,
)
def test_ecf_applies_deflator_0_42_to_total_cost_per_adjusted_area() -> None:
# Arrange — Equation (7): ECF = 0.42 × total_cost / (TFA + 45). For a
# 100 m² dwelling costing £500/year:
# ECF = 0.42 × 500 / 145 ≈ 1.4483
# Act
result = energy_cost_factor(total_cost_gbp=500.0, total_floor_area_m2=100.0)
# Assert
assert result == pytest.approx(1.4483, abs=0.005)
def test_sap_rating_uses_linear_branch_when_ecf_below_3_5() -> None:
# Arrange — Equation (9): SAP = 100 13.95 × ECF when ECF < 3.5.
# For ECF = 1.4483 → SAP = 100 20.204 ≈ 79.80, rounds to 80.
# Act
result = sap_rating(ecf=1.4483)
# Assert — un-rounded SAP value for hand-verification.
assert result == pytest.approx(79.80, abs=0.05)
def test_sap_rating_uses_log_branch_when_ecf_above_3_5() -> None:
# Arrange — Equation (8): SAP = 117 121 × log10(ECF) for ECF ≥ 3.5.
# For a high-cost dwelling, ECF = 4.965:
# log10(4.965) ≈ 0.6960
# SAP = 117 121 × 0.6960 ≈ 32.78
# Act
result = sap_rating(ecf=4.965)
# Assert
assert result == pytest.approx(32.78, abs=0.05)
def test_sap_rating_log_threshold_boundary_is_continuous() -> None:
# Arrange — At ECF = 3.5 (the branch boundary) both formulas should
# yield the same value within tolerance, since the rating curve has
# been calibrated to be continuous at the transition.
# linear: 100 13.95 × 3.5 = 51.175
# log: 117 121 × log10(3.5) = 117 65.83 ≈ 51.17
# Act
at_threshold = sap_rating(ecf=3.5)
just_below = sap_rating(ecf=3.499)
# Assert
assert at_threshold == pytest.approx(just_below, abs=0.05)
def test_net_energy_exporter_returns_sap_above_100() -> None:
# Arrange — §13 explicitly: "The SAP rating scale has been set so that
# SAP 100 is achieved at zero-ECF. It can rise above 100 if the dwelling
# is a net exporter of energy." For ECF = -0.3:
# SAP = 100 13.95 × (0.3) = 100 + 4.185 = 104.185
# Act
result = sap_rating(ecf=-0.3)
# Assert
# 100 13.95 × (0.3) = 104.185 is exact arithmetic — no float drift.
assert result == pytest.approx(104.185, abs=1e-12)
assert result > 100.0
def test_sap_rating_integer_rounds_to_nearest_and_clamps_to_minimum_one() -> None:
# Arrange — §13: "The SAP rating is rounded to the nearest integer. If
# the result of the calculation is less than 1 the rating should be
# quoted as 1." So a catastrophically high-cost dwelling (e.g. ECF ≈ 10)
# → SAP = 117 121 × 1 = 4, clamps to 1.
# A typical case ECF = 1.4483 → 79.80 → 80.
# Act
typical = sap_rating_integer(ecf=1.4483)
catastrophic = sap_rating_integer(ecf=10.0)
# Assert
assert typical == 80
assert catastrophic == 1
def test_environmental_impact_rating_linear_branch_below_threshold() -> None:
# Arrange — Equations (10)/(12): CF = CO2/(TFA+45); when CF < 28.3,
# EI = 100 1.34 × CF. For a 100 m² home emitting 1500 kg CO2/yr:
# CF = 1500 / 145 ≈ 10.345
# EI = 100 1.34 × 10.345 ≈ 86.14
# Act
result = environmental_impact_rating(
co2_emissions_kg_per_yr=1500.0,
total_floor_area_m2=100.0,
)
# Assert
assert result == pytest.approx(86.14, abs=0.05)
def test_environmental_impact_rating_log_branch_above_threshold() -> None:
# Arrange — When CF ≥ 28.3, EI = 200 95 × log10(CF). For a 100 m²
# home emitting 5000 kg CO2/yr:
# CF = 5000 / 145 ≈ 34.48
# log10(34.48) ≈ 1.5377
# EI = 200 95 × 1.5377 ≈ 53.92
# Act
result = environmental_impact_rating(
co2_emissions_kg_per_yr=5000.0,
total_floor_area_m2=100.0,
)
# Assert
assert result == pytest.approx(53.92, abs=0.05)