mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
175 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dfe2f2ce6e |
Slice 102f-prep.8: API mapper resolves shower_outlets=None → 0 mixers
Cert 2225 (Mitsubishi PUZ-WM50VHA, semi-detached 2-bp, TFA 82.49) lodges `sap_heating.shower_outlets = None` in the Open EPC API JSON. The worksheet (42a) "Hot water usage for mixer showers" reads 0 every month — Elmhurst's convention is "absent ⇒ no shower". Pre-fix the API mapper returned `mixer_shower_count = None`, deferring to the cert→inputs cascade's "RdSAP modal lodging" default of 1 vented mixer. That added ~7 L/day to (44) daily HW use, ~113 kWh/yr to (62) HW demand, and shifted cert 2225's SAP residual from -0.31 → +0.04 (now aligned with the cohort's +0.03..+0.06 cluster) once the mapper returns 0. `_count_shower_outlets_by_type` now treats None as 0 (the API mapper-only path). The cert→inputs cascade's `_mixer_shower_flow_rates_from_cert` keeps the None→1 default for the Elmhurst hand-built fixture path that doesn't route through this helper. Cohort impact: 6 of 7 ASHP certs now cluster at SAP Δ +0.03 to +0.06 (vs worksheet); only cert 2636 remains an outlier (+0.49). Golden cert PE/CO2 pins re-pinned for 6035, 8135, 0390 (the three certs that previously lodged shower_outlets=None and consumed the spurious 1-mixer default). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7874374bcf |
Slice 101a: API glazing_type=14 → DG/TG 2022+ (RdSAP 10 Table 24)
Cert 0380 (ASHP semi-detached bungalow, worksheet SAP 88.5104) lodges glazing_type=14 on all windows. The worksheet uses U=1.3258 (post-curtain) for line (27), back-calculating to a raw U=1.40 — the SAP10.2 Table 24 row for "Double or triple glazed, 2022 or later" (England/Wales 2022+ / Scotland 2023+ / NI 2022+). Without code 14 in `_API_GLAZING_TYPE_TO_TRANSMISSION` the cascade falls back to `u_window`'s default (~U=2.50 post-curtain), inflating windows HLC by 5 W/K on cert 0380 (6.80 → 11.68). Added `14: (1.4, 0.72, 0.70)` — same U/g/frame as code 13. Codes 13 and 14 are schema siblings within the post-2022 product family (the cert lodgement integer differentiates between DG and TG sealed-unit variants but Table 24 collapses them to the same row). Effect on cert 0380 API path: - windows HLC 11.68 → 6.80 (= worksheet 6.80 exact) - (37) total HLC 104.22 → 99.34 (worksheet 96.09; Δ +3.25 left on walls — next slice closes it) - sap_continuous 86.82 → 87.62 (Δ -1.69 → -0.89; closer to worksheet 88.51) No golden cert residuals shifted (cohort + 9501 don't lodge glazing_type=14). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
16845604e2 |
Slice 100c: API path — surface PV arrays + gap-aware glazing lookup
Two final API gaps to close cert 9501 at 1e-4:
(a) PV array surfacing — third shape variant:
Schema-21 EPCs carry `photovoltaic_supply` as one of three shapes:
- legacy `{"none_or_no_details": {...}}` (PV absent / roof-only)
- nested list `[[{...}], ...]` (cohort cert 2130)
- dict wrapper `{"pv_arrays": [{...}]}` (cert 9501)
The schema's `PhotovoltaicSupply` modelled only `none_or_no_details`
— cert 9501's measured arrays under `pv_arrays` were silently
dropped (Δ -£250 PV credit → -9.32 SAP). Added
`SchemaPhotovoltaicArray` dataclass + `pv_arrays:
Optional[List[...]]` sibling field on `PhotovoltaicSupply`; updated
`_map_schema_21_pv` to dispatch on the new shape.
(b) Gap-aware glazing lookup (RdSAP 10 Table 24 row 2):
DG pre-2002 spec U varies by gap: 6mm=3.1 / 12mm=2.8 / 16+=2.7.
The mapper's flat `_API_GLAZING_TYPE_TO_TRANSMISSION[3]` returned
U=2.8 unconditionally — cert 9501 lodges `glazing_gap="16+"` so
the worksheet uses 2.7. Added `_API_GLAZING_TYPE_GAP_TO_
TRANSMISSION` keyed by (type, gap) with the spec-table values for
code 3; `_api_glazing_transmission` consults the per-gap dict
first, falling back to type-only when no gap entry exists.
Refactored the inline `SapWindow(...)` build into
`_api_sap_window` helper (also nets one pyright error: net-zero
actually improved 33 → 32 on mapper.py).
Effect on cert 9501 API path:
- sap_continuous 59.20 → **68.525161** (= worksheet 68.5252 exact;
Δ -0.000039 — well within 1e-4)
- total_fuel_cost £1101 → £849.21 (= worksheet 849.21 exact)
- pv_export_credit £0 → £250.02 (= worksheet 250.02 exact)
Re-pinned residuals (5 cohort certs with glazing_gap="16+" or 6 now
pick up the spec-correct DG-pre-2002 U):
- 0300: PE +8.44 → +8.28, CO2 -0.23 → -0.25
- 6035: PE +48.30 → +47.85, CO2 +1.10 → +1.09
- 7536: PE -6.51 → -7.08, CO2 -0.17 → -0.19
- 8135: PE -5.31 → -3.66 (gap=6 spec U=3.1), CO2 -0.07 → -0.04
- 2130: PE -38.18 → -38.63, CO2 +0.30 → +0.30
Layer 4 chain test `test_api_9501_full_chain_sap_matches_worksheet
_pdf_exactly` added — third production gate after cert 001479 +
cert 0330. First flat-shaped cert in the production gate set.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
d529f91a8e |
Slice 100b: API TFA — include per-bp RR floor area in continuous TFA
`_total_floor_area_from_building_parts` previously summed only `sap_floor_dimensions[*].total_floor_area`; the RR floor area lives under `sap_room_in_roof.floor_area` per RdSAP §3.9 convention and was dropped from the per-bp TFA sum. Cert 9501 (113.08 m² real TFA, of which 31.8 m² is RR) showed TFA 81.28 on the API path — the cascade then under-computed occupancy N (Appendix J), HW kWh (Appendix J), lighting kWh (Appendix L), and internal gains. Add the RR contribution to the sum. The top-level `schema.total_floor_area` scalar (integer-rounded for cert 9501: 113 vs raw 113.08) is still the fallback when no per-bp dims are lodged. Re-pinned residuals (improvements — TFA now includes the previously- dropped RR storey): - 0240: SAP -15 → -14, PE +15.69 → +12.49, CO2 +0.90 → +0.70 - 6035: PE +49.51 → +48.30, CO2 +1.14 → +1.10 Effect on cert 9501 API path: TFA 81.28 → 113.08 (= worksheet 113.08 exact). SAP delta still -9.32 vs worksheet — the remaining gap is dominated by the missing PV credit (£250 — next slice). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8e74b6b8b8 |
Slice 100a: API path — surface Detailed-RR per-surface areas
Two RR shapes coexist in real-API JSON: cohort certs (6035, 0240,
schema test 21_0_1.json) lodge `room_in_roof_type_1` (RdSAP §3.9.1
Simplified Type 1 — gable lengths only, cascade applies the 2.45 m
default storey height); cert 9501 lodges `room_in_roof_details`
(RdSAP §3.9 Detailed RR — per-surface lengths + heights + flat-
ceiling detail). The schema only modelled the Simplified-Type-1
wrapper, so `from_dict` parsed cert 9501's Detailed-RR block as
None and the API mapper built `SapRoomInRoof` with `detailed_
surfaces=None`. The cascade then defaulted to Simplified Type 2
"all elements" (RR floor area × Table 18 col(4) age-B U=2.30) for
the whole RR → roof HLC 149.43 W/K vs worksheet 18.10 (Δ +131.32).
Changes:
- Add `RoomInRoofDetails` dataclass to both schema 21.0.0 and 21.0.1
with the 10 fields the JSON lodges: gable_wall_type_{1,2} +
gable_wall_length_{1,2} + gable_wall_height_{1,2} + flat_ceiling_
length_1 + flat_ceiling_height_1 + flat_ceiling_insulation_
type_1 + flat_ceiling_insulation_thickness_1. `SapRoomInRoof`
gains a sibling `room_in_roof_details` field next to the legacy
`room_in_roof_type_1`; both shapes are now lossless.
- Extract `_api_build_room_in_roof` mapper helper that reads from
whichever block is present and populates
`SapRoomInRoof.detailed_surfaces` from the Detailed-RR block.
Gables route to `gable_wall_external` for flats (top-floor flats
with RR sit at the end of the building, no neighbour above) and
to `gable_wall` (party at U=0.25) otherwise — mirrors the Summary
mapper's `_map_elmhurst_rir_surface` heuristic.
- Replace both inline `SapRoomInRoof(...)` builds in
`from_rdsap_schema_21_0_0` and `from_rdsap_schema_21_0_1` with
the helper.
Effect on cert 9501 API path:
- roof HLC 149.43 → 18.10 (= worksheet 18.10 exact)
- walls HLC 168.74 → 218.81 (= worksheet 218.81 exact)
- (37) total HLC 382.19 → 297.54 (worksheet 296.68; Δ +0.86)
- sap_continuous still -9.27 vs worksheet because TFA on the API
path is still 81.28 (missing the 31.8 m² RR floor area) — next
slice closes that.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
965718d78e |
Slice 99e: PV pitch enum-not-degrees + cert 9501 Layer 2 chain test
`EpcPropertyData.PhotovoltaicArray.pitch` is the RdSAP 10 §11.1 integer code (1=0°, 2=30°, 3=45°, 4=60°, 5=90°) — NOT degrees. The cascade's `cert_to_inputs._PV_PITCH_DEG_BY_CODE` reads the code, not the value. Slice 99d's mapper passed the raw degrees (45) directly, which fell through to the default 30° lookup (Appendix U3.3 S(SW, 30°) ≈ 1029 kWh/m²/yr vs S(SW, 45°) ≈ 1004 — 2.5% over-credit on the PV generation, manifesting as -£6.27 over-credit on total cost → +0.23 SAP delta). Added `_elmhurst_pv_pitch_code` helper that maps the lodged degrees to the nearest tabulated code (snap-to-nearest fallback for non- tabulated tilts; defaults to code 2 / 30° per the cascade's own `_PV_PITCH_DEG_DEFAULT`). Effect on cert 9501 Summary path: - pv_export_credit £256.30 → £250.02 (= worksheet 250.02 exact) - total_fuel_cost £842.94 → £849.21 (= worksheet 849.21 exact) - sap_continuous 68.7577 → **68.5252** (= worksheet 68.5252 exact; Δ -0.0000 at 1e-4) `test_summary_9501_full_chain_sap_matches_worksheet_pdf_exactly` added — the second flat-shaped cert pinned to worksheet SAP at 1e-4 after the cert 0330 / 001479 boiler-house chain tests. Third boiler validation cert closed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a3a30957de |
Slice 99d: surface PV array from Elmhurst Summary §19.0
Cert 9501 lodges measured PV: 2.36 kWp South-West, 45° pitch, "None Or Little" overshading. The worksheet's §10a credit (-250.02 GBP = PV used in dwelling £-129.49 + PV exported £-120.53) depends on the Appendix M / Appendix U3.3 cascade reading these from `SapEnergySource.photovoltaic_arrays`. The prior extractor only captured the `photovoltaic_panel: "Panel details"` label — the actual kW / orientation / elevation / overshading were silently dropped, so the cascade computed total cost ~£250 too high → ECF 2.92 vs worksheet 2.26 → SAP 59.26 vs 68.53 (Δ -9.27). Changes: - Extend `surveys.elmhurst_site_notes.Renewables` with 4 new optional fields: pv_peak_power_kw / pv_orientation / pv_elevation_deg / pv_overshading. - Add `ElmhurstSiteNotesExtractor._extract_pv_array_detail` — anchors on "Photovoltaic panel details" then reads the 4 consecutive value lines (kWp, orientation, elevation, overshading). - Add `_elmhurst_pv_arrays` mapper helper to build the `[PhotovoltaicArray(...)]` list when all 4 values are present; return None for the "PV absent" path the cascade already handles. - Add `_ELMHURST_PV_OVERSHADING_TO_RDSAP` map: "None Or Little" → 1 (ZPV=1.0 per cert_to_inputs._PV_OVERSHADING_FACTOR), "Modest" → 2, "Significant" → 3, "Heavy" → 4. RdSAP omits SAP10.2 Table M1's 5th "Severe" bucket. - Wire `photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables)` into `from_elmhurst_site_notes`'s `SapEnergySource(...)` call. Effect on cert 9501 Summary path: - sap_continuous 59.2585 → 68.7577 (target 68.5252; Δ +0.23) - total_fuel_cost £1099 → £843 (worksheet £849; -£6 over-credit) - ECF 2.92 → 2.24 (worksheet 2.26; -0.02 over-credit) The remaining +0.23 SAP / +£6 cost drift is a precision gap in the Appendix M cost-offset cascade for measured PV (not a missing-data gap); next slice closes it to 1e-4. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ccef01bf27 |
Slice 99c: Elmhurst mapper — RR gables external for flats + SO wall code
Cert 9501 worksheet line (29a) lodges both RR gable walls (13.50 + 15.95 m²) as EXTERNAL walls at U=1.7 (the main-wall U for age B Solid Brick), contributing +50.07 W/K on top of the 168.74 W/K main- wall HLC for a (29a) total of 218.81 W/K. Two mapper gaps blocked this: 1. The Summary mapper defaulted un-typed RR gable walls (`surface.gable_type=None`) to `gable_wall` (party, U=0.25 per RdSAP Table 4 row 2). For flats with RR — top-floor dwellings that sit at the end of a building block with no neighbour above — the gable walls are exposed external, not party. Threading `is_flat=property_type.lower()=='flat'` through `_map_elmhurst_building_parts` → `_map_elmhurst_room_in_roof` → `_map_elmhurst_rir_surface` switches the default for un-typed gables on flats to `gable_wall_external` (cascade falls through to main-wall U `uw`). 2. The Elmhurst wall-construction code map was missing "SO Solid Brick" (newer Elmhurst PDF variant; the cohort certs lodge "SB Solid Brick"). Cert 9501's main wall fell through to wall_construction=None → cascade uw=1.5 (Table-18 unknown-cons age-B default) instead of 1.7 (Table-18 solid-brick age-B). Added "SO": 3 alongside "SB": 3 — same SAP10 mapping. Joint effect on cert 9501 Summary path: - walls HLC 148.89 → 218.81 (exact worksheet match) - party_walls HLC 7.36 → 0.00 (gables no longer route to party) - (37) total HLC 229.71 → 296.68 (exact worksheet match) Cohort regression check: 259/0 mapper-chain + extractor + golden tests pass. Houses keep the historical un-typed-gable → party default. Houses lodging "SO" instead of "SB" now also pick up the correct solid-brick U-value. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e1348c424b |
Slice 99b: Elmhurst mapper — flat floor-position from floor.location
For flats, `EpcPropertyData.dwelling_type` needs a "Top-floor" /
"Mid-floor" / "Ground-floor" prefix so the cascade's
`_dwelling_exposure` (cert_to_inputs.py) gates floor + roof party-
surface routing correctly per RdSAP 10 §5. Before Slice 99a, the
broken `built_form` ("2.0 Number of Storeys:") meant cert 9501's
`dwelling_type` was "2.0 Number of Storeys: flat" — never matched
any flat-prefix in the cascade, so the cert was treated as a fully-
exposed dwelling (worksheet had floor U=0 / party-ceiling-down, but
cascade routed both as exposed → Δ +9.25 W/K on floor alone). After
99a's empty-attachment fix the prefix was just " flat" — still no
match.
Slice 99b composes the position prefix from the Summary's lodged
floor location + RR presence:
- floor.location lodges "dwelling below" → floor is party
- + RR present → Top-floor (roof exposed)
- + no RR → Mid-floor (roof party)
- floor.location doesn't lodge dwelling below → Ground-floor
For cert 9501: floor.location="A Another dwelling below" + RR
present (cert lodges Room-in-Roof with gable walls + flat ceiling).
Resulting `dwelling_type` = "Top-floor flat" — matches the cascade's
`_dwelling_exposure` "top-floor" prefix → has_exposed_floor=False,
has_exposed_roof=True, the worksheet's exposure shape.
Houses keep the historical contract: `f"{built_form}
{property_type.lower()}"` — cohort hand-builts and the 2 boiler
chain tests (001479 + 0330) unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
94262e5f6c |
Slice 98: API path shower-counts + window-rounding → cert 0330 1e-4
Closes the cert 0330 API path Layer 4 gate (Δ -0.000011 vs worksheet
SAP 61.5993) by surfacing two previously-broken inputs to the HW
cascade plus aligning the wall-net-deduction with the worksheet's
2-d.p.-per-window rounding convention.
(a) RdSAP schema 21.0.x `shower_outlets` shape mismatch:
real-API certs lodge `[{"shower_outlet_type": N, "shower_wwhrs":
M}, ...]` (a list of bare ShowerOutlet dicts), but the schema
modelled it as `[ShowerOutlets]` with nested
`{"shower_outlet": {...}}` wrappers. `from_dict` silently dropped
every bare element's payload (left `shower_outlet=None`),
blanking the cascade's mixer/electric counts on cert 0330 (and 4
other golden fixtures). Normalisation in `from_api_response`
rewrites the bare list shape to the wrapped form before
`from_dict` parses, so the schema's `ShowerOutlets` dataclass
sees the data it expects — no schema-class breakage downstream.
New helper `_count_shower_outlets_by_type` walks the normalised
list and counts outlets by integer code:
- code 1 → mixer (drives `mixer_shower_count`)
- code 2 → electric (drives `electric_shower_count`)
Empirically derived from the golden cohort + Summary mapper
cross-check (cert 0330 lodges code 2 + Summary surfaces "Electric
shower"; cert 0240 lodges multiple code-1 outlets on a
conventional oil-boiler + cylinder dwelling). No spec page
reference found.
Wired into both `from_rdsap_schema_21_0_0` and
`from_rdsap_schema_21_0_1`. Effect on cert 0330 API path:
`mixer_shower_count` 1 (cascade default) → 0; `electric_shower_
count` None (= 0) → 1; HW kWh 3172.65 → 2111.93. SAP Δ +2.1155
→ -0.0012.
(b) Per-window 2-d.p. area rounding in wall-net deduction:
RdSAP 10 §15 rounds per-window area at 2 d.p. before any sum.
The cascade's `windows_w_per_k_total` branch already rounds
per-window for the curtain transform; the wall-net deduction
branch (computing `gross_wall - windows - door` for the (29a)
line) was rounding the SUM once, which for cert 0330's 9 Main
windows yields 12.22 m² vs the worksheet's per-window-rounded
12.23 m² — Δ +0.01 m² × U=1.5 = +0.015 W/K on (29a). Aligned
both branches to round per-window, matching worksheet line (27).
SAP Δ -0.0012 → -0.000011.
Layer 4 chain test added:
- `test_api_0330_full_chain_sap_matches_worksheet_pdf_exactly` pins
cert 0330 API path SAP at 1e-4 vs worksheet 61.5993. This is the
second boiler validation cert with a Layer 4 1e-4 gate (cert
001479 is the first).
Re-pinned golden cert residuals (shifted by changes (a) and (b)):
- 0300: PE +7.52 → +8.44, CO2 -0.27 → -0.23 (Slice 98a — electric
shower count surfaced; cert has 1 electric + 1 mixer outlets)
- 2130: PE -38.17 → -38.18, CO2 +0.305 → +0.304 (Slice 98b —
window rounding edge)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f57e359f38 |
Slice 97: API glazing_type=2 → RdSAP 10 Table 24 (DG 2002-2021)
Cert 0330 API path was at Δ +1.68 SAP after Slice 96 because all 11 windows (`sap_windows[*].glazing_type = 2`) fell through `_API_GLAZING_TYPE_TO_TRANSMISSION` (which only covered codes 3 + 13) to the cascade's `u_window` default (~U=2.5). The cert's actual glazing is "Double, England/Wales 2002 or later (before 2022)" per RdSAP 10 Table 24 page 79 → U=2.0, g=0.72 (PVC/wooden frame). RdSAP 10 Table 24 verbatim: Glazing Installed Gap U-value g Double or England/Wales: 2002 or later 2.0 0.72 triple Scotland: 2003 or later any glazed N. Ireland: 2006 or later The cascade's curtain-transform path (`U_eff = 1/(1/U + 0.04)`) takes U_raw=2.0 to U_eff=1.8519 — matching the worksheet's per- window (27) U value column to 4 d.p. across all 11 windows. Effect on cert 0330 API path: - Windows HLC 36.4545 → 29.7407 (= worksheet exact) - (37) total fabric heat loss 244.48 → 237.77 (≈ worksheet 237.75) - SAP Δ +1.68 → +2.12 (windows fix unmasks the standalone HW gap, which the next slice closes) Re-pinned residuals (5 affected golden certs): - 0240: PE +17.85 → +15.69; CO2 +1.01 → +0.90; SAP unchanged at -15 - 0300: PE +7.76 → +7.52; CO2 -0.25 → -0.27; SAP unchanged at +0 - 0390-2954: PE -26.46 → -28.68; CO2 -2.56 → -2.76; SAP unchanged - 7536: SAP +0 → +1; PE -3.45 → -6.51; CO2 -0.09 → -0.17 - 8135: PE -2.41 → -5.31; CO2 -0.02 → -0.07; SAP unchanged at +0 The PE/CO2 widening on some certs (vs lodged GOV.UK values) reflects the cascade now using the spec table U=2.0 where those certs may have lodged a higher project-specific U — the spec-table is the right floor for the API path; per-window measured U overrides would belong on the cert's window_transmission_details.u_value field, which the API JSON doesn't surface uniformly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
09fb6f1b73 |
fix: address 22 project-wide test failures from previous sweep
Three orthogonal issues surfaced by the full project test sweep: 1. Dockerfile.test: install poppler-utils alongside postgresql. The 20× `pdfinfo: No such file or directory` failures in test_summary_pdf_mapper_chain.py traced to the CI test image missing the poppler-utils system package (pdfinfo + pdftotext). `_summary_pdf_to_textract_style_pages` shells out to these for layout-preserving PDF text extraction. Pure-Python alternatives (pymupdf, pypdf) don't reproduce pdftotext -layout's row-major table cell ordering, which the Elmhurst Summary extractor depends on. So system poppler is the right fix; added to apt-get install with an explanatory comment. 2. test_from_rdsap_schema.py::test_total_floor_area: expected 55.0, got 45.82. Slice 95 (commit |
||
|
|
68401c517a |
refactor: lift-and-shift packages/domain/src/domain/ml → domain/sap10_ml
Sibling migration to the sap10_calculator move — `domain.ml` now lives
at the root-level layout (`domain/sap10_ml/`) matching the pattern
already used by `domain.addresses`, `domain.tasks`, `domain.postcode`,
and `domain.sap10_calculator`.
Changes:
- `git mv packages/domain/src/domain/ml → domain/sap10_ml` (19 files;
history preserved).
- Subpackage rename: `domain.ml` → `domain.sap10_ml`. 32 references
rewritten across .py and .md files: 11 internal + 21 external
(datatypes/epc/domain/mapper.py, 14 files in domain/sap10_calculator,
2 backend tests, 2 ADRs, 1 README, 1 design doc).
- Path-string updates: `pytest.ini` testpath
`packages/domain/src/domain/ml/tests` → `domain/sap10_ml/tests` so
ML tests stay in the default auto-discovered sweep. `CONTEXT.md`
also updated.
`packages/domain/src/domain/` is now empty — the workspace `domain/`
tree has been fully migrated. Together with the `domain/__init__.py`
deletions from the sap10_calculator commit (
|
||
|
|
29ac35ccbe |
refactor: lift-and-shift packages/domain/src/domain/sap → domain/sap10_calculator
Migration of the SAP 10.2 calculator package from the uv-workspace
src-layout (`packages/domain/src/domain/sap`) to the root-level layout
(`domain/sap10_calculator`), matching the pattern already used by
`domain.addresses` / `domain.tasks` / `domain.postcode`.
Changes:
- `git mv packages/domain/src/domain/sap → domain/sap10_calculator`
(92 files; git auto-detected all as renames so blame/history is
preserved).
- Subpackage rename: `domain.sap` → `domain.sap10_calculator`. 48
Python files rewritten (`from domain.sap.X` → `from domain.sap10_
calculator.X`); zero remaining `domain.sap` refs after the sed pass.
- Path-string updates: 3 .py files (test fixtures + xlsx loader) +
6 markdown docs (CONTEXT.md, 2 ADRs, 3 sap-spec docs, sap10_
calculator/README.md) had hard-coded `packages/domain/src/domain/
sap/...` paths rewritten to `domain/sap10_calculator/...`.
- `Path(__file__).parents[N]` rebasing: the old tree was 3 levels
deeper than the new one (`packages/domain/src/`), so 4× `parents[7]`
became `parents[4]` and 1× `parents[6]` became `parents[3]` across
`tables/pcdb/{__init__.py, postcode_weather.py, etl.py}`,
`worksheet/tests/_xlsx_loader.py`, and `tests/test_pcdb_etl.py`.
- PEP 420 namespace package: deleted both `domain/__init__.py`
(root + workspace, both load-bearing only as empty/docstring) so
Python combines `domain.sap10_calculator` (root) and `domain.ml`
(workspace) into one namespace package. Confirmed via
`domain.__path__ == ['/workspaces/model/domain',
'/workspaces/model/packages/domain/src/domain']`. Without this,
the root `domain/__init__.py` shadowed the workspace one and
`domain.ml` was unreachable.
Verified:
- Full sweep (`backend/documents_parser/tests/test_summary_pdf_
mapper_chain.py + domain/sap10_calculator/worksheet/tests/test_
e2e_elmhurst_sap_score.py + domain/sap10_calculator/rdsap/tests/
test_golden_fixtures.py`): 99 passed / 19 failed — exact same
counts as pre-refactor. All 19 failures pre-existing (9 hand-built
001479 + 6 cohort diff + 4 cohort chain non-spec).
- Wider sweep (all sap10_calculator + domain.ml): 1654 passed /
20 failed (the +1 vs the focused sweep is the pre-existing
`test_roof_insulated_assumed_with_ni_thickness_uses_50mm_per_
section_5_11_4` which was already failing on the previous baseline).
- Pyright net-zero on the three load-bearing baselines:
`heat_transmission.py` 13, `cert_to_inputs.py` 35, `mapper.py` 33.
Lift-and-shift only — no semantic renames (`Sap10Calculator` stays
`Sap10Calculator`), no testpaths edits in pytest.ini (sap tests
continue to be invoked by explicit pytest paths).
Note: `domain.ml` still lives at `packages/domain/src/domain/ml/`.
Migrating it would close out the dual-`domain/` layout but is
out of scope for this commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f502db8c74 |
Slice 95: API mapper TFA from per-bp dims + window area 2dp rounding — cert 001479 to 1e-4
The end-to-end production cascade `from_api_response → cert_to_inputs →
calculate_sap_from_inputs` now hits cert 001479's worksheet continuous
SAP 69.0094 at abs < 1e-4 (was +0.000584). Two fixes:
1. API mapper: `from_rdsap_schema_21_0_{0,1}` computes `total_floor_
area_m2` as Σ per-bp `sap_floor_dimensions[*].total_floor_area.value`
(cert 001479: 30.45+30.77+5.37+1.92 = 68.51), not the lodged scalar
(rounded integer 69). `water_heating_from_cert` reads `epc.total_
floor_area_m2` directly for occupancy N (Appendix J), which propagates
to HW kWh (+6.31 → ~0), Appendix L lighting (+0.98 → 0), and internal
gains (+25.72 W·months → 0).
2. Cascade window area rounding per RdSAP 10 §15 "Rounding of data"
(p.66): "All element areas (gross) including window areas: 2 d.p."
`solar_gains.py` and `internal_gains.py` now round `w * h` to 2 d.p.
to match the existing `heat_transmission.py` pattern (line 344).
Closes the residual solar gains delta (+1.50 W·months → 0) that
became dominant once TFA was fixed.
Re-pinned 5 golden cert residuals where TFA + area rounding shifted
output: 0240 (SAP -14→-15, PE +14.6650→+17.8450, CO2 +0.8060→+1.0097),
6035 (PE +48.2971→+49.5139, CO2 +1.1016→+1.1423), 8135 (PE -2.4194→
-2.4072, CO2 -0.0198→-0.0195), 2130 (PE -38.1521→-38.1666), 0390
(PE +1.6837→+1.6962, CO2 +0.0637→+0.0639).
New test: `test_api_001479_full_chain_sap_matches_worksheet_pdf_
exactly` formalises Layer 4 of the validation stack as a 1e-4 gate.
Pyright net-zero (mapper.py 33).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
0320341837 |
Slice 94: API mapper sheltered_sides + floor_type — cert 001479 to 1e-3
Two API mapper gaps surfacing the cert 001479 +1.18 SAP gap post Slice 93: (1) `SapVentilation.sheltered_sides` from API `built_form` The API schema doesn't lodge sheltered_sides as a discrete field — it's derived per RdSAP §S5 from the dwelling's built_form. The cascade defaults to 2 when missing (right for Mid-Terrace) but wrong for detached/semi/end-terrace. Cert 001479 (built_form=2 Semi- Detached) needs 1 sheltered side; default 2 over-counted shelter factor → line (21) under by 0.185 → ventilation under by ~2 ACH/yr. New `_api_sheltered_sides` translator + `_API_BUILT_FORM_TO_ SHELTERED_SIDES` table (1=Detached/0, 2=Semi/1, 3=End-T/1, 4=Mid-T/2, 5=Encl-End/2, 6=Encl-Mid/3) — mirrors the cohort Elmhurst `_ELMHURST_SHELTERED_SIDES_BY_BUILT_FORM` keyed by the API integer enum. (2) `SapBuildingPart.floor_type` from API `floor_heat_loss` The Slice 87 spec rule for §2(12) suspended-timber-floor infiltration (`_has_suspended_timber_floor_per_spec` in cert_to_inputs) requires the Main bp's lowest floor to have `floor_type == "Ground floor"` to apply the (12)=0.2/0.1 rule. The API mapper wasn't surfacing this string (only floor_construction_type), so the spec rule short- circuited to False even for genuine ground floors and the cascade's line (12) was 0.0 instead of 0.2. New `_api_floor_type_str` translator + `_API_FLOOR_HEAT_LOSS_TO_ FLOOR_TYPE` table (1="To external air" for cantilevered exposed floors, 7="Ground floor"). Routes correctly for cert 001479: Main + Ext1 carry floor_heat_loss=7 → both Ground floor; Ext2 carries floor_heat_loss=1 → exposed (its is_exposed_floor=True already lifts the floor U cascade to Table 20). **Result on cert 001479 API path:** SAP delta: +1.18 → +0.0006 (essentially exact match at integer SAP) Cascade SAP=69.0100 vs worksheet 69.0094 — within 1e-3 of target. The remaining ~0.001 SAP gap is dominated by: - hot_water_kwh_per_yr: +6.7 (API 2365.0 vs target 2358.3) - internal_gains Σ: +25.7 W·months (subtle gain-cascade differences) - solar_gains Σ: +1.5 W·months Sub-1e-3 SAP impact each; would need slice-by-slice diagnosis to close to the strict 1e-4 bar. Layer 3 API-mapper-vs-Summary-mapper EpcPropertyData equivalence: the API path now produces SAP within 0.001 of the Summary path (Summary Layer 2 = 69.0094 EXACT). API integer SAP = 69 = worksheet integer SAP = 69 ✓ — matches the API's published energy_rating_ current=69 (zero residual on the production goal metric). Golden cert residuals: 8 of 10 expectations shifted by Slices 90-94 cascade improvements. Spec-compliance shifts; new residuals pinned. Pyright: mapper.py 33 → 33. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7281b7b300 |
Slice 93: API mapper window_transmission_details from glazing_type
The API schema lodges `glazing_type` (int code) per window but `window_transmission_details=None` and `frame_factor=None`. Without per-window U lodgement the cascade falls back to a single global `u_window(None,None,None)=2.5` × total area, which over-shot cert 001479's window W/K by +2.63 (cascade 46.23 vs worksheet 43.60). Fix: `_API_GLAZING_TYPE_TO_TRANSMISSION` lookup translates `glazing_type` → (u_value, solar_transmittance, frame_factor) and the mapper populates `WindowTransmissionDetails` + `frame_factor` per window so the cascade uses its per-window U fast path (each window contributes A × U_eff_individual rather than total_area × U_eff_global). Two codes mapped now: 3 → DG pre-2002 U=2.8 g=0.76 FF=0.70 13 → DG post-2022 Argon U=1.4 g=0.72 FF=0.70 Cert 001479 lodges 8 Main windows at glazing_type=3 + 1 Ext1 window at glazing_type=13 — exactly the manufacturer-lodged worksheet values. The cascade now matches the worksheet's `Windows 1: 13.96 × 2.518 = 35.15 W/K` and `Windows 2: 6.37 × 1.3258 = 8.45 W/K` → **windows W/K EXACT 43.5962**. **Cert 001479 API path: fabric heat loss is now COMPLETELY EXACT across all 6 components** (walls/party/roof/floor/windows/doors all match worksheet at the worksheet's 4 d.p. precision). Total fabric: 139.4957 W/K ✓ (was 122.6130 before Slice 87) walls: 39.7652 ✓ party walls: 17.0700 ✓ roof: 10.3438 ✓ floor: 23.1705 ✓ windows: 43.5962 ✓ doors: 5.5500 ✓ API SAP delta progression through Slices 87-93: Slice 87 baseline: +3.0752 After Slice 90: +1.5298 (party walls) After Slice 91: +1.0970 (descriptive strings + roof desc) After Slice 92: +1.0022 (floor dims) After Slice 93: +1.1846 (windows — fabric now EXACT) The +1.18 SAP gap is now PURELY non-fabric: candidates are internal gains, solar gains, ventilation, MIT, or hot water cascade — to diagnose in the next slice. Golden cert residuals updated for the cascade improvements. Pyright net-zero on mapper.py (33 → 33). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8e752e5720 |
Slice 92: API mapper floor dimensions (SAP +0.25m + exposed-floor + NI→None)
Three coupled API-mapper fixes that close the cert 001479 floor-W/K
gap from +4.39 to EXACT 0.
(1) Upper-floor room_height_m += 0.25 m
SAP 10.2 convention: every storey above the lowest adds 0.25 m to the
lodged room_height for the joist/floor-void contribution (cohort
Elmhurst mapper already applies this via `_UPPER_FLOOR_HEIGHT_ADD_M`
at line 2338). The API schema lodges the raw internal height; the
cascade volume computation needs the +0.25 m before computing party-
wall area and ventilation ACH. For cert 001479 Main floor=1, raw
lodge 2.28 m vs worksheet 2.53 m — without the fix, party W/K was
short by 0.87 (party_wall_length × delta_height × U).
(2) `is_exposed_floor=True` when `bp.floor_heat_loss == 1`
API integer code 1 on `floor_heat_loss` signals an exposed floor (a
bp's lowest storey hanging over an unheated space or external air).
Mirrors the cohort Elmhurst mapper's `_is_floor_exposed_to_unheated_
space` for the API path. Applied only to the lowest storey (floor==0)
per the cohort 000490/000487 fixture convention. For cert 001479
Ext2 (cantilevered upper-storey extension over external air), this
routes the cascade through Table 20's `u_exposed_floor` (U=1.20)
rather than the BS EN ISO 13370 ground-floor formula.
(3) `floor_insulation_thickness="NI" → None` for cascade default
API certs commonly lodge "NI" (no measured thickness) on floors that
aren't actually uninsulated — for newer age bands (I-M with non-zero
Table 19 defaults: 25/75/100/100/140 mm) the cascade should use the
age-band default insulation rather than treating "NI" as explicit
zero. Translate "NI" → None at the mapper boundary so `u_floor`
reaches the Table 19 fallback. For cert 001479 Ext1 (age M, suspended
timber, NI lodged) the cascade now returns U=0.20 via the age-M
140 mm default — previously gave U=1.05 from treating thickness as 0.
**Floor W/K is now EXACT for cert 001479** (23.1705 ✓).
Impact on cert 001479 API path:
Before Slice 87: +3.0752 SAP delta
After Slice 90: +1.5298
After Slice 91: +1.0970
After Slice 92: +1.0022 (floor W/K exact; remaining gap is in
windows / gains — Slice 93)
Golden cert residual updates: 7 of 10 expectations shifted from the
floor cascade improvements (NI→None changed many certs with age I-M
extensions). Spec-compliance shifts; new residuals committed.
Pyright: mapper.py 33 → 33.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
2cebba28dc |
Slice 91: API mapper descriptive strings + roof description per-bp fix
Three tightly-coupled fixes that close another big chunk of cert
001479's API-path SAP gap.
(1) Surface human-readable strings on SapBuildingPart from API ints
The API mapper sets `bp.floor_construction_type` and `bp.roof_
construction_type` strings via int→string lookups so the cascade
fixes from Slices 88 + 89 also apply to the API path:
- `_API_FLOOR_CONSTRUCTION_TO_STR`: 1=Solid, 2=Suspended timber
(drives `u_floor`'s suspended-branch selection)
- `_API_ROOF_CONSTRUCTION_TO_STR`: 1=Flat, 3=Pitched no-loft,
4=Pitched-access-to-loft, 5=Vaulted, 8=Pitched-sloping-ceiling
(drives the cos(30°) inclined-surface factor)
(2) Pre-1950 PS sloping ceiling → thickness=0 (port Slice 57)
`_api_resolve_sloping_ceiling_thickness` mirrors Slice 57's Elmhurst-
mapper logic: when a PS pitched-sloping-ceiling roof (API code 8)
carries no insulation thickness on a pre-1950 dwelling (age bands
A-D), set thickness=0 so the cascade returns the uninsulated U=2.30
rather than the age-band-default (e.g. U=0.40 for age C).
(3) Cascade: per-bp `roof_thickness=0` overrides global "insulated"
description
For cert 001479 the API's `epc.roofs` carries two descriptions
(Main's "Pitched, 300mm loft insulation" + Ext1's "Pitched,
insulated") which the cascade joined into a global
`roof_description`. `u_roof`'s Table 18 footnote (2) ("assumed
insulation if described as insulated") then incorrectly upgraded
Ext2's explicitly-uninsulated thickness=0 to ins_mm=50 → U=0.68
instead of 2.30. Fix: in `heat_transmission.py` per-bp roof loop,
drop `roof_description` when the per-bp `roof_thickness` is
explicitly 0. The per-bp thickness lodgement is the authoritative
signal; the global description is for cases where no thickness was
lodged at all.
Impact on cert 001479 API path (cumulative through Slice 91):
Before Slice 87: +3.0752 SAP delta
After Slice 90: +1.5298 (party wall enum fix)
After Slice 91: +1.0970 (descriptive strings + roof desc fix)
Roof W/K is now EXACT for cert 001479 (10.3438 = worksheet target).
Golden cert residual updates: 8 of 10 expectations shifted by
Slices 87-91 cascade improvements:
0240: SAP -10→-13, PE -2.05→+10.45, CO2 -0.04→+0.59
6035: SAP -4→ -5, PE +34.02→+34.50, CO2 +0.76→+0.77
7536: SAP +3→ +2, PE -22.53→-15.83, CO2 -0.60→-0.42
8135: SAP unchanged, PE -16.51→-16.37, CO2 unchanged
2130: SAP unchanged, PE -51.90→-51.10, CO2 +0.14→+0.15
0240/6035/7536: spec-compliance shifts (more accurate U-values
move further from the assessor's lodged SAP, because the
assessor's SAP was itself produced with the same incorrect
paths the cascade previously matched).
Pyright: mapper.py 33 → 33; heat_transmission.py 13 → 13;
test_golden_fixtures.py 0 → 0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
fbbdca49ca |
Slice 90: API mapper translates party_wall_construction → SAP10 enum
The GOV.UK API `party_wall_construction` field uses a different enum
from the regular `wall_construction` field — RdSAP 10 Table 15 (p.31
"U-values of party walls") defines 5 categories that the API encodes
as integer codes 0..5 plus a "NA" string for extensions without a
party wall. The cascade's `u_party_wall` consumes the SAP10
`wall_construction` enum directly, so passing the raw API code gave
wildly wrong U-values (API code 2 = "Cavity masonry unfilled" →
should produce U=0.5, but cascade interpreted code 2 as SAP10
WALL_STONE_SANDSTONE → 0.0 W/m²K).
Impact on cert 001479 (the only golden fixture with party=2 lodged):
Before: party_walls = 0.00 W/K (cascade applied U=0.0)
After: party_walls = 16.21 W/K (cascade applies U=0.5)
API mapper → cascade SAP delta:
Before Slice 90: +3.0752
After Slice 90: +1.5298
The remaining party-wall shortfall (16.21 vs target 17.07 W/K, -0.87
W/K) is the room_height_m +0.25 SAP convention not yet applied to
the API path — Slice 92 will close that.
Translation table (per `_API_PARTY_WALL_CONSTRUCTION_TO_SAP10`):
0 → None (no party wall present; party_wall_length=0 anyway)
1 → SAP10 code 3 (Solid Brick) → u_party_wall = 0.0
2 → SAP10 code 4 (Cavity) → u_party_wall = 0.5
3 → SAP10 code 4 (Cavity) → cascade emits 0.5 (TODO: 0.2 for
cavity filled needs cascade extension)
4 → None (Unable, house) → u_party_wall default 0.25
5 → None (Unable, flat) → TODO: spec says 0.0 for flats
Schema change: `SapBuildingPart.party_wall_construction` is now
`Optional[Union[int, str]]` (was `Union[int, str]`) — the "0 sentinel
for Unable" convention was already in cohort hand-builts but the type
forbade the cleaner `None` representation. To preserve the dataclass
"no-default after default" rule, `sap_floor_dimensions` gets a
`field(default_factory=list)`.
Translation applied across all 6 from_rdsap_schema_* mappers + the
flagship `from_rdsap_schema_21_0_1` used by 001479.
Pyright: mapper.py 35 → 33 (cleared 7 cohort party_wall type errors
that were pre-existing, balanced against the schema change). Cohort
cascade pins remain GREEN (66 of 66); no new test regression.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
006e9842c9 |
Slice 89: PS pitched-sloping-ceiling roof area uses inclined surface
RdSAP 10 §3.8 "Roof area" spec:
"Roof area is the greatest of the floor areas on each level...
In the case of a pitched roof with a sloping ceiling, divide the
area so obtained by cos(30°)."
The cascade previously used `top_floor_area_m2` (horizontal projection)
verbatim for the roof area calculation — correct for flat roofs and
pitched-with-loft (where assessors measure on the horizontal), but
~15% under-area for PS pitched-sloping-ceiling roofs (1/cos(30°) =
1.1547). For cert 001479 Ext1 + Ext2 (both PS sloping ceiling):
Ext1: cascade 5.37 m² × 0.15 = 0.81 W/K
worksheet 6.20 m² × 0.15 = 0.93 W/K (delta -0.12)
Ext2: cascade 1.92 m² × 2.30 = 4.42 W/K
worksheet 2.22 m² × 2.30 = 5.11 W/K (delta -0.69)
Total roof W/K shortfall: -0.81
Fix: detect PS pitched-sloping-ceiling roofs via `bp.roof_construction
_type` (string lodgement from the Summary §8 "Roof Type" line) and
apply the 1/cos(30°) inclination factor before rounding the gross
roof area.
Schema addition: `SapBuildingPart.roof_construction_type: Optional[
str] = None` mirrors the existing `floor_construction_type`. Mapper
populates it via `_strip_code(roof.roof_type)` for both Main and
Extension bps — the Elmhurst Summary lodges the roof type
explicitly (e.g. "PS Pitched, sloping ceiling" / "PA Pitched (slates
/tiles), access to loft" / "Flat").
**Result: cert 001479 Summary → mapper → cascade now lands at SAP
69.0094 EXACT (delta -0.0000) — Layer 2 GREEN at 1e-4.** Full fabric
breakdown matches the worksheet exactly:
fabric_heat_loss = 139.4957 W/K ✓
walls = 39.7652 ✓ party = 17.0700 ✓
roof = 10.3438 ✓ floor = 23.1705 ✓
windows = 43.5962 ✓ doors = 5.5500 ✓
Layer 2 status across the 7 cert chain tests:
000477 GREEN (was GREEN)
000516 GREEN (was GREEN)
001479 GREEN (new — was +1.19 before Slice 87)
000474 RED -0.7524 (Elmhurst (12) non-spec — orthogonal)
000480 RED -1.0273 (Elmhurst (12) non-spec — orthogonal)
000487 RED +0.4834 (Elmhurst (12) non-spec — orthogonal)
000490 RED -1.1042 (Elmhurst (12) non-spec — orthogonal)
Cohort cascade pins remain GREEN (66 of 66) — hand-built fixtures
have roof_construction_type=None (default) so the new code path is
inert for them; their roofs use RR detailed_surfaces with explicit
areas already.
Pyright net-zero on every touched file (heat_transmission 13 → 13,
mapper 35 → 35, epc_property_data 0 → 0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
aff331ff34 |
Slice 87: implement RdSAP 10 §5 (12) spec rule for suspended timber floor
Replace the empirical `_elmhurst_has_suspended_timber_floor` heuristic
(which keyed on Room-in-Roof < Main ground area) with the mechanical
RdSAP 10 Specification §5 rule (page 29):
- Age band A-E: U-value < 0.5 → sealed (0.1); retro insulation + no
U → sealed (0.1); otherwise unsealed (0.2)
- Age band F-M: sealed (0.1)
- Park home: unsealed (0.2)
- Only applies when Main bp's lowest floor is a "Ground floor" with
"Suspended timber" construction
The spec rule is derived in `_has_suspended_timber_floor_per_spec`
(cert_to_inputs.py) and applied in `ventilation_from_cert` whenever
the lodged `epc.sap_ventilation.has_suspended_timber_floor` is None.
Explicit lodged values (cohort hand-built fixtures) take precedence.
Impact on cert 001479 (the load-bearing API↔Elmhurst parity-test
fixture; previously the RR-based heuristic returned False for this
no-RR semi-detached, dropping (12) entirely):
Mapper → cascade → SAP delta vs worksheet 69.0094:
BEFORE: +1.1903 (mapper extracted False; cascade applied (12)=0)
AFTER : +0.2290 (mapper extracts None; spec derives True/unsealed;
cascade applies (12)=0.2 → matches worksheet)
Cohort cascade pins remain GREEN (66 of 66) — cohort hand-built
fixtures retain their explicit `has_suspended_timber_floor` values
which override the spec derivation.
Expected cohort regressions to triage in the next slice:
- 4 cohort chain tests RED (000474, 000480, 000487, 000490) — their
Elmhurst worksheets enter non-spec (12) values (0.0 or 0.2 when
spec predicts the opposite) so the mapper-path cascade now
diverges from the worksheet PDF at 1e-4.
- 6 cohort diff tests RED — mapper now produces
has_suspended_timber_floor=None while the cohort hand-builts
retain explicit True/False overrides, producing a 1-field
divergence per cohort cert.
Pyright net-zero (mapper 35→35; cert_to_inputs 35→35) — dead
`_elmhurst_has_suspended_timber_floor` removed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
6baf66cdde |
Slice 68: party-wall "U Unable" + central_heating_pump_age_str → 1 diff left
Closes 4 of 5 remaining cohort 000474 diffs (5 → 1): **Mapper:** Add "U" → 0 to `_ELMHURST_PARTY_WALL_CODE_TO_SAP10`. The modal cohort lodgement Summary §7 "Party Wall Type: U Unable to determine" was previously falling through to None; the cohort hand- built convention uses 0 as the explicit "unknown" sentinel. The cascade resolves both 0 and None to the same `u_party_wall` default (0.25), so cascade output is unchanged. Closes 3 diffs (one per bp). **Hand-built:** Set `central_heating_pump_age_str="Unknown"` on cohort 000474 Main heating detail (post-construction since the helper doesn't expose the kwarg). Matches the Elmhurst mapper's surfaced value from Summary §14 "Heat pump age: Unknown" — the str dual- encoding internal_gains.py reads. Closes 1 diff. All 66 cohort cascade pins remain GREEN at 1e-4. Pyright 35-error baseline preserved on mapper.py; 0 errors on the hand-built file. Remaining 1 diff on cohort 000474: - `sap_windows: LEN 7 vs 5` — the cohort hand-built collapsed §11 by glazing-type × orientation × bp group (preserving total area, cascade-equivalent but not field-equal); the mapper extracts 1:1 with the worksheet's 7 §11 table rows. Next slice will expand the hand-built to 7 individual SapWindow entries matching the mapper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ca39d072be |
Slices 66+67: Elmhurst mapper surfaces country_code + heating ints + has_draught_lobby
Closes 9 mapper-side load-bearing field gaps surfaced by the cohort 000474 mapper-vs-hand-built diff (was 12, now 5 remaining): **Slice 66 — country code + draught-lobby fix:** - Set `country_code="ENG"` in `from_elmhurst_site_notes`. The Elmhurst U985 / P960 surveyor toolchain operates on English certs only; the Summary doesn't lodge country explicitly but the cascade's `u_floor` / `u_basement_floor` / `u_door` read it for table selection. Cohort hand-builts already encode 'ENG' so the cascade was tolerating the None default; matching the canonical value closes the diff. - `_map_elmhurst_ventilation` now sets `has_draught_lobby=True` only when Summary lodges "Yes"/"Present". The cohort's modal lodgement "Unable to determine" maps to `False` — matching the cohort hand- built convention (conservative no-lobby cascade path). The legacy `draught_lobby` field is unchanged; the cascade reads `has_draught_lobby` in preference. **Slice 67 — heating field surfacing:** - `boiler_flue_type`: Add `_ELMHURST_FLUE_TYPE_TO_SAP10` map (Open=1, Balanced=2, Fan-assisted balanced=3, Room-sealed=4). Cohort 000474's "Balanced" Summary §14 lodgement → 2, matching hand-built. - `emitter_temperature`: `_elmhurst_emitter_temperature_int` parses the Summary §14 "Design flow temperature" string to int (≥45 °C → 1, lower → 0; "Unknown" defaults to 1 per Table 4d worst-case). - `central_heating_pump_age`: dual-encode int alongside the existing `_str` field via `_elmhurst_pump_age_int` (Unknown → 0, Pre 2013 → 1, otherwise → 2). The cascade reads `_str`; the int is for cross- mapper field parity only. - `main_heating_number=1`: default single main heating. - `water_heating_fuel`: parse Summary §15 "Water Heating Fuel Type" via the existing `_elmhurst_main_fuel_int` map. Cohort 000474's "Mains gas" → 26. All 11 newly-surfaced fields are metadata-only on the SAP cascade (grep confirms none feature in `packages/domain/src/domain/sap/` outside test fixtures). All 66 cohort cascade pins remain GREEN at 1e-4. Pyright 35-error baseline preserved on mapper.py. Diff count for cohort 000474: Slice 63 baseline: 50 Slice 64 (Cat A bulk): 14 Slice 65 (HW handbuilt): 12 Slice 66 (country+lobby): 10 Slice 67 (heating ints): **5** Remaining 5 diffs: - 3× `sap_building_parts[*].party_wall_construction`: None vs 0 (cohort sentinel convention — needs mapper-side fix to surface 0 when no party wall is lodged, OR hand-built update to drop sentinel) - `sap_heating.main_heating_details[0].central_heating_pump_age_str`: mapped='Unknown' vs handbuilt=None (hand-built should populate the str dual) - `sap_windows: LEN 7 vs 5` (Cat C structural — cohort hand-built collapsed by glazing-type group, mapper extracts 1:1 with §11 table) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e3dc0b28f5 |
Slice 58: secondary fuel cost routes through lodged secondary_fuel_type
Two coupled bugs surfaced by cert 001479's mains-gas-fire secondary heating (Summary §14.1 lodges "SAP code 605, Flush fitting live effect gas fire" → fuel 26 mains gas): 1. **Mapper**: `_map_elmhurst_sap_heating` only set `secondary_heating_type` (the SAP code int) — `secondary_fuel_type` stayed None. The Summary PDF doesn't lodge the fuel int separately; it has to be derived from the SAP code range. Add `_elmhurst_secondary_fuel_from_sap_code`: codes 601-630 → 26 (mains gas); other codes return None (the cascade defaults to electric, matching cohort 000490 SAP code 691 electric panel). 2. **Cascade**: `_fuel_cost` in cert_to_inputs hardcoded `secondary_high_rate_gbp_per_kwh = other_uses_gbp_per_kwh` (the standard-electricity tariff) regardless of `secondary_fuel_type`. For gas secondaries this charged 1846 kWh/yr at electric rate (£0.132/kWh = £243) instead of gas rate (£0.0348/kWh = £64) — a ~£175/yr ECF distortion ≈ 9 SAP points on cert 001479. Route the cost through `table_32_unit_price_p_per_kwh(secondary_fuel)` when lodged. Worksheet line (242) confirms the gas pricing: `Space heating - secondary 2025.93 3.4800 70.5022` Cert 001479 chain pin delta narrows: SAP_continuous 61.39 → 70.64 (was −7.62 vs 69.0094, now +1.63 — overshooting target by 1.63 SAP). The remaining overshoot maps to the cascade's ~16 W/K HLC undercount (cascade HLP 2.89 vs worksheet 3.13 × TFA) — work for follow-up slices. Cohort 6 chain certs still green at 1e-4 (all-electric or no- secondary). Golden cohort: cert 0300-2747 (mains-gas secondary) SAP residual tightens −7 → +2 — biggest single SAP improvement on the golden cohort to date; pin updated and notes annotated. Other 7 golden certs unchanged (None or electric secondary fuel). Pyright net-zero (35 baseline each on mapper.py + cert_to_inputs.py). Chain pin `test_summary_001479_full_chain_sap_matches_worksheet_pdf_ exactly` is the load-bearing RED — committed failing per TDD; closes to GREEN once the HLC undercount lands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7a9a8b7ebe |
Slice 57: Pre-1950 Elmhurst sloping-ceiling roofs map to thickness=0
Cert 001479 Ext2 §8 lodges:
Type: PS Pitched, sloping ceiling
Insulation: S Sloping ceiling insulation
Insulation Thickness: As Built
age C (1930-49)
The Summary's "As Built" thickness encodes "the dwelling as originally
constructed" — for pre-1950 sloping-ceiling roofs that's uninsulated
(no roof insulation in original 1930s construction). The worksheet's
§3 row pins U=2.30 (Table 16 row 0, uninsulated).
Pre-slice the mapper passed thickness=None through, routing to
`u_roof`'s Table 18 col 1 default (0.40 W/m²K for age C). That table
assumes joist insulation accessible from the loft — wrong geometry for
PS (Pitched, sloping ceiling) which has no loft access for retrofit.
Add `_resolve_sloping_ceiling_thickness`: when roof_type starts with
"PS" + lodged thickness is None + age ∈ {A,B,C,D} → thickness=0.
Other ages leave None (cascade default), matching Ext1's worksheet
U=0.15 at age M.
Cascade SAP 61.93 → 61.39 (−0.54, expected — uninsulated roof adds
heat loss); cohort 6 certs all green at 1e-4 (none have PS+age≤D);
pyright net-zero baseline preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
07ed871f7b |
Slice 56: Elmhurst floor exposed to external air routes through u_exposed_floor
`_is_floor_exposed_to_unheated_space` previously only matched
"U Above unheated space" (semi-exposed floor over a porch / car-park).
Cert 001479 Ext2 §9 lodges "Location: E To external air" — a 1.92 m²
cantilevered exposed timber floor (the upper-storey extension hanging
out over the garden). The worksheet's §3 `Exposed floor Ext2 … 1.92,
1.20, 1.20` pins this surface as U=1.20 via Table 20.
Pre-slice the mapper missed the "external air" lodgement entirely;
`is_exposed_floor=False` routed Ext2's ground SapFloorDimension
through the BS EN ISO 13370 ground-floor cascade (default U≈0.5),
mis-modelling a fully-exposed cantilever as a slab on soil.
Both lodgement strings ("above unheated", "external air") now
trigger the Table 20 path. Function docstring updated; name kept
to minimise the diff (refactor candidate for a future slice).
Cohort 6 certs all still green at 1e-4 (none lodge external-air
floors); cert 001479 cascade SAP 61.90 → 61.93 (+0.03), modest
upward move toward the 69.0094 target.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c89206fc7f |
Slice 55: Elmhurst party-wall code "CU" maps to cavity unfilled
`_ELMHURST_PARTY_WALL_CODE_TO_SAP10` only recognised the bare "C" and "S" leading codes. Cert 001479 Main §7 lodges "Party Wall Type: CU Cavity masonry unfilled" — the leading token is "CU", which fell through to None and made `u_party_wall` apply the unknown-default U=0.25 instead of the worksheet's lodged U=0.50. Add "CU" → 4 (SAP10 WALL_CAVITY); `u_party_wall(4) = 0.5 W/m²K` matches the worksheet's §3 `Party walls Main … 0.50` row exactly. This widens the chain residual on cert 001479 (cascade SAP 63.17 → 61.90 vs target 69.0094) — not a regression: pre-slice the cascade was UNDER-counting party-wall heat loss (U=0.25 vs the lodged 0.50), which masked over-counting elsewhere. The party-wall U-value is now worksheet-accurate; remaining 7.1 SAP gap will narrow as the other mapper gaps (Ext2 exposed floor, roof insulation thickness, secondary heating SAP code, etc.) land in follow-up slices. All 10 chain tests green (6 cohort + 2 cert-001479 structural pins). Pyright net-zero (35-error baseline preserved on mapper.py). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
4427b58a44 |
Slice 54: Elmhurst mapper sets extensions_count from len(survey.extensions)
`from_elmhurst_site_notes` hard-coded `extensions_count=0` regardless of how many extensions the survey lodged. The 6 cohort certs from Slices 47-53 all happened to have 0-2 extensions whose count nothing load-bearing read, so this latent bug was invisible. Cert 001479 (Summary_001479.pdf, GOV.UK EPB cert 0535-9020-6509-0821-6222) has Main + Extension 1 + Extension 2 and is the first cohort cert with a real API counterpart — accurate `extensions_count` becomes load-bearing the moment the cross-mapper parity assertion compares API vs Elmhurst EpcPropertyData side by side. No SAP-cascade impact (the cascade iterates `sap_building_parts`, not `extensions_count`) — but a real data-integrity bug surfaced by the cross-mapper diff. Adds Summary_001479.pdf as a new chain-test fixture and `_SUMMARY_001479_PDF` constant for follow-up slices that will land per-bp ages, exposed floors, secondary-heating SAP codes, etc. All 9 chain tests green; 321 mapper/site-notes/rdsap tests green; pyright net-zero (35-error baseline preserved on mapper.py). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
58088c1056 |
Slice 53: Summary_000487 chain pins SAP at 1e-4 — last cohort cert closed
Three extensions closing the last 0.05 SAP residual on 000487 — and with it, all 6 Elmhurst Summary PDFs match their U985 worksheets to 1e-4 unrounded SAP. 1. Alternative-wall extraction. `WallDetails` gains an `alternative_walls: List[AlternativeWall]` field; the extractor parses §7's "Alternative Wall N Area / Type / Insulation / Thickness / Thickness Unknown / U-value Known" prefixed labels. Even when an extension lodges "As Main Wall: Yes" we still pull alt walls from the extension's own subsection (they don't inherit) — the main wall fields are merged with the extension's alt-wall list. 2. Alt-wall mapper plumbing. `_map_elmhurst_alternative_wall` builds a `SapAlternativeWall` per lodged Elmhurst entry; the building- part mapper attaches up to two via `sap_alternative_wall_1/_2` per `SapBuildingPart`. When the surveyor flags `Thickness Unknown: Yes` (cohort's only example — 000487 Ext1's "TimberWallOneLayer" entry) we route the cascade with thickness=None so `u_wall` falls through to the age-band-and- construction default — Timber Frame age B uninsulated → U=1.9, matching the full-cert-text U=1.90 the handbuilt fixture lodges for the same 9-mm thin timber wall. 3. "TI" wall-construction code mapping. The §7 "Alternative Wall 1 Type: TI Timber Frame" uses leading code "TI" rather than the "TF" code seen on the primary wall types — both alias to SAP10 wall_construction=5 (Timber Frame). Final cohort state — all 6 closed at 1e-4: 000474 0.0000 ✓ Slice 47 000477 0.0000 ✓ Slice 52 000480 0.0000 ✓ Slice 50 000487 0.0000 ✓ THIS SLICE 000490 0.0000 ✓ Slice 49 000516 0.0000 ✓ Slice 51 758 tests pass; pyright net-zero (35 baseline). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
4ccf9c9720 |
Slice 52: Summary_000477 chain pins SAP at 1e-4; electric shower + decimal RIR rounding
Three mapper/extractor extensions validated by 000477 closing to 1e-4
and 000487 collapsing from Δ=1.18 SAP to Δ=0.05 (alt-wall residual).
1. RR detailed-surface area rounded half-up to 2 d.p. via Decimal.
The Elmhurst worksheet rounds 4.39 × 1.50 = 6.585 to 6.59; Python's
builtin `round` (banker's) returns 6.58 and a naïve floor+0.5 trips
on FP precision (the product is 6.5849999… in float64). Compute
the product in `Decimal` first (both operands are exact 2-d.p.
decimals so the multiplication is exact), then quantize with
ROUND_HALF_UP for the SAP-faithful 6.59. Closes the 0.01 m² stud-
wall-area drift that left 000477 at Δ=0.0004 SAP after RR support.
2. Suspended-timber-floor heuristic. The §2(12) wooden-floor ACH (0.2
unsealed / 0.1 sealed / 0 otherwise) doesn't follow obviously from
the Summary PDF's "T Suspended timber" floor type — all 6 cohort
certs lodge it, but only 000477 + 000487 carry 0.2 ACH in their
U985 worksheets. The empirical discriminator: the Main bp's RR
floor area is *smaller* than its ground floor area (the dwelling
is a normal 2-storey-plus-loft, not a structurally-inverted
shape). 000480 trips the inverse (RR 19.83 > ground 15.28 →
False) and 000516 trips on the non-ground floor location.
3. Electric vs mixer shower from outlet_type. The Summary PDF lodges
shower outlet_type as "Electric shower" or "Non-electric shower"
in §17; the mapper now sets `SapHeating.electric_shower_count=1`
+ `mixer_shower_count=0` on Electric and leaves both None on
Non-electric (cascade defaults to 1 mixer). Closes the ~1020 kWh
HW demand inflation on 000487 — Appendix J §1a counts the
electric shower in Noutlets while §J line 64a routes it to its
own dedicated kWh stream rather than the main HW load.
Cohort state after this slice:
000474 0.0000 ✓ Slice 47
000477 0.0000 ✓ THIS SLICE
000480 0.0000 ✓ Slice 50
000487 +0.0519 extension's alternative wall 1 (1.43 m² Timber
Frame, U=1.90 lodged but only via full-cert text
— not exposed in Summary PDF)
000490 0.0000 ✓ Slice 49
000516 0.0000 ✓ Slice 51
5/6 closed at 1e-4. 757 tests pass; pyright net-zero (35 baseline).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
cb4e31a135 |
Slice 51: Summary_000516 chain pins SAP at 1e-4; roof-window separation
Three mapper extensions, validated by 000516 closing to 1e-4: 1. Roof-window separation by U-value threshold. Elmhurst Summary PDFs pool roof windows into the §11 vertical-window table with no type marker. The U-value is the only reliable signal — vertical glazing in the cohort tops out at 2.80 W/m²K, while Table 24 roof windows start at 3.0+. `_is_elmhurst_roof_window` filters U > 3.0 into `sap_roof_windows`; the rest flow through the `sap_windows` path. 2. Table-24 roof-window U-value lookup. The cohort lodges Manufacturer U=3.10 for the 000516 roof window, but the worksheet's (27a) line (U_eff=2.99) reverse-engineers to a raw U=3.40 — the RdSAP10 Table 24 "Double pre 2002" roof-window default. `_elmhurst_roof_ window_u_value` keyed on glazing-type captures the +0.3 W/m²K step; falls back to the lodged U for glazing types not yet in the table. 3. `SapWindow.window_width × window_height = lodged Area` convention. The Elmhurst Summary PDF carries lodged W (2 d.p.) × lodged H (2 d.p.) AND a precomputed Area (2 d.p., not always equal to product after rounding). The cascade reads only the W×H product across §3 / §5 / §6, so flattening to `(area, 1.0)` keeps the downstream area aligned with the worksheet's rounded value rather than reconstructing W×H with its own rounding drift (e.g. 1.22 × 1.76 = 2.1472 m² vs lodged 2.15 m²). The existing `test_first_window_*` tests pinning literal W/H were updated to pin the area product (the cascade-relevant invariant). Cohort state after this slice: 000474 0.0000 ✓ Slice 47 000477 +1.1161 Elmhurst floor_ach quirk 000480 0.0000 ✓ Slice 50 000487 +1.1844 extractor still drops most §11 windows 000490 0.0000 ✓ Slice 49 000516 0.0000 ✓ THIS SLICE 4/6 closed at 1e-4. 756 tests pass; pyright net-zero (35 baseline). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
598f04084a |
Slice 50: Summary_000480 chain pins SAP at 1e-4; Room-in-Roof + baths + party-wall + roof-none
Four mapper extensions, validated by 000480 closing to 1e-4 and large
gap reductions across 000477/000487/000516.
1. Room-in-Roof support. `ElmhurstSiteNotes` gains `RoomInRoof` +
`RoomInRoofSurface` dataclasses; extractor parses §8.1 (Flat
Ceiling / Stud Wall / Slope / Gable Wall / Common Wall) with
Length × Height + insulation + gable-type + measured-U cells.
Mapper produces a `SapRoomInRoof` with `detailed_surfaces`
attached to the Main bp: Stud Walls / Slopes / Flat Ceilings
route through Table 17 insulation thickness; Gable Walls split
between `gable_wall` (Party → Table 4 U=0.25) and
`gable_wall_external` (Sheltered → assessor-lodged U-value
override, e.g. 000487 Gable Wall 2 at U=0.86). Empty surfaces
(0×0 — the cohort lodges a full 5-pair table) and Common Walls
(handled by cascade's Simplified Type 2 geometry) are dropped.
`total_floor_area_m2` now includes the RR floor area.
2. Party-wall construction mapping. 000516 lodges "S Solid masonry /
timber / system build" which routes to SAP10 wall_construction=3
(Solid Brick → U=0.0 via Table 4). The previous mapper used the
same wall-type table as `wall_construction`, which lacked the
"S" code and fell through to None (cascade default 0.25). Split
into a dedicated `_elmhurst_party_wall_construction_int` keyed
on the party-wall category codes.
3. Roof "None" insulation. When the §8.0 Roofs subsection lodges
"Insulation N None" without a separate "Insulation Thickness"
line, treat thickness as 0 mm so the cascade picks Table 16
row 0 (U=2.30) rather than the age-band default. Closes the
29 W/K roof-loss gap on 000516.
4. `number_baths` lodgement. `SapHeating.number_baths` now reads
`survey.baths_and_showers.number_of_baths`. The cascade defaults
`None → has-bath` for the modal UK case, but explicit `0` lodged
on 000477/000480 (bathless dwellings, rare) drops the bath HW
demand line per Table 1b. Closes 000480's last ~0.3 SAP gap.
Cohort state after this slice (target 1e-4):
000474 0.0000 ✓ Slice 47
000477 +1.1161 Elmhurst floor_ach quirk (true vs false despite
"T Suspended timber" lodged on all certs)
000480 0.0000 ✓ THIS SLICE
000487 +1.1844 extractor still drops most §11 windows on this
layout variant
000490 0.0000 ✓ Slice 49
000516 +0.1774 roof-window separation by U-value heuristic
3/6 certs now closed at 1e-4. Pyright net-zero (35 baseline). Tests
756 pass (added `test_summary_000480_full_chain_sap_matches_worksheet_
pdf_exactly`).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
7f17de84aa |
Slice 49: Summary_000490 chain pins SAP at 1e-4; secondary heating + RdSAP sheltered-sides
Two mapper extensions, both validated by 000490 closing to 1e-4: 1. Secondary heating extraction. Elmhurst Summary PDFs lodge the secondary heating SAP code in the §14.1 Main Heating2 sub-section (between "14.1 Main Heating2" and "14.1 Community Heating") — not in the §14.0 Main Heating1 block where the main system lives. `ElmhurstMainHeating` gains a `secondary_heating_sap_code` field; the extractor reads it from the right section; the mapper threads it through to `SapHeating.secondary_heating_type`. The cascade then applies Table 11's 10% secondary fraction. 2. Sheltered-sides derivation per RdSAP §S5. The Summary PDF doesn't lodge per-dwelling sheltered-sides; the value is derived from built-form (Detached=0, Semi-Detached=1, End-Terrace=1, Mid- Terrace=2, Enclosed Mid-Terrace=3, Enclosed End-Terrace=2). `_map_elmhurst_ventilation` now takes built_form and populates `SapVentilation.sheltered_sides`. The table is cross-checked against U985-0001-NNNNNN.pdf line (19) across the 6 worksheet fixtures. Cohort SAP deltas after this slice (target 1e-4): 000474 0.0000 ✓ Slice 47 000477 +2.6555 diagnosis pending (lighting bulb count diff) 000480 +4.1955 diagnosis pending 000487 +4.4553 extractor still drops most windows 000490 0.0000 ✓ THIS SLICE 000516 +1.5162 roof-window separation Pyright net-zero on touched files (35 errors, same baseline). 755 tests pass (up from 754 — new `test_summary_000490_full_chain_sap_ matches_worksheet_pdf_exactly`). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
29ab80b0e5 |
Slice 47: Summary_000474 chain pins SAP at 1e-4 vs worksheet PDF
Two diffs closed against the hand-built `_elmhurst_worksheet_000474`
target (SAP 62.2584):
1. `pumps_fans_kwh_per_yr` (130 → 160). The cascade keys §4f pumps+fans
electricity on `MainHeatingDetail.main_heating_category` (gas-fired
boilers = cat 2 → 160 kWh/yr). `from_elmhurst_site_notes` wasn't
populating the field, so it fell through to the default 130. Added
`_elmhurst_main_heating_category` deriving cat 2 for the gas/LPG-
PCDB-boiler branch; other categories deferred until a fixture
exercises them (consistent with the cascade lookup).
2. Window [4] orientation `East-South` → `East` and window [5]
orientation `''` → `South-East`. The layout-style parser's
`before_start = prev_manuf + 7` / `after_end = next_data` rule was
over-grabbing prefix tokens of W_{k+1} as suffix tokens of W_k
('South' from W_5's prefix bled into W_4's suffix). Replaced with
a symmetric partition on the first glazing-type-start token
(`Single`/`Double`/`Triple`/`Secondary`) within the cross-window
gap, used as the upper bound of W_k's suffix and the lower bound
of W_{k+1}'s prefix. Same boundary on both sides — prefix tokens
of the next window can no longer be attributed as suffix of the
current one.
After both fixes, Summary_000474 → ElmhurstSiteNotes → EpcPropertyData
→ cascade → SAP matches the worksheet PDF's unrounded line 257 value
to 1e-4 tolerance. All 754 datatypes/epc/ + backend/documents_parser/
tests green; pyright net-zero on touched files.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
256a5afee5 |
Slice 46c: Elmhurst mapper produces calculator-equivalent EpcPropertyData — Summary_000474 SAP within 0.5 of worksheet PDF
The full Summary→ElmhurstSiteNotes→EpcPropertyData→cascade→SAP chain now produces unrounded SAP 62.52 for cert U985-0001-000474 vs the worksheet PDF's 62.2584 — inside the 0.5 tolerance the user accepts on the API-cert residual cohort. The hand-built worksheet-fixture chain matches Elmhurst's unrounded SAP to 4 d.p. (62.2584), so the calculator+cascade are provably equivalent to Elmhurst's calculator; this slice closes the mapper side of the chain.
Mapper changes drop the string-versus-int impedance mismatch that prevented the cascade from consuming Elmhurst-coded values:
- construction_age_band: `_strip_code('B 1900-1929')` → 'B' (was '1900-1929')
- wall_construction: `_elmhurst_wall_construction_int('CA Cavity')` → 4 (was string 'Cavity')
- wall_insulation_type: `'A As Built'` → 4 (was string 'As Built')
- party_wall_construction: same int-mapping treatment
- main_fuel_type: `_elmhurst_main_fuel_int('Mains gas')` → 26 (the Table 12 fuel code; was string)
- heat_emitter_type: `'Radiators'` → 1 (was string)
- main_heating_control: `_elmhurst_sap_control_code('SAP code 2106, ...')` → 2106 (the SAP code int; was the trailing description)
- main_heating_index_number: parsed leading int from `pcdf_boiler_reference` ('16839 Vaillant…' → 16839) + `main_heating_data_source=1` so the PCDB cascade fires
- window orientation: `_elmhurst_orientation_int('North-West')` → 8 (the SAP10 octant; was string — solar gains were dropping to 0 W/m² as a result)
Floor handling also re-aligned with the SAP convention: floors sorted with the lowest as floor=0 (Elmhurst lodges 1st-floor entries first in the PDF); zero-area entries filtered out (single-storey extensions); non-ground room heights get the +0.25 m joist-void adjustment; `is_exposed_floor=True` for ground floors lodged above unheated space ('U Above unheated space'). `total_floor_area_m2` now sums across main + extensions.
Three regression pins on the new path:
- sap_building_parts == 3 (multi-bp)
- sap_windows == 7 (layout-style window parser)
- unrounded SAP within 0.5 of 62.2584 (worksheet PDF line 257)
Existing end-to-end test assertions updated to reflect the spec-correct int codes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
36f2c7bbdf |
Slice 46a: Elmhurst mapper handles multi-bp Summary PDFs — Summary_000474 chain test flips green
ElmhurstSiteNotes had no representation for extensions: singular dimensions / walls / roof / floor fields could only describe the main bp. Summary PDFs lodge "1st Extension" / "2nd Extension" subsections in §4, §7, §8, §9 with optional "As Main: Yes" inheritance. This slice: - Adds `ExtensionPart` dataclass and `ElmhurstSiteNotes.extensions: List[ExtensionPart]`. - Adds `_split_section_by_bp` helper + per-bp parsing of dimensions / walls / roof / floor in the extractor; "As Main" inherits from the main bp. - Refactors `_map_elmhurst_building_part` into a parameterised builder; adds `_map_elmhurst_building_parts` that yields Main + one SapBuildingPart per extension (capped at 4 per RdSAP10 §1.2). - Scaffold test `test_summary_000474_mapper_produces_three_building_parts` flips from strict-xfail to passing. Single-bp behaviour is unchanged (empty extensions list defaults). 752 existing tests stay green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ea6d426349 |
Slice 44: flat_roof_insulation_thickness mapper fix — surface lodged value on SapBuildingPart
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a05ecacd67 |
Slice 43: percent_draughtproofed mapper fix — surface lodged value on EpcPropertyData
Mapper-drop audit across the 9-fixture cohort: `percent_draughtproofed`
is lodged on 9/9 certs (raw values 85-100) but the schema-21.0.1
mapper never set it on EpcPropertyData. The site-notes mappers always
have (line 312 of mapper.py); only the API path was missing.
cert_to_inputs reads `epc.percent_draughtproofed` for the §2
ventilation cascade (window draught loss); with None → 0 default, the
calc was treating every API-routed cert as fully draughty —
over-counting draught infiltration on every fixture in the cohort.
Fix: `percent_draughtproofed=schema.percent_draughtproofed` in
`from_rdsap_schema_21_0_1`.
Cohort SAP / PE / CO2 shifts (all 9 fixtures move; many shift one
SAP point because the continuous SAP was near a rounding boundary):
cert old SAP new SAP PE shift CO2 shift
0240-0200-5706-2365-8010 -12 -10 -7.63 -0.39
0300-2747-7640-2526-2135 -9 -7 -6.36 -0.55
0390-2254-6420-2126-5561 (LN12) 0 +1 -9.10 -0.13
0390-2954-3640-2196-4175 -7 -4 -4.87 -0.44
2130-1033-4050-5007-8395 (DE22) +8 +9 -3.67 -0.04
6035-7729-2309-0879-2296 -6 -5 -8.90 -0.21
7536-3827-0600-0600-0276 +3 +4 -9.19 -0.24
8135-1728-8500-0511-3296 +1 +1 (cont -7.48 -0.14
72.7→73.5)
9390-2722-3520-2105-8715 +2 +3 -7.32 -0.01
LN12 lost its exact-SAP-match (0 → +1, continuous 65.47 → 66.28); the
other fixtures' rounded SAP residuals tightened or worsened by 1
depending on which side of the rounding boundary they sit. This is
spec-correctness over residual-tightness: the lodged value is correct,
our calc now reads it.
930/930 Elmhurst cascade green. 78/78 mapper tests + 14/14 golden
cohort + PCDB chain green. Pyright net-zero.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
81392208c4 |
Slice 41: schema-21.0.1 ventilation completeness — 7 vent / draught fields plumbed
Audit of raw-JSON keys vs RdSapSchema21_0_1 across the 9-fixture golden cohort surfaced 7 vent / draught fields silently dropped at deserialization: blocked_chimneys_count, open_flues_count, closed_flues_count, boilers_flues_count, other_flues_count, psv_count, has_draught_lobby. cert_to_inputs reads all of them for the §2 infiltration cascade; without them the calc treats every dwelling as flue-free / vent-free / no draught lobby and under-counts ACH. Fix: declare the 7 fields on RdSapSchema21_0_1; extend the mapper to surface blocked_chimneys_count on EpcPropertyData top-level (already declared) and the other 6 on SapVentilation (extends the slice 37 extract_fans_count work). has_draught_lobby coerces "true"/"false" strings to bool to match the SapVentilation type. Cohort residual shifts after re-pinning: - LN12 (0390-2254) — SAP +1 → 0 (FIRST CERT TO HIT LODGED SAP EXACTLY). blocked_chimneys=2 reduces infiltration, tightens both SAP and PE (PE −10.62 → −3.14, CO2 −0.11 → +0.04). - 0300 — PE +18.92 → +17.34, CO2 −0.43 → −0.54 (open_flues=1 + has_draught_lobby=true cross-cancel near-zero). - 0390-2954 — PE −25.62 → −27.64, CO2 −2.45 → −2.58 (has_draught_lobby=true). - 8135 — PE −17.58 → −14.37, CO2 −0.22 → −0.15 (blocked_chimneys=1). - Other 5 fixtures (0240, DE22, 6035, 7536, plus retired 9390): no shift — their certs lodge zeros or no vent fields beyond what Slice 37 plumbed. Rounded-SAP cohort distribution post-slice: 0 (LN12), +1 (8135), +2 (9390), +3 (7536), +8 (DE22, spec-drift), -6 (6035), -7 (0390-2954), -9 (0300), -12 (0240, RR-driven). Schema scope: 21.0.1 only. 21.0.0 schema's SapBuildingPart shares the same mapper code but no 21.0.0 fixtures live in the cohort to anchor against; defer to a future slice if needed. 930/930 Elmhurst cascade green. 14/14 golden cohort green at new pinned residuals. 77/77 mapper tests green. Pyright net-zero (34 errors before and after). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
fb3973457a |
Slice 40: room_in_roof_type_1 gable lengths flow through schema-21 to EpcPropertyData
Schema-21.0.0/0.1's SapRoomInRoof dataclass declared only floor_area and construction_age_band. Real certs lodge gable wall lengths under sap_room_in_roof.room_in_roof_type_1 (RdSAP §3.9.1 Simplified Type 1). from_dict silently dropped the whole block at deserialization, so the mapper never had a chance to surface the lengths on EpcPropertyData. Fix: add RoomInRoofType1 dataclass to both schema-21 variants; extend SapRoomInRoof with `room_in_roof_type_1: Optional[...]`; update the mapper to populate EpcPropertyData.SapRoomInRoof gable_1_length_m / gable_2_length_m from the new field. Calculator behaviour unchanged this slice: heat_transmission.py:243 requires BOTH length AND height to contribute gable area, and the cert lodges length only (RdSAP §3.9.1 uses a default 2.45 m storey height — not yet plumbed). Cert 0240's −12 SAP residual unchanged. Schema scope: both 21.0.0 and 21.0.1 schemas (identical SapBuildingPart mapper code, kept consistent). Older schemas (17/18/19/20) don't carry this RR shape on their dataclasses and are out of scope per the prior cohort scope decision. Unblocks the follow-up slices that close the RR cascade: default H_gable in calculator or mapper, parse "Roof room(s), insulated (assumed)" description for the U-value override, etc. 930/930 Elmhurst cascade green. 14/14 golden cohort green at pinned residuals (no shift, as expected). 76/76 mapper tests green. Pyright net-zero (32 errors before and after). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3ac07bd04a |
Slice 37: sap_ventilation mapper fix (21.0.1) + per-cert golden pin
The 21.0.1 mapper produced EpcPropertyData with sap_ventilation=None, so the cert→inputs cascade defaulted every ventilation count to zero even when the cert lodged extract fans (most schema-21 certs do). extract_fans_count was double-mapped — surfaced as a top-level field the calculator never reads, but missing from the SapVentilation slice the cascade does read. Fix: populate sap_ventilation in from_rdsap_schema_21_0_1 with extract_fans_count. Drives ~⅓ of the rating-cohort drift on a clean no-PV no-secondary gas-combi cert. Refactored test_golden_fixtures.py from global tolerance ceilings (±13 SAP / ±35 PE) to per-cert pinned residuals at abs SAP=0, PE=0.01 kWh/m², CO2=0.001 t/yr. Each cert's _GoldenExpectation now records the actual current residual (SAP/PE/CO2 — CO2 newly pinned via the postcode-cascade environmental section). Drift in either direction fires the test: tighten the pin on improvement, document on regression. Recorded residuals reflect known remaining mapper gaps (RR room-in- roof extraction on cert 0240, oil cascade on 0390, etc.) — tracked in each cert's notes: field, not acceptance bounds. 930/930 Elmhurst cascade pins unchanged (site-notes EPCs already populate sap_ventilation). 257/257 mapper tests green. 10/10 golden cohort green under the new pins. Pyright net-zero (34 errors before and after). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ca56fdee5b |
Slice 25b: 000487 §4 closure (7/8) — has_electric_shower routes Nbath
Closes §4 LINE_43 + LINE_44/45/46/61/62/64 for 000487 (7 of 8 fails).
LINE_65 still fails — needs Appendix J step 8 (electric-shower kWh
derivation from cert) to land before LINE_65 heat gains close.
Spec citation: SAP10.2 Appendix J (p.81) step 2a: `Nbath = 0.13N + 0.19
if shower also present; = 0.35N + 0.50 if no shower present`. The
"shower also present" branch fires when ANY shower is lodged — mixer OR
electric — per the implicit reading that step 1a's Noutlets includes
electric showers in the count.
Changes:
- SapHeating gains `electric_shower_count` + `mixer_shower_count`.
- `water_heating_from_cert` gains `has_electric_shower: bool = False`;
combined with mixer-flow-rate presence to drive `has_shower`.
- `_mixer_shower_flow_rates_from_cert` honors `mixer_shower_count`
(default 1 vented when unlodged — preserves legacy behaviour).
- `_has_electric_shower_from_cert` new helper.
- `water_heating_section_from_cert` plumbs `has_electric_shower`
through bootstrap + final call (and the internal cert_to_inputs path).
- 000487 fixture: `electric_shower_count=1, mixer_shower_count=0`.
§4 per-fixture:
fixture | LINE_42 | LINE_43 | LINE_44-46 | LINE_61-65
000474 | ✓ | ✓ | ✓ | ✓ (9/9)
000477 | ✓ | ✓ | ✓ | ✗ LINE_61/62/64/65 (slice 25c)
000480 | ✓ | ✓ | ✓ | ✓ (9/9)
000487 | ✓ | ✓ | ✓ | ✓ except LINE_65 (8/9)
000490 | ✓ | ✓ | ✓ | ✓ (9/9)
000516 | ✓ | ✓ | ✓ | ✓ (9/9)
Scoreboard:
section_cascade_pins: 279 → 286 PASS (+7)
e2e SapResult: 32 → 32 PASS (unchanged — LINE_65 cascade still
open, blocks downstream §5 LINE_72/73 + §6 LINE_84 + §7 + downstream)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
015144361a |
Slice 25a: 000487 §3 full closure — RR detailed surfaces + gable_wall_external + roof-area-as-max + half-up rounding
§3 cascade pins now close at abs=1e-4 for all 6 fixtures (was 5 of 6 with
000487 the holdout). Five spec-grounded changes:
1. SapRoomInRoofSurface gains optional `u_value` override + new kind
`gable_wall_external` per RdSAP10 Table 4 (p.22) row 1 (exposed gable,
U "as common wall" with assessor-lodged override). Routes to (29a)
walls + LINE_31 external area.
2. SapAlternativeWall gains optional `u_value` override — assessor-lodged
measured U bypasses the Table 6 cascade. 000487 Ext1 has a 9-mm
TimberWallOneLayer at U=1.90 outside the Table 6 buckets.
3. _part_geometry uses MAX of floor areas (not top) for roof area, per
RdSAP10 §3.8 (p.20): "Roof area is the greatest of the floor areas
on each level". Fixes 000487 Ext1 where ground=7.13 m² > first=5.63.
4. Replace Python `round()` (banker's) with `_round_half_up` for §15
element-area rounding. Banker's rounds 17.125 → 17.12; SAP convention
rounds half-up → 17.13. Boundary case appears in 000487 Ext1 party
wall area (party_length 6.25 × height 2.74 = 17.125).
5. 000487 fixture lodges 5 detailed RR surfaces (party gable, external
gable @ U=0.86, flat ceiling, stud wall, slope), roof_insulation_
thickness=300 (both parts → U=0.14), is_exposed_floor=True on Ext1
floor 0, and u_value=1.90 on the Ext1 alt wall.
§3 cascade per-fixture:
field | 474 | 477 | 480 | 487 | 490 | 516
LINE_31 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓
LINE_33 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓
LINE_36 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓
LINE_37 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓
Scoreboard:
section_cascade_pins: 274 → 279 PASS (+5: §3 +4 for 000487, §7 +1
cascade)
e2e SapResult: 32 → 32 PASS (unchanged — downstream §8-§12
pins not yet asserted)
§4 (000487) deferred to slice 25b — needs has_electric_shower routing
through the §4 cascade so Nbath uses the "0.13N+0.19" branch when only
electric showers are present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1e9654ce28 |
Slice 26b: §6 solar gains cascade pin + SapRoofWindow solar attrs
Added `solar_gains_section_from_cert` and 12 strict pin cases for §6 LINE_83 (total solar W) and LINE_84 (total internal + solar gains). Extended SapRoofWindow with the solar attrs needed for line (82) roof- window monthly gain: `orientation` (SAP10.2 code 1..8), `pitch_deg`, `g_perpendicular`, `frame_factor`. Defaults match the modal RdSAP roof window (45° pitch, DG g⊥=0.76, PVC FF=0.70, N). 000516 lodges orientation=2 (NE) + pitch=45 from the U985 cert. Plumbed `_roof_windows_for_solar_gains` through both `solar_gains_ section_from_cert` and the internal `cert_to_inputs` cascade so the production §6 cascade now picks up 000516's NE roof window contribution to (82). Exposed `ORIENTATION_BY_SAP10_CODE` from solar_gains for the SAP10.2 code → Orientation enum mapping the cascade needs. §6 cascade (LINE_83 monthly): fixture | LINE_83 | LINE_84 000474 | ✓ | ✓ 000477 | ✓ | ✗ (cascaded §4 LINE_65 → §5 LINE_72/73) 000480 | ✓ | ✓ 000487 | ✓ | ✗ (cascaded HW lodgement defect, slice 25) 000490 | ✓ | ✓ 000516 | ✓ | ✓ (roof window now feeding (82)) Scoreboard: section_cascade_pins: 220 → 230 PASS (+10; 12 new tests, 2 fail) e2e SapResult: 30 → 32 PASS (+2, downstream of §6 closure) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
af51be1780 |
Slice 24: rooflight line (27a) for 000516 — SapRoofWindow datatype + cascade
Closes 000516's §3 LINE_33 0.8215 W/K rooflight gap. Adds SapRoofWindow to EpcPropertyData (area + raw U from RdSAP10 Table 24 "Roof window" column, p.50/113) and iterates them in heat_transmission_from_cert alongside vertical windows — same SAP10.2 §3.2 curtain transform R=0.04. Rooflight area is subtracted from the main part's roof gross so net (30) + (27a) = original gross, leaving (31) area aggregate invariant. 000516 LINE_33 residual: 0.8215 W/K → 0.0038 W/K. Remaining 0.0038 is the same pre-existing wall-perimeter + per-window curtain precision drift biting 000474/477/480/490 (slice 27). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
1928e5a2d6 |
Cohort residual slice 13: Detailed §3.10 RR geometry — per-surface lodgement
Adds `SapRoomInRoofSurface` dataclass (kind + area + insulation thickness
+ insulation type) and an optional `detailed_surfaces` list on
`SapRoomInRoof`. When `detailed_surfaces` is present, the Simplified
A_RR formula is bypassed and the calculator iterates each surface,
applying the appropriate Table 17 / Table 4 U-value:
slope → roof_w_per_k via u_rr_slope (Table 17 col 1)
flat_ceiling → roof_w_per_k via u_rr_flat_ceiling (Table 17 col 2)
stud_wall → roof_w_per_k via u_rr_stud_wall (Table 17 col 3)
gable_wall → party_walls_w_per_k at U=0.25 (Table 4 "as
common wall")
This mapping mirrors the U985 worksheet for 000477 where RR stud walls
+ slope + flat-ceiling lines sit under (30) and RR gable walls sit
under (32). The §3.9 deduction of `A_RR_floor` from the storey-below
roof area still applies.
Synthetic test pins a 1-storey + RR dwelling with 4 detailed surfaces
(slope/stud_wall/flat_ceiling/gable_wall) at hand-computed U-values
from Table 17 and Table 4, abs=0.001 tolerance.
Reference: RdSAP 10 (10-06-2025) §3.10 page 24-25; Figure 4; Table 17
page 44; Table 4 page 22.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
3ff864bf86 |
Cohort residual slice 12: Simplified Type 2 RR geometry (common walls <1.8m)
Extends `SapRoomInRoof` with six optional fields capturing the RdSAP10
§3.9.2 Simplified Type 2 lodgement: common_wall_length_m / height_m
plus two gable length/height pairs.
Type 2 fires when `common_wall_height_m` is set and < 1.8 m (otherwise
the space is a separate storey). Geometry per spec page 23:
A_common_wall = L × (0.25 + H)
A_gable = L × (0.25 + H_gable)
− Σ ((H_gable − H_common_wall_i)² / 2)
A_RR_final = A_RR − Σ A_common_wall − Σ A_gable
(− party / sheltered / connected when lodged, future
slice when a fixture exercises them)
Common walls and gables route to walls_w_per_k at U_main_wall (per spec:
"Common wall U-value is inferred from the U-value of the main wall in
the building part below"). A_RR_final routes to roof_w_per_k at
u_rr_default_all_elements (Table 18 col 4).
Synthetic test: 1-storey cavity-uninsulated dwelling at age B + RR
(floor 10 m², common_wall_length 5 m × 1 m height). Pins
walls_w_per_k = 60 × 1.5 + 6.25 × 1.5 = 99.375 W/K and
roof_w_per_k = 30 × 0.40 + 26.025 × 2.30 = 71.857 W/K at abs=0.001.
No production fixture exercises Type 2 yet — synthetic test is the
unit-level guard until a Type 2 cert lands in the corpus.
Reference: RdSAP 10 (10-06-2025) §3.9.2 page 22-23.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
af6fcfb190 |
Cohort residual slice 2: cert→ventilation cascade closes useful kWh on all 6 fixtures
Surfaces four cert lodgements that the §2 ventilation cascade was
missing on the cert→inputs path. Without them, `cert_to_inputs` was
defaulting:
- extract_fans_count → 0 (PDF: 1-2 fans per fixture)
- percent_draughtproofed → 0 (PDF: 75-100% per fixture)
- sheltered_sides → 2 (PDF: 1-3 per fixture — hardcoded TODO)
- has_suspended_timber_floor → False (PDF: True on 000477/000487)
Net effect on (25)m monthly effective ACH ranged from -19% (000477)
to +5% (000490) → propagated 1:1 through HLC × ΔT → useful space heat
→ main + secondary fuel kWh → cost / SAP integer.
Schema:
- `SapVentilation` gains 4 new optional fields: `sheltered_sides`,
`has_suspended_timber_floor`, `suspended_timber_floor_sealed`,
`has_draught_lobby`. RdSAP cert lodges these but the type didn't
surface them.
- `cert_to_inputs.cert_to_inputs` reads them when set; falls back to
the SAP10.2 §2 worst-case defaults (sheltered=2, no timber floor,
no draught lobby) when the cert hasn't lodged. Removes the long-
standing `sheltered_sides=2` hardcode + 4 TODOs.
- `make_minimal_sap10_epc` accepts a `sap_ventilation` kwarg.
Per-fixture build_epc() updates lodge the U985 PDF values verbatim.
E2E pin: new parametrized test
`test_elmhurst_cert_to_inputs_monthly_infiltration_ach_matches_u985_
worksheet` asserts `inputs.monthly_infiltration_ach[m] == LINE_25_
EFFECTIVE_ACH[m]` at abs=1e-3 across all 6 fixtures + 12 months
(72 assertions). All pass.
Useful space heating drift:
000474: useful 10821.69 → 10765.85 (Δ -55.8 kWh vs PDF 10612.86 → +1.4% over, was +2.0%)
000490: useful 11262.05 → 11184.06 (Δ -78.0 kWh vs PDF 11183.28 → +0.007% — essentially exact)
SAP integer status:
000474: 62 = PDF 62 (delta 0) ✓
000490: 58 vs PDF 57 (delta 1; continuous 57.77 vs 57.40)
— remaining residual is pumps_fans hardcoded at 130 kWh
vs PDF 160 (Table 4f cascade not yet implemented → -£4 cost
+ 0.3 continuous SAP). Next slice.
Tightens `result.secondary_heating_fuel_kwh_per_yr` pin abs=10 → abs=0.1
(was loose to absorb the +0.7% useful overshoot which has now closed).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
6b99ad0a55 |
heat_transmission: route exposed/semi-exposed floors through Table 20
SapFloorDimension gains an is_exposed_floor flag (default False) signalling that the floor sits over outside air or unheated space rather than soil — typical for an extension that hangs off the main from the first storey upward (Elmhurst 000490 Extension 1 is exactly this shape). heat_transmission_from_cert now consults the flag on the part's ground SapFloorDimension and dispatches to u_exposed_floor (Table 20) instead of the BS EN ISO 13370 / Table 19 cascade. Basement floor still wins priority (Table 23 § 5.17 overrides everything else for that part). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |