# EPC Prediction — production wiring handover (for Jun-te) The EPC Prediction **gap-fill** is wired end-to-end behind seams, with one real dependency stubbed: reading an EPC-less Property's resolved Landlord Overrides. This note is what's needed to finish it once your `property_overrides` read path lands. Design is **ADR-0031**; terms in **CONTEXT.md** (EPC Prediction, Effective EPC, EPC Anomaly Flag). ## What's already built (slices 5a–5e, all on `feature/epc-prediction`) - **5a** `Property.predicted_epc` slot + a `"predicted"` `source_path` / `effective_epc` branch — used only when there's no lodged EPC and no Site Notes (a real source always wins). - **5b** `ComparablePropertiesRepository.candidates_for(postcode)` + `EpcComparablePropertiesRepository` adapter (postcode search → per-cert fetch → batched UPRN→coords). Composes with `EpcClientService` + `GeospatialS3Repository`. - **5c** EPC store `source` discriminator (`lodged` | `predicted`) so the two coexist per property; `get_predicted_for_property` / `_for_properties`; `PropertyPostgresRepository` hydrates `predicted_epc`. **Needs a DB migration — see `docs/MIGRATION_NOTE_predicted_epc_source.md`.** - **5d** `build_prediction_target(identity, coords, attributes)` + the eligibility **gate** (unknown `property_type` → not predicted). Override attributes come through the `PredictionTargetAttributesReader` port (the stub). - **5e** `IngestionOrchestrator` wiring: when the three prediction collaborators are injected, an EPC-less Property is predicted from its cohort and persisted to the predicted slot. The collaborators are **optional** — unwired, ingestion is unchanged. ## Your part — three things ### 1. Implement `PredictionTargetAttributesReader` (the stub) `repositories/property/prediction_target_attributes_reader.py` defines the port: `attributes_for(property_id) -> PredictionTargetAttributes` (property_type, built_form, wall_construction). Build the adapter as a read over the `property_overrides` fact layer (the finaliser writes it via `PropertyOverrideRepository.upsert_all`; you're adding the read side). **Code-space gotcha.** `select_comparables` filters `comparable.epc.property_type == target.property_type`, and the cohort EPCs carry gov **API codes** (e.g. `"0"`/`"2"`). Landlord Overrides resolve to enum *value* strings (e.g. `"House"`). Your adapter must map override value → the API-code space, or `property_type` will never match and every cohort comes back empty. Same for `built_form`. (`domain/epc/property_type.py`, `built_form_type.py` are the enums; `datatypes/epc/domain/epc_codes.csv` has the code table.) `property_type` unresolved → return `PredictionTargetAttributes(property_type=None)` so the gate skips the Property. ### 2. Run the Drizzle migration `epc_property.source` column — full spec in `docs/MIGRATION_NOTE_predicted_epc_source.md` (column + default `'lodged'` + relax any `property_id` uniqueness to `(property_id, source)`). ### 3. Pass the two collaborators at the composition root This is now wired: `build_first_run_pipeline` (in `applications/ara_first_run/handler.py`) already constructs `epc_prediction=EpcPrediction()` and accepts the other two as optional params that it threads into the `IngestionOrchestrator`. So the on-switch is just supplying them once they exist: ```python build_first_run_pipeline( ..., comparables_repo=EpcComparablePropertiesRepository(epc_client, geospatial_repo), prediction_attributes_reader=, # task #1 ) ``` `epc_client` is the same EPC source client behind `epc_fetcher` (the concrete `EpcClientService` exposes `search_by_postcode` + `get_by_certificate_number`), so build it alongside the other source clients in `_source_clients_from_env` (pending #1136). Until **both** are passed, ingestion ignores prediction — no orchestrator or handler edits needed, just the two arguments. ## One open item — Validation Cohort exclusion A predicted-source Property has **no real lodged record**, so it must not be scored as if it did (CONTEXT: Validation Cohort; ADR-0031 dec-3). There is **no Validation-Cohort code path today** to exclude it from — when one is built (or in any QA that compares `calc(effective_epc)` vs lodged), exclude on the structural signal: ```python if prop.source_path == "predicted": continue # predicted EPC — no ground truth to validate against ``` Note too: `PropertyBaselinePerformance.lodged` is derived from `effective_epc` regardless of source (`property_baseline_orchestrator` → `lodged_performance`), so for a predicted Property that "lodged" is synthesised, not real. Decide whether baseline should null/flag it for predicted properties when this lands. ## Anomaly dual-use (later, not now) Slice-5 is gap-fill only (`epc is None`). The slot model already supports predicting for *every* Property to compare predicted vs lodged (**EPC Anomaly Flags**) — see ADR-0031 dec-4. Reuses the same `ComparableProperties` repo + the predicted slot.