diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index c6ba82a1..542dac15 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -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 diff --git a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py index e2637995..edac9197 100644 --- a/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py +++ b/domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py @@ -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 "