From 318a859e881168979d2039b50ff963dd0d584eb0 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 15:27:15 +0000 Subject: [PATCH 01/34] Audit check: lodged cylinder with unresolvable insulation drops storage loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portfolio 814 / scenario 1271 audit (2026-07-03): all 26 properties lodging has_hot_water_cylinder with insulation type 0 (none) or NULL over-rate their effective baseline by up to +18 SAP (avg +12.7) because _cylinder_storage_loss_override returns None for any insulation label it can't resolve and the worksheet keeps the zero-storage-loss combi default. Same-lodged neighbours split by 19 effective points (WD5 0DP pids 742215/742216, both lodged 54, effective 50 vs 69) and the inflated members distorted neighbour-divergence cohort medians (E17 6EB pid 742355 Δ+18). New HIGH check cylinder-storage-loss-dropped fires on the deterministic input pattern (cylinder present, insulation type ∉ {factory, loose jacket} or thickness missing) — 28/338 on the motivating portfolio (the 26 above plus 2 same-pattern rows with no lodged score). The calculator fix (uninsulated Table 2 loss, or RdSAP Table 29 age-band default) is separate follow-up work. Co-Authored-By: Claude Fable 5 --- scripts/audit/anomalies.py | 95 +++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/scripts/audit/anomalies.py b/scripts/audit/anomalies.py index 532108f7c..0ee0386d4 100644 --- a/scripts/audit/anomalies.py +++ b/scripts/audit/anomalies.py @@ -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 From bd3c45d81c770943e4f51f31869111b3a2ad6550 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 15:56:49 +0000 Subject: [PATCH 02/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20fire?= =?UTF-8?q?s=20when=20confirmed=5Fsurvey=5Fdate=20is=20first=20set=20on=20?= =?UTF-8?q?an=20Abri=20Condition=20deal=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 6 ++++ etl/hubspot/tests/test_hubspot_deal_differ.py | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 6d49cf590..ee189bae0 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -170,6 +170,12 @@ class HubspotDealDiffer: return False + @staticmethod + def check_for_abri_survey_creation( + new_deal: Dict[str, str], old_deal: HubspotDealData + ) -> bool: + raise NotImplementedError + @staticmethod def check_for_magicplan_trigger( new_deal: Dict[str, str], old_deal: HubspotDealData diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 949524243..7ba259cae 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -343,6 +343,36 @@ 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, + old_deal=old_deal, + ) + + # Assert + assert result is True + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 26bcb3e9a683275029f1ce1665e1757674d50aec Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 15:57:04 +0000 Subject: [PATCH 03/34] Pass new cylinder fields through _make_audit test helper PropertyAudit gained three required cylinder fields; the pre-existing divergence/goal-shortfall tests construct it via _make_audit, which wasn't updated. Co-Authored-By: Claude Fable 5 --- tests/scripts/test_audit_anomalies.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/scripts/test_audit_anomalies.py b/tests/scripts/test_audit_anomalies.py index 2cc510bba..dd8771500 100644 --- a/tests/scripts/test_audit_anomalies.py +++ b/tests/scripts/test_audit_anomalies.py @@ -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, ) From 51ac5e2f5ed1748af35d845da271ba4edb782ed0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 15:57:26 +0000 Subject: [PATCH 04/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20fire?= =?UTF-8?q?s=20when=20confirmed=5Fsurvey=5Fdate=20is=20first=20set=20on=20?= =?UTF-8?q?an=20Abri=20Condition=20deal=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index ee189bae0..e757c7e59 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -174,7 +174,7 @@ class HubspotDealDiffer: def check_for_abri_survey_creation( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: - raise NotImplementedError + return True @staticmethod def check_for_magicplan_trigger( From 697df8ade36aac9ccd7f33df5a113358a8357b71 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 15:58:09 +0000 Subject: [PATCH 05/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20igno?= =?UTF-8?q?res=20deals=20that=20already=20have=20a=20confirmed=5Fsurvey=5F?= =?UTF-8?q?date=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 7ba259cae..fcbe80e08 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -373,6 +373,31 @@ def test_abri_survey_creation__date_first_set_on_abri_deal__returns_true() -> No 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, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From fc2ba887597af67c100b4042eb231ca6a61d752a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 15:58:41 +0000 Subject: [PATCH 06/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20igno?= =?UTF-8?q?res=20deals=20that=20already=20have=20a=20confirmed=5Fsurvey=5F?= =?UTF-8?q?date=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index e757c7e59..b4f130f63 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -174,7 +174,7 @@ class HubspotDealDiffer: def check_for_abri_survey_creation( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: - return True + return old_deal.confirmed_survey_date is None @staticmethod def check_for_magicplan_trigger( From 882eb1eff95a5f962404d119544c037dc54e2cf1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 15:59:05 +0000 Subject: [PATCH 07/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20igno?= =?UTF-8?q?res=20deals=20outside=20the=20Abri=20Condition=20project=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index fcbe80e08..e9372bf6b 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -398,6 +398,31 @@ def test_abri_survey_creation__date_already_set__returns_false() -> None: 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, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 97bf001fbfd82be9fdefc1d60a917a94b12b6dce Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 15:59:38 +0000 Subject: [PATCH 08/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20igno?= =?UTF-8?q?res=20deals=20outside=20the=20Abri=20Condition=20project=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index b4f130f63..bacc60459 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -174,6 +174,8 @@ class HubspotDealDiffer: def check_for_abri_survey_creation( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: + if new_deal.get("project_code") != "Abri Condition": + return False return old_deal.confirmed_survey_date is None @staticmethod From bd0c45aa5718f6015f1cb3db768ff035f2559c94 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:00:09 +0000 Subject: [PATCH 09/34] =?UTF-8?q?Abri=20Condition=20project=20code=20lives?= =?UTF-8?q?=20in=20a=20reusable=20class=20constant=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index bacc60459..b2a9024b2 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -12,6 +12,7 @@ class HubspotDealDiffer: ] RETROFIT_DESIGN_COMPLETE = "uploaded" LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] + ABRI_CONDITION_PROJECT_CODE = "Abri Condition" @staticmethod def check_for_db_update_trigger( @@ -174,7 +175,10 @@ class HubspotDealDiffer: def check_for_abri_survey_creation( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: - if new_deal.get("project_code") != "Abri Condition": + if ( + new_deal.get("project_code") + != HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE + ): return False return old_deal.confirmed_survey_date is None From 5b9038ab6116f86b96498411277960929fae22e8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:01:25 +0000 Subject: [PATCH 10/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20matc?= =?UTF-8?q?hes=20the=20Abri=20Condition=20project=20code=20case-insensitiv?= =?UTF-8?q?ely=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index e9372bf6b..7391706e9 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -423,6 +423,31 @@ def test_abri_survey_creation__non_abri_project_code__returns_false() -> None: 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, + old_deal=old_deal, + ) + + # Assert + assert result is True + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From fb2b7e164c065fa5c6df5f372b4c780831da73b8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:01:43 +0000 Subject: [PATCH 11/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20matc?= =?UTF-8?q?hes=20the=20Abri=20Condition=20project=20code=20case-insensitiv?= =?UTF-8?q?ely=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index b2a9024b2..29fd15c35 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -175,10 +175,8 @@ class HubspotDealDiffer: def check_for_abri_survey_creation( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: - if ( - new_deal.get("project_code") - != HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE - ): + new_project_code = (new_deal.get("project_code") or "").lower() + if new_project_code != HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower(): return False return old_deal.confirmed_survey_date is None From c059771af5fe13d508aabc85cdbf2d57a668c648 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:02:08 +0000 Subject: [PATCH 12/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20igno?= =?UTF-8?q?res=20syncs=20with=20no=20parseable=20confirmed=5Fsurvey=5Fdate?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 7391706e9..a50634dea 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -448,6 +448,41 @@ def test_abri_survey_creation__project_code_cased_differently__returns_true() -> 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, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 7aabfdfa5b4f7fb7a335e67566d497bdfcade61a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:02:36 +0000 Subject: [PATCH 13/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20igno?= =?UTF-8?q?res=20syncs=20with=20no=20parseable=20confirmed=5Fsurvey=5Fdate?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 29fd15c35..2e963eb54 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -178,7 +178,11 @@ class HubspotDealDiffer: new_project_code = (new_deal.get("project_code") or "").lower() if new_project_code != HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower(): return False - return old_deal.confirmed_survey_date is None + + 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_magicplan_trigger( From 592173e1e578aab04d887825bef78fc66b3696dd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:03:34 +0000 Subject: [PATCH 14/34] =?UTF-8?q?Abri=20Condition=20project=20gate=20is=20?= =?UTF-8?q?a=20reusable=20helper=20for=20future=20Abri=20triggers=20?= =?UTF-8?q?=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 2e963eb54..6fbcf3432 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -175,8 +175,7 @@ class HubspotDealDiffer: def check_for_abri_survey_creation( new_deal: Dict[str, str], old_deal: HubspotDealData ) -> bool: - new_project_code = (new_deal.get("project_code") or "").lower() - if new_project_code != HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower(): + if not HubspotDealDiffer._is_abri_condition_deal(new_deal): return False if old_deal.confirmed_survey_date is not None: @@ -184,6 +183,11 @@ class HubspotDealDiffer: return parse_hs_date(new_deal.get("confirmed_survey_date")) is not None + @staticmethod + def _is_abri_condition_deal(new_deal: Dict[str, str]) -> bool: + 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 From db488de43d71355725868cf140b5cfddd483b8a5 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 16:04:16 +0000 Subject: [PATCH 15/34] =?UTF-8?q?Uninsulated=20cylinder=20(type=200)=20tak?= =?UTF-8?q?es=20the=20SAP=20Table=202=20jacket=20t=3D0=20storage=20loss=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../rdsap/test_cert_to_inputs.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index ec770e72b..0f20e7a39 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -6365,6 +6365,92 @@ 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_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, From e813cc0ef26404ef17d4eadd8bec05b2194ec4aa Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 16:06:33 +0000 Subject: [PATCH 16/34] =?UTF-8?q?Uninsulated=20cylinder=20(type=200)=20tak?= =?UTF-8?q?es=20the=20SAP=20Table=202=20jacket=20t=3D0=20storage=20loss=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/sap10_calculator/rdsap/cert_to_inputs.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index b725555a8..3260dd181 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -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,18 @@ 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 (lodged insulation type 0) + # takes the loose-jacket formula at t = 0 — the WORST loss SAP knows, + # never the zero-loss combi default (which over-rates the dwelling). + 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: return None - thickness_mm = sh.cylinder_insulation_thickness_mm if thickness_mm is None: return None storage_56m = cylinder_storage_loss_monthly_kwh( From 4cf3db388ff0deae72ed3d415648f356b21ba79e Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 16:07:20 +0000 Subject: [PATCH 17/34] =?UTF-8?q?Elmhurst=20'No=20Insulation'=20cylinder?= =?UTF-8?q?=20(0=20mm=20lodged)=20takes=20the=20uninsulated=20storage=20lo?= =?UTF-8?q?ss=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../rdsap/test_cert_to_inputs.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 0f20e7a39..62d75c22d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -6451,6 +6451,78 @@ def test_uninsulated_cylinder_computes_storage_loss_via_table2_jacket_t0() -> No 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_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, From 233f085fb35569ba241df53bb4fdfffe78348419 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 16:08:46 +0000 Subject: [PATCH 18/34] =?UTF-8?q?Elmhurst=20'No=20Insulation'=20cylinder?= =?UTF-8?q?=20(0=20mm=20lodged)=20takes=20the=20uninsulated=20storage=20lo?= =?UTF-8?q?ss=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/sap10_calculator/rdsap/cert_to_inputs.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 3260dd181..5bb76429d 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7115,11 +7115,17 @@ def _cylinder_storage_loss_override( if sh.cylinder_insulation_thickness_mm is not None else None ) - # SAP 10.2 Table 2: an uninsulated cylinder (lodged insulation type 0) - # takes the loose-jacket formula at t = 0 — the WORST loss SAP knows, - # never the zero-loss combi default (which over-rates the dwelling). + # 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" if insulation_label is None: return None if thickness_mm is None: From 6accf365d7d3d78734059ac1736524f3f14d274f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 16:09:46 +0000 Subject: [PATCH 19/34] =?UTF-8?q?Cylinder=20with=20unknown=20insulation=20?= =?UTF-8?q?defaults=20per=20RdSAP=20Table=2029=20age=20band=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../rdsap/test_cert_to_inputs.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py index 62d75c22d..c8836a92b 100644 --- a/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py +++ b/tests/domain/sap10_calculator/rdsap/test_cert_to_inputs.py @@ -6523,6 +6523,79 @@ def test_elmhurst_no_insulation_cylinder_thickness_zero_takes_uninsulated_loss() 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, From ecefd439fde0a3bf016a0d57eedebe2b66e1105d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Thu, 2 Jul 2026 16:11:10 +0000 Subject: [PATCH 20/34] =?UTF-8?q?Cylinder=20with=20unknown=20insulation=20?= =?UTF-8?q?defaults=20per=20RdSAP=20Table=2029=20age=20band=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- domain/sap10_calculator/rdsap/cert_to_inputs.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index 5bb76429d..22d379c32 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -7126,6 +7126,20 @@ def _cylinder_storage_loss_override( 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 if thickness_mm is None: From cc682e3e74b87bce5cd0cfe349acbaf5cddf3e38 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:27:05 +0000 Subject: [PATCH 21/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20reco?= =?UTF-8?q?gnises=20deals=20by=20their=20associated=20Abri=20project=20id?= =?UTF-8?q?=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 12 +++++-- etl/hubspot/tests/test_hubspot_deal_differ.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 6fbcf3432..2017487a0 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -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,7 @@ 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 @@ -173,9 +175,11 @@ class HubspotDealDiffer: @staticmethod def check_for_abri_survey_creation( - new_deal: Dict[str, str], old_deal: HubspotDealData + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, ) -> bool: - if not HubspotDealDiffer._is_abri_condition_deal(new_deal): + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): return False if old_deal.confirmed_survey_date is not None: @@ -184,7 +188,9 @@ class HubspotDealDiffer: return parse_hs_date(new_deal.get("confirmed_survey_date")) is not None @staticmethod - def _is_abri_condition_deal(new_deal: Dict[str, str]) -> bool: + def _is_abri_condition_deal( + new_deal: Dict[str, str], new_project: Optional[ProjectData] + ) -> bool: new_project_code = (new_deal.get("project_code") or "").lower() return new_project_code == HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower() diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index a50634dea..ea0c3679f 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -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) @@ -366,6 +367,7 @@ def test_abri_survey_creation__date_first_set_on_abri_deal__returns_true() -> No # Act result = HubspotDealDiffer.check_for_abri_survey_creation( new_deal=new_deal, + new_project=None, old_deal=old_deal, ) @@ -391,6 +393,7 @@ def test_abri_survey_creation__date_already_set__returns_false() -> None: # Act result = HubspotDealDiffer.check_for_abri_survey_creation( new_deal=new_deal, + new_project=None, old_deal=old_deal, ) @@ -416,6 +419,7 @@ def test_abri_survey_creation__non_abri_project_code__returns_false() -> None: # Act result = HubspotDealDiffer.check_for_abri_survey_creation( new_deal=new_deal, + new_project=None, old_deal=old_deal, ) @@ -441,6 +445,7 @@ def test_abri_survey_creation__project_code_cased_differently__returns_true() -> # Act result = HubspotDealDiffer.check_for_abri_survey_creation( new_deal=new_deal, + new_project=None, old_deal=old_deal, ) @@ -476,6 +481,7 @@ def test_abri_survey_creation__no_parseable_new_date__returns_false( # Act result = HubspotDealDiffer.check_for_abri_survey_creation( new_deal=new_deal, + new_project=None, old_deal=old_deal, ) @@ -483,6 +489,36 @@ def test_abri_survey_creation__no_parseable_new_date__returns_false( 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 + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From cf5a272d7e49de009f707db199c20ac5556e5f1a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:27:29 +0000 Subject: [PATCH 22/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20reco?= =?UTF-8?q?gnises=20deals=20by=20their=20associated=20Abri=20project=20id?= =?UTF-8?q?=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 2017487a0..636bf7521 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -191,6 +191,13 @@ class HubspotDealDiffer: def _is_abri_condition_deal( new_deal: Dict[str, str], new_project: Optional[ProjectData] ) -> bool: + if ( + new_project is not None + and new_project["project_id"] + == HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID + ): + return True + new_project_code = (new_deal.get("project_code") or "").lower() return new_project_code == HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower() From 911e77d7cfe4c4eeb2e79b2a6cdf9a52e591b493 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:28:13 +0000 Subject: [PATCH 23/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20fall?= =?UTF-8?q?s=20back=20to=20project=20code=20only=20when=20no=20project=20i?= =?UTF-8?q?s=20associated=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index ea0c3679f..a072b175f 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -519,6 +519,35 @@ def test_abri_survey_creation__associated_abri_project_id__returns_true() -> Non 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 + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From fdeb15501f9a6f03215d454704dc2feffa3d6294 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:29:11 +0000 Subject: [PATCH 24/34] =?UTF-8?q?Abri=20survey=20creation=20trigger=20fall?= =?UTF-8?q?s=20back=20to=20project=20code=20only=20when=20no=20project=20i?= =?UTF-8?q?s=20associated=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 636bf7521..68b075182 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -191,12 +191,11 @@ class HubspotDealDiffer: def _is_abri_condition_deal( new_deal: Dict[str, str], new_project: Optional[ProjectData] ) -> bool: - if ( - new_project is not None - and new_project["project_id"] - == HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID - ): - return True + 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() From 1c75b446306884dfc1b1fe9d6e48dab4d6c4634a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:47:07 +0000 Subject: [PATCH 25/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20fir?= =?UTF-8?q?es=20when=20the=20confirmed=20survey=20date=20changes=20on=20an?= =?UTF-8?q?=20Abri=20condition=20deal=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 8 +++++ etl/hubspot/tests/test_hubspot_deal_differ.py | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 68b075182..fc49d02c9 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -187,6 +187,14 @@ class HubspotDealDiffer: 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: + raise NotImplementedError + @staticmethod def _is_abri_condition_deal( new_deal: Dict[str, str], new_project: Optional[ProjectData] diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index a072b175f..c57fcbe07 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -548,6 +548,37 @@ def test_abri_survey_creation__non_abri_project_id_with_abri_code__returns_false 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 + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From dbbb12fa54c91f47784d9d6ddb8764c1209c6673 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:47:29 +0000 Subject: [PATCH 26/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20fir?= =?UTF-8?q?es=20when=20the=20confirmed=20survey=20date=20changes=20on=20an?= =?UTF-8?q?=20Abri=20condition=20deal=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index fc49d02c9..c521ff843 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -193,7 +193,7 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - raise NotImplementedError + return True @staticmethod def _is_abri_condition_deal( From 16b25c4b54dba0daa8a174fc1959495771548943 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:47:51 +0000 Subject: [PATCH 27/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20ign?= =?UTF-8?q?ores=20deals=20that=20are=20not=20Abri=20condition=20deals=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index c57fcbe07..eaa2940d3 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -579,6 +579,32 @@ def test_abri_survey_amendment__date_changed_on_abri_deal__returns_true() -> Non 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 + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 7bfd26476ebd5d789fdd66ea2975563230fa36b5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:48:26 +0000 Subject: [PATCH 28/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20ign?= =?UTF-8?q?ores=20deals=20that=20are=20not=20Abri=20condition=20deals=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index c521ff843..b2f38c0f3 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -193,7 +193,7 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - return True + return HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project) @staticmethod def _is_abri_condition_deal( From fce9d305895db6abb9f276797659af7ee22f81c3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:48:53 +0000 Subject: [PATCH 29/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20sta?= =?UTF-8?q?ys=20quiet=20when=20the=20survey=20date=20and=20time=20are=20un?= =?UTF-8?q?changed=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index eaa2940d3..9c581da89 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -605,6 +605,34 @@ def test_abri_survey_amendment__non_abri_deal__returns_false() -> None: 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 + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From bf344e1b5832c7f42a35b0705cf9903282a1a57a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:49:19 +0000 Subject: [PATCH 30/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20sta?= =?UTF-8?q?ys=20quiet=20when=20the=20survey=20date=20and=20time=20are=20un?= =?UTF-8?q?changed=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index b2f38c0f3..9a94ed4fb 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -193,7 +193,11 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - return HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project) + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) + return old_deal.confirmed_survey_date != new_survey_date @staticmethod def _is_abri_condition_deal( From 8c5d1a27fff2a7212efc47cce16663fb7c920ae1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:50:53 +0000 Subject: [PATCH 31/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20fir?= =?UTF-8?q?es=20when=20the=20confirmed=20survey=20time=20changes=20on=20an?= =?UTF-8?q?=20Abri=20condition=20deal=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 9c581da89..2cdc0cd5c 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -633,6 +633,34 @@ def test_abri_survey_amendment__date_and_time_unchanged__returns_false() -> None 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 + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 597cd1c83f737a3d6c0364f97b666682fd73d896 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:52:00 +0000 Subject: [PATCH 32/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20fir?= =?UTF-8?q?es=20when=20the=20confirmed=20survey=20time=20changes=20on=20an?= =?UTF-8?q?=20Abri=20condition=20deal=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 9a94ed4fb..8e11efa52 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -197,7 +197,10 @@ class HubspotDealDiffer: return False new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) - return old_deal.confirmed_survey_date != new_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( From 1529544c191a326aacd64c0da86883231085c65a Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:52:26 +0000 Subject: [PATCH 33/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20def?= =?UTF-8?q?ers=20first-time=20survey=20dates=20to=20the=20creation=20trigg?= =?UTF-8?q?er=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 2cdc0cd5c..544242623 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -661,6 +661,32 @@ def test_abri_survey_amendment__time_changed_on_abri_deal__returns_true() -> Non 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 # ======================= From 60146f0c30f1777879bc4c23cc152e28dcdff5df Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Thu, 2 Jul 2026 16:52:49 +0000 Subject: [PATCH 34/34] =?UTF-8?q?Abri=20survey=20amendment=20trigger=20def?= =?UTF-8?q?ers=20first-time=20survey=20dates=20to=20the=20creation=20trigg?= =?UTF-8?q?er=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 8e11efa52..c4e9c0148 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -196,6 +196,10 @@ class HubspotDealDiffer: 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