Compare commits

..

No commits in common. "6dc11e4d64483c7c83fae2a05fb38d5f2ad3911d" and "87b6045c97cedec4e22bdd048079d0a4acf629fa" have entirely different histories.

139 changed files with 446 additions and 349 deletions

View file

@ -132,7 +132,7 @@ _Avoid_: outlier, mismatch, divergence flag
### ML training
**EPC ML Transform**:
The versioned class at `domain/sap10_ml/transform.py` that maps an EpcPropertyData to a fixed-width row of features + targets. The single ML-data contract between this repo and the AutoGluon training repo. Owns the windows compression, building-parts compression, Top-N Code Taxonomy, and UCL folding decisions. Each version is tagged on the deployed scoring lambda; a mismatch is a deploy-time fail.
The versioned class at `packages/domain/src/domain/ml/transform.py` that maps an EpcPropertyData to a fixed-width row of features + targets. The single ML-data contract between this repo and the AutoGluon training repo. Owns the windows compression, building-parts compression, Top-N Code Taxonomy, and UCL folding decisions. Each version is tagged on the deployed scoring lambda; a mismatch is a deploy-time fail.
_Avoid_: feature builder, ML mapper, EPC vectoriser
**Feature Schema Version**:

View file

@ -1,15 +1,8 @@
FROM python:3.11-slim
# System binaries:
# - postgresql: pytest-postgresql spawns ephemeral test databases
# - poppler-utils: provides pdfinfo / pdftotext, used by
# backend/documents_parser/tests/test_summary_pdf_mapper_chain.py's
# `_summary_pdf_to_textract_style_pages` helper for layout-preserving
# PDF text extraction. Pure-Python alternatives (pymupdf, pypdf) don't
# reproduce pdftotext -layout's row-major table cell ordering, which
# the Elmhurst Summary extractor depends on.
# Install PostgreSQL binaries — required by pytest-postgresql to spawn ephemeral test databases
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql poppler-utils \
&& apt-get install -y --no-install-recommends postgresql \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app

View file

@ -412,7 +412,7 @@ For tests, each repo has a `FakeXRepo` companion backed by a dict. Service unit
| Concern | Owner |
|---|---|
| Defining the EPC → features transform | **This repo** (`ara.domain.sap10_ml.EpcMlTransform`) |
| Defining the EPC → features transform | **This repo** (`ara.domain.ml.EpcMlTransform`) |
| Loading data, applying transform, writing training parquet to S3 | **This repo** (sub-PRD (ii) batch job) |
| Training, hyperparameter search, deployment | **Autogluon repo** |
| Scoring at modelling time | **This repo** (`FeatureBuilder` calls `EpcMlTransform`, sends DataFrame to deployed lambda) |

View file

@ -140,12 +140,12 @@ class TestBuildingPart:
def test_wall_construction(self, result: EpcPropertyData) -> None:
# SAP10 wall_construction integer: 4 = Cavity (per
# domain.sap10_ml.rdsap_uvalues.WALL_CAVITY).
# domain.ml.rdsap_uvalues.WALL_CAVITY).
assert result.sap_building_parts[0].wall_construction == 4
def test_wall_insulation_type(self, result: EpcPropertyData) -> None:
# SAP10 wall_insulation_type integer: 2 = Filled cavity (per
# domain.sap10_ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY).
# domain.ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY).
assert result.sap_building_parts[0].wall_insulation_type == 2
def test_wall_thickness_measured(self, result: EpcPropertyData) -> None:
@ -262,15 +262,9 @@ class TestHeating:
assert result.sap_heating.main_heating_details[0].heat_emitter_type == 1
def test_emitter_temperature(self, result: EpcPropertyData) -> None:
# The Elmhurst Summary §14 lodges "Design flow temperature: Unknown"
# for this cert. `_elmhurst_emitter_temperature_int` (mapper.py)
# converts that to SAP10.2 Table 4d code 1 (high-temp / ≥45 °C —
# the worst-case assumption for an unmeasured gas boiler). This
# int encoding mirrors the API mapper's `MainHeatingDetail.
# emitter_temperature` for cross-mapper field parity; the older
# behaviour of surfacing the raw "Unknown" string was replaced
# when the int conversion landed.
assert result.sap_heating.main_heating_details[0].emitter_temperature == 1
assert (
result.sap_heating.main_heating_details[0].emitter_temperature == "Unknown"
)
def test_fan_flue_present(self, result: EpcPropertyData) -> None:
assert result.sap_heating.main_heating_details[0].fan_flue_present is True

View file

@ -1,6 +1,6 @@
"""End-to-end validation for the Elmhurst Summary→EpcPropertyData chain.
The 6 Elmhurst worksheet fixtures in `domain.sap10_calculator.worksheet.tests`
The 6 Elmhurst worksheet fixtures in `domain.sap.worksheet.tests`
build their `EpcPropertyData` synthetically they validate the
calculator + cascade in isolation from the mapper. This file pins
the OTHER half of the chain: `from_elmhurst_site_notes` must produce
@ -37,9 +37,9 @@ from typing import cast
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
from domain.sap10_calculator.worksheet.tests import (
from domain.sap.calculator import calculate_sap_from_inputs
from domain.sap.rdsap.cert_to_inputs import SAP_10_2_SPEC_PRICES, cert_to_inputs
from domain.sap.worksheet.tests import (
_elmhurst_worksheet_000474 as _w000474,
_elmhurst_worksheet_000477 as _w000477,
_elmhurst_worksheet_000480 as _w000480,
@ -63,7 +63,7 @@ _SUMMARY_001479_PDF = _FIXTURES / "Summary_001479.pdf"
# matches worksheet continuous SAP at 1e-4".
_API_001479_JSON = (
Path(__file__).parents[3]
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
/ "packages/domain/src/domain/sap/rdsap/tests/fixtures/golden"
/ "0535-9020-6509-0821-6222.json"
)
@ -108,7 +108,7 @@ def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
def test_summary_000474_mapper_produces_three_building_parts() -> None:
# Arrange — cert U985-0001-000474 is a mid-terrace with 3 building
# parts (Main + 2 extensions) per the hand-built worksheet fixture
# at domain/sap10_calculator/worksheet/tests/
# at packages/domain/src/domain/sap/worksheet/tests/
# _elmhurst_worksheet_000474.py. Routing the Summary PDF through
# extractor + mapper must yield the same count.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000474_PDF)
@ -137,20 +137,32 @@ def test_summary_000474_mapper_extracts_seven_windows() -> None:
assert len(epc.sap_windows) == 7
# Cohort chain SAP-pin tests follow. NOTE: certs 000474, 000480, 000487,
# 000490 previously had chain tests here pinning their cascade SAP
# against the U985 worksheet PDF — those tests were removed because
# their worksheets violate RdSAP 10 §5 (12) "Floor infiltration
# (suspended timber ground floor only)". Our cascade applies the spec
# rule (via `cert_to_inputs._has_suspended_timber_floor_per_spec`);
# the worksheet does not. So the spec-correct chain SAP for those
# certs can't match the worksheet SAP — by design, not by mapper bug.
# The Layer 1 hand-built fixtures for those 4 certs absorb the
# worksheet quirk by lodging `has_suspended_timber_floor=False`
# explicitly (overriding the spec inference) — so Layer 1 cascade pins
# still pin the worksheet value exactly. The chain tests below remain
# only for 000477, 000516 (and 001479 further down), where the
# worksheet IS spec-correct.
def test_summary_000474_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — the full Summary→ElmhurstSiteNotes→EpcPropertyData→cascade
# →SAP path against the U985-0001-000474 worksheet PDF's unrounded
# SAP rating (line 257: SAP value 62.2584, rating (258) = 62).
# Because the Summary PDF carries the same source-of-truth data that
# the hand-built worksheet fixture encodes by hand, and because the
# cascade matches Elmhurst's calculator to 4 d.p. on those hand-
# built inputs, this end-to-end path MUST produce the same unrounded
# SAP value. Any non-trivial drift = a real mapper bug dropping
# information from the Summary PDF.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000474_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert — within the same 1e-4 tolerance the other Elmhurst worksheet
# tests pin against. 0.5 is the API-cert residual tolerance (the API
# publishes rounded SAP integers, so up to half a SAP point is just
# rounding); for Elmhurst worksheet inputs the cascade reproduces
# Elmhurst exactly and we expect identical outputs.
worksheet_unrounded_sap = 62.2584
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_000477_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
@ -175,6 +187,49 @@ def test_summary_000477_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_000480_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cert U985-0001-000480 is a mid-terrace with main + one
# extension and a 19.83 m² room-in-roof storey. Worksheet PDF lodges
# unrounded SAP 61.2986 on line "SAP value". The Detailed §3.10 RR
# surfaces (2 stud walls @ 0mm + 2 slopes @ 0mm + 1 flat ceiling @
# 0mm + 2 party gables) plus zero baths drive the chain to 1e-4.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000480_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert
worksheet_unrounded_sap = 61.2986
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_000487_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cert U985-0001-000487 is an enclosed-mid-terrace with
# main bp + 1st extension, a 21.03 m² Room-in-Roof, an electric
# shower, and a 1.43 m² Timber Frame alternative wall on the
# extension. Worksheet PDF lodges unrounded SAP 61.6431. The mapped
# chain has to thread the alt-wall U-value cascade (Thickness
# Unknown → cascade falls back to age-band default U=1.9 for thin
# timber walls) plus the §11 layout variant where the frame_factor
# appears unprefixed on its own line (no "PVC"/"Wood" frame_type).
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000487_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert
worksheet_unrounded_sap = 61.6431
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_000516_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cert U985-0001-000516 is a mid-terrace with main bp +
# 19.02 m² room-in-roof. Worksheet PDF lodges unrounded SAP 62.7937.
@ -197,6 +252,27 @@ def test_summary_000516_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_000490_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
# Arrange — cert U985-0001-000490 is an end-terrace with main +
# 1st extension. The worksheet PDF lodges unrounded SAP 57.3979.
# End-terrace built-form drives sheltered_sides=1 (RdSAP §S5) and
# the cert's Summary §14.1 Main Heating2 sub-section carries a
# secondary heating SAP code (691, electric panel) — both required
# for the mapped chain to reproduce the worksheet to 1e-4.
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000490_PDF)
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
# Act
result = calculate_sap_from_inputs(
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
)
# Assert
worksheet_unrounded_sap = 57.3979
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
def test_summary_001479_mapper_extensions_count_matches_extension_bps() -> None:
# Arrange — cert 0535-9020-6509-0821-6222 (Summary_001479) is the first
# cohort cert with an actual GOV.UK API counterpart. Worksheet PDF
@ -399,32 +475,6 @@ def _is_excluded_path(path: str) -> bool:
return True
if suffix == "window_transmission_details.data_source":
return True
# `roof_construction_type` is set by the Elmhurst mapper from
# `roof.roof_type` (e.g. "Pitched (slates/tiles), access to loft") and
# left None by the cohort hand-builts. The cascade in
# `heat_transmission.py:562` only dispatches on the "sloping ceiling"
# substring (RdSAP §3.8); none of the cohort certs lodge pitched-
# sloping-ceiling roofs, so both values produce identical cascade
# output. Exclude from the diff to avoid flagging informational drift.
if path.startswith("sap_building_parts[") and path.endswith(".roof_construction_type"):
return True
# `sap_ventilation.has_suspended_timber_floor` and
# `..._sealed` are set explicitly on the hand-builts (to mirror the
# cohort U985 worksheets' (12) infiltration values) but left None by
# the Elmhurst mapper because the Summary PDF doesn't surface floor-
# construction in a parseable form. When None, `cert_to_inputs._
# has_suspended_timber_floor_per_spec` infers the value mechanically
# from per-bp floor-construction data — producing the same cascade
# output the explicit-bool hand-built path produces for cohort 000477
# / 000516 (where the spec inference and the worksheet agree). Where
# the spec inference and worksheet disagree (cohort 000474, 000480,
# 000487, 000490), the chain SAP-pin tests fail separately — that's
# a known Elmhurst-worksheet-vs-RdSAP-10 §5 (12) divergence, not a
# mapper diff issue.
if path == "sap_ventilation.has_suspended_timber_floor":
return True
if path == "sap_ventilation.suspended_timber_floor_sealed":
return True
return False

View file

@ -1974,7 +1974,7 @@ def _leading_code(value: str) -> str:
# Elmhurst wall-type codes mapped to SAP10 wall_construction integers
# (matches the constants defined in domain.sap10_ml.rdsap_uvalues).
# (matches the constants defined in domain.ml.rdsap_uvalues).
_ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
"ST": 1, # Stone (granite/sandstone) — placeholder; sandstone vs granite
# ambiguity resolved downstream via walls[].description.
@ -1990,7 +1990,7 @@ _ELMHURST_WALL_CODE_TO_SAP10: Dict[str, int] = {
# Elmhurst wall-insulation-type codes mapped to the SAP10 integer enum
# documented at domain.sap10_ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY.
# documented at domain.ml.rdsap_uvalues.WALL_INSULATION_FILLED_CAVITY.
_ELMHURST_INSULATION_CODE_TO_SAP10: Dict[str, int] = {
"E": 1, # External wall insulation
"F": 2, # Filled cavity

View file

@ -6,7 +6,7 @@ Two boundary factories convert raw inputs to canonical members:
- `BuildingPartIdentifier.extension(n)` (site-notes / construction id)
P6.1 starts P6 (strict-type EpcPropertyData) from the documented pain
point in domain/sap10_calculator/worksheet/dimensions.py:74-82.
point in packages/domain/src/domain/sap/worksheet/dimensions.py:74-82.
"""
from __future__ import annotations

View file

@ -367,14 +367,7 @@ class TestFromRdSapSchema21_0_1:
assert result.inspection_date == date(2025, 4, 4)
def test_total_floor_area(self, result: EpcPropertyData) -> None:
# Slice 95 (commit f502db8c) changed the API mapper to compute
# `total_floor_area_m2` from the precise sum of per-bp
# `sap_floor_dimensions[*].total_floor_area` (here: 45.82, a
# single ground-floor dimension) rather than the lodged scalar
# (here: 55, an integer-rounded display value that doesn't
# match the per-bp geometry in this synthetic fixture). The
# worksheet uses per-bp sums and the mapper now mirrors that.
assert result.total_floor_area_m2 == 45.82
assert result.total_floor_area_m2 == 55.0
# --- property flags ---

View file

@ -9,7 +9,7 @@ Seven open questions resolved through a `/grill-with-docs` session before Sessio
| # | Question | Decision |
|---|---|---|
| 0 | Domain placement | **Option B** — new term **Calculated SAP10 Performance**, parallel to Effective Performance (ML) and Lodged Performance (gov register). Effective Performance is **not** retired now; a future ADR may promote Calculated to its current role once parity is confirmed. Process named **SAP10 Calculation**. |
| 1 | PCDB heat-pump COP source for Session A | **Stub-seam.** Define `PcdbLookup` Protocol, ship `NoOpPcdbLookup` returning None, fall back to Table 4a. Session C bundles a CSV PCDB extract under `domain/sap10_calculator/tables/pcdb/data/` and implements the lookup. |
| 1 | PCDB heat-pump COP source for Session A | **Stub-seam.** Define `PcdbLookup` Protocol, ship `NoOpPcdbLookup` returning None, fall back to Table 4a. Session C bundles a CSV PCDB extract under `docs/sap-spec/` and implements the lookup. |
| 2 | MCS installation factors | **Boolean input on calculator inputs, default `False`.** Plumbing in Session A; no behaviour change until the input is populated. Slice 18f (separate, tracked in HANDOFF §7-D0) lifts `mcs_installed_heat_pump` from gov API → `EpcPropertyData.MainHeatingDetail` so calculator can apply the factor on the ~1.5% of HP certs that carry it. |
| 3 | Thermal bridging | **Global y factor** (the path SAP 10.3 specifies for RdSAP-driven assessments). Per-junction Table R2 sum requires junction-count inputs the cert doesn't carry — not available on the RdSAP-driven flow. |
| 4 | Living-area fraction default | **RdSAP 10 Table 27** — direct lookup from `habitable_rooms_count`. Unambiguous, one-line table. |
@ -20,7 +20,7 @@ Seven open questions resolved through a `/grill-with-docs` session before Sessio
## Additional findings from the grill that change Session A scope
- **SAP rating formula belongs to RdSAP, not SAP 10.3.** RdSAP §19 ("RdSAP10-specific SAP rating equations referred to as EER") defines the SAP-score equation used for RdSAP-driven assessments. SAP 10.3 §13 defines the rating for new-build assessments. The cert's `energy_rating_current` was computed by RdSAP §19, so parity validation must compute against RdSAP §19, not SAP 10.3 §13.
- **RdSAP 10 (June 2025) cross-references SAP 10.2 (March 2025) for heating-system identification (Appendix A).** RdSAP was published before SAP 10.3 (Jan 2026). Until BRE updates RdSAP to reference SAP 10.3, the calculator's heating-identification logic reads SAP 10.2 Appendix A while everything else reads SAP 10.3. Keep both PDFs in `domain/sap10_calculator/docs/specs/`.
- **RdSAP 10 (June 2025) cross-references SAP 10.2 (March 2025) for heating-system identification (Appendix A).** RdSAP was published before SAP 10.3 (Jan 2026). Until BRE updates RdSAP to reference SAP 10.3, the calculator's heating-identification logic reads SAP 10.2 Appendix A while everything else reads SAP 10.3. Keep both PDFs in `docs/sap-spec/`.
- **RdSAP Table 29 ("Heating and hot water parameters") is a 20+-entry defaulting table** that the `cascade_defaults.py` module needs to encode. Current scope of `rdsap_uvalues.py` is U-values only; Table 29 extends the cascade pattern to cylinder insulation, primary-pipework insulation, boiler interlock, emitter temperature, underfloor-heating routing, solar-panel parameters, heat-network defaults. Adds ~1-2 hrs to Session A (effective Session A.5 if not split).
- **MCS field exists in gov API** but is dropped by the current mapper. Slice 18f (lift `mcs_installed_heat_pump` into `EpcPropertyData`) is a prerequisite for the MCS-factor path. ~30 min slice; can ship before Session A or in parallel.
@ -35,7 +35,7 @@ These cannot be closed by another tree feature. They require executing the calcu
## Decision
Build a deterministic **`Sap10Calculator`** that reads `EpcPropertyData` and emits the same outputs the certificate's BRE-approved assessor software emits: `sap_score`, `co2_emissions`, `peui_raw`, `peui_ucl`, `space_heating_kwh`, `hot_water_kwh`. Target the SAP 10.3 specification (DESNZ/BRE, 13-01-2026) and the RdSAP 10 specification (BRE, 10-06-2025), both held in `domain/sap10_calculator/docs/specs/`.
Build a deterministic **`Sap10Calculator`** that reads `EpcPropertyData` and emits the same outputs the certificate's BRE-approved assessor software emits: `sap_score`, `co2_emissions`, `peui_raw`, `peui_ucl`, `space_heating_kwh`, `hot_water_kwh`. Target the SAP 10.3 specification (DESNZ/BRE, 13-01-2026) and the RdSAP 10 specification (BRE, 10-06-2025), both held in `docs/sap-spec/`.
The ML model is **not deprecated**. It is repurposed as a **residual learner** against `actual_sap calculator_sap` (and similar deltas for the other five targets). Residual distributions are much narrower than the raw target distributions (calculator is within ~1 SAP-point on 95% of typical certs, per the working hypothesis), so the ML residual head should fit the corrections with far fewer features and reach the MAE ≤ 0.5 target.
@ -51,7 +51,7 @@ The ML model is **not deprecated**. It is repurposed as a **residual learner** a
A full SAP 10.3 worksheet plus the data-extraction rules from RdSAP 10 Appendix S. Module organisation:
```
domain/sap10_calculator/
packages/domain/src/domain/sap/
__init__.py # Sap10Calculator entry point + SapResult dataclass
worksheet/
dimensions.py # §1
@ -79,7 +79,7 @@ domain/sap10_calculator/
cascade_defaults.py # the RdSAP10 "assume-typical" rules (currently in rdsap_uvalues.py)
```
The existing `domain.sap10_ml.*` modules stay where they are during Session A; they continue serving the live ML pipeline. Session B promotes them into `domain.sap10_calculator.*` once parity is reached.
The existing `domain.ml.*` modules stay where they are during Session A; they continue serving the live ML pipeline. Session B promotes them into `domain.sap.*` once parity is reached.
## Sap10Calculator interface
@ -118,7 +118,7 @@ We do **not** retire the existing ML pipeline until both validations pass.
- **The six ML targets remain those from ADR-0007.** The residual head predicts deltas against the same six quantities.
- **ADR-0008's physics-as-feature pattern stays valid for the ML residual head.** The residual head probably needs fewer features, but the cascade U-value defaults and SAP efficiency lookups remain useful as feature builders if the calculator subset alone underfits.
- **`energy_rating_current` remains excluded from features.** Same leakage rule.
- **RdSAP 10 cert-extraction rules are now first-class in the codebase.** Rules that were ad-hoc in `transform.py` move into `domain.sap10_calculator.rdsap.appendix_s`.
- **RdSAP 10 cert-extraction rules are now first-class in the codebase.** Rules that were ad-hoc in `transform.py` move into `domain.sap.rdsap.appendix_s`.
- **The training parquet schema continues at v2.x.** A new column `calculator_sap_score` lands as a non-breaking addition once Session A reaches parity. The schema version bumps to v3.0.0 only when the residual targets replace the raw targets — a coordinated AutoGluon-repo deploy, per ADR-0008's cutover discipline.
## SAP 10.2 → SAP 10.3 implications
@ -147,7 +147,7 @@ Re-derivation work is bounded — a few hundred numbers across tables — and th
## Consequences
- A new top-level domain area `domain.sap10_calculator.*` is introduced; over Sessions B/C it absorbs `domain.sap10_ml.{envelope,demand,ecf,rdsap_uvalues,sap_efficiencies,ventilation}.py`. The ML transform stops shipping those as standalone features once the residual head takes over.
- A new top-level domain area `domain.sap.*` is introduced; over Sessions B/C it absorbs `domain.ml.{envelope,demand,ecf,rdsap_uvalues,sap_efficiencies,ventilation}.py`. The ML transform stops shipping those as standalone features once the residual head takes over.
- The codebase carries two SAP outputs: cert-reported `sap_score` (ground truth at training time) and calculator-emitted `sap_score` (ground truth at inference time for any RdSAP cert input). The product layer chooses; for "score this hypothetical post-retrofit state", calculator wins.
- The deterministic calculator is **version-bound to SAP 10.3.** A future SAP 10.4 is a calculator MAJOR bump and an ADR. The ML residual head is SAP-version-agnostic only insofar as the residual distribution it learns stays stationary; in practice a spec bump retrains the residual head.
- Spec PDFs live in `domain/sap10_calculator/docs/specs/` (this repo). The repo now carries the canonical reference for what the calculator computes. License: SAP 10.3 © Crown copyright 2026; RdSAP 10 © BRE — both are public-interest references for SAP-compliant software, included for traceability.
- Spec PDFs live in `docs/sap-spec/` (this repo). The repo now carries the canonical reference for what the calculator computes. License: SAP 10.3 © Crown copyright 2026; RdSAP 10 © BRE — both are public-interest references for SAP-compliant software, included for traceability.

