mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
114 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8133521c43 |
S0380.237: map "Secondary glazing - Low emissivity" → SAP 10.2 code 12
Completes the secondary-glazing family. S0380.235 mapped the unknown-data (7) and normal-emissivity (11) secondary variants; the RdSAP-21.0.1 `glazed_type` enum also defines code 12 "secondary glazing, low emissivity", whose Elmhurst §11 label "Secondary glazing - Low emissivity" was unmapped and would strict-raise. Cascade code 12 carries the same daylight/solar bucket as 7/11 (g_L=0.80, g⊥=0.76); the lodged manufacturer U/g drive §3/§6. With this the double family (codes 1/2/3/ 7/13 via their Elmhurst phrasings) and the secondary family (4/11/12) are fully covered. Coverage test extended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ea35bed24c |
S0380.236: extension party-wall type read independently of "As Main Wall"
RdSAP 10 §3.3: "As Main Wall: Yes" makes an extension inherit the main dwelling's external wall CONSTRUCTION only — the party wall type is lodged separately per building part in the Summary §7 block and may differ. `_extract_extensions` was copying `main_walls.party_wall_type` into the inherited WallDetails, so every extension reused the main's party wall U. On the double_glazing fixture (Summary_001431) the Main lodges party "CU Cavity masonry unfilled" (SAP10 wall_construction 4 → u_party_wall 0.5) but the 1st Extension lodges "U Unable to determine" (→ 0 → RdSAP default 0.25). Pre-fix both building parts used 0.5, inflating worksheet (32) party-wall heat loss by 6.56 W/K (Ext1 26.25 m² × 0.25). After the fix worksheet (32) is exact: ours 32.573 vs worksheet 32.5725. Now reads the extension's own "Party Wall Type" from its §7 chunk, falling back to the main's only when the extension lodges none. Adds a fixture + test asserting Main=4 / Ext=0 with distinct u_party_wall. Suite 2413 pass; no cohort regression. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
3e45b7fa3b |
S0380.235: map the remaining Elmhurst §11 glazing labels to SAP 10.2 Table 6b
The double_glazing recommendation fixture (Summary_001431) exercises every RdSAP-21 §11 glazing lodging in one cert; five labels were missing from `_ELMHURST_GLAZING_LABEL_TO_SAP10` and strict-raised `UnmappedElmhurstLabel`: "Secondary glazing" -> 7 (Table 6b "secondary glazing", g_L 0.80) "Secondary glazing - Normal emissivity" -> 11 (RdSAP-21 secondary normal-E, g_L 0.80) "Triple pre 2002" -> 10 (triple pre-2002, g_L 0.70) "Triple with unknown install date" -> 6 (generic triple glazed, g_L 0.70) "Single glazing, known data" -> 15 (single known-data, g_L 0.90) The glazing code's only cascade effect is the §5 (66)..(67) daylight factor g_L in `_G_LIGHT_BY_GLAZING_CODE` (single 0.90 / double+secondary 0.80 / triple 0.70); the lodged manufacturer U-value and solar_transmittance drive §3 / §6 directly (`_g_perpendicular` prefers the lodged value). Codes are the semantically-exact RdSAP-21 rows within the correct g_L bucket, kept distinct for the strict-raise audit trail. Adds a full-coverage test over all 13 distinct labels. Suite 2413 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
f326e4eb53 |
mapper: Elmhurst path populates roof_construction (int) for cross-mapper parity
The gov-EPC API mapper sets BOTH roof_construction (int) and roof_construction_type (str, derived via _API_ROOF_CONSTRUCTION_TO_STR), but the Elmhurst mapper set only the string — leaving roof_construction None on every site-notes cert. The SAP cascade reads the STRING (so SAP cross-mapper parity always held), but consumers of the int (e.g. domain/sap10_ml/transform.py ML aggregates `main_dwelling_roof_ construction`) silently saw None on the Elmhurst path. New `_elmhurst_roof_construction_int` maps the Elmhurst roof-type code to the same SAP10 int the API lodges (F→1, PN→3, PA→4, PS→8, S/A→7), harvested from the committed Summary fixtures. Unlike the wall map it returns None (not a strict-raise) for unmapped codes: the int is not cascade-load-bearing, so an unknown roof must not block the cert (vaulted 5 / thatched 6 / NR omitted until a fixture surfaces them). The 6 hand-built U985 reference fixtures gain the matching roof_construction int (4/4/3 etc.) so test_from_elmhurst_site_notes_ matches_hand_built_* still asserts structural parity. SAP output is unchanged (cascade reads the string). §4 suite green (2407 passed); the two pre-existing stone-§5.6 sap10_ml failures are unrelated/out of scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
bd25a3c774 |
mapper: disambiguate SY system-built from B basement wall (both share code 6)
RdSAP10 `wall_construction == 6` is canonically WALL_SYSTEM_BUILT — a WALL TYPE — but the gov-EPC basement heuristic hijacked it: Elmhurst lodges both "SY System build" and "B Basement wall" as code 6, and the API lodges basements as code 6 too, so a system-built wall was mis-flagged `main_wall_is_basement` → wrong RdSAP §5.17 / Table 23 u_basement_wall/u_basement_floor overrides, and downstream the solid-wall Recommendation Generator couldn't offer EWI/IWI on system-built walls. System-built stays the wall type on its canonical code 6; the basement signal moves OFF code 6 to a dedicated `is_basement` (SapAlternativeWall) / `wall_is_basement` (SapBuildingPart) Optional[bool] flag: - Elmhurst: `_elmhurst_wall_is_basement` sets it from the distinct "SY"/"B" labels (False for SY, True for B, None otherwise). - gov-EPC API: per-wall code 6 can't be told apart at lodging time, so `from_api_response` post-processes via `_clear_basement_flag_when_ system_built` — when the cert addendum marks the dwelling system-built, the code-6 basement heuristic is cleared. A genuine basement (no addendum signal) keeps the code-6 fallback. - `main_wall_is_basement` / `is_basement_wall` honour the flag when set, else fall back to the code-6 heuristic — so untouched API basements and the cert 000565 "B" cohort are unchanged. `EpcPropertyData.system_build` is a derived property over the wall type: the MAIN wall is system-built iff `wall_construction == 6` and it is not flagged basement. System-built lives on `wall_construction`; the basement attribute is separate. Acceptance: a system-built main wall (Elmhurst SY, or API addendum system_build) → wall_construction == 6, main_wall_is_basement is False, system_build is True; a genuine basement main wall → main_wall_is_basement is True, system_build is False. Full §4 suite green (2404 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c236aa5836 |
S0380.226: map Elmhurst "Jacket" cylinder insulation → loose-jacket (code 2)
The Summary-path mapper raised UnmappedElmhurstLabel for a §15.1 "Cylinder Insulation Type: Jacket" lodging — only "Foam" (→1, factory) was mapped. SAP10 cylinder_insulation_type uses 2 for loose jacket (matching the GOV.UK API codes), and SAP 10.2 Table 2 Note 1 gives it a separate ~2× storage-loss factor that the cascade now handles (S0380.224). Add "Jacket" → 2 for cross-mapper parity with the API path and so the loose-jacket storage-loss branch fires on the Summary path. Surfaced by simulated case 19 (a 210 L jacket cylinder + electric storage heaters), which previously couldn't extract at all. §4 suite 2397 passed; mapper.py pyright unchanged at 32. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d7d5084f90 |
Move sap10_calculator tests to tests/domain/sap10_calculator/ for CI
The calculator tests lived under domain/sap10_calculator/{tests,worksheet/
tests,rdsap/tests,climate/tests,validation/tests}, none of which are in
pytest.ini testpaths — so CI (which collects tests/) never ran them. Relocate
all five dirs to tests/domain/sap10_calculator/{,worksheet,rdsap,climate,
validation}, mirroring the tests/domain/property_baseline/ convention, so the
cascade-pin / golden / e2e conformance suites run in CI.
Mechanics:
- git mv preserves history (110 files).
- Flattening the trailing /tests keeps each file's depth-to-repo-root
identical, so all 16 repo-root parents[4] fixture refs stay valid. Only
test_pcdb_etl.py's parents[1] (→ pcdb data) and one hardcoded absolute
golden-fixture path in test_cert_to_inputs.py needed rebasing.
- Cross-imports rewritten domain.sap10_calculator.worksheet.tests →
tests.domain.sap10_calculator.worksheet (21 files incl. the external
importer backend/documents_parser/tests/test_summary_pdf_mapper_chain.py).
- Golden-fixture path strings in test_summary_pdf_mapper_chain.py +
scripts/fetch_cohort2_api_jsons.py updated to the new location (the JSONs
moved with the rdsap tests).
load_cells / gitignored worksheet xlsx: the xlsx-pinned tests (test_dimensions
/ ventilation / water_heating) read 2026-05-19-17-18 RdSap10Worksheet.xlsx,
which is gitignored (.gitignore `*.xlsx`) and so absent in CI. _xlsx_loader.
load_cells now pytest.skip()s when the file is absent, so those tests run
locally and skip cleanly in CI instead of erroring — no new CI failures from
the move, and the gitignore policy is respected.
Verified: tests/domain/sap10_calculator + backend/documents_parser +
tests/domain/property_baseline = 2248 pass, 1 skipped; pyright resolves the
new import paths with zero import-resolution errors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
eda6f449e4 |
Slice S0380.143: RdSAP 10 §10.11 Table 29 — derive cylinder insulation defaults from construction age band when §15.1 lodges "No Access"
RdSAP 10 Specification §10.11 Table 29 page 56 — "Heating and hot water parameters" → row "Hot water cylinder insulation if not accessible": Age band of main property A to F: 12 mm loose jacket Age band of main property G, H: 25 mm foam Age band of main property I to M: 38 mm foam Pre-slice the Elmhurst mapper passed through cylinder_insulation_type and cylinder_insulation_thickness_mm as None whenever §15.1 lodged "Cylinder Size: No Access" (the inaccessible-cylinder lodging form) because the Summary doesn't carry the measured insulation label / thickness on inaccessible cylinders. The cascade's §4 (56)m water storage loss override at `_cylinder_storage_loss_override` then returned None (gates on `insulation_type == _CYLINDER_INSULATION_ TYPE_FACTORY` + thickness lodged), so the worksheet's (56)m sum was dropped entirely from (62)m. Cert pcdb 1 (corpus 001431, Potterton KOA PCDB 716 + 110 L cylinder + §15.1 "No Access" + age G 1983-1990) exposes the gap: worksheet (56)m monthly ≈ 59.06 kWh ((51) factor 0.024 from Note 1 formula L = 0.005 + 0.55 / (t + 4) at t = 25 mm) × (52) volume factor 1.0294 × (53) Table 2b temperature factor 0.702 — annual sum ≈ 695 kWh, missing from the pre-slice cascade entirely. New helper `_resolve_elmhurst_inaccessible_cylinder_insulation(age_band)` in `datatypes/epc/domain/mapper.py` returns the `(insulation_type_code, thickness_mm)` tuple for age G/H (factory foam, 25 mm) and I/J/K/L/M (factory foam, 38 mm). Age bands A-F (loose jacket, 12 mm) raise `UnmappedElmhurstLabel` — no current Elmhurst corpus member is age A-F with §15.1 = "No Access", and the loose-jacket SAP10 cylinder_insulation_type enum value is not yet plumbed into the calculator's `cylinder_storage_loss_factor_table_2` dispatch (only factory=1 is exercised). The strict-raise mirrors the [[reference-unmapped-sap-code]] pattern so a future fixture forces the loose-jacket extension explicitly. `_map_elmhurst_sap_heating` calls the resolver before constructing SapHeating; the accessible-cylinder path stays unchanged (measured label + thickness from §15.1). Corpus impact: - pcdb 1 (only "No Access" cylinder variant in the corpus): SAP +2.86 → +0.57; cost -£63.22 → -£12.55; CO2 -328.74 → -51.19; PE -1257.97 → -109.46. The remaining residual is a ~1.3% cascade- side undercount on space-heating demand (cascade SH 7900 kWh vs worksheet (98c) 8004 kWh) plus minor pumps/fans rate noise — well within the spec-cascade floor. Combined with S0380.141 (§9.4.11 -5pp interlock on SH + Eq D1) and S0380.142 (§4 lines 7700/7702 cylinder-presence gates), the pre-slice pcdb 1 residual SAP +6.95 closes to +0.57 (-92% magnitude), cost -£157.61 to -£12.55, PE -3135.30 to -109.46. Extended handover suite: 886 pass, 0 fail. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
0d2d41abbb |
Slice S0380.133: derive solid-fuel main fuel from §14.0 EES Code
The Elmhurst Summary §14.0 "Main Heating EES Code" is a three-letter
identifier that resolves to the specific fuel for solid-fuel main
heating systems. The §14.0 "Main Heating SAP Code" alone can't
disambiguate because Table 4a categorises solid-fuel systems by
appliance type rather than fuel — SAP code 160 ("Closed room heater
with boiler") is shared by anthracite, wood chips, dual fuel and
smokeless across the heating-systems corpus.
Three changes land together:
1. `MainHeating` dataclass (`elmhurst_site_notes.py`) gains a
`main_heating_ees: str = ""` field for the §14.0 EES code.
2. `ElmhurstSiteNotesExtractor._extract_main_heating` reads "Main
Heating EES Code" from §14.0.
3. `_map_elmhurst_sap_heating` adds a fourth fuel-derivation
fallback (after the existing electric-SAP-code + §15.0-liquid-
fuel branches): when `main_fuel_int is None` and the §14.0 EES
code is in `_ELMHURST_MAIN_HEATING_EES_TO_FUEL_CODE`, use that
dict's value as the main fuel.
Dict (corpus-derived, 10 entries → 7 distinct Table 32 fuels):
BAF, BAI, RAM → 15 anthracite (3.64 / 0.395 / 1.064)
BCC → 11 house coal (3.67 / 0.395 / 1.064)
BDI → 10 dual fuel (3.99 / 0.087 / 1.049)
BKI → 12 smokeless (4.61 / 0.366 / 1.261)
BQI → 21 wood chips (3.07 / 0.023 / 1.046)
RPS → 22 wood pellets bags (5.81 / 0.053 / 1.325)
RUN → 23 bulk pellets (5.26 / 0.053 / 1.325)
RWN → 20 wood logs (4.23 / 0.028 / 1.046)
Dict values are Table 32 fuel codes, NOT API `main_fuel` enum codes
— the API codes 1-9 collide with Table 32 codes for unrelated fuels
(e.g. API 5 = "anthracite" vs Table 32 5 = "bottled LPG main
heating"). `unit_price_p_per_kwh` / `co2_factor_kg_per_kwh` /
`primary_energy_factor` all check the Table 32 dict before falling
through to the API translation, so using Table 32 codes here avoids
the collision and routes cost/CO2/PE through the correct fuel row.
Heating-systems corpus impact — all 10 solid-fuel variants move
from `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` (assert-on-raise) back
onto the residual-pin grid in `_EXPECTATIONS`:
variant ΔSAP Δcost ΔCO2 ΔPE
solid fuel 2 +4.79 -£110 -484 kg +441 kWh anthracite
solid fuel 3 +4.43 -£102 -1206 +1452 anthracite
solid fuel 4 +4.13 -£95 -714 +1655 anthracite
solid fuel 5 +2.71 -£62 -301 +2360 house coal — smallest
solid fuel 6 -7.38 +£168 -154 +2519 dual fuel — only negative
solid fuel 7 +5.82 -£131 -758 +2968 smokeless
solid fuel 8 +4.24 -£98 -15 +2513 wood chips
solid fuel 9 +3.44 -£79 -8 +2428 wood pellets bags
solid fuel 10 +5.14 -£118 -53 +1849 wood pellets bulk
solid fuel 11 +4.35 -£100 -9 +1536 wood logs
Remaining residuals trace to heating-system efficiency / control
type — separate slices. 16 variants still in `_BLOCKED`: community
heating ×5, electric storage ×4, no system, oil non-Heating-oil ×5,
Bulk LPG ×1. Each is its own derivation slice.
Extended handover suite at HEAD post-slice: 876 pass / 0 fail (was
875 + 1 new EES wiring AAA test).
Pyright net-zero on touched files (45 → 45 — all pre-existing).
No golden fixture impact — no golden cert lodges an EES code via
the Elmhurst path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c848607718 |
Slice S0380.130: route Elmhurst oil mains via §15.0 Water Heating Fuel Type
Elmhurst Summary §14.0 Main Heating1 leaves "Fuel Type" empty for
Table 4b liquid-fuel boilers (heating oil / HVO / FAME / B30K /
bioethanol — SAP codes 120-141). Unlike gas boilers (codes 101-119)
where Elmhurst explicitly lodges "Mains gas", liquid-fuel boilers
take the fuel from §15.0 "Water Heating Fuel Type" since the same
boiler heats space + water.
Pre-slice:
- `_elmhurst_main_fuel_int(mh.fuel_type)` returned None for the
empty §14.0 fuel string.
- The electric-SAP-code inference (`_ELECTRIC_SAP_MAIN_HEATING_CODES`)
didn't fire because SAP 127 is a Table 4b oil boiler, not electric.
- `main_fuel_type` fell through to the raw empty string.
- `cert_to_inputs._main_fuel_code` returned None.
- `table_32.unit_price_p_per_kwh(None)` defaulted to mains gas
(3.48 p/kWh).
- The cascade therefore priced ~13.7k kWh/yr of oil space + water
heating at the gas tariff — a 56% under-count vs the worksheet's
Table 32 oil rate.
Two complementary fixes:
1. Add "Heating oil" → 28 ("oil (not community)" per epc_codes.csv
row main_fuel,28) to `_ELMHURST_MAIN_FUEL_TO_SAP10`. The existing
`API_FUEL_TO_TABLE_32` then routes API 28 → Table 32 code 4
(heating oil — 7.64 p/kWh / 0.298 kg CO2/kWh / 1.180 PE factor
per RdSAP 10 spec p.95). This fix handles pcdb 1 directly because
pcdb 1 lodges §14.0 "Fuel Type: Heating oil" explicitly.
2. Thread a §15.0-fuel fallback for the main_fuel inference: when
`mh.fuel_type` is empty AND `mh.main_heating_sap_code` is in the
Table 4b liquid-fuel range (120-141 per SAP 10.2 Table 4b
"Seasonal efficiency for gas and liquid fuel boilers"), use the
§15.0 water_heating_fuel as the main fuel too. Gated on the SAP
code range so this can't accidentally fire on solid-fuel-mains
+ electric-HW certs (where §15.0 lodges "Electricity" for the
immersion but the SH fuel is the solid fuel implicit in the SAP
code). This fix handles oil 1 + oil pcdb 1/2/3 (where §14.0 is
silent but §15.0 lodges "Heating oil").
Residual shifts at HEAD post-slice (5 variants legitimately re-pinned):
oil 1 +13.67 SAP → -9.70 SAP (cascade now over-counts at the
spec's 7.64 p/kWh — vs worksheet's 5.44)
oil pcdb 1/2 +11.17 → -11.63
oil pcdb 3 +11.87 → -10.87
pcdb 1 +21.90 → -9.41
Remaining negative residuals are the price-spec-vs-worksheet gap
queued for slice S0380.131 (5.44 vs 7.64 p/kWh oil). The mapper now
correctly identifies the fuel; what's left is the cascade tariff.
The other 36 corpus variants are unchanged — restricting the §15.0
fallback to SAP 120-141 keeps solid-fuel-mains and electric-mains
certs at their existing pins.
Extended handover suite at HEAD post-slice: **874 pass, 0 fail**
(was 873 + 1 new AAA test).
Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
729ee29c84 |
Slice S0380.128: extractor §14.0 closure falls back to "14.1 Community Heating"
Elmhurst Summary §14.0 Main Heating1 normally closes at "14.1 Main
Heating2", but community-heated dwellings and "no system" certs lodge
§14.0 followed directly by "14.1 Community Heating/Heat Network" (no
second main system exists on a community-heated dwelling). Pre-slice
the extractor's `_between("14.0 Main Heating1", "14.1 Main Heating2")`
returned an empty string for these shapes — every §14.0 field
(including `Main Heating SAP Code`) came back None, then the mapper
strict-raised `UnmappedElmhurstLabel` with "§14.0 Main Heating1 has
neither PCDF boiler reference (None) nor SAP code (None)".
The fix adds a `_section_lines_first_end(start, ends)` helper that
accepts a tuple of end-marker candidates and uses whichever appears
first after `start`. `_extract_main_heating` now closes §14.0 at
either "14.1 Main Heating2" or "14.1 Community Heating" — whichever
Summary lodges.
Impact on heating-systems corpus 001431 at `sap worksheets/heating
systems examples/`:
Variant Pre-S0380.128 -> Post-S0380.128
------------------------ ------------------ -----------------
community heating 1 mapper-raise -> SAP code 301 OK
community heating 2 mapper-raise -> SAP code 302 OK
community heating 3 mapper-raise -> SAP code 304 OK
community heating 4 mapper-raise -> SAP code 302 OK
community heating 6 mapper-raise -> SAP code 302 OK
no system mapper-raise -> SAP code 699 OK
Corpus tally: **35/41 -> 41/41 cascade-OK**. With all populated
variants now executing, the cascade-vs-worksheet residual cluster is
fully visible for the first time. Notably community heating 6 surfaces
the FIRST negative ΔSAP in the corpus (-6.87 — cascade undershooting
the worksheet rather than overshooting), a distinct diagnostic shape
worth investigating next.
The fix is structural (extractor section bracketing) — no spec rule
to cite. RdSAP 10 §17 page 85 row 1.0 ("Main Heating") + §17 row
10-1a ("Community Heat Source") confirm that community-heated certs
have only one main heating system (no Main 2 block).
Extended handover suite at HEAD post-slice: **832 pass, 0 fail**
(was 831 + 1 new AAA test).
Pyright net-zero on touched files (13 → 13 — pre-existing errors
unrelated).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
11ecac94dc |
Slice S0380.127: resolve Elmhurst "No Access" cylinder via RdSAP 10 Table 28
Elmhurst Summary §15.1 sometimes lodges "Cylinder Size: No Access" (the
inaccessible-cylinder lodging form). Pre-slice the mapper strict-raised
`UnmappedElmhurstLabel` because `_ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10`
only carried the three lodged-size labels (Normal/Medium/Large).
Per RdSAP 10 Specification Table 28 page 55 ("Cylinder size"):
> "Inaccessible:
> - if off-peak electric dual immersion: 210 litres
> - if from solid fuel boiler: 160 litres
> - otherwise: 110 litres"
And per §10.5.1 page 53:
> "An electric immersion is assumed dual in the following cases:
> - cylinder is inaccessible and electricity tariff is dual"
So the 210-L "off-peak electric dual immersion" branch fires automatically
when both (a) cylinder is inaccessible AND (b) water heating is electric
AND (c) meter type is dual / off-peak (no separate dual-immersion lodging
required).
New helper `_resolve_elmhurst_inaccessible_cylinder_size` keys off
§15.0 "Water Heating Fuel Type" + §14.2 "Electricity meter type":
- solid fuel water heating fuel (Anthracite, House coal, Wood, etc.)
→ 160 L → SAP10 cylinder_size enum 3 (Medium)
- "Electricity" + dual/18-hour/24-hour/off-peak meter
→ 210 L → SAP10 cylinder_size enum 4 (Large)
- otherwise → 110 L → SAP10 cylinder_size enum 2 (Normal)
`_elmhurst_cylinder_size_code` extended with optional water_heating_fuel
+ meter_type kwargs; the single call site at line 4459 threads
`survey.water_heating.water_heating_fuel_type` and
`survey.meters.electricity_meter_type`.
Property 001431 (the heating-systems corpus dwelling) lodges `pcdb 1`
with §14.0 Potterton oil boiler (PCDF 716) + §15.0 "Water Heating Fuel
Type: Heating oil" + §14.2 "Electricity meter type: 18 Hour" — water
fuel is oil (not electric, not solid fuel) → "otherwise" branch → 110 L
→ enum 2 (Normal). `pcdb 1` now cascade-executes (corpus tally 34 → 35
OK / 41 populated).
Extended handover suite at HEAD post-slice: **831 pass, 0 fail**
(was 830 + 1 new AAA test).
Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e25aa02109 |
Slice S0380.126: resolve Elmhurst bare "Underfloor Heating" via RdSAP 10 §10.11
Elmhurst Summary §14.0 Main Heating1 sometimes lodges the bare form
"Heat Emitter: Underfloor Heating" without a subtype qualifier (in
screed / timber floor). The mapper's `_ELMHURST_HEAT_EMITTER_TO_SAP10`
dict only carried the qualified forms, so the bare lodging fell through
to None and was passed as a raw string into `MainHeatingDetail.heat_
emitter_type` — causing `_responsiveness` to strict-raise
`UnmappedSapCode` on every cert with this lodging (2 variants on the
heating-systems corpus: `electric 1` + `oil 6`).
Per RdSAP 10 Specification §10.11 Table 29 page 56 ("Heating and hot
water parameters"):
> "Underfloor heating: If dwelling has a ground floor, then according
> to the floor construction (see Table 19 if unknown):
> - solid, main property age band A to E: concrete slab
> - solid, main property age band F to M: in screed
> - suspended timber: in timber floor
> - suspended, not timber: in screed
> Otherwise (i.e. upper floor flats), take floor as suspended"
New helper `_resolve_elmhurst_underfloor_subtype` keys off the main BP's
`floor.floor_type` + `construction_age_band` and returns:
- SAP10.2 Table 4d emitter code 2 (in screed) → R=0.75 — for
solid + age F-M, suspended-not-timber, and upper-floor-flat cases
- SAP10.2 Table 4d emitter code 3 (timber floor) → R=1.0 — for
suspended-timber
The solid + age A-E "concrete slab" branch (R=0.25) has no cert-side
enum entry yet, so the helper strict-raises `UnmappedElmhurstLabel`
when that combination lands — the next variant lodging an A-E solid
underfloor will surface the gap loudly per
[[reference-unmapped-sap-code]].
Property 001431 (the heating-systems corpus dwelling) lodges §9.0
"Type: S Solid" + §3.0 "Date Built: G 1983-1990" (age band G ∈ F-M)
→ "in screed" → code 2 → R=0.75. Both `electric 1` and `oil 6` now
cascade-execute (corpus tally 32 → 34 OK / 41 populated).
Extended handover suite at HEAD post-slice: **830 pass, 0 fail**
(was 829 + 1 new AAA test).
Pyright net-zero on touched files (45 → 45 — pre-existing errors
unrelated).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
f2e8b657ce |
Slice S0380.116: A_RR_shell rounded to 2 d.p. per RdSAP 10 §15 (p.66)
RdSAP 10 Specification §15 "Rounding of data" (PDF p.66):
"For consistency of application, after expanding the RdSAP data into
SAP data using the rules in this Appendix, the data are rounded
before being passed to the SAP calculator. The rounding rules are:
U-values: 2 d.p.
All element areas (gross) including window areas and conservatory
wall area: 2 d.p."
The §3.9.1 / §3.10.1 shell formula A_RR_shell = 12.5 × √(A_RR_floor /
1.5) produces a gross element area for the room-in-roof. Pre-slice the
cascade kept the raw float (e.g. cert 000565 BP[0]: 12.5 × √30 =
68.46532...), then subtracted lodged wall surfaces to obtain the (30)
residual roof area. The worksheet rounds A_RR_shell to 2 d.p. (68.47)
BEFORE the subtraction — per §15 above.
Cert 000565 has three BPs that fire this path (Main, Ext1, Ext3 — all
have detailed wall surfaces with no `slope` / `flat_ceiling` /
`stud_wall` lodgement, so §3.10.1 residual fires). Each contributes a
sub-rounding residual that the unrounded cascade was missing:
BP[0] Main: 68.4653 → 68.47; residual 43.9653 → 43.97 (+0.0016 W/K)
BP[1] Ext1: 59.5119 → 59.51; residual 18.2519 → 18.25 (−0.0007 W/K)
BP[3] Ext3: 57.7350 → 57.74; residual 17.3450 → 17.35 (+0.0017 W/K)
Movement (HEAD `d0268a5b` → this slice) for cert 000565:
roof_w_per_k 51.3768 → 51.3795 ✓ EXACT (Δ −0.0027 → 0.0)
thermal_bridging 128.6448 → 128.6460 ✓ EXACT (Δ −0.0012 → 0.0)
total_external_a 857.6323 → 857.6400 ✓ EXACT (Δ −0.0077 → 0.0)
space_heating_kwh 59008.2363 → 59008.3499 ✓ EXACT (Δ −0.1136 → 0.0)
main_fuel_kwh 34710.7272 → 34710.7941 ✓ EXACT (Δ −0.0669 → 0.0)
total_fuel_cost 4680.2515 → 4680.2593 ✓ EXACT (Δ −0.0078 → 0.0)
co2_kg_per_yr 6447.6161 → 6447.6263 ✓ EXACT (Δ −0.0102 → 0.0)
sap_score_cont 28.5087 → 28.5087 ✓ EXACT (Δ +4.2e-5 → −4.7e-5)
sap_score (int) 29 ✓ EXACT (preserved)
ecf 5.38682 → 5.38683 (vs ws 5.3868, Δ +3.2e-5)
Cert 000565 truly closes — every SAP-result field within 1e-4 of the
worksheet PDF.
Cohort safety: 6 cohort certs (000474..000516) unchanged — cohort
000516's roof routes through the Detailed branch with `slope` /
`flat_ceiling` / `stud_wall` lodgements, so `has_roof_lodgement=True`
short-circuits the §3.10.1 residual block. Cohort certs 000474/477/
480/487/490 are pre-S0380.103 hand-built fixtures whose RR fields don't
exercise the simplified A_RR_shell path (rir.floor_area=0 or
detailed_surfaces only).
Test added: `test_summary_000565_a_rr_shell_rounded_2_dp_closes_roof_
w_per_k_per_rdsap_10_section_15` pins the cascade roof_w_per_k = 51.3795
exactly (Δ ≤ 1e-4 vs worksheet (30) Σ).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
cc70e55917 |
Slice S0380.114: pump gain via Table 5a Note a) (SAP 10.2 p.177)
SAP 10.2 Table 5a (PDF p.177) verbatim:
"Central heating pump in heated space, 2013 or later: 3 W"
Note a): "Where there are two main heating systems serving
different parts of the dwelling, assume each has its own
circulation pump and therefore include two figures from this
table. ... Set to zero in summer months. **Not applicable for
electric heat pumps from database.** Where two main systems serve
the same space a single pump is assumed."
The Note a) "not applicable for electric heat pumps" rule zeros the
pump GAIN only for HP-category systems themselves. Where a cert
lodges a non-HP main system alongside an HP, the non-HP system's
circulation pump still operates and dissipates 3/7/10 W into the
dwelling as an internal gain.
Pre-slice the cascade conflated TWO different spec rules:
Table 4f (ELECTRICITY) — HP pump electricity is in the COP, so
worksheet line 230b = 0 for HP certs.
Table 5a (GAIN) — HP-from-database pump gain is omitted
ONLY for that HP system, not for any
non-HP system in the same cert.
`_main_heating_category_from_cert(epc)` returned `details[0].
main_heating_category` and the caller zeroed pump_w whenever that
was category 4. This dropped the 3 W gain for any cert whose first
main system was an HP — even when system 2 was a non-HP boiler with
its own pump.
Cert 000565 lodges TWO main systems:
[0] HP (category 4) pump_age "2013 or later"
[1] Gas boiler (category 2) pump_age None
Per spec the system [1] gas boiler's pump contributes 3 W (post-2013
date from [0]'s lodgement). Worksheet (70) confirms:
Pumps, fans 3.0 3.0 3.0 3.0 3.0 0.0 0.0 0.0 0.0 3.0 3.0 3.0 (70)
Pre-slice cascade returned 0 every month, missing 24 W·months of
winter internal gains. Downstream: +10 kWh space heating, +£0.71
fuel cost, +0.90 kg CO2, -0.008 continuous SAP.
Cert 0380 (cohort-1 ASHP, HP-only):
[0] HP (category 4) pump_age unknown
(no [1])
Worksheet (70) = 0 every month. Cascade post-slice: every main
system is HP → pump_w = 0 ✓ unchanged.
Fix:
`domain/sap10_calculator/worksheet/internal_gains.py`:
- Replace `_main_heating_category_from_cert` + the {4} set-membership
check with `_all_main_systems_are_heat_pumps(epc)`. Returns True
iff every lodged `main_heating_details[i].main_heating_category`
equals 4. Pump gain is zeroed only in that case.
- Existing `_pump_date_category_from_cert` (reads [0]'s pump_age)
unchanged — Elmhurst lodges the dwelling's pump_age on detail[0]
regardless of which system the pump serves.
Cohort safety: all 6 cohort certs have a single main system (gas
boiler, category 2) → `all_main_systems_are_heat_pumps` returns
False → pump_w applies, same as the prior `else` branch. Cert 0380
(ASHP) has a single HP main → True → pump_w = 0, unchanged.
Cert 000565 cascade snapshot (HEAD
|
||
|
|
59de805e63 |
Slice S0380.113: H=0 gable lodgement deducts per RdSAP 10 §3.9.2 step (b)
RdSAP 10 §3.9.2 step (b) (PDF p.23) verbatim:
"Software calculates the area of each gable or adjacent wall by
using the equation:
A_RR_gable = L_gable × (0.25 + H_gable) − [(H_gable − H_common_1)² / 2
+ (H_gable − H_common_2)² / 2]"
Step (d):
A_RR_final = A_RR_wall − (Σ A_common + Σ A_gable + Σ A_party
+ Σ A_sheltered + Σ A_connected)
The spec equation is signed and applies for all L > 0 — including
H_gable = 0. When the gable is shorter than the common walls the
correction term `(H_gable − H_common)² / 2` exceeds the
L × (0.25 + H_gable) term, producing a negative A_RR_gable.
Elmhurst's worksheet evaluates the equation literally; the negative
value adjusts A_RR_final upward via step (d) without billing a
physical wall area.
Cert 000565 §8.1 lodges Ext3's RR (Simplified Type 2) with an
absent Gable Wall 2:
Gable Wall 1 L=9.00 H=7.00 Exposed U=0.45
Gable Wall 2 L=4.00 H=0.00 U=0.00 ← lodged but H=0
Common Wall 1 L=5.00 H=1.50 U=0.45
Common Wall 2 L=7.50 H=0.30 U=0.45
Spec equation for Gable Wall 2:
A_gable_2 = 4 × (0.25 + 0) − (0 − 1.5)²/2 − (0 − 0.30)²/2
= 1.0 − 1.125 − 0.045 = −0.17 m²
Worksheet (30) Ext3 residual = 17.35 m² back-solves exactly:
A_RR_shell = 12.5 × √(32.0 / 1.5) = 57.7350
Σ walls (incl. -0.17 absent gable) = 40.3850
residual = shell − walls = 17.3500 ✓ 4 d.p.
Pre-slice the mapper had two clamps that together dropped the
spec-computed −0.17 m² adjustment:
mapper.py:3350 `if length_m <= 0 or height_m <= 0: return None`
→ filtered out any H=0 surface
mapper.py:3443 `area_m2 = max(0.0, length_m * (0.25 + H) − correction)`
→ clamped negative gable areas at 0
Combined the cascade computed residual = 17.18 m² (cascade UNDER
by 0.17). Plus a related secondary `if height_m > h` filter on the
correction sum that masked the all-common-walls-taller case.
3-layer fix:
1. `datatypes/epc/domain/mapper.py` `_map_elmhurst_rir_surface`:
- Split the early-return filter: drop only when L<=0 (no wall),
OR when H<=0 AND not (Simplified Type 2 with common walls).
- Apply the spec gable-area formula to BOTH `gable_wall` (party
default) and `gable_wall_external` kinds in Simplified Type 2
(the U-value routing differs by kind, but the area equation
is the same).
- Remove `max(0.0, ...)` clamp so the signed result reaches the
cascade.
- Remove `if height_m > h` correction-sum filter (spec applies
the full square unconditionally).
2. `domain/sap10_calculator/worksheet/heat_transmission.py` per-
surface loop:
- `gable_wall` branch: skip `party += 0.25 × area` when area < 0
(wall doesn't exist physically) but still add the signed area
to `rr_walls_in_a_rr_area` so the residual deduction in step (d)
grows by |area|.
- `gable_wall_external` branch: same skip pattern for `walls +=
u × area` and `rr_detailed_area += area`.
Cohort safety: only cert 000565 Ext3 hits this in the corpus. All
other cohort certs are Type 1 RR (no common walls, formula gives
the same answer) or have all gables H > 0. The cascade's per-element
test pins (Ext1's Connected gable + Exposed gable, Ext4's Detailed
RR) unchanged.
Cert 000565 cascade snapshot (HEAD
|
||
|
|
a461b70d19 |
Slice S0380.112: per-BP rooflight allocation (RdSAP 10 §3.7 p.19)
RdSAP 10 §3.7 (PDF p.19) verbatim:
"for each building part, software will deduct window/door areas
contained in the relevant wall areas"
The same per-BP deduction applies to roof windows / rooflights
piercing each BP's roof. Pre-slice the cascade lumped every
rooflight's area onto BP[0] Main's `rw_area_part` (S0380.106-era
convention), leaving the actual host BP's gross roof un-deducted.
Cert 000565 §11 Openings lodges:
Roof Windows 1(Ext2) External roof Ext2, 1.20 m²
Roof Windows 2(Ext4) External roof Ext4, 0.50 m²
Worksheet (30) ground truth — each rooflight deducts from its
host BP's gross roof:
Ext2: 25.00 − 1.20 = 23.80 net × 0.30 = 7.1400 W/K
Ext4: 3.00 − 0.50 = 2.50 net × 0.00 = 0.0000 W/K
Pre-slice cascade:
Ext2: 25.00 (un-deducted) × 0.30 = 7.5000 (+0.36 W/K over)
Plus 1.70 m² of RW area lumped onto Main's external aggregate
→ +1.20 m² double-count (Ext2 gross + Main rw_area_part)
3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `window_location:
Union[int, str] = 0` to SapRoofWindow (mirror of
`SapWindow.window_location` shape).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
thread `w.building_part` through (mirror of
`_map_elmhurst_window`'s pass-through).
3. `domain/sap10_calculator/worksheet/heat_transmission.py`: pre-loop
compute `rw_area_by_bp[i]` from each `SapRoofWindow.window_location`
via the existing `_window_bp_index` resolver; per-BP loop reads
`rw_area_by_bp[i]` instead of allocating everything to BP[0].
Cohort safety: cert 000516's lone rooflight is on the Main BP
(Summary §11 row "Main, External wall"), so the per-BP allocation
returns Main = 0 = same as the prior lump-on-Main convention. The
000516 hand-built fixture's SapRoofWindow now sets
`window_location="Main"` to mirror the Elmhurst mapper string-form.
Cert 000565 cascade snapshot (HEAD
|
||
|
|
794ef7ed8b |
Slice S0380.111: roof-window inclination adj via Table 6e Note 2 (SAP 10.2 p.180)
SAP 10.2 §3.2 "Roof windows" (PDF p.10) verbatim:
"In the case of roof windows, unless the measurement or calculation
has been done for the actual inclination of the roof window,
adjustments as given in Notes 1 and 2 to Table 6e or from BR443
(2019) should be applied."
SAP 10.2 Table 6e Note 2 (PDF p.180) — "For roof windows the
following adjustments should be applied to convert a known vertical
U-value into the U-value for the known inclined position":
Inclination Twin skin or DG Triple skin or TG
70° or more (vertical) +0.0 +0.0
< 70° and > 60° +0.2 +0.1
60° and > 40° +0.3 +0.2
40° and > 30° +0.4 +0.2
30° or less (horizontal) +0.5 +0.3
SAP 10.2 §3.2 formula (2):
U_w,effective = 1 / (1/U_w + 0.04) (2)
The +0.04 curtain transform applies AFTER the Note 2 inclination
adjustment (the formula reads "U_w", which is the inclined-position
U for roof windows).
Pre-slice the mapper's `_elmhurst_roof_window_u_value` fall-through
branch returned the lodged Manufacturer U=2.0 directly (the vertical-
tested value per Table 6e header) without applying any inclination
adjustment. The cascade then applied formula (2) → U_eff = 1/(1/2.0 +
0.04) = 1.852 for both cert 000565 rooflights, totalling 1.7 × 1.852
= 3.1484 W/K vs the worksheet's (27a) Σ A × 2.1062 = 3.5806 W/K
(residual -0.43 W/K).
Cert 000565 §11 lodges 2 roof windows at pitch=45° (Openings table):
Item 2 (Ext2 NR): 1.2 m², "Triple between 2002 and 2021",
Manufacturer U=2.0, g=0.72, PVC FF=0.70
Item 5 (Ext4 A): 0.5 m², "Double between 2002 and 2021",
Manufacturer U=2.0, g=0.72, Wood FF=0.70
Both lodge at pitch=45° → Note 2 "60° and > 40°" row. The worksheet
applies +0.30 W/m²K uniformly to both (DG-column value), yielding
U_inclined = 2.30 → formula (2) → U_eff = 2.1062 in both cases.
Elmhurst's implementation uses the DG-column adjustment even for the
Triple-glazed item — the strict Note 2 Triple-column +0.20
alternative would yield 2.0222 for Item 2, contradicting the
worksheet's 2.1062.
Fix scope (mapper-side, single helper):
`datatypes/epc/domain/mapper.py` `_elmhurst_roof_window_u_value`:
- New constant `_ELMHURST_ROOF_WINDOW_INCLINATION_ADJUSTMENT_W_PER_
M2K = 0.30` (Table 6e Note 2 DG @ 40-60°).
- Fall-through branch now returns `w.u_value + 0.30` instead of
`w.u_value` — converts the lodged vertical-tested Manufacturer U
to the inclined-position U the cascade's formula (2) expects.
- Lookup path (`_ELMHURST_ROOF_WINDOW_U_BY_GLAZING["Double pre 2002"]
= 3.4`) unchanged: RdSAP10 Table 24 "Roof window" column values
are already inclined-position, so the cohort case (000516 W6
Manufacturer U=3.10 → Table 24 returns 3.40 → cascade formula
(2) → 2.9930) stays bit-exact.
Cohort safety verified at 000516 worksheet (27a): U_eff = 2.9930
preserved (Table 24 lookup path unaffected).
Cert 000565 cascade snapshot (HEAD
|
||
|
|
9461e657a5 |
Slice S0380.110: per-rooflight g_L in Appendix L L2a (SAP 10.2 p.88)
SAP 10.2 Appendix L §L2a (PDF p.88) verbatim:
GL = 0.9 × Σ (Aw × gL × FF × ZL) / TFA (L2a)
where
FF is the frame factor (fraction of window that is glazed) for
the actual window or from Table 6c
Aw is the area of a window, m²
gL is the light transmittance factor from Table 6b
ZL is the light access factor from Table 6d
Table 6b gL (PDF p.178) — light transmittance column:
Single glazed 0.90
Double glazed (any variant) 0.80
Triple glazed (any variant) 0.70
Table 6d note 2 (PDF p.178): "A solar access factor of 1.0 and a light
access factor of 1.0 should be used for roof windows/rooflights."
Pre-slice `_daylight_factor_from_cert` collapsed every rooflight into
a single `rooflight_total_area_m2 × _G_LIGHT_DEFAULT (0.80) ×
_FRAME_FACTOR_DEFAULT (0.70)` product, overcounting any Triple-glazed
rooflight (gL=0.70) or any non-default frame factor.
Cert 000565 §11 lodges 2 rooflights (per S0380.107 routing):
Item 2 (Ext2 NR rooflight): 1.2 m², "Triple between 2002 and 2021",
PVC FF=0.70 → gL=0.70 (Table 6b Triple). Correct numerator
contribution 1.2 × 0.70 × 0.70 = 0.588; pre-slice cascade used
1.2 × 0.80 × 0.70 = 0.672 (+0.084 over).
Item 5 (Ext4 A rooflight): 0.5 m², "Double between 2002 and 2021",
Wood FF=0.70 → gL=0.80 (Table 6b Double). Already matched.
The +0.084 numerator delta lowered GL → lowered C_daylight → lowered
worksheet (232) by 2.17 kWh/yr.
3-layer fix:
1. `datatypes/epc/domain/epc_property_data.py`: add `glazing_type:
int = 3` to SapRoofWindow (default = Double 2002-2021, the cohort
modal).
2. `datatypes/epc/domain/mapper.py` `_map_elmhurst_roof_window`:
populate `glazing_type` via `_elmhurst_glazing_type_code(w.
glazing_type)` — mirror of `_map_elmhurst_window`.
3. `domain/sap10_calculator/worksheet/internal_gains.py`
`_daylight_factor_from_cert`: iterate `epc.sap_roof_windows` for
the rooflight g_L numerator, dispatching via existing
`_G_LIGHT_BY_GLAZING_CODE` + `rw.frame_factor`. Z_L = 1.0 per
Table 6d note 2.
Test coverage:
- AAA test `test_summary_000565_rooflight_per_window_g_l_routes_via_
glazing_type_per_sap_10_2_appendix_l_l2a` pins both per-rooflight
glazing codes (9 Triple / 3 Double) AND `inputs.lighting_kwh_per_
yr` at 1384.8353 ±1e-4.
- 000516 hand-built fixture updated to explicitly set glazing_type=2
("Double pre 2002") matching the lodged label.
Cert 000565 cascade snapshot (HEAD
|
||
|
|
efb203f7ad |
Slice S0380.109: Solid brick + insulation via §5.7 Table 13 + §5.8 Table 14 (RdSAP 10)
Closes the remaining cert 000565 BP[0] Main wall residual (-1.54 W/K
under ws) by routing solid-brick walls with documentary wall
thickness + lodged insulation through the RdSAP 10 §5.7 + §5.8
formula chain. Adds a Table-6 footnote (a) cap on the §5.6 stone
formula to handle thin uninsulated stone walls (Ext1 BP[1] Granite
W=50 mm).
RdSAP 10 §5.7 Table 13 (PDF p.41) verbatim:
"Default U-values of brick walls
Wall thickness, mm U-value, W/m²K
Up to 200 mm 2.5
200 to 280 mm 1.7
280 to 420 mm 1.4 ← cert 000565 Main W = 300 mm
More than 420 mm 1.1"
RdSAP 10 §5.8 step 2 (PDF p.41-42) verbatim:
"The U-value of the insulated wall is U = 1 / (1/U₀ + R_insulation)
...
Where R_insulation comes from Table 14: Insulation thickness and
corresponding resistance.
...
R = 0.025 × T + 0.25 when λ = 0.04 W/m·K
R = 0.0333 × T + 0.248 when λ = 0.03 W/m·K
R = 0.040 × T + 0.25 when λ = 0.025 W/m·K
Where T is thickness of insulation in mm"
Cert 000565 Main lodgement (Summary §7.0):
Type SO Solid Brick (wall_construction = 3)
Insulation E External (wall_insulation_type = 1)
Insulation Thickness 75 mm
Wall Thickness 300 mm (measured)
Conductivity Known No → λ defaults to 0.04 (§5.8 final note)
Age band A
Formula chain:
U₀ = 1.4 (§5.7 Table 13 row "280 to 420 mm")
R = 0.025 × 75 + 0.25 = 2.125 m²K/W
U = 1 / (1/1.4 + 2.125) = 1 / 2.8393 = 0.3522 → 0.35 (2 d.p.)
Pre-slice the cascade bucketed 75 mm into the Table-6 "100 mm
external/internal insulation" row → 0.32 for age A. The -0.03 U
delta on Main's 51.72 m² external wall is the entire -1.54 W/K
under-count driving the cohort's remaining fabric residual.
RdSAP 10 Table 6 footnote (a) (PDF p.34) verbatim:
"Or from equations in 5.6 if the calculated U-value is less than
1.7."
Applies only to the AS-BUILT (no insulation, no dry-line) Table 6
row. For thin walls where §5.6 gives U ≥ 1.7 the Table 6 row
default of 1.7 caps the result. Verified empirically against cert
000565 Main alt_wall_1 (granite W=120 mm dry-lined): raw §5.6 →
3.879 + dry-line → 2.34 matches worksheet, NOT capped 1.7 + dry-
line → 1.32. The cap therefore only fires when neither dry-lining
nor insulation is present (cert 000565 BP[1] Ext1: granite W=50 mm
"Insulation Unknown" → §5.6 = 6.09 → capped to 1.7, matches ws).
3-layer fix:
1. `domain/sap10_ml/rdsap_uvalues.py`:
- Add `_u_brick_thin_wall_age_a_to_e(W_mm)` per §5.7 Table 13
- Add `_r_insulation_table_14(T_mm, λ)` per §5.8 Table 14
interpolation rule (handles all 3 λ columns)
- Wire §5.7+§5.8 chain into `u_wall` for WALL_SOLID_BRICK + age
A-E + lodged thickness + (External | Internal) insulation +
thickness > 0
- Add Table 6 footnote (a) cap to `_u_stone_thin_wall_age_a_to_e`
(cap at 1.7 only when not dry-lined)
- Round dry-lined §5.6 result to 2 d.p. (worksheet A×U precision)
2. `domain/sap10_calculator/worksheet/heat_transmission.py` passes
`wall_thickness_mm=part.wall_thickness_mm` through to `u_wall`
for the per-BP main wall U (previously passed only for alt walls).
3. AAA test pins cert 000565 walls_w_per_k = 604.07 within 1e-4.
Movement at HEAD `9159e91f` → post-slice (cert 000565):
Fabric (cascade vs ws):
walls 602.53 → 604.08 (Δ -1.54 → +0.01 W/K — sub-spec
alt-wall float rounding artifact)
total W/K 935.54 → 937.09 (Δ -1.52 → +0.03 W/K — essentially
zero net fabric HTC residual)
End-result pins:
sap_score (int) 29 ✓ EXACT (unchanged)
sap_score_continuous 28.5380 → 28.5028 (Δ +0.0293 → -0.0059;
80% magnitude reduction)
ecf 5.3838 → 5.3874 (Δ -0.0028 → +0.0008)
total_fuel_cost_gbp 4677.64 → 4680.78 (Δ -2.62 → +0.52)
co2_kg_per_yr 6444.27 → 6448.34 (Δ -3.35 → +0.72)
space_heating 58974.84 → 59020.02 (Δ -33.5 → +11.7)
main_heating_fuel 34691.09 → 34717.66 (Δ -19.7 → +6.87)
lighting_kwh 1382.67 (unchanged)
pumps_fans_kwh ✓ EXACT (unchanged)
Continuous SAP magnitude improved 80% (0.0293 → 0.0059). All
SH-driven downstream residuals (cost, co2, SH kwh, main_heating
fuel) magnitude-reduced 65-80%. Integer SAP stays exact at 29.
Cohort safety verified: 6 cohort certs (000474-000516) lodge wc=4
(cavity) + wit=4 (as-built) — neither precondition for the new
§5.7+§5.8 path. §5.6 cap only fires when not dry-lined (cohort
certs don't trigger). All 11 cert→inputs and 6 sap_result_pin
cohort tests pass unchanged.
Golden cert 6035-7729-2309-0879-2296 (mid-terrace age A solid
brick) sees the §5.7+§5.8 chain fire on its Main wall:
PE +46.7562 → +46.0936 kWh/m² (cascade closer to actual EPC)
CO2 +1.0652 → +1.0495 tonnes/yr (cascade closer to actual EPC)
Per [[feedback-golden-residuals-near-zero]] the expected pin is
updated to track the improvement (target → ~0 as mapper closes).
Test count: 608 pass + 7 expected 000565 fails → **608 pass + 7
expected 000565 fails** (new §5.7+§5.8 formula test green; golden
cert 6035 pin re-pinned; integer SAP stays at 29). Pyright net-zero
per touched file (27 baseline → 27 post-change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
9159e91fbc |
Slice S0380.108: Connected-to-heated-space RR gables deduct from A_RR (RdSAP 10 §3.9.2 + Table 4 row 4)
Closes the largest single localised fabric residual on cert 000565
(roof +1.59 W/K over, area +4.70 m² over) by routing
Connected-gable surfaces through a new `connected_wall` kind that
deducts area from the residual A_RR per the spec but contributes
0 W/K per RdSAP 10 Table 4 row 4.
RdSAP 10 §3.9.2 step (d) (PDF p.23) verbatim:
"The areas of gable walls are deducted from the calculated total
RR area, and the remaining area of RR, ARR_final is then
calculated. This area is treated as roof structure.
ARR_final = ARR_wall − (ΣARR_common_wall + ΣARR_gable +
ΣARR_party + ΣARR_sheltered +
ΣARR_connected)"
RdSAP 10 Table 4 row 4 (PDF p.22):
"ARR_connected — Adjacent to heated space — U-value = 0"
The U=0 means no heat-loss contribution, but the area STILL appears
in the deduction equation as ΣARR_connected. Pre-slice the mapper's
`_map_elmhurst_rir_surface` returned None for Connected gables,
dropping them entirely from `detailed_surfaces` so the cascade
neither billed them nor deducted them. The residual A_RR was
therefore over by their lodged area.
Cert 000565 Ext1 §8.1 lodges (Simplified Type 2):
Gable Wall 1 L=4.00 H=6.00 Connected U=0
Gable Wall 2 L=8.00 H=9.00 Exposed U=1.70
Common Wall 1 L=9.00 H=1.00 U=1.70
Common Wall 2 L=5.00 H=1.80 U=1.70
Gable Wall 1 area via §3.9.2 quadratic:
A_gable_1 = 4 × (0.25 + 6)
− (6 − 1)²/2 ← subtract triangle above Common Wall 1
− (6 − 1.8)²/2 ← subtract triangle above Common Wall 2
= 25.0 − 12.5 − 8.82
= 3.68 m²
Pre-slice:
A_RR shell = 12.5 × √(34 / 1.5) = 59.51 m²
Σ wall areas = 11.25 + 10.25 + 16.08 = 37.58 m²
Residual = 21.93 m² (worksheet: 18.25; over by +3.68)
Roof W/K = 21.93 × 0.35 = 7.68 (worksheet: 6.39; over by +1.29)
3-layer fix:
1. Mapper `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py)
now routes "Connected" gable_type to kind="connected_wall" with
u_value=0 and area via the Simplified Type 2 quadratic correction.
2. Heat transmission `heat_transmission_from_cert` (domain/sap10_
calculator/worksheet/heat_transmission.py) adds a connected_wall
branch that deducts area from rr_walls_in_a_rr_area but skips
walls/party W/K contribution.
3. AAA test pins Ext1 Connected gable area at 3.68 m² and U=0.
Movement at HEAD `b7fa5f74` → post-slice (cert 000565):
Fabric (cascade vs ws):
walls 602.53 → 602.53 (Δ -1.54 W/K; unchanged)
roof 52.97 → 51.68 (Δ +1.59 → +0.30 W/K; closes 81%)
TB 129.35 → 128.80 (Δ +0.70 → +0.15 W/K; closes 79%)
total area 862.34 → 858.66 (Δ +4.70 → +1.02 m²; closes 78%)
total W/K 937.40 → 935.54 (Δ +0.33 → -1.52 W/K; sign flips)
End-result pins:
**sap_score (int) 28 → 29 ✓ EXACT vs ws 29** (RECOVERED from
S0380.107 transient
rounding flip)
sap_score_continuous 28.4959 → 28.5380 (Δ -0.0128 → +0.0293)
ecf 5.3881 → 5.3838 (Δ +0.0015 → -0.0028)
total_fuel_cost_gbp 4681.39 → 4677.64 (Δ +1.13 → -2.62)
co2_kg_per_yr 6449.13 → 6444.27 (Δ +1.51 → -3.35)
space_heating_kwh 59028.80 → 58974.84 (Δ +20.5 → -33.5)
main_heating_fuel 34722.83 → 34691.09 (Δ +12.0 → -19.7)
lighting_kwh 1382.67 → 1382.67 (unchanged)
pumps_fans_kwh ✓ EXACT (unchanged)
Continuous SAP and downstream pins SIGN-FLIPPED again
(cascade was over post-.107, now under post-.108). Per user
direction: transient drift acceptable while closing a true
intermediate-value bug. The remaining net HTC -1.52 W/K is
mostly walls (-1.54 W/K) — closing the Detailed-RR walls
residual is the next leverage front.
Cohort safety: none of the 6 cohort certs lodge a Connected
gable (grep audit across all Summary fixtures). The new
`connected_wall` branch only fires for the cert 000565 Ext1 BP.
Test count: 606 pass + 8 expected 000565 fails → **608 pass +
7 expected 000565 fails** (sap_score back to exact + new
Connected-gable test green). Pyright net-zero per touched
file (57 baseline → 57 post-change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
b7fa5f74ec |
Slice S0380.107: window vs roof window routing via BP roof type (RdSAP 10 §3.7.1)
Replaces the U > 3.0 W/m²K heuristic with a 3-rule cascade
discriminator that uses the BP's lodged §8 roof type alongside the
glazing type. Closes cert 000565 windows misrouting where the
previous heuristic mis-classified 3 of 6 windows.
RdSAP 10 §3.7.1 (PDF p.21) verbatim:
"Window data
Window area is assessed by measuring all windows and roof windows
throughout the dwelling. ...
Additional information to be noted: ...
• window or roof window;
• orientation"
RdSAP 10 §8.2 (PDF p.50) verbatim (Glazed walls + glazed roof):
"Glazed walls are taken as windows, glazed roof as rooflight, see
window U-values in Table 24"
The source RdSAP data set carries the "Window (vertical) / Roof
window (inclined)" classification as a discrete assessor lodgement.
The Elmhurst Summary PDF §11.0 flattens that signal — every row's
Location column reads "External wall" regardless of physical
position. The mapper must therefore reconstruct the classification.
New heuristic, in priority order:
1. "Single glazing" → never a rooflight. Approved Document L
(2006+) disallows single-glazed rooflights on energy-efficiency
grounds; SAP convention assumes Table 6c double-glazing minimum
for any (27a) entry.
2. BP roof type ∈ {"A Another dwelling above", "NR Non-residential
space above"} → rooflight. These BPs have their own structural
external roof distinct from a pitched dwelling roof — the
worksheet (30) External roof + (27a) Roof Windows treatment
follows this routing.
3. U > 3.0 W/m²K → rooflight (cohort backstop, catches cohort cert
000516 W6 Wood-frame Double pre-2002 U=3.10 on Main PA, the
only U > 3 vertical-glazing reading the cohort lodges that the
worksheet routes via (27a)).
4. Otherwise vertical.
Cohort verification: all 6 cohort certs have BPs with only PA/PN
pitched roof types (no NR/A). Rule 2 doesn't fire on cohort certs;
rule 1 doesn't block any cohort rooflights (all cohort high-U
windows are Double glazed). Rule 3 catches cohort 000516 W6
unchanged. No cohort regressions on cert→inputs cascade pins.
Cert 000565 routing fix (Summary §11.0 6-window list):
- Items 1, 6 (Main, Double, U=2.0) — vertical (unchanged)
- Item 3 (Ext1, Double, U=1.74) — vertical (unchanged; Ext1 roof
"S Same dwelling above" doesn't fire rule 2)
- Item 4 (Main, Single, U=3.35) — vertical (rule 1; was wrongly
classified as rooflight by U > 3 backstop)
- Item 2 (Ext2 NR, Triple, U=2.0) — rooflight (rule 2)
- Item 5 (Ext4 A, Double, U=2.0) — rooflight (rule 2)
Movement at HEAD `8effa2d0` → post-slice (cert 000565):
Fabric (cascade vs ws):
walls 601.22 → 602.53 (Δ -2.85 → -1.54 W/K; closes 46%)
windows 9.60 → 11.48 (Δ -1.87 → 0.00 W/K; ✓ EXACT vs ws)
roof_windows 5.02 → 3.15 (Δ +1.44 → -0.43 W/K; cascade U
formula gap exposed, see TODO below)
net fabric HTC Δ -0.99 → +0.33 W/K (magnitude improved 67%)
End-result pins:
sap_score_continuous 28.5269 → 28.4959 (Δ +0.0182 → -0.0128;
magnitude improved 30%)
ecf 5.3850 → 5.3881 (Δ -0.0016 → +0.0015)
total_fuel_cost_gbp 4678.64 → 4681.39 (Δ -1.62 → +1.13)
co2_kg_per_yr 6445.51 → 6449.13 (Δ -2.12 → +1.51)
space_heating_kwh 58980.82 → 59028.80 (Δ -27.5 → +20.5)
main_heating_fuel 34694.60 → 34722.83 (Δ -16.2 → +12.0)
lighting_kwh 1387.02 → 1382.67 (Δ +2.19 → -2.17, sign
flips: cascade DF now uses
correct rooflight area;
remaining gap is the
rooflight g×FF default-vs-
lodged drift, separate
slice)
pumps_fans_kwh ✓ EXACT (unchanged)
**Transient sap_score (integer) regression**: continuous SAP crossed
the 28.5 rounding boundary downward (28.5269 → 28.4959), so the
integer rounds to 28 instead of 29. This is a rounding artifact —
the continuous metric IS closer to ws (Δ magnitude 0.0182 → 0.0128).
Per user direction (NEXT_AGENT_PROMPT): primary metric is continuous,
transient drift OK while closing a true intermediate-value bug.
The integer pin returns to 29 once continuous SAP closes above the
ws value 28.5087.
S0380.103 cost test reframed: previously asserted total_fuel_cost
delta < +£0.05 over ws — a snapshot threshold that the SH-cascade
sign flip naturally breaks. The MEV cost split rate (12.4467
p/kWh kWh-weighted blend) is what S0380.103 specifically closes;
the test now pins that rate directly via `inputs.pumps_fans_
fuel_cost_gbp_per_kwh`, decoupled from downstream SH cascade
effects.
3-layer fix:
1. Mapper `_is_elmhurst_roof_window` predicate now takes the survey
for BP roof type lookup; new `_elmhurst_bp_roof_type` helper.
2. Two call sites at lines 327, 331 pass `survey` through.
3. New AAA test `test_summary_000565_window_routing_uses_bp_roof_
type_per_rdsap_10_section_3_7_1` pins the 4-vertical + 2-roof
classification.
Test count: 605 pass + 7 expected 000565 fails → **606 pass + 8
000565 fails** (new window-routing test + S0380.103 test reframe
both GREEN; sap_score added to work queue as a rounding-boundary
artifact). Pyright net-zero per touched file (45 baseline →
45 post-change).
Open work (in decreasing leverage on continuous SAP):
- Roof BP[1] Ext1 RR area formula refinement (+1.59 W/K over,
deferred to a separate slice per the original handover)
- Walls -1.54 W/K residual (Detailed-RR per-element investigation)
- Roof window U formula gap (-0.43 W/K; cascade formula 1/(1/U +
0.04) gives 1.852 for U_raw=2.0 but ws shows 2.1062)
- Lighting rooflight g×FF default-vs-lodged drift (-2.17 kWh)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
8effa2d00d |
Slice S0380.106: MEV fans PE split via Table 12a Grid 2 + Table 12e (SAP 10.2 §10a / §10c)
PE-side mirror of S0380.103 (cost) + S0380.105 (CO2). Completes the
MEV cascade trifecta for off-peak tariff certs. Cert 000565
worksheet line (281):
Pumps, fans and electric keep-hot 252.5159 1.5239 383.3796 (281)
The displayed factor (1.5239) is the ALL_OTHER_USES Table 12e Σ
days-weighted blend; the displayed product (383.3796) is the kWh-
weighted blend across the two Grid 2 categories:
F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 1.51268 kWh/kWh
F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 1.52391 kWh/kWh
F_eff = (127.5159 × 1.51268 + 125.0 × 1.52391) / 252.5159
= 1.51824 kWh/kWh
PE = 252.5159 × 1.51824 = 383.3796 kWh/yr ✓
Pre-slice the cascade applied 1.52391 to ALL 252.5159 kWh →
384.81 → +1.43 over ws.
SAP 10.2 Table 12a Grid 2 (PDF p.191) — same dispatch as Slice
S0380.105 — splits the off-peak high-rate fraction by end-use
between `FANS_FOR_MECH_VENT` and `ALL_OTHER_USES`.
SAP 10.2 Table 12e (PDF p.195) verbatim header:
"Where electricity is the fuel used, the relevant set of factors
in the table below should be used to calculate the monthly
primary energy instead the annual average factor given in
Table 12."
The Grid 2 high-rate fraction blends Table 12e high-rate × low-
rate codes per `F_blended = high_frac × F_high + (1 − high_frac)
× F_low`. MEV fans bill at the lower 0.58 high_frac → lower PE
factor on the higher-PE high-rate code 34. Identical structural
fix as the .105 CO2 slice; the only delta is the underlying Table
12 column.
2-layer fix:
1. New helper `_pumps_fans_primary_factor` in cert_to_inputs.py
— mirror of `_pumps_fans_co2_factor_kg_per_kwh`. Returns kWh-
weighted blend of FANS_FOR_MECH_VENT + ALL_OTHER_USES factors.
Falls back to ALL_OTHER_USES rate on STANDARD / no-MEV certs.
2. Call site at line 4640 wires `mev_kwh_for_cost_split` +
`pumps_fans_kwh` through the helper.
Movement at HEAD `8a3aaf7a` → post-slice (cert 000565):
| Pin | Pre | Post |
|--------------------------------|-----------:|-----------:|
| pumps_fans_primary_factor | 1.52391 | 1.51824 |
| pumps_fans_pe_kwh_per_yr | 384.8122 | 383.3797 | ✓ EXACT vs ws (281)
| primary_energy_kwh_per_yr | 62228.4896 | 62227.0570 |
| primary_energy_kwh_per_m2 | 194.5187 | 194.5143 |
No effect on sap_score_continuous (ECF is cost-based, not PE-based),
ecf, or any of the 7 currently-failing 000565 pins. The total PE
residual remains dominated by an unrelated SH cascade PE factor
gap (cascade 170 kWh/m² vs ws 135.6 — separate slice).
Cohort safety: STANDARD-tariff and no-MEV certs return the existing
ALL_OTHER_USES rate (helper falls through). No-MEV certs return
the same rate (mev_kwh_per_yr=0 short-circuit). Pyright net-zero
per touched file (45 baseline → 45 post-change).
Test count: 605 pass + 7 expected 000565 fails → **606 pass + 7
expected 000565 fails** (new
test_summary_000565_mev_fans_pe_factor_uses_table_12a_grid_2_
fans_for_mech_vent_split GREEN; 7 known 000565 fails set unchanged).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
8a3aaf7ae6 |
Slice S0380.105: MEV fans CO2 split via Table 12a Grid 2 + Table 12d (SAP 10.2 §10a / §10b)
Mirror of S0380.103 for the CO2 cascade. Cert 000565 worksheet line
(267):
Pumps, fans and electric keep-hot 252.5159 0.1412 35.3349 (267)
The displayed factor (0.1412) is the ALL_OTHER_USES Table 12d Σ
days-weighted blend; the displayed product (35.3349) is the kWh-
weighted blend across the two Grid 2 categories:
F_FANS = 0.58 × F_code34 + 0.42 × F_code33 = 0.13872 kg/kWh
F_OTHER = 0.80 × F_code34 + 0.20 × F_code33 = 0.14116 kg/kWh
F_eff = (127.5159 × 0.13872 + 125.0 × 0.14116) / 252.5159
= 0.13993 kg/kWh
CO2 = 252.5159 × 0.13993 = 35.3349 kg/yr ✓
Pre-slice the cascade applied 0.14116 to ALL 252.5159 kWh →
35.6457 → +0.31 over ws.
SAP 10.2 Table 12a Grid 2 (PDF p.191) verbatim header:
"Fractions of electricity used at the higher rate, for use in
off-peak tariff calculations
...
Fans for mechanical ventilation systems 10-hour: 0.58
All other uses, and locally generated 10-hour: 0.80
electricity"
SAP 10.2 Table 12d (PDF p.194) verbatim header:
"Where electricity is the fuel used, the relevant set of factors
in the table below should be used to calculate the monthly CO2
emissions INSTEAD of the annual average factor given in Table
12."
The Grid 2 high-rate fraction blends Table 12d high-rate × low-
rate codes per `F_blended = high_frac × F_high + (1 − high_frac)
× F_low`. MEV fans bill at the lower 0.58 high_frac → lower CO2
factor on the higher-carbon high-rate code 34. Cost-side S0380.103
landed the same split for tariff prices; this slice mirrors it
for the CO2 factor.
3-layer fix:
1. New helper `_pumps_fans_co2_factor_kg_per_kwh` returns the
kWh-weighted blend across `FANS_FOR_MECH_VENT` + `ALL_OTHER_USES`
factors. Falls back to the existing `ALL_OTHER_USES` rate on
STANDARD tariff and no-MEV certs (cohort-safe).
2. cert_to_inputs.py wires `mev_kwh_for_cost_split` +
`pumps_fans_kwh` through to the new helper.
3. Field `CalculatorInputs.pumps_fans_co2_factor_kg_per_kwh`
already exists from S0380.65; calculator legacy path unchanged.
Movement at HEAD `7df3fef8` → post-slice (cert 000565):
| Pin | Pre | Post | Δ vs ws |
|------------------------------|-----------:|-----------:|---------:|
| pumps_fans_co2_kg_per_yr | 35.6457 | 35.3349 | ✓ 0 |
| co2_kg_per_yr (TOTAL) | 6445.8198 | 6445.5090 | −2.1173 |
The total CO2 residual moves -1.81 → -2.12 (sign-flip pattern of
S0380.103): the previously-cancelling pumps_fans CO2 over-count
masked the main-heating-fuel CO2 under-count (downstream of the
§3-§8 SH cascade -16 kWh fuel residual). Per user direction
(NEXT_AGENT_PROMPT) transient continuous-SAP / TOTAL drift is OK
while closing a true spec-correct intermediate-value bug; the SH
cascade closure is a separate slice.
Cohort safety: STANDARD-tariff certs return the existing
ALL_OTHER_USES rate (helper falls through). No-MEV certs return
the same rate (mev_kwh_per_yr=0 short-circuit).
Test count: 604 pass + 7 expected 000565 fails → **605 pass + 7
expected 000565 fails** (new
test_summary_000565_mev_fans_co2_factor_uses_table_12a_grid_2_
fans_for_mech_vent_split GREEN). Pyright net-zero per touched
file (45 baseline → 45 post-change).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e3abe9b2b5 |
Slice S0380.103: MEV fans cost split via Table 12a Grid 2 FANS_FOR_MECH_VENT rate (SAP 10.2 Table 12a)
SAP 10.2 Table 12a Grid 2 (PDF p.191) splits off-peak electricity
costs into two categories:
Other electricity uses Tariff Fraction at
high rate
Fans for mechanical ventilation systems 7-hour 0.71
10-hour 0.58
All other uses, and locally generated 7-hour 0.90
electricity 10-hour 0.80
Cert 000565 (Dual meter, 10-hour off-peak, MEV decentralised) lodges
127.5159 kWh of MEV-fan electricity (line 230a) that bills at the
`FANS_FOR_MECH_VENT` blend (0.58 × 14.68 + 0.42 × 7.50 = 11.6644
p/kWh), distinct from the 125 kWh of other pumps_fans (45 kWh gas-
boiler flue fan + 80 kWh solar HW pump) which bills at the
`ALL_OTHER_USES` blend (0.80 × 14.68 + 0.20 × 7.50 = 13.2440 p/kWh).
Pre-slice the cascade applied `ALL_OTHER_USES` to ALL 252.5159 kWh,
over-counting MEV cost by 127.5159 × (0.13244 - 0.11664) = +£2.01/yr.
Worksheet pin verification (line (249)):
"Pumps, fans and electric keep-hot ... 172.5159 13.2440 20.8338"
127.5159 × 0.11664 + 45 × 0.13244 = £14.8753 + £5.9598 = £20.8351
≈ ws £20.8338 ✓
Pump for solar water heating 80.0 × 0.13244 = £10.5952 ✓
Implementation (3-layer):
1. `calculator.py:CalculatorInputs` — new optional
`pumps_fans_fuel_cost_gbp_per_kwh: Optional[float] = None`.
2. `calculator.py` legacy cost path — `pumps_fans_cost` resolves
via the new field with fallback to `other_fuel_cost_gbp_per_kwh`.
3. `cert_to_inputs.py:_pumps_fans_fuel_cost_gbp_per_kwh` — computes
the kWh-weighted blended rate when off-peak + MEV is lodged.
Reuses `_mev_decentralised_kwh_per_yr_from_cert` (S0380.102) to
recover the MEV portion.
Cohort safety: STANDARD-tariff certs (the entire cohort except cert
000565) get None back → existing `other_fuel_cost_gbp_per_kwh`
fallback unchanged. Certs without MEV (zero MEV kWh) also get None
→ no behavioural change.
Movement at HEAD (cert 000565):
- pumps_fans_kwh_per_yr ✓ EXACT (unchanged)
- total_fuel_cost_gbp: 4680.6514 → 4678.6372 (Δ +£0.39 → -£1.62)
- ecf: 5.3873 → 5.3850 (Δ +0.0007 → -0.0016)
- sap_score_continuous: 28.5043 → 28.5269 (Δ -0.0044 → +0.0182)
Continuous-SAP residual drifted from -0.0044 to +0.0182 in absolute
value: closing the MEV cost over-count exposes a pre-existing
space-heating cascade under-count (main_heating_fuel_kwh is -16 kWh
under ws). Per user direction [[feedback-spec-floor-skepticism]]:
shipping spec-correct intermediate-value fixes even when they
transiently drift continuous SAP. The remaining residual is now
SH-cascade driven; a separate slice.
Test count: 597 pass + 7 expected 000565 fails unchanged.
Pyright net-zero per touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1b183f9c86 |
Slice S0380.101: HP SAP code 211-227/521-527 → main_heating_category=4 (SAP 10.2 Table 4a)
SAP 10.2 Table 4a (PDF p.165) lists "Heat pumps" as category 4 for
SAP main-heating codes:
211-217 — ground/water source heat pumps
221-227 — air source heat pumps (224 = ASHP 2013+, COP 1.70)
521-527 — warm-air heat pumps
Cert 000565 Main 1 lodges `Main Heating SAP Code = 224` (ASHP 2013+)
with `PCDF boiler Reference = 0` — i.e. no PCDB Table 362 lookup is
possible. Pre-slice `_elmhurst_main_heating_category` returned None
on this path (the existing PCDB-Table-362-membership check failed),
falling through to the cascade's `_DEFAULT_PUMPS_FANS_KWH_PER_YR =
130` (incorrect — HP circulation pump's electricity is inside the
system COP per SAP 10.2 Table 4f line "Heat pumps", so the cascade
row is 0 kWh/year for category 4).
Single-line fix: after the existing PCDB-resolution branches, check
`mh.main_heating_sap_code in _HEAT_PUMP_SAP_MAIN_HEATING_CODES` and
return category 4 if so. New frozenset of HP codes (subset of the
existing `_ELECTRIC_SAP_MAIN_HEATING_CODES`).
Transient state at HEAD (cert 000565):
- main_heating_category: None → 4 ✓
- pumps_fans cascade: 255.0 → 125.0 kWh/yr (HP base 0 + flue 45 +
solar HW 80; MEV +127.5 kWh still missing — wiring lands in
S0380.102)
- sap_score (int): 29 ✓ EXACT preserved
- sap_score_continuous: 28.31 → 28.69 (transient drift +0.39 vs ws;
the previously-cancelling +130 over-count is gone, restoring the
MEV-under net negative — closes when S0380.102 lands)
Cohort safety: cohort certs 000474..000516 are gas-combi with
`sap_main_heating_code=None` (PCDB Table 105 boiler identified via
the index instead). No cohort cert affected. Cert 0380 + other
golden HP fixtures lodge category=4 via the API mapper, also
unaffected.
Per the spec citation in [[feedback-spec-citation-in-commits]] +
the standing TODO at mapper.py:4037-4043, this slice is the
category half of the coupled cert 000565 closure arc.
Pyright net-zero per touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
7121a86b86 |
Slice S0380.97: Floor "Insulation Thickness" extractor + mapper (RdSAP 10 §5.13 Table 20)
RdSAP 10 Specification §5.13 "U-values of exposed and semi-exposed
upper floors" (PDF p.47) + Table 20:
"Otherwise, to simplify data collection no distinction is made in
terms of U-value between an exposed floor (to outside air below)
and a semi-exposed floor (to an enclosed but unheated space
below) and the U-values in Table 20 are used."
Table 20 (excerpt, age bands A-G | H or I):
Age band Unknown/as built 50mm 100mm 150mm
A to G 1.20 0.50 0.30 0.22
H or I 0.51 0.50 0.30 0.22
Cert 000565 Summary §9 2nd Extension lodges:
Location: U Above unheated space
Type: N Suspended, not timber
Insulation: R Retro-fitted
Insulation Thickness: 200 mm
Default U-value: 0.22
Pre-slice the extractor's `_floor_details_from_lines` did NOT read
the "Insulation Thickness" cell (only the §8 roof extractor had the
field). FloorDetails carried no thickness → mapper plumbed
`SapBuildingPart.floor_insulation_thickness=None` → cascade
`u_exposed_floor(age=H, ins=None)` returned U=0.51 (Table 20 row[0]
unknown/as-built) vs worksheet 0.22 (Table 20 150 mm column for
age H) — over-counting BP[2] floor by (0.51-0.22) × 30 m² = +8.70
W/K.
Three-layer fix:
1. Schema (`elmhurst_site_notes.py:FloorDetails`) — add
`insulation_thickness_mm: Optional[int] = None` (mirror of
`RoofDetails`).
2. Extractor (`elmhurst_extractor.py:_floor_details_from_lines`) —
parse "Insulation Thickness" via existing `_local_val` (mirror of
`_roof_details_from_lines` pattern at line 333).
3. Mapper (`mapper.py:_map_elmhurst_building_part`) — translate
`floor.insulation_thickness_mm` to `SapBuildingPart.floor_
insulation_thickness=f"{n}mm"` (digit-prefix string convention
matching the API mapper + the wall pattern at line 3125-3129).
Cascade no-op: existing `_parse_thickness_mm` accepts "200mm" → 200;
`u_exposed_floor(age=H, ins=200)` returns 0.22 (clamps thickness ≥
125 mm to Table 20 row[3]) ✓.
Movement at HEAD (cert 000565):
- BP[2] Ext2 floor cascade U: 0.51 → 0.22 ✓ EXACT vs ws 0.22
- floor_w_per_k: 70.37 → 61.67 ✓ EXACT vs ws 61.67 (closed +8.70)
- sap_score (int): 28 → 29 ✓ EXACT vs ws 29
- sap_score_continuous: 28.31 → 28.5086 vs ws 28.5087 (Δ -0.20 →
-0.0001 — within 1e-4 strict floor!)
- SH: -38 kWh vs ws (was +218 → essentially closed)
Test count: 587 → 590 pass (+2 new AAA tests + sap_score integer
pin flipped from FAIL to PASS) + 8 expected 000565 fails (sap_score
integer pin removed from the work queue).
Cohort safety: only cert 000565 §9 lodges "Insulation Thickness"
(grep audit across Summary fixtures); cohort certs lodge "As built"
or omit the line. Pyright net-zero per touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
32a4cf2080 |
Slice S0380.96: RIR insulation "Unknown" thickness extractor + mapper (RdSAP 10 §3.10.1)
RdSAP 10 Specification §3.10.1 (PDF p.24) "Default U-values of the
roof rooms":
"Where the details of insulation are not available, the default
U-values are those for the appropriate age band for the
construction of the roof rooms (see Table 18 : Assumed roof
U-values when Table 16 or Table 17 do not apply). The default
U-values apply when the roof room insulation is 'as built' or
'unknown'."
Cert 000565 Summary §8.1 BP[4] Ext4 lodges:
Flat Ceiling 1 5.00 1.00 Unknown PUR or PIR 0.15 No
Worksheet line (30): `Roof room Ext4 Flat Ceiling 1: 5 × 0.15 =
0.75 W/K` (U985-0001-000565 line 333).
Pre-slice the extractor allow-list `_RIR_INSULATION_THICKNESS_RE
| ("As Built", "None")` did NOT include the "Unknown" thickness
token, so the cell was dropped (`insulation = ""`). The mapper
translated `""` to `insulation_thickness_mm=0`, and the cascade
hit Table 17 row 0 → U=2.30 vs worksheet 0.15 (over-counting
BP[4] FC1 by +10.75 W/K on a 5 m² ceiling).
Two-layer fix:
1. Extractor (`elmhurst_extractor.py:_parse_rir_surface_row`) — add
"Unknown" as the third spec-valid thickness token alongside
"As Built" and "None".
2. Mapper (`mapper.py:_elmhurst_rir_insulation_thickness_mm`) —
return `Optional[int]`; "Unknown" → None. The cascade's existing
`_u_rr_table_17` already falls back to `u_rr_default_all_elements`
(Table 18 col 4) when thickness is None — for cert 000565 BP[4]
age band M, returns 0.15 W/m²K ✓.
Cascade no-op: the existing None → Table 18 col 4 fallback IS the
spec-correct path per §3.10.1; no calculator changes needed.
Movement at HEAD (cert 000565):
- BP[4] FC1 cascade U: 2.30 → 0.15 ✓ EXACT vs ws 0.15
- roof_w_per_k: 63.72 → 52.97 (Δ +12.34 → +1.59, closed -10.75)
- sap_score_continuous: 28.07 → 28.31 (Δ -0.44 → -0.20)
- sap_score (int): 28 (continuous still below 28.5 threshold;
remaining residual + BP[1] residual + BP[2] floor)
- SH: +533 → +218 kWh
Test count: 585 → 587 pass (+2 new AAA tests) + 9 expected 000565
fails unchanged.
Cohort safety: "Unknown" RIR insulation appears only in cert 000565
across the Summary fixture set (grep audit); cohort certs lodge
concrete thickness or "None"/"As Built". Pyright net-zero per
touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
fa6974bdd9 |
Slice S0380.95: Detailed-RR residual area cascade per RdSAP 10 §3.10.1
RdSAP 10 §3.10.1 (PDF p.24) "Default U-values of the roof rooms":
> "The residual area (area of roof less the floor area of room(s)-in-
> roof) has a U-value from Table 16 : Roof U-values when loft
> insulation thickness is known according to its insulation thickness
> if at least half the area concerned is accessible, otherwise it is
> the default for the age band of the original property or extension."
Plus RdSAP 10 §3.9.1 step (d-e) (PDF p.21-22) — the Simplified A_RR
formula `12.5 × √(A_RR_floor / 1.5)` is the empirical estimator for
the total RR exposed shell; residual = A_RR − Σ lodged walls. The
worksheet applies this same formula to Detailed mode when the lodged
surface set has no roof-going entries (cert 000565 BP[0]:
12.5 × √(45/1.5) − (9.8 + 14.7) = 43.96 ≈ ws 43.97).
Pre-slice the cascade computed residual area ONLY in the Simplified
RR branch (via `_part_geometry`'s `rr_simplified_a_rr_m2` − rr_common
− rr_gable subtractions). The Detailed-RR branch in
`heat_transmission` iterated `rir.detailed_surfaces` and missed the
residual entirely. Cert 000565 routes all 5 BPs through Detailed mode
(the Elmhurst mapper translates Summary "Simplified" lodgements to
`SapRoomInRoofSurface` records when per-surface L×H is present), so
cascade total_external_element_area_m2 was 779.27 m² vs worksheet
(31) = 857.64 m² (Δ −78.37 m² → thermal_bridging cascade −11.76 W/K
under).
Slice span (1 file):
- `heat_transmission.py`: Detailed-RR branch adds residual area via
the §3.9.1 A_RR formula minus wall-going lodgements (gable_wall,
gable_wall_external, common_wall). Residual area contributes to
`rr_detailed_area` (→ part_external_area → (31) → thermal_bridging
multiplier) and to `roof` at `u_rr_default_all_elements`.
- Discriminator: residual fires only when no roof-going surface kinds
(slope, flat_ceiling, stud_wall) are lodged — true Detailed-mode
lodgements (cohort fixture 000516) lodge the entire roof shell
explicitly and have no residual.
Cert 000565 movement (HEAD `78c57c0d` → this slice):
- thermal_bridging_w_per_k: 116.89 → 129.35 ✓ vs ws 128.65 (Δ +0.70)
- total_external_area_m2: 779.27 → 862.34 ✓ vs ws 857.64 (Δ +4.70)
- roof_w_per_k: 34.64 → 63.72 (Δ −16.74 → +12.34)
- sap_score_continuous: 29.02 → 28.07 (Δ +0.51 → −0.44)
- sap_score (integer): 29 → 28 (temp regression
past 28.5 threshold)
- space_heating_kwh: −685 → +533
- main_heating_fuel: −403 → +321
- hot_water_kwh: ✓ 0 EXACT unchanged
Per user direction temporary continuous-SAP drift is acceptable when
fixing real spec-correct sub-component bugs; the absolute continuous-
SAP residual is now −0.44 (was +0.51) — slightly closer to zero
overall. The roof overshoot localises to:
- BP[4] Flat Ceiling 1 "Unknown PUR or PIR" lodgement (cascade 2.30
vs ws 0.15, over by +10.75 W/K) — Elmhurst-specific "Unknown +
known material" convention not yet wired
- BP[1] residual formula gives +3.68 m² over worksheet (Δ +1.29 W/K)
— Detailed-mode residual is spec-ambiguous for extensions with
non-2.45 m RR height; future slice may add a height-aware formula
Cohort safety: discriminator `has_roof_lodgement` filters out true
Detailed-mode lodgements (cohort fixtures 000474/000477/000480/
000487/000490/000516 all lodge slope/flat_ceiling/stud_wall surfaces).
Initial implementation broke 41 cohort pins; the discriminator
restores cohort behaviour exactly. Test baseline: 585 pass + 9
expected `000565` fails (was 585 + 8 — sap_score moved from passing
to failing during the slice's transient overshoot; expected per
user direction).
Pyright net-zero per touched file (test_summary_pdf_mapper_chain.py
13 → 13 preserved; heat_transmission.py 13 → 12 improved by −1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
78c57c0dc7 |
Slice S0380.94: RIR insulation "400+ mm PUR or PIR" extractor + mapper + cascade (RdSAP 10 Table 17 col 3b)
RdSAP 10 §5.11.3 + Table 17 (PDF p.42-43) "Roof room U-values when
insulation thickness is known". Column (3b) "Stud wall — PUR or PIR
optional" 400 mm row → 0.10 W/m²K. Cert 000565 Summary §8.1 BP[2] Ext2
(Detailed) lodges:
Stud Wall 2 2.00 × 2.00 400+ mm PUR or PIR Default U=0.10
Pre-slice three coupled bugs silently dropped the lodgement, routing
the cascade through the uninsulated Table 17 row 0 (U=2.30) — over-
counting Stud Wall 2 by (2.30 − 0.10) × 4 m² = +8.80 W/K on roof:
1. **Extractor regex** `_RIR_INSULATION_THICKNESS_RE = ^\d+\s*mm$`
failed to match the "400+ mm" bucket-cap form (Table 17's largest
tabulated row is annotated with a trailing "+" in the Summary).
2. **Extractor insulation_type allow-list** `("Mineral or EPS",
"PUR", "PIR")` failed to match the disjunction "PUR or PIR" — the
actual Summary form when the assessor doesn't distinguish PUR from
PIR. (Both columns Table 17 column (b) anyway.)
3. **Mapper thickness parser** `_elmhurst_rir_insulation_thickness_mm`
used the same `^\d+\s*mm$` regex — also failed on "400+ mm".
Plus a fourth coupled fix: the cascade's `_is_rigid_foam` checked a
frozenset `{"pur", "pir", "rigid"}` that didn't include the canonical
mapper-side code "rigid_foam" — even if the mapper translated "PUR or
PIR" → "rigid_foam", the cascade would route to column (a) mineral-
wool instead of column (b) rigid-foam.
Slice span (4 layers):
1. **Extractor regex** — `^\d+\+?\s*mm$` matches both "100 mm" and
"400+ mm".
2. **Extractor allow-list** — add "PUR or PIR" alongside individual
"PUR" / "PIR" + "Mineral or EPS".
3. **Mapper** — `_RIR_INSULATION_TYPE_TO_SAP10` canonicalises all
rigid-foam strings to "rigid_foam"; thickness parser regex matches
"400+ mm" → 400 mm int.
4. **Cascade** — `_RR_RIGID_FOAM_INSULATION_TYPES` adds "rigid_foam"
alongside the legacy "pur"/"pir"/"rigid" aliases.
Cert 000565 movement (HEAD `23aaa4fa` → this slice):
- cascade BP[2] Ext2 Stud Wall 2 U: 2.30 → 0.10 ✓ EXACT vs ws 0.10
- cascade roof_w_per_k: 43.44 → 34.64 (Δ−7.94 → Δ−16.74)
- sap_score: 29 ✓ EXACT unchanged
- sap_score_continuous: 28.81 → 29.02 (Δ+0.26 → Δ+0.51)
- space_heating_kwh: −427 → −685
- main_heating_fuel: −251 → −403
- hot_water_kwh: ✓ 0 EXACT unchanged
Closing one spec-correct sub-component while others remain non-spec-
correct drifts continuous SAP further; per user direction temporary
drift is acceptable as long as we're fixing true intermediate-value
problems — once every sub-component is spec-correct, the continuous
SAP error closes to zero by construction. The remaining −16.74 W/K
roof gap localises to:
- BP[0/1/3] missing RR residual area for Detailed-RR mode (§3.10.1
spec — cascade only handles Simplified mode today); +27.85 W/K
closure when wired.
- BP[4] Flat Ceiling 1 lodges "Unknown thickness, PUR or PIR" → ws
U=0.15; cascade over-counts at 2.30 (uninsulated). Elmhurst's
"Unknown PUR or PIR" → 200 mm convention is non-spec; the spec-
correct path falls back to Table 18 col 4 default (`u_rr_default
_all_elements`). Separate diagnostic slice.
Cohort safety: 21 other Elmhurst Summary fixtures lodge no RIR detailed
surfaces with "400+ mm" or "PUR or PIR" (modal cohort uses As Built /
None / no detailed surfaces). Existing "Mineral or EPS" tests at
`test_u_rr_stud_wall_table17_col3a_mineral_wool_100mm_returns_0_36`
remain green — the new aliases extend rather than replace.
Test baseline: 585 pass + 8 expected `000565` fails (was 583 + 8; +2
new tests). Pyright net-zero per touched file (0/32/1/65/13 preserved).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
23aaa4fa66 |
Slice S0380.93: floor above partially-heated space U=0.7 (RdSAP 10 §5.14)
RdSAP 10 §5.14 (PDF p.47) "U-value of floor above a partially heated space": > "The U-value of a floor above partially heated premises is taken as > 0.7 W/m²K. This applies typically for a flat above non-domestic > premises that are not heated to the same extent or duration as the > flat." Cert 000565 Ext1 lodges Summary §9 "Location: P Above partially heated space" + "Default U-value: 0.70". Worksheet line (28b) confirms "Exposed floor Ext1 ... 34.0000 0.7000 23.8000". Pre-slice the cascade routed BP[1] floor through the BS EN ISO 13370 ground-floor formula (the "else" branch of the floor U-value dispatch in `heat_transmission.py`) — producing cascade U=0.76 vs spec 0.70. Over-counted floor heat loss by (0.76 − 0.70) × 34 m² = +2.04 W/K on the part subtotal and on the total HTC. Slice span (4 layers): 1. **Helper** — `u_floor_above_partially_heated_space()` in `domain/sap10_ml/rdsap_uvalues.py`, verbatim spec constant 0.7 (no age-band / insulation-thickness inputs). Lives in `sap10_ml` per [[project-sap10_ml-deprecation]] (edit existing file fine). 2. **Schema** — `SapFloorDimension.is_above_partially_heated_space: bool = False` (parallel to existing `is_exposed_floor`). Mutually exclusive with the exposed-floor / basement-floor branches. 3. **Mapper** — new `_is_floor_above_partially_heated_space(location)` helper detecting "above partially heated" in the Elmhurst §9 floor location string. Plumbed into `_map_elmhurst_building_part` floor- dim construction; only applies to the ground floor (i==0). 4. **Cascade** — `heat_transmission.py` adds a new branch between the exposed-floor and ground-floor branches: `is_above_partial → u_floor_above_partially_heated_space()`. Cert 000565 movement (HEAD `a7894b11` → this slice): - cascade floor_w_per_k: 72.41 → 70.37 (Δ +10.74 → Δ +8.70) - cascade BP[1] floor U: 0.76 → 0.70 (✓ EXACT vs ws 0.70) - sap_score (integer): 29 ✓ EXACT (unchanged — at goal) - sap_score_continuous: 28.7663 → 28.8131 (+0.0468 drift) - space_heating_kwh: −367 → −427 (small drift further under) - main_heating_fuel: −216 → −251 (downstream of SH) - co2_kg_per_yr: −32 → −37 - total_fuel_cost_gbp: −23 → −27 - hot_water_kwh: ✓ 0 EXACT unchanged The small continuous-SAP drift is the expected arithmetic of closing a single component when adjacent components remain unclosed (floor +10.74 was cancelling thermal_bridging −11.76 + roof −7.94 at the net-HTC level). Per [[feedback-zero-error-strict]] + [[feedback- spec-citation-in-commits]] the spec-correct slice ships regardless of transient continuous-SAP drift; remaining residual components (floor +8.70 from BP[2] Ext2 lodged 200 mm insulation thickness; roof −7.94; thermal_bridging −11.76; walls −1.67) each get their own spec-cited slice. Cohort safety: only cert 000565 Ext1 in the cohort lodges "Above partially heated space". All other Elmhurst cohort fixtures + 9 golden + 38 cohort-2 API certs default to `is_above_partially_ heated_space=False` so cascade behaviour is unchanged. Test baseline: 583 pass + 8 expected `000565` fails (was 582 + 8; +1 new mapper-chain test). Pyright net-zero per touched file (1/65/1/32/13/13 preserved). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a7894b1185 |
Slice S0380.92: AP4 + MEV decentralised plumbing (SAP 10.2 §2 (17a)/(18)/(23a)/(24c))
SAP 10.2 §2 lines (17a)/(18) "Air permeability value, AP4 (m³/h/m²)" (PDF p.12-13): > "The air permeability at 4 Pa (AP4) measured with the low-pressure > pulse technique [...] is used in the following formula to estimate > of the air infiltration rate at typical pressure differences. > In this case (9) to (16) of the worksheet are not used." > > Air infiltration rate (ach) = 0.263 × AP4^0.924 > > If based on air permeability value at 4 Pa, > then (18) = [0.263 × (17a)^0.924] + (8) SAP 10.2 §2 lines (23a)/(24c)/(25) "MEV" + "Whole-house extract ventilation" (PDF p.13/133): > "The SAP calculation is based on a throughput of 0.5 air changes per > hour through the mechanical system." (23a) = 0.5 > > If whole house extract ventilation or positive input ventilation > from outside: > if (22b)m < 0.5 × (23b), then (24c) = (23b) > otherwise (24c) = (22b)m + 0.5 × (23b) Cert 000565 lodges: - Summary §12.1 "Mechanical Ventilation Type: Mechanical extract, decentralised (MEV dc)" (PCDF 500755) - Summary §12.2 "Test Method: Pulse" + "Pressure Test Result (AP4): 2.00" Pre-slice both lodgements were silently dropped by the Elmhurst extractor / mapper / `cert_to_inputs` cascade: - AP4 had no schema field on `VentilationAndCooling` or `SapVentilation` even though `ventilation.py:ventilation_from_inputs(air_permeability_ ap4=...)` already implemented the spec formula. - Mechanical Ventilation Type had no schema field; `cert_to_inputs. ventilation_from_cert` hardcoded `mv_kind=MechanicalVentilationKind. NATURAL` regardless of the lodgement, routing cert 000565 through the (24d) natural-vent formula instead of (24c). These bugs are coupled: AP4 alone would close (18) but the cascade's (25) NATURAL pass-through would then *under*-count the effective ach by 0.25 (the missing MEV contribution). MEV alone would over-count because the (18) over-count remains. Per [[feedback-bigger-slices- for-uniform-work]] + handover precedent on coupling-aware reverts, these land together. Slice span (5 layers): 1. **Schema** — `VentilationAndCooling.air_permeability_ap4_m3_h_m2` + `VentilationAndCooling.mechanical_ventilation_type` (site-notes); `SapVentilation.air_permeability_ap4_m3_h_m2` + `SapVentilation.mechanical_ventilation_kind` (domain). 2. **Extractor** — `_extract_ventilation` parses "Pressure Test Result (AP4)" scoped to §12.2 and "Mechanical Ventilation Type" scoped to §12.1. Both default to None when the cert lodges no MV / no Pulse test (cohort modal case). 3. **Mapper** — `_map_elmhurst_ventilation` plumbs AP4 through; new `_ELMHURST_MV_TYPE_TO_KIND` dispatch with strict-raise on unmapped labels (per [[reference-unmapped-elmhurst-label]] mirror pattern). 4. **cert_to_inputs** — `ventilation_from_cert` reads AP4 and resolves `mechanical_ventilation_kind` name → `MechanicalVentilationKind` enum. MEV/MV/MVHR kinds set `mv_system_ach=0.5` per spec (23a). 5. **Tests** — 4 in test_summary_pdf_mapper_chain.py (extractor + mapper for both AP4 and MEV kind), 2 in test_cert_to_inputs.py (cascade AP4 formula + MEV kind dispatch). All AAA-structured. Cert 000565 movement (HEAD `83218630` → this slice): - cascade (18) pressure_test_ach: 2.4037 → 2.0287 ✓ EXACT vs ws 2.0287 - cascade (21) shelter-adj: 2.0431 → 1.7244 ✓ EXACT vs ws 1.7244 - cascade mean (25)m: 2.2347 → 2.1360 vs ws 2.086 (+0.05) - **sap_score (integer): 28 → 29 ✓ EXACT vs ws 29** (Δ−1 → Δ 0) - sap_score_continuous: 27.99 → 28.77 (Δ−0.52 → +0.26) - ecf: 5.44 → 5.36 (Δ+0.05 → −0.03) - total_fuel_cost_gbp: 4726.75 → 4657.37 (Δ+46 → Δ−23) - co2_kg_per_yr: 6506.48 → 6415.56 (Δ+59 → Δ−32) - **space_heating_kwh: +631 → −367** (~75% closed) - main_heating_fuel: +371 → −216 (~58% closed) - hot_water_kwh: ✓ 0 EXACT unchanged - lighting / pumps_fans: sub-spec residuals unchanged The residual cascade-over-by-0.05 ach on (25)m is the cascade using the cert-agnostic Table U2 wind tuple instead of the cert's regional wind lookup; future ventilation_from_cert wires a `postcode_climate` arg through which `cert_to_demand_inputs` already does for the demand cascade, but the SAP-rating cascade keeps the Table U2 default. Cohort safety: - All 21 other Elmhurst cohort fixtures lodge `pressure_test_method= "Not available"` and `mechanical_ventilation=False` → both new fields default to None → cascade behaviour unchanged. - 9 golden + 38 cohort-2 API certs route through `_map_sap_ventilation` (the API mapper variant), which leaves both new SapVentilation fields at their None default → cascade behaviour unchanged. Test baseline: 582 pass + 8 expected `000565` fails (was 575 + 9; +6 new tests + sap_score reclassified from fail to pass). 1763 pass in broader sap10_ml + worksheet + epc.domain suites + 3 pre-existing fails unchanged. Pyright net-zero per touched file (1/0/0/32/34→32/13/ 11 → 1/0/0/32/32/13/11, cert_to_inputs.py improved −2). Per [[project-sap10_ml-deprecation]] the new fields live on the existing `SapVentilation` domain type; no new modules under sap10_ml. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8321863015 |
Slice S0380.91: party-wall Cavity-masonry-filled U=0.2 (RdSAP 10 Table 15 row 3)
RdSAP 10 §5.10 Table 15 (PDF p.42) "U-values of party walls":
Party wall type U
--------------------------------------------- ----
Solid masonry / timber frame / system built 0.0
Cavity masonry unfilled 0.5
Cavity masonry filled 0.2
Unable to determine, house or bungalow 0.25
Unable to determine, flat or maisonette* 0.0
Pre-slice the cascade collapsed CF (Cavity masonry filled) into the same
SAP10 wall_construction code 4 as CU (Cavity masonry unfilled), so the
filled-cavity row's spec U=0.2 was silently rounded up to the unfilled
U=0.5. The mapper at `_ELMHURST_PARTY_WALL_CODE_TO_SAP10["CF"]: 4` and
`_API_PARTY_WALL_CONSTRUCTION_TO_SAP10[3]: 4` both flagged this as a
known approximation since S0380.64; today's slice closes it.
Introduces a party-wall-only synthetic SAP10 code
`WALL_CAVITY_FILLED_PARTY = 11` (distinct from the main wall_construction
codes 1-10 since Table 15 treats filled vs unfilled cavity as separate
party-wall types). `u_wall` doesn't consume code 11 so main-wall U-value
cascades are unaffected. Cohort + golden audit: only cert 000565 Ext1
lodges CF on the Elmhurst side; zero golden certs lodge API code 3, so
flipping the dispatch is scoped to one BP.
Cert 000565 movement (HEAD
|
||
|
|
6c8bbbc9e2 |
Slice S0380.86: §5.6 thin-wall stone + §5.8 dry-line closes BP[0] alt1 cascade gap
RdSAP 10 §5.6 (PDF p.40) "U-values of uninsulated stone walls, age
bands A to E":
Table 12 — Default U-values of stone walls
Sandstone or limestone: U = 54.876 × W^(-0.561)
Granite or whinstone: U = 45.315 × W^(-0.513)
Where W is wall thickness in mm.
"Apply the adjustment according to Table 14: Insulation thickness
and corresponding resistance if wall is insulated or dry-lined
including lath and plaster."
Combined with §5.8 (PDF p.40) + Table 14 (PDF p.41) dry-line R = 0.17
m²K/W: U = 1 / (1/U₀ + 0.17).
Cert 000565 BP[0] Main alt1 is the cohort fixture: Stone Granite, age
band A (inherited from Main), 120 mm wall thickness, dry-lined.
§5.6 formula: U₀ = 45.315 × 120^(-0.513) ≈ 3.8871.
§5.8 + Table 14 dry-line: U = 1/(1/3.8871 + 0.17) ≈ **2.3405**.
→ matches worksheet U985-0001-000565 line (29a) "External walls Main
alt.1 ... SolidWallDensePlasterInsul, Solid, 0.0, 2.34" EXACT.
Pre-S0380.86 two coupled bugs blocked this path:
1. Mapper mis-name per [[feedback-no-misleading-insulation-type]]:
`_map_elmhurst_alternative_wall` routed the Elmhurst Summary §7
"Alternative Wall N Thickness" lodging (the WALL thickness)
onto `SapAlternativeWall.wall_insulation_thickness="120"`. The
cascade then mis-bucketed it as 100 mm insulation (bucket=100
→ _BRICK_INS_100 row at age A → U=0.32). The Elmhurst Summary
schema has no "Alternative Wall N Insulation Thickness" line at
all — `wall_insulation_thickness` on alts was always
semantically the wall thickness, never insulation.
2. `u_wall` had no §5.6 thin-wall stone branch. Stone constructions
fell through to Table 6 row values (designed for typical-
thickness ~300mm+ walls), which dramatically under-state heat
loss for sub-200mm stone.
Fix span:
- datatypes/epc/domain/epc_property_data.py:SapAlternativeWall:
new `wall_thickness_mm: Optional[int] = None` field, mirroring
`SapBuildingPart.wall_thickness_mm`.
- datatypes/epc/domain/mapper.py:_map_elmhurst_alternative_wall:
routes Elmhurst `a.thickness_mm` (Wall thickness) onto
`wall_thickness_mm`; leaves `wall_insulation_thickness=None`
on this path (no Elmhurst Summary alt-wall insulation-thickness
line exists).
- domain/sap10_ml/rdsap_uvalues.py:
new `_u_stone_thin_wall_age_a_to_e(construction, W)` helper
implements §5.6 Table 12 formulas. `u_wall` accepts a new
`wall_thickness_mm: Optional[int] = None` param; dispatches
§5.6 formula when (a) wall thickness lodged, (b) age band ∈
A-E, (c) construction ∈ {STONE_GRANITE, STONE_SANDSTONE}.
§5.8 + Table 14 R=0.17 applied on top when dry_lined=True.
- domain/sap10_calculator/worksheet/heat_transmission.py:
`_alt_wall_contribution_w_per_k` passes
`wall_thickness_mm=alt_wall.wall_thickness_mm` to `u_wall`.
Tests (7 new, AAA-structure):
- 5 in domain/sap10_ml/tests/test_rdsap_uvalues.py — granite at
120 mm with dry-line (U=2.34); granite raw formula (U=3.89);
sandstone (U=3.74); age-G gate (Table 6 row, NOT formula); no
wall_thickness fallback (Table 6 row 1.7).
- 2 in backend/documents_parser/tests/test_summary_pdf_mapper_chain
.py — mapper pin (wall_thickness_mm=120 on BP[0] alt1;
wall_insulation_thickness=None) and cascade pin (walls_w_per_k
≥ 595, post-S0380.85 was 555.93).
**Cert 000565 cascade walls: 555.93 → 602.40 W/K (worksheet 604.07;
0.27% residual).** BP[0] alt1 cascade U: 0.32 → 2.34. Cascade walls
within 2 W/K of worksheet target across S0380.85+.86 closure cycle.
Test baseline: 560 pass (was 558 + 7 new − 5 already passing pins
that moved) + 9 expected `test_sap_result_pin[000565-*]` fails
unchanged. Cohort + golden + cert 9501 unaffected: of the 6 cohort
fixtures only cert 000565 alt1 lodged a `wall_insulation_thickness`
value on `SapAlternativeWall` (audit confirmed) — and that value was
always semantically the wall thickness, so the rename is a fix not
a behaviour change. The API mapper path defaults `wall_thickness_mm`
to None (API schema doesn't yet surface alt-wall thickness; safe
forward-compat).
Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover
predicted SH residual would close after the wall fixes. Empirically
SH grew +2591 → +6348 → +7924 across S0380.84/.85/.86 — confirming a
SEPARATE SH-channel over-count that's independent of fabric (each
+1 W/K of spec-correct walls adds ~33.5 kWh of cascade SH, vs the
worksheet's ~38.96 kWh/W/K rate). The walls fixes are spec-correct;
the SH over-count is now a single isolated open work-item for the
next slice (~+8 k kWh structural).
Pyright net-zero per touched file (test_rdsap_uvalues.py error count
actually decreased by 1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
647c1aad0e |
Slice S0380.85: Curtain Wall §5.18 dispatch closes BP[2] Ext2 cascade gap
RdSAP 10 §5.18 (PDF p.48) "Curtain wall - U-value and other parameters":
"If documentary evidence is available, use calculated U-value of the
whole curtain wall. Otherwise for the purpose of RdSAP, U= 2.0 W/m²K
for pre-2023 curtain walls, And for post-2023 (2024 in Scotland)
U-values as for windows given in Notes below Table 24."
Table 24 row "Double or triple glazed England/Wales: 2022 or later"
PVC/wood column = 1.4 W/m²K. Whole-wall curtain walls use Frame
Factor=1 per the §5.18 closer.
Pre-S0380.85 `WALL_CURTAIN=9` was defined at rdsap_uvalues.py:116 but
NOT included in `known_types`, so `u_wall(construction=9)` fell through
to `_DEFAULT_WALL_BY_AGE.get(band, WALL_CAVITY)` → cavity table at age
H = 0.60. Cert 000565 BP[2] Ext2 lodges `Type: CW Curtain Wall` +
`Curtain Wall Age: Post 2023` per Summary PDF §7; worksheet pins U=1.40
(matching the §5.18 Post-2023 PVC/wood row). Cascade under-counted
walls by Δ U=0.80 × area = −112.2 W/K on this BP — 70% of the
post-S0380.84 BP main-wall residual (−161 W/K total).
§5.18 keys the curtain-wall U-value on the per-BP installation age,
NOT on the dwelling-wide `construction_age_band` — cert 000565 is
age H (1991-1995) but the curtain wall itself was installed
Post-2023. Plumb a new optional field through the extractor → datatype
→ mapper → cascade so the §5.18 dispatch sees it.
Files touched (5-layer slice span):
- backend/documents_parser/elmhurst_extractor.py:
`_wall_details_from_lines` reads "Curtain Wall Age" via
`_local_val` so absent lines stay None (not "").
- datatypes/epc/surveys/elmhurst_site_notes.py:WallDetails:
`curtain_wall_age: Optional[str] = None` field added.
- datatypes/epc/domain/epc_property_data.py:SapBuildingPart:
`curtain_wall_age: Optional[str] = None` field added.
- datatypes/epc/domain/mapper.py:_map_elmhurst_building_part:
threads `walls.curtain_wall_age` onto SapBuildingPart.
- domain/sap10_ml/rdsap_uvalues.py:
new `_u_curtain_wall(curtain_wall_age)` helper + WALL_CURTAIN
dispatch in `u_wall` before the `known_types` lookup.
"Post 2023" / "Post-2023" → 1.4; everything else (incl. None)
→ 2.0 per §5.18 fallback.
- domain/sap10_calculator/worksheet/heat_transmission.py:
passes `curtain_wall_age=part.curtain_wall_age` to `u_wall`
on the main-wall path. (Alt-wall path unchanged — cert 000565
lodges CW only as a main wall, never as an alt sub-area; alt
coverage is a follow-up slice if a future cert exercises it.)
Tests (6 new, AAA-structure):
- 3 in domain/sap10_ml/tests/test_rdsap_uvalues.py — `u_wall` direct
unit tests for Post 2023 (1.4), Pre 2023 (2.0), and absent
lodging fallback (2.0).
- 3 in backend/documents_parser/tests/test_summary_pdf_mapper_chain
.py — extractor pin (BP[2] Ext2 surfaces "Post 2023", non-CW BPs
stay None), mapper pin (curtain_wall_age threaded to BP[2]
SapBuildingPart), cascade pin (`heat_transmission_from_cert`
walls subtotal ≥ 540 W/K — pre-S0380.85 was 443).
Cert 000565 cascade walls: 443 → 555.93 W/K (worksheet 604.07; 70%
closer). Test baseline: 558 pass (was 555 + 3 new) + 9 expected
`test_sap_result_pin[000565-*]` fails unchanged.
Per [[feedback-verify-handover-claims]]: the post-S0380.84 handover
predicted SH residual would close +2591 → ~+800 kWh after this slice,
but the cascade is actually OVER-counting SH despite walls being
UNDER-counted. Closing the wall under-count makes the SH residual
*larger* (+2591 → +6348). The wall fix is spec-correct; the SH
over-count is a separate channel that surfaces more sharply now. Per
[[feedback-spec-citation-in-commits]] + [[feedback-spec-floor-skepticism]]
+ the S0380.84 precedent, ship the spec-correct change and document
the surfaced gap for the next slice rather than reverting to the
compensating-bugs state.
Pyright net-zero on every touched file (existing pre-existing errors
unchanged). Cohort + golden + cert 9501 unaffected — curtain_wall_age
defaults to None on those certs and `u_wall` ignores it unless
`construction == WALL_CURTAIN`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
49622f5525 |
Slice S0380.84: RR mapper spec-correct routing + cascade common_wall handling per RdSAP 10 §3.9.2/§3.10
Cascades the spec-correct §3.10 Room-in-Roof routing through the
mapper + heat-transmission section. Three coupled changes:
1. **Mapper drops "Connected" gables** — per RdSAP 10 Table 4 (PDF p.22)
row 4 a gable wall "Connected to heated space" is an internal
partition, NOT a heat-loss surface. The Elmhurst Summary §8.1 PDF
may lodge the short form "Connected" or the verbose "Connected to
heated space"; both route to `return None` in
`_map_elmhurst_rir_surface`.
2. **Mapper routes "Exposed" gables → `gable_wall_external` with the
lodged U** — per Table 4 row 1 an exposed RR gable wall bills at the
lodged U-value (or the storey-below main-wall U). For non-flat
dwellings the `default_u_value` rides through as `u_value` override
so the cascade uses the lodged figure directly. Flats preserve their
legacy no-override routing so the cascade falls through to main-wall
U (cert 9501).
3. **Mapper surfaces Common Wall surfaces + applies spec area formula**
per RdSAP 10 §3.9.2 + Table 4:
Detailed assessment → raw L × H per surface
Simplified + Common Walls → L × (0.25 + H) for common walls;
L × (0.25 + H_gable)
− Σ_n (H_gable − H_common,n)² / 2
for gables
Simplified + no Common Walls → raw L × H for gables
The 0.25-m structural-gap offset accounts for the space between the
RR floor and the storey-below ceiling. The gable correction
subtracts the triangular slice above each common wall.
4. **Cascade adds `common_wall` kind** in `heat_transmission.py` — mirror
of `gable_wall_external`: walls += area × (`surf.u_value` or main-wall
U). Mapper precomputes the spec area so the cascade reads `area_m2`
directly.
Verified against the cert 000565 U985 worksheet PDF "External Walls"
section per BP:
| BP | Surface | Formula | Worksheet | Cascade |
|----|---------------------|-------------------------------------------|-----------|---------|
| 0 | Main GW1 (Exposed) | 4 × 2.45 (Simplified, no CW) | 9.80 | 9.80 ✓ |
| 0 | Main GW2 (Sheltered)| 6 × 2.45 | 14.70 | 14.70 ✓|
| 1 | Ext1 CW1 | 9 × (0.25 + 1.0) (Simplified + CW) | 11.25 | 11.25 ✓|
| 1 | Ext1 CW2 | 5 × (0.25 + 1.8) | 10.25 | 10.25 ✓|
| 1 | Ext1 GW2 (Exposed) | 8 × (0.25 + 9) − ((9−1)²+(9−1.8)²)/2 | 16.08 | 16.08 ✓|
| 2 | Ext2 GW2 (Exposed) | 3 × 8 (Detailed) | 24.00 | 24.00 ✓|
| 3 | Ext3 CW1 | 5 × (0.25 + 1.5) (Simplified + CW) | 8.75 | 8.75 ✓ |
| 3 | Ext3 CW2 | 7.5 × (0.25 + 0.3) | 4.13 | 4.13 ✓ |
| 3 | Ext3 GW1 (Exposed) | 9 × (0.25+7) − ((7−1.5)²+(7−0.3)²)/2 | 27.68 | 27.68 ✓|
| 4 | Ext4 CW1 | 4 × 1 (Detailed) | 4.00 | 4.00 ✓ |
| 4 | Ext4 CW2 | 3.5 × 0.6 | 2.10 | 2.10 ✓ |
Cohort impact:
- Cert 9501 (top-floor flat with Detailed RR + Exposed gables) —
PASSES (the flat-RR elif still routes; gables stay at main-wall U
via cascade fall-through).
- All other cohort fixtures: unaffected (no RR or fully-Detailed RR
where raw L × H is also the spec answer).
Cert 000565 cascade subtotals close substantially:
walls 322.21 → 443.51 (worksheet 604.07, Δ −282 → Δ −161, 43% closed)
party walls 153.46 → 93.26 (worksheet 65.13, Δ +88 → Δ +28, 68% closed)
HTC fabric 716.43 → 795.24 (Δ +79 W/K — cascade closer to worksheet)
The remaining 161 W/K under-count in walls + 28 W/K over-count in
party walls localise to the BP main-wall cascade (NOT RR). The cert
000565 sap_score e2e pin regresses from EXACT (29) to Δ−3 (26) because
the previous compensating cascade gaps are now exposed — the
spec-correct fix is real, the residual is real, and the next slice
closes the BP main-wall gap (likely the "External walls Main alt.1"
basement-override at 23 m² × U=2.34 = 53.82 W/K + per-BP main-wall
U/area refinements). Per [[feedback-spec-citation-in-commits]] +
[[feedback-spec-floor-skepticism]] the spec-correct fix ships even
when the test pin temporarily regresses; the diagnostic signal is
sharper now.
Test baseline: 555 pass + 9 expected `test_sap_result_pin[000565-*]`
fails (was 555 + 8; sap_score now in the failing set with cascade-
exposed BP main-wall gap surfaced). Cohort + golden fixtures
unaffected. Pyright net-zero on touched files (59 errors, matches
baseline).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
ed8fdc6ae3 |
Slice S0380.83: Extractor + mapper recognise Exposed / Connected gable_type per RdSAP 10 §3.10
The Elmhurst Summary PDF §8.1 "Room(s) in Roof" per-surface table publishes the
gable-wall environment column with one of four values:
Party → §8.1 party-wall row
Sheltered → §8.1 sheltered external row
Exposed → §8.1 exposed external row
Connected (to heated space) → §8.1 internal partition
Per RdSAP 10 §3.10 (PDF p.30-35) "Detailed Room-in-Roof" + Table 4 (p.22)
"Heat-loss surface variants":
- Exposed gable wall → external wall at the lodged U-value
- Sheltered gable wall → external wall at the lodged U-value
- Party gable wall → party wall at U=0.25 (Table 4 row 2)
- Connected gable wall → internal partition to heated space, NOT a
heat-loss surface
The extractor was only capturing `gable_type ∈ {"Party", "Sheltered",
"Connected to heated space"}` — neither `"Exposed"` (every external gable
on cert 000565) nor the plain `"Connected"` string (the actual PDF
lodging value, vs the verbose "Connected to heated space" form used on
other Summary schemas) was recognised. Both fell through with
`gable_type=None`, masking the downstream cascade gap (cert 000565
BP[0] Main Gable Wall 1 is lodged "Exposed" at U=0.35 but extracted
as untyped → mapper routes to `gable_wall` party at U=0.25, vs the
worksheet's "Roof room Main Gable Wall 1" at U=0.35).
This slice closes the extractor side only:
backend/documents_parser/elmhurst_extractor.py:_parse_rir_surface_row
expands its `gable_type` lookup set to include "Exposed" and the
plain "Connected" lodging value.
Mapper-side: `_map_elmhurst_rir_surface` (datatypes/epc/domain/mapper.py)
preserves cert 9501's behaviour — its flat-RR elif previously hinged
on `surface.gable_type is None and is_flat`; now extends to
`surface.gable_type in (None, "Exposed") and is_flat` so the same
flat-RR routing fires whichever lodging shape the Summary PDF uses.
Net cascade impact: zero. Cert 9501 (top-floor flat) retains its
RR-gables-as-external routing. Cert 000565 (house) keeps falling
through to the default `gable_wall` (party at U=0.25) routing for
"Exposed" + "Connected" gables — the next slice in the block reroutes
those to external walls + drops Connected surfaces per RdSAP 10
Table 4. This commit is pure data-extraction completion; pin
movement lands when S0380.84 wires the mapper through.
Test baseline: 555 pass + 8 expected `test_sap_result_pin[000565-*]`
fails (was 554 + 8 at S0380.82; one new test pins the spec rule).
Pyright net-zero on touched files (45 errors, matches baseline).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
509ef4fbbf |
Slice S0380.78: §1x.0 shower extractor + (247a) fallback cost close cert 000565 (45)m
Two coupled fixes that together close the +903 kWh (45)m
energy-content over-count on cert 000565. Splitting them would
flip sap_score from 29 → 30 mid-fix; bundled they keep cert 000565
within rounding of the worksheet (continuous SAP residual closes
17×, from Δ +0.60 to Δ −0.035).
## 1. Elmhurst extractor — §1x.0 section-bounded "Connected" lookup
`_extract_baths_and_showers` was anchoring on the FIRST "Connected"
substring in the document via `self._lines.index("Connected")`.
Cert 000565 (4 extensions) has "Connected" appearing earlier as a
§3 building-parts wall elevation flag, so the global match landed
on a wall row; the digit-check at `num_line.isdigit()` failed
immediately on the "0.00" wall length and the shower roster came
back empty.
Both `1x.0 Baths and Showers` and `18.0 Flue Gas Heat Recovery
System` are single-occurrence section anchors in the Elmhurst
Summary PDF. Routing the "Connected" lookup through `_section_
lines(...)` bounds the search to the §1x.0 block, so multi-
extension certs no longer lose the shower roster.
## 2. SAP 10.2 §10a line (247a) — electric shower cost in fallback path
SAP 10.2 §10a (PDF p.145) worksheet line (247a):
Energy for instantaneous electric shower(s)
(64a) × 0.01 = (247a)
Total energy cost (240)...(242) + (245)...(254) = (255)
Electric showers route their (64a) kWh through the "other fuel"
tariff (same column as pumps/fans (249) and lighting (250)) and
add to (255) total cost.
`calculator.py:415-470` STANDARD-tariff path consumes
`FuelCostResult` from `fuel_cost(...)` which already plumbs
`instant_shower_cost_gbp` (worksheet/fuel_cost.py:214). The
fallback scalar path at `calculator.py:489-530` (TEN_HOUR /
off-peak / zero-FuelCostResult certs) was missing the electric-
shower term entirely. Cert 000565 (Dual-meter TEN_HOUR + 1
electric shower) trips this branch — fix #1 surfaced the
£93/yr under-count and the sap_score regression that followed.
Fix: add
electric_shower_cost = inputs.electric_shower_kwh_per_yr
× inputs.other_fuel_cost_gbp_per_kwh
into the `total_cost = max(0, ...)` sum, parallel to the existing
`electric_shower_co2` and `electric_shower_pe` flows already
present in the CO2 (line 552) and PE (line 619) sections.
## Why bundled
SAP 10.2 Appendix J §J2 step 2a (PDF p.81) routes baths via
`N_bath = 0.13 N + 0.19` when a shower is present, `0.35 N + 0.50`
when no shower is present — a 2.67× swing in (42b)m that
compounds into (45)m energy content. The extractor fix closes
(45)m to EXACT (1286.3266 = 1286.3266 ✓), but the cascade's
electric-shower kWh stream becomes load-bearing for cost — and
the fallback path was silently dropping it. Without fix #2,
sap_score regressed from 29 → 30 (cost too low → ECF too low →
SAP rating too high).
## Cert 000565 movements at HEAD (post-S0380.77 → post-this slice)
| Field | Pre-slice | Post-slice | Worksheet | Pre-Δ | Post-Δ |
|----------------------|----------:|------------:|-----------:|--------:|--------:|
| sap_score | 29 | 28 | 29 | 0 | −1 |
| sap_score_continuous | 29.1090 | 28.4735 | 28.5087 | +0.60 | **−0.035** |
| ecf | 5.3256 | 5.3904 | 5.3866 | −0.06 | **+0.004** |
| total_fuel_cost_gbp | 4627.10 | 4683.39 | 4680.26 | −53.16 | **+3.13** |
| co2_kg | 6616.0 | 6480.6 | 6447.6 | +168.4 | +32.94 |
| hot_water_kwh | 5154.0 | 4014.6 | 3755.0 | +1399 | +259.6 |
| space_heating_kwh | 58725.8 | 58793.0 | 59008.4 | −282.6 | −215.4 |
| main_heating_fuel | 34544.6 | 34584.1 | 34710.8 | −166.2 | −126.7 |
| (45)m sum | 2189.38 | **1286.33**| 1286.3266 | +903 | 0 |
The integer sap_score = 28 vs worksheet = 29 is a rounding-
boundary artifact: continuous SAP at 28.4735 rounds DOWN, just
0.035 below the 28.5 threshold. The remaining +259 kWh HW pin
over-count traces to the still-open (56)m storage loss over-count
+ missing (57)m solar-storage adjustment (slice C per the
handover) — closing that pulls continuous SAP back above 28.5 and
restores integer 29.
## Tests
- `test_summary_000565_extractor_finds_electric_shower_in_section_1x_0`
(test_summary_pdf_mapper_chain.py) — pins extractor finds the
Electric shower in §1x.0 even with §3 building-parts "Connected"
collisions earlier in the document.
- `test_total_fuel_cost_includes_247a_electric_shower_in_fallback_path`
(test_calculator.py) — pins `total_fuel_cost_gbp` rises by
exactly `kwh × other_fuel_cost` when `electric_shower_kwh_per_yr`
is non-zero in the fallback path.
Test baseline: 547 → 570 pass (+3 new tests across the 4 modified
files + indirect knock-ons in golden fixtures); 9 → 10 expected
`test_sap_result_pin[000565-*]` fails (now includes the integer
`sap_score` until slice C closes the remaining +259 kWh HW
residual). Pyright net-zero on all 4 touched files (50 baseline =
50 after).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
6b02bad018 |
Slice S0380.64: Elmhurst per-extension wall_construction mappings + strict-raise
Pre-S0380.64 the mapper silently fell through to wall_construction=None
on three Elmhurst code lodgements that the cohort PDFs use:
- "SG Stone: granite or whinstone" (cert 000565 Ext1)
- "B Basement wall" (cert 000565 Ext3 + Ext4)
- "CF Cavity masonry filled" party wall (cert 000565 Ext1)
Cascade impact on cert 000565 (vs U985-0001-000565.pdf worksheet):
- sap_score 30 → 29 EXACT (was Δ +1)
- sap_score_continuous 30.23 → 29.14 (Δ +1.72 → +0.63)
- space_heating_kwh_per_yr 57909 → 59274 (Δ −1100 → +266)
- HTC 1281 → 1321 W/K (was 234 W/K short
of worksheet line 39 monthly avg 1515.38)
Spec basis:
- SG → 1 (WALL_STONE_GRANITE per domain.sap10_ml.rdsap_uvalues)
is the granite-specific Elmhurst variant of "ST Stone"; same
SAP10 enum, no cascade behaviour change for stone walls.
- B → 6 (BASEMENT_WALL_CONSTRUCTION_CODE per
datatypes/epc/domain/epc_property_data.py:361) routes the
cascade through `part.main_wall_is_basement` →
`u_basement_wall(age_band)` per RdSAP 10 §5.17 / Table 23
(heat_transmission.py:640). Empirically established from a
2026 50k-bulk GOV.UK API sweep (88% co-occurrence with
walls[].description = "Basement wall").
- CF → 4 (Cavity, RdSAP 10 Table 15 row 3 spec U=0.20). The
cascade's `u_party_wall` returns 0.0 / 0.5 / 0.25 for code 4
today, so CF conservatively rounds up to the cavity-unfilled
U=0.5 — matches the pre-existing
`_API_PARTY_WALL_CONSTRUCTION_TO_SAP10[3]` approximation
until `u_party_wall` gains a filled-cavity branch (TODO).
Strict-coverage gate per [[reference-unmapped-api-code]] mirror:
`_elmhurst_wall_construction_int` and
`_elmhurst_party_wall_construction_int` now raise
`UnmappedElmhurstLabel` on a non-empty Elmhurst code that isn't in
the lookup dict, rather than silently returning None. Empty
lodgings (absent fields) continue to return None — the cascade's
own defaults apply. The silent-None failure mode is what hid cert
000565's ~300 W/K cascade fabric-loss gap from the audit chain
until the S0380.64 space-heating residual probe surfaced it.
Cohort coverage swept: every Summary PDF in the test fixtures
folder lodges only {SO, CA, CW, SG, B} wall types and
{'', S, U, CU, CF} party-wall types — the new dict entries cover
all observed codes, so strict-raise does not regress any cohort
fixture (478 pass, 9 expected 000565 cascade-gap fails; was 427
pass + 10 fails per HANDOVER_CERT_000565_COST_CASCADE.md).
Pyright net-zero on touched files (mapper.py 32 → 32 errors;
test_summary_pdf_mapper_chain.py 13 → 13 errors — all pre-existing
in unrelated sections).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
b7fbbcca96 |
Slice S0380.51: strict-raise UnmappedApiCode on API integer enums
Mirrors the Elmhurst `UnmappedElmhurstLabel` coverage gate on the GOV.UK API path. The same failure mode (silently routing an unknown enum to a default / None hides cascade gaps until a downstream SAP- delta investigation surfaces them) was hitting the API mapper: existing helpers like `_api_floor_construction_str` returned None on unrecognised codes per the comment "Only the values observed across the 10 golden fixtures (1, 2) are mapped; unrecognised codes fall through to None." Adds `UnmappedApiCode(ValueError)` at the API mapper boundary and threads it through five strict helpers: - `_api_party_wall_construction_int` (RdSAP10 Table 15) - `_api_floor_construction_str` (Slice 88 floor signal) - `_api_floor_type_str` (RdSAP10 §5 rule (12)) - `_api_roof_construction_str` (Slice 89 cos(30°) factor) - `_api_sheltered_sides` (SAP10.2 §S5) Each helper distinguishes: - "lodging absent" → return None (unchanged behaviour) - "lodging present and mapped" → translate (unchanged behaviour) - "lodging present but unrecognised" → raise UnmappedApiCode (NEW) Two coverage gaps surfaced immediately at strict-run, both fixed in the same slice with the worksheet-backed lodged-floor descriptions: 1. `floor_heat_loss=2` — cert 7536 Main lodges this (floors[] description "To unheated space, insulated"); also lodged on cert 2031 / etc. Added mapping → "To unheated space". 2. `floor_heat_loss=3` — cert 7536 Ext2 lodges this with the same floors[] description as Main code 2 — same cascade signal. 3. `floor_heat_loss=6` — cert 9501 + cert 9390 (top-floor flats) lodge this with floors[] description "(another dwelling below)". The cascade routes party-floor handling via property_type=Flat + cert.floors[] description independently of this string, so the explicit None entry preserves the cascade match (cert 9501 stays at exact 1e-4 SAP vs worksheet 68.5252) while distinguishing "decided no string" from "unknown". Six new tests document the contract: - Five unit tests inject an out-of-range integer (99) into a real cohort cert JSON and assert UnmappedApiCode raises with the right `field` and `value`. - One coverage forcing function (`test_all_golden_fixtures_extract _via_api_without_unmapped_code_raise`) loops every JSON under `fixtures/golden/` through `from_api_response` and asserts no raise — future fixtures with unmapped enums fail this test until a dict entry is added. 763 → 769 pass + 0 fail (5 unit + 1 cohort-coverage test added). Pyright net-zero (32 → 32 baseline preserved). The pattern is ready to extend to other silently-falling-through helpers — e.g., `_api_glazing_transmission` (codes 4-12, 15+ noted in the existing comment as "not yet mapped — incremental coverage as new fixtures surface them"), `_api_cascade_glazing_type` (pass- through is intentional, so probably leave alone). Each addition is its own slice. |
||
|
|
6dccb15b03 |
Slice S0380.43: SAP 631 open-fire → House coal spec fuel — closes cert 2102
Cert 2102 lodges `secondary_heating_type=631` ("Open fire in grate"
per SAP 10.2 Appendix M Table 4a, BS EN 13229:2001 inset-appliance
class — solid fuel) but `secondary_fuel_type=33` (electricity, Table 32
off-peak 7hr) — physically incompatible (an open fire grate doesn't
run on electricity). The Elmhurst Summary path independently resolves
to Coal (Table 32 code 11) via the §15 "Secondary Fuel: Coal" lodgement
(see `test_summary_2102_secondary_heating_routes_house_coal_for_open_fire`).
API mapper now applies the same spec-derived default via the new
`_api_secondary_fuel_type` helper:
- When `secondary_heating_type` is in the
`_API_SECONDARY_HEATING_SPEC_FUEL` dispatch (currently {631: 11}),
AND the lodged `secondary_fuel_type` is electric (codes 30-40),
substitute the spec default (House coal).
- Legitimate non-default solid-fuel lodgement (e.g. SAP 631 with
lodged fuel_type=15 Wood logs) passes through unchanged.
The override is keyed on the heating-type → spec-fuel dispatch dict
(extend as new fixtures surface analogous inconsistencies), not a
blanket per-code rewrite — keeps the lodged data trusted by default
while spec-correcting the narrow class of inconsistent lodgements.
Applied at all 6 API schema-version mapping sites in `from_api_response`
via replace_all (lines 637/767/922/1080/1278/1544). Worksheet target
for cert 2102: line (242) "Space heating - secondary 3585.24 × 3.6700
= 131.58" confirms 3.67 p/kWh = Table 32 fuel code 11 (House coal).
Test impact:
- Cohort-2 cert 2102 API path: -6.30 → +4.9e-5 (<1e-4 ✓).
Moves from `_COHORT_2_API_OPEN` to `_COHORT_2_API_CLOSED`.
- `_COHORT_2_API_OPEN` is now empty — the residual-pin test
`test_api_cohort_2_open_cert_residual_matches_current_pin` is
deleted (cohort fully closed; re-add if future cert surfaces).
- Cohort-2 API path: **38/38 < 1e-4** matching Summary path 38/38.
Cross-mapper parity at the cascade is fully established for
cohort-2 per [[feedback-cross-mapper-parity-via-cascade]].
- Cohort-1 ASHP 9/9 unchanged.
Test suite: 750 pass + 0 fail. Pyright net-zero on touched files
(mapper.py 32/32 baseline; chain test 0/0).
Spec citations:
- SAP 10.2 Appendix M Table 4a code 631 "Open fire in grate"
(Category C, Room heaters, eff 37/32%, solid fuel via BS EN
13229:2001 inset-appliance class — see spec p.156).
- SAP 10.2 Table 32 code 11 "House coal" 3.67 p/kWh.
- Cert 2102 worksheet line (242) reproduces 131.58 = 35.84 × 3.67
confirming house-coal pricing for the secondary cascade.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e1b7b30c40 |
Slice S0380.42: Decimal HALF_UP per-window areas per RdSAP10 §15 — closes cert 1536
Cert 1536 lodged window dimensions including (0.65 × 0.70) × 3
windows. In float arithmetic 0.65 × 0.70 = 0.45499999999999996,
which the `_round_half_up(float, dp)` helper snaps to 0.45 vs the
spec answer 0.46 (Decimal: 0.65 × 0.70 = 0.4550 exact, HALF_UP at
2 d.p. = 0.46). The shortfall of 0.01 m² × 3 windows = 0.03 m²
under-counted as ~0.073 W/K of conduction loss vs the worksheet's
windows_w_per_k = 25.6354 — closing the cert 1536 residual at
+0.00152 to <2e-6.
Same class of bug as the S0380.34/35 living-area / gross-wall /
party-wall closures (Decimal HALF_UP at the 0.005 boundary that
float drops). RdSAP10 §15 (p.66) lists "all element areas (gross)
including window areas: 2 d.p." — Decimal is the only arithmetic
that matches that boundary deterministically.
Three cascade sites now use Decimal HALF_UP for per-window areas:
- heat_transmission.py: `_decimal_round_half_up_product(W, H, 2)`
replaces `_round_half_up(W × H, 2)` at the windows_w_per_k cascade
AND at the per-bp window-area accumulation (the wall-net deduction
branch must agree with the conduction branch for cascade-internal
consistency, per the existing comment at line 575-583).
- internal_gains.py: `_decimal_window_area_2dp(W, H)` replaces the
inline `_round_area_2dp(W × H)` in the daylight factor `g_l`
sum so §5 (66)..(67) sees the same per-window areas as §3 (27).
- solar_gains.py: same Decimal helper replaces `_round_area_2dp` in
`_wall_window_solar_gain_monthly_w` so §6 (74)..(81) area = (27).
The `_round_area_2dp` helpers were inlined per-module in pre-S0380.42
work; this slice deletes them since the Decimal-aware product
replaces all call sites. `_round_half_up` stays in heat_transmission
for non-product per-element area calls (single-value rounds).
Test impact:
- Cohort-2 cert 1536 API path: +0.00152 → -1e-6 (<1e-4 ✓).
Moves from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED. Cohort
distribution: 37/38 exact (was 34/38 at start of session);
only cert 2102 (-6.30 secondary-heating routing) remains open.
- Cohort-2 cert 0300/9380 unchanged (already <1e-4 after S0380.41).
- Cohort-1 ASHP 9/9 unchanged: <1e-4 on both paths.
- Elmhurst 6-cert worksheet sweep: unchanged (lodges
`window_width=area, window_height=1.0` per the Elmhurst lodging
convention — Decimal(area) × Decimal(1.0) = Decimal(area), no
rounding shift).
Test suite: 750 pass + 0 fail. Pyright net-zero per touched file
(heat_transmission 13/13; internal_gains 4/4 pre-existing; solar_gains
0/0; chain test 0/0).
Spec citation: RdSAP 10 Specification §15 "Rounding of data" p.66 —
"All element areas (gross) including window areas and conservatory
wall area: 2 d.p." Decimal is the float-precision-stable arithmetic
that matches this rule at the .005 boundary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
a96e6765ab |
Slice S0380.41: GOV.UK RdSAP 21 glazing-type code 1 → DG pre-2002 cascade
Closes the cohort-2 API-path +0.42..+0.44 cluster (certs 0300/9380
closed to <1e-4; cert 1536 partially closed +0.4445 → +0.0015 — a
sub-2e-3 secondary tail remains for Slice S0380.42).
Root cause: per `datatypes/epc/domain/epc_codes.csv` the GOV.UK API
schema RdSAP-Schema-21.0.0 defines `glazed_type=1` as "double glazing
installed before 2002 in EAW, 2003 in SCT, 2006 NI". Three cohort-2
certs (0300/1536/9380) lodge this code with `glazing_gap=16+` and
description "Fully double glazed" — but the API mapper passed the
raw code straight through to SapWindow.glazing_type, and:
1. `_api_glazing_transmission` had no (1, "16+") entry, so the
U-value lookup returned None and the cascade defaulted to U=2.5
instead of the spec-correct U=2.7 (RdSAP 10 Table 24 row 2,
PVC/wooden frame, 16+ gap = 2.7).
2. The cascade's `_G_LIGHT_BY_GLAZING_CODE` table is keyed on the
SAP 10.2 Table 6b enum (the Elmhurst extractor produces this
enum via `_ELMHURST_GLAZING_LABEL_TO_SAP10`), where code 1 means
"single glazed" (g_L=0.90). Passing RdSAP 21 code 1 straight
through gave the cascade the wrong g_L for the daylight factor
calculation, off by 0.90 vs spec 0.80.
Both gaps closed in one slice because they're the same misinterpretation:
- `_API_GLAZING_TYPE_TO_TRANSMISSION` + `_API_GLAZING_TYPE_GAP_TO_
TRANSMISSION` now alias code 1 as a schema sibling of code 3 — both
resolve to RdSAP 10 Table 24 row 2 ("DG pre-2002 / unknown install
date"). Per-gap entries cover the full 6mm=3.1 / 12mm=2.8 / 16+=2.7
row; type-only fallback uses the 12mm default U=2.8.
- New `_API_TO_SAP10_CASCADE_GLAZING_CODE = {1: 2}` remap is applied
in `_api_sap_window` AFTER the U-value lookup, so SapWindow.glazing_
type carries the SAP 10.2 cascade enum (code 2 = DG pre-2002 air-
filled, g_L=0.80) while the U lookup stays keyed on the raw GOV.UK
API code. The cohort-1 codes 2/3/13/14 already coincide with the
cascade table's intended SAP 10.2 g_L values, so no remap entry
required for them; only divergent codes get a remap.
Test impact:
- Cohort-2 API path: 34/38 → 36/38 at 1e-4 (0300 +4.8e-5; 9380 -5e-6
both move from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED).
- Cert 1536 pin updated from 66.337334 to 65.894324; ws Δ now +0.0015
(was +0.4445) — same root-cause fix dominated, residual tail is
distinct-cause work for the next slice.
- Cert 2102 unchanged (-6.30 residual, secondary-heating routing gap).
- Cohort-1 (9 ASHP certs) unaffected: 9/9 still < 1e-4 on both paths.
Test suite: 750 pass + 0 fail. Pyright net-zero per touched file.
Spec citations:
- RdSAP-Schema-21.0.0 glazed_type=1 → datatypes/epc/domain/epc_codes.csv
- RdSAP 10 Specification §8.2 Table 24 (p.49) row 2 "Double glazed:
Installed England/Wales before 2002 / Scotland before 2003 /
N. Ireland before 2006" — U=2.7 (PVC/wooden, 16+ gap).
- SAP 10.2 Table 6b: DG air-filled g_L=0.80 (vs single 0.90).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
ff25746f44 |
Slice S0380.40: parametrized API-path chain sweep for cohort-2 (34/38 at 1e-4)
Mirror of the cohort-2 Summary-path sweep that closed across
S0380.30..38: for each of the 38 cohort-2 certs whose API JSON was
fetched in S0380.39, drive the full API chain (`from_api_response`
→ `cert_to_inputs` → `calculate_sap_from_inputs`) and assert
`sap_score_continuous` vs the worksheet's lodged SAP at abs <= 1e-4.
Per cross-mapper parity ([[feedback-cross-mapper-parity-via-cascade]]):
the SAP cascade is the load-bearing equivalence check between
EpcPropertyData produced by from_api_response and from_elmhurst_site_notes.
If both paths hit the worksheet at 1e-4, they're cascade-output-
equivalent for load-bearing fields — strictly stronger than a noisy
structural EpcPropertyData diff.
Two parametrized tests, both green at HEAD:
- test_api_cohort_2_full_chain_sap_matches_worksheet_at_1e_minus_4:
34 certs that hit the worksheet at 1e-4 on the API path immediately
(the cascade can't tell which mapper produced the EPC).
- test_api_cohort_2_open_cert_residual_matches_current_pin:
4 certs that don't yet hit 1e-4 — pinned at their current cascade
output as forcing functions per [[project-api-to-sap-residual-test]].
When a follow-up slice closes the underlying mapper/spec gap, the
cascade output moves and the pin fires, forcing the cert to migrate
from _COHORT_2_API_OPEN to _COHORT_2_API_CLOSED.
Open cohort residuals (handover to Slice C+):
- 0300/1536/9380: tight +0.42..+0.44 band — likely a single shared
cascade-spec gap (API-mapper-specific, since Summary path hits 1e-4)
- 2102: -6.30 — Summary test (test_summary_2102_secondary_heating_
routes_house_coal_for_open_fire) shows the cert lodges house-coal
open-fire secondary heating; API mapper likely routes secondary
fuel differently. Probe `secondary_heating` block first.
Test suite: 712 → 750 pass (0 fails). Pyright net-zero on touched file.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1cea73df7c |
Slice S0380.37: drop cert 001479 hand-built fixture — covered by passing production-path chain tests
Cert 001479 was added in
|
||
|
|
b0919e8d6f |
Slice S0380.36: tighten _ASHP_COHORT_CHAIN_TOLERANCE 0.04 -> 1e-4 after S0380.31 closes cohort
Cohort-1 ASHP cohort residuals at HEAD
|
||
|
|
86226ebdb6 |
Slice S0380.31: deduct alt-wall window opening from (31) net external area — closes cert 2636 cantilever residual -0.015 → -2.4e-6
SAP 10.2 Appendix K eqn (K2) p.84:
HTB = y × Σ(Aexp)
where Aexp is "the total area of external elements calculated at
worksheet (31)". The worksheet (31) column header reads "Total NET
area of external elements" — net of openings.
Cert 2636 (dr87-0001-000898 line 187): (31) = 160.33 m² =
47.70 main net + 11.57 alt net + 42.92 roof + 39.18 ground floor
+ 3.74 cantilever + 11.52 windows + 3.70 doors.
Pre-fix cascade summed the alt-wall at its 12.76 m² gross (no
opening deduction) — (31) was 161.52, driving (36) to 24.228 vs
worksheet 24.0495 (Δ +0.1785 W/K). That drift propagated through
(39) HTC → MIT → space heating, leaving cert 2636 at Δ -0.015
SAP — the only ASHP cohort cert above the 1e-4 floor.
`alt_walls_total_area` aggregates per-alt-wall gross at line 736;
this slice subtracts `alt_window_area` from it in the (31) sum so
the alt-wall contribution is net, matching the (29a) net-area
convention already applied per-element to the A×U sums.
Cohort-1 ASHP cohort: 9/9 certs < 1e-4 Summary path (was 8/9 with
cert 2636 at -0.015). Cert 2636 API path also closes to < 1e-4 —
the bug was path-symmetric in the cascade, not in either mapper.
Cohort-2 unchanged at 33 exact + 5 ≤0.07.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
e27b923bca |
Slice S0380.29: tighten _ASHP_COHORT_CHAIN_TOLERANCE 0.07 → 0.04
Post-S0380.28 (Appendix N footnote 43 reciprocal η interpolation), the
ASHP-cohort chain-test residuals collapsed:
Summary path:
cert 0380: +0.000001 (was +0.034)
cert 0350: +0.000022 (was ~+0.046)
cert 2225: -0.000048 (was ~+0.044)
cert 2636: -0.014945 (was ~+0.003 — cantilever-specific)
cert 3800: -0.000020 (was +0.021)
cert 9285: -0.000034 (was +0.021)
cert 9418: -0.000000 (was +0.00004)
API path (cohort handover thread 4 — open):
cert 0380: +0.025273
cert 0350: +0.030594 (worst)
cert 2225: +0.028517
cert 2636: +0.014705
cert 3800: +0.023327
cert 9285: +0.028674
The previous 0.07 tolerance gave 130%+ headroom over the pre-slice
worst residual; with S0380.28 closing the cluster the same tolerance
gives 130%+ headroom over the post-slice API worst (0.031), letting
regressions hide for a long time before firing.
0.04 gives ~30% headroom over the API path's worst residual (cert
0350 +0.0306) and ~170% over the Summary path's worst (cert 2636
-0.015 — the cantilever fixture). Fires loudly on any regression
beyond the documented API-path residual cluster.
Tightens 15 chain tests (8 Summary path + 7 API path). All pass.
Tests: 710 pass (unchanged), 10 expected fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
012cbd183f |
Slice S0380.27: thread floor_construction_type into _main_floor_u_value — closes cert 9796 +0.55 → +0.00174
Per RdSAP10 §5 page 29 "Floor infiltration (suspended timber ground
floor only)":
Age band A-E:
a) if floor U-value < 0.5, assume "sealed" → 0.1
b) if retro-fit + no U → "sealed" → 0.1
otherwise "unsealed" → 0.2
The cascade routes the (12) sealed/unsealed verdict through
`_main_floor_u_value`, which calls `u_floor` to compute the BS EN ISO
13370 U-value the spec rule keys on. That helper was a stale duplicate
of the real heat-transmission path that did NOT respect the per-bp
`floor_construction_type` lodgement:
Pre-slice: u_floor(construction=int_or_None, description=None, ...)
Cascade: u_floor(construction=int_or_None, description="Suspended
timber" if floor_construction_type else <fallback>, ...)
For cert 9796-3058-6205-0346-9200 (Mid-Terrace bungalow age D,
46.87 m² / 15.0 m perimeter, suspended-timber lodged):
- Broken `_main_floor_u_value` routes through the solid default
(no description, construction=None) → BS EN ISO 13370 solid →
U=0.49 W/m²K.
- 0.49 < 0.5 → spec rule (a) fires → (12) = 0.1 (sealed).
- Real heat-transmission cascade routes through the suspended branch
via `effective_floor_description = floor_construction_type` →
U=0.56 → unsealed → (12) = 0.2.
The 0.1 ach gap then propagated:
(18) infiltration_rate 0.74 → ws 0.84 (cascade -0.10)
(25)m Jan 0.82 → ws 0.91 (cascade -0.09)
(38)m Jan 29.08 W/K → ws 32.37 (cascade -3.29 W/K)
(39) Jan 110.35 W/K → ws 113.64 (cascade -3.29 W/K)
HLP Jan 2.35 W/m²K → ws 2.42 (cascade -0.07)
T_h2 Jan 19.11°C → ws 19.07 (cascade +0.04)
MIT Jan 18.51°C → ws 18.45 (cascade +0.06)
SAP +0.55 vs worksheet 90.13.
Fix mirrors heat_transmission's `effective_floor_description` rule in
`_main_floor_u_value`: the per-bp `floor_construction_type` takes
precedence over a joined `epc.floors[].description` because it's the
explicit Elmhurst Summary §3/§9 surface. Inlined the description join
(vs importing `_joined_descriptions` from heat_transmission) so
cert_to_inputs stays free of cross-module private-symbol imports.
Cohort-2 outcome (38 certs, Summary path):
exact (<1e-4): 23 → 23
≤±0.07: 14 → **15** (+1: cert 9796 +0.55 → +0.00174)
±0.5..1: 1 → **0** (last cohort-2 mid-range gap closes)
The remaining cert 9796 +0.00174 SAP residual is the cohort-1 HP-COP
precision floor (the same +0.001..+0.04 SAP that the other 10
triple-glazed HP certs sit at; see handover thread 3).
Cohort-1 golden fixture cert 8135-1728-8500-0511-3296 (Semi-detached
age C, suspended-timber ground floor with floor_construction=2 lodged
but description=None pre-slice) had the same bug:
Pre-slice: u_floor returned 0.48 (solid branch via construction=2
present-but-not-suspended) → false sealed verdict (12)=0.1
Post-slice: u_floor returns 0.54 (suspended branch via description=
"Suspended timber") → correct unsealed verdict (12)=0.2
PE residual: -4.9611 → **-0.0748** kWh/m² (+4.89 closer to API EPC)
CO2 residual: -0.0678 → **+0.0246** t/yr (closer to API EPC)
SAP residual: 0 → 0 (unchanged, EPC integer)
Pin updated on cert 8135 to reflect the new (correct) cascade-vs-API
alignment; no other golden fixtures shifted.
Pyright net-zero per touched file:
cert_to_inputs.py: 35 → 35
tests/test_cert_to_inputs.py: 13 → 12 (suppressed pre-existing
private-import error on
_water_heating_worksheet_and_gains
at the same time as adding
suppressions for the two new
private imports)
tests/test_golden_fixtures.py: 1 → 1
tests/test_summary_pdf_mapper_chain.py: 0 → 0
Tests: 708 → 710 pass (+2 new: `_main_floor_u_value` routes
suspended-timber via per-bp lodgement; cert 9796 chain pin against
worksheet 90.1318 within ±0.07 ASHP-cohort spec floor), 10 expected
fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
c144d444e2 |
Slice S0380.26: RdSAP10 §5.8 dry-lining adjustment on alt walls — closes cert 7700 -0.44 → +5e-5
Per RdSAP10 §5.8 final note + Table 14 page 41:
"For drylining including laths and plaster use Rinsulation = 0.17 m²K/W."
Applied additively to the base U-value of an otherwise-uninsulated wall:
U_adjusted = 1 / (1/U_base + 0.17) — rounded to 2 d.p. half-up.
Closed form for the cohort fixture (cavity-as-built age C, U_base=1.5):
1 / (1/1.5 + 0.17) = 1.19522... → 1.20 ✓ matches worksheet
Cert 7700-3362-0922-7022-3563 (Summary_000905.pdf / dr87-0001-000905.pdf)
is an End-Terrace house age C lodging:
- Main wall: CavityWallDensePlasterDenseBlock, Filled Cavity, U=0.70
- Alt wall 1: 14.44 m² Cavity As-Built, Dry-lining: Yes (worksheet
`CavityWallPlasterOnDabsDenseBlock`, U=1.20)
Pre-slice the Elmhurst alt-wall mapper hard-coded `wall_dry_lined="N"`
and the cascade ignored the field everywhere — alt-wall U routed to the
cavity-as-built default (1.50), giving fabric (33) 148.72 W/K vs
worksheet 144.38 (Δ +4.33 W/K = ~+0.44 SAP). Worksheet "SAP value" line
lodges unrounded SAP 63.4425.
Implementation:
1. `AlternativeWall.dry_lined: bool = False` on the Elmhurst surveys
dataclass.
2. Elmhurst extractor reads "Alternative Wall N Dry-lining: Yes/No"
into the new field.
3. `_map_elmhurst_alternative_wall` propagates `wall_dry_lined="Y"`
instead of the hard-coded "N".
4. `u_wall` gains a `dry_lined: bool = False` kwarg and a single
§5.8 adjustment site at the as-built bucket (bucket=0). Insulated
buckets already absorb the dry-lining R via Table 14.
5. `_alt_wall_w_per_k` passes `dry_lined=alt_wall.wall_dry_lined == "Y"`.
Scope is the alt-wall path only — main BPs in the corpus all lodge
`wall_dry_lined="N"` (or the Summary PDF omits the field for the main
wall), so the main-wall call site is untouched. Conservative regression
posture per the user's strict cohort-pin convention.
Cohort-2 outcome (38 certs, Summary path):
exact (<1e-4): 22 → **23** (+1: cert 7700 -0.44 → +4.87e-05)
0.07..0.5: 1 → **0** (-1: cert 7700 closes out)
0.5..1: 1 → 1 (cert 9796 unchanged — MIT precision floor)
RAISES: 0 → 0
Cohort-1 ASHP cohort untouched: all certs lodge wall_dry_lined="N", so
the alt-wall call site short-circuits to the original cascade. Verified
no regressions across the 22 previously-exact cohort-2 certs either.
Pyright net-zero on all 8 touched files (183 → 183).
Tests: 704 → 708 pass (+4 new: u_wall §5.8 adjustment fires
correctly; cavity-as-built unchanged without flag; insulated bucket
unaffected by flag; heat_transmission alt-wall delta = 14.44 × 0.30
W/K; cert 7700 full chain hits worksheet 63.4425 at < 1e-4),
10 expected fails unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|