mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Locality of reference — SAP-specific docs, specs, and runtime data
now live alongside the calculator that consumes them, mirroring the
prior packages→domain layout moves.
Move targets:
- Narrative MDs → domain/sap10_calculator/docs/
NEXT_AGENT_PROMPT.md, HANDOVER_NEXT.md, SAP_CALCULATOR.md
- Spec PDFs → domain/sap10_calculator/docs/specs/
RdSAP 10 Specification 10-06-2025.pdf
PCDF_Spec_Rev-06b_12_May_2021.pdf
sap-10-2-full-specification-2025-03-14.pdf
sap-10-3-full-specification-2026-01-13.pdf
- PCDB runtime data → domain/sap10_calculator/tables/pcdb/data/
pcdb10.dat (8.3MB) + 7× pcdb_table_*.jsonl (18MB total)
Path code rewrites (load-bearing):
- tables/pcdb/__init__.py: replaced parents[4]/'docs'/'sap-spec' with
Path(__file__).resolve().parent/'data' for Table 105 JSONL loading.
- tables/pcdb/postcode_weather.py: same rebase for the pcdb10.dat path
read by _postcode_climate_table().
- tables/pcdb/etl.py __main__: same rebase for the manual ETL invocation
(source + output_dir both now point inside the package).
- tests/test_pcdb_etl.py: _PCDB_DAT_PATH now derives from
parents[1]/'tables'/'pcdb'/'data' (was parents[3]/'docs'/'sap-spec').
Citation rewrites:
- 12 .py docstrings and 4 .md docs (ADRs + READMEs + narrative docs)
had `docs/sap-spec/<file>` strings rewritten to their new locations.
- Two cases where the catch-all sed misfired (an ADR-0009 line about a
PCDB extract; the pcdb __init__.py docstring about ETL output) were
hand-corrected to point at tables/pcdb/data/ rather than docs/specs/.
docs/sap-spec/ is now empty (will be removed in a follow-up sweep or
left as a vestigial empty dir for future repurposing). ADRs 0009 and
0010 remain at docs/adr/ — they're part of the chronological
cross-cutting decision log, not calculator-specific narrative.
Verified:
- Calculator's 1e-4 production gate
(test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly) GREEN.
- Wider sweep (domain/sap10_calculator/ + domain/sap10_ml/): 1654
passed / 20 failed — exact pre-move baseline. All 20 failures
pre-existing (10 hand-built skeleton + 4 cohort chain + 6 cohort
diff).
- Pyright net-zero on the 4 touched runtime/test files (0 errors)
and unchanged on heat_transmission.py (13) / cert_to_inputs.py (35) /
mapper.py (33).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
|
|
|
|
Locks the `Tariff` enum, the `tariff_from_meter_type` cert resolver,
|
|
and the per-system / per-use high-rate-fraction lookups against the
|
|
published SAP10.2 specification at
|
|
`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`, page 191.
|
|
|
|
RdSAP10 §19.1 cross-references Table 12a in SAP10.2 for off-peak
|
|
splitting — the table itself is not duplicated in the RdSAP10 PDF.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from domain.sap10_calculator.tables.table_12a import (
|
|
OtherUse,
|
|
Table12aSystem,
|
|
Tariff,
|
|
other_use_high_rate_fraction,
|
|
space_heating_high_rate_fraction,
|
|
tariff_from_meter_type,
|
|
water_heating_high_rate_fraction,
|
|
)
|
|
|
|
|
|
def test_tariff_enum_has_five_members() -> None:
|
|
"""Table 12a columns: standard (no off-peak split), 7-hour, 10-hour,
|
|
18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for
|
|
spec completeness even though RdSAP10 meter_type enum (1..5) doesn't
|
|
route to it — see ADR-0010 §3 unreachable-branch policy."""
|
|
# Arrange
|
|
# Act
|
|
members = set(Tariff)
|
|
|
|
# Assert
|
|
assert members == {
|
|
Tariff.STANDARD,
|
|
Tariff.SEVEN_HOUR,
|
|
Tariff.TEN_HOUR,
|
|
Tariff.EIGHTEEN_HOUR,
|
|
Tariff.TWENTY_FOUR_HOUR,
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"meter_type, expected",
|
|
[
|
|
# RdSAP cert meter_type string forms
|
|
("Single", Tariff.STANDARD),
|
|
("Standard", Tariff.STANDARD),
|
|
("Dual", Tariff.SEVEN_HOUR),
|
|
("Dual (24 hour)", Tariff.TWENTY_FOUR_HOUR),
|
|
("Off-peak 18 hour", Tariff.EIGHTEEN_HOUR),
|
|
# Per Q11b: "Unknown" maps to STANDARD (no off-peak heuristic).
|
|
("Unknown", Tariff.STANDARD),
|
|
("", Tariff.STANDARD),
|
|
# Numeric forms (cert sometimes lodges integers per S-B9 finding)
|
|
(2, Tariff.STANDARD),
|
|
(1, Tariff.SEVEN_HOUR),
|
|
(4, Tariff.TWENTY_FOUR_HOUR),
|
|
(5, Tariff.EIGHTEEN_HOUR),
|
|
(3, Tariff.STANDARD),
|
|
# None / missing → STANDARD
|
|
(None, Tariff.STANDARD),
|
|
],
|
|
)
|
|
def test_tariff_from_meter_type_maps_cert_codes(
|
|
meter_type: object, expected: Tariff
|
|
) -> None:
|
|
"""RdSAP cert `meter_type` field carries either a string or an int
|
|
enum (1..5). Per Q11b grilling: "Unknown" (code 3) maps to STANDARD
|
|
rather than the legacy off-peak heuristic — spec-faithful since
|
|
RdSAP10 has no rule for unresolved tariffs."""
|
|
# Arrange
|
|
# Act
|
|
tariff = tariff_from_meter_type(meter_type)
|
|
|
|
# Assert
|
|
assert tariff is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"system, tariff, expected_fraction, label",
|
|
[
|
|
# Integrated storage+direct (storage heaters 408, underfloor 422/423)
|
|
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR, 0.20, "integrated 408/422/423 7-hr"),
|
|
# Other storage heaters
|
|
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR, 0.00, "other storage 7-hr"),
|
|
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR, 0.00, "other storage 24-hr"),
|
|
# Electric dry core / water storage boiler / Electricaire
|
|
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR, 0.00, "electric dry core 7-hr"),
|
|
# Direct-acting electric boiler
|
|
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR, 0.90, "direct-acting boiler 7-hr"),
|
|
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR, 0.50, "direct-acting boiler 10-hr"),
|
|
# Underfloor heating (above insulation / timber / below floor)
|
|
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR, 0.90, "underfloor 7-hr"),
|
|
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR, 0.50, "underfloor 10-hr"),
|
|
# Ground/water source heat pump — Appendix N calculated
|
|
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "GSHP App N 7-hr"),
|
|
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.80, "GSHP App N 10-hr"),
|
|
# GSHP otherwise
|
|
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR, 0.70, "GSHP otherwise 7-hr"),
|
|
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR, 0.60, "GSHP otherwise 10-hr"),
|
|
# Air source heat pump — Appendix N
|
|
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "ASHP App N 7-hr"),
|
|
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.80, "ASHP App N 10-hr"),
|
|
# ASHP otherwise
|
|
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR, 0.90, "ASHP otherwise 7-hr"),
|
|
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR, 0.60, "ASHP otherwise 10-hr"),
|
|
# Other direct-acting electric (incl secondary)
|
|
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR, 1.00, "other direct-acting 7-hr"),
|
|
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR, 0.50, "other direct-acting 10-hr"),
|
|
],
|
|
)
|
|
def test_space_heating_high_rate_fraction_matches_table_12a_grid_1(
|
|
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
|
|
) -> None:
|
|
"""Table 12a Grid 1 SH column, verbatim from SAP10.2 PDF page 191.
|
|
Each (system, tariff) pair pinned to its published high-rate
|
|
fraction. Tariff columns not listed for a row (e.g. integrated
|
|
storage at 10-hr) are out-of-domain and raise — covered separately."""
|
|
# Arrange
|
|
# Act
|
|
fraction = space_heating_high_rate_fraction(system, tariff)
|
|
|
|
# Assert
|
|
assert fraction == expected_fraction, (
|
|
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
|
|
)
|
|
|
|
|
|
def test_space_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
|
|
"""STANDARD tariff = no off-peak split. Every system bills 100% at
|
|
the (single) unit price, so high-rate fraction collapses to 1.0.
|
|
This is the passthrough path every gas-heated fixture in scope A
|
|
will exercise."""
|
|
# Arrange
|
|
# System choice is irrelevant on STANDARD — pick a representative one.
|
|
system = Table12aSystem.OTHER_STORAGE_HEATERS
|
|
|
|
# Act
|
|
fraction = space_heating_high_rate_fraction(system, Tariff.STANDARD)
|
|
|
|
# Assert
|
|
assert fraction == 1.0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"system, tariff, expected_fraction, label",
|
|
[
|
|
# Heat-pump WH (App N + otherwise) — same fractions for 7-hr / 10-hr
|
|
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "GSHP App N WH 7-hr"),
|
|
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.70, "GSHP App N WH 10-hr"),
|
|
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "GSHP other off-peak immersion 7-hr"),
|
|
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "GSHP other off-peak immersion 10-hr"),
|
|
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "GSHP other no immersion 7-hr"),
|
|
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "GSHP other no immersion 10-hr"),
|
|
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "ASHP App N WH 7-hr"),
|
|
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.70, "ASHP App N WH 10-hr"),
|
|
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "ASHP other off-peak immersion 7-hr"),
|
|
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "ASHP other off-peak immersion 10-hr"),
|
|
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "ASHP other no immersion 7-hr"),
|
|
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "ASHP other no immersion 10-hr"),
|
|
],
|
|
)
|
|
def test_water_heating_high_rate_fraction_matches_table_12a_grid_1(
|
|
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
|
|
) -> None:
|
|
"""Table 12a Grid 1 WH column, verbatim from SAP10.2 PDF page 191.
|
|
Heat-pump WH carries 0.70 high-rate by default (or 0.17 when paired
|
|
with off-peak immersion). Immersion / HP-DHW-only WH (Table 13) and
|
|
Electric CPSU (Appendix F) are out-of-scope until a fixture lands."""
|
|
# Arrange
|
|
# Act
|
|
fraction = water_heating_high_rate_fraction(system, tariff)
|
|
|
|
# Assert
|
|
assert fraction == expected_fraction, (
|
|
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
|
|
)
|
|
|
|
|
|
def test_water_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
|
|
"""STANDARD-tariff passthrough — water heating bills 100% at the
|
|
single rate."""
|
|
# Arrange
|
|
system = Table12aSystem.ASHP_OTHER_NO_IMMERSION
|
|
|
|
# Act
|
|
fraction = water_heating_high_rate_fraction(system, Tariff.STANDARD)
|
|
|
|
# Assert
|
|
assert fraction == 1.0
|
|
|
|
|
|
def test_water_heating_high_rate_fraction_for_immersion_raises() -> None:
|
|
"""`IMMERSION_OR_HP_DHW_ONLY` sources its fraction from Table 13,
|
|
which lives in a separate spec section. Defer until first immersion
|
|
fixture lands (per Q5 deferred list)."""
|
|
# Arrange
|
|
system = Table12aSystem.IMMERSION_OR_HP_DHW_ONLY
|
|
|
|
# Act / Assert
|
|
with pytest.raises(NotImplementedError):
|
|
water_heating_high_rate_fraction(system, Tariff.SEVEN_HOUR)
|
|
|
|
|
|
def test_water_heating_high_rate_fraction_for_electric_cpsu_raises() -> None:
|
|
"""`ELECTRIC_CPSU` sources its fraction from Appendix F. Defer until
|
|
first CPSU fixture lands."""
|
|
# Arrange
|
|
system = Table12aSystem.ELECTRIC_CPSU
|
|
|
|
# Act / Assert
|
|
with pytest.raises(NotImplementedError):
|
|
water_heating_high_rate_fraction(system, Tariff.TEN_HOUR)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"use, tariff, expected_fraction, label",
|
|
[
|
|
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR, 0.71, "fans 7-hr"),
|
|
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR, 0.58, "fans 10-hr"),
|
|
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR, 0.90, "all other 7-hr"),
|
|
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR, 0.80, "all other 10-hr"),
|
|
],
|
|
)
|
|
def test_other_use_high_rate_fraction_matches_table_12a_grid_2(
|
|
use: OtherUse, tariff: Tariff, expected_fraction: float, label: str
|
|
) -> None:
|
|
"""Table 12a Grid 2 (PDF page 191) — "Other electricity uses" sub-
|
|
table for fans/MV vs all-other-uses-and-locally-generated. Lighting
|
|
+ pumps + locally-generated PV credit all bill via ALL_OTHER_USES."""
|
|
# Arrange
|
|
# Act
|
|
fraction = other_use_high_rate_fraction(use, tariff)
|
|
|
|
# Assert
|
|
assert fraction == expected_fraction, (
|
|
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
|
|
)
|
|
|
|
|
|
def test_other_use_high_rate_fraction_returns_one_for_standard_tariff() -> None:
|
|
"""STANDARD passthrough."""
|
|
# Arrange
|
|
use = OtherUse.ALL_OTHER_USES
|
|
|
|
# Act
|
|
fraction = other_use_high_rate_fraction(use, Tariff.STANDARD)
|
|
|
|
# Assert
|
|
assert fraction == 1.0
|