Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	tests/infrastructure/epc_client/test_sap_accuracy_corpus.py
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-22 08:38:04 +00:00
commit 190325b23b
347 changed files with 40641 additions and 265 deletions

View file

@ -115,6 +115,16 @@ one-line reason. Absent ≠ zero everywhere — note which.
- **Water heating** 903 = Electric immersion off-peak → Elmhurst "Water Heater"
category, not "Boiler Circulator" (901 = from main system).
- **Windows** are synthesised from `glazed_area` band × TFA — not real geometry.
- **Room height — give the RAW surveyed room height, NOT the engine's storey
height.** Elmhurst adds the RdSAP +0.25 m upper-storey (joist-void) allowance
itself. Our mapper already applies that +0.25 to upper floors
(`_UPPER_FLOOR_HEIGHT_ADD_M`), so `sap_floor_dimensions[*].room_height_m` for
a first floor is *already* `raw + 0.25`. Reading that value onto the sheet
makes Elmhurst add a SECOND 0.25 → storey height too tall → ~4-5 m² extra wall
+ volume → **~1 SAP too low** (it under-scored 6 Barry Road 60→59 until the
first-floor height was corrected from 2.76 back to the raw 2.51). Pull heights
from the survey/raw `building_measurements`, not the transformed
`sap_floor_dimensions`; the ground/lowest floor takes the raw value either way.
- **Age band — pick by construction YEAR, not the engine's band letter.** The
live RdSAP-10 tool bands differently from `mapping.md`'s older table: it
offers `…K 2007-2011`, **`L 2012-2022`**, **`M 2023 onwards`**. A 2020 build

View file

@ -106,6 +106,12 @@ RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \
RUN curl -fsSL https://claude.ai/install.sh | bash
ENV PATH="/home/vscode/.local/bin:${PATH}"
# Install herdr (terminal workspace manager for AI coding agents). Same pattern
# as Claude Code above: the installer drops a self-contained static binary into
# ~/.local/bin, which is now on PATH. Lets you run herdr from inside the
# container. `herdr update` self-updates from herdr.dev thereafter.
RUN curl -fsSL https://herdr.dev/install.sh | sh
# Playwright chromium browser for the hyde Elmhurst automation. Run as the
# vscode user so the download lands in /home/vscode/.cache/ms-playwright (the
# OS deps were installed as root above). Headed chromium is required — the

View file

@ -107,8 +107,6 @@ on:
required: false
TF_VAR_abri_relay_password:
required: false
TF_VAR_abri_relay_default_resource:
required: false
TF_VAR_test_abri_project_code_override:
required: false
jobs:
@ -189,7 +187,6 @@ jobs:
TF_VAR_abri_relay_url: ${{ secrets.TF_VAR_abri_relay_url }}
TF_VAR_abri_relay_username: ${{ secrets.TF_VAR_abri_relay_username }}
TF_VAR_abri_relay_password: ${{ secrets.TF_VAR_abri_relay_password }}
TF_VAR_abri_relay_default_resource: ${{ secrets.TF_VAR_abri_relay_default_resource }}
TF_VAR_test_abri_project_code_override: ${{ secrets.TF_VAR_test_abri_project_code_override }}
run: |
ECR_REPO_URL_VAR=""
@ -247,7 +244,6 @@ jobs:
TF_VAR_abri_relay_url: ${{ secrets.TF_VAR_abri_relay_url }}
TF_VAR_abri_relay_username: ${{ secrets.TF_VAR_abri_relay_username }}
TF_VAR_abri_relay_password: ${{ secrets.TF_VAR_abri_relay_password }}
TF_VAR_abri_relay_default_resource: ${{ secrets.TF_VAR_abri_relay_default_resource }}
run: |
EXTRA_VARS=""
if [[ -n "${{ inputs.ecr_repo }}" ]]; then

View file

