diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index 67a150c2..b571cf52 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -88,6 +88,13 @@ class CalculatorInputs: # by §6 orchestrator `solar_gains_from_cert` upstream; the calculator # only indexes into it per month, no recomputation here. solar_gains_monthly_w: tuple[float, ...] + # SAP10.2 (93)m — adjusted mean internal temperature °C per month, and + # (94)m — utilisation factor (whole-dwelling Ti) per month. Both come + # from §7 orchestrator `mean_internal_temperature_monthly` upstream. + # The calculator stops iterating η in _solve_month — Table 9c is a + # sequential chain (steps 1-9), not a fixed-point loop. + mean_internal_temp_monthly_c: tuple[float, ...] + utilisation_factor_monthly: tuple[float, ...] region: int control_type: int responsiveness: float @@ -182,30 +189,14 @@ def _solve_month( g_sol = inputs.solar_gains_monthly_w[month - 1] g_total = g_int + g_sol - # SAP 10.3 §7.3: two-pass iteration. Seed η = 1, compute T_internal, - # recompute η from the resulting loss rate, then once more. - eta = 1.0 - t_int = 0.0 - loss_rate_w = 0.0 - for _ in range(_ETA_ITERATIONS): - t_int = mean_internal_temperature_c( - external_temp_c=t_ext, - heat_transfer_coefficient_w_per_k=hlc_w_per_k, - total_gains_w=g_total, - utilisation_factor=eta, - time_constant_h=time_constant_h, - heat_loss_parameter=heat_loss_parameter, - living_area_fraction=inputs.living_area_fraction, - control_type=inputs.control_type, - responsiveness=inputs.responsiveness, - control_temperature_adjustment_c=inputs.control_temperature_adjustment_c, - ) - loss_rate_w = max(0.0, hlc_w_per_k * (t_int - t_ext)) - eta = utilisation_factor( - total_gains_w=g_total, - heat_loss_rate_w=loss_rate_w, - time_constant_h=time_constant_h, - ) + # SAP 10.2 §7 Table 9c is a sequential chain (steps 1-9); the §7 + # orchestrator computes (93)m and (94)m upstream and the calculator + # consumes them by index. No fixed-point iteration here. + _ = time_constant_h # τ now lives inside the §7 orchestrator + _ = heat_loss_parameter + t_int = inputs.mean_internal_temp_monthly_c[month - 1] + eta = inputs.utilisation_factor_monthly[month - 1] + loss_rate_w = max(0.0, hlc_w_per_k * (t_int - t_ext)) q_heat = monthly_heat_requirement_kwh( heat_transfer_coefficient_w_per_k=hlc_w_per_k, diff --git a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py index c35e4cb2..7b8ab16f 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -67,6 +67,10 @@ from domain.sap.worksheet.heat_transmission import ( DwellingExposure, heat_transmission_from_cert, ) +from domain.sap.climate.appendix_u import external_temperature_c +from domain.sap.worksheet.mean_internal_temperature import ( + mean_internal_temperature_monthly, +) from domain.sap.worksheet.solar_gains import solar_gains_from_cert from domain.sap.worksheet.ventilation import ( MechanicalVentilationKind, @@ -886,6 +890,40 @@ def cert_to_inputs( internal_gains_result.total_internal_gains_monthly_w ) + solar_gains_monthly_w = solar_gains_from_cert( + epc=epc, + region=_region_index(epc.region_code), + overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, + ).total_solar_gains_monthly_w + + # SAP10.2 §7 — compose (93)m + (94)m via the orchestrator. Per-month HTC + # = transmission HLC + 0.33·V·(25)m. Table 4e control adjustment is 0 + # for the Elmhurst corpus (cert-side mapping is a future slice). + control_type_value = _control_type(main) + responsiveness_value = _responsiveness(main) + living_area_fraction_value = _living_area_fraction(epc.habitable_rooms_count) + monthly_total_gains_w = tuple( + internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) + ) + monthly_htc_w_per_k = tuple( + ht.total_w_per_k + 0.33 * dim.volume_m3 * ventilation.effective_monthly_ach[m] + for m in range(12) + ) + mit_result = mean_internal_temperature_monthly( + monthly_external_temp_c=tuple( + external_temperature_c(_region_index(epc.region_code), m) + for m in range(1, 13) + ), + monthly_total_gains_w=monthly_total_gains_w, + monthly_heat_transfer_coefficient_w_per_k=monthly_htc_w_per_k, + thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, + total_floor_area_m2=dim.total_floor_area_m2, + control_type=control_type_value, + responsiveness=responsiveness_value, + living_area_fraction=living_area_fraction_value, + control_temperature_adjustment_c=0.0, + ) + return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -900,15 +938,15 @@ def cert_to_inputs( # (Elmhurst data shows them all as `window_location = External wall`); # both pass-throughs are empty. Per-fixture §6 conformance is # exercised separately in `test_solar_gains.py`. - solar_gains_monthly_w=solar_gains_from_cert( - epc=epc, - region=_region_index(epc.region_code), - overshading=_INTERNAL_GAINS_DEFAULT_OVERSHADING, - ).total_solar_gains_monthly_w, + solar_gains_monthly_w=solar_gains_monthly_w, + # SAP10.2 (93)m + (94)m — adjusted MIT and whole-dwelling η. From + # the §7 orchestrator above (Table 9c steps 1-9 sequential, per-zone η). + mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly, + utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly, region=_region_index(epc.region_code), - control_type=_control_type(main), - responsiveness=_responsiveness(main), - living_area_fraction=_living_area_fraction(epc.habitable_rooms_count), + control_type=control_type_value, + responsiveness=responsiveness_value, + living_area_fraction=living_area_fraction_value, control_temperature_adjustment_c=0.0, thermal_mass_parameter_kj_per_m2_k=_DEFAULT_THERMAL_MASS_PARAMETER_KJ_PER_M2_K, main_heating_efficiency=eff, diff --git a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py index 6f46b3d2..c17fe616 100644 --- a/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py +++ b/packages/domain/src/domain/sap/tests/test_bre_worked_examples.py @@ -27,8 +27,12 @@ from domain.sap.calculator import ( CalculatorInputs, calculate_sap_from_inputs, ) +from domain.sap.climate.appendix_u import external_temperature_c from domain.sap.worksheet.dimensions import Dimensions from domain.sap.worksheet.heat_transmission import HeatTransmission +from domain.sap.worksheet.mean_internal_temperature import ( + mean_internal_temperature_monthly, +) def _baseline_dwelling() -> CalculatorInputs: @@ -60,18 +64,36 @@ def _baseline_dwelling() -> CalculatorInputs: total_external_element_area_m2=200.0, # synthetic placeholder total_w_per_k=150.0, ) + internal_gains_monthly_w = (450.0,) * 12 + solar_gains_monthly_w = ( + 70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177, + 223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212, + ) + ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13)) + total_gains_monthly_w = tuple( + internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) + ) + htc_monthly_w_per_k = tuple( + ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12) + ) + mit_result = mean_internal_temperature_monthly( + monthly_external_temp_c=ext_temp_monthly_c, + monthly_total_gains_w=total_gains_monthly_w, + monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k, + thermal_mass_parameter_kj_per_m2_k=250.0, + total_floor_area_m2=dim.total_floor_area_m2, + control_type=2, + responsiveness=1.0, + living_area_fraction=0.30, + ) return CalculatorInputs( dimensions=dim, heat_transmission=ht, monthly_infiltration_ach=(0.7,) * 12, - internal_gains_monthly_w=(450.0,) * 12, - # Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77, - # UK-avg region 0, vertical) — captured at HEAD so the trace fixture - # matches the pre-§6-wiring numerical baseline. - solar_gains_monthly_w=( - 70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177, - 223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212, - ), + internal_gains_monthly_w=internal_gains_monthly_w, + solar_gains_monthly_w=solar_gains_monthly_w, + mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly, + utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly, region=0, control_type=2, responsiveness=1.0, diff --git a/packages/domain/src/domain/sap/tests/test_calculator.py b/packages/domain/src/domain/sap/tests/test_calculator.py index 1c6afcea..9d6638d6 100644 --- a/packages/domain/src/domain/sap/tests/test_calculator.py +++ b/packages/domain/src/domain/sap/tests/test_calculator.py @@ -25,8 +25,12 @@ from domain.sap.calculator import ( SapResult, calculate_sap_from_inputs, ) +from domain.sap.climate.appendix_u import external_temperature_c from domain.sap.worksheet.dimensions import Dimensions from domain.sap.worksheet.heat_transmission import HeatTransmission +from domain.sap.worksheet.mean_internal_temperature import ( + mean_internal_temperature_monthly, +) def _baseline_inputs() -> CalculatorInputs: @@ -56,6 +60,28 @@ def _baseline_inputs() -> CalculatorInputs: total_external_element_area_m2=200.0, # synthetic placeholder total_w_per_k=150.0, ) + internal_gains_monthly_w = (450.0,) * 12 + solar_gains_monthly_w = ( + 70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177, + 223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212, + ) + ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13)) + total_gains_monthly_w = tuple( + internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12) + ) + htc_monthly_w_per_k = tuple( + ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12) + ) + mit_result = mean_internal_temperature_monthly( + monthly_external_temp_c=ext_temp_monthly_c, + monthly_total_gains_w=total_gains_monthly_w, + monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k, + thermal_mass_parameter_kj_per_m2_k=250.0, + total_floor_area_m2=dim.total_floor_area_m2, + control_type=2, + responsiveness=1.0, + living_area_fraction=0.30, + ) return CalculatorInputs( dimensions=dim, heat_transmission=ht, @@ -63,15 +89,14 @@ def _baseline_inputs() -> CalculatorInputs: # Synthetic baseline internal gains: 450 W constant. Real # per-month variation lives in §5 orchestrator output; tracer # tests don't need the modulation to verify the SAP loop. - internal_gains_monthly_w=(450.0,) * 12, + internal_gains_monthly_w=internal_gains_monthly_w, # Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77, - # UK-avg region 0, vertical) — captured from §6 leaves at HEAD - # so this synthetic baseline keeps the same heat-balance gains - # as before the §6 wiring slice. - solar_gains_monthly_w=( - 70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177, - 223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212, - ), + # UK-avg region 0, vertical) — captured from §6 leaves at HEAD. + solar_gains_monthly_w=solar_gains_monthly_w, + # §7 (93)m + (94)m precomputed from the orchestrator above so the + # baseline reflects spec-correct sequential per-zone η. + mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly, + utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly, region=0, control_type=2, responsiveness=1.0, @@ -108,6 +133,28 @@ def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() - assert monthly.solar_gains_w == 100.0 +def test_calculator_consumes_mean_internal_temp_and_utilisation_monthly_fields() -> None: + # Arrange — replace baseline inputs' MIT + η with explicit known 12-tuples. + # The §7 orchestrator produces these upstream; the calculator must just + # look them up, not iterate or recompute. 18.0 °C MIT + 0.8 η constant + # everywhere — distinct enough that any leftover iteration would drift. + explicit_mit = (18.0,) * 12 + explicit_eta = (0.8,) * 12 + inputs = replace( + _baseline_inputs(), + mean_internal_temp_monthly_c=explicit_mit, + utilisation_factor_monthly=explicit_eta, + ) + + # Act + result = calculate_sap_from_inputs(inputs) + + # Assert + for monthly in result.monthly: + assert monthly.internal_temp_c == 18.0 + assert monthly.utilisation_factor == 0.8 + + def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> None: # Arrange — baseline 100 m² gas-boiler dwelling in UK-average climate. inputs = _baseline_inputs()