From 00af7b5a54b972d868802b59e03746e67ca44722 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 12:42:53 +0000 Subject: [PATCH 1/9] data types --- .../expand-sap-accuracy-corpus/worklist.md | 90 +++++ applications/modelling_e2e/handler.py | 37 +- .../uprn_100061905751/elmhurst_summary.pdf | Bin 0 -> 67142 bytes .../uprn_100061905751/elmhurst_worksheet.pdf | Bin 0 -> 44569 bytes .../uprn_100061905751/epc.json | 335 ++++++++++++++++++ datatypes/epc/domain/mapper.py | 21 +- .../epc/domain/tests/test_from_sap_schema.py | 30 ++ .../lambda/modelling_e2e/variables.tf | 2 +- .../epc_comparable_properties_repository.py | 55 ++- scripts/hyde/elmhurst_download.py | 2 +- .../modelling_e2e/test_handler.py | 93 +++++ ...st_epc_comparable_properties_repository.py | 63 ++++ 12 files changed, 715 insertions(+), 13 deletions(-) create mode 100644 backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.3/uprn_100061905751/elmhurst_summary.pdf create mode 100644 backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.3/uprn_100061905751/elmhurst_worksheet.pdf create mode 100644 backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.3/uprn_100061905751/epc.json diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index 3825927b..e5c6b8fa 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -11,12 +11,102 @@ large, or a new/complex build pattern needed; NEEDS INVESTIGATION. 2020 new-build flat) β€” full loop proven: eng 77 / elm 78, engine-on-Elmhurst- inputs 79 (calculator faithful within ~1). Use it to sanity-check the pipeline. +# Files saving instructions +I've noticed we arn't saving the epc.json (from epc api), input summary and worksheets from elmhurst. We shuold be doing that so we can reproduce behaviours quicker and verify. This is important as other memebers of team can help improve the verification. Files should be saved on backend/epc_api/json_samples/real_life_examples//uprn_/ + + ## Do next β€” new schemas (need a mapper) These two were flagged NOT MAPPABLE in the UI (red βœ—). Mapper coverage now ADDED (dedicated per-schema methods in `EpcPropertyDataMapper`; corpus gauge green, 0 new pyright errors). Elmhurst build + pin still pending β€” run the normal loop. +### πŸ”§πŸ” FOR KHALIM β€” 100061905751 (cert 2628-3046-6233-4584-2970, SAP-16.3) β€” mapper fix landed, Elmhurst +5 to review +Surfaced as a PRODUCTION crash (NOT in The 100): `modelling_e2e` property 730259 +`predict_epc` β†’ `EpcComparablePropertiesRepository.candidates_for(postcode)` maps +EVERY cohort cert in one list comprehension, and THIS cert raised +`ValueError: RdSapSchema17_1: missing required field 'insulated_door_count'`, +aborting the whole prediction. πŸ”§ FIXED generically in `_normalize_sap_schema_16_x` +(`mapper.py`): `setdefault("insulated_door_count", 0)` β€” door refinement, absent β†’ +"no insulated doors recorded" β†’ 0 (conservative RdSAP). +regression test +`test_16_x_missing_insulated_door_count_defaults_to_zero` (test_from_sap_schema.py); +0 new pyright errors; real-cert accuracy + 16.x suites green. +Cert: END-TERRACE HOUSE 2-storey, band A pre-1900, cavity wall AS-BUILT **no +insulation** (U0.70), pitched 100mm loft, suspended uninsulated floor, mains-gas +COMBI (PCDB 16366 Glow-worm Ultracom 2 35cxi), control 2106 CBE, double glazed, +**2 OPEN FIREPLACES** (chimneys), lodged **secondary** open-fire smokeless fuel +(SAP 631 = Elmhurst RKJ), 3.84mΒ² band-B extension, natural vent, 17% LED, TFA 139. +**eng 57 = lodged 57 EXACTLY** / elm worksheet 52 (+5). Built in Elmhurst end-to-end +(boiler 16366, CBE/2106, 2 open chimneys, secondary RKJ open-fire-in-grate, window +20.57mΒ², party wall geometry GF 6.31/1F 5.14 CF). engine-on-Elmhurst-inputs 53 β‰ˆ +worksheet 52 β†’ **calculator faithful**. The +5 decomposes as: ~2 SAP = the documented +16.x reduced-field **party-wall gap** (lodges no party_wall_length β†’ engine models +NONE; Elmhurst forces `Party walls Main 31.02mΒ² Γ— U0.250 = 7.76 W/K`), SCALED UP by +2-storey geometry (party wall area ~31mΒ² vs ~16mΒ² on the single-storey 16.2 pins +100021985993/100090182288 which showed only +2); remainder ~2-3 = reduced-field +build choices (3.84mΒ² extension folded into GF, wall thickness 250 vs lodged 300, +draughtproofing default). build_100061905751.py complete. **NOT yet pinned** β€” to +review with Khalim (his call per CONTEXT.md): pin engine=lodged 57 like other 16.x, +and/or whether 16.x end-terrace should model a party wall to close the reduced-field +gap. Sample saved under SAP-Schema-16.3/uprn_100061905751 (epc.json + both PDFs). + +### πŸ”§πŸ” modelling_e2e production failure sweep (2026-06-23) β€” cohort resilience + mapper gaps +Investigated the 45 FAILED `modelling_e2e` subtasks (DevAssessmentModelDB, portfolio +796) β€” 36 property rows, ~25 unique UPRNs. Root cause for most: `predict_epc` β†’ +`EpcComparablePropertiesRepository.candidates_for(postcode)` mapped EVERY cohort cert +in one comprehension, so ONE unmappable cohort cert aborted the whole prediction. + +**βœ… DONE β€” cohort resilience (skip + report)** so a single bad cert can't sink a +prediction, and the skipped certs surface for follow-up: +- `candidates_for` now skips certs that raise `ValueError` (mapper failures: + missing-field + `UnmappedApiCode`/`UnmappedElmhurstLabel`, both `ValueError`), + recording `SkippedCohortCert(certificate_number, error)` on `repo.skipped` + a + `logger.warning`. Transient API errors (not `ValueError`) still propagate. +- handler surfaces them in the subtask **outputs** (success β†’ `outputs.result. + skipped_unmappable_cohort_certs`; failure β†’ appended to the error message), with + cert numbers, so the gaps can be closed deliberately. +3 tests; 0 new pyright. + Live-verified on BN11 4EP: cohort now builds 35 + records the 1 skip. + +**βœ… DONE β€” 2 safe generic mapper fixes (+regression tests, 0 new pyright):** +- `_normalize_sap_schema_16_x`: `setdefault("insulated_door_count", 0)` (the + original prod crash) AND `setdefault("multiple_glazed_proportion", 100)` (16.3 + cert 0418-3986-7250-2884-7970; ML-only field the SAP calc ignores; modal 100). +- `_with_recorded_performance` co2/consumption/rating: `float(co2)` β†’ crashed on + certs lodging `co2_emissions_current` as a Measurement dict `{'value':3.5, + 'quantity':'tonnes per year'}` (16.x cert 2308-4997-7262-0137-9930, surfaced as + `TypeError: float() argument must be ... not 'dict'`). Now coerced via + `_measurement_value` (handles Measurement/dict/number). Verified maps β†’ 3.5. + +**βš–οΈ FOR KHALIM β€” uncovered mapper gaps (mapper is your domain per CONTEXT.md):** +- **`built_form` missing (SAP-16.0)** β€” cert 8742-6624-9300-2780-4926 (uprn + 10070004512); has `property_type` but no `built_form`. Defaulting it drives + party-wall/exposed-perimeter modelling β†’ a real modelling decision, not a neutral + default. Blocks props 710339, 723589. +- **`window` missing + genuinely sparse (SAP-16.2)** β€” cert 8257-7539-1649-0633-4992 + (uprn 10070009758); `windows` is a DICT not list, AND it omits `door_count`, + `habitable_room_count`, `glazed_area` β€” fail-loud is likely correct (data + insufficient). Blocks props 733315, 730259. +- **πŸ” RdSAP-21.0.0 systematically lacks the ADR-0028 Optional widenings 21.0.1 got.** + Cert 3135-3223-5500-0919-2206 (uprn 100021919725) omits 13 top-level fields + (`wet_rooms_count`, `open_chimneys_count`, `mechanical_ventilation_index_number`, + `led/cfl_fixed_lighting_bulbs_count`, the `mechanical_vent_duct_*`, + `insulated_door_u_value`, `pressure_test_certificate_number`, + `windows_transmission_details`) + `SapWindow` (`pvc_frame`/`glazing_gap`/ + `frame_factor`/`window_transmission_details`) β€” ALL required in 21.0.0 but + `Optional[...] = None` in 21.0.1. NOT applied: widening risks pushing `None` into + `from_rdsap_schema_21_0_0` which may assume non-None (needs mapper-coalescing + review). Recommend aligning RdSapSchema21_0_0 to 21.0.1 (incl. the same Optionals + on insulated_door_count etc.) + reviewing the 21.0.0 mapper. Blocks prop 719897. + (The resilience change already stops all of these from crashing prod.) + +Caveat: ~13 other failed props showed no mapper gap on replay β€” genuinely +not-predictable (empty/insufficient cohort) or already fixed by the +`insulated_door_count` default; cohort replay only checked the first 60 certs/postcode +and skipped rate-limited fetches, so a deeper gap could be missed. + +**Also done:** `deployment/terraform/lambda/modelling_e2e/variables.tf` +`maximum_concurrency` default 2 β†’ 4 (per request). + - [x] πŸ”§ 10096028301 β€” SAP-Schema-19.1.0 (full-SAP g/f FLAT, band M, combi PCDB 17929, MEV, AP50 3.5) Β· eng 82 / elm 82 (lodged 85) Β· PINNED engine 82. πŸ”§ mapper added: `from_sap_schema_19_1_0`. Built in Elmhurst (boiler 17929 via search, control CBE/2106, water from primary, MEV on, AP50 Blower Door 3.5+cert). Engine EXACTLY matches Elmhurst worksheet (82.11 vs 82); engine-on-Elmhurst-inputs 82.16 β‰ˆ 82 β†’ calculator faithful. βˆ’3 vs lodged = measured-U-vs-RdSAP-default + MEV extract-not-recovery (documented). No mapper change beyond coverage. - [x] πŸ”§ 100021943298 β€” SAP-Schema-16.1 (g/f FLAT, band B, solid-brick internal, combi PCDB 10328) Β· eng 76 / elm 75 (lodged 72) Β· PINNED engine 76. πŸ”§ mapper added: `from_sap_schema_16_1`. Built in Elmhurst (boiler 10328 via search, control CBE/2106, water from primary, wall insulation thickness Unknown); worksheet 75 β†’ engine within ~1 (tightest agreement, reduced-field). Boiler-select + water-heating + control dialogs all driven via automation (two-step rowβ†’Select / cascade + coordinate-OK). No mapper change beyond coverage. diff --git a/applications/modelling_e2e/handler.py b/applications/modelling_e2e/handler.py index de7b5542..86b5ad5f 100644 --- a/applications/modelling_e2e/handler.py +++ b/applications/modelling_e2e/handler.py @@ -61,6 +61,7 @@ from applications.modelling_e2e.modelling_e2e_trigger_body import ( ) from repositories.comparable_properties.epc_comparable_properties_repository import ( EpcComparablePropertiesRepository, + SkippedCohortCert, ) from repositories.geospatial.geospatial_s3_repository import ( GeospatialS3Repository, @@ -131,6 +132,20 @@ def _solar_insights_for( return None +def _dedupe_skipped( + skipped: list[SkippedCohortCert], +) -> list[SkippedCohortCert]: + """First occurrence of each skipped cert number (the same cert can appear in + more than one postcode cohort across a batch).""" + seen: set[str] = set() + unique: list[SkippedCohortCert] = [] + for cert in skipped: + if cert.certificate_number not in seen: + seen.add(cert.certificate_number) + unique.append(cert) + return unique + + def _predict_epc( *, property_id: int, @@ -168,7 +183,7 @@ def _predict_epc( @task_handler(task_source="modelling_e2e", source=Source.PROPERTY) -def handler(body: dict[str, Any], context: Any) -> None: +def handler(body: dict[str, Any], context: Any) -> Optional[dict[str, Any]]: trigger = ModellingE2ETriggerBody.model_validate(body) property_ids = trigger.property_ids portfolio_id = trigger.portfolio_id @@ -349,7 +364,25 @@ def handler(body: dict[str, Any], context: Any) -> None: ) errors.append(property_id) + # Cohort certs the mapper could not consume were skipped (not aborted on) + # so prediction could proceed; surface them β€” with cert numbers β€” in the + # subtask outputs so the mapper gaps can be closed later. + skipped_certs: list[dict[str, str]] = [ + {"certificate_number": s.certificate_number, "error": s.error} + for s in _dedupe_skipped(comparables_repo.skipped) + ] + if skipped_certs: + logger.info( + f"skipped {len(skipped_certs)} unmappable cohort cert(s): " + f"{[s['certificate_number'] for s in skipped_certs]}" + ) + if errors: - raise RuntimeError(f"failed property_ids: {errors}") + message = f"failed property_ids: {errors}" + if skipped_certs: + message += f"; skipped_unmappable_cohort_certs: {skipped_certs}" + raise RuntimeError(message) + + return {"skipped_unmappable_cohort_certs": skipped_certs} if skipped_certs else None finally: read_session.close() diff --git a/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.3/uprn_100061905751/elmhurst_summary.pdf b/backend/epc_api/json_samples/real_life_examples/SAP-Schema-16.3/uprn_100061905751/elmhurst_summary.pdf new file mode 100644 index 0000000000000000000000000000000000000000..805c6c25357c9b3d73764a9877e59b43f14c34ef GIT binary patch literal 67142 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 Dict[str, Any]: # still fail loud β€” that is correct, the fabric data is insufficient. d.setdefault("insulated_door_count", 0) + # Some 16.x certs (e.g. 16.3 cert 0418-3986-7250-2884-7970) omit + # `multiple_glazed_proportion` while still lodging `multiple_glazing_type` β€” + # RdSapSchema17_1 requires it. It is an ML-feature field the SAP-10 calculator + # never reads (only `sap10_ml/transform`), so the value is non-load-bearing for + # the score; default the modal 100 ("fully" the lodged glazing type) to keep the + # cert mappable. Mirrors the `insulated_door_count` default above. + d.setdefault("multiple_glazed_proportion", 100) + # 16.2 lodges glazing in BOTH `multiple_glazing_type` (frequently the "ND" # not-defined sentinel) AND the windows[].description. When the numeric field # is undefined, honour an explicit "Single glazed" description so it is not diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 7b75b84c..85f8566e 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -538,6 +538,36 @@ class TestFromSapSchema16_2: assert isinstance(epc, EpcPropertyData) assert epc.insulated_door_count == 0 + def test_16_x_missing_multiple_glazed_proportion_still_maps(self) -> None: + # Some 16.x certs (e.g. 16.3 cert 0418-3986-7250-2884-7970) lodge + # `multiple_glazing_type` but omit `multiple_glazed_proportion`, which + # RdSapSchema17_1 requires β€” previously raised "missing required field + # 'multiple_glazed_proportion'", aborting the prediction cohort. The + # normaliser defaults the modal 100 so the cert maps. (The field is an + # ML-only feature the SAP calculator never reads, and from_rdsap_schema_17_1 + # does not carry it onto EpcPropertyData β€” the point here is mappability.) + data = load("sap_16_3.json") + del data["multiple_glazed_proportion"] + assert "multiple_glazed_proportion" not in data + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert isinstance(epc, EpcPropertyData) + + def test_recorded_co2_as_measurement_dict_is_coerced_not_crashed(self) -> None: + # Some certs (e.g. 16.x cert 2308-4997-7262-0137-9930) lodge + # `co2_emissions_current` as a Measurement dict {'value': 3.5, 'quantity': + # 'tonnes per year'} rather than a plain number. `_with_recorded_performance` + # previously did `float(co2)` β†’ "float() argument must be ... not 'dict'", + # crashing the whole prediction cohort. It now coerces via _measurement_value. + data = load("sap_16_3.json") + data["co2_emissions_current"] = {"value": 3.5, "quantity": "tonnes per year"} + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert isinstance(epc, EpcPropertyData) + assert epc.co2_emissions_current == 3.5 + def test_16_2_normalizer_does_not_mutate_caller_dict(self) -> None: # Mirror _normalize_shower_outlets' contract: the caller's dict is # untouched (deep copy), so a re-dispatch sees the original shape. diff --git a/deployment/terraform/lambda/modelling_e2e/variables.tf b/deployment/terraform/lambda/modelling_e2e/variables.tf index 417f2780..27063494 100644 --- a/deployment/terraform/lambda/modelling_e2e/variables.tf +++ b/deployment/terraform/lambda/modelling_e2e/variables.tf @@ -26,7 +26,7 @@ variable "reserved_concurrent_executions" { variable "maximum_concurrency" { type = number - default = 2 + default = 4 description = "Maximum concurrent Lambda invocations from the SQS trigger." } diff --git a/repositories/comparable_properties/epc_comparable_properties_repository.py b/repositories/comparable_properties/epc_comparable_properties_repository.py index 3bfd92b9..e0d09e0a 100644 --- a/repositories/comparable_properties/epc_comparable_properties_repository.py +++ b/repositories/comparable_properties/epc_comparable_properties_repository.py @@ -9,6 +9,8 @@ UPRNs share a partition). Register metadata the cert itself doesn't carry from __future__ import annotations +import logging +from dataclasses import dataclass from datetime import date from typing import Optional, Protocol @@ -38,12 +40,30 @@ class CohortGeospatial(Protocol): ) -> dict[int, Coordinates]: ... +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class SkippedCohortCert: + """A postcode-cohort cert the mapper could not consume, so it was excluded + from the cohort rather than sinking the whole prediction. The certificate + number + error are captured so the gap surfaces (subtask outputs + logs) and + can be closed by extending the mapper later (ADR-0031).""" + + certificate_number: str + error: str + + class EpcComparablePropertiesRepository(ComparablePropertiesRepository): def __init__( self, epc_client: CohortEpcClient, geospatial: CohortGeospatial ) -> None: self._epc_client = epc_client self._geospatial = geospatial + # Cohort certs skipped because they are not yet mappable. Accumulates + # across every postcode the instance serves; the caller reads it after + # the run to report the mapper gaps (see modelling_e2e handler). + self.skipped: list[SkippedCohortCert] = [] def candidates_for(self, postcode: str) -> list[ComparableProperty]: results: list[EpcSearchResult] = self._epc_client.search_by_postcode( @@ -53,14 +73,37 @@ class EpcComparablePropertiesRepository(ComparablePropertiesRepository): coordinates: dict[int, Coordinates] = self._geospatial.coordinates_for_uprns( uprns ) - return [self._comparable(result, coordinates) for result in results] + cohort: list[ComparableProperty] = [] + for result in results: + comparable = self._comparable_or_skip(result, coordinates) + if comparable is not None: + cohort.append(comparable) + return cohort - def _comparable( + def _comparable_or_skip( self, result: EpcSearchResult, coordinates: dict[int, Coordinates] - ) -> ComparableProperty: - epc: EpcPropertyData = self._epc_client.get_by_certificate_number( - result.certificate_number - ) + ) -> Optional[ComparableProperty]: + """Map one cohort cert, or record + skip it when the mapper raises a + ``ValueError`` (missing required field / unmapped code β€” see + ``UnmappedApiCode``). One unmappable cert must NOT abort the whole cohort; + transient API errors are NOT ``ValueError`` and still propagate.""" + try: + epc: EpcPropertyData = self._epc_client.get_by_certificate_number( + result.certificate_number + ) + except ValueError as exc: + self.skipped.append( + SkippedCohortCert( + certificate_number=result.certificate_number, + error=f"{type(exc).__name__}: {exc}", + ) + ) + logger.warning( + "skipping unmappable cohort cert %s: %s", + result.certificate_number, + exc, + ) + return None resolved: Optional[Coordinates] = ( coordinates.get(result.uprn) if result.uprn is not None else None ) diff --git a/scripts/hyde/elmhurst_download.py b/scripts/hyde/elmhurst_download.py index 7688a30f..f148ba28 100644 --- a/scripts/hyde/elmhurst_download.py +++ b/scripts/hyde/elmhurst_download.py @@ -28,7 +28,7 @@ SESSION_DIR = HERE / ".elmhurst-session" SAMPLE_DIR = ( HERE.parent.parent / "backend/epc_api/json_samples/real_life_examples" - / "RdSAP-Schema-17.1/uprn_10010215568" + / "SAP-Schema-16.3/uprn_100061905751" ) ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105" diff --git a/tests/applications/modelling_e2e/test_handler.py b/tests/applications/modelling_e2e/test_handler.py index 762cea60..e7ac4ced 100644 --- a/tests/applications/modelling_e2e/test_handler.py +++ b/tests/applications/modelling_e2e/test_handler.py @@ -198,6 +198,99 @@ def test_lodged_epc_path_saves_epc_plan_and_marks_modelled() -> None: mock_uow.commit.assert_called_once() +def test_skipped_cohort_certs_are_surfaced_in_the_outputs() -> None: + """Cohort certs the mapper can't consume are skipped (so prediction is not + aborted) and surfaced β€” with cert numbers β€” in the subtask outputs, so the + mapper gaps can be reported and closed.""" + from repositories.comparable_properties.epc_comparable_properties_repository import ( + SkippedCohortCert, + ) + + mock_engine = _engine_mock([PROPERTY_ID], [UPRN], [POSTCODE]) + mock_plan = _plan_mock() + skipped = [ + SkippedCohortCert( + certificate_number="8257-7539-1649-0633-4992", + error="ValueError: RdSapSchema17_1: missing required field 'window'", + ) + ] + + with ExitStack() as stack: + stack.enter_context( + patch("applications.modelling_e2e.handler.os.environ", _ENV) + ) + stack.enter_context( + patch( + "applications.modelling_e2e.handler._get_engine", + return_value=mock_engine, + ) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcClientService") + ).return_value.get_by_uprn.return_value = MagicMock() + stack.enter_context( + patch("applications.modelling_e2e.handler.GeospatialS3Repository") + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.GoogleSolarApiClient") + ) + stack.enter_context( + patch("applications.modelling_e2e.handler._spatial_for", return_value=None) + ) + stack.enter_context( + patch( + "applications.modelling_e2e.handler._solar_insights_for", + return_value=None, + ) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.overlays_from", return_value=[]) + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.PropertyOverridesPostgresReader") + ) + stack.enter_context( + patch("applications.modelling_e2e.handler.ScenarioPostgresRepository") + ).return_value.get_many.return_value = [MagicMock()] + stack.enter_context( + patch( + "applications.modelling_e2e.handler.catalogue_with_off_catalogue_overrides" + ) + ) + stack.enter_context(patch("applications.modelling_e2e.handler.Session")) + stack.enter_context( + patch( + "applications.modelling_e2e.handler.run_modelling", + return_value=mock_plan, + ) + ) + # The repo accumulated a skipped (unmappable) cohort cert during the run. + stack.enter_context( + patch("applications.modelling_e2e.handler.EpcComparablePropertiesRepository") + ).return_value.skipped = skipped + MockUoW = stack.enter_context( + patch("applications.modelling_e2e.handler.PostgresUnitOfWork") + ) + MockUoW.return_value.__enter__.return_value = MagicMock() + MockUoW.return_value.__exit__.return_value = False + + # Act + result = _call_handler(_BODY) + + # Assert β€” the handler's return (β†’ subtask outputs.result) carries the cert + # numbers + errors of every skipped cohort cert. + assert result == { + "skipped_unmappable_cohort_certs": [ + { + "certificate_number": "8257-7539-1649-0633-4992", + "error": ( + "ValueError: RdSapSchema17_1: missing required field 'window'" + ), + } + ] + } + + # --------------------------------------------------------------------------- # EPC Prediction path # --------------------------------------------------------------------------- diff --git a/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py b/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py index 662d5b75..8f71be30 100644 --- a/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py +++ b/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py @@ -9,6 +9,8 @@ from datetime import date from pathlib import Path from typing import Any, Optional +import pytest + from datatypes.epc.domain.epc_property_data import EpcPropertyData from datatypes.epc.domain.mapper import EpcPropertyDataMapper from datatypes.epc.search.epc_search_result import EpcSearchResult @@ -126,3 +128,64 @@ def test_no_certs_in_the_postcode_yields_no_candidates() -> None: # Assert β€” no candidates, and the postcode was searched (normalisation/IO ran). assert candidates == [] assert client.searched_postcode == "LS6 1AA" + + +class _FakeEpcClientRaising: + """Serves the cohort, but raises the given exception for specific certs.""" + + def __init__( + self, results: list[EpcSearchResult], failures: dict[str, Exception] + ) -> None: + self._results = results + self._failures = failures + + def search_by_postcode(self, postcode: str) -> list[EpcSearchResult]: + return self._results + + def get_by_certificate_number(self, cert_num: str) -> EpcPropertyData: + if cert_num in self._failures: + raise self._failures[cert_num] + return _epc() + + +def test_unmappable_cohort_cert_is_skipped_and_recorded() -> None: + # Arrange β€” two certs share the postcode; one can't be mapped (the mapper + # raises ValueError for a missing required field, as the gov-API 16.x / sparse + # certs do). + here = Coordinates(longitude=-1.55, latitude=53.81) + client = _FakeEpcClientRaising( + [_result("CERT-OK", uprn=12345), _result("CERT-BAD", uprn=67890)], + failures={ + "CERT-BAD": ValueError( + "RdSapSchema17_1: missing required field 'window'" + ) + }, + ) + geospatial = _FakeGeospatial({12345: here}) + repo = EpcComparablePropertiesRepository(client, geospatial) + + # Act + candidates = repo.candidates_for("LS6 1AA") + + # Assert β€” the unmappable cert is excluded (does NOT sink the cohort) and the + # good one survives; the skip is recorded with the cert number + error so the + # mapper gap can be reported and closed later. + assert [c.certificate_number for c in candidates] == ["CERT-OK"] + assert len(repo.skipped) == 1 + assert repo.skipped[0].certificate_number == "CERT-BAD" + assert "missing required field 'window'" in repo.skipped[0].error + + +def test_transient_fetch_error_is_not_swallowed_as_unmappable() -> None: + # Arrange β€” a non-ValueError (e.g. a transient API failure) must NOT be + # silently skipped as "unmappable"; it propagates so the run fails loudly. + client = _FakeEpcClientRaising( + [_result("CERT-1", uprn=12345)], + failures={"CERT-1": RuntimeError("EPC API error 503")}, + ) + repo = EpcComparablePropertiesRepository(client, _FakeGeospatial({})) + + # Act / Assert + with pytest.raises(RuntimeError, match="503"): + repo.candidates_for("LS6 1AA") + assert repo.skipped == [] From 666cf304373fb142400fc8c2c1ceaf2bb03479f6 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 13:16:34 +0000 Subject: [PATCH 2/9] gated propertly --- datatypes/epc/domain/mapper.py | 15 ++++++------ .../epc/domain/tests/test_from_sap_schema.py | 23 +++++++++---------- .../test_real_cert_sap_accuracy.py | 22 ++++++++++++++++++ 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index ce0dbcc5..2b2045b6 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -3313,13 +3313,14 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: # still fail loud β€” that is correct, the fabric data is insufficient. d.setdefault("insulated_door_count", 0) - # Some 16.x certs (e.g. 16.3 cert 0418-3986-7250-2884-7970) omit - # `multiple_glazed_proportion` while still lodging `multiple_glazing_type` β€” - # RdSapSchema17_1 requires it. It is an ML-feature field the SAP-10 calculator - # never reads (only `sap10_ml/transform`), so the value is non-load-bearing for - # the score; default the modal 100 ("fully" the lodged glazing type) to keep the - # cert mappable. Mirrors the `insulated_door_count` default above. - d.setdefault("multiple_glazed_proportion", 100) + # NB: we deliberately do NOT default `multiple_glazed_proportion` here. A 16.x + # cert that omits it (e.g. 16.3 cert 0418-3986-7250-2884-7970) is left to fail + # the RdSapSchema17_1 parse and be handled by the cohort skip-and-report path β€” + # synthesising a value (tried `100`) drew the otherwise-unmappable cert into the + # EPC-prediction donor pool and tipped near-tie similarity matches, regressing + # the frozen-fixture component-accuracy gate (has_hot_water_cylinder 30β†’29/36, + # door_count residual 23β†’25/36). The field is ML-only (the SAP calc never reads + # it), so there is no calc cost to leaving such certs unmapped. See worklist. # 16.2 lodges glazing in BOTH `multiple_glazing_type` (frequently the "ND" # not-defined sentinel) AND the windows[].description. When the numeric field diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index 85f8566e..d8d23fec 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -538,21 +538,20 @@ class TestFromSapSchema16_2: assert isinstance(epc, EpcPropertyData) assert epc.insulated_door_count == 0 - def test_16_x_missing_multiple_glazed_proportion_still_maps(self) -> None: - # Some 16.x certs (e.g. 16.3 cert 0418-3986-7250-2884-7970) lodge - # `multiple_glazing_type` but omit `multiple_glazed_proportion`, which - # RdSapSchema17_1 requires β€” previously raised "missing required field - # 'multiple_glazed_proportion'", aborting the prediction cohort. The - # normaliser defaults the modal 100 so the cert maps. (The field is an - # ML-only feature the SAP calculator never reads, and from_rdsap_schema_17_1 - # does not carry it onto EpcPropertyData β€” the point here is mappability.) + def test_16_x_missing_multiple_glazed_proportion_fails_loud(self) -> None: + # A 16.x cert omitting `multiple_glazed_proportion` (e.g. 16.3 cert + # 0418-3986-7250-2884-7970) is deliberately NOT defaulted β€” it fails the + # RdSapSchema17_1 parse and is handled by the cohort skip-and-report path. + # Synthesising a value drew the otherwise-unmappable cert into the + # EPC-prediction donor pool and regressed the component-accuracy gate; + # the field is ML-only so there is no calc cost to leaving it unmapped. data = load("sap_16_3.json") del data["multiple_glazed_proportion"] - assert "multiple_glazed_proportion" not in data - epc = EpcPropertyDataMapper.from_api_response(data) - - assert isinstance(epc, EpcPropertyData) + with pytest.raises( + ValueError, match="missing required field 'multiple_glazed_proportion'" + ): + EpcPropertyDataMapper.from_api_response(data) def test_recorded_co2_as_measurement_dict_is_coerced_not_crashed(self) -> None: # Some certs (e.g. 16.x cert 2308-4997-7262-0137-9930) lodge diff --git a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py index aa8344b2..56989c71 100644 --- a/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py +++ b/tests/domain/sap10_calculator/test_real_cert_sap_accuracy.py @@ -581,6 +581,28 @@ _EXPECTATIONS: Final[tuple[RealCertExpectation, ...]] = ( cert_num="9978-7098-7226-2633-3994", sap_score=75, ), + # UPRN 10002468137 β†’ cert 0215-2818-7357-9703-2145. RdSAP-Schema-17.1, + # all-electric high-heat-retention storage heaters on Economy 7, solid- + # brick uninsulated end-terrace. Ground truth is Elmhurst RdSAP10 = 60, + # reproduced on identical inputs (summary + full SAP 10.2 worksheet saved + # alongside: elmhurst_summary.pdf / elmhurst_worksheet.pdf). The engine + # produces 62 β€” a +2 over-rating localised to OFF-PEAK WATER HEATING: + # the worksheet (lines 243-246) prices the 7-hour off-peak immersion at a + # Table 13 split (19.36% @ 15.29p high + 80.64% @ 5.5p low), but the engine + # prices 100% at the 5.5p low rate, under-costing the bill (Β£595.68 vs + # Β£629.67) β†’ lower ECF (2.69 vs 2.84) β†’ SAP 62 not 60. (Space heating 100% + # off-peak IS correct for storage heaters β€” the worksheet agrees.) Strict + # xfail until the off-peak water-heating rate split is implemented. + RealCertExpectation( + schema="RdSAP-Schema-17.1", + sample="uprn_10002468137", + cert_num="0215-2818-7357-9703-2145", + sap_score=60, + known_bug_xfail=( + "off-peak (7-hour) water-heating high/low rate split not applied β€” " + "engine prices 100% at the low rate; see elmhurst_worksheet.pdf (243-246)" + ), + ), ) From 60ec922430bc33c62fd013cc244654149c9af4d4 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 13:17:23 +0000 Subject: [PATCH 3/9] fix regression tests --- .../skills/expand-sap-accuracy-corpus/worklist.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index e5c6b8fa..310582bc 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -67,10 +67,19 @@ prediction, and the skipped certs surface for follow-up: cert numbers, so the gaps can be closed deliberately. +3 tests; 0 new pyright. Live-verified on BN11 4EP: cohort now builds 35 + records the 1 skip. -**βœ… DONE β€” 2 safe generic mapper fixes (+regression tests, 0 new pyright):** +**βœ… DONE β€” generic mapper fixes (+regression tests, 0 new pyright):** - `_normalize_sap_schema_16_x`: `setdefault("insulated_door_count", 0)` (the - original prod crash) AND `setdefault("multiple_glazed_proportion", 100)` (16.3 - cert 0418-3986-7250-2884-7970; ML-only field the SAP calc ignores; modal 100). + original prod crash). +- ⚠️ REVERTED `setdefault("multiple_glazed_proportion", 100)` β€” it made an + otherwise-unmappable 16.3 cert (0418-3986-7250-2884-7970) join the EPC-prediction + **frozen-fixture donor pool** and tipped near-tie similarity matches, regressing + the component-accuracy gate (`has_hot_water_cylinder` 30β†’29/36; `door_count` + residual 23β†’25/36 β€” both ≫ the gate's 1e-3 float tolerance). The field is ML-only + (SAP calc never reads it) and the cohort skip-and-report path handles the cert, + so we leave it fail-loud instead of synthesising a value. Re-baselining the gate + was rejected (loosens a tighten-only gate for a real regression). For Khalim: if + 16.x certs missing `multiple_glazed_proportion` should map, derive it (singleβ†’0 / + doubleβ†’100) rather than a flat default, and re-measure the gate. - `_with_recorded_performance` co2/consumption/rating: `float(co2)` β†’ crashed on certs lodging `co2_emissions_current` as a Measurement dict `{'value':3.5, 'quantity':'tonnes per year'}` (16.x cert 2308-4997-7262-0137-9930, surfaced as From 2b0cb180bf276e7feb322444af54ed0c7ef63a1f Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 13:21:31 +0000 Subject: [PATCH 4/9] update air permebility --- datatypes/epc/domain/tests/test_from_sap_schema.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index d8d23fec..d1bf36ed 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -324,7 +324,7 @@ class TestFromSapSchema17_1CodeBasedHeating: class TestFromSapSchema17_1Ventilation: - """Slice D5-vent: full-SAP sap_ventilation β†’ measured air permeability (AP4), + """Slice D5-vent: full-SAP sap_ventilation β†’ measured air permeability (AP50), ventilation_type β†’ MechanicalVentilationKind, sheltered sides, wet rooms and the MEV PCDB index.""" @@ -333,9 +333,13 @@ class TestFromSapSchema17_1Ventilation: schema = from_dict(SapSchema17_1, load("sap_17_1.json")) return EpcPropertyDataMapper.from_sap_schema_17_1(schema) - def test_measured_air_permeability_fed_as_ap4(self, sample: EpcPropertyData) -> None: + def test_measured_air_permeability_fed_as_ap50(self, sample: EpcPropertyData) -> None: + # The lodged `air_permeability` is a q50 Blower-Door result, so it feeds the + # engine's AP50 path `(18) = AP50/20 + (8)` β€” NOT the AP4/Pulse formula + # `0.263 Γ— AP4^0.924` (the air-permeability AP50 fix, uprn_10093116528). assert sample.sap_ventilation is not None - assert sample.sap_ventilation.air_permeability_ap4_m3_h_m2 == 2.6 + assert sample.sap_ventilation.air_permeability_ap50_m3_h_m2 == 2.6 + assert sample.sap_ventilation.air_permeability_ap4_m3_h_m2 is None def test_ventilation_type_6_is_extract(self, sample: EpcPropertyData) -> None: # ventilation_type 6 = MEV decentralised β†’ EXTRACT_OR_PIV_OUTSIDE. From c12bfc74139b777725cf1e0998bf123bdde5fb3d Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 14:11:30 +0000 Subject: [PATCH 5/9] test for 16 --- .../expand-sap-accuracy-corpus/worklist.md | 49 ++++++++++++++++++- .../lambda/modelling_e2e/variables.tf | 2 +- .../epc_comparable_properties_repository.py | 16 ++++-- ...st_epc_comparable_properties_repository.py | 25 ++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index 310582bc..caf8851d 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -114,7 +114,54 @@ not-predictable (empty/insufficient cohort) or already fixed by the and skipped rate-limited fetches, so a deeper gap could be missed. **Also done:** `deployment/terraform/lambda/modelling_e2e/variables.tf` -`maximum_concurrency` default 2 β†’ 4 (per request). +`maximum_concurrency` default 2 β†’ 4 (per request). Cohort skip-guard widened to +`(ValueError, AttributeError, KeyError, TypeError)` (mapper-shape errors) so a +malformed cert (e.g. PV-as-list AttributeError) is skipped+reported too, not just +missing-field ValueErrors; transient `EpcApiError` (subclasses `Exception`) still +propagates. +regression test. + +### πŸ“‹ PLAN β€” close the 8 modelling_e2e mapping gaps (2026-06-23 run, portfolio 796) +The 8 failed prediction targets reduce to **5 distinct mapper-gap classes** (the fix +targets). Per class: fix the mapper GENERICALLY, guard with BOTH the RdSAP-21.0.1 +corpus gauge AND the **component-accuracy gate** (`test_component_accuracy_gate.py` +β€” every newly-mappable cert joins the prediction donor pool and can tip near-tie +similarity, as `multiple_glazed_proportion=100` did: regressed has_hot_water_cylinder +30β†’29/36 + door_count 23β†’25/36 β‡’ REVERTED). Then validate the now-mappable cert via +`/expand-sap-accuracy-corpus` on **the cohort cert's UPRN** (below), saving +epc.json + elmhurst_summary.pdf + elmhurst_worksheet.pdf under +`real_life_examples//uprn_/` and committing them. + +- **P1 β€” `built_form` missing (SAP-16.0)** β€” HIGHEST impact (5 certs / 3 targets: + 10013151061, 100020397529, 100020407755). Certβ†’UPRN: 8742-…-4926β†’10070004512, + 0004-…-7395β†’100020407745, 2338-…-5950β†’100020407732, 9328-…-0924β†’100020407771 + (8904-…-4623 has NO uprn β€” validate via one of the others). ⚠ built_form IS a + strong prediction similarity feature β†’ highest gate-regression risk: DERIVE it + (from `dwelling_type` text / `property_type`), don't flat-default; re-measure the + gate. Corpus-validate uprn 10070004512. +- **P2 β€” `photovoltaic_supply` lodged as a LIST β†’ AttributeError (`'list' has no + attribute none_or_no_details`)** β€” target 22086690 (BN2 9ZN). NEW real shape bug + (not missing-field); escaped the skip net until widened above. Clean fix: coerce + the list shape in the PV mapper (like the Measurement-dict co2 fix). Low gate risk. + ⚠ FIRST identify the offending cert in the BN2 9ZN cohort (lookup didn't surface it + in the first 50 β€” search the full cohort for `photovoltaic_supply` as list). +- **P3 β€” `window` missing, `windows` is a DICT not list (SAP-16.1)** β€” cert + 9768-…-7974 β†’ uprn 100062614005, target 100061905741. Clean normaliser fix: handle + `windows` as a dict (take it directly) in `_normalize_sap_schema_16_x`. Corpus- + validate uprn 100062614005. +- **P4 β€” `multiple_glazed_proportion` missing (SAP-16.3)** β€” cert 0418-…-7970 β†’ uprn + 22144943, target 22082258. The flat-100 default REGRESSED the gate (reverted). + DERIVE it (singleβ†’0 / doubleβ†’100 from `multiple_glazing_type` / window desc) so it + doesn't perturb similarity, then re-measure the gate. Corpus-validate uprn 22144943. +- **P5 β€” RdSAP-21.0.0 systematic ADR-0028 alignment** (`SapWindow.glazing_gap` + + 13 top-level fields) β€” cert 3135-…-2206 β†’ uprn 100021919725, target 100021919718. + Align RdSapSchema21_0_0 Optionals to 21.0.1 + review `from_rdsap_schema_21_0_0` + None-coalescing. Larger/riskier. Corpus-validate uprn 100021919725. +- **P6 β€” `window` + genuinely sparse (SAP-16.2)** β€” cert 8257-…-4992 β†’ uprn + 10070009758, target 100020401711. Also omits door_count/habitable/glazed_area β†’ + fail-loud is likely CORRECT (insufficient data). DOCUMENT decision; don't force-map. + +After all: re-run the modelling_e2e failure sweep to confirm the cohort skips drop +to zero (or only P6-class), and that no new gate regression slipped in. - [x] πŸ”§ 10096028301 β€” SAP-Schema-19.1.0 (full-SAP g/f FLAT, band M, combi PCDB 17929, MEV, AP50 3.5) Β· eng 82 / elm 82 (lodged 85) Β· PINNED engine 82. πŸ”§ mapper added: `from_sap_schema_19_1_0`. Built in Elmhurst (boiler 17929 via search, control CBE/2106, water from primary, MEV on, AP50 Blower Door 3.5+cert). Engine EXACTLY matches Elmhurst worksheet (82.11 vs 82); engine-on-Elmhurst-inputs 82.16 β‰ˆ 82 β†’ calculator faithful. βˆ’3 vs lodged = measured-U-vs-RdSAP-default + MEV extract-not-recovery (documented). No mapper change beyond coverage. - [x] πŸ”§ 100021943298 β€” SAP-Schema-16.1 (g/f FLAT, band B, solid-brick internal, combi PCDB 10328) Β· eng 76 / elm 75 (lodged 72) Β· PINNED engine 76. πŸ”§ mapper added: `from_sap_schema_16_1`. Built in Elmhurst (boiler 10328 via search, control CBE/2106, water from primary, wall insulation thickness Unknown); worksheet 75 β†’ engine within ~1 (tightest agreement, reduced-field). Boiler-select + water-heating + control dialogs all driven via automation (two-step rowβ†’Select / cascade + coordinate-OK). No mapper change beyond coverage. diff --git a/deployment/terraform/lambda/modelling_e2e/variables.tf b/deployment/terraform/lambda/modelling_e2e/variables.tf index 27063494..02cba590 100644 --- a/deployment/terraform/lambda/modelling_e2e/variables.tf +++ b/deployment/terraform/lambda/modelling_e2e/variables.tf @@ -26,7 +26,7 @@ variable "reserved_concurrent_executions" { variable "maximum_concurrency" { type = number - default = 4 + default = 16 description = "Maximum concurrent Lambda invocations from the SQS trigger." } diff --git a/repositories/comparable_properties/epc_comparable_properties_repository.py b/repositories/comparable_properties/epc_comparable_properties_repository.py index e0d09e0a..e1e7bd4d 100644 --- a/repositories/comparable_properties/epc_comparable_properties_repository.py +++ b/repositories/comparable_properties/epc_comparable_properties_repository.py @@ -80,18 +80,26 @@ class EpcComparablePropertiesRepository(ComparablePropertiesRepository): cohort.append(comparable) return cohort + # Mapper-shape errors: the cert's lodged data does not fit the schema/mapper. + # `ValueError` β€” missing required field / unmapped code (`UnmappedApiCode`); + # `AttributeError` β€” a field lodged in the wrong shape (e.g. `photovoltaic_supply` + # as a list where a `PhotovoltaicSupply` object is expected β†’ "'list' object has + # no attribute 'none_or_no_details'"); `KeyError`/`TypeError` β€” analogous + # structural mismatches. Transient API failures (`EpcApiError` &c.) subclass + # `Exception` directly, NOT these, so they still propagate and fail the run loud. + _UNMAPPABLE_CERT_ERRORS = (ValueError, AttributeError, KeyError, TypeError) + def _comparable_or_skip( self, result: EpcSearchResult, coordinates: dict[int, Coordinates] ) -> Optional[ComparableProperty]: """Map one cohort cert, or record + skip it when the mapper raises a - ``ValueError`` (missing required field / unmapped code β€” see - ``UnmappedApiCode``). One unmappable cert must NOT abort the whole cohort; - transient API errors are NOT ``ValueError`` and still propagate.""" + mapper-shape error (see ``_UNMAPPABLE_CERT_ERRORS``). One unmappable cert + must NOT abort the whole cohort; transient API errors still propagate.""" try: epc: EpcPropertyData = self._epc_client.get_by_certificate_number( result.certificate_number ) - except ValueError as exc: + except self._UNMAPPABLE_CERT_ERRORS as exc: self.skipped.append( SkippedCohortCert( certificate_number=result.certificate_number, diff --git a/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py b/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py index 8f71be30..418b2af6 100644 --- a/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py +++ b/tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py @@ -176,6 +176,31 @@ def test_unmappable_cohort_cert_is_skipped_and_recorded() -> None: assert "missing required field 'window'" in repo.skipped[0].error +def test_malformed_cert_shape_attributeerror_is_skipped_and_recorded() -> None: + # Arrange β€” a cohort cert lodges a field in the wrong shape (e.g. + # `photovoltaic_supply` as a list), so the mapper raises AttributeError + # ("'list' object has no attribute 'none_or_no_details'") β€” a mapper-shape + # error like the missing-field ValueErrors, so it must skip + record, not abort. + client = _FakeEpcClientRaising( + [_result("CERT-OK", uprn=12345), _result("CERT-BAD", uprn=67890)], + failures={ + "CERT-BAD": AttributeError( + "'list' object has no attribute 'none_or_no_details'" + ) + }, + ) + repo = EpcComparablePropertiesRepository(client, _FakeGeospatial({})) + + # Act + candidates = repo.candidates_for("LS6 1AA") + + # Assert β€” bad cert skipped + recorded; good cert survives. + assert [c.certificate_number for c in candidates] == ["CERT-OK"] + assert len(repo.skipped) == 1 + assert repo.skipped[0].certificate_number == "CERT-BAD" + assert "none_or_no_details" in repo.skipped[0].error + + def test_transient_fetch_error_is_not_swallowed_as_unmappable() -> None: # Arrange β€” a non-ValueError (e.g. a transient API failure) must NOT be # silently skipped as "unmappable"; it propagates so the run fails loudly. From 6ee2f6257ab529a219c258ddb1f33cba0a618174 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 14:51:40 +0000 Subject: [PATCH 6/9] fix(mapper): close modelling_e2e cohort mapping gaps (built_form, PV-list, windows-dict) Closes the mapper-coverage gaps surfaced by the modelling_e2e prediction-cohort failures (portfolio 796): - built_form (SAP-16.0): derive from dwelling_type in _normalize_sap_schema_16_x (Mid-terrace->4, End-terrace->3, Semi-detached->2, Detached->1; flats->modal 4). ML-only field (SAP calc never reads it) so SAP- and gate-neutral. 5 flat certs that omitted built_form now map. - photovoltaic_supply as a measured-array LIST: routed all pre-21 RdSAP mappers (17.0/17.1/18.0/19.0/20.0.0) through _map_schema_21_pv, whose list branch is now dict-tolerant (_pv_array_field reads dict OR dataclass). They capture the PV arrays like 21.0.x instead of raising "'list' object has no attribute none_or_no_details" and sinking the whole cohort. - windows-as-dict (16.x): handled in the normalizer (not just windows-as-list). Genuinely-sparse certs (omit door_count/habitable/glazed_area) remain fail-loud; the gate-regressing multiple_glazed_proportion default and the recursive RdSAP-21.0.0 ADR-0028 alignment are left fail-loud + flagged for review (worklist). +5 regression tests; component-accuracy gate 26/26; 0 new pyright errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../expand-sap-accuracy-corpus/worklist.md | 41 +++++ datatypes/epc/domain/mapper.py | 141 +++++++++++------- .../domain/tests/test_from_rdsap_schema.py | 24 +++ .../epc/domain/tests/test_from_sap_schema.py | 26 ++++ 4 files changed, 177 insertions(+), 55 deletions(-) diff --git a/.claude/skills/expand-sap-accuracy-corpus/worklist.md b/.claude/skills/expand-sap-accuracy-corpus/worklist.md index caf8851d..cdd3abbd 100644 --- a/.claude/skills/expand-sap-accuracy-corpus/worklist.md +++ b/.claude/skills/expand-sap-accuracy-corpus/worklist.md @@ -163,6 +163,47 @@ epc.json + elmhurst_summary.pdf + elmhurst_worksheet.pdf under After all: re-run the modelling_e2e failure sweep to confirm the cohort skips drop to zero (or only P6-class), and that no new gate regression slipped in. +### βœ… OUTCOMES (2026-06-23, "do all") β€” 553 tests pass, gate 26/26, 0 new pyright +- **P1 βœ… FIXED** β€” `_normalize_sap_schema_16_x` derives `built_form` from the + dwelling_type text (`_derive_built_form_16x`: Mid-terraceβ†’4 / End-terraceβ†’3 / + Semi-detachedβ†’2 / Detachedβ†’1; flats β†’ modal 4). built_form is ML-only (SAP calc + never reads it) so SAP-neutral; gate verified insensitive (26/26). 5 SAP-16.0 + flat certs now map (8742β†’SAP 66=lodged, etc.). +2 tests. +- **P2 βœ… FIXED** β€” `photovoltaic_supply` as a measured-array LIST crashed + `from_rdsap_schema_{17_0,17_1,18_0,19_0,20_0_0}` (`'list' has no attribute + none_or_no_details`). All 5 now route through `_map_schema_21_pv`, whose list + branch is made dict-tolerant (`_pv_array_field` reads dict OR dataclass) β€” they + now CAPTURE the PV arrays like 21.0.x instead of crashing/dropping. cert + 6102-…-2292 (uprn 22086693, RdSAP-20.0.0) maps with 2 arrays @1.14kW. +test. +- **P3 βœ… generalised / cert fail-loud** β€” `_normalize_sap_schema_16_x` now handles + `windows` as a dict (not just list). cert 9768 (uprn 100062614005, 16.1) still + fails loud β€” it ALSO omits door_count/habitable_room_count/glazed_area + (genuinely sparse "High performance glazing" cert) β†’ CORRECT to fail; resilience + skips+reports it. +- **P6 βœ… same as P3** β€” cert 8257 (uprn 10070009758, 16.2) β€” identical sparse + "High performance glazing" pattern, fails loud on door_count. Correct. +- **P4 βš–οΈ FOR KHALIM (gate decision)** β€” left `multiple_glazed_proportion` fail-loud. + Re-confirmed: defaulting it (ANY value) still regresses the gate (29/36 + + 25/36) because it makes a frozen-fixture cert a prediction donor β€” the field is + ML-only (not carried to EpcPropertyData), so the regression is donor-pool + composition, NOT the value. Closing this gap needs a GATE RE-BASELINE decision + (whether the donor-pool tip is benign per the #1245 precedent) β€” your call; I did + not unilaterally loosen the tighten-only gate. +- **P5 πŸ” FOR KHALIM (large recursive alignment)** β€” RdSAP-21.0.0 lacks the + ADR-0028 Optional widenings 21.0.1 has, and the gap is RECURSIVE (not just the + ~13 top-level + SapWindow): `SapBuildingPart` (13 fields incl. + construction_age_band/wall_construction/identifier β€” CORE FABRIC), + `SapEnergySource` (pv_battery_count, wind_turbine_details), MainHeatingDetail, + ShowerOutlets, PvBatteries, Addendum, + top-level low_energy_fixed_lighting_ + bulbs_count/multiple_glazed_proportion/suggested_improvements. Aligning to + 21.0.1's "everything Optional, tolerate sparse" means the 21.0.0 mapper must + None-coalesce CORE fabric β€” a modelling decision in your domain. Partial widening + was REVERTED (half-aligned schema is worse). cert 3135 (uprn 100021919725) stays + fail-loud β†’ resilience skips+reports. Recommend: align RdSapSchema21_0_0 fully to + 21.0.1 (recursive) + review mapper coalescing, in a dedicated change. +- **Corpus-validation pending** for the 2 now-mappable certs (P1 uprn 10070004512, + P2 uprn 22086693) β€” Elmhurst build + summary/worksheet + commit. + - [x] πŸ”§ 10096028301 β€” SAP-Schema-19.1.0 (full-SAP g/f FLAT, band M, combi PCDB 17929, MEV, AP50 3.5) Β· eng 82 / elm 82 (lodged 85) Β· PINNED engine 82. πŸ”§ mapper added: `from_sap_schema_19_1_0`. Built in Elmhurst (boiler 17929 via search, control CBE/2106, water from primary, MEV on, AP50 Blower Door 3.5+cert). Engine EXACTLY matches Elmhurst worksheet (82.11 vs 82); engine-on-Elmhurst-inputs 82.16 β‰ˆ 82 β†’ calculator faithful. βˆ’3 vs lodged = measured-U-vs-RdSAP-default + MEV extract-not-recovery (documented). No mapper change beyond coverage. - [x] πŸ”§ 100021943298 β€” SAP-Schema-16.1 (g/f FLAT, band B, solid-brick internal, combi PCDB 10328) Β· eng 76 / elm 75 (lodged 72) Β· PINNED engine 76. πŸ”§ mapper added: `from_sap_schema_16_1`. Built in Elmhurst (boiler 10328 via search, control CBE/2106, water from primary, wall insulation thickness Unknown); worksheet 75 β†’ engine within ~1 (tightest agreement, reduced-field). Boiler-select + water-heating + control dialogs all driven via automation (two-step rowβ†’Select / cascade + coordinate-OK). No mapper change beyond coverage. diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index 2b2045b6..2f2bd00b 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -189,6 +189,15 @@ def _sap_opening_area_m2(width: Any, height: Any) -> float: ) +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 + that leave the list unparsed).""" + if isinstance(array, dict): + return cast(Dict[str, Any], array).get(name) + return getattr(array, name) + + def _map_schema_21_pv( es_pv_supply: Any, ) -> tuple[Optional[PhotovoltaicSupply], Optional[List[PhotovoltaicArray]]]: @@ -206,12 +215,18 @@ def _map_schema_21_pv( None. With no PV data at all, both are None. """ if isinstance(es_pv_supply, list): + # The nested list's leaves are `PhotovoltaicArray` dataclasses when the + # schema's Union type parsed them (21.0.x), or raw dicts when an older + # schema (17.0–20.0.0) types the field as the wrapper only and leaves the + # list unparsed β€” `_pv_array_field` reads either shape. flattened = [ PhotovoltaicArray( - peak_power=_measurement_value(array.peak_power), - pitch=int(_measurement_value(array.pitch)), - orientation=_pv_orientation(array.orientation), - overshading=int(_measurement_value(array.overshading)), + peak_power=_measurement_value(_pv_array_field(array, "peak_power")), + pitch=int(_measurement_value(_pv_array_field(array, "pitch"))), + orientation=_pv_orientation(_pv_array_field(array, "orientation")), + overshading=int( + _measurement_value(_pv_array_field(array, "overshading")) + ), ) for inner_list in es_pv_supply for array in inner_list @@ -538,6 +553,7 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_17_0(schema: RdSapSchema17_0) -> EpcPropertyData: es = schema.sap_energy_source + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) return EpcPropertyData( uprn=schema.uprn, assessment_type=schema.assessment_type, @@ -656,17 +672,8 @@ class EpcPropertyDataMapper: is_dwelling_export_capable=False, wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), electricity_smart_meter_present=False, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - # ADR-0028: photovoltaic_supply can be absent, None, or a - # sparse shape without none_or_no_details β€” guard the read. - if getattr(es.photovoltaic_supply, "none_or_no_details", None) - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, ), sap_building_parts=[ SapBuildingPart( @@ -954,6 +961,7 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_17_1(schema: RdSapSchema17_1) -> EpcPropertyData: es = schema.sap_energy_source + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) # ADR-0028: instantaneous_wwhrs holds bath/shower ROOM counts. iw = schema.sap_heating.instantaneous_wwhrs return EpcPropertyData( @@ -1097,15 +1105,8 @@ class EpcPropertyDataMapper: is_dwelling_export_capable=False, wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), electricity_smart_meter_present=False, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - if getattr(es.photovoltaic_supply, "none_or_no_details", None) - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, ), sap_building_parts=[ SapBuildingPart( @@ -1145,6 +1146,7 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_18_0(schema: RdSapSchema18_0) -> EpcPropertyData: es = schema.sap_energy_source + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) # ADR-0028: instantaneous_wwhrs holds bath/shower ROOM counts. iw = schema.sap_heating.instantaneous_wwhrs return EpcPropertyData( @@ -1295,16 +1297,8 @@ class EpcPropertyDataMapper: is_dwelling_export_capable=False, wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), electricity_smart_meter_present=False, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - if es.photovoltaic_supply - and es.photovoltaic_supply.none_or_no_details - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, ), sap_building_parts=[ SapBuildingPart( @@ -1375,6 +1369,7 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_19_0(schema: RdSapSchema19_0) -> EpcPropertyData: es = schema.sap_energy_source + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) return EpcPropertyData( uprn=schema.uprn, assessment_type=schema.assessment_type, @@ -1496,17 +1491,8 @@ class EpcPropertyDataMapper: is_dwelling_export_capable=False, wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), electricity_smart_meter_present=False, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - # ADR-0028: photovoltaic_supply can be absent, None, or a - # sparse shape without none_or_no_details β€” guard the read. - if getattr(es.photovoltaic_supply, "none_or_no_details", None) - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, ), sap_building_parts=[ SapBuildingPart( @@ -1581,6 +1567,12 @@ class EpcPropertyDataMapper: @staticmethod def from_rdsap_schema_20_0_0(schema: RdSapSchema20_0_0) -> EpcPropertyData: es = schema.sap_energy_source + # `photovoltaic_supply` is polymorphic in 20.0.0 too (None / wrapper dict / + # measured-array LIST) β€” route it through the shared dispatch like 21.0.0 so a + # cert lodging measured arrays as a list (e.g. cert 6102-6227-8000-0083-2292, + # uprn 22086693) maps instead of raising "'list' object has no attribute + # 'none_or_no_details'" and sinking the whole prediction cohort. + pv_supply, pv_arrays = _map_schema_21_pv(es.photovoltaic_supply) # ADR-0027: instantaneous_wwhrs holds bath/shower ROOM counts. iw = schema.sap_heating.instantaneous_wwhrs return EpcPropertyData( @@ -1737,16 +1729,8 @@ class EpcPropertyDataMapper: is_dwelling_export_capable=False, wind_turbines_terrain_type=str(es.wind_turbines_terrain_type), electricity_smart_meter_present=False, - photovoltaic_supply=( - PhotovoltaicSupply( - none_or_no_details=PhotovoltaicSupplyNoneOrNoDetails( - percent_roof_area=es.photovoltaic_supply.none_or_no_details.percent_roof_area, - ) - ) - if es.photovoltaic_supply - and es.photovoltaic_supply.none_or_no_details - else None - ), + photovoltaic_supply=pv_supply, + photovoltaic_arrays=pv_arrays, ), sap_building_parts=[ SapBuildingPart( @@ -3258,6 +3242,34 @@ def _default_missing_post_town(data: Dict[str, Any]) -> Dict[str, Any]: return {**data, "post_town": ""} +def _derive_built_form_16x(dwelling_type: Any) -> int: + """Derive the RdSAP `built_form` code from a cert's `dwelling_type` text when + the numeric field is omitted (some 16.0 certs). The form is stated in the + description for houses/bungalows ("Mid-terrace house" β†’ 4, "End-terrace" β†’ 3, + "Semi-detached" β†’ 2, "Detached" β†’ 1). Flats ("Ground-floor flat") do not encode + a form, so fall back to the modal 4 (Mid-terrace) β€” `built_form` is an ML-only + feature the SAP calculator never reads, so the fallback is SAP-neutral. + + `dwelling_type` arrives as a plain string or a `{'value': ...}` dict (real-API + shape); both are handled.""" + if isinstance(dwelling_type, dict): + dwelling_type = cast(Dict[str, Any], dwelling_type).get("value") + text = str(dwelling_type or "").lower() + if "semi-detached" in text: + return 2 + if "detached" in text: + return 1 + if "enclosed end" in text: + return 5 + if "enclosed mid" in text: + return 6 + if "end-terrace" in text: + return 3 + if "mid-terrace" in text or "terrace" in text: + return 4 + return 4 # flats / unstated form β†’ modal built_form (SAP- and gate-neutral) + + def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: """Rewrite a `SAP-Schema-16.2`/`16.3` API doc onto the `RdSAP-Schema-17.1` shape so it can reuse the tested `from_rdsap_schema_17_1` mapper. @@ -3294,6 +3306,13 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: if isinstance(windows, list) and windows: window_list: List[Any] = cast(List[Any], windows) d.setdefault("window", window_list[0]) + elif isinstance(windows, dict): + # Some 16.x certs (e.g. "High performance glazing" certs 9768-…-7974 / + # 8257-…-4992) lodge `windows` as a single dict rather than a list β€” take + # it directly. (These particular certs still fail loud below: they also omit + # door_count / habitable_room_count / glazed_area β€” genuinely insufficient + # data β€” but a windows-as-dict cert that DOES carry the rest now maps.) + d.setdefault("window", windows) d.setdefault("schema_version_original", d.get("schema_version", "")) # Some 16.x certs (notably 16.0) omit `tenure` β€” RdSapSchema17_1 requires it. # It is address/occupancy metadata the SAP cascade never reads, so default a @@ -3313,6 +3332,18 @@ def _normalize_sap_schema_16_x(data: Dict[str, Any]) -> Dict[str, Any]: # still fail loud β€” that is correct, the fabric data is insufficient. d.setdefault("insulated_door_count", 0) + # Some 16.x certs (notably 16.0, e.g. cert 8742-6624-9300-2780-4926) lodge + # `property_type`/`dwelling_type` but omit `built_form` β€” RdSapSchema17_1 + # requires it. `built_form` is an ML-only feature (the SAP-10 calculator never + # reads it β€” only `sap10_ml/transform`), so it is SAP-neutral; and a default is + # gate-neutral here (verified β€” the component-accuracy fixture has no + # built-form-missing cert, so unlike `multiple_glazed_proportion` it does not + # tip the donor pool). DERIVE it from the dwelling_type description where the + # form is stated (houses), falling back to the modal value for flats (whose + # dwelling_type β€” "Ground-floor flat" β€” does not encode form). + if "built_form" not in d: + d["built_form"] = _derive_built_form_16x(d.get("dwelling_type")) + # NB: we deliberately do NOT default `multiple_glazed_proportion` here. A 16.x # cert that omits it (e.g. 16.3 cert 0418-3986-7250-2884-7970) is left to fail # the RdSapSchema17_1 parse and be handled by the cohort skip-and-report path β€” diff --git a/datatypes/epc/domain/tests/test_from_rdsap_schema.py b/datatypes/epc/domain/tests/test_from_rdsap_schema.py index 77be7628..e0c6b1ba 100644 --- a/datatypes/epc/domain/tests/test_from_rdsap_schema.py +++ b/datatypes/epc/domain/tests/test_from_rdsap_schema.py @@ -191,6 +191,30 @@ class TestFromRdSapSchema20_0_0: schema = from_dict(RdSapSchema20_0_0, load("20_0_0.json")) return EpcPropertyDataMapper.from_rdsap_schema_20_0_0(schema) + def test_photovoltaic_supply_as_dict_list_is_mapped_not_crashed(self) -> None: + # 20.0.0 types `photovoltaic_supply` as the wrapper only (not the 21.0.x + # Union), so a cert lodging measured arrays as a LIST leaves the leaves as + # raw dicts. The mapper previously did `es.photovoltaic_supply.none_or_no_ + # details` β†’ "'list' object has no attribute 'none_or_no_details'", sinking + # the whole prediction cohort. Regression for cert 6102-6227-8000-0083-2292 + # (uprn 22086693). It now routes through `_map_schema_21_pv`, whose + # dict-tolerant array reader captures the arrays. + data = load("20_0_0.json") + data["sap_energy_source"]["photovoltaic_supply"] = [ + [{"pitch": 2, "peak_power": {"value": 1.14, "quantity": "kW"}, + "orientation": 5, "overshading": 1}], + [{"pitch": 2, "peak_power": 1.14, "orientation": 5, "overshading": 1}], + ] + schema = from_dict(RdSapSchema20_0_0, data) + + result = EpcPropertyDataMapper.from_rdsap_schema_20_0_0(schema) + + arrays = result.sap_energy_source.photovoltaic_arrays + assert arrays is not None and len(arrays) == 2 + assert arrays[0].peak_power == 1.14 + assert arrays[0].orientation == 5 + assert result.sap_energy_source.photovoltaic_supply is None + def test_uprn(self, result: EpcPropertyData) -> None: assert result.uprn == 12457 diff --git a/datatypes/epc/domain/tests/test_from_sap_schema.py b/datatypes/epc/domain/tests/test_from_sap_schema.py index d1bf36ed..4cc08213 100644 --- a/datatypes/epc/domain/tests/test_from_sap_schema.py +++ b/datatypes/epc/domain/tests/test_from_sap_schema.py @@ -523,6 +523,32 @@ class TestFromSapSchema16_2: assert epc.uprn == 100020933894 assert Sap10Calculator().calculate(epc).sap_score == 61 # lodged 56 + def test_16_x_missing_built_form_is_derived_from_dwelling_type(self) -> None: + # Some 16.0 certs (e.g. cert 8742-6624-9300-2780-4926) lodge + # `property_type`/`dwelling_type` but omit `built_form`, which RdSapSchema17_1 + # requires β€” previously aborted the prediction cohort. The normaliser derives + # it from the dwelling_type text ("Semi-detached house" β†’ 2). built_form is + # ML-only (SAP calc never reads it), so this is SAP- and gate-neutral. + data = load("sap_16_0.json") + del data["built_form"] + assert data["dwelling_type"] == "Semi-detached house" + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert isinstance(epc, EpcPropertyData) + assert epc.built_form == "2" + + def test_16_x_missing_built_form_for_flat_falls_back_to_modal(self) -> None: + # A flat's dwelling_type ("Ground-floor flat") does not encode a form, so the + # derivation falls back to the modal 4 (Mid-terrace) β€” keeps the cert mappable. + data = load("sap_16_0.json") + del data["built_form"] + data["dwelling_type"] = "Ground-floor flat" + + epc = EpcPropertyDataMapper.from_api_response(data) + + assert epc.built_form == "4" + def test_16_x_missing_insulated_door_count_defaults_to_zero(self) -> None: # Some 16.x certs lodge `door_count` but omit `insulated_door_count`, # which RdSapSchema17_1 requires β€” previously raised "missing required From 5737923622cdf46dc579ffe1d4354b47740d876c Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:01:34 +0000 Subject: [PATCH 7/9] 32 and delete in plan --- repositories/plan/plan_postgres_repository.py | 30 ++++++--- repositories/plan/plan_repository.py | 15 +++-- .../plan/test_plan_postgres_repository.py | 61 ++++++++++++++----- 3 files changed, 75 insertions(+), 31 deletions(-) diff --git a/repositories/plan/plan_postgres_repository.py b/repositories/plan/plan_postgres_repository.py index 376cf8b8..7be21bac 100644 --- a/repositories/plan/plan_postgres_repository.py +++ b/repositories/plan/plan_postgres_repository.py @@ -1,6 +1,6 @@ from __future__ import annotations -from sqlmodel import Session, col, delete +from sqlmodel import Session, col, update from domain.modelling.plan import Plan from infrastructure.postgres.modelling import PlanModel, RecommendationModel @@ -10,7 +10,12 @@ from repositories.plan.plan_repository import PlanRepository class PlanPostgresRepository(PlanRepository): """Maps a Plan and its Plan Measures onto the live ``plan`` / ``recommendation`` tables (ADR-0017). Does not commit β€” the Unit of Work - owns the transaction (ADR-0012).""" + owns the transaction (ADR-0012). + + A re-run INSERTs a fresh Plan rather than deleting the prior one (the cascade + delete was slow); when the new Plan is the default it demotes any prior + default Plan for the same (property_id, scenario_id) to ``is_default=False``, + so readers can select the current Plan via ``is_default=True``.""" def __init__(self, session: Session) -> None: self._session = session @@ -24,15 +29,20 @@ class PlanPostgresRepository(PlanRepository): portfolio_id: int, is_default: bool, ) -> int: - # Idempotent replace for (property_id, scenario_id): deleting the Plan - # cascades to its recommendation rows via the plan_id FK (ON DELETE - # CASCADE), so a re-run overwrites rather than duplicating (ADR-0012). - self._session.exec( # type: ignore[call-overload] - delete(PlanModel).where( - col(PlanModel.property_id) == property_id, - col(PlanModel.scenario_id) == scenario_id, + # Soft-replace (ADR-0012): keep prior Plans as history rather than DELETEing + # them β€” the cascade delete of recommendation rows was the slow part. When + # this Plan is the default, demote every prior Plan for the same + # (property_id, scenario_id) to is_default=False, so exactly one Plan for + # the pair stays default (the one just inserted). + if is_default: + self._session.exec( # type: ignore[call-overload] + update(PlanModel) + .where( + col(PlanModel.property_id) == property_id, + col(PlanModel.scenario_id) == scenario_id, + ) + .values(is_default=False) ) - ) plan_row = PlanModel.from_domain( plan, diff --git a/repositories/plan/plan_repository.py b/repositories/plan/plan_repository.py index 02bafe25..b534e8ea 100644 --- a/repositories/plan/plan_repository.py +++ b/repositories/plan/plan_repository.py @@ -8,10 +8,12 @@ from domain.modelling.plan import Plan class PlanRepository(ABC): """Persists a Plan (and its Plan Measures) for a Property + Scenario. - One Plan per (Property, Scenario). The write is idempotent on re-run: it - replaces the existing Plan for that pair rather than duplicating (ADR-0012 - / ADR-0017). `portfolio_id` and `is_default` are supplied by the - orchestrator (the former from the trigger, the latter from the Scenario). + A re-run INSERTs a fresh Plan and keeps the prior one as history rather than + deleting it. When the new Plan is the default, prior default Plans for the + same (Property, Scenario) are demoted to `is_default=False`, so the current + Plan is the one with `is_default=True` (ADR-0012 / ADR-0017). `portfolio_id` + and `is_default` are supplied by the orchestrator (the former from the + trigger, the latter from the Scenario). """ @abstractmethod @@ -24,6 +26,7 @@ class PlanRepository(ABC): portfolio_id: int, is_default: bool, ) -> int: - """Persist ``plan`` and return its Plan id, replacing any existing Plan - for ``(property_id, scenario_id)``.""" + """Persist ``plan`` and return its Plan id. Keeps prior Plans for + ``(property_id, scenario_id)`` as history; when ``is_default`` is True, + demotes those prior Plans to ``is_default=False``.""" ... diff --git a/tests/repositories/plan/test_plan_postgres_repository.py b/tests/repositories/plan/test_plan_postgres_repository.py index 3e428bd8..200c38d6 100644 --- a/tests/repositories/plan/test_plan_postgres_repository.py +++ b/tests/repositories/plan/test_plan_postgres_repository.py @@ -2,10 +2,11 @@ Plan Measures to the live ``plan`` / ``recommendation`` tables (ADR-0017). The Plan is the parent; each selected Plan Measure is a ``recommendation`` row -linked by the new ``plan_id`` FK. A re-run replaces (delete the Plan for the -(property, scenario) β†’ cascade its recommendations β†’ insert fresh), so the -batch write is idempotent (ADR-0012). COβ‚‚ is stored in tonnes (calculator kg -Γ· 1000) to match the live column contract. +linked by the new ``plan_id`` FK. A re-run INSERTs a fresh Plan and keeps the +prior one as history (no cascade delete); when the new Plan is the default it +demotes prior default Plans for the (property, scenario) to ``is_default=False`` +(ADR-0012). COβ‚‚ is stored in tonnes (calculator kg Γ· 1000) to match the live +column contract. """ from __future__ import annotations @@ -147,31 +148,61 @@ def test_save_persists_null_per_measure_savings_when_unbilled( assert rec_rows[0].energy_cost_savings is None -def test_save_is_idempotent_on_rerun_for_the_same_property_and_scenario( +def test_rerun_keeps_history_and_demotes_the_prior_default_plan( db_engine: Engine, ) -> None: - # Arrange β€” first run + # Arrange β€” first (default) run with Session(db_engine) as session: - PlanPostgresRepository(session).save( + first_id = PlanPostgresRepository(session).save( _plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True ) session.commit() - # Act β€” re-run the same (property, scenario) + # Act β€” re-run the same (property, scenario) as the default with Session(db_engine) as session: - PlanPostgresRepository(session).save( + second_id = PlanPostgresRepository(session).save( _plan(), property_id=10, scenario_id=7, portfolio_id=1, is_default=True ) session.commit() - # Assert β€” replaced, not duplicated (cascade removed the old measures) + # Assert β€” the prior Plan is kept as history (no delete), and only the new + # Plan is the default; exactly one Plan for the pair stays is_default=True. with Session(db_engine) as session: plan_rows = session.exec( select(PlanModel).where(col(PlanModel.property_id) == 10) ).all() - rec_rows = session.exec( - select(RecommendationModel).where(col(RecommendationModel.property_id) == 10) - ).all() + by_id = {p.id: p for p in plan_rows} - assert len(plan_rows) == 1 - assert len(rec_rows) == 1 + assert len(plan_rows) == 2 + assert first_id != second_id + assert by_id[first_id].is_default is False # demoted + assert by_id[second_id].is_default is True # the current default + assert sum(1 for p in plan_rows if p.is_default) == 1 + + +def test_rerun_as_non_default_does_not_demote_the_prior_default( + db_engine: Engine, +) -> None: + # Arrange β€” a default Plan exists + with Session(db_engine) as session: + first_id = PlanPostgresRepository(session).save( + _plan(), property_id=12, scenario_id=7, portfolio_id=1, is_default=True + ) + session.commit() + + # Act β€” re-run as NON-default (e.g. a non-default scenario); no demotion runs + with Session(db_engine) as session: + PlanPostgresRepository(session).save( + _plan(), property_id=12, scenario_id=7, portfolio_id=1, is_default=False + ) + session.commit() + + # Assert β€” the prior default is untouched (we only demote when saving a default) + with Session(db_engine) as session: + plan_rows = session.exec( + select(PlanModel).where(col(PlanModel.property_id) == 12) + ).all() + by_id = {p.id: p for p in plan_rows} + + assert len(plan_rows) == 2 + assert by_id[first_id].is_default is True From 119ff3740c642853ac3f089b92d7acc8e8fc87d7 Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:35:47 +0000 Subject: [PATCH 8/9] fix(task_handler): persist cloud_logs_url for modelling_e2e @task_handler never built or passed cloud_logs_url, so every app using it (incl. modelling_e2e) ran run_subtask with the None default and the CloudWatch deep-link was never saved onto the SubTask. @subtask_handler did this correctly. Extract the URL builder into a shared utilities/aws_lambda/cloud_logs.py (public cloudwatch_url()), use it from both handlers, and pass the URL into run_subtask from @task_handler. Add regression tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../utilities/aws_lambda/test_task_handler.py | 104 ++++++++++++++++++ utilities/aws_lambda/cloud_logs.py | 27 +++++ utilities/aws_lambda/subtask_handler.py | 22 +--- utilities/aws_lambda/task_handler.py | 3 + 4 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 tests/utilities/aws_lambda/test_task_handler.py create mode 100644 utilities/aws_lambda/cloud_logs.py diff --git a/tests/utilities/aws_lambda/test_task_handler.py b/tests/utilities/aws_lambda/test_task_handler.py new file mode 100644 index 00000000..fae35de2 --- /dev/null +++ b/tests/utilities/aws_lambda/test_task_handler.py @@ -0,0 +1,104 @@ +from collections.abc import Generator, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any +from uuid import UUID + +import pytest +from sqlalchemy import Engine +from sqlmodel import Session + +from domain.tasks.tasks import Source +from orchestration.task_orchestrator import TaskOrchestrator +from repositories.tasks.subtask_postgres_repository import SubTaskPostgresRepository +from repositories.tasks.task_postgres_repository import TaskPostgresRepository +from utilities.aws_lambda.task_handler import task_handler + + +@dataclass +class Harness: + orchestrator: TaskOrchestrator + tasks: TaskPostgresRepository + subtasks: SubTaskPostgresRepository + + @contextmanager + def factory(self) -> Generator[TaskOrchestrator, None, None]: + yield self.orchestrator + + +@pytest.fixture +def harness(db_engine: Engine) -> Iterator[Harness]: + with Session(db_engine) as session: + tasks = TaskPostgresRepository(session=session) + subtasks = SubTaskPostgresRepository(session=session) + yield Harness( + orchestrator=TaskOrchestrator(task_repo=tasks, subtask_repo=subtasks), + tasks=tasks, + subtasks=subtasks, + ) + + +def _direct_event(property_id: str) -> dict[str, Any]: + return {"property_id": property_id} + + +def test_task_handler_records_cloudwatch_url_on_subtask( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + # arrange + monkeypatch.setenv("AWS_REGION", "eu-west-2") + monkeypatch.setenv( + "AWS_LAMBDA_LOG_GROUP_NAME", "/aws/lambda/modelling-e2e" + ) + monkeypatch.setenv( + "AWS_LAMBDA_LOG_STREAM_NAME", "2026/05/20/[$LATEST]abc123" + ) + + @task_handler( + task_source="modelling_e2e", + source=Source.PROPERTY, + orchestrator_cm=harness.factory, + ) + def handler(body: dict[str, Any], context: Any) -> None: + return None + + # act + result = handler(_direct_event("prop-1"), context=None) + + # assert + subtask_id = result[0]["subtask_id"] + saved_url = harness.subtasks.get(UUID(subtask_id)).cloud_logs_url + assert saved_url is not None + assert saved_url.startswith( + "https://eu-west-2.console.aws.amazon.com/cloudwatch/home" + ) + # Log group / stream are console-encoded ("/" -> "$252F"). + assert "$252Faws$252Flambda$252Fmodelling-e2e" in saved_url + assert "$255B$2524LATEST$255D" in saved_url + + +def test_task_handler_leaves_cloudwatch_url_unset_outside_lambda( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + # arrange + for var in ( + "AWS_REGION", + "AWS_LAMBDA_LOG_GROUP_NAME", + "AWS_LAMBDA_LOG_STREAM_NAME", + ): + monkeypatch.delenv(var, raising=False) + + @task_handler( + task_source="modelling_e2e", + source=Source.PROPERTY, + orchestrator_cm=harness.factory, + ) + def handler(body: dict[str, Any], context: Any) -> None: + return None + + # act + result = handler(_direct_event("prop-1"), context=None) + + # assert + subtask_id = result[0]["subtask_id"] + assert harness.subtasks.get(UUID(subtask_id)).cloud_logs_url is None diff --git a/utilities/aws_lambda/cloud_logs.py b/utilities/aws_lambda/cloud_logs.py new file mode 100644 index 00000000..9a8da920 --- /dev/null +++ b/utilities/aws_lambda/cloud_logs.py @@ -0,0 +1,27 @@ +"""Build a CloudWatch console deep-link for the running Lambda invocation. + +Shared by @task_handler and @subtask_handler so both persist the same +`cloud_logs_url` onto the SubTask they run. +""" + +import os +from typing import Optional +from urllib.parse import quote + + +def _console_encode(value: str) -> str: + return quote(value, safe="").replace("%", "$25") + + +def cloudwatch_url() -> Optional[str]: + """Deep-link to this invocation's log stream, or None outside Lambda.""" + region = os.environ.get("AWS_REGION") + log_group = os.environ.get("AWS_LAMBDA_LOG_GROUP_NAME") + log_stream = os.environ.get("AWS_LAMBDA_LOG_STREAM_NAME") + if not (region and log_group and log_stream): + return None + return ( + f"https://{region}.console.aws.amazon.com/cloudwatch/home" + f"?region={region}#logsV2:log-groups/log-group/" + f"{_console_encode(log_group)}/log-events/{_console_encode(log_stream)}" + ) diff --git a/utilities/aws_lambda/subtask_handler.py b/utilities/aws_lambda/subtask_handler.py index e5ac086a..6b513ba4 100644 --- a/utilities/aws_lambda/subtask_handler.py +++ b/utilities/aws_lambda/subtask_handler.py @@ -6,12 +6,11 @@ TaskOrchestrator.run_subtask(...) calls. import json import logging -import os from contextlib import AbstractContextManager from functools import wraps from typing import Any, Callable, Optional, cast -from urllib.parse import quote +from utilities.aws_lambda.cloud_logs import cloudwatch_url from utilities.aws_lambda.default_orchestrator import default_orchestrator from utilities.aws_lambda.subtask_trigger_body import SubtaskTriggerBody from orchestration.task_orchestrator import TaskOrchestrator @@ -42,7 +41,7 @@ def subtask_handler( @wraps(func) def wrapper(event: dict[str, Any], context: Any) -> None: - cloud_logs_url = _cloudwatch_url() + cloud_logs_url = cloudwatch_url() with factory() as orchestrator: for record in _records(event): body = _parse_body(record) @@ -95,20 +94,3 @@ def _records(event: dict[str, Any]) -> list[dict[str, Any]]: if isinstance(raw_records, list): return [r for r in cast(list[Any], raw_records) if isinstance(r, dict)] return [event] - - -def _console_encode(value: str) -> str: - return quote(value, safe="").replace("%", "$25") - - -def _cloudwatch_url() -> Optional[str]: - region = os.environ.get("AWS_REGION") - log_group = os.environ.get("AWS_LAMBDA_LOG_GROUP_NAME") - log_stream = os.environ.get("AWS_LAMBDA_LOG_STREAM_NAME") - if not (region and log_group and log_stream): - return None - return ( - f"https://{region}.console.aws.amazon.com/cloudwatch/home" - f"?region={region}#logsV2:log-groups/log-group/" - f"{_console_encode(log_group)}/log-events/{_console_encode(log_stream)}" - ) diff --git a/utilities/aws_lambda/task_handler.py b/utilities/aws_lambda/task_handler.py index 34811515..43699aee 100644 --- a/utilities/aws_lambda/task_handler.py +++ b/utilities/aws_lambda/task_handler.py @@ -10,6 +10,7 @@ from contextlib import AbstractContextManager from functools import wraps from typing import Any, Callable, Optional, cast +from utilities.aws_lambda.cloud_logs import cloudwatch_url from utilities.aws_lambda.default_orchestrator import default_orchestrator from domain.tasks.tasks import Source from orchestration.task_orchestrator import TaskOrchestrator @@ -41,6 +42,7 @@ def task_handler( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @wraps(func) def wrapper(event: dict[str, Any], context: Any) -> Any: + cloud_logs_url = cloudwatch_url() with factory() as orchestrator: task_ids: list[dict[str, str]] = [] failures: list[dict[str, Any]] = [] @@ -66,6 +68,7 @@ def task_handler( orchestrator.run_subtask( subtask.id, work=lambda body=body: func(body, context), + cloud_logs_url=cloud_logs_url, ) except Exception: logger.exception( From a7d3e001786431de95707bbd4b6931c50d67c99b Mon Sep 17 00:00:00 2001 From: Jun-te Kim Date: Tue, 23 Jun 2026 15:38:38 +0000 Subject: [PATCH 9/9] 32 --- .../uprn_22086693/epc.json | 438 ++++++++++++++++++ .../lambda/modelling_e2e/variables.tf | 2 +- scripts/hyde/elmhurst_download.py | 2 +- 3 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json diff --git a/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json new file mode 100644 index 00000000..343215d1 --- /dev/null +++ b/backend/epc_api/json_samples/real_life_examples/RdSAP-Schema-20.0.0/uprn_22086693/epc.json @@ -0,0 +1,438 @@ +{ + "uprn": 22086693, + "roofs": [ + { + "description": { + "value": "Pitched, 200 mm loft insulation", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + { + "description": { + "value": "Flat, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + } + ], + "walls": [ + { + "description": { + "value": "Cavity wall, as built, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 2, + "environmental_efficiency_rating": 2 + }, + { + "description": { + "value": "Cavity wall, as built, insulated (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "floors": [ + { + "description": { + "value": "Suspended, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + { + "description": { + "value": "Solid, no insulation (assumed)", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + } + ], + "status": "entered", + "tenure": 2, + "window": { + "description": { + "value": "Fully double glazed", + "language": "1" + }, + "energy_efficiency_rating": 3, + "environmental_efficiency_rating": 3 + }, + "addendum": { + "addendum_numbers": [ + 8 + ], + "cavity_fill_recommended": "true" + }, + "lighting": { + "description": { + "value": "Low energy lighting in all fixed outlets", + "language": "1" + }, + "energy_efficiency_rating": 5, + "environmental_efficiency_rating": 5 + }, + "postcode": "BN2 9ZN", + "hot_water": { + "description": { + "value": "From main system", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + }, + "post_town": "BRIGHTON", + "built_form": 2, + "created_at": "2022-03-22 14:09:56", + "door_count": 1, + "glazed_area": 1, + "glazing_gap": "16+", + "region_code": 14, + "report_type": 2, + "sap_heating": { + "cylinder_size": 1, + "water_heating_code": 901, + "water_heating_fuel": 26, + "instantaneous_wwhrs": { + "rooms_with_bath_and_or_shower": 1, + "rooms_with_mixer_shower_no_bath": 0, + "rooms_with_bath_and_mixer_shower": 0 + }, + "secondary_fuel_type": 29, + "main_heating_details": [ + { + "has_fghrs": "N", + "main_fuel_type": 26, + "boiler_flue_type": 2, + "fan_flue_present": "Y", + "heat_emitter_type": 1, + "emitter_temperature": 0, + "main_heating_number": 1, + "main_heating_control": 2106, + "main_heating_category": 2, + "main_heating_fraction": 1, + "sap_main_heating_code": 113, + "central_heating_pump_age": 0, + "main_heating_data_source": 2 + } + ], + "immersion_heating_type": "NA", + "secondary_heating_type": 691, + "has_fixed_air_conditioning": "false" + }, + "sap_version": 9.94, + "schema_type": "RdSAP-Schema-20.0.0", + "uprn_source": "Energy Assessor", + "country_code": "EAW", + "main_heating": [ + { + "description": { + "value": "Boiler and radiators, mains gas", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "dwelling_type": { + "value": "Semi-detached house", + "language": "1" + }, + "language_code": 1, + "property_type": 0, + "address_line_1": "30 Hallett Road", + "assessment_type": "RdSAP", + "completion_date": "2022-03-22", + "inspection_date": "2022-03-17", + "extensions_count": 1, + "measurement_type": 1, + "total_floor_area": 80, + "transaction_type": 8, + "conservatory_type": 1, + "heated_room_count": 4, + "pvc_window_frames": "true", + "registration_date": "2022-03-22", + "sap_energy_source": { + "mains_gas": "Y", + "meter_type": 1, + "photovoltaic_supply": [ + [ + { + "pitch": 2, + "peak_power": { + "value": 1.14, + "quantity": "kW" + }, + "orientation": 5, + "overshading": 1, + "pv_connection": 2 + } + ], + [ + { + "pitch": 2, + "peak_power": { + "value": 1.14, + "quantity": "kW" + }, + "orientation": 7, + "overshading": 1, + "pv_connection": 2 + } + ] + ], + "wind_turbines_count": 0, + "wind_turbines_terrain_type": 2 + }, + "secondary_heating": { + "description": { + "value": "Room heaters, electric", + "language": "1" + }, + "energy_efficiency_rating": 0, + "environmental_efficiency_rating": 0 + }, + "lzc_energy_sources": [ + 11 + ], + "sap_building_parts": [ + { + "identifier": "Main Dwelling", + "wall_dry_lined": "N", + "wall_thickness": 280, + "floor_heat_loss": 7, + "roof_construction": 4, + "wall_construction": 4, + "building_part_number": 1, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.3, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 36.86, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.8, + "quantity": "metres" + }, + "floor_construction": 2, + "heat_loss_perimeter": { + "value": 13.4, + "quantity": "metres" + } + }, + { + "floor": 1, + "room_height": { + "value": 2.3, + "quantity": "metres" + }, + "total_floor_area": { + "value": 36.86, + "quantity": "square metres" + }, + "party_wall_length": { + "value": 6.8, + "quantity": "metres" + }, + "heat_loss_perimeter": { + "value": 17.4, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "C", + "party_wall_construction": 0, + "wall_thickness_measured": "Y", + "roof_insulation_location": 2, + "roof_insulation_thickness": "200mm", + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI" + }, + { + "identifier": "Extension", + "wall_dry_lined": "N", + "floor_heat_loss": 7, + "roof_construction": 1, + "wall_construction": 4, + "building_part_number": 2, + "sap_floor_dimensions": [ + { + "floor": 0, + "room_height": { + "value": 2.3, + "quantity": "metres" + }, + "floor_insulation": 1, + "total_floor_area": { + "value": 6, + "quantity": "square metres" + }, + "party_wall_length": 0, + "floor_construction": 1, + "heat_loss_perimeter": { + "value": 7, + "quantity": "metres" + } + } + ], + "wall_insulation_type": 4, + "construction_age_band": "H", + "party_wall_construction": 0, + "wall_thickness_measured": "N", + "roof_insulation_location": 6, + "wall_insulation_thickness": "NI", + "floor_insulation_thickness": "NI", + "flat_roof_insulation_thickness": "AB" + } + ], + "low_energy_lighting": 100, + "solar_water_heating": "N", + "habitable_room_count": 4, + "heating_cost_current": { + "value": 710, + "currency": "GBP" + }, + "insulated_door_count": 0, + "co2_emissions_current": 2.4, + "energy_rating_average": 60, + "energy_rating_current": 72, + "lighting_cost_current": { + "value": 74, + "currency": "GBP" + }, + "main_heating_controls": [ + { + "description": { + "value": "Programmer, room thermostat and TRVs", + "language": "1" + }, + "energy_efficiency_rating": 4, + "environmental_efficiency_rating": 4 + } + ], + "multiple_glazing_type": 3, + "open_fireplaces_count": 0, + "has_hot_water_cylinder": "false", + "heating_cost_potential": { + "value": 548, + "currency": "GBP" + }, + "hot_water_cost_current": { + "value": 95, + "currency": "GBP" + }, + "mechanical_ventilation": 0, + "percent_draughtproofed": 100, + "suggested_improvements": [ + { + "sequence": 1, + "typical_saving": { + "value": 112, + "currency": "GBP" + }, + "indicative_cost": "\u00a3500 - \u00a31,500", + "improvement_type": "B", + "improvement_details": { + "improvement_number": 6 + }, + "improvement_category": 5, + "energy_performance_rating": 77, + "environmental_impact_rating": 75 + }, + { + "sequence": 2, + "typical_saving": { + "value": 49, + "currency": "GBP" + }, + "indicative_cost": "\u00a3800 - \u00a31,200", + "improvement_type": "W1", + "improvement_details": { + "improvement_number": 57 + }, + "improvement_category": 5, + "energy_performance_rating": 79, + "environmental_impact_rating": 78 + }, + { + "sequence": 3, + "typical_saving": { + "value": 30, + "currency": "GBP" + }, + "indicative_cost": "\u00a34,000 - \u00a36,000", + "improvement_type": "N", + "improvement_details": { + "improvement_number": 19 + }, + "improvement_category": 5, + "energy_performance_rating": 81, + "environmental_impact_rating": 80 + } + ], + "co2_emissions_potential": 1.5, + "energy_rating_potential": 81, + "lighting_cost_potential": { + "value": 74, + "currency": "GBP" + }, + "schema_version_original": "LIG-19.0", + "alternative_improvements": [ + { + "improvement": { + "sequence": 1, + "typical_saving": { + "value": 59, + "currency": "GBP" + }, + "improvement_type": "Q2", + "improvement_details": { + "improvement_number": 55 + }, + "improvement_category": 6, + "energy_performance_rating": 80, + "environmental_impact_rating": 78 + } + } + ], + "hot_water_cost_potential": { + "value": 66, + "currency": "GBP" + }, + "renewable_heat_incentive": { + "water_heating": 2107, + "impact_of_cavity_insulation": -1824, + "space_heating_existing_dwelling": 9574 + }, + "energy_consumption_current": 171, + "has_fixed_air_conditioning": "false", + "multiple_glazed_proportion": 100, + "calculation_software_version": "2.1.0.1", + "energy_consumption_potential": 103, + "environmental_impact_current": 69, + "fixed_lighting_outlets_count": 10, + "windows_transmission_details": { + "u_value": 2.6, + "data_source": 2, + "solar_transmittance": 0.76 + }, + "current_energy_efficiency_band": "C", + "environmental_impact_potential": 80, + "has_heated_separate_conservatory": "false", + "potential_energy_efficiency_band": "B", + "co2_emissions_current_per_floor_area": 30, + "low_energy_fixed_lighting_outlets_count": 10 +} \ No newline at end of file diff --git a/deployment/terraform/lambda/modelling_e2e/variables.tf b/deployment/terraform/lambda/modelling_e2e/variables.tf index 02cba590..2595d86c 100644 --- a/deployment/terraform/lambda/modelling_e2e/variables.tf +++ b/deployment/terraform/lambda/modelling_e2e/variables.tf @@ -26,7 +26,7 @@ variable "reserved_concurrent_executions" { variable "maximum_concurrency" { type = number - default = 16 + default = 32 description = "Maximum concurrent Lambda invocations from the SQS trigger." } diff --git a/scripts/hyde/elmhurst_download.py b/scripts/hyde/elmhurst_download.py index f148ba28..0c317033 100644 --- a/scripts/hyde/elmhurst_download.py +++ b/scripts/hyde/elmhurst_download.py @@ -28,7 +28,7 @@ SESSION_DIR = HERE / ".elmhurst-session" SAMPLE_DIR = ( HERE.parent.parent / "backend/epc_api/json_samples/real_life_examples" - / "SAP-Schema-16.3/uprn_100061905751" + / "SAP-Schema-16.0/uprn_10070004512" ) ASSESSMENT_GUID = "B44A0DB4-4C08-4241-B818-86F060172105"