@ -740,7 +740,6 @@ jobs:
TF_VAR_abri_relay_url: ${{ secrets.ABRI_RELAY_URL }}
TF_VAR_abri_relay_username: ${{ secrets.ABRI_RELAY_USERNAME }}
TF_VAR_abri_relay_password: ${{ secrets.ABRI_RELAY_PASSWORD }}
TF_VAR_abri_relay_default_resource: ${{ secrets.ABRI_RELAY_DEFAULT_RESOURCE }}
# ============================================================
# Build Audit Generator image

View file

@ -28,8 +28,8 @@ The date an EPC was lodged with the government register; used to identify the mo
_Avoid_: assessment date, submission date
**EPC Band**:
A single letter AG representing a property's current or potential energy efficiency rating.
_Avoid_: energy rating, EPC grade, EPC score
A single letter AG representing a property's current or potential energy efficiency rating. A dwelling is in a band by its **published rating** — the continuous SAP rounded **half up** to the integer printed on the certificate (SAP 10.2 §13; Python's built-in `round` is half-to-*even* and must not be used for this). Anything holding a continuous score therefore judges band membership via `Epc.from_sap_continuous`, and any "has it reached band X?" test compares against `Epc.sap_lower_bound_continuous()` — the integer floor **less half a point** (C → 68.5), which is where the published rating enters the band. Comparing a continuous score against the raw integer floor makes a dwelling that already publishes in the band read as a fraction short (ADR-0064).
_Avoid_: energy rating, EPC grade, EPC score; comparing a continuous SAP against `sap_lower_bound()`
**Schema Type**:
The versioned RdSAP or SAP schema that describes the structure of an EPC's raw data (e.g. `RdSAP-Schema-21.0.1`).
@ -156,7 +156,7 @@ The SAP / EPC Band / carbon emissions / Primary Energy Intensity recorded on the
_Avoid_: original performance, raw EPC values, recorded baseline
**Effective Performance**:
The SAP / EPC Band / carbon emissions / Primary Energy Intensity the modelling pipeline actually scored against — equal to Lodged Performance when no Rebaselining trigger fires, replaced by **SAP10 Calculation** output (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013) when triggered. The half of Baseline Performance that says "what we modelled".
The SAP / EPC Band / carbon emissions / Primary Energy Intensity the modelling pipeline actually scored against — equal to Lodged Performance when no Rebaselining trigger fires, replaced by **SAP10 Calculation** output (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013) when triggered. The half of Baseline Performance that says "what we modelled". **Modelling does not read it**: it re-scores the Effective EPC itself (it needs a continuous score and a `SapResult` to bill from, and ADR-0011 forbids an in-memory hand-off between stages), then **asserts** its re-score agrees with this persisted figure and logs an error when it does not. The two are deliberately *not* reconciled — a divergence means the stages scored different pictures, and anchoring the Plan's SAP to this integer would leave it disagreeing with its own carbon, energy and bill figures (ADR-0066). Because a Plan's `post_*` are read against this figure, an unchecked divergence surfaces to the user as a gain nobody modelled.
_Avoid_: modelled performance, rebaselined performance (only correct when rebaselining ran), scored values
**Calculated SAP10 Performance**:
@ -309,7 +309,7 @@ A "selecting A requires B" edge between **Recommendations**, for couplings that
_Avoid_: best-practice measure (legacy term), forced measure
**Optimised Package**:
The subset of a Property's Recommendations selected by the Optimiser Service for installation. For an **Increasing EPC** goal the objective is **least-cost-to-target**: the cheapest package that reaches the goal band — so it **stops at the target and does not overshoot** into a higher band, leaving surplus budget unspent. When the target is **unreachable within budget**, it falls back to the **maximum improvement the budget buys** (best effort, below target). With **no budget** it is simply the cheapest package that reaches the target. Reaching the target is judged on the **true whole-package re-score** (ADR-0016), not on summed per-measure scores. (Other goals — Energy Savings, Reducing CO₂ — don't yet set a target and currently maximise improvement within budget; future work.)
The subset of a Property's Recommendations selected by the Optimiser Service for installation. For an **Increasing EPC** goal the objective is **least-cost-to-target**: the cheapest package that reaches the goal band — so it **stops at the target and does not overshoot** into a higher band, leaving surplus budget unspent. When the target is **unreachable within budget**, it falls back to the **maximum improvement the budget buys** (best effort, below target). With **no budget** it is simply the cheapest package that reaches the target. Reaching the target is judged on the **true whole-package re-score** (ADR-0016), not on summed per-measure scores — and on the **published** band (see **EPC Band**): the target is the goal band's floor on the continuous scale, so a dwelling whose published baseline already meets the goal is **already at target** and gets an empty, £0 package rather than a marginal top-up (ADR-0064). (Other goals — Energy Savings, Reducing CO₂ — don't yet set a target and currently maximise improvement within budget; future work.)
_Avoid_: selected measures, default measures, optimal solution, recommended bundle
**Measure Type**:

View file

@ -9,7 +9,7 @@ visibility surface.
from datetime import date
from typing import Dict, List, Literal, Optional, Tuple
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"]
@ -19,8 +19,13 @@ AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"]
# alone would reject valid messages; the orchestrator fails the flow if a deal
# carries neither. Its outcome is optional too, only feeding the reason seam.
_FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = {
"amend_job": ("confirmed_survey_date",),
"log_job": ("place_ref", "deal_name", "confirmed_survey_date"),
"amend_job": ("confirmed_survey_date", "third_party_surveyor_identifier"),
"log_job": (
"place_ref",
"deal_name",
"confirmed_survey_date",
"third_party_surveyor_identifier",
),
"sync_tenant_data": ("place_ref",),
"abandon_job": (),
}
@ -37,6 +42,18 @@ class AbriTriggerRequest(BaseModel):
confirmed_survey_time: Optional[str] = None
last_submission_date: Optional[date] = None
outcome: Optional[str] = None
# OpenHousing's bookable resource for the survey; required by the log and
# amend flows. Empty string is also rejected by Abri, so not allowed here
third_party_surveyor_identifier: Optional[str] = None
@field_validator("third_party_surveyor_identifier")
@classmethod
def _blank_surveyor_identifier_is_missing(
cls, value: Optional[str]
) -> Optional[str]:
if value is not None and not value.strip():
return None
return value
@model_validator(mode="after")
def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest":

View file

@ -81,13 +81,19 @@ def dispatch_abri_flows(
def _run_amend(request: AbriTriggerRequest, flows: AbriFlows) -> str:
if request.confirmed_survey_date is None:
raise ValueError("amend_job fired without a confirmed_survey_date")
if (
request.confirmed_survey_date is None
or request.third_party_surveyor_identifier is None
):
raise ValueError(
"amend_job fired without a confirmed_survey_date/surveyor identifier"
)
outcome = flows.amend_job(
AppointmentChange(
deal_id=request.hubspot_deal_id,
confirmed_survey_date=request.confirmed_survey_date,
confirmed_survey_time=request.confirmed_survey_time,
third_party_surveyor_identifier=request.third_party_surveyor_identifier,
)
)
if isinstance(outcome, AbriRequestRejected):
@ -111,8 +117,12 @@ def _run_log(
request.place_ref is None
or request.deal_name is None
or request.confirmed_survey_date is None
or request.third_party_surveyor_identifier is None
):
raise ValueError("log_job fired without place_ref/deal_name/survey date")
raise ValueError(
"log_job fired without place_ref/deal_name/survey date/"
"surveyor identifier"
)
try:
outcome = flows.log_job(
ConfirmedSurveyBooking(
@ -121,6 +131,9 @@ def _run_log(
deal_name=request.deal_name,
confirmed_survey_date=request.confirmed_survey_date,
confirmed_survey_time=request.confirmed_survey_time,
third_party_surveyor_identifier=(
request.third_party_surveyor_identifier
),
)
)
except LogJobWriteBackError as error:

View file

@ -67,6 +67,7 @@ class HubspotDealData(SQLModel, table=True):
lodgement_date: Optional[datetime] = Field(default=None)
expected_commencement_date: Optional[datetime] = Field(default=None)
surveyor: Optional[str] = Field(default=None)
third_party_surveyor_identifier: Optional[str] = Field(default=None)
confirmed_survey_date: Optional[datetime] = Field(default=None)
confirmed_survey_time: Optional[str] = Field(default=None)
surveyed_date: Optional[datetime] = Field(default=None)

View file

@ -481,7 +481,9 @@ class PasHubRdSapSiteNotesExtractor:
def extract_room_count_elements(self) -> RoomCountElements:
rce_section = self._section("Room Count Elements", "Customer Response")
heated_rooms_raw = self._get_in(rce_section, "Number of heated rooms?")
heated_rooms_raw = self._get_in(
rce_section, "Please enter the number of HEATED rooms:"
)
return RoomCountElements(
number_of_habitable_rooms=int(
self._get_in(rce_section, "Number of habitable rooms?") or 0
@ -518,6 +520,15 @@ class PasHubRdSapSiteNotesExtractor:
number_of_fixed_cfl_bulbs=int(
self._get_in(rce_section, "Number of fixed CFL bulbs:") or 0
),
number_of_fixed_low_energy_lights=(
int(low_energy_raw)
if (
low_energy_raw := self._get_in(
rce_section, "Number of fixed low energy lights?"
)
)
else None
),
waste_water_heat_recovery=self._get_in(
rce_section, "Are there any waste water heat recovery systems?"
)
@ -615,7 +626,11 @@ class PasHubRdSapSiteNotesExtractor:
condensing=self._bool_in(data, "Condensing"),
year=self._get_in(data, "Year") or "",
mount=self._get_in(data, "Mount") or "",
open_flue=self._get_in(data, "Open Flue") or "",
# PCDF-Search "Open Flue" column wins; a Manual-Entry boiler instead
# lodges the flue under "Is there an open flue?" (Yes/No) (#1649 A).
open_flue=self._get_in(data, "Open Flue")
or self._get_in(data, "Is there an open flue?")
or "",
fan_assist=self._bool_in(data, "Fan Assist"),
status=self._get_in(data, "Status") or "",
central_heating_pump_age=self._get_in(data, "Central heating pump age:")
@ -627,6 +642,12 @@ class PasHubRdSapSiteNotesExtractor:
weather_compensator=self._bool_in(data, "Is there a weather compensator?"),
emitter=self._get_in(data, "Emitter:") or "",
emitter_temperature=self._get_in(data, "Emitter Temperature:") or "",
# Manual-Entry boiler descriptor (blank on a PCDF-Search boiler):
# "Type of boiler:" and "Heating System (Boiler):" (the latter
# carries the pre/post-1998 age band) → the mapper's Table 4b
# efficiency lookup (#1649 A).
boiler_type=self._get_in(data, "Type of boiler:") or "",
boiler_age_band=self._get_in(data, "Heating System (Boiler):") or "",
)
def _parse_secondary_heating(self, data: List[str]) -> SecondaryHeating:
@ -856,4 +877,9 @@ class PasHubRdSapSiteNotesExtractor:
or "",
wall_thickness_mm=self._wall_thickness_in(data),
dry_lined=self._optional_bool_in(data, "Wall Dry-Lined?"),
# Surveyed alt-wall insulation thickness ("50 mm") → the mapper's
# §5.4 thickness input; dropped, the cascade assumes 100 mm (#1649 D).
insulation_thickness_mm=self._parse_insulation_thickness(
self._get_in(data, "Wall insulation thickness:")
)[0],
)

View file

@ -1091,8 +1091,8 @@
{
"deal_id": "507639151845",
"uprn": null,
"pre_sap_raw": "C80",
"pre_sap_score": 80,
"pre_sap_raw": "C76",
"pre_sap_score": 76,
"pdf": "507639151845.pdf"
},
{

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more