Commit graph

10 commits

Author SHA1 Message Date
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
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
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
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
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
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