Commit graph

22 commits

Author SHA1 Message Date
Jun-te Kim
ce0f14a829 Normalize RdSAP insulated_door_u_value on the 20.0.0 + 21.0.0 paths
PRD #1385 phase 2. The 20.0.0 and 21.0.0 mappers dropped insulated_door_u_value
(top-level Optional float), unlike 21.0.1. heat_transmission reads it as the
measured door U-value override (cert_to_inputs -> heat_transmission:809), so
dropping it forced every door onto the RdSAP default U-value.

Verification: new test 3 passed (21.0.0 sample lodges 3; 20.0.0 latent -> inject
1.5 + absent -> None); corpus 6002 passed; SAP-accuracy regressions pass; pyright
unchanged (39 -> 39).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:48:25 +00:00
Jun-te Kim
7ee33b9ce8 Normalize RdSAP MVHR PCDF index + duct type on the 21.0.0 path
PRD #1385 phase 2. The 21.0.0 mapper dropped mechanical_ventilation_index_number
and mechanical_vent_duct_type, unlike 21.0.1. cert_to_inputs reads both (PCDB
Table 4f/329 heat-recovery/SFP lookup + the Table 329 in-use factor), so an
MVHR/MEV cert on the 21.0.0 path fell back to the SAP default efficiency.

Mirrors 21.0.1 exactly -- only the two fields it actually maps. The other four
mechanical_vent_duct_* schema fields have no mapper OR calculator consumer even
on 21.0.1 (verified), so they're deliberately left alone rather than populating
dead domain fields.

Verification: new test 1 passed (harvested 21.0.0 sample lodges index=12,
duct_type=3); corpus 6002 passed; SAP-accuracy regressions pass; pyright
unchanged (39 -> 39).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:24:12 +00:00
Jun-te Kim
e95c3cf906
Merge pull request #1393 from Hestia-Homes/fix/rdsap-21-0-0-percent-draughtproofed
Normalize RdSAP percent_draughtproofed on the 21.0.0 path
2026-07-01 15:17:50 +01:00
Jun-te Kim
a3a620a7e9 Normalize RdSAP percent_draughtproofed on the 21.0.0 path
PRD #1385 phase 2. 21.0.0 was the last mapper still dropping the lodged
percent_draughtproofed (top-level int) — every other path (17.0/…/21.0.1) reads
it. Dropping it understated the SAP §2 draughtproofing/infiltration credit for
21.0.0 certs.

Verification: new test 1 passed (harvested 21.0.0 sample lodges 100); corpus
6002 passed; SAP-accuracy regressions pass; pyright unchanged (39 -> 39).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:13:17 +00:00
Jun-te Kim
6819ca7d89
Merge pull request #1390 from Hestia-Homes/fix/rdsap-mapper-wwhrs-parity
Normalize RdSAP hot-water demand (WWHRS rooms) on the degraded 17.0/19.0 paths
2026-07-01 15:10:37 +01:00
Jun-te Kim
2892ce8560
Merge pull request #1391 from Hestia-Homes/fix/rdsap-mapper-building-part-fabric
Normalize RdSAP building-part fabric mapping on the degraded 17.0 path
2026-07-01 14:58:28 +01:00
Jun-te Kim
460d687b58
Merge pull request #1389 from Hestia-Homes/fix/rdsap-mapper-main-heating-controls
Normalize RdSAP main_heating_controls mapping on the degraded 17.0 path
2026-07-01 14:58:20 +01:00
Jun-te Kim
7bb89a36f1 Normalize RdSAP building-part fabric mapping on the degraded 17.0 path
PRD #1385, building-part fabric family. The 17.0 mapper hardcoded
floor_insulation_thickness / sap_room_in_roof to None and omitted
flat_roof_insulation_thickness, all of which the 17.0 schema lodges and the
19.0/20.0/21.x paths read:

  * floor_insulation_thickness: "NI" (no measured thickness) -> None so u_floor
    reaches its age-band default, else pass through (mirrors the 19.0 note).
  * flat_roof_insulation_thickness: pass through (was dropped).
  * sap_room_in_roof: map to SapRoomInRoof(floor_area, construction_age_band),
    None if absent (mirrors 19.0).

17.0-only: the 17.1 SapBuildingPart schema has none of these fields, so its None
there is correct (verified against the dataclass). roof_insulation_thickness is
intentionally left raw -- the sloping-ceiling resolve helper moves roof U-values
and is a separate follow-up.

