Slice 39: PV credit input boundary uses RdSAP10 Table 32 + DE22 PV fixture

`_pv_export_credit_gbp_per_kwh` previously read from `prices.unit_price`
(SAP10.2 Table 12 code 60 = 5.59 p/kWh) while the actual rating
cascade inside _fuel_cost reads from `table_32_unit_price_p_per_kwh`
(RdSAP10 Table 32 code 60 = 13.19 p/kWh, same as standard electricity).
The exposed CalculatorInputs.pv_export_credit_gbp_per_kwh therefore
misled about what the cascade applied. The calculator's fallback path
at calculator.py:442 fires for synthetic inputs without `fuel_cost`
and would compute the wrong PV credit by reading the misleading input.

Per ADR-0010 §10 the rating cascade uses Table 32 prices. Unified
both code paths on Table 32 so the input boundary reports the same
13.19 p/kWh the cascade applies. Cert-path math unchanged (cert path
always sets fuel_cost). Synthetic/fallback path now consistent with
cert path.

Also adds cert 2130-1033-4050-5007-8395 (DE22, end-terrace + 1 ext,
gas combi PCDB 17505, 2× 2.04 kWp PV) as 9th golden fixture. First
PV-bearing cert in the cohort. Pinned residual is SAP +8 / PE −61 /
CO2 +0.19 — spec-version drift not a code bug (cert was scored by
SAP10.2 software using Table 12 PV export 5.59 p/kWh = £194 credit
→ SAP 82; calc targets RdSAP10 Table 32 = 13.19 p/kWh = £457 credit
→ SAP 90). Both internally consistent against their own price table.
The PE residual is amplified because PV gen also offsets PE via
inputs.other_primary_factor, which scales with gen kWh independently
of the export-credit price.

930/930 Elmhurst cascade green. 14/14 golden cohort + 1 new
cert_to_inputs unit test green. Pyright net-zero (49 errors before
and after).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-24 11:49:04 +00:00
parent 6a6811e548
commit 1d7c13b995
4 changed files with 501 additions and 6 deletions

View file

