mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge remote-tracking branch 'origin/main' into fix/electric-room-heater-overlay-companions
This commit is contained in:
commit
dd30903ace
6 changed files with 758 additions and 2 deletions
|
|
@ -5879,6 +5879,10 @@ _CYLINDER_INSULATION_TYPE_FACTORY: Final[int] = 1
|
|||
# which SAP 10.2 Table 2 Note 1 gives a SEPARATE (higher) loss factor
|
||||
# L = 0.005 + 1.76 / (t + 12.8) vs the factory L = 0.005 + 0.55 / (t+4).
|
||||
_CYLINDER_INSULATION_TYPE_LOOSE_JACKET: Final[int] = 2
|
||||
# RdSAP 10 field 7-11 code 0 = NO insulation. SAP 10.2 Table 2 has no
|
||||
# separate uninsulated row — the loose-jacket formula at t = 0 IS the
|
||||
# uninsulated factor (L = 0.005 + 1.76 / 12.8 = 0.1425 kWh/L/day).
|
||||
_CYLINDER_INSULATION_TYPE_NONE: Final[int] = 0
|
||||
|
||||
|
||||
def _cylinder_storage_loss_insulation_label(
|
||||
|
|
@ -7106,9 +7110,38 @@ def _cylinder_storage_loss_override(
|
|||
insulation_label = _cylinder_storage_loss_insulation_label(
|
||||
sh.cylinder_insulation_type
|
||||
)
|
||||
thickness_mm: Optional[float] = (
|
||||
float(sh.cylinder_insulation_thickness_mm)
|
||||
if sh.cylinder_insulation_thickness_mm is not None
|
||||
else None
|
||||
)
|
||||
# SAP 10.2 Table 2: an uninsulated cylinder takes the loose-jacket
|
||||
# formula at t = 0 — the WORST loss SAP knows, never the zero-loss
|
||||
# combi default (which over-rates the dwelling). Two lodgement shapes
|
||||
# describe it: the EPB API's insulation type 0, and the Elmhurst
|
||||
# site-notes path's type None + explicit 0 mm ("No Insulation" maps to
|
||||
# no material; the lodged thickness carries the signal — see
|
||||
# `_ELMHURST_CYLINDER_NO_INSULATION_LABELS` in the mapper).
|
||||
if _int_or_none(sh.cylinder_insulation_type) == _CYLINDER_INSULATION_TYPE_NONE:
|
||||
insulation_label, thickness_mm = "loose_jacket", 0.0
|
||||
if insulation_label is None and thickness_mm == 0.0:
|
||||
insulation_label = "loose_jacket"
|
||||
# RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
|
||||
# accessible": a lodged cylinder with NO insulation data at all takes
|
||||
# the age-band default — the same row the §10.7 no-water-heating
|
||||
# default uses. An ABSENT thickness means not accessible (Table 29);
|
||||
# a lodged 0 mm means uninsulated (handled above). When the age band
|
||||
# itself is unresolvable, fall through to the legacy None (zero loss)
|
||||
# rather than raising mid-rebaseline.
|
||||
if insulation_label is None and thickness_mm is None:
|
||||
band = (_dwelling_age_band(epc) or "")[:1].upper()
|
||||
table_29_default = _TABLE_29_DEFAULT_CYLINDER_INSULATION_BY_AGE.get(band)
|
||||
if table_29_default is not None:
|
||||
default_code, default_mm = table_29_default
|
||||
insulation_label = _cylinder_storage_loss_insulation_label(default_code)
|
||||
thickness_mm = float(default_mm)
|
||||
if insulation_label is None:
|
||||
return None
|
||||
thickness_mm = sh.cylinder_insulation_thickness_mm
|
||||
if thickness_mm is None:
|
||||
return None
|
||||
storage_56m = cylinder_storage_loss_monthly_kwh(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Dict, List, Optional
|
||||
|
||||
from backend.app.db.models.hubspot_deal_data import HubspotDealData
|
||||
from etl.hubspot.project_data import ProjectData
|
||||
from etl.hubspot.utils import parse_hs_bool, parse_hs_date
|
||||
|
||||
|
||||
|
|
@ -12,6 +13,8 @@ class HubspotDealDiffer:
|
|||
]
|
||||
RETROFIT_DESIGN_COMPLETE = "uploaded"
|
||||
LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"]
|
||||
ABRI_CONDITION_ASSOCIATED_PROJECT_ID = "123456"
|
||||
ABRI_CONDITION_PROJECT_CODE = "Abri Condition"
|
||||
|
||||
@staticmethod
|
||||
def check_for_db_update_trigger(
|
||||
|
|
@ -170,6 +173,52 @@ class HubspotDealDiffer:
|
|||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def check_for_abri_survey_creation(
|
||||
new_deal: Dict[str, str],
|
||||
new_project: Optional[ProjectData],
|
||||
old_deal: HubspotDealData,
|
||||
) -> bool:
|
||||
if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project):
|
||||
return False
|
||||
|
||||
if old_deal.confirmed_survey_date is not None:
|
||||
return False
|
||||
|
||||
return parse_hs_date(new_deal.get("confirmed_survey_date")) is not None
|
||||
|
||||
@staticmethod
|
||||
def check_for_abri_survey_amendment(
|
||||
new_deal: Dict[str, str],
|
||||
new_project: Optional[ProjectData],
|
||||
old_deal: HubspotDealData,
|
||||
) -> bool:
|
||||
if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project):
|
||||
return False
|
||||
|
||||
# A first-time survey date is a creation, not an amendment.
|
||||
if old_deal.confirmed_survey_date is None:
|
||||
return False
|
||||
|
||||
new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date"))
|
||||
if old_deal.confirmed_survey_date != new_survey_date:
|
||||
return True
|
||||
|
||||
return old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time")
|
||||
|
||||
@staticmethod
|
||||
def _is_abri_condition_deal(
|
||||
new_deal: Dict[str, str], new_project: Optional[ProjectData]
|
||||
) -> bool:
|
||||
if new_project is not None:
|
||||
return (
|
||||
new_project["project_id"]
|
||||
== HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID
|
||||
)
|
||||
|
||||
new_project_code = (new_deal.get("project_code") or "").lower()
|
||||
return new_project_code == HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower()
|
||||
|
||||
@staticmethod
|
||||
def check_for_magicplan_trigger(
|
||||
new_deal: Dict[str, str], old_deal: HubspotDealData
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
|
||||
from backend.app.db.models.hubspot_deal_data import HubspotDealData
|
||||
from etl.hubspot.hubspot_deal_differ import HubspotDealDiffer
|
||||
from etl.hubspot.project_data import ProjectData
|
||||
|
||||
|
||||
BASE_TIME = datetime(2025, 12, 1, 12, 0, 0)
|
||||
|
|
@ -343,6 +344,349 @@ def test_magicplan_trigger__outcome_surveyed_uppercase__returns_true() -> None:
|
|||
assert result is True
|
||||
|
||||
|
||||
# ==================================
|
||||
# ABRI SURVEY CREATION TRIGGER TESTS
|
||||
# ==================================
|
||||
|
||||
|
||||
def test_abri_survey_creation__date_first_set_on_abri_deal__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_survey_creation__date_already_set__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_survey_creation__non_abri_project_code__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_survey_creation__project_code_cased_differently__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="ABRI CONDITION",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="ABRI CONDITION",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_overrides",
|
||||
[
|
||||
{},
|
||||
{"confirmed_survey_date": ""},
|
||||
{"confirmed_survey_date": "not-a-date"},
|
||||
],
|
||||
)
|
||||
def test_abri_survey_creation__no_parseable_new_date__returns_false(
|
||||
new_overrides: Dict[str, str],
|
||||
) -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
**new_overrides,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_survey_creation__associated_abri_project_id__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
new_project = ProjectData(
|
||||
project_id=HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID,
|
||||
name="Abri Condition",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=new_project,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_survey_creation__non_abri_project_id_with_abri_code__returns_false() -> (
|
||||
None
|
||||
):
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
new_project = ProjectData(project_id="999999", name="Southern Retrofit")
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_creation(
|
||||
new_deal=new_deal,
|
||||
new_project=new_project,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
# ===================================
|
||||
# ABRI SURVEY AMENDMENT TRIGGER TESTS
|
||||
# ===================================
|
||||
|
||||
|
||||
def test_abri_survey_amendment__date_changed_on_abri_deal__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_amendment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_survey_amendment__non_abri_deal__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
confirmed_survey_date=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_amendment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_survey_amendment__date_and_time_unchanged__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=datetime(2026, 7, 15, tzinfo=timezone.utc),
|
||||
confirmed_survey_time="09:30",
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
confirmed_survey_time="09:30",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_amendment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_survey_amendment__time_changed_on_abri_deal__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=datetime(2026, 7, 15, tzinfo=timezone.utc),
|
||||
confirmed_survey_time="09:30",
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
confirmed_survey_time="14:00",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_amendment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_survey_amendment__date_first_set_on_abri_deal__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
confirmed_survey_date="2026-07-15",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_survey_amendment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
# =======================
|
||||
# DB UPDATE TRIGGER TESTS
|
||||
# =======================
|
||||
|
|
|
|||
|
|
@ -55,6 +55,26 @@ def _band_of(score: Optional[float]) -> Optional[str]:
|
|||
return Epc.from_sap_score(round(score)).value
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> Optional[int]:
|
||||
"""Coerce a jsonb-backed scalar (int / digit-string / None) to int."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _float_or_none(value: object) -> Optional[float]:
|
||||
"""Coerce a jsonb-backed scalar (number / numeric-string / None) to float."""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(str(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class Severity(IntEnum):
|
||||
LOW = 1
|
||||
MEDIUM = 2
|
||||
|
|
@ -89,6 +109,12 @@ class PropertyAudit:
|
|||
solar_sap_points: Optional[float] # max SAP a single solar_pv measure earns
|
||||
solar_bill_savings: Optional[float] # the solar_pv measure's £/yr bill saving
|
||||
n_measures: int
|
||||
# Hot-water-cylinder fields from the latest lodged EPC row (None when the
|
||||
# Property has no epc_property row). Insulation type codes are the RdSAP
|
||||
# lodgement codes: 0 = none, 1 = factory foam, 2 = loose jacket.
|
||||
has_hot_water_cylinder: Optional[bool]
|
||||
cylinder_insulation_type: Optional[int]
|
||||
cylinder_insulation_thickness_mm: Optional[float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -342,6 +368,54 @@ def _negative_bill_savings(a: PropertyAudit) -> Optional[str]:
|
|||
return f"energy_bill_savings £{a.energy_bill_savings:.0f}/yr on £{a.cost_of_works:.0f} of works"
|
||||
|
||||
|
||||
@check("cylinder-storage-loss-dropped", Severity.HIGH)
|
||||
def _cylinder_storage_loss_dropped(a: PropertyAudit) -> Optional[str]:
|
||||
"""A lodged hot-water cylinder whose insulation fields cannot resolve a SAP
|
||||
10.2 Table 2 loss branch — insulation type is not factory foam (1) or loose
|
||||
jacket (2), or the thickness is missing — so the calculator's
|
||||
``_cylinder_storage_loss_override`` (domain/sap10_calculator/rdsap/
|
||||
cert_to_inputs.py) returns None and the worksheet keeps the zero-storage-loss
|
||||
default meant for combi / instantaneous systems. The baseline then
|
||||
OVER-RATES: an UNINSULATED cylinder (type 0, the worst case SAP knows) scores
|
||||
~+13 SAP better than a properly insulated one instead of much worse.
|
||||
|
||||
Provenance: portfolio 814 / scenario 1271 audit (2026-07-03). All 26
|
||||
properties with ``has_hot_water_cylinder`` and insulation type 0 (23) or NULL
|
||||
(3) over-rate vs their lodged score — Δ+1..+18, avg +12.7 for type 0 — while
|
||||
the type-1/2 population averages Δ−2. Same-lodged neighbours split by up to
|
||||
19 effective points (WD5 0DP: pid 742216 lodged 54 → effective 69 vs pid
|
||||
742215 lodged 54 → effective 50, identical building parts), and the inflated
|
||||
members distorted neighbour-divergence cohort medians (E17 6EB pid 742355
|
||||
Δ+18; WD5 0DP pushed honest pid 742210 over the flag threshold). Fix is in
|
||||
the calculator (apply the uninsulated Table 2 loss for type 0, or the RdSAP
|
||||
Table 29 age-band default when unknown), not in the data.
|
||||
|
||||
Fires on the input pattern (deterministic — the loss IS dropped for every
|
||||
such row), not on a tuned delta threshold; the reason string reports the
|
||||
effective-vs-lodged gap where a lodged score exists so triage can rank by
|
||||
materiality. A full-SAP cert lodging a manufacturer's declared loss instead
|
||||
of type/thickness would bypass the bug and false-positive here — none exist
|
||||
on the motivating portfolio; revisit if one appears."""
|
||||
if a.has_hot_water_cylinder is not True:
|
||||
return None
|
||||
if (
|
||||
a.cylinder_insulation_type in (1, 2)
|
||||
and a.cylinder_insulation_thickness_mm is not None
|
||||
):
|
||||
return None
|
||||
gap = (
|
||||
f", effective {a.effective_sap:.0f} vs lodged {a.lodged_sap:.0f} "
|
||||
f"(Δ{a.effective_sap - a.lodged_sap:+.0f})"
|
||||
if a.effective_sap is not None and a.lodged_sap is not None
|
||||
else ""
|
||||
)
|
||||
return (
|
||||
f"cylinder lodged with insulation_type={a.cylinder_insulation_type} "
|
||||
f"thickness={a.cylinder_insulation_thickness_mm} — storage loss dropped "
|
||||
f"to zero (combi default), baseline over-rates{gap}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────── runner ───────────────────────
|
||||
|
||||
_QUERY = text(
|
||||
|
|
@ -351,12 +425,24 @@ _QUERY = text(
|
|||
pbp.lodged_sap_score, pbp.lodged_epc_band,
|
||||
pbp.effective_sap_score, pbp.effective_epc_band, pbp.rebaseline_reason,
|
||||
pl.post_sap_points, pl.post_epc_rating, pl.cost_of_works,
|
||||
pl.energy_bill_savings, pl.energy_consumption_savings
|
||||
pl.energy_bill_savings, pl.energy_consumption_savings,
|
||||
ep.has_hot_water_cylinder, ep.cylinder_insulation_type,
|
||||
ep.cylinder_insulation_thickness_mm
|
||||
FROM property p
|
||||
LEFT JOIN property_baseline_performance pbp ON pbp.property_id = p.id
|
||||
LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = TRUE
|
||||
AND (:scenario_id IS NULL OR pl.scenario_id = :scenario_id)
|
||||
LEFT JOIN scenario s ON s.id = pl.scenario_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT e.has_hot_water_cylinder,
|
||||
e.heating_cylinder_insulation_type AS cylinder_insulation_type,
|
||||
e.heating_cylinder_insulation_thickness_mm
|
||||
AS cylinder_insulation_thickness_mm
|
||||
FROM epc_property e
|
||||
WHERE e.property_id = p.id
|
||||
ORDER BY e.id DESC
|
||||
LIMIT 1
|
||||
) ep ON TRUE
|
||||
WHERE (:portfolio_id IS NULL OR p.portfolio_id = :portfolio_id)
|
||||
AND (:property_id IS NULL OR p.id = :property_id)
|
||||
ORDER BY p.id
|
||||
|
|
@ -479,6 +565,13 @@ def _load(
|
|||
solar_sap_points=solar_sap,
|
||||
solar_bill_savings=solar_bill,
|
||||
n_measures=n_measures,
|
||||
has_hot_water_cylinder=m["has_hot_water_cylinder"],
|
||||
cylinder_insulation_type=_int_or_none(
|
||||
m["cylinder_insulation_type"]
|
||||
),
|
||||
cylinder_insulation_thickness_mm=_float_or_none(
|
||||
m["cylinder_insulation_thickness_mm"]
|
||||
),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -6365,6 +6365,237 @@ def test_loose_jacket_cylinder_computes_storage_loss_via_table2_loose_jacket_bra
|
|||
assert got_jan_kwh > 36.9530 # loose jacket loses more than factory
|
||||
|
||||
|
||||
def test_uninsulated_cylinder_computes_storage_loss_via_table2_jacket_t0() -> None:
|
||||
"""SAP 10.2 Table 2 (PDF p.158): an UNINSULATED cylinder takes the
|
||||
loose-jacket loss factor at t = 0 — L = 0.005 + 1.76 / 12.8 = 0.1425
|
||||
kWh/L/day, the worst storage loss SAP knows. The EPB API lodges
|
||||
cylinder_insulation_type=0 = no insulation (1 = factory, 2 = loose
|
||||
jacket) with no thickness field. Before this fix
|
||||
`_cylinder_storage_loss_override` returned None for type 0, so the
|
||||
worksheet kept the zero-storage-loss combi default and the dwelling
|
||||
OVER-RATED — the opposite of what "no insulation" means. Portfolio
|
||||
814's audit (2026-07-03) found all 26 such properties over-rated
|
||||
+1..+18 SAP vs lodged (avg +12.7 for type 0); same-lodged WD5 0DP
|
||||
neighbours (pids 742215/742216, both lodged 54, identical building
|
||||
parts) split effective 50 vs 69 because one lodged a 12 mm jacket and
|
||||
the other lodged no insulation.
|
||||
"""
|
||||
# Arrange — identical to the loose-jacket storage-loss test but
|
||||
# cylinder_insulation_type=0 (none) and no lodged thickness.
|
||||
from domain.sap10_calculator.worksheet.water_heating import (
|
||||
cylinder_storage_loss_factor_table_2,
|
||||
cylinder_temperature_factor_table_2b,
|
||||
cylinder_volume_factor_table_2a,
|
||||
)
|
||||
|
||||
hp_main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=29,
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2206,
|
||||
main_heating_category=4,
|
||||
sap_main_heating_code=None,
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=4,
|
||||
country_code="ENG",
|
||||
has_hot_water_cylinder=True,
|
||||
sap_building_parts=[make_building_part()],
|
||||
sap_heating=make_sap_heating(
|
||||
main_heating_details=[hp_main],
|
||||
water_heating_code=901,
|
||||
cylinder_size=3, # Medium → 160 L
|
||||
cylinder_insulation_type=0, # no insulation
|
||||
cylinder_insulation_thickness_mm=None,
|
||||
cylinder_thermostat="Y",
|
||||
),
|
||||
)
|
||||
# Expected (56)m Jan from the Table 2 loose-jacket branch at t=0 —
|
||||
# the SAP uninsulated-cylinder factor (same V / VF / TF as the
|
||||
# loose-jacket test; only the thickness differs).
|
||||
loss_factor = cylinder_storage_loss_factor_table_2(
|
||||
insulation_type="loose_jacket", thickness_mm=0.0
|
||||
)
|
||||
vol_factor = cylinder_volume_factor_table_2a(160.0)
|
||||
temp_factor = cylinder_temperature_factor_table_2b(
|
||||
has_cylinder_thermostat=True, separately_timed_dhw=True
|
||||
)
|
||||
expected_jan_kwh = 160.0 * loss_factor * vol_factor * temp_factor * 31
|
||||
|
||||
# Act
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=1.7,
|
||||
is_instantaneous=False,
|
||||
primary_age="D",
|
||||
pcdb_record=None,
|
||||
)
|
||||
|
||||
# Assert — non-None (was the zero-loss combi default) and equal to
|
||||
# the loose-jacket branch at t=0; an uninsulated cylinder must lose
|
||||
# MORE than the 50 mm loose-jacket case, never less.
|
||||
assert wh_result is not None
|
||||
got_jan_kwh = wh_result.solar_storage_monthly_kwh[0]
|
||||
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4
|
||||
jacket_50mm_jan_kwh = (
|
||||
160.0
|
||||
* cylinder_storage_loss_factor_table_2(
|
||||
insulation_type="loose_jacket", thickness_mm=50.0
|
||||
)
|
||||
* vol_factor
|
||||
* temp_factor
|
||||
* 31
|
||||
)
|
||||
assert got_jan_kwh > jacket_50mm_jan_kwh
|
||||
|
||||
|
||||
def test_elmhurst_no_insulation_cylinder_thickness_zero_takes_uninsulated_loss() -> None:
|
||||
"""The Elmhurst site-notes mapper lodges a "No Insulation" cylinder as
|
||||
`cylinder_insulation_type=None` + `cylinder_insulation_thickness_mm=0`
|
||||
— per its own comment "the lodged §15.1 Insulation Thickness (0 mm)
|
||||
carries the storage-loss signal the cascade's SAP 10.2 Table 2
|
||||
dispatch needs" (`_ELMHURST_CYLINDER_NO_INSULATION_LABELS`,
|
||||
datatypes/epc/domain/mapper.py). The cascade must honour that signal:
|
||||
a lodged 0 mm with no insulation type is the uninsulated cylinder —
|
||||
Table 2 loose-jacket branch at t = 0 — not the zero-loss combi
|
||||
default it previously fell to (the type-None guard returned None
|
||||
before the thickness was ever read).
|
||||
"""
|
||||
# Arrange — same dwelling as the type-0 test; only the lodgement
|
||||
# shape differs (type None + explicit 0 mm, the Elmhurst path).
|
||||
from domain.sap10_calculator.worksheet.water_heating import (
|
||||
cylinder_storage_loss_factor_table_2,
|
||||
cylinder_temperature_factor_table_2b,
|
||||
cylinder_volume_factor_table_2a,
|
||||
)
|
||||
|
||||
hp_main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=29,
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2206,
|
||||
main_heating_category=4,
|
||||
sap_main_heating_code=None,
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=4,
|
||||
country_code="ENG",
|
||||
has_hot_water_cylinder=True,
|
||||
sap_building_parts=[make_building_part()],
|
||||
sap_heating=make_sap_heating(
|
||||
main_heating_details=[hp_main],
|
||||
water_heating_code=901,
|
||||
cylinder_size=3, # Medium → 160 L
|
||||
cylinder_insulation_type=None, # Elmhurst "No Insulation"
|
||||
cylinder_insulation_thickness_mm=0,
|
||||
cylinder_thermostat="Y",
|
||||
),
|
||||
)
|
||||
expected_jan_kwh = (
|
||||
160.0
|
||||
* cylinder_storage_loss_factor_table_2(
|
||||
insulation_type="loose_jacket", thickness_mm=0.0
|
||||
)
|
||||
* cylinder_volume_factor_table_2a(160.0)
|
||||
* cylinder_temperature_factor_table_2b(
|
||||
has_cylinder_thermostat=True, separately_timed_dhw=True
|
||||
)
|
||||
* 31
|
||||
)
|
||||
|
||||
# Act
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=1.7,
|
||||
is_instantaneous=False,
|
||||
primary_age="D",
|
||||
pcdb_record=None,
|
||||
)
|
||||
|
||||
# Assert — identical series to the gov-API type-0 lodgement: the two
|
||||
# front-ends describe the same uninsulated cylinder.
|
||||
assert wh_result is not None
|
||||
got_jan_kwh = wh_result.solar_storage_monthly_kwh[0]
|
||||
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4
|
||||
|
||||
|
||||
def test_cylinder_with_unknown_insulation_defaults_per_table_29_age_band() -> None:
|
||||
"""RdSAP 10 Table 29 (PDF p.56) "Hot water cylinder insulation if not
|
||||
accessible": when a cylinder is lodged but its insulation is unknown
|
||||
(type absent AND no thickness — ADR-0028 records ~308/1000 17.0 certs
|
||||
omitting `cylinder_insulation_type`), the age-band default applies:
|
||||
bands A-F → 12 mm loose jacket, G/H → 25 mm factory, I-M → 38 mm
|
||||
factory. This is the same Table 29 row the §10.7 no-water-heating
|
||||
default already uses. Previously the cascade returned None (zero
|
||||
storage loss), silently treating an unknown cylinder as a combi.
|
||||
Distinct from the uninsulated case: a lodged 0 mm means NO insulation
|
||||
(t = 0), an absent thickness means NOT ACCESSIBLE (Table 29).
|
||||
"""
|
||||
# Arrange — same dwelling, insulation fields both absent; the
|
||||
# fixture's building part lodges construction_age_band "B" → the
|
||||
# Table 29 A-F row (12 mm loose jacket).
|
||||
from domain.sap10_calculator.worksheet.water_heating import (
|
||||
cylinder_storage_loss_factor_table_2,
|
||||
cylinder_temperature_factor_table_2b,
|
||||
cylinder_volume_factor_table_2a,
|
||||
)
|
||||
|
||||
hp_main = MainHeatingDetail(
|
||||
has_fghrs=False,
|
||||
main_fuel_type=29,
|
||||
heat_emitter_type=1,
|
||||
emitter_temperature=1,
|
||||
main_heating_control=2206,
|
||||
main_heating_category=4,
|
||||
sap_main_heating_code=None,
|
||||
)
|
||||
epc = make_minimal_sap10_epc(
|
||||
total_floor_area_m2=_TYPICAL_TFA_M2,
|
||||
habitable_rooms_count=4,
|
||||
country_code="ENG",
|
||||
has_hot_water_cylinder=True,
|
||||
sap_building_parts=[make_building_part()], # age band B
|
||||
sap_heating=make_sap_heating(
|
||||
main_heating_details=[hp_main],
|
||||
water_heating_code=901,
|
||||
cylinder_size=3, # Medium → 160 L
|
||||
cylinder_insulation_type=None, # not lodged
|
||||
cylinder_insulation_thickness_mm=None, # not lodged
|
||||
cylinder_thermostat="Y",
|
||||
),
|
||||
)
|
||||
expected_jan_kwh = (
|
||||
160.0
|
||||
* cylinder_storage_loss_factor_table_2(
|
||||
insulation_type="loose_jacket", thickness_mm=12.0
|
||||
)
|
||||
* cylinder_volume_factor_table_2a(160.0)
|
||||
* cylinder_temperature_factor_table_2b(
|
||||
has_cylinder_thermostat=True, separately_timed_dhw=True
|
||||
)
|
||||
* 31
|
||||
)
|
||||
|
||||
# Act
|
||||
wh_result, _ = _water_heating_worksheet_and_gains(
|
||||
epc=epc,
|
||||
water_efficiency_pct=1.7,
|
||||
is_instantaneous=False,
|
||||
primary_age="D",
|
||||
pcdb_record=None,
|
||||
)
|
||||
|
||||
# Assert — the Table 29 band-B default (12 mm loose jacket), not the
|
||||
# zero-loss combi default and not the harsher uninsulated t=0.
|
||||
assert wh_result is not None
|
||||
got_jan_kwh = wh_result.solar_storage_monthly_kwh[0]
|
||||
assert abs(got_jan_kwh - expected_jan_kwh) < 1e-4
|
||||
|
||||
|
||||
def test_no_water_heating_default_age_a_to_f_uses_12mm_loose_jacket_per_table_29() -> None:
|
||||
"""RdSAP 10 §10.7 + Table 29 (PDF p.55-56): when no water heating
|
||||
system is lodged, the default cylinder takes the age-band insulation,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ def _make_audit(
|
|||
post_band: Optional[str] = None,
|
||||
post_sap: Optional[float] = None,
|
||||
cost_of_works: Optional[float] = None,
|
||||
has_hot_water_cylinder: Optional[bool] = None,
|
||||
cylinder_insulation_type: Optional[int] = None,
|
||||
cylinder_insulation_thickness_mm: Optional[float] = None,
|
||||
) -> PropertyAudit:
|
||||
return PropertyAudit(
|
||||
property_id=1,
|
||||
|
|
@ -41,6 +44,9 @@ def _make_audit(
|
|||
solar_sap_points=None,
|
||||
solar_bill_savings=None,
|
||||
n_measures=0,
|
||||
has_hot_water_cylinder=has_hot_water_cylinder,
|
||||
cylinder_insulation_type=cylinder_insulation_type,
|
||||
cylinder_insulation_thickness_mm=cylinder_insulation_thickness_mm,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue