PRD #1385, ventilation family. The 17.0 and 19.0 API mappers dropped lodged
ventilation fields that the reference-complete 17.1/18.0/20.0 paths read:
* open_fireplaces_count -> open_chimneys_count: hardcoded 0 on both
(understating §2 infiltration).
* percent_draughtproofed: omitted on both.
* 17.0 additionally passed no sap_ventilation block, so the §2 cascade fell
back to NATURAL + its default sheltered_sides=2. Restored from the lodged
mechanical_ventilation + built_form via the existing _api_* helpers.
Semantic parity verified (not just type-equality): built_form and
mechanical_ventilation use the same gov code lists the 17.1/18.0/19.0/20.0/21.x
paths already map through these helpers; all 244 RdSAP fixtures resolve with zero
UnmappedApiCode, and the strict-coverage guard raises on any future divergence.
For the current corpus the 17.0 score-mover is sheltered_sides from built_form
(every 17.0 cert lodges mechanical_ventilation=0 -> NATURAL).
Adds a regression test asserting both paths carry the fields through
from_api_response on real certs.
Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A focused sibling to audit-ara-portfolio: that skill audits baselines/plans/SAP;
this one audits the *recommendations themselves* — why a measure was or wasn't
offered. Motivated by the portfolio-814 review (Khalim's HHRSH-on-community-
heating, missing-HHRSH, missing-secondary-heating-removal, and a neighbour split).
Adds:
- .claude/skills/find-weird-recommendations/SKILL.md — scan -> neighbour scan ->
live re-model deep-dive -> root-cause -> codify, with a seeded known-bug
catalogue and the query-safety rules inherited from audit-ara-portfolio.
- scripts/audit/anomalies.py: new `plan-stops-short-of-goal` HIGH check — the
default plan ends below the goal band on an unlimited-budget scenario (the
deterministic worklist for "why didn't this get recommended X"). Adds
scenario_budget to the bundle/query so budget-capped scenarios are excluded.
- scripts/audit/neighbour_divergence.py: groups a portfolio by (postcode,
property_type, built_form) and flags effective-SAP outliers vs the cohort
median. Never touches the 26m-row recommendation table, so it is safe
portfolio-wide.
- Tests for both (12 passing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `from_rdsap_schema_17_0` path hardcoded `sap_heating.secondary_heating_type`
and `secondary_fuel_type` to None, unlike every other API path (19.0/20.0/…),
which read them from `schema.sap_heating`. The gov EPC API *does* populate a
secondary heating SAP code for a genuine fixed secondary (e.g. 691 "electric
room heaters", 694), so the hardcode silently discarded it.
Because `recommend_secondary_heating_removal` gates purely on
`sap_heating.secondary_heating_type`, any 17.0 cert carrying a fixed secondary
would never get its removal recommendation. Surfaced while auditing portfolio
814's recommendations; 814 has 17.0 certs but none with a fixed secondary, so
the defect was latent there — this restores parity with the other paths so it
cannot bite.
Mirrors the 19.0 path exactly (secondary_fuel_type via `_api_secondary_fuel_type`).
Adds a regression guard that injects a fixed secondary into a real 17.0 cert and
asserts the code survives the mapping.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-time script (dry-run default, --apply in a transaction, idempotent) mapping a
flat roof's stated depth (flat: Nmm) onto the nearest Table 16 rung member so the
overlay scores it by thickness, not the age-band default. Updates property_overrides
(TEXT) immediately; the roof_type pgEnum cache writes are deferred until the FE adds
the FLAT_*MM members. Dry-run against the audited DB reports 102 rows, deferring
Flat 50/100/150 mm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-time script (dry-run default, --apply in a transaction, idempotent) that maps
every party-ceiling override (another/same dwelling or premises above) currently
stored as an external roof value onto the party-ceiling member, reusing the live
roof_party_ceiling_guard so the backfill and the classifier cannot drift. Updates
property_overrides (TEXT, the modelling read path) and the roof classifier cache;
dry-run against the audited DB reports 106 rows. No FE migration — the party-
ceiling values already exist in the roof pgEnum.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One-time script (dry-run default, --apply in a transaction, idempotent) that
NULLs the four lodged_* columns on every predicted-source baseline row — a
predicted EPC exists and no lodged one does — leaving Effective, the bill block,
and rebaseline_reason intact. Dry-run against the audited DB reports 12,236 rows.
Must run after the FE-owned Drizzle ALTER ... DROP NOT NULL lands.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The four lodged_* columns are nullable as a unit; from_domain writes them all
NULL when a predicted Property has no Lodged Performance, and to_domain
reconstructs lodged=None (lodged_sap_score is the read discriminator, mirroring
the bill block). The production lodged_* columns are FE-owned (Drizzle) and need
a companion ALTER ... DROP NOT NULL migration before the backend writes NULL.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PropertyBaselineOrchestrator now reads no Lodged Performance off a predicted
Property's neighbour-synthesised EPC: lodged is None when source_path is
'predicted', so no phantom lodged figure is manufactured. The Effective half is
unchanged. Rebaseliner port + PropertyBaselinePerformance.lodged widen to
Optional; the pristine-cert paths assert non-None (lodged is None only for a
predicted Property, which is always physical_state_changed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deterministic catalogue remap (no LLM) that re-resolves the stored archetype for
the canonical RdSAP heating descriptions the under-populated taxonomy
mis-classified. Dry-run by default; --apply writes inside a transaction;
idempotent. Updates property_overrides.override_value (TEXT — what the modelling
reads, the actual band-G fix: 3,028 rows incl. 2,210 community boilers + 770
panel/convector room heaters). Cache (the Drizzle-owned main_heating_system
pgEnum) updates are deferred per-value until the FE-repo enum migration adds the
new archetypes — surfaced explicitly by the script.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Heat pump drags category 4 + electricity (29). Meter stays single-rate: a heat
pump does not imply an off-peak tariff the way storage/CPSU do (SAP §12 Rule 3
is conditional), so forcing Dual would mis-bill it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The PV-array (ea7f4f43) and electricity-tariff (6ec09892) mapper fixes shifted
the observed output of five frozen gates that weren't updated alongside:
- has_pv component-accuracy floor 0.9798 -> 0.9697: carrying full-SAP lodged PV
now reads the true has_pv=True for full-SAP PV dwellings, so the leave-one-out
scorer's actual changes (ground-truth-method shift, ADR-0037 pattern).
- uprn_10093116528 80->... pin 82 -> 83: tariff=1 (standard) was wrongly read as
dual/Economy 7; translating to "single" re-prices the gas semi's electricity.
- uprn_10096028301 82 -> 84, uprn_10023444324 80 -> 82 (== lodged 82),
uprn_10023444320 81 -> 83: now credit the lodged sap_energy_source.pv_arrays
the schema previously dropped. Comments document the per-cert PV/Elmhurst
relationship (incl. the mid-floor sibling landing +2 over its lodged integer).
Pre-existing, unrelated failures untouched: the missing
sap_16_0_full_no_floor_dims.json fixture and the RdSAP-21 floor-area test (both
reproduce on origin/main).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>