@ -698,11 +698,15 @@ def _pv_generation_kwh_per_yr(epc: EpcPropertyData) -> float:
return total_kwp * _PV_ANNUAL_YIELD_KWH_PER_KWP
def _pv_export_credit_gbp_per_kwh(prices: PriceTable) -> float:
"""PV cost credit per kWh generated. SAP 10.2 Table 12 code 60 (PV
export to grid) 5.59 p/kWh on the spec table, 13.19 p/kWh under
cert calibration (legacy unit prices)."""
return prices.unit_price_p_per_kwh(_PV_EXPORT_TARIFF_CODE) * _PENCE_TO_GBP
def _pv_export_credit_gbp_per_kwh() -> float:
"""PV cost credit per kWh generated. Per ADR-0010 §10 the rating
cascade uses RdSAP10 Table 32 prices; code 60 (PV export to grid)
= 13.19 p/kWh (same as standard electricity PV gen displaces
grid imports at the standard rate). The legacy SAP 10.2 Table 12
value (5.59 p/kWh) is no longer the target and is intentionally
not read here, so the CalculatorInputs boundary reports the same
rate _fuel_cost applies internally."""
return table_32_unit_price_p_per_kwh(_PV_EXPORT_TARIFF_CODE) * _PENCE_TO_GBP
def _other_fuel_cost_gbp_per_kwh(
@ -2178,7 +2182,7 @@ def cert_to_inputs(
_STANDARD_ELECTRICITY_FUEL_CODE,
),
pv_generation_kwh_per_yr=_pv_generation_kwh_per_yr(epc),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(prices),
pv_export_credit_gbp_per_kwh=_pv_export_credit_gbp_per_kwh(),
secondary_heating_fraction=secondary_fraction_value,
secondary_heating_efficiency=secondary_efficiency_value,
energy_requirements=energy_requirements_result,

View file

@ -0,0 +1,453 @@
{
"uprn": 100030334057,
"roofs": [
{
"description": "Pitched, 300 mm loft insulation",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
{
"description": "Pitched, 100 mm loft insulation",
"energy_efficiency_rating": 3,
"environmental_efficiency_rating": 3
}
],
"walls": [
{
"description": "Solid brick, as built, no insulation (assumed)",
"energy_efficiency_rating": 1,
"environmental_efficiency_rating": 1
},
{
"description": "Solid brick, with internal insulation",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"floors": [
{
"description": "Suspended, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
{
"description": "Solid, no insulation (assumed)",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
}
],
"status": "entered",
"tenure": 3,
"window": {
"description": "Fully double glazed",
"energy_efficiency_rating": 2,
"environmental_efficiency_rating": 2
},
"addendum": {
"addendum_numbers": [
8
]
},
"lighting": {
"description": "Excellent lighting efficiency",
"energy_efficiency_rating": 5,
"environmental_efficiency_rating": 5
},
"postcode": "DE22 3RW",
"hot_water": {
"description": "From main system",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
},
"post_town": "DERBY",
"psv_count": 0,
"built_form": 3,
"created_at": "2025-07-24 16:36:27",
"door_count": 2,
"region_code": 6,
"report_type": 2,
"sap_heating": {
"number_baths": 1,
"cylinder_size": 1,
"shower_outlets": [
{
"shower_outlet": {
"shower_wwhrs": 1,
"shower_outlet_type": 1
}
}
],
"number_baths_wwhrs": 0,
"water_heating_code": 901,
"water_heating_fuel": 26,
"cylinder_thermostat": "N",
"main_heating_details": [
{
"has_fghrs": "N",
"main_fuel_type": 26,
"boiler_flue_type": 2,
"fan_flue_present": "Y",
"heat_emitter_type": 1,
"emitter_temperature": 1,
"main_heating_number": 1,
"main_heating_control": 2106,
"main_heating_category": 2,
"main_heating_fraction": 1,
"central_heating_pump_age": 0,
"main_heating_data_source": 1,
"main_heating_index_number": 17505
}
],
"immersion_heating_type": "NA",
"has_fixed_air_conditioning": "false"
},
"sap_version": 10.2,
"sap_windows": [
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 1,
"glazing_type": 3,
"window_width": {
"value": 0.95,
"quantity": "m"
},
"window_height": {
"value": 1.75,
"quantity": "m"
},
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 8,
"window_type": 1,
"glazing_type": 3,
"window_width": {
"value": 0.95,
"quantity": "m"
},
"window_height": {
"value": 1.7,
"quantity": "m"
},
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 4,
"window_type": 1,
"glazing_type": 3,
"window_width": {
"value": 1,
"quantity": "m"
},
"window_height": {
"value": 1.7,
"quantity": "m"
},
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 4,
"window_type": 1,
"glazing_type": 3,
"window_width": {
"value": 0.95,
"quantity": "m"
},
"window_height": {
"value": 1.7,
"quantity": "m"
},
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 6,
"window_type": 1,
"glazing_type": 3,
"window_width": {
"value": 0.7,
"quantity": "m"
},
"window_height": {
"value": 0.9,
"quantity": "m"
},
"draught_proofed": "true",
"window_location": 0,
"window_wall_type": 1,
"permanent_shutters_present": "N",
"permanent_shutters_insulated": "N"
},
{
"pvc_frame": "true",
"glazing_gap": "16+",
"orientation": 6,
"window_type": 1,
"glazing_type": 3,
"window_width": {
"value": 0.65,
"quantity": "m"
},
"window_height": {
"value": 0.5,
"quantity": "m"
},
"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": "End-terrace house",
"language_code": 1,
"pressure_test": 4,
"property_type": 0,
"address_line_1": "5 Lynton Street",
"assessment_type": "RdSAP",
"completion_date": "2025-07-24",
"inspection_date": "2025-07-18",
"extensions_count": 1,
"measurement_type": 1,
"open_flues_count": 0,
"total_floor_area": 64,
"transaction_type": 1,
"conservatory_type": 1,
"heated_room_count": 4,
"other_flues_count": 0,
"registration_date": "2025-07-24",
"sap_energy_source": {
"mains_gas": "Y",
"meter_type": 2,
"pv_connection": 2,
"photovoltaic_supply": [
[
{
"pitch": 2,
"peak_power": 2.04,
"orientation": 4,
"overshading": 1
}
],
[
{
"pitch": 2,
"peak_power": 2.04,
"orientation": 8,
"overshading": 2
}
]
],
"wind_turbines_count": 0,
"gas_smart_meter_present": "false",
"is_dwelling_export_capable": "true",
"wind_turbines_terrain_type": 2,
"electricity_smart_meter_present": "true"
},
"secondary_heating": {
"description": "None",
"energy_efficiency_rating": 0,
"environmental_efficiency_rating": 0
},
"closed_flues_count": 0,
"extract_fans_count": 1,
"lzc_energy_sources": [
11
],
"sap_building_parts": [
{
"identifier": "Main Dwelling",
"wall_dry_lined": "N",
"wall_thickness": 230,
"floor_heat_loss": 7,
"roof_construction": 4,
"wall_construction": 3,
"building_part_number": 1,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": 2.64,
"floor_insulation": 1,
"total_floor_area": 28.2,
"party_wall_length": 8.27,
"floor_construction": 2,
"heat_loss_perimeter": 13.38
},
{
"floor": 1,
"room_height": 2.58,
"total_floor_area": 28.2,
"party_wall_length": 8.27,
"heat_loss_perimeter": 15.09
}
],
"wall_insulation_type": 4,
"construction_age_band": "B",
"party_wall_construction": 1,
"wall_thickness_measured": "Y",
"roof_insulation_location": 2,
"roof_insulation_thickness": "300mm",
"wall_insulation_thickness_measured": 100
},
{
"identifier": "Extension 1",
"wall_dry_lined": "N",
"floor_heat_loss": 7,
"roof_construction": 5,
"wall_construction": 3,
"building_part_number": 2,
"sap_floor_dimensions": [
{
"floor": 0,
"room_height": 2.5,
"floor_insulation": 1,
"total_floor_area": 7.21,
"party_wall_length": 0,
"floor_construction": 1,
"heat_loss_perimeter": 9.88
}
],
"wall_insulation_type": 3,
"construction_age_band": "B",
"party_wall_construction": "NA",
"wall_thickness_measured": "N",
"roof_insulation_location": 2,
"roof_insulation_thickness": "100mm",
"wall_insulation_thickness": "measured",
"wall_insulation_thickness_measured": 100,
"wall_insulation_thermal_conductivity": 1
}
],
"boilers_flues_count": 0,
"open_chimneys_count": 0,
"solar_water_heating": "N",
"habitable_room_count": 4,
"heating_cost_current": {
"value": 939,
"currency": "GBP"
},
"insulated_door_count": 0,
"co2_emissions_current": 2.7,
"energy_rating_average": 60,
"energy_rating_current": 82,
"lighting_cost_current": {
"value": 45,
"currency": "GBP"
},
"main_heating_controls": [
{
"description": "Programmer, room thermostat and TRVs",
"energy_efficiency_rating": 4,
"environmental_efficiency_rating": 4
}
],
"blocked_chimneys_count": 0,
"has_hot_water_cylinder": "false",
"heating_cost_potential": {
"value": 591,
"currency": "GBP"
},
"hot_water_cost_current": {
"value": 161,
"currency": "GBP"
},
"mechanical_ventilation": 0,
"percent_draughtproofed": 100,
"schema_version_current": "LIG-21.0",
"suggested_improvements": [
{
"sequence": 1,
"typical_saving": 290,
"indicative_cost": "\u00a37,500 - \u00a311,000",
"improvement_type": "Q",
"improvement_details": {
"improvement_number": 7
},
"improvement_category": 5,
"energy_performance_rating": 91,
"environmental_impact_rating": 76
},
{
"sequence": 2,
"typical_saving": 58,
"indicative_cost": "\u00a35,000 - \u00a310,000",
"improvement_type": "W1",
"improvement_details": {
"improvement_number": 57
},
"improvement_category": 5,
"energy_performance_rating": 92,
"environmental_impact_rating": 79
}
],
"co2_emissions_potential": 1.6,
"energy_rating_potential": 92,
"lighting_cost_potential": {
"value": 45,
"currency": "GBP"
},
"schema_version_original": "LIG-21.0",
"hot_water_cost_potential": {
"value": 161,
"currency": "GBP"
},
"renewable_heat_incentive": {
"water_heating": 2166.19,
"space_heating_existing_dwelling": 10128.81
},
"draughtproofed_door_count": 2,
"energy_consumption_current": 232,
"has_fixed_air_conditioning": "false",
"multiple_glazed_proportion": 100,
"calculation_software_version": "10.2.2.0",
"energy_consumption_potential": 138,
"environmental_impact_current": 65,
"cfl_fixed_lighting_bulbs_count": 0,
"current_energy_efficiency_band": "B",
"environmental_impact_potential": 79,
"led_fixed_lighting_bulbs_count": 9,
"has_heated_separate_conservatory": "false",
"potential_energy_efficiency_band": "A",
"co2_emissions_current_per_floor_area": 43,
"incandescent_fixed_lighting_bulbs_count": 0
}

View file

@ -394,6 +394,23 @@ def test_calculator_always_uses_uk_average_weather_for_rating() -> None:
assert inputs_default.region == 0
def test_pv_export_credit_input_reports_rdsap10_table_32_rate() -> None:
# Arrange — per ADR-0010 the rating cascade uses RdSAP10 Table 32
# prices (code 60 = 13.19 p/kWh for PV export). The CalculatorInputs
# boundary must report the same rate _fuel_cost applies internally
# so synthetic consumers (and the calculator's fallback path) don't
# see a different number from what the cascade produces. Previously
# this read from SAP10.2 Table 12 (5.59 p/kWh), leaving the input
# boundary out of step with the cascade.
epc = _typical_semi_detached_epc()
# Act
inputs = cert_to_inputs(epc)
# Assert
assert abs(inputs.pv_export_credit_gbp_per_kwh - 0.1319) <= 1e-6
def test_open_chimneys_raise_infiltration_ach() -> None:
# Arrange — Direction check: chimneys add Table 2.1 volume to the
# infiltration calc, so an otherwise identical dwelling with 2 open

View file

@ -132,6 +132,26 @@ _EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
expected_co2_resid_tonnes_per_yr=-0.2234,
notes="Semi-detached, TFA 102, age C, gas PCDB-listed.",
),
_GoldenExpectation(
cert_number="2130-1033-4050-5007-8395",
actual_sap=82,
expected_sap_resid=+8,
expected_pe_resid_kwh_per_m2=-61.2533,
expected_co2_resid_tonnes_per_yr=+0.1856,
notes=(
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
"(SE + NE). Cert scored under SAP10.2 software (Table 12 PV "
"export = 5.59 p/kWh, £194 PV credit → SAP 82). Our calc "
"targets RdSAP10 (Table 32 PV export = 13.19 p/kWh, £457 PV "
"credit → SAP 90) per ADR-0010. The +8 SAP / 61 PE residual "
"is spec-version drift, not a code bug — both calcs are "
"internally consistent against their own price table. The PE "
"residual is amplified because PV generation also offsets PE "
"via inputs.other_primary_factor; that offset scales with the "
"PV gen kWh, not the export-credit price."
),
),
_GoldenExpectation(
cert_number="0390-2254-6420-2126-5561",
actual_sap=65,
@ -221,6 +241,7 @@ _PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float], ...] = (
("0300-2747-7640-2526-2135", 17992, None), # gas PCDB-listed
("8135-1728-8500-0511-3296", 17702, None), # gas PCDB-listed
("0390-2254-6420-2126-5561", 18119, None), # LN12 gas combi PCDB-listed
("2130-1033-4050-5007-8395", 17505, None), # DE22 gas combi PCDB-listed + PV
)