mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
TEMPORARY guard (remove once the SAP calculator's oil-heating under-score is fixed): a predicted oil-boiler picture scores SAP 13/G against its own synthesised recorded SAP of 50/E, so the optimiser overshoots goal C all the way to band A and publishes nonsense. A predicted EpcPropertyData carries its recorded SAP (energy_rating_current). When the calculator baseline diverges from it by more than ~one band (20 SAP points), withhold the Plan: raise inside the per-property loop so the existing failure isolation drops just that property into `failures` and fails the subtask, while every other property still models and persists. Lodged Properties are untouched — they have a real recorded cert and the Rebaseliner already owns this check. Verified end-to-end against property 713406 (UPRN 100061849247): baseline 13.2 vs recorded 50 -> quarantined, no Plan written. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
549 lines
24 KiB
Python
549 lines
24 KiB
Python
"""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.
|
||
|
||
All Measure Types are considered: pricing goes through
|
||
``catalogue_with_off_catalogue_overrides`` so the measures the live ``material``
|
||
catalogue cannot supply (``secondary_heating_removal``, the glazing and heating
|
||
gaps) are priced from the committed off-catalogue overlay instead of crashing.
|
||
|
||
DB engine is module-scoped so the connection pool is reused across warm
|
||
invocations (ADR-0012).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import io
|
||
import json
|
||
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 (
|
||
PredictionTarget,
|
||
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.property.property import Property, PropertyIdentity
|
||
from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner
|
||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||
from domain.tasks.tasks import Source
|
||
from harness.console import run_modelling
|
||
from orchestration.property_baseline_orchestrator import (
|
||
PropertyBaselineOrchestrator,
|
||
)
|
||
from infrastructure.epc_client.epc_client_service import EpcClientService
|
||
from infrastructure.postcodes_io.postcodes_io_client import PostcodesIoClient
|
||
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,
|
||
SkippedCohortCert,
|
||
)
|
||
from repositories.geospatial.geospatial_s3_repository import (
|
||
GeospatialS3Repository,
|
||
ParquetReader,
|
||
)
|
||
from repositories.fuel_rates.fuel_rates_static_file_repository import (
|
||
FuelRatesStaticFileRepository,
|
||
)
|
||
from repositories.postgres_unit_of_work import PostgresUnitOfWork
|
||
from repositories.product.composite_product_repository import (
|
||
catalogue_with_off_catalogue_overrides,
|
||
)
|
||
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 repositories.solar.solar_postgres_repository import SolarPostgresRepository
|
||
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]] = {}
|
||
# Broadened (nearby-postcode) cohorts, keyed by (seed postcode, target property
|
||
# type): the early-stop walk depends on the type it is filling for, so two types
|
||
# in the same postcode must not share a cached result.
|
||
_nearby_cohort_cache: dict[tuple[str, str], list[ComparableProperty]] = {}
|
||
|
||
logger = setup_logger()
|
||
|
||
|
||
def _get_engine() -> Engine:
|
||
global _engine
|
||
if _engine is None:
|
||
config = PostgresConfig.from_env(dict(os.environ))
|
||
# Reduced pool for Lambda: 32 concurrent containers × 3 connections = 96 max,
|
||
# vs the default 3+5=8 which would reach 256+ and exhaust RDS max_connections.
|
||
# pool_size=2 covers the simultaneous read_session + UoW session per invocation.
|
||
_engine = make_engine(dataclasses.replace(config, pool_size=2, max_overflow=1))
|
||
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 _dedupe_skipped(
|
||
skipped: list[SkippedCohortCert],
|
||
) -> list[SkippedCohortCert]:
|
||
"""First occurrence of each skipped cert number (the same cert can appear in
|
||
more than one postcode cohort across a batch)."""
|
||
seen: set[str] = set()
|
||
unique: list[SkippedCohortCert] = []
|
||
for cert in skipped:
|
||
if cert.certificate_number not in seen:
|
||
seen.add(cert.certificate_number)
|
||
unique.append(cert)
|
||
return unique
|
||
|
||
|
||
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]],
|
||
broaden: Callable[[PredictionTarget], 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.
|
||
|
||
When the property's own postcode holds no same-type comparables (a sparse
|
||
postcode — e.g. the only flat among houses), the cohort is broadened to the
|
||
real unit postcodes physically nearest it (``broaden``) before giving up.
|
||
|
||
Returns None when property_type is unresolvable (hard cohort filter cannot
|
||
fire) or when even the broadened 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:
|
||
comparables = select_comparables(target, broaden(target))
|
||
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
|
||
|
||
|
||
# --- TEMPORARY GUARD: remove once the SAP calculator's oil-heating under-score
|
||
# is fixed (predicted oil-boiler picture scores SAP 13/G vs a recorded 50/E). ---
|
||
# A predicted EpcPropertyData carries its own recorded SAP (energy_rating_current,
|
||
# synthesised from the cohort). When the calculator's baseline score contradicts
|
||
# that by more than ~one EPC band the picture is being mis-scored, so any Plan
|
||
# built on it overshoots (e.g. goal C lands at band A). Quarantine the property —
|
||
# skip its Plan — rather than ship nonsense. Lodged properties are unaffected:
|
||
# they have a real recorded cert and the Rebaseliner already owns this check.
|
||
_PREDICTED_BASELINE_DIVERGENCE_GUARD = 20.0 # SAP points (~one EPC band)
|
||
|
||
|
||
class ImplausiblePredictedBaseline(Exception):
|
||
"""A predicted Property's calculator baseline contradicts its recorded SAP by
|
||
more than a band — the calculator is mis-scoring the synthesised picture, so
|
||
the Plan is untrustworthy and is withheld (caught per-property as a failure)."""
|
||
|
||
|
||
def _predicted_baseline_is_implausible(
|
||
baseline_sap: float, recorded_sap: Optional[int]
|
||
) -> bool:
|
||
"""True when a predicted Property's calculator baseline diverges from the
|
||
picture's own recorded SAP by more than the guard band. A missing recorded
|
||
SAP (no reference) is never implausible — the guard only fires on a concrete
|
||
contradiction."""
|
||
if recorded_sap is None:
|
||
return False
|
||
return abs(baseline_sap - recorded_sap) > _PREDICTED_BASELINE_DIVERGENCE_GUARD
|
||
|
||
|
||
@task_handler(task_source="modelling_e2e", source=Source.PROPERTY)
|
||
def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]:
|
||
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, nearby_postcodes=PostcodesIoClient()
|
||
)
|
||
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]
|
||
|
||
def _broaden(target: PredictionTarget) -> list[ComparableProperty]:
|
||
"""The nearby-postcode cohort for a gated-out target — the real unit
|
||
postcodes nearest it, walked until enough same-type comparables surface
|
||
(ADR-0034). Memoised per (postcode, property_type) so co-located
|
||
same-type misses share one walk."""
|
||
key = (target.postcode, target.property_type)
|
||
if key not in _nearby_cohort_cache:
|
||
_nearby_cohort_cache[key] = (
|
||
comparables_repo.candidates_near(
|
||
target.postcode,
|
||
target.coordinates,
|
||
enough=lambda c: c.epc.property_type == target.property_type,
|
||
)
|
||
if target.postcode
|
||
else []
|
||
)
|
||
return _nearby_cohort_cache[key]
|
||
|
||
# Re-establishes each lodged Property's Baseline Performance from the just-
|
||
# persisted EPC (one UoW per property, committed after the Plan's). Predicted
|
||
# Properties have no lodged figures, so they get no baseline (mirrors the e2e
|
||
# runner and the ara_first_run Baseline stage).
|
||
baseline_orchestrator = PropertyBaselineOrchestrator(
|
||
unit_of_work=lambda: PostgresUnitOfWork(lambda: Session(engine)),
|
||
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
|
||
fuel_rates=FuelRatesStaticFileRepository(),
|
||
)
|
||
|
||
read_session = Session(engine)
|
||
try:
|
||
scenario = ScenarioPostgresRepository(read_session).get_many([scenario_id])[0]
|
||
products = catalogue_with_off_catalogue_overrides(read_session)
|
||
solar_reader = SolarPostgresRepository(read_session)
|
||
|
||
failures: list[dict[str, Any]] = []
|
||
|
||
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))
|
||
predicted_epc: Optional[EpcPropertyData] = None
|
||
|
||
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,
|
||
broaden=_broaden,
|
||
predictor=predictor,
|
||
)
|
||
if predicted_epc is None:
|
||
raise ValueError(
|
||
f"no EPC for UPRN {uprn} and not predictable "
|
||
f"(unresolved property_type, or no same-type "
|
||
f"comparables in or near '{postcode}')"
|
||
)
|
||
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
|
||
|
||
# Read-before-fetch: the Google Solar call is paid, so skip it
|
||
# when this UPRN's insights are already persisted. Only a cache
|
||
# miss hits Google — re-runs cost nothing for solar.
|
||
solar_insights: Optional[dict[str, Any]]
|
||
solar_was_fetched = False
|
||
if no_solar:
|
||
solar_insights = None
|
||
else:
|
||
solar_insights = solar_reader.get(uprn)
|
||
if solar_insights is None:
|
||
solar_insights = _solar_insights_for(solar_client, spatial)
|
||
solar_was_fetched = solar_insights is not None
|
||
|
||
# All Measure Types are considered: the off-catalogue overlay
|
||
# (catalogue_with_off_catalogue_overrides) prices the measures the
|
||
# live material catalogue cannot supply, so none need excluding.
|
||
plan = run_modelling(
|
||
effective_epc,
|
||
planning_restrictions=restrictions,
|
||
solar_insights=solar_insights,
|
||
considered_measures=None,
|
||
products=products,
|
||
scenario=scenario,
|
||
print_table=False,
|
||
)
|
||
logger.info(
|
||
f"property={property_id} modelling complete "
|
||
f"measures={len(plan.measures)}"
|
||
)
|
||
|
||
# Quarantine a predicted Property whose calculator baseline
|
||
# contradicts its synthesised recorded SAP (TEMPORARY guard —
|
||
# see _predicted_baseline_is_implausible). Raising drops this one
|
||
# property into `failures` and skips its Plan/Baseline; the rest
|
||
# of the batch is unaffected.
|
||
if predicted_epc is not None and _predicted_baseline_is_implausible(
|
||
plan.baseline.sap_continuous, effective_epc.energy_rating_current
|
||
):
|
||
raise ImplausiblePredictedBaseline(
|
||
f"property={property_id}: predicted baseline SAP "
|
||
f"{plan.baseline.sap_continuous:.1f} diverges from the "
|
||
f"picture's recorded SAP {effective_epc.energy_rating_current} "
|
||
f"by > {_PREDICTED_BASELINE_DIVERGENCE_GUARD:.0f} points — "
|
||
f"likely a calculator mis-score; withholding the plan"
|
||
)
|
||
|
||
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
|
||
)
|
||
elif predicted_epc is not None:
|
||
# Persist the synthesised EPC in the predicted slot (ADR-0031),
|
||
# so the Baseline stage can re-hydrate it and downstream sees
|
||
# the picture the Plan was modelled from.
|
||
uow.epc.save(
|
||
predicted_epc,
|
||
property_id=property_id,
|
||
portfolio_id=portfolio_id,
|
||
source="predicted",
|
||
)
|
||
if spatial is not None:
|
||
uow.spatial.save(uprn, spatial)
|
||
if (
|
||
solar_was_fetched
|
||
and 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")
|
||
|
||
# Baseline Performance is re-established from the persisted EPC
|
||
# (lodged or predicted), so it runs after the Plan UoW commits. By
|
||
# here the property always has a persisted EPC — a property that
|
||
# could be neither fetched nor predicted raised earlier.
|
||
baseline_orchestrator.run([property_id])
|
||
logger.info(f"property={property_id} baseline saved")
|
||
|
||
except Exception as error: # noqa: BLE001
|
||
logger.error(
|
||
f"property={property_id} uprn={uprns.get(property_id)}: "
|
||
f"{type(error).__name__}: {error}",
|
||
exc_info=True,
|
||
)
|
||
failures.append(
|
||
{
|
||
"property_id": property_id,
|
||
"uprn": uprns.get(property_id),
|
||
"error_type": type(error).__name__,
|
||
"error": str(error),
|
||
}
|
||
)
|
||
|
||
# Cohort certs the mapper could not consume were skipped (not aborted on)
|
||
# so prediction could proceed; surface them — with cert numbers — in the
|
||
# subtask outputs so the mapper gaps can be closed later.
|
||
skipped_certs: list[dict[str, str]] = [
|
||
{"certificate_number": s.certificate_number, "error": s.error}
|
||
for s in _dedupe_skipped(comparables_repo.skipped)
|
||
]
|
||
if skipped_certs:
|
||
logger.info(
|
||
f"skipped {len(skipped_certs)} unmappable cohort cert(s): "
|
||
f"{[s['certificate_number'] for s in skipped_certs]}"
|
||
)
|
||
|
||
# A property that errored AND a cohort cert the mapper could not consume
|
||
# are both surfaced as failures, so the subtask is marked failed and
|
||
# shows up for debugging. The whole batch has already run by this point —
|
||
# every property that could be modelled was written to DB above — so
|
||
# failing here flags the run without discarding the work done so far.
|
||
if failures or skipped_certs:
|
||
parts: list[str] = []
|
||
if failures:
|
||
failed_ids = [f["property_id"] for f in failures]
|
||
# Persisted verbatim into the subtask's outputs.error (via
|
||
# SubTask.fail): include each property's error type + message,
|
||
# not just the IDs, so failed runs are diagnosable without
|
||
# cross-referencing CloudWatch.
|
||
parts.append(
|
||
f"failed property_ids: {failed_ids}; "
|
||
f"details: {json.dumps(failures)}"
|
||
)
|
||
if skipped_certs:
|
||
parts.append(
|
||
f"skipped_unmappable_cohort_certs: {json.dumps(skipped_certs)}"
|
||
)
|
||
raise RuntimeError("; ".join(parts))
|
||
|
||
return None
|
||
finally:
|
||
read_session.close()
|