Commit graph

5910 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
143f8b0805 docs(modelling): handover — reflect generators/scoring/optimisation layout
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:50:21 +00:00
Khalim Conn-Kowlessar
84ec6da032 refactor(modelling): group domain/modelling into generators/scoring/optimisation
domain/modelling/ had grown to 15 flat modules. Group the behavioural ones into
subpackages — generators/ (wall/roof/floor Recommendation Generators), scoring/
(overlay applicator, package scorer, role-1/3 scoring), optimisation/ (optimiser
+ measure dependency) — and leave the shared value-object vocabulary
(recommendation, plan, scenario, product, contingencies, simulation) flat at the
top, since it is imported everywhere. Pure move + import-path rewrite across 89
import sites; no behaviour change. 136 pass, pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:48:36 +00:00
Khalim Conn-Kowlessar
90387c4a36 docs(modelling): handover — #1161 (ventilation Measure Dependency) closed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:37:03 +00:00
Khalim Conn-Kowlessar
0fec069988 feat(modelling): wire the ventilation Measure Dependency into the orchestrator (#1161)
ModellingOrchestrator builds the ventilation dependency per Property
(suppressed when already mechanically ventilated) and passes it to
optimise_package, so a selected wall measure forces MEV into the package before
the re-score. Ventilation joins the role-3 cascade in best-practice order
(walls -> roof -> ventilation -> floor) and persists as a Plan Measure carrying
its real negative marginal and its cost. Added the mechanical_ventilation
contingency rate (0.26, per legacy Costs.CONTINGENCIES). Integration test now
seeds the ventilation Product and asserts the forced measure persists with
<=0 SAP and 2x900 cost; the full-pipeline test seeds the Product too (the
dependency is built for every not-yet-ventilated dwelling). On 000490 the real
calculator scores MEV at -1.275 SAP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:34:40 +00:00
Khalim Conn-Kowlessar
1bf5b4102d feat(modelling): ventilation Measure Dependency builder + has_ventilation guard (#1161)
ventilation_dependency(epc, products) returns the forced 'fabric requires
ventilation' edge: triggers = MEASURES_NEEDING_VENTILATION (cavity/internal/
external wall, mirroring legacy assumptions.measures_needing_ventilation), and a
required Option installing decentralised MEV (mechanical_ventilation_kind=
EXTRACT_OR_PIV_OUTSIDE), priced at two fully-loaded units. Returns None when the
dwelling already lodges a mechanical ventilation kind (legacy has_ventilation
guard), so MEV is never forced onto an already-ventilated dwelling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:27:56 +00:00
Khalim Conn-Kowlessar
6b11c90295 feat(modelling): inject forced Measure Dependencies into the package (#1161)
MeasureDependency(triggers, required) is a data-declared 'A requires B' edge.
optimise_package gains a dependencies param: after the warm-start it injects any
dependency whose triggers intersect the selected measure-types, BEFORE the
whole-package re-score, so the dependency's (negative) SAP lands in the truthful
figure and the undershoot/repair decision (ADR-0016). Forced — injected
regardless of budget — but its cost counts toward package spend, so repair sees
less headroom. Repair candidates fold in any dependency they newly trigger, so
their marginal SAP-per-£ and incremental cost are truthful. The dependency never
competes in the optimiser pool. Returned selected includes the injected deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:25:40 +00:00
Khalim Conn-Kowlessar
7c59e9198a feat(modelling): Simulation Overlay grows a dwelling ventilation segment (#1161)
VentilationOverlay (all-optional partial of SapVentilation) + EpcSimulation.
ventilation; apply_simulations folds it onto sap_ventilation, creating one when
the baseline lodged none. This is the surface a Measure Dependency (ventilation)
writes — whole-dwelling, no building part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:20:45 +00:00
Khalim Conn-Kowlessar
42d9411954 docs(modelling): handover — #1157 + #1160 closed, #1161 next
Brings HANDOVER_MODELLING.md fully current: #1157 (Plan persistence) and
#1160 (Optimiser) closed this session; records the locked design
decisions (multi-phase deferred, Plan Measure term, reuse-live-tables
via SQLModel mirrors, pure-Python knapsack not mip), the gotchas (mip/CBC
broken on aarch64, moto missing, drive-Modelling-directly for fixtures
without lodged perf, seed materials per fired measure type), and the
remaining work (#1161 ventilation Measure Dependency + deferred fronts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:11:43 +00:00
Khalim Conn-Kowlessar
34d4748a3a feat(modelling): wire the Optimiser into the orchestrator (#1160)
Slice 3b — closes #1160. ModellingOrchestrator._plan_for now runs the
full ADR-0016 flow instead of a single cavity measure:

  generate wall + roof + floor Recommendations → score each Option
  independently (role 1) into grouped ScoredOptions → optimise_package
  (grouped knapsack within budget + whole-package re-score + greedy
  repair toward the Scenario's SAP target) → attribute the selected set
  via the best-practice marginal cascade (role 3) → persist the Plan
  with its Plan Measures.

The repair target comes from the goal: INCREASING_EPC → the goal_value
band floor via Epc.sap_lower_bound(); other goals carry no SAP target
yet (later slice). Best-practice order walls → roof → floor.

Integration test: an uninsulated cavity wall + suspended floor (000490)
driven directly through the Modelling stage off a repo-seeded EPC
(the calculator fixture has no lodged recorded-performance fields, so
Baseline can't run it) persists a Plan with two attributed, priced Plan
Measures. The existing first-run test keeps full-pipeline coverage and
now exercises real modelling (its sample EPC's uninsulated solid floor
yields a floor measure). Replaces the single-measure cavity integration
test (subsumed). 138 pass; pyright strict clean.

Multi-phase remains descoped (ADR-0005); single-phase optimiser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:07:14 +00:00
Khalim Conn-Kowlessar
504f592a27 feat(modelling): Epc.sap_lower_bound() — band → minimum SAP (#1160)
Slice 3a. The inverse of Epc.from_sap_score: the minimum SAP rating in a
band (C → 69, B → 81, …), used as the Optimiser's repair target for an
INCREASING_EPC goal (goal_value "C" → target SAP 69). Keeps the
band-target derivation in the domain rather than re-coupling to
backend.app.utils.epc_to_sap_lower_bound. 8 tests incl. round-trip
through from_sap_score; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:50:40 +00:00
Khalim Conn-Kowlessar
49e86344d2 feat(modelling): whole-package re-score + greedy repair (#1160)
Slice 2 of #1160 — the ADR-0016 truth step on top of the warm-start
knapsack. optimise_package(groups, scorer, baseline_epc, budget,
target_sap) -> OptimisedPackage:

  warm-start optimise() (role-1 signal) → re-score the chosen package on
  the real scorer (role-2 truth) → while the true SAP undershoots
  target_sap and budget remains, greedy-add the untreated-group Option
  with the best *marginal* SAP-per-£ (re-scored, not the role-1 signal),
  re-score, repeat until the target is met, nothing positive-marginal is
  affordable, or the budget is spent.

`Scorer` is a structural Protocol (PackageScorer satisfies it) so the
repair loop is tested with a stub scorer — no calculator, runs on ARM.
The key case: role-1 under-counts roof so the warm-start skips it, the
re-score undershoots, and repair adds roof back to hit the target. 3
repair tests + the 6 core tests; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:45:05 +00:00
Khalim Conn-Kowlessar
77983caed8 feat(modelling): Optimiser core — exact grouped knapsack (#1160)
Slice 1 of #1160. Recycles the GainOptimiser/CostOptimiser formulation
(≤1 Option per Recommendation, maximise SAP gain subject to budget) as a
clean typed DDD function — but as an exact pure-Python multiple-choice
knapsack rather than the legacy `mip` MILP, since mip's CBC backend does
not load on aarch64 (so the legacy solver path can't run / be tested
here). At retrofit scale the candidate space Π(|group|+1) is tiny, so
exhaustive enumeration is exact and instant; ADR-0016 only needs the
knapsack as a warm-start signal anyway (the truthful figure comes from
the whole-package re-score + repair, next slice).

`optimise(groups, budget) -> list[ScoredOption]`: maximise total gain,
tie-break toward lower cost, skip-per-group covers "select none". 6 tests
(budget-bound selection, ≤1/group, unconstrained, budget-too-small,
empty groups, partial-affordability); pyright strict clean.

Multi-phase remains descoped (ADR-0005) — single-phase optimiser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:39:47 +00:00
Khalim Conn-Kowlessar
c7e2aa3755 feat(modelling): ModellingOrchestrator persists a Plan end-to-end (#1157)
Slice 4b — closes the #1157 tracer. ModellingOrchestrator.run(property_ids,
scenario_ids, portfolio_id) now does real work in one Unit of Work,
committed once (ADR-0011/0012/0016/0017):

  read Property (effective EPC) + Scenario via repos → recommend_cavity_wall
  → select its Option → PackageScorer.score (role-2 package total) +
  marginal_impacts (role-3 attribution) → build Plan/PlanMeasure →
  uow.plan.save → commit.

- AraFirstRunPipeline / ModellingStage thread portfolio_id from the trigger
  body (one source of truth); handler builds the real orchestrator
  (unit_of_work + Sap10Calculator), dropping the Scenario/Materials stubs.
- ScenarioRepository.get_many promoted to @abstractmethod now the bare-stub
  instantiations are gone.
- New ara_first_run-style integration test: a property with an uninsulated
  cavity wall yields a persisted Plan + one cavity_wall_insulation Plan
  Measure (priced from the Product, figures present, linked by plan_id).
  Numeric SAP correctness is pinned separately in test_elmhurst_cascade_pins.
- Existing pipeline integration test updated: seeds scenario 7 and runs the
  real Modelling stage (its already-insulated sample wall yields an empty
  package — no crash).

121 pass across repositories/modelling/orchestration/app; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:08:32 +00:00
Khalim Conn-Kowlessar
e778d1fb97 feat(modelling): expose scenario/product/plan repos on the UnitOfWork (#1157)
Slice 4a. The Modelling stage reads the Scenario + Product catalogue and
writes the Plan + its Plan Measures on one session, committed once
(ADR-0012/0017). Adds uow.scenario / uow.product / uow.plan to the
UnitOfWork port and constructs them in PostgresUnitOfWork.__enter__.
Additive — existing stages and the bare-stub Modelling wiring are
unaffected. Wiring test asserts the unit exposes the three ports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:53:34 +00:00
Khalim Conn-Kowlessar
d66f7eed84 feat(modelling): plan/recommendation SQLModel mirrors + PlanRepository (#1157)
Slice 3 of #1157. Persists a Plan and its Plan Measures to the live
plan / recommendation tables via SQLModel mirrors (ADR-0017).

- infrastructure/postgres/plan_table.py: PlanRow (`plan`) + RecommendationRow
  (`recommendation`) mirrors. RecommendationRow adds the new `plan_id` FK
  (ON DELETE CASCADE) linking each Plan Measure to its Plan, replacing the
  plan_recommendations m2m for new writes. from_domain mappers convert CO2
  kg → tonnes to match the live column contract and derive post_epc_rating
  from the rounded SAP. Only the impact + cost + identity columns the tracer
  fills are declared; energy/bill, U-value, valuation, labour, plan_type are
  left to later slices.
- PlanRepository port + PlanPostgresRepository.save(plan, *, property_id,
  scenario_id, portfolio_id, is_default) -> plan id. Idempotent replace:
  deleting the Plan cascades to its recommendation rows via plan_id, so a
  re-run overwrites (ADR-0012). No commit — the UoW owns the transaction.

2 tests (persist + idempotent re-run); pyright strict clean; 73 pass across
repositories/modelling/orchestration with no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:51:02 +00:00
Khalim Conn-Kowlessar
0ebd9cc7fd feat(modelling): domain Plan + PlanMeasure types (#1157)
Slice 2 of #1157. The per-Property output of one Scenario's modelling
run, per ADR-0017.

- PlanMeasure: a selected Measure Option frozen with its installed Cost
  and role-3 (final-package cascade) attributed MeasureImpact — the
  output counterpart of a Recommendation's candidate Option.
- Plan: the selected Plan Measures + baseline/post-retrofit Scores.
  Single-phase (ADR-0005); derives the persisted headline figures —
  cost_of_works, contingency_cost, co2_savings_kg_per_yr (kg; the mapper
  converts to tonnes), post_sap_continuous, and post_epc_rating (band
  from the rounded SAP via Epc.from_sap_score).

1 unit test, pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:40:27 +00:00
Khalim Conn-Kowlessar
62a968119c feat(modelling): domain Scenario + ScenarioPostgresRepository (#1157)
Slice 1 of the #1157 build. The FE creates a Scenario and passes only
its id to the pipeline; the Modelling stage reads it back here.

- domain/modelling/scenario.py: thin `Scenario(id, goal, goal_value,
  budget, is_default)` — the slice the stage uses today (goal/budget for
  the Optimiser later; is_default drives plan.is_default). No phases
  (ADR-0005); legacy file-path/aggregate columns not modelled.
- infrastructure/postgres/scenario_table.py: `ScenarioRow` SQLModel
  mirror of the live `scenario` table (ADR-0017), declaring only the
  read columns; goal mapped as its string value.
- ScenarioPostgresRepository.get_many(scenario_ids) -> list[Scenario]:
  bulk read, input-order-preserving, raises on a missing id.

The method shape lives on the concrete repo for now; it is promoted to
an @abstractmethod on the port when the real orchestrator is wired and
the bare-stub instantiations retire (keeps the stubbed Modelling wiring
composing meanwhile). 2 tests, pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:19:52 +00:00
Khalim Conn-Kowlessar
772cdd4f5a docs(modelling): #1157 Plan-persistence design review
Outcome of the /grill-with-docs session scoping #1157.

- CONTEXT.md: add **Plan Measure** (the persisted selected Option +
  role-3 attribution + cost); Recommendation stays the candidate.
  Remove Scenario Phase / Plan Phase / Rolled-over Options — multi-phase
  is deferred. Reshape Scenario + Plan to single-phase; fix relationships,
  dialogue, and the "phase" ambiguity note.
- ADR-0005: rewritten to Deferred (multi-phase was speculative
  prospective-client work; single-phase now; future plan_phase back-fill
  path preserved). Stray phase refs cleaned in ADR-0016 / ADR-0009.
- ADR-0017 (new): Plan persistence — reuse the live plan/recommendation
  tables via SQLModel mirrors + a PlanRepository on the UoW; add
  recommendation.plan_id, retire the plan_recommendations m2m; flat
  post-retrofit on plan; idempotent replace; CO2 in tonnes. Unselected
  alternatives + bills noted as deferred directions.
- docs/migrations/recommendation-plan-id.md: the FE-owned Drizzle change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:12:54 +00:00
Khalim Conn-Kowlessar
cc0bb8f9bb feat(modelling): ProductJsonRepository behind the ProductRepository port
Adds the file-backed Product catalogue — the stopgap source for costs
the ETL does not yet supply, behind the same ProductRepository port as
ProductPostgresRepository. The JSON file maps each Measure Type to its
fully-loaded unit cost; the per-Measure-Type contingency is joined from
config (not stored in the file), so config stays the single source of
truth for contingency — mirroring the Postgres repo's mapping.

Strict-raises (ValueError) on an absent measure type, a non-object
entry, or a missing/non-numeric unit_cost_per_m2, matching the
repo-wide strict-no-silent-default convention. tmp_path-backed tests,
no DB fixture needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:49:02 +00:00
Khalim Conn-Kowlessar
ed6cd9c11a docs(modelling): handover — parser gate cleared, #1154/#1158/#1159 closed
Records that the Elmhurst recommendation Summaries parse via the
extractor chain (not parse_site_notes_pdf), so the "parser gate" never
blocked the cascade pins. All four pins close at delta 0; loft 270→300
and the suspended-floor insulation-type field were the two gaps fixed.
Remaining: #1157 (HITL schema review) + ProductJsonRepository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:43:24 +00:00
Khalim Conn-Kowlessar
a0b6a952c3 feat(modelling): floor insulation-type overlay field + cascade pins (#1159)
Completes #1159 end-to-end with solid and suspended-floor before/after
cascade pins on cert 001431, both closing at delta 0.000000.

Adds floor_insulation_type_str to BuildingPartOverlay (the generic
field-fold applicator picks it up with no change) and has
recommend_floor_insulation set it to "Retro-fitted". Insulating an
as-built floor re-lodges its insulation as retro-fitted; the calculator
keys on this for a suspended timber floor's sealed/unsealed
determination (cert_to_inputs.py: "retro" + no U-value supplied →
sealed). Without it the suspended-floor cascade left a +1.40 SAP gap
(the floor stayed "unsealed", wrong U-value); with it the cascade
closes exactly. Solid floors are unaffected by the seal logic and stay
at delta 0; both Elmhurst after-certs lodge "Retro-fitted", so setting
it uniformly is faithful.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:41:54 +00:00
Khalim Conn-Kowlessar
44d62c0c9b feat(modelling): loft overlay 270→300 mm + Elmhurst cascade pin (#1158)
Completes #1158 end-to-end. recommend_loft_insulation now emits a
300 mm overlay (was 270 mm). The Elmhurst before/after re-lodgement of
the loft-insulation measure on cert 001431 lodges the after-cert at
300 mm roof insulation; pinning before→overlay→after requires the
overlay to match that depth — at 270 mm the cascade left a +0.173 SAP
residual, at 300 mm it closes at delta 0.000000 on SAP/CO2/PE.

Adds test_loft_overlay_reproduces_the_relodged_after and updates the
roof generator unit test's thickness assertion to 300.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:39:21 +00:00
Khalim Conn-Kowlessar
4c0a907a54 test(modelling): Elmhurst before/after cascade pin for cavity wall (#1154)
Closes #1154 — the Package Scorer's Elmhurst cascade pin. Drives
recommend_cavity_wall on the parsed `before` Summary, scores its
Option's overlay through PackageScorer, and asserts delta 0 (abs<=1e-4
on SAP/CO2/PE) vs the calculator's score on the re-lodged `after`
Summary.

Key finding: the handover's stated parser gate (parse_site_notes_pdf
throwing 'Manufacturer' on cert 001431) does NOT block these pins. The
Elmhurst recommendation Summaries route cleanly through the same
ElmhurstSiteNotesExtractor + EpcPropertyDataMapper chain the worksheet
e2e fixtures use (_elmhurst_worksheet_001431.build_epc). The Textract
path's window bug is unrelated and unused here.

The before→after field change is exactly wall_insulation_type 4
(uninsulated) → 2 (filled cavity), which is precisely the overlay
recommend_cavity_wall emits; the cascade closes at delta 0.000000 on
all three metrics. Before/after Summaries mirrored into
tests/domain/modelling/fixtures/ so the pin does not depend on the
unstaged workspace.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:36:53 +00:00
Khalim Conn-Kowlessar
9ed4ccc28e docs(modelling): handover for the Modelling stage rebuild
Captures issue status (#1153-#1161), the built compute spine, key
facts/gotchas (hand-built 000490 fixture, calculator entry, worktree-vs-main
import trap, test/commit conventions), and the two gates (parser fix -> wire
Elmhurst cascade pins; #1157 persist-Plan HITL schema review). For picking
the work back up in a fresh session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:18:31 +00:00
Khalim Conn-Kowlessar
4c10405071 feat(modelling): floor Recommendation Generator + ground-floor-area geometry
recommend_floor_insulation(epc, products) detects an uninsulated ground floor
(SapBuildingPart.floor_insulation_thickness blank/zero) and its construction
from floor_construction_type — 'Suspended timber' -> suspended_floor_insulation,
'Solid' -> solid_floor_insulation — emitting the matching single Option (a
floor is one construction, like a cavity wall) with the overlay
(floor_insulation_thickness = 100 mm) and a priced Cost (ground-floor area x
the Product's fully-loaded unit cost + contingency).

- building_geometry.ground_floor_area(epc, identifier): the lowest floor's
  (floor == 0) area. Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains floor_insulation_thickness (generic Applicator
  writes it unchanged). suspended (0.20) / solid (0.26) floor contingencies.

Progress on #1159 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and parser. Four behaviour tests (suspended / solid
/ none / cost) + geometry pin. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:12:29 +00:00
Khalim Conn-Kowlessar
3c87be8e1e feat(modelling): roof (loft) Recommendation Generator + roof-area geometry
recommend_loft_insulation(epc, products) detects an uninsulated main loft
(SapBuildingPart.roof_insulation_thickness == 0) and emits a
Recommendation("Roof") with one loft_insulation Option carrying the overlay
(roof_insulation_thickness = 270 mm, the recommended top-up) and a priced
Cost (roof area x the Product's fully-loaded unit cost + contingency).

- building_geometry.roof_area(epc, identifier): the part's greatest
  per-storey floor area (RdSAP 10 §3.8). Pinned 14.85 m^2 on 000490 MAIN.
- BuildingPartOverlay gains roof_insulation_thickness; the generic Overlay
  Applicator writes it with NO change (validated by the tracer) — the
  deep-module field-fold paying off.
- loft_insulation contingency (0.10) added.

Progress on #1158 (generator + geometry); end-to-end + Elmhurst pin pending
the orchestrator (#1157) and the parser fix. Four behaviour tests
(geometry pin; detect / none / cost). pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 09:05:38 +00:00
Khalim Conn-Kowlessar
d02b7348a6 Merge branch 'main' of https://github.com/Hestia-Homes/Model into feature/bill-derivation 2026-06-03 08:52:36 +00:00
Khalim Conn-Kowlessar
13dd5fe81a feat(modelling): per-measure scoring — marginal cascade + per-Option signal (#1156)
scoring.py adds the telescoping marginal cascade that serves two of the three
ADR-0016 scoring roles:
- marginal_impacts(scorer, baseline, overlays): applies overlays cumulatively
  in order and reports each measure's marginal MeasureImpact (sap_points +
  carbon/energy savings). Role 3 (final-package attribution) — the marginals
  telescope EXACTLY to the whole-package total.
- independent_option_impacts(scorer, baseline, options): role 1 — scores each
  Option's overlay independently vs baseline, scoring each DISTINCT overlay
  once (Options sharing an overlay reuse the result). Approximate signal for
  the optimiser; never surfaced as a measure's true impact.

Role 2 (whole-package re-score) is PackageScorer.score directly. Three
behaviour tests on the real Sap10Calculator / a counting stand-in (hand-built
EPD): single-overlay marginal == improvement-over-baseline; two-overlay
marginals telescope to the package total; per-Option dedup scores each
distinct overlay once. Closes #1156. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:50:49 +00:00
KhalimCK
010a576a4a
Merge pull request #1162 from Hestia-Homes/feature/per-cert-mapper-validation
Feature/per cert mapper validation
2026-06-03 09:45:54 +01:00
Khalim Conn-Kowlessar
7a478cff6e feat(modelling): Package Scorer — compose overlays + score on the calculator
PackageScorer(calculator: SapCalculator).score(baseline, simulations) folds
the Simulation Overlays onto the baseline via the Overlay Applicator and
scores the throwaway EpcPropertyData on the injected deterministic SAP
calculator, returning Score(sap_continuous, co2_kg_per_yr,
primary_energy_kwh_per_yr). Depends on the SapCalculator abstraction, not a
concrete engine. This is the reusable scoring primitive (ADR-0016) — the
same call serves the optimiser's whole-package re-score and a future live
re-score of a user-assembled plan.

Two behaviour tests against the real Sap10Calculator on a hand-built EPD:
filling the main cavity improves SAP (right-directional through the real
physics); an empty package scores the unmodified baseline (pins the
SapResult->Score mapping). The Elmhurst before/after cascade PIN (#1154's
acceptance) lands once cert 001431 parses (external _extract_windows fix).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:41:30 +00:00
Khalim Conn-Kowlessar
bb2c0068ff feat(modelling): price the cavity Option from area x Product — closes #1155
recommend_cavity_wall now takes a ProductRepository and prices the Measure
Option: Cost(total = gross_heat_loss_wall_area(MAIN) x product.unit_cost_per_m2,
contingency_rate = product.contingency_rate). Detection is unchanged and runs
before pricing, so ineligible walls still return None without a catalogue hit.

Completes #1155 — the cavity-wall Recommendation Generator now detects an
uninsulated main cavity wall and emits a priced Option carrying the filled-
cavity overlay. Four behaviour tests (detection x3 + fully-loaded cost).
pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:35:52 +00:00
Khalim Conn-Kowlessar
b2c8980dd2 feat(modelling): ProductRepository + Postgres materials-table source
Product(measure_type, unit_cost_per_m2, contingency_rate). ProductRepository
is the DDD port abstracting the catalogue source; ProductPostgresRepository
reads the externally-owned material table (defensive SQLModel view
MaterialRow) and maps an active row to a Product — total_cost becomes the
fully-loaded unit_cost_per_m2 — joining the per-measure-type contingency
(contingencies.py, mirrors Costs.CONTINGENCIES; cavity 0.10). Strict-raise
on missing/inactive row. A JSON-backed impl will follow behind the same
port for ETL-gap costs.

Two DB tests against an ephemeral Postgres (map active row; raise on
inactive-only). Toward #1155 cost (4b). Also generalises the CONTEXT
Simulation Overlay wording: windows are targeted by index, building-part
association carried via window_location (_window_bp_index). pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 08:32:38 +00:00
Khalim Conn-Kowlessar
ec9ef0e8bb fix(extractor): drop windows-table header remnant from first window glazing type
Summary PDFs preprocessed from `pdftotext -layout` wrap the windows-table
header across several lines. The third header line's tail ("U value / g
value / Draught Proofed / Permanent Shutters") tokenises to "value value
Proofed Shutters" and lands directly above the FIRST window's data row.

Because the first window in a building part has `before_start = 0`, its
prefix block reaches back into that header remnant. The remnant is
neither an orientation nor a building-part fragment, so it survived the
pops in `_compose_window_descriptors` and leaked into glazing_type as
"value value Proofed Shutters Double between 2002 and 2021" (windows 2-3,
whose prefix starts after the previous window's manufacturer line, were
clean).

Fix: the glazing-type phrase always starts with a glazing-start word
(Single/Double/Triple/Secondary), so trim any prefix fragments preceding
that word before joining the glazing type. Orientation/bp pops still run
on the full prefix, so they are unaffected.

Reproduced from `sap worksheets/Recommendations Elmhurst Files/
cavity_wall_insulation - main wall/before/Summary_001431.pdf`. Added a
regression test driving the real `_extract_windows_from_layout` path with
the verbatim tokenised header+rows. 2306 passed (+4), pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:54:49 +00:00
Khalim Conn-Kowlessar
0ba0575877 feat(modelling): shared gross heat-loss wall area geometry helper
domain/building_geometry.gross_heat_loss_wall_area(epc, identifier) sums
heat_loss_perimeter x room_height across a building part's storeys — the
heat-loss wall area (party walls excluded by construction), not total
wall area. Lives outside the calculator so Modelling cost quantities can
reuse it; the calculator computes the same quantity inline today and
should be DRY'd onto this later (coordinated with the calculator branch).

Pinned at 45.93 m^2 against the 000490 MAIN part. Toward #1155 cost
(behaviour 4). pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:53:12 +00:00
Khalim Conn-Kowlessar
214b38ff78 feat(modelling): wall Recommendation Generator — cavity-fill detection + overlay
recommend_cavity_wall(epc) detects an uninsulated main cavity wall
(wall_construction=4, wall_insulation_type=4) and emits a Recommendation
whose single Measure Option carries the Simulation Overlay setting MAIN
wall_insulation_type=2 (Table 6 'Filled cavity'; cf. domain/sap10_ml/
rdsap_uvalues.py u_wall). Returns None for already-insulated or
non-cavity main walls.

Recommendation/MeasureOption reshaped per design review: the target is
encoded in the Option's overlay (addresses a building part / window /
system), not a typed key on Recommendation — generalises to glazing and
heating without changing the type. CONTEXT partition wording generalised
to match.

Three behaviour tests (hand-built EPD, no PDF). Cost (behaviour 4 of
#1155) outstanding — needs net heat-loss wall area + ProductRepository.
WIP on #1155. pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:49:33 +00:00
Khalim Conn-Kowlessar
896b5740c3 S0380.191: pin simulated 001431 gas-combi end-to-end at 1e-4 (e2e harness)
Adds the user-simulated 001431 case (the cert that drove S0380.189/.190)
as an Elmhurst-only e2e fixture: Summary PDF → extractor → mapper →
calculator, every Block-1 SapResult field pinned against the
P960-0001-001431 worksheet at abs=1e-4. All 11 pins pass with zero
residual — the case is clean, confirming the S0380.190 gas-combi fuel
derivation closes the Summary path natively.

Verified the handover's flagged "+0.0007 SAP" was a target artifact, not
a cascade gap: the worksheet displays ECF (257) rounded to 1.6047 and
integer SAP (258)=78; the cascade's continuous SAP is computed from the
UNROUNDED ECF = (255)*(256)/((4)+45) = 660.9750*0.4200/173.0, giving
77.6147 — which matches the worksheet's own unrounded value. Pinning the
continuous SAP from the display-rounded ECF (→ 77.6144) was the wrong
target. Block-1 line refs all match exactly: (211) 10699.7225, (219)
3327.1592, (231) 86.0, (232) 283.2229, (255) 660.9750, (272) 3000.1664,
Σ(98) 8987.7669.

Summary mirrored into the tracked fixtures dir as
Summary_001431_gas_combi.pdf (distinct name — the corpus reuses cert
001431 across every heating variant); source Summary + worksheet tracked
under sap worksheets/golden fixture debugging/ as the pin ground truth.

2302 passed (+11), 0 failed; pyright net-zero on new/changed files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:44:32 +00:00
Khalim Conn-Kowlessar
e43ff79c77 S0380.190: derive gas-combi main fuel from §15.0 when §14.0 Fuel Type is empty
The newer Elmhurst Summary export lodges a gas combi as §14.0 "Fuel Type"
empty + "Main Heating SAP Code" 104 (EES "BGW"), with no fuel string. The
site-notes mapper left `main_fuel_type=''`, so `cert_to_inputs` raised
`MissingMainFuelType` — blocking the whole gas-combi Summary path
(reproduced on the simulated 001431 case).

SAP 10.2 Table 4b (PDF p.168) rows 101-119 are "Gas boilers (including
mains gas, LPG and biogas)": the code fixes the boiler type/efficiency but
NOT the carrier, so 104 alone can't distinguish mains gas from LPG. The
disambiguator is §15.0 "Water Heating Fuel Type" — a combi/boiler heats
space + water from one appliance — exactly mirroring the existing
liquid-fuel (codes 120-141) fallback. `_elmhurst_gas_boiler_main_fuel`
adopts the §15.0 carrier only when the SAP code is in 101-119 AND §15.0
resolves to a gas/LPG fuel, so a regular boiler + electric immersion
(§15.0 = "Electricity") still strict-raises rather than mis-billing gas
as electric.

2291 passed (+1), 0 failed; pyright net-zero on both files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:23:02 +00:00
Khalim Conn-Kowlessar
350f4c8e76 feat(modelling): Overlay Applicator folds EpcSimulation onto EpcPropertyData
EpcSimulation is the Simulation Overlay — a narrow all-optional partial
mirror of EpcPropertyData/SapBuildingPart (wall surface first), targeting
building parts by BuildingPartIdentifier (composition, not inheritance).
apply_simulations(baseline, simulations) deep-copies the baseline, folds
overlays in order (later wins on a shared field) via a generic non-None
field write, and returns a throwaway EpcPropertyData for the calculator;
the baseline is never mutated.

Four behaviour tests (hand-built EPD from the 000490 fixture, no PDF):
targeted-write-leaves-others-untouched, empty-overlay no-op, sequential
last-wins, baseline-immutability. pyright strict clean.

Slice 1 of the Modelling stage rebuild (ADR-0016). Closes #1153.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:13:51 +00:00
Khalim Conn-Kowlessar
0ba45a09cc docs(modelling): record stage design — CONTEXT terms + ADR-0016
Reframe Recommendation as a target surface (partitions the EpcPropertyData
surface, so selected overlays never collide); add Measure Option,
Simulation Overlay (EpcSimulation), Product, Cost, Contingency, and
Measure Dependency. ADR-0016 fixes the scoring/optimisation approach
(warm-start grouped-knapsack MILP -> deterministic package re-score ->
greedy repair, with a final-package marginal cascade for display
attribution), resolving the open question in ADR-0005 §14.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:13:51 +00:00
Khalim Conn-Kowlessar
e63d046b9d docs: handover post S0380.189 — TMP/Table 22 + the two open follow-ups
Point-in-time note for the next agent: what S0380.185-189 shipped (worksheet
PE/CO2 pins, the two D_PV electricity-vs-gain fixes, and the thermal-mass-
parameter Table 22 fix), the per-line diagnosis template, the two worksheet-
block / gains-vs-solar traps, and the ranked open slices (Summary-path fuel
derivation first, then pin the simulated 001431 case, then cert 6035).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:05:41 +00:00
Khalim Conn-Kowlessar
e03f08cdc8 S0380.189: thermal mass parameter per RdSAP 10 §5.16 Table 22, not hardcoded 250
The §7 mean-internal-temperature cascade hardcoded the thermal mass parameter
(TMP) to 250 kJ/m²K at all 5 call sites, ignoring construction. RdSAP 10
§5.16 Table 22 (PDF p.48) makes TMP construction-dependent:

  100 kJ/m²K — timber frame, cob, park home (regardless of internal
               insulation); OR masonry (stone/solid brick/cavity/system
               built) WITH internal insulation.
  250 kJ/m²K — masonry WITHOUT internal insulation.

A too-high TMP inflates the §7 time constant τ = Cm/(3.6·H) (e.g. 40 h vs
16 h), under-cuts the temperature reduction between heating periods, and
over-states mean internal temperature → over-states space heating.

`_thermal_mass_parameter_kj_per_m2_k(epc)` classifies the MAIN building's
wall via the RdSAP `wall_construction` codes (5/7/8 = timber/cob/park) and
`wall_insulation_type` codes (3/7 = internal); unknown/curtain fall back to
the masonry 250 (no regression on unlisted classes). 17-case parametrised
test covers every Table 22 branch.

Diagnosis (per-line walk vs the user-simulated 001431 worksheet, same
archetype as golden cert 6035): fabric (26-37), internal gains (73), climate
(96)m and HTC (39) all EXACT; the entire +8.78 PE / -1.76 SAP gap was §7 MIT
(92) +0.71 °C, traced to TMP 250 vs Table 22's 100 (solid brick WITH internal
insulation). Fix closes the simulated case to 1e-4 on PE and CO2.

Blast radius: only golden cert 6035 re-pins (solid brick + internal
insulation) — SAP resid -6 → -2, PE +46.42 → +19.16, CO2 +1.07 → +0.42. The
47 dr87 cohort, 6 U985 fixtures and 41-variant heating corpus are all
masonry-no-internal → TMP unchanged at 250, all still pass. 2290 pass
(+17 new), 0 fail; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:01:35 +00:00
Khalim Conn-Kowlessar
1382c8c886 docs: add AGENT_GUIDE.md — fresh-start onboarding for the SAP calculator
A single durable doc so agents can pick up the calculator without reading
historical handovers: (1) the accuracy bar for the two input paths
(site-notes 1e-4 vs worksheet; API 1e-4 when a worksheet exists, ±0.5
register fallback otherwise; cross-mapper parity); (2) the per-line-walk
debugging loop incl. comparing site-notes vs API; (3) the tools &
pipeline (Summary PDF → extractor → from_elmhurst_site_notes →
cert_to_inputs → calculate_sap_from_inputs → SapResult, plus the API
from_api_response front-end, section helpers, and where the test vectors
live). Pointer added from SAP_CALCULATOR.md; HANDOVER_* flagged as
point-in-time notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:32:29 +00:00
Khalim Conn-Kowlessar
72743eb8a4 S0380.188: D_PV,m uses lighting ELECTRICITY (L10) not the L12 gain — closes PV cohort to 1e-4
SAP 10.2 Appendix M1 §3a (p.93) defines PV-eligible demand as
  D_PV,m = E_L,m + E_A,m + E_cook,m + E_ES,m + (231)·n_m/365 + E_space,m + E_water,m
where E_L,m is the lighting ELECTRICITY (Appendix L eq L10, = line (232)).
The cascade fed `internal_gains_result.lighting_monthly_w` — the L12 internal
heat GAIN G_L,m = E_L,m × 0.85 ("assuming 15%" of lighting energy does not
become internal heat) — into D_PV, understating it by 15% of lighting on
every PV cert. That depressed the monthly β onsite/export split and
under-credited PV primary energy uniformly across the year.

Same gain-vs-electricity class as the cooking fix S0380.73 (L18 gain vs L20
electricity). Fix: scale the (shape-identical) lighting gain profile to the
annual E_L `lighting_kwh_per_yr` (= (232)), mirroring the (219)m hot-water
scale-to-annual. Magnitude-only, so the shape-weighted lighting CO2/PE
effective factor (Σkwh×f/Σkwh, magnitude-invariant) is unchanged; appliances
need no scaling (G_A = E_A, no 0.85). Diagnosis was empirical first (calc
lighting D_PV 95.1 vs worksheet (232) 111.88, ratio exactly 0.85) then
confirmed against the spec text (L9d/L10/L12, M1 §3a).

Impact (calc − full-precision dr87 worksheet): ALL 47 worksheet certs now
match at <1e-4 on BOTH PE (max |Δ| 0.0000 kWh/m²) and CO2 (max |Δ| 0.0000 kg)
— the convergence target, met cohort-wide. Combined with S0380.187 this
closes the entire gas+PV + ASHP PV residual. Re-pinned 47 worksheet residuals
to 0.0000 and 31 drifted lodged residuals (PV certs). SAP integers unchanged;
chain SAP 1e-4 intact (164 pass). 2273 pass, 0 regressions; pyright net-zero.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:05:12 +00:00
Khalim Conn-Kowlessar
a5d886187c S0380.187: include electric secondary heating in Appendix M1 D_PV,m — closes gas+PV PE/CO2 gap
The PV onsite/export β-split (SAP 10.2 Appendix M1 §3a, p.93) divides PV
generation by the monthly PV-eligible electricity demand D_PV,m. The cascade
included main and water electricity (when those fuels are electric) but had
no term for SECONDARY space heating. For the 10 cohort-2 gas-main +
electric-secondary + PV certs, the (215)m secondary electric fuel was dropped
from D_PV,m — understating demand in the heating months only, depressing the
monthly β, and under-crediting onsite PV primary energy.

Spec: Appendix M1 §3a counts E_space,m as the dwelling's TOTAL electric
space-heating demand; for a gas-main/electric-secondary dwelling that is the
secondary fuel. Diagnosis was decisive: E_PV (generation) matched the
worksheet exactly every month, the onsite (233a) split diverged ONLY in
heating months (Jun-Sep near-exact), and all 10 affected certs have PV while
all clean gas certs have none. Empirically adding (215)m to D_PV closed cert
3136 onsite 726.9 → 790.3 (worksheet 792.1).

Impact (calc − full-precision dr87 worksheet), the 10 certs:
  PE +0.5..+1.5 → +0.02..+0.046 kWh/m²; CO2 −0.5..−1.1 → +0.002..+0.0095 kg.
The whole 47-cert cohort now matches at PE <0.05 / CO2 <0.025. SAP integers
unchanged; chain SAP 1e-4 pins intact (164 pass). The uniform ~0.03 PE remnant
on PV certs is the separate (233a)/(233b) summer-month D_PV discrepancy.

Re-pinned the 10 worksheet + 9 lodged golden residuals (improvements).
2273 pass, 0 regressions; pyright net-zero (file's 32 errors pre-existing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:28:50 +00:00
Khalim Conn-Kowlessar
5f4a78e4c9 S0380.186: pin golden PE/CO2 against full-precision dr87 worksheets (47 certs)
The existing golden test compares calc PE/CO2 against the integer-rounded
lodged register values (energy_consumption_current / co2_emissions_current),
which conflates real calculator gaps with register rounding. This adds a
parallel pin against each cert's Elmhurst dr87 worksheet (286)/(272) at full
precision — a clean calculator-vs-Elmhurst signal for the 47 worksheet-backed
certs (9 ASHP + 38 cohort-2).

Findings at capture (calc − worksheet, on the worksheet's own decimal TFA):
  - 37/47 exact on both PE (<0.05 kWh/m²) and CO2 (<0.02 kg).
  - 10 higher-consumption gas certs carry PE +0.5..+1.5 kWh/m² AND
    CO2 -0.5..-1.1 kg simultaneously. PE-over + CO2-under on the same
    certs is the fingerprint of a small gas→electricity fuel-split
    difference (elec PE 1.51 > gas 1.13, but elec CO2 0.136 < gas 0.21),
    not a factor-value error — next slice candidate.

An earlier "41/47 PE gaps" reading was a JSON-integer-TFA division artifact;
comparing on the worksheet's decimal TFA (which the calculator also uses)
collapses it to the real 10. Worksheet values frozen as literals (the dr87
PDFs are untracked, so not parsed at test time) per the worksheet_unrounded_sap
convention. Also replaced a pre-existing pytest.approx with abs-diff to keep
the file at zero pyright errors (feedback_abs_diff_over_pytest_approx).

106 passed (was 59); pyright 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:54:45 +00:00
Khalim Conn-Kowlessar
57241322ea S0380.185: record CH6 pin-forever proof — distribution-loss is a Summary-export gap
CH6's P960 worksheet input lodges Distribution Loss = "Two adjoining
dwellings sharing a single heating system" → (306) DLF = 1.0000, vs CH4's
"Calculated" → 1.5 → (306) = 1.4500. That DLF choice swings SAP/cost/CO2/PE
materially, but it is NOT present in the Summary PDF that the corpus pipeline
consumes (Summary → ElmhurstSiteNotesExtractor → mapper → calculator).

Proven empirically with a user-supplied controlled pair (CH adjoined
dwellings/Summary_001431 (1) vs (2)): the two Summaries are byte-identical
across every RdSAP INPUT field, differing only in the derived header
(SAP 80 vs 75, bill £954 vs £1237, emissions 5.407 vs 7.394 t). A
case-insensitive scan of the CH6 Summary for "distribution"/"adjoin" returns
0 hits. Since CH4/CH6 Summaries are themselves identical bar fuel type, no
Summary-derivable rule can yield CH4=1.45 AND CH6=1.0.

Doc-only change (comment in _EXPECTATIONS); 20/20 community-heating corpus
tests pass. Closes the CH6 re-litigation: pin held.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:21:28 +00:00
Khalim Conn-Kowlessar
f179950519 feat(baseline): wire BillDerivation into the orchestrator and persist the Bill (ADR-0014)
The PropertyBaselineOrchestrator now reads the current Fuel Rates snapshot
once per batch, builds a BillDerivation, and prices each scored property's
SapResult -> EnergyBreakdown into a Bill carried on PropertyBaselinePerformance
(None only on the stub no-calculator path). The Bill is flattened onto nullable
bill_* flat columns (per-section kwh+cost, standing charges, SEG credit, total)
on the postgres table, with bill_total_annual_bill_gbp as the not-null
discriminator on read-back. Section absent from the bill stays None, not 0.

Updated all four orchestrator construction sites to inject the FuelRatesRepository
port (handler + three test sites), and the FE migration doc to reflect the
prefixed columns and that they are now populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:51:18 +00:00
Khalim Conn-Kowlessar
82f7315f8d S0380.184: community electric-HP network CO2/PE uses monthly Table 12d/12e — closes CH3
SAP 10.2 worksheet block 12b/13b (367)/(467) for a community heating
electric heat pump (Table 4a code 304 → Table 12 fuel 41 "heat from
electric heat pump"). The HP meters grid electricity, so per Table 12
note (s)/(t) + block 12b/13b footnote (a) its emission/PE factor is the
MONTHLY Table 12d/12e cascade (fuel 41 = standard-electricity profile),
weighted by the network heat profile, then × 1/heat-source-eff (1/COP):

  (367)/(467) = [(307)+(310)] / COP × Σ((307+310)_m × factor_m)/Σ(...)

Per-line walk of CH3 (the displayed (367) 0.1535 / (467) 1.5717 are PDF
artifacts; the (373)/(473) totals reconcile only with):
  CO2 factor = 0.15040 (monthly Table 12d wtd) vs cascade annual 0.136
  PE  factor = 1.55692 (monthly Table 12e wtd) vs cascade annual 1.501

Pre-slice the cascade routed code 304 through the non-electric branch
(`_co2_factor_kg_per_kwh(main) × 1/COP` = annual × scaling). New
`_is_heat_network_electric_main` (heat-network main whose fuel has a
Table 12d monthly set — i.e. fuel 41) routes all four factor helpers
(main + HW, CO2 + PE) through the monthly cascade × 1/COP. Non-electric
heat networks (gas 51 / oil 53 / coal 54) have no monthly set → annual
path unchanged (CH1, CH6 untouched).

Closure (CH3 was already SAP+cost EXACT):
  CH3 (HP/Elec)  CO2 −75.32→+0.0000 (= [(307+310)/3]×(0.1504−0.136)),
                 PE −249.32→−0.0000 (× (1.5569−1.501))  — FULLY EXACT

Corpus now 40/41 EXACT on all four metrics. Only CH6 remains: its
worksheet lodges a manual DLF=1.0 ("two adjoining dwellings") absent
from the Summary PDF (byte-identical to CH4 bar fuel type) — an
architectural limit, not a cascade gap. 2226 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 43→43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:43:16 +00:00
Khalim Conn-Kowlessar
f7dc9dbccb feat(baseline): Rebaseliner returns RebaselineResult carrying the SapResult
The Rebaseliner is the assemble-and-score step (ADR-0013 amendment); its
SapResult is the scored picture that Bill Derivation also prices (ADR-0014),
so rebaseline() now returns a RebaselineResult{effective, reason, sap_result}
instead of (Performance, reason). CalculatorRebaseliner sets sap_result on
both branches (the bill prices it whether lodged or calculated figures win);
StubRebaseliner returns sap_result=None (runs no calculator). Orchestrator
unpacks the result; the bill wiring lands in the next slice.

Also refreshes the stale ML-era docstrings in rebaseliner.py to the
assemble-and-score model (the calculator, not ML, is the rebaseliner
mechanism per ADR-0013).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:37:13 +00:00
Khalim Conn-Kowlessar
803da062a2 S0380.183: community-heating HW bills at heat-network rate (§10b) — closes CH2/CH4 fully
SAP 10.2 §10b: hot water for a community-heating dwelling bills at the
heat-network rate, not the cert-lodged fuel. Elmhurst §15.0 lodges
`water_heating_fuel_type = "Mains gas"` (3.48 p/kWh) as a placeholder on
community certs; the worksheet (342) Water-heating cost = (310) × the
S0380.171 CHP heat-fraction blend — the SAME rate as space heating (340).

Per-line walk of the CH2 block 10b:
  (340) space   = 11837.83 × 0.037955 = 449.3047  (cascade EXACT)
  (342) water   =  3854.12 × 0.037955 = 146.2830  (cascade billed
                  3854.12 × 0.0348 = 134.12 → −£12.16, the whole residual)
  (350) lighting + (351) standing → (355) 754.1502.

`_hot_water_fuel_cost_gbp_per_kwh`'s `inherit_main_for_community_heating`
path already routes HW cost through `_fuel_cost_gbp_per_kwh(main)` (the
CHP blend), but its gate `_is_community_heating_hw_from_main` excluded
code 302. S0380.182 wired the 302 CO2/PE credit via
`_heat_network_code_302_effective_factor`, which intercepts the HW
CO2/PE helpers ABOVE this predicate's branch — so extending the
predicate to include 302 now affects ONLY the cost path.

Closures:
  CH2 (CHP/Gas)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH4 (CHP/Oil)  SAP +0.5277→−0.0000, cost −£12.16→−£0.00  — FULLY EXACT
  CH6 (CHP/Coal) SAP −7.49→−8.02, cost +£172.68→+£184.84 — its HW now
                 also bills the blend, compounding the DLF=1.0 quirk
                 (cascade DLF=1.45); same separate CH6 DLF front.

Corpus now 39 variants EXACT on all four metrics (CH2/CH4 join). Open:
CH3 CO2/PE (code-304 community-HP COP), CH6 all-metric (DLF=1.0 manual
override the Summary doesn't carry). 2225 pass + 1 skip + 0 fail
(tolerances 1e-4 all metrics); pyright net-zero 32→32.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:29:57 +00:00