Verification: new test 3 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:07:46 +00:00
Jun-te Kim
28b72fc67c Normalize RdSAP hot-water demand (WWHRS rooms) on the degraded 17.0/19.0 paths
PRD #1385, shower/WWHRS family. The 17.0 and 19.0 API mappers left
sap_heating.number_baths / mixer_shower_count as None, so the water-heating
demand (cert_to_inputs) fell back to defaults instead of the lodged bath/shower
room counts. Derive them from schema.sap_heating.instantaneous_wwhrs, mirroring
the 17.1/18.0/20.0 paths:

  number_baths       = rooms_with_bath_and_or_shower + rooms_with_bath_and_mixer_shower
  mixer_shower_count = rooms_with_mixer_shower_no_bath + rooms_with_bath_and_mixer_shower

Guarded on instantaneous_wwhrs is not None (Optional in the schema), like 17.1.
Counts, same meaning across the reduced-field family.

Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:52:33 +00:00
Jun-te Kim
04c29cc02a Normalize RdSAP main_heating_controls mapping on the degraded 17.0 path
PRD #1385, heating-controls family. The 17.0 API mapper omitted
main_heating_controls entirely, leaving the domain field None while the
19.0/20.0/21.x paths map the first lodged control via _map_energy_element. 17.0
lodges main_heating_controls (List[EnergyElement]) in all 23 local fixtures.

The mapped element is the same EnergyElement type 17.0 already carries for
main_heating / window, so this is straight parity (not new logic). Guarded on a
non-empty list -> None, matching the siblings.

