Commit graph

606 commits

Author SHA1 Message Date
Daniel Roth
313ae5253a Strict-raise a PasHub control label on a non-boiler system 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:46:23 +00:00
Daniel Roth
1e9231a086 Merge branch 'main' into feature/pashub-map-main-heating-control-1557 2026-07-14 16:47:17 +00:00
Daniel Roth
f7e6bf1184
Merge pull request #1579 from Hestia-Homes/feat/pashub-glazing-1562
PAS Hub mapper: window glazing_type label -> SAP10 code (#1562)
2026-07-14 17:47:03 +01:00
Daniel Roth
ca38c8a403 Merge branch 'main' into feature/pashub-map-main-heating-control-1557 2026-07-14 16:37:33 +00:00
Jun-te Kim
af83606183
Merge pull request #1577 from Hestia-Homes/fix/sap15-energyelement-party-wall-description
Default description on bare N/A-rated wall elements in SAP-15.0 certs
2026-07-14 17:36:46 +01:00
Daniel Roth
73666ffb0f Merge branch 'main' into feature/pashub-map-main-heating-control-1557 2026-07-14 16:36:25 +00:00
KhalimCK
de8e5c5621
Merge pull request #1576 from Hestia-Homes/feat/pashub-wall-construction-1560
PAS Hub mapper: wall_construction label -> SAP10 code (#1560)
2026-07-14 17:36:16 +01:00
Khalim Conn-Kowlessar
ff36014e6f Map PasHub window glazing_type labels to SAP10 cascade codes
`_map_sap_window` copied the raw `glazing_type` survey label; the
calculator's isinstance-int guards read it as non-int and fell to the
double-pre-2002 U=2.8 default for every window — over-counting heat loss
on the ~852 windows the survey dates to 2002-2021 (RdSAP Table 24 U=2.0).

Add `_pashub_glazing_type_int` mapping the surveyed labels to the SAP10
cascade glazing codes the calculator reads for window U
(`_GLAZING_CODE_TO_UWINDOW`) and solar g (`_G_PERPENDICULAR_BY_GLAZING_TYPE`):
before 2002 -> 3, 2002-2021 -> 2, unknown install date -> 3 (pre-2002
default), post-2022 -> 13. Strict-raises `UnmappedPasHubLabel` on an unknown
label; blank passes through as "".

Completes the fabric stack (party-wall/wall-construction/insulation/glazing):
cohort MAE 2.79 -> 2.66, within-0.5 11.4% -> 12.9%, signed -0.04 -> +0.33.

Closes #1562

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:33:34 +00:00
Jun-te Kim
4bc042f56a Correct overclaim: rating 0 means N/A, not confirmed party wall
Reviewer pushback: the gov EPC code table (epc_codes.csv) defines
energy_efficiency_rating 0 generically as "N/A", not specifically
"party wall". The payload carries no other identifying field, so the
actual cause can't be determined from the data alone. The fix itself
is unaffected (still calc-neutral regardless of cause) — just
correcting comments/test naming to not assert an unconfirmed fact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 16:29:28 +00:00
Jun-te Kim
7f7dcbc059 Default description on bare zero-rated party-wall elements in SAP-15.0 certs
Task 9d271e98-96e6-4be6-bb50-e9be6f954003 (portfolio 796) failed 21
"Top-floor flat" properties with "EnergyElement: missing required
field 'description'". Every one lodges a second walls[] entry that is
a bare zero-rated party-wall boundary element
({"energy_efficiency_rating": 0, "environmental_efficiency_rating": 0},
no description key at all) — unlike the sibling floors/roofs
"(other premises above/below)" sentinel convention, which does carry
text. Confirmed schema-wide across all 21 failing certs.

Default description to "" rather than fabricate construction text:
_joined_descriptions (heat_transmission.py) already filters out falsy
descriptions, so this is calc-neutral — it only unblocks the parse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 16:28:55 +00:00
Daniel Roth
168c610ade Strict-raise on an unrecognised PasHub main-heating control label 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:23:19 +00:00
Daniel Roth
be076bb9a4 Resolve the cohort's remaining PasHub boiler control labels to Table 4e codes 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:22:23 +00:00
Daniel Roth
a213150d7d Resolve the cohort's remaining PasHub boiler control labels to Table 4e codes 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:20:38 +00:00
Khalim Conn-Kowlessar
ed15bd13f7 Map PasHub walls_insulation_type labels to SAP10 codes
`from_site_notes` copied the raw `walls_insulation_type` survey label onto
the building part; the calculator's `_int_or_none` read it as `None`, so
the ~101 filled-cavity walls in the cohort lost their lower-U credit and
were over-counted.

Add `_pashub_wall_insulation_type_int` (mirroring the wall-construction
helper and the Elmhurst `_ELMHURST_WALL_INSULATION_TO_SAP10` sibling)
mapping the surveyed labels to the SAP10 wall-insulation codes `u_wall`
consumes ("As built" -> 4 assumed/default, "Filled Cavity" -> 2,
"External" -> 1), strict-raising `UnmappedPasHubLabel` on an unknown label.

Stacked on #1560, this is the fabric fix that removes the cohort's
systematic SAP bias: mean signed -0.79 -> -0.04, MAE 3.11 -> 2.79,
within-0.5 8.5% -> 11.4%.

Closes #1561

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:19:55 +00:00
Khalim Conn-Kowlessar
aa338dd152 Map PasHub walls_construction_type labels to SAP10 codes
`from_site_notes` copied the raw `walls_construction_type` survey label
onto the building part; the calculator's `_int_or_none` read it as `None`,
so every wall fell back to the age-band + thickness U-value and lost the
solid/cavity/timber/system-built distinction.

Add `_pashub_wall_construction_int` (mirroring `_pashub_party_wall_
construction_int`) mapping the four surveyed labels to the WALL_* codes
`u_wall` consumes, strict-raising `UnmappedPasHubLabel` on an unknown
label and passing a blank label through as the empty-string "no lodging"
sentinel (the field is `Union[int, str]`). On the Guinness GMCA cohort
this trims SAP MAE 3.22 -> 3.11 and lifts within-1.0 12.9% -> 14.9%.

Closes #1560

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:11:36 +00:00
Jun-te Kim
5d5fa73372 Derive cylinder_size when has_hot_water_cylinder is false on SAP-15.0/16.x certs
Task 9d271e98-96e6-4be6-bb50-e9be6f954003 (portfolio 796) failed property
715358 with "SapHeating: missing required field 'cylinder_size'": cert
0685-2881-6867-9029-1761 lodges has_hot_water_cylinder="false" but omits
sap_heating.cylinder_size entirely. Mirrors the already-handled inverse
gap in _normalize_sap_schema_16_x, so default to code 1 ("no cylinder")
rather than fail loud.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 16:11:24 +00:00
Daniel Roth
59215c822e Resolve PasHub main-heating control label to its SAP Table 4e code 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:01:04 +00:00
Daniel Roth
fc53594c99 Resolve PasHub main-heating control label to its SAP Table 4e code 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:59:45 +00:00
Daniel Roth
abab5bf9ac
Merge branch 'main' into feature/pashub-heat-emitter-code-1556 2026-07-14 16:34:28 +01:00
Khalim Conn-Kowlessar
f9bf45de5c Map PasHub party_wall_construction labels to SAP10 codes
`from_site_notes` copied the raw `party_wall_construction_type` survey
label onto the building part; the calculator read `None` and applied the
U=0.25 house default to every party wall — phantom heat loss on the
~165/205 solid party walls that RdSAP 10 Table 15 rates U=0.0.

Add `_pashub_party_wall_construction_int` (mirroring `_pashub_main_fuel_code`)
mapping the six surveyed labels to the SAP10 codes `u_party_wall` consumes,
strict-raising `UnmappedPasHubLabel` on an unknown label, and wire it into
both site-note building-part mappers. On the Guinness GMCA cohort this
halves the systematic SAP under-rate (mean signed -1.58 -> -0.85).

Refs #1559

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:26:30 +00:00
Daniel Roth
64e31742b3 Strict-raise on an unrecognised PasHub main-heating emitter label 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:17:56 +00:00
Daniel Roth
5b0f25847c Normalize PasHub site-notes emitter label to its SAP10 code 🟩
_map_sap_heating resolves the raw emitter label (e.g. Radiators) to its
SAP10 emitter code via _pashub_heat_emitter_code, reusing the Elmhurst
emitter map. Blank passes through; an unrecognised label strict-raises
UnmappedPasHubLabel at the mapper boundary (ADR-0015) instead of
resurfacing as the calculator's UnmappedSapCode: heat_emitter_type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:17:06 +00:00
Daniel Roth
8d45fcb544 Normalize PasHub site-notes emitter label to its SAP10 code 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:16:08 +00:00
Daniel Roth
b91763fc1d Preserve PasHub blank-Fuel electric-system inference through fuel-code normalization 🟪
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:34:28 +00:00
Daniel Roth
a8afcd1b3d Strict-raise on an unrecognised PasHub main-heating Fuel label 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:31:32 +00:00
Daniel Roth
1fead6e4c7 Strict-raise on an unrecognised PasHub main-heating Fuel label 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:30:49 +00:00
Daniel Roth
02ad8701d3 Normalize PasHub main-heating Mains gas to SAP fuel code 26 🟩
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:29:26 +00:00
Daniel Roth
52c5826afc Normalize PasHub main-heating Mains gas to SAP fuel code 26 🟥
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:27:34 +00:00
Jun-te Kim
64abeb9066 Map SAP-Schema-15.0's "Main building" identifier to MAIN
Properties 741843/741872 (uprns 100060714155/100021944594) failed
modelling_e2e subtasks 6ac7841a-9338-4efe-97e5-7e0d19b3055d and
c5450f03-5b0d-4068-a3b3-d1470bc0af57 with a bare StopIteration: their
SAP-Schema-15.0 (2011-era LIG-lodged) certs identify the main dwelling's
building part as "Main building" rather than "Main Dwelling", so
from_api_string fell to OTHER and left the property with no MAIN part —
crashing wall_recommendation.py's unguarded next(...).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 15:03:28 +00:00
Daniel Roth
b5c05166db Persist resolved UPRN on PasHub site-notes rows (#1537)
Thread the UPRN the PashubService has already resolved for a job into the
PasHub site-notes mapper so the EpcPropertyData aggregate is born with its
uprn set, and the existing save_epc_property_data path persists it.

- EpcPropertyDataMapper.from_site_notes gains uprn: Optional[int] = None and
  sets it unconditionally (site notes never carry a UPRN natively).
- parse_site_notes_pdf / _parse_pashub forward the uprn; the Elmhurst branch
  is untouched.
- PashubService coerces uprn str -> int in one place, carries it on the
  internal upload record, and passes it into parse_site_notes_pdf.
- No new lookups; jobs with no known UPRN still persist null, unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:19:28 +00:00
Jun-te Kim
086294fe36
Merge pull request #1535 from Hestia-Homes/verify/sap15-uprn-100010344955
Fix two SAP-15/16.x reduced-field mapper gaps blocking portfolio 824 modelling
2026-07-10 13:04:07 +01:00
Jun-te Kim
c0b4fd1e6a Fix has_hot_water_cylinder derivation to lodge the string convention, not a bool
The prior commit derived a Python bool (`cylinder_size != 1`), but
`from_rdsap_schema_17_1` reads the field via `schema.has_hot_water_cylinder
== "true"` — every real 15.0/16.x fixture lodges the lowercase string
"true"/"false", not a JSON boolean. A bare bool compares False against
that string check either way, so the derivation silently mapped every
cylinder-present cert to has_hot_water_cylinder=False too.

Caught by the requested true-branch test (mutating sap_16_2.json, which
lodges cylinder_size=2/has_hot_water_cylinder="true", to omit the field)
— it failed under the original bool-typed fix. Now emits "true"/"false"
strings to match the lodged convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:58:07 +00:00
Jun-te Kim
4f9bec7a4a Derive has_hot_water_cylinder from cylinder_size when the 16.x-family cert omits it
Task b9fcd354-2a33-4fc4-a64e-388c060a055c (portfolio 824 / scenario 1278)
also failed property_id 749229 (UPRN 100010349400) with "RdSapSchema17_1:
missing required field 'has_hot_water_cylinder'". Cert
9568-3034-6211-6089-5960 (SAP-Schema-15.0) lodges `sap_heating.cylinder_size`
but omits the separate top-level `has_hot_water_cylinder` boolean
RdSapSchema17_1 also requires.

Unlike `multiple_glazed_proportion` (deliberately left un-defaulted a few
lines above — a prior guessed default regressed the accuracy gate),
`cylinder_size` isn't a proxy needing inference: RdSAP 10 Table 28 defines
code 1 as "no cylinder" — literally the same fact `has_hot_water_cylinder`
encodes. Confirmed 1:1 across every real 15.0/16.x fixture that lodges both
fields (cylinder_size == 1 <-> false, every other code <-> true).

Added to the shared `_normalize_sap_schema_16_x`, so it covers the whole
15.0/16.0/16.1/16.2/16.3 reduced-field family, not just 15.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:48:12 +00:00
Jun-te Kim
2851a0151b Drop phantom alternative-wall building parts lodged inline (reinstates dropped fix)
Task b9fcd354-2a33-4fc4-a64e-388c060a055c (portfolio 824 / scenario 1278)
still failed 5 of 128 batches with NotNullViolation on
epc_building_part.construction_age_band even after the SAP-Schema-15.0
mapper landed (PR #1531). Cert 8169-6926-5310-0447-8996 (UPRN
100010344955, SAP-Schema-15.0) lodges a bare alternative-wall record
(wall_area/wall_construction/wall_insulation_type, no identifier, age
band, or floor dimensions) as a second sap_building_parts element rather
than under a distinct sap_alternative_wall_1 key. RdSapSchema17_1 (which
the 15.0/16.x reduced-field mappers delegate to) has no alt-wall slot to
route it to, so it persisted as a phantom building part with a null age
band.

_drop_building_parts_without_age_band already solved exactly this shape
of problem for lodging artifacts (ae81a81e), but that commit sits on
fix/drop-building-part-without-age-band, an unmerged branch that has
since drifted far behind main. Reinstating the same filter (fresh, not
cherry-picked) fixes both the original artifact case and this SAP-15
alt-wall case, since RdSapSchema17_1 has nowhere else to put it either
way.

Verified against all 174 properties across the task's originally-failing
batches: zero remaining null-age-band building parts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 11:34:17 +00:00
Jun-te Kim
b540cf1ca2 Unsupported EPC schema versions are skipped with a warning, not a crash
An unrecognised schema_type used to raise ValueError and abort the whole
call; now from_api_response logs a warning and returns None so one
unmapped cert doesn't break a batch. Schema 15.0 already has a mapper
(PR #1531) so it's unaffected; only genuinely unmapped versions skip.
2026-07-10 11:17:07 +00:00
Jun-te Kim
066cac8cb1
Merge pull request #1531 from Hestia-Homes/fix/sap-schema-15-0-mapper
Map SAP-Schema-15.0 certs (LIG-lodged, 2011-era reduced-field RdSAP shape)
2026-07-10 11:10:40 +01:00
Jun-te Kim
e91e0fa33e Fix cylinder_size/boiler_flue_type input errors for UPRN 100010086084 (SAP-15.0)
Both flagged mismatches were Elmhurst input errors (same silent-stale-value
contamination class as the earlier chimneys/wall-thickness bugs), not
parser or calculator bugs:

- cylinder_size: build_100010086084.py's water_heating() selected
  DropDownListCylinderSize by raw value "2", but this DOM's option values
  ARE their visible litre-band text (no "2" among them) — Playwright
  silently no-ops on a non-matching value, leaving a prior cert's "Medium
  (131-170L)" selection in place. Fixed to match by text ("Normal"), and
  added the missing mapper dict entries (Normal/Large litre-suffixed
  labels) to _ELMHURST_CYLINDER_SIZE_LABEL_TO_SAP10 (a real mapper-coverage
  gap — the calculator raises UnmappedElmhurstLabel rather than silently
  mis-mapping).

- boiler_flue_type: the boiler-code search dialog's combined "Balanced/Open
  Flue" Table 4b category doesn't drive the separate, independently
  selectable RadioButtonListFlueType field, which was left at an inherited
  "Balanced" from a prior cert. Fixed space_heating() to explicitly select
  "Open" to match the lodged gov-API code. This field isn't consumed by
  Sap10Calculator (ML/generator-only), so it had zero effect on the SAP
  score.

Re-downloaded elmhurst_summary.pdf/elmhurst_worksheet.pdf after the fixes
(Recommendations page confirmed clean). Elmhurst-PDF-inputs path moved
46->47 SAP from the cylinder-volume correction; gov-API SAP (53) and
Elmhurst's own worksheet (51) are unchanged. Full accuracy suites re-run
clean (67 passed, 67 skipped, 1 xfailed, same as before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-10 09:11:41 +00:00
Jun-te Kim
283b311fcd Real Elmhurst RdSAP10 ground truth for one SAP-Schema-15.0 cert
Built UPRN 100010086084 (the largest gov-API-vs-lodged outlier from the
20-cert batch) in Elmhurst's accredited RdSAP10 tool to get real ground
truth, per feedback that lodged rating isn't a valid comparison target.

Elmhurst worksheet: SAP 46-48 (orientation-dependent), vs this engine's
53 on gov-API inputs -- a real ~5-7pt residual gap. Ruled out with
direct empirical tests: total_floor_area, missing party_wall_length,
region/climate (SAP rating always uses UK-average weather per Appendix
U -- confirmed inert), boiler/secondary efficiency (exact match, 66%/
63% both sides). Confirmed real but NOT blind-fixed (need more than one
cert's evidence, and a wall-U-value patch tested the wrong direction in
isolation): a wall-thickness-unknown U-value fallback mismatch (this
engine 1.70, Elmhurst's own "unknown" resolution 1.40) and a window
U-value divergence (1.85 vs 2.52 W/m2K for the same nominal glazing
pick). Single-window orientation confirmed a ~2pt partial contributor.

Also fixes a small, separate, evidenced gap the build surfaced: the
Elmhurst summary parser didn't recognise the live tool's "Medium
(131-170L)" cylinder-size label (only the bare "Medium").

Full write-up is in the RealCertExpectation comment for this cert in
test_real_cert_sap_accuracy.py and the worklist. Residual gap is
intentionally left open for follow-up, not force-closed.
2026-07-09 22:31:01 +00:00
Jun-te Kim
c9fc2c6baf Map SAP-Schema-15.0 certs (LIG-lodged, 2011-era reduced-field RdSAP shape)
Task a40e71c4-fd56-4a33-8677-e8643e6a5bb4 (portfolio 824, scenario 1278)
failed 2 of 29 properties with "Unsupported EPC schema: 'SAP-Schema-15.0'"
(property_id 748445/748451). Traced to two real 2011 lodgements
(schema_version "LIG-15.0", assessment_type "RdSAP") for UPRNs
100010359769/100010359788 — structurally the same reduced-field RdSAP-17.1
shape as the already-handled SAP-Schema-16.x family, one revision older.

from_sap_schema_15_0 reuses _normalize_sap_schema_16_x and adds three
schema-wide defaults this older generation never lodges at all (confirmed
absent on both real certs, not just sparse on one):
  - door_count=1 (single external door, standard RdSAP assumption)
  - instantaneous_wwhrs=0/0/0 and has_fghrs="N" (predates WWHRS/FGHRS
    lodging — absence-means-none-fitted, same convention used elsewhere)
  - percent_draughtproofed=0 (mirrors the existing has_draught_lobby
    "assume none if unknown" convention in cert_to_inputs.py)
  - energy_rating_average=60 (inert metadata, never read by the calculator)

Both real certs now map and run end-to-end through the SAP-10 engine,
matching (or within 1 point of) their lodged SAP score.
2026-07-09 18:30:08 +00:00
Jun-te Kim
93b34981c9 Reject CEPC (commercial EPC) certs explicitly at the API mapper boundary
epb-data-warehouse's fixture set covers CEPC-7.0/7.1/8.0.0 (SBEM
non-domestic certs) alongside the RdSAP/SAP families we already map.
CEPC has no sensible EpcPropertyData shape (asset_rating/BER instead of
a SAP score, activities/hvac_systems instead of building elements), so
from_api_response now raises a typed NonDomesticSchema instead of
falling through to the generic "Unsupported EPC schema" ValueError.
2026-07-09 14:58:25 +00:00
Jun-te Kim
460058d970 Make RdSapSchema21_0_1.built_form optional — some real certs omit it entirely
Some gov-API RdSAP-21.0.1 certs (e.g. 2461-5385-9264-6821-5357, property_id=750232,
portfolio 824) omit `built_form` from the response entirely rather than sending a
code. `from_dict` hard-fails any dataclass field with no default that's absent
from the raw dict, so the whole cert failed to parse.

built_form: Optional[int] = None (kw_only, matching the has_fixed_air_conditioning
precedent already on this dataclass) — the downstream SAP-cascade consumer
(_api_sheltered_sides) already treats a non-int built_form as "no lodging" and
falls back to the documented default, so None here is an already-safe
degradation, not a new failure mode. Also guards mapper.py's
`built_form=str(schema.built_form)` against stringifying None into "None".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 11:24:43 +00:00
Jun-te Kim
a76e49e133
Merge pull request #1507 from Hestia-Homes/worktree-rdsap-glazing-cascade-fix
Cascade raw API glazing_type on lodged sap_windows (was passed through raw)
2026-07-08 15:27:18 +01:00
Jun-te Kim
41ded4740e Cascade raw API glazing_type on lodged sap_windows, not pass through raw
datatypes/epc/domain/mapper.py's from_rdsap_schema_{17_0,17_1,18_0,19_0,
20_0_0,21_0_0} all stored a lodged sap_windows[].glazing_type verbatim
instead of routing it through _api_cascade_glazing_type (which
_api_sap_window, used by 21_0_1, already does correctly). The raw API code
is the RdSAP-21 glazing enum (e.g. code 1 = "DG pre-2002"), not the SAP
10.2 Table 6b cascade enum the calculator's U/g-value tables are keyed on
(cascade code 1 = single glazed) -- so any lodged window whose raw code
happened to collide with a different cascade meaning was silently
mis-rated.

Found while validating PR #1503 (property 753950 / uprn 100021969385)
against Elmhurst: our engine's windows_w_per_k was 82.27 vs Elmhurst's
51.44 -- the dwelling's larger window (91% of glazed area) lodges raw
glazing_type=1 and was being modelled as single glazed instead of double,
the dominant contributor to a SAP 42 vs Elmhurst 44 gap. Fixing this
narrows windows_w_per_k to 50.26 (Elmhurst 51.44, ~2% off) and moves the
engine's score to 43.

Extracted the 5 duplicated reduced-schema-window blocks (17.0/17.1/18.0/
19.0/20.0.0, which lodge a minimal window shape with no glazing_gap/
frame_factor/transmission fields) into a shared _reduced_field_api_sap_
window helper that also now populates window_transmission_details/
frame_factor via the SAP10 lookup instead of leaving them None. 21.0.0's
richer inline block gets the same one-line cascade fix.

+6 regression tests (one per affected schema seam + 21.0.0). Full domain/
schema/prediction/comparable-properties/sap10_calculator/modelling_e2e
suites pass (2746 passed, only the 2 pre-existing unrelated failures);
component-accuracy gate 26/26 unaffected.
2026-07-08 13:05:43 +00:00
Jun-te Kim
430ac9eaa4 Derive multiple_glazed_proportion for 16.x certs with explicit glazing wording
Property 753950 (uprn 100021969385, cert 0141-2860-6891-9124-5625,
SAP-Schema-16.2) hard-failed modelling_e2e: RdSapSchema17_1 requires
multiple_glazed_proportion as a non-optional int, and this 16.x cert omits
the field entirely, lodging only the multiple_glazing_type="ND" sentinel.

The cert's own window.description ("Fully double glazed") states the
glazing extent unambiguously, so _normalize_sap_schema_16_x now derives
0/100 from "single"/"double" wording when the field is absent, mirroring
the existing single-glazed multiple_glazing_type cascade. This is
worklist P4 (.claude/skills/expand-sap-accuracy-corpus/worklist.md) —
a flat default was tried previously and reverted because making such
certs mappable at all pulls them into the EPC-prediction donor pool and
tips near-tie similarity matches; deriving from explicit text (rather
than a blind default) was the suggested unblock.

Re-measured the component-accuracy gate as the worklist asked: it drops
(has_hot_water_cylinder 0.8687->0.8586, cylinder_insulation_type
0.3333->0.1667, door_count residual 0.3131->0.3333) via the same
donor-pool-composition mechanism as the prior #1245/ADR-0037
re-baselines, not a prediction-logic loosening. Re-baselined the floors
with that rationale recorded inline.
2026-07-08 11:53:51 +00:00
Jun-te Kim
dbcdf29bd9 refactor(address2uprn): name the match/decision return types; rename helper
Address PR review (dancafc):
- introduce UprnMatch NamedTuple (datatypes/address_match.py) for the
  (uprn, address, lexiscore, certificate_number) return, replacing the bare
  4-tuple in get_uprn_from_epc_df / get_uprn_from_historic_epc /
  HistoricEpcResolver.resolve_uprn. Tuple-compatible, so unpacking is unchanged.
- rename get_uprn_with_epc_df -> get_uprn_from_epc_df (+ callers).
- type resolve_group_ambiguity via a GroupDecision NamedTuple and trim its
  docstring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:00:00 +00:00
Khalim Conn-Kowlessar
246834fac0 Map shard rows to HistoricEpc field-by-field so every column is pyright-checked 🟩
The row→domain mapper now names all 93 constructor arguments explicitly
instead of splatting a lowercased dict, takes a plain Mapping (a
DataFrame.to_dict("records") row) instead of a pandas Series, and ignores
columns the domain type doesn't know. A missing/renamed CSV column fails
loudly as a KeyError at the row. Both iterrows() call sites move to
to_dict("records") — pandas-stubs types iterrows' Series unparameterized,
which strict mode rejects. pandas-stubs + boto3-stubs[s3] make the stack
check clean: pyright strict is now 0 errors across the PR's files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 14:58:09 +00:00
Khalim Conn-Kowlessar
ab245de68d Merge remote-tracking branch 'origin/main' into feature/historic-epc-repository 2026-07-04 11:19:59 +00:00
Jun-te Kim
838668bfdb
Merge pull request #1451 from Hestia-Homes/fix/sap-heating-optional-air-conditioning
Make nested SapHeating.has_fixed_air_conditioning optional across all RdSAP schemas
2026-07-03 16:34:27 +01:00
Jun-te Kim
d769f4488a Suppress roof heat loss for API roof_construction codes 7 and 9
RdSAP 10 Specification (10-06-2025) §5.2.5: "If a dwelling or part of a
dwelling has commercial premises above record as another dwelling
above" — so code 9 ("another premises above") must get the same
zero-heat-loss treatment as code 7 ("another dwelling above"), per the
spec's own "there is no heat loss through the roof of a building part
that has the same dwelling above or another dwelling above."

The previous fix mapped both to None, which only avoided the mapper
crash — it didn't achieve zero heat loss. heat_transmission.py's
per-part suppression (part_roof_is_party) only fires when
roof_construction_type CONTAINS the literal string "another dwelling
above"; that string was only ever set on the Elmhurst ingestion path.
The API path mapped 7 (and, until now, 9) to None, so the suppression
never fired for any API-derived cert — has_exposed_roof (the
dwelling_type-label-only fallback) was the sole gate, with no way to
know about a per-part party-ceiling lodgement. Both codes now map to
"(another dwelling above)" so the existing suppression logic fires on
both ingestion paths, matching what
domain/epc/property_overrides/roof_party_ceiling_guard.py already does
independently for the landlord-override pipeline (same spec citation).

Verified against the motivating cert (uprn 10013320122): roof_w_per_k
now correctly computes to 0.0. Full RdSAP-21.0.1 corpus (1000 certs)
re-run: within-0.5 unchanged at 77.8%, MAE improves marginally
(0.6364 vs 0.637 ceiling) — no regression.
2026-07-03 14:44:01 +00:00
Jun-te Kim
71b970a697 Map API roof_construction code 9 ("another premises above") to None
Same party-ceiling semantics as the already-mapped code 7 ("another
dwelling above" / "same dwelling above") — the unit has no exposed
roof, so there's nothing for the cascade to compute a U-value for.
Confirmed against the live cert (uprn 10013320122): roofs[].description
= "(another premises above)", roof_insulation_location = "ND" (the
codebase's established "no exposed roof" signal).

Surfaced by 1 failed modelling_e2e subtask on portfolio 817
(task_source='modelling_e2e', job_completed > '2026-07-03 12:50:37.555+01:00').
2026-07-03 13:24:10 +00:00