Commit graph

34 commits

Author SHA1 Message Date
Khalim Conn-Kowlessar
9f0d23adc6 Slice S0380.170: Community heating mapper unblock (Table 12 dispatch)
Closes the 5 community-heating variants in the heating-systems corpus
(community heating 1/2/3/4/6 on property 001431). Pre-slice the
mapper returned `MainHeatingDetail.main_fuel_type=''` for every
community-heating cert because §14.0 lodges no Fuel Type — only EES
'COM' + a Table 4a heat-network SAP code (301/302/304). The cascade
strict-raised `MissingMainFuelType` per S0380.132. The actual fuel
that bills the cascade lives in the §14.1 Community Heating/Heat
Network block, which the extractor was skipping entirely.

SAP 10.2 Table 12 (PDF p.189) defines the heat-network fuel codes:

  Boilers + Mains Gas        → 51 (heat from boilers — mains gas)
  Boilers + Mineral oil      → 53 (heat from boilers — oil)
  Boilers + Coal             → 54 (heat from boilers — coal)
  Boilers + Biomass          → 43 (heat from boilers — biomass)
  Combined Heat and Power    → 48 (heat from CHP; fuel-agnostic)
  Heat pump + Electricity    → 41 (heat from electric heat pump)

Per spec text the upstream fuel determines the boiler-side code; CHP
is fuel-agnostic at the Table 12 cost / CO2 / PE level.

Three layers wired:

1. Survey schema — new `CommunityHeating` dataclass alongside
   `MainHeating2` carrying the §14.1 fields (heating_type,
   community_heat_source, community_fuel_type, heating_controls_ees,
   heating_controls_sap, chp_fuel_factor). Mutually exclusive with
   `main_heating_2` at the §14.1 level. Attached as
   `MainHeating.community_heating: Optional[CommunityHeating] = None`.

2. Extractor — new `_extract_community_heating()` method bracketed by
   "14.1 Community Heating/Heat Network" / "14.2 Meters". Returns
   None on individually-heated dwellings (no Community Heat Source
   lodged). Wired into `_extract_main_heating()`.

3. Mapper — new `_resolve_community_heating_fuel_code(heat_source,
   fuel)` dispatch helper + `_ELMHURST_COMMUNITY_BOILER_FUEL_TO_TABLE_12`
   constant for the boiler upstream-fuel split. Wired in
   `_map_elmhurst_sap_heating` after the EES-code-to-fuel dispatch
   and before the strict-raise on absent SAP code.

Per the standard slice workflow + [[feedback-aaa-test-convention]]:

- 5 new AAA tests in `test_community_heating_mapper_resolves_table_12_
  fuel_code` parametrized over the 5 corpus variants, asserting the
  mapper resolves the expected Table 12 code per variant.

- The existing parametrized residual-pin test in
  `test_heating_systems_corpus_residual_matches_pin` picks up the
  5 community-heating variants with cascade-side residuals pinned as
  forcing functions for follow-up slices:

      variant            dSAP    dcost     dCO2     dPE
      CH1 (Boilers/Gas)  +0.59   -£14    -787    -3827
      CH2 (CHP/Gas)      +4.50  -£104   -1430    +1506
      CH3 (HP/Elec)      +0.59   -£14   +1614   +11879
      CH4 (CHP/Oil)      +4.50  -£104   -4397     +495
      CH6 (CHP/Coal)     -3.52   +£81   -2935    +7865

  These reflect open cascade-side work (SAP 10.2 Appendix C CHP/
  boiler heat-fraction split missing — cascade treats CHP+Boilers as
  100% CHP; community-HP COP cascade missing — cascade doesn't divide
  delivered heat by COP for Table 12 code 41; heat-network overall
  CO2/PE blended-factor cascade missing — cascade doesn't compute
  worksheet rows (386)/(486)). Pinned per [[feedback-zero-error-strict]];
  follow-up slices close gaps and re-pin smaller residuals.

- `_BLOCKED_BY_MISSING_MAIN_FUEL_TYPE` tuple now empty; the
  blocked-tier test pytest-skipped via `pytest.mark.skipif` with a
  reason naming this slice.

Test baseline at HEAD: 921 pass + 1 skipped (was 916 + 0 at
predecessor 7e08e7af). Pyright net-zero on affected files
(elmhurst_site_notes.py, elmhurst_extractor.py, mapper.py,
test_heating_systems_corpus.py): 32 → 32.

Per [[feedback-spec-citation-in-commits]] the dispatch is grounded
in SAP 10.2 Table 12 (PDF p.189). Per
[[feedback-bigger-slices-for-uniform-work]] all 5 variants land in
one slice — the work is uniform (single Elmhurst label dict + single
dispatch helper) and the per-variant residuals surface together
because of cascade-side gaps, not mapper-side variation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 10:50:21 +00:00
Khalim Conn-Kowlessar
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>
2026-05-31 10:04:28 +00:00
Khalim Conn-Kowlessar
a0413155ae Slice S0380.102: Wire MEV decentralised cascade into pumps_fans (SAP 10.2 §2.6.4 + Table 4f line 230a)
SAP 10.2 Table 4f line (230a) annual electricity for mechanical
ventilation fans, decentralised MEV branch:

    E_fans_kwh = SFPav × 1.22 × V

where SFPav is the §2.6.4 equation (1) flow-weighted average SFP
across every fan in the installation, with PCDB Table 322 supplying
per-configuration (flow, SFP) and PCDB Table 329 supplying the
ducting-type IUF.

This slice composes the foundation slices S0380.98 (Table 322),
S0380.99 (Table 329), S0380.100 (SFPav helper) into a cert-driven
cascade — `_mev_decentralised_kwh_per_yr_from_cert(epc)` reads:

    MV PCDF Reference Number  → PCDB Table 322 record (per-config SFP)
    Duct Type (Flexible/Rigid) → PCDB Table 329 in-use factor
    Wet Rooms count           → per-fan-type count distribution

Three coupled changes:

1. Elmhurst extractor + schema — `_extract_ventilation` parses §12.1
   "MV PCDF Reference Number", "Wet Rooms", "Duct Type", "Approved
   Installation". New fields on `VentilationAndCooling`.
2. Mapper — plumbs the lodgements through to
   `EpcPropertyData.mechanical_ventilation_index_number`,
   `.wet_rooms_count`, `.mechanical_vent_duct_type`. New
   `_elmhurst_mv_duct_type_int` helper (Flexible→1, Rigid→2 per PCDF
   Spec §A.20 field 12 convention) with strict-raise on unknown
   labels per [[unmapped-elmhurst-label]].
3. Cascade — `_table_4f_additive_components` calls the new
   `_mev_decentralised_kwh_per_yr_from_cert(epc)` to add the (230a)
   contribution alongside the existing flue-fan + solar-HW pump
   additions.