View file

@ -4,7 +4,7 @@
## Why this ADR exists
ADR-0009 was written before a second-order problem in the validation corpus was visible: the 250k-cert training parquet spans **multiple SAP spec versions** (SAP 10.1 from 2019, SAP 10.2 pre- and post-14-March-2025 amendment), each of which was the active table when its certs were lodged. The prior session's `domain.sap10_calculator.tables.table_12_cert_calibration` layer was implicitly absorbing this version mixture into a single "best fit" price set ~1025 % lower than the SAP 10.2 (14-03-2025) spec — closer to the SAP 10.1 era prices. Every spec-correctness slice that touched a downstream component (HW cylinder zero-loss, gas standing charges, Table 12a fractional blending) registered as a regression on the parity probe because the cert-cal layer had been numerically calibrated against the buggy state of every other component.
ADR-0009 was written before a second-order problem in the validation corpus was visible: the 250k-cert training parquet spans **multiple SAP spec versions** (SAP 10.1 from 2019, SAP 10.2 pre- and post-14-March-2025 amendment), each of which was the active table when its certs were lodged. The prior session's `domain.sap.tables.table_12_cert_calibration` layer was implicitly absorbing this version mixture into a single "best fit" price set ~1025 % lower than the SAP 10.2 (14-03-2025) spec — closer to the SAP 10.1 era prices. Every spec-correctness slice that touched a downstream component (HW cylinder zero-loss, gas standing charges, Table 12a fractional blending) registered as a regression on the parity probe because the cert-cal layer had been numerically calibrated against the buggy state of every other component.
This ADR resolves four entangled decisions at once. They are coupled — none of them is the right call in isolation.
@ -14,7 +14,7 @@ This ADR resolves four entangled decisions at once. They are coupled — none of
ADR-0009 named SAP 10.3 (13-01-2026) as the calculator's target. No SAP-10.3-lodged certs exist in the corpus; assessor software has not migrated. Targeting SAP 10.3 produces a calculator whose output is verifiable against no cert. The active target is SAP 10.2 (14-03-2025 amendment) — both the document RdSAP 10 (10-06-2025) cross-references for heating-system identification, and the amendment that current assessor software is on.
`domain/sap10_calculator/tables/table_12.py` is re-labelled as SAP 10.2 (14-03-2025). Its CO2 factors are corrected to spec (0.210 kg/kWh mains gas, 0.136 kg/kWh standard electricity — the file currently has SAP 10.3 values 0.214 and 0.086). Prices already match SAP 10.2 (3.64 p mains gas, 16.49 p standard electricity, etc.) — the misleading "+25 % shift from SAP 10.2 to 10.3" comment is removed; the 13.19 p figure is from SAP 10.1, not SAP 10.2.
`packages/domain/src/domain/sap/tables/table_12.py` is re-labelled as SAP 10.2 (14-03-2025). Its CO2 factors are corrected to spec (0.210 kg/kWh mains gas, 0.136 kg/kWh standard electricity — the file currently has SAP 10.3 values 0.214 and 0.086). Prices already match SAP 10.2 (3.64 p mains gas, 16.49 p standard electricity, etc.) — the misleading "+25 % shift from SAP 10.2 to 10.3" comment is removed; the 13.19 p figure is from SAP 10.1, not SAP 10.2.
A future ADR retargets to SAP 10.3 once the cert corpus migrates (expected late 2026 or 2027 once BRE updates RdSAP to reference SAP 10.3).
@ -24,7 +24,7 @@ The cert-calibration table is bug-masking. Its prices are pre-March-2025 SAP val
This includes the `cert_calibration_e7_codes` extension that routes codes 191196 (direct-electric) and 691696 (room heaters) to off-peak rates — Table 12a is explicit that "other direct-acting electric heating" bills 100 % at the high rate on a 7-hour tariff. The S-B14 finding that motivated this hack is in §8 of the handover as a documented dead-end.
`domain.sap10_calculator.tables.table_12.unit_price_p_per_kwh` becomes the only price API. Parity probes are updated to use it.
`domain.sap.tables.table_12.unit_price_p_per_kwh` becomes the only price API. Parity probes are updated to use it.
### 3. Validation Cohort is filtered to a single spec-version window
@ -58,7 +58,7 @@ Each `domain/sap/worksheet/*.py` module must mirror the SAP 10.2 worksheet struc
- ADR-0009's "MAE ≤ 1.0 SAP-point on typical subset" success criterion is restated against the Validation Cohort (not the full corpus). The "typical subset" exclusions in ADR-0009 (sap_score ≤ 5, ≥ 100, multi-heating, conservatory, RIR) still apply on top of the cohort filter.
- The training parquet schema bumps when `inspection_date` is added — a non-breaking MINOR addition under [ADR-0008](0008-physics-as-feature.md)'s `Feature Schema Version` discipline.
- The handover document `domain/sap10_calculator/docs/HANDOVER_SYSTEMATIC_REVIEW.md` is rewritten in lockstep: §3 (diagnosis), §4 (scope), §7 (state-A-vs-state-B framing deleted), §7b (findings re-framed), §10 (fixture strategy), and a new §2.5 listing the five prerequisites.
- The handover document `docs/sap-spec/HANDOVER_SYSTEMATIC_REVIEW.md` is rewritten in lockstep: §3 (diagnosis), §4 (scope), §7 (state-A-vs-state-B framing deleted), §7b (findings re-framed), §10 (fixture strategy), and a new §2.5 listing the five prerequisites.
- Sessions A/B/C from ADR-0009 collapse into a single sequence: prerequisites land, then the section sweep runs against a clean probe with PCDB available.
## Considered alternatives
@ -84,9 +84,9 @@ The 000490 Elmhurst fixture had a recorded -12.5% cost gap (£706 vs £807 PDF)
### Consequences
- **`domain/sap10_calculator/tables/table_32.py`** ships the RdSAP10 unit prices + standing charges + Table 12 note (a) gating function. Table 12 keeps the CO2 + PEF columns.
- **`domain/sap10_calculator/tables/table_12a.py`** ships the high-rate-fraction lookups for off-peak split (Table 12a in SAP 10.2 PDF page 191 — RdSAP10 §19.1 cross-references this table directly). `Tariff.TEN_HOUR` carried for spec completeness even though RdSAP cert `meter_type` enum (1..5) has no 10-hour code.
- **`domain/sap10_calculator/worksheet/fuel_cost.py`** ships the §10a orchestrator producing `FuelCostResult` (32 fields, line refs (240)..(255)). `cert_to_inputs._fuel_cost` precompute wires it from cert state.
- **`packages/domain/src/domain/sap/tables/table_32.py`** ships the RdSAP10 unit prices + standing charges + Table 12 note (a) gating function. Table 12 keeps the CO2 + PEF columns.
- **`packages/domain/src/domain/sap/tables/table_12a.py`** ships the high-rate-fraction lookups for off-peak split (Table 12a in SAP 10.2 PDF page 191 — RdSAP10 §19.1 cross-references this table directly). `Tariff.TEN_HOUR` carried for spec completeness even though RdSAP cert `meter_type` enum (1..5) has no 10-hour code.
- **`packages/domain/src/domain/sap/worksheet/fuel_cost.py`** ships the §10a orchestrator producing `FuelCostResult` (32 fields, line refs (240)..(255)). `cert_to_inputs._fuel_cost` precompute wires it from cert state.
- The 000474 Elmhurst fixture cost residual widened from -0.6% to +10.7% (SAP rating ceiling loosened 2 → 4) because the pre-amendment wrong-table-but-cancels-kWh accidentally compensated for upstream §4 HW kWh + Appendix L lighting overestimates. **§4 HW worksheet tightening is the next ticket** — see project memory `project_section_4_hw_next_ticket`. Ceiling drops back to 2 (or below) when that lands.
- Golden corpus SAP tolerance widened ±7 → ±11 per the Validation Cohort discipline (oil unit price +55% from Table 12 → Table 32 moves oil-heated golden certs whose lodged SAP scores pre-date Table 32).
@ -120,12 +120,12 @@ Two engine bugs surfaced during the wire-up:
- **000490 SAP integer + fuel cost tests xfail** (strict). Appendix L closure is spec-faithful (lighting kWh 614 → 171 matches U985 (232)=171.4217 to abs=1e-4), but the cost residual widens from -4.7% to -12.9% and SAP delta widens 3 → 6. The remaining residual is from other broken components on this fixture — primary suspects: fuel pricing for the pre-2025-07-01 cohort (Table 32 lodge-date snapshot semantics), main heating fuel +2.5% overshoot, Table D1/D2/D3 Ecodesign corrections, Appendix N heat-pump cascade. Per `feedback-e2e-validation-philosophy` memory: don't widen, hunt. Tests re-enable when each next component closes.
- **Golden fixture `_PE_TOLERANCE_KWH_PER_M2` widened 30 → 35** to absorb the elec-PEF × lighting-Δ contribution (~4 kWh/m²) on the non-Elmhurst cohort. Pre-Appendix-L baseline residuals already sat near -28 kWh/m² from unrelated components on those certs. Tightens back when the dominant remaining components close.
- **Per-component worksheet-level pins land**: `result.lighting_kwh_per_yr == U985 (232)` at abs=1e-4 for the 2 e2e fixtures, and `InternalGainsResult.lighting_kwh_per_yr == U985 (232)` at abs=1e-4 for all 6 §5 fixtures. New per-fixture constant `LINE_232_LIGHTING_KWH_PER_YR` pins each lodged value.
- **`predicted_lighting_kwh` kept** in `domain/ml/demand.py` with a deprecation note. Still used by `domain.sap10_ml.ecf.energy_cost_factor` and `domain.sap10_ml.transform.transform_to_predictions` — both legacy ML pre-SAP-rewrite call sites; rip when those migrate.
- **`predicted_lighting_kwh` kept** in `domain/ml/demand.py` with a deprecation note. Still used by `domain.ml.ecf.energy_cost_factor` and `domain.ml.transform.transform_to_predictions` — both legacy ML pre-SAP-rewrite call sites; rip when those migrate.
### Deferred work (named in Appendix L slice 3)
- **000490 / cohort SAP-integer closure (residual hunt).** Next ticket. Suspects above. Driven by user's next batch of test fixtures (battle-testing the engine) → emergent residual identification.
- **`predicted_lighting_kwh` deletion.** Future cleanup ticket once `domain.sap10_ml.ecf` + `domain.sap10_ml.transform` are off the legacy heuristic.
- **`predicted_lighting_kwh` deletion.** Future cleanup ticket once `domain.ml.ecf` + `domain.ml.transform` are off the legacy heuristic.
- **RdSAP10 → API integration test.** End-state e2e harness: RdSAP API response → `cert_to_inputs``calculate_sap_from_inputs` → SAP integer = lodged integer. Once enough cohort fixtures pass delta=0 on isolated components.
## Amendment — Cohort residual hunt + SAP 10.2 rating constants (2026-05-22)

