feat: persist 7 calculator-read EPC fields 🟩

Wire community heating fuel + CHP fraction (epc_main_heating_detail),
alt-wall is_sheltered + wall insulation thermal conductivity
(epc_building_part), and pv_diverter_present / measured cylinder volume /
AP50 air permeability (epc_property) through save + _compose/_to_*. All
deep-equal round-trip; coverage guard now enforces their reconstruction.
Columns live (FE migration applied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-26 18:46:42 +00:00
parent 5c4a8d9094
commit 513c9b9897
2 changed files with 36 additions and 0 deletions

View file

@ -139,6 +139,9 @@ class EpcPropertyModel(SQLModel, table=True):
)
energy_pv_percent_roof_area: Optional[int] = Field(default=None)
energy_pv_battery_capacity: Optional[float] = Field(default=None)
# An EV/PV diverter present on the dwelling (SAP §M; the calculator reads it
# via cert_to_inputs). Non-optional bool defaulting False, matching the domain.
energy_pv_diverter_present: bool = Field(default=False)
energy_wind_turbine_hub_height: Optional[float] = Field(default=None)
energy_wind_turbine_rotor_diameter: Optional[float] = Field(default=None)
@ -162,6 +165,7 @@ class EpcPropertyModel(SQLModel, table=True):
default=None, sa_column=Column(JSONB, nullable=True)
)
heating_cylinder_insulation_thickness_mm: Optional[int] = Field(default=None)
heating_cylinder_volume_measured_l: Optional[int] = Field(default=None)
heating_wwhrs_index_number_1: Optional[int] = Field(default=None)
heating_wwhrs_index_number_2: Optional[int] = Field(default=None)
heating_shower_outlet_type: Optional[Union[int, str]] = Field(
@ -193,6 +197,7 @@ class EpcPropertyModel(SQLModel, table=True):
ventilation_suspended_timber_floor_sealed: Optional[bool] = Field(default=None)
ventilation_has_draught_lobby: Optional[bool] = Field(default=None)
ventilation_air_permeability_ap4_m3_h_m2: Optional[float] = Field(default=None)
ventilation_air_permeability_ap50_m3_h_m2: Optional[float] = Field(default=None)
ventilation_mechanical_ventilation_kind: Optional[str] = Field(default=None)
mechanical_ventilation: Optional[int] = Field(default=None)
mechanical_vent_duct_type: Optional[int] = Field(default=None)
@ -341,6 +346,7 @@ class EpcPropertyModel(SQLModel, table=True):
pv.none_or_no_details.percent_roof_area if pv else None
),
energy_pv_battery_capacity=pvb.pv_battery.battery_capacity if pvb else None,
energy_pv_diverter_present=es.pv_diverter_present,
energy_wind_turbine_hub_height=wt.hub_height if wt else None,
energy_wind_turbine_rotor_diameter=wt.rotor_diameter if wt else None,
heating_cylinder_size=h.cylinder_size,
@ -352,6 +358,7 @@ class EpcPropertyModel(SQLModel, table=True):
heating_secondary_fuel_type=h.secondary_fuel_type,
heating_secondary_heating_type=h.secondary_heating_type,
heating_cylinder_insulation_thickness_mm=h.cylinder_insulation_thickness_mm,
heating_cylinder_volume_measured_l=h.cylinder_volume_measured_l,
heating_wwhrs_index_number_1=h.instantaneous_wwhrs.wwhrs_index_number1,
heating_wwhrs_index_number_2=h.instantaneous_wwhrs.wwhrs_index_number2,
heating_shower_outlet_type=shower.shower_outlet_type if shower else None,
@ -385,6 +392,9 @@ class EpcPropertyModel(SQLModel, table=True):
ventilation_air_permeability_ap4_m3_h_m2=(
v.air_permeability_ap4_m3_h_m2 if v else None
),
ventilation_air_permeability_ap50_m3_h_m2=(
v.air_permeability_ap50_m3_h_m2 if v else None
),
ventilation_mechanical_ventilation_kind=(
v.mechanical_ventilation_kind if v else None
),
@ -544,6 +554,10 @@ class EpcMainHeatingDetailModel(SQLModel, table=True):
main_heating_data_source: Optional[int] = Field(default=None)
condensing: Optional[bool] = Field(default=None)
weather_compensator: Optional[bool] = Field(default=None)
# Community-heating fields (SAP Table 4d; the calculator reads them via
# cert_to_inputs for a community-heated dwelling).
community_heating_boiler_fuel_type: Optional[int] = Field(default=None)
community_heating_chp_fraction: Optional[float] = Field(default=None)
@classmethod
def from_domain(
@ -569,6 +583,8 @@ class EpcMainHeatingDetailModel(SQLModel, table=True):
main_heating_data_source=detail.main_heating_data_source,
condensing=detail.condensing,
weather_compensator=detail.weather_compensator,
community_heating_boiler_fuel_type=detail.community_heating_boiler_fuel_type,
community_heating_chp_fraction=detail.community_heating_chp_fraction,
)
@ -601,6 +617,11 @@ class EpcBuildingPartModel(SQLModel, table=True):
wall_insulation_thickness: Optional[Union[str, int]] = Field(
default=None, sa_column=Column(JSONB, nullable=True)
)
# Union[int, str] SAP code (int from the API, str from Site Notes) — JSONB to
# preserve the type on round-trip, like the insulation-thickness siblings.
wall_insulation_thermal_conductivity: Optional[Union[int, str]] = Field(
default=None, sa_column=Column(JSONB, nullable=True)
)
floor_heat_loss: Optional[int] = Field(default=None)
floor_insulation_thickness: Optional[str] = Field(default=None)
flat_roof_insulation_thickness: Optional[Union[str, int]] = Field(
@ -627,12 +648,14 @@ class EpcBuildingPartModel(SQLModel, table=True):
alt_wall_1_insulation_type: Optional[int] = Field(default=None)
alt_wall_1_thickness_measured: Optional[str] = Field(default=None)
alt_wall_1_insulation_thickness: Optional[str] = Field(default=None)
alt_wall_1_is_sheltered: Optional[bool] = Field(default=None)
alt_wall_2_area: Optional[float] = Field(default=None)
alt_wall_2_dry_lined: Optional[str] = Field(default=None)
alt_wall_2_construction: Optional[int] = Field(default=None)
alt_wall_2_insulation_type: Optional[int] = Field(default=None)
alt_wall_2_thickness_measured: Optional[str] = Field(default=None)
alt_wall_2_insulation_thickness: Optional[str] = Field(default=None)
alt_wall_2_is_sheltered: Optional[bool] = Field(default=None)
@classmethod
def from_domain(
@ -653,6 +676,7 @@ class EpcBuildingPartModel(SQLModel, table=True):
wall_dry_lined=part.wall_dry_lined,
wall_thickness_mm=part.wall_thickness_mm,
wall_insulation_thickness=part.wall_insulation_thickness,
wall_insulation_thermal_conductivity=part.wall_insulation_thermal_conductivity,
floor_heat_loss=part.floor_heat_loss,
floor_insulation_thickness=part.floor_insulation_thickness,
flat_roof_insulation_thickness=part.flat_roof_insulation_thickness,
@ -677,6 +701,7 @@ class EpcBuildingPartModel(SQLModel, table=True):
alt_wall_1_insulation_thickness=(
aw1.wall_insulation_thickness if aw1 else None
),
alt_wall_1_is_sheltered=aw1.is_sheltered if aw1 else None,
alt_wall_2_area=aw2.wall_area if aw2 else None,
alt_wall_2_dry_lined=aw2.wall_dry_lined if aw2 else None,
alt_wall_2_construction=aw2.wall_construction if aw2 else None,
@ -685,6 +710,7 @@ class EpcBuildingPartModel(SQLModel, table=True):
alt_wall_2_insulation_thickness=(
aw2.wall_insulation_thickness if aw2 else None
),
alt_wall_2_is_sheltered=aw2.is_sheltered if aw2 else None,
)

View file

@ -691,6 +691,7 @@ class EpcPostgresRepository(EpcRepository):
secondary_fuel_type=p.heating_secondary_fuel_type,
secondary_heating_type=p.heating_secondary_heating_type,
cylinder_insulation_thickness_mm=p.heating_cylinder_insulation_thickness_mm,
cylinder_volume_measured_l=p.heating_cylinder_volume_measured_l,
number_baths=p.heating_number_baths,
number_baths_wwhrs=p.heating_number_baths_wwhrs,
electric_shower_count=p.heating_electric_shower_count,
@ -718,6 +719,8 @@ class EpcPostgresRepository(EpcRepository):
main_heating_data_source=m.main_heating_data_source,
condensing=m.condensing,
weather_compensator=m.weather_compensator,
community_heating_boiler_fuel_type=m.community_heating_boiler_fuel_type,
community_heating_chp_fraction=m.community_heating_chp_fraction,
)
@private
@ -768,6 +771,7 @@ class EpcPostgresRepository(EpcRepository):
wall_dry_lined=bp.wall_dry_lined,
wall_thickness_mm=bp.wall_thickness_mm,
wall_insulation_thickness=bp.wall_insulation_thickness,
wall_insulation_thermal_conductivity=bp.wall_insulation_thermal_conductivity,
sap_alternative_wall_1=self._to_alt_wall(bp, 1),
sap_alternative_wall_2=self._to_alt_wall(bp, 2),
floor_heat_loss=bp.floor_heat_loss,
@ -819,6 +823,7 @@ class EpcPostgresRepository(EpcRepository):
if n == 1
else bp.alt_wall_2_insulation_thickness
)
sheltered = bp.alt_wall_1_is_sheltered if n == 1 else bp.alt_wall_2_is_sheltered
return SapAlternativeWall(
wall_area=area,
wall_dry_lined=_require(dry_lined, f"alt_wall_{n}_dry_lined"),
@ -830,6 +835,9 @@ class EpcPostgresRepository(EpcRepository):
thickness_measured, f"alt_wall_{n}_thickness_measured"
),
wall_insulation_thickness=insulation_thickness,
# Nullable column (added later than the alt wall itself); a row from
# before the column existed reads None → the domain default False.
is_sheltered=sheltered if sheltered is not None else False,
)
@private
@ -864,6 +872,7 @@ class EpcPostgresRepository(EpcRepository):
if pv_array_rows
else None
),
pv_diverter_present=p.energy_pv_diverter_present,
mains_gas=p.energy_mains_gas,
meter_type=p.energy_meter_type,
pv_battery_count=p.energy_pv_battery_count,
@ -949,6 +958,7 @@ class EpcPostgresRepository(EpcRepository):
suspended_timber_floor_sealed=p.ventilation_suspended_timber_floor_sealed,
has_draught_lobby=p.ventilation_has_draught_lobby,
air_permeability_ap4_m3_h_m2=p.ventilation_air_permeability_ap4_m3_h_m2,
air_permeability_ap50_m3_h_m2=p.ventilation_air_permeability_ap50_m3_h_m2,
mechanical_ventilation_kind=p.ventilation_mechanical_ventilation_kind,
)