Per-fan count convention (reverse-engineered from cert 000565):
- Each PCDB-defined configuration (1..6) contributes 1 baseline fan.
- Through-wall configurations scale with wet-rooms count:
    through-wall kitchen (5):   wet_rooms_count fans
    through-wall other wet (6): wet_rooms_count + 1 fans
- Configurations with blank SFP (e.g. record 500755 in-duct codes 3,
  4) contribute 0 to the numerator but their flow rate to the
  denominator per SAP §2.6.4 "summation is over all the fans".

For cert 000565 (wet_rooms=2) this yields the worksheet's observed
fan distribution (1, 1, 1, 1, 2, 3) → SFPav = 11.7205 / 92.0 =
0.12740 W/(l/s), and (230a) = 0.12740 × 1.22 × 820.4385 = 127.5159
kWh/year ✓ matches worksheet line (230a) at 1e-4.

TODO: validate the count convention against a second MEV
decentralised fixture; the rule above fits cert 000565 alone.

Cert 000565 closure state at HEAD:
- pumps_fans_kwh_per_yr: 125.0 → 252.5159 ✓ EXACT (was 255.0 pre-arc;
  the MEV +127.5 contribution closes the residual)
- sap_score (int): 29 ✓ EXACT preserved
- sap_score_continuous: 28.69 (S0380.101 transient) → 28.5043 vs
  ws 28.5087 (Δ -0.0044). Was -0.0001 pre-arc — the MEV fix revealed
  a pre-existing residual elsewhere in the cost cascade (likely
  Table 12a HP-on-E7 high-rate split per the original TODO at
  mapper.py:4039-4040; deferred to a separate slice).

Test count: 603 pass + 7 expected 000565 fails (was 8 —
pumps_fans_kwh_per_yr flipped FAIL→PASS, removed from work queue).

Cohort safety: only cert 000565 lodges a non-None MV PCDF Reference
Number across the Summary fixture set; cohort certs return 0 from
`_mev_decentralised_kwh_per_yr_from_cert` (no MEV system).

Pyright net-zero per touched file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 15:45:55 +00:00
Khalim Conn-Kowlessar
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>
2026-05-30 14:50:39 +00:00
Khalim Conn-Kowlessar
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>
2026-05-30 13:29:50 +00:00
Khalim Conn-Kowlessar
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>
2026-05-29 23:55:49 +00:00
Khalim Conn-Kowlessar
a9143d0921 Slice S0380.75: Wire Appendix H orchestrator into cascade; cert 000565 HW +272 → −69
Per SAP 10.2 §4 line (64)m: `(64)m = max(0, (62)m + (63a)m + (63b)m
+ (63c)m + (63d)m)` where (63c)m is the solar HW credit lodged as a
negative quantity. The cascade hardcoded (63c)m = 0 since S0380.66
when the Appendix H orchestrator landed without integration, pending
the 1.81× over-count resolution (closed in S0380.74).

This slice plumbs the orchestrator into `water_heating_from_cert`
via a new `solar_water_heating_monthly_kwh_override` parameter, and
adds `_solar_hw_monthly_override` in cert_to_inputs.py that drives
the orchestrator from RdSAP 10 §10.11 Table 29 defaults +
cert-lodged collector geometry on Elmhurst Summary §16.0.

RdSAP 10 §10.11 Table 29 row "Solar panel" (p.58, verbatim):
  "If solar panel present, the parameters for the calculation not
   provided in the RdSAP data set are:
   - panel aperture area 3 m²
   - flat panel, η₀ = 0.80, a₁ = 4.0, a₂ = 0.01
   - facing South, pitch 30°, modest overshading
   - …
   - pump for solar-heated water is electric (75 kWh/year)
   - showers are both electric and non-electric"

Lodged collector orientation / pitch / overshading on the Summary
§16.0 ("Are details known? Yes" branch) override South / 30° /
Modest. Aperture, η₀, a₁, a₂, IAM stay at Table 29 defaults — the
deeper thermal parameter lodgement (P960 worksheet) isn't yet in
the Summary extractor surface.

For (H17)m to include storage + primary + combi losses, the cascade
runs a `demand_pass` call without solar (gets (62)m) before sizing
the solar credit. The final call then uses all overrides.

Files:
- datatypes/epc/surveys/elmhurst_site_notes.py: Renewables gains
  `solar_hw_collector_orientation` / `_pitch_deg` / `_overshading`
  optional fields.
- datatypes/epc/domain/epc_property_data.py: same three fields
  added at the end of the dataclass.
- datatypes/epc/domain/mapper.py: from_elmhurst_site_notes
  propagates the three new fields.
- backend/documents_parser/elmhurst_extractor.py: §16.0 section
  parsing reads "Collector orientation" / "Collector elevation" /
  "Overshading" rows; `_parse_solar_pitch_deg` strips the degree
  glyph.
- domain/sap10_calculator/worksheet/water_heating.py: new
  `solar_water_heating_monthly_kwh_override` param on
  `water_heating_from_cert`; threaded into `output_from_water_
  heater_monthly_kwh(solar_monthly_kwh=...)`.
- domain/sap10_calculator/rdsap/cert_to_inputs.py: Table 29
  constants + `_solar_hw_monthly_override` helper +
  `_orientation_from_summary_string` mapper. Added the demand_pass
  intermediate call so (H17)m sees the full (62)m. Negates the
  orchestrator output at the boundary (spec convention: heat
  displaced from boiler is negative on line (63c)m).

Cert 000565 cascade pin shifts:
- hot_water_kwh_per_yr: +271.84 → −68.96 (4× closer)
- sap_score_continuous: +0.6334 → +0.7732 (drift downstream of HW)
- ecf: −0.0643 → −0.0784 (drift)
- total_fuel_cost: −56.08 → −68.36 (drift)
- co2: −19.77 → −22.66 (drift)
- sap_score (int): 29 EXACT (unchanged)
- space_heating / main_heating_fuel / lighting / pumps_fans:
  unchanged