Verification: new test 2 passed; test_mapper_corpus 6002 passed; recommendation
+ heating-control tests 85 passed (the domain EnergyElement field is distinct
from the subscriptable dict HeatingControlRecommender reads via the Property
wrapper, so no recommender destabilisation); SAP-accuracy regressions pass;
pyright unchanged (39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:44:02 +00:00
Jun-te Kim
a3bdcccfb1 Normalize RdSAP lighting mapping on the degraded 17.0/19.0 paths
PRD #1385, lighting family. The 17.0 and 19.0 API mappers hardcoded every
fixed-lighting bulb count to 0, dropping the lodged lighting mix that the
reference-complete 17.1/18.0/20.0 paths read. The schema lodges total +
low-energy OUTLET counts (ADR-0028); mirror the reference split — low-energy
outlets → low_energy bulbs, the remainder → incandescent — so the SAP
lighting-energy calc sees the real low-energy credit instead of assuming none.

led/cfl stay 0 (reduced-field certs don't split those); 21.0.x use a different
bulb-based lighting schema and are out of scope. The `total - low_energy`
subtraction is safe: all 37 local 17.0/19.0 fixtures have low_energy <= total
(no negative incandescent), matching the reference paths that already do this
unclamped.

Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions 56 passed (no pinned shifts); pyright unchanged
(39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:30:17 +00:00
Jun-te Kim
cf4c56df2e
Merge pull request #1386 from Hestia-Homes/fix/rdsap-mapper-ventilation-parity
Normalize RdSAP ventilation mapping on the degraded 17.0/19.0 paths
2026-07-01 13:26:25 +01:00
Jun-te Kim
e05f873282 Normalize RdSAP ventilation mapping on the degraded 17.0/19.0 paths
PRD #1385, ventilation family. The 17.0 and 19.0 API mappers dropped lodged
ventilation fields that the reference-complete 17.1/18.0/20.0 paths read:

  * open_fireplaces_count -> open_chimneys_count: hardcoded 0 on both
    (understating §2 infiltration).
  * percent_draughtproofed: omitted on both.
  * 17.0 additionally passed no sap_ventilation block, so the §2 cascade fell
    back to NATURAL + its default sheltered_sides=2. Restored from the lodged
    mechanical_ventilation + built_form via the existing _api_* helpers.

Semantic parity verified (not just type-equality): built_form and
mechanical_ventilation use the same gov code lists the 17.1/18.0/19.0/20.0/21.x
paths already map through these helpers; all 244 RdSAP fixtures resolve with zero
UnmappedApiCode, and the strict-coverage guard raises on any future divergence.
For the current corpus the 17.0 score-mover is sheltered_sides from built_form
(every 17.0 cert lodges mechanical_ventilation=0 -> NATURAL).

Adds a regression test asserting both paths carry the fields through
from_api_response on real certs.

Verification: new test 2 passed; test_mapper_corpus 6002 passed; SAP-accuracy /
RealCertExpectation regressions pass; pyright unchanged (39 -> 39, pre-existing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:17:54 +00:00
Jun-te Kim
12529a3f23 Merge remote-tracking branch 'origin/main' into fix/rdsap-17-0-sap-heating-drops
# Conflicts:
#	datatypes/epc/domain/mapper.py
2026-07-01 12:16:29 +00:00
Jun-te Kim
cd798d3513 Fix RdSAP-17.0 mapper dropping the rest of the lodged sap_heating block
`from_rdsap_schema_17_0` was an older copy that hardcoded a cluster of
lodged `sap_heating` fields to None while every other API path
(19.0/20.0/…) reads them from `schema.sap_heating`. PR #1380 restored the
secondary-heating pair; this restores the rest of the same block, all of
which `RdSapSchema17_0` lodges:

  MainHeatingDetail: boiler_flue_type, fan_flue_present,
    central_heating_pump_age, main_heating_index_number (PCDB efficiency index)
  SapHeating: cylinder_thermostat, cylinder_insulation_thickness

Impact — cylinder_thermostat (12/23 local 17.0 fixtures) read as "no
thermostat", so the water-heating worksheet over-applied its ×1.3 penalty
and `recommend_cylinder_thermostat` could fire on a dwelling that already
has one; cylinder_insulation_thickness (13/23) discarded the measured value
so the cylinder-loss cascade fell back to age-band defaults;
main_heating_index_number lost the PCDB efficiency lookup.

Mirrors the 19.0 path exactly. secondary_*_type left untouched so this PR
stays independent of #1380 (no overlapping lines). Adds a regression test
asserting the whole block survives `from_api_response` on a real 17.0 cert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:02:32 +00:00
Jun-te Kim
3282cef04e Fix RdSAP-17.0 mapper dropping lodged secondary heating
The `from_rdsap_schema_17_0` path hardcoded `sap_heating.secondary_heating_type`
and `secondary_fuel_type` to None, unlike every other API path (19.0/20.0/…),
which read them from `schema.sap_heating`. The gov EPC API *does* populate a
secondary heating SAP code for a genuine fixed secondary (e.g. 691 "electric
room heaters", 694), so the hardcode silently discarded it.

Because `recommend_secondary_heating_removal` gates purely on
`sap_heating.secondary_heating_type`, any 17.0 cert carrying a fixed secondary
would never get its removal recommendation. Surfaced while auditing portfolio
814's recommendations; 814 has 17.0 certs but none with a fixed secondary, so
the defect was latent there — this restores parity with the other paths so it
cannot bite.

Mirrors the 19.0 path exactly (secondary_fuel_type via `_api_secondary_fuel_type`).
Adds a regression guard that injects a fixed secondary into a real 17.0 cert and
asserts the code survives the mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 10:41:52 +00:00
Khalim Conn-Kowlessar
23245a085c test(elmhurst): party-ceiling BP window is vertical, not a rooflight 🟥
_is_elmhurst_roof_window mis-routes a vertical window (Location "External
wall") to sap_roof_windows when the building part's roof is a PARTY ceiling
(A "Another dwelling above" / NR "Non-residential space above"). A party
ceiling has no external roof, so it cannot host a rooflight.

Surfaced by simulated case 53 (cert 000565 re-keyed as a mid-floor electric-
storage flat, roof "A Another dwelling above"): its External-wall window
(U 2.00) routed to sap_roof_windows -> window area not deducted from the wall
(wall over-counted ~7 W/K) + priced as a roof window -> our SAP 74.0 vs
Elmhurst worksheet 75. Elmhurst lodges it Type "Window", Location "External
wall".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 07:06:04 +00:00
Jun-te Kim
6d98ecf375 Tolerate non-string building-part identifier (fix TypeError)
A SAP-16.x cohort cert (9258-4062-7265-2844-7954) lodges a bare int building-
part identifier (the second part as `1` after "Main Dwelling").
`BuildingPartIdentifier.from_api_string` regex-matched it assuming a string and
raised "TypeError: expected string or bytes-like object, got 'int'", failing the
whole property.

Fix: guard the match on `isinstance(api_identifier, str)` so a non-string
identifier falls to OTHER, matching the documented "anything unrecognised ->
OTHER" contract. The baseline SAP fabric sums all building parts regardless of
identifier, so OTHER is SAP-neutral; the identifier only labels parts for
measure targeting. Fixes every mapper (all route through from_api_string).

Cert now maps + calculates (sap 63). Regression test added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:33:23 +00:00
Khalim Conn-Kowlessar
d05fdbe6f2 fix(mapper): map truncated Elmhurst glazing label "Double between 2002" (RdSAP 10 Table 24 code 3)
The full RdSAP-Schema-21 label is "Double between 2002 and 2021" (double
glazing installed 2002-2021, SAP 10.2 Table 24 code 3). When the Elmhurst
Summary PDF wraps the trailing "and 2021" into an adjacent table cell that
the extractor joins away, the surfaced label truncates to "Double between
2002" — the same artifact already handled for "Triple post or during".
`_elmhurst_glazing_type_code` raised UnmappedElmhurstLabel on it, blocking
the whole Summary (surfaced on the simulated-case-46 multi-attribute
worksheet). Added the truncated form as a code-3 alias.

pyright not installed in this container.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:15:25 +00:00
Khalim Conn-Kowlessar
b2b6f8e954 fix(mapper): map Elmhurst "Value known" cylinder to measured volume (code 6)
The Elmhurst Summary §15.1 lodges "Cylinder Size: Value known" with the
measured volume in the "Cylinder Volume (l)" line — the Summary-path
equivalent of the gov-API "Exact" descriptor. The mapper had no entry for
"Value known" so `_elmhurst_cylinder_size_code` raised UnmappedElmhurstLabel,
and even once mapped the measured volume was never threaded through, so the
cascade dropped the cylinder storage loss (~468 kWh/yr) from (219) water
heating on every measured-volume-cylinder Summary.

Per RdSAP 10 §10.5 Table 28 (p.55) a measured cylinder volume is used
directly. Map "Value known" → cascade code 6 (Exact) and thread the §15.1
"Cylinder Volume (l)" value into SapHeating.cylinder_volume_measured_l, which
`_cylinder_volume_l_from_code` (cert_to_inputs.py:5281) already reads for
code 6 — mirroring the gov-API path (mapper.py:1575/1885).

Pins simulated case 39 (P960-0001-001431): an age-A mid-terrace on direct-
acting electric room heaters (SAP code 691, cat 10, control 2602) with
electric-immersion DHW off a 117 L "Value known" cylinder. The full
extractor→mapper→calculator cascade now reproduces the worksheet's SAP-rating
block EXACTLY — SAP value 36.6365 (band F) and (272) CO2 2056.0731 kg/yr,
with (219) water heating 2637.5049 and (255) total energy cost 1802.0039.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:57:25 +00:00
Khalim Conn-Kowlessar
545bb8c328 fix(mapper): map cylinder "No Insulation" to insulation_type=None
The Elmhurst §15.1 "Insulated: No Insulation" label was lodged but absent
from `_ELMHURST_CYLINDER_INSULATION_LABEL_TO_SAP10`, so
`_elmhurst_cylinder_insulation_code` raised UnmappedElmhurstLabel — blocking
the parse of every Solar PV example cert (the solar `before` lodges "No
Insulation"). An uninsulated cylinder has no insulation *type*, so per the
no-misleading-insulation convention it maps to `cylinder_insulation_type =
None` rather than naming a material; the lodged 0 mm thickness carries the
storage-loss signal the SAP 10.2 Table 2 dispatch needs.

Slice 1 of the Solar PV Recommendation Generator (ADR-0026).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:52:06 +00:00
Khalim Conn-Kowlessar
504f592a27 feat(modelling): Epc.sap_lower_bound() — band → minimum SAP (#1160)
Slice 3a. The inverse of Epc.from_sap_score: the minimum SAP rating in a
band (C → 69, B → 81, …), used as the Optimiser's repair target for an
INCREASING_EPC goal (goal_value "C" → target SAP 69). Keeps the
band-target derivation in the domain rather than re-coupling to
backend.app.utils.epc_to_sap_lower_bound. 8 tests incl. round-trip
through from_sap_score; pyright strict clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:50:40 +00:00