The fixture lodges is_exposed_floor=True, which was silently dropped on
save->reload before this branch — so the orchestrator (reads the DB) modelled
the floor as not-exposed. Now it round-trips, the exposed floor is honoured,
and the ASHP-led max-gain fallback on this gas dwelling is bill-neutral
(marginally negative) rather than bill-positive: large energy saving, but the
gas->electricity fuel switch offsets the GBP saving. Same optimal package,
same telescoping; only the bill-savings sign assumption changed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ADR-0036 guard only inspected EpcPropertyData's top-level fields, so a
dropped field on a NESTED object (the PV-array list, the floor heat-loss
flags) slipped straight through. Generalise it to walk every domain
dataclass reachable from EpcPropertyData and check each field is
reconstructed by a _compose/_to_* mapper or allow-listed (per-field or
whole-class), keyed by Class.field.
Surfaced 14 pre-existing nested gaps the old guard was blind to: 7 are
calculator-read with no FE column (scoring-relevant silent-drop, same class
as the PV bug — tracked follow-up), the rest dormant or awaiting FE tables.
Each is now explicit and justified.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sap_energy_source.photovoltaic_arrays has no table, so every array is
dropped on save — worth ~12 SAP points on an electrically-heated dwelling
(persist != score). Inject two ordered arrays onto a PV-free fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
is_exposed_floor / is_above_partially_heated_space have no
epc_floor_dimension column, so a True flag round-trips back to the False
default and silently flips the floor's heat-loss path (persist != score).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A Landlord heating-system override was applied as a sparse patch, so the
replaced system's fields bled through. A storage flat reclassified as a gas
combi (property 728513) kept mains_gas=False, heating category 7, the 2401
storage charge control, a Dual meter and an electric-immersion cylinder — an
incoherent record that gated out the gas-boiler-upgrade Measure and made the
heating Generator read the dwelling as off-gas (offering HHRSH storage).
Extend the ADR-0035 drag-along to gas boilers (Table 4b 102/104/120): the
overlay now sets the whole coherent companion set — mains_gas, gas main fuel,
heating category 2, fanned flue, full modern controls (2106), a single-rate
meter, and hot water from the main system with the cylinder set from the boiler
type (combi → none, regular/CPSU → cylinder). The main_fuel overlay also flips
mains_gas=True for a "mains gas" fuel. Non-off-peak archetypes now drag an
explicit Single meter so a system switch never leaves a stale Dual.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The heating-donor display synthesis reads donor.epc.main_heating, which has no
dataclass default — so a partial object.__new__ EpcPropertyData must set it.
test_validation's _comparable builder didn't, failing the two leave-one-out
scorer tests in CI (the full epc_prediction suite wasn't run pre-push).
main_heating_controls / sap_ventilation default to None via class attributes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three corrections found by re-running property 742003 end-to-end:
- roofSegmentStats are POSITIONAL — real responses omit the segmentIndex field
the fixture happened to carry; key the centre/area lookup by array position.
- Base the cap on ground_floor_area (the footprint the roof covers), not the
greatest per-storey area; roof_area is the fallback.
- Clamp the basis by total_floor_area: predicted EPCs borrow the structural
template's geometry (742003: a 118.62 m² MAIN ground floor) decoupled from
the predicted 55 m² (ADR-0029), so without the clamp the cap reads the
template's larger footprint.
Result: 742003 plan A/92.4 (16 kWp) -> C/74.4 (6.4 kWp). 29 solar tests +
orchestration threading + products green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
select_conservative_configs must accept the dwelling's roof area and cap panels
to its usable roof (ADR-0038) — bounding a 55m² dwelling to ~16 panels under
Google footprint conflation, while staying a no-op on correctly-matched homes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Dwelling-Roof Cap (ADR-0038) sizes by usable roof area and ranks segments
by distance from the dwelling, so the projection must carry each panel's
footprint and each segment's centre + area (from roofSegmentStats).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prediction never synthesises ventilation — it keeps the size-template's
sap_ventilation, so a predicted dwelling in an MEV/MVHR neighbourhood is scored
+ displayed as natural (predicted property 721167 follow-up). Mode the
mechanical_ventilation_kind across the cohort like glazing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_apply_heating_donor copies the donor's calc sap_heating but leaves the
display rows (main_heating, main_heating_controls) on the structural template
— incoherent, and 'Heating Control: Unknown' when the template lodged no
control (predicted property 721167, ADR-0029 follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When refetch_epc=False and no stored lodged EPC exists, the handler no longer
falls back to a live EPC API call — it treats the property as EPC-less and
hands it to the prediction path. This keeps REFETCH_EPC (lodged path) and
REPREDICT_EPC (prediction path) cleanly independent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Full-SAP certs mapped property_type=None, so the hard cohort filter silently
excluded them as comparables. Correctly typing them admits real lodged EPCs as
donors — a ground-truth-method change (cf #1245). Net over the n=36 fixture: 16
components better, 4 worse, 6 unchanged; gains concentrated in the physical
characteristics full-SAP certs measure (window_count 3.83->1.69, building_parts,
total_window_area, floor_construction, construction_age_band, glazing, walls).
The 4 that fell are new-build-vs-old-stock service mismatch on 1-2 targets each
(heating/water fuel, cylinder insulation) + floor_area. Tighten 16, loosen 4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Route the Google Solar client through the shared call_with_retry with
full jitter (de-synchronises the 32 concurrent containers per Google's
"avoid synchronised requests" guidance), honouring Retry-After, a 60s max
backoff (rides out the 600 QPM per-minute window), and 6 bounded retries.
429/5xx/transport errors are transient; other 4xx propagate immediately;
404-entity-not-found stays BuildingInsightsNotFoundError. On exhaustion a
TransientHttpError surfaces so the subtask fails and is re-triggered (no
silent degrade).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Even after batching the data writes, the handler still wrote to the DB per
property through the orchestrator's SubTask bookkeeping: create + start +
complete each self-committed, and _cascade re-listed every sibling and re-saved
the parent on every transition — ~5 writes per property plus an O(N^2) cascade.
- TaskOrchestrator.run_subtasks: create all children in one INSERT, run each
(failures isolated per child), then persist all terminal states in one bulk
save and cascade the parent once. Children go WAITING -> terminal; the
transient IN_PROGRESS row is never written.
- SubTaskRepository.create_many / save_many (bulk INSERT / bulk fetch + update).
- _cascade short-circuits when the Task is already FAILED (terminal) — skips the
sibling roll-up entirely.
- modelling_e2e handler fans out via run_subtasks instead of per-property
create_child_subtask + run_subtask.
Per N-property batch the SubTask bookkeeping drops from ~5N writes + an O(N^2)
cascade to ~2 writes + 1 cascade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The handler fired ~2+2N read round-trips and N+N write transactions per
SQS batch, pinning RDS CPU under ~32 concurrent containers on pool_size=1.
Reads: merge the duplicate property query and add overrides_for_many /
SolarRepository.get_many so overrides, solar, and property rows each load
in one query (2+2N -> 3).
Writes: buffer each modelled property's persistence intent in memory
(_PropertyWrite) during the loop, then flush the whole batch in one
PostgresUnitOfWork with a single commit, and run the baseline orchestrator
once for all written ids (N+N -> 2 transactions). Per-property modelling
failures stay isolated in the loop; the batch write is all-or-nothing and
retried via SQS (saves are idempotent upserts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An Economy-7 storage dwelling now prices heating at the 0.20-day/0.80-
night blend through cert -> calculator -> bill, instead of raising
UnpricedFuel and aborting the modelling_e2e batch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the hot-water (Table 13 / HP-DHW), secondary (direct-acting),
main-2 and ALL_OTHER_USES High-Rate Fractions on CalculatorInputs from
the same Table 12a helpers the SAP cost path uses, so Bill Derivation's
day/night split matches the rating's exactly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The modelling_e2e Lambda runs on a single-connection pool (pool_size=1,
max_overflow=0) so one invocation uses one Postgres connection. But re-hydrating
a Property through PostgresUnitOfWork resolved its Landlord Overrides through a
PropertyOverridesPostgresReader built from the unit's session *factory* — which
opens a brand-new Session per call. While the unit's own read transaction was
still open (PropertyPostgresRepository.get_many had checked out the connection),
that second Session asked the pool for a second connection, found none, and timed
out after 30s:
QueuePool limit of size 1 overflow 0 reached, connection timed out, timeout 30.00
The baseline stage (PropertyBaselineOrchestrator.run -> uow.property.get_many ->
landlord overrides) hit this on every invocation.
Read the overrides on the unit's OWN session instead. property_overrides is
committed reference data, so reading it inside the unit's transaction sees the
same rows and keeps the invocation on one connection. Extract the query/mapping
into a shared helper and add OpenSessionPropertyOverridesReader (reads on a
caller-owned, already-open session without closing it) for the unit; the
standalone PropertyOverridesPostgresReader still opens its own short session for
use outside a unit.
Regression test pins the invariant with a real pool_size=1/max_overflow=0 engine:
without the fix it reproduces the exact QueuePool timeout.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The modelling_e2e Lambda held up to ~4 concurrent Postgres connections per
invocation: the read Session stayed open across the write loop (the catalogue
was queried live and overrides were read per-Property), each per-Property Unit
of Work opened a second, and the TaskOrchestrator ran on its own NullPool
engine — so the pool needed pool_size=2 + max_overflow=1 just for the modelling
work. Under 32 concurrent containers that approached RDS max_connections.
Restructure the handler to read everything up front — overrides, Scenario, an
in-memory catalogue snapshot, and stored Solar — through one short-lived read
Session, close it, then write each Property in a sequential Unit of Work. The
read and write Sessions no longer overlap, so the engine drops to pool_size=1,
max_overflow=0. Fold the orchestrator onto the same pooled engine: its repos
commit on every save, releasing the connection between bookkeeping calls, so it
holds none during the work. One invocation now uses one connection at a time.
The catalogue becomes a per-invocation snapshot (MaterialSnapshotRepository),
mirroring ProductPostgresRepository.get exactly — same drift mapping, lowest-id
pick, and errors — but priced after the Session closes. Transaction isolation
is preserved: per-Property writes and orchestrator bookkeeping keep their own
independent transactions, just drawn sequentially from a single connection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_predict_epc returned None for three unrelated causes — unresolved
property_type, an empty same-type cohort, and a degenerate (no MAIN part)
prediction — which the handler collapsed into one generic "not predictable"
string. The SubTask output could not say which cause fired or which data to
fix.
Raise a specific PropertyNotModellableError subclass per cause, each carrying
the property's identity (property_id, uprn, postcode, portfolio_id) and
cause-specific context. The unresolved-property-type message points at the
likely missing/contradictory Landlord Override. All subclass ValueError, so the
per-property failure boundary keeps catching them and records str(exc).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fail if any EpcPropertyData field is neither reconstructed by _compose nor on a
documented allow-list, turning latent persistence gaps into explicit decisions
(would have caught the conservatory and roof-window drops). ADR-0036.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persist SapConservatory as five nullable conservatory_* columns on epc_property
(1:1 with the dwelling) and rebuild it in _compose, so the §6.1 fold survives
save -> reload -> score. Without this the scored (re-hydrated) EPC silently
dropped the conservatory (persist != score) — a latent gap shared with the
21.0.1 path. Adds a deep-equality round-trip test. ADR-0036.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>