Merge remote-tracking branch 'origin/main' into feature/e2e-runs

This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 11:05:15 +00:00
commit 52e5a4d402
31 changed files with 1575 additions and 12 deletions

View file

@ -151,6 +151,14 @@ Pattern: `with E.session() as (ctx,page): E.goto(...); E.set_text/set_select(...
2022" ≈ U1.4/g0.72) — the "...known data" option demands per-row Frame-Factor/
Data-Source/Location/Building-Part and fails validation. Trust the grid only
after Save & Close + reload (the live grid mis-shows older rows as 0.00).
- **Window-width `.fill()` can DOUBLE the value** → "Openings: Width has to be less
or equal to 50" validation error. `_add_window`'s `page.locator(TextBoxExtWidth)
.fill(str(area))` went through Elmhurst's keystroke/numeric formatter and rendered
`11.1` as `111.10` (uprn_10010215568) — the Recommendations gate then blocks the
download. After building Openings, VERIFY the grid width (reload + read the row);
if wrong, JS-set it: `el.value='11.1'; dispatch input+change+blur`, then Save&Close
(the entry boxes mirror the single row = edit-in-place). JS-set bypasses the
formatter and sticks. Belt-and-braces: set the width by JS in `_add_window` too.
- **U-value-known overrides** (walls/roof/floor) don't toggle via automation →
accept the natural RdSAP age-band U-value; note the divergence for reconciliation.
- **Recommendations page = validation gate.** It must show ZERO errors before the

View file

@ -78,7 +78,7 @@ Skip the 🚩 MVHR / 🚩 heat-pump-fuel and ⛔ sparse certs.
- [⚠] 10093388044 — SAP-17.1 · eng 87 / lodged 93 · 🚩 heat-pump fuel-39 (flagged)
- [x] 10090944225 — SAP-17.0 (full-SAP GROUND-FLOOR FLAT, band L 2015, combi PCDB 17045 Ideal Logic Combi ES35, control 2110 CBI time+temp zone, NATURAL vent + 2 extract fans, AP50 3.48, party wall 3.41m² U0, measured U walls 0.27/floor 0.14/window 1.41, TFA 49.97, no cylinder/no secondary) · eng 81 / elm 80 (lodged 82) · PINNED engine 81. Built in Elmhurst (boiler 17045 via search, control CBI/2110 under Boilers→Zone control, water from primary, AP50 Blower Door 3.48+cert, Single meter). engine-on-Elmhurst-inputs 80 = worksheet 80 (exact) → faithful; eng 81 ≈ lodged 82 (1). Remaining +1 = documented full-SAP→RdSAP residual localised to the FLOOR (cert measured U 0.14 vs Elmhurst RdSAP solid default 0.23; walls/party match) — engine correctly uses measured 0.14, NOT a mapper bug. ⚠ Initial worksheet read 79 due to a spurious leftover REA/691 electric secondary (Table-11 frac 0.1, ~1.5 SAP) the shared assessment retained — REMOVED via present=Yes→clear secondary code to empty→present=No (79→80). 🔧 Build notes: storage→combi reset (clear leftover SEB MainHeatingCode pass-1, reset meter Single); control 2110 is Boilers→**Zone control**→CBI (not Standard). No mapper change.
- [⚠] 10090341811 — SAP-17.0 · eng 80 / lodged 89 · 🚩 MVHR idx 500352 not credited (flagged)
- [ ] 10010215568 — RdSAP-17.1 · eng 75 / lodged 74
- [x] 10010215568 — RdSAP-17.1 (NATIVE; END-TERRACE BUNGALOW single-storey, band J 2003-2006, cavity as-built insulated, pitched 200mm loft, solid insulated floor, mains-gas REGULAR boiler PCDB 15100 Vaillant Ecotec plus 415 + cylinder size2/Normal-110L/foam 25mm/thermostat, control 2106 CBE, water from primary, party wall 9.8m, double glazed, natural vent, 86% LED, TFA 75.46) · eng 75 / elm 73 (lodged 74) · PINNED engine 75. Built in Elmhurst (House+1 storey bungalow, boiler 15100 via search, CBE/2106 control, cylinder Normal/foam/25mm/thermostat, water from primary, window 11.1m² double-unknown-date). engine-on-Elmhurst-inputs 74 ≈ worksheet 73 → calculator faithful; eng 75 ≈ lodged 74 (+1). +2 vs Elmhurst = documented engine-uses-lodged vs Elmhurst-RdSAP-default (wall thickness 250 default vs lodged 310, baths 2 vs lodged 1). Cylinder thickness 25mm correctly carried by RdSAP-17.1 mapper (no cylinder-gap). ⚠ window width fill bug: `.fill("11.1")` rendered 111.10 (keystroke formatter doubled it) → validation "Width ≤ 50"; fixed by JS-setting width. No mapper change. build_10010215568.py.
- [⚠] 10093117227 — SAP-17.1 · eng 90 / lodged 80 · 🚩 heat-pump fuel-39 (flagged)
- [⚠] 10023444170 — SAP-17.0 · eng 80 / lodged 83 · 🚩 MVHR idx 500167 not credited (flagged)
- [ ] 100020980961 — SAP-16.3 · eng 66 / lodged 65

View file

@ -97,6 +97,9 @@ on:
TF_VAR_openai_api_key:
required: false
TF_VAR_open_epc_api_token:
required: false
jobs:
deploy:
runs-on: ubuntu-latest
@ -170,6 +173,7 @@ jobs:
TF_VAR_magicplan_customer_id: ${{ secrets.TF_VAR_magicplan_customer_id }}
TF_VAR_magicplan_api_key: ${{ secrets.TF_VAR_magicplan_api_key }}
TF_VAR_openai_api_key: ${{ secrets.TF_VAR_openai_api_key }}
TF_VAR_open_epc_api_token: ${{ secrets.TF_VAR_open_epc_api_token }}
run: |
ECR_REPO_URL_VAR=""
if [[ -n "${{ inputs.ecr_repo }}" ]]; then
@ -222,6 +226,7 @@ jobs:
TF_VAR_magicplan_customer_id: ${{ secrets.TF_VAR_magicplan_customer_id }}
TF_VAR_magicplan_api_key: ${{ secrets.TF_VAR_magicplan_api_key }}
TF_VAR_openai_api_key: ${{ secrets.TF_VAR_openai_api_key }}
TF_VAR_open_epc_api_token: ${{ secrets.TF_VAR_open_epc_api_token }}
run: |
EXTRA_VARS=""
if [[ -n "${{ inputs.ecr_repo }}" ]]; then

View file

@ -736,6 +736,44 @@ jobs:
TF_VAR_db_name: ${{ secrets.DEV_DB_NAME }}
TF_VAR_db_port: ${{ secrets.DEV_DB_PORT }}
# ============================================================
# Build Modelling E2E image and Push
# ============================================================
modelling_e2e_image:
needs: [determine_stage, shared_terraform]
uses: ./.github/workflows/_build_image.yml
with:
ecr_repo: modelling-e2e-${{ needs.determine_stage.outputs.stage }}
dockerfile_path: applications/modelling_e2e/Dockerfile
build_context: .
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.DEV_AWS_REGION }}
# ============================================================
# Deploy Modelling E2E Lambda
# ============================================================
modelling_e2e_lambda:
needs: [modelling_e2e_image, determine_stage]
uses: ./.github/workflows/_deploy_lambda.yml
with:
lambda_name: modelling_e2e
lambda_path: deployment/terraform/lambda/modelling_e2e
stage: ${{ needs.determine_stage.outputs.stage }}
ecr_repo: modelling-e2e-${{ needs.determine_stage.outputs.stage }}
image_digest: ${{ needs.modelling_e2e_image.outputs.image_digest }}
terraform_apply: ${{ needs.determine_stage.outputs.terraform_apply }}
secrets:
AWS_ACCESS_KEY_ID: ${{ secrets.DEV_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ secrets.DEV_AWS_REGION }}
TF_VAR_db_host: ${{ secrets.DEV_DB_HOST }}
TF_VAR_db_name: ${{ secrets.DEV_DB_NAME }}
TF_VAR_db_port: ${{ secrets.DEV_DB_PORT }}
TF_VAR_open_epc_api_token: ${{ secrets.DEV_OPEN_EPC_API_TOKEN }}
TF_VAR_google_solar_api_key: ${{ secrets.DEV_GOOGLE_SOLAR_API_KEY }}
# ============================================================
# Deploy Hubspot ETL Lambda
# ============================================================

View file

@ -0,0 +1,24 @@
FROM public.ecr.aws/lambda/python:3.11
WORKDIR /var/task
COPY applications/modelling_e2e/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY datatypes/ datatypes/
COPY domain/ domain/
COPY infrastructure/ infrastructure/
COPY orchestration/ orchestration/
COPY repositories/ repositories/
COPY utilities/ utilities/
COPY harness/ harness/
# harness/console.py imports in-memory fakes from tests/orchestration/ at module
# load time; the fakes have no pytest dependency and are safe to ship.
COPY tests/__init__.py tests/__init__.py
COPY tests/orchestration/__init__.py tests/orchestration/__init__.py
COPY tests/orchestration/fakes.py tests/orchestration/fakes.py
COPY applications/ applications/
CMD ["applications.modelling_e2e.handler.handler"]

View file

@ -0,0 +1,360 @@
"""SQS-triggered Lambda: fetch EPC (or predict) → run modelling → persist plan.
One SQS message = one batch of properties sharing a portfolio, scenario, and
(by caller convention) postcode. The handler reads ``property_ids``,
``portfolio_id``, ``scenario_id``, ``no_solar``, and ``dry_run`` from the
message body, fetches or predicts each property's EPC, runs the full modelling
pipeline (SAP10 optimiser) via ``harness.console.run_modelling``, and
persists the resulting Plan via ``PostgresUnitOfWork`` in one atomic transaction
per property.
When no lodged EPC is found, EPC Prediction (Path 3, ADR-0031) synthesises one
from the postcode cohort. ``_cohort_cache`` is module-level so warm Lambda
containers re-processing the same postcode avoid redundant fetches.
``secondary_heating_removal`` is excluded unconditionally: the live ``material``
catalogue does not yet carry this measure type, causing a crash during catalogue
reads for properties with a lodged secondary heater.
DB engine is module-scoped so the connection pool is reused across warm
invocations (ADR-0012).
"""
from __future__ import annotations
import io
import os
from collections.abc import Callable
from typing import Any, Optional, cast
import boto3
import pandas as pd # pyright: ignore[reportMissingTypeStubs]
from sqlalchemy import Engine, text
from sqlmodel import Session
from datatypes.epc.domain.epc_property_data import (
BuildingPartIdentifier,
EpcPropertyData,
)
from domain.epc_prediction.comparable_properties import (
ComparableProperty,
select_comparables,
)
from domain.epc_prediction.epc_prediction import EpcPrediction
from domain.epc_prediction.prediction_target import build_prediction_target
from domain.geospatial.coordinates import Coordinates
from domain.geospatial.planning_restrictions import PlanningRestrictions
from domain.geospatial.spatial_reference import SpatialReference
from domain.modelling.measure_type import MeasureType
from domain.property.property import Property, PropertyIdentity
from domain.tasks.tasks import Source
from harness.console import run_modelling
from infrastructure.epc_client.epc_client_service import EpcClientService
from infrastructure.postgres.config import PostgresConfig
from infrastructure.postgres.engine import make_engine
from infrastructure.solar.google_solar_api_client import (
BuildingInsightsNotFoundError,
GoogleSolarApiClient,
)
from applications.modelling_e2e.modelling_e2e_trigger_body import (
ModellingE2ETriggerBody,
)
from repositories.comparable_properties.epc_comparable_properties_repository import (
EpcComparablePropertiesRepository,
)
from repositories.geospatial.geospatial_s3_repository import (
GeospatialS3Repository,
ParquetReader,
)
from repositories.postgres_unit_of_work import PostgresUnitOfWork
from repositories.product.product_postgres_repository import ProductPostgresRepository
from repositories.property.landlord_override_overlays import overlays_from
from repositories.property.override_backed_prediction_attributes_reader import (
OverrideBackedPredictionAttributesReader,
)
from repositories.property.property_overrides_postgres_reader import (
PropertyOverridesPostgresReader,
)
from repositories.scenario.scenario_postgres_repository import (
ScenarioPostgresRepository,
)
from utilities.aws_lambda.task_handler import task_handler
from utilities.logger import setup_logger
_engine: Optional[Engine] = None
_cohort_cache: dict[str, list[ComparableProperty]] = {}
logger = setup_logger()
def _get_engine() -> Engine:
global _engine
if _engine is None:
_engine = make_engine(PostgresConfig.from_env(dict(os.environ)))
return _engine
def _s3_parquet_reader() -> ParquetReader:
bucket = os.environ["DATA_BUCKET"]
def read(key: str) -> pd.DataFrame:
s3: Any = cast(
Any, boto3.client("s3")
) # pyright: ignore[reportUnknownMemberType]
raw = cast(bytes, s3.get_object(Bucket=bucket, Key=key)["Body"].read())
return pd.read_parquet(io.BytesIO(raw)) # type: ignore[return-value]
return read
def _spatial_for(
geospatial: GeospatialS3Repository, uprn: int
) -> Optional[SpatialReference]:
try:
return geospatial.spatial_for(uprn)
except Exception: # noqa: BLE001
return None
def _solar_insights_for(
solar_client: GoogleSolarApiClient, spatial: Optional[SpatialReference]
) -> Optional[dict[str, Any]]:
if spatial is None or spatial.coordinates is None:
return None
try:
return solar_client.get_building_insights(
spatial.coordinates.longitude, spatial.coordinates.latitude
)
except BuildingInsightsNotFoundError:
return None
def _predict_epc(
*,
property_id: int,
uprn: int,
postcode: str,
portfolio_id: int,
attributes_reader: OverrideBackedPredictionAttributesReader,
coordinates: Optional[Coordinates],
cohort_for: Callable[[str], list[ComparableProperty]],
predictor: EpcPrediction,
) -> Optional[EpcPropertyData]:
"""Synthesise an EpcPropertyData for an EPC-less property from its postcode
cohort (EPC Prediction Path 3, ADR-0031), or None when ineligible.
Returns None when property_type is unresolvable (hard cohort filter cannot
fire) or when the postcode cohort is empty after filtering.
"""
attributes = attributes_reader.attributes_for(property_id)
identity = PropertyIdentity(
portfolio_id=portfolio_id, postcode=postcode, address="", uprn=uprn
)
target = build_prediction_target(identity, coordinates, attributes)
if target is None:
return None
comparables = select_comparables(target, cohort_for(target.postcode))
if not comparables.members:
return None
predicted = predictor.predict(target, comparables)
if not any(
part.identifier is BuildingPartIdentifier.MAIN
for part in predicted.sap_building_parts
):
return None
return predicted
@task_handler(task_source="modelling_e2e", source=Source.PROPERTY)
def handler(body: dict[str, Any], context: Any) -> None:
trigger = ModellingE2ETriggerBody.model_validate(body)
property_ids = trigger.property_ids
portfolio_id = trigger.portfolio_id
scenario_id = trigger.scenario_id
no_solar = trigger.no_solar
dry_run = trigger.dry_run
logger.info(
f"start property_ids={property_ids} portfolio={portfolio_id} "
f"scenario={scenario_id} no_solar={no_solar} dry_run={dry_run}"
)
engine = _get_engine()
epc_client = EpcClientService(os.environ["OPEN_EPC_API_TOKEN"])
geospatial = GeospatialS3Repository(_s3_parquet_reader())
solar_client = GoogleSolarApiClient(os.environ["GOOGLE_SOLAR_API_KEY"])
with engine.connect() as conn:
uprn_rows = conn.execute(
text("SELECT id, uprn FROM property WHERE id = ANY(:ids)"),
{"ids": property_ids},
).fetchall()
postcode_rows = conn.execute(
text("SELECT id, postcode FROM property WHERE id = ANY(:ids)"),
{"ids": property_ids},
).fetchall()
uprns: dict[int, int] = {int(row[0]): int(row[1]) for row in uprn_rows}
postcodes: dict[int, str] = {int(row[0]): (row[1] or "") for row in postcode_rows}
overrides_reader = PropertyOverridesPostgresReader(lambda: Session(engine))
prediction_attrs_reader = OverrideBackedPredictionAttributesReader(overrides_reader)
comparables_repo = EpcComparablePropertiesRepository(epc_client, geospatial)
predictor = EpcPrediction()
def _get_cohort(postcode: str) -> list[ComparableProperty]:
if postcode not in _cohort_cache:
_cohort_cache[postcode] = (
comparables_repo.candidates_for(postcode) if postcode else []
)
return _cohort_cache[postcode]
read_session = Session(engine)
try:
scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0]
products = ProductPostgresRepository(read_session)
errors: list[int] = []
for property_id in property_ids:
try:
uprn = uprns[property_id]
postcode = postcodes.get(property_id, "")
logger.info(f"property={property_id} uprn={uprn} postcode={postcode!r}")
spatial = _spatial_for(geospatial, uprn)
restrictions = (
spatial.restrictions
if spatial is not None
else PlanningRestrictions()
)
coordinates: Optional[Coordinates] = (
spatial.coordinates if spatial is not None else None
)
epc: Optional[EpcPropertyData] = epc_client.get_by_uprn(uprn)
overrides = overlays_from(overrides_reader.overrides_for(property_id))
if epc is not None:
logger.info(f"property={property_id} lodged EPC found")
effective_epc = Property(
identity=PropertyIdentity(
portfolio_id=portfolio_id,
postcode=postcode,
address="",
uprn=uprn,
),
epc=epc,
landlord_overrides=overrides,
).effective_epc
else:
logger.info(
f"property={property_id} no lodged EPC — attempting prediction"
)
predicted_epc = _predict_epc(
property_id=property_id,
uprn=uprn,
postcode=postcode,
portfolio_id=portfolio_id,
attributes_reader=prediction_attrs_reader,
coordinates=coordinates,
cohort_for=_get_cohort,
predictor=predictor,
)
if predicted_epc is None:
raise ValueError(
f"no EPC for UPRN {uprn} and not predictable "
f"(unresolved property_type or empty '{postcode}' cohort)"
)
effective_epc = Property(
identity=PropertyIdentity(
portfolio_id=portfolio_id,
postcode=postcode,
address="",
uprn=uprn,
),
epc=None,
predicted_epc=predicted_epc,
landlord_overrides=overrides,
).effective_epc
solar_insights: Optional[dict[str, Any]] = (
None if no_solar else _solar_insights_for(solar_client, spatial)
)
# secondary_heating_removal is absent from the live material.type
# enum; exclude unconditionally until the catalogue gap is resolved.
# system_tune_up and system_tune_up_zoned have no active product.
considered: Optional[frozenset[MeasureType]] = (
frozenset(MeasureType)
- {MeasureType.SECONDARY_HEATING_REMOVAL}
- {MeasureType.SYSTEM_TUNE_UP_ZONED}
- {MeasureType.SYSTEM_TUNE_UP}
)
plan = run_modelling(
effective_epc,
planning_restrictions=restrictions,
solar_insights=solar_insights,
considered_measures=considered,
products=products,
scenario=scenario,
print_table=False,
)
logger.info(
f"property={property_id} modelling complete "
f"measures={len(plan.measures)}"
)
if dry_run:
measure_types = (
", ".join(m.measure_type for m in plan.measures) or "none"
)
logger.info(
f"[dry_run] property={property_id} "
f"measures=[{measure_types}] — skipping DB write"
)
continue
with PostgresUnitOfWork(lambda: Session(engine)) as uow:
if epc is not None:
uow.epc.save(
epc, property_id=property_id, portfolio_id=portfolio_id
)
if spatial is not None:
uow.spatial.save(uprn, spatial)
if (
solar_insights is not None
and spatial is not None
and spatial.coordinates is not None
):
uow.solar.save(
uprn,
longitude=spatial.coordinates.longitude,
latitude=spatial.coordinates.latitude,
insights=solar_insights,
)
uow.plan.save(
plan,
property_id=property_id,
scenario_id=scenario_id,
portfolio_id=portfolio_id,
is_default=scenario.is_default,
)
uow.property.mark_modelled(
property_id, has_recommendations=bool(plan.measures)
)
uow.commit()
logger.info(f"property={property_id} plan saved")
except Exception as error: # noqa: BLE001
logger.error(
f"property={property_id}: {type(error).__name__}: {error}",
exc_info=True,
)
errors.append(property_id)
if errors:
raise RuntimeError(f"failed property_ids: {errors}")
finally:
read_session.close()

View file

@ -0,0 +1,36 @@
# Local-test environment for the modelling_e2e Lambda.
#
# cp .env.local.example .env.local then fill in the values below.
#
# .env.local is gitignored. The container hits REAL AWS and a REAL Postgres,
# so every value here points at infrastructure that exists.
#
# Set dry_run=true in invoke_local_lambda.py to run the full pipeline without
# writing anything to the DB — safe for local testing.
#
# Keep comments on their own lines — docker-compose's env_file parser folds a
# trailing "# ..." into the value.
# --- Postgres (infrastructure/postgres/config.py -> PostgresConfig.from_env) ---
# POSTGRES_HOST <- DB_HOST, PORT <- DB_PORT, USERNAME <- DB_USERNAME,
# PASSWORD <- DB_PASSWORD, DATABASE <- DB_NAME.
POSTGRES_HOST=
POSTGRES_PORT=5432
POSTGRES_USERNAME=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=
# POSTGRES_DRIVER=psycopg2 (optional; defaults to psycopg2)
# --- Handler config (applications/modelling_e2e/handler.py) ---
# OPEN_EPC_API_TOKEN: gov.uk EPC API token (root .env: OPEN_EPC_API_TOKEN).
# GOOGLE_SOLAR_API_KEY: Google Solar API key (root .env: GOOGLE_SOLAR_API_KEY).
# DATA_BUCKET: S3 bucket holding geospatial parquet files (root .env: DATA_BUCKET).
OPEN_EPC_API_TOKEN=
GOOGLE_SOLAR_API_KEY=
DATA_BUCKET=
# --- AWS credentials for boto3 (S3 + EPC client) ---
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=eu-west-2
# AWS_SESSION_TOKEN= (only if using temporary/SSO credentials)

View file

@ -0,0 +1,9 @@
services:
modelling-e2e:
build:
context: ../../../
dockerfile: applications/modelling_e2e/Dockerfile
ports:
- "9004:8080"
env_file:
- ../../../.env

View file

@ -0,0 +1,30 @@
#!/usr/bin/env python3
import json
import requests
HOST = "localhost"
PORT = "9004"
LAMBDA_URL = f"http://{HOST}:{PORT}/2015-03-31/functions/function/invocations"
payload = {
"Records": [
{
"body": json.dumps(
{
"property_ids": [722473],
"portfolio_id": 796,
"scenario_id": 1268,
"no_solar": False,
"dry_run": False,
}
)
}
]
}
response = requests.post(LAMBDA_URL, json=payload)
print("Status code:", response.status_code)
print("Response:")
print(response.text)

View file

@ -0,0 +1,11 @@
from pydantic import BaseModel, ConfigDict
class ModellingE2ETriggerBody(BaseModel):
model_config = ConfigDict(extra="allow")
property_ids: list[int]
portfolio_id: int
scenario_id: int
no_solar: bool = False
dry_run: bool = False

View file

@ -0,0 +1,11 @@
awslambdaric
boto3
numpy==2.1.2
pandas==2.2.2
pyarrow==17.0.0
httpx
requests
pydantic
sqlalchemy==2.0.36
sqlmodel
psycopg2-binary==2.9.10

View file

@ -3296,6 +3296,16 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]:
# still fail loud at from_dict — that is correct, the data is insufficient.
d.setdefault("tenure", 0)
# Some 16.x certs (notably 16.3, cert 2628-…-2970) lodge `door_count` but
# omit `insulated_door_count` — RdSapSchema17_1 requires it. It is a door
# *refinement* (which of the lodged doors are the better-insulated type), not
# a core fabric field: an absent value means "no insulated doors recorded", so
# default 0 (the conservative RdSAP assumption — all doors take the standard
# door U-value; the engine's `insulated_share = insulated/door_count` is then
# 0). Mirrors the `tenure` default above. Certs that omit `door_count` itself
# still fail loud — that is correct, the fabric data is insufficient.
d.setdefault("insulated_door_count", 0)
# 16.2 lodges glazing in BOTH `multiple_glazing_type` (frequently the "ND"
# not-defined sentinel) AND the windows[].description. When the numeric field
# is undefined, honour an explicit "Single glazed" description so it is not

View file

@ -519,6 +519,25 @@ class TestFromSapSchema16_2:
assert epc.uprn == 100020933894
assert Sap10Calculator().calculate(epc).sap_score == 61 # lodged 56
def test_16_x_missing_insulated_door_count_defaults_to_zero(self) -> None:
# Some 16.x certs lodge `door_count` but omit `insulated_door_count`,
# which RdSapSchema17_1 requires — previously raised "missing required
# field 'insulated_door_count'", aborting the whole prediction cohort
# (EpcComparablePropertiesRepository.candidates_for maps every cohort
# cert in one list comprehension, so one unmappable cert killed the
# predict_epc path). Regression for cert 2628-3046-6233-4584-2970
# (SAP-Schema-16.3, uprn 100061905751) seen via modelling_e2e property
# 730259. The normaliser defaults the missing count to 0 — no insulated
# doors recorded, the conservative RdSAP assumption.
data = load("sap_16_3.json")
del data["insulated_door_count"]
assert "insulated_door_count" not in data
epc = EpcPropertyDataMapper.from_api_response(data)
assert isinstance(epc, EpcPropertyData)
assert epc.insulated_door_count == 0
def test_16_2_normalizer_does_not_mutate_caller_dict(self) -> None:
# Mirror _normalize_shower_outlets' contract: the caller's dict is
# untouched (deep copy), so a re-dispatch sees the original shape.

View file

@ -0,0 +1,51 @@
data "terraform_remote_state" "shared" {
backend = "s3"
config = {
bucket = "assessment-model-terraform-state"
key = "env:/${var.stage}/terraform.tfstate"
region = "eu-west-2"
}
}
data "aws_secretsmanager_secret_version" "db_credentials" {
secret_id = "${var.stage}/assessment_model/db_credentials"
}
locals {
db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string)
}
module "lambda" {
source = "../../modules/lambda_with_sqs"
name = var.lambda_name
stage = var.stage
image_uri = local.image_uri
reserved_concurrent_executions = var.reserved_concurrent_executions
batch_size = var.batch_size
maximum_concurrency = var.maximum_concurrency
timeout = 900
memory_size = 3008
environment = {
STAGE = var.stage
LOG_LEVEL = "info"
POSTGRES_USERNAME = local.db_credentials.db_assessment_model_username
POSTGRES_PASSWORD = local.db_credentials.db_assessment_model_password
POSTGRES_HOST = var.db_host
POSTGRES_DATABASE = var.db_name
POSTGRES_PORT = var.db_port
OPEN_EPC_API_TOKEN = var.open_epc_api_token
GOOGLE_SOLAR_API_KEY = var.google_solar_api_key
DATA_BUCKET = "retrofit-data-${var.stage}"
}
}
resource "aws_iam_role_policy_attachment" "modelling_e2e_s3_read" {
role = module.lambda.role_name
policy_arn = data.terraform_remote_state.shared.outputs.modelling_e2e_s3_read_arn
}

View file

@ -0,0 +1,9 @@
output "modelling_e2e_queue_url" {
value = module.lambda.queue_url
description = "URL of the modelling-e2e SQS queue (pass to trigger_modelling_e2e_sqs.py --sqs-url)"
}
output "modelling_e2e_queue_arn" {
value = module.lambda.queue_arn
description = "ARN of the modelling-e2e SQS queue"
}

View file

@ -0,0 +1,20 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
backend "s3" {
bucket = "modelling-e2e-terraform-state"
key = "terraform.tfstate"
region = "eu-west-2"
}
required_version = ">= 1.2.0"
}
provider "aws" {
region = "eu-west-2"
}

View file

@ -0,0 +1,65 @@
variable "lambda_name" {
type = string
description = "Logical name of the lambda"
}
variable "stage" {
description = "Deployment stage (e.g. dev, prod)"
type = string
}
variable "ecr_repo_url" {
type = string
description = "ECR repository URL (no tag, no digest)"
}
variable "image_digest" {
type = string
description = "Image digest (sha256:...)"
}
variable "reserved_concurrent_executions" {
type = number
default = -1
description = "Reserved concurrency for the Lambda. -1 = unreserved."
}
variable "maximum_concurrency" {
type = number
default = 2
description = "Maximum concurrent Lambda invocations from the SQS trigger."
}
variable "batch_size" {
type = number
default = 1
}
variable "db_host" {
type = string
sensitive = true
}
variable "db_name" {
type = string
sensitive = true
}
variable "db_port" {
type = string
sensitive = true
}
variable "open_epc_api_token" {
type = string
sensitive = true
}
variable "google_solar_api_key" {
type = string
sensitive = true
}
locals {
image_uri = "${var.ecr_repo_url}@${var.image_digest}"
}

View file

@ -858,3 +858,35 @@ module "sharepoint_renamer_registry" {
stage = var.stage
}
################################################
# Modelling E2E Lambda
################################################
module "modelling_e2e_state_bucket" {
source = "../modules/tf_state_bucket"
bucket_name = "modelling-e2e-terraform-state"
}
module "modelling_e2e_registry" {
source = "../modules/container_registry"
name = "modelling-e2e"
stage = var.stage
}
module "modelling_e2e_s3_read" {
source = "../modules/s3_iam_policy"
policy_name = "ModellingE2EReadS3"
policy_description = "Allow modelling-e2e Lambda to read spatial parquet from the data bucket"
bucket_arns = ["arn:aws:s3:::retrofit-data-${var.stage}"]
actions = ["s3:GetObject", "s3:ListBucket"]
resource_paths = ["/*"]
}
output "modelling_e2e_s3_read_arn" {
value = module.modelling_e2e_s3_read.policy_arn
}
output "modelling_e2e_ecr_url" {
value = module.modelling_e2e_registry.repository_url
}

View file

@ -284,6 +284,7 @@ def recommend_heating(
epc: EpcPropertyData,
products: ProductRepository,
restrictions: PlanningRestrictions = PlanningRestrictions(),
considered_measures: Optional[frozenset[MeasureType]] = None,
) -> Optional[Recommendation]:
"""Return a "Heating & Hot Water" Recommendation of competing whole-system
bundles for the dwelling, else None when no bundle is eligible. ASHP is
@ -302,7 +303,7 @@ def recommend_heating(
if boiler_option is not None:
options.append(boiler_option)
options.extend(_system_tune_up_options(epc, products))
options.extend(_system_tune_up_options(epc, products, considered_measures))
if not options:
return None
@ -310,7 +311,9 @@ def recommend_heating(
def _system_tune_up_options(
epc: EpcPropertyData, products: ProductRepository
epc: EpcPropertyData,
products: ProductRepository,
considered_measures: Optional[frozenset[MeasureType]] = None,
) -> list[MeasureOption]:
"""The system tune-up options: keep the existing wet boiler but install
better heating controls (standard 2106 and/or zone 2110, as competing
@ -338,7 +341,10 @@ def _system_tune_up_options(
),
)
)
if control_code not in _ZONE_CONTROL_CODES:
if control_code not in _ZONE_CONTROL_CODES and (
considered_measures is None
or _SYSTEM_TUNE_UP_ZONED_MEASURE_TYPE in considered_measures
):
options.append(
_tune_up_option(
epc,

View file

@ -17,6 +17,7 @@ class TaskStatus(str, Enum):
class Source(str, Enum):
PORTFOLIO = "portfolio_id"
HUBSPOT_DEAL = "hubspot_deal_id"
PROPERTY = "property_id"
@dataclass

View file

@ -331,7 +331,7 @@ def _candidate_recommendations(
MeasureType.SYSTEM_TUNE_UP,
MeasureType.SYSTEM_TUNE_UP_ZONED,
),
lambda: recommend_heating(effective_epc, products, planning_restrictions),
lambda: recommend_heating(effective_epc, products, planning_restrictions, considered_measures),
),
(
admitted(MeasureType.SECONDARY_HEATING_REMOVAL),

View file

@ -28,7 +28,7 @@ SESSION_DIR = HERE / ".elmhurst-session"
SAMPLE_DIR = (
HERE.parent.parent
/ "backend/epc_api/json_samples/real_life_examples"
/ "SAP-Schema-17.0/uprn_10090944225"
/ "RdSAP-Schema-17.1/uprn_10010215568"
)
ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105"

View file

@ -0,0 +1,105 @@
"""Enqueue one SQS message per property for the modelling_e2e Lambda.
Reads all property IDs for the given portfolio from the DB and sends a batch of
SQS messages, one per property. The Lambda then processes each message
independently, enabling concurrent modelling at scale.
Edit the CONFIG block below, then run via VSCode Run button or Jupyter.
AWS creds come from the ambient ~/.aws profile; DB creds from backend/.env.
"""
from __future__ import annotations
# --------------------------------------------------------------------------
# CONFIG — edit these before running
# ---------------------------------------------------------------------------
PORTFOLIO_ID: int = 785
SCENARIO_ID: int = 1266
SQS_URL: str = "https://sqs.eu-west-2.amazonaws.com/ACCOUNT_ID/modelling-e2e-STAGE"
# Set to a positive integer to enqueue only the first N properties (trial run).
LIMIT: int | None = 10
# True → Lambda runs the full pipeline but skips all DB writes (safe for testing).
DRY_RUN: bool = True
# True → Lambda skips the Google Solar fetch.
NO_SOLAR: bool = False
# ---------------------------------------------------------------------------
import json
import sys
from pathlib import Path
from typing import Any, cast
from uuid import uuid4
_REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(_REPO_ROOT))
import boto3 # noqa: E402
from sqlalchemy import text # noqa: E402
from scripts.e2e_common import ENV_PATH, build_engine, load_env # noqa: E402
_BATCH_SIZE = 10
def _property_ids(portfolio_id: int, limit: int | None, engine: object) -> list[int]:
from sqlalchemy.engine import Engine
assert isinstance(engine, Engine)
query = "SELECT id FROM property WHERE portfolio_id = :pid ORDER BY id"
if limit is not None:
query += f" LIMIT {int(limit)}"
with engine.connect() as conn:
rows = conn.execute(text(query), {"pid": portfolio_id}).fetchall()
return [int(r[0]) for r in rows]
def _batches(items: list[int], size: int) -> list[list[int]]:
return [items[i : i + size] for i in range(0, len(items), size)]
def main() -> None:
load_env(ENV_PATH)
engine = build_engine()
ids = _property_ids(PORTFOLIO_ID, LIMIT, engine)
if not ids:
print(f"no properties found for portfolio {PORTFOLIO_ID}")
return
print(
f"enqueuing {len(ids)} properties "
f"(portfolio={PORTFOLIO_ID}, scenario={SCENARIO_ID}, "
f"no_solar={NO_SOLAR}, dry_run={DRY_RUN}) → {SQS_URL}"
)
sqs: Any = cast(
Any, boto3.client("sqs")
) # pyright: ignore[reportUnknownMemberType]
sent = 0
for batch in _batches(ids, _BATCH_SIZE):
entries = [
{
"Id": str(uuid4()).replace("-", "")[:8] + str(i),
"MessageBody": json.dumps(
{
"property_id": [pid],
"portfolio_id": PORTFOLIO_ID,
"scenario_id": SCENARIO_ID,
"no_solar": NO_SOLAR,
"dry_run": DRY_RUN,
}
),
}
for i, pid in enumerate(batch)
]
sqs.send_message_batch(QueueUrl=SQS_URL, Entries=entries)
sent += len(batch)
print(f" sent {sent}/{len(ids)}", end="\r")
print(f"\ndone — {sent} messages enqueued")
main()

View file

@ -0,0 +1,679 @@
"""Tests for the modelling_e2e Lambda handler.
Tests exercise the handler's external behaviour through handler.__wrapped__,
patching I/O boundaries (EPC client, DB reads, UoW) so no real DB or network
is needed. One test per distinct behaviour path.
"""
from __future__ import annotations
from contextlib import ExitStack
from typing import Any
from unittest.mock import MagicMock, call, patch
import pytest
from pydantic import ValidationError
from applications.modelling_e2e.modelling_e2e_trigger_body import (
ModellingE2ETriggerBody,
)
PROPERTY_ID = 12345
UPRN = 987654321
POSTCODE = "SW1A 1AA"
PORTFOLIO_ID = 100
SCENARIO_ID = 200
_ENV = {
"OPEN_EPC_API_TOKEN": "test-token",
"DATA_BUCKET": "test-bucket",
"GOOGLE_SOLAR_API_KEY": "test-solar-key",
}
_BODY = {
"property_ids": [PROPERTY_ID],
"portfolio_id": PORTFOLIO_ID,
"scenario_id": SCENARIO_ID,
"no_solar": True,
"dry_run": False,
}
def _call_handler(body: dict[str, Any]) -> Any:
from applications.modelling_e2e.handler import handler
return handler.__wrapped__(body, None) # type: ignore[attr-defined]
def _engine_mock(
property_ids: list[int],
uprns: list[int],
postcodes: list[str],
) -> MagicMock:
"""Mock engine whose connect() returns UPRN then postcode rows."""
mock_engine = MagicMock()
mock_conn = mock_engine.connect.return_value.__enter__.return_value
uprn_result = MagicMock()
uprn_result.fetchall.return_value = list(zip(property_ids, uprns))
postcode_result = MagicMock()
postcode_result.fetchall.return_value = list(zip(property_ids, postcodes))
mock_conn.execute.side_effect = [uprn_result, postcode_result]
return mock_engine
def _plan_mock() -> MagicMock:
plan = MagicMock()
plan.measures = []
plan.cost_of_works = 0.0
return plan
# ---------------------------------------------------------------------------
# Fixture: clear module-level cohort cache between tests
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _clear_cohort_cache() -> None:
import applications.modelling_e2e.handler as h
h._cohort_cache.clear()
# ---------------------------------------------------------------------------
# Trigger body validation
# ---------------------------------------------------------------------------
def test_trigger_body_requires_property_ids() -> None:
"""property_ids (plural) must be provided and must be a list of ints."""
# Arrange
body = {
"property_ids": [1, 2],
"portfolio_id": PORTFOLIO_ID,
"scenario_id": SCENARIO_ID,
}
# Act
result = ModellingE2ETriggerBody.model_validate(body)
# Assert
assert result.property_ids == [1, 2]
def test_trigger_body_rejects_missing_property_ids() -> None:
with pytest.raises(ValidationError):
ModellingE2ETriggerBody.model_validate(
{"portfolio_id": PORTFOLIO_ID, "scenario_id": SCENARIO_ID}
)
# ---------------------------------------------------------------------------
# Lodged EPC path
# ---------------------------------------------------------------------------
def test_lodged_epc_path_saves_epc_plan_and_marks_modelled() -> None:
"""When get_by_uprn returns an EPC the handler saves it, saves the plan,
and marks the property as modelled all inside one UoW per property."""
# Arrange
mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE])
mock_epc = MagicMock()
mock_plan = _plan_mock()
mock_uow = MagicMock()
with ExitStack() as stack:
stack.enter_context(
patch("applications.modelling_e2e.handler.os.environ", _ENV)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._get_engine",
return_value=mock_engine,
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcClientService")
).return_value.get_by_uprn.return_value = mock_epc
stack.enter_context(
patch("applications.modelling_e2e.handler.GeospatialS3Repository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.GoogleSolarApiClient")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._spatial_for", return_value=None
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._solar_insights_for",
return_value=None,
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.overlays_from", return_value=[]
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.ScenarioPostgresRepository")
).return_value.get_many.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ProductPostgresRepository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.Session")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.run_modelling",
return_value=mock_plan,
)
)
MockUoW = stack.enter_context(
patch("applications.modelling_e2e.handler.PostgresUnitOfWork")
)
MockUoW.return_value.__enter__.return_value = mock_uow
MockUoW.return_value.__exit__.return_value = False
# Act
_call_handler(_BODY)
# Assert — EPC saved (lodged path), plan saved, property marked modelled
mock_uow.epc.save.assert_called_once_with(
mock_epc, property_id=PROPERTY_ID, portfolio_id=PORTFOLIO_ID
)
mock_uow.plan.save.assert_called_once()
mock_uow.property.mark_modelled.assert_called_once_with(
PROPERTY_ID, has_recommendations=False
)
mock_uow.commit.assert_called_once()
# ---------------------------------------------------------------------------
# EPC Prediction path
# ---------------------------------------------------------------------------
def test_prediction_path_saves_plan_without_epc_save() -> None:
"""When get_by_uprn returns None the handler synthesises an EPC via
prediction and saves the plan but never calls epc.save."""
# Arrange
mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE])
mock_plan = _plan_mock()
mock_uow = MagicMock()
mock_predicted_epc = MagicMock()
# _predict_epc checks for a MAIN building part
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
mock_part = MagicMock()
mock_part.identifier = BuildingPartIdentifier.MAIN
mock_predicted_epc.sap_building_parts = [mock_part]
mock_comparables = MagicMock()
mock_comparables.members = [MagicMock()] # non-empty cohort
with ExitStack() as stack:
stack.enter_context(
patch("applications.modelling_e2e.handler.os.environ", _ENV)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._get_engine",
return_value=mock_engine,
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcClientService")
).return_value.get_by_uprn.return_value = None # no lodged EPC
stack.enter_context(
patch("applications.modelling_e2e.handler.GeospatialS3Repository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.GoogleSolarApiClient")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._spatial_for", return_value=None
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._solar_insights_for",
return_value=None,
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.overlays_from", return_value=[]
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader")
)
# Prediction infrastructure
from domain.epc_prediction.prediction_target import PredictionTargetAttributes
stack.enter_context(
patch(
"applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader"
)
).return_value.attributes_for.return_value = PredictionTargetAttributes(
property_type="2"
)
stack.enter_context(
patch("applications.modelling_e2e.handler.select_comparables")
).return_value = mock_comparables
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcPrediction")
).return_value.predict.return_value = mock_predicted_epc
stack.enter_context(
patch(
"applications.modelling_e2e.handler.EpcComparablePropertiesRepository"
)
).return_value.candidates_for.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ScenarioPostgresRepository")
).return_value.get_many.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ProductPostgresRepository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.Session")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.run_modelling",
return_value=mock_plan,
)
)
MockUoW = stack.enter_context(
patch("applications.modelling_e2e.handler.PostgresUnitOfWork")
)
MockUoW.return_value.__enter__.return_value = mock_uow
MockUoW.return_value.__exit__.return_value = False
# Act
_call_handler(_BODY)
# Assert — epc.save NOT called (no lodged cert), plan IS saved
mock_uow.epc.save.assert_not_called()
mock_uow.plan.save.assert_called_once()
mock_uow.commit.assert_called_once()
# ---------------------------------------------------------------------------
# Prediction gate-out (empty cohort)
# ---------------------------------------------------------------------------
def test_empty_cohort_gates_property_out_and_raises() -> None:
"""When candidates_for returns an empty list the property cannot be
predicted; the handler records it as an error and raises RuntimeError."""
# Arrange
mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE])
empty_comparables = MagicMock()
empty_comparables.members = [] # empty cohort → gate-out
with ExitStack() as stack:
stack.enter_context(
patch("applications.modelling_e2e.handler.os.environ", _ENV)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._get_engine",
return_value=mock_engine,
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcClientService")
).return_value.get_by_uprn.return_value = None
stack.enter_context(
patch("applications.modelling_e2e.handler.GeospatialS3Repository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.GoogleSolarApiClient")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._spatial_for", return_value=None
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.overlays_from", return_value=[]
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader")
)
from domain.epc_prediction.prediction_target import PredictionTargetAttributes
stack.enter_context(
patch(
"applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader"
)
).return_value.attributes_for.return_value = PredictionTargetAttributes(
property_type="2"
)
stack.enter_context(
patch("applications.modelling_e2e.handler.select_comparables")
).return_value = empty_comparables
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcPrediction")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.EpcComparablePropertiesRepository"
)
).return_value.candidates_for.return_value = []
stack.enter_context(
patch("applications.modelling_e2e.handler.ScenarioPostgresRepository")
).return_value.get_many.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ProductPostgresRepository")
)
stack.enter_context(patch("applications.modelling_e2e.handler.Session"))
MockUoW = stack.enter_context(
patch("applications.modelling_e2e.handler.PostgresUnitOfWork")
)
# Act
with pytest.raises(RuntimeError, match=str(PROPERTY_ID)):
_call_handler(_BODY)
# UoW never entered — the property errored before the write block
MockUoW.return_value.__enter__.assert_not_called()
# ---------------------------------------------------------------------------
# Partial batch failure
# ---------------------------------------------------------------------------
def test_partial_batch_failure_raises_runtime_error_listing_failed_ids() -> None:
"""Two properties: property 1 succeeds, property 2 raises during modelling.
Handler raises RuntimeError naming only the failed property; property 1's
UoW was committed."""
# Arrange
pid1, pid2 = 111, 222
mock_engine = _engine_mock([pid1, pid2], [1001, 1002], [POSTCODE, POSTCODE])
mock_plan = _plan_mock()
mock_uow = MagicMock()
def _run_modelling_side_effect(*args: Any, **kwargs: Any) -> Any:
# Fail on second call (pid2)
if not hasattr(_run_modelling_side_effect, "_calls"):
_run_modelling_side_effect._calls = 0 # type: ignore[attr-defined]
_run_modelling_side_effect._calls += 1 # type: ignore[attr-defined]
if _run_modelling_side_effect._calls == 2: # type: ignore[attr-defined]
raise ValueError("modelling exploded")
return mock_plan
with ExitStack() as stack:
stack.enter_context(
patch("applications.modelling_e2e.handler.os.environ", _ENV)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._get_engine",
return_value=mock_engine,
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcClientService")
).return_value.get_by_uprn.return_value = MagicMock() # lodged EPC
stack.enter_context(
patch("applications.modelling_e2e.handler.GeospatialS3Repository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.GoogleSolarApiClient")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._spatial_for", return_value=None
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._solar_insights_for",
return_value=None,
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.overlays_from", return_value=[]
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.ScenarioPostgresRepository")
).return_value.get_many.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ProductPostgresRepository")
)
stack.enter_context(patch("applications.modelling_e2e.handler.Session"))
stack.enter_context(
patch(
"applications.modelling_e2e.handler.run_modelling",
side_effect=_run_modelling_side_effect,
)
)
MockUoW = stack.enter_context(
patch("applications.modelling_e2e.handler.PostgresUnitOfWork")
)
MockUoW.return_value.__enter__.return_value = mock_uow
MockUoW.return_value.__exit__.return_value = False
# Act
with pytest.raises(RuntimeError, match=str(pid2)):
_call_handler(
{
"property_ids": [pid1, pid2],
"portfolio_id": PORTFOLIO_ID,
"scenario_id": SCENARIO_ID,
"no_solar": True,
"dry_run": False,
}
)
# Property 1 succeeded — its UoW was committed
mock_uow.commit.assert_called_once()
# ---------------------------------------------------------------------------
# Cohort cache hit
# ---------------------------------------------------------------------------
def test_cohort_cache_prevents_duplicate_candidates_for_calls() -> None:
"""Two properties sharing a postcode: candidates_for is fetched once and
cached EpcComparablePropertiesRepository.candidates_for called once."""
# Arrange
pid1, pid2 = 301, 302
mock_engine = _engine_mock(
[pid1, pid2], [3001, 3002], [POSTCODE, POSTCODE]
)
mock_plan = _plan_mock()
mock_uow = MagicMock()
mock_predicted_epc = MagicMock()
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
mock_part = MagicMock()
mock_part.identifier = BuildingPartIdentifier.MAIN
mock_predicted_epc.sap_building_parts = [mock_part]
mock_comparables = MagicMock()
mock_comparables.members = [MagicMock()]
with ExitStack() as stack:
stack.enter_context(
patch("applications.modelling_e2e.handler.os.environ", _ENV)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._get_engine",
return_value=mock_engine,
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcClientService")
).return_value.get_by_uprn.return_value = None # force prediction path
stack.enter_context(
patch("applications.modelling_e2e.handler.GeospatialS3Repository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.GoogleSolarApiClient")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._spatial_for", return_value=None
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.overlays_from", return_value=[]
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader")
)
from domain.epc_prediction.prediction_target import PredictionTargetAttributes
stack.enter_context(
patch(
"applications.modelling_e2e.handler.OverrideBackedPredictionAttributesReader"
)
).return_value.attributes_for.return_value = PredictionTargetAttributes(
property_type="2"
)
stack.enter_context(
patch("applications.modelling_e2e.handler.select_comparables")
).return_value = mock_comparables
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcPrediction")
).return_value.predict.return_value = mock_predicted_epc
MockCandidates = stack.enter_context(
patch(
"applications.modelling_e2e.handler.EpcComparablePropertiesRepository"
)
)
MockCandidates.return_value.candidates_for.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ScenarioPostgresRepository")
).return_value.get_many.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ProductPostgresRepository")
)
stack.enter_context(patch("applications.modelling_e2e.handler.Session"))
stack.enter_context(
patch(
"applications.modelling_e2e.handler.run_modelling",
return_value=mock_plan,
)
)
MockUoW = stack.enter_context(
patch("applications.modelling_e2e.handler.PostgresUnitOfWork")
)
MockUoW.return_value.__enter__.return_value = mock_uow
MockUoW.return_value.__exit__.return_value = False
# Act
_call_handler(
{
"property_ids": [pid1, pid2],
"portfolio_id": PORTFOLIO_ID,
"scenario_id": SCENARIO_ID,
"no_solar": True,
"dry_run": False,
}
)
# Assert — cohort fetched exactly once despite two properties
MockCandidates.return_value.candidates_for.assert_called_once_with(POSTCODE)
# ---------------------------------------------------------------------------
# Dry-run
# ---------------------------------------------------------------------------
def test_dry_run_skips_all_db_writes() -> None:
"""dry_run=True: run_modelling executes but PostgresUnitOfWork is never
entered no DB writes occur for any property in the batch."""
# Arrange
mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE])
with ExitStack() as stack:
stack.enter_context(
patch("applications.modelling_e2e.handler.os.environ", _ENV)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._get_engine",
return_value=mock_engine,
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.EpcClientService")
).return_value.get_by_uprn.return_value = MagicMock()
stack.enter_context(
patch("applications.modelling_e2e.handler.GeospatialS3Repository")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.GoogleSolarApiClient")
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._spatial_for", return_value=None
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler._solar_insights_for",
return_value=None,
)
)
stack.enter_context(
patch(
"applications.modelling_e2e.handler.overlays_from", return_value=[]
)
)
stack.enter_context(
patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader")
)
stack.enter_context(
patch("applications.modelling_e2e.handler.ScenarioPostgresRepository")
).return_value.get_many.return_value = [MagicMock()]
stack.enter_context(
patch("applications.modelling_e2e.handler.ProductPostgresRepository")
)
stack.enter_context(patch("applications.modelling_e2e.handler.Session"))
stack.enter_context(
patch(
"applications.modelling_e2e.handler.run_modelling",
return_value=_plan_mock(),
)
)
MockUoW = stack.enter_context(
patch("applications.modelling_e2e.handler.PostgresUnitOfWork")
)
# Act
_call_handler({**_BODY, "dry_run": True})
# Assert — UoW never entered
MockUoW.return_value.__enter__.assert_not_called()

View file

@ -557,6 +557,30 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = (
cert_num="2298-4036-7306-3186-4930",
sap_score=81,
),
# UPRN 10010215568 → cert 9978-7098-7226-2633-3994. RdSAP-Schema-17.1 — NATIVE
# RdSAP, END-TERRACE BUNGALOW (single-storey), band J (2003-2006), cavity wall
# as-built insulated (assumed), pitched 200 mm loft, solid insulated floor,
# mains-gas REGULAR boiler (PCDB 15100 Vaillant Ecotec plus 415) + cylinder
# (size 2 = Normal/110 L, factory foam 25 mm, thermostat), control 2106 (CBE
# programmer + room thermostat + TRVs), water from primary, party wall 9.8 m,
# double glazed, natural ventilation, 86% LED, TFA 75.46. Lodged 74; engine 75.
# Built in Elmhurst RdSAP10 on the lodged inputs (evidence saved:
# elmhurst_summary.pdf / elmhurst_worksheet.pdf): worksheet SAP 73. Engine on
# Elmhurst's own parsed inputs = 74 ≈ worksheet 73 → calculator faithful (~1).
# The cylinder insulation thickness (25 mm) IS carried by the RdSAP-17.1 mapper
# (cylinder_insulation_thickness_mm=25) — no mapper gap here (cf. the RdSAP-17.0
# cylinder-thickness drop on uprn_68151071). The residual engine 75 vs Elmhurst
# 73 (+2) is the documented engine-uses-lodged vs Elmhurst-RdSAP-default gap:
# Elmhurst defaulted the cavity wall thickness to 250 mm (lodged 310 mm) and
# number of baths to 2 (lodged 1, lifting Elmhurst's HW demand). Engine 75 ≈
# lodged 74 (+1) is the validated anchor. PINNED to the observed engine 75 —
# mapping deliberately untuned.
RealCertExpectation(
schema="RdSAP-Schema-17.1",
sample="uprn_10010215568",
cert_num="9978-7098-7226-2633-3994",
sap_score=75,
),
)

View file

View file

View file

@ -5,6 +5,7 @@ TaskOrchestrator.create_task_with_subtask(...) + run_subtask(...).
"""
import json
import logging
from contextlib import AbstractContextManager
from functools import wraps
from typing import Any, Callable, Optional, cast
@ -13,6 +14,8 @@ from utilities.aws_lambda.default_orchestrator import default_orchestrator
from domain.tasks.tasks import Source
from orchestration.task_orchestrator import TaskOrchestrator
logger = logging.getLogger(__name__)
OrchestratorCM = Callable[[], AbstractContextManager[TaskOrchestrator]]
@ -39,7 +42,7 @@ def task_handler(
@wraps(func)
def wrapper(event: dict[str, Any], context: Any) -> Any:
with factory() as orchestrator:
results: list[Any] = []
task_ids: list[dict[str, str]] = []
failures: list[dict[str, Any]] = []
for record in _records(event):
@ -49,20 +52,27 @@ def task_handler(
str(raw_source_id) if raw_source_id is not None else None
)
_, subtask = orchestrator.create_task_with_subtask(
task, subtask = orchestrator.create_task_with_subtask(
task_source=task_source,
inputs=body,
source=source,
source_id=source_id,
)
task_ids.append(
{"task_id": str(task.id), "subtask_id": str(subtask.id)}
)
try:
result = orchestrator.run_subtask(
orchestrator.run_subtask(
subtask.id,
work=lambda body=body: func(body, context),
)
results.append(result)
except Exception:
logger.exception(
"subtask failed (task_source=%s source_id=%s)",
task_source,
source_id,
)
if "Records" in event:
message_id = record.get("messageId", "")
failures.append({"itemIdentifier": message_id})
@ -70,8 +80,8 @@ def task_handler(
raise
if "Records" in event:
return {"batchItemFailures": failures}
return results
return {"batchItemFailures": failures, "tasks": task_ids}
return task_ids
return wrapper