Commit graph

168 commits

Author SHA1 Message Date
Jun-te Kim
daa1cd7967 feat(address2uprn): withhold ambiguous cross-row UPRN matches (ADR-0057)
Phase 1 of confirming UPRNs before finalise. address2uprn matched each
row independently, so one UPRN could be the best match for two distinct
addresses (a coarse EPC record absorbing several real addresses, e.g.
flats in a block). Those distinct addresses were then silently merged by
the property identity insert, and collided in property_overrides.

resolve_group_ambiguity() withholds a UPRN claimed by >=2 distinct
normalised addresses within a postcode group (keeps genuine same-address
re-listings), and the handler now emits an address2uprn_status column
(matched | ambiguous_duplicate | unmatched | invalid_postcode | error).
Withheld rows drop to a null UPRN but keep their lexiscore for triage on
the (upcoming) confirmation page.

Also adds the ADR-0057 backstop dedup in property_overrides upsert_all so
the ON CONFLICT statement can never double-touch a row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:15:59 +00:00
Daniel Roth
7ad0dc0595 Merge branch 'main' into feature/abri-api-integration 2026-07-07 12:46:17 +00:00
Daniel Roth
a3985389ab The deal row names the job number client_booking_reference, matching HubSpot and the schema 🟪
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:43:40 +00:00
Daniel Roth
3e11dc54e5 The deal database gateway reads back a deal's recorded job_no 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:55:24 +00:00
Daniel Roth
28c7e44c95 The deal database gateway records a deal's OpenHousing job_no 🟩
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:54:16 +00:00
Daniel Roth
d33c171494 The deal database gateway records a deal's OpenHousing job_no 🟥
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:53:42 +00:00
Khalim Conn-Kowlessar
f32b0d8405 An expired-source EPC occupies the predicted slot without stranding rows 🟩
EpcSource widens to "expired" (ADR-0054; the column is TEXT — no
migration). "predicted"/"expired" form one slot family: _slot_sources
routes every slot read and slot-clearing delete through the family, so a
re-ingestion flipping the flavour replaces the row instead of stranding
its sibling. FakeEpcRepo mirrors the family.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:33:16 +00:00
Khalim Conn-Kowlessar
f9f80f7184 Resolve a UPRN to its historic EPC record by exact match 🟩
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:17:54 +00:00
Khalim Conn-Kowlessar
4bbcdb6a61 Resolve a UPRN to its historic EPC record by exact match 🟥
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 08:17:07 +00:00
Khalim Conn-Kowlessar
cbffae07b8 Annotate locals assigned from cross-module calls in the historic-EPC stack 🟪
Review ask (dancafc): resolver/repository locals now carry explicit types
(matches: list[ScoredHistoricEpc], records: list[HistoricEpc], df:
pd.DataFrame, ...) so the flow reads without chasing callee signatures.
CLAUDE.md's Type Safety section gains the rule so future sessions enforce it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:59:13 +00:00
Khalim Conn-Kowlessar
246834fac0 Map shard rows to HistoricEpc field-by-field so every column is pyright-checked 🟩
The row→domain mapper now names all 93 constructor arguments explicitly
instead of splatting a lowercased dict, takes a plain Mapping (a
DataFrame.to_dict("records") row) instead of a pandas Series, and ignores
columns the domain type doesn't know. A missing/renamed CSV column fails
loudly as a KeyError at the row. Both iterrows() call sites move to
to_dict("records") — pandas-stubs types iterrows' Series unparameterized,
which strict mode rejects. pandas-stubs + boto3-stubs[s3] make the stack
check clean: pyright strict is now 0 errors across the PR's files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:58:09 +00:00
Khalim Conn-Kowlessar
ab245de68d Merge remote-tracking branch 'origin/main' into feature/historic-epc-repository 2026-07-04 11:19:59 +00:00
Daniel Roth
3b4415e773 Rename DB column energy_mains_gas → energy_gas_connection_available
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 11:22:29 +00:00
Daniel Roth
a174301136 rename mains_gas to gas_connection_available 2026-07-01 10:50:58 +00:00
Khalim Conn-Kowlessar
dbcd2e3840 Run the fuel-mismatch check on every Property's resolved overrides 🟩
Wire flag_fuel_mismatch into the two override-resolution paths (the property
repository and the modelling-e2e handler), keeping overlays_from a pure mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:21:32 +00:00
Khalim Conn-Kowlessar
13a6a213fb Treat a same-family fuel refinement as consistent, not a mismatch 🟩
Compare fuel families (not exact codes), so a solid-fuel room heater refined to
smokeless/dual/biomass is consistent; only a different family (gas/electric on a
solid heater) is logged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:18:51 +00:00
Khalim Conn-Kowlessar
79fe54a822 Log a landlord fuel that contradicts the heating archetype's natural fuel 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:16:14 +00:00
Khalim Conn-Kowlessar
6276730cfe A main_fuel override beats a heating archetype's natural-fuel default 🟩
overlays_from now applies main_fuel after main_heating_system (stable sort), so
an explicit landlord fuel wins the natural-fuel default the heating archetype
drags, regardless of override row order. apply_simulations is last-wins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 17:06:56 +00:00
Jun-te Kim
7aea692521 historic EPC: read via infrastructure/s3, not the utils.s3 utility
HistoricEpcS3Repository reached into utils/s3.py (read_csv_gz_from_s3 +
parse_s3_uri), the legacy utility that self-constructs boto3 inside free
functions. The other S3 repositories deliberately depend on the
infrastructure/s3 layer instead (UnstandardisedAddressListCsvS3Repository
injects a CsvS3Client). Bring historic EPC into line.

