From e7af6fda6638cc01c42e26c3872102de4464a809 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 8 Jun 2026 23:16:34 +0000 Subject: [PATCH] fix(ventilation): map API mechanical_ventilation_index_number for MEV fan electricity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the §2 MV-kind slice. Once MEV dwellings stopped under-stating their ventilation HEAT loss, a +0.9 SAP over-rate residual remained — the MEV FAN ELECTRICITY (§5 Table 4f line (230a), `SFPav × 1.22 × V`, PCDB Tables 322 decentralised-MEV + 329 in-use factors). `_mev_decentralised_kwh_per_yr_from_cert` already composes it, but reads `epc.mechanical_ventilation_index_number` + `epc.mechanical_vent_duct_type`, and the API builder (`from_rdsap_schema_21_0_1`) never set either — so `pcdf_id is None` short-circuited the fan energy to 0 on every API cert (the Summary/ Elmhurst path set them, so cert 000565 already billed it). Wire both schema fields through the 21.0.1 API construction (the corpus schema). Eval: the 9 MEV certs carrying a PCDB index closed +0.90 -> +0.13 signed (fan electricity now billed); headline within-0.5 55.01% -> 55.12%, mean|err| 1.233 -> 1.232, 909 computed / 0 raises. Only those 9 certs move (clean diff). The 11 index-less MEV certs still sit at +1.36 — they need the SAP Table 4h DEFAULT specific fan power (no PCDB record), a separate slice. New end-to-end test + fixture (cert 1300, Titon-class dMEV index 500777, Flexible duct): from_api_response preserves the index + duct type and (230a) resolves to a positive fan-energy contribution. Goldens + full calc/epc regression green; pyright net-zero. Co-Authored-By: Claude Opus 4.8 --- datatypes/epc/domain/mapper.py | 12 +++++++++ .../golden/1300-7634-0922-3203-3563.json | 1 + .../rdsap/test_golden_fixtures.py | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json diff --git a/datatypes/epc/domain/mapper.py b/datatypes/epc/domain/mapper.py index b9de5feb..0c7a0e76 100644 --- a/datatypes/epc/domain/mapper.py +++ b/datatypes/epc/domain/mapper.py @@ -1559,6 +1559,18 @@ class EpcPropertyDataMapper: open_chimneys_count=schema.open_chimneys_count, insulated_door_count=schema.insulated_door_count, draughtproofed_door_count=schema.draughtproofed_door_count, + # Mechanical ventilation PCDB plumbing — feeds the §5 Table 4f + # line (230a) decentralised-MEV fan electricity + # (`SFPav × 1.22 × V`, PCDB Tables 322/329). Without the index + # the cascade's `_mev_decentralised_kwh_per_yr_from_cert` + # short-circuits to 0, leaving the MEV fan running cost off the + # bill (the +0.9 SAP over-rate residual on the MEV cohort once + # the §2 ventilation heat-loss was fixed). Duct type selects the + # Table 329 in-use factor (1=Flexible / 2=Rigid). + mechanical_ventilation_index_number=( + schema.mechanical_ventilation_index_number + ), + mechanical_vent_duct_type=schema.mechanical_vent_duct_type, # Lighting led_fixed_lighting_bulbs_count=schema.led_fixed_lighting_bulbs_count, cfl_fixed_lighting_bulbs_count=schema.cfl_fixed_lighting_bulbs_count, diff --git a/tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json b/tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json new file mode 100644 index 00000000..3e4b64aa --- /dev/null +++ b/tests/domain/sap10_calculator/rdsap/fixtures/golden/1300-7634-0922-3203-3563.json @@ -0,0 +1 @@ +{"uprn": 100010371693, "roofs": [{"description": "Pitched, 400+ mm loft insulation", "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5}], "walls": [{"description": "Cavity wall, filled cavity", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}], "floors": [{"description": "Solid, no insulation (assumed)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}], "status": "entered", "tenure": 2, "window": {"description": "Fully double glazed", "energy_efficiency_rating": 2, "environmental_efficiency_rating": 2}, "addendum": {"addendum_numbers": [8]}, "lighting": {"description": "Good lighting efficiency", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, "postcode": "PR6 0AZ", "hot_water": {"description": "From main system", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}, "post_town": "CHORLEY", "built_form": 2, "created_at": "2026-05-12 09:06:05", "door_count": 2, "region_code": 19, "report_type": 2, "sap_heating": {"number_baths": 0, "cylinder_size": 1, "shower_outlets": [{"shower_wwhrs": 1, "shower_outlet_type": 2}], "number_baths_wwhrs": 0, "water_heating_code": 901, "water_heating_fuel": 26, "secondary_fuel_type": 29, "main_heating_details": [{"has_fghrs": "N", "main_fuel_type": 26, "boiler_flue_type": 2, "fan_flue_present": "Y", "heat_emitter_type": 1, "ttzc_index_number": 200131, "emitter_temperature": 0, "main_heating_number": 1, "main_heating_control": 2112, "main_heating_category": 2, "main_heating_fraction": 1, "central_heating_pump_age": 0, "main_heating_data_source": 1, "main_heating_index_number": 19080}], "immersion_heating_type": "NA", "secondary_heating_type": 691, "has_fixed_air_conditioning": "false"}, "sap_version": 10.2, "sap_windows": [{"pvc_frame": "true", "glazing_gap": 12, "orientation": 3, "window_type": 1, "glazing_type": 3, "window_width": 0.4, "window_height": 1.3, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 3, "window_type": 1, "glazing_type": 3, "window_width": 0.4, "window_height": 1.3, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 3, "window_type": 1, "glazing_type": 3, "window_width": 1.75, "window_height": 1.3, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 7, "window_type": 1, "glazing_type": 3, "window_width": 1.1, "window_height": 1.1, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}, {"pvc_frame": "true", "glazing_gap": 12, "orientation": 7, "window_type": 1, "glazing_type": 3, "window_width": 1.2, "window_height": 1.2, "draught_proofed": "true", "window_location": 0, "window_wall_type": 1, "permanent_shutters_present": "N", "permanent_shutters_insulated": "N"}], "schema_type": "RdSAP-Schema-21.0.1", "uprn_source": "Energy Assessor", "country_code": "ENG", "main_heating": [{"description": "Boiler and radiators, mains gas", "energy_efficiency_rating": 4, "environmental_efficiency_rating": 4}], "air_tightness": {"description": "(not tested)", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, "dwelling_type": "Semi-detached bungalow", "language_code": 1, "pressure_test": 4, "property_type": 1, "address_line_1": "27 Epping Place", "assessment_type": "RdSAP", "completion_date": "2026-05-12", "inspection_date": "2026-05-07", "wet_rooms_count": 2, "extensions_count": 0, "measurement_type": 1, "total_floor_area": 39, "transaction_type": 5, "conservatory_type": 1, "heated_room_count": 2, "registration_date": "2026-05-12", "sap_energy_source": {"mains_gas": "Y", "meter_type": 2, "pv_connection": 2, "photovoltaic_supply": [[{"pitch": 3, "peak_power": 1.35, "orientation": 7, "overshading": 1}], [{"pitch": 3, "peak_power": 1.35, "orientation": 3, "overshading": 1}]], "wind_turbines_count": 0, "gas_smart_meter_present": "true", "is_dwelling_export_capable": "true", "wind_turbines_terrain_type": 2, "electricity_smart_meter_present": "true"}, "secondary_heating": {"description": "Room heaters, electric", "energy_efficiency_rating": 0, "environmental_efficiency_rating": 0}, "lzc_energy_sources": [11], "sap_building_parts": [{"identifier": "Main Dwelling", "wall_dry_lined": "N", "wall_thickness": 300, "floor_heat_loss": 7, "roof_construction": 4, "wall_construction": 4, "building_part_number": 1, "sap_floor_dimensions": [{"floor": 0, "room_height": {"value": 2.48, "quantity": "metres"}, "floor_insulation": 1, "total_floor_area": {"value": 39.2, "quantity": "square metres"}, "party_wall_length": {"value": 7, "quantity": "metres"}, "floor_construction": 1, "heat_loss_perimeter": {"value": 18.2, "quantity": "metres"}}], "wall_insulation_type": 2, "construction_age_band": "C", "party_wall_construction": 0, "wall_thickness_measured": "Y", "roof_insulation_location": 2, "roof_insulation_thickness": "400mm+", "wall_insulation_thickness": "NI", "floor_insulation_thickness": "NI"}], "solar_water_heating": "N", "habitable_room_count": 2, "heating_cost_current": {"value": 670, "currency": "GBP"}, "insulated_door_count": 0, "co2_emissions_current": 1.2, "energy_rating_average": 60, "energy_rating_current": 82, "lighting_cost_current": {"value": 29, "currency": "GBP"}, "main_heating_controls": [{"description": "Time and temperature zone control", "energy_efficiency_rating": 5, "environmental_efficiency_rating": 5}], "has_hot_water_cylinder": "false", "heating_cost_potential": {"value": 601, "currency": "GBP"}, "hot_water_cost_current": {"value": 203, "currency": "GBP"}, "mechanical_ventilation": 2, "percent_draughtproofed": 100, "suggested_improvements": [{"sequence": 1, "typical_saving": {"value": 68, "currency": "GBP"}, "indicative_cost": "\u00a35,000 - \u00a310,000", "improvement_type": "W2", "improvement_details": {"improvement_number": 58}, "improvement_category": 5, "energy_performance_rating": 84, "environmental_impact_rating": 84}], "co2_emissions_potential": 1.0, "energy_rating_potential": 84, "kitchen_duct_fans_count": 0, "kitchen_room_fans_count": 1, "kitchen_wall_fans_count": 0, "lighting_cost_potential": {"value": 29, "currency": "GBP"}, "schema_version_original": "21.0.1", "hot_water_cost_potential": {"value": 203, "currency": "GBP"}, "renewable_heat_incentive": {"water_heating": 1148.49, "space_heating_existing_dwelling": 5025.34}, "draughtproofed_door_count": 2, "mechanical_vent_duct_type": 1, "energy_consumption_current": 181, "has_fixed_air_conditioning": "false", "multiple_glazed_proportion": 100, "non_kitchen_duct_fans_count": 0, "non_kitchen_room_fans_count": 1, "non_kitchen_wall_fans_count": 0, "calculation_software_version": "5.02r0344", "energy_consumption_potential": 158, "environmental_impact_current": 81, "current_energy_efficiency_band": "B", "environmental_impact_potential": 84, "has_heated_separate_conservatory": "false", "potential_energy_efficiency_band": "B", "mechanical_ventilation_index_number": 500777, "co2_emissions_current_per_floor_area": 30, "low_energy_fixed_lighting_bulbs_count": 5, "incandescent_fixed_lighting_bulbs_count": 0, "is_mechanical_vent_approved_installer_scheme": "false"} \ No newline at end of file diff --git a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py index 99dbad76..63e3b83d 100644 --- a/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py +++ b/tests/domain/sap10_calculator/rdsap/test_golden_fixtures.py @@ -983,6 +983,31 @@ def test_api_to_domain_mapper_preserves_main_heating_index_number( assert abs(inputs.main_heating_efficiency - expected_winter_eff) <= 1e-3 +def test_api_mapper_preserves_mechanical_ventilation_pcdb_index_for_mev_fan_energy() -> None: + # Arrange — cert 1300 is a decentralised-MEV dwelling + # (mechanical_ventilation=2) lodging a PCDB index (500777) + duct type + # (1=Flexible). The API path previously DROPPED + # `mechanical_ventilation_index_number`, so the §5 Table 4f line (230a) + # MEV fan electricity (`SFPav × 1.22 × V`, PCDB Tables 322/329) + # short-circuited to 0 in `_mev_decentralised_kwh_per_yr_from_cert` — + # leaving the fan running cost off the bill (+0.9 SAP over-rate residual + # on the MEV cohort once the §2 ventilation heat loss was fixed). + from domain.sap10_calculator.rdsap.cert_to_inputs import ( + _mev_decentralised_kwh_per_yr_from_cert, # pyright: ignore[reportPrivateUsage] + ) + + doc = _load_cert("1300-7634-0922-3203-3563") + + # Act + epc = EpcPropertyDataMapper.from_api_response(doc) + + # Assert — the PCDB pointer + duct type survive the API mapping, and the + # (230a) MEV fan electricity resolves to a positive contribution. + assert epc.mechanical_ventilation_index_number == 500777 + assert epc.mechanical_vent_duct_type == 1 + assert _mev_decentralised_kwh_per_yr_from_cert(epc) > 0.0 + + def test_0240_api_wall_type_4_windows_map_to_roof_windows() -> None: """Cert 0240 lodges 6 windows with `window_wall_type=4` — the RdSAP API code for a roof window ("Roof of Room" rooflight / inclined