`u_window` hard-coded the 12 mm gap row for pre-2002 double/triple glazing
(double 2.8, triple 2.1), ignoring the lodged glazing gap. Table 24 splits
the pre-2002 rows by gap: double 6mm=3.1 / 12mm=2.8 / 16mm+=2.7; triple
6mm=2.4 / 12mm=2.1 / 16mm+=2.0 (PVC/wooden), with a metal-frame column
(+0.5/+0.5/+0.5 ish). Added a `glazing_gap` parameter + `_glazing_gap_row`
helper and wired `w.glazing_gap` through the synthesised-window caller in
heat_transmission.
Corpus impact nil by design: the gov-API mapper already resolves per-window
U gap-aware via `_API_GLAZING_TYPE_GAP_TO_TRANSMISSION` (e.g. code 3 + gap
"16+" → 2.7), so corpus certs use that lodged per-window U, not this fallback.
This aligns the reduced-field / worksheet fallback path with the mapper and
Table 24. Unknown gap still defaults to the 12 mm row.
(Metal frames are not distinguishable on the gov-API path — only a `pvc_frame`
boolean exists and Table 24 groups PVC+wooden — so the PVC/wooden U stands
there; the metal column applies only where frame material is lodged.)
Spec-pinned: pre-2002 double + triple gap-row tests. pyright not installed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Table 24 "Double or triple glazed, 2022 or later" row gives U = 1.4 for
PVC/wooden frames and 1.6 for metal frames. `u_window` returned 1.4 for both,
ignoring the metal-frame variant and under-counting metal-frame heat loss on
post-2022 windows. The 2002-2021 and pre-2002 rows already split PVC/metal
correctly; this aligns the 2022+ row.
Spec-pinned in test_u_window_post_2022_metal_returns_table24_1_6_not_pvc_1_4.
pyright not installed in this container.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A full row-by-row audit of the roof U-value tables (16, 17, 18) and floor
tables (19, 20) against the PDF found one numeric error: Table 18 column (2)
"Pitched, insulation at rafters" band M is 0.15 W/m²K (footnote (1) only — no
country variation; the whole M row converges to 0.15), but _ROOF_RAFTERS_BY_AGE
carried 0.18. The rafters column diverges above the joist column at H-L
(0.35/0.35/0.20/0.20/0.18) and rejoins it at M=0.15.
Everything else in the roof/floor tables is exact: Table 16 joist + rafter
thickness ladders, Table 17 room-in-roof (all 6 columns), Table 18 cols (1)
joists / (3) flat / (4) room-in-roof, Table 19 (England & Wales) floor
insulation defaults, and Table 20 (England) exposed-floor U-values.
Known remaining gaps (NOT fixed — zero England-corpus reach, would need a new
roof country-override mechanism): Scotland Table 18 footnote (2) K=0.20 (flat/
RR/thatch) and (3) joists-L=0.15; Scotland/NI Table 19 thicknesses; Scotland/
Wales Table 20 L/M overrides. Logged for a future country pass.
Corpus unchanged (band-M rafter roofs essentially absent from the 1000-cert
England corpus). pyright not installed in this container.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The corrected stone branch (age-E-only cap, §3.5 Table-3 unknown-thickness
default, Scotland band-J override) improved the corpus gauge to SAP MAE 0.819
and PE MAE 3.6 kWh/m2/yr. Lock the gains in and record the corrected
mechanism in the provenance log; within-0.5 (71.6%) and CO2 (0.08) unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A full code-vs-spec sweep of unknown-thickness as-built U-values across all
seven wall types found exactly one divergence: Scotland age band J. Table 7
(Scotland) gives 0.30 for every uninsulated wall type, but the England base
(Table 6) is 0.35 and the _COUNTRY_KLM_OVERRIDES[SCT] dicts listed H/K/L/M
while omitting J — so a Scotland band-J wall wrongly returned England's 0.35.
Added 'J': 0.30 to all 7 SCT as-built entries (granite, sandstone, solid
brick, cavity, timber frame, system built, cob).
The audit's companion finding — Scotland sandstone age E = 1.5 — needs NO
table override: the §5.6 stone branch intercepts all sandstone bands A-E and
already caps age E at 1.5 for Scotland (e_default in the prior commit), so a
table-E override would be dead code. England base tables and Isle of Man
(= England) are confirmed correct by the same sweep.
Corpus unchanged (England-only corpus has no Scotland band-J certs).
pyright not installed in this container — strict type gate not run locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a stone wall lodges no documentary thickness, RdSAP 10 §3.5 Table 3
supplies the default thickness to feed the §5.6 formula — it does NOT fall
to a flat 1.7. Table 3 stone row: A-D = 500 mm, E = 450, F-H = 420;
Scotland footnote (*) adds 200 mm for bands A,B and 100 mm otherwise.
This matches accredited Elmhurst: an England age-B granite/whinstone wall,
as built, unknown thickness defaults to 500 mm →
U = 45.315 × 500^(-0.513) = 1.87 (sandstone → 1.68).
The previous flat-1.7 fallback was a setup error — Table 6 reads "According
to 5.6" for bands A-D with no 1.7 entry, so the formula (not 1.7) is the
spec target. The age-E footnote-(a) cap still applies on top.
Adds `_table_3_stone_thickness(band, country)`. Updated the stale
`..._returns_table_6_age_a_default` pin (was 1.7 → now 1.87 via Table-3)
and added sandstone (1.68) + Scotland-+200mm (700 mm → 1.39) pins.
Corpus: within-0.5 71.6% unchanged, SAP MAE 0.821 → 0.819, PE MAE 3.7 → 3.6.
pyright not installed in this container — strict type gate not run locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Commit 034d4b7c corrected two stone cases but via the wrong mechanism: it
removed the 1.7 cap for ALL age bands and gated the §5.6 formula on
`wall_insulation_type is not None`. Both diverge from RdSAP 10 Tables 6-10:
- The stone rows read "According to 5.6" for bands A-D (uncapped formula)
and "1.7 a" at band E, where footnote (a) = "Or from equations in 5.6 if
the calculated U-value is less than 1.7" → U_E = min(formula, 1.7). The
cap belongs ONLY at age E. Removing it for E let an as-built 50 mm granite
age-E wall return the formula's 6.09 instead of 1.70.
- Scotland sandstone/limestone age E defaults to 1.5 (Table 7, PDF p.35);
granite/whinstone stays 1.7.
- The insulation-state gate is not a spec rule. It sent age-A-D stone with
"insulation Unknown" (wall_insulation_type None) to the flat-1.7 table
instead of the §5.6 formula; it only "worked" for fixture 000565 Ext1
because that wall is age E, where 1.7 is correct anyway. Removed: the
age-E cap, not the gate, is what produces 000565 Ext1's worksheet 1.70.
Bands A-D stay uncapped (sandstone 400 mm → 1.90, granite 120 mm → 3.89).
Unknown-thickness handling unchanged in this commit (still flat-table; the
§3.5 Table-3 default follows separately).
pyright not installed in this container — strict type gate not run locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two-pass org_ref-matched builder for property_overrides (classify via ChatGPT
into the landlord ledger, validate+apply user edits, write idempotently);
ephemeral-Postgres smoke proves the one-property chain without creds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caught live writing property_overrides on portfolio 796: the Python
override_component SAEnum lagged the DB enum, so reading a new-component row
back threw LookupError. Guard it with a consistency test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The FE-owned `material.type` pgEnum cannot carry `secondary_heating_removal`,
so pricing it through the DB catalogue raises a DataError that poisons the
session — the modelling pipeline crashed on any property with a lodged
secondary heater unless the measure was excluded on the Scenario.
Realise the `ProductRepository` docstring's intent (DB catalogue today, a JSON
file for costs the ETL does not yet supply, behind the same port): add a
`CompositeProductRepository` that resolves an override source first, then the
catalogue. Checking the override first keeps that Measure Type away from the DB
entirely; every other type misses the override and falls through unchanged.
- off_catalogue_costs.json prices it at £270 flat per-dwelling — the legacy
`Costs.heater_removal` ported to the new flat model (ADR-0028):
(£25 + £200 baseline) x 1.2 VAT, for the single fixed secondary a cert lodges.
Contingency (0.25) is joined from config, not the file.
- Wire the composite into PostgresUnitOfWork.product and run_modelling_e2e, so
the first-run pipeline and the local runner both honour the overlay.
- Integration test: drop the unrealistic seeded secondary_heating_removal DB
rows (the pgEnum can't hold the type) and assert it is JSON-sourced
(material_id is None, cost £270) end-to-end through a real Unit of Work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An uninsulated stone wall of lodged thickness, age bands A-E, is billed by
the RdSAP 10 §5.6 Table-12 formula on its measured thickness — sandstone /
limestone U = 54.876·W^-0.561, granite / whinstone U = 45.315·W^-0.513. Two
bugs suppressed it:
1. CAP: the as-built branch capped the formula result at the Table-6
typical-thickness default (`if u0 >= 1.7: return 1.7`). But the formula
only dips below 1.7 past ~488 mm (sandstone) / ~640 mm (granite), so the
cap nullified §5.6 for essentially every real-thickness stone wall,
under-counting fabric loss and over-rating. A measured 400 mm sandstone
age-B wall is 1.90 (Elmhurst-confirmed), not 1.70.
2. NO INSULATION-STATE GATE: the branch fired for any stone wall with a
lodged thickness, including ones whose insulation is "Unknown". RdSAP
treats an unknown-insulation wall via the Table-6 typical-thickness
default, NOT as bare stone of the lodged thickness — so a 50 mm granite
wall with Unknown insulation must read 1.70 (worksheet), not the formula's
6.09. Gated on `wall_insulation_type is not None`.
Fixes the 2 long-standing stone-U unit tests (granite 120 mm → 3.8871,
sandstone 120 mm → 3.7408 — they were correct red tests, not "known fails").
Corpus within-0.5 70.3% -> 71.6% (MAE 0.833 -> 0.822); ratcheted floors to
0.71 / 0.83. Worksheet fixture 000565 (granite 50 mm Unknown → 1.70) still
passes via the insulation gate. The two stone groups (sandstone/limestone vs
granite/whinstone) keep their distinct §5.6 coefficients.
pyright not installed in this codespace (strict gate not run locally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SAP rating is spec-floored at 1 ("if the result of the calculation is
less than 1, the rating is 1"). `sap_rating_integer` already clamps, but the
continuous `sap_score_continuous` did not — so a degenerate dwelling could
emit a physically impossible negative SAP. Apply the same max(1, …) floor to
the continuous value (the un-rounded part is for sensitivity near real
ratings, not for negative ratings).
Removes a -12.3 accuracy outlier on the committed corpus (cert 422000111926,
lodged at the floor of 1, was computing -11.3): within-0.5 70.2% -> 70.3%,
MAE 0.845 -> 0.833. Ratcheted the corpus MAE ceiling to 0.84. Unit-pinned in
test_calculator.
pyright not installed in this codespace (strict gate not run locally).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>