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

252 lines
9.1 KiB
Python
Raw Permalink 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.

"""Crude annual heat/hot-water/lighting demand approximations.
Used by `transform.py` to populate the `predicted_*_kwh` features (slice 16d).
These are deliberately coarse: they give the model a physics-shaped starting
point that it can adjust against the cert's real kWh labels. See ADR-0008
for the "crude annual" rationale.
Formulas:
- predicted_space_heating_kwh
~= envelope_heat_loss_w_per_k * HDH_region / efficiency_main_heating
where HDH_region is heating degree hours per year by SAP region.
- predicted_hot_water_kwh (SAP10.2 Appendix J simplified)
V_d ~= 25 * N_occupants + 36 (litres / day)
Q_HW_useful ~= 4.18 * V_d * (55 - 12) * 365 * 1e-3 (kWh / yr)
N_occupants defaulted from total_floor_area_m2 per SAP J Table 1b.
- predicted_lighting_kwh (SAP10.2 Section L simplified)
base ~= 9.3 * TFA * (1 - 0.5 * led_share - 0.4 * cfl_share)
"""
from __future__ import annotations
from math import exp
from typing import Final, Optional
# SAP10.2 Table 6 / U6 heating degree hours per year by SAP region (K * h).
# Coarse grouping: most regions cluster ~50-60k; using region_code if known.
_HDH_BY_REGION: Final[dict[int, float]] = {
1: 51000, # Thames
2: 52000, # SE England
3: 50000, # Southern
4: 51000, # SW England
5: 53000, # Severn (5E + 5W treated together)
6: 54000, # Midlands
7: 55000, # W Pennines / Lancashire
8: 55000, # NW England / SW Scotland
9: 56000, # Borders / North Tyne
10: 56000, # NE England
11: 56000, # E Pennines / Yorkshire
12: 54000, # E Anglia
13: 56000, # Wales (Mid)
14: 58000, # W Scotland
15: 59000, # E Scotland
16: 60000, # NE Scotland
17: 62000, # Highland
18: 60000, # Western Isles
19: 60000, # Orkney
20: 62000, # Shetland
21: 54000, # Northern Ireland
22: 53000, # Isle of Man
}
_HDH_UK_AVG: Final[float] = 53000.0
def _hdh_for_region(region_code: Optional[str]) -> float:
if region_code is None:
return _HDH_UK_AVG
try:
code = int(region_code)
except (TypeError, ValueError):
return _HDH_UK_AVG
return _HDH_BY_REGION.get(code, _HDH_UK_AVG)
def predicted_space_heating_kwh(
envelope_heat_loss_w_per_k: float,
region_code: Optional[str],
seasonal_efficiency_main: float,
ventilation_heat_loss_w_per_k: float = 0.0,
) -> float:
"""Annual delivered space-heating kWh.
delivered_kWh = HLC * HDH_region * 1e-3 / efficiency
where HLC = envelope (conduction + bridging) + ventilation (infiltration),
HDH is K*hours/year. SAP10.2 §5 routes both losses through the same demand
pipeline. Ventilation defaults to 0 for back-compat with pre-slice-20a
callers.
"""
hlc = envelope_heat_loss_w_per_k + ventilation_heat_loss_w_per_k
if hlc <= 0 or seasonal_efficiency_main <= 0:
return 0.0
hdh = _hdh_for_region(region_code)
useful_kwh = hlc * hdh * 1e-3
return useful_kwh / seasonal_efficiency_main
def _default_occupants_sap_j(total_floor_area_m2: float) -> float:
"""SAP10.2 Appendix J Table 1b default occupancy.
N = 1 + 1.76 * (1 - exp(-0.000349 * (TFA - 13.9)^2)) + 0.0013 * (TFA - 13.9)
for TFA > 13.9 m^2; otherwise N = 1.
"""
if total_floor_area_m2 <= 13.9:
return 1.0
x = total_floor_area_m2 - 13.9
return 1.0 + 1.76 * (1.0 - exp(-0.000349 * x * x)) + 0.0013 * x
def predicted_hot_water_kwh(
total_floor_area_m2: Optional[float],
seasonal_efficiency_water: float,
*,
cylinder_size: Optional[int] = None,
cylinder_insulation_thickness_mm: Optional[int] = None,
cylinder_insulation_type: Optional[int] = None,
age_band: Optional[str] = None,
has_wwhrs: bool = False,
has_solar_water_heating: bool = False,
) -> float:
"""Annual delivered hot-water kWh per SAP10.2 Appendix J (slice 17b).
Components (all kWh useful, sum then divided by efficiency for delivered):
useful_demand = 4.18 * Vd * 43 * 365 / 3600 (Vd in litres/day)
distribution_loss = useful_demand * 0.15
storage_loss = volume * insulation_factor * 365 * 0.6
primary_loss(age) = 245 (A-J) or 60 (K-M)
wwhrs_credit = useful_demand * 0.12 if has_wwhrs
solar_hw_credit = 250 if has_solar_water_heating
Defaults follow RdSAP10 §11 / Table 29 for missing cylinder fields.
"""
if total_floor_area_m2 is None or total_floor_area_m2 <= 0:
return 0.0
if seasonal_efficiency_water <= 0:
return 0.0
n = _default_occupants_sap_j(total_floor_area_m2)
vd_litres = 25.0 * n + 36.0
useful_kwh = 4.18 * vd_litres * (55.0 - 12.0) * 365.0 / 3600.0
distribution_loss = useful_kwh * 0.15
storage_loss = _cylinder_storage_loss_kwh(
cylinder_size=cylinder_size,
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
cylinder_insulation_type=cylinder_insulation_type,
age_band=age_band,
)
primary_loss = _primary_circuit_loss_kwh(age_band)
wwhrs_credit = useful_kwh * 0.12 if has_wwhrs else 0.0
solar_credit = 250.0 if has_solar_water_heating else 0.0
total_useful = max(
0.0,
useful_kwh + distribution_loss + storage_loss + primary_loss - wwhrs_credit - solar_credit,
)
return total_useful / seasonal_efficiency_water
# SAP10.2 cylinder volume by RdSAP10 size code (Table 28).
_CYLINDER_VOLUME_L: Final[dict[int, float]] = {1: 110.0, 2: 160.0, 3: 210.0}
# SAP10.2 Table 2 storage loss factor (kWh / litre / day) by insulation
# thickness in mm. Lower number = better insulation.
_STORAGE_LOSS_FACTOR: Final[dict[int, float]] = {
0: 0.0203, # uninsulated -> high loss
12: 0.0152, # 12 mm jacket
25: 0.0078, # 25 mm foam
38: 0.0056,
50: 0.0043,
80: 0.0025,
100: 0.0022,
150: 0.0014,
200: 0.0011,
}
# RdSAP10 Table 29 cylinder-insulation default by age band when unknown:
# A-F -> 12 mm jacket, G-H -> 25 mm foam, I-M -> 38 mm foam.
_AGE_TO_DEFAULT_CYLINDER_INS_MM: Final[dict[str, int]] = {
"A": 12, "B": 12, "C": 12, "D": 12, "E": 12, "F": 12,
"G": 25, "H": 25,
"I": 38, "J": 38, "K": 38, "L": 38, "M": 38,
}
def _cylinder_storage_loss_kwh(
cylinder_size: Optional[int],
cylinder_insulation_thickness_mm: Optional[int],
cylinder_insulation_type: Optional[int],
age_band: Optional[str],
) -> float:
"""Annual cylinder storage loss (kWh useful, before efficiency division).
Returns 0 when no cylinder is described AND age_band is unknown (assume
instantaneous / combi without storage). Heated-space modifier 0.6.
"""
if cylinder_size is None and age_band is None:
return 0.0
volume = _CYLINDER_VOLUME_L.get(cylinder_size or 1, 110.0)
thickness = cylinder_insulation_thickness_mm
if thickness is None and age_band is not None:
thickness = _AGE_TO_DEFAULT_CYLINDER_INS_MM.get(age_band.upper())
if thickness is None:
thickness = 38
factor = _nearest_storage_loss_factor(thickness)
heated_space_modifier = 0.6
return volume * factor * 365.0 * heated_space_modifier
def _nearest_storage_loss_factor(thickness_mm: int) -> float:
"""Pick the SAP10.2 Table 2 row with thickness closest <= the supplied
value. For thicknesses below 12 mm, uses the uninsulated 0-row."""
candidates = sorted(_STORAGE_LOSS_FACTOR.keys())
chosen = candidates[0]
for t in candidates:
if t <= thickness_mm:
chosen = t
return _STORAGE_LOSS_FACTOR[chosen]
def _primary_circuit_loss_kwh(age_band: Optional[str]) -> float:
"""Annual primary-pipework loss (kWh useful) by age band.
RdSAP10 Table 29: pre-2007 (A-J) no primary insulation -> 245 kWh/yr;
K, L, M -> full insulation -> 60 kWh/yr. Unknown -> 245.
"""
if age_band is None:
return 245.0
return 60.0 if age_band.upper() in ("K", "L", "M") else 245.0
def predicted_lighting_kwh(
total_floor_area_m2: Optional[float],
cfl_count: Optional[int],
led_count: Optional[int],
incandescent_count: Optional[int],
) -> float:
"""Annual lighting kWh (SAP10.2 Section L simplified).
Base demand ~ 9.3 * TFA kWh/yr; reduced by low-energy bulb share. LED
bulbs cut consumption by ~50%, CFL by ~40%, incandescent by 0%.
Missing counts treated as zero.
DEPRECATED for SAP rating use. The spec-faithful Appendix L L1-L11
cascade is in `domain.sap10_calculator.worksheet.internal_gains.annual_lighting_kwh`
and is what `cert_to_inputs` now plumbs into `inputs.lighting_kwh_per_yr`.
This heuristic over-counts ~3× on the Elmhurst cohort (528 vs 140 kWh
on 000474). Kept only for `domain.sap10_ml.ecf.energy_cost_factor` and
`domain.sap10_ml.transform.transform_to_predictions` — legacy ML predictor
callsites that pre-date the SAP rewrite. Rip when those migrate.
See ADR-0010 amendment "Appendix L lighting (2026-05-22)".
"""
if total_floor_area_m2 is None or total_floor_area_m2 <= 0:
return 0.0
cfl = cfl_count or 0
led = led_count or 0
inc = incandescent_count or 0
total_bulbs = max(1, cfl + led + inc)
led_share = led / total_bulbs
cfl_share = cfl / total_bulbs
reduction = 0.5 * led_share + 0.4 * cfl_share
return 9.3 * total_floor_area_m2 * (1.0 - reduction)