0390's floor+ASHP package reaches post 68.772, which publishes as 69 = band C,
so under ADR-0064 it now meets the goal and stops before low-energy lighting —
the same over-purchase the ADR removes, one level up at the package. 0380 fires
the same three measures with identical trigger attributes, so every assertion
stands unchanged, and it lands at post 74.4, clear of a band floor.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Golden cert 0330 now reaches band C on the dwelling-sized ASHP alone
(ADR-0049), dropping its solid_floor_insulation companion; cert 0390
still fires three measures, so the per-measure trigger-attribute
assertions move there (and gain the lighting triggers).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The measures a run considers should come from the Scenario, not a CLI flag.
The live scenario table persists exclusions only (no inclusions column), as a
Postgres text-array of exact MeasureType values.
- Scenario gains `exclusions: frozenset[MeasureType]` + `considered_measures()`
(all measures minus the excluded ones, or None when none are excluded).
- ScenarioModel.to_domain parses the `{a,b,c}` exclusions array into
MeasureTypes, raising on a token that is not an exact MeasureType value
(no high-level category expansion), per the strict-enum convention.
- ModellingOrchestrator._plan_for derives the allowlist from the Scenario's
exclusions, combined (intersection) with any explicit considered_measures
override via the new `combine_considered_measures`.
- run_modelling_e2e sources the allowlist from the Scenario; --measures /
--exclude-measures become optional overlays (e.g. the technical
secondary_heating_removal exclusion the catalogue cannot yet stock).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The run only showed the measures the Optimiser selected, so a candidate it
passed over (e.g. an ASHP it found too costly for the target band) and that
measure's cost were invisible.
Add `harness.console.candidate_recommendations` — every Generator Option
with its per-Option cost, before optimisation — and have run_modelling_e2e
print the full menu per property (flagging the selected Options), write a
"cost per measure" section into the markdown, and emit a per-Option
modelling_e2e_candidates.csv.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds coordinates: Optional[Coordinates] to Comparable and PredictionTarget
(data carriers — the pure predictor stays IO-free), and wires load_corpus to
read an optional _coordinates.json sidecar ({uprn: [lon, lat]}) and populate
each Comparable from its cert's uprn; iter_predictions threads the held-out
target's coordinates through. Absent sidecar -> geo-weighting stays off (no
behaviour change yet — weighting lands next slice). fetch_corpus_coordinates
now writes the sidecar into the corpus dir. load_corpus populates 99% of
corpus comparables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three more pre-existing failures (present at 9ee38211, before this branch's
recent commits; same family as the orchestration multi-measure re-pin) —
golden-cert plan expectations that predate the ASHP generator (ADR-0025)
and the optimiser folding forced dependencies into candidate gain (ADR-0016):
- test_console: a multi-measure plan now leads with air_source_heat_pump,
not cavity_wall_insulation (which is dropped — its forced ventilation makes
the pair net-negative). Assert a measure actually in the package.
- test_report 0330: package is now {solid_floor_insulation, air_source_heat_
pump}; cavity_wall + forced mechanical_ventilation correctly excluded.
- test_report 0036: gain-maximising package is now {solid_floor_insulation,
low_energy_lighting}.
Same verified-correct optimiser evolution as 077e3a39 (cavity_wall +2.9 SAP
alone but its forced fabric→ventilation dep drags the pair net-negative).
Re-pin to the actual packages + their trigger fields; the forced wall→vent
edge stays covered by test_measure_dependency / test_optimiser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The committed gate needs frozen, reproducible data without dumping real UK
addresses into the repo. Add:
- harness anonymise_payload + stable_hash: hash street address + cert number
into opaque, dedup-stable tokens; blank secondary address lines + post_town;
keep postcode + all component/lodged fields (gov data is OGL). Unit-tested.
- scripts/build_epc_prediction_fixture.py: curate qualifying postcodes (>=1
SAP 10.2 target + >=2 distinct addresses) from the local scratch corpus,
anonymise, freeze under tests/fixtures/epc_prediction/.
- The frozen fixture: 15 postcodes / 280 certs / 36 SAP-10.2 targets.
Verified no plaintext address_line_1 and post_town all blank.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Orchestrator runs recommend_secondary_heating_removal; report._triggers_for
explains it via the lodged secondary_heating_type; harness catalogue + ARA seed
price it. Re-pins the golden/integration plans it shifts: it is a cheap (\£250)
SAP lever, so on gas-main certs lodging an electric secondary (691) it displaces
the \£12k ASHP (0330, 0036) or joins the all-beneficial-measures package (000490,
where its marginal SAP is 0 under the category-4 ASHP but the heater is still
physically removed). Consistent with the optimiser's existing kitchen-sink
package behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the system tune-up to the heating Recommendation: keep the existing wet
boiler but install better heating controls and fix the cylinder. Two competing
Options (the Optimiser picks <=1 across the whole heating rec) per the user's
two best control end-states:
- system_tune_up — standard controls (programmer + room thermostat +
TRVs, SAP 10.2 Table 4e code 2106)
- system_tune_up_zoned — time-and-temperature zone control (code 2110, type 3):
more SAP uplift for more cost
Both keep the boiler (no fuel / SAP code / flue change), set the control
ABSOLUTELY to their end-state, and apply the conditional cylinder fixes (an
80 mm jacket when under-insulated, a thermostat when absent — only when a
cylinder exists). Each control option is offered only when it genuinely improves
the existing control — standard is skipped when the control is already 2106 /
2110 / 2112, zone when already 2110 / 2112 — so neither is ever a downgrade or a
no-op.
Validated against the Elmhurst "system tune up" re-lodgements (cert 001431):
nine befores spanning controls 2101-2113 all converge to the two common afters,
proving the control overlay is absolute. The cascade pin is parametrised over
two starting controls (2101 "no control" + 2113 "room thermostat and TRVs") x
both afters, delta 0 (SAP/CO2/PE).
Wires the two MeasureTypes through contingencies (0.15), the offline catalogue
(500 / 900), the catalogue-coverage list, the report triggers, and the ARA
first-run seed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the `gas_boiler_upgrade` branch to `report._triggers_for`, mirroring the
generator's eligibility guard so a cohort report explains why the boiler upgrade
fired: the wet-boiler SAP code, the mains-gas connection that makes the gas
end-state installable, and the cylinder presence that shapes the bundle (combi
vs regular + cylinder fixes).
No golden API cert selects the boiler upgrade (it competes with — and on houses
loses to — the ASHP bundle within the one heating Recommendation), so the branch
is covered by a direct `_triggers_for` unit test, following the repo pattern for
testing internal helpers (cert_to_inputs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the first boiler-upgrade option to the single "Heating & Hot Water"
Recommendation (ADR-0024 expansion): a dwelling whose existing wet boiler heats
a hot-water cylinder is offered a new gas condensing boiler, with the cylinder
jacketed when under-insulated and given a thermostat when absent. One competing
Option (the Optimiser picks <=1), folded into one composite Plan line.
The end-state is read from the Elmhurst before/after re-lodgements (cert 001431,
gas boiler upgrade - with cylinder), which REVISE ADR-0024:
- Target is always a gas condensing boiler, not fuel-preserving: every after
lodges fuel 26. Gas->gas always; a non-gas wet boiler ->gas only with a
mains-gas connection; electric boilers are left alone (electrification is the
upgrade path). Eligibility = wet-boiler SAP code (Table 4a/4b 101-141 /
151-161 / 191-196) + not an electric boiler + mains gas present.
- End-state is a Table 4b SAP code, not a PCDB index: code 102 (regular boiler
+ cylinder). The calculator derives the condensing seasonal efficiency from
the code, so no efficiency input exists or is needed.
- A modern condensing boiler has a fanned flue: the after flips
`fan_flue_present` False->True on every cert (SAP 10.2 Table 4f flue-fan +
the Table 4b condensing-efficiency basis). Added as a new HeatingOverlay
field, routed to main_heating_details[0].
- Cylinder thermostat is always added when absent (user-locked); the jacket is
the 80 mm `cylinder_insulation_type=2` end-state, applied only when the
cylinder is below 80 mm (never downgrading a better one). Both are conditional
per-dwelling components, not a frozen overlay.
Cascade-pinned delta-0 (SAP/CO2/PE) against the relodged after via
`_assert_overlay_reproduces_after`. NB the absolute SAP on this dwelling is
subject to a separate Summary-path mapper roof-fidelity gap (we read the roof
better-insulated than Elmhurst, scoring ~75 vs the printed 56); the gap is
identical on before+after (the boiler measure never touches the roof) so it
cancels and the pin still proves the exact heating field-delta. Tracked on the
calculator branch.
Wires the new `gas_boiler_upgrade` MeasureType through contingencies (0.26),
the offline sample catalogue, the catalogue-coverage list, and the ARA
first-run integration seed (the option fires on any mains-gas boiler+cylinder
dwelling).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tighten the recommendation/plan vocabulary off generic str:
MeasureOption.measure_type and PlanMeasure.measure_type are now MeasureType
(also _GlazingTarget.measure_type, MeasureDependency.triggers ->
frozenset[MeasureType], and the optimiser's chosen/required-type locals).
Because MeasureType is a StrEnum the change is transparent to persistence
(the `recommendation` varchar column), the optimiser group-by key, and every
`== "solar_pv"`-style comparison — so pyright now enforces the enum at every
construction site with no runtime behaviour change.
The catalogue boundary stays str: ProductRepository.get(measure_type: str)
and Product.measure_type are unchanged (they map arbitrary DB/JSON rows), so
the fake product repos in tests need no edit. Test construction helpers coerce
their str arg via MeasureType(...); direct constructions use members.
Suite green: tests/domain/modelling + orchestration + harness 253 pass + 3
xfail; pyright clean on production + tests (pre-existing moto + property-
override-rowcount baselines untouched).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The efficient Vaillant aroTHERM plus 5 kW (ADR-0025) now raises SAP even on gas
dwellings, so the Optimiser selects the ASHP bundle far more often -- a deliberate
product shift (user-confirmed). Updated the integration/harness tests:
- ARA multi-measure: ASHP is additive to the kept fabric package -> add to set.
- ARA listed_uprn: is_listed blocks walls AND ASHP (blocks_internal); the gate is
now observable via ASHP (selected unrestricted, blocked listed).
- report three-measures: ASHP + solid-floor displace the fabric stack; assert
their triggers (property_type, main_heating_category / floor).
- console hhr + solid_wall coverage tests: assert the measure is OFFERED as a
candidate (the wiring/eligibility intent), since ASHP now out-competes them in
selection; also assert the optimised package leads with ASHP.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_heating now receives planning_restrictions in the orchestrator (the
ASHP planning gate); the ASHP bundle joins the free candidate pool for every
house/bungalow. Catalogue + contingency (legacy 0.25) gain air_source_heat_pump;
report.py _triggers_for explains the ASHP trigger; the harness forcing test
covers it. Integration tests seed an air_source_heat_pump MaterialRow (ASHP
fires on every house, the broadest trigger yet). NB the optimiser correctly does
NOT select ASHP for an EPC-band goal — gas->electric does not improve the SAP
cost-rating; ASHP is a CO2/PE measure, selectable once non-EPC goals land. ASHP
bundle COMPLETE (S5-S7). ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
recommend_heating joins the free candidate pool in _candidate_recommendations;
the HHR storage bundle reaches the optimised package for an electric/off-gas
dwelling. Catalogue + contingency (legacy 0.10) gain
high_heat_retention_storage_heaters; report.py _triggers_for explains the
heating trigger (electric/off-gas main); the harness _GENERATOR_MEASURE_TYPES
forcing test covers it. ASHP + boiler bundles still to come. ADR-0024.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 of the lighting generator (ADR-0023): run recommend_lighting in
_candidate_recommendations (no planning gate). Price low_energy_lighting in the
offline catalogue + contingency table (0.26, the legacy rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both. A run_modelling test pins
the wiring end-to-end (an incandescent-lit dwelling gets the LED upgrade in the
optimised package).
Downstream updates, all because lighting now fires on any cert with non-LED
bulbs: report.py gains the low_energy_lighting trigger (the non-LED counts); the
two golden-cert report tests and the multi-measure integration test now expect
low_energy_lighting alongside the fabric measures (the sample/golden EPCs lodge
low-energy-unknown bulbs); first-run integration seeds a low_energy_lighting
MaterialRow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The worksheet build_epc() fixtures wrapped a module-level SECTION_6_VERTICAL_
WINDOWS tuple in list(), so every call returned the SAME SapWindow objects. A
test that mutated a returned window (the glazing slices flip glazing_type to
single) leaked that change into every later build_epc() -- which surfaced as
double_glazing-product failures in the first-run integration tests only when
test_console ran first in the same process.
Deep-copy the windows per call in all six fixtures (000474/477/480/487/490/516)
so each EpcPropertyData owns an independent window graph, and drop the
now-redundant defensive copy at the glazing test's call site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 of the glazing generator (ADR-0022): run recommend_glazing in
_candidate_recommendations, threading the Property's PlanningRestrictions so a
protected dwelling is offered secondary glazing instead of double (mirrors
recommend_solid_wall). Price both Measure Types in the offline catalogue
(double £600/window, secondary £510 -- the legacy 0.85x scaling) and the
contingency table (0.15, the legacy windows_glazing rate); the
_GENERATOR_MEASURE_TYPES forcing test enforces both entries exist.
run_modelling tests pin the wiring end-to-end on an all-single-glazed dwelling:
double when unrestricted, secondary when listed. The first-run integration test
seeds a double_glazing Product because its lodged EPC has a single-glazed
window. _single_glazed_epc() deep-copies build_epc() (which shares its window
objects) so the mutation can't leak into other tests' baselines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 4 (ADR-0021). The roof dispatcher can now emit sloping_ceiling_insulation
and flat_roof_insulation, so wire both into contingencies and the sample
catalogue; the forcing-function test now asserts every generator measure type
is both priced and has a contingency rate, so an offline/live run over a
sloping or flat roof never dies on a missing entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3b+3d (ADR-0019/0020). Property gains a planning_restrictions attribute
(default unrestricted); the ModellingOrchestrator threads it from the Property
through _plan_for -> _scored_candidate_groups -> _candidate_recommendations into
recommend_solid_wall, replacing the unrestricted default. run_modelling exposes
a planning_restrictions param so the offline harness can inspect restricted
properties. Integration test: a listed solid-brick dwelling that gets IWI when
unrestricted now yields no wall insulation. 145 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2e. recommend_solid_wall joins the orchestrator's fabric generator pool
(restrictions default unrestricted until slice 3 sources them); the harness
catalogue + contingencies (26%) gain external_wall_insulation /
internal_wall_insulation. run_modelling on an uninsulated solid-brick dwelling
(baseline SAP 36.6) now selects internal wall insulation into the optimised
package; the catalogue-completeness guard covers both new measure types.
Golden cohort 57/57 still error-free; IWI now fires on a real cohort cert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bulk endpoint 302-redirects to a 15.7 GB S3 ZIP with one NDJSON member per
year; each line wraps the per-cert payload in a stringified 'document' that
parses to the same RdSAP-Schema-21.0.1 shape from_api_response already handles.
parse_bulk_line unwraps a record; is_sap_version filters to SAP 10.2; RangeFile
exposes the S3 object as a seekable file so zipfile streams a single year's
member (and a sampler stops early) without downloading the whole archive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
format_report_csv emits one comma-safe row per property: the calculator-error
fields (lodged/calculated/Δ/flag), the Plan headline figures (baseline+post
SAP/band, measures, cost+contingency, bill & CO2 savings, valuation %), the
flattened measure triggers, and any captured error — sortable in a spreadsheet
for a large dump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
format_report_markdown emits: (1) cohort parity stats + a per-property
lodged-vs-calculated table flagging |Δ| > 0.5 (errors shown inline),
(2) Plans + costings (SAP/band jump, cost + contingency, bill & CO2 savings,
valuation uplift), (3) each fired measure with the EPC attributes that
triggered it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build_property_reports models a dump in order (errors captured per-cert);
parity_report_for aggregates the lodged-vs-calculated SAP across the cohort
into the existing ParityReport (MAE/RMSE/bias/worst-N), excluding certs that
couldn't be mapped or scored. Residual convention is the calculator's own
(predicted - actual), the negative of PropertyReport.sap_error.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Section 3 of the report: build_property_report now runs the Modelling stage
and, for every Plan Measure, records the EPC attribute(s) that caused its
generator to fire (MeasureTrigger) — wall_construction/insulation for cavity
fill, roof thickness for loft, floor thickness/construction for floors, the
absent mechanical kind for ventilation. Modelling raises are captured as
plan_error, independent of the calculator-error capture.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Section 1 of the property inspection report: PropertyReport compares the
cert's lodged energy_rating_current to Sap10Calculator's un-rounded SAP and
flags |Δ| > 0.5 (the ADR-0010/0013 shadow-validation design target). A
mapping/scoring raise is captured per-cert as calculator_error, never
propagated, so one bad cert can't abort the sweep.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CertResult now carries its Plan (with flat baseline/post-SAP/measures
properties), and `format_cohort_csv` renders one browsable row per cert
(SAP transition, band, measures, cost, bill saving, valuation %, error).
`scripts/run_modelling_cohort.py` is turnkey: no args runs the committed
golden cohort, prints a sense-check table for the first measure-bearing
certs (a capped preview so a large dump doesn't flood the terminal), the
summary, and writes modelling_cohort.csv (gitignored). Point it at the
EPC dump when it lands.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`harness.cohort.run_cohort(paths)` parses each API-shaped EPC JSON with
from_api_response and models it via run_modelling — no database, no
network — capturing per-cert errors instead of aborting the sweep, plus
`format_cohort_summary`. A thin `scripts/run_modelling_cohort.py` CLI
points it at a directory. Proven over the 57 golden API certs: 56 ran
offline, 15 produced measures, 1 errored (COAL has no Fuel Rates entry —
a BillDerivation coverage gap, not a harness one). Ready for the EPC dump.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two fixes that unblock offline, no-database inspection over an arbitrary
EPC dump:
- Complete the harness sample catalogue with loft_insulation and
solid_floor_insulation — the four fabric generators can emit five
Measure Types, but the catalogue priced only three, so an offline run
on a property with an uninsulated loft or solid floor raised mid-run.
A new test pins the catalogue to cover every generator Measure Type.
- Add `run_modelling(epc, ...)` — runs ONLY the Modelling stage (no
Ingestion / Baseline), so it needs no lodged recorded-performance / RHI
and inspects recommendations on any calculator-scorable EPC. `run_one`
(full pipeline) stays for when you want Baseline too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Plan derives its Valuation Uplift (ADR-0018) from its baseline -> post
band jump and works+contingency cost, given one external input — the
Property's current market value (a Property Valuation, mostly absent).
`Plan.valuation` / `Plan.baseline_epc_rating` are derived like the other
headline figures; `PlanModel.from_domain` maps the £ forms to the live
plan.valuation_* columns (NULL when no value — the percentage is not
persisted on those columns). `Property.current_market_value` is the new
optional source; the orchestrator threads it onto the Plan. `run_one`
takes a `current_market_value` so the harness can value the uplift, and
the sense-check table shows the average % (always) plus the £ forms when
known.
Sourcing the current market value (upload / default) remains deferred
(ADR-0018); it is None throughout until that lands, so the columns stay
NULL at scale.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 3. `harness.console.run_one(epc, goal_band=...)` wires the full
AraFirstRunPipeline against in-memory fakes — no Postgres, no network —
runs one property, prints the sense-check table, and returns the Plan
for interactive poking from a REPL at the worktree root. Defaults to the
committed harness sample catalogue.
Refactors the slice-1 integration test to delegate to run_one (dropping
~70 lines of duplicated wiring + the now-unused test catalogue fixture),
so it exercises the shipped entrypoint rather than a parallel copy. The
new console test covers run_one's print/return contract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Slice 2. `harness.plan_table.format_plan_table(plan)` renders a Plan as a
plain-text table — one package summary line (baseline SAP/band -> post
SAP/band, CO2 saved, cost of works + contingency, bill saved) and one
line per Plan Measure (signed SAP points, cost, delivered kWh + £
savings). Pure presentation: reads the Plan, computes nothing. The
DB-less First Run test now prints it (visible under `pytest -s`) so the
modelled package can be eyeballed and debugged by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>