refactor(baseline): SapCalculator ABC replaces the Calculator Protocol

PR feedback: prefer an abstract base the calculator inherits from over a
structural Protocol. Define `SapCalculator(ABC)` in the calculator package
(the engine owns its own contract) and have `Sap10Calculator` inherit it;
a future methodology is another subclass. Placing the ABC with the engine —
not in property_baseline — keeps the dependency pointing consumer -> engine
(sap10_calculator imports nothing from property_baseline). Consistent with
the repo's existing port convention (FuelRatesRepository(ABC)).

CalculatorRebaseliner keeps its reference to SapCalculator type-only (under
TYPE_CHECKING), so the module still does not import the calculator at
runtime. Test fakes now inherit the ABC since structural conformance no
longer applies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-02 13:45:48 +00:00 committed by Jun-te Kim
parent d33d46fb6d
commit 298755fbe0
3 changed files with 22 additions and 14 deletions

View file

@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Optional, Protocol
from typing import TYPE_CHECKING, Optional
from datatypes.epc.domain.epc import Epc
from domain.property_baseline.performance import Performance
@ -9,7 +9,7 @@ from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineReason
if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.sap10_calculator.calculator import SapResult
from domain.sap10_calculator.calculator import SapCalculator, SapResult
logger = logging.getLogger(__name__)
@ -22,13 +22,6 @@ _REL_TOL = 0.01
_KG_PER_TONNE = 1000.0
class Calculator(Protocol):
"""The slice of `Sap10Calculator` the rebaseliner needs — `Sap10Calculator`
satisfies it structurally, so this module does not import the calculator."""
def calculate(self, epc: "EpcPropertyData") -> "SapResult": ...
def performance_from_sap_result(result: "SapResult") -> Performance:
"""The four rated quantities, read off a `SapResult`: band derived from the
score, CO2 converted kgtonnes, PEUI rounded to the lodged integer scale."""
@ -58,7 +51,7 @@ class CalculatorRebaseliner(Rebaseliner):
is fixed immediately.
"""
def __init__(self, calculator: Calculator) -> None:
def __init__(self, calculator: "SapCalculator") -> None:
self._calculator = calculator
def rebaseline(

View file

@ -41,6 +41,7 @@ Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Final, Optional, TYPE_CHECKING
@ -751,7 +752,21 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
)
class Sap10Calculator:
class SapCalculator(ABC):
"""The contract a SAP calculator satisfies: an `EpcPropertyData` in, a
typed `SapResult` out. `Sap10Calculator` is the SAP 10.2 implementation;
a future methodology (e.g. SAP 10.3 / a successor) is another subclass.
Consumers (e.g. `CalculatorRebaseliner`) depend on this abstraction, not
on a concrete calculator so the engine can be swapped without touching
them.
"""
@abstractmethod
def calculate(self, epc: "EpcPropertyData") -> SapResult: ...
class Sap10Calculator(SapCalculator):
"""Deterministic SAP 10.2 calculator entry point. Maps an
`EpcPropertyData` to typed `CalculatorInputs` via the RdSAP-driven
`cert_to_inputs` mapper and runs the 12-month worksheet loop.

View file

@ -9,7 +9,7 @@ from datatypes.epc.domain.epc import Epc
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner
from domain.property_baseline.performance import Performance
from domain.sap10_calculator.calculator import SapResult
from domain.sap10_calculator.calculator import SapCalculator, SapResult
from domain.sap10_calculator.exceptions import UnmappedSapCode
@ -54,7 +54,7 @@ def _sap_result(
)
class _StubCalculator:
class _StubCalculator(SapCalculator):
def __init__(self, result: SapResult) -> None:
self._result = result
@ -122,7 +122,7 @@ def test_a_10_2_cert_logs_divergence_when_the_calculator_disagrees(
def test_a_calculator_raise_propagates_and_aborts() -> None:
# Arrange — the calculator is load-bearing, so a raise is not swallowed.
class _Raising:
class _Raising(SapCalculator):
def calculate(self, epc: EpcPropertyData) -> SapResult:
raise UnmappedSapCode("heat_emitter_type", 99)