diff --git a/CONTEXT.md b/CONTEXT.md index 259096bfd..b21958fbb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -105,12 +105,34 @@ _Avoid_: document category, file kind, doc class **Download Package**: The single ZIP archive a **Bulk Document Download** produces: one folder per property (named by human-readable address, enriched from the hubspot deals data, with `landlord_property_id` appended for uniqueness), each folder holding the latest file of each **Document Type** held for that property. Built best-effort — properties/documents that don't resolve are skipped and listed in the run's `sub_task.outputs`, not failed (ADR-0060). Streamed to a dedicated `DOCUMENT_EXPORTS_BUCKET` (never held whole in memory — a package can be several GB); delivered as a 60-minute presigned URL. -_Avoid_: export (that is the plan/scenario XLSX export), bundle, archive (ambiguous), zip (the format, not the concept) +_Avoid_: export (that is the **Scenario Export**), bundle, archive (ambiguous), zip (the format, not the concept) **Bulk Document Download**: The feature/job that assembles a **Download Package** for a chosen set of properties (the union of one or more whole HubSpot projects, selected by `project_code`, and a hand-picked `landlord_property_id` list) and emails the requester a link. Runs on the app-owned-task + attach-mode lane (ADR-0055): the FastAPI route resolves the selection to a distinct `landlord_property_id` set — project codes are expanded via `hubspot_deal_data` (which holds the project↔property grain), hand-picked ids are taken as given — pins that set and the recipient email onto the `sub_task`, and the `applications/bulk_document_download` Lambda builds the package, writes the URL to `sub_task.outputs`, and emails it (ADR-0059/0060). The Lambda matches files by `hubspot_deal_id`, bridging each selected `landlord_property_id` through `hubspot_deal_data` (`uploaded_files.landlord_property_id` is unpopulated in practice, so the property identity comes from the deal bridge); coverage is limited to deal-id-linked sources. Selection is capped by property count at the trigger. _Avoid_: document export, bulk export, file dump +**Scenario Export** (canonical short form: **Export**): +The single branded `.xlsx` a user pulls from the front end to get a portfolio's +**modelling results** — property information plus each Property's **Plan** under +one or more **Scenarios** — with a link emailed to the requester. It is +**scenario-scoped**: the rows for a Scenario are the Properties that have a +**Plan** under it, so a Property with no Plan for that Scenario is absent (model +A, ADR-0065); a filter or a hand-picked id list only narrows that set. One +workbook holds **one sheet per selected Scenario** (`scenario_ids`), the same +filtered Property set rendered per Scenario — so sheets may legitimately differ +in row count. Property selection reuses the **Modelling Run**'s filter contract +(`PropertyGroupFilters` + `resolve_filtered_property_ids`, ADR-0056); an explicit +`property_ids` list is an **override** of the filters, not a co-filter. Built +**best-effort** from **persisted** data only (no live EPC calls), reading each +Property's **Effective EPC** (so `property_overrides` win over the lodged cert), +against a frozen 21-measure column contract. Runs on the app-owned-task + +attach-mode lane and is delivered by presigned URL + email exactly like a +**Download Package** (ADR-0055/0059), but its payload is plan/scenario data, not +survey documents. Distinct from a **Download Package** (the ZIP of survey +documents) and from a **Modelling Run** (which *produces* the Plans an Export +reads). +_Avoid_: data export / bulk export (ambiguous), download (that is the **Download Package**), report, dump, plan export (it is scenario-scoped, not per-Plan) + ### Source data **Site Notes**: diff --git a/applications/ara_export/__init__.py b/applications/ara_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/applications/ara_export/ara_export_trigger_body.py b/applications/ara_export/ara_export_trigger_body.py new file mode 100644 index 000000000..cf12f13be --- /dev/null +++ b/applications/ara_export/ara_export_trigger_body.py @@ -0,0 +1,19 @@ +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class AraExportTriggerBody(BaseModel): + """SQS trigger for the ara_export Lambda (ADR-0055/0065). + + Attach mode: the FastAPI route created the Task + SubTask and pinned the + recipe (``portfolio_id``, ``scenario_ids``, ``property_ids``, + ``recipient_email``, ``export_name``) onto the SubTask's ``inputs``. The + message therefore carries only the identifiers — the (potentially large) + property set never travels through the 256 KB SQS body. + """ + + model_config = ConfigDict(extra="allow") + + task_id: UUID + subtask_id: UUID diff --git a/applications/ara_export/handler.py b/applications/ara_export/handler.py new file mode 100644 index 000000000..b94170caf --- /dev/null +++ b/applications/ara_export/handler.py @@ -0,0 +1,106 @@ +"""ara_export Lambda (ADR-0065). + +Attach-mode (ADR-0055) handler. The FastAPI route created the app-owned Task + +SubTask and pinned the recipe onto the SubTask's ``inputs``; this reads that +recipe, builds the sheet-per-scenario branded workbook through the orchestrator, +and returns the result (presigned URL + counts), which ``TaskOrchestrator`` +records on ``sub_task.outputs``. A selection that yields no rows raises +``SubTaskFailure`` from the orchestrator — recorded, not retried. + +PostgresConfig-only (POSTGRES_*), like the bulk_document_download Lambda. The +workbook is written to the dedicated ``DOCUMENT_EXPORTS_BUCKET``; SES SMTP +settings arrive as env vars. +""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +import boto3 + +from applications.ara_export.ara_export_trigger_body import AraExportTriggerBody +from domain.tasks.tasks import Source +from infrastructure.email.ses_smtp_email_sender import SesSmtpEmailSender +from infrastructure.postgres.config import PostgresConfig +from infrastructure.postgres.engine import make_engine, make_session +from infrastructure.s3.s3_client import S3Client +from orchestration.scenario_export_orchestrator import ScenarioExportOrchestrator +from repositories.scenario_export.scenario_export_postgres_repository import ( + ScenarioExportPostgresRepository, +) +from repositories.scenario_export.scenario_names_postgres_repository import ( + ScenarioNamesPostgresRepository, +) +from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository +from utilities.aws_lambda.task_handler import task_handler + +logging.getLogger().setLevel(logging.INFO) +logger = logging.getLogger(__name__) + +# The export link is valid for 60 minutes (ADR-0065). +_URL_TTL_SECONDS = 3600 + + +def _email_sender() -> SesSmtpEmailSender: + return SesSmtpEmailSender( + host=os.environ["SES_SMTP_HOST"], + port=int(os.environ["SES_SMTP_PORT"]), + username=os.environ["SES_SMTP_USERNAME"], + password=os.environ["SES_SMTP_PASSWORD"], + from_address=os.environ["SES_SMTP_FROM_ADDRESS"], + ) + + +@task_handler(task_source="ara_export", source=Source.PORTFOLIO) +def handler(body: dict[str, Any], context: Any) -> dict[str, Any]: + trigger = AraExportTriggerBody.model_validate(body) + engine = make_engine(PostgresConfig.from_env(os.environ)) + session = make_session(engine) + try: + subtask = SubTaskPostgresRepository(session).get(trigger.subtask_id) + recipe: dict[str, Any] = subtask.inputs or {} + portfolio_id = int(recipe["portfolio_id"]) + scenario_ids: list[int] = [int(x) for x in recipe["scenario_ids"]] + property_ids: list[int] = [int(x) for x in recipe["property_ids"]] + recipient_email = str(recipe["recipient_email"]) + export_name = str(recipe["export_name"]) + # ADR-0065 plan selection: "scenario" (default) or "default" (each + # home's is_default Plan). Absent on pre-existing recipes → "scenario". + plan_selection = str(recipe.get("plan_selection") or "scenario") + + logger.info( + "ara_export: starting %s export for %d properties across %d " + "scenario(s) (subtask=%s)", + plan_selection, + len(property_ids), + len(scenario_ids), + trigger.subtask_id, + ) + + boto_s3: Any = boto3.client("s3") # pyright: ignore[reportUnknownMemberType] + orchestrator = ScenarioExportOrchestrator( + rows=ScenarioExportPostgresRepository(session), + scenarios=ScenarioNamesPostgresRepository(session), + exports=S3Client(boto_s3, os.environ["DOCUMENT_EXPORTS_BUCKET"]), + email=_email_sender(), + url_ttl_seconds=_URL_TTL_SECONDS, + ) + result = orchestrator.run( + portfolio_id=portfolio_id, + scenario_ids=scenario_ids, + property_ids=property_ids, + recipient_email=recipient_email, + export_name=export_name, + plan_selection=plan_selection, + ) + finally: + session.close() + + return { + "presigned_url": result.presigned_url, + "export_s3_key": result.export_s3_key, + "sheet_count": result.sheet_count, + "row_count": result.row_count, + } diff --git a/backend/app/config.py b/backend/app/config.py index 8f0d551f1..c5ec42948 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -47,6 +47,7 @@ class Settings(BaseSettings): FINALISER_SQS_URL: str = "changeme" MODELLING_E2E_SQS_URL: str = "changeme" BULK_DOCUMENT_DOWNLOAD_SQS_URL: str = "changeme" + ARA_EXPORT_SQS_URL: str = "changeme" # Third parties EPC_AUTH_TOKEN: str = "changeme" diff --git a/backend/app/exports/__init__.py b/backend/app/exports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/exports/export_tasks.py b/backend/app/exports/export_tasks.py new file mode 100644 index 000000000..c323058e7 --- /dev/null +++ b/backend/app/exports/export_tasks.py @@ -0,0 +1,104 @@ +"""The Scenario Export route's view of the app-owned task's sub_task (ADR-0065). + +Parameterised SQL rather than the SQLModel mirrors: importing the +``infrastructure.postgres`` mirror of ``sub_task`` into the FastAPI process +double-registers the table and crashes at import (the same constraint the +Modelling Run distributor and the bulk-download route work around). +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from typing import Any, cast +from uuid import UUID + +from sqlalchemy import text +from sqlmodel import Session + +from backend.app.modelling.property_filters import ( + PropertyGroupFilters, + resolve_filtered_property_ids, +) + + +class ScenarioExportTasks: + def __init__(self, session: Session) -> None: + self._session = session + + def read_selection_config(self, task_id: UUID) -> dict[str, Any]: + """The task's selection recipe, read from the FE-owned ``task.inputs`` + JSON (ADR-0065) — ``{portfolio_id, scenario_ids, filters?, property_ids?, + recipient_email?}``. Empty dict when the task has no inputs.""" + row = ( + self._session.connection() + .execute( + text("SELECT inputs FROM tasks WHERE id = :task_id"), + {"task_id": task_id}, + ) + .first() + ) + if row is None or row[0] is None: + return {} + parsed: Any = json.loads(row[0]) + return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {} + + def already_distributed(self, task_id: UUID) -> bool: + """Whether the task already has a sub_task — the 409 guard against a + double-submit re-triggering the same export.""" + row = ( + self._session.connection() + .execute( + text("SELECT 1 FROM sub_task WHERE task_id = :task_id LIMIT 1"), + {"task_id": task_id}, + ) + .first() + ) + return row is not None + + def resolve_property_ids( + self, + *, + portfolio_id: int, + filters: PropertyGroupFilters, + property_ids: list[int], + ) -> list[int]: + """Resolve the selection to the distinct internal ``property.id`` set the + export is built from (ADR-0065). An explicit ``property_ids`` list is an + **override**, not a co-filter: when present it is used as given; + otherwise the ``filters`` are resolved against the portfolio through the + shared Modelling Run resolver (ADR-0056).""" + if property_ids: + return sorted(set(property_ids)) + resolved = resolve_filtered_property_ids( + self._session, portfolio_id, filters + ) + return sorted({filtered.property_id for filtered in resolved}) + + def create_export_subtask( + self, task_id: UUID, subtask_id: UUID, recipe: dict[str, Any] + ) -> bool: + """Pre-create the single ``waiting`` sub_task under the app-owned task, + pinning the recipe (resolved ``property_ids``, ``scenario_ids``, + ``recipient_email``, ``export_name``) onto its inputs (ADR-0055/0065). + + The insert is conditional on the task having no sub_task yet, so the DB + arbitrates a double-submit race. Returns whether this call created it.""" + now = datetime.now(timezone.utc) + result = self._session.connection().execute( + text( + "INSERT INTO sub_task (id, task_id, status, inputs, updated_at)" + " SELECT :id, :task_id, 'waiting', :inputs, :updated_at" + " WHERE NOT EXISTS (" + " SELECT 1 FROM sub_task WHERE task_id = :task_id" + " )" + ), + { + "id": subtask_id, + "task_id": task_id, + "inputs": json.dumps(recipe), + "updated_at": now, + }, + ) + self._session.commit() + return result.rowcount == 1 diff --git a/backend/app/exports/router.py b/backend/app/exports/router.py new file mode 100644 index 000000000..a9ec9afdd --- /dev/null +++ b/backend/app/exports/router.py @@ -0,0 +1,188 @@ +"""Scenario Export trigger: POST /v1/exports/scenario (ADR-0065). + +Resolves the authenticated requester's email, reads the FE-written selection +recipe from ``task.inputs``, resolves + caps the property selection (an explicit +``property_ids`` list overrides the filters; otherwise the shared Modelling Run +filter resolver runs), pins the resolved recipe onto a single pre-created +sub_task under the app-owned task, and enqueues one message to the ara_export +worker. Never builds the workbook synchronously — the worker emails the link. +""" + +import json +from collections.abc import Callable, Iterator +from typing import Any, cast +from uuid import uuid4 + +import boto3 +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from backend.app.config import get_settings +from backend.app.db.connection import db_engine +from backend.app.dependencies import validate_jwt_token, validate_token +from backend.app.exports.export_tasks import ScenarioExportTasks +from backend.app.exports.schemas import ScenarioExportRequest +from backend.app.modelling.property_filters import PropertyGroupFilters + +# The selection cap (ADR-0065) — rejected at the trigger so an oversized export +# never reaches the worker. Configurable ceiling; the JSON-column mechanism has +# no limit of its own. +MAX_PROPERTIES = 100_000 + +MessageSender = Callable[[str], None] + + +def get_session() -> Iterator[Session]: + with Session(db_engine) as session: + yield session + + +def get_message_sender() -> MessageSender: + settings = get_settings() + client: Any = cast(Any, boto3.client("sqs", settings.AWS_DEFAULT_REGION)) # pyright: ignore[reportUnknownMemberType] + queue_url = settings.ARA_EXPORT_SQS_URL + + def send(body: str) -> None: + client.send_message(QueueUrl=queue_url, MessageBody=body) + + return send + + +def get_requesting_user_email(user: Any = Depends(validate_jwt_token)) -> str: + """The authenticated requester's email — the export recipient (ADR-0059).""" + email: Any = getattr(user, "email", None) + if email is None and isinstance(user, dict): + email = cast(dict[str, Any], user).get("email") + if not email and get_settings().ENVIRONMENT == "local": + email = "local-dev@example.com" + if not email: + raise HTTPException( + status_code=400, + detail="Could not determine the requesting user's email address.", + ) + return str(email) + + +router = APIRouter( + prefix="/exports", + tags=["exports"], + dependencies=[Depends(validate_token)], +) + + +@router.post("/scenario", status_code=202) +async def scenario_export( + body: ScenarioExportRequest, + session: Session = Depends(get_session), + send_message: MessageSender = Depends(get_message_sender), + recipient_email: str = Depends(get_requesting_user_email), +) -> dict[str, str]: + tasks = ScenarioExportTasks(session) + if tasks.already_distributed(body.task_id): + raise HTTPException( + status_code=409, + detail=( + f"Task {body.task_id} already has a sub_task — an export has " + "already been started for it." + ), + ) + + config = tasks.read_selection_config(body.task_id) + portfolio_id: Any = config.get("portfolio_id") + raw_scenario_ids: Any = config.get("scenario_ids") or [] + raw_property_ids: Any = config.get("property_ids") or [] + raw_filters: Any = config.get("filters") or {} + # ADR-0065 plan selection: "scenario" (per-Scenario sheets, the default) or + # "default" (one sheet of each home's is_default Plan). Absent → "scenario". + plan_selection: Any = config.get("plan_selection") or "scenario" + + if not isinstance(portfolio_id, int): + raise HTTPException( + status_code=400, detail="task.inputs must provide an integer portfolio_id." + ) + if plan_selection not in ("scenario", "default"): + raise HTTPException( + status_code=400, + detail='task.inputs plan_selection must be "scenario" or "default".', + ) + # scenario_ids is required only for a per-Scenario export; the default-plan + # selection is scenario-independent (one default Plan per home). + if plan_selection == "scenario": + if not _is_int_list(raw_scenario_ids) or not raw_scenario_ids: + raise HTTPException( + status_code=400, + detail="task.inputs scenario_ids must be a non-empty list of " + "integers for a scenario export.", + ) + elif raw_scenario_ids and not _is_int_list(raw_scenario_ids): + raise HTTPException( + status_code=400, + detail="task.inputs scenario_ids, if provided, must be integers.", + ) + if not _is_int_list(raw_property_ids): + raise HTTPException( + status_code=400, + detail="task.inputs property_ids must be a list of integers.", + ) + if not isinstance(raw_filters, dict): + raise HTTPException( + status_code=400, detail="task.inputs filters must be an object." + ) + + scenario_ids: list[int] = cast(list[int], raw_scenario_ids) + property_ids: list[int] = cast(list[int], raw_property_ids) + try: + filters = PropertyGroupFilters(**cast(dict[str, Any], raw_filters)) + except Exception as exc: # pragma: no cover - pydantic validation detail + raise HTTPException( + status_code=400, detail=f"task.inputs filters are invalid: {exc}" + ) from exc + + resolved_property_ids = tasks.resolve_property_ids( + portfolio_id=portfolio_id, filters=filters, property_ids=property_ids + ) + if not resolved_property_ids: + raise HTTPException( + status_code=400, + detail="The selection resolves to no properties.", + ) + if len(resolved_property_ids) > MAX_PROPERTIES: + raise HTTPException( + status_code=400, + detail=( + f"The selection has {len(resolved_property_ids)} properties, over " + f"the {MAX_PROPERTIES} limit — narrow your selection." + ), + ) + + subtask_id = uuid4() + recipe = { + "portfolio_id": portfolio_id, + "scenario_ids": scenario_ids, + "property_ids": resolved_property_ids, + "recipient_email": recipient_email, + "export_name": f"portfolio-{portfolio_id}-{body.task_id}", + "plan_selection": plan_selection, + } + created = tasks.create_export_subtask(body.task_id, subtask_id, recipe) + if not created: + raise HTTPException( + status_code=409, + detail=( + f"Task {body.task_id} already has a sub_task — an export has " + "already been started for it." + ), + ) + + send_message( + json.dumps({"task_id": str(body.task_id), "subtask_id": str(subtask_id)}) + ) + return {"message": "Scenario export started"} + + +def _is_int_list(value: Any) -> bool: + # bool is an int subclass; exclude it so a stray True/False isn't a valid id. + return isinstance(value, list) and all( + isinstance(item, int) and not isinstance(item, bool) + for item in cast(list[Any], value) + ) diff --git a/backend/app/exports/schemas.py b/backend/app/exports/schemas.py new file mode 100644 index 000000000..bd3554d07 --- /dev/null +++ b/backend/app/exports/schemas.py @@ -0,0 +1,12 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class ScenarioExportRequest(BaseModel): + """The Scenario Export trigger body (ADR-0065). Carries only the app-owned + ``task_id``: the FE wrote the selection recipe (portfolio, scenarios, + filters, optional property_ids) into ``task.inputs``, so the (potentially + large) selection never travels in the HTTP body.""" + + task_id: UUID diff --git a/backend/app/main.py b/backend/app/main.py index 270917f63..2060d0604 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from backend.app.plan import router as plan_router from backend.app.tasks import router as tasks_router from backend.app.bulk_uploads import router as bulk_uploads_router from backend.app.documents import router as documents_router +from backend.app.exports import router as exports_router from backend.app.modelling import router as modelling_router from backend.app.dependencies import validate_api_key from backend.app.config import get_settings @@ -67,6 +68,7 @@ app.include_router(whlg_router.router, prefix="/v1") app.include_router(bulk_uploads_router.router, prefix="/v1") app.include_router(modelling_router.router, prefix="/v1") app.include_router(documents_router.router, prefix="/v1") +app.include_router(exports_router.router, prefix="/v1") if get_settings().ENVIRONMENT == "local": from backend.app.local import router as local_router diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b506995c4..a487f10de 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1,5 +1,6 @@ import copy import logging +import math import re from dataclasses import replace from datetime import date @@ -212,6 +213,35 @@ def _sap_opening_area_m2(width: Any, height: Any) -> float: ) +def _normalised_main_heating_fraction( + raw: Optional[Union[int, float]], + all_fractions: Sequence[Optional[Union[int, float]]], +) -> Optional[int]: + """Return `raw` as a canonical PERCENT (RdSAP 10 item 7-5, spec p.81). + + The gov-EPC API lodges a two-main dwelling's `main_heating_fraction` pair in + two incompatible encodings — a DECIMAL fraction summing to ~1.0 + (`[0.8, 0.2]`) or an integer PERCENT summing to ~100 (`[80, 20]`) — and every + consumer divides by 100 unconditionally. A decimal pair therefore collapses + the second system's share to ~1% of its true value, billing almost the whole + load to the first system's fuel and efficiency (#1666). + + Discriminate on the pair sum: a genuine two-main split summing nearer 1.0 + than 100 is decimal-encoded and scaled to percent; a percent-encoded pair + (sum ~100) and any single main are left exactly as lodged, so no + percent-encoded cert regresses. Returned as an int to match the domain + invariant (`MainHeatingDetail.main_heating_fraction` is a 0-100 percent). + """ + if raw is None: + return None + present = [float(f) for f in all_fractions if f is not None] + if len(present) >= 2: + pair_sum = sum(present) + if abs(pair_sum - 1.0) < abs(pair_sum - 100.0): + return round(float(raw) * 100.0) + return round(float(raw)) + + def _pv_array_field(array: Any, name: str) -> Any: """Read a PV-array field from either a `PhotovoltaicArray` dataclass (21.0.x, where the schema Union parsed the nested list) or a raw dict (older schemas @@ -753,7 +783,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -1385,7 +1421,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=None, main_heating_data_source=d.main_heating_data_source, @@ -1573,7 +1615,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -1799,7 +1847,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -2052,7 +2106,13 @@ class EpcPropertyDataMapper: main_heating_number=d.main_heating_number, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -2285,7 +2345,13 @@ class EpcPropertyDataMapper: boiler_ignition_type=d.boiler_ignition_type, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -2634,7 +2700,13 @@ class EpcPropertyDataMapper: boiler_ignition_type=d.boiler_ignition_type, main_heating_control=d.main_heating_control, main_heating_category=d.main_heating_category, - main_heating_fraction=d.main_heating_fraction, + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [ + x.main_heating_fraction + for x in schema.sap_heating.main_heating_details + ], + ), sap_main_heating_code=d.sap_main_heating_code, central_heating_pump_age=d.central_heating_pump_age, main_heating_data_source=d.main_heating_data_source, @@ -3130,13 +3202,16 @@ class EpcPropertyDataMapper: ) return None - return _clear_basement_flag_when_system_built( - _with_renewable_heat_incentive( - _with_recorded_performance( - _drop_building_parts_without_age_band(mapped), data - ), - data, - ) + return _with_internal_dimensions( + _clear_basement_flag_when_system_built( + _with_renewable_heat_incentive( + _with_recorded_performance( + _drop_building_parts_without_age_band(mapped), data + ), + data, + ) + ), + data, ) @@ -3323,10 +3398,12 @@ def _sap_17_1_heating(schema: SapSchema17_1) -> SapHeating: main_heating_index_number=d.main_heating_index_number, main_heating_number=d.main_heating_number, main_heating_category=d.main_heating_category, - main_heating_fraction=( - int(d.main_heating_fraction) - if d.main_heating_fraction is not None - else None + # #1666: normalise the encoding (and fix the old `int()` floor + # that silently deleted a decimal-lodged second main, e.g. + # int(0.35) -> 0) via the shared pair-sum discriminator. + main_heating_fraction=_normalised_main_heating_fraction( + d.main_heating_fraction, + [x.main_heating_fraction for x in sh.main_heating_details], ), main_heating_data_source=d.main_heating_data_source, ) @@ -3351,6 +3428,181 @@ def _sap_back_solved_habitable_rooms(schema: SapSchema17_1) -> int: ) +# --------------------------------------------------------------------------- +# RdSAP 10 §3.4 conversion of external to internal dimensions (#1669) +# --------------------------------------------------------------------------- + +# `measurement_type` lodged value: 1 = internal dimensions, 2 = external. +_EXTERNAL_MEASUREMENT: Final[int] = 2 + + +def _table_3_row(*vals: int) -> Dict[str, int]: + """Expand a Table 3 wall-thickness row's 10 spec columns to the 13 age + bands: bands I/J/K share the 9th column and L/M share the 10th.""" + a, b, c, d, e, f, g, h, ijk, lm = vals + return { + "A": a, "B": b, "C": c, "D": d, "E": e, "F": f, "G": g, "H": h, + "I": ijk, "J": ijk, "K": ijk, "L": lm, "M": lm, + } + + +# RdSAP 10 Table 3 (PDF p.19): default main-dwelling wall thickness (mm) by wall +# construction row and age band, used only when the thickness is not lodged. +_TABLE_3_WALL_THICKNESS_MM: Final[Dict[str, Dict[str, int]]] = { + "solid": _table_3_row(220, 220, 220, 220, 240, 250, 270, 270, 300, 300), + "cavity": _table_3_row(250, 250, 250, 250, 250, 260, 270, 270, 300, 300), + "timber": _table_3_row(150, 150, 150, 250, 270, 270, 270, 270, 300, 300), + "cob": _table_3_row(540, 540, 540, 540, 540, 540, 560, 560, 590, 590), + "system": _table_3_row(250, 250, 250, 250, 250, 300, 300, 300, 300, 300), +} +# `wall_construction` code → Table 3 row. Table 3 has no stone row (stone is +# normally measured); stone (1/2) and any non-masonry/unknown fall back to the +# solid-masonry column. +_WALL_CONSTRUCTION_TO_TABLE_3_ROW: Final[Dict[int, str]] = { + 1: "solid", 2: "solid", 3: "solid", # stone/granite, sandstone, solid brick + 4: "cavity", 5: "timber", 6: "system", 7: "cob", +} + + +def _main_wall_thickness_m(epc: EpcPropertyData) -> Optional[float]: + """The main dwelling's wall thickness in metres (RdSAP 10 §3.4 `w`): the + lodged value if present, else the Table 3 default for the main part's wall + construction + age band. None when neither is derivable.""" + parts = epc.sap_building_parts + if not parts: + return None + main = parts[0] + if main.wall_thickness_mm is not None: + return main.wall_thickness_mm / 1000.0 + construction = main.wall_construction + row = ( + _WALL_CONSTRUCTION_TO_TABLE_3_ROW.get(construction, "solid") + if isinstance(construction, int) + else "solid" + ) + band = (main.construction_age_band or "").strip().upper()[:1] + mm = _TABLE_3_WALL_THICKNESS_MM[row].get(band) + return mm / 1000.0 if mm is not None else None + + +def _table_2_ratios( + built_form: Optional[str], area_ext: float, perim_ext: float, w: float +) -> tuple[float, float]: + """RdSAP 10 Table 2 (PDF p.18) whole-dwelling external→internal + (area_ratio, perimeter_ratio) by built form (1 detached, 2 semi, 3 + end-terrace, 4 mid-terrace, 5 enclosed end-terrace, 6 enclosed mid-terrace). + `area_ext`/`perim_ext` are the ground-floor FOOTPRINT (§3.4 Note 4: one ratio + for the whole dwelling). Returns (1, 1) — no conversion — for an unrecognised + form or a degenerate footprint.""" + if area_ext <= 0.0 or perim_ext <= 0.0: + return (1.0, 1.0) + bf = (built_form or "").strip() + if bf == "1": # detached + perim_int = perim_ext - 8.0 * w + area_int = area_ext - w * perim_int - 4.0 * w * w + elif bf in ("2", "3"): # semi-detached / end-terrace + if perim_ext**2 >= 8.0 * area_ext: + perim_int = perim_ext - 5.0 * w + a = 0.5 * (perim_ext - math.sqrt(perim_ext**2 - 8.0 * area_ext)) + area_int = area_ext - w * (perim_ext + 0.5 * a) + 3.0 * w * w + else: + perim_int = perim_ext - 3.0 * w + area_int = area_ext - w * perim_ext + 3.0 * w * w + elif bf == "4": # mid-terrace + perim_int = perim_ext - 2.0 * w + area_int = area_ext - w * (perim_ext + 2.0 * area_ext / perim_ext) + 2.0 * w * w + elif bf == "5": # enclosed end-terrace + perim_int = perim_ext - 3.0 * w + area_int = area_ext - 1.5 * w * perim_ext + 2.25 * w * w + elif bf == "6": # enclosed mid-terrace + perim_int = perim_ext - w + area_int = area_ext - w * (area_ext / perim_ext + 1.5 * perim_ext) + 1.5 * w * w + else: + return (1.0, 1.0) + if area_int <= 0.0 or perim_int <= 0.0: + return (1.0, 1.0) + return (area_int / area_ext, perim_int / perim_ext) + + +def _domain_total_floor_area_m2(building_parts: List[SapBuildingPart]) -> float: + """Sum the domain floor-dimension areas (plus each part's always-internal + room-in-roof floor area) — used to refresh `total_floor_area_m2` after a + §3.4 conversion, since `water_heating_from_cert` reads that scalar for + occupancy N.""" + total = 0.0 + for bp in building_parts: + for fd in bp.sap_floor_dimensions: + total += fd.total_floor_area_m2 or 0.0 + rir = bp.sap_room_in_roof + if rir is not None and rir.floor_area: + total += rir.floor_area + return total + + +def _with_internal_dimensions( + epc: EpcPropertyData, data: Dict[str, Any] +) -> EpcPropertyData: + """Plumb the lodged `measurement_type` onto `epc` and, when the horizontal + dimensions were measured externally (== 2), convert them to internal + dimensions per RdSAP 10 §3.4 + Table 2 before they reach the cascade (#1669). + + §3.4 Note 4: ONE whole-dwelling area/perimeter ratio (from the ground-floor + footprint) is applied to every storey of every part — not a per-storey + conversion, which over-corrects multi-part mid-terraces. Room-in-roof floor + area is always measured internally (§3.1) and is left untouched; party wall + lengths are reduced by 2w (Table 2 Note 5). Internal certs are unchanged. + + Scope: the conversion is applied only to the RdSAP-Schema reduced-data family + (17.0-21.x), where per-storey external dimensions are the lodgement standard + and the fix is corpus-validated (RdSAP-21.0.1). `measurement_type` is still + plumbed for every schema, but the legacy SAP-Schema-15.0/16.x certs — whose + per-storey areas do not reconcile to the lodged TFA — are left unconverted.""" + epc.measurement_type = data.get("measurement_type") + schema_type = data.get("schema_type", "") + if ( + epc.measurement_type != _EXTERNAL_MEASUREMENT + or not isinstance(schema_type, str) + or not schema_type.startswith("RdSAP-Schema-") + or not epc.sap_building_parts + ): + return epc + w = _main_wall_thickness_m(epc) + if w is None: + return epc + ground_levels = [ + fd.floor + for bp in epc.sap_building_parts + for fd in bp.sap_floor_dimensions + if fd.floor is not None + ] + if not ground_levels: + return epc + ground = min(ground_levels) + area_ext = sum( + fd.total_floor_area_m2 or 0.0 + for bp in epc.sap_building_parts + for fd in bp.sap_floor_dimensions + if fd.floor == ground + ) + perim_ext = sum( + fd.heat_loss_perimeter_m or 0.0 + for bp in epc.sap_building_parts + for fd in bp.sap_floor_dimensions + if fd.floor == ground + ) + area_ratio, perim_ratio = _table_2_ratios(epc.built_form, area_ext, perim_ext, w) + if area_ratio == 1.0 and perim_ratio == 1.0: + return epc + for bp in epc.sap_building_parts: + for fd in bp.sap_floor_dimensions: + fd.total_floor_area_m2 *= area_ratio + fd.heat_loss_perimeter_m *= perim_ratio + if fd.party_wall_length_m: + fd.party_wall_length_m = max(0.0, fd.party_wall_length_m - 2.0 * w) + epc.total_floor_area_m2 = _domain_total_floor_area_m2(epc.sap_building_parts) + return epc + + def _with_recorded_performance( epc: EpcPropertyData, data: Dict[str, Any] ) -> EpcPropertyData: @@ -5269,24 +5521,67 @@ _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: Dict[ } +# RdSAP 10 Table 24 METAL-frame U-column (PDF p.50-51) — #1667. Only the U +# differs from the PVC/wooden column above (the glazing g is frame-independent); +# the frame factor becomes 0.8 (SAP 10.2 Table 6c) instead of 0.70. Secondary +# glazing (types 4/11/12) has no separate metal U — its combined U is +# frame-independent — so it is absent here and keeps the PVC-column U. Values +# mirror `u_window`'s metal column (`rdsap_uvalues.py`): single 5.7; DG pre-2002 +# 6/12/16 = 3.7/3.4/3.3; triple pre-2002 6/12/16 = 2.9/2.6/2.5; DG/triple 2002+ +# = 2.2; 2022+ = 1.6. +_API_GLAZING_TYPE_TO_METAL_U: Dict[int, float] = { + 1: 3.4, 3: 3.4, 7: 3.4, # DG pre-2002 / unknown-date, 12mm default + 2: 2.2, 9: 2.2, # DG (2) / triple (9) 2002+ (pre-2022) + 13: 1.6, 14: 1.6, # DG / DG-or-triple 2022+ + 5: 5.7, 15: 5.7, # single glazing + 6: 2.6, 8: 2.6, 10: 2.6, # triple pre-2002 / unknown / known, 12mm default +} +_API_GLAZING_TYPE_GAP_TO_METAL_U: Dict[tuple[int, object], float] = { + (1, 6): 3.7, (1, 12): 3.4, (1, "16+"): 3.3, + (3, 6): 3.7, (3, 12): 3.4, (3, "16+"): 3.3, + (7, 6): 3.7, (7, 12): 3.4, (7, "16+"): 3.3, + (6, 6): 2.9, (6, 12): 2.6, (6, "16+"): 2.5, + (8, 6): 2.9, (8, 12): 2.6, (8, "16+"): 2.5, + (10, 6): 2.9, (10, 12): 2.6, (10, "16+"): 2.5, +} + +# SAP 10.2 Table 6c window frame factor: 0.8 for metal, 0.70 for PVC/wooden. +_METAL_FRAME_FACTOR: Final[float] = 0.8 + + def _api_glazing_transmission( glazing_type: Optional[int], glazing_gap: object, + pvc_frame: object = None, ) -> Optional[tuple[float, float, float]]: """Resolve (U, g, frame_factor) for an API window from RdSAP 10 Table 24. Per-gap override takes precedence over the type-only default. Returns None only when `glazing_type` is absent (None → cascade default). A glazing_type PRESENT but unmapped raises `UnmappedApiCode` rather than silently routing the window to the u_window all-None default U=2.5 — the forcing function - that surfaced single-glazing (code 5 → 4.8) instead of letting it hide.""" + that surfaced single-glazing (code 5 → 4.8) instead of letting it hide. + + `pvc_frame` is the lodged RdSAP input 6-5 boolean (spec p.80): "true" = + wooden/PVC (grouped in Table 24), "false" = metal. Only an explicit "false" + routes the window to the Table 24 metal U-column + FF 0.8 (#1667); "true", + None or any other value keeps the PVC/wooden column + FF 0.70, so no + PVC-lodged cert regresses.""" if glazing_type is None: return None gap_key = (glazing_type, glazing_gap) if gap_key in _API_GLAZING_TYPE_GAP_TO_TRANSMISSION: - return _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] - if glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: - return _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] - raise UnmappedApiCode("glazing_type", glazing_type) + base = _API_GLAZING_TYPE_GAP_TO_TRANSMISSION[gap_key] + elif glazing_type in _API_GLAZING_TYPE_TO_TRANSMISSION: + base = _API_GLAZING_TYPE_TO_TRANSMISSION[glazing_type] + else: + raise UnmappedApiCode("glazing_type", glazing_type) + if pvc_frame != "false": + return base + u_pvc, g_perp, _pvc_ff = base + metal_u = _API_GLAZING_TYPE_GAP_TO_METAL_U.get( + gap_key, _API_GLAZING_TYPE_TO_METAL_U.get(glazing_type, u_pvc) + ) + return (metal_u, g_perp, _METAL_FRAME_FACTOR) # GOV.UK RdSAP 21 `glazing_type` integer → SAP 10.2 Table 6b cascade @@ -5405,7 +5700,7 @@ def _api_sap_roof_window(w: Any) -> SapRoofWindow: Table 6e Note 2 inclination adjustment (+0.30 W/m²K at 45° pitch) to the inclined-position value the worksheet bills on (27a). Mirror of the site-notes `_map_elmhurst_roof_window`.""" - transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame) vertical_u = transmission[0] if transmission is not None else 2.0 g_perp = transmission[1] if transmission is not None else 0.76 frame_factor = w.frame_factor @@ -5475,7 +5770,7 @@ def _api_sap_window(w: Any) -> SapWindow: to the SAP 10.2 Table 6b cascade enum so the cascade's daylight g_L lookup picks the spec-correct value (e.g. RdSAP 21 code 1 = DG pre-2002, cascade g_L=0.80, not single-glazed 0.90).""" - transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap) + transmission = _api_glazing_transmission(w.glazing_type, w.glazing_gap, w.pvc_frame) frame_factor: Optional[float] = w.frame_factor if frame_factor is None and transmission is not None: frame_factor = transmission[2] diff --git a/deployment/terraform/lambda/ara_export/main.tf b/deployment/terraform/lambda/ara_export/main.tf new file mode 100644 index 000000000..04978e69e --- /dev/null +++ b/deployment/terraform/lambda/ara_export/main.tf @@ -0,0 +1,84 @@ +############################################ +# ara_export Lambda (ADR-0065) — builds one branded sheet-per-scenario .xlsx +# from persisted modelling data, uploads it to the exports bucket, and emails a +# presigned link. Reads only the DB (no source-bucket reads, unlike +# bulk_document_download), so the role needs only exports-bucket write/presign. +############################################ +data "aws_secretsmanager_secret_version" "db_credentials" { + secret_id = "${var.stage}/assessment_model/db_credentials" +} + +data "aws_secretsmanager_secret_version" "ses_smtp" { + secret_id = "${var.stage}/ses/smtp_credentials" +} + +locals { + db_credentials = jsondecode(data.aws_secretsmanager_secret_version.db_credentials.secret_string) + ses_smtp = jsondecode(data.aws_secretsmanager_secret_version.ses_smtp.secret_string) + + document_exports_bucket = "retrofit-document-exports-${var.stage}" +} + +############################################ +# Lambda + SQS queue + trigger +############################################ +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 + # Normal-mode openpyxl holds the workbook in memory (ADR-0065); 4 GB covers the + # 100k-property ceiling. Raise here (then switch to write_only) if it OOMs. + memory_size = 4096 + + 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 + + DOCUMENT_EXPORTS_BUCKET = local.document_exports_bucket + + # SES SMTP — IAM-user credentials sourced from Secrets Manager (modules/ses). + SES_SMTP_HOST = "email-smtp.eu-west-2.amazonaws.com" + SES_SMTP_PORT = "587" + SES_SMTP_USERNAME = local.ses_smtp.username + SES_SMTP_PASSWORD = local.ses_smtp.password + SES_SMTP_FROM_ADDRESS = var.ses_from_address + } +} + +############################################ +# IAM: write + presign-read the workbook on the dedicated exports bucket. +# GetObject is required so the Lambda-signed presigned GET URL resolves. +############################################ +module "s3_exports" { + source = "../../modules/s3_iam_policy" + + policy_name = "AraExportS3Exports-${var.stage}" + policy_description = "Allow ara_export Lambda to write and presign-read Scenario Exports on the exports bucket" + bucket_arns = ["arn:aws:s3:::${local.document_exports_bucket}"] + actions = ["s3:PutObject", "s3:GetObject"] + resource_paths = ["/*"] +} + +resource "aws_iam_role_policy_attachment" "s3_exports" { + role = module.lambda.role_name + policy_arn = module.s3_exports.policy_arn +} + +# NOTE: no ses:* on the role — the handler sends over SMTP with the SES IAM-user +# credentials injected above. diff --git a/deployment/terraform/lambda/ara_export/outputs.tf b/deployment/terraform/lambda/ara_export/outputs.tf new file mode 100644 index 000000000..08e258638 --- /dev/null +++ b/deployment/terraform/lambda/ara_export/outputs.tf @@ -0,0 +1,9 @@ +output "ara_export_queue_url" { + value = module.lambda.queue_url + description = "URL of the ara-export SQS queue (FastAPI enqueues jobs here)" +} + +output "ara_export_queue_arn" { + value = module.lambda.queue_arn + description = "ARN of the ara-export SQS queue" +} diff --git a/deployment/terraform/lambda/ara_export/provider.tf b/deployment/terraform/lambda/ara_export/provider.tf new file mode 100644 index 000000000..ec65a064b --- /dev/null +++ b/deployment/terraform/lambda/ara_export/provider.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } + + backend "s3" { + bucket = "ara-export-terraform-state" + key = "terraform.tfstate" + region = "eu-west-2" + } + + required_version = ">= 1.2.0" +} + +provider "aws" { + region = "eu-west-2" +} diff --git a/deployment/terraform/lambda/ara_export/variables.tf b/deployment/terraform/lambda/ara_export/variables.tf new file mode 100644 index 000000000..e8425d492 --- /dev/null +++ b/deployment/terraform/lambda/ara_export/variables.tf @@ -0,0 +1,61 @@ +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 = 5 + 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 "ses_from_address" { + type = string + description = "Verified SES sender for Scenario Export notifications (ADR-0059)." + default = "noreply@domna.homes" +} + +locals { + image_uri = "${var.ecr_repo_url}@${var.image_digest}" +} diff --git a/docs/adr/0065-ara-scenario-export.md b/docs/adr/0065-ara-scenario-export.md new file mode 100644 index 000000000..cf934ee46 --- /dev/null +++ b/docs/adr/0065-ara-scenario-export.md @@ -0,0 +1,237 @@ +--- +status: accepted (builds on ADR-0055, ADR-0056, ADR-0059, ADR-0060) +--- + +# ARA Scenario Export builds one branded workbook per request, sheet-per-scenario + +Users need to pull a portfolio's modelling results — property information plus +each Property's **Plan** under one or more **Scenarios** — out of the product as +a single spreadsheet, initiated from the front end and delivered as a link +emailed to the requester. This is the **Export** the glossary already reserves +the word for ("the plan/scenario XLSX export"), distinct from a **Download +Package** (the ZIP of survey documents, ADR-0060). + +The data layer already half-exists as `scripts/data_exports.py` (Jun-te Kim): a +CLI over the new DDD schema that reads `property` / `scenario` / `plan` / +`recommendation` / `material`, pivots measures onto a **stable 21-measure column +contract** (`EXPECTED_MEASURE_COLUMNS`), and emits one sheet per scenario. But +as written it (a) fetches EPC descriptive fields **live** from the gov API per +property (the retired `property_details_epc` table's replacement), (b) has **no +filters** and auto-derives every scenario with plans, (c) lives in `scripts/` +outside the DDD layout, and (d) has no styling, S3, or email. None of those +survive a front-end feature that must scope by scenario + filters and scale to +**tens of thousands** of properties inside a Lambda. + +The trigger/email/large-selection plumbing is already solved by +**Bulk Document Download** (ADR-0055/0059/0060) and is reused wholesale. + +## Decision + +**A request produces exactly one branded `.xlsx` Export: one sheet per selected +Scenario, over a filtered, scenario-scoped Property set, built from persisted +data, on the app-owned-task + attach-mode lane (ADR-0055), delivered by +presigned URL + email (ADR-0059).** + +- **Scenario-scoped selection (model A).** The unit of scope is *a Scenario's + results*. The base set for each sheet is the Properties that have a **Plan** + under that Scenario; filters and any hand-picked ids only **narrow** it. A + Property that filters in but has **no Plan for that Scenario drops out** of + that sheet — there is nothing to export for it. Scenario membership is applied + in the data layer (the `plan` join), not in the filter resolver. + +- **Sheet-per-Scenario, one workbook (A2).** The recipe carries + `scenario_ids: list[int]`; the **same** filtered Property set is rendered once + per Scenario, each on its own sheet. Under model A the sheets may legitimately + have **different row counts** (a Property with a Plan under Scenario X but not + Y appears on the X sheet only). Excel-only for v1 — a sheet-per-scenario + workbook is an inherently Excel artifact; there is no `format` field (or it is + fixed to `"xlsx"`). CSV degradation is a fast-follow if a warehouse consumer + ever needs it. + +- **Trigger & lifecycle (mirror ADR-0055).** The **front end** creates the + app-owned `tasks` row and writes the **selection recipe** — + `{portfolio_id, scenario_ids, filters, property_ids?, recipient_email}` — into + `tasks.inputs` (JSON, FE-owned), then calls the FastAPI route + (`backend/app/exports/`) with **only the `task_id`**. The route reads the + recipe from `tasks.inputs`, **resolves** it to the concrete internal + `property.id` set (below), **pins the resolved id list + recipe** onto **one + pre-created `sub_task`'s `inputs`**, and drops one SQS message + (`task_id`, `sub_task_id`) on a new `ARA_EXPORT_SQS_URL` queue. The new + `applications/ara_export` Lambda runs in **attach mode**, reading that recipe + from the sub_task; `TaskOrchestrator` owns status + roll-up. Nothing large + ever travels in an HTTP body **or** on SQS — the id list rides + `tasks.inputs` → `sub_task.inputs` (both Postgres JSON), so the 256 KB SQS + limit is irrelevant by construction and the "tens of thousands of ids" + concern is structurally impossible. + +- **Filters are the ADR-0056 shared contract.** The route resolves `filters` + with the **same** `PropertyGroupFilters` model and + `resolve_filtered_property_ids(session, portfolio_id, filters)` function the + Modelling Run Distributor uses — one filter vocabulary, one resolver, two + triggers. New filter dimensions land on `PropertyGroupFilters` and both + triggers inherit them. `property_ids` is an **override, not a co-filter**: + present and non-empty ⇒ export exactly those ids; absent ⇒ resolve from + `filters`. Precedence (not intersection) avoids the "I ticked 5 rows, got 3" + surprise where a lingering filter silently drops hand-picked rows. Both paths + key on internal `property.id` (as `data_exports.py` and the modelling resolver + already do), so identifiers align. + +- **Persisted data only — the Effective EPC, no live API.** Because model A + guarantees every exported Property has a Plan, it has been through Ingestion → + Baseline → Modelling and its cert / Effective EPC / Baseline Performance / + Plans are all persisted. The Export is a pure read. Descriptive fields follow + the **Effective EPC** precedence — `property_overrides` → lodged EPC → + predicted EPC — the same ladder `property_filters._resolved_component_values` + already implements, widened from `property_type`/`built_form` to the full + descriptive set. **No live `EpcClientService` calls**: tens of thousands of + sequential gov-API fetches would blow the Lambda's 15-minute ceiling and + hammer the register. + +- **Promote the data layer into DDD, split by concern.** A `repositories/` + read repository returns the persisted, override-resolved rows for a + `(portfolio_id, scenario_id, property-id set)`; a **pure `domain/` + column-shaper** owns the frozen 21-measure contract and the + measure→cost-column pivot + savings summation (trivially unit-testable). The + `applications/ara_export` Lambda composes: repo read → domain shape → styling + → S3 → email. `scripts/data_exports.py` becomes a thin CLI wrapper over the + same modules (or is retired) so the query/column logic exists **once** — this + also removes the `_PROPERTY_TYPE_CODES` map duplicated today in both + `data_exports.py` and `property_filters.py`. + +- **Frozen column contract, mimic `data_exports.py`.** The 21-measure + `EXPECTED_MEASURE_COLUMNS` contract is **stable** — every measure column + always present, blank where a Property lacks that measure — rather than + emitting only the measures that happen to appear. Property identity + Effective + EPC descriptive fields + pivoted per-measure cost columns + summed savings + + Plan post-works figures (SAP / kWh / CO₂ / bill), per sheet. + +- **Programmatic branding, palette in `infrastructure/`.** Styling is a + **programmatic openpyxl** pass (header fill + bold header font, frozen top + row, banded rows, coloured cost/band columns, column widths), not a populated + template. A2's **dynamic sheet count** defeats a fixed-sheet template (which + would force lossy per-sheet `copy_worksheet` cloning); a helper styles + whatever it generates. The brand palette + styling helper live in + `infrastructure/xlsx/` — filling the "no brand-colour constants exist + anywhere" gap once, reusable enough that `audit_generator` could later adopt + it. `domain/` stays pure and colour-agnostic. Monday.com-style **defaults** + ship now behind a `# TODO(brand)` marker until real Domna hex values land. + +- **Caps & memory.** The bulk-download `MAX_PROPERTIES = 500` (a ZIP-of-GBs + policy) is dropped; the Export payload is one `.xlsx` of mostly numbers. The + route rejects `> ARA_EXPORT_MAX_PROPERTIES` (**100 000**, configurable) + synchronously with a legible "narrow your selection" error. The Lambda builds + in **normal openpyxl mode on a 4 GB-memory Lambda** to start, streamed to a + temp file on `/tmp` and multipart-uploaded (`S3Client.upload_file`), never an + in-memory `BytesIO`. The 100k ceiling in normal mode may need more memory + (openpyxl holds every cell as a Python object — file size is **not** the RAM + gauge); the fallback if it OOMs is to raise the Lambda memory, then switch to + `write_only` mode (which constrains the styling helper to style-at-write). The + common case (hundreds–low-thousands per portfolio) is comfortably within + 4 GB. + +- **Delivery — both channels (ADR-0059).** The workbook is written to the + dedicated `DOCUMENT_EXPORTS_BUCKET` (reused, not the shared `DATA_BUCKET`); + the presigned URL (60-minute expiry, `_URL_TTL_SECONDS = 3600`) and a + summary are written to `sub_task.outputs` **and** emailed via + `SesSmtpEmailSender` to `recipient_email`. Email send is **best-effort** + (wrapped in try/except — a delivery failure does not fail the task; the URL is + still on `sub_task.outputs`, which the FE polls). + +- **Best-effort rows.** A Property/Plan that fails to resolve is skipped and + recorded in `sub_task.outputs`, not a hard failure. The run **fails only** on + an infrastructure error (DB / S3 / workbook / upload) or when the whole + selection yields **zero exportable rows**. + +- **Idempotency.** The `sub_task` is created with the same idempotent + `INSERT ... WHERE NOT EXISTS` the DB arbitrates for double-submit + (`create_download_subtask` pattern). + +## Deferred / future work + +- **Tag-based filtering.** The next filter the front end wants is **tags**, but + there is no tag model in the backend (no `property_tags` table, no + override→EPC→predicted resolution ladder for it) — it is a new concept that + needs its own `/grill-with-docs` before landing on `PropertyGroupFilters`. + Once designed, both the Export and the Modelling Run triggers inherit it via + the ADR-0056 shared contract. A `# TODO(tags)` marker at the filter seam + points back to this ADR. +- **CSV output.** Only if a warehouse/finance consumer needs a flat feed; + would collapse the sheets to one table with a `scenario_id` column, unbranded. +- **`write_only` streaming** if the 100k ceiling proves too heavy for a + reasonably-sized Lambda in normal mode. +- **Exports-bucket retention** — the Exports are transient; an expiry lifecycle + rule is likely warranted but is left to a follow-up (as with ADR-0060). + +## Considered options + +- **Property-scoped export (model B)** — resolve Properties first, attach + scenario columns, keep no-Plan Properties with blank scenario cells. Rejected: + the feature is "give me the modelled results of Scenario X", so a Property with + no Plan for the Scenario has nothing to show; blanks would mislead. +- **One Scenario per export (A1)** — simplest contract, single flat result. + Rejected: the FE screen lets users tick several Scenarios for comparison, which + is exactly the sheet-per-Scenario workbook. +- **Import `scripts/data_exports.py` from the Lambda.** Rejected: re-buries raw + SQL in `scripts/`, keeps the dead live-EPC dependency, duplicates the + code-maps, and can't sit cleanly in the DDD layout. +- **Live EPC fetch (as the script does).** Rejected: tens of thousands of + sequential gov-API calls won't finish in a Lambda and abuse the register; + model A guarantees the data is already persisted. +- **`property_ids` intersects `filters`.** Rejected: a hand-picked selection + silently shrinking because of a stale filter is a surprising, hard-to-debug + UX; override precedence is predictable. +- **Populate a branded `.xlsx` template** (the `audit_generator` pattern). + Rejected for A2: a fixed-sheet template can't hold a dynamic number of + scenario sheets without lossy runtime `copy_worksheet` cloning, and couples the + deploy to a binary asset. +- **`write_only` from day one.** Deferred: it constrains styling to + style-at-write for no benefit at the common scale; adopt only if 4 GB normal + mode proves insufficient at the ceiling. + +## Consequences + +- New DDD pieces: `applications/ara_export/` (thin `@task_handler` + + trigger body carrying only `task_id`/`subtask_id`), + `orchestration/ara_export_orchestrator.py`, a `ScenarioExportRepository` in + `repositories/` returning an override-resolved read-model (so the orchestrator + never names `infrastructure.postgres.*`), a pure column-shaper in `domain/`, + and a brand palette + openpyxl styling helper in `infrastructure/xlsx/`. The + only `backend/` touches are the new `backend/app/exports/` route and reusing + `resolve_filtered_property_ids`. +- New infra: an `ARA_EXPORT_SQS_URL` queue (terraform), the Lambda at 4 GB + memory with `/tmp` for the workbook, reuse of `DOCUMENT_EXPORTS_BUCKET` and the + SES/S3 adapters from ADR-0059. +- The pinned, resolved `property.id` set makes a run reproducible even if the + portfolio changes after submission. +- `PropertyGroupFilters` / `resolve_filtered_property_ids` (ADR-0056) now has a + second production caller; changes to the shared contract affect both triggers — + the intended shareability, made explicit. +- `scripts/data_exports.py`'s query/column logic is de-duplicated into the new + modules; the standalone CLI either wraps them or is retired. + +## Addendum — plan-selection option (default plan per home) + +A second **plan selection** was added alongside the original per-Scenario export, +chosen by `plan_selection` on the export request: + +- **`"scenario"` (default, unchanged):** one sheet per requested Scenario, the + freshest Plan per `(property, scenario)`. `scenario_ids` required. +- **`"default"` (new):** a single sheet titled **"Default Plans"** of each home's + **default Plan** (`plan.is_default = TRUE`), which is one-per-property across + all Scenarios (ADR-0012 / ADR-0017) — the portfolio's current state, one row + per home. `scenario_ids` is not required and is ignored. + +The router (`backend/app/exports/router.py`) validates `plan_selection ∈ +{"scenario", "default"}` and only requires a non-empty `scenario_ids` for the +scenario selection; the recipe carries `plan_selection` through to the handler +and orchestrator. The repository gains `default_rows_for(portfolio_id, +property_ids)` — the same read-model and Effective-EPC join as `rows_for`, with +the plan-selection `WHERE scenario_id = …` swapped for `WHERE is_default = TRUE` +(no scenario parameter). A recipe without `plan_selection` (pre-existing tasks) +defaults to `"scenario"`, so the change is backward-compatible. + +**New column — `plan_name`.** Both selections surface the **source Scenario's +name** (`scenario.name`, via `plan.scenario_id`) as a `plan_name` column heading +the plan post-works block, so a reader sees which Scenario each home's Plan came +from (e.g. "Fabric Only" / "ASHP + PV"). The freeform `plan.name` is unset on +modelling-generated Plans, so the Scenario name is the meaningful identifier. diff --git a/domain/sap10_calculator/rdsap/cert_to_inputs.py b/domain/sap10_calculator/rdsap/cert_to_inputs.py index ecb72917c..0f9df93ef 100644 --- a/domain/sap10_calculator/rdsap/cert_to_inputs.py +++ b/domain/sap10_calculator/rdsap/cert_to_inputs.py @@ -2141,9 +2141,52 @@ def _control_type(main: Optional[MainHeatingDetail]) -> int: raise UnmappedSapCode("main_heating_control", code) +# RdSAP 10 Table 29 (PDF p.56-57): a solid floor in age bands A-E carries no +# insulation layer, so wet underfloor pipes sit in a bare concrete slab (SAP 10.2 +# Table 4d R=0.25). F-M solid floors carry insulation → screed above (0.75). #1668 +_CONCRETE_SLAB_UNDERFLOOR_BANDS: Final[frozenset[str]] = frozenset("ABCDE") +# Lumped gov-EPC "underfloor" heat-emitter code — carries no screed/timber/slab +# subtype; the subtype is derived from floor construction + age band per Table 29. +_UNDERFLOOR_EMITTER_CODE: Final[int] = 2 + + +def _underfloor_responsiveness( + floor_description: Optional[str], age_band: Optional[str] +) -> float: + """SAP 10.2 Table 4d underfloor responsiveness selected via RdSAP 10 Table 29 + from the main part's floor construction + age band (#1668): + + - suspended TIMBER floor → 1.0 (fully responsive timber-floor UFH) + - solid floor, age A-E → 0.25 (bare concrete slab, no insulation) + - solid F-M / suspended-not-timber / unknown / no ground floor + → 0.75 (screed above insulation — the prior + default, so ambiguous certs do not move). + """ + desc = (floor_description or "").lower() + is_timber = "timber" in desc and "not timber" not in desc + if is_timber: + return 1.0 + band = (age_band or "").strip().upper()[:1] + if "solid" in desc and band in _CONCRETE_SLAB_UNDERFLOOR_BANDS: + return 0.25 + return 0.75 + + +def _main_underfloor_floor_context( + epc: Optional[EpcPropertyData], +) -> tuple[Optional[str], Optional[str]]: + """(floor construction description, age band) of the main building part — + the Table 29 discriminator for the underfloor subtype in `_responsiveness`.""" + if epc is None or not epc.sap_building_parts: + return (None, None) + main_bp = epc.sap_building_parts[0] + return (_effective_floor_description(epc, main_bp), main_bp.construction_age_band) + + def _responsiveness( main: Optional[MainHeatingDetail], tariff: Optional[Tariff] = None, + epc: Optional[EpcPropertyData] = None, ) -> float: """SAP 10.2 responsiveness R ∈ [0, 1] per spec line 15271: @@ -2183,9 +2226,10 @@ def _responsiveness( 4 = Warm air → R = 1.0 5 = Fan coils → R = 1.0 - "Concrete slab" UFH (Table 4d R=0.25) has no cert-side enum entry - yet — that variant would need a new mapper code before the cascade - can dispatch it. + The lumped underfloor code 2 carries no screed/timber/concrete-slab + subtype; its Table 4d responsiveness (0.25 / 0.75 / 1.0) is derived + from the main part's floor construction + age band per RdSAP 10 + Table 29 via `_underfloor_responsiveness` (needs `epc`; #1668). Strict-dispatch per [[reference-unmapped-sap-code]]: absent lodging (None / 0 / "") returns modal default R=1.0 (radiators); lodging @@ -2210,6 +2254,9 @@ def _responsiveness( emitter = main.heat_emitter_type if not emitter: return 1.0 + if emitter == _UNDERFLOOR_EMITTER_CODE: + floor_description, age_band = _main_underfloor_floor_context(epc) + return _underfloor_responsiveness(floor_description, age_band) if isinstance(emitter, int) and emitter in _RESPONSIVENESS_BY_EMITTER_CODE: return _RESPONSIVENESS_BY_EMITTER_CODE[emitter] raise UnmappedSapCode("heat_emitter_type", emitter) @@ -4958,7 +5005,7 @@ def mean_internal_temperature_section_from_cert( thermal_mass_parameter_kj_per_m2_k=_thermal_mass_parameter_kj_per_m2_k(epc), total_floor_area_m2=dim.total_floor_area_m2, control_type=_control_type(main), - responsiveness=_responsiveness(main, tariff=tariff), + responsiveness=_responsiveness(main, tariff=tariff, epc=epc), living_area_fraction=_living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ), @@ -8255,7 +8302,7 @@ def cert_to_inputs( # for the Elmhurst corpus (cert-side mapping is a future slice). control_type_value = _control_type(main) _mit_tariff = tariff_from_meter_type(epc.sap_energy_source.meter_type) - responsiveness_value = _responsiveness(main, tariff=_mit_tariff) + responsiveness_value = _responsiveness(main, tariff=_mit_tariff, epc=epc) living_area_fraction_value = _living_area_fraction( epc.habitable_rooms_count, dim.total_floor_area_m2 ) @@ -8279,7 +8326,7 @@ def cert_to_inputs( main_2_control_type_value = _control_type(_mit_main_2) main_2_fraction_value = _mit_main_2.main_heating_fraction / 100.0 main_2_responsiveness_value = _responsiveness( - _mit_main_2, tariff=_mit_tariff + _mit_main_2, tariff=_mit_tariff, epc=epc ) monthly_total_gains_w = tuple( internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) diff --git a/domain/scenario_export/__init__.py b/domain/scenario_export/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/domain/scenario_export/fabric_description.py b/domain/scenario_export/fabric_description.py new file mode 100644 index 000000000..54529f212 --- /dev/null +++ b/domain/scenario_export/fabric_description.py @@ -0,0 +1,284 @@ +"""Human-readable fabric descriptions from an EPC's structured SAP fields +(ADR-0065). + +The PasHub re-survey lodges *structured* fabric — SAP building parts, a main +heating detail, coded fuel/water-heating — but no gov-EPC descriptive prose, so +the export's descriptive columns would be blank for a re-surveyed Property. This +module composes the client-sheet text for walls / roof / floor (from the main +``epc_building_part``) and heating / hot water (from ``epc_main_heating_detail`` +and the ``epc_property`` water-heating fields), decoding the SAP integer codes +with the same code space the SAP engine uses. Windows and lighting are left on +the gov-EPC prose fallback (their per-element codes do not cleanly reconstruct +the dwelling-level phrase). + +Pure logic: the repository reads the structured rows and calls these; each +renderer returns ``None`` when it has nothing to say, so the caller can fall +back to the gov-EPC prose. +""" + +from __future__ import annotations + +from typing import Any, Optional + +# --- SAP integer-code → display-text maps --------------------------------- +# wall_construction (SAP10 codes, domain/sap10_ml/rdsap_uvalues.py:127-142). +_WALL_CONSTRUCTION: dict[int, str] = { + 1: "Granite or whinstone", + 2: "Sandstone", + 3: "Solid brick", + 4: "Cavity", + 5: "Timber frame", + 6: "System build", + 7: "Cob", + 8: "Park home", + 9: "Curtain wall", + 10: "Unknown", + 11: "Cavity (filled party wall)", +} +# wall_insulation_type (RdSAP schema codes, rdsap_uvalues.py:145-158). +_WALL_INSULATION: dict[int, str] = { + 1: "external insulation", + 2: "filled cavity", + 3: "internal insulation", + 4: "as built", + 5: "no insulation", + 6: "filled cavity + external insulation", + 7: "filled cavity + internal insulation", +} +# heat_emitter_type (SAP10.2, mapper.py _ELMHURST_HEAT_EMITTER_TO_SAP10). +_HEAT_EMITTER: dict[int, str] = { + 1: "radiators", + 2: "underfloor heating (in screed)", + 3: "underfloor heating (timber floor)", + 4: "warm air", + 5: "fan coils", +} +# main_fuel_type / water_heating_fuel (RdSAP "(not community)" family; invert of +# main_fuel_overlay._FUEL_CODES). +_FUEL: dict[int, str] = { + 3: "bottled LPG", + 6: "wood logs", + 10: "dual fuel (mineral and wood)", + 15: "smokeless coal", + 17: "LPG (special condition)", + 20: "mains gas (community)", + 25: "electricity (community)", + 26: "mains gas", + 27: "LPG (bulk)", + 28: "oil", + 29: "electricity", + 31: "biomass (community)", + 33: "house coal", +} +# main_heating_control (SAP10.2 Table 4e Group 1; comments in +# heating_recommendation._CONTROL_FEATURES_BY_CODE). +_HEATING_CONTROL: dict[int, str] = { + 2101: "no time or thermostatic control", + 2102: "programmer, no room thermostat", + 2103: "room thermostat only", + 2104: "programmer and room thermostat", + 2105: "programmer and at least two room thermostats", + 2106: "programmer, room thermostat and TRVs", + 2107: "programmer, TRVs and bypass", + 2108: "programmer, TRVs and flow switch", + 2109: "programmer, TRVs and boiler energy manager", + 2111: "TRVs and bypass", + 2113: "room thermostat and TRVs", +} +# water_heating_code (SAP Table 4a; water_heating_overlay docstring). +_WATER_HEATING_SYSTEM: dict[int, str] = { + 901: "from main system", + 903: "electric immersion", + 911: "gas boiler / circulator", +} +# RdSAP England-&-Wales construction age-band letters → the date range the EPC +# prints (domain/epc/property_overrides/construction_age_band.py). The lodged +# `epc_building_part.construction_age_band` carries the bare letter. +_AGE_BAND: dict[str, str] = { + "A": "before 1900", + "B": "1900-1929", + "C": "1930-1949", + "D": "1950-1966", + "E": "1967-1975", + "F": "1976-1982", + "G": "1983-1990", + "H": "1991-1995", + "I": "1996-2002", + "J": "2003-2006", + "K": "2007-2011", + "L": "2012-2022", + "M": "2023 onwards", +} + + +def _as_int(value: Any) -> Optional[int]: + """A SAP code as ``int``. Accepts the raw int, a numeric string, or ``None``; + a non-numeric label (e.g. an insulation ``"As built"`` string in a numeric + slot) yields ``None``.""" + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + try: + return int(str(value).strip()) + except ValueError: + return None + + +def _clean_text(value: Any) -> Optional[str]: + """A lodged free-text field trimmed to a non-empty string, else ``None``. + A bare integer (a code lodged in a text slot) is not display text.""" + if value is None or isinstance(value, (int, float, bool)): + return None + text = str(value).strip() + return text or None + + +# built_form codes (epc_codes.csv; override_code_mapping._BUILT_FORM_CODES). +# The lodged value is usually the display text already; a minority of records +# carry the bare integer code instead. +_BUILT_FORM: dict[int, str] = { + 1: "Detached", + 2: "Semi-detached", + 3: "End-terrace", + 4: "Mid-terrace", + 5: "Enclosed end-terrace", + 6: "Enclosed mid-terrace", +} + + +def built_form_description(built_form: Any) -> Optional[str]: + """The dwelling's built form. Decodes the integer code where a record + lodges one, otherwise passes the lodged display text through unchanged.""" + code = _as_int(built_form) + if code is not None: + return _BUILT_FORM.get(code) + return _clean_text(built_form) + + +def age_band_description(band: Any) -> Optional[str]: + """The construction age band as its date range, e.g. ``"1900-1929 (band B)"``. + The lodged value is a bare RdSAP letter; an unrecognised or ``"Unknown"`` + band renders nothing.""" + letter = _clean_text(band) + if letter is None: + return None + date_range = _AGE_BAND.get(letter.strip().upper()) + return f"{date_range} (band {letter.strip().upper()})" if date_range else None + + +def wall_description(construction: Any, insulation: Any) -> Optional[str]: + """A wall part's text, e.g. ``"Cavity wall, filled cavity"``. The + construction names the wall; the insulation qualifies it when known.""" + cons = _WALL_CONSTRUCTION.get(_as_int(construction) or 0) + if cons is None: + return None + ins = _WALL_INSULATION.get(_as_int(insulation) or 0) + return f"{cons} wall, {ins}" if ins else f"{cons} wall" + + +def _insulation_thickness_mm(thickness: Any) -> Optional[int]: + """A roof-insulation thickness in mm from a numeric code or a ``"225mm"`` + string; a non-numeric label (``"As built"``, ``"ND"``) or ``None`` yields + ``None``. Zero is a real value (no insulation) and is preserved.""" + as_int = _as_int(thickness) + if as_int is not None: + return as_int + text = _clean_text(thickness) + if text is None: + return None + digits = "".join(ch for ch in text if ch.isdigit()) + return int(digits) if digits else None + + +def roof_description( + construction_type: Any, insulation_location: Any, insulation_thickness: Any +) -> Optional[str]: + """A roof part's text, e.g. ``"Pitched roof (Slates or tiles), Access to + loft; 270mm insulation at joists"``. The construction is already lodged as + prose; the insulation clause is appended when a thickness or a named + location is present.""" + cons = _clean_text(construction_type) + if cons is None: + return None + thickness_mm = _insulation_thickness_mm(insulation_thickness) + location = _clean_text(insulation_location) + if location is not None and location.lower() in {"unknown", "none"}: + location = None + if thickness_mm == 0: + return f"{cons}; no insulation" + if thickness_mm is None and location is None: + return cons + clause = "insulation" if thickness_mm is None else f"{thickness_mm}mm insulation" + if location is not None: + clause = f"{clause} at {location.lower()}" + return f"{cons}; {clause}" + + +def floor_description( + floor_type: Any, construction_type: Any, insulation: Any +) -> Optional[str]: + """A floor part's text, e.g. ``"Ground floor, suspended timber, insulation + as built"``. Composed from the lodged floor type, construction, and + insulation status (all lodged as prose).""" + parts: list[str] = [] + location = _clean_text(floor_type) + if location is not None: + parts.append(location[0].upper() + location[1:].lower()) + construction = _clean_text(construction_type) + if construction is not None: + parts.append(construction.lower()) + ins = _clean_text(insulation) + if ins is not None: + parts.append(f"insulation {ins.lower()}") + return ", ".join(parts) or None + + +def heating_description( + fuel: Any, emitter: Any, control: Any, condensing: Any +) -> Optional[str]: + """The main heating system's text from its SAP codes, e.g. ``"Mains gas, + radiators (condensing); programmer, room thermostat and TRVs"``. Reports + only what the codes state — the appliance archetype is not lodged on a + re-survey, so no boiler/heat-pump type is asserted.""" + fuel_text = _FUEL.get(_as_int(fuel) or 0) + emitter_text = _HEAT_EMITTER.get(_as_int(emitter) or 0) + lead_parts = [p for p in (fuel_text, emitter_text) if p] + if not lead_parts: + return None + lead = ", ".join(p[0].upper() + p[1:] if i == 0 else p for i, p in enumerate(lead_parts)) + if condensing is True: + lead = f"{lead} (condensing)" + control_text = _HEATING_CONTROL.get(_as_int(control) or 0) + return f"{lead}; {control_text}" if control_text else lead + + +def hot_water_description( + water_heating_code: Any, + water_heating_fuel: Any, + main_fuel: Any, + has_cylinder: Any, + solar: Any, +) -> Optional[str]: + """The hot-water system's text from its SAP codes, e.g. ``"From main + system, mains gas"``. Where the water-heating code is not lodged on the + re-survey, it falls back to the SAP default (a dwelling with no cylinder + draws hot water from the main system) using the main heating fuel.""" + system = _WATER_HEATING_SYSTEM.get(_as_int(water_heating_code) or 0) + fuel = _FUEL.get(_as_int(water_heating_fuel) or 0) or _FUEL.get(_as_int(main_fuel) or 0) + if system is None: + # No lodged water-heating code: a dwelling without a cylinder inherits + # hot water from the main system (SAP Table 4a code 901). + if has_cylinder is True: + system = "hot water cylinder" + elif has_cylinder is False: + system = "from main system" + else: + return None + text = f"{system}, {fuel}" if fuel else system + text = text[0].upper() + text[1:] + if solar is True: + text = f"{text}; with solar water heating" + return text diff --git a/domain/scenario_export/scenario_sheet.py b/domain/scenario_export/scenario_sheet.py new file mode 100644 index 000000000..055c91812 --- /dev/null +++ b/domain/scenario_export/scenario_sheet.py @@ -0,0 +1,253 @@ +"""The Scenario Export sheet shape (ADR-0065). + +Pure logic: given the persisted, override-resolved data for the Properties in one +Scenario, lay out the wide export sheet — property identity + Effective-EPC +descriptive fields, each measure's cost pivoted onto its own column against a +frozen column contract, savings summed per Property, and the Plan's post-works +figures. No I/O: the repository reads the rows and the infrastructure layer +renders the workbook; this module only shapes one sheet's rows. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Sequence + +# The measure cost columns always present on every sheet, in a stable order, so +# every scenario sheet in the workbook shares one column contract regardless of +# which measures a given selection happens to contain (ADR-0065). A Property that +# lacks a measure carries a blank in that column, never a missing column. +MEASURE_COLUMNS: tuple[str, ...] = ( + "suspended_floor_insulation", + "solid_floor_insulation", + "external_wall_insulation", + "internal_wall_insulation", + "cavity_wall_insulation", + "loft_insulation", + "flat_roof_insulation", + "room_roof_insulation", + "secondary_glazing", + "double_glazing", + "solar_pv", + "high_heat_retention_storage_heaters", + "air_source_heat_pump", + "boiler_upgrade", + "gas_boiler_upgrade", + "roomstat_programmer_trvs", + "time_temperature_zone_control", + "low_energy_lighting", + "mechanical_ventilation", + "system_tune_up", + "system_tune_up_zoned", +) + +# A solar_pv Option that installs a battery pivots to its own column, kept in the +# frozen contract so a sheet with battery-solar shares columns with one without. +_BATTERY_MEASURE_COLUMNS: tuple[str, ...] = ("solar_pv_with_battery",) + +# Every measure cost column present on a sheet: the frozen base contract plus the +# battery variant. +_ALL_MEASURE_COLUMNS: tuple[str, ...] = MEASURE_COLUMNS + _BATTERY_MEASURE_COLUMNS + +# Property identity and Effective-EPC descriptive columns, leading every sheet +# before the measure columns. The descriptive fields are already override-resolved +# by the repository (property_overrides → lodged EPC → predicted, ADR-0065). +_ID_COLUMNS: tuple[str, ...] = ( + "property_id", + "landlord_property_id", + "uprn", + "address", + "postcode", + "property_type", + "built_form", + "property_age_band", + "walls", + "roof", + "floor", + "windows", + "heating", + "hot_water", + "lighting", + "total_floor_area", + "number_of_rooms", + "lodgement_date", + "is_expired", + "current_epc_rating", + "current_sap_points", +) + +# Per-Property roll-ups of the selected measures' attributed impact, trailing the +# measure columns (ADR-0065). +_SAVINGS_COLUMNS: tuple[str, ...] = ( + "sap_points", + "co2_equivalent_savings", + "kwh_savings", + "energy_cost_savings", +) + +# Package-level totals, trailing the savings roll-ups. +_TOTAL_COLUMNS: tuple[str, ...] = ("total_retrofit_cost",) + +# The default Plan's post-works figures, taken straight from the persisted Plan +# (the SAP calculator's output) with no recompute (ADR-0065). +_PLAN_COLUMNS: tuple[str, ...] = ( + "plan_name", + "post_sap_points", + "post_epc_rating", + "cost_of_works", + "contingency_cost", + "co2_savings", + "energy_bill_savings", + "energy_consumption_savings", + "valuation_increase", +) + + +def _blank(value: Any) -> Any: + """A missing value renders as a blank cell, not a zero or ``None``.""" + return "" if value is None else value + + +def _sum(values: Sequence[Optional[float]]) -> float: + """Sum optional numbers, treating an absent contribution as zero.""" + return sum(value or 0.0 for value in values) + + +def _measure_column(measure: ExportMeasure) -> str: + """The pivot column for a measure: a solar_pv Option carrying a battery lands + in its own ``solar_pv_with_battery`` column (ADR-0065).""" + if measure.measure_type == "solar_pv" and measure.includes_battery: + return "solar_pv_with_battery" + return measure.measure_type + + +@dataclass(frozen=True) +class ExportMeasure: + """One selected measure on a Property's default Plan for the Scenario: the + measure type (the pivot column), its installed cost, and the SAP / carbon / + energy / bill savings attributed to it. ``includes_battery`` distinguishes a + solar-with-battery Option so it pivots to its own column (ADR-0065).""" + + measure_type: str + estimated_cost: Optional[float] = None + sap_points: Optional[float] = None + co2_equivalent_savings: Optional[float] = None + kwh_savings: Optional[float] = None + energy_cost_savings: Optional[float] = None + includes_battery: bool = False + + +@dataclass(frozen=True) +class PropertyScenarioData: + """One Property's persisted data for one Scenario: identity, the + Effective-EPC descriptive fields (already override-resolved by the + repository), the default Plan's post-works figures, and the selected + measures. The input row the shaper turns into one wide sheet row.""" + + property_id: int + landlord_property_id: Optional[str] = None + uprn: Optional[str] = None + address: Optional[str] = None + postcode: Optional[str] = None + property_type: Optional[str] = None + built_form: Optional[str] = None + property_age_band: Optional[str] = None + walls: Optional[str] = None + roof: Optional[str] = None + floor: Optional[str] = None + windows: Optional[str] = None + heating: Optional[str] = None + hot_water: Optional[str] = None + lighting: Optional[str] = None + total_floor_area: Optional[float] = None + number_of_rooms: Optional[int] = None + lodgement_date: Optional[str] = None + is_expired: Optional[bool] = None + current_epc_rating: Optional[str] = None + current_sap_points: Optional[float] = None + plan_name: Optional[str] = None + post_sap_points: Optional[float] = None + post_epc_rating: Optional[str] = None + cost_of_works: Optional[float] = None + contingency_cost: Optional[float] = None + co2_savings: Optional[float] = None + energy_bill_savings: Optional[float] = None + energy_consumption_savings: Optional[float] = None + valuation_increase: Optional[float] = None + measures: Sequence[ExportMeasure] = () + + +@dataclass(frozen=True) +class ExportSheet: + """One laid-out scenario sheet: the ordered column contract and the rows, + each a column-keyed mapping ready for the workbook renderer.""" + + columns: tuple[str, ...] + rows: tuple[dict[str, Any], ...] + + +_COLUMNS: tuple[str, ...] = ( + _ID_COLUMNS + _ALL_MEASURE_COLUMNS + _SAVINGS_COLUMNS + _TOTAL_COLUMNS + _PLAN_COLUMNS +) + + +def _shape_row(prop: PropertyScenarioData) -> dict[str, Any]: + """One Property's wide row: identity + Effective-EPC descriptive fields, the + measures pivoted onto their cost columns, the savings rolled up, and the + Plan's post-works figures (ADR-0065).""" + row: dict[str, Any] = { + "property_id": prop.property_id, + "landlord_property_id": _blank(prop.landlord_property_id), + "uprn": _blank(prop.uprn), + "address": _blank(prop.address), + "postcode": _blank(prop.postcode), + "property_type": _blank(prop.property_type), + "built_form": _blank(prop.built_form), + "property_age_band": _blank(prop.property_age_band), + "walls": _blank(prop.walls), + "roof": _blank(prop.roof), + "floor": _blank(prop.floor), + "windows": _blank(prop.windows), + "heating": _blank(prop.heating), + "hot_water": _blank(prop.hot_water), + "lighting": _blank(prop.lighting), + "total_floor_area": _blank(prop.total_floor_area), + "number_of_rooms": _blank(prop.number_of_rooms), + "lodgement_date": _blank(prop.lodgement_date), + "is_expired": _blank(prop.is_expired), + "current_epc_rating": _blank(prop.current_epc_rating), + "current_sap_points": _blank(prop.current_sap_points), + } + # Every measure column is present, blank unless this Property has it, so all + # scenario sheets share one contract (ADR-0065). + for column in _ALL_MEASURE_COLUMNS: + row[column] = "" + for measure in prop.measures: + column = _measure_column(measure) + if column in _ALL_MEASURE_COLUMNS: + row[column] = measure.estimated_cost + # Roll the selected measures' attributed impact up to the Property. + row["sap_points"] = _sum([m.sap_points for m in prop.measures]) + row["co2_equivalent_savings"] = _sum([m.co2_equivalent_savings for m in prop.measures]) + row["kwh_savings"] = _sum([m.kwh_savings for m in prop.measures]) + row["energy_cost_savings"] = _sum([m.energy_cost_savings for m in prop.measures]) + row["total_retrofit_cost"] = _sum([m.estimated_cost for m in prop.measures]) + # The default Plan's post-works figures, passed through as persisted. + row["plan_name"] = _blank(prop.plan_name) + row["post_sap_points"] = _blank(prop.post_sap_points) + row["post_epc_rating"] = _blank(prop.post_epc_rating) + row["cost_of_works"] = _blank(prop.cost_of_works) + row["contingency_cost"] = _blank(prop.contingency_cost) + row["co2_savings"] = _blank(prop.co2_savings) + row["energy_bill_savings"] = _blank(prop.energy_bill_savings) + row["energy_consumption_savings"] = _blank(prop.energy_consumption_savings) + row["valuation_increase"] = _blank(prop.valuation_increase) + return row + + +def shape_scenario_sheet( + properties: Sequence[PropertyScenarioData], +) -> ExportSheet: + """Shape one Scenario's Properties into a wide export sheet (ADR-0065).""" + rows = tuple(_shape_row(prop) for prop in properties) + return ExportSheet(columns=_COLUMNS, rows=rows) diff --git a/infrastructure/postgres/product_table.py b/infrastructure/postgres/product_table.py index b353b3006..fe2639d17 100644 --- a/infrastructure/postgres/product_table.py +++ b/infrastructure/postgres/product_table.py @@ -22,3 +22,6 @@ class MaterialRow(SQLModel, table=True): cost_unit: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) is_active: bool = Field(default=True) + # Whether a solar_pv Option's product bundle includes a battery; the Scenario + # Export reads it to pivot solar-with-battery to its own column (ADR-0065). + includes_battery: bool = Field(default=False) diff --git a/infrastructure/xlsx/__init__.py b/infrastructure/xlsx/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/infrastructure/xlsx/branding.py b/infrastructure/xlsx/branding.py new file mode 100644 index 000000000..b2a7f1622 --- /dev/null +++ b/infrastructure/xlsx/branding.py @@ -0,0 +1,51 @@ +"""Monday.com-style branding for the Scenario Export workbook (ADR-0065). + +A programmatic openpyxl styling pass (no template): a bold indigo header band +with white text, a frozen header row, subtle alternating row bands, and roomy +column widths. The palette lives here as the single home for brand colour — the +"no brand-colour constants anywhere" gap the review flagged. + +TODO(brand): swap the placeholder Monday-style hex for the real Domna brand +palette once design supplies it. +""" + +from __future__ import annotations + +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.worksheet import Worksheet + +# openpyxl colours are 8-char ARGB. Placeholder Monday-style palette. +HEADER_FILL: str = "FF6C6CFF" # indigo header band +HEADER_FONT: str = "FFFFFFFF" # white header text +ROW_BAND_FILL: str = "FFF1F1FF" # subtle alternating-row tint + +_COLUMN_WIDTH: int = 18 + + +def style_sheet(worksheet: Worksheet) -> None: + """Paint the Monday-style branding onto a populated worksheet: header band + + font, frozen header row, banded rows, and column widths.""" + header_fill = PatternFill( + start_color=HEADER_FILL, end_color=HEADER_FILL, fill_type="solid" + ) + header_font = Font(bold=True, color=HEADER_FONT) + for cell in worksheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="left", vertical="center") + + # Keep the header visible while scrolling a long portfolio. + worksheet.freeze_panes = "A2" + + band_fill = PatternFill( + start_color=ROW_BAND_FILL, end_color=ROW_BAND_FILL, fill_type="solid" + ) + for row_index in range(3, worksheet.max_row + 1, 2): + for cell in worksheet[row_index]: + cell.fill = band_fill + + for column_index in range(1, worksheet.max_column + 1): + worksheet.column_dimensions[get_column_letter(column_index)].width = ( + _COLUMN_WIDTH + ) diff --git a/infrastructure/xlsx/scenario_export_workbook.py b/infrastructure/xlsx/scenario_export_workbook.py new file mode 100644 index 000000000..d437ee0ba --- /dev/null +++ b/infrastructure/xlsx/scenario_export_workbook.py @@ -0,0 +1,64 @@ +"""Renders the Scenario Export workbook (ADR-0065). + +One ``.xlsx`` with one sheet per Scenario (model A2), each sheet's rows shaped by +``domain.scenario_export`` and painted with the Monday-style branding in +``infrastructure.xlsx.branding``. Programmatic openpyxl (no template) so a +dynamic number of scenario sheets is handled natively. +""" + +from __future__ import annotations + +import re +from typing import Sequence + +from openpyxl import Workbook + +from domain.scenario_export.scenario_sheet import ExportSheet +from infrastructure.xlsx.branding import style_sheet + +# Excel forbids these in a sheet title and caps titles at 31 characters. +_ILLEGAL_SHEET_CHARS = re.compile(r"[:\\/?*\[\]]") +_MAX_SHEET_NAME = 31 + + +def _safe_sheet_name(name: str, used: set[str]) -> str: + """An Excel-legal, unique sheet name: illegal characters stripped, capped at + 31 chars, and suffixed on collision (two scenarios can share a name).""" + clean = _ILLEGAL_SHEET_CHARS.sub("", name or "scenario").strip() or "scenario" + clean = clean[:_MAX_SHEET_NAME] + base, index = clean, 1 + while clean in used: + suffix = f" ({index})" + clean = base[: _MAX_SHEET_NAME - len(suffix)] + suffix + index += 1 + used.add(clean) + return clean + + +def render_workbook( + sheets: Sequence[tuple[str, ExportSheet]], destination_path: str +) -> None: + """Render the scenario sheets into a single branded ``.xlsx`` at + ``destination_path``. Each tuple is a Scenario name and its shaped sheet; + sheet order follows the input (the requested ``scenario_ids`` order). + + Saves straight to disk (``Workbook.save(path)``) so the caller can + ``S3Client.upload_file`` it with multipart — the workbook is never also held + as an in-memory ``bytes`` copy (ADR-0065; the OOM path the 4 GB sizing was + against at the 100k-row cap). The further memory win is openpyxl + ``write_only`` mode; that switch would live at this seam. + """ + workbook = Workbook() + default = workbook.active + if default is not None: + workbook.remove(default) + + used_names: set[str] = set() + for name, sheet in sheets: + worksheet = workbook.create_sheet(title=_safe_sheet_name(name, used_names)) + worksheet.append(list(sheet.columns)) + for row in sheet.rows: + worksheet.append([row.get(column, "") for column in sheet.columns]) + style_sheet(worksheet) + + workbook.save(destination_path) diff --git a/orchestration/scenario_export_orchestrator.py b/orchestration/scenario_export_orchestrator.py new file mode 100644 index 000000000..657d15b7a --- /dev/null +++ b/orchestration/scenario_export_orchestrator.py @@ -0,0 +1,221 @@ +"""The Scenario Export orchestrator (ADR-0065). + +Ties one export together: for each requested Scenario, read its Properties' +persisted rows, shape them into a sheet, render the sheet-per-scenario branded +workbook, upload it to the exports bucket, mint a presigned URL, and email the +requester. Best-effort email (the link is also recorded on ``sub_task.outputs``); +a selection that yields no rows for any Scenario is a recorded failure — never an +empty workbook emailed to the user. +""" + +from __future__ import annotations + +import html +import logging +import os +import tempfile +from dataclasses import dataclass +from typing import Protocol, Sequence + +from domain.scenario_export.scenario_sheet import ( + ExportSheet, + PropertyScenarioData, + shape_scenario_sheet, +) +from domain.tasks.subtasks import SubTaskFailure +from infrastructure.s3.s3_client import S3Client +from infrastructure.xlsx.scenario_export_workbook import render_workbook +from repositories.email.email_sender import EmailSender + +logger = logging.getLogger(__name__) + +# The single sheet title for the default-plan selection (ADR-0065). +_DEFAULT_PLAN_SHEET_NAME = "Default Plans" + + +class ExportRowReader(Protocol): + def rows_for( + self, + *, + portfolio_id: int, + scenario_id: int, + property_ids: Sequence[int], + ) -> list[PropertyScenarioData]: ... + + def default_rows_for( + self, + *, + portfolio_id: int, + property_ids: Sequence[int], + ) -> list[PropertyScenarioData]: ... + + +class ScenarioNames(Protocol): + def name_for(self, scenario_id: int) -> str: ... + + +@dataclass(frozen=True) +class ScenarioExportResult: + """What a completed export reports (lands on ``sub_task.outputs``).""" + + presigned_url: str + export_s3_key: str + sheet_count: int + row_count: int + + +class ScenarioExportOrchestrator: + def __init__( + self, + *, + rows: ExportRowReader, + scenarios: ScenarioNames, + exports: S3Client, + email: EmailSender, + url_ttl_seconds: int, + key_prefix: str = "scenario-exports", + ) -> None: + self._rows = rows + self._scenarios = scenarios + self._exports = exports + self._email = email + self._url_ttl_seconds = url_ttl_seconds + self._key_prefix = key_prefix + + def _scenario_sheets( + self, + portfolio_id: int, + scenario_ids: Sequence[int], + property_ids: Sequence[int], + ) -> list[tuple[str, ExportSheet]]: + named_sheets: list[tuple[str, ExportSheet]] = [] + for scenario_id in scenario_ids: + rows = self._rows.rows_for( + portfolio_id=portfolio_id, + scenario_id=scenario_id, + property_ids=property_ids, + ) + named_sheets.append( + (self._scenarios.name_for(scenario_id), shape_scenario_sheet(rows)) + ) + return named_sheets + + def _default_plan_sheets( + self, portfolio_id: int, property_ids: Sequence[int] + ) -> list[tuple[str, ExportSheet]]: + # One sheet: each home's default Plan (`is_default`), across Scenarios. + rows = self._rows.default_rows_for( + portfolio_id=portfolio_id, property_ids=property_ids + ) + return [(_DEFAULT_PLAN_SHEET_NAME, shape_scenario_sheet(rows))] + + def run( + self, + *, + portfolio_id: int, + scenario_ids: Sequence[int], + property_ids: Sequence[int], + recipient_email: str, + export_name: str, + plan_selection: str = "scenario", + ) -> ScenarioExportResult: + # ADR-0065 two selections: + # - "scenario": one sheet per requested Scenario, the freshest Plan per + # (property, scenario). + # - "default": ONE sheet of each home's default Plan (`is_default`), + # one-per-property across all Scenarios — the portfolio's current + # state. `scenario_ids` is ignored. + if plan_selection == "default": + named_sheets = self._default_plan_sheets(portfolio_id, property_ids) + else: + named_sheets = self._scenario_sheets( + portfolio_id, scenario_ids, property_ids + ) + row_count = sum(len(sheet.rows) for _, sheet in named_sheets) + + if row_count == 0: + # No Property had a Plan under the selection (model A) — a recorded + # failure, never an empty workbook emailed (ADR-0065). + raise SubTaskFailure( + "the scenario export selection produced no exportable rows", + details={ + "portfolio_id": portfolio_id, + "plan_selection": plan_selection, + "scenario_ids": list(scenario_ids), + "property_count": len(property_ids), + }, + ) + + key = f"{self._key_prefix}/{export_name}.xlsx" + # Render to a /tmp file and multipart-upload it, so the workbook is never + # also held in memory as a bytes copy (ADR-0065 — the OOM path at the + # 100k-row cap). The temp file is always cleaned up. + handle, workbook_path = tempfile.mkstemp(suffix=".xlsx") + os.close(handle) + try: + render_workbook(named_sheets, workbook_path) + self._exports.upload_file(workbook_path, key) + finally: + try: + os.remove(workbook_path) + except OSError: + pass + url = self._exports.generate_presigned_url(key, self._url_ttl_seconds) + + subject, body, html_body = self._compose_email( + url=url, sheets=len(named_sheets), rows=row_count + ) + try: + self._email.send( + to=recipient_email, subject=subject, body=body, html_body=html_body + ) + logger.info("scenario_export: emailed %s", recipient_email) + except Exception: + # Best-effort delivery (ADR-0065): the link is also written to + # sub_task.outputs, so an email/transport failure must not lose an + # export that was already built and uploaded. + logger.warning( + "scenario_export: email delivery to %s failed; the link is still " + "recorded on sub_task.outputs", + recipient_email, + exc_info=True, + ) + + return ScenarioExportResult( + presigned_url=url, + export_s3_key=key, + sheet_count=len(named_sheets), + row_count=row_count, + ) + + def _compose_email( + self, *, url: str, sheets: int, rows: int + ) -> tuple[str, str, str]: + """The delivery email (ADR-0059/0065): a subject, a plain-text body + carrying the raw link, and an HTML alternative with a clean download + button so the long presigned URL isn't the visible content.""" + minutes = self._url_ttl_seconds // 60 + sheet_noun = "scenario" if sheets == 1 else "scenarios" + subject = f"Your scenario export is ready ({sheets} {sheet_noun})" + body = ( + "Your scenario export is ready.\n\n" + f"It covers {sheets} {sheet_noun} across {rows} property row(s).\n\n" + f"Download it here (link valid for {minutes} minutes):\n{url}\n" + ) + safe_url = html.escape(url, quote=True) + html_body = ( + '
Your scenario export is ready.
" + f"It covers {sheets} {sheet_noun} across " + f"{rows} property row(s).
" + '' + f'Download export
' + f'This link expires in {minutes} '
+ "minutes. If the button doesn't work, paste this URL into your browser:"
+ f'
{safe_url}