View file

@ -41,14 +41,14 @@ plumbing fails loudly.
### Public API (the only thing you need from the SAP module)
```python
from domain.sap10_calculator.rdsap.cert_to_inputs import (
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs, # Rating cascade
cert_to_demand_inputs, # Demand cascade
local_climate_for_cert,
environmental_section_from_cert,
primary_energy_section_from_cert,
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs, SapResult
from domain.sap.calculator import calculate_sap_from_inputs, SapResult
```
See `SAP_CALCULATOR.md` §2 for the recommended `dwelling_outputs(epc)`
@ -101,8 +101,8 @@ side is what you need to map out:
schema versions (SAP-Schema-18/19, RdSAP-Schema-18) to
`EpcPropertyData` lives there.
3. **Is SAP scoring already wired to the API?** Search the backend for
imports of `domain.sap10_calculator.rdsap.cert_to_inputs` or
`domain.sap10_calculator.calculator`. If it's not yet wired, the integration test
imports of `domain.sap.rdsap.cert_to_inputs` or
`domain.sap.calculator`. If it's not yet wired, the integration test
is a forcing function for wiring it.
4. **What's the response shape?** The 4 outputs above are what the EPC
publishes; the API may already expose them, or may expose a wider
@ -131,11 +131,11 @@ expanding.
| File | Why |
|---|---|
| [`domain/sap10_calculator/docs/SAP_CALCULATOR.md`](./SAP_CALCULATOR.md) | Module API + architecture (you're heading there) |
| [`domain/sap10_calculator/calculator.py`](../../domain/sap10_calculator/calculator.py) | `SapResult` fields you'll assert against |
| [`domain/sap10_calculator/rdsap/cert_to_inputs.py`](../../domain/sap10_calculator/rdsap/cert_to_inputs.py) | The 3 public entry points + the section helpers |
| [`domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000474.py`](../../domain/sap10_calculator/worksheet/tests/_elmhurst_worksheet_000474.py) | A reference fixture — `build_epc()` shows the EpcPropertyData shape |
| [`domain/sap10_calculator/worksheet/tests/test_e2e_elmhurst_sap_score.py`](../../domain/sap10_calculator/worksheet/tests/test_e2e_elmhurst_sap_score.py) | The current e2e test pattern — model your integration test on this |
| [`docs/sap-spec/SAP_CALCULATOR.md`](./SAP_CALCULATOR.md) | Module API + architecture (you're heading there) |
| [`packages/domain/src/domain/sap/calculator.py`](../../packages/domain/src/domain/sap/calculator.py) | `SapResult` fields you'll assert against |
| [`packages/domain/src/domain/sap/rdsap/cert_to_inputs.py`](../../packages/domain/src/domain/sap/rdsap/cert_to_inputs.py) | The 3 public entry points + the section helpers |
| [`packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py`](../../packages/domain/src/domain/sap/worksheet/tests/_elmhurst_worksheet_000474.py) | A reference fixture — `build_epc()` shows the EpcPropertyData shape |
| [`packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py`](../../packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py) | The current e2e test pattern — model your integration test on this |
| `backend/` (explore) | API entry points |
| [`datatypes/epc/domain/mapper.py`](../../datatypes/epc/domain/mapper.py) | Schema → EpcPropertyData mappers |
@ -146,18 +146,18 @@ expanding.
```bash
# Confirm SAP calculator is still 930/930 green
python -m pytest \
domain/sap10_calculator/worksheet/tests/test_section_cascade_pins.py \
domain/sap10_calculator/worksheet/tests/test_e2e_elmhurst_sap_score.py \
packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py \
packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py \
--no-cov --no-header --tb=no -q
# Show the 4 EPC outputs for fixture 000474
cd packages/domain/src && python -c "
from domain.sap10_calculator.rdsap.cert_to_inputs import (
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs, local_climate_for_cert,
environmental_section_from_cert, primary_energy_section_from_cert,
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.worksheet.tests import _elmhurst_worksheet_000474 as w
from domain.sap.calculator import calculate_sap_from_inputs
from domain.sap.worksheet.tests import _elmhurst_worksheet_000474 as w
epc = w.build_epc()
pc = local_climate_for_cert(epc)
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
@ -183,7 +183,7 @@ postcode-conditions EI value, not what the EPC publishes.
- **Extending the SAP calculator.** It's closed at the EPC-output layer.
If you find an additional cert-shape variation that breaks the
calculator, capture it as a new conformance fixture (see
`domain/sap10_calculator/README.md`) — don't paper over it in
`packages/domain/src/domain/sap/README.md`) — don't paper over it in
the integration test.
- **BEDF fuel pricing.** The Fuel Bill on the EPC uses postcode-specific
BEDF prices (PCDB Table 200), which are deferred. The 4 outputs above

View file

@ -44,7 +44,7 @@ Layer 4: API mapper cascade SAP = worksheet SAP at 1e-4 (production goal)
The big breakthrough: implementing the RdSAP 10 §5 (12) spec rule
(`Floor infiltration (suspended timber ground floor only)` — page 29
of `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`) revealed a
of `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf`) revealed a
series of API-mapper coverage gaps that all needed fixing for the
spec rule's premise to be met. Each slice closed one gap:
@ -111,7 +111,7 @@ from datatypes.epc.domain.mapper import EpcPropertyDataMapper
import json, dataclasses
from pathlib import Path
api = json.loads(Path('/workspaces/model/domain/sap10_calculator/rdsap/tests/fixtures/golden/0535-9020-6509-0821-6222.json').read_text())
api = json.loads(Path('/workspaces/model/packages/domain/src/domain/sap/rdsap/tests/fixtures/golden/0535-9020-6509-0821-6222.json').read_text())
api_mapped = EpcPropertyDataMapper.from_api_response(api)
pages = _summary_pdf_to_textract_style_pages(Path('/workspaces/model/backend/documents_parser/tests/fixtures/Summary_001479.pdf'))
sn = ElmhurstSiteNotesExtractor(pages).extract()
@ -234,7 +234,7 @@ override field, (c) wait for more cert pairs to confirm pattern.
## Cached artefacts
- `domain/sap10_calculator/rdsap/tests/fixtures/golden/0535-
- `packages/domain/src/domain/sap/rdsap/tests/fixtures/golden/0535-
9020-6509-0821-6222.json` — API JSON for cert 001479 (RdSAP-Schema-
21.0.1).
- `backend/documents_parser/tests/fixtures/Summary_001479.pdf`
@ -262,7 +262,7 @@ f863598d Slice 85: bulk-update cohort 000516 hand-built for Cat A diff parity
```
Earlier slice context (71-86 closed cohort Layer 2) is in the prior
handover at commit `86eff23f` (`domain/sap10_calculator/docs/NEXT_AGENT_PROMPT.md`
handover at commit `86eff23f` (`docs/sap-spec/NEXT_AGENT_PROMPT.md`
before this rewrite).
## First action
@ -273,8 +273,8 @@ before this rewrite).
```bash
PYTHONPATH=/workspaces/model:/workspaces/model/packages/domain/src \
python -m pytest backend/documents_parser/tests/test_summary_pdf_mapper_chain.py \
domain/sap10_calculator/worksheet/tests/test_e2e_elmhurst_sap_score.py \
domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py \
packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py \
packages/domain/src/domain/sap/rdsap/tests/test_golden_fixtures.py \
--no-cov -q
```
Expect **99 passed / 19 failed**. All 19 failures pre-existing:

View file

@ -10,21 +10,21 @@ Current Carbon, Current Primary Energy, and Fuel Bill).
**Current state: 930/930 pins green** (768 rating + 90 demand + 72 e2e).
This document is the public API + architecture reference. For fixture
authoring see [`domain/sap10_calculator/README.md`](../../domain/sap10_calculator/README.md).
authoring see [`packages/domain/src/domain/sap/README.md`](../../packages/domain/src/domain/sap/README.md).
---
## 1. Public API
Three entry points, all in `domain.sap10_calculator.rdsap.cert_to_inputs`:
Three entry points, all in `domain.sap.rdsap.cert_to_inputs`:
```python
from domain.sap10_calculator.rdsap.cert_to_inputs import (
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs, # SAP rating + EI rating (UK-avg climate)
cert_to_demand_inputs, # Current Carbon + Current PE (postcode climate)
local_climate_for_cert, # postcode → PostcodeClimate (None on miss)
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs, SapResult
from domain.sap.calculator import calculate_sap_from_inputs, SapResult
```
### 1.1 Rating cascade — `cert_to_inputs(epc)`
@ -93,11 +93,11 @@ upgrade wall insulation), re-run, observe the delta. The shape:
```python
import dataclasses
from domain.sap10_calculator.rdsap.cert_to_inputs import (
from domain.sap.rdsap.cert_to_inputs import (
cert_to_inputs, local_climate_for_cert,
environmental_section_from_cert, primary_energy_section_from_cert,
)
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap.calculator import calculate_sap_from_inputs
def dwelling_outputs(epc):
"""The 4 EPC-facing outputs for any cert.
@ -165,7 +165,7 @@ Two cascades stacked on a shared physics core:
Climate is the only difference between the two cascades. Internally, the
climate is plumbed through as either an `int` region index (0..21) or a
`PostcodeClimate` instance (PCDB Table 172). Four functions in
`domain.sap10_calculator.climate.appendix_u` dispatch on `isinstance`:
`domain.sap.climate.appendix_u` dispatch on `isinstance`:
`external_temperature_c`, `wind_speed_m_per_s`,
`horizontal_solar_irradiance_w_per_m2`, plus `_latitude_deg` in
`worksheet/solar_gains.py`.
@ -192,7 +192,7 @@ on `CalculatorInputs`.
## 4. File map
```
domain/sap10_calculator/
packages/domain/src/domain/sap/
├── calculator.py # Top-level orchestrator (CalculatorInputs → SapResult)
├── README.md # Fixture authoring cookbook
├── rdsap/
@ -228,7 +228,7 @@ domain/sap10_calculator/
├── parser.py # PCDB row parsers
└── (other PCDB tables)
domain/sap10_calculator/docs/specs/
docs/sap-spec/
├── sap-10-2-full-specification-2025-03-14.pdf # SAP 10.2 spec
├── RdSAP 10 Specification 10-06-2025.pdf # RdSAP 10 spec
├── pcdb10.dat # PCDB raw data (Table 172 + others)
@ -288,12 +288,12 @@ monthly_infiltration_ach 6/6
```bash
# Full SAP calculator suite (cascade pins + e2e + helpers)
python -m pytest domain/sap10_calculator/ --no-cov
python -m pytest packages/domain/src/domain/sap/ --no-cov
# Cascade pins only (the conformance suite)
python -m pytest \
domain/sap10_calculator/worksheet/tests/test_section_cascade_pins.py \
domain/sap10_calculator/worksheet/tests/test_e2e_elmhurst_sap_score.py \
packages/domain/src/domain/sap/worksheet/tests/test_section_cascade_pins.py \
packages/domain/src/domain/sap/worksheet/tests/test_e2e_elmhurst_sap_score.py \
--no-cov --no-header --tb=no -q
```
@ -316,7 +316,7 @@ These are non-negotiable per `[[feedback-zero-error-strict]]` /
## 6. Adding a new conformance fixture
See [`domain/sap10_calculator/README.md#adding-a-new-elmhurst-conformance-fixture`](../../domain/sap10_calculator/README.md#adding-a-new-elmhurst-conformance-fixture)
See [`packages/domain/src/domain/sap/README.md#adding-a-new-elmhurst-conformance-fixture`](../../packages/domain/src/domain/sap/README.md#adding-a-new-elmhurst-conformance-fixture)
for the step-by-step cookbook. Summary:
1. Drop a fixture module at `worksheet/tests/_elmhurst_worksheet_NNNNNN.py`
@ -370,6 +370,6 @@ RdSAP 10 (10-06-2025):
Table 12a (standing/off-peak) p.191
PCDB10:
Table 105 (gas/oil boilers) domain/sap10_calculator/docs/specs/pcdb_table_105_...
Table 172 (postcode-district weather) domain/sap10_calculator/tables/pcdb/data/pcdb10.dat
Table 105 (gas/oil boilers) docs/sap-spec/pcdb_table_105_...
Table 172 (postcode-district weather) docs/sap-spec/pcdb10.dat
```

16
packages/README.md Normal file
View file

@ -0,0 +1,16 @@
# Shared packages
Workspace packages consumed by `services/*`. Each package is its own Python distribution with its own `pyproject.toml`; services import via the workspace dependency mechanism (`{ workspace = true }`).
| Package | Purpose |
|---------|---------|
| [`domain/`](./domain/) | Shared domain types — `Property`, `BaselinePerformance`, `Plan`, `Scenario`, `EpcPropertyData`, etc. No persistence, no IO, no business logic. |
| [`repos/`](./repos/) | Persistence layer — one repo per aggregate. Owns the SQL. Depends on `domain`. |
| [`fetchers/`](./fetchers/) | External API clients (gov EPC, Ofgem, Google Solar, etc.). Depend on `domain` for response shapes. |
| [`utils/`](./utils/) | Cross-cutting infra — logging, S3, CloudWatch URL builders, SQS task helpers. |
## Adding a new shared package
Only when a real second consumer materialises. Don't pre-shatter (`repos-epc`, `repos-property`, ...) — split when a deployment needs to drop a dep, not before.
See [`../ara_backend_design.md`](../ara_backend_design.md) §11 for the broader monorepo layout and [`../CONTEXT.md`](../CONTEXT.md) for the domain glossary that names the types living in `domain/`.

30
packages/domain/README.md Normal file
View file

@ -0,0 +1,30 @@
# domna-domain
Shared domain types — `Property`, `Properties`, `BaselinePerformance`, `Plan`, `PlanPhase`, `Scenario`, `ScenarioPhase`, `ScenarioSnapshot`, `Recommendation`, `OptimisedPackage`, `EpcPropertyData`, etc.
**Boundary**: types only. No persistence, no IO, no business logic. Other packages and services depend on `domna-domain`; this package depends on nothing internal.
Domain definitions live in [`../../CONTEXT.md`](../../CONTEXT.md). New types added here must match the glossary terms.
## Layout
```
src/domain/
├── __init__.py
├── property.py # Property, Properties, PropertyIdentity
├── site_notes.py
├── landlord_overrides.py
├── baseline_performance.py # lodged + effective pair (ADR-0004)
├── plan.py # Plan, PlanPhase, OptimisedPackage
├── scenario.py # Scenario, ScenarioPhase, ScenarioSnapshot (ADR-0005)
├── recommendation.py
├── geospatial.py
├── solar.py
├── anomaly_flags.py
└── ml/
├── __init__.py
├── transform.py # EpcMlTransform (versioned per §8.3)
└── schema.py
```
When `datatypes/epc/domain/` folds in, the EPC schema types move under `src/domain/epc/`.

View file

@ -0,0 +1,16 @@
[project]
name = "domna-domain"
version = "0.1.0"
description = "Shared domain types for the Ara modelling pipeline and sibling Domna services."
requires-python = ">=3.11"
dependencies = [
"pandas>=2.0",
"pandas-stubs",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/domain"]

View file

@ -0,0 +1,4 @@
"""Shared domain types for the Ara modelling pipeline and sibling Domna services.
No persistence, no IO, no business logic. See README.md for layout.
"""

View file

@ -232,11 +232,11 @@ def predicted_lighting_kwh(
Missing counts treated as zero.
DEPRECATED for SAP rating use. The spec-faithful Appendix L L1-L11
cascade is in `domain.sap10_calculator.worksheet.internal_gains.annual_lighting_kwh`
cascade is in `domain.sap.worksheet.internal_gains.annual_lighting_kwh`
and is what `cert_to_inputs` now plumbs into `inputs.lighting_kwh_per_yr`.
This heuristic over-counts ~3× on the Elmhurst cohort (528 vs 140 kWh
on 000474). Kept only for `domain.sap10_ml.ecf.energy_cost_factor` and
`domain.sap10_ml.transform.transform_to_predictions` legacy ML predictor
on 000474). Kept only for `domain.ml.ecf.energy_cost_factor` and
`domain.ml.transform.transform_to_predictions` legacy ML predictor
callsites that pre-date the SAP rewrite. Rip when those migrate.
See ADR-0010 amendment "Appendix L lighting (2026-05-22)".
"""

View file

@ -15,7 +15,7 @@ from __future__ import annotations
from math import log10
from typing import Final, Optional
from domain.sap10_ml.sap_efficiencies import fuel_unit_price_p_per_kwh
from domain.ml.sap_efficiencies import fuel_unit_price_p_per_kwh
# SAP10 deflator applied to total cost before the rating equation (Table 32).

View file

@ -15,7 +15,7 @@ from typing import Any, Optional
from datatypes.epc.domain.epc_property_data import SapBuildingPart
from domain.sap10_ml.rdsap_uvalues import (
from domain.ml.rdsap_uvalues import (
Country,
WALL_CAVITY,
WALL_UNKNOWN,

View file

@ -2,7 +2,7 @@
import pytest
from domain.sap10_ml.demand import (
from domain.ml.demand import (
predicted_hot_water_kwh,
predicted_lighting_kwh,
predicted_space_heating_kwh,

View file

@ -5,7 +5,7 @@ from math import log10
import pytest
from domain.sap10_ml.ecf import (
from domain.ml.ecf import (
predicted_ecf,
predicted_log10_ecf,
predicted_pv_generation_kwh,

View file

@ -6,8 +6,8 @@ test cases stay close to the shape transform.py sees on a real cert.
import pytest
from domain.sap10_ml.envelope import envelope_heat_loss_w_per_k
from domain.sap10_ml.tests._fixtures import make_building_part, make_floor_dimension
from domain.ml.envelope import envelope_heat_loss_w_per_k
from domain.ml.tests._fixtures import make_building_part, make_floor_dimension
def test_envelope_single_storey_no_windows_no_doors_age_g_cavity_returns_expected_w_per_k() -> None:

View file

@ -20,7 +20,7 @@ from typing import Optional
import pytest
from domain.sap10_ml.rdsap_uvalues import (
from domain.ml.rdsap_uvalues import (
Country,
WALL_CAVITY,
WALL_INSULATION_FILLED_CAVITY,
@ -210,7 +210,7 @@ def test_u_wall_filled_cavity_england_age_band_e_returns_table6_value() -> None:
# Arrange — RdSAP 10 Table 6 (England) row "Filled cavity", age band E
# (1967-1975) -> 0.7 W/m^2K. The cert records this as the triple
# (wall_construction=4 cavity, wall_insulation_type=2 filled,
# wall_insulation_thickness="NI"). Spec: domain/sap10_calculator/docs/specs/rdsap-10-
# wall_insulation_thickness="NI"). Spec: docs/sap-spec/rdsap-10-
# specification-2025-06-10.pdf page 33.
# Act

View file

@ -11,7 +11,7 @@ Helpers never raise on missing codes; they fall back to typical-fuel values.
import pytest
from domain.sap10_ml.sap_efficiencies import (
from domain.ml.sap_efficiencies import (
fuel_unit_price_p_per_kwh,
seasonal_efficiency,
water_heating_efficiency,

View file

@ -8,8 +8,8 @@ from datatypes.epc.domain.epc_property_data import (
SapRoomInRoof,
WindowTransmissionDetails,
)
from domain.sap10_ml.schema import ColumnSpec, TransformSchema
from domain.sap10_ml.tests._fixtures import (
from domain.ml.schema import ColumnSpec, TransformSchema
from domain.ml.tests._fixtures import (
make_building_part,
make_floor_dimension,
make_main_heating_detail,
@ -18,7 +18,7 @@ from domain.sap10_ml.tests._fixtures import (
make_sap_heating,
make_window,
)
from domain.sap10_ml.transform import EpcMlTransform
from domain.ml.transform import EpcMlTransform
_EXPECTED_TARGET_DTYPES: dict[str, type] = {
@ -1269,7 +1269,7 @@ def test_schema_advertises_envelope_heat_loss_feature() -> None:
def test_to_row_emits_positive_envelope_heat_loss_for_sap10_epc() -> None:
# Arrange
from domain.sap10_ml.tests._fixtures import make_building_part, make_floor_dimension
from domain.ml.tests._fixtures import make_building_part, make_floor_dimension
main = make_building_part(
identifier=BuildingPartIdentifier.MAIN,

View file

@ -9,7 +9,7 @@ fixed m³/h additions; draught-proofed windows reduce the structural baseline.
import pytest
from domain.sap10_ml.ventilation import ventilation_heat_loss_w_per_k
from domain.ml.ventilation import ventilation_heat_loss_w_per_k
def test_ventilation_bare_masonry_no_openings_returns_structural_baseline_only() -> None:

View file

@ -24,22 +24,22 @@ from datatypes.epc.domain.epc_property_data import (
SapHeating,
SapWindow,
)
from domain.sap10_ml.demand import (
from domain.ml.demand import (
predicted_hot_water_kwh,
predicted_lighting_kwh,
predicted_space_heating_kwh,
)
from domain.sap10_ml.ecf import (
from domain.ml.ecf import (
predicted_ecf,
predicted_log10_ecf,
predicted_pv_generation_kwh,
predicted_total_fuel_cost_gbp,
)
from domain.sap10_ml.envelope import envelope_heat_loss_w_per_k
from domain.sap10_ml.ventilation import ventilation_heat_loss_w_per_k
from domain.sap10_ml.sap_efficiencies import seasonal_efficiency, water_heating_efficiency
from domain.sap10_ml.schema import ColumnSpec, TransformSchema
from domain.sap10_ml.ucl import apply_ucl_correction
from domain.ml.envelope import envelope_heat_loss_w_per_k
from domain.ml.ventilation import ventilation_heat_loss_w_per_k
from domain.ml.sap_efficiencies import seasonal_efficiency, water_heating_efficiency
from domain.ml.schema import ColumnSpec, TransformSchema
from domain.ml.ucl import apply_ucl_correction
# SAP10 orientation codes: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW.

View file

@ -19,9 +19,9 @@ sap/
└── tables/ # Table U2 wind, Table 6 walls, Table 21 bridging, …
```
Spec references: `domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf` (SAP 10.2, the active target per ADR-0010), `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf` (RdSAP cascade). Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root — loaded by `_xlsx_loader.py`.
Spec references: `docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf` (SAP 10.2, the active target per ADR-0010), `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf` (RdSAP cascade). Canonical worked example: `2026-05-19-17-18 RdSap10Worksheet.xlsx` at repo root — loaded by `_xlsx_loader.py`.
**Validation contract.** Per `[[feedback-zero-error-strict]]` the 6 Elmhurst U985 fixtures are deterministic test vectors: every line ref of every output must pin against the U985 PDF at `abs=1e-4`. See `worksheet/tests/test_section_cascade_pins.py` (per-section line refs, 768 rating + 90 demand pins) and `test_e2e_elmhurst_sap_score.py::test_sap_result_pin` (top-level SapResult fields). Tolerances are never widened. **Current state: 930/930 pins green.** The public API + architecture overview lives in `domain/sap10_calculator/docs/SAP_CALCULATOR.md`.
**Validation contract.** Per `[[feedback-zero-error-strict]]` the 6 Elmhurst U985 fixtures are deterministic test vectors: every line ref of every output must pin against the U985 PDF at `abs=1e-4`. See `worksheet/tests/test_section_cascade_pins.py` (per-section line refs, 768 rating + 90 demand pins) and `test_e2e_elmhurst_sap_score.py::test_sap_result_pin` (top-level SapResult fields). Tolerances are never widened. **Current state: 930/930 pins green.** The public API + architecture overview lives in `docs/sap-spec/SAP_CALCULATOR.md`.
## Adding a new Elmhurst conformance fixture
@ -50,7 +50,7 @@ The assessor exports two PDFs from Elmhurst's RdSAP tool:
5. **Run the conformance tests**:
```
python -m pytest domain/sap10_calculator/worksheet/tests/ \
python -m pytest packages/domain/src/domain/sap/worksheet/tests/ \
-k elmhurst --no-cov -v
```
Each fixture appears 3× (one parametrize per section), pytest id = the cert ref number.
@ -110,7 +110,7 @@ So a 2.91 m upper-storey internal height appears on the worksheet as 3.16 m. Mir
- Simplified Type 1 (RR lodged with only `floor_area`) still works via the spec's `A_RR = 12.5 × √(A_RR_floor/1.5)` formula at `u_rr_default_all_elements` (Table 18 col 4). Detailed lodgement supersedes when present.
### Party wall U mapping
`party_wall_construction` integer codes resolve via `domain.sap10_ml.rdsap_uvalues.u_party_wall`:
`party_wall_construction` integer codes resolve via `domain.ml.rdsap_uvalues.u_party_wall`:
- `0` (Unknown / "Unable to determine") → 0.25 W/m²K
- `1` (Stone granite) / `3` (Solid brick) / `5` (Timber frame) / `6` (System built) → 0.0
- `4` (Cavity, unfilled) → 0.5

View file

@ -3,7 +3,7 @@
Drives the 12-month heat-balance loop from a typed `CalculatorInputs`
aggregate and emits a typed `SapResult`. This module is the physics
assembly only the RdSAP certinputs mapping lives in
`domain.sap10_calculator.rdsap.cert_to_inputs`. Splitting the two keeps orchestration
`domain.sap.rdsap.cert_to_inputs`. Splitting the two keeps orchestration
testable against synthetic inputs without dragging in cert-shape
assumptions.
@ -44,15 +44,15 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Final, Optional, TYPE_CHECKING
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap.climate.appendix_u import external_temperature_c
if TYPE_CHECKING:
from datatypes.epc.domain.epc_property_data import EpcPropertyData
from domain.sap10_calculator.worksheet.dimensions import Dimensions
from domain.sap10_calculator.worksheet.energy_requirements import EnergyRequirementsResult
from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult
from domain.sap10_calculator.worksheet.heat_transmission import HeatTransmission
from domain.sap10_calculator.worksheet.rating import (
from domain.sap.worksheet.dimensions import Dimensions
from domain.sap.worksheet.energy_requirements import EnergyRequirementsResult
from domain.sap.worksheet.fuel_cost import FuelCostResult
from domain.sap.worksheet.heat_transmission import HeatTransmission
from domain.sap.worksheet.rating import (
ECF_LOG_THRESHOLD,
ENERGY_COST_DEFLATOR,
FLOOR_AREA_OFFSET_M2,
@ -621,6 +621,6 @@ class Sap10Calculator:
"""
def calculate(self, epc: "EpcPropertyData") -> SapResult:
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
from domain.sap.rdsap.cert_to_inputs import cert_to_inputs
return calculate_sap_from_inputs(cert_to_inputs(epc))

View file

@ -20,7 +20,7 @@ from __future__ import annotations
from typing import Final
from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate
from domain.sap.tables.pcdb.postcode_weather import PostcodeClimate
# Table U1 — Mean external temperature (°C), 22 regions × 12 months.

View file

@ -8,7 +8,7 @@ global solar irradiance on a horizontal plane and monthly solar declination.
import pytest
from domain.sap10_calculator.climate.appendix_u import (
from domain.sap.climate.appendix_u import (
external_temperature_c,
horizontal_solar_irradiance_w_per_m2,
solar_declination_deg,

View file

@ -61,88 +61,88 @@ from datatypes.epc.domain.epc_property_data import (
SapWindow,
)
from domain.sap10_ml.demand import predicted_hot_water_kwh
from domain.sap10_ml.rdsap_uvalues import Country, u_floor
from domain.sap10_ml.sap_efficiencies import (
from domain.ml.demand import predicted_hot_water_kwh
from domain.ml.rdsap_uvalues import Country, u_floor
from domain.ml.sap_efficiencies import (
seasonal_efficiency,
water_heating_efficiency as _legacy_water_heating_efficiency,
)
from domain.sap10_calculator.calculator import CalculatorInputs
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
from domain.sap10_calculator.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
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.pcdb.postcode_weather import (
PostcodeClimate,
postcode_climate,
)
from domain.sap10_calculator.tables.table_12 import (
from domain.sap.tables.table_12 import (
co2_monthly_factors_kg_per_kwh,
co2_factor_kg_per_kwh,
pe_monthly_factors_kwh_per_kwh,
primary_energy_factor,
unit_price_p_per_kwh,
)
from domain.sap10_calculator.tables.table_12a import (
from domain.sap.tables.table_12a import (
Tariff,
tariff_from_meter_type,
)
from domain.sap10_calculator.tables.table_32 import (
from domain.sap.tables.table_32 import (
additional_standing_charges_gbp,
unit_price_p_per_kwh as table_32_unit_price_p_per_kwh,
)
from domain.sap10_calculator.worksheet.fuel_cost import FuelCostResult, fuel_cost
from domain.sap10_calculator.worksheet.rating import (
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,
)
from domain.sap10_calculator.worksheet.dimensions import dimensions_from_cert
from domain.sap10_calculator.worksheet.internal_gains import (
from domain.sap.worksheet.dimensions import dimensions_from_cert
from domain.sap.worksheet.internal_gains import (
InternalGainsResult,
OvershadingCategory,
internal_gains_from_cert,
)
from domain.sap10_calculator.worksheet.solar_gains import (
from domain.sap.worksheet.solar_gains import (
ORIENTATION_BY_SAP10_CODE,
RoofWindowInput,
SolarGainsResult,
solar_gains_from_cert,
surface_solar_flux_w_per_m2,
)
from domain.sap10_calculator.worksheet.heat_transmission import (
from domain.sap.worksheet.heat_transmission import (
DwellingExposure,
HeatTransmission,
_AREA_ROUND_DP,
_round_half_up,
heat_transmission_from_cert,
)
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
from domain.sap.climate.appendix_u import external_temperature_c
from domain.sap.worksheet.mean_internal_temperature import (
MeanInternalTemperatureResult,
mean_internal_temperature_monthly,
)
from domain.sap10_calculator.worksheet.energy_requirements import (
from domain.sap.worksheet.energy_requirements import (
EnergyRequirementsResult,
space_heating_fuel_monthly_kwh,
)
from domain.sap10_calculator.worksheet.fabric_energy_efficiency import (
from domain.sap.worksheet.fabric_energy_efficiency import (
fabric_energy_efficiency_kwh_per_m2_yr,
)
from domain.sap10_calculator.worksheet.space_cooling import (
from domain.sap.worksheet.space_cooling import (
SpaceCoolingResult,
space_cooling_monthly_kwh,
)
from domain.sap10_calculator.worksheet.space_heating import (
from domain.sap.worksheet.space_heating import (
SpaceHeatingResult,
space_heating_monthly_kwh,
)
from domain.sap10_calculator.worksheet.ventilation import (
from domain.sap.worksheet.ventilation import (
MechanicalVentilationKind,
VentilationResult,
ventilation_from_inputs,
)
from domain.sap10_calculator.worksheet.water_heating import (
from domain.sap.worksheet.water_heating import (
TABLE_J1_TCOLD_FROM_MAINS_C,
WaterHeatingResult,
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
@ -805,7 +805,7 @@ def _other_fuel_cost_gbp_per_kwh(
# Water-heating codes that say "inherit from the main system" — the
# `seasonal_efficiency` cascade returns 0 as a sentinel for these in the
# legacy `domain.sap10_ml.sap_efficiencies` module. We need to inherit through
# legacy `domain.ml.sap_efficiencies` module. We need to inherit through
# the SAME cascade the main heating uses, including the main_heating_
# category fallback (e.g. heat pumps return 2.30 via category 4).
_WATER_INHERIT_FROM_MAIN_CODES: Final[frozenset[int]] = frozenset({901, 902, 914})

View file

@ -20,22 +20,22 @@ import pytest
from datatypes.epc.domain.epc_property_data import MainHeatingDetail, PhotovoltaicArray
from domain.sap10_ml.tests._fixtures import (
from domain.ml.tests._fixtures import (
make_building_part,
make_floor_dimension,
make_minimal_sap10_epc,
make_sap_heating,
make_window,
)
from domain.sap10_calculator.calculator import Sap10Calculator, SapResult
from domain.sap10_calculator.rdsap.cert_to_inputs import (
from domain.sap.calculator import Sap10Calculator, SapResult
from domain.sap.rdsap.cert_to_inputs import (
cert_to_demand_inputs,
cert_to_inputs,
pcdb_combi_loss_override,
)
from domain.sap10_calculator.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record
from domain.sap10_calculator.worksheet.tests import _elmhurst_worksheet_000477 as _w000477
from domain.sap10_calculator.worksheet.water_heating import (
from domain.sap.tables.pcdb import GasOilBoilerRecord, gas_oil_boiler_record
from domain.sap.worksheet.tests import _elmhurst_worksheet_000477 as _w000477
from domain.sap.worksheet.water_heating import (
combi_loss_monthly_kwh_table_3b_row_1_instantaneous,
combi_loss_monthly_kwh_table_3c_two_profile_instantaneous,
)

View file

@ -36,8 +36,8 @@ from typing import Any
import pytest
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
from domain.sap10_calculator.calculator import calculate_sap_from_inputs
from domain.sap10_calculator.rdsap.cert_to_inputs import (
from domain.sap.calculator import calculate_sap_from_inputs
from domain.sap.rdsap.cert_to_inputs import (
SAP_10_2_SPEC_PRICES,
cert_to_demand_inputs,
cert_to_inputs,

View file

@ -15,7 +15,7 @@ Public surface:
- `parser.py`: per-table row parsers (Table 105 typed; raw walker for the
other 7 tables).
- `etl.py`: walks the multi-table `pcdb10.dat` source and writes one
newline-delimited JSON file per table under `domain/sap10_calculator/tables/pcdb/data/`.
newline-delimited JSON file per table under `docs/sap-spec/`.
Reference: BRE PCDB pcdb10.dat (April 2026 revision); SAP 10.2
specification (14-03-2025) Appendix D2.1.
@ -27,14 +27,16 @@ import json
from pathlib import Path
from typing import Final, Optional
from domain.sap10_calculator.tables.pcdb.parser import GasOilBoilerRecord
from domain.sap.tables.pcdb.parser import GasOilBoilerRecord
__all__ = ["GasOilBoilerRecord", "gas_oil_boiler_record"]
_PCDB_DATA_DIR: Final[Path] = Path(__file__).resolve().parent / "data"
_REPO_SAP_SPEC_DIR: Final[Path] = (
Path(__file__).resolve().parents[7] / "docs" / "sap-spec"
)
_TABLE_105_JSONL: Final[Path] = (
_PCDB_DATA_DIR / "pcdb_table_105_gas_oil_boilers.jsonl"
_REPO_SAP_SPEC_DIR / "pcdb_table_105_gas_oil_boilers.jsonl"
)

View file

@ -2,7 +2,7 @@
Idempotent. Re-run when BRE publishes an updated pcdb10.dat. JSON files
are committed in-repo alongside the source .dat so callers can load
without a build step. Run via `python -m domain.sap10_calculator.tables.pcdb.etl`.
without a build step. Run via `python -m domain.sap.tables.pcdb.etl`.
Reference: BRE PCDB pcdb10.dat (April 2026 revision).
"""
@ -13,7 +13,7 @@ import json
from dataclasses import asdict
from pathlib import Path
from domain.sap10_calculator.tables.pcdb.parser import (
from domain.sap.tables.pcdb.parser import (
GasOilBoilerRecord,
RawPcdbRecord,
parse_table_105,
@ -75,8 +75,8 @@ def run_etl(*, source: Path, output_dir: Path) -> None:
if __name__ == "__main__": # pragma: no cover — manual ETL invocation
data_dir = Path(__file__).resolve().parent / "data"
repo_root = Path(__file__).resolve().parents[7]
run_etl(
source=data_dir / "pcdb10.dat",
output_dir=data_dir,
source=repo_root / "docs" / "sap-spec" / "pcdb10.dat",
output_dir=repo_root / "docs" / "sap-spec",
)

View file

@ -66,7 +66,7 @@ class GasOilBoilerRecord:
final_year_of_manufacture: Optional[int]
# SAP10.2 Appendix J Table 3b/3c — combi-loss fields per BRE PCDF Spec
# Rev 6b (12 May 2021), Gas and Oil Boiler Table, fields 48 / 51 / 52
# / 56 / 57 (see `domain/sap10_calculator/docs/specs/PCDF_Spec_Rev-06b_12_May_2021.pdf`
# / 56 / 57 (see `docs/sap-spec/PCDF_Spec_Rev-06b_12_May_2021.pdf`
# pp. 14-15). Populated only for boilers EN 13203-2 / OPS 26 tested;
# SAP-default boilers leave them all blank → `separate_dhw_tests=0`
# and (61)m falls back to Table 3a. Field 48 encodes the test

View file

@ -9,7 +9,7 @@ The "rating" cascade (SAP rating, EI rating) uses UK-average climate per
Appendix U; the "demand" cascade (EPC emissions, primary energy, fuel
cost) uses the postcode-specific climate from this table.
Reference: PCDB10 data file `domain/sap10_calculator/tables/pcdb/data/pcdb10.dat`.
Reference: PCDB10 data file `docs/sap-spec/pcdb10.dat`.
"""
from __future__ import annotations
@ -21,7 +21,7 @@ from typing import Final, Optional
_PCDB_DAT_PATH: Final[Path] = (
Path(__file__).resolve().parent / "data" / "pcdb10.dat"
Path(__file__).resolve().parents[7] / "docs" / "sap-spec" / "pcdb10.dat"
)
_TABLE_172_TAG: Final[str] = "$172"

View file

@ -14,7 +14,7 @@ factors are largely unchanged. When the corpus migrates to SAP 10.3
this module re-points to those values.
The Energy Cost Deflator stays at 0.36 (used in ECF see
`domain.sap10_calculator.worksheet.rating`).
`domain.sap.worksheet.rating`).
"""
from __future__ import annotations
@ -202,7 +202,7 @@ _DEFAULT_CO2_KG_PER_KWH: Final[float] = 0.210 # mains gas baseline
# Gov EPC API main_fuel_type → SAP 10.3 Table 12 fuel code. Lifted from
# the SAP 10.2 mapper (`domain.sap10_ml.sap_efficiencies._API_TO_TABLE32`) —
# the SAP 10.2 mapper (`domain.ml.sap_efficiencies._API_TO_TABLE32`) —
# the API enum and Table 32/12 codes are unchanged across spec versions.
API_FUEL_TO_TABLE_12: Final[dict[int, int]] = {
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,

View file

@ -1,6 +1,6 @@
"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
Sourced verbatim from `domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-
Sourced verbatim from `docs/sap-spec/sap-10-2-full-specification-2025-
03-14.pdf`, page 191 (Table 12a). RdSAP10 §19.1 cross-references this
table from RdSAP10 §10a/§10b the table is not duplicated in the
RdSAP10 PDF.

View file

@ -1,20 +1,20 @@
"""RdSAP10 Table 32 — fuel prices, standing charges, PV export credit.
Sourced verbatim from `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`,
Sourced verbatim from `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf`,
page 95 (Table 32). RdSAP10 §19.1: SAP rating for RdSAP10 is calculated
using Table 32 prices (not Table 12) for §10a and §10b. The calculator
targets RdSAP10 cost per ADR-0010 amendment.
CO2 emission factors and primary energy factors are unchanged from
SAP10.2 Table 12 (RdSAP10 §19.2), so they continue to live in
`domain.sap10_calculator.tables.table_12` rather than being duplicated here.
`domain.sap.tables.table_12` rather than being duplicated here.
"""
from __future__ import annotations
from typing import Final, Optional
from domain.sap10_calculator.tables.table_12a import Tariff
from domain.sap.tables.table_12a import Tariff
_DEFAULT_P_PER_KWH: Final[float] = 3.48 # fall back to mains gas

View file

@ -2,14 +2,14 @@
synthetic baseline dwelling.
**Provenance.** The SAP 10.2 worksheet template (pages 131148 of
domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf) is the canonical
docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf) is the canonical
calculation form every SAP implementation must mirror, using the item
reference numbers (1a), (4), (33), (39), (40), (91), (92), (93), (257),
(272), (286) etc. The PDF's form fields are non-functional, so this test
is **spec-formula-derived** each expected value is computed independently
from the worksheet formulas applied to the baseline inputs below, not from
BRE-published worked-example tables. BRE worked-example values were not
located in any of the three SAP-spec PDFs in domain/sap10_calculator/docs/specs/; if they
located in any of the three SAP-spec PDFs in docs/sap-spec/; if they
surface later, only the expected numbers need updating, not this file's
structure.
@ -23,17 +23,17 @@ from __future__ import annotations
import pytest
from domain.sap10_calculator.calculator import (
from domain.sap.calculator import (
CalculatorInputs,
calculate_sap_from_inputs,
)
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.dimensions import Dimensions
from domain.sap10_calculator.worksheet.heat_transmission import HeatTransmission
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
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,
)
from domain.sap10_calculator.worksheet.space_heating import space_heating_monthly_kwh
from domain.sap.worksheet.space_heating import space_heating_monthly_kwh
def _baseline_dwelling() -> CalculatorInputs:

View file

@ -20,18 +20,18 @@ from dataclasses import replace
import pytest
from domain.sap10_calculator.calculator import (
from domain.sap.calculator import (
CalculatorInputs,
SapResult,
calculate_sap_from_inputs,
)
from domain.sap10_calculator.climate.appendix_u import external_temperature_c
from domain.sap10_calculator.worksheet.dimensions import Dimensions
from domain.sap10_calculator.worksheet.heat_transmission import HeatTransmission
from domain.sap10_calculator.worksheet.mean_internal_temperature import (
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,
)
from domain.sap10_calculator.worksheet.space_heating import space_heating_monthly_kwh
from domain.sap.worksheet.space_heating import space_heating_monthly_kwh
def _baseline_inputs() -> CalculatorInputs:

View file

@ -15,17 +15,16 @@ from pathlib import Path
import pytest
from domain.sap10_calculator.tables.pcdb.etl import run_etl
from domain.sap10_calculator.tables.pcdb.parser import (
from domain.sap.tables.pcdb.etl import run_etl
from domain.sap.tables.pcdb.parser import (
parse_table_105,
parse_table_105_row,
parse_table_raw,
)
_PCDB_DAT_PATH: Path = (
Path(__file__).resolve().parents[1] / "tables" / "pcdb" / "data" / "pcdb10.dat"
)
_REPO_ROOT: Path = Path(__file__).resolve().parents[6]
_PCDB_DAT_PATH: Path = _REPO_ROOT / "docs" / "sap-spec" / "pcdb10.dat"
# Verified by user against ncm-pcdb.org.uk: Baxi Heating Wm 20/3rs.

View file

@ -10,7 +10,7 @@ Reference: BRE PCDB pcdb10.dat (April 2026); user-verified records.
from __future__ import annotations
from domain.sap10_calculator.tables.pcdb import gas_oil_boiler_record
from domain.sap.tables.pcdb import gas_oil_boiler_record
def test_gas_oil_boiler_record_returns_verified_baxi_98() -> None:

View file

@ -10,7 +10,7 @@ Reference: BRE PCDB pcdb10.dat Table 172 (Postcodes).
"""
from __future__ import annotations
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
from domain.sap.tables.pcdb.postcode_weather import (
PostcodeClimate,
postcode_climate,
)

View file

@ -2,7 +2,7 @@
Locks the CO2 emission factors and primary energy factors against the
published SAP 10.2 specification at
`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`, page 189.
`docs/sap-spec/sap-10-2-full-specification-2025-03-14.pdf`, page 189.
The price column (`UNIT_PRICE_P_PER_KWH`) was already SAP 10.2-correct
when the calculator code was authored; the CO2 column was authored
@ -15,7 +15,7 @@ from __future__ import annotations
import pytest
from domain.sap10_calculator.tables.table_12 import (
from domain.sap.tables.table_12 import (
co2_factor_kg_per_kwh,
primary_energy_factor,
unit_price_p_per_kwh,

View file

@ -3,7 +3,7 @@
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.
`docs/sap-spec/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.
@ -12,7 +12,7 @@ from __future__ import annotations
import pytest
from domain.sap10_calculator.tables.table_12a import (
from domain.sap.tables.table_12a import (
OtherUse,
Table12aSystem,
Tariff,

View file

@ -2,7 +2,7 @@
Locks unit prices, standing charges, PV export credit, and the Table 12
note (a) standing-charge gating against the published RdSAP10
specification at `domain/sap10_calculator/docs/specs/RdSAP 10 Specification 10-06-2025.pdf`,
specification at `docs/sap-spec/RdSAP 10 Specification 10-06-2025.pdf`,
page 95 (Table 32).
RdSAP10 §19.1: "The SAP rating for RdSAP 10 is to be calculated using
@ -13,8 +13,8 @@ from __future__ import annotations
import pytest
from domain.sap10_calculator.tables.table_12a import Tariff
from domain.sap10_calculator.tables.table_32 import (
from domain.sap.tables.table_12a import Tariff
from domain.sap.tables.table_32 import (
additional_standing_charges_gbp,
standing_charge_gbp,
unit_price_p_per_kwh,

View file

@ -15,7 +15,7 @@ from __future__ import annotations
import pytest
from domain.sap10_calculator.validation.parity_report import (
from domain.sap.validation.parity_report import (
ParityCase,
ParityReport,
build_parity_report,

View file

@ -23,14 +23,14 @@ Worksheet line mapping (SAP 10.2 §3, canonical xlsx rows 121-207):
(36) thermal bridging = y × Σ exposed area (RdSAP Table 21)
(37) total fabric heat loss = (33) + (36)
This is the calculator-vocabulary sibling of `domain.sap10_ml.envelope`. During
This is the calculator-vocabulary sibling of `domain.ml.envelope`. During
Session A both modules coexist the legacy envelope.py continues to feed
the ML transform's `envelope_heat_loss_w_per_k` physics-feature. Session B
will retire envelope.py in favour of this module (ADR-0009 §"Module
layout").
U-value lookups cascade through `domain.sap10_ml.rdsap_uvalues` migrating to
`domain.sap10_calculator.rdsap.cascade_defaults` in Session B.
U-value lookups cascade through `domain.ml.rdsap_uvalues` migrating to
`domain.sap.rdsap.cascade_defaults` in Session B.
Reference: SAP 10.2 specification §3 (pages 17-22); RdSAP 10 §5 (Tables
6-24); xlsx worked example at `2026-05-19-17-18 RdSap10Worksheet.xlsx`,
@ -49,7 +49,7 @@ from datatypes.epc.domain.epc_property_data import (
SapRoofWindow,
)
from domain.sap10_ml.rdsap_uvalues import (
from domain.ml.rdsap_uvalues import (
Country,
WALL_UNKNOWN,
_described_as_insulated,

Some files were not shown because too many files have changed in this diff Show more