Slice 100b: API TFA — include per-bp RR floor area in continuous TFA

`_total_floor_area_from_building_parts` previously summed only
`sap_floor_dimensions[*].total_floor_area`; the RR floor area lives
under `sap_room_in_roof.floor_area` per RdSAP §3.9 convention and
was dropped from the per-bp TFA sum. Cert 9501 (113.08 m² real
TFA, of which 31.8 m² is RR) showed TFA 81.28 on the API path —
the cascade then under-computed occupancy N (Appendix J), HW kWh
(Appendix J), lighting kWh (Appendix L), and internal gains.

Add the RR contribution to the sum. The top-level
`schema.total_floor_area` scalar (integer-rounded for cert 9501:
113 vs raw 113.08) is still the fallback when no per-bp dims are
lodged.

Re-pinned residuals (improvements — TFA now includes the previously-
dropped RR storey):
- 0240: SAP -15 → -14, PE +15.69 → +12.49, CO2 +0.90 → +0.70
- 6035: PE +49.51 → +48.30, CO2 +1.14 → +1.10

Effect on cert 9501 API path: TFA 81.28 → 113.08 (= worksheet
113.08 exact). SAP delta still -9.32 vs worksheet — the remaining
gap is dominated by the missing PV credit (£250 — next slice).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-26 22:05:51 +00:00 committed by Jun-te Kim
parent 8e74b6b8b8
commit d529f91a8e
2 changed files with 25 additions and 12 deletions

View file

@ -1875,15 +1875,24 @@ def _measurement_value(field: Any) -> float:
def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float]:
"""Sum per-bp `sap_floor_dimensions[*].total_floor_area` to recover the
precise TFA. The GOV.UK EPB API JSON's top-level `total_floor_area`
is rounded to the integer (cert 001479: 30.45+30.77+5.37+1.92 = 68.51
lodged 69), but the worksheet computes continuous SAP from the
unrounded geometry. `epc.total_floor_area_m2` is read directly by
"""Sum per-bp `sap_floor_dimensions[*].total_floor_area` (plus each bp's
`sap_room_in_roof.floor_area` when present) to recover the precise
TFA.
The GOV.UK EPB API JSON's top-level `total_floor_area` is rounded to
the integer (cert 001479: 30.45+30.77+5.37+1.92 = 68.51 lodged 69),
but the worksheet computes continuous SAP from the unrounded
geometry. `epc.total_floor_area_m2` is read directly by
`water_heating_from_cert` to derive occupancy N (Appendix J), which
drives HW, lighting (Appendix L), and internal-gains kWh so the
rounded scalar shifts SAP by ~+0.0006 on cert 001479. Returns None
when no per-bp dims are lodged so callers fall back to the scalar."""
rounded scalar shifts SAP by ~+0.0006 on cert 001479. RR floor area
is NOT in `sap_floor_dimensions` on the API path (it lives under
`sap_room_in_roof.floor_area` per RdSAP §3.9 convention), so cert
9501's RR storey (31.8 m²) was previously dropped from the per-bp
TFA sum TFA 81.28 vs worksheet 113.08.
Returns None when no per-bp dims are lodged so callers fall back to
the scalar."""
if not building_parts:
return None
total = 0.0
@ -1893,6 +1902,10 @@ def _total_floor_area_from_building_parts(building_parts: Any) -> Optional[float
for fd in floor_dims:
total += _measurement_value(fd.total_floor_area)
found = True
rir: Any = getattr(bp, "sap_room_in_roof", None)
if rir is not None and getattr(rir, "floor_area", None) is not None:
total += _measurement_value(rir.floor_area)
found = True
return total if found else None

View file

@ -74,9 +74,9 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
_GoldenExpectation(
cert_number="0240-0200-5706-2365-8010",
actual_sap=73,
expected_sap_resid=-15,
expected_pe_resid_kwh_per_m2=+15.6873,
expected_co2_resid_tonnes_per_yr=+0.8999,
expected_sap_resid=-14,
expected_pe_resid_kwh_per_m2=+12.4941,
expected_co2_resid_tonnes_per_yr=+0.6957,
notes=(
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
@ -135,8 +135,8 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
cert_number="6035-7729-2309-0879-2296",
actual_sap=70,
expected_sap_resid=-6,
expected_pe_resid_kwh_per_m2=+49.5139,
expected_co2_resid_tonnes_per_yr=+1.1423,
expected_pe_resid_kwh_per_m2=+48.3043,
expected_co2_resid_tonnes_per_yr=+1.1019,
notes=(
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
"Slice 59 per-bp window apportionment tightens all 3 "