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/ashp-size-to-dwelling-heat-loss
This commit is contained in:
commit
ac2b600bbd
10 changed files with 712 additions and 13 deletions
|
|
@ -103,6 +103,10 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits
|
|||
|
||||
**Landlord-Description Classification**:
|
||||
Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041).
|
||||
|
||||
**Heating Companion Set**:
|
||||
The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise.
|
||||
_Avoid_: coherent companions (informal), dragged fields, heating defaults
|
||||
_Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output
|
||||
|
||||
**Lodgement Sentinels (ND / NI / AB)**:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class HubspotDealData(SQLModel, table=True):
|
|||
landlord_property_id: Optional[str] = Field(default=None)
|
||||
uprn: Optional[str] = Field(default=None)
|
||||
outcome: Optional[str] = Field(default=None)
|
||||
number_of_attempts: Optional[str] = Field(default=None)
|
||||
outcome_notes: Optional[str] = Field(default=None)
|
||||
booking_status: Optional[str] = Field(default=None)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
# A system-replacing heating override forces category and control for coherence, and defers fuel and meter where multiple values are valid
|
||||
|
||||
## Status
|
||||
|
||||
accepted
|
||||
|
||||
## Context
|
||||
|
||||
A **Landlord Override** naming a main heating system resolves to a SAP Table 4a
|
||||
code, and `main_heating_system_overlay` synthesises the **coherent companions**
|
||||
that code implies (ADR-0035): heating category, charge control, natural fuel,
|
||||
meter. The overlay is applied over the lodged cert by last-wins composition, so a
|
||||
companion the overlay leaves **unset (`None`)** is *inherited from the replaced
|
||||
system* — not cleared.
|
||||
|
||||
That inheritance is a silent mis-rating when the archetype genuinely determines
|
||||
the companion. A portfolio-796 audit found the case end-to-end on **86+
|
||||
properties**: a lodged **slimline storage heater** (SAP 402, category 7, control
|
||||
2401, off-peak) overridden to **"Electric room heaters"** (SAP 691). The overlay
|
||||
stamped code 691, fuel, and a Dual meter — but `_category_for(691)` and
|
||||
`_control_for(691)` both returned `None`, because their room-heater branch keyed
|
||||
on `_CATEGORY_10_ROOM_HEATER_CODES`, a set that held only the *fuel-burning*
|
||||
(gas/oil/solid) room heaters and **excluded electric 691–701** despite electric
|
||||
room heaters also being Table 4a **Category 10**.
|
||||
|
||||
Two independent mis-ratings followed from the surviving storage companions:
|
||||
|
||||
1. **Category 7 survived.** The SAP 10.2 Table 12a resolver keys on category
|
||||
first, read `OTHER_STORAGE_HEATERS`, and — with the dwelling on the §12 Rule 3
|
||||
10-hour tariff — hit `(OTHER_STORAGE_HEATERS, TEN_HOUR)`, a combination the
|
||||
Grid-1 table does not carry, so it fell to the all-low-rate fallback. The
|
||||
peaky room heaters were billed **100% at the night rate** — the precise
|
||||
"all-low over-credit" the overlay's own Dual-meter comment claims to avoid.
|
||||
Effect: baseline SAP over-rated **64.1 vs the spec-correct 56.4**; the HHRSH
|
||||
upgrade measure showed a **negative** bill saving (−£13/yr) because the
|
||||
baseline it was compared against was itself under-billed.
|
||||
2. **Control 2401 survived.** `main_heating_control` fed Table 4e as the storage
|
||||
**manual charge control → +0.7 °C** mean-internal-temperature adjustment — the
|
||||
largest in the table — riding on a direct-acting room heater, which should
|
||||
carry the Group 6 room-heater control **2601 → +0.3 °C**.
|
||||
|
||||
The companions are **not uniform** in how strongly the archetype determines
|
||||
them, which is the crux of the decision:
|
||||
|
||||
- **Category is deterministic** — SAP Table 4a assigns exactly one category per
|
||||
code (gas→2, heat pump→4, network→6, storage→7, **electric underfloor→8**,
|
||||
warm-air→9, **room heater→10**). A `None` category is never valid.
|
||||
- **Control is an assumption but always archetype-supplied** — the Table 4e
|
||||
*Group* is fixed by category; the specific conservative default within the
|
||||
group is a modelling choice (storage→manual 2401, HHRSH→2404 per
|
||||
[ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md),
|
||||
room heater→2601, gas boiler→full controls). It is never "inherit the replaced
|
||||
system".
|
||||
- **Fuel is sometimes genuinely ambiguous** — electric/gas/storage/heat-pump
|
||||
archetypes pin their fuel, but **community heating** could be gas CHP, biomass,
|
||||
or waste heat, so its fuel legitimately **defers** to a `main_fuel` override,
|
||||
else the lodged value (ADR-0041 last-wins).
|
||||
- **Meter is physics-locked for some systems, flexible for others** — storage /
|
||||
CPSU / HHRSH **require** Dual (they charge overnight); non-electric systems are
|
||||
stamped Single to stop an Economy-7 split bleeding onto a gas boiler
|
||||
([ADR-0035](0035-coherent-heating-system-synthesis.md)); screed underfloor
|
||||
defers to the cert ([ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)).
|
||||
|
||||
## Decision
|
||||
|
||||
A system-replacing heating override must emit a **complete, coherent companion
|
||||
set**. Companions split by whether the archetype *determines* the value:
|
||||
|
||||
1. **Category and control are archetype-forced** — always written, never
|
||||
inherited, because a wrong value mis-scores or mis-bills the cert. Electric
|
||||
room heaters (Table 4a 691–701) join the Category-10 room-heater branch of
|
||||
both `_category_for` (→ category **10**) and `_control_for` (→ control
|
||||
**2601**, the Group 6 conservative no-thermostat default, matching the
|
||||
gas/oil/solid siblings). Their fuel (electricity 29) and Dual meter were
|
||||
already correct and are unchanged.
|
||||
|
||||
2. **Fuel and meter defer where multiple values are coherent** — this is *not*
|
||||
a gap. Fuel defers only for genuinely ambiguous carriers (community heating);
|
||||
meter defers only for meter-flexible systems (room heaters run coherently on
|
||||
either meter; screed underfloor per ADR-0046). Meter-locked systems still
|
||||
force (storage/CPSU/HHRSH → Dual; non-electric → Single).
|
||||
|
||||
3. **An incomplete companion set is logged, not raised (for now).** Where the
|
||||
overlay cannot yet supply a confident category or control — heat pumps
|
||||
(Group 2), community (Group 3), electric underfloor (Group 7), electric
|
||||
boiler / CPSU (Group 1) — `main_heating_overlay_for` **logs an error** naming
|
||||
the archetype and code and continues, matching the `flag_fuel_mismatch`
|
||||
log-not-raise convention. Log-and-continue keeps a mid-portfolio run alive
|
||||
and surfaces the gap (CloudWatch) for review, rather than turning a latent
|
||||
inheritance bug into an outage. The intended end-state is to fill each
|
||||
archetype's category/control with spec sight and then **flip the log to a
|
||||
raise**, so the coherence invariant is enforced.
|
||||
|
||||
Category expansion for the still-`None` archetypes (electric boiler/CPSU→2,
|
||||
underfloor→8) is deterministic from Table 4a but **deferred** to its own change:
|
||||
it flows into the category-keyed Table 12a resolver and needs a corpus check, so
|
||||
it does not ride on the room-heater fix.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The 86+ room-heater dwellings score against a correctly-billed baseline: the
|
||||
space-heating high-rate fraction is the spec **0.50** (10-hour direct-acting),
|
||||
not 0.0; baseline SAP corrects **64.1 → 56.4**; the HHRSH measure's bill saving
|
||||
flips **−£13/yr → +£235/yr** (property 710788, uprn 22003310, scenario 1268).
|
||||
- The `refetch_solar`-corrected portfolio's `negative-bill-savings` audit group
|
||||
loses its ~86-property HHRSH-on-room-heater cluster after re-model.
|
||||
- The overlay now emits a visible error for any archetype it cannot fully
|
||||
companion — a standing signal (wire a CloudWatch metric filter on the log
|
||||
string) for which control default to fill next.
|
||||
- Extends [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag
|
||||
from the code) and [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md)
|
||||
(complete taxonomy); sits beside
|
||||
[ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md)
|
||||
(a control named by the archetype) and
|
||||
[ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)
|
||||
(meter deferred where the tariff is ambiguous).
|
||||
|
||||
### Alternatives rejected
|
||||
|
||||
- **Make every companion total (force fuel and meter too).** Rejected: it breaks
|
||||
the deliberate deferrals — community-heating fuel (ambiguous carrier) and the
|
||||
ADR-0046 screed-underfloor meter — whose `None` is a load-bearing "defer to the
|
||||
cert/override" signal, not a gap.
|
||||
- **Fix the Table 12a resolver to prefer the SAP code over a stale category.**
|
||||
A sound defence-in-depth follow-up, but it treats the symptom; the overlay
|
||||
emitting an incoherent cert is the root cause, and category/control coherence
|
||||
must hold for every consumer (predicted EPCs, raw lodged certs), not only the
|
||||
billing resolver.
|
||||
- **Raise on an incomplete companion set now.** Rejected for this change: heat
|
||||
pump / community / underfloor overrides model today (by luck of inheritance),
|
||||
so an immediate raise is a blast-radius change mid-campaign. Log now, raise once
|
||||
the common archetypes are filled.
|
||||
|
|
@ -28,6 +28,7 @@ are left UNKNOWN until modelled. Unresolvable values produce no overlay.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from domain.modelling.simulation import EpcSimulation, HeatingOverlay
|
||||
|
|
@ -35,6 +36,8 @@ from domain.sap10_calculator.tables.table_12a import (
|
|||
OFF_PEAK_IMPLYING_HEATING_CODES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at
|
||||
# the low rate and cannot run economically on a single-rate meter; "Dual" lets
|
||||
# the §12 dispatch resolve the specific tariff (storage 7-hour, CPSU 10-hour).
|
||||
|
|
@ -266,7 +269,7 @@ def _control_for(code: int) -> Optional[int]:
|
|||
return _MANUAL_CHARGE_CONTROL
|
||||
if code in _GAS_BOILER_CODES:
|
||||
return _FULL_BOILER_CONTROL
|
||||
if code in _CATEGORY_10_ROOM_HEATER_CODES:
|
||||
if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES:
|
||||
return _ROOM_HEATER_CONTROL
|
||||
return None
|
||||
|
||||
|
|
@ -280,7 +283,7 @@ def _category_for(code: int) -> Optional[int]:
|
|||
category 7); without it a storage override left a stale category behind,
|
||||
which the SAP 10.2 Table 12a resolver (keyed on category) could misread as a
|
||||
non-storage system and price the off-peak storage heating at the peak rate."""
|
||||
if code in _CATEGORY_10_ROOM_HEATER_CODES:
|
||||
if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES:
|
||||
return _ROOM_HEATER_CATEGORY
|
||||
if code in _STORAGE_HEATER_CODES:
|
||||
return _STORAGE_HEATER_CATEGORY
|
||||
|
|
@ -351,13 +354,32 @@ def main_heating_overlay_for(
|
|||
return None
|
||||
if code in _GAS_BOILER_CODES:
|
||||
return EpcSimulation(heating=_gas_boiler_overlay(code))
|
||||
category = _category_for(code)
|
||||
control = _control_for(code)
|
||||
if category is None or control is None:
|
||||
# A system-replacing override should stamp a complete, coherent companion
|
||||
# set; an unmapped category or control leaves the field unset, so the
|
||||
# effective cert inherits the REPLACED system's value (a silent mis-rating
|
||||
# — e.g. a room heater keeping a storage heater's category/control). We do
|
||||
# not yet have confident defaults for every archetype, so log-and-continue
|
||||
# (matching `flag_fuel_mismatch`) to make the gap visible for review
|
||||
# rather than raise mid-run.
|
||||
logger.error(
|
||||
"Landlord main_heating_system %r (SAP code %d) overlaid with an "
|
||||
"incomplete companion set (category=%s, control=%s); the effective "
|
||||
"cert may inherit the replaced system's value — known gap, review",
|
||||
main_heating_value,
|
||||
code,
|
||||
category,
|
||||
control,
|
||||
)
|
||||
return EpcSimulation(
|
||||
heating=HeatingOverlay(
|
||||
sap_main_heating_code=code,
|
||||
main_heating_category=_category_for(code),
|
||||
main_heating_category=category,
|
||||
main_fuel_type=_natural_fuel_for(code),
|
||||
meter_type=_meter_for(code),
|
||||
main_heating_control=_control_for(code),
|
||||
main_heating_control=control,
|
||||
# A landlord override describes the existing dwelling, so its assumed
|
||||
# off-peak meter must not downgrade a more-off-peak cert meter
|
||||
# (e.g. a 24-hour all-low tariff). Measures, which re-meter for real,
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ class HubspotClient:
|
|||
"dealstage",
|
||||
"pipeline",
|
||||
"outcome",
|
||||
"number_of_attempts",
|
||||
"outcome_notes",
|
||||
"booking_status",
|
||||
"project_code",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ class HubspotDataToDb:
|
|||
),
|
||||
"uprn": listing.get("national_uprn", None) if listing else None,
|
||||
"outcome": deal_data.get("outcome"),
|
||||
"number_of_attempts": deal_data.get("number_of_attempts"),
|
||||
"outcome_notes": deal_data.get("outcome_notes"),
|
||||
"booking_status": deal_data.get("booking_status"),
|
||||
"project_code": deal_data.get("project_code"),
|
||||
|
|
@ -322,6 +323,7 @@ class HubspotDataToDb:
|
|||
),
|
||||
uprn=listing.get("national_uprn") if listing else None,
|
||||
outcome=deal_data.get("outcome"),
|
||||
number_of_attempts=deal_data.get("number_of_attempts"),
|
||||
outcome_notes=deal_data.get("outcome_notes"),
|
||||
booking_status=deal_data.get("booking_status"),
|
||||
project_code=deal_data.get("project_code"),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,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
|
||||
from etl.hubspot.utils import parse_hs_bool, parse_hs_date, parse_hs_int
|
||||
|
||||
|
||||
class HubspotDealDiffer:
|
||||
|
|
@ -13,6 +13,14 @@ class HubspotDealDiffer:
|
|||
]
|
||||
RETROFIT_DESIGN_COMPLETE = "uploaded"
|
||||
LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"]
|
||||
ABANDONMENT_ATTEMPTS_THRESHOLD = 3
|
||||
NEGATIVE_OUTCOMES: List[str] = [
|
||||
"no answer",
|
||||
"cancelled",
|
||||
"no show",
|
||||
"tenant refusal",
|
||||
"not viable",
|
||||
]
|
||||
ABRI_CONDITION_ASSOCIATED_PROJECT_ID = "123456"
|
||||
ABRI_CONDITION_PROJECT_CODE = "Abri Condition"
|
||||
|
||||
|
|
@ -52,6 +60,7 @@ class HubspotDealDiffer:
|
|||
# --- Field mappings ---
|
||||
FIELD_MAP = {
|
||||
"outcome": "outcome",
|
||||
"number_of_attempts": "number_of_attempts",
|
||||
"dealstage": "dealstage",
|
||||
"dealname": "dealname",
|
||||
"project_code": "project_code",
|
||||
|
|
@ -206,6 +215,59 @@ class HubspotDealDiffer:
|
|||
|
||||
return old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time")
|
||||
|
||||
@staticmethod
|
||||
def check_for_abri_tenant_data_fetch(
|
||||
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.expected_commencement_date is not None:
|
||||
return False
|
||||
|
||||
return parse_hs_date(new_deal.get("expected_commencement_date")) is not None
|
||||
|
||||
@staticmethod
|
||||
def check_for_abri_deal_abandonment(
|
||||
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 HubspotDealDiffer._is_abandoned(
|
||||
old_deal.number_of_attempts, old_deal.outcome
|
||||
):
|
||||
return False
|
||||
|
||||
return HubspotDealDiffer._is_abandoned(
|
||||
new_deal.get("number_of_attempts"), new_deal.get("outcome")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def check_for_magicplan_trigger(
|
||||
new_deal: Dict[str, str], old_deal: HubspotDealData
|
||||
) -> bool:
|
||||
new_outcome = (new_deal.get("outcome") or "").lower()
|
||||
old_outcome = (old_deal.outcome or "").lower()
|
||||
return new_outcome == "surveyed" and old_outcome != "surveyed"
|
||||
|
||||
@staticmethod
|
||||
def _is_abandoned(
|
||||
number_of_attempts: Optional[str], outcome: Optional[str]
|
||||
) -> bool:
|
||||
attempts: Optional[int] = parse_hs_int(number_of_attempts)
|
||||
if (
|
||||
attempts is None
|
||||
or attempts < HubspotDealDiffer.ABANDONMENT_ATTEMPTS_THRESHOLD
|
||||
):
|
||||
return False
|
||||
|
||||
return (outcome or "").lower() in HubspotDealDiffer.NEGATIVE_OUTCOMES
|
||||
|
||||
@staticmethod
|
||||
def _is_abri_condition_deal(
|
||||
new_deal: Dict[str, str], new_project: Optional[ProjectData]
|
||||
|
|
@ -219,14 +281,6 @@ class HubspotDealDiffer:
|
|||
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
|
||||
) -> bool:
|
||||
new_outcome = (new_deal.get("outcome") or "").lower()
|
||||
old_outcome = (old_deal.outcome or "").lower()
|
||||
return new_outcome == "surveyed" and old_outcome != "surveyed"
|
||||
|
||||
@staticmethod
|
||||
def _has_valid_pashub_link(new_pashub_link: str) -> bool:
|
||||
return bool(new_pashub_link)
|
||||
|
|
|
|||
|
|
@ -687,6 +687,390 @@ def test_abri_survey_amendment__date_first_set_on_abri_deal__returns_false() ->
|
|||
assert result is False
|
||||
|
||||
|
||||
# ====================================
|
||||
# ABRI TENANT DATA FETCH TRIGGER TESTS
|
||||
# ====================================
|
||||
|
||||
|
||||
def test_abri_tenant_data_fetch__commencement_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",
|
||||
expected_commencement_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
expected_commencement_date="2026-08-01",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_tenant_data_fetch(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_tenant_data_fetch__non_abri_deal__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
expected_commencement_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
expected_commencement_date="2026-08-01",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_tenant_data_fetch(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_tenant_data_fetch__commencement_date_already_set__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
expected_commencement_date=datetime(2026, 7, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
expected_commencement_date="2026-08-01",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_tenant_data_fetch(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_overrides",
|
||||
[
|
||||
{},
|
||||
{"expected_commencement_date": ""},
|
||||
{"expected_commencement_date": "not-a-date"},
|
||||
],
|
||||
)
|
||||
def test_abri_tenant_data_fetch__no_parseable_new_commencement_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",
|
||||
expected_commencement_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
**new_overrides,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_tenant_data_fetch(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_abri_tenant_data_fetch__associated_abri_project_id__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
expected_commencement_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
expected_commencement_date="2026-08-01",
|
||||
)
|
||||
new_project = ProjectData(
|
||||
project_id=HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID,
|
||||
name="Abri Condition",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_tenant_data_fetch(
|
||||
new_deal=new_deal,
|
||||
new_project=new_project,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_tenant_data_fetch__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",
|
||||
expected_commencement_date=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
expected_commencement_date="2026-08-01",
|
||||
)
|
||||
new_project = ProjectData(project_id="999999", name="Southern Retrofit")
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_tenant_data_fetch(
|
||||
new_deal=new_deal,
|
||||
new_project=new_project,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
# ====================================
|
||||
# ABRI DEAL ABANDONMENT TRIGGER TESTS
|
||||
# ====================================
|
||||
|
||||
|
||||
def test_abri_deal_abandonment__third_attempt_with_negative_outcome__returns_true() -> (
|
||||
None
|
||||
):
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
outcome=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
number_of_attempts="3",
|
||||
outcome="No Answer",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_deal_abandonment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_deal_abandonment__non_abri_deal__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
outcome=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Southern Retrofit",
|
||||
number_of_attempts="3",
|
||||
outcome="No Answer",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_deal_abandonment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_overrides",
|
||||
[
|
||||
{"number_of_attempts": "2"},
|
||||
{"number_of_attempts": ""},
|
||||
{"number_of_attempts": "not-a-number"},
|
||||
{},
|
||||
],
|
||||
)
|
||||
def test_abri_deal_abandonment__fewer_than_three_parseable_attempts__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",
|
||||
outcome=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
outcome="No Answer",
|
||||
**new_overrides,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_deal_abandonment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"new_overrides",
|
||||
[
|
||||
{"outcome": "Surveyed"},
|
||||
{"outcome": ""},
|
||||
{},
|
||||
],
|
||||
)
|
||||
def test_abri_deal_abandonment__outcome_not_negative__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",
|
||||
outcome=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
number_of_attempts="3",
|
||||
**new_overrides,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_deal_abandonment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome",
|
||||
[
|
||||
"No Answer",
|
||||
"Cancelled",
|
||||
"No Show",
|
||||
"Tenant Refusal",
|
||||
"Not Viable",
|
||||
"NO ANSWER",
|
||||
],
|
||||
)
|
||||
def test_abri_deal_abandonment__each_negative_outcome__returns_true(
|
||||
outcome: str,
|
||||
) -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
outcome=None,
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
number_of_attempts="3",
|
||||
outcome=outcome,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_deal_abandonment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_abri_deal_abandonment__deal_already_abandoned__returns_false() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
project_code="Abri Condition",
|
||||
number_of_attempts="3",
|
||||
outcome="No Answer",
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
project_code="Abri Condition",
|
||||
number_of_attempts="4",
|
||||
outcome="No Answer",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_abri_deal_abandonment(
|
||||
new_deal=new_deal,
|
||||
new_project=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
# =======================
|
||||
# DB UPDATE TRIGGER TESTS
|
||||
# =======================
|
||||
|
|
@ -744,6 +1128,32 @@ def test_db_update_trigger__dealname_changed__returns_true() -> None:
|
|||
assert result is True
|
||||
|
||||
|
||||
def test_db_update_trigger__number_of_attempts_changed__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
# Arrange
|
||||
old_deal = make_old_deal(
|
||||
id=deal_id,
|
||||
number_of_attempts="2",
|
||||
)
|
||||
new_deal = make_new_deal(
|
||||
deal_id,
|
||||
hs_object_id="1",
|
||||
number_of_attempts="3",
|
||||
)
|
||||
|
||||
# Act
|
||||
result = HubspotDealDiffer.check_for_db_update_trigger(
|
||||
new_deal=new_deal,
|
||||
new_company=None,
|
||||
new_listing=None,
|
||||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_db_update_trigger__company_changed__returns_true() -> None:
|
||||
deal_id = uuid.uuid4()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ def parse_hs_date(value: Optional[str]) -> Optional[datetime]:
|
|||
return None
|
||||
|
||||
|
||||
def parse_hs_int(value: Optional[str]) -> Optional[int]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_hs_bool(value: Optional[str]) -> Optional[bool]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ calculator reads from the primary system; the overlay is whole-dwelling.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType
|
||||
|
|
@ -58,6 +60,68 @@ def test_electric_room_heaters_assume_a_dual_economy7_meter() -> None:
|
|||
assert simulation.heating.meter_type == "Dual"
|
||||
|
||||
|
||||
def test_electric_room_heaters_overlay_the_room_heater_category() -> None:
|
||||
# A landlord "Electric room heaters" override replaces the lodged system, so
|
||||
# the overlay must stamp the room-heater heating category (SAP Table 4a
|
||||
# Category 10). Leaving it unset lets a replaced storage heater's category 7
|
||||
# survive onto the effective cert, which the SAP 10.2 Table 12a resolver
|
||||
# (keyed on category) mis-reads as off-peak storage and bills the peaky room
|
||||
# heaters at the all-night low rate.
|
||||
# Act
|
||||
simulation = main_heating_overlay_for("Electric room heaters", 0)
|
||||
|
||||
# Assert — SAP Table 4a Category 10 (Room heaters).
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_category == 10
|
||||
|
||||
|
||||
def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None:
|
||||
# A system-replacing override must stamp its own control, not inherit the
|
||||
# replaced system's. Electric room heaters take the Table 4e Group 6
|
||||
# room-heater control 2601 (+0.3 C, the conservative no-thermostat default,
|
||||
# matching the gas/oil/solid room heaters). Leaving it unset lets a replaced
|
||||
# storage heater's manual charge control 2401 (+0.7 C) survive and over-warm
|
||||
# the modelled dwelling.
|
||||
# Act
|
||||
simulation = main_heating_overlay_for("Electric room heaters", 0)
|
||||
|
||||
# Assert — SAP Table 4e Group 6 room-heater control.
|
||||
assert simulation is not None
|
||||
assert simulation.heating is not None
|
||||
assert simulation.heating.main_heating_control == 2601
|
||||
|
||||
|
||||
def test_overlay_logs_when_it_cannot_complete_a_companion_set(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# An archetype the overlay cannot yet fully companion — here an air-source
|
||||
# heat pump, whose Table 4e Group 2 conservative control is not yet mapped —
|
||||
# is still overlaid (log-and-continue), but the incomplete companion set is
|
||||
# logged as an error so the known gap is visible (CloudWatch) rather than
|
||||
# silently inheriting the replaced system's stale value.
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR):
|
||||
main_heating_overlay_for("Air source heat pump", 0)
|
||||
|
||||
# Assert — the gap is reported and names the archetype.
|
||||
assert "Air source heat pump" in caplog.text
|
||||
|
||||
|
||||
def test_complete_room_heater_overlay_does_not_log_a_companion_gap(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Electric room heaters now resolve a full companion set (category 10 +
|
||||
# control 2601), so the overlay must NOT log an incomplete-companion gap —
|
||||
# guarding that the fix keeps the set complete.
|
||||
# Act
|
||||
with caplog.at_level(logging.ERROR):
|
||||
main_heating_overlay_for("Electric room heaters", 0)
|
||||
|
||||
# Assert
|
||||
assert "incomplete companion set" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("main_heating_value", "code"),
|
||||
[
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue