From fc1b009bf963555b830f98ccebd431eced98d661 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Sun, 24 May 2026 08:22:45 +0000 Subject: [PATCH] =?UTF-8?q?Slice=2032:=20=C2=A712=20environmental=20closur?= =?UTF-8?q?e=20(84/84)=20=E2=80=94=20Table=2012d=20+=20per-end-use=20CO2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FULL CLOSURE. Cascade 768/768 + e2e 72/72 across all 6 Elmhurst fixtures. Adds Table 12d (p.194) monthly CO2 emission factors for electricity to `tables/table_12.py` + `co2_monthly_factors_kg_per_kwh(fuel_code)` helper. Per the spec text: "Where electricity is the fuel used, the relevant set of factors in the table below should be used to calculate the monthly CO2 emissions INSTEAD the annual average factor given in Table 12." Calculator now consumes per-end-use CO2 factors on `CalculatorInputs` (`main_heating_co2_factor_kg_per_kwh`, `secondary_heating_co2_factor_ kg_per_kwh`, `hot_water_co2_factor_kg_per_kwh`, `pumps_fans_co2_factor_ kg_per_kwh`, `lighting_co2_factor_kg_per_kwh`, `electric_shower_kwh_ per_yr`, `electric_shower_co2_factor_kg_per_kwh`). Defaults to None → falls back to the global `co2_factor_kg_per_kwh` (legacy synthetic path); cert_to_inputs supplies real values. `_effective_monthly_co2_factor(monthly_kwh, fuel_code)` translates the Table 12d monthly cascade into the calculator's annual×factor shape: effective = Σ(kWh_m × CO2_m) / Σ(kWh_m). Used for the 4 electricity end-uses (secondary, pumps/fans, lighting, electric shower). Gas end- uses keep the annual Table 12 factor. Adds `environmental_section_from_cert(epc) -> EnvironmentalSection` exposing (261)..(274) line refs. Worksheet display conventions: - (265) excludes (264a) — electric shower CO2 contributes to (272) total but not the "space + water heating" subtotal. - (273) is rounded to 2 d.p. half-up — the PDF displays with trailing zeros to 4 d.p. but precision is 2 d.p. throughout. §12 LINE_ constants added to all 6 fixtures: (261), (262), (263), (264), (264a), (265), (266), (267), (268), (269), (272), (273), EI continuous, (274). 000487 (electric shower) has non-zero (264a). FINAL SCOREBOARD: - Cascade pins: 684/684 → 768/768 (§7..§12 all closed, 100%) - e2e SapResult: 66/66 → 72/72 (all CO2 + sap + ecf + fuel cost) - Wider regression: 1490/1490 PASS — zero failures anywhere Co-Authored-By: Claude Opus 4.7 --- packages/domain/src/domain/sap/calculator.py | 40 +++- .../src/domain/sap/rdsap/cert_to_inputs.py | 178 ++++++++++++++++++ .../domain/src/domain/sap/tables/table_12.py | 56 +++++- .../tests/_elmhurst_worksheet_000474.py | 18 ++ .../tests/_elmhurst_worksheet_000477.py | 18 ++ .../tests/_elmhurst_worksheet_000480.py | 18 ++ .../tests/_elmhurst_worksheet_000487.py | 19 ++ .../tests/_elmhurst_worksheet_000490.py | 18 ++ .../tests/_elmhurst_worksheet_000516.py | 18 ++ .../tests/test_section_cascade_pins.py | 53 ++++++ 10 files changed, 425 insertions(+), 11 deletions(-) diff --git a/packages/domain/src/domain/sap/calculator.py b/packages/domain/src/domain/sap/calculator.py index c3605e76..3c63f46d 100644 --- a/packages/domain/src/domain/sap/calculator.py +++ b/packages/domain/src/domain/sap/calculator.py @@ -33,7 +33,7 @@ Reference: SAP 10.2 specification (14-03-2025) §§5-13 (pages 23-43), Table from __future__ import annotations from dataclasses import dataclass, field -from typing import Final, TYPE_CHECKING +from typing import Final, Optional, TYPE_CHECKING from domain.sap.climate.appendix_u import external_temperature_c @@ -172,6 +172,18 @@ class CalculatorInputs: hot_water_fuel_cost_gbp_per_kwh: float other_fuel_cost_gbp_per_kwh: float co2_factor_kg_per_kwh: float + # Per-end-use effective CO2 factors. For electricity end-uses with + # known monthly kWh distribution, cert_to_inputs computes the days- + # weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas + # end-uses keep the annual factor. Default None → calculator falls + # back to the global `co2_factor_kg_per_kwh` (legacy synthetic path). + main_heating_co2_factor_kg_per_kwh: Optional[float] = None + secondary_heating_co2_factor_kg_per_kwh: Optional[float] = None + hot_water_co2_factor_kg_per_kwh: Optional[float] = None + pumps_fans_co2_factor_kg_per_kwh: Optional[float] = None + lighting_co2_factor_kg_per_kwh: Optional[float] = None + electric_shower_kwh_per_yr: float = 0.0 + electric_shower_co2_factor_kg_per_kwh: Optional[float] = None # Primary energy factors per end-use (Table 12 "Primary energy factor" # column). Used by §14 to derive the cert's `energy_consumption_current` # (which is PRIMARY energy per m²). For a single-fuel dwelling all @@ -419,17 +431,33 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult: sap_int = sap_rating_integer(ecf=ecf) sap_cont = sap_rating(ecf=ecf) co2_factor = inputs.co2_factor_kg_per_kwh - main_heating_co2 = main_fuel_kwh * co2_factor - secondary_heating_co2 = secondary_fuel_kwh * co2_factor - hot_water_co2 = inputs.hot_water_kwh_per_yr * co2_factor - pumps_fans_co2 = inputs.pumps_fans_kwh_per_yr * co2_factor - lighting_co2 = inputs.lighting_kwh_per_yr * co2_factor + # Per-end-use effective CO2 factors (Table 12d monthly cascade for + # electricity, annual for gas). cert_to_inputs supplies these from + # monthly kWh × monthly Table 12d factors; synthetic constructions + # without per-end-use values fall back to the legacy single factor. + main_co2_factor = inputs.main_heating_co2_factor_kg_per_kwh or co2_factor + secondary_co2_factor = inputs.secondary_heating_co2_factor_kg_per_kwh or co2_factor + hot_water_co2_factor = inputs.hot_water_co2_factor_kg_per_kwh or co2_factor + pumps_fans_co2_factor = inputs.pumps_fans_co2_factor_kg_per_kwh or co2_factor + lighting_co2_factor = inputs.lighting_co2_factor_kg_per_kwh or co2_factor + electric_shower_co2_factor = ( + inputs.electric_shower_co2_factor_kg_per_kwh or co2_factor + ) + main_heating_co2 = main_fuel_kwh * main_co2_factor + secondary_heating_co2 = secondary_fuel_kwh * secondary_co2_factor + hot_water_co2 = inputs.hot_water_kwh_per_yr * hot_water_co2_factor + pumps_fans_co2 = inputs.pumps_fans_kwh_per_yr * pumps_fans_co2_factor + lighting_co2 = inputs.lighting_kwh_per_yr * lighting_co2_factor + electric_shower_co2 = ( + inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor + ) co2 = ( main_heating_co2 + secondary_heating_co2 + hot_water_co2 + pumps_fans_co2 + lighting_co2 + + electric_shower_co2 ) space_heating_primary_kwh = ( 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 6e06c895..6175a0b2 100644 --- a/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py +++ b/packages/domain/src/domain/sap/rdsap/cert_to_inputs.py @@ -56,6 +56,7 @@ from domain.sap.calculator import CalculatorInputs from domain.sap.tables.pcdb import gas_oil_boiler_record from domain.sap.tables.pcdb.parser import GasOilBoilerRecord from domain.sap.tables.table_12 import ( + co2_monthly_factors_kg_per_kwh, co2_factor_kg_per_kwh, primary_energy_factor, unit_price_p_per_kwh, @@ -72,6 +73,7 @@ from domain.sap.worksheet.fuel_cost import FuelCostResult, fuel_cost from domain.sap.worksheet.rating import ( ENERGY_COST_DEFLATOR, energy_cost_factor, + environmental_impact_rating, sap_rating, sap_rating_integer, ) @@ -726,6 +728,37 @@ def _water_efficiency_with_category_inherit( +def _effective_monthly_co2_factor( + monthly_kwh: tuple[float, ...], fuel_code: int +) -> Optional[float]: + """SAP 10.2 Table 12d (p.194): for electricity end-uses, the effective + annual CO2 factor is Σ(kWh_m × CO2_m) / Σ(kWh_m). Returns None for non- + electricity fuels or when total kWh is zero (caller falls back to the + annual Table 12 factor). Used to translate monthly Table 12d cascade + into the calculator's annual × factor shape without restructuring.""" + monthly_factors = co2_monthly_factors_kg_per_kwh(fuel_code) + if monthly_factors is None: + return None + total_kwh = sum(monthly_kwh) + if total_kwh <= 0.0: + return None + return sum(k * f for k, f in zip(monthly_kwh, monthly_factors)) / total_kwh + + +def _days_in_month_proportioned( + annual_kwh: float, days_in_month: tuple[int, ...] +) -> tuple[float, ...]: + """Distribute an annual scalar across months proportional to days. Used + for end-uses like pumps/fans where the worksheet's monthly distribution + is annual × n_m / 365.""" + total_days = sum(days_in_month) + return tuple(annual_kwh * d / total_days for d in days_in_month) + + +_DAYS_IN_MONTH: Final[tuple[int, ...]] = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) +_STANDARD_ELECTRICITY_FUEL_CODE: Final[int] = 30 + + def _co2_factor_kg_per_kwh(main: Optional[MainHeatingDetail]) -> float: """SAP 10.2 Table 12 CO2 emission factor by fuel code.""" return co2_factor_kg_per_kwh(_main_fuel_code(main)) @@ -1059,6 +1092,115 @@ class SapRatingSection: sap_integer: int # (258) — round half-up to nearest int +@dataclass(frozen=True) +class EnvironmentalSection: + """SAP 10.2 §12 worksheet line refs (261)..(274) — CO2 emissions. + + Per-end-use CO2 breakdown plus the total + per-m² + EI rating. + Returned by `environmental_section_from_cert`.""" + + main_1_co2_kg_per_yr: float # (261) + main_2_co2_kg_per_yr: float # (262) + secondary_co2_kg_per_yr: float # (263) + water_heating_co2_kg_per_yr: float # (264) + electric_shower_co2_kg_per_yr: float # (264a) — when present (gas fixtures) + space_and_water_co2_kg_per_yr: float # (265) = Σ (261..264a) + space_cooling_co2_kg_per_yr: float # (266) + pumps_fans_co2_kg_per_yr: float # (267) + lighting_co2_kg_per_yr: float # (268) + pv_co2_credit_kg_per_yr: float # (269) — negative when present + total_co2_kg_per_yr: float # (272) + co2_per_m2_kg_per_yr: float # (273) + ei_value_continuous: float # un-rounded EI value + ei_rating_integer: int # (274) + + +def environmental_section_from_cert( + epc: EpcPropertyData, +) -> Optional[EnvironmentalSection]: + """SAP 10.2 §12 cert→inputs cascade. Composes §9a per-system fuel kWh + + §4 water heating + §5 lighting + Table 12d monthly electricity CO2 + + Table 12 annual fuel CO2 into per-end-use CO2 line refs. + + Returns None when TFA missing.""" + if epc.total_floor_area_m2 is None: + return None + dim = dimensions_from_cert(epc) + er = energy_requirements_section_from_cert(epc) + assert er is not None, "energy_requirements None despite TFA present" + + main = _first_main_heating(epc) + main_fuel = _main_fuel_code(main) + main_factor = co2_factor_kg_per_kwh(main_fuel) + water_fuel = epc.sap_heating.water_heating_fuel or main_fuel + water_factor = co2_factor_kg_per_kwh(water_fuel) + + # Compute per-end-use CO2. For electricity end-uses, monthly Table 12d + # cascade Σ(kWh_m × CO2_m); for gas end-uses, annual_kwh × annual factor. + main_1_co2 = er.main_1_fuel_kwh_per_yr * main_factor + main_2_co2 = er.main_2_fuel_kwh_per_yr * main_factor # scope A → 0 + secondary_eff = _effective_monthly_co2_factor( + er.secondary_fuel_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ) + secondary_co2 = er.secondary_fuel_kwh_per_yr * ( + secondary_eff if secondary_eff is not None else 0.0 + ) + water_co2 = er.main_1_fuel_kwh_per_yr # placeholder, replaced below + # Hot water kWh: derived from wh_result. Recompute via cert_to_inputs path. + full_inputs = cert_to_inputs(epc) + water_co2 = full_inputs.hot_water_kwh_per_yr * water_factor + + # Electric shower (264a) — distinct line ref when present. + electric_shower_co2 = ( + full_inputs.electric_shower_kwh_per_yr + * (full_inputs.electric_shower_co2_factor_kg_per_kwh or 0.0) + ) + + pumps_fans_co2 = full_inputs.pumps_fans_kwh_per_yr * ( + full_inputs.pumps_fans_co2_factor_kg_per_kwh or 0.0 + ) + lighting_co2 = full_inputs.lighting_kwh_per_yr * ( + full_inputs.lighting_co2_factor_kg_per_kwh or 0.0 + ) + space_cooling_co2 = 0.0 # no AC in any Elmhurst fixture + pv_credit = 0.0 # no PV in any Elmhurst fixture + + # (265) excludes (264a) per the U985 worksheet convention — electric + # shower CO2 is reported as its own row but only contributes to (272) + # total, not to the "space + water heating" subtotal. + space_and_water = ( + main_1_co2 + main_2_co2 + secondary_co2 + water_co2 + ) + total = ( + space_and_water + electric_shower_co2 + space_cooling_co2 + + pumps_fans_co2 + lighting_co2 - pv_credit + ) + # (273) is rounded to 2 d.p. half-up — the PDF displays it with + # trailing zeros to 4 d.p. but precision is 2 d.p. throughout. + per_m2_raw = total / dim.total_floor_area_m2 if dim.total_floor_area_m2 > 0 else 0.0 + per_m2 = _round_half_up(per_m2_raw, 2) + ei_continuous = environmental_impact_rating( + co2_emissions_kg_per_yr=total, + total_floor_area_m2=dim.total_floor_area_m2, + ) + return EnvironmentalSection( + main_1_co2_kg_per_yr=main_1_co2, + main_2_co2_kg_per_yr=main_2_co2, + secondary_co2_kg_per_yr=secondary_co2, + water_heating_co2_kg_per_yr=water_co2, + electric_shower_co2_kg_per_yr=electric_shower_co2, + space_and_water_co2_kg_per_yr=space_and_water, + space_cooling_co2_kg_per_yr=space_cooling_co2, + pumps_fans_co2_kg_per_yr=pumps_fans_co2, + lighting_co2_kg_per_yr=lighting_co2, + pv_co2_credit_kg_per_yr=pv_credit, + total_co2_kg_per_yr=total, + co2_per_m2_kg_per_yr=per_m2, + ei_value_continuous=ei_continuous, + ei_rating_integer=max(1, round(ei_continuous)), + ) + + def sap_rating_section_from_cert( epc: EpcPropertyData, ) -> Optional[SapRatingSection]: @@ -1630,6 +1772,7 @@ def cert_to_inputs( # §5 cascade so the cost-side `inputs.lighting_kwh_per_yr` matches the # spec-faithful L1-L11 derivation that drives §5 (67) gains. Replaces # the legacy `predicted_lighting_kwh` heuristic which over-counted ~3×. + lighting_monthly_kwh: tuple[float, ...] = (0.0,) * 12 if epc.total_floor_area_m2 is None: internal_gains_monthly_w = (0.0,) * 12 lighting_kwh = 0.0 @@ -1645,6 +1788,13 @@ def cert_to_inputs( internal_gains_result.total_internal_gains_monthly_w ) lighting_kwh = internal_gains_result.lighting_kwh_per_yr + # Watts → kWh via n_days_in_month × 24 hours / 1000 W per kWh. + lighting_monthly_kwh = tuple( + w * d * 24.0 / 1000.0 + for w, d in zip( + internal_gains_result.lighting_monthly_w, _DAYS_IN_MONTH + ) + ) solar_gains_monthly_w = solar_gains_from_cert( epc=epc, @@ -1828,6 +1978,34 @@ def cert_to_inputs( epc.sap_energy_source.meter_type, prices ), co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), + # SAP10.2 Table 12d (p.194) per-end-use effective CO2 factors. For + # electricity end-uses Σ(kWh_m × CO2_m) / Σ(kWh_m) replaces the + # annual-average Table 12 factor; gas end-uses pass through as the + # annual Table 12 value. None → calculator falls back to the global + # `co2_factor_kg_per_kwh`. Secondary heating defaults to standard + # electricity per RdSAP §A.2.2 (portable electric heater). + main_heating_co2_factor_kg_per_kwh=_co2_factor_kg_per_kwh(main), + secondary_heating_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + energy_requirements_result.secondary_fuel_monthly_kwh, + _STANDARD_ELECTRICITY_FUEL_CODE, + ), + hot_water_co2_factor_kg_per_kwh=co2_factor_kg_per_kwh( + epc.sap_heating.water_heating_fuel or main_fuel + ), + pumps_fans_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + _days_in_month_proportioned(pumps_fans_kwh, _DAYS_IN_MONTH), + _STANDARD_ELECTRICITY_FUEL_CODE, + ), + lighting_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + lighting_monthly_kwh, _STANDARD_ELECTRICITY_FUEL_CODE, + ), + electric_shower_kwh_per_yr=( + wh_result.electric_shower_kwh_per_yr if wh_result is not None else 0.0 + ), + electric_shower_co2_factor_kg_per_kwh=_effective_monthly_co2_factor( + wh_result.electric_shower_monthly_kwh if wh_result is not None else (0.0,) * 12, + _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), secondary_heating_fraction=secondary_fraction_value, diff --git a/packages/domain/src/domain/sap/tables/table_12.py b/packages/domain/src/domain/sap/tables/table_12.py index f4f72fb1..63b6af14 100644 --- a/packages/domain/src/domain/sap/tables/table_12.py +++ b/packages/domain/src/domain/sap/tables/table_12.py @@ -19,7 +19,7 @@ The Energy Cost Deflator stays at 0.36 (used in ECF — see from __future__ import annotations -from typing import Final +from typing import Final, Optional # SAP 10.3 Table 12 — unit price in pence per kWh. @@ -77,10 +77,56 @@ UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = { _DEFAULT_P_PER_KWH: Final[float] = 3.64 # fall back to mains gas -# SAP 10.2 Table 12 — CO2 emission factor in kg CO2-equivalent per kWh -# of delivered energy. Grid electricity uses the annual-average 0.136 -# kg/kWh; the monthly factors in Table 12d are for comparison only per -# note (s). +# SAP 10.2 Table 12 — annual-average CO2 emission factor in kg CO2- +# equivalent per kWh of delivered energy. For ELECTRICITY end-uses, +# Table 12d (above) overrides this annual factor with monthly values per +# the spec text on p.194; the value here is the legacy fallback when +# monthly distribution isn't available. +# SAP 10.2 Table 12d (p.194) — monthly variation in CO2 emission factors +# for electricity. The spec text: "Where electricity is the fuel used, the +# relevant set of factors in the table below should be used to calculate +# the monthly CO2 emissions INSTEAD of the annual average factor given in +# Table 12." So for ratings, electricity end-uses use Σ(kWh_m × CO2_m) +# rather than annual_kwh × annual_factor. +CO2_KG_PER_KWH_MONTHLY: Final[dict[int, tuple[float, ...]]] = { + # Standard tariff (default electricity) + 30: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # 7-hour tariff + 32: (0.171, 0.168, 0.161, 0.150, 0.138, 0.125, 0.117, 0.118, 0.128, 0.143, 0.158, 0.171), + 31: (0.143, 0.141, 0.135, 0.126, 0.116, 0.105, 0.098, 0.099, 0.107, 0.120, 0.133, 0.144), + # 10-hour tariff + 34: (0.168, 0.165, 0.159, 0.148, 0.136, 0.124, 0.115, 0.116, 0.126, 0.141, 0.156, 0.168), + 33: (0.155, 0.153, 0.146, 0.137, 0.126, 0.114, 0.106, 0.107, 0.116, 0.130, 0.144, 0.155), + # 18-hour tariff (matches standard tariff) + 38: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + 40: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # 24-hour heating tariff + 35: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # Electricity sold to grid (PV) + 60: (0.196, 0.190, 0.175, 0.153, 0.129, 0.106, 0.092, 0.093, 0.110, 0.138, 0.169, 0.197), + # Electricity sold to grid, other + 36: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # Electricity, any tariff + 39: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # Heat from electric heat pump + 41: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # Low-grade heat recovered from process + 49: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), + # Electricity for pumping in distribution network + 50: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163), +} + + +def co2_monthly_factors_kg_per_kwh(fuel_code: int | None) -> Optional[tuple[float, ...]]: + """SAP 10.2 Table 12d (p.194) monthly CO2 factors for electricity. Returns + None for non-electricity fuels (use the annual `co2_factor_kg_per_kwh`).""" + if fuel_code is None: + return None + if fuel_code in CO2_KG_PER_KWH_MONTHLY: + return CO2_KG_PER_KWH_MONTHLY[fuel_code] + return None + + CO2_KG_PER_KWH: Final[dict[int, float]] = { # Gas fuels 1: 0.210, diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py index 4997a70b..0b6f6aea 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py @@ -496,3 +496,21 @@ LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200 LINE_257_ECF: float = 2.7055 SAP_VALUE_CONTINUOUS: float = 62.2584 LINE_258_SAP_RATING_INTEGER: int = 62 + +# ============================================================================ +# §12 Carbon dioxide emissions — line refs (261)..(274) +# ============================================================================ +LINE_261_MAIN_1_CO2: float = 2512.6274 +LINE_262_MAIN_2_CO2: float = 0.0 +LINE_263_SECONDARY_CO2: float = 0.0 +LINE_264_WATER_CO2: float = 481.2735 +LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0 +LINE_265_SPACE_AND_WATER_CO2: float = 2993.9009 +LINE_266_COOLING_CO2: float = 0.0 +LINE_267_PUMPS_FANS_CO2: float = 22.1940 +LINE_268_LIGHTING_CO2: float = 20.1984 +LINE_269_PV_CO2_CREDIT: float = 0.0 +LINE_272_TOTAL_CO2: float = 3036.2933 +LINE_273_CO2_PER_M2: float = 53.4700 +EI_VALUE_CONTINUOUS: float = 59.9093 +LINE_274_EI_RATING_INTEGER: int = 60 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py index 028b7518..dc7b2bb7 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000477.py @@ -463,3 +463,21 @@ LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200 LINE_257_ECF: float = 2.5086 SAP_VALUE_CONTINUOUS: float = 65.0057 LINE_258_SAP_RATING_INTEGER: int = 65 + +# ============================================================================ +# §12 Carbon dioxide emissions — line refs (261)..(274) +# ============================================================================ +LINE_261_MAIN_1_CO2: float = 2156.9042 +LINE_262_MAIN_2_CO2: float = 0.0 +LINE_263_SECONDARY_CO2: float = 155.2882 +LINE_264_WATER_CO2: float = 444.3677 +LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0 +LINE_265_SPACE_AND_WATER_CO2: float = 2756.5602 +LINE_266_COOLING_CO2: float = 0.0 +LINE_267_PUMPS_FANS_CO2: float = 22.1940 +LINE_268_LIGHTING_CO2: float = 29.1080 +LINE_269_PV_CO2_CREDIT: float = 0.0 +LINE_272_TOTAL_CO2: float = 2807.8621 +LINE_273_CO2_PER_M2: float = 36.1900 +EI_VALUE_CONTINUOUS: float = 69.3055 +LINE_274_EI_RATING_INTEGER: int = 69 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py index e22ef452..9b8a2a62 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000480.py @@ -505,3 +505,21 @@ LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200 LINE_257_ECF: float = 2.7743 SAP_VALUE_CONTINUOUS: float = 61.2986 LINE_258_SAP_RATING_INTEGER: int = 61 + +# ============================================================================ +# §12 Carbon dioxide emissions — line refs (261)..(274) +# ============================================================================ +LINE_261_MAIN_1_CO2: float = 2641.8617 +LINE_262_MAIN_2_CO2: float = 0.0 +LINE_263_SECONDARY_CO2: float = 190.1873 +LINE_264_WATER_CO2: float = 508.9643 +LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0 +LINE_265_SPACE_AND_WATER_CO2: float = 3341.0133 +LINE_266_COOLING_CO2: float = 0.0 +LINE_267_PUMPS_FANS_CO2: float = 22.1940 +LINE_268_LIGHTING_CO2: float = 30.6780 +LINE_269_PV_CO2_CREDIT: float = 0.0 +LINE_272_TOTAL_CO2: float = 3393.8852 +LINE_273_CO2_PER_M2: float = 40.2100 +EI_VALUE_CONTINUOUS: float = 64.8574 +LINE_274_EI_RATING_INTEGER: int = 65 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py index da027d5c..b7120ea2 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000487.py @@ -529,3 +529,22 @@ LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200 LINE_257_ECF: float = 2.7496 SAP_VALUE_CONTINUOUS: float = 61.6431 LINE_258_SAP_RATING_INTEGER: int = 62 + +# ============================================================================ +# §12 Carbon dioxide emissions — line refs (261)..(274) +# ============================================================================ +# 000487 has electric shower: (264a) is non-zero. +LINE_261_MAIN_1_CO2: float = 2313.8678 +LINE_262_MAIN_2_CO2: float = 0.0 +LINE_263_SECONDARY_CO2: float = 166.2086 +LINE_264_WATER_CO2: float = 312.7117 +LINE_264A_ELECTRIC_SHOWER_CO2: float = 83.6458 +LINE_265_SPACE_AND_WATER_CO2: float = 2792.7881 +LINE_266_COOLING_CO2: float = 0.0 +LINE_267_PUMPS_FANS_CO2: float = 22.1940 +LINE_268_LIGHTING_CO2: float = 32.8621 +LINE_269_PV_CO2_CREDIT: float = 0.0 +LINE_272_TOTAL_CO2: float = 2931.4900 +LINE_273_CO2_PER_M2: float = 35.9400 +EI_VALUE_CONTINUOUS: float = 68.9642 +LINE_274_EI_RATING_INTEGER: int = 69 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py index 40cb5c91..a1b092fa 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000490.py @@ -479,3 +479,21 @@ LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200 LINE_257_ECF: float = 3.0539 SAP_VALUE_CONTINUOUS: float = 57.3979 LINE_258_SAP_RATING_INTEGER: int = 57 + +# ============================================================================ +# §12 Carbon dioxide emissions — line refs (261)..(274) +# ============================================================================ +LINE_261_MAIN_1_CO2: float = 2396.4161 +LINE_262_MAIN_2_CO2: float = 0.0 +LINE_263_SECONDARY_CO2: float = 171.5647 +LINE_264_WATER_CO2: float = 598.6197 +LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0 +LINE_265_SPACE_AND_WATER_CO2: float = 3166.6005 +LINE_266_COOLING_CO2: float = 0.0 +LINE_267_PUMPS_FANS_CO2: float = 22.1940 +LINE_268_LIGHTING_CO2: float = 24.7414 +LINE_269_PV_CO2_CREDIT: float = 0.0 +LINE_272_TOTAL_CO2: float = 3213.5359 +LINE_273_CO2_PER_M2: float = 48.6500 +EI_VALUE_CONTINUOUS: float = 61.1646 +LINE_274_EI_RATING_INTEGER: int = 61 diff --git a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py index b084d298..1089c179 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py +++ b/packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000516.py @@ -505,3 +505,21 @@ LINE_256_ENERGY_COST_DEFLATOR: float = 0.4200 LINE_257_ECF: float = 2.6671 SAP_VALUE_CONTINUOUS: float = 62.7937 LINE_258_SAP_RATING_INTEGER: int = 63 + +# ============================================================================ +# §12 Carbon dioxide emissions — line refs (261)..(274) +# ============================================================================ +LINE_261_MAIN_1_CO2: float = 2647.3475 +LINE_262_MAIN_2_CO2: float = 0.0 +LINE_263_SECONDARY_CO2: float = 190.4096 +LINE_264_WATER_CO2: float = 523.5699 +LINE_264A_ELECTRIC_SHOWER_CO2: float = 0.0 +LINE_265_SPACE_AND_WATER_CO2: float = 3361.3270 +LINE_266_COOLING_CO2: float = 0.0 +LINE_267_PUMPS_FANS_CO2: float = 22.1940 +LINE_268_LIGHTING_CO2: float = 33.3239 +LINE_269_PV_CO2_CREDIT: float = 0.0 +LINE_272_TOTAL_CO2: float = 3416.8449 +LINE_273_CO2_PER_M2: float = 37.7400 +EI_VALUE_CONTINUOUS: float = 66.2198 +LINE_274_EI_RATING_INTEGER: int = 66 diff --git a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py index f1bfe4b7..6f52080f 100644 --- a/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py +++ b/packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py @@ -19,6 +19,7 @@ import pytest from domain.sap.rdsap.cert_to_inputs import ( cert_to_inputs, energy_requirements_section_from_cert, + environmental_section_from_cert, fabric_energy_efficiency_from_cert, fuel_cost_section_from_cert, heat_transmission_section_from_cert, @@ -891,3 +892,55 @@ def test_section_11a_line_refs_match_pdf( # Assert _pin(actual, expected, f"§11a {fixture_attr} {fixture_name}") + + +# ============================================================================ +# §12 Environmental (CO2 emissions) — LINE_261..LINE_274 +# ============================================================================ + +_SECTION_12_PINS: Final[tuple[tuple[str, str], ...]] = ( + ("LINE_261_MAIN_1_CO2", "main_1_co2_kg_per_yr"), + ("LINE_262_MAIN_2_CO2", "main_2_co2_kg_per_yr"), + ("LINE_263_SECONDARY_CO2", "secondary_co2_kg_per_yr"), + ("LINE_264_WATER_CO2", "water_heating_co2_kg_per_yr"), + ("LINE_264A_ELECTRIC_SHOWER_CO2", "electric_shower_co2_kg_per_yr"), + ("LINE_265_SPACE_AND_WATER_CO2", "space_and_water_co2_kg_per_yr"), + ("LINE_266_COOLING_CO2", "space_cooling_co2_kg_per_yr"), + ("LINE_267_PUMPS_FANS_CO2", "pumps_fans_co2_kg_per_yr"), + ("LINE_268_LIGHTING_CO2", "lighting_co2_kg_per_yr"), + ("LINE_269_PV_CO2_CREDIT", "pv_co2_credit_kg_per_yr"), + ("LINE_272_TOTAL_CO2", "total_co2_kg_per_yr"), + ("LINE_273_CO2_PER_M2", "co2_per_m2_kg_per_yr"), + ("EI_VALUE_CONTINUOUS", "ei_value_continuous"), + ("LINE_274_EI_RATING_INTEGER", "ei_rating_integer"), +) + + +@pytest.mark.parametrize( + "fixture_name,fixture_attr,result_attr", + [ + (fix, line, attr) + for fix in _FIXTURES + for line, attr in _SECTION_12_PINS + ], + ids=lambda x: x if isinstance(x, str) else None, +) +def test_section_12_line_refs_match_pdf( + fixture_name: str, fixture_attr: str, result_attr: str +) -> None: + """§12 pins — every (261)..(274) line ref of `environmental_section_ + from_cert` matches the U985 PDF to abs=1e-4. Electricity end-uses use + Table 12d (p.194) monthly cascade Σ(kWh_m × CO2_m); gas end-uses use + the annual Table 12 factor 0.21.""" + # Arrange + mod = _FIXTURES[fixture_name] + epc = mod.build_epc() # type: ignore[attr-defined] + expected = getattr(mod, fixture_attr) + + # Act + env = environmental_section_from_cert(epc) + assert env is not None, f"{fixture_name}: env_from_cert returned None" + actual = getattr(env, result_attr) + + # Assert + _pin(actual, expected, f"§12 {fixture_attr} {fixture_name}")