§5 slice 8: (73) total_internal_gains_monthly_w + InternalGainsResult

Closes the §5 leaf-function surface:
  - total_internal_gains_monthly_w sums (66) + (67) + (68) + (69)
    + (70) + (71) + (72) element-wise. (71) carries negative sign so the
    losses term subtracts.
  - InternalGainsResult frozen dataclass bundles all 7 line refs plus the
    total as 12-tuples — the typed payload returned by the orchestrator.

Verified against Elmhurst U985-0001-000490 (73)m to ≤1e-2 W/month.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-20 18:38:46 +00:00
parent f77229e4b4
commit 53aba1332e
2 changed files with 119 additions and 0 deletions

View file

@ -297,6 +297,57 @@ def pumps_fans_monthly_w(
)
def total_internal_gains_monthly_w(
*,
metabolic_monthly_w: tuple[float, ...],
lighting_monthly_w: tuple[float, ...],
appliances_monthly_w: tuple[float, ...],
cooking_monthly_w: tuple[float, ...],
pumps_fans_monthly_w: tuple[float, ...],
losses_monthly_w: tuple[float, ...],
water_heating_gains_monthly_w: tuple[float, ...],
) -> tuple[float, ...]:
"""SAP10.2 §5 line (73) — total internal gains.
(73)m = (66)m + (67)m + (68)m + (69)m + (70)m + (71)m + (72)m
Pure element-wise sum across the seven gain streams. (71)m carries a
negative sign (losses) so the contribution is a subtraction.
"""
return tuple(
m + l + a + c + p + s + w
for m, l, a, c, p, s, w in zip(
metabolic_monthly_w,
lighting_monthly_w,
appliances_monthly_w,
cooking_monthly_w,
pumps_fans_monthly_w,
losses_monthly_w,
water_heating_gains_monthly_w,
)
)
@dataclass(frozen=True)
class InternalGainsResult:
"""SAP10.2 §5 line refs (66)..(73), each a 12-tuple of watts per month.
Returned by `internal_gains_from_cert`. Downstream §6/§7/§9 calculators
consume `total_internal_gains_monthly_w` directly; the per-line tuples
are exposed for worksheet conformance + audit. Field names mirror the
SAP10.2 line refs.
"""
metabolic_monthly_w: tuple[float, ...] # line (66)
lighting_monthly_w: tuple[float, ...] # line (67)
appliances_monthly_w: tuple[float, ...] # line (68)
cooking_monthly_w: tuple[float, ...] # line (69)
pumps_fans_monthly_w: tuple[float, ...] # line (70)
losses_monthly_w: tuple[float, ...] # line (71)
water_heating_gains_monthly_w: tuple[float, ...] # line (72)
total_internal_gains_monthly_w: tuple[float, ...] # line (73)
def water_heating_gains_monthly_w(
*,
heat_gains_from_water_heating_monthly_kwh: tuple[float, ...],

View file

@ -13,6 +13,7 @@ Table 5a + Appendix L (lighting/appliances/cooking) + Appendix J Table 1b
import pytest
from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
PumpDateCategory,
appliances_monthly_w,
balanced_mv_no_hr_fan_w,
@ -26,6 +27,7 @@ from domain.sap.worksheet.internal_gains import (
metabolic_monthly_w,
piv_fan_w,
pumps_fans_monthly_w,
total_internal_gains_monthly_w,
warm_air_heating_fan_w,
water_heating_gains_monthly_w,
)
@ -344,3 +346,69 @@ def test_heat_interface_unit_converts_kwh_per_day_to_constant_watts() -> None:
# Assert
assert w == pytest.approx(20.0, abs=1e-9)
def test_total_internal_gains_sums_seven_components_per_month_for_000490() -> None:
"""SAP10.2 §5 line (73): G_total,m = G_M + G_L + G_A + G_C + G_pumps
+ G_losses + G_WH per month. (71) is negative net subtraction.
Verified against Elmhurst U985-0001-000490 worksheet (73)m row.
"""
# Arrange — component tuples from the 000490 worksheet rows (66)..(72)
metabolic = (128.8087,) * 12
lighting = (24.2665, 21.5533, 17.5283, 13.2701, 9.9195, 8.3745,
9.0489, 11.7621, 15.7871, 20.0454, 23.3959, 24.9410)
appliances = (280.4965, 283.4071, 276.0723, 260.4574, 240.7463, 222.2207,
209.8445, 206.9338, 214.2686, 229.8835, 249.5946, 268.1202)
cooking = (50.0277,) * 12
pumps = (7.0, 7.0, 7.0, 7.0, 7.0, 0.0, 0.0, 0.0, 0.0, 7.0, 7.0, 7.0)
losses = (-85.8725,) * 12
water_heating = (101.0798, 98.8663, 94.6647, 85.5412, 79.9343, 74.0821,
70.5249, 73.5203, 77.0253, 83.5192, 92.2698, 99.9197)
expected_total = (
505.8067, 503.7906, 488.2293, 459.2325, 430.5641, 397.6412,
382.3822, 385.1801, 400.0449, 433.4120, 465.2242, 492.9448,
)
# Act
total = total_internal_gains_monthly_w(
metabolic_monthly_w=metabolic,
lighting_monthly_w=lighting,
appliances_monthly_w=appliances,
cooking_monthly_w=cooking,
pumps_fans_monthly_w=pumps,
losses_monthly_w=losses,
water_heating_gains_monthly_w=water_heating,
)
# Assert
assert len(total) == 12
for m, (actual, exp) in enumerate(zip(total, expected_total)):
assert actual == pytest.approx(exp, abs=1e-2), f"month {m+1}"
def test_internal_gains_result_dataclass_holds_all_seven_lines_plus_total() -> None:
"""InternalGainsResult bundles every line (66)..(73) as a 12-tuple so
downstream §6/§7/§9 callers receive a single typed payload from the
orchestrator. Field names mirror the worksheet line refs."""
# Arrange
zeros = (0.0,) * 12
# Act
result = InternalGainsResult(
metabolic_monthly_w=zeros,
lighting_monthly_w=zeros,
appliances_monthly_w=zeros,
cooking_monthly_w=zeros,
pumps_fans_monthly_w=zeros,
losses_monthly_w=zeros,
water_heating_gains_monthly_w=zeros,
total_internal_gains_monthly_w=zeros,
)
# Assert — every field is a 12-tuple
assert all(len(getattr(result, f)) == 12 for f in (
"metabolic_monthly_w", "lighting_monthly_w", "appliances_monthly_w",
"cooking_monthly_w", "pumps_fans_monthly_w", "losses_monthly_w",
"water_heating_gains_monthly_w", "total_internal_gains_monthly_w",
))