mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
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>
252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
"""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)
|