Model/domain/sap10_ml/ucl.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

59 lines
1.9 KiB
Python

"""UCL per-band correction for Primary Energy Intensity.
Per Few et al. 2023 — "The over-prediction of energy use by EPCs in Great Britain"
(Energy & Buildings 288, 113024). Table 3 per-band linear correction.
Ported from `backend/ml_models/AnnualBillSavings.adjust_energy_to_metered`. Applied
to PEUI training labels per ADR-0007, *not* at runtime — the discontinuities at
EPC band boundaries that arose when this was applied post-prediction are what made
us fold it into the training labels instead.
Open question §15.14 in the PRD: the paper was calibrated on gas-heated, non-PV
homes in England and Wales rated under SAP 2012. The current implementation
extrapolates silently to all properties.
"""
from typing import Final
from datatypes.epc.domain.epc import Epc
_GRADIENTS: Final[dict[Epc, float]] = {
Epc.A: -0.10,
Epc.B: -0.10,
Epc.C: -0.43,
Epc.D: -0.52,
Epc.E: -0.70,
Epc.F: -0.76,
Epc.G: -0.76,
}
_INTERCEPTS: Final[dict[Epc, float]] = {
Epc.A: 28.0,
Epc.B: 28.0,
Epc.C: 97.0,
Epc.D: 119.0,
Epc.E: 160.0,
Epc.F: 157.0,
Epc.G: 157.0,
}
def apply_ucl_correction(peui_raw: float, band: Epc) -> float:
"""Return the metered-equivalent PEUI for an EPC's raw PEUI in a given band.
The Few et al. correction is one-sided: EPCs over-predict consumption, so the
correction only ever subtracts from PEUI. When the linear correction would
instead *add* to PEUI for an unusually low-PEUI property in its band, we clamp
to zero — leaving PEUI unchanged rather than inflating it.
"""
consumption_difference = _GRADIENTS[band] * peui_raw + _INTERCEPTS[band]
if consumption_difference > 0:
consumption_difference = 0.0
adjusted = peui_raw + consumption_difference
if adjusted < 0:
raise ValueError(
f"UCL-corrected PEUI is negative ({adjusted}) — "
f"impossible for raw PEUI {peui_raw} band {band.value}"
)
return adjusted