Model/domain/sap10_ml/ecf.py
Khalim Conn-Kowlessar 68401c517a refactor: lift-and-shift packages/domain/src/domain/ml → domain/sap10_ml
Sibling migration to the sap10_calculator move — `domain.ml` now lives
at the root-level layout (`domain/sap10_ml/`) matching the pattern
already used by `domain.addresses`, `domain.tasks`, `domain.postcode`,
and `domain.sap10_calculator`.

Changes:

- `git mv packages/domain/src/domain/ml → domain/sap10_ml` (19 files;
  history preserved).
- Subpackage rename: `domain.ml` → `domain.sap10_ml`. 32 references
  rewritten across .py and .md files: 11 internal + 21 external
  (datatypes/epc/domain/mapper.py, 14 files in domain/sap10_calculator,
  2 backend tests, 2 ADRs, 1 README, 1 design doc).
- Path-string updates: `pytest.ini` testpath
  `packages/domain/src/domain/ml/tests` → `domain/sap10_ml/tests` so
  ML tests stay in the default auto-discovered sweep. `CONTEXT.md`
  also updated.

`packages/domain/src/domain/` is now empty — the workspace `domain/`
tree has been fully migrated. Together with the `domain/__init__.py`
deletions from the sap10_calculator commit (29ac35cc), `domain` is
now a single root-level namespace package with subpackages
{addresses, sap10_calculator, sap10_ml, tasks} + the standalone
`postcode.py` module.

Verified:

- Focused sweep (backend mapper-chain + sap10_calculator worksheet
  e2e + golden fixtures): 99 passed / 19 failed — identical baseline.
- Wider sweep (all sap10_calculator + sap10_ml): 1654 passed / 20
  failed (same pre-existing failures).
- domain/sap10_ml/tests: 210/210 PASSED at new path.
- Pyright net-zero: heat_transmission.py 13, cert_to_inputs.py 35,
  mapper.py 33, rdsap_uvalues.py 1 (all unchanged from baseline).

Note: `packages/domain/pyproject.toml` still declares
`packages = ["src/domain"]` for the hatchling wheel — that target
directory is now empty and the wheel build is effectively a no-op.
Retiring the workspace package or repointing the wheel is a follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:01:35 +00:00

108 lines
4.1 KiB
Python

"""SAP10 §20.1 cost reconstruction: predicted total fuel cost + ECF.
ECF = 0.42 * total_cost / (TFA + 45) (SAP rating relationship)
Total cost (gbp/yr) = (space_kwh * space_fuel_price + dhw_kwh * dhw_fuel_price
+ lighting_kwh * elec_price) / 100 [pence -> pounds]
Standing charges are deliberately omitted at this slice -- they add a
fuel-mix-conditional offset the tree-based model can learn (ADR-0008,
"+ Lighting" scope).
"""
from __future__ import annotations
from math import log10
from typing import Final, Optional
from domain.sap10_ml.sap_efficiencies import fuel_unit_price_p_per_kwh
# SAP10 deflator applied to total cost before the rating equation (Table 32).
_DEFLATOR: float = 0.42
# Electricity standard tariff fuel code (Table 32) — used for lighting + PV credit.
_ELECTRICITY_STANDARD_CODE: int = 30
# Annual PV yield (kWh / kWp / yr) by SAP10.2 region. Derived from Table 6e
# climate data integrated over an average roof (South-facing, 30 deg pitch,
# average overshading). Southern England ~ 900, Scotland ~ 700, Highland ~ 600.
_PV_YIELD_BY_REGION: Final[dict[int, float]] = {
1: 920, 2: 950, 3: 970, 4: 960, 5: 920, 6: 880, 7: 850, 8: 830,
9: 820, 10: 820, 11: 830, 12: 880, 13: 850, 14: 770, 15: 740,
16: 700, 17: 650, 18: 700, 19: 690, 20: 680, 21: 870, 22: 870,
}
_PV_YIELD_UK_AVG: Final[float] = 850.0
def predicted_pv_generation_kwh(
pv_total_peak_power_kw: Optional[float],
region_code: Optional[str],
) -> float:
"""Annual PV generation (kWh/yr) for the dwelling's PV array(s).
Linear in peak power; uses a SAP-region yield factor with South-facing,
30 deg pitch, average overshading assumptions (SAP10.2 Table 6e).
"""
if pv_total_peak_power_kw is None or pv_total_peak_power_kw <= 0:
return 0.0
yield_factor = _PV_YIELD_UK_AVG
if region_code is not None:
try:
yield_factor = _PV_YIELD_BY_REGION.get(int(region_code), _PV_YIELD_UK_AVG)
except (TypeError, ValueError):
pass
return pv_total_peak_power_kw * yield_factor
def predicted_total_fuel_cost_gbp(
predicted_space_heating_kwh: float,
predicted_hot_water_kwh: float,
predicted_lighting_kwh: float,
main_fuel_code: Optional[int],
water_heating_fuel_code: Optional[int],
predicted_pv_kwh: float = 0.0,
) -> float:
"""Annual regulated fuel cost (gbp/yr).
Skips standing charges; sums delivered kWh * unit price across the
three included end uses (lighting always at standard electricity).
Slice 17a: subtracts predicted_pv_kwh * standard electricity price as
a flat PV credit. SAP10.2 splits PV between self-consumption and
export with separate rates; both are 13.19 p/kWh in Table 32 so a
single rate is fine at this fidelity.
"""
space_p_per_kwh = fuel_unit_price_p_per_kwh(main_fuel_code)
dhw_p_per_kwh = fuel_unit_price_p_per_kwh(water_heating_fuel_code)
light_p_per_kwh = fuel_unit_price_p_per_kwh(_ELECTRICITY_STANDARD_CODE)
pv_p_per_kwh = fuel_unit_price_p_per_kwh(_ELECTRICITY_STANDARD_CODE)
total_pence = (
predicted_space_heating_kwh * space_p_per_kwh
+ predicted_hot_water_kwh * dhw_p_per_kwh
+ predicted_lighting_kwh * light_p_per_kwh
- predicted_pv_kwh * pv_p_per_kwh
)
return total_pence / 100.0
def predicted_ecf(
predicted_total_cost_gbp: float,
total_floor_area_m2: Optional[float],
) -> float:
"""SAP rating Energy Cost Factor: 0.42 * total_cost / (TFA + 45)."""
if total_floor_area_m2 is None or total_floor_area_m2 <= 0:
return 0.0
return _DEFLATOR * predicted_total_cost_gbp / (total_floor_area_m2 + 45.0)
def predicted_log10_ecf(predicted_ecf_value: float) -> float:
"""log10(ECF). Returns 0.0 for non-positive input so the feature is
finite for the (rare) all-PV property.
The SAP rating formula uses log10(ECF) for ECF >= 3.5 (low-SAP region);
in the high-SAP linear region the model can still use log10_ecf as a
monotone proxy for SAP."""
if predicted_ecf_value <= 0:
return 0.0
return log10(predicted_ecf_value)