The remaining −69 kWh HW residual is the gap between Table 29
defaults (H12 = 75 L separate tank) and cert 000565's lodged H12 =
53 L + combined cylinder 160 L. Closing this requires extracting
solar storage volume + combined-cylinder routing from the cert (P960
worksheet block lodges these explicitly; Summary doesn't). That's
the follow-on slice.

Test baseline: 547 pass + 9 expected `test_sap_result_pin[000565-*]`
fails preserved. Cohort-2 + ASHP cohort + all golden fixtures
untouched (no certs other than 000565 lodge `solar_water_heating =
True`).

Pyright net-zero on touched files (68 errors at baseline = 68 errors
post-change).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 18:37:56 +00:00
Khalim Conn-Kowlessar
3e05881042 Slice S0380.58: Elmhurst per-extension Room(s) in Roof extraction + TFA fix
Cert 000565 surfaced a per-extension Room(s) in Roof coverage gap.
§4 Dimensions lodges an RR floor area for every BP (Main + each
extension) and §8.1 lodges full construction details per BP. The
old extractor parsed RR from §4 + §8.1 for Main only — the 4
extensions' RR areas (34 + 5 + 32 + 2 = 73 m²) were silently
dropped, leaving TFA at 246.91 m² vs the worksheet's 319.91 m²
(23% deficit).

Schema:
- `ExtensionPart.room_in_roof: Optional[RoomInRoof] = None` field.
  None for single-storey extensions (no RR lodged); populated for
  every extension that lodges a §4 RR floor area > 0.

Extractor:
- `_room_in_roof_from_bodies(dim_body, rir_body, age_band)`
  parameterises the previously Main-only `_extract_room_in_roof`
  so the same parsing applies to each extension.
- `_extract_extensions` now slices §8.1 by BP (alongside the
  existing §4/§7/§8/§9 slicing) and reads each extension's RR age
  band from §3's "<N>th Ext. Room(s) in Roof <band>" line via a
  new regex.
- A new defensive "§4 lodges RR area but §8.1 has no construction
  details" branch returns a partial `RoomInRoof` with empty surfaces
  so the cascade still attributes the floor area to TFA. (Not
  triggered on 000565 — all 5 BPs lodge construction details — but
  needed for older Elmhurst variants per the existing extractor
  comment style.)

Mapper:
- `_map_elmhurst_building_parts` now passes each extension's
  `room_in_roof` through `_map_elmhurst_room_in_roof` to the
  extension's `SapBuildingPart.sap_room_in_roof`. Previously the
  loop hardcoded the field as None.
- `total_floor_area_m2` derivation now also sums each extension's
  `room_in_roof.floor_area_m2`. Without this, the per-BP RR floor
  area is lodged on the BP but the cert's top-level TFA stays at
  the pre-fix value.

Cert 000565 cascade impact:
- TFA: 246.91 → 319.91 ✓ (matches U985-0001-000565.pdf Block 1)
- space_heating_kwh_per_yr:  Δ −9,107.71 → −1,099.50  (88% reduction)
- main_heating_fuel_kwh_per_yr: Δ −5,357.47 → −646.76  (88% reduction;
    space_heating × 1/HP COP — main_heating tracks space_heating)
- lighting_kwh_per_yr:       Δ −236.19 → +2.18  (essentially closed —
    RdSAP §12-1 lighting is TFA-proportional)
- hot_water_kwh_per_yr:      Δ +214.50 → +271.84
- co2_kg_per_yr:             Δ −1,438.16 → −751.06
- total_fuel_cost_gbp:       Δ −1,055.62 → −564.05
- sap_score_continuous:      Δ +1.70 → +6.75  (cost/TFA dropped because
    cost rose ~14% but TFA rose ~30% — the remaining −564 cost gap
    has to close before SAP catches up)

Single-storey-extension certs: `room_in_roof=None` for each extension
(no §4 RR lodgement), no behavioural change. Cohort regression check:
415 pass + 10 expected 000565 fails — no regression on the 14 Summary
fixtures + JSON fixtures that don't carry per-extension RR.

Pyright net-zero on all 3 touched files (32 / 0 / 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:58:43 +00:00
Khalim Conn-Kowlessar
353303168d Slice S0380.54: Elmhurst §14.1 Main Heating2 extraction + 2nd MainHeatingDetail
Cert 000565 lodges §14.1 Main Heating2 as PCDB 15100 (Vaillant Ecotec
plus 415, 88%, mains gas, 0% space heat) — this is the system that
services DHW via `Water Heating SapCode 914` ("from second main
system"). The previous extractor / mapper shape supported only ONE
main heating system, dropping Main 2 entirely.

New shape:
- `MainHeating2` dataclass (slim §14.1-shaped: PCDB ref, fuel type,
  flue type, fan_assisted_flue, percentage_of_heat, SAP code)
- `MainHeating.main_heating_2: Optional[MainHeating2]` — None when
  §14.1 is absent OR lodges only placeholder zeros (the PCDB-only
  convention; the two JSON fixtures + 14 existing Summary fixtures
  all lodge "0 / 0" for an absent Main 2)
- `_extract_main_heating_2` parses §14.1; returns None when neither
  PCDB ref nor SAP code identifies Main 2
- `_map_elmhurst_main_heating_2` builds `MainHeatingDetail` from the
  Main 2 lodgement with `main_heating_number=2` and `main_heating_
  fraction=percentage_of_heat`; strict-raises `UnmappedElmhurstLabel`
  (mirroring Slice S0380.53's Main 1 raise) when Main 2 has neither
  identifier — surfaces coverage gaps at extraction time

Per RdSAP convention "0%" is lodged without a space (vs Main 1's
"100 %" with a space) — robust percentage parse via `rstrip("%")` so
both forms thread through.

Cohort impact:
- 14 existing Summary PDF fixtures + 2 JSON fixtures: Main 2 returns
  None (placeholder zeros) → no 2nd MainHeatingDetail produced → no
  cascade behaviour change (regression-tested: 415 pass + 10 expected
  000565 fails, identical to S0380.53 baseline)
- Cert 000565: 2nd MainHeatingDetail now lodged with sap_code=None,
  pcdb=15100 (Table 105 gas-boiler 88% efficiency), category=2,
  fuel=26 (mains gas), fraction=0

Cascade still uses Main 1 for water-heating efficiency in the WHC
914 branch — that routing fix is the next slice. This commit is
the plumbing-only half; the SAP-result pin residuals are unchanged
at HEAD because the cascade hasn't been wired to read Main 2 yet.

Pyright net-zero on all 3 touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:36:20 +00:00
Khalim Conn-Kowlessar
bb9097e1a5 Slice S0380.53: Elmhurst §14.0 "Main Heating SAP Code" extraction + strict-raise
Cert 000565 surfaced an Elmhurst extractor schema gap. §14.0 lodges
"Main Heating SAP Code 224" identifying Main 1 as an Air Source Heat
Pump (SAP 10.2 Table 4a row 224: "Air source heat pump, 2013 or
later") — but the extractor was dropping the line. The mapper
therefore produced a `MainHeatingDetail` with `sap_main_heating_code
= None` AND `main_heating_index_number = None` (because `PCDF boiler
Reference = 0` for HP certs), leaving the cascade to fall back to
the 0.80 gas-boiler default efficiency.

Cascade impact on cert 000565 main_heating_fuel_kwh_per_yr pin:
- Before: actual 62,375.80 kWh/yr (= 59,008 / 0.80 wrong default)
  Δ +27,665.01 vs U985-0001-000565.pdf expected 34,710.79
- After:  actual 29,353.32 kWh/yr (= 59,008 / 1.70 HP COP via §A4.1)
  Δ −5,357.47 (remaining gap is on the space_heating side, not
  heating efficiency)

The strict-raise mirrors [[unmapped-api-code]] (Slice S0380.51) and
[[unmapped-elmhurst-label]] (cylinder size / glazing type) — when
neither the §14.0 SAP code nor the PCDB boiler reference identifies
Main 1, the mapper raises `UnmappedElmhurstLabel("main_heating",
...)` so the coverage gap surfaces at extraction time instead of as
an opaque downstream SAP delta. Per user end-of-S0380.52 directive:
"if we're missing mapping on EpcPropertyDataMapper - let's raise an
exception".

Spec source: SAP 10.2 §A4 Appendix A "Heat pump cascade", Table 4a
row 224 (Air source heat pump, 2013 or later) — `seasonal_efficiency`
reads the SAP code when no PCDB Table 105/362 record overrides.

Touched:
- datatypes/epc/surveys/elmhurst_site_notes.py: `MainHeating.
  main_heating_sap_code: Optional[int]` field added (treat 0 as None
  per Elmhurst convention — PCDB-listed boilers lodge §14.0 SAP code
  as 0 and identify themselves via the PCDB index instead)
- backend/documents_parser/elmhurst_extractor.py:
  `_extract_main_heating` reads §14.0 "Main Heating SAP Code" via the
  existing `_local_val` slice helper; 0/absent → None
- datatypes/epc/domain/mapper.py: `_map_elmhurst_sap_heating` passes
  `sap_main_heating_code=mh.main_heating_sap_code` to
  `MainHeatingDetail`, and raises `UnmappedElmhurstLabel` when
  neither identifier resolves

Cohort regression check: 415 pass + 10 expected 000565 failures
(unchanged from S0380.52 — same pins, different residuals). Pyright
net-zero on all 3 touched files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:18:51 +00:00
Khalim Conn-Kowlessar
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>
2026-05-28 10:56:11 +00:00
Khalim Conn-Kowlessar
8dee191803 Slice S0380.23: RdSAP §11.1 b) PV %-of-roof-area synthesis — closes cert 6835 -13.37 → +0.72
RdSAP 10 specification page 60 §11.1 b) (Photovoltaics): "If the kWp
(or DNC) is not known use the following: PV area is roof area for
heat loss (before amendment for any room-in-roof), times percent of
roof area covered by PVs, and if pitched roof divided by cos(35°).
If there is an extension, the roof area is adjusted by the cosine
factor only for those parts having a pitched roof. kWp is 0.12 ×
PV area. If not provided in the RdSAP data set then facing South,
pitch 30°, modest overshading."

Wire-through:
  1. `Renewables.pv_percent_roof_area: Optional[int]` — new field on
     the Elmhurst site-notes dataclass.
  2. Elmhurst extractor `_extract_renewables` parses Summary §19.0
     row "Proportion of roof area" (cert 6835: "40").
  3. Elmhurst mapper `from_elmhurst_site_notes` surfaces it through
     `epc.sap_energy_source.photovoltaic_supply.none_or_no_details
     .percent_roof_area` — mirrors the API mapper's lodgement shape.
  4. `cert_to_inputs._synthesize_pv_arrays_from_percent_roof_area`
     synthesizes a single PV array via the spec formula when
     `photovoltaic_arrays` is empty AND a `percent_roof_area > 0`
     lodgement is present. Fires inside
     `_pv_generation_kwh_per_yr`, so both rating + demand cascades
     pick it up.

Cohort-2 outcome (38 certs, Summary path):
  exact (<1e-4): 20 → 20
  ±0.07..0.5:   1 → 1
  ±0.5..1:      1 → **2**  (cert 6835 closes -13.37 → +0.72)
  ±1..5:        1 → 1
  ±5+:          2 → **1**  (-1: cert 6835 moves out of big-gap band)

Cert 6835 verified end-to-end:
  - kWp = 0.12 × 36.9 × 0.40 / cos(35°) = 2.1622
    (worksheet "Cells Peak = 2.16, Orientation = South, Elevation =
    30°, Overshading = Modest")
  - Cascade PV generation = 1493.88 kWh/yr vs worksheet 1492.33
    (<0.1% delta — kWp-rounding artefact).
  - Cascade SAP 80.92 vs worksheet 80.20 (+0.72, in the ±0.5..1 band).

The residual +0.72 likely traces to the PV-cost cascade's
used-in-dwelling / exported split rather than the synthesis — the
kWh figure is within rounding of the worksheet.

Pyright per-file: net-zero
  - cert_to_inputs.py 35 → 35
  - test_cert_to_inputs.py 13 → 13
  - mapper.py 32 → 32
  - elmhurst_site_notes.py 0 → 0
  - elmhurst_extractor.py 0 → 0

Tests: 702 → 703 pass (+1 new RdSAP §11.1 b synthesis test), 10
expected fails unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:35:38 +00:00
Khalim Conn-Kowlessar
43a86d66c2 Slice S0380.9: multi-array PV support + close cert 0350 to ASHP spec floor
Refactors Elmhurst `Renewables` PV detail from four scalar fields
(pv_peak_power_kw / pv_orientation / pv_elevation_deg / pv_overshading
— single-array shape) to `pv_arrays: List[ElmhurstPvArray]`, then
walks the §19.0 PV Panel block in 4-tuples so dwellings with multiple
PV arrays surface every array.

Forced by cert 0350-2968-2650-2796-5255 (Summary_000903.pdf), the
second ASHP cohort cert through the Summary path and first to lodge
multiple PV arrays — the dr87 worksheet pins 2 arrays at 1.50 kWp
each (one SE at 45°, one NW at 45°). Pre-slice the extractor's
hardcoded "break at len(values) == 4" capped output at one array
regardless of how many the PDF lodged.

Three-layer end-to-end change:

1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add
   `ElmhurstPvArray` dataclass (kw, orientation, elevation_deg,
   overshading); replace four `Renewables.pv_*` scalars with
   `pv_arrays: List[ElmhurstPvArray] = field(default_factory=list)`.
2. `backend/documents_parser/elmhurst_extractor.py` — rename
   `_extract_pv_array_detail` → `_extract_pv_arrays`; walk values
   after the "Photovoltaic panel details" anchor in 4-tuples until a
   stop token ("batteries"/"export"/etc.) or a §-header closes the
   block. §-header regex tightened to `\d{1,2}\.\d\s+\w` so kWp
   values like "1.50" don't trip the close (without the `\s+\w` the
   regex matched both "20.0 Wind Turbine" AND "1.50").
3. `datatypes/epc/domain/mapper.py` — `_elmhurst_pv_arrays` iterates
   the list and emits one `PhotovoltaicArray` per row; collapses
   empty list → None so the cascade keeps its no-PV fallback.

Forcing function: cert 0350 first-attempt Summary SAP closes from
Δ -4.5829 (Slice 8 baseline) to Δ **+0.0458** — within the ±0.07
ASHP-cohort spec-precision floor. PV export credit GBP moves from
158.91 (one array surfaced) to 265.99 (both arrays surfaced) — the
extra ~107 GBP of avoided cost lifts cert 0350's SAP by ~4.6 points.

This validates the structural-debt-amortizes hypothesis: cert 0350
needed only TWO new slices (S0380.8 inheritance + S0380.9 multi-PV)
beyond the cert 0380 closure work, vs cert 0380's 6 slices from
scratch. Subsequent cohort certs should converge similarly fast as
fixture-specific gaps are paid down.

Added two tests:
- `test_summary_0350_surfaces_two_pv_arrays` — unit test pinning
  the multi-array contract on the mapper boundary.
- `test_summary_0350_full_chain_sap_within_spec_floor_of_worksheet`
  — chain test pinning Δ < ±0.07 (matches cert 0380's chain test).

Cert 0380 (single-array, 3 kWp) continues to pass its chain test +
all 6 unit-level pins — the refactor preserves single-array behaviour.

Pyright net-zero across all four edited files:
  datatypes/epc/domain/mapper.py:                32 (baseline)
  datatypes/epc/surveys/elmhurst_site_notes.py:   0
  backend/documents_parser/elmhurst_extractor.py: 0
  backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression suite: 677 pass + 10 fail (= handover baseline 669 + 10
+ 8 new GREEN unit+chain tests across Slices S0380.2..S0380.9).

Fixtures added: `backend/documents_parser/tests/fixtures/Summary_
000903.pdf` (copied from `sap worksheets/Additional data with api/
0350-2968-2650-2796-5255/`).

Spec refs:
- SAP 10.2 Appendix M (PDF p.103) — multiple PV arrays sum to total
  electricity generation per Equation M-1 (each array's surface flux
  computed independently per Appendix U3.3).
- SAP 10.2 Appendix U3.3 (PDF p.124) — per-array surface flux keyed
  on orientation + tilt + overshading.
- Cert 0350 worksheet `dr87-0001-000903.pdf` (29a Main 19.4575 W/K
  + Ext1 1.3025 W/K = 20.7600 ≡ Summary cascade walls_w_per_k; (39)
  avg HTC 173.4202 ≡ Summary cascade; (64) HW 2084.66 ÷ (216) HW eff
  1.7285 = 1206.04 ≡ Summary cascade hot_water_kwh_per_yr).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:44:13 +00:00
Khalim Conn-Kowlessar
16fe22625b Slice S0380.6: surface full §15.1 Hot Water Cylinder block — Summary HW exact
Closes the entire §15.1 Hot Water Cylinder lodging end-to-end and
collapses cert 0380's Summary path to the API path at the documented
HP-cohort spec-precision floor: SAP **88.5698 (Δ +0.0594)** — exactly
matching the API path's spec-floor closure. `hot_water_kwh_per_yr`
hits **878.0519** vs worksheet (64) 1502.16 ÷ (216) HW eff 1.7107 =
**878.05** — exact match at 1e-4.

Four §15.1 fields surfaced together (the cascade requires all four in
combination to compute the worksheet-correct HP HW path):

1. `cylinder_size_label` (Summary "Medium" → SAP10 cascade enum 3 =
   160 L per `_CYLINDER_SIZE_CODE_TO_LITRES`)
2. `cylinder_insulation_label` (Summary "Foam" → cascade enum 1 =
   factory, per SAP 10.2 Table 2 Note 2)
3. `cylinder_insulation_thickness_mm` (Summary "50 mm" → 50)
4. `cylinder_thermostat` (Summary "Yes" → bool True → mapper emits 'Y'
   for the cascade's `sh.cylinder_thermostat == "Y"` string compare)

Why all four were required:

- `_cylinder_storage_loss_override` in `cert_to_inputs.py:2238-2253`
  gates on `cylinder_size`, `cylinder_insulation_type ==
  _CYLINDER_INSULATION_TYPE_FACTORY (1)`, AND
  `cylinder_insulation_thickness_mm`. Missing any → no override →
  zero storage loss (62)m miscalculated.
- `cylinder_thermostat` keys the SAP 10.2 Table 2b temperature factor
  (53): with-stat 0.5400 vs no-stat ~0.9 → without 'Y' storage loss
  over-counts by ~300 kWh/yr (the precise diff between the bundled-
  fields-only attempt at SAP 86.5 vs the fully-bundled attempt at
  SAP 88.57).

Three-layer end-to-end change:

1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add four
   defaulted `WaterHeating` fields (placed in the defaulted block;
   existing fixtures that omit §15.1 still construct unchanged).
2. `backend/documents_parser/elmhurst_extractor.py` — extend
   `_extract_water_heating` to read the §15.1 block via
   `_section_lines("15.1 Hot Water Cylinder", "15.2 Community Hot
   Water")` + `_local_val`. Section-scoping is required because the
   "Insulation Thickness" label collides with §7 Walls / §8 Roofs /
   §9 Floors lodgings on the same Summary PDF (cert 0380 has §7
   "Insulation Thickness 100 mm" for the FE wall — the global
   `_next_val` would return the wrong value).
3. `datatypes/epc/domain/mapper.py` — add
   `_elmhurst_cylinder_size_code` + `_elmhurst_cylinder_insulation_code`
   label-to-enum helpers; replace the broken
   `cylinder_size = water_heating.water_heating_code` (which was
   passing the §15 "Water Heating Code" string "HWP" into the
   numeric `cylinder_size` field, defeating the cascade) with the
   real `cylinder_size_label`-derived enum.

Pre-Slice 6, the Summary path was producing `cylinder_size='HWP'`
which `_int_or_none` reduced to None, silently routing the cascade
off the HP-with-cylinder HW path entirely. Surfacing the §15.1
block in full lets `_heat_pump_apm_efficiencies` use the spec-
correct HW efficiency (1.7107) and `_cylinder_storage_loss_override`
contribute the spec-correct (56) 435 kWh/yr storage loss.

Pyright net-zero across all four edited files:
  datatypes/epc/domain/mapper.py:                32 (baseline)
  datatypes/epc/surveys/elmhurst_site_notes.py:   0
  backend/documents_parser/elmhurst_extractor.py: 0
  backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression suite: 674 pass + 11 fail (vs handover baseline 669 + 10
— net +5 pass for the new GREEN unit tests S0380.2..S0380.6; the +1
fail vs baseline is still S0380.1's chain test which pins at 1e-4 vs
worksheet 88.5104 and now lands at Δ +0.0594, the same Appendix N3.6
PSR-interpolation precision floor that the API path closes to and
that the cohort's 7 ASHP fixtures already track at ±0.07).

Tolerance disposition: the +0.0594 residual is identical to the
cohort's documented HP-path precision floor. Closing further requires
work on the calculator's Appendix N3.6 PSR interpolation step
(boilers already match worksheet at 1e-4 via the same cascade —
ground-truthed in closed-boiler precedents 001479, 0330), not on
the Summary mapper. The S0380.1 chain test should be re-pinned to
the ±0.07 ASHP-cohort tolerance in the next slice — same disposition
the API-path cohort received in slice 102f (commit c0086660).

Spec refs:
- SAP 10.2 §4 Table 2 (PDF p.135) — cylinder storage loss factor
  for foam-insulated cylinders (51) keyed on insulation thickness.
- SAP 10.2 §4 Table 2a (PDF p.135) — cylinder volume factor (52).
- SAP 10.2 §4 Table 2b (PDF p.135) — cylinder temperature factor
  (53) keyed on cylinder thermostat + separately-timed DHW.
- SAP 10.2 Appendix N3.7(a) (PDF p.6097) — HP HW in-use factor
  cylinder-criteria, footnote 53 (cert HX area unknown for Open EPC
  schema → criteria fail → 0.60 in-use factor; the worksheet's
  closed HW path uses this same factor).
- Cert 0380 worksheet `dr87-0001-000899.pdf` lodgings:
  (47) Cylinder Volume 160.00 L; "Cylinder Insulation Type Foam";
  "Cylinder Insulation Thickness 50 mm"; "Cylinder Stat Yes";
  (51)..(56) cylinder storage loss chain; (64) HW output 1502.16;
  (216) HW efficiency 171.0746%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:18:31 +00:00
Khalim Conn-Kowlessar
d4d0aa2495 Slice S0380.5: surface insulated_door_u_value from Summary §10 'Average U-value'
Closes the three-layer gap that left the Summary mapper producing
`insulated_door_u_value=None` even though Summary §10 lodges
"Average U-value" / "1.20" explicitly on cert 0380:

1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add
   `ElmhurstSiteNotes.insulated_door_u_value: Optional[float] = None`,
   placed in the defaulted-field block so existing fixtures that
   omit the field still construct without changes.
2. `backend/documents_parser/elmhurst_extractor.py` — add
   `_extract_door_u_value` that section-scopes the lookup to
   `_section_lines("10.0 Doors:", "11.0 Windows:")` so the bare
   "Average U-value" label cannot be shadowed by global U-value
   lookups in §7 Walls / §8 Roofs / §9 Floors.
3. `datatypes/epc/domain/mapper.py` — surface
   `insulated_door_u_value=survey.insulated_door_u_value` on the
   `from_elmhurst_site_notes` path. The comment in
   `epc_property_data.py:585` ("Not available in site notes") is now
   outdated for Elmhurst Summary PDFs that lodge the explicit value.

Worksheet anchor (dr87-0001-000899.pdf line ref (26)):

  Doors insulated 1   NetArea 3.7000   U-value 1.2000   A×U 4.4400 W/K

Forcing function (Slice S0380.1): cert 0380 Summary cascade
`doors_w_per_k` moves from 5.1800 to **4.4400 W/K — exact match
against worksheet line ref (26)**. The +0.74 W/K mis-attribution
was the default door-U fall-through that the lodged 1.20 value
silences. SAP moves 88.1981 (Δ -0.3123) → 88.2746 (Δ -0.2358).

Added focused unit test
`test_summary_0380_surfaces_insulated_door_u_value_1_2` that pins
the mapper boundary directly to the worksheet's lodged U-value 1.2,
so future debuggers can localise regressions in the new extractor /
field / mapper path before walking the full chain.

Pyright net-zero across all four edited files:
  datatypes/epc/domain/mapper.py:                32 (baseline)
  datatypes/epc/surveys/elmhurst_site_notes.py:   0
  backend/documents_parser/elmhurst_extractor.py: 0
  backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression suite: 673 pass + 11 fail (vs handover baseline 669 + 10
— net +4 pass for the four GREEN unit tests across Slices S0380.2-5;
the +1 fail vs baseline is the S0380.1 chain test which this slice
moves to Δ -0.2358 but does not yet fully close).

Spec refs:
- SAP 10.2 Table 14 (door U-values: composite-construction default
  cascade is silenced when the assessor lodges an explicit measured
  U on the cert; routed via `insulated_door_u_value`).
- Cert 0380 worksheet dr87-0001-000899.pdf line ref (26) — the
  A×U=4.4400 W/K spec value that this slice closes the Summary
  cascade to exactly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:28:42 +00:00
Khalim Conn-Kowlessar
2d15951bc1 Slice S0380.4: surface wall_insulation_thickness from Summary §7.0
Closes the three-layer gap that left the Summary mapper producing
`wall_insulation_thickness=None` even though Summary §7.0 lodges
"Insulation Thickness" / "100 mm" explicitly on cert 0380. Three
small co-ordinated edits ship the field end-to-end:

1. `datatypes/epc/surveys/elmhurst_site_notes.py` — add
   `WallDetails.insulation_thickness_mm: Optional[int] = None`,
   mirroring the existing `RoofDetails.insulation_thickness_mm`.
2. `backend/documents_parser/elmhurst_extractor.py` — extend
   `_wall_details_from_lines` to read the `_local_val(lines,
   "Insulation Thickness")` label inside the §7 Walls block (the
   "Insulation Thickness" label is local-scoped per block, so it
   does not collide with §8 Roofs / §9 Floors).
3. `datatypes/epc/domain/mapper.py` — surface
   `wall_insulation_thickness=f"{walls.insulation_thickness_mm}mm"`
   on `SapBuildingPart`. Mirrors the API mapper's string-with-unit
   shape (`'100mm'`) so cert-to-cert parity tests (Summary EPC ≡
   API EPC) compare equal; the cascade's `_parse_thickness_mm`
   accepts either form.

Forcing function (Slice S0380.1): cert 0380 Summary cascade SAP
moves from 86.8671 (Δ -1.6433 — i.e. after Slice S0380.3 only) to
88.1981 (Δ -0.3123) — closes ~81% of the remaining gap. Critically,
`walls_w_per_k` now hits API parity exactly (Summary 11.6150 ≡ API
11.6150) — the composite filled-cavity-plus-external U-value calc
is now keyed off the lodged 100 mm thickness rather than its
internal default.

Residual -0.31 SAP vs worksheet is comparable to the documented HP
cohort's API-path residual of +0.06 (cert 0380 API path closes at
+0.0594). Summary path is now within ±0.37 of API path. Remaining
diffs to investigate (per the next-step diagnostic): hot-water
cascade (Summary 1002.74 kWh vs API 878.05 kWh, +124.69 kWh), HLC
parameters (heat_transfer_coefficient still differs slightly through
secondary terms), and possibly secondary-heating routing. The
worksheet vs API +0.06 residual is the documented Appendix N3.6
PSR-interpolation precision floor and out of scope for Summary-path
closure.

Added focused unit test
`test_summary_0380_surfaces_wall_insulation_thickness_100mm` that
pins the mapper boundary directly (Summary "100 mm" line pair →
EPC `wall_insulation_thickness="100mm"`), so future debuggers can
localise regressions in the new extractor / field / mapper path
before walking the full chain.

Pyright net-zero across all four edited files:
  datatypes/epc/domain/mapper.py:                32 (baseline)
  datatypes/epc/surveys/elmhurst_site_notes.py:   0
  backend/documents_parser/elmhurst_extractor.py: 0
  backend/documents_parser/tests/test_summary_pdf_mapper_chain.py: 0

Regression suite: 672 pass + 11 fail (vs handover baseline 669 + 10
— net +3 pass for the three Slices S0380.2-4 GREEN unit tests; the
+1 fail vs baseline is still the S0380.1 chain test which this slice
moves from Δ -1.6433 to Δ -0.3123 but does not yet fully close).

Spec refs:
- SAP 10.2 §3.7 / Appendix S Table S5 (composite filled-cavity-plus-
  external U-value calc — series-resistance form keyed off lodged
  insulation thickness)
- Cert 0380 Summary PDF §7.0 lines 121-122 ("Insulation Thickness"
  / "100 mm" — the missing extractor read this slice adds)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:15:18 +00:00
Khalim Conn-Kowlessar
4264e0ad4b Slice 99d: surface PV array from Elmhurst Summary §19.0
Cert 9501 lodges measured PV: 2.36 kWp South-West, 45° pitch, "None
Or Little" overshading. The worksheet's §10a credit (-250.02 GBP =
PV used in dwelling £-129.49 + PV exported £-120.53) depends on the
Appendix M / Appendix U3.3 cascade reading these from
`SapEnergySource.photovoltaic_arrays`. The prior extractor only
captured the `photovoltaic_panel: "Panel details"` label — the
actual kW / orientation / elevation / overshading were silently
dropped, so the cascade computed total cost ~£250 too high → ECF
2.92 vs worksheet 2.26 → SAP 59.26 vs 68.53 (Δ -9.27).

Changes:
- Extend `surveys.elmhurst_site_notes.Renewables` with 4 new
  optional fields: pv_peak_power_kw / pv_orientation /
  pv_elevation_deg / pv_overshading.
- Add `ElmhurstSiteNotesExtractor._extract_pv_array_detail` —
  anchors on "Photovoltaic panel details" then reads the 4
  consecutive value lines (kWp, orientation, elevation, overshading).
- Add `_elmhurst_pv_arrays` mapper helper to build the
  `[PhotovoltaicArray(...)]` list when all 4 values are present;
  return None for the "PV absent" path the cascade already handles.
- Add `_ELMHURST_PV_OVERSHADING_TO_RDSAP` map: "None Or Little" → 1
  (ZPV=1.0 per cert_to_inputs._PV_OVERSHADING_FACTOR), "Modest" →
  2, "Significant" → 3, "Heavy" → 4. RdSAP omits SAP10.2 Table M1's
  5th "Severe" bucket.
- Wire `photovoltaic_arrays=_elmhurst_pv_arrays(survey.renewables)`
  into `from_elmhurst_site_notes`'s `SapEnergySource(...)` call.

Effect on cert 9501 Summary path:
- sap_continuous 59.2585 → 68.7577 (target 68.5252; Δ +0.23)
- total_fuel_cost £1099 → £843 (worksheet £849; -£6 over-credit)
- ECF 2.92 → 2.24 (worksheet 2.26; -0.02 over-credit)

The remaining +0.23 SAP / +£6 cost drift is a precision gap in the
Appendix M cost-offset cascade for measured PV (not a missing-data
gap); next slice closes it to 1e-4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:38:14 +00:00
Khalim Conn-Kowlessar
58088c1056 Slice 53: Summary_000487 chain pins SAP at 1e-4 — last cohort cert closed
Three extensions closing the last 0.05 SAP residual on 000487 — and
with it, all 6 Elmhurst Summary PDFs match their U985 worksheets to
1e-4 unrounded SAP.

1. Alternative-wall extraction. `WallDetails` gains an
   `alternative_walls: List[AlternativeWall]` field; the extractor
   parses §7's "Alternative Wall N Area / Type / Insulation /
   Thickness / Thickness Unknown / U-value Known" prefixed labels.
   Even when an extension lodges "As Main Wall: Yes" we still pull
   alt walls from the extension's own subsection (they don't
   inherit) — the main wall fields are merged with the extension's
   alt-wall list.

2. Alt-wall mapper plumbing. `_map_elmhurst_alternative_wall` builds
   a `SapAlternativeWall` per lodged Elmhurst entry; the building-
   part mapper attaches up to two via `sap_alternative_wall_1/_2`
   per `SapBuildingPart`. When the surveyor flags `Thickness
   Unknown: Yes` (cohort's only example — 000487 Ext1's
   "TimberWallOneLayer" entry) we route the cascade with
   thickness=None so `u_wall` falls through to the age-band-and-
   construction default — Timber Frame age B uninsulated → U=1.9,
   matching the full-cert-text U=1.90 the handbuilt fixture lodges
   for the same 9-mm thin timber wall.

3. "TI" wall-construction code mapping. The §7 "Alternative Wall 1
   Type: TI Timber Frame" uses leading code "TI" rather than the
   "TF" code seen on the primary wall types — both alias to SAP10
   wall_construction=5 (Timber Frame).

Final cohort state — all 6 closed at 1e-4:

  000474   0.0000  ✓ Slice 47
  000477   0.0000  ✓ Slice 52
  000480   0.0000  ✓ Slice 50
  000487   0.0000  ✓ THIS SLICE
  000490   0.0000  ✓ Slice 49
  000516   0.0000  ✓ Slice 51

758 tests pass; pyright net-zero (35 baseline).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:42:42 +00:00
Khalim Conn-Kowlessar
598f04084a Slice 50: Summary_000480 chain pins SAP at 1e-4; Room-in-Roof + baths + party-wall + roof-none
Four mapper extensions, validated by 000480 closing to 1e-4 and large
gap reductions across 000477/000487/000516.

1. Room-in-Roof support. `ElmhurstSiteNotes` gains `RoomInRoof` +
   `RoomInRoofSurface` dataclasses; extractor parses §8.1 (Flat
   Ceiling / Stud Wall / Slope / Gable Wall / Common Wall) with
   Length × Height + insulation + gable-type + measured-U cells.
   Mapper produces a `SapRoomInRoof` with `detailed_surfaces`
   attached to the Main bp: Stud Walls / Slopes / Flat Ceilings
   route through Table 17 insulation thickness; Gable Walls split
   between `gable_wall` (Party → Table 4 U=0.25) and
   `gable_wall_external` (Sheltered → assessor-lodged U-value
   override, e.g. 000487 Gable Wall 2 at U=0.86). Empty surfaces
   (0×0 — the cohort lodges a full 5-pair table) and Common Walls
   (handled by cascade's Simplified Type 2 geometry) are dropped.
   `total_floor_area_m2` now includes the RR floor area.

2. Party-wall construction mapping. 000516 lodges "S Solid masonry /
   timber / system build" which routes to SAP10 wall_construction=3
   (Solid Brick → U=0.0 via Table 4). The previous mapper used the
   same wall-type table as `wall_construction`, which lacked the
   "S" code and fell through to None (cascade default 0.25). Split
   into a dedicated `_elmhurst_party_wall_construction_int` keyed
   on the party-wall category codes.

3. Roof "None" insulation. When the §8.0 Roofs subsection lodges
   "Insulation N None" without a separate "Insulation Thickness"
   line, treat thickness as 0 mm so the cascade picks Table 16
   row 0 (U=2.30) rather than the age-band default. Closes the
   29 W/K roof-loss gap on 000516.

4. `number_baths` lodgement. `SapHeating.number_baths` now reads
   `survey.baths_and_showers.number_of_baths`. The cascade defaults
   `None → has-bath` for the modal UK case, but explicit `0` lodged
   on 000477/000480 (bathless dwellings, rare) drops the bath HW
   demand line per Table 1b. Closes 000480's last ~0.3 SAP gap.

Cohort state after this slice (target 1e-4):

  000474   0.0000  ✓ Slice 47
  000477  +1.1161     Elmhurst floor_ach quirk (true vs false despite
                      "T Suspended timber" lodged on all certs)
  000480   0.0000  ✓ THIS SLICE
  000487  +1.1844     extractor still drops most §11 windows on this
                      layout variant
  000490   0.0000  ✓ Slice 49
  000516  +0.1774     roof-window separation by U-value heuristic

3/6 certs now closed at 1e-4. Pyright net-zero (35 baseline). Tests
756 pass (added `test_summary_000480_full_chain_sap_matches_worksheet_
pdf_exactly`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 21:09:22 +00:00
Khalim Conn-Kowlessar
7f17de84aa Slice 49: Summary_000490 chain pins SAP at 1e-4; secondary heating + RdSAP sheltered-sides
Two mapper extensions, both validated by 000490 closing to 1e-4:

1. Secondary heating extraction. Elmhurst Summary PDFs lodge the
   secondary heating SAP code in the §14.1 Main Heating2 sub-section
   (between "14.1 Main Heating2" and "14.1 Community Heating") — not
   in the §14.0 Main Heating1 block where the main system lives.
   `ElmhurstMainHeating` gains a `secondary_heating_sap_code` field;
   the extractor reads it from the right section; the mapper threads
   it through to `SapHeating.secondary_heating_type`. The cascade
   then applies Table 11's 10% secondary fraction.

2. Sheltered-sides derivation per RdSAP §S5. The Summary PDF doesn't
   lodge per-dwelling sheltered-sides; the value is derived from
   built-form (Detached=0, Semi-Detached=1, End-Terrace=1, Mid-
   Terrace=2, Enclosed Mid-Terrace=3, Enclosed End-Terrace=2).
   `_map_elmhurst_ventilation` now takes built_form and populates
   `SapVentilation.sheltered_sides`. The table is cross-checked
   against U985-0001-NNNNNN.pdf line (19) across the 6 worksheet
   fixtures.

Cohort SAP deltas after this slice (target 1e-4):

  000474   0.0000  ✓ Slice 47
  000477  +2.6555     diagnosis pending (lighting bulb count diff)
  000480  +4.1955     diagnosis pending
  000487  +4.4553     extractor still drops most windows
  000490   0.0000  ✓ THIS SLICE
  000516  +1.5162     roof-window separation

Pyright net-zero on touched files (35 errors, same baseline). 755
tests pass (up from 754 — new `test_summary_000490_full_chain_sap_
matches_worksheet_pdf_exactly`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 20:13:19 +00:00
Khalim Conn-Kowlessar
36f2c7bbdf Slice 46a: Elmhurst mapper handles multi-bp Summary PDFs — Summary_000474 chain test flips green
ElmhurstSiteNotes had no representation for extensions: singular dimensions / walls / roof / floor fields could only describe the main bp. Summary PDFs lodge "1st Extension" / "2nd Extension" subsections in §4, §7, §8, §9 with optional "As Main: Yes" inheritance. This slice:

- Adds `ExtensionPart` dataclass and `ElmhurstSiteNotes.extensions: List[ExtensionPart]`.
- Adds `_split_section_by_bp` helper + per-bp parsing of dimensions / walls / roof / floor in the extractor; "As Main" inherits from the main bp.
- Refactors `_map_elmhurst_building_part` into a parameterised builder; adds `_map_elmhurst_building_parts` that yields Main + one SapBuildingPart per extension (capped at 4 per RdSAP10 §1.2).
- Scaffold test `test_summary_000474_mapper_produces_three_building_parts` flips from strict-xfail to passing.

Single-bp behaviour is unchanged (empty extensions list defaults). 752 existing tests stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:55:13 +00:00
Daniel Roth
78da2f88b6 Handle wall thickness "Unmeasurable" 🟩 2026-04-30 16:41:16 +00:00
Daniel Roth
01ebb2e0e1 extract window frame details from elmhurst site notes 🟩 2026-04-27 16:04:02 +00:00
Daniel Roth
8f94bb5435 extract window frame details from elmhurst site notes 🟥 2026-04-27 15:50:25 +00:00
Daniel Roth
afedbd2365 add energy fields to ElmhurstSiteNotes 2026-04-27 12:09:35 +00:00
Daniel Roth
540ee2c3c1 Elmhurst site notes dataclasses 2026-04-24 13:07:20 +00:00
Daniel Roth
0e69f8e7a5 extract cylinder thermostat 🟥 2026-04-21 15:16:49 +00:00
Daniel Roth
691efcad72 extracy heating immersion type 🟥 2026-04-21 15:07:18 +00:00
Daniel Roth
1e0d72a805 extract secondary heating system 🟥 2026-04-21 11:47:35 +00:00
Daniel Roth
0a8b9e0767 Include inspection metadata in output 2026-04-20 09:04:54 +00:00
Daniel Roth
968f025bc3 inspection date is date not str 2026-04-20 08:17:01 +00:00
Daniel Roth
edecbe6bef pdf json to RdSapSiteNotes full extract 🟩 2026-04-20 08:10:45 +00:00
Daniel Roth
6415980384 correct broken sitenote parsing tests 2026-04-13 16:22:21 +00:00
Daniel Roth
a7d460a2d4 pashub rdsap sitenotes pdf output to dataclasses 2026-04-13 11:35:02 +00:00