- Add GzipCsvS3Client(S3Client) in infrastructure/s3: read_csv_gz(key) ->
  DataFrame (get_object + gzip decode).
- Inject it into HistoricEpcS3Repository; the bucket lives in the client and
  the repo only builds the per-postcode key + maps rows (no S3/HTTP code).
  Add with_default_s3_client(s3_root) for composition roots.
- Update main.py and the match_addresses_for_postcode seam to the factory.
- Repo tests inject a real GzipCsvS3Client over a controlled boto stub
  (exact key assertions + AccessDenied); add a moto-based client test and a
  factory test covering s3_root -> bucket+key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQE5TsSuQTeNSCSz9A9GQf
2026-06-30 09:19:57 +00:00
Jun-te Kim
1ebced2c42 Merge remote-tracking branch 'origin/main' into feature/historic-epc-repository 2026-06-29 15:52:05 +00:00
Jun-te Kim
0536f70162 Take a Postcode value object at the historic EPC repository boundary 🟩
The port accepts a normalised Postcode and rejects malformed/empty ones via
Postcode.is_valid() (PostcodeNotFound) — dropping the per-lookup postcodes.io
HTTP call and the cross-module use of the private _sanitise_postcode. Mapper
helper promoted to public.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 15:24:18 +00:00
Daniel Roth
4764bc7c15 Batch plan saves reduce RDS CPU during bulk modelling runs 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:08:47 +00:00
Daniel Roth
46ca714ef9 Batch plan saves reduce RDS CPU during bulk modelling runs 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:06:47 +00:00
Daniel Roth
9c6b477025 Batch plan saves reduce RDS CPU during bulk modelling runs 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:04:54 +00:00
Jun-te Kim
fa6357a5cf Re-plug historic EPC lookup onto the repository; a missing postcode now yields empty matches 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:58:23 +00:00
Jun-te Kim
b47c90f9cd Resolve an address to its unambiguous historic EPC UPRN 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:52:20 +00:00
Jun-te Kim
a30ab59f3c Compose repository and matcher into scored historic EPC matches 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:51:45 +00:00
Jun-te Kim
d7fd093c4f Compose repository and matcher into scored historic EPC matches 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:51:33 +00:00
Jun-te Kim
66b776e0aa Raise PostcodeNotFound for an unusable postcode 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:48:06 +00:00
Jun-te Kim
88f4739ce8 Return empty list for a postcode with no historic EPC shard 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:46:42 +00:00
Jun-te Kim
a372f2995f Map historic EPC S3 shard to domain records 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:45:25 +00:00
Jun-te Kim
74b8ad86b1 Map historic EPC S3 shard to domain records 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:44:56 +00:00
Daniel Roth
f27d1e21bb Batch EPC writes in EpcPostgresRepository pass pyright strict 🟪
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:17:29 +00:00
Daniel Roth
587465bff7 Batch EPC writes via save_batch() on EpcPostgresRepository 🟩
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 13:04:30 +00:00
Daniel Roth
fe69bccf22 Batch EPC writes via save_batch() on EpcPostgresRepository 🟥
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 12:19:49 +00:00
Khalim Conn-Kowlessar
6faa5171af feat: reconstruct top-level extract_fans_count from its persisted mirror 🟩
The deterministic calculator reads sap_ventilation.extract_fans_count (which
already round-trips); the top-level epc.extract_fans_count is its mirror (the
mapper sets both from one source). Reconstruct it from the same column so
EpcPropertyData round-trips complete, dropping the allow-list exception.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:54:43 +00:00
Khalim Conn-Kowlessar
513c9b9897 feat: persist 7 calculator-read EPC fields 🟩
Wire community heating fuel + CHP fraction (epc_main_heating_detail),
alt-wall is_sheltered + wall insulation thermal conductivity
(epc_building_part), and pv_diverter_present / measured cylinder volume /
AP50 air permeability (epc_property) through save + _compose/_to_*. All
deep-equal round-trip; coverage guard now enforces their reconstruction.
Columns live (FE migration applied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:46:42 +00:00
Khalim Conn-Kowlessar
09ba84edf9 feat: persist solar PV arrays through the EPC round-trip 🟩
Add EpcPhotovoltaicArrayModel (epc_photovoltaic_array child table) and wire
save / delete / read so sap_energy_source.photovoltaic_arrays survives
load->save->load in order. Threaded through both the single get() and the
bulk _for_properties() paths via _compose -> _to_energy_source. Column
names match the FE migration (feature/epc-pv-and-floor-heatloss-schema).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:47:37 +00:00
Khalim Conn-Kowlessar
e5d94adc36 feat: persist floor heat-loss flags through the EPC round-trip 🟩
Add is_exposed_floor / is_above_partially_heated_space to
EpcFloorDimensionModel and wire from_domain + _to_floor_dimension. Column
names match the FE schema (feature/epc-pv-and-floor-heatloss-schema).
Live DB migration is run post-merge (drizzle-kit generate picks them up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:42:20 +00:00
Jun-te Kim
77133f4b42
Merge pull request #1321 from Hestia-Homes/fix/off-peak-bill-day-night-split
Fix/off peak bill day night split
2026-06-24 20:35:03 +01:00
Jun-te Kim
b1ff711260 perf(modelling_e2e): batch SubTask bookkeeping to stop per-property writes
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>
2026-06-24 19:26:42 +00:00
Jun-te Kim
de5e9a2362 perf(modelling_e2e): bulk reads and batch writes to cut RDS load
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>
2026-06-24 18:43:42 +00:00
Khalim Conn-Kowlessar
1acfc08fce Load the off-peak day/night rate from the committed snapshot 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:20:19 +00:00
Jun-te Kim
de71f9abb6 Resolve overrides on the unit's own session, not a second connection
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>
2026-06-24 17:01:41 +00:00
Jun-te Kim
17b9ae08eb Hold one DB connection per modelling_e2e invocation
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>
2026-06-24 16:58:21 +00:00
Khalim Conn-Kowlessar
03a0d9c1ca Round-trip the non-separated conservatory through persistence 🟩
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>
2026-06-24 13:56:05 +00:00
Khalim Conn-Kowlessar
f6ec96fbcd Merge remote-tracking branch 'origin/main' into feature/e2e-runs
# Conflicts:
#	domain/sap10_calculator/tables/table_12a.py
2026-06-24 11:07:42 +00:00
Jun-te Kim
d97b8e87a4 Add dual-fuel (mineral+wood) billing carrier (fix UnmappedSapCode 10)
10 modelling_e2e properties failed with "unmapped SAP code in fuel_code: 10":
the billing layer (`sap_code_to_fuel`) had no carrier for Table-32 code 10
(dual fuel, mineral + wood) and raised rather than guess one.

SAP 10.2 treats dual fuel as its OWN fuel (its own Table-12 factors), so model
it as its own billing carrier rather than collapsing onto wood or coal:

- New `Fuel.DUAL_FUEL_MINERAL_AND_WOOD`.
- `_CODE_TO_FUEL[10]` -> that carrier.
- Fuel Rates snapshot prices it at 7.69 p/kWh — the midpoint of the COAL proxy
  (7.13) and WOOD_LOGS (8.25). This mirrors SAP's own construction: Table-32
  dual fuel (3.99) ~= midpoint of house coal (3.67) and wood logs (4.23).
  Marked `derived` with a documented _note/_gap/_assumption (like the COAL and
  HEAT_NETWORK proxies), since there is no retail blend price.

A dedicated carrier + rate (vs a one-line map to an existing carrier) keeps the
fuel identity faithful to SAP and avoids mispricing dual fuel as pure wood/coal.

Tests: code 10 -> DUAL_FUEL carrier; snapshot prices it at 7.69; grid-export
codes (36/60) still raise (the genuine no-carrier case).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:07:57 +00:00
Khalim Conn-Kowlessar
7a970ea393 fix(repositories): fold landlord overrides when re-hydrating a Property via the UoW
PostgresUnitOfWork built its PropertyPostgresRepository without an overrides
reader, so a Property re-hydrated through the unit silently dropped its
Landlord Overrides (ADR-0032). The Baseline orchestrator runs through the UoW,
so it scored the bare lodged EPC while the Plan modelled the override-folded
Effective EPC — the two diverged (e.g. baseline effective 71/C vs plan
baseline 62/D), producing "already at band C yet recommends reaching C".

Wire PropertyOverridesPostgresReader into the unit's property repo (uow-
independent committed reference data, read via the same session factory) so
every re-hydration folds overrides, matching the live modelling path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 08:34:30 +00:00
Khalim Conn-Kowlessar
4f4ec32e51 Merge remote-tracking branch 'origin/main' into feature/e2e-runs
# Conflicts:
#	repositories/comparable_properties/epc_comparable_properties_repository.py
#	tests/repositories/comparable_properties/test_epc_comparable_properties_repository.py
2026-06-23 17:07:27 +00:00