mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
Merge pull request #1146 from Hestia-Homes/feature/property-baseline-sap10
Property Baseline: SAP calculator load-bearing + Bill Derivation engine
This commit is contained in:
commit
c05c7ead63
32 changed files with 1269 additions and 39 deletions
26
CONTEXT.md
26
CONTEXT.md
|
|
@ -82,11 +82,11 @@ The EpcPropertyData scored by the modelling pipeline for a single Property, deri
|
|||
_Avoid_: modelling EPC, working EPC, resolved EPC, derived EPC
|
||||
|
||||
**Rebaselining**:
|
||||
Re-predicting a Property's SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh via ML so the modelling pipeline scores it against the current SAP10 methodology. Triggered when either (a) the Effective EPC was lodged under a pre-SAP10 schema (`sap_version < 10.0`), so the recorded scores reflect a superseded methodology, or (b) Site Notes / Landlord Overrides changed the physical state of the Property (walls / heating / windows / etc.) so the lodged scores no longer reflect what's installed. Both triggers may fire together. Produces Effective Performance; Lodged Performance is preserved unchanged. kWh is included as ML targets per ADR-0007 — see [[epc-ml-transform]].
|
||||
Re-predicting a Property's SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh via **SAP10 Calculation** (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013) so the modelling pipeline scores it against the current SAP10 methodology. Triggered when either (a) the Effective EPC was lodged under a methodology the calculator supersedes (`sap_version < 10.2`, the calculator's target spec), so the recorded scores reflect a superseded methodology, or (b) Site Notes / Landlord Overrides changed the physical state of the Property (walls / heating / windows / etc.) so the lodged scores no longer reflect what's installed. Both triggers may fire together. Produces Effective Performance; Lodged Performance is preserved unchanged. kWh is included as ML targets per ADR-0007 — see [[epc-ml-transform]].
|
||||
_Avoid_: re-scoring, re-prediction, performance recomputation, refresh (for cache-freshness)
|
||||
|
||||
**Baseline Performance**:
|
||||
A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus annual space heating kWh, hot water kWh, fuel split, and bills derived from the Effective EPC — kWh values come from the EPC's recorded fields for SAP10 baselines or from ML when Rebaselining fires; bills are derived deterministically from kWh × current Fuel Rates. Persisted as one row; surfaced as one block in the UI.
|
||||
A Property's current performance aggregate, holding both Lodged Performance and Effective Performance plus the energy block: delivered kWh **per end use** (heating, hot water, lighting, appliances, cooking, pumps/fans, …) and the **annual bill** composed into per-section costs plus a total, produced by **Bill Derivation** from SAP10 Calculation's per-end-use kWh × current Fuel Rates. Persisted as one row (flat typed columns, per-section kWh + cost + total); surfaced as one block in the UI.
|
||||
_Avoid_: baseline predictions, predicted baseline, rebaselined values
|
||||
|
||||
**Lodged Performance**:
|
||||
|
|
@ -94,15 +94,15 @@ The SAP / EPC Band / carbon emissions / Primary Energy Intensity recorded on the
|
|||
_Avoid_: original performance, raw EPC values, recorded baseline
|
||||
|
||||
**Effective Performance**:
|
||||
The SAP / EPC Band / carbon emissions / Primary Energy Intensity the modelling pipeline actually scored against — equal to Lodged Performance when no Rebaselining trigger fires, replaced by ML output when triggered. The half of Baseline Performance that says "what we modelled".
|
||||
The SAP / EPC Band / carbon emissions / Primary Energy Intensity the modelling pipeline actually scored against — equal to Lodged Performance when no Rebaselining trigger fires, replaced by **SAP10 Calculation** output (the deterministic `Sap10Calculator`, which superseded the old ML-API rebaseliner; an ML residual head over the calculator is future — ADR-0009/0013) when triggered. The half of Baseline Performance that says "what we modelled".
|
||||
_Avoid_: modelled performance, rebaselined performance (only correct when rebaselining ran), scored values
|
||||
|
||||
**Calculated SAP10 Performance**:
|
||||
The SAP score, EPC Band, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh produced by **SAP10 Calculation** from a Property's EpcPropertyData. Distinct from Effective Performance (ML output) and Lodged Performance (gov register) during the validation phase. Surfaced alongside Effective Performance in the UI; may supersede Effective Performance in a later ADR once parity is confirmed against the cert-reported SAP across ≥1000 sample certs lodged on the calculator's target spec version (see [[sap-spec-version]]). ADR-0009 (as amended by ADR-0010).
|
||||
_Avoid_: calculator output, computed performance, worksheet performance, SAP10 output
|
||||
The SAP score, EPC Band, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh produced by **SAP10 Calculation** from a Property's EpcPropertyData. It is **not** a separately-persisted third value-set beside Lodged and Effective: in every baselining scenario the calculator's output *is* the **Effective Performance** (real lodged SAP10 EPC with no overrides ⇒ Calculated = Lodged = Effective; overrides or an estimated / pre-SAP10 EPC ⇒ Calculated = Effective, there being no lodged SAP10 figure to compare against). The calculator is therefore the mechanism that produces Effective Performance, having superseded the old ML-API rebaseliner. The calculator is **load-bearing**: for `sap_version < 10.2` (lodged under a superseded methodology) its output *is* the Effective Performance; for `≥ 10.2` the API's lodged figures are kept and the calculator runs **alongside, logging any divergence** (SAP > 0.5, PEUI/CO2 beyond tolerance) as a validation signal (see [[sap-spec-version]]). It is load-bearing for **Bill Derivation regardless of version** (the EPC lodges no per-end-use kWh), so a calculator strict-raise **aborts the batch** and the un-mapped cert is fixed immediately. ADR-0009 introduced the term, amended by ADR-0010, realized by ADR-0013 (whose shadow stepping-stone is superseded) and ADR-0014.
|
||||
_Avoid_: calculator output, computed performance, worksheet performance, SAP10 output, calculated value-set (it is not a stored third set)
|
||||
|
||||
**SAP10 Calculation**:
|
||||
The process that runs the deterministic SAP 10.2 (14-03-2025 amendment) worksheet over a Property's EpcPropertyData and emits **Calculated SAP10 Performance**. Implemented by the `Sap10Calculator` service class in `domain/sap/`. Reads cert fabric/heating/geometry fields, applies the RdSAP 10 (10-06-2025) cert→input mapping, executes the 12-month heat balance per SAP 10.2 §§1-14, looks up boiler/heat-pump performance in the **PCDB** when the cert lodges a product index, and returns a `SapResult` carrying the five Calculated SAP10 Performance quantities plus a monthly breakdown and worksheet-line audit trail. Distinct from **Rebaselining**, which is ML-based. ADR-0009 originally targeted SAP 10.3 (13-01-2026); ADR-0010 retargets to SAP 10.2 (14-03-2025) until the cert corpus migrates.
|
||||
The process that runs the deterministic SAP 10.2 (14-03-2025 amendment) worksheet over a Property's EpcPropertyData and emits **Calculated SAP10 Performance**. Implemented by the `Sap10Calculator` service class in `domain/sap10_calculator/` (`calculator.py`). Reads cert fabric/heating/geometry fields, applies the RdSAP 10 (10-06-2025) cert→input mapping, executes the 12-month heat balance per SAP 10.2 §§1-14, looks up boiler/heat-pump performance in the **PCDB** when the cert lodges a product index, and returns a `SapResult` carrying the five Calculated SAP10 Performance quantities plus a monthly breakdown and worksheet-line audit trail. Distinct from **Rebaselining**, which is ML-based. ADR-0009 originally targeted SAP 10.3 (13-01-2026); ADR-0010 retargets to SAP 10.2 (14-03-2025) until the cert corpus migrates.
|
||||
_Avoid_: SAP calculation (ambiguous with the gov calculator), SAP scoring, calculator run, SAP 10.3 calculation (active target is 10.2 — see [[sap-spec-version]])
|
||||
|
||||
**SAP Spec Version**:
|
||||
|
|
@ -117,9 +117,9 @@ _Avoid_: parity cohort, validation set, corpus sample
|
|||
The process that translates an Optimised Package into cert-field changes and produces the "ending state snapshot" EpcPropertyData that Plan Phase persists. Implemented by the `MeasureApplicator` service class in `domain/sap/` (or a sibling package). Each Measure Type's translation rules (e.g. `loft_insulation` → `roof_insulation_thickness_mm = 270mm`, `ashp` → `main_heating_details[0]` replacement) live here. Pure function — does not run SAP10 Calculation itself; the caller chains `MeasureApplicator.apply(epc, package) → Sap10Calculator.calculate(post_epc)`. ADR-0009.
|
||||
_Avoid_: measure overrides (rejected during ADR-0009 grill — phantom mid-layer), package applier, retrofit simulator
|
||||
|
||||
**EPC Energy Derivation**:
|
||||
The process that derives a Property's fuel split and annual bills from its space heating kWh and hot water kWh values plus the heating fuel deduced from SAP fields. kWh values themselves come from the EPC's recorded fields (`renewable_heat_incentive.space_heating_kwh` and `.water_heating_kwh`) for SAP10 baselines, or from ML prediction when Rebaselining fires or when scoring a post-measure state. Bills are computed deterministically from delivered kWh × current Fuel Rates + standing charges + SEG credits. The UCL Correction is no longer applied at runtime — it is folded into ML training labels (see [[epc-ml-transform]] and ADR-0007).
|
||||
_Avoid_: kWh prediction (kWh is now an ML target — see Rebaselining), baseline kWh, energy estimation
|
||||
**Bill Derivation**:
|
||||
The deterministic process that derives a Property's annual energy **bill**, composed into per-end-use sections (heating, hot water, lighting, appliances, cooking, pumps/fans, …) plus a **total**, by pricing **SAP10 Calculation**'s delivered kWh per end use at **current Fuel Rates** — each end use billed at its fuel's rate, rolled up per fuel for **standing charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) minus **SEG** export credit on PV. Implemented by `BillDerivation` in `domain/property_baseline/` (deterministic, ADR-0006). Reads Fuel Rates from a committed static snapshot via `FuelRatesRepository` (no live ETL yet). **Distinct from the calculator's `total_fuel_cost_gbp`**, which is the SAP-rating notional cost at RdSAP Table 32 standardised prices (~half the real electricity price) — not what the household pays. Raises on a fuel it has no rate for (e.g. house coal, heat network). ADR-0014.
|
||||
_Avoid_: EPC Energy Derivation (renamed), EpcEnergyDerivationService (no "service" suffix), kWh prediction, baseline kWh, energy estimation
|
||||
|
||||
**UCL Correction**:
|
||||
The per-band linear correction (Few et al. 2023, _Energy & Buildings_ 288 113024) that aligns EPC-modelled Primary Energy Intensity with metered consumption. Folded into ML training labels at fit time (per ADR-0007) rather than applied at runtime — the trained model emits metered-equivalent PEUI directly, avoiding the discontinuities at EPC band boundaries that arose when the per-band linear correction was applied post-prediction. Calibrated against gas-heated, non-PV homes in England and Wales rated under SAP 2012; the current implementation extrapolates it to all properties (open question §15.14).
|
||||
|
|
@ -174,11 +174,11 @@ _Avoid_: code list, code dictionary, vocab
|
|||
### Reference data
|
||||
|
||||
**Fuel Rates**:
|
||||
The current per-fuel rate (pence/kWh) and standing charge used to compute a Property's bills; time-versioned and regional, refreshed from Ofgem's published caps via an ETL. The Smart Export Guarantee rate sits in the same set as `electricity_export`. Consumed by EPC Energy Derivation.
|
||||
The current per-fuel rate (pence/kWh) and standing charge used to compute a Property's bills; time-versioned and regional. Sourced for now from a **committed static snapshot** (national, Ofgem-cap period for gas/electricity + DESNZ/NEP for off-gas fuels), read via `FuelRatesRepository`; an Ofgem-cap ETL automating the refresh is future, not a prerequisite. The Smart Export Guarantee rate sits in the same set as `electricity_export`. Consumed by Bill Derivation.
|
||||
_Avoid_: fuel prices (commodity prices, different concept), tariff, energy cost
|
||||
|
||||
**Carbon Factors**:
|
||||
The per-fuel CO2 emission factor (kgCO2e/kWh) used to compute a Property's carbon emissions; time-versioned, refreshed from Defra's annual publication. Consumed by EPC Energy Derivation.
|
||||
The per-fuel CO2 emission factor (kgCO2e/kWh) used to compute a Property's carbon emissions; time-versioned, refreshed from Defra's annual publication. Consumed by Bill Derivation.
|
||||
_Avoid_: emission factors (ambiguous), CO2 rates
|
||||
|
||||
### Outputs
|
||||
|
|
@ -277,7 +277,7 @@ _Avoid_: API key, auth token, secret
|
|||
- When a **Property** has both **Site Notes** and a public **EPC**, the newer of the two derives the **Effective EPC**. **Landlord Overrides** apply only when the **EPC** is the source — never when **Site Notes** are.
|
||||
- A Property's **Baseline Performance** holds two halves: **Lodged Performance** (the gov register's SAP / band / carbon / heat) and **Effective Performance** (what the modelling pipeline scored against). The two are equal unless **Rebaselining** fires.
|
||||
- **Rebaselining** produces **Effective Performance** by ML re-prediction across SAP score, CO2 emissions, Primary Energy Intensity, space heating kWh, and hot water kWh, when either (a) the Effective EPC was lodged under a pre-SAP10 schema, or (b) the Effective EPC's physical state diverges from the lodged EPC. **Lodged Performance** is never overwritten.
|
||||
- **EPC Energy Derivation** derives **fuel split** and **bills** from kWh values (sourced from the EPC's `renewable_heat_incentive` fields for baseline SAP10 properties, or from ML when Rebaselining fires), reading current **Fuel Rates** and **Carbon Factors** from their respective repos.
|
||||
- **Bill Derivation** derives **fuel split** and **bills** from kWh values (sourced from the EPC's `renewable_heat_incentive` fields for baseline SAP10 properties, or from ML when Rebaselining fires), reading current **Fuel Rates** and **Carbon Factors** from their respective repos.
|
||||
- The **EPC Prediction Service** uses **Comparable Properties** for both gap-filling and producing **EPC Anomaly Flags**.
|
||||
- A **Scenario** carries one or more ordered **Scenario Phases**. Triggering the model against N Scenarios produces N **Plans** per Property; each Plan carries an ordered list of **Plan Phases** matching the Scenario's shape.
|
||||
- Each **Plan Phase** holds its **Optimised Package**, the ending state snapshot, and any **Rolled-over Options** that flow as candidates into the next Plan Phase. A single-phase Scenario is one Scenario Phase with all measure types allowed; the same machinery handles it.
|
||||
|
|
@ -289,7 +289,7 @@ _Avoid_: API key, auth token, secret
|
|||
|
||||
> **Dev:** "A landlord uploads a corrected boiler for one of their properties. What happens?"
|
||||
>
|
||||
> **Domain expert:** "That's a **Landlord Override** on the heating fields. Save it against the **Property**. The **Effective EPC** has changed, so **Rebaselining** runs to re-predict SAP / carbon / PEUI / space heating kWh / hot water kWh, and **EPC Energy Derivation** re-runs to update the fuel split and bills based on the new kWh values and fuel deduction. With fresh **Baseline Performance** we regenerate **Recommendations**."
|
||||
> **Domain expert:** "That's a **Landlord Override** on the heating fields. Save it against the **Property**. The **Effective EPC** has changed, so **Rebaselining** runs to re-predict SAP / carbon / PEUI / space heating kWh / hot water kWh, and **Bill Derivation** re-runs to update the fuel split and bills based on the new kWh values and fuel deduction. With fresh **Baseline Performance** we regenerate **Recommendations**."
|
||||
|
||||
> **Dev:** "What if the same Property also has Site Notes?"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ from sqlmodel import Session
|
|||
from applications.ara_first_run.ara_first_run_trigger_body import (
|
||||
AraFirstRunTriggerBody,
|
||||
)
|
||||
from domain.property_baseline.rebaseliner import StubRebaseliner
|
||||
from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner
|
||||
from domain.sap10_calculator.calculator import Sap10Calculator
|
||||
from infrastructure.postgres.config import PostgresConfig
|
||||
from infrastructure.postgres.engine import make_engine
|
||||
from orchestration.property_baseline_orchestrator import PropertyBaselineOrchestrator
|
||||
|
|
@ -80,7 +81,10 @@ def build_first_run_pipeline(
|
|||
),
|
||||
baseline=PropertyBaselineOrchestrator(
|
||||
unit_of_work=unit_of_work,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
# The calculator is load-bearing: effective=calculated for pre-10.2
|
||||
# certs, lodged + divergence-logged at/above 10.2; a raise aborts the
|
||||
# batch (ADR-0013 amendment).
|
||||
rebaseliner=CalculatorRebaseliner(Sap10Calculator()),
|
||||
),
|
||||
modelling=ModellingOrchestrator(
|
||||
scenario_repo=ScenarioRepository(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
# ARA Backend Redesign — Design PRD
|
||||
|
||||
> ⚠️ **SUPERSEDED (architecture sections).** This is an early draft PRD. The actual
|
||||
> architecture as built differs — see the ADRs in `docs/adr/` (especially 0011
|
||||
> composable stage orchestrators, 0012 Unit-of-Work per-stage batch) and
|
||||
> `docs/HANDOVER_ARA_NEXT.md` for current state. Treat this doc as historical context,
|
||||
> not the source of truth for layout/contracts.
|
||||
|
||||
**Status**: Draft for team review
|
||||
**Author**: Khalim Conn-Kowlessar (with Claude grill session)
|
||||
**Branch**: `ara-backend-design-prd`
|
||||
|
|
|
|||
153
docs/HANDOVER_ARA_NEXT.md
Normal file
153
docs/HANDOVER_ARA_NEXT.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Handover — Ara backend: Property Baseline (SAP calculator) + Modelling
|
||||
|
||||
You are picking up a clean, merged baseline. The `ara_first_run` backend rebuild is
|
||||
**done and shipped**; the next two fronts are (1) wiring the SAP calculator into
|
||||
Property Baseline, and (2) starting Modelling. This doc is the orientation — the ADRs
|
||||
and CONTEXT.md are authoritative for decisions; don't re-derive them.
|
||||
|
||||
## Where things stand
|
||||
|
||||
- The **`ara_first_run` rebuild is complete and merged to `main`** (via
|
||||
`feature/per-cert-mapper-validation`): the full pipeline spine
|
||||
**Ingestion → Baseline → Modelling(stub)** on a flat-hexagonal layout with a
|
||||
per-stage Unit-of-Work. Issues #1129–#1138 (parent PRD #1128) are all done.
|
||||
- **Branch + worktree:** you are on `feature/property-baseline-sap10`, cut from the
|
||||
up-to-date `feature/per-cert-mapper-validation` (which contains `main` + the merged
|
||||
ara work + the ongoing per-cert SAP-calculator validation slices). Worktree:
|
||||
`/workspaces/home/hestia-worktrees/model-assemble-new-backend`. The
|
||||
`/workspaces/model` worktree holds `feature/per-cert-mapper-validation` itself.
|
||||
- **PRs go into `feature/per-cert-mapper-validation`, NOT `main` directly** — one PR
|
||||
per slice, the rhythm used for #1129–#1138.
|
||||
|
||||
## Read first (authoritative — don't re-derive)
|
||||
|
||||
- **ADRs** `docs/adr/`: 0002 (Property aggregate root), 0003 (strict Ingestion→Modelling
|
||||
separation, amended), 0004 (BaselinePerformance = Lodged+Effective pair, amended for
|
||||
the standalone table), 0005 (multi-phase Scenarios, per-phase recompute — **governs
|
||||
Modelling**), 0006/0007 (deterministic kWh / kWh-as-ML-target), 0009+0010
|
||||
(deterministic SAP calculator + its spec target & validation cohort), 0011 (composable
|
||||
stage orchestrators, one lambda per use case, stages talk through repos), 0012
|
||||
(Unit-of-Work per-stage batch transaction).
|
||||
- **CONTEXT.md** — the glossary; use this vocabulary in code + commits.
|
||||
- **`ara_backend_design.md`** is a **stale draft PRD** — its architecture sections are
|
||||
superseded by ADR-0011/0012 (a banner now says so). Trust the ADRs, not it.
|
||||
|
||||
## Architecture (current — flat hexagonal at repo root)
|
||||
|
||||
```
|
||||
applications/<lambda>/ thin handler + trigger body + Dockerfile + local_handler
|
||||
orchestration/ stage orchestrators + AraFirstRunPipeline (deps injected)
|
||||
domain/ pure aggregates + services
|
||||
repositories/<agg>/ port (ABC) + adapter (*_postgres_repository / *_s3_repository)
|
||||
infrastructure/ clients + SQLModel rows (*_table.py) + engine/config
|
||||
```
|
||||
|
||||
Stages communicate **only through repos**, threading just `property_ids` — never an
|
||||
in-memory hand-off (ADR-0011/0003). Each stage runs its batch in **one Unit of Work and
|
||||
commits once** (ADR-0012); all-or-nothing per batch, fail noisily → subtask FAILED →
|
||||
debug & re-run; re-runs are idempotent (replace-by-`property_id`). Ingestion is
|
||||
fetch-then-write so a DB connection is never held during external IO.
|
||||
|
||||
## Key files (note the recent rename: baseline → property_baseline; FirstRun → AraFirstRun)
|
||||
|
||||
- `orchestration/ara_first_run_pipeline.py` — `AraFirstRunPipeline`, `AraFirstRunCommand`,
|
||||
the `IngestionStage`/`PropertyBaselineStage`/`ModellingStage` Protocols.
|
||||
- `orchestration/property_baseline_orchestrator.py` — `PropertyBaselineOrchestrator`
|
||||
(**this is where the SAP calculator gets wired**).
|
||||
- `orchestration/ingestion_orchestrator.py`, `orchestration/modelling_orchestrator.py` (stub).
|
||||
- `domain/property_baseline/` — `PropertyBaselinePerformance`, `Performance`,
|
||||
`lodged_performance()`, `Rebaseliner`/`StubRebaseliner`.
|
||||
- `repositories/property_baseline/` (port + postgres adapter),
|
||||
`repositories/unit_of_work.py` + `repositories/postgres_unit_of_work.py`.
|
||||
- `repositories/scenario/`, `repositories/materials/` — **empty seam ports** for Modelling.
|
||||
- `infrastructure/postgres/property_baseline_performance_table.py` — flat-column row.
|
||||
- `applications/ara_first_run/handler.py` — `build_first_run_pipeline` wiring +
|
||||
`_source_clients_from_env` (a seam that **raises** — see Stubs below).
|
||||
- **SAP calculator (for task 1):** `domain/sap10_calculator/calculator.py`, class
|
||||
`Sap10Calculator`, returns a `SapResult` (5 quantities + monthly + worksheet audit).
|
||||
It is mature and heavily validated by the per-cert work on this branch.
|
||||
|
||||
## Conventions + gotchas
|
||||
|
||||
- **TDD**, one test → one impl; `# Arrange / # Act / # Assert` headers; **commit per
|
||||
slice** with a spec/ADR citation and the
|
||||
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer.
|
||||
- Tests: real ephemeral PostgreSQL via the `db_engine` fixture (JSONB needs real PG).
|
||||
**Orchestrator/repo unit tests use fakes** — `tests/orchestration/fakes.py`
|
||||
(`FakeUnitOfWork` exposing `property`/`epc`/`solar`/`property_baseline` repos + commit
|
||||
count). Run with `-p no:cacheprovider`; ignore coverage spam.
|
||||
- **pyright strict, zero errors.** Known noise to ignore: a `venvPath` warning; the
|
||||
`moto`-not-installed import errors in `test_postcode_splitter_orchestrator.py` +
|
||||
`test_user_address_csv_s3_repository.py` (those modules don't collect — `--ignore`
|
||||
them); and 4 pre-existing failures outside `tests/` (summary_pdf_mapper_chain ×3 +
|
||||
from_rdsap_schema total_floor_area).
|
||||
- **Pushing from this worktree:** the VS Code git credential helpers are broken
|
||||
(missing node binaries), so use a one-shot gh override:
|
||||
`git -c credential.helper= -c credential.helper='!gh auth git-credential' push`.
|
||||
|
||||
## Next task 1 — SAP calculator on Property Baseline (the user expects this to be simple)
|
||||
|
||||
Wire `Sap10Calculator` into `PropertyBaselineOrchestrator` to produce **Calculated SAP10
|
||||
Performance** per property. Per CONTEXT (≈line 100), this is a quantity **distinct from**
|
||||
Lodged/Effective Performance — surfaced *alongside* them during the validation phase; it
|
||||
may supersede Effective Performance in a later ADR once parity is confirmed (ADR-0009/0010).
|
||||
|
||||
**Grill these two before coding (`/grill-with-docs`):**
|
||||
1. **Where it sits.** Recommended: a *third* value-set on `PropertyBaselinePerformance`
|
||||
(`calculated: Performance` + its space/water kWh), persisted as `calculated_*` columns
|
||||
on `property_baseline_performance` — **not** an overwrite of `effective`. Pin the
|
||||
aggregate shape + table migration in one pass (the table migration is FE-owned/Drizzle —
|
||||
see `docs/migrations/property-baseline-performance-table.md`).
|
||||
2. **Failure posture.** The calculator strict-raises (`UnmappedSapCode`, etc.) on certs it
|
||||
can't yet handle. Running it over a real cohort *surfaces those gaps* — which is the
|
||||
validation work `feature/per-cert-mapper-validation` exists for. Decide: let the raise
|
||||
abort the batch (ADR-0012 all-or-nothing), or collect/skip-and-report. This is the main
|
||||
judgment call; "simple to wire" but it lights up the validation surface.
|
||||
|
||||
Then TDD: inject the calculator into `PropertyBaselineOrchestrator`, call it on the
|
||||
Effective EPC, persist the calculated set in the same unit.
|
||||
|
||||
## Next task 2 — Modelling (Recommendations / Optimiser / Plans)
|
||||
|
||||
`ModellingOrchestrator.run(property_ids, scenario_ids)` is a **no-op stub**;
|
||||
`ScenarioRepository` and `MaterialsRepository` are **empty seam ports**. Building this out
|
||||
is the third stage. ADR-0005 (multi-phase Scenarios, per-phase recompute) governs it.
|
||||
Relevant CONTEXT terms: Modelling (stage), Scenario, Scenario Phase, Scenario Snapshot,
|
||||
Optimised Package, Plans, Recommendations, Optimiser Service.
|
||||
|
||||
Before coding, grill the port shapes + the Scenario/Materials domain aggregates. Two
|
||||
known open points:
|
||||
- **`MaterialsRepository` naming.** A PR reviewer suggested `BuildingMaterialsRepository`;
|
||||
this was **deliberately deferred to this grill** because "building materials" may
|
||||
under-describe retrofit measures (a heat pump / ASHP is a *measure/product*, not a
|
||||
building material). Settle the term (Materials / Measures / Products / BuildingMaterials)
|
||||
here.
|
||||
- **Modelling will need a Unit of Work** when it writes Plans — the stub currently takes
|
||||
no `unit_of_work`; it gains one (ADR-0012) when its body is built.
|
||||
|
||||
## Stubs / seams that raise or no-op (do NOT mistake for "done")
|
||||
|
||||
- `applications/ara_first_run/handler.py::_source_clients_from_env` — **raises**
|
||||
`NotImplementedError`. EPC-API / Google-Solar / geospatial-S3 client config + env-var
|
||||
names + pandas/s3fs deps + Terraform wiring are a separate deploy piece (out of scope so
|
||||
far). The lambda is not end-to-end runnable until this is filled in.
|
||||
- `ModellingOrchestrator.run` — no-op.
|
||||
- `ScenarioRepository` / `MaterialsRepository` — empty ABC ports.
|
||||
- `StubRebaseliner` — raises `RebaselineNotImplemented` on pre-SAP10 certs (`sap_version
|
||||
< 10`); ML Rebaselining is not implemented.
|
||||
- **EPC Energy Derivation** (fuel split + bills + the Ofgem-cap Fuel Rates ETL) is
|
||||
deferred — kWh is carried on `PropertyBaselinePerformance`, the rest is not.
|
||||
|
||||
## Known doc drift to be aware of (flagged, intentionally not auto-fixed)
|
||||
|
||||
- **CONTEXT.md term vs code class.** The glossary term is **"Baseline Performance"**; the
|
||||
code class is **`PropertyBaselinePerformance`** (renamed on PR review). The glossary was
|
||||
*deliberately* left un-renamed — treat "Baseline Performance" as the spoken concept and
|
||||
`PropertyBaselinePerformance` as its class. If you want them aligned, rename the term to
|
||||
"Property Baseline Performance" across CONTEXT + ADR prose (a quick, mechanical change).
|
||||
|
||||
## Issues / process
|
||||
|
||||
Parent PRD: `gh issue view 1128 --repo Hestia-Homes/Model`. #1129–#1138 done (each with a
|
||||
"Done." comment). New work → new issues (use `/to-issues` or `/triage`), `ready-for-agent`
|
||||
labelled, parented to #1128.
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
Status: accepted
|
||||
---
|
||||
|
||||
# The `Sap10Calculator` produces Effective Performance (it is the Rebaseliner); Calculated SAP10 Performance is not a persisted third value-set, and is wired in shadow first
|
||||
|
||||
Refines [ADR-0004](0004-baseline-performance-lodged-effective-pair.md) (the Lodged/Effective
|
||||
pair), [ADR-0009](0009-deterministic-sap-calculator.md)/[ADR-0010](0010-sap10-calculator-spec-target-and-validation.md)
|
||||
(the calculator + the **Calculated SAP10 Performance** term), [ADR-0011](0011-composable-stage-orchestrators.md)
|
||||
(the `Rebaseliner` seam) and [ADR-0012](0012-unit-of-work-per-stage-batch-transaction.md)
|
||||
(all-or-nothing per batch). Decided in a `/grill-with-docs` session (2026-06-01) before wiring
|
||||
`Sap10Calculator` into `PropertyBaselineOrchestrator`.
|
||||
|
||||
## Context
|
||||
|
||||
The old `model_engine` (`backend/engine/engine.py`) called out to an **ML API**
|
||||
(`model_api.predict_all` over `BASELINE_MODEL_PREFIXES`) to rebaseline the properties that needed
|
||||
it. The rebuild replaces that round-trip with the **deterministic `Sap10Calculator`, run live**.
|
||||
|
||||
The handover and CONTEXT (line 100) framed **Calculated SAP10 Performance** as a *third* value-set
|
||||
persisted *alongside* Lodged and Effective (`calculated_*` columns). Walking the baselining
|
||||
scenarios shows that framing reifies a distinction that does not exist in the domain:
|
||||
|
||||
- real lodged SAP10 EPC, no overrides ⇒ Calculated = Lodged = Effective;
|
||||
- real EPC + property/landlord overrides ⇒ Calculated = Lodged-plus-overrides = Effective;
|
||||
- estimated EPC (± overrides), or a pre-SAP10 EPC ⇒ Calculated = Effective (no lodged SAP10 to
|
||||
compare against — Lodged Performance exists only for a *real lodged* EPC).
|
||||
|
||||
In every scenario **Effective = Calculated**. There is no third quantity.
|
||||
|
||||
## Decision
|
||||
|
||||
**The calculator is the mechanism that produces Effective Performance** — i.e. the deterministic
|
||||
`Rebaseliner` (ADR-0011's seam), superseding the old ML-API rebaseliner. "Calculated SAP10
|
||||
Performance" is the *name of that output during validation*, **not** a separately-persisted third
|
||||
value-set. No `calculated_*` columns are added; `property_baseline_performance` keeps its
|
||||
Lodged/Effective shape (ADR-0004). The ADR-0009 ML model is repositioned as a *future residual head*
|
||||
over the calculator, not the baseline producer.
|
||||
|
||||
**Shadow-first, then promotion.** The calculator still strict-raises (`UnmappedSapCode`,
|
||||
`MissingMainFuelType`, `UnresolvedPcdbCombiLoss`) on cert mappings it has not yet hardened, and the
|
||||
strict-typing of `EpcPropertyData` that will close most of those gaps is still pending. A ~40,000
|
||||
property test cohort is about to flow through baselining. So this lands in two steps:
|
||||
|
||||
1. **This slice — shadow.** Performance is still **defined by the input data**: `StubRebaseliner`
|
||||
keeps producing Effective (`= Lodged` for the only live scenario, real SAP10 + no overrides).
|
||||
The calculator runs *beside* it, on every Property's Effective EPC, **purely to be battle-tested
|
||||
in the wild**. It is **not load-bearing**, therefore:
|
||||
- a calculator raise is **caught and logged at `error`, never aborts the batch** — otherwise one
|
||||
unmappable cert would lose the load-bearing Lodged/Effective write for the whole batch, and
|
||||
over a 40k run most batches would never baseline;
|
||||
- on success, its output is **compared to Lodged and logged, not persisted** — `warning` when
|
||||
`|sap_continuous − lodged_sap| > 0.5`, or PEUI / CO2 diverge beyond tolerance (CO2 after the
|
||||
kg→tonnes conversion). Each log is tagged with the cert's `sap_version` so SAP-10.2 divergence
|
||||
(a real calculator signal) is separable from older-spec drift (expected — see
|
||||
[ADR-0010](0010-sap10-calculator-spec-target-and-validation.md) Validation Cohort).
|
||||
|
||||
2. **Next slice or two — load-bearing.** When overrides + EPC estimation land (days away),
|
||||
`StubRebaseliner` is replaced by a calculator-backed `Rebaseliner`: the calculator's output
|
||||
**becomes Effective Performance**. The failure posture **flips to abort** per ADR-0012 — now that
|
||||
the calculator *is* the baseline, a silent wrong answer is the expensive outcome, so a raise must
|
||||
fail the batch noisily. Same exception, opposite handling, because the calculator went from
|
||||
shadow to load-bearing. The shadow logging is then retired.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **A third persisted `calculated_*` value-set on `PropertyBaselinePerformance`** (the handover's
|
||||
recommendation) — rejected: `Effective = Calculated` in every scenario, so the columns would
|
||||
store a distinction with no domain reality, and the future "supersede effective" promotion would
|
||||
be a data move instead of nothing.
|
||||
- **Promote the calculator to drive Effective immediately** — rejected for this one slice: it still
|
||||
strict-raises on un-hardened mappings, so over the imminent 40k run it would gate the
|
||||
load-bearing baseline write. Shadow-first surfaces every gap as an aggregatable error log without
|
||||
blocking baselining.
|
||||
- **A separate `calculator_shadow` validation table** — held in reserve: log-only is enough while
|
||||
the calculator is moving and the shadow step is a 1–2 day stepping stone; we add a queryable table
|
||||
only if log aggregation proves too weak.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `property_baseline_performance` is **unchanged** this slice — no migration.
|
||||
- CONTEXT **Calculated SAP10 Performance**, **Effective Performance**, and **Rebaselining** are
|
||||
updated: the calculator (not ML) is the rebaseliner mechanism in the rebuilt engine; Calculated is
|
||||
not a stored third set.
|
||||
- The shadow runner's broad `except` is deliberate (the point is to discover *what* breaks in the
|
||||
wild); each caught exception is logged with its type and `property_id`.
|
||||
- This decision is short-lived in its shadow form by design; the durable half — "the calculator
|
||||
produces Effective Performance; there is no third value-set" — outlives it.
|
||||
|
||||
## Amendment (2026-06-02): shadow collapsed — the calculator is load-bearing now
|
||||
|
||||
The shadow stepping-stone was right in shape but wrong in duration: the calculator was ready, and
|
||||
wiring [Bill Derivation](0014-bill-derivation-from-real-fuel-rates.md) onto its delivered-kWh
|
||||
breakdown makes it load-bearing for *bills on every property* — so the "shadow until overrides /
|
||||
estimation land" timeline collapses to now. The durable decision stands (calculator produces
|
||||
Effective Performance; no third value-set); only the timing changes:
|
||||
|
||||
- **`sap_version < 10.2`** → effective performance **is** the calculator's output (the
|
||||
`StubRebaseliner` floor moves `10.0 → 10.2`; mechanism is the calculator, not ML).
|
||||
- **`sap_version ≥ 10.2`** → effective = the API's lodged figures; the calculator still runs
|
||||
**alongside, logging divergence** (the surviving half of the shadow runner) as a validation signal.
|
||||
- **Failure posture flips to abort:** the calculator is load-bearing for Bill Derivation regardless
|
||||
of version, so a strict-raise **aborts the batch** (ADR-0012) — the un-mapped cert is fixed
|
||||
immediately rather than skipped. The shadow's catch-and-log of raises is retired; divergence
|
||||
*warnings* on `≥ 10.2` certs remain.
|
||||
|
||||
The `≥1000-cert parity` gate from ADR-0009/0010 still governs whether the calculator's figures are
|
||||
*trusted as definitive* for the SAP-10.2 cohort, but it no longer gates *wiring* — pre-10.2 certs
|
||||
have no current-spec lodged figure to fall back to, so the calculator is the only source there.
|
||||
103
docs/adr/0014-bill-derivation-from-real-fuel-rates.md
Normal file
103
docs/adr/0014-bill-derivation-from-real-fuel-rates.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
---
|
||||
Status: accepted
|
||||
---
|
||||
|
||||
# Bill Derivation: whole-home annual bill from the calculator's delivered kWh × real Fuel Rates (not SAP prices)
|
||||
|
||||
Lifts the bills/fuel-split deferral in [ADR-0004](0004-baseline-performance-lodged-effective-pair.md)
|
||||
and its migration note, and builds on [ADR-0013](0013-calculator-produces-effective-performance-shadow-first.md)
|
||||
(the calculator is load-bearing). Decided in a `/grill-with-docs` session (2026-06-02).
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0004's amendment deferred fuel split + bills "because bills require a current Fuel Rates
|
||||
source (Ofgem-cap ETL) that does not yet exist." A static snapshot lifts that blocker. The old
|
||||
`backend/ml_models/AnnualBillSavings.py` is the fragile reference (a blended `PRICE_FACTOR`, two
|
||||
disagreeing rate sources, a standing-charge precedence bug, a 10× unit slip) — we rewrite, not port.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. The bill is whole-home, composed per end use, from the calculator's delivered kWh
|
||||
|
||||
`SAP10 Calculation` already emits delivered (post-efficiency, billable) kWh for every regulated end
|
||||
use — main/secondary heating, hot water, pumps/fans, lighting, cooling — and computes appliances +
|
||||
cooking electricity internally (Appendix L L13-L20). **`BillDerivation`** consumes that per-end-use
|
||||
breakdown and produces per-section costs + a total. The EPC lodges no per-end-use kWh, so the
|
||||
calculator is the only source — which is why it is **load-bearing for bills regardless of
|
||||
`sap_version`** (a raise aborts the batch, ADR-0013).
|
||||
|
||||
### 2. Bills use real Fuel Rates, not the calculator's `total_fuel_cost_gbp`
|
||||
|
||||
The calculator's fuel cost is the SAP-rating notional cost at **RdSAP Table 32 standardised
|
||||
prices** — deliberately frozen for rating comparability, and ~half the real electricity price
|
||||
(Table 32 elec ~13 p/kWh vs Ofgem Apr–Jun 2026 cap ~24.7 p/kWh). Billing on it would roughly halve
|
||||
an electric/heat-pump home's bill. So `BillDerivation` **re-prices** the delivered kWh at current
|
||||
**Fuel Rates**, and the calculator's `total_fuel_cost_gbp` is used only for the SAP rating.
|
||||
|
||||
### 3. Fuel Rates = committed static snapshot, read via `FuelRatesRepository`
|
||||
|
||||
A national snapshot (Ofgem-cap period for gas/electricity, DESNZ/NEP for off-gas fuels), keyed by a
|
||||
canonical **`Fuel`** enum (`MAINS_GAS, ELECTRICITY, ELECTRICITY_OFF_PEAK, OIL, LPG, SMOKELESS,
|
||||
WOOD_LOGS, WOOD_PELLETS, HEAT_NETWORK`), each entry carrying `unit_rate_p_per_kwh` +
|
||||
`standing_charge_p_per_day`, plus a top-level `seg_export_p_per_kwh`. The calculator's per-end-use
|
||||
SAP fuel codes map to this enum via the existing `is_gas_code` / `is_electric_fuel_code` /
|
||||
`is_liquid_fuel_code` helpers — so the snapshot and the calculator meet at one vocabulary, not raw
|
||||
SAP codes. Read through a `FuelRatesRepository` port (ADR-0011: a Repo reads stored reference data
|
||||
by key); an Ofgem-cap ETL automating the refresh is future, behind the same port — not a
|
||||
prerequisite. National now; the 14 cap regions are a later refinement behind the same port.
|
||||
|
||||
### 4. Bill arithmetic
|
||||
|
||||
Total = Σ (per-end-use delivered kWh × that end use's fuel unit rate) + per-meter **standing
|
||||
charges** (metered fuels only — gas/electricity; oil/LPG/solid have none) − **SEG** export credit on
|
||||
PV. Off-peak electricity splits day/night via the calculator's existing Table 12a high/low-rate
|
||||
fractions.
|
||||
|
||||
### 5. Strict-raise on an unpriced fuel
|
||||
|
||||
`BillDerivation` **raises** on a fuel it has no rate for — same discipline as the calculator. Two
|
||||
named gaps surface immediately rather than billing at a wrong default:
|
||||
- **House coal** — no standard domestic price (its domestic sale is illegal in England).
|
||||
- **Communal / heat network** — scheme-specific, no national tariff. The one common case (flats);
|
||||
a heat-network rate model is a named follow-up.
|
||||
|
||||
### 6. Persistence: flat per-section columns on `property_baseline_performance`
|
||||
|
||||
The energy block lands as **flat typed columns** on the existing row (ADR-0004's flat-column rule
|
||||
holds — the SAP end-uses are a *fixed enumerable set*, so there is no column explosion and no
|
||||
variable-shape JSON): per-section `*_kwh` + `*_cost_gbp` (heating, hot water, lighting, appliances,
|
||||
cooking, pumps/fans), `standing_charges_gbp`, `seg_credit_gbp`, and `total_annual_bill_gbp`. The
|
||||
production migration is FE-owned (Drizzle); `docs/migrations/` updated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `BillDerivation` is named for the operation, **no "Service" suffix** (user preference).
|
||||
- A `Fuel` enum + a SAP-code→`Fuel` mapping become first-class; `FuelRates` + `FuelRatesRepository`
|
||||
+ a committed snapshot file are new.
|
||||
- Carbon emissions are unaffected (they stay on Lodged/Effective Performance from the calculator's
|
||||
CO2 factors); this ADR is about £ bills only.
|
||||
- The snapshot goes stale on the Ofgem-cap cadence (quarterly); the file records its period, and the
|
||||
ETL that automates refresh is the deferred follow-up.
|
||||
|
||||
## Deferred / TODO
|
||||
|
||||
- **Appliances + cooking kWh** are computed inside `cert_to_inputs` (Appendix L L13-L20) but not
|
||||
yet threaded onto `SapResult`. Until they are, the `SapResult` → `EnergyBreakdown` adapter
|
||||
**stubs them at 0 kWh**, so the bill total currently understates by the unregulated electricity
|
||||
load. Khalim is adding the fields to `SapResult` directly; the adapter wires the
|
||||
`APPLIANCES`/`COOKING` sections in as soon as they land.
|
||||
- **Off-peak (Economy 7) day/night split** — the snapshot carries the E7 day/night rates, but
|
||||
`FuelRates` exposes single-rate fuels only; the day/night accessor + the calculator's Table 12a
|
||||
high/low-rate split land in a later slice.
|
||||
- **Heat-network rate model** — heat-network certs raise `UnpricedFuel` for now (the one common gap).
|
||||
- **Regional rates + Ofgem-cap ETL** — national snapshot now; both are later refinements behind the
|
||||
same `FuelRatesRepository` port.
|
||||
|
||||
## Considered alternatives
|
||||
|
||||
- **Bill from `RenewableHeatIncentive` heating+HW kWh only** (CONTEXT's original scope) — rejected:
|
||||
the user wants the whole-home bill, and heating+HW omits lighting/appliances/cooking, which only
|
||||
the calculator supplies.
|
||||
- **Bill at SAP Table 32 prices** — rejected: standardised rating prices, ~half real electricity.
|
||||
- **JSON `bill_breakdown` block** — rejected: end-uses are fixed-cardinality, so flat columns are
|
||||
clean and stay queryable (ADR-0004).
|
||||
|
|
@ -27,17 +27,45 @@ straight lift-and-shift of the columns below.
|
|||
| `effective_co2_emissions_t_per_yr` | float | tonnes CO₂/yr (whole dwelling) |
|
||||
| `effective_primary_energy_intensity_kwh_per_m2_yr` | int | kWh/m²/yr |
|
||||
| `rebaseline_reason` | text | `none` \| `pre_sap10` \| `physical_state_changed` \| `both` |
|
||||
| `space_heating_kwh` | float | off `renewable_heat_incentive`; deterministic (ADR-0006) |
|
||||
| `water_heating_kwh` | float | off `renewable_heat_incentive` |
|
||||
| `space_heating_kwh` | float | EPC `renewable_heat_incentive` recorded demand. **Superseded** by `heating_kwh` (delivered) when the bill block populates; kept until then to avoid an empty-kWh gap, dropped in the population slice. |
|
||||
| `water_heating_kwh` | float | EPC `renewable_heat_incentive`; **superseded** by `hot_water_kwh`. |
|
||||
|
||||
This slice has no ML rebaselining, so `effective_* == lodged_*` and `rebaseline_reason = 'none'`
|
||||
for every row written (a pre-SAP10 cert raises rather than persisting a wrong-but-plausible row —
|
||||
see #1135). The `effective_*` columns exist now so the table shape is stable when ML lands.
|
||||
### Bill block (ADR-0014) — the energy bill, composed per section
|
||||
|
||||
## Deferred (follow-up — EPC Energy Derivation + Fuel Rates)
|
||||
Produced by **Bill Derivation**: the calculator's **delivered** kWh per end use priced at current
|
||||
**Fuel Rates** (a committed snapshot, not SAP's standardised prices), per section + the total.
|
||||
Per-section kWh is *delivered fuel* (demand ÷ efficiency — what the household pays for), distinct
|
||||
from the recorded-demand `space_heating_kwh`/`water_heating_kwh` above which it supersedes.
|
||||
|
||||
`fuel_split` and `bills` are **not** in this table yet. They are produced by
|
||||
`EpcEnergyDerivationService`, which needs a current **Fuel Rates** source (Ofgem-cap ETL) that does
|
||||
not exist yet. They land together in the follow-up so this table is not migrated twice. Likely
|
||||
shape: a `bills`-style block (per-fuel kWh + standing charge + SEG) — to be specified in that
|
||||
slice's migration note.
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `fuel_rates_period` | text | which Fuel Rates snapshot priced this bill (e.g. `"2026-04 to 2026-06"`) — provenance |
|
||||
| `heating_kwh` | float | delivered fuel kWh (main + secondary heating) |
|
||||
| `heating_cost_gbp` | float | priced at the heating fuel's current rate |
|
||||
| `hot_water_kwh` | float | |
|
||||
| `hot_water_cost_gbp` | float | |
|
||||
| `lighting_kwh` | float | |
|
||||
| `lighting_cost_gbp` | float | |
|
||||
| `appliances_kwh` | float | unregulated load — **0 until the appliances/cooking fields land on `SapResult`** (ADR-0014 TODO) |
|
||||
| `appliances_cost_gbp` | float | |
|
||||
| `cooking_kwh` | float | unregulated load — 0 until `SapResult` carries it |
|
||||
| `cooking_cost_gbp` | float | |
|
||||
| `pumps_fans_kwh` | float | |
|
||||
| `pumps_fans_cost_gbp` | float | |
|
||||
| `cooling_kwh` | float | mostly 0 in UK homes; carried for completeness as it affects the bill |
|
||||
| `cooling_cost_gbp` | float | |
|
||||
| `standing_charges_gbp` | float | daily standing charge × 365, once per distinct metered fuel (off-gas fuels have none) |
|
||||
| `seg_credit_gbp` | float | SEG export credit on PV (subtracted) |
|
||||
| `total_annual_bill_gbp` | float | Σ section costs + standing charges − SEG |
|
||||
|
||||
The calculator is **load-bearing** (ADR-0013 amendment): for `sap_version < 10.2` the `effective_*`
|
||||
columns hold the calculator's output (so `effective_* != lodged_*` legitimately); at/above 10.2 they
|
||||
mirror the lodged figures and divergence is logged. A cert the calculator cannot score aborts the
|
||||
batch rather than persisting a wrong row.
|
||||
|
||||
### Population timing
|
||||
|
||||
The bill columns are **defined now so the FE can create them**, but are populated only once the
|
||||
`SapResult` → `EnergyBreakdown` adapter + `BillDerivation` wiring land (gated on the appliances /
|
||||
cooking `SapResult` fields). Until then the SQLModel mirror in `infrastructure/postgres/` adds these
|
||||
columns as nullable; the Drizzle migration can create them nullable in parallel.
|
||||
|
|
|
|||
0
domain/fuel_rates/__init__.py
Normal file
0
domain/fuel_rates/__init__.py
Normal file
43
domain/fuel_rates/fuel.py
Normal file
43
domain/fuel_rates/fuel.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class Fuel(Enum):
|
||||
"""A canonical billing fuel — the join key between the calculator's
|
||||
per-end-use fuel (mapped from SAP fuel codes) and the Fuel Rates snapshot
|
||||
(ADR-0014). Member names match the snapshot's keys.
|
||||
|
||||
``COAL`` (traditional house coal) and ``HEAT_NETWORK`` are carried as
|
||||
members so a cert lodging them maps to a Fuel, but they have no national
|
||||
rate — pricing them raises ``UnpricedFuel`` (house coal's domestic sale is
|
||||
illegal in England; heat networks are scheme-specific).
|
||||
"""
|
||||
|
||||
MAINS_GAS = "MAINS_GAS"
|
||||
ELECTRICITY = "ELECTRICITY"
|
||||
ELECTRICITY_OFF_PEAK = "ELECTRICITY_OFF_PEAK"
|
||||
OIL = "OIL"
|
||||
LPG = "LPG"
|
||||
COAL = "COAL"
|
||||
SMOKELESS = "SMOKELESS"
|
||||
WOOD_LOGS = "WOOD_LOGS"
|
||||
WOOD_PELLETS = "WOOD_PELLETS"
|
||||
HEAT_NETWORK = "HEAT_NETWORK"
|
||||
|
||||
|
||||
class UnpricedFuel(ValueError):
|
||||
"""Bill Derivation was asked for a rate on a fuel the current Fuel Rates
|
||||
snapshot does not price (ADR-0014).
|
||||
|
||||
Raised rather than billing at a wrong default so the gap surfaces
|
||||
immediately — house coal and heat networks have no national rate, and
|
||||
off-peak electricity needs the day/night split that a later slice adds.
|
||||
"""
|
||||
|
||||
def __init__(self, fuel: Fuel) -> None:
|
||||
super().__init__(
|
||||
f"no rate for fuel {fuel.name} in the current Fuel Rates snapshot; "
|
||||
f"add it to the snapshot or map this end use to a priced fuel"
|
||||
)
|
||||
self.fuel = fuel
|
||||
46
domain/fuel_rates/fuel_rates.py
Normal file
46
domain/fuel_rates/fuel_rates.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FuelRate:
|
||||
"""One fuel's current tariff: unit price + daily standing charge.
|
||||
|
||||
Off-gas fuels (oil / LPG / solid / wood) carry a ``0.0`` standing charge —
|
||||
they are delivered, not metered, so there is no daily charge.
|
||||
"""
|
||||
|
||||
unit_rate_p_per_kwh: float
|
||||
standing_charge_p_per_day: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FuelRates:
|
||||
"""A current Fuel Rates snapshot — the rate per billing Fuel plus the SEG
|
||||
export credit (ADR-0014). ``period`` records which window it is for, since
|
||||
a committed snapshot goes stale on the Ofgem-cap (quarterly) cadence.
|
||||
|
||||
Pricing a fuel the snapshot does not carry raises ``UnpricedFuel`` rather
|
||||
than defaulting — see [[reference-unmapped-sap-code]] for the same strict
|
||||
discipline on the calculator side.
|
||||
"""
|
||||
|
||||
period: str
|
||||
seg_export_p_per_kwh: float
|
||||
rates: Mapping[Fuel, FuelRate]
|
||||
|
||||
def unit_rate_p_per_kwh(self, fuel: Fuel) -> float:
|
||||
return self._rate(fuel).unit_rate_p_per_kwh
|
||||
|
||||
def standing_charge_p_per_day(self, fuel: Fuel) -> float:
|
||||
return self._rate(fuel).standing_charge_p_per_day
|
||||
|
||||
def _rate(self, fuel: Fuel) -> FuelRate:
|
||||
rate = self.rates.get(fuel)
|
||||
if rate is None:
|
||||
raise UnpricedFuel(fuel)
|
||||
return rate
|
||||
58
domain/property_baseline/bill.py
Normal file
58
domain/property_baseline/bill.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel
|
||||
|
||||
|
||||
class BillSection(Enum):
|
||||
"""A user-meaningful slice of the annual energy bill — the calculator's raw
|
||||
end uses folded into the sections the UI shows (ADR-0014)."""
|
||||
|
||||
HEATING = "HEATING"
|
||||
HOT_WATER = "HOT_WATER"
|
||||
LIGHTING = "LIGHTING"
|
||||
APPLIANCES = "APPLIANCES"
|
||||
COOKING = "COOKING"
|
||||
PUMPS_FANS = "PUMPS_FANS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnergyLine:
|
||||
"""One section's delivered energy on one fuel. A section may have more than
|
||||
one line (e.g. gas main heating + electric secondary heating)."""
|
||||
|
||||
section: BillSection
|
||||
fuel: Fuel
|
||||
kwh: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnergyBreakdown:
|
||||
"""A Property's delivered energy per end use, the input to Bill Derivation —
|
||||
produced from SAP10 Calculation in a later slice. ``exported_kwh`` is PV
|
||||
generation exported to the grid, credited at the SEG rate."""
|
||||
|
||||
lines: Sequence[EnergyLine]
|
||||
exported_kwh: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BillSectionCost:
|
||||
"""One section's rolled-up delivered kWh and annual cost (£)."""
|
||||
|
||||
kwh: float
|
||||
cost_gbp: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bill:
|
||||
"""A Property's annual energy bill, composed per section plus the per-meter
|
||||
standing charges and the SEG export credit, and the total (ADR-0014)."""
|
||||
|
||||
sections: Mapping[BillSection, BillSectionCost]
|
||||
standing_charges_gbp: float
|
||||
seg_credit_gbp: float
|
||||
total_gbp: float
|
||||
71
domain/property_baseline/bill_derivation.py
Normal file
71
domain/property_baseline/bill_derivation.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Final
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel
|
||||
from domain.fuel_rates.fuel_rates import FuelRates
|
||||
from domain.property_baseline.bill import (
|
||||
Bill,
|
||||
BillSection,
|
||||
BillSectionCost,
|
||||
EnergyBreakdown,
|
||||
)
|
||||
|
||||
_DAYS_PER_YEAR: Final[float] = 365.0
|
||||
_PENCE_PER_POUND: Final[float] = 100.0
|
||||
|
||||
|
||||
class BillDerivation:
|
||||
"""Derives a Property's annual energy Bill by pricing a delivered-energy
|
||||
breakdown at current Fuel Rates (ADR-0014).
|
||||
|
||||
Each end-use line is billed at its fuel's unit rate; **standing charges are
|
||||
added once per distinct fuel used** (a meter, not an end use — off-gas fuels
|
||||
carry a 0 standing charge so they contribute nothing); the SEG export credit
|
||||
is subtracted. Deterministic (ADR-0006). Raises ``UnpricedFuel`` (via
|
||||
``FuelRates``) on a fuel the snapshot does not price.
|
||||
"""
|
||||
|
||||
def __init__(self, fuel_rates: FuelRates) -> None:
|
||||
self._rates = fuel_rates
|
||||
|
||||
def derive(self, breakdown: EnergyBreakdown) -> Bill:
|
||||
section_kwh: defaultdict[BillSection, float] = defaultdict(float)
|
||||
section_cost_p: defaultdict[BillSection, float] = defaultdict(float)
|
||||
fuels_used: set[Fuel] = set()
|
||||
for line in breakdown.lines:
|
||||
section_kwh[line.section] += line.kwh
|
||||
section_cost_p[line.section] += (
|
||||
line.kwh * self._rates.unit_rate_p_per_kwh(line.fuel)
|
||||
)
|
||||
if line.kwh > 0:
|
||||
fuels_used.add(line.fuel)
|
||||
|
||||
sections = {
|
||||
section: BillSectionCost(
|
||||
kwh=section_kwh[section], cost_gbp=section_cost_p[section] / _PENCE_PER_POUND
|
||||
)
|
||||
for section in section_kwh
|
||||
}
|
||||
standing_charges_gbp = (
|
||||
sum(
|
||||
(self._rates.standing_charge_p_per_day(fuel) * _DAYS_PER_YEAR for fuel in fuels_used),
|
||||
0.0,
|
||||
)
|
||||
/ _PENCE_PER_POUND
|
||||
)
|
||||
seg_credit_gbp = (
|
||||
breakdown.exported_kwh * self._rates.seg_export_p_per_kwh / _PENCE_PER_POUND
|
||||
)
|
||||
total_gbp = (
|
||||
sum((section.cost_gbp for section in sections.values()), 0.0)
|
||||
+ standing_charges_gbp
|
||||
- seg_credit_gbp
|
||||
)
|
||||
return Bill(
|
||||
sections=sections,
|
||||
standing_charges_gbp=standing_charges_gbp,
|
||||
seg_credit_gbp=seg_credit_gbp,
|
||||
total_gbp=total_gbp,
|
||||
)
|
||||
94
domain/property_baseline/calculator_rebaseliner.py
Normal file
94
domain/property_baseline/calculator_rebaseliner.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from domain.property_baseline.performance import Performance
|
||||
from domain.property_baseline.rebaseliner import Rebaseliner, RebaselineReason
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.sap10_calculator.calculator import SapCalculator, SapResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The calculator targets SAP 10.2 (14-03-2025). A cert lodged below this carries
|
||||
# a superseded methodology and is rebaselined to the calculator's output; at or
|
||||
# above it, the API's lodged figures are kept and the calculator only validates.
|
||||
_SAP10_2_FLOOR = 10.2
|
||||
_SAP_ABS_TOL = 0.5
|
||||
_REL_TOL = 0.01
|
||||
_KG_PER_TONNE = 1000.0
|
||||
|
||||
|
||||
def _relative_diff(calculated: float, lodged: float) -> float:
|
||||
if lodged == 0:
|
||||
return 0.0 if calculated == 0 else float("inf")
|
||||
return abs(calculated - lodged) / abs(lodged)
|
||||
|
||||
|
||||
class CalculatorRebaseliner(Rebaseliner):
|
||||
"""Produces Effective Performance from the deterministic `Sap10Calculator`
|
||||
(ADR-0013 amendment — the calculator is load-bearing).
|
||||
|
||||
Runs the calculator on every Property. For a cert lodged under a superseded
|
||||
methodology (``sap_version < 10.2``) the calculator's output **is** Effective
|
||||
Performance. At or above 10.2 the API's lodged figures are kept and the
|
||||
calculator only **logs divergence** (a validation signal). A calculator
|
||||
strict-raise propagates — the batch aborts (ADR-0012) and the un-mapped cert
|
||||
is fixed immediately.
|
||||
"""
|
||||
|
||||
def __init__(self, calculator: "SapCalculator") -> None:
|
||||
self._calculator = calculator
|
||||
|
||||
def rebaseline(
|
||||
self, property_id: int, effective_epc: "EpcPropertyData", lodged: Performance
|
||||
) -> tuple[Performance, RebaselineReason]:
|
||||
# A raise (UnmappedSapCode, etc.) propagates: the calculator is
|
||||
# load-bearing, so the batch aborts and the cert is fixed at once.
|
||||
result: SapResult = self._calculator.calculate(effective_epc)
|
||||
sap_version: Optional[float] = effective_epc.sap_version
|
||||
if sap_version is not None and sap_version < _SAP10_2_FLOOR:
|
||||
return Performance.from_sap_result(result), "pre_sap10"
|
||||
self._log_divergence(
|
||||
property_id=property_id, sap_version=sap_version, result=result, lodged=lodged
|
||||
)
|
||||
return lodged, "none"
|
||||
|
||||
def _log_divergence(
|
||||
self,
|
||||
*,
|
||||
property_id: int,
|
||||
sap_version: Optional[float],
|
||||
result: "SapResult",
|
||||
lodged: Performance,
|
||||
) -> None:
|
||||
if abs(result.sap_score_continuous - lodged.sap_score) > _SAP_ABS_TOL:
|
||||
self._warn(property_id, sap_version, "sap_score", lodged.sap_score, result.sap_score_continuous)
|
||||
if _relative_diff(result.primary_energy_kwh_per_m2, lodged.primary_energy_intensity) > _REL_TOL:
|
||||
self._warn(
|
||||
property_id, sap_version, "primary_energy_intensity",
|
||||
lodged.primary_energy_intensity, result.primary_energy_kwh_per_m2,
|
||||
)
|
||||
calculated_co2_t = result.co2_kg_per_yr / _KG_PER_TONNE
|
||||
if _relative_diff(calculated_co2_t, lodged.co2_emissions) > _REL_TOL:
|
||||
self._warn(property_id, sap_version, "co2_emissions", lodged.co2_emissions, calculated_co2_t)
|
||||
|
||||
def _warn(
|
||||
self,
|
||||
property_id: int,
|
||||
sap_version: Optional[float],
|
||||
quantity: str,
|
||||
lodged: float,
|
||||
calculated: float,
|
||||
) -> None:
|
||||
logger.warning(
|
||||
"SAP10 calculator divergence on %s for property_id=%s sap_version=%s: "
|
||||
"lodged=%s calculated=%s",
|
||||
quantity,
|
||||
property_id,
|
||||
sap_version,
|
||||
lodged,
|
||||
calculated,
|
||||
)
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, TypeVar
|
||||
from typing import Optional, TYPE_CHECKING, TypeVar
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from domain.sap10_calculator.calculator import SapResult
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_KG_PER_TONNE = 1000.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -24,6 +28,20 @@ class Performance:
|
|||
co2_emissions: float
|
||||
primary_energy_intensity: int
|
||||
|
||||
@classmethod
|
||||
def from_sap_result(cls, result: "SapResult") -> "Performance":
|
||||
"""The four rated quantities, read off a calculator `SapResult`
|
||||
(ADR-0013): band derived from the score, CO2 converted kg→tonnes, PEUI
|
||||
rounded to the lodged integer scale. The `from_*` factory mirrors
|
||||
`Epc.from_sap_score`; living on the target keeps the SAP calculator
|
||||
free of any `property_baseline` dependency."""
|
||||
return cls(
|
||||
sap_score=result.sap_score,
|
||||
epc_band=Epc.from_sap_score(result.sap_score),
|
||||
co2_emissions=result.co2_kg_per_yr / _KG_PER_TONNE,
|
||||
primary_energy_intensity=round(result.primary_energy_kwh_per_m2),
|
||||
)
|
||||
|
||||
|
||||
def _require(value: Optional[_T], field: str) -> _T:
|
||||
if value is None:
|
||||
|
|
|
|||
|
|
@ -36,20 +36,22 @@ class Rebaseliner(ABC):
|
|||
|
||||
@abstractmethod
|
||||
def rebaseline(
|
||||
self, effective_epc: EpcPropertyData, lodged: Performance
|
||||
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
||||
) -> tuple[Performance, RebaselineReason]: ...
|
||||
|
||||
|
||||
class StubRebaseliner(Rebaseliner):
|
||||
"""The no-ML stub for the validation phase.
|
||||
"""A no-calculator stub for tests that don't want the real calculator.
|
||||
|
||||
SAP10 certs pass through untouched — Effective Performance equals Lodged,
|
||||
reason ``"none"``. A pre-SAP10 cert genuinely needs ML rebaselining, which is
|
||||
not implemented yet (#1135), so it raises rather than fabricating a "none".
|
||||
reason ``"none"``. A pre-SAP10 cert genuinely needs rebaselining, which this
|
||||
stub does not do, so it raises rather than fabricating a "none". Production
|
||||
uses ``CalculatorRebaseliner`` (the calculator is load-bearing — ADR-0013
|
||||
amendment); this stub stays for orchestrator/repo unit tests.
|
||||
"""
|
||||
|
||||
def rebaseline(
|
||||
self, effective_epc: EpcPropertyData, lodged: Performance
|
||||
self, property_id: int, effective_epc: EpcPropertyData, lodged: Performance
|
||||
) -> tuple[Performance, RebaselineReason]:
|
||||
sap_version = effective_epc.sap_version
|
||||
if sap_version is not None and sap_version < _SAP10_FLOOR:
|
||||
|
|
|
|||
41
domain/property_baseline/sap_fuel.py
Normal file
41
domain/property_baseline/sap_fuel.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel
|
||||
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
||||
|
||||
# SAP 10.2 / Table 32 fuel code -> canonical billing Fuel (ADR-0014). Bounded to
|
||||
# the ~47 Table 32 fuel codes (the keys of `table_12.UNIT_PRICE_P_PER_KWH`) — the
|
||||
# carrier, NOT the PCDB product, so a thousand PCDB heat pumps all share one code.
|
||||
# Input is a normalised Table 32 fuel code (the calculator sets `main_fuel_type`
|
||||
# to Table 32 codes); an unmapped code raises `UnmappedSapCode` rather than
|
||||
# guessing — a bounded, self-surfacing backlog [[reference-unmapped-sap-code]].
|
||||
_CODE_TO_FUEL: Final[dict[int, Fuel]] = {
|
||||
**dict.fromkeys([1, 7], Fuel.MAINS_GAS), # mains gas, grid biogas
|
||||
**dict.fromkeys([2, 3, 5, 9], Fuel.LPG),
|
||||
**dict.fromkeys([4, 71, 73, 75, 76], Fuel.OIL), # heating oil + bio-liquids
|
||||
**dict.fromkeys([11, 15], Fuel.COAL), # house coal, anthracite
|
||||
**dict.fromkeys([12], Fuel.SMOKELESS),
|
||||
**dict.fromkeys([20, 21], Fuel.WOOD_LOGS), # logs, chips
|
||||
**dict.fromkeys([22, 23], Fuel.WOOD_PELLETS),
|
||||
**dict.fromkeys([30], Fuel.ELECTRICITY), # standard tariff
|
||||
# 7/10/18-hour off-peak tariffs + 24-hour heating tariff — priced once the
|
||||
# off-peak day/night slice lands; ELECTRICITY_OFF_PEAK is unpriced until then.
|
||||
**dict.fromkeys([31, 32, 33, 34, 35, 38, 40], Fuel.ELECTRICITY_OFF_PEAK),
|
||||
# "heat from ..." community/heat-network + distribution codes (41-58).
|
||||
**dict.fromkeys(range(41, 59), Fuel.HEAT_NETWORK),
|
||||
}
|
||||
|
||||
|
||||
def sap_code_to_fuel(code: int) -> Fuel:
|
||||
"""Map a SAP 10.2 / Table 32 fuel code to its canonical billing Fuel.
|
||||
|
||||
Raises ``UnmappedSapCode`` on a code with no single billing carrier — e.g.
|
||||
dual fuel (10) or the grid-export codes (36/60), which are not an end use's
|
||||
input fuel.
|
||||
"""
|
||||
fuel = _CODE_TO_FUEL.get(code)
|
||||
if fuel is None:
|
||||
raise UnmappedSapCode("fuel_code", code)
|
||||
return fuel
|
||||
|
|
@ -41,6 +41,7 @@ Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Final, Optional, TYPE_CHECKING
|
||||
|
||||
|
|
@ -751,7 +752,21 @@ def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
|
|||
)
|
||||
|
||||
|
||||
class Sap10Calculator:
|
||||
class SapCalculator(ABC):
|
||||
"""The contract a SAP calculator satisfies: an `EpcPropertyData` in, a
|
||||
typed `SapResult` out. `Sap10Calculator` is the SAP 10.2 implementation;
|
||||
a future methodology (e.g. SAP 10.3 / a successor) is another subclass.
|
||||
|
||||
Consumers (e.g. `CalculatorRebaseliner`) depend on this abstraction, not
|
||||
on a concrete calculator — so the engine can be swapped without touching
|
||||
them.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def calculate(self, epc: "EpcPropertyData") -> SapResult: ...
|
||||
|
||||
|
||||
class Sap10Calculator(SapCalculator):
|
||||
"""Deterministic SAP 10.2 calculator entry point. Maps an
|
||||
`EpcPropertyData` to typed `CalculatorInputs` via the RdSAP-driven
|
||||
`cert_to_inputs` mapper and runs the 12-month worksheet loop.
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ class PropertyBaselineOrchestrator:
|
|||
effective_epc = prop.effective_epc
|
||||
lodged = lodged_performance(effective_epc)
|
||||
effective, reason = self._rebaseliner.rebaseline(
|
||||
effective_epc, lodged
|
||||
property_id, effective_epc, lodged
|
||||
)
|
||||
rhi = _require_rhi(effective_epc)
|
||||
baseline = PropertyBaselinePerformance(
|
||||
|
|
|
|||
0
repositories/fuel_rates/__init__.py
Normal file
0
repositories/fuel_rates/__init__.py
Normal file
27
repositories/fuel_rates/data/fuel_rates_2026_q2.json
Normal file
27
repositories/fuel_rates/data/fuel_rates_2026_q2.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"period": "2026-04 to 2026-06",
|
||||
"basis": "GB national average; Ofgem price cap (gas/electricity), DESNZ/NEP May 2026 (off-gas fuels)",
|
||||
"sources": {
|
||||
"gas_electricity": "Ofgem energy price cap unit rates and standing charges, announced 2026-02-25, cap period Apr-Jun 2026",
|
||||
"off_gas": "DESNZ QEP petroleum table (oil, May 2026) + Nottingham Energy Partnership May 2026 comparison (LPG, smokeless, wood)",
|
||||
"seg": "Solar Energy UK SEG league table, updated 2026-05-12"
|
||||
},
|
||||
"seg_export_p_per_kwh": 15.0,
|
||||
"fuels": {
|
||||
"MAINS_GAS": { "unit_rate_p_per_kwh": 5.74, "standing_charge_p_per_day": 29.09 },
|
||||
"ELECTRICITY": { "unit_rate_p_per_kwh": 24.67, "standing_charge_p_per_day": 57.21 },
|
||||
"ELECTRICITY_OFF_PEAK": { "day_p_per_kwh": 29.73, "night_p_per_kwh": 13.89, "standing_charge_p_per_day": 56.99 },
|
||||
"OIL": { "unit_rate_p_per_kwh": 9.16, "standing_charge_p_per_day": 0.0 },
|
||||
"LPG": { "unit_rate_p_per_kwh": 17.61, "standing_charge_p_per_day": 0.0 },
|
||||
"SMOKELESS": { "unit_rate_p_per_kwh": 10.0, "standing_charge_p_per_day": 0.0 },
|
||||
"WOOD_LOGS": { "unit_rate_p_per_kwh": 8.83, "standing_charge_p_per_day": 0.0 },
|
||||
"WOOD_PELLETS": { "unit_rate_p_per_kwh": 7.99, "standing_charge_p_per_day": 0.0, "_note": "bagged pellets; blown bulk is 6.76 p/kWh" },
|
||||
"COAL": null,
|
||||
"HEAT_NETWORK": null
|
||||
},
|
||||
"_gaps": {
|
||||
"COAL": "no standard domestic price (traditional house coal sale for domestic use is illegal in England)",
|
||||
"HEAT_NETWORK": "scheme-specific; no national tariff or price-cap unit rate",
|
||||
"ELECTRICITY_OFF_PEAK": "day/night split; priced once the off-peak slice adds the day/night accessor"
|
||||
}
|
||||
}
|
||||
17
repositories/fuel_rates/fuel_rates_repository.py
Normal file
17
repositories/fuel_rates/fuel_rates_repository.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from domain.fuel_rates.fuel_rates import FuelRates
|
||||
|
||||
|
||||
class FuelRatesRepository(ABC):
|
||||
"""Reads the current Fuel Rates used to price a Property's bill (ADR-0014).
|
||||
|
||||
A Repo, not a Fetcher (ADR-0011): it reads stored reference data, no live
|
||||
API call. The adapter backs onto a committed static snapshot today; an
|
||||
Ofgem-cap ETL is a future adapter behind this same port.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_current(self) -> FuelRates: ...
|
||||
43
repositories/fuel_rates/static_file_fuel_rates_repository.py
Normal file
43
repositories/fuel_rates/static_file_fuel_rates_repository.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel
|
||||
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
|
||||
from repositories.fuel_rates.fuel_rates_repository import FuelRatesRepository
|
||||
|
||||
_DEFAULT_SNAPSHOT = Path(__file__).parent / "data" / "fuel_rates_2026_q2.json"
|
||||
|
||||
|
||||
class StaticFileFuelRatesRepository(FuelRatesRepository):
|
||||
"""Reads Fuel Rates from a committed JSON snapshot (ADR-0014).
|
||||
|
||||
Only **single-rate** fuels (those lodging a ``unit_rate_p_per_kwh``) are
|
||||
exposed. Off-peak (day/night) and the unpriced gaps (null entries — house
|
||||
coal, heat network) are skipped, so pricing them raises ``UnpricedFuel``.
|
||||
The day/night accessor for off-peak lands in a later slice.
|
||||
"""
|
||||
|
||||
def __init__(self, snapshot_path: Optional[Path] = None) -> None:
|
||||
self._snapshot_path = snapshot_path or _DEFAULT_SNAPSHOT
|
||||
|
||||
def get_current(self) -> FuelRates:
|
||||
payload: dict[str, Any] = json.loads(self._snapshot_path.read_text())
|
||||
fuels: dict[str, Any] = payload["fuels"]
|
||||
rates: dict[Fuel, FuelRate] = {}
|
||||
for name, entry in fuels.items():
|
||||
if entry is None:
|
||||
continue # an unpriced gap (house coal / heat network)
|
||||
if "unit_rate_p_per_kwh" not in entry:
|
||||
continue # off-peak day/night — priced in a later slice
|
||||
rates[Fuel[name]] = FuelRate(
|
||||
unit_rate_p_per_kwh=float(entry["unit_rate_p_per_kwh"]),
|
||||
standing_charge_p_per_day=float(entry["standing_charge_p_per_day"]),
|
||||
)
|
||||
return FuelRates(
|
||||
period=str(payload["period"]),
|
||||
seg_export_p_per_kwh=float(payload["seg_export_p_per_kwh"]),
|
||||
rates=rates,
|
||||
)
|
||||
0
tests/domain/fuel_rates/__init__.py
Normal file
0
tests/domain/fuel_rates/__init__.py
Normal file
33
tests/domain/fuel_rates/test_fuel_rates.py
Normal file
33
tests/domain/fuel_rates/test_fuel_rates.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
|
||||
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
|
||||
|
||||
|
||||
def _rates() -> FuelRates:
|
||||
return FuelRates(
|
||||
period="test",
|
||||
seg_export_p_per_kwh=15.0,
|
||||
rates={Fuel.MAINS_GAS: FuelRate(unit_rate_p_per_kwh=5.74, standing_charge_p_per_day=29.09)},
|
||||
)
|
||||
|
||||
|
||||
def test_unit_rate_and_standing_charge_read_back_for_a_priced_fuel() -> None:
|
||||
# Arrange
|
||||
rates = _rates()
|
||||
|
||||
# Act / Assert
|
||||
assert rates.unit_rate_p_per_kwh(Fuel.MAINS_GAS) == 5.74
|
||||
assert rates.standing_charge_p_per_day(Fuel.MAINS_GAS) == 29.09
|
||||
|
||||
|
||||
def test_a_fuel_absent_from_the_snapshot_raises_unpriced_fuel() -> None:
|
||||
# Arrange — LPG is not in this snapshot.
|
||||
rates = _rates()
|
||||
|
||||
# Act / Assert — the raise carries the offending fuel for the operator.
|
||||
with pytest.raises(UnpricedFuel) as excinfo:
|
||||
rates.unit_rate_p_per_kwh(Fuel.LPG)
|
||||
assert excinfo.value.fuel is Fuel.LPG
|
||||
95
tests/domain/property_baseline/test_bill_derivation.py
Normal file
95
tests/domain/property_baseline/test_bill_derivation.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
|
||||
from domain.fuel_rates.fuel_rates import FuelRate, FuelRates
|
||||
from domain.property_baseline.bill import BillSection, EnergyBreakdown, EnergyLine
|
||||
from domain.property_baseline.bill_derivation import BillDerivation
|
||||
|
||||
|
||||
def _rates() -> FuelRates:
|
||||
return FuelRates(
|
||||
period="test",
|
||||
seg_export_p_per_kwh=15.0,
|
||||
rates={
|
||||
Fuel.MAINS_GAS: FuelRate(unit_rate_p_per_kwh=5.74, standing_charge_p_per_day=29.09),
|
||||
Fuel.ELECTRICITY: FuelRate(unit_rate_p_per_kwh=24.67, standing_charge_p_per_day=57.21),
|
||||
Fuel.OIL: FuelRate(unit_rate_p_per_kwh=9.16, standing_charge_p_per_day=0.0),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_derive_prices_a_single_gas_heating_line_with_its_standing_charge() -> None:
|
||||
# Arrange — 10,000 kWh of mains-gas heating.
|
||||
breakdown = EnergyBreakdown(
|
||||
lines=[EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=10000.0)]
|
||||
)
|
||||
derivation = BillDerivation(_rates())
|
||||
|
||||
# Act
|
||||
bill = derivation.derive(breakdown)
|
||||
|
||||
# Assert — heating = 10000 × 5.74p = £574; standing = 29.09p × 365 = £106.1785.
|
||||
assert abs(bill.sections[BillSection.HEATING].cost_gbp - 574.0) <= 1e-9
|
||||
assert abs(bill.standing_charges_gbp - 106.1785) <= 1e-9
|
||||
assert abs(bill.total_gbp - 680.1785) <= 1e-9
|
||||
|
||||
|
||||
def test_two_sections_on_the_same_fuel_share_one_standing_charge() -> None:
|
||||
# Arrange — gas heating + gas hot water are one meter, not two.
|
||||
breakdown = EnergyBreakdown(
|
||||
lines=[
|
||||
EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=8000.0),
|
||||
EnergyLine(section=BillSection.HOT_WATER, fuel=Fuel.MAINS_GAS, kwh=2000.0),
|
||||
]
|
||||
)
|
||||
|
||||
# Act
|
||||
bill = BillDerivation(_rates()).derive(breakdown)
|
||||
|
||||
# Assert — one gas standing charge (29.09p × 365 = £106.1785), not two.
|
||||
assert abs(bill.standing_charges_gbp - 106.1785) <= 1e-9
|
||||
assert abs(bill.sections[BillSection.HOT_WATER].cost_gbp - 114.8) <= 1e-9
|
||||
|
||||
|
||||
def test_distinct_fuels_each_add_their_own_standing_charge() -> None:
|
||||
# Arrange — gas heating + electric lighting: two meters.
|
||||
breakdown = EnergyBreakdown(
|
||||
lines=[
|
||||
EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=8000.0),
|
||||
EnergyLine(section=BillSection.LIGHTING, fuel=Fuel.ELECTRICITY, kwh=500.0),
|
||||
]
|
||||
)
|
||||
|
||||
# Act
|
||||
bill = BillDerivation(_rates()).derive(breakdown)
|
||||
|
||||
# Assert — gas 29.09 + elec 57.21 = 86.30 p/day × 365 = £314.995.
|
||||
assert abs(bill.standing_charges_gbp - 314.995) <= 1e-9
|
||||
|
||||
|
||||
def test_exported_pv_is_credited_at_the_seg_rate() -> None:
|
||||
# Arrange — 1000 kWh exported at 15p, against a single gas heating line.
|
||||
breakdown = EnergyBreakdown(
|
||||
lines=[EnergyLine(section=BillSection.HEATING, fuel=Fuel.MAINS_GAS, kwh=10000.0)],
|
||||
exported_kwh=1000.0,
|
||||
)
|
||||
|
||||
# Act
|
||||
bill = BillDerivation(_rates()).derive(breakdown)
|
||||
|
||||
# Assert — SEG credit £150 subtracted from the £680.1785 gross.
|
||||
assert abs(bill.seg_credit_gbp - 150.0) <= 1e-9
|
||||
assert abs(bill.total_gbp - 530.1785) <= 1e-9
|
||||
|
||||
|
||||
def test_an_unpriced_fuel_in_a_line_raises() -> None:
|
||||
# Arrange — a heat-network line; the snapshot prices no heat network.
|
||||
breakdown = EnergyBreakdown(
|
||||
lines=[EnergyLine(section=BillSection.HEATING, fuel=Fuel.HEAT_NETWORK, kwh=5000.0)]
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnpricedFuel):
|
||||
BillDerivation(_rates()).derive(breakdown)
|
||||
134
tests/domain/property_baseline/test_calculator_rebaseliner.py
Normal file
134
tests/domain/property_baseline/test_calculator_rebaseliner.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from domain.property_baseline.calculator_rebaseliner import CalculatorRebaseliner
|
||||
from domain.property_baseline.performance import Performance
|
||||
from domain.sap10_calculator.calculator import SapCalculator, SapResult
|
||||
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
||||
|
||||
|
||||
def _epc(*, sap_version: Optional[float]) -> EpcPropertyData:
|
||||
epc = object.__new__(EpcPropertyData)
|
||||
epc.sap_version = sap_version
|
||||
return epc
|
||||
|
||||
|
||||
def _lodged() -> Performance:
|
||||
return Performance(
|
||||
sap_score=72, epc_band=Epc.C, co2_emissions=1.8, primary_energy_intensity=180
|
||||
)
|
||||
|
||||
|
||||
def _sap_result(
|
||||
*,
|
||||
sap_score: int = 72,
|
||||
co2_kg_per_yr: float = 1800.0,
|
||||
primary_energy_kwh_per_m2: float = 180.0,
|
||||
) -> SapResult:
|
||||
return SapResult(
|
||||
sap_score=sap_score,
|
||||
sap_score_continuous=float(sap_score),
|
||||
ecf=0.0,
|
||||
total_fuel_cost_gbp=0.0,
|
||||
co2_kg_per_yr=co2_kg_per_yr,
|
||||
space_heating_kwh_per_yr=0.0,
|
||||
space_cooling_kwh_per_yr=0.0,
|
||||
fabric_energy_efficiency_kwh_per_m2_yr=0.0,
|
||||
main_heating_fuel_kwh_per_yr=0.0,
|
||||
main_2_heating_fuel_kwh_per_yr=0.0,
|
||||
secondary_heating_fuel_kwh_per_yr=0.0,
|
||||
space_cooling_fuel_kwh_per_yr=0.0,
|
||||
hot_water_kwh_per_yr=0.0,
|
||||
pumps_fans_kwh_per_yr=0.0,
|
||||
lighting_kwh_per_yr=0.0,
|
||||
primary_energy_kwh_per_yr=0.0,
|
||||
primary_energy_kwh_per_m2=primary_energy_kwh_per_m2,
|
||||
monthly=(),
|
||||
intermediate={},
|
||||
)
|
||||
|
||||
|
||||
class _StubCalculator(SapCalculator):
|
||||
def __init__(self, result: SapResult) -> None:
|
||||
self._result = result
|
||||
|
||||
def calculate(self, epc: EpcPropertyData) -> SapResult:
|
||||
return self._result
|
||||
|
||||
|
||||
def test_pre_10_2_cert_is_rebaselined_to_the_calculator_output() -> None:
|
||||
# Arrange — a SAP 10.0 cert: lodged figures are a superseded methodology, so
|
||||
# the calculator's output becomes Effective Performance (ADR-0013 amendment).
|
||||
calculator = _StubCalculator(
|
||||
_sap_result(sap_score=70, co2_kg_per_yr=1900.0, primary_energy_kwh_per_m2=185.4)
|
||||
)
|
||||
rebaseliner = CalculatorRebaseliner(calculator)
|
||||
epc = _epc(sap_version=10.0)
|
||||
|
||||
# Act
|
||||
effective, reason = rebaseliner.rebaseline(
|
||||
property_id=10, effective_epc=epc, lodged=_lodged()
|
||||
)
|
||||
|
||||
# Assert — calculated Performance: band from the score, CO2 kg->t, PEUI rounded.
|
||||
assert effective == Performance(
|
||||
sap_score=70, epc_band=Epc.C, co2_emissions=1.9, primary_energy_intensity=185
|
||||
)
|
||||
assert reason == "pre_sap10"
|
||||
|
||||
|
||||
def test_a_10_2_cert_keeps_the_lodged_figures() -> None:
|
||||
# Arrange — a SAP 10.2 cert: the API's lodged figures are on-target, so they
|
||||
# stand; the calculator runs only to validate.
|
||||
calculator = _StubCalculator(_sap_result(sap_score=72))
|
||||
rebaseliner = CalculatorRebaseliner(calculator)
|
||||
epc = _epc(sap_version=10.2)
|
||||
|
||||
# Act
|
||||
effective, reason = rebaseliner.rebaseline(
|
||||
property_id=10, effective_epc=epc, lodged=_lodged()
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert effective == _lodged()
|
||||
assert reason == "none"
|
||||
|
||||
|
||||
def test_a_10_2_cert_logs_divergence_when_the_calculator_disagrees(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange — calculated SAP 76 vs lodged 72 (> 0.5 out) on a 10.2 cert.
|
||||
calculator = _StubCalculator(_sap_result(sap_score=76))
|
||||
rebaseliner = CalculatorRebaseliner(calculator)
|
||||
epc = _epc(sap_version=10.2)
|
||||
|
||||
# Act
|
||||
with caplog.at_level(logging.WARNING):
|
||||
rebaseliner.rebaseline(property_id=42, effective_epc=epc, lodged=_lodged())
|
||||
|
||||
# Assert — a divergence warning, tagged with property_id + sap_version.
|
||||
assert len(caplog.records) == 1
|
||||
message = caplog.records[0].getMessage()
|
||||
assert "sap_score" in message
|
||||
assert "property_id=42" in message
|
||||
assert "sap_version=10.2" in message
|
||||
|
||||
|
||||
def test_a_calculator_raise_propagates_and_aborts() -> None:
|
||||
# Arrange — the calculator is load-bearing, so a raise is not swallowed.
|
||||
class _Raising(SapCalculator):
|
||||
def calculate(self, epc: EpcPropertyData) -> SapResult:
|
||||
raise UnmappedSapCode("heat_emitter_type", 99)
|
||||
|
||||
rebaseliner = CalculatorRebaseliner(_Raising())
|
||||
epc = _epc(sap_version=10.0)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedSapCode):
|
||||
rebaseliner.rebaseline(property_id=10, effective_epc=epc, lodged=_lodged())
|
||||
|
|
@ -29,7 +29,7 @@ def test_sap10_epc_is_not_rebaselined_so_effective_equals_lodged() -> None:
|
|||
rebaseliner = StubRebaseliner()
|
||||
|
||||
# Act
|
||||
effective, reason = rebaseliner.rebaseline(epc, lodged)
|
||||
effective, reason = rebaseliner.rebaseline(10, epc, lodged)
|
||||
|
||||
# Assert — Effective Performance equals Lodged, reason "none".
|
||||
assert effective == lodged
|
||||
|
|
@ -45,4 +45,4 @@ def test_pre_sap10_epc_raises_because_rebaselining_is_not_implemented() -> None:
|
|||
|
||||
# Act / Assert
|
||||
with pytest.raises(RebaselineNotImplemented):
|
||||
rebaseliner.rebaseline(epc, _lodged())
|
||||
rebaseliner.rebaseline(10, epc, _lodged())
|
||||
|
|
|
|||
42
tests/domain/property_baseline/test_sap_fuel.py
Normal file
42
tests/domain/property_baseline/test_sap_fuel.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel
|
||||
from domain.property_baseline.sap_fuel import sap_code_to_fuel
|
||||
from domain.sap10_calculator.exceptions import UnmappedSapCode
|
||||
|
||||
|
||||
def test_mains_gas_code_maps_to_mains_gas() -> None:
|
||||
# Arrange / Act / Assert — Table 32 code 1 is mains gas.
|
||||
assert sap_code_to_fuel(1) == Fuel.MAINS_GAS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("code", "fuel"),
|
||||
[
|
||||
(1, Fuel.MAINS_GAS),
|
||||
(2, Fuel.LPG),
|
||||
(4, Fuel.OIL),
|
||||
(76, Fuel.OIL), # bioethanol — a liquid fuel row
|
||||
(11, Fuel.COAL), # house coal
|
||||
(15, Fuel.COAL), # anthracite
|
||||
(12, Fuel.SMOKELESS),
|
||||
(20, Fuel.WOOD_LOGS),
|
||||
(23, Fuel.WOOD_PELLETS),
|
||||
(30, Fuel.ELECTRICITY), # standard tariff
|
||||
(32, Fuel.ELECTRICITY_OFF_PEAK), # 7-hour tariff
|
||||
(41, Fuel.HEAT_NETWORK), # heat from electric heat pump (community)
|
||||
(50, Fuel.HEAT_NETWORK), # electricity for distribution pumping
|
||||
],
|
||||
)
|
||||
def test_table_32_codes_map_to_their_billing_fuel(code: int, fuel: Fuel) -> None:
|
||||
# Arrange / Act / Assert
|
||||
assert sap_code_to_fuel(code) == fuel
|
||||
|
||||
|
||||
def test_an_unmapped_code_raises_rather_than_guessing() -> None:
|
||||
# Arrange — code 10 (dual fuel) has no single billing fuel.
|
||||
# Act / Assert
|
||||
with pytest.raises(UnmappedSapCode):
|
||||
sap_code_to_fuel(10)
|
||||
|
|
@ -111,7 +111,8 @@ def test_first_run_baselines_through_repos_and_is_idempotent_on_rerun(
|
|||
solar_fetcher=_UnusedSolarFetcher(),
|
||||
),
|
||||
baseline=PropertyBaselineOrchestrator(
|
||||
unit_of_work=unit_of_work, rebaseliner=StubRebaseliner()
|
||||
unit_of_work=unit_of_work,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
),
|
||||
modelling=ModellingOrchestrator(
|
||||
scenario_repo=ScenarioRepository(),
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ def test_run_establishes_persists_and_commits_the_batch_once() -> None:
|
|||
property_baseline=property_baseline_repo,
|
||||
)
|
||||
orchestrator = PropertyBaselineOrchestrator(
|
||||
unit_of_work=lambda: uow, rebaseliner=StubRebaseliner()
|
||||
unit_of_work=lambda: uow,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
)
|
||||
|
||||
# Act
|
||||
|
|
@ -79,7 +80,8 @@ def test_run_raises_on_a_pre_sap10_property_and_does_not_commit() -> None:
|
|||
property_baseline=property_baseline_repo,
|
||||
)
|
||||
orchestrator = PropertyBaselineOrchestrator(
|
||||
unit_of_work=lambda: uow, rebaseliner=StubRebaseliner()
|
||||
unit_of_work=lambda: uow,
|
||||
rebaseliner=StubRebaseliner(),
|
||||
)
|
||||
|
||||
# Act / Assert — the raise propagates; the batch is neither persisted nor
|
||||
|
|
|
|||
0
tests/repositories/fuel_rates/__init__.py
Normal file
0
tests/repositories/fuel_rates/__init__.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.fuel_rates.fuel import Fuel, UnpricedFuel
|
||||
from repositories.fuel_rates.static_file_fuel_rates_repository import (
|
||||
StaticFileFuelRatesRepository,
|
||||
)
|
||||
|
||||
|
||||
def test_get_current_loads_the_committed_snapshot_mains_gas_rate() -> None:
|
||||
# Arrange
|
||||
repository = StaticFileFuelRatesRepository()
|
||||
|
||||
# Act
|
||||
rates = repository.get_current()
|
||||
|
||||
# Assert — the committed Apr–Jun 2026 snapshot prices mains gas at 5.74 p/kWh.
|
||||
assert rates.unit_rate_p_per_kwh(Fuel.MAINS_GAS) == 5.74
|
||||
|
||||
|
||||
def test_snapshot_prices_metered_and_delivered_fuels_plus_seg() -> None:
|
||||
# Arrange
|
||||
rates = StaticFileFuelRatesRepository().get_current()
|
||||
|
||||
# Act / Assert — electricity carries a daily standing charge; oil is
|
||||
# delivered (no meter) so its standing charge is 0; SEG is a flat credit.
|
||||
assert rates.unit_rate_p_per_kwh(Fuel.ELECTRICITY) == 24.67
|
||||
assert rates.standing_charge_p_per_day(Fuel.ELECTRICITY) == 57.21
|
||||
assert rates.unit_rate_p_per_kwh(Fuel.OIL) == 9.16
|
||||
assert rates.standing_charge_p_per_day(Fuel.OIL) == 0.0
|
||||
assert rates.seg_export_p_per_kwh == 15.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fuel", [Fuel.HEAT_NETWORK, Fuel.COAL, Fuel.ELECTRICITY_OFF_PEAK]
|
||||
)
|
||||
def test_unpriced_fuels_raise_rather_than_defaulting(fuel: Fuel) -> None:
|
||||
# Arrange — house coal + heat network have no national rate, and off-peak
|
||||
# needs the day/night split a later slice adds (ADR-0014).
|
||||
rates = StaticFileFuelRatesRepository().get_current()
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(UnpricedFuel):
|
||||
rates.unit_rate_p_per_kwh(fuel)
|
||||
Loading…
Add table
Reference in a new issue