mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-06-08 11:17:27 +00:00
merge conflicts
This commit is contained in:
commit
429e138ea7
179 changed files with 93689 additions and 432 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -242,6 +242,7 @@ fabric.properties
|
|||
# Locally stored data
|
||||
local_data/*
|
||||
/local_data/*
|
||||
/data/ml_training/
|
||||
etl/epc/local_data/*
|
||||
/backend/condition/sample_data/lbwf/*
|
||||
/backend/condition/sample_data/peabody/*
|
||||
|
|
@ -280,6 +281,8 @@ cache/
|
|||
*.png
|
||||
*.pptx
|
||||
*.csv
|
||||
# Tracked reference CSV: SAP enum codes (gov api /api/codes) co-located with EpcPropertyData.
|
||||
!datatypes/epc/domain/epc_codes.csv
|
||||
*.xlsx
|
||||
# *.pdf
|
||||
**/Chunks/
|
||||
|
|
|
|||
1
.idea/.name
generated
Normal file
1
.idea/.name
generated
Normal file
|
|
@ -0,0 +1 @@
|
|||
AGENTS.md
|
||||
14
.idea/webResources.xml
generated
Normal file
14
.idea/webResources.xml
generated
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="WebResourcesPaths">
|
||||
<contentEntries>
|
||||
<entry url="file://$PROJECT_DIR$">
|
||||
<entryData>
|
||||
<resourceRoots>
|
||||
<path value="file://$PROJECT_DIR$" />
|
||||
</resourceRoots>
|
||||
</entryData>
|
||||
</entry>
|
||||
</contentEntries>
|
||||
</component>
|
||||
</project>
|
||||
14
CLAUDE.md
14
CLAUDE.md
|
|
@ -7,28 +7,26 @@ Five Claude Code skills are installed in this repo's dev container. Each maps to
|
|||
|-------|--------|-------------|
|
||||
| **grill-me** | `/grill-me` | Before implementing — stress-tests a design through sequential questioning |
|
||||
| **to-prd** | `/to-prd` | After a planning conversation — formalises context into a GitHub issue PRD |
|
||||
| **ubiquitous-language** | `/ubiquitous-language` | When domain terms are drifting or ambiguous — builds/updates `UBIQUITOUS_LANGUAGE.md` |
|
||||
| **grill-with-docs** | `/grill-with-docs` | When domain terms are drifting or new concepts are landing — challenges plans against `CONTEXT.md`, sharpens terminology inline, and writes ADRs for load-bearing decisions in `docs/adr/`. Replaces the older `ubiquitous-language` skill. |
|
||||
| **tdd** | `/tdd` | During implementation — enforces vertical-slice TDD (one test → one impl → repeat) |
|
||||
| **improve-codebase-architecture** | `/improve-codebase-architecture` | During refactoring — surfaces shallow modules and proposes deepening opportunities |
|
||||
|
||||
Domain glossary lives at [CONTEXT.md](./CONTEXT.md); load-bearing decisions live at [docs/adr/](./docs/adr/). The legacy [UBIQUITOUS_LANGUAGE.md](./UBIQUITOUS_LANGUAGE.md) is a redirect.
|
||||
|
||||
### Typical session chains
|
||||
|
||||
**Feature planning:**
|
||||
`/grill-me` → `/to-prd` → `/ubiquitous-language`
|
||||
`/grill-me` → `/to-prd` → `/grill-with-docs`
|
||||
|
||||
**Implementation:**
|
||||
`/tdd` (+ `/grill-me` if a design fork appears mid-session)
|
||||
|
||||
**Refactoring:**
|
||||
`/improve-codebase-architecture` → `/grill-me` → `/tdd` → `/ubiquitous-language`
|
||||
`/improve-codebase-architecture` → `/grill-me` → `/tdd` → `/grill-with-docs`
|
||||
|
||||
### First time setting up?
|
||||
|
||||
New containers install all skills automatically via the Dockerfile. If you're in an existing container, run:
|
||||
|
||||
```bash
|
||||
bash .devcontainer/backend/install-claude-skills.sh
|
||||
```
|
||||
Skills are installed automatically when the dev container is built, via the postCreate step that pulls from `Hestia-Homes/agentic-toolkit` (see `.devcontainer/backend/Dockerfile`). If an existing container is missing skills, rebuild the dev container.
|
||||
|
||||
## Type Safety
|
||||
|
||||
|
|
|
|||
306
CONTEXT.md
Normal file
306
CONTEXT.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# Ara
|
||||
|
||||
The Domna product for domestic retrofit modelling: ingests open-source EPC data, lets users correct or supersede it with their own surveys, and produces optimised retrofit packages for each property in a portfolio.
|
||||
|
||||
## Language
|
||||
|
||||
### Product
|
||||
|
||||
**Ara**:
|
||||
The Domna product. Latin for "the altar"; named under Domna's classical-naming convention. Covers both the modelling product and the backend that powers it.
|
||||
_Avoid_: ARA (acronym style), v2 backend, the new backend
|
||||
|
||||
**Domna**:
|
||||
The company. Roman name; sibling to Ara in the same naming convention.
|
||||
|
||||
### Energy Performance Certificates
|
||||
|
||||
**EPC**:
|
||||
An Energy Performance Certificate — a government-issued document rating a dwelling's energy efficiency from A (best) to G (worst).
|
||||
_Avoid_: energy certificate, energy report
|
||||
|
||||
**Certificate Number**:
|
||||
The unique identifier assigned to an EPC by the government registry.
|
||||
_Avoid_: cert number, EPC ID
|
||||
|
||||
**Registration Date**:
|
||||
The date an EPC was lodged with the government register; used to identify the most recent certificate for a property.
|
||||
_Avoid_: assessment date, submission date
|
||||
|
||||
**EPC Band**:
|
||||
A single letter A–G representing a property's current or potential energy efficiency rating.
|
||||
_Avoid_: energy rating, EPC grade, EPC score
|
||||
|
||||
**Schema Type**:
|
||||
The versioned RdSAP or SAP schema that describes the structure of an EPC's raw data (e.g. `RdSAP-Schema-21.0.1`).
|
||||
_Avoid_: schema version, EPC format
|
||||
|
||||
**Domestic Certificate**:
|
||||
An EPC issued for a residential dwelling, as opposed to a commercial one.
|
||||
_Avoid_: residential EPC, home EPC
|
||||
|
||||
### Properties and addresses
|
||||
|
||||
**Property**:
|
||||
The Ara domain aggregate representing a single dwelling under modelling: its identity, source data, enrichments, and modelling outputs.
|
||||
_Avoid_: dwelling, unit, home, asset
|
||||
|
||||
**Properties**:
|
||||
A first-class collection of Property objects; the unit of bulk operation in services.
|
||||
_Avoid_: property list, batch (used for SQS chunks)
|
||||
|
||||
**UPRN**:
|
||||
Unique Property Reference Number — the government-issued permanent identifier for a physical address in the UK.
|
||||
_Avoid_: property ID, address ID, code
|
||||
|
||||
**Postcode**:
|
||||
A UK postal code used to group nearby addresses; the primary search key for finding EPC records.
|
||||
_Avoid_: zip code, postal code
|
||||
|
||||
**User Address**:
|
||||
A structured dataclass (`domain.addresses.user_address.UserAddress`) capturing a customer-supplied address: a free-text `user_address` line, a canonical `postcode` (sanitised on construction), and an optional `internal_reference`. The bare string sense — the raw free-text address line as it arrives from upstream ingestion, before being wrapped — remains valid when discussing CSV columns, API payloads, or other upstream contexts; in domain code, prefer the dataclass.
|
||||
_Avoid_: user input, raw address, user_inputed_address
|
||||
|
||||
**Comparable Properties**:
|
||||
The reference cohort matched to a target Property by both geographic proximity (postcode prefix / UPRN range) and physical similarity (property type, built form, age band); used by the EPC Prediction Service for gap-filling and anomaly detection.
|
||||
_Avoid_: neighbours, similar properties, peer set
|
||||
|
||||
### Source data
|
||||
|
||||
**Site Notes**:
|
||||
The full-coverage record produced by a Domna survey of a single Property; carries every EPC field the modelling pipeline requires, and when present supersedes the public EPC for that Property — except when the public EPC is newer.
|
||||
_Avoid_: energy assessment, site survey, field survey, Domna survey, Hestia survey
|
||||
|
||||
**Landlord Overrides**:
|
||||
Property data supplied by a landlord that may correct or supplement the public EPC for a single Property; triggers Rebaselining when applied; not applicable when Site Notes are present.
|
||||
_Avoid_: patches (deprecated), corrections, manual EPC, edits
|
||||
|
||||
### Modelling
|
||||
|
||||
**Effective EPC**:
|
||||
The EpcPropertyData scored by the modelling pipeline for a single Property, derived from either Site Notes alone or the public EPC with Landlord Overrides applied; carries source-derived physical fields and originally recorded performance values, with model-rebaselined performance held separately in Baseline Performance.
|
||||
_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]].
|
||||
_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.
|
||||
_Avoid_: baseline predictions, predicted baseline, rebaselined values
|
||||
|
||||
**Lodged Performance**:
|
||||
The SAP / EPC Band / carbon emissions / heat demand recorded on the public EPC (or the Site Notes' as-surveyed values when Site Notes are the source) — unmodified by modelling. The half of Baseline Performance that says "what the government register says about this Property".
|
||||
_Avoid_: original performance, raw EPC values, recorded baseline
|
||||
|
||||
**Effective Performance**:
|
||||
The SAP / EPC Band / carbon emissions / heat demand 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".
|
||||
_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
|
||||
|
||||
**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.
|
||||
_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**:
|
||||
The dated revision of the SAP specification that produced a given SAP/PEUI/CO2 value. Domain-meaningful because the same EpcPropertyData yields different `sap_score` under different spec versions — fuel-price tables, CO2 factors, PCDB references, and rating-equation deflators all change between revisions. **Lodged Performance** carries the version current when the cert was lodged (mostly SAP 10.1 / SAP 10.2 pre- and post-14-03-2025 amendment in the corpus). **Calculated SAP10 Performance** is locked to SAP 10.2 (14-03-2025). A 1-to-1 Lodged-vs-Calculated comparison therefore only makes sense within a **Validation Cohort** of certs lodged on the same spec version.
|
||||
_Avoid_: SAP version (ambiguous with the `sap_version` field on the cert, which only carries the major version like 10.2 — not the amendment date), spec revision
|
||||
|
||||
**Validation Cohort**:
|
||||
The subset of corpus certs used to validate **SAP10 Calculation** against **Lodged Performance**, filtered to certs lodged after the calculator's target **SAP Spec Version** rolled out in commercial assessor software — currently `inspection_date ≥ 2025-07-01` (a buffer past 14-03-2025 to allow vendor rollout). Smaller than the full corpus but each cert is comparable under the same spec, so probe MAE is a clean signal of calculator-vs-spec correctness rather than spec-version mixture noise. ADR-0010.
|
||||
_Avoid_: parity cohort, validation set, corpus sample
|
||||
|
||||
**Measure Application**:
|
||||
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_existing_dwelling` and `.water_heating`) 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
|
||||
|
||||
**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).
|
||||
_Avoid_: UCL adjustment, energy correction, metered correction
|
||||
|
||||
**EPC Anomaly Flag**:
|
||||
A per-field indicator that a Property's value for an EPC field differs significantly from Comparable Properties; advisory only — surfaces in the UI to prompt user review, does not block modelling.
|
||||
_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.
|
||||
_Avoid_: feature builder, ML mapper, EPC vectoriser
|
||||
|
||||
**Feature Schema Version**:
|
||||
The semver version of the EPC ML Transform (e.g. `0.1.0`), included in the parquet output path and the deployed scoring lambda's tag. MAJOR bump when columns are removed or renamed; MINOR when optional columns are added; PATCH for non-behavioural fixes.
|
||||
_Avoid_: transform version, schema version (overloaded with the SAP RdSAP schema version on EPCs), model version
|
||||
|
||||
**Primary Energy Intensity** (**PEUI**):
|
||||
A Property's total annual primary energy use per square metre of floor area (kWh/m²/yr), the SAP10 quantity recorded as `energy_consumption_current` on the EPC. Covers all end uses (heating, hot water, lighting, appliances, cooking) weighted by SAP primary energy factors per fuel. The quantity the UCL Correction aligns to metered consumption.
|
||||
_Avoid_: heat demand (which colloquially means the building's space heating thermal requirement — a distinct concept), energy demand, total energy use, kWh per square metre
|
||||
|
||||
**PV Capacity Source**:
|
||||
A flag on the EPC ML Transform feature set indicating whether a Property's PV capacity is `measured` (from `sap_energy_source.photovoltaic_supply[].peak_power`), `estimated_from_roof_area` (the `percent_roof_area` fallback used when the surveyor could not confirm array configuration), or `none` (no PV present). Lets the model weight the correct capacity signal per property.
|
||||
_Avoid_: PV source, PV configuration type, solar source
|
||||
|
||||
**Top-N Code Taxonomy**:
|
||||
The empirical top-N SAP code list (covering ~95% of mass on the training sample) committed by the EPC ML Transform for each list-aggregated categorical field (`wall_construction`, `glazing_type`, `frame_material`, etc.). Rare codes go into a per-field `_other` bucket. The taxonomy is locked at each Feature Schema Version; changes warrant a MINOR bump (adding) or MAJOR bump (removing codes).
|
||||
_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.
|
||||
_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.
|
||||
_Avoid_: emission factors (ambiguous), CO2 rates
|
||||
|
||||
### Outputs
|
||||
|
||||
**Scenario**:
|
||||
A named portfolio-level retrofit plan, built by a user in the scenario-builder UI and persisted before any modelling fires; carries the overall goal (e.g. Increasing EPC), budget, exclusions, housing type, and an ordered list of Scenario Phases. The model is triggered against one or more Scenarios at once; each Scenario yields one Plan per Property.
|
||||
_Avoid_: project, batch, run-set
|
||||
|
||||
**Scenario Phase**:
|
||||
One ordered step inside a Scenario, carrying a measure-type allowlist (e.g. "loft insulation and walls in phase 1; ASHP in phase 2"), an optional phase budget, and an optional phase target. A single-phase Scenario is one Scenario Phase with all measure types allowed and the full budget on it — there is no special-case path.
|
||||
_Avoid_: scenario stage, scenario step, tranche
|
||||
|
||||
**Scenario Snapshot**:
|
||||
A frozen copy of a Scenario pinned at trigger time, keyed by (task, scenario); used by the modelling pipeline so mid-run edits to the live Scenario do not affect an in-flight job. Snapshots are read-only and may be garbage-collected after the task completes.
|
||||
_Avoid_: scenario version, frozen scenario, pinned scenario
|
||||
|
||||
**Plan**:
|
||||
The per-Property output of one Scenario's modelling run; carries an ordered list of Plan Phases matching the Scenario's Phase shape. A Property modelled against N Scenarios in one trigger ends up with N Plans.
|
||||
_Avoid_: recommendation set, output, result
|
||||
|
||||
**Plan Phase**:
|
||||
The per-Property output of one Scenario Phase: the Optimised Package selected for that phase, the ending state snapshot (the Property's SAP / kWh / bills after the package is applied), and any Rolled-over Options that flow as candidates into the next Plan Phase.
|
||||
_Avoid_: plan stage, plan step
|
||||
|
||||
**Rolled-over Options**:
|
||||
Recommendations generated but not selected by the Optimiser in a given Plan Phase, that remain eligible as candidates in subsequent Plan Phases. Exact roll-over rule (automatic vs user-marked) is under design.
|
||||
_Avoid_: deferred measures, leftover recommendations
|
||||
|
||||
**Recommendation**:
|
||||
A single proposed retrofit measure for a Property, with its cost, SAP impact, kWh savings, carbon savings, and parts list.
|
||||
_Avoid_: suggestion, option
|
||||
|
||||
**Optimised Package**:
|
||||
The subset of a Property's Recommendations selected by the Optimiser Service for installation, chosen to satisfy the Scenario's goal subject to budget.
|
||||
_Avoid_: selected measures, default measures, optimal solution, recommended bundle
|
||||
|
||||
**Measure Type**:
|
||||
The catalogue classification of a retrofit measure (e.g. `solar_pv`, `loft_insulation`, `ashp`); one or more Recommendations reference the same Measure Type with property-specific cost and impact.
|
||||
_Avoid_: measure (ambiguous), category
|
||||
|
||||
### Address matching
|
||||
|
||||
**Lexiscore**:
|
||||
A similarity score in [0, 1] between a User Address and a candidate EPC address; combines token overlap and character-level similarity.
|
||||
_Avoid_: score, match score, similarity
|
||||
|
||||
**Lexirank**:
|
||||
Dense rank of candidates sorted by Lexiscore descending; rank 1 = best match.
|
||||
_Avoid_: rank, position
|
||||
|
||||
**UPRN Candidate**:
|
||||
An EPC Search Result that is a plausible match for a given User Address, before scoring decides the winner.
|
||||
_Avoid_: match candidate, result
|
||||
|
||||
**Score Threshold**:
|
||||
The minimum Lexiscore (currently 0.6) below which no match is returned even if a candidate exists.
|
||||
_Avoid_: minimum score, cutoff
|
||||
|
||||
**Ambiguous Match**:
|
||||
A matching outcome where two or more candidates share Lexirank 1, making it impossible to select a unique winner.
|
||||
_Avoid_: tie, draw, duplicate
|
||||
|
||||
**Best Match**:
|
||||
The single UPRN Candidate with Lexirank 1 that meets or exceeds the Score Threshold.
|
||||
_Avoid_: winner, top result
|
||||
|
||||
### API and integration
|
||||
|
||||
**EPC Search Result**:
|
||||
A lightweight record returned by the government domestic search endpoint — address lines, postcode, UPRN, band, and certificate number, but not full certificate data.
|
||||
_Avoid_: search row, EPC row, result
|
||||
|
||||
**EPC Property Data**:
|
||||
The fully mapped domain object produced after fetching and parsing a complete EPC certificate; the schema the modelling pipeline operates against.
|
||||
_Avoid_: EPC data, certificate data, parsed EPC
|
||||
|
||||
**Old EPC API**:
|
||||
The retired government API (`epc.opendatacommunities.org`) using HTTP Basic auth; decommissioned 30 May 2026.
|
||||
_Avoid_: legacy API
|
||||
|
||||
**New EPC API**:
|
||||
The replacement government API (`api.get-energy-performance-data.communities.gov.uk`) using Bearer Token auth.
|
||||
_Avoid_: new API, current API
|
||||
|
||||
**Bearer Token**:
|
||||
The auth credential required by the New EPC API; stored in the `EPC_AUTH_TOKEN` environment variable.
|
||||
_Avoid_: API key, auth token, secret
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Property** represents a single physical dwelling for modelling; identified by `(portfolio_id, UPRN)` or `(portfolio_id, landlord_property_id)`.
|
||||
- A **Property** has zero or more **EPCs** across time, exactly one **Effective EPC**, zero or one set of **Site Notes**, and zero or one set of **Landlord Overrides**.
|
||||
- An **EPC** belongs to exactly one **Property** and has one **Certificate Number**.
|
||||
- An **EPC** carries an **EPC Band** and is identifiable by its **Registration Date**; the most recent one is the current.
|
||||
- A **UPRN** identifies a physical dwelling permanently; it does not change when the property changes owner — but each portfolio gets its own **Property** keyed against it.
|
||||
- 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.
|
||||
- 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.
|
||||
- A **Scenario Snapshot** is pinned at trigger time per (task, scenario) so mid-run edits to the live Scenario do not affect an in-flight modelling job.
|
||||
- A **Recommendation** references one **Measure Type** and carries property-specific cost and impact.
|
||||
- **Address Matching** uses a **User Address** and **Postcode** to find a **UPRN** by scoring **UPRN Candidates** from an EPC search. A **Lexirank** of 1 with no **Ambiguous Match** and a **Lexiscore** ≥ the **Score Threshold** produces a **Best Match**.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **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**."
|
||||
|
||||
> **Dev:** "What if the same Property also has Site Notes?"
|
||||
>
|
||||
> **Domain expert:** "**Site Notes** supersede the public **EPC**, so **Landlord Overrides** don't apply. We model from the **Site Notes** version of the **Effective EPC**. If the public **EPC** is newer than the **Site Notes**, that's the one exception — we use the newer one."
|
||||
|
||||
> **Dev:** "After modelling we end up with a list of measures. Which ones get installed?"
|
||||
>
|
||||
> **Domain expert:** "The **Optimiser Service** picks the **Optimised Package** — a subset of **Recommendations** that hits the **Scenario** goal within budget. The rest stay in the **Plan** as alternatives the user can swap in."
|
||||
|
||||
> **Dev:** "I'm looking at a property where the EPC says cavity walls but every other house on the street has solid. Is that a bug?"
|
||||
>
|
||||
> **Domain expert:** "That's an **EPC Anomaly Flag**. We compute it against the **Comparable Properties** for that postcode. It's advisory — the UI surfaces it and the landlord can apply a **Landlord Override** if it's wrong."
|
||||
|
||||
> **Dev:** "The property card shows two SAP scores side by side. Why?"
|
||||
>
|
||||
> **Domain expert:** "Those are **Lodged Performance** and **Effective Performance**. **Lodged** is what the gov register says — the EPC was rated under SAP 2012. **Effective** is what we scored against — we ran **Rebaselining** to predict the SAP10-equivalent rating because the methodology changed. Both stay on the **Baseline Performance** so users can see what's on record and what we're modelling against."
|
||||
|
||||
> **Dev:** "A landlord wants a 3-year retrofit plan — fabric work this year, heat pump next, solar after. How do we model that?"
|
||||
>
|
||||
> **Domain expert:** "Three **Scenario Phases** in one **Scenario**. Phase 1 allows fabric measures with this year's budget, phase 2 allows the heat pump with next year's budget, phase 3 allows solar. When we model, the **Optimiser Service** runs per phase against the rolling state — the heat pump is scored against the post-insulation property, not the original one. Each **Plan Phase** captures the **Optimised Package** plus the ending SAP / bills, and any **Rolled-over Options** that didn't make this phase's budget become candidates next phase."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **"property"** was historically warned against in favour of "dwelling"; that has been inverted. **Property** is now canonical for the Ara domain aggregate. Legacy code still uses "dwelling" in places — treat as alias.
|
||||
- **"energy assessment"** in the existing codebase (`energy_assessment_functions`, `energy_assessments_by_uprn`) refers to what is now canonically called **Site Notes**. New code uses **Site Notes**.
|
||||
- **"patch"** / `patch_epc` in the existing codebase has been merged into **Landlord Overrides**; the original concept is deprecated.
|
||||
- **"already_installed measures"** in the existing codebase is likely subsumed by **Landlord Overrides** ("we have a heat pump now" → override the heating fields). Final call deferred to implementation.
|
||||
- **"address"** appears as both the raw **User Address** (free-text from customer data, or the structured `UserAddress` dataclass that wraps it) and a structured field on an **EPC Search Result** (normalised lines). Always qualify: "user address" vs "EPC address" or "address line 1". Within `domain/`, **User Address** specifically means the `UserAddress` dataclass; in upstream ingestion contexts (CSV columns, SQS payloads) it can still mean the raw string sense.
|
||||
- **"score"** is used for `AddressMatch.score()` output, the `lexiscore` column, and informally. Prefer **Lexiscore** in domain discussions; reserve "score" for method-level code comments.
|
||||
- **"user_inputed_address"** in `backend/address2UPRN/main.py` is a misspelling and a synonym for **User Address** — the canonical term. New code should use `user_address`.
|
||||
- **"EPC"** is overloaded as both the document and the rating band letter. Use **EPC** for the document, **EPC Band** for the letter.
|
||||
- **"re-scoring"** has two meanings in the codebase — **Rebaselining** (re-predicting baseline performance after an EPC change) and post-optimisation measure re-prediction. Prefer **Rebaselining** for the former; for the latter, the **Optimiser Service** step does its own scoring without a special name.
|
||||
- **"phase"** appears in two unrelated contexts: as cut-over timeline language in the PRD ("Phase 0 — Status quo", "Phase 1 — Forced cut-over") and as a domain concept in **Scenario Phase** / **Plan Phase**. Only the latter is a glossary term; cut-over phases are project-management vocabulary that does not enter code.
|
||||
- **"stale"** appears in two senses: cache-freshness ("a Repo record is stale and the orchestrator should refetch") — a legitimate operational concept; and as loose shorthand for the EPC's recorded cost fields being unusable. The cost fields are not stale — they are pinned to the inspection-date fuel rates by design. Use "pinned to inspection date" or "pre-SAP10 schema" (whichever applies) instead.
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
# Install PostgreSQL binaries — required by pytest-postgresql to spawn ephemeral test databases
|
||||
# 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.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends postgresql \
|
||||
&& apt-get install -y --no-install-recommends postgresql poppler-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
|
|
|||
|
|
@ -1,90 +1,7 @@
|
|||
# Ubiquitous Language
|
||||
|
||||
Domain terminology glossary for this project. Generated and maintained by the `/ubiquitous-language` Claude Code skill.
|
||||
This file has been **superseded by [CONTEXT.md](./CONTEXT.md)**.
|
||||
|
||||
Invoke `/ubiquitous-language` in any session to extract new terms from the conversation, flag ambiguities, and update this file with canonical definitions.
|
||||
The project's domain glossary now lives at the repo root in `CONTEXT.md`, maintained by the `/grill-with-docs` skill (which replaced `/ubiquitous-language`).
|
||||
|
||||
---
|
||||
|
||||
## Energy Performance Certificates
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
|------|------------|------------------|
|
||||
| **EPC** | An Energy Performance Certificate — a government-issued document rating a dwelling's energy efficiency from A (best) to G (worst). | "energy certificate", "energy report" |
|
||||
| **Certificate Number** | The unique identifier assigned to an EPC by the government registry. | "cert number", "EPC ID" |
|
||||
| **Registration Date** | The date an EPC was lodged with the government register; used to identify the most recent certificate for a property. | "assessment date", "submission date" |
|
||||
| **EPC Band** | A single letter A–G representing a property's current or potential energy efficiency rating. | "energy rating", "EPC grade", "EPC score" |
|
||||
| **Schema Type** | The versioned RdSAP or SAP schema that describes the structure of a certificate's raw data (e.g. `RdSAP-Schema-21.0.1`). | "schema version", "EPC format" |
|
||||
| **Domestic Certificate** | An EPC issued for a residential dwelling, as opposed to a commercial one. | "residential EPC", "home EPC" |
|
||||
|
||||
## Properties and Addresses
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
|------|------------|------------------|
|
||||
| **UPRN** | Unique Property Reference Number — the government-issued permanent identifier for a physical address in the UK. | "property ID", "address ID", "code" |
|
||||
| **Postcode** | A UK postal code used to group nearby addresses; the primary search key for finding EPC records. | "zip code", "postal code" |
|
||||
| **Unstandardised Address** | A frozen dataclass (`domain.addresses.unstandardised_address.UnstandardisedAddress`) capturing a single address exactly as a customer supplied it, before any standardisation: a free-text `address` line (intentionally NOT normalised), a canonical `postcode` (a `Postcode` value object, sanitised on construction), an optional `org_reference` (the customer's own identifier for the property), and `additional_info` (the full source row — every column of the customer's upload, preserved verbatim). | "user address", "asset list", "raw address", "landlord address", "Hyde address" |
|
||||
| **Address List** | A nominal `NewType` over `list[UnstandardisedAddress]` (`domain.addresses.unstandardised_address.AddressList`) — a batch of unstandardised addresses, such as one customer's bulk-onboarding upload or a postcode-grouped sub-batch produced for downstream processing. Being nominal, it is constructed explicitly: `AddressList([...])`. It is the raw *input* to ingestion; the standardised *output* is a **Standardised Asset List**. | "asset list", "Hyde address list", "user addresses" |
|
||||
| **Standardised Asset List (SAL)** | A customer's property portfolio after ingestion has cleaned and standardised it — each property carrying a canonical field set (UPRN, standardised address, postcode, property type, built form, …). It is the standardised *output* of the pipeline whose raw *input* is an **Address List** of **Unstandardised Addresses**. (Legacy implementation: `asset_list.AssetList` via `load_standardised_asset_list`.) | "address list" (that is the raw input), "asset register", "portfolio list" |
|
||||
| **Dwelling** | A single residential unit that can hold an EPC — a house, flat, or maisonette. | "property", "unit", "home" |
|
||||
|
||||
## Address Matching
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
|------|------------|------------------|
|
||||
| **Lexiscore** | A similarity score in [0, 1] between an unstandardised address and a candidate EPC address; combines token overlap and character-level similarity. | "score", "match score", "similarity" |
|
||||
| **Lexirank** | Dense rank of candidates sorted by lexiscore descending; rank 1 = best match. | "rank", "position" |
|
||||
| **UPRN Candidate** | An EPC search result that is a plausible match for a given unstandardised address, before scoring decides the winner. | "match candidate", "result" |
|
||||
| **Score Threshold** | The minimum lexiscore (currently 0.6) below which no match is returned even if a candidate exists. | "minimum score", "cutoff" |
|
||||
| **Ambiguous Match** | A matching outcome where two or more candidates share lexirank 1, making it impossible to select a unique winner. | "tie", "draw", "duplicate" |
|
||||
| **Best Match** | The single UPRN candidate with lexirank 1 that meets or exceeds the score threshold. | "winner", "top result" |
|
||||
|
||||
## API and Integration
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
|------|------------|------------------|
|
||||
| **EPC Search Result** | A lightweight record returned by the government domestic search endpoint — contains address lines, postcode, UPRN, band, and certificate number but not the full certificate data. | "search row", "EPC row", "result" |
|
||||
| **EPC Property Data** | The fully mapped domain object produced after fetching and parsing a complete EPC certificate. | "EPC data", "certificate data", "parsed EPC" |
|
||||
| **Old EPC API** | The retired government API (`epc.opendatacommunities.org`) using HTTP Basic auth; decommissioned May 2026. | "legacy API" |
|
||||
| **New EPC API** | The replacement government API (`api.get-energy-performance-data.communities.gov.uk`) using Bearer token auth. | "new API", "current API" |
|
||||
| **Bearer Token** | The auth credential required by the new EPC API; stored in the `EPC_AUTH_TOKEN` environment variable. | "API key", "auth token", "secret" |
|
||||
|
||||
## Methodology
|
||||
|
||||
| Term | Definition | Aliases to avoid |
|
||||
|------|------------|------------------|
|
||||
| **DDD** | Domain-Driven Design — the design approach this glossary supports, modelling software around a shared domain language. | "domain design", "driven design" |
|
||||
|
||||
## Relationships
|
||||
|
||||
- An **EPC** belongs to exactly one **Dwelling** and has one **Certificate Number**.
|
||||
- A **Dwelling** may have multiple **EPCs** across time; the one with the most recent **Registration Date** is the current one.
|
||||
- A **UPRN** identifies a **Dwelling** permanently; it does not change when the property changes owner.
|
||||
- An **EPC Search Result** is a summary; it points to a full **EPC** via its **Certificate Number**.
|
||||
- An **Address List** is an ordered batch of **Unstandardised Addresses**; a customer's bulk-onboarding upload arrives as one.
|
||||
- Ingestion turns an **Address List** (raw input) into a **Standardised Asset List** (standardised output) — the **SAL Orchestrator** drives this.
|
||||
- **Address Matching** uses an **Unstandardised Address** and **Postcode** to find a **UPRN** by scoring **UPRN Candidates** from an EPC search.
|
||||
- A **Lexirank** of 1 with no **Ambiguous Match** and a **Lexiscore** ≥ the **Score Threshold** produces a **Best Match**.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "We have an unstandardised address and postcode. How do we find the UPRN?"
|
||||
|
||||
> **Domain expert:** "Search the **New EPC API** by **Postcode** — you get back a list of **EPC Search Results** for that area. Each one has an address and a **UPRN**. Score each against the **Unstandardised Address** using the **Lexiscore**. If the top **UPRN Candidate** scores above the **Score Threshold** and there's no **Ambiguous Match**, that's your **Best Match**."
|
||||
|
||||
> **Dev:** "What if two results share the same address line 1?"
|
||||
|
||||
> **Domain expert:** "That's an **Ambiguous Match** — two candidates at **Lexirank** 1. Fall back to scoring on the full address using all address lines joined together. If that still ties, return nothing."
|
||||
|
||||
> **Dev:** "Once we have the best match, do we use the UPRN or fetch the full EPC?"
|
||||
|
||||
> **Domain expert:** "Depends on what you need. The **EPC Search Result** gives you the **EPC Band** and **Certificate Number**. If you need energy efficiency detail, use the **Certificate Number** to fetch the full **EPC Property Data**."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **"address"** appears in several senses: the **Unstandardised Address** dataclass (one customer-supplied address before standardisation), its free-text `address` field, and the normalised address lines on an **EPC Search Result**. Always qualify: "unstandardised address" vs "EPC address" or "address line 1". Within `domain/addresses/`, the dataclass is **Unstandardised Address**; in upstream ingestion contexts (CSV columns, SQS payloads) "address" may still mean the bare free-text string.
|
||||
- **"score"** is used for the `AddressMatch.score()` function output, the `lexiscore` DataFrame column, and informally in conversation. Prefer **Lexiscore** in domain discussions; reserve "score" for method-level code comments.
|
||||
- **"user_inputed_address"** (and `user_address`) in `backend/address2UPRN/` is legacy naming — a misspelled synonym for what is now the **Unstandardised Address**. That address-matching code has not been renamed; new code should use **Unstandardised Address**.
|
||||
- **"Hyde address list"** — "Hyde" is the name of one customer, not a domain concept. A domain expert may say "the Hyde address list" because Hyde is the customer in front of them, but the generalised term is **Address List** (and **Unstandardised Address** for a single item). A customer's identity is data — it belongs in `org_reference` or `additional_info`, never in a type or module name.
|
||||
- **"address list"** vs **"asset list"** — opposite ends of the ingestion pipeline; do not conflate them. An **Address List** is the raw *input* (unstandardised addresses as the customer supplied them); a **Standardised Asset List** is the standardised *output*. The historical `AssetList` dataclass (now **Unstandardised Address**) misnamed the input an "asset list" — that mistake is what the rename corrected.
|
||||
- **"EPC"** is overloaded as both the document (an Energy Performance Certificate) and the rating band letter. Use **EPC** for the document and **EPC Band** for the letter.
|
||||
If you arrived here from a link in `CLAUDE.md` or older docs, follow the link above. This file is kept only to preserve git history and may be removed once internal references are updated.
|
||||
|
|
|
|||
783
ara_backend_design.md
Normal file
783
ara_backend_design.md
Normal file
|
|
@ -0,0 +1,783 @@
|
|||
# ARA Backend Redesign — Design PRD
|
||||
|
||||
**Status**: Draft for team review
|
||||
**Author**: Khalim Conn-Kowlessar (with Claude grill session)
|
||||
**Branch**: `ara-backend-design-prd`
|
||||
**Scope**: Service architecture + domain model + contracts for the new modelling backend. Linked sub-PRDs cover ML training pipeline, DB schema migration, and historical EPC re-mapping.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
### 1.1 The forcing function
|
||||
|
||||
The current modelling backend (`backend/engine/engine.py` — `model_engine`, 1331 LOC) was built as an MVP. It is:
|
||||
|
||||
- **Tightly coupled** to a specific gov EPC API that is being **decommissioned on 30 May 2026** (~17 days from today).
|
||||
- **A monolith** — one async function reaches into DB modules, HTTP clients, ML lambdas, S3, and queue infrastructure directly.
|
||||
- **Bottlenecked on a single person** — Khalim is the only contributor able to safely modify the engine because no one else can predict the blast radius of a change.
|
||||
- **Already returning erroneous data** from the old API (clients are aware). The replacement API is partially built (`backend/epc_client/epc_client_service.py`) on the current feature branch.
|
||||
|
||||
### 1.2 What needs to change
|
||||
|
||||
Beyond just swapping API clients, this is the moment to **rebuild the backend into a production-grade, contribute-able codebase**, with:
|
||||
|
||||
- A clear domain model rooted in the new EPC schema (`EpcPropertyData`).
|
||||
- Service boundaries that other team members can read, fix, and extend without needing the entire mental model.
|
||||
- Repository-mediated persistence so business logic can be tested without spinning up a database.
|
||||
- A separation between **data fetching** (slow, IO-heavy, external) and **modelling** (deterministic, fast, internal).
|
||||
- Baseline kWh and bills derived deterministically from the Effective EPC (SAP physics + UCL correction + per-fuel rates from a refreshable repo) rather than from the EPC's recorded cost fields (which use fuel rates pinned to the inspection date) or from an ML kWh prediction.
|
||||
|
||||
### 1.3 Out of scope for this PRD
|
||||
|
||||
These ship as **linked sub-PRDs**:
|
||||
|
||||
- **Sub-PRD (ii) — ML training pipeline** (autogluon repo + parquet generation in this repo + scoring model retraining for the new EPC schema)
|
||||
- **Sub-PRD (iii) — DB schema migration** (new tables: `site_notes`, `landlord_overrides`, EPC cache, parallel write strategy)
|
||||
- **Sub-PRD (iv) — Historical EPC re-mapping** (one-off + ongoing batch job: legacy stored EPCs → new `EpcPropertyData` shape)
|
||||
|
||||
The contracts this PRD defines are the inputs each sub-PRD consumes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals and non-goals
|
||||
|
||||
### 2.1 Goals
|
||||
|
||||
1. **Survive the 30 May API shutdown** — even if it means a brief degraded window, modelling continues to function against the new gov EPC API.
|
||||
2. **Decouple data fetching from modelling** — modelling never makes external HTTP calls; it reads everything from repositories.
|
||||
3. **Make every service unit-testable against fakes** — no test needs a real DB, a real gov API, or a real ML lambda to verify business logic.
|
||||
4. **Establish a single `Property` aggregate root** as the domain centrepiece; all 9 modelling concerns are slices of one aggregate.
|
||||
5. **Versioned ML data contract** — the EPC-to-features transform is the single shared artifact between this repo and the autogluon repo.
|
||||
6. **Per-property UI surfaces** — fetched data can be shown to users for review and override **before** modelling runs; modelling is triggered separately. This will enable a landlord facing version of the product where we fetch the open data, present back to the user for review and then perform the modelling.
|
||||
|
||||
### 2.2 Non-goals
|
||||
|
||||
- Multi-region deploy, GDPR-class data minimisation work, or compliance reporting — separate workstreams.
|
||||
- Replacement of the front-end. The new APIs preserve enough of the existing response shape that the FE migrates incrementally.
|
||||
- Removing pandas. The ML transform output is a parquet-friendly DataFrame-like shape; that stays.
|
||||
- A workflow engine (Prefect / Temporal / Airflow). Coordinator-class orchestration plus the existing SQS-fanout pattern is sufficient at the scale we serve.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cutover plan
|
||||
|
||||
Forced cut-over, driven by the 30 May deadline. There is no strangler period because the Old EPC API death takes `model_engine` with it.
|
||||
|
||||
### 3.1 Phase 0 — Status quo (now → 30 May)
|
||||
|
||||
- `model_engine` keeps running against the Old EPC API for as long as it works.
|
||||
- Build of the 9 new services starts **this week**, in parallel to the old engine continuing to serve traffic.
|
||||
- The new `ara/` package lives alongside `backend/` but is not yet wired into any production endpoint.
|
||||
- Goal: keep the lights on until the API dies; start the build immediately so the dark period is short.
|
||||
|
||||
### 3.2 Phase 1 — Forced cut-over (30 May onwards)
|
||||
|
||||
- On 30 May the Old EPC API dies; `model_engine` ceases to function for any new modelling run.
|
||||
- Some downtime is expected and accepted. Clients are aware.
|
||||
- Modelling resumes when the new pipeline is ready end-to-end. Remains to be decided if we have a per-portfolio flag, purely for the front end to reference old tables where necessary. No parallel pipelines, no traffic split — the new pipeline is the only pipeline.
|
||||
- **Calico** and **Hyde** are the first live clients onto the new pipeline in June.
|
||||
- `model_engine`, `SearchEpc`, the legacy `Property`, and surrounding modules in `backend/` are deleted once the new pipeline is serving all traffic.
|
||||
|
||||
### 3.3 What is *not* done
|
||||
|
||||
- No strangler — there is nothing to strangle once the Old EPC API dies on 30 May.
|
||||
- No parallel-shadow run — would double compute and require diff tooling we don't have, while the old engine is already known to return bad data so diffs would be noise.
|
||||
- TBC per-portfolio feature flag. Without this, the cut-over is all-or-nothing. All old portfolios are broken.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Trigger endpoint(s) │
|
||||
│ (one or two — see §4.5; deferred decision) │
|
||||
└───────────┬──────────────────────────────────────────┬──────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ IngestionPipe │ SQS, batches of N │ ModellingPipe │
|
||||
│ ----------- │ ◄─────────────────────│ ----------- │
|
||||
│ Fetchers run │ │ Reads via Repos │
|
||||
│ Persist via │ │ Calls Services │
|
||||
│ Repos │ │ ML predictions │
|
||||
└────────┬────────┘ └────────┬────────┘
|
||||
│ │
|
||||
└───────────────► Repos ◄─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Postgres tables │
|
||||
│ (property, │
|
||||
│ epc_cache, │
|
||||
│ site_notes, │
|
||||
│ landlord_ │
|
||||
│ overrides, │
|
||||
│ plans, etc.) │
|
||||
└──────────────────┘
|
||||
|
||||
┌──────────────────────────┐
|
||||
│ RefreshOrchestrator │ triggers Ingestion → diff → conditionally Modelling
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.1 Class taxonomy
|
||||
|
||||
Every class falls into exactly one of four roles:
|
||||
|
||||
| Role | Job | Examples |
|
||||
|------|-----|----------|
|
||||
| **Fetchers** | Call external APIs. Return raw response data. No DB. | `EpcClientService`, `GeospatialFetcher`, `SolarFetcher`, `SiteNotesIngester` |
|
||||
| **Repos** | Persist and load domain aggregates. SQL hidden inside. No external IO. | `PropertyRepo`, `EpcCacheRepo`, `SiteNotesRepo`, `LandlordOverridesRepo`, `RecommendationsRepo`, `GenericDataRepo`, `SubtaskRepo` |
|
||||
| **Services** | Business logic over domain objects. No external IO except via injected Fetchers / Repos. | `EpcRemappingService`, `EpcPredictionService`, `EpcEnergyDerivationService`, `KwhImpactService`, `ImpactPredictionService`, `RecommendationService`, `OptimiserService`, `FeatureBuilder`, `ResultsPersister` |
|
||||
| **Orchestrators** | Compose Fetchers + Services + Repos to produce an end-to-end result. The only place where step order is encoded. | `IngestionPipeline`, `ModellingPipeline`, `RefreshOrchestrator` |
|
||||
|
||||
This taxonomy is **strict**. A class that fetches *and* persists belongs in the Service layer and depends on a Fetcher + a Repo. No back-channels.
|
||||
|
||||
### 4.2 Two pipelines, one direction
|
||||
|
||||
Data flows one way only: **Ingestion → Repos → Modelling**.
|
||||
|
||||
- **Ingestion** writes; never calls Modelling.
|
||||
- **Modelling** reads; never calls Fetchers.
|
||||
|
||||
If Modelling needs fresh data, it returns "stale" and the caller decides whether to ingest first. This makes Modelling a pure function of repository state, which is the property that makes it reproducible, debuggable, and testable.
|
||||
|
||||
### 4.3 RefreshOrchestrator
|
||||
|
||||
Sits above both pipelines. Job:
|
||||
|
||||
1. Trigger `IngestionPipeline` for a portfolio.
|
||||
2. After ingestion completes, ask repos: "did anything change vs the last modelled snapshot?"
|
||||
3. If yes, trigger `ModellingPipeline`. If no, return early.
|
||||
|
||||
This avoids re-modelling 100k properties when only 200 had refreshed EPC data.
|
||||
|
||||
### 4.4 SQS fanout (preserved from current architecture)
|
||||
|
||||
The existing `trigger_plan_entrypoint` SQS-chunking pattern is kept. Both pipelines fan out per batch of ~30–100 properties (tuneable). Each consumer runs one batch end-to-end through the relevant pipeline.
|
||||
|
||||
UPRN partitioning: the trigger endpoint groups UPRNs by **locality** (postcode prefix / UPRN range) before chunking, so each batch maximises shared upstream fetches (one geospatial-range pull serves all 30 properties in the batch).
|
||||
|
||||
### 4.5 One endpoint for v1
|
||||
|
||||
For Phase 1 we ship **one trigger endpoint** that internally chains Ingestion → Modelling via `RefreshOrchestrator`. This matches the current FastAPI-fronted Lambda pattern (the FastAPI app in `services/<svc>/` is a thin entrypoint that invokes the modelling Lambda).
|
||||
|
||||
We can split into two endpoints later (refresh-only vs model-only) once a real workflow demands it — e.g. a Landlord-Override edit that should re-model without re-fetching open data. The class taxonomy and `RefreshOrchestrator` boundary allow this split without re-architecting.
|
||||
|
||||
### 4.6 Trigger contract
|
||||
|
||||
The trigger payload is reduced compared to today's `PlanTriggerRequest` ([backend/app/plan/schemas.py:98](../../backend/app/plan/schemas.py#L98)) — most of what's currently in the request body moves into the persisted `Scenario` aggregate.
|
||||
|
||||
```python
|
||||
class ModelTriggerRequest(BaseModel):
|
||||
portfolio_id: UUID
|
||||
property_ids: list[UUID] | S3Ref # inline up to ~10k, S3 ref above
|
||||
scenario_ids: list[UUID] # 1+; resolved + pinned to ScenarioSnapshot at fan-out
|
||||
task_id: UUID
|
||||
subtask_id: UUID # SQS state machine, preserved from today
|
||||
```
|
||||
|
||||
Everything that used to ride at the top level dies or moves:
|
||||
|
||||
- `goal`, `budget`, `goal_value`, `inclusions`, `exclusions`, `required_measures`, `enforce_fabric_first`, `scenario_name`, `housing_type` → into `Scenario` / `ScenarioPhase`.
|
||||
- `patches_file_path`, `already_installed_file_path`, `non_invasive_recommendations_file_path` → gone; Landlord Overrides covers all three.
|
||||
- `valuation_file_path` → gone; `ValuationService` derives it.
|
||||
- `ashp_cop`, `default_u_values` → `HeatingSystemAssumptionsRepo` / global config; not per-trigger.
|
||||
- `multi_plan` → gone; `scenario_ids: list[...]` handles N runs natively (one Plan per scenario per property).
|
||||
- `event_type`, `epc_certificate_number`, `lmk_key`, `file_format`, `sheet_name`, `index_start`/`index_end`, `file_type` → ingestion-side concerns; if needed, ride on a separate ingestion-trigger payload.
|
||||
|
||||
**Scenario snapshotting**: at fan-out time `RefreshOrchestrator` reads each requested `Scenario`, writes a `ScenarioSnapshot` keyed by `(task_id, scenario_id)`, and per-batch SQS messages reference the snapshot. Mid-run edits to the live `Scenario` do not affect an in-flight modelling job. Snapshots are read-only and can be garbage-collected after the task completes.
|
||||
|
||||
---
|
||||
|
||||
## 5. Domain model
|
||||
|
||||
### 5.1 Aggregate root: `Property`
|
||||
|
||||
`Property` is the centrepiece. Every service operates on one or more `Property` instances. Every repo writes one slice of `Property`. The aggregate carries all state for a single property's modelling run.
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PropertyIdentity:
|
||||
portfolio_id: UUID
|
||||
uprn: Optional[int]
|
||||
landlord_property_id: Optional[str]
|
||||
address: AddressLines
|
||||
postcode: str
|
||||
|
||||
@dataclass
|
||||
class Property:
|
||||
identity: PropertyIdentity
|
||||
|
||||
# --- Source data — modelling path is determined by which of these are set ---
|
||||
epc: Optional[EpcPropertyData] # from gov API (or remapped historical)
|
||||
site_notes: Optional[SiteNotes] # our own survey; supersedes EPC when present
|
||||
landlord_overrides: Optional[LandlordOverrides] # sparse, only meaningful when epc set
|
||||
|
||||
# --- Enrichments ---
|
||||
geospatial: Optional[GeoSpatial]
|
||||
solar: Optional[SolarPotential]
|
||||
epc_anomaly_flags: Optional[EpcAnomalyFlags] # from EpcPredictionService vs neighbours
|
||||
|
||||
# --- Modelling outputs ---
|
||||
baseline_performance: Optional[BaselinePerformance] # carries lodged + effective pair; see §5.4
|
||||
recommendations: list[Recommendation]
|
||||
impact_predictions: Optional[ImpactPredictions]
|
||||
plans: list[Plan] # one per Scenario the property was modelled against
|
||||
|
||||
# --- Derived ---
|
||||
@property
|
||||
def source_path(self) -> Literal["site_notes", "epc_with_overlay"]: ...
|
||||
|
||||
@property
|
||||
def effective_epc(self) -> EpcPropertyData:
|
||||
"""The EPC the modelling pipeline actually scores against."""
|
||||
...
|
||||
```
|
||||
|
||||
### 5.2 `Properties` collection
|
||||
|
||||
A first-class iterable, so batch operations are obvious:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class Properties:
|
||||
items: list[Property]
|
||||
|
||||
def __iter__(self) -> Iterator[Property]: ...
|
||||
def __len__(self) -> int: ...
|
||||
def filter(self, pred: Callable[[Property], bool]) -> "Properties": ...
|
||||
def map(self, fn: Callable[[Property], Property]) -> "Properties": ...
|
||||
def with_landlord_overrides(self) -> "Properties": ...
|
||||
```
|
||||
|
||||
Services typically take and return `Properties`, not lists.
|
||||
|
||||
### 5.3 Other aggregates
|
||||
|
||||
| Aggregate | Owns | Repo |
|
||||
|---|---|---|
|
||||
| `Property` | property identity, epc, site_notes, landlord_overrides, enrichments, modelling results | `PropertyRepo` |
|
||||
| `Plan` | per-property modelling output for one Scenario: ordered `phases: list[PlanPhase]`, each carrying its `OptimisedPackage`, ending state snapshot, and rolled-over options | `RecommendationsRepo` |
|
||||
| `Scenario` | portfolio-wide scenario metadata (goal, budget, exclusions, housing type) plus ordered `phases: list[ScenarioPhase]`; each phase carries `measure_types_allowed`, phase budget, phase target | `RecommendationsRepo` |
|
||||
| `ScenarioSnapshot` | frozen copy of a `Scenario` pinned at trigger time, keyed by `(task_id, scenario_id)`, so mid-run scenario edits don't affect an in-flight modelling job | `RecommendationsRepo` |
|
||||
| `Subtask` / `Task` | SQS fanout state | `SubtaskRepo` |
|
||||
| `EpcCache` | gov-API responses keyed by UPRN, with freshness/TTL | `EpcCacheRepo` |
|
||||
| `GenericData` | UPRN-range geospatial, postcode lookups, shared static data | `GenericDataRepo` |
|
||||
| `FuelRates` | time-versioned, region-aware per-fuel rates (pence/kWh), standing charges, SEG export rate, calorific values | `FuelRatesRepo` |
|
||||
| `CarbonFactors` | time-versioned per-fuel CO2 emission factors (kgCO2e/kWh); Defra publishes annually | `CarbonFactorsRepo` |
|
||||
| `HeatingSystemAssumptions` | boiler efficiency tables, ASHP/GSHP COPs, solar-thermal coverage proportion; per-property physical assumptions, not fuel-market data | `HeatingSystemAssumptionsRepo` |
|
||||
|
||||
Aggregates are loaded **whole** — never half a `Property`. If a slice is too large to load eagerly (e.g. recommendation history), it lives in a separate aggregate.
|
||||
|
||||
A single-phase Scenario is `phases: [<one ScenarioPhase>]` with all measure types allowed and the full budget on it — no special-case path through the pipeline.
|
||||
|
||||
### 5.4 `BaselinePerformance` carries lodged + effective
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class BaselinePerformance:
|
||||
# As-lodged: unmodified EPC fields (or Site Notes' recorded values where Site Notes are the source).
|
||||
lodged_sap: int
|
||||
lodged_band: Epc
|
||||
lodged_carbon: float
|
||||
lodged_heat_demand: float
|
||||
|
||||
# Effective: what the modelling pipeline actually scored against.
|
||||
# Equals lodged when neither rebaselining trigger fires; equals ML output when rebaselined.
|
||||
effective_sap: int
|
||||
effective_band: Epc
|
||||
effective_carbon: float
|
||||
effective_heat_demand: float
|
||||
|
||||
# kWh / fuel split / bills — always derived deterministically from the Effective EPC by
|
||||
# EpcEnergyDerivationService (SAP physics + UCL correction + FuelRates lookup).
|
||||
# Lodged kWh / bills are not stored separately — the EPC's recorded cost fields are pinned to
|
||||
# inspection-date fuel rates, so we always re-derive bills from current FuelRates regardless.
|
||||
annual_kwh: float
|
||||
fuel_split: dict[Fuel, float]
|
||||
annual_bills: dict[Fuel, float]
|
||||
|
||||
rebaselined: bool
|
||||
rebaseline_reason: Optional[Literal["pre_sap10", "physical_state_changed", "both"]]
|
||||
```
|
||||
|
||||
The pair lets the FE show "lodged rating vs SAP10-equivalent rebaselined rating" side by side without a separate query. Both fields are always populated; when no rebaselining trigger fires, `effective_*` equals `lodged_*`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Source-of-truth and overlay precedence
|
||||
|
||||
There are exactly **two modelling paths**. The `Property.source_path` property selects.
|
||||
|
||||
### 6.1 Path 1 — Site notes
|
||||
|
||||
If a `Property` has `site_notes` and they are newer than any available EPC (or no EPC exists), site notes are the **complete** source of truth:
|
||||
|
||||
- `effective_epc` = `site_notes.to_epc_property_data()`.
|
||||
- EPC fields not covered by site notes — **none expected**. Site notes are committed to being a full-coverage survey. Treat any gap as a survey-quality bug, not a fallback signal.
|
||||
- `LandlordOverrides` are not applicable in Path 1 (the survey supersedes).
|
||||
|
||||
### 6.2 Path 2 — EPC with landlord overlay
|
||||
|
||||
If a `Property` has no site notes (or the EPC is newer):
|
||||
|
||||
- `effective_epc` = `epc` with `landlord_overrides` applied as a sparse field-level overlay (`landlord > epc`).
|
||||
- `LandlordOverrides` are sparse: each row represents one corrected field. Schema TBD at implementation time; assume flat input via Excel/CSV for v1, with a flag to revisit shape after first customer onboarding.
|
||||
|
||||
### 6.3 Recency tie-break
|
||||
|
||||
When a property has **both** site notes and a public EPC, the newer of the two wins. Rationale: a recent EPC may reflect retrofit work done after our survey; conversely a recent survey reflects on-site observations the EPC cannot capture.
|
||||
|
||||
This tie-break is implemented in `Property.source_path` and may be tuned later (e.g. always prefer surveys regardless of date, or per-portfolio policy).
|
||||
|
||||
### 6.4 Rebaselining trigger
|
||||
|
||||
ML re-predicts SAP / carbon / heat when **either** of these holds:
|
||||
|
||||
1. **Pre-SAP10 schema** — `effective_epc.sap_version < 10.0`. The EPC was rated under SAP 2012 (or earlier) and we want a SAP10-equivalent baseline so all properties are scored against the same model version. Canonical signal is the `sap_version: float` field; fall back to `schema_type` string, then to `lodgement_date` if both are absent. Site Notes are assumed SAP10 by construction (PasHub / ECMK produce them now) — Path 1 typically doesn't trigger this leg.
|
||||
2. **Physical state changed** — `effective_epc` differs from the lodged EPC's physical fields (walls / heating / windows / etc.). Triggered by Landlord Overrides changing physical state, or by Site Notes that contradict the lodged EPC.
|
||||
|
||||
When triggered, a single ML call re-predicts SAP/carbon/heat with the current Effective EPC state as input. Both reasons can fire together; the prediction is still one call.
|
||||
|
||||
kWh is **always** re-derived via `EpcEnergyDerivationService` — even when no ML rebaseline runs — because the EPC's recorded cost fields use fuel rates pinned to the inspection date, and current rates from `FuelRatesRepo` are what we want to surface to users.
|
||||
|
||||
The diff mechanism for "physical state changed" (content hash, dirty flag, etc.) is an implementation detail; start with a content hash of the physical-state subset of `EpcPropertyData` stored alongside the previous run.
|
||||
|
||||
### 6.5 Deprecated concepts
|
||||
|
||||
- **Patches** (`patch_epc`) — removed. Functionality subsumed by `LandlordOverrides`.
|
||||
- **Already-installed measures** — likely subsumed by `LandlordOverrides` ("we have a heat pump now" → override heating fields). Confirmed at implementation time.
|
||||
- **Non-invasive recommendations** — TBD whether this concept survives; not blocking.
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistence: repositories and unit of work
|
||||
|
||||
### 7.1 What a repository is
|
||||
|
||||
A repository owns the SQL for one aggregate. Nothing else writes SQL for that aggregate. Callers see only domain objects.
|
||||
|
||||
```python
|
||||
class PropertyRepo(Protocol):
|
||||
def get(self, identity: PropertyIdentity) -> Optional[Property]: ...
|
||||
def bulk_save(self, uow: UnitOfWork, properties: Properties) -> None: ...
|
||||
def find_by_portfolio(self, portfolio_id: UUID) -> Properties: ...
|
||||
def find_stale(self, portfolio_id: UUID, threshold: timedelta) -> Properties: ...
|
||||
```
|
||||
|
||||
Implementation references current `db_funcs.*` modules during phase 0 to avoid a big-bang SQL rewrite, but the interface is fixed.
|
||||
|
||||
### 7.2 Unit of Work
|
||||
|
||||
Multi-table writes inside a single aggregate, or across aggregates that share a transaction (e.g. property + plan + recommendations) go through a `UnitOfWork`:
|
||||
|
||||
```python
|
||||
with self.uow_factory() as uow:
|
||||
self.property_repo.bulk_save(uow, properties)
|
||||
self.recommendations_repo.bulk_save(uow, plans)
|
||||
uow.commit()
|
||||
```
|
||||
|
||||
UoW owns the SQLAlchemy session lifecycle. Repos use the session passed in via the UoW. Outside a UoW, repos use a short-lived read session.
|
||||
|
||||
### 7.3 Repository inventory
|
||||
|
||||
| Repo | Tables it owns |
|
||||
|------|----------------|
|
||||
| `PropertyRepo` | `properties`, `property_details_epc`, `property_spatial` |
|
||||
| `EpcCacheRepo` | new table: `epc_api_cache` (TTL, raw API response, mapped `EpcPropertyData`) |
|
||||
| `SiteNotesRepo` | new table: `site_notes` (replaces current `energy_assessments`) |
|
||||
| `LandlordOverridesRepo` | new table: `landlord_overrides` (sparse, per-field rows for audit) |
|
||||
| `RecommendationsRepo` | `plans`, `plan_phases`, `recommendations`, `recommendation_parts`, `scenarios`, `scenario_phases`, `scenario_snapshots` |
|
||||
| `GenericDataRepo` | new table or S3-backed: UPRN-range geospatial + postcode-keyed shared static data |
|
||||
| `FuelRatesRepo` | new table: `fuel_rates` — `(fuel_type, rate_pence_per_kwh, standing_charge_pence_per_day, calorific_value_kwh_per_unit, unit, effective_from, effective_to, region_code Optional, source)`. SEG export rate is a row with `fuel_type = 'electricity_export'`. |
|
||||
| `CarbonFactorsRepo` | new table: `carbon_factors` — `(fuel_type, kgco2e_per_kwh, effective_from, effective_to, source)`. Defra publishes annually. |
|
||||
| `HeatingSystemAssumptionsRepo` | new table(s): boiler efficiency, ASHP/GSHP COP, solar-thermal coverage proportion. Static-ish, manual refresh. |
|
||||
| `SubtaskRepo` | `tasks`, `subtasks` (existing) |
|
||||
|
||||
DDL migrations are scoped to sub-PRD (iii).
|
||||
|
||||
### 7.4 Fakes
|
||||
|
||||
For tests, each repo has a `FakeXRepo` companion backed by a dict. Service unit tests inject fakes. No DB required.
|
||||
|
||||
---
|
||||
|
||||
## 8. ML contract
|
||||
|
||||
### 8.1 Where ML lives
|
||||
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| Defining the EPC → features transform | **This repo** (`ara.domain.sap10_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) |
|
||||
|
||||
The autogluon repo is intentionally **dumb**: it consumes parquet, knows which column is the target, knows which columns to ignore. It has no EPC semantics.
|
||||
|
||||
### 8.2 `EpcMlTransform`
|
||||
|
||||
A separate class (not a method on `EpcPropertyData`), because:
|
||||
|
||||
- The data class stays clean of training-infrastructure concerns.
|
||||
- Versioned transforms (`EpcMlTransformV1`, `EpcMlTransformV2`) swap easily.
|
||||
- Future need: injection of normalisation stats from the training set is straightforward on a class, awkward on a dataclass.
|
||||
|
||||
```python
|
||||
class EpcMlTransform:
|
||||
VERSION: str = "1.0.0" # semver
|
||||
|
||||
def to_row(self, epc: EpcPropertyData) -> dict[str, Any]: ...
|
||||
def to_rows(self, properties: Properties) -> pd.DataFrame: ...
|
||||
def schema(self) -> dict[str, type]: ... # for parquet emission + validation
|
||||
```
|
||||
|
||||
The interesting work — flattening `List[SapWindow]`, `List[SapBuildingPart]` into fixed-width columns — lives inside this class. Domain decisions (top-N windows, aggregate roofs, etc.) are encoded here and reviewed by Khalim. Sub-PRD (ii) goes into detail.
|
||||
|
||||
### 8.3 Versioning
|
||||
|
||||
- Transform class is **semver-tagged** (`VERSION = "1.0.0"`).
|
||||
- S3 path for training parquet includes the version: `s3://.../training/v1.0.0/...`.
|
||||
- Deployed scoring lambda is tagged with the transform version it was trained against.
|
||||
- Modelling pipeline asserts at startup that its `EpcMlTransform.VERSION` matches the deployed lambda's tag; mismatch = hard fail at deploy time.
|
||||
|
||||
Bump major when removing or renaming columns. Bump minor when adding optional columns (older models still scoreable; new models can be trained against new fields).
|
||||
|
||||
### 8.4 ML model families
|
||||
|
||||
Both ML calls (rebaselining + per-measure impact) use the same `EpcMlTransform`:
|
||||
|
||||
| Service | Lambda | Target |
|
||||
|---|---|---|
|
||||
| `RebaseliningService` (S4b) | `baseline-models-*` | SAP / carbon / heat demand under the current Effective EPC state (SAP10-equivalent) |
|
||||
| `ImpactPredictionService` (S6) | `impact-models-*` | SAP / carbon / heat demand impact per measure (and per battery option, using new EPC battery fields) |
|
||||
|
||||
Annual kWh and bills are never an ML target — derived deterministically by `EpcEnergyDerivationService` (S4a). Recommendation kWh delta is derived from the SAP delta predicted by S6 plus heating-system fuel + COP, not via a separate ML call.
|
||||
|
||||
The two families are trained against the same input feature schema; only target columns differ. Sub-PRD (ii) handles training-time details.
|
||||
|
||||
---
|
||||
|
||||
## 9. Service catalogue
|
||||
|
||||
The classes below implement the pipeline end-to-end. Detailed signatures are deliberately left for implementers — this PRD documents purpose, dependencies, and rough shape; per-service grill sessions produce the contracts.
|
||||
|
||||
**Out of the legacy engine** (deleted, not migrated): `PredictionMatrix` (debug-only, moves to test fixtures), `extract_portfolio_aggregation_data` (dead code, FE aggregates dynamically per §10), inspections plumbing (`inspections_map` is initialised but never populated in the current engine), patches / `already_installed` / `non_invasive_recommendations` (subsumed by Landlord Overrides), ECO4 / WHLG funding integration (`get_funding_data` and `optimise_with_scenarios`' funding paths), the pre-recommendation kWh ML lambda (`KWH_MODEL_PREFIXES`), and floor-count / heat-loss-perimeter estimation from geospatial (now on `EpcPropertyData`). Address matching (`address2UPRN`) lives as a separate service, not inside `EpcClientService`.
|
||||
|
||||
### 9.1 Fetchers (called by `IngestionPipeline`)
|
||||
|
||||
| # | Class | Purpose | Dependencies |
|
||||
|---|---|---|---|
|
||||
| F1 | `EpcClientService` | Fetches EPCs from new gov API. Already exists at `backend/epc_client/`. Scope narrows compared to current `SearchEpc` — address matching (`address2uprn`) and OS API estimation are not its concern. | httpx |
|
||||
| F2 | `GeospatialFetcher` | Fetches UPRN-range geospatial data. Replaces `OpenUprnClient`. **Floor count and heat-loss perimeter estimation are no longer needed** — both are now on `EpcPropertyData` directly (`number_of_storeys`, `SapFloorDimension.heat_loss_perimeter_m`). Scope reduces to building geometry and postcode-area context. | S3 / Ordnance Survey API |
|
||||
| F3 | `SolarFetcher` | Wraps Google Solar API; building-level + unit-level scenes. | Google Solar API |
|
||||
| F4 | `SiteNotesIngester` | Loads site notes from Excel uploads / structured input. Persists via `SiteNotesRepo`. | S3, repo |
|
||||
| F5 | `FuelRatesFetcher` | Scheduled ETL — scrapes Ofgem regional caps and per-fuel rates, writes timeseries rows to `FuelRatesRepo`. Manual CSV upload fallback for off-cycle corrections. | Ofgem feed, repo |
|
||||
| F6 | `CarbonFactorsFetcher` | Same shape as F5 against Defra's annual CO2 factor publication. | Defra feed, repo |
|
||||
|
||||
### 9.2 Domain services (called by `ModellingPipeline`)
|
||||
|
||||
| # | Class | Original-list # | Purpose | Reads | Writes |
|
||||
|---|---|---|---|---|---|
|
||||
| S1 | `EpcRemappingService` | 4 | Re-map legacy / historical EPCs into new `EpcPropertyData` shape. | `EpcCacheRepo` | `EpcCacheRepo` (mapped column) |
|
||||
| S2 | `EpcPredictionService` | 3 | For every property: produce predicted EPC + per-field anomaly flags vs neighbours. Used both for gap-fill (Path 2 if EPC missing) and UI surfacing. | `EpcCacheRepo`, `GenericDataRepo` | — |
|
||||
| S3 | `FeatureBuilder` | (new) | Wraps `EpcMlTransform`. Converts `Properties` → scoring DataFrame. | — | — |
|
||||
| S4a | `EpcEnergyDerivationService` | (new) | Derives annual kWh + fuel split + bills from the Effective EPC. Deterministic, no ML. Pipeline: (1) source regulated PEUI — either from `energy_consumption_current × floor_area` when EPC field present and no physical override, or from SAP physics (heat demand × area + SAP hot-water + SAP lighting) for Site Notes / overridden cases; (2) add appliance + cooking via SAP Appendix L formulas (port of [`AnnualBillSavings.estimate_appliances_energy_use`](../../backend/ml_models/AnnualBillSavings.py)); (3) apply UCL per-band correction (Few et al. 2023, Table 3), keyed on the **post-state Effective EPC's band** — not the lodged band; (4) decompose total PEUI into end-use shares via SAP-physics proportions; (5) primary→delivered per fuel using SAP primary factors; (6) bills = delivered kWh per fuel × current rate from `FuelRatesRepo` + standing charges + SEG credits. CO2 emissions from `CarbonFactorsRepo`. | `FuelRatesRepo`, `CarbonFactorsRepo`, `HeatingSystemAssumptionsRepo` | — |
|
||||
| S4b | `RebaseliningService` | (new, partial overlap with old "rebaselining" logic) | Triggered by §6.4 conditions (pre-SAP10 schema **or** physical state changed). Calls SAP/carbon/heat ML lambdas to produce SAP10-equivalent baseline against the current Effective EPC state. Both `BaselinePerformance.lodged_*` and `effective_*` are populated downstream — pair is always stored, equal when not rebaselined. kWh is re-derived via S4a, not ML. | `FeatureBuilder` | — |
|
||||
| S5 | `RecommendationService` | 6 | Generates per-property recommendations against the current rolling Effective EPC. Invoked **once per (scenario × phase)** — filters candidates to the phase's `measure_types_allowed`, returns candidates eligible against the post-prior-phase state. Replaces current `Recommendations` (1383 LOC). | `MaterialsRepo` | — |
|
||||
| S6 | `ImpactPredictionService` | 7 | Calls SAP / carbon / heat impact ML lambda for **every** candidate recommendation (FE displays all options to user). Invoked per (scenario × phase) with the rolling state's feature vector. Recommendation kWh delta is derived deterministically from SAP delta + heating-system fuel/COP, not from a separate ML call. Battery impact uses the new EPC battery fields (`energy_pv_battery_count`, `energy_pv_battery_capacity`) as ML inputs — the deterministic `BatterySAPScorer` from the legacy engine is replaced by ML prediction. | `FeatureBuilder` | — |
|
||||
| S7 | `OptimiserService` | 8 | Per-phase optimisation against rolling state. Reads `PlanPhase.state_at_end[n-1]` to honour cross-phase constraints (fabric-first, heat-pump-needs-insulation, ventilation). Wraps current `CostOptimiser` / `GainOptimiser` / `optimise_with_scenarios` minus the dead ECO-funding paths. Unselected candidates roll into phase n+1's candidate pool (auto vs user-marked TBD, §15). | — | — |
|
||||
| S8 | `ValuationService` | — | Estimates per-property valuation (current + post-retrofit) from academic-paper-based regression on EPC change, property type, region. Improvement on the existing `PropertyValuation.estimate` code — exact shape deferred to per-service grill. | — | — |
|
||||
| S9 | `ResultsPersister` | 9 | Final step: writes Plan (with `phases[]`) + Recommendations + Property updates via repos under one UoW, per scenario. | — | All write repos |
|
||||
|
||||
### 9.3 Orchestrators
|
||||
|
||||
| # | Class | Purpose |
|
||||
|---|---|---|
|
||||
| O1 | `IngestionPipeline` | Per-batch SQS consumer. Calls F1–F4, persists via repos. |
|
||||
| O2 | `ModellingPipeline` | Per-batch SQS consumer. Reads from repos, runs S1→S8 in order, ends with persistence. |
|
||||
| O3 | `RefreshOrchestrator` | Top-level: triggers Ingestion → diff → optionally Modelling. |
|
||||
|
||||
### 9.4 `ModellingPipeline` step order
|
||||
|
||||
For each `Property` in the batch, against each pinned `ScenarioSnapshot` from the trigger payload:
|
||||
|
||||
```
|
||||
Per-property setup (runs once regardless of scenario count):
|
||||
1. PropertyRepo.get() → Property (epc, site_notes, overrides, geospatial, solar)
|
||||
2. EpcRemappingService — if epc is in legacy schema, upgrade to current
|
||||
3. EpcPredictionService — predicted EPC + per-field anomaly flags (always runs)
|
||||
4. Compute Property.effective_epc (path-1 or path-2)
|
||||
5. RebaseliningService — IF §6.4 conditions hold (pre-SAP10 OR physical state changed),
|
||||
re-predict SAP/carbon/heat via ML against the Effective EPC state.
|
||||
Populate BaselinePerformance.lodged_* + effective_*.
|
||||
6. EpcEnergyDerivationService — SAP-physics + UCL (post-state band) + FuelRates → kWh, fuel split, bills.
|
||||
|
||||
Per-scenario loop:
|
||||
Per-phase loop (in scenario phase order):
|
||||
7. RecommendationService — generate candidate measures, restricted to phase's measure_types_allowed,
|
||||
against the rolling Effective EPC state (baseline for phase 1; updated for phase 2+).
|
||||
8. ImpactPredictionService — predict SAP/carbon/heat impact for those candidates, ML scored against
|
||||
the rolling state's feature vector. All candidates scored (FE shows options).
|
||||
9. OptimiserService — select package within phase budget + phase goal. Reads earlier-phase state to honour
|
||||
cross-phase constraints (fabric-first, heat-pump-needs-insulation, ventilation).
|
||||
10. Apply package → roll state forward (simulate post-package SAP / kWh / bills via S4a + impact predictions
|
||||
from step 8). Record `PlanPhase.state_at_end`. Unselected options become
|
||||
`PlanPhase.rolled_over_options` and are eligible candidates next phase.
|
||||
11. ResultsPersister — write Plan (phases[]) + Recommendations under one UoW for this scenario.
|
||||
```
|
||||
|
||||
Steps 1–6 run **once per property** regardless of scenario count.
|
||||
Steps 7–10 run **once per (scenario × phase)** per property.
|
||||
Step 11 runs once per scenario per property.
|
||||
|
||||
Batching: steps 5, 8 batch the whole batch into one ML call where possible. Step 8's cost scales with `N_phases × N_scenarios × N_candidate_measures`; multi-phase pays its own ML bill, single-phase scenarios cost the same as today.
|
||||
|
||||
Note vs the current `model_engine`: the **pre-recommendation** kWh ML call has been removed. Baseline kWh now comes from `EpcEnergyDerivationService` (SAP physics + UCL + FuelRates). ML is reserved for SAP/carbon/heat (rebaselining + impact prediction). Recommendation-level kWh delta is derived deterministically from the impact-predicted SAP delta plus heating-system fuel + COP from `HeatingSystemAssumptionsRepo`; no separate kWh ML lambda.
|
||||
|
||||
**Open future change** (flagged §15): SAP-impact-of-a-measure is not strictly additive — installing measure A changes the SAP impact of measure B. The current per-measure ML scoring + linear optimisation approximates this. A future iteration may pre-define candidate packages and ML-score whole packages, accepting the combinatorial cost in return for accuracy. Defer until implementation reveals where the approximation hurts.
|
||||
|
||||
### 9.5 Per-service contracts — deferred
|
||||
|
||||
Method signatures, return types, error semantics, and edge-case behaviour are **explicitly out of scope** for this PRD. The implementer of each service runs a `/grill-me` session against this document and produces a detailed sub-design before coding.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cross-batch concerns
|
||||
|
||||
| Concern | Status | Approach |
|
||||
|---|---|---|
|
||||
| Building-level solar adjustment | Deferred — future TODO, not implemented today. | The current `building_ids` block in `model_engine` is dead-ish; it operates on the in-process batch only. New design preserves that limitation. Future feature: a post-modelling consolidation pass that groups results by `building_id` across batches and re-optimises. |
|
||||
| Portfolio aggregation | Dropped. | Front-end computes aggregations dynamically from per-property plans. `extract_portfolio_aggregation_data` in current engine is dead code (defined, never called) — deleting. |
|
||||
| Shared upstream data | Handled by orchestrator partitioning + `GenericDataRepo`. | Trigger endpoint groups UPRNs by postcode / UPRN-range before SQS chunking so each batch maximises intra-batch sharing. `GenericDataRepo` caches across batches so first batch pays, subsequent batches hit cache. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Repository layout — monorepo via uv workspaces
|
||||
|
||||
The repo is restructured as a Python monorepo using **uv workspaces**. Shared types and shared infra live as workspace packages under `packages/`; each deployable Lambda or microservice lives as its own package under `services/`. Each `services/<svc>/` has its own `pyproject.toml`, `Dockerfile`, and Lambda image — the bundle contains only that service's deps + its workspace deps, keeping cold-start size and package weight contained.
|
||||
|
||||
```
|
||||
/
|
||||
├── pyproject.toml # workspace root
|
||||
├── uv.lock
|
||||
│
|
||||
├── packages/ # shared workspace packages — imported by services/
|
||||
│ ├── domain/ # "domna-domain"
|
||||
│ │ ├── pyproject.toml
|
||||
│ │ └── src/domain/
|
||||
│ │ ├── property.py # Property, Properties, PropertyIdentity
|
||||
│ │ ├── site_notes.py
|
||||
│ │ ├── landlord_overrides.py
|
||||
│ │ ├── baseline_performance.py # lodged + effective pair
|
||||
│ │ ├── plan.py # Plan, PlanPhase, OptimisedPackage
|
||||
│ │ ├── scenario.py # Scenario, ScenarioPhase, ScenarioSnapshot
|
||||
│ │ ├── recommendation.py
|
||||
│ │ ├── geospatial.py
|
||||
│ │ ├── solar.py
|
||||
│ │ ├── anomaly_flags.py
|
||||
│ │ └── ml/
|
||||
│ │ ├── transform.py # EpcMlTransform (versioned)
|
||||
│ │ └── schema.py
|
||||
│ │
|
||||
│ ├── repos/ # "domna-repos" — persistence, no business logic
|
||||
│ │ ├── pyproject.toml
|
||||
│ │ └── src/repos/
|
||||
│ │ ├── unit_of_work.py
|
||||
│ │ ├── property_repo.py
|
||||
│ │ ├── epc_cache_repo.py
|
||||
│ │ ├── site_notes_repo.py
|
||||
│ │ ├── landlord_overrides_repo.py
|
||||
│ │ ├── recommendations_repo.py
|
||||
│ │ ├── generic_data_repo.py
|
||||
│ │ ├── fuel_rates_repo.py
|
||||
│ │ ├── carbon_factors_repo.py
|
||||
│ │ ├── heating_system_assumptions_repo.py
|
||||
│ │ └── subtask_repo.py
|
||||
│ │
|
||||
│ ├── fetchers/ # "domna-fetchers" — external API clients
|
||||
│ │ ├── pyproject.toml
|
||||
│ │ └── src/fetchers/
|
||||
│ │ ├── epc_client.py # wraps backend/epc_client/
|
||||
│ │ ├── geospatial.py
|
||||
│ │ ├── solar.py
|
||||
│ │ ├── fuel_rates_fetcher.py
|
||||
│ │ └── carbon_factors_fetcher.py
|
||||
│ │
|
||||
│ └── utils/ # "domna-utils" — logging, AWS, S3, cloudwatch, subtasks
|
||||
│ ├── pyproject.toml
|
||||
│ └── src/utils/
|
||||
│
|
||||
├── services/ # deployable units, one Lambda image each
|
||||
│ ├── ara/ # the modelling backend
|
||||
│ │ ├── pyproject.toml # deps: domna-domain, domna-repos, domna-fetchers, domna-utils, ML libs
|
||||
│ │ ├── Dockerfile
|
||||
│ │ ├── src/ara/
|
||||
│ │ │ ├── services/ # EpcRemappingService, EpcPredictionService,
|
||||
│ │ │ │ # EpcEnergyDerivationService, RebaseliningService,
|
||||
│ │ │ │ # FeatureBuilder, RecommendationService,
|
||||
│ │ │ │ # ImpactPredictionService, OptimiserService,
|
||||
│ │ │ │ # ValuationService, ResultsPersister
|
||||
│ │ │ ├── orchestrators/ # IngestionPipeline, ModellingPipeline, RefreshOrchestrator
|
||||
│ │ │ └── lambdas/ # handler.py per Lambda + event-shape contracts
|
||||
│ │ └── tests/
|
||||
│ │ ├── fakes/ # FakePropertyRepo, FakeEpcClient, etc.
|
||||
│ │ ├── unit/ # service tests using fakes only
|
||||
│ │ └── integration/ # real DB + real SQS via localstack
|
||||
│ │
|
||||
│ ├── address2uprn/ # messy-address → UPRN matching, pre-modelling step
|
||||
│ │ ├── pyproject.toml
|
||||
│ │ ├── Dockerfile
|
||||
│ │ └── src/address2uprn/
|
||||
│ ├── hubspot/ # existing Hubspot ETL
|
||||
│ ├── pashub/ # PasHub survey ingestion
|
||||
│ ├── ecmk/ # ECMK assessment ingestion
|
||||
│ └── magicplan/ # MagicPlan integration
|
||||
│
|
||||
├── backend/ # legacy FastAPI app + microservices, kept until cut-over
|
||||
│ ├── app/ # FastAPI; thin entrypoints that invoke service Lambdas
|
||||
│ └── ... # legacy engine, SearchEpc, etc.; deleted after cut-over
|
||||
│
|
||||
├── datatypes/ # existing — EPC schemas; eventually folds into packages/domain/
|
||||
└── docs/
|
||||
└── adr/ # architectural decision records
|
||||
```
|
||||
|
||||
**Boundary properties** (enforced by package structure, not convention):
|
||||
- A `services/<svc>/` package can `import domain.*`, `import repos.*`, `import fetchers.*`, `import utils.*`. It **cannot** import another service's modules — they're separate distributions with no cross-import path.
|
||||
- ADR-0003 (Ingestion / Modelling separation) is preserved: modelling services in `services/ara/src/ara/services/` depend only on `repos.*` + `domain.*`, never on fetchers. Orchestrators are the only place fetchers and services meet.
|
||||
|
||||
**Migration** (incremental, not big-bang):
|
||||
1. Carve out `packages/domain/` first — fold `datatypes/epc/domain/` + the new aggregate types into it.
|
||||
2. Carve out `packages/utils/` from current `utils/` + `backend/utils/`.
|
||||
3. Carve out `packages/repos/` and `packages/fetchers/` once `services/ara/` is being built and needs them.
|
||||
4. `services/ara/` is greenfield — no legacy code lives in it.
|
||||
5. `services/address2uprn/`, `services/pashub/`, etc. are split out as their owners pick them up.
|
||||
6. `backend/` shrinks to the FastAPI entrypoint layer once everything else has moved.
|
||||
|
||||
**Reused intact** (no rewrite needed at carve-out time):
|
||||
- `backend/epc_client/` → folds into `packages/fetchers/src/fetchers/epc_client.py`.
|
||||
- `datatypes/epc/domain/` → folds into `packages/domain/src/domain/epc/`.
|
||||
- `recommendations/optimiser/` → wrapped by `services/ara/src/ara/services/optimiser.py`.
|
||||
- `backend/app/db/` → repos delegate into `db_funcs.*` until SQL is rewritten under sub-PRD (iii).
|
||||
|
||||
---
|
||||
|
||||
## 12. Testing strategy
|
||||
|
||||
### 12.1 Unit tests (the bulk)
|
||||
|
||||
Every service test injects fake fetchers and fake repos. No DB, no network, no ML lambda. A service test verifies one slice of logic in 5–30 lines.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
def test_epc_prediction_flags_anomalous_wall_type():
|
||||
neighbours = [_make_epc(wall_construction="solid") for _ in range(5)]
|
||||
target = _make_property(epc=_make_epc(wall_construction="cavity"))
|
||||
repo = FakeGenericDataRepo(neighbours_by_postcode={target.identity.postcode: neighbours})
|
||||
|
||||
svc = EpcPredictionService(generic_repo=repo)
|
||||
result = svc.run(Properties([target]))
|
||||
|
||||
assert result[0].epc_anomaly_flags.wall_construction == "differs_from_neighbours"
|
||||
```
|
||||
|
||||
### 12.2 Integration tests
|
||||
|
||||
One per pipeline (Ingestion, Modelling, Refresh). Real Postgres (testcontainers or localstack), fake fetchers (hitting recorded fixtures), fake ML lambdas (returning canned predictions). Catches schema / SQL / transaction issues.
|
||||
|
||||
### 12.3 Contract tests
|
||||
|
||||
The transform (`EpcMlTransform`) has its own test suite:
|
||||
|
||||
- Golden file: given a fixed `Property`, output matches an expected DataFrame row exactly.
|
||||
- Schema test: the output columns exactly match a checked-in CSV header (so autogluon team sees breakage on PR).
|
||||
|
||||
### 12.4 What is NOT tested
|
||||
|
||||
- The autogluon repo's training code — owned there.
|
||||
- The gov EPC API behaviour — assumed via the official spec.
|
||||
- Front-end aggregation logic — owned there.
|
||||
|
||||
---
|
||||
|
||||
## 13. Observability
|
||||
|
||||
Each pipeline step emits a **structured log line** at start and end with:
|
||||
|
||||
```
|
||||
{step, property_id, uprn, portfolio_id, subtask_id, duration_ms, outcome, error?}
|
||||
```
|
||||
|
||||
Errors propagate with the `Property.identity` attached, so a portfolio of 100k can be triaged by grep.
|
||||
|
||||
The existing task/subtask state machine is preserved — `IngestionPipeline` and `ModellingPipeline` update subtask status at start (`in progress`), end (`complete` / `failed`), with the CloudWatch log URL attached as today.
|
||||
|
||||
CloudWatch alarms exist on subtask failure rate; thresholds remain unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 14. Data flow: a worked example
|
||||
|
||||
A landlord uploads a corrected heating system for UPRN 12345 via the UI.
|
||||
|
||||
1. **UI** → `POST /properties/12345/overrides` → writes to `landlord_overrides` table via `LandlordOverridesRepo`.
|
||||
2. **RefreshOrchestrator** invoked (either automatically on override-write, or by a "re-model" button). Notes: ingestion is *not* triggered because no external state changed.
|
||||
3. **ModellingPipeline** invoked on a batch of `[12345]`:
|
||||
- Reads `Property(uprn=12345)` from `PropertyRepo`.
|
||||
- `Property.effective_epc` = epc + landlord_overrides → heating system fields differ from baseline.
|
||||
- `RebaseliningService` triggered: ML re-predicts SAP / carbon / heat against the new effective EPC.
|
||||
- `EpcEnergyDerivationService` re-runs over the new effective EPC to derive baseline kWh + fuel split + bills (no ML).
|
||||
- `RecommendationService` regenerates recommendations against the new baseline.
|
||||
- `OptimiserService` re-picks optimal package.
|
||||
- `ResultsPersister` writes new plan under one UoW (old plan is superseded; whether to soft-archive is a sub-PRD (iii) decision).
|
||||
|
||||
Total external calls: zero. The override write is the only thing that hit a network boundary, and that was the inbound HTTP from the UI.
|
||||
|
||||
---
|
||||
|
||||
## 15. Open questions for team review
|
||||
|
||||
1. **One endpoint vs two** (§4.5) — **resolved**: single endpoint for Phase 1; split later when a real workflow demands it.
|
||||
2. **`LandlordOverrides` shape** (§6.2) — flat-Excel-shape for v1, with a flag to revisit after first customer.
|
||||
3. **`already_installed` and `non_invasive_recommendations`** (§6.5) — both likely subsumed by overlay, but final call deferred.
|
||||
4. **Recency tie-break policy** (§6.3) — default "newer wins"; team to consider per-portfolio override.
|
||||
5. **`GenericDataRepo` storage backend** — Postgres table, S3, or DynamoDB. Postgres is the path of least infra change; recommend defaulting to that.
|
||||
6. **Soft-archive vs hard-overwrite** for superseded plans (§14) — affects audit / undo behaviour. Defer to sub-PRD (iii).
|
||||
7. **Building-level optimisation as a Phase 2 service** (§10) — agreed deferred; flag for roadmap discussion.
|
||||
8. **Transform versioning policy** (§8.3) — semver chosen; team to confirm bump conventions.
|
||||
9. **UCL EPC-correction model** (§9.2 S4a) — **resolved**: Few et al. 2023 (Energy & Buildings 288, 113024). Implementation pattern already in [`AnnualBillSavings.adjust_energy_to_metered`](../../backend/ml_models/AnnualBillSavings.py) — port the per-band gradients/intercepts (Table 3) into `EpcEnergyDerivationService`, keyed on the post-state Effective EPC band.
|
||||
10. **Fuel-price source for bill calculation** (§9.2 S4a) — **resolved**: `FuelRatesRepo` is a time-versioned, region-aware table; ETL by `FuelRatesFetcher` (Ofgem feed + manual upload fallback). Per-portfolio override deferred to v2 — confirm whether Calico / Hyde have bulk-buy contracts before first onboarding.
|
||||
11. **kWh handling under Rebaselining** (§9.4) — **resolved**: ML re-predicts SAP/carbon/heat only; `EpcEnergyDerivationService` re-derives kWh from the rebaselined Effective EPC. Heating-fuel-type change is handled naturally because S4a re-reads heating fields from the Effective EPC.
|
||||
12. **Phase rollover semantics** (§9.2 S7) — when a candidate measure isn't selected in phase n, does it auto-roll into phase n+1's candidate pool, or does the user mark which measure types can roll? Auto is simpler; user-marked is more flexible. Decide at scenario-builder UX time.
|
||||
13. **Package-level vs per-measure ML scoring** (§9.4) — SAP impact of a measure is not strictly additive; the current per-measure scoring + linear optimisation approximates this. A future iteration may pre-define candidate packages and ML-score whole packages. Defer until per-service grill on `OptimiserService`.
|
||||
14. **UCL extrapolation scope** (§9.2 S4a) — the Few et al. paper is gas-heated, no PV, England + Wales only. Current legacy code applies the correction to all properties regardless. Keep silent extrapolation for v1, or stratify (no correction for non-gas / PV) and surface uncertainty to FE? Defer to per-service grill.
|
||||
15. **`ValuationService` rebuild** (§9.2 S8) — existing `PropertyValuation.estimate` cites several papers; the rebuild should improve the regression. Shape deferred to per-service grill.
|
||||
16. **Battery-via-ML cutover** (§9.2 S6) — confirm the new ML model is trained against `energy_pv_battery_count` + `energy_pv_battery_capacity` and the legacy `BatterySAPScorer` can be retired without regression for battery-equipped properties.
|
||||
|
||||
---
|
||||
|
||||
## 16. Linked sub-PRDs (placeholders)
|
||||
|
||||
- **Sub-PRD (ii) — ML training pipeline** — `docs/sub-prds/ml-training-pipeline.md` (TBC)
|
||||
- **Sub-PRD (iii) — DB schema migration** — `docs/sub-prds/db-schema-migration.md` (TBC)
|
||||
- **Sub-PRD (iv) — Historical EPC re-mapping** — `docs/sub-prds/historical-epc-remap.md` (TBC)
|
||||
|
||||
Each sub-PRD owner: TBC. Each is independently reviewable but consumes the contracts defined in §5 (`Property` aggregate), §7 (repos), §8 (ML transform).
|
||||
|
||||
---
|
||||
|
||||
## 17. Next steps
|
||||
|
||||
1. Team review of this PRD (target: ~1 week).
|
||||
2. Open follow-up grill sessions per service (`/grill-me` on each of S1–S8 + F1–F4) before that service is implemented.
|
||||
3. Break into issues via `/to-issues` against the project tracker.
|
||||
4. Stand up the empty `ara/` package skeleton + fakes + first integration-test scaffold as PR-1.
|
||||
5. Land services in dependency order: domain → repos → fetchers → services → orchestrators → API.
|
||||
|
||||
Phase 1 milestone gate: first portfolio (Calico or Hyde) routed through the new pipeline end-to-end in June, with a manual spot-check on 5 representative properties to confirm outputs are reasonable. No parity-against-old-engine check — the old engine is dead by then.
|
||||
|
|
@ -225,7 +225,7 @@ class EpcPropertyModel(SQLModel, table=True):
|
|||
pressure_test_certificate_number=data.pressure_test_certificate_number,
|
||||
percent_draughtproofed=data.percent_draughtproofed,
|
||||
insulated_door_u_value=data.insulated_door_u_value,
|
||||
multiple_glazed_proportion=data.multiple_glazed_propertion,
|
||||
multiple_glazed_proportion=data.multiple_glazed_proportion,
|
||||
windows_transmission_u_value=(
|
||||
data.windows_transmission_details.u_value
|
||||
if data.windows_transmission_details
|
||||
|
|
@ -501,7 +501,7 @@ class EpcBuildingPartModel(SQLModel, table=True):
|
|||
aw2 = part.sap_alternative_wall_2
|
||||
return cls(
|
||||
epc_property_id=epc_property_id,
|
||||
identifier=part.identifier,
|
||||
identifier=part.identifier.value,
|
||||
construction_age_band=part.construction_age_band,
|
||||
wall_construction=str(part.wall_construction),
|
||||
wall_insulation_type=str(part.wall_insulation_type),
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@ class HubspotDealData(SQLModel, table=True):
|
|||
damp_mould_and_repairs_comments: Optional[str] = Field(default=None)
|
||||
pre_sap: Optional[str] = Field(default=None)
|
||||
batch: Optional[str] = Field(default=None)
|
||||
batch_description: Optional[str] = Field(default=None)
|
||||
block_reference: Optional[str] = Field(default=None)
|
||||
nonfunded_measures: Optional[str] = Field(default=None)
|
||||
epc_prn: Optional[str] = Field(default=None)
|
||||
potential_post_sap_score_dropdown: Optional[str] = Field(default=None)
|
||||
ei_score: Optional[str] = Field(default=None)
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ from datetime import date, datetime
|
|||
from typing import List, Optional
|
||||
|
||||
from datatypes.epc.surveys.elmhurst_site_notes import (
|
||||
AlternativeWall,
|
||||
BathsAndShowers,
|
||||
BuildingPartDimensions,
|
||||
ElmhurstSiteNotes,
|
||||
ExtensionPart,
|
||||
FloorDetails,
|
||||
FloorDimension,
|
||||
Lighting,
|
||||
|
|
@ -14,6 +16,8 @@ from datatypes.epc.surveys.elmhurst_site_notes import (
|
|||
PropertyDetails,
|
||||
Renewables,
|
||||
RoofDetails,
|
||||
RoomInRoof,
|
||||
RoomInRoofSurface,
|
||||
Shower,
|
||||
SurveyorInfo,
|
||||
VentilationAndCooling,
|
||||
|
|
@ -79,6 +83,36 @@ class ElmhurstSiteNotesExtractor:
|
|||
except ValueError:
|
||||
return ""
|
||||
|
||||
# Multi-bp helpers: Summary PDFs subdivide §4/§7/§8/§9 with explicit
|
||||
# "Main Property" / "1st Extension" / "2nd Extension" headers. The
|
||||
# existing single-bp fixture also carries "Main Property" as a header
|
||||
# before the body. This helper splits a section into per-bp chunks.
|
||||
_BP_HEADER_RE = re.compile(
|
||||
r"^(Main Property|\d+(?:st|nd|rd|th) Extension)\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
def _split_section_by_bp(self, section_text: str) -> List[tuple[str, str]]:
|
||||
"""Split a section's text into per-bp subsections.
|
||||
|
||||
Returns ``[(bp_name, body), ...]`` in document order. Body is
|
||||
the text between this bp's header and the next bp's header
|
||||
(exclusive). Returns ``[("Main Property", section_text)]`` when
|
||||
no headers are found (defensive fallback for malformed PDFs).
|
||||
"""
|
||||
matches = list(self._BP_HEADER_RE.finditer(section_text))
|
||||
if not matches:
|
||||
return [("Main Property", section_text)]
|
||||
result: List[tuple[str, str]] = []
|
||||
for i, m in enumerate(matches):
|
||||
name = m.group(1)
|
||||
body_start = m.end()
|
||||
body_end = (
|
||||
matches[i + 1].start() if i + 1 < len(matches) else len(section_text)
|
||||
)
|
||||
result.append((name, section_text[body_start:body_end]))
|
||||
return result
|
||||
|
||||
def _section_lines(self, start: str, end: str) -> List[str]:
|
||||
text = self._between(start, end)
|
||||
return [l.strip() for l in text.splitlines() if l.strip()]
|
||||
|
|
@ -151,14 +185,13 @@ class ElmhurstSiteNotesExtractor:
|
|||
m = re.search(r"1\.0 Property type:\n[^\n]+\n([^\n]+)", self._text)
|
||||
return " ".join(m.group(1).strip().split()) if m else ""
|
||||
|
||||
def _extract_dimensions(self) -> BuildingPartDimensions:
|
||||
dim_type = self._str_val("Dimension type")
|
||||
section = self._between("4.0 Dimensions:", "5.0 Conservatory:")
|
||||
floor_matches = re.findall(
|
||||
def _floors_from_dimensions_body(self, body: str) -> List[FloorDimension]:
|
||||
"""Parse FloorDimension entries from a single bp's §4 body."""
|
||||
matches = re.findall(
|
||||
r"([A-Za-z ]+Floor):\n([\d.]+)\n([\d.]+)\n([\d.]+)\n([\d.]+)",
|
||||
section,
|
||||
body,
|
||||
)
|
||||
floors = [
|
||||
return [
|
||||
FloorDimension(
|
||||
name=name.strip(),
|
||||
area_m2=float(area),
|
||||
|
|
@ -166,12 +199,22 @@ class ElmhurstSiteNotesExtractor:
|
|||
heat_loss_perimeter_m=float(hlp),
|
||||
party_wall_length_m=float(pwl),
|
||||
)
|
||||
for name, area, height, hlp, pwl in floor_matches
|
||||
for name, area, height, hlp, pwl in matches
|
||||
]
|
||||
return BuildingPartDimensions(dimension_type=dim_type, floors=floors)
|
||||
|
||||
def _extract_walls(self) -> WallDetails:
|
||||
lines = self._section_lines("7.0 Walls:", "8.0 Roofs:")
|
||||
def _extract_dimensions(self) -> BuildingPartDimensions:
|
||||
"""Main-property dimensions only. Extensions are picked up by
|
||||
`_extract_extensions`."""
|
||||
dim_type = self._str_val("Dimension type")
|
||||
section = self._between("4.0 Dimensions:", "5.0 Conservatory:")
|
||||
bp_chunks = self._split_section_by_bp(section)
|
||||
main_body = bp_chunks[0][1] if bp_chunks else section
|
||||
return BuildingPartDimensions(
|
||||
dimension_type=dim_type,
|
||||
floors=self._floors_from_dimensions_body(main_body),
|
||||
)
|
||||
|
||||
def _wall_details_from_lines(self, lines: List[str]) -> WallDetails:
|
||||
thickness_raw = self._local_val(lines, "Wall Thickness")
|
||||
thickness_mm = (
|
||||
int(thickness_raw.split()[0]) if thickness_raw else None
|
||||
|
|
@ -183,23 +226,81 @@ class ElmhurstSiteNotesExtractor:
|
|||
u_value_known=self._local_bool(lines, "U-value Known"),
|
||||
party_wall_type=self._local_str(lines, "Party Wall Type"),
|
||||
thickness_mm=thickness_mm,
|
||||
alternative_walls=self._alternative_walls_from_lines(lines),
|
||||
)
|
||||
|
||||
def _extract_roof(self) -> RoofDetails:
|
||||
lines = self._section_lines("8.0 Roofs:", "8.1 Rooms in Roof:")
|
||||
def _alternative_walls_from_lines(self, lines: List[str]) -> List[AlternativeWall]:
|
||||
"""Parse up to two §7 "Alternative Wall N" sub-area lodgements.
|
||||
The Elmhurst Summary PDF lays them out as a contiguous block of
|
||||
prefixed labels ("Alternative Wall 1 Area", "Alternative Wall 1
|
||||
Type", …); we read each numbered slot independently and drop
|
||||
slots whose Area is missing/zero."""
|
||||
result: List[AlternativeWall] = []
|
||||
for n in (1, 2):
|
||||
area_raw = self._local_val(lines, f"Alternative Wall {n} Area")
|
||||
if not area_raw:
|
||||
continue
|
||||
try:
|
||||
area = float(area_raw.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if area <= 0:
|
||||
continue
|
||||
thickness_raw = self._local_val(lines, f"Alternative Wall {n} Thickness")
|
||||
thickness_mm = (
|
||||
int(thickness_raw.split()[0])
|
||||
if thickness_raw and thickness_raw.split()[0].isdigit()
|
||||
else None
|
||||
)
|
||||
result.append(AlternativeWall(
|
||||
area_m2=area,
|
||||
wall_type=self._local_str(lines, f"Alternative Wall {n} Type"),
|
||||
insulation=self._local_str(lines, f"Alternative Wall {n} Insulation"),
|
||||
thickness_unknown=self._local_bool(
|
||||
lines, f"Alternative Wall {n} Thickness Unknown"
|
||||
),
|
||||
thickness_mm=thickness_mm,
|
||||
u_value_known=self._local_bool(
|
||||
lines, f"Alternative Wall {n} U-value Known"
|
||||
),
|
||||
))
|
||||
return result
|
||||
|
||||
def _extract_walls(self) -> WallDetails:
|
||||
section = self._between("7.0 Walls:", "8.0 Roofs:")
|
||||
bp_chunks = self._split_section_by_bp(section)
|
||||
main_body = bp_chunks[0][1] if bp_chunks else section
|
||||
lines = [l.strip() for l in main_body.splitlines() if l.strip()]
|
||||
return self._wall_details_from_lines(lines)
|
||||
|
||||
def _roof_details_from_lines(self, lines: List[str]) -> RoofDetails:
|
||||
thickness_raw = self._local_val(lines, "Insulation Thickness")
|
||||
thickness_mm = (
|
||||
int(thickness_raw.split()[0]) if thickness_raw else None
|
||||
int(thickness_raw.split()[0]) if thickness_raw and thickness_raw.split()[0].isdigit() else None
|
||||
)
|
||||
insulation = self._local_str(lines, "Insulation")
|
||||
# The Summary PDF omits the "Insulation Thickness" line entirely
|
||||
# when no retrofit insulation is lodged (e.g. "Insulation: N None"
|
||||
# on 000516). Treat that case as 0 mm so the cascade picks Table
|
||||
# 16 row 0 (U=2.30) rather than the age-band default — the
|
||||
# surveyor explicitly recorded "None".
|
||||
if thickness_mm is None and insulation.split(" ", 1)[0] == "N":
|
||||
thickness_mm = 0
|
||||
return RoofDetails(
|
||||
roof_type=self._local_str(lines, "Type"),
|
||||
insulation=self._local_str(lines, "Insulation"),
|
||||
insulation=insulation,
|
||||
u_value_known=self._local_bool(lines, "U-value Known"),
|
||||
insulation_thickness_mm=thickness_mm,
|
||||
)
|
||||
|
||||
def _extract_floor(self) -> FloorDetails:
|
||||
lines = self._section_lines("9.0 Floors:", "10.0 Doors:")
|
||||
def _extract_roof(self) -> RoofDetails:
|
||||
section = self._between("8.0 Roofs:", "8.1 Rooms in Roof:")
|
||||
bp_chunks = self._split_section_by_bp(section)
|
||||
main_body = bp_chunks[0][1] if bp_chunks else section
|
||||
lines = [l.strip() for l in main_body.splitlines() if l.strip()]
|
||||
return self._roof_details_from_lines(lines)
|
||||
|
||||
def _floor_details_from_lines(self, lines: List[str]) -> FloorDetails:
|
||||
u_val_raw = self._local_val(lines, "Default U-value")
|
||||
default_u = float(u_val_raw) if u_val_raw else None
|
||||
return FloorDetails(
|
||||
|
|
@ -210,14 +311,251 @@ class ElmhurstSiteNotesExtractor:
|
|||
default_u_value=default_u,
|
||||
)
|
||||
|
||||
def _extract_floor(self) -> FloorDetails:
|
||||
section = self._between("9.0 Floors:", "10.0 Doors:")
|
||||
bp_chunks = self._split_section_by_bp(section)
|
||||
main_body = bp_chunks[0][1] if bp_chunks else section
|
||||
lines = [l.strip() for l in main_body.splitlines() if l.strip()]
|
||||
return self._floor_details_from_lines(lines)
|
||||
|
||||
# RIR surface row: `<name> <length> <height> [<insulation> [<ins_type>]
|
||||
# [<gable_type>] <default_u> <known> <u>]`. The middle slot
|
||||
# widths vary by surface kind; we match the four leading numerics
|
||||
# robustly (length, height, default_u, u_value) and slot the
|
||||
# remaining textual fields by position. The layout preprocessor
|
||||
# collapses multi-space-separated cells into single newlines, so
|
||||
# each row in the dump occupies multiple lines per cell.
|
||||
_RIR_SURFACE_NAMES: tuple[str, ...] = (
|
||||
"Flat Ceiling 1", "Flat Ceiling 2",
|
||||
"Stud Wall 1", "Stud Wall 2",
|
||||
"Slope 1", "Slope 2",
|
||||
"Gable Wall 1", "Gable Wall 2",
|
||||
"Common Wall 1", "Common Wall 2",
|
||||
)
|
||||
|
||||
def _extract_room_in_roof(
|
||||
self, main_dim_body: str, age_band_text: str
|
||||
) -> Optional[RoomInRoof]:
|
||||
"""Parse the §8.1 Rooms in Roof section for the Main bp. Returns
|
||||
None when no RR is lodged (single-storey or simple loft houses).
|
||||
`main_dim_body` is the Main-property §4 chunk used to pull the
|
||||
RR floor area; `age_band_text` is the §3 raw text holding the
|
||||
"Main Prop. Room(s) in Roof <band>" line."""
|
||||
# RR floor area lives in §4 Dimensions immediately above the
|
||||
# storey floor entries: "Room(s) in Roof: 15.06".
|
||||
m = re.search(r"Room\(s\) in Roof:\s+(\d+(?:\.\d+)?)", main_dim_body)
|
||||
if m is None:
|
||||
return None
|
||||
floor_area = float(m.group(1))
|
||||
if floor_area <= 0:
|
||||
return None
|
||||
|
||||
section = self._between("8.1 Rooms in Roof:", "9.0 Floors:")
|
||||
if not section.strip() or "Room in roof type" not in section:
|
||||
return None
|
||||
bp_chunks = self._split_section_by_bp(section)
|
||||
main_body = bp_chunks[0][1] if bp_chunks else section
|
||||
lines = [l.strip() for l in main_body.splitlines() if l.strip()]
|
||||
|
||||
assessment_idx = next(
|
||||
(i for i, l in enumerate(lines) if l == "Assessment"), None
|
||||
)
|
||||
assessment = (
|
||||
lines[assessment_idx + 1] if assessment_idx is not None and assessment_idx + 1 < len(lines) else ""
|
||||
)
|
||||
|
||||
surfaces: List[RoomInRoofSurface] = []
|
||||
for name in self._RIR_SURFACE_NAMES:
|
||||
try:
|
||||
idx = lines.index(name)
|
||||
except ValueError:
|
||||
continue
|
||||
surfaces.append(self._parse_rir_surface_row(name, lines, idx))
|
||||
|
||||
# Age band from §3: "Main Prop. Room(s) in Roof B 1900-1929"
|
||||
age_m = re.search(
|
||||
r"Main Prop\. Room\(s\) in Roof\s+([A-M] [^\n]+)", age_band_text
|
||||
)
|
||||
age_band = age_m.group(1).strip() if age_m else None
|
||||
|
||||
return RoomInRoof(
|
||||
floor_area_m2=floor_area,
|
||||
construction_age_band=age_band,
|
||||
assessment=assessment,
|
||||
surfaces=surfaces,
|
||||
)
|
||||
|
||||
_RIR_NUMERIC_RE = re.compile(r"^-?\d+(?:\.\d+)?$")
|
||||
_RIR_INSULATION_THICKNESS_RE = re.compile(r"^\d+\s*mm$")
|
||||
|
||||
def _parse_rir_surface_row(
|
||||
self, name: str, lines: List[str], idx: int
|
||||
) -> RoomInRoofSurface:
|
||||
"""One RR surface row spans the name line followed by ~6-9 tokens
|
||||
depending on which optional cells the surveyor filled. The token
|
||||
order is stable: length, height, [insulation], [ins_type],
|
||||
[gable_type], default_u, u_known, u_value. Numeric cells (length,
|
||||
height, default_u, u_value) are the anchor; everything else is
|
||||
slotted into the appropriate textual field."""
|
||||
# Walk forward until either we exhaust the cell budget or hit
|
||||
# the next RIR row's name marker — the layout dump puts each
|
||||
# numeric / textual cell on its own line and we can't tell
|
||||
# the LAST cell of THIS row from the FIRST cell of the next
|
||||
# without that signal.
|
||||
tokens: List[str] = []
|
||||
scan_end = min(idx + 10, len(lines))
|
||||
for j in range(idx + 1, scan_end):
|
||||
if self._is_next_rir_row(lines[j]):
|
||||
break
|
||||
tokens.append(lines[j])
|
||||
# First two numerics = length, height
|
||||
length = float(tokens[0]) if tokens and self._RIR_NUMERIC_RE.match(tokens[0]) else 0.0
|
||||
height = float(tokens[1]) if len(tokens) > 1 and self._RIR_NUMERIC_RE.match(tokens[1]) else 0.0
|
||||
|
||||
# Last numeric is u_value; preceding "Yes"/"No" is u_value_known;
|
||||
# the numeric before that is default_u.
|
||||
# Walk from the end backwards looking for the u_value, then known
|
||||
# flag, then default_u.
|
||||
u_value = 0.0
|
||||
u_value_known = False
|
||||
default_u: Optional[float] = None
|
||||
# The known/default_u tail is fairly stable; collect the trailing
|
||||
# tokens and slot by position. The "known" token is "No" or "Yes".
|
||||
rev = list(reversed(tokens[2:]))
|
||||
# rev[0] = u_value, rev[1] = u_value_known, rev[2] = default_u
|
||||
if len(rev) >= 1 and self._RIR_NUMERIC_RE.match(rev[0]):
|
||||
u_value = float(rev[0])
|
||||
if len(rev) >= 2 and rev[1] in ("Yes", "No"):
|
||||
u_value_known = rev[1] == "Yes"
|
||||
if len(rev) >= 3 and self._RIR_NUMERIC_RE.match(rev[2]):
|
||||
default_u = float(rev[2])
|
||||
|
||||
# Middle textual cells: insulation, insulation_type, gable_type.
|
||||
# Drop the leading length/height (already consumed) and the
|
||||
# trailing 3 tokens (default_u, known, u_value).
|
||||
middle = tokens[2:-3] if len(tokens) >= 5 else []
|
||||
insulation = ""
|
||||
insulation_type: Optional[str] = None
|
||||
gable_type: Optional[str] = None
|
||||
for t in middle:
|
||||
if self._RIR_INSULATION_THICKNESS_RE.match(t) or t in ("As Built", "None"):
|
||||
if not insulation:
|
||||
insulation = t
|
||||
elif t in ("Mineral or EPS", "PUR", "PIR"):
|
||||
insulation_type = t
|
||||
elif t in ("Party", "Sheltered", "Connected to heated space"):
|
||||
gable_type = t
|
||||
return RoomInRoofSurface(
|
||||
name=name,
|
||||
length_m=length,
|
||||
height_m=height,
|
||||
insulation=insulation,
|
||||
insulation_type=insulation_type,
|
||||
gable_type=gable_type,
|
||||
default_u_value=default_u,
|
||||
u_value_known=u_value_known,
|
||||
u_value=u_value,
|
||||
)
|
||||
|
||||
def _is_next_rir_row(self, line: str) -> bool:
|
||||
return line in self._RIR_SURFACE_NAMES
|
||||
|
||||
def _extract_extensions(self) -> List[ExtensionPart]:
|
||||
"""Collect non-Main building parts. Cross-references the §4, §7,
|
||||
§8, §9 per-bp subsections by extension name. "As Main: Yes"
|
||||
within a section body inherits the main bp's data for that
|
||||
section; otherwise the section body is parsed in isolation."""
|
||||
# Gather per-section chunks once.
|
||||
dim_section = self._between("4.0 Dimensions:", "5.0 Conservatory:")
|
||||
wall_section = self._between("7.0 Walls:", "8.0 Roofs:")
|
||||
roof_section = self._between("8.0 Roofs:", "8.1 Rooms in Roof:")
|
||||
floor_section = self._between("9.0 Floors:", "10.0 Doors:")
|
||||
dim_type = self._str_val("Dimension type")
|
||||
|
||||
dim_chunks = dict(self._split_section_by_bp(dim_section))
|
||||
wall_chunks = dict(self._split_section_by_bp(wall_section))
|
||||
roof_chunks = dict(self._split_section_by_bp(roof_section))
|
||||
floor_chunks = dict(self._split_section_by_bp(floor_section))
|
||||
|
||||
main_walls = self._extract_walls()
|
||||
main_roof = self._extract_roof()
|
||||
main_floor = self._extract_floor()
|
||||
|
||||
# Per-bp age-band lookup. Section 3 contains lines like
|
||||
# "1st Extension B 1900-1929" — the band sits after the name.
|
||||
age_band_re = re.compile(
|
||||
r"^(\d+(?:st|nd|rd|th) Extension)\s+([A-M] [^\n]+)$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
age_bands = {m.group(1): m.group(2).strip() for m in age_band_re.finditer(self._text)}
|
||||
|
||||
# Collect names in document order from the dimensions section
|
||||
# (excluding Main Property).
|
||||
names = [
|
||||
name for name, _ in self._split_section_by_bp(dim_section)
|
||||
if name != "Main Property"
|
||||
]
|
||||
|
||||
extensions: List[ExtensionPart] = []
|
||||
for name in names:
|
||||
dim_body = dim_chunks.get(name, "")
|
||||
wall_body = wall_chunks.get(name, "")
|
||||
roof_body = roof_chunks.get(name, "")
|
||||
floor_body = floor_chunks.get(name, "")
|
||||
|
||||
wall_lines = [l.strip() for l in wall_body.splitlines() if l.strip()]
|
||||
roof_lines = [l.strip() for l in roof_body.splitlines() if l.strip()]
|
||||
floor_lines = [l.strip() for l in floor_body.splitlines() if l.strip()]
|
||||
|
||||
if self._local_bool(wall_lines, "As Main Wall"):
|
||||
# Alternative walls live in the extension's own chunk
|
||||
# even when the main wall fields are inherited; merge
|
||||
# them into the inherited WallDetails so the bp carries
|
||||
# them through to its SapBuildingPart.
|
||||
walls = WallDetails(
|
||||
wall_type=main_walls.wall_type,
|
||||
insulation=main_walls.insulation,
|
||||
thickness_unknown=main_walls.thickness_unknown,
|
||||
u_value_known=main_walls.u_value_known,
|
||||
party_wall_type=main_walls.party_wall_type,
|
||||
thickness_mm=main_walls.thickness_mm,
|
||||
alternative_walls=self._alternative_walls_from_lines(wall_lines),
|
||||
)
|
||||
else:
|
||||
walls = self._wall_details_from_lines(wall_lines)
|
||||
roof = main_roof if self._local_bool(roof_lines, "As Main") else self._roof_details_from_lines(roof_lines)
|
||||
floor = main_floor if self._local_bool(floor_lines, "As Main") else self._floor_details_from_lines(floor_lines)
|
||||
|
||||
extensions.append(
|
||||
ExtensionPart(
|
||||
name=name,
|
||||
construction_age_band=age_bands.get(name, ""),
|
||||
dimensions=BuildingPartDimensions(
|
||||
dimension_type=dim_type,
|
||||
floors=self._floors_from_dimensions_body(dim_body),
|
||||
),
|
||||
walls=walls,
|
||||
roof=roof,
|
||||
floor=floor,
|
||||
)
|
||||
)
|
||||
return extensions
|
||||
|
||||
def _extract_windows(self) -> List[Window]:
|
||||
# Textract-style pages keep "Permanent\s+Shutters" adjacent in
|
||||
# reading order and the windows table flows as one column-block
|
||||
# the existing token-walker can step through. PDF-derived pages
|
||||
# (Summary PDFs preprocessed from `pdftotext -layout`) break the
|
||||
# header across lines, so this regex misses entirely and the
|
||||
# `_extract_windows_from_layout` fallback below picks them up
|
||||
# by anchoring on the W/H/Area data line.
|
||||
m = re.search(
|
||||
r"Permanent\s+Shutters\n(.*?)Draught Proofing",
|
||||
self._text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not m:
|
||||
return []
|
||||
return self._extract_windows_from_layout()
|
||||
tokens = [t.strip() for t in m.group(1).splitlines() if t.strip()]
|
||||
windows: List[Window] = []
|
||||
i = 0
|
||||
|
|
@ -285,6 +623,323 @@ class ElmhurstSiteNotesExtractor:
|
|||
)
|
||||
return windows
|
||||
|
||||
# Anchors used by the layout-style window parser. The W/H/Area anchor
|
||||
# is sometimes followed by a joined glazing-type phrase on the same
|
||||
# line (e.g. '1.22 1.76 2.15 Double pre 2002'); the optional 4th
|
||||
# capture surfaces that text so the parser can use it instead of a
|
||||
# separately-laid-out prefix line.
|
||||
_WIDTH_HEIGHT_AREA_RE = re.compile(
|
||||
r"^(\d+\.\d+)\s+(\d+\.\d+)\s+(\d+\.\d+)(?:\s+(\S.*?))?$"
|
||||
)
|
||||
_MANUFACTURER_RE = re.compile(r"^(Manufacturer|Default)\s+(\d+\.\d+)$")
|
||||
_ORIENTATION_TOKENS = frozenset({
|
||||
"North", "South", "East", "West", "NE", "NW", "SE", "SW",
|
||||
})
|
||||
_BP_INLINE_TOKENS = frozenset({"Main"}) # "Extension" only appears as suffix
|
||||
# The Elmhurst Summary PDF lodges each window's glazing-type as a
|
||||
# capitalised phrase like "Double between 2002" / "Double with unknown"
|
||||
# / "Single" / "Triple" / "Secondary". The first token of that phrase
|
||||
# marks the start of a new window's prefix block in the layout dump,
|
||||
# which is the only stable signal partitioning one window's suffix
|
||||
# from the next window's prefix.
|
||||
_GLAZING_TYPE_PREFIX_WORDS = frozenset({
|
||||
"Single", "Double", "Triple", "Secondary",
|
||||
})
|
||||
|
||||
def _extract_windows_from_layout(self) -> List[Window]:
|
||||
"""Fallback window parser for Summary PDFs preprocessed from
|
||||
`pdftotext -layout`. Each window has two stable anchors:
|
||||
a "W H Area" line and a "Manufacturer <U_value>" line a few
|
||||
lines further down. Everything between holds frame_type,
|
||||
frame_factor, and a variable mix of glazing_gap, building_part,
|
||||
location, and orientation (depending on which fields the
|
||||
surveyor lodged); everything around the window holds glazing-
|
||||
type/building-part/orientation prefix/suffix tokens split by
|
||||
the layout preprocessor.
|
||||
"""
|
||||
m = re.search(
|
||||
r"11\.0 Windows:(.*?)(Draught Proofing|12\.0 Ventilation)",
|
||||
self._text, re.DOTALL,
|
||||
)
|
||||
if not m:
|
||||
return []
|
||||
lines = m.group(1).splitlines()
|
||||
|
||||
# Locate all (data_line, manufacturer_line) pairs in document
|
||||
# order. Each pair is one window.
|
||||
data_anchors: List[tuple[int, re.Match[str]]] = []
|
||||
for i, line in enumerate(lines):
|
||||
anchor = self._WIDTH_HEIGHT_AREA_RE.match(line.strip())
|
||||
if anchor is not None:
|
||||
data_anchors.append((i, anchor))
|
||||
|
||||
windows: List[Window] = []
|
||||
for k, (data_idx, anchor) in enumerate(data_anchors):
|
||||
manuf_idx = self._find_manufacturer_after(lines, data_idx)
|
||||
if manuf_idx is None:
|
||||
continue
|
||||
prev_manuf_idx = (
|
||||
self._find_manufacturer_after(lines, data_anchors[k - 1][0])
|
||||
if k > 0 else None
|
||||
)
|
||||
next_data_idx = (
|
||||
data_anchors[k + 1][0] if k + 1 < len(data_anchors) else len(lines)
|
||||
)
|
||||
# Partition the cross-window gap between this window's suffix
|
||||
# and the next window's prefix on the first glazing-type-start
|
||||
# token (Single/Double/Triple/Secondary). The same boundary
|
||||
# is used symmetrically — current window's `after_end` = next
|
||||
# window's `before_start` — so prefix tokens of W_{k+1} never
|
||||
# get attributed as suffix of W_k (which was the bug producing
|
||||
# orientation='East-South' for windows where 'South' actually
|
||||
# belonged to the next row).
|
||||
before_start = (
|
||||
self._partition_after_manuf(lines, prev_manuf_idx, data_idx)
|
||||
if prev_manuf_idx is not None else 0
|
||||
)
|
||||
after_end = self._partition_after_manuf(lines, manuf_idx, next_data_idx)
|
||||
try:
|
||||
window = self._parse_window_from_anchors(
|
||||
lines=lines,
|
||||
data_idx=data_idx,
|
||||
manuf_idx=manuf_idx,
|
||||
anchor=anchor,
|
||||
before_start=before_start,
|
||||
after_end=after_end,
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if window is not None:
|
||||
windows.append(window)
|
||||
return windows
|
||||
|
||||
def _find_manufacturer_after(self, lines: List[str], data_idx: int) -> Optional[int]:
|
||||
for j in range(data_idx + 1, min(data_idx + 12, len(lines))):
|
||||
if self._MANUFACTURER_RE.match(lines[j].strip()):
|
||||
return j
|
||||
return None
|
||||
|
||||
_FRAME_TYPE_AND_FACTOR_RE = re.compile(r"^(\S+(?:\s+\S+)*?)\s+(\d\.\d+)$")
|
||||
_FRAME_FACTOR_ONLY_RE = re.compile(r"^(\d\.\d+)$")
|
||||
|
||||
def _parse_frame_type_and_factor(
|
||||
self, lines: List[str], data_idx: int
|
||||
) -> tuple[str, Optional[float], int]:
|
||||
"""Return `(frame_type, frame_factor, middle_start_idx)` from
|
||||
the lines immediately after the data anchor. Layouts vary:
|
||||
(a) "PVC" on data+1, "0.70" on data+2 — the original 000474
|
||||
shape;
|
||||
(b) "Wood 0.70" on data+1 — joined-cell variant from 000487
|
||||
and 000516 first-row windows;
|
||||
(c) "0.70" alone on data+1 (no frame_type word at all) —
|
||||
seen in 000487's subsequent windows where the
|
||||
preprocessor dropped the frame-type column. frame_type
|
||||
is recovered downstream from glazing-type defaults or
|
||||
left empty."""
|
||||
first = lines[data_idx + 1].strip()
|
||||
combined = self._FRAME_TYPE_AND_FACTOR_RE.match(first)
|
||||
if combined is not None:
|
||||
return combined.group(1), float(combined.group(2)), data_idx + 2
|
||||
factor_only = self._FRAME_FACTOR_ONLY_RE.match(first)
|
||||
if factor_only is not None:
|
||||
return "", float(factor_only.group(1)), data_idx + 2
|
||||
if data_idx + 2 >= len(lines):
|
||||
return first, None, data_idx + 2
|
||||
frame_type = first
|
||||
try:
|
||||
frame_factor = float(lines[data_idx + 2].strip())
|
||||
except ValueError:
|
||||
return frame_type, None, data_idx + 3
|
||||
return frame_type, frame_factor, data_idx + 3
|
||||
|
||||
def _partition_after_manuf(
|
||||
self, lines: List[str], manuf_idx: int, next_data_idx: int
|
||||
) -> int:
|
||||
"""Return the exclusive upper bound for this window's suffix
|
||||
block (and the inclusive lower bound for the next window's prefix
|
||||
block). After the manufacturer line come 3 fixed tokens (g_value,
|
||||
draught, shutters); the variable suffix lines start at manuf+4
|
||||
and run until either (a) the next window's glazing-type-start
|
||||
token (e.g. 'Double between 2002', 'Single', 'Triple ...') or
|
||||
(b) the second orientation token in the gap, whichever comes
|
||||
first. Branch (b) covers layouts where the glazing-type is
|
||||
joined to the data line (no separate prefix line exists), so
|
||||
the only signal of window-transition is the orientation tokens
|
||||
rotating: orient_suffix(k) → orient_prefix(k+1). Falls through
|
||||
to `next_data_idx` when neither marker is present."""
|
||||
scan_start = manuf_idx + 4
|
||||
seen_orient = False
|
||||
for j in range(scan_start, next_data_idx):
|
||||
stripped = lines[j].strip()
|
||||
first_word = stripped.split(" ", 1)[0]
|
||||
if first_word in self._GLAZING_TYPE_PREFIX_WORDS:
|
||||
return j
|
||||
if stripped in self._ORIENTATION_TOKENS:
|
||||
if seen_orient:
|
||||
return j
|
||||
seen_orient = True
|
||||
return next_data_idx
|
||||
|
||||
def _parse_window_from_anchors(
|
||||
self,
|
||||
*,
|
||||
lines: List[str],
|
||||
data_idx: int,
|
||||
manuf_idx: int,
|
||||
anchor: re.Match[str],
|
||||
before_start: int,
|
||||
after_end: int,
|
||||
) -> Optional[Window]:
|
||||
width = float(anchor.group(1))
|
||||
height = float(anchor.group(2))
|
||||
area = float(anchor.group(3))
|
||||
# Layout-style cell joining sometimes leaves the glazing-type
|
||||
# phrase trailing the W H Area triplet on the same line (e.g.
|
||||
# "1.22 1.76 2.15 Double pre 2002"); when present we pass it
|
||||
# through as `inline_glazing_type` and the composer skips the
|
||||
# would-be glazing-prefix scan.
|
||||
inline_glazing_type = anchor.group(4) if anchor.lastindex and anchor.lastindex >= 4 else None
|
||||
|
||||
# frame_type and frame_factor immediately follow the data line.
|
||||
# Layout-style cell joining sometimes collapses them onto a
|
||||
# single "Wood 0.70" line; treat both shapes uniformly so the
|
||||
# downstream `middle` slice still starts at the first variable
|
||||
# field (glazing_gap / bp / location / orient).
|
||||
if data_idx + 1 >= len(lines):
|
||||
return None
|
||||
frame_type, frame_factor, middle_start = self._parse_frame_type_and_factor(
|
||||
lines, data_idx
|
||||
)
|
||||
if frame_factor is None or not 0.0 < frame_factor <= 1.0:
|
||||
return None
|
||||
|
||||
# Variable-order tokens between frame_factor and Manufacturer.
|
||||
middle = [lines[j].strip() for j in range(middle_start, manuf_idx)]
|
||||
glazing_gap = next((t for t in middle if "mm" in t.lower()), None)
|
||||
location = next((t for t in middle if "wall" in t.lower()), "External wall")
|
||||
bp_inline = next((t for t in middle if t in self._BP_INLINE_TOKENS), None)
|
||||
orient_inline = next(
|
||||
(t for t in middle if t in self._ORIENTATION_TOKENS), None
|
||||
)
|
||||
|
||||
# Manufacturer line carries data_source + u_value.
|
||||
manuf_match = self._MANUFACTURER_RE.match(lines[manuf_idx].strip())
|
||||
if manuf_match is None:
|
||||
return None
|
||||
data_source = manuf_match.group(1)
|
||||
u_value = float(manuf_match.group(2))
|
||||
|
||||
# Post-manufacturer: g_value, draught, shutters.
|
||||
if manuf_idx + 3 >= len(lines):
|
||||
return None
|
||||
try:
|
||||
g_value = float(lines[manuf_idx + 1].strip())
|
||||
except ValueError:
|
||||
return None
|
||||
draught_proofed = lines[manuf_idx + 2].strip().lower() == "yes"
|
||||
permanent_shutters = lines[manuf_idx + 3].strip()
|
||||
|
||||
# Prefix / suffix tokens (variable count) carry the
|
||||
# glazing-type, building-part, and orientation strings split by
|
||||
# the layout preprocessor.
|
||||
before = [lines[j].strip() for j in range(before_start, data_idx) if lines[j].strip()]
|
||||
after = [lines[j].strip() for j in range(manuf_idx + 4, after_end) if lines[j].strip()]
|
||||
|
||||
glazing_type, building_part, orientation = self._compose_window_descriptors(
|
||||
before=before,
|
||||
after=after,
|
||||
bp_inline=bp_inline,
|
||||
orient_inline=orient_inline,
|
||||
inline_glazing_type=inline_glazing_type,
|
||||
)
|
||||
|
||||
return Window(
|
||||
width_m=width,
|
||||
height_m=height,
|
||||
area_m2=area,
|
||||
glazing_type=glazing_type,
|
||||
frame_factor=frame_factor,
|
||||
building_part=building_part,
|
||||
location=location,
|
||||
orientation=orientation,
|
||||
data_source=data_source,
|
||||
u_value=u_value,
|
||||
g_value=g_value,
|
||||
draught_proofed=draught_proofed,
|
||||
permanent_shutters=permanent_shutters,
|
||||
frame_type=frame_type,
|
||||
glazing_gap=glazing_gap,
|
||||
)
|
||||
|
||||
def _compose_window_descriptors(
|
||||
self,
|
||||
*,
|
||||
before: List[str],
|
||||
after: List[str],
|
||||
bp_inline: Optional[str],
|
||||
orient_inline: Optional[str],
|
||||
inline_glazing_type: Optional[str] = None,
|
||||
) -> tuple[str, str, str]:
|
||||
"""Re-join the glazing-type / building-part / orientation tokens
|
||||
split by the layout preprocessor. Each is at most 2 fragments
|
||||
(one before the data line, one after); inline tokens in the
|
||||
between-segment win over prefix/suffix fragments."""
|
||||
# before holds (in document order, possibly): glazing_prefix,
|
||||
# bp_prefix, orient_prefix — bp/orient may be missing.
|
||||
# after holds: glazing_suffix, bp_suffix, orient_suffix — same.
|
||||
prefix = list(before[-3:]) # last 3 lines preceding data
|
||||
suffix = list(after[:3])
|
||||
|
||||
def pop_if_orientation(tokens: List[str]) -> Optional[str]:
|
||||
for t in tokens:
|
||||
if t in self._ORIENTATION_TOKENS:
|
||||
tokens.remove(t)
|
||||
return t
|
||||
return None
|
||||
|
||||
def pop_if_bp_fragment(tokens: List[str]) -> Optional[str]:
|
||||
# Prefix fragments like "1st" / "2nd" — match digit-prefixed
|
||||
# ordinals; suffix fragments are always "Extension".
|
||||
for t in tokens:
|
||||
if re.match(r"^\d+(?:st|nd|rd|th)$", t) or t == "Extension":
|
||||
tokens.remove(t)
|
||||
return t
|
||||
return None
|
||||
|
||||
orient_prefix_token = pop_if_orientation(prefix)
|
||||
orient_suffix_token = pop_if_orientation(suffix)
|
||||
bp_prefix_frag = pop_if_bp_fragment(prefix)
|
||||
bp_suffix_frag = pop_if_bp_fragment(suffix)
|
||||
|
||||
# Glazing type: an inline glazing-type captured from the data
|
||||
# line (layout-joined variant) wins; otherwise join the remaining
|
||||
# prefix + suffix fragments.
|
||||
if inline_glazing_type is not None:
|
||||
glazing_type = inline_glazing_type
|
||||
else:
|
||||
glazing_type = " ".join([*prefix, *suffix]).strip()
|
||||
|
||||
# Building part: inline token wins; otherwise join prefix + suffix.
|
||||
if bp_inline is not None:
|
||||
building_part = bp_inline
|
||||
else:
|
||||
building_part = " ".join(
|
||||
t for t in (bp_prefix_frag, bp_suffix_frag) if t
|
||||
).strip()
|
||||
|
||||
# Orientation: inline token wins for the primary direction;
|
||||
# combine with the opposite-direction fragment when present.
|
||||
primary = orient_inline or orient_prefix_token or ""
|
||||
secondary_candidates = [
|
||||
t for t in (orient_prefix_token, orient_suffix_token) if t and t != primary
|
||||
]
|
||||
if primary and secondary_candidates:
|
||||
orientation = f"{primary}-{secondary_candidates[0]}"
|
||||
else:
|
||||
orientation = primary
|
||||
|
||||
return glazing_type, building_part, orientation
|
||||
|
||||
def _extract_ventilation(self) -> VentilationAndCooling:
|
||||
return VentilationAndCooling(
|
||||
open_chimneys_count=self._int_val("No. of open chimneys"),
|
||||
|
|
@ -326,6 +981,20 @@ class ElmhurstSiteNotesExtractor:
|
|||
lines = self._section_lines("14.0 Main Heating1", "14.1 Main Heating2")
|
||||
pct_raw = self._local_val(lines, "Percentage of Heat")
|
||||
pct = int(pct_raw.split()[0]) if pct_raw else 0
|
||||
# The "Secondary Heating SapCode" key is lodged inside §14.1 Main
|
||||
# Heating2 — Elmhurst uses the Main-2 block to also carry the
|
||||
# cert's secondary heating system (when one exists). Look for it
|
||||
# in that section; absence (or "0") means no secondary lodged.
|
||||
secondary_lines = self._section_lines(
|
||||
"14.1 Main Heating2", "14.1 Community Heating"
|
||||
)
|
||||
secondary_raw = self._local_val(secondary_lines, "Secondary Heating SapCode")
|
||||
secondary_code = (
|
||||
int(secondary_raw)
|
||||
if secondary_raw is not None and secondary_raw.isdigit()
|
||||
and int(secondary_raw) > 0
|
||||
else None
|
||||
)
|
||||
return MainHeating(
|
||||
heat_emitter=self._local_str(lines, "Heat Emitter"),
|
||||
fuel_type=self._local_str(lines, "Fuel Type"),
|
||||
|
|
@ -337,6 +1006,7 @@ class ElmhurstSiteNotesExtractor:
|
|||
percentage_of_heat=pct,
|
||||
pcdf_boiler_reference=self._local_val(lines, "PCDF boiler Reference"),
|
||||
heat_pump_age=self._local_val(lines, "Heat pump age"),
|
||||
secondary_heating_sap_code=secondary_code,
|
||||
)
|
||||
|
||||
def _extract_meters(self) -> Meters:
|
||||
|
|
@ -448,4 +1118,15 @@ class ElmhurstSiteNotesExtractor:
|
|||
water_heating=self._extract_water_heating(),
|
||||
baths_and_showers=self._extract_baths_and_showers(),
|
||||
renewables=self._extract_renewables(),
|
||||
extensions=self._extract_extensions(),
|
||||
room_in_roof=self._extract_room_in_roof_from_text(),
|
||||
)
|
||||
|
||||
def _extract_room_in_roof_from_text(self) -> Optional[RoomInRoof]:
|
||||
"""Convenience wrapper: pulls the Main §4 body + the §3 age-band
|
||||
text once so `_extract_room_in_roof` doesn't need to re-slice
|
||||
the document."""
|
||||
dim_section = self._between("4.0 Dimensions:", "5.0 Conservatory:")
|
||||
bp_chunks = self._split_section_by_bp(dim_section)
|
||||
main_body = bp_chunks[0][1] if bp_chunks else dim_section
|
||||
return self._extract_room_in_roof(main_body, self._text)
|
||||
|
|
|
|||
BIN
backend/documents_parser/tests/fixtures/Summary_000474.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000474.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_000477.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000477.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_000480.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000480.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_000487.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000487.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_000490.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000490.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_000516.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_000516.pdf
vendored
Normal file
Binary file not shown.
BIN
backend/documents_parser/tests/fixtures/Summary_001479.pdf
vendored
Normal file
BIN
backend/documents_parser/tests/fixtures/Summary_001479.pdf
vendored
Normal file
Binary file not shown.
|
|
@ -5,7 +5,7 @@ from datetime import date
|
|||
import pytest
|
||||
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
from datatypes.epc.domain.epc_property_data import EpcPropertyData
|
||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier, EpcPropertyData
|
||||
from datatypes.epc.domain.mapper import EpcPropertyDataMapper
|
||||
|
||||
FIXTURE_PATH = os.path.join(
|
||||
|
|
@ -130,16 +130,23 @@ class TestBuildingPart:
|
|||
assert len(result.sap_building_parts) == 1
|
||||
|
||||
def test_identifier(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].identifier == "main"
|
||||
assert result.sap_building_parts[0].identifier is BuildingPartIdentifier.MAIN
|
||||
|
||||
def test_construction_age_band(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].construction_age_band == "1950-1966"
|
||||
# Spec age-band letter code per RdSAP10 Table 1; the cascade
|
||||
# reads this code letter for U-value lookups, not the year-range
|
||||
# description.
|
||||
assert result.sap_building_parts[0].construction_age_band == "D"
|
||||
|
||||
def test_wall_construction(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].wall_construction == "Cavity"
|
||||
# SAP10 wall_construction integer: 4 = Cavity (per
|
||||
# domain.sap10_ml.rdsap_uvalues.WALL_CAVITY).
|
||||
assert result.sap_building_parts[0].wall_construction == 4
|
||||
|
||||
def test_wall_insulation_type(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].wall_insulation_type == "Filled Cavity"
|
||||
# SAP10 wall_insulation_type integer: 2 = Filled cavity (per
|
||||
# domain.sap10_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:
|
||||
assert result.sap_building_parts[0].wall_thickness_measured is True
|
||||
|
|
@ -194,14 +201,25 @@ class TestWindows:
|
|||
def test_window_count(self, result: EpcPropertyData) -> None:
|
||||
assert len(result.sap_windows) == 4
|
||||
|
||||
def test_first_window_width(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_windows[0].window_width == 1.30
|
||||
def test_first_window_area(self, result: EpcPropertyData) -> None:
|
||||
# The Elmhurst mapper lodges the Summary PDF's precomputed Area
|
||||
# (1.30 × 1.10 = 1.43 m²) as `window_width × 1.0` to avoid the
|
||||
# 2-d.p. round-trip drift that W × H reintroduces. The cascade
|
||||
# reads only the product, so flattening to (area, 1.0) is
|
||||
# behaviourally equivalent to (1.30, 1.10) modulo precision.
|
||||
w = result.sap_windows[0]
|
||||
assert w.window_width * w.window_height == 1.43
|
||||
|
||||
def test_first_window_height(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_windows[0].window_height == 1.10
|
||||
# See `test_first_window_area` — the mapper normalises height
|
||||
# to 1.0 so the lodged Area can be carried as the canonical
|
||||
# geometry without re-multiplying.
|
||||
assert result.sap_windows[0].window_height == 1.0
|
||||
|
||||
def test_first_window_orientation(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_windows[0].orientation == "North"
|
||||
# SAP10 octant code: 1 = North. The solar-gains cascade keys
|
||||
# off the integer, not the cardinal-direction string.
|
||||
assert result.sap_windows[0].orientation == 1
|
||||
|
||||
def test_first_window_glazing_type(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_windows[0].glazing_type == "Double post or during 2022"
|
||||
|
|
@ -210,7 +228,8 @@ class TestWindows:
|
|||
assert result.sap_windows[0].draught_proofed is True
|
||||
|
||||
def test_third_window_orientation(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_windows[2].orientation == "South"
|
||||
# SAP10 octant code: 5 = South.
|
||||
assert result.sap_windows[2].orientation == 5
|
||||
|
||||
def test_frame_factor(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_windows[0].frame_factor == 0.7
|
||||
|
|
@ -233,17 +252,25 @@ class TestHeating:
|
|||
assert len(result.sap_heating.main_heating_details) == 1
|
||||
|
||||
def test_fuel_type(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_heating.main_heating_details[0].main_fuel_type == "Mains gas"
|
||||
# SAP10.2 Table 12 fuel code: 26 = mains gas (not community).
|
||||
# The cascade only consumes the int code; strings drop the
|
||||
# standing-charge / PE-factor / CO2-factor lookups.
|
||||
assert result.sap_heating.main_heating_details[0].main_fuel_type == 26
|
||||
|
||||
def test_heat_emitter_type(self, result: EpcPropertyData) -> None:
|
||||
assert (
|
||||
result.sap_heating.main_heating_details[0].heat_emitter_type == "Radiators"
|
||||
)
|
||||
# SAP10.2 heat-emitter code: 1 = Radiators.
|
||||
assert result.sap_heating.main_heating_details[0].heat_emitter_type == 1
|
||||
|
||||
def test_emitter_temperature(self, result: EpcPropertyData) -> None:
|
||||
assert (
|
||||
result.sap_heating.main_heating_details[0].emitter_temperature == "Unknown"
|
||||
)
|
||||
# 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
|
||||
|
||||
def test_fan_flue_present(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_heating.main_heating_details[0].fan_flue_present is True
|
||||
|
|
@ -252,10 +279,10 @@ class TestHeating:
|
|||
assert result.sap_heating.main_heating_details[0].has_fghrs is False
|
||||
|
||||
def test_main_heating_control(self, result: EpcPropertyData) -> None:
|
||||
assert (
|
||||
result.sap_heating.main_heating_details[0].main_heating_control
|
||||
== "Programmer, room thermostat and TRVs"
|
||||
)
|
||||
# SAP10.2 main_heating_control code extracted from the Elmhurst
|
||||
# "SAP code 2106, Programmer, room thermostat and TRVs" string;
|
||||
# the cascade keys efficiency adjustments off the integer.
|
||||
assert result.sap_heating.main_heating_details[0].main_heating_control == 2106
|
||||
|
||||
def test_shower_outlet_type(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_heating.shower_outlets is not None
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
from backend.documents_parser.extractor import PasHubRdSapSiteNotesExtractor
|
||||
from backend.documents_parser.pdf import pdf_to_text_list
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
InstantaneousWwhrs,
|
||||
MainHeatingDetail,
|
||||
|
|
@ -187,7 +188,7 @@ class TestPdfToEpcPropertyData:
|
|||
),
|
||||
sap_building_parts=[
|
||||
SapBuildingPart(
|
||||
identifier="main",
|
||||
identifier=BuildingPartIdentifier.MAIN,
|
||||
construction_age_band="1950-1966",
|
||||
wall_construction="Cavity",
|
||||
wall_insulation_type="Filled Cavity",
|
||||
|
|
@ -218,7 +219,7 @@ class TestPdfToEpcPropertyData:
|
|||
floor_u_value_known=False,
|
||||
),
|
||||
SapBuildingPart(
|
||||
identifier="extension_1",
|
||||
identifier=BuildingPartIdentifier.EXTENSION_1,
|
||||
construction_age_band="2003-2006",
|
||||
wall_construction="Cavity",
|
||||
wall_insulation_type="As built",
|
||||
|
|
|
|||
710
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py
Normal file
710
backend/documents_parser/tests/test_summary_pdf_mapper_chain.py
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
"""End-to-end validation for the Elmhurst Summary→EpcPropertyData chain.
|
||||
|
||||
The 6 Elmhurst worksheet fixtures in `domain.sap10_calculator.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
|
||||
a calculator-equivalent `EpcPropertyData` when fed the Summary PDF
|
||||
the worksheet was generated from. Together with the worksheet
|
||||
cascade tests, this closes the loop: extractor + mapper + cascade
|
||||
+ calculator validated end-to-end against the authoritative
|
||||
Elmhurst documents.
|
||||
|
||||
Status: GREEN. For cert U985-0001-000474, this pipeline produces an
|
||||
unrounded SAP within 0.5 of the worksheet PDF's `62.2584` (line 257).
|
||||
The cascade itself reproduces Elmhurst's calculator exactly on
|
||||
hand-built inputs (handbuilt → 62.2584 to 4 d.p.); the remaining
|
||||
sub-half-point gap from the mapped path is non-load-bearing field
|
||||
drift (e.g. central_heating_pump_age the Summary PDF doesn't lodge).
|
||||
|
||||
Preprocessing: the existing `ElmhurstSiteNotesExtractor` was written
|
||||
against Textract-style output (label\\nvalue pairs in spatial
|
||||
reading order). We don't have Textract in the test environment, so
|
||||
this helper converts `pdftotext -layout` output (label-whitespace-
|
||||
value on a single line) into the Textract-style sequence the
|
||||
extractor expects. Test-only preprocessing; production runs through
|
||||
Textract directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
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 (
|
||||
_elmhurst_worksheet_000474 as _w000474,
|
||||
_elmhurst_worksheet_000477 as _w000477,
|
||||
_elmhurst_worksheet_000480 as _w000480,
|
||||
_elmhurst_worksheet_000487 as _w000487,
|
||||
_elmhurst_worksheet_000490 as _w000490,
|
||||
_elmhurst_worksheet_000516 as _w000516,
|
||||
)
|
||||
|
||||
_FIXTURES = Path(__file__).parent / "fixtures"
|
||||
_SUMMARY_000474_PDF = _FIXTURES / "Summary_000474.pdf"
|
||||
_SUMMARY_000477_PDF = _FIXTURES / "Summary_000477.pdf"
|
||||
_SUMMARY_000480_PDF = _FIXTURES / "Summary_000480.pdf"
|
||||
_SUMMARY_000487_PDF = _FIXTURES / "Summary_000487.pdf"
|
||||
_SUMMARY_000490_PDF = _FIXTURES / "Summary_000490.pdf"
|
||||
_SUMMARY_000516_PDF = _FIXTURES / "Summary_000516.pdf"
|
||||
_SUMMARY_001479_PDF = _FIXTURES / "Summary_001479.pdf"
|
||||
|
||||
# GOV.UK EPB API JSON for cert 001479 — the API-path counterpart of the
|
||||
# Summary_001479.pdf fixture. Together they drive the API ≡ Summary
|
||||
# parity workstream; Layer 4 of the validation stack is "API cascade SAP
|
||||
# matches worksheet continuous SAP at 1e-4".
|
||||
_API_001479_JSON = (
|
||||
Path(__file__).parents[3]
|
||||
/ "domain/sap10_calculator/rdsap/tests/fixtures/golden"
|
||||
/ "0535-9020-6509-0821-6222.json"
|
||||
)
|
||||
|
||||
|
||||
def _summary_pdf_to_textract_style_pages(pdf_path: Path) -> list[str]:
|
||||
"""Convert a Summary PDF into the per-page text format the existing
|
||||
`ElmhurstSiteNotesExtractor` expects (label\\nvalue sequences).
|
||||
|
||||
`pdftotext -layout` preserves the spatial pairing of label and value
|
||||
on each line; we split each line on 2+ spaces to surface the
|
||||
label/value tokens, then concatenate them back into a single
|
||||
newline-delimited stream per page.
|
||||
"""
|
||||
info = subprocess.run(
|
||||
["pdfinfo", str(pdf_path)], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
m = re.search(r"Pages:\s+(\d+)", info)
|
||||
if m is None:
|
||||
raise RuntimeError(f"Could not parse page count from {pdf_path}")
|
||||
page_count = int(m.group(1))
|
||||
|
||||
pages: list[str] = []
|
||||
for i in range(1, page_count + 1):
|
||||
layout = subprocess.run(
|
||||
[
|
||||
"pdftotext", "-layout", "-f", str(i), "-l", str(i),
|
||||
str(pdf_path), "-",
|
||||
],
|
||||
capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
tokens: list[str] = []
|
||||
for line in layout.splitlines():
|
||||
if not line.strip():
|
||||
tokens.append("")
|
||||
continue
|
||||
parts = [p for p in re.split(r"\s{2,}", line.strip()) if p]
|
||||
tokens.extend(parts)
|
||||
pages.append("\n".join(tokens))
|
||||
return pages
|
||||
|
||||
|
||||
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/
|
||||
# _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)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert len(epc.sap_building_parts) == 3
|
||||
|
||||
|
||||
def test_summary_000474_mapper_extracts_seven_windows() -> None:
|
||||
# Arrange — cert U985-0001-000474's §11 table lodges 7 windows
|
||||
# across Main + 1st Extension + 2nd Extension. The legacy Textract-
|
||||
# style window parser couldn't anchor on the Summary PDF's tabular
|
||||
# layout; the new W/H/Area-plus-Manufacturer anchor pair picks them
|
||||
# all up.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000474_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
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_000477_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert U985-0001-000477 is a single-bp mid-terrace with
|
||||
# a 15.06 m² Room-in-Roof storey and zero baths lodged. Worksheet
|
||||
# PDF lodges unrounded SAP 65.0057. Drives the chain through the
|
||||
# `RoomInRoof.detailed_surfaces` cascade with stud walls @ 100mm
|
||||
# Mineral, two uninsulated slopes, two party gable walls, plus the
|
||||
# RR/storey-area suspended-timber-floor heuristic (RIR < storey →
|
||||
# 0.2 ACH floor infiltration).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000477_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 = 65.0057
|
||||
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.
|
||||
# The §11 table mixes 5 vertical windows (U=2.80) with 1 roof
|
||||
# window (U=3.10 in cert, U=3.40 Table 24 raw); the mapper
|
||||
# discriminates by `U > 3.0` and routes the high-U entry to
|
||||
# `sap_roof_windows` so its solar gains feed §6 with the right
|
||||
# pitch (45°) and Table-24 U-value.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000516_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 = 62.7937
|
||||
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
|
||||
# lodges Main + Extension 1 + Extension 2 (3 building parts, 2
|
||||
# extensions). Pre-slice the Elmhurst mapper hard-coded
|
||||
# `extensions_count=0` regardless of survey.extensions; this asserts
|
||||
# the count flows through.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.extensions_count == 2
|
||||
assert len(epc.sap_building_parts) == 3
|
||||
|
||||
|
||||
def test_summary_001479_main_party_wall_construction_is_cavity_unfilled() -> None:
|
||||
# Arrange — cert 001479 Main §7 Walls lodges "Party Wall Type: CU
|
||||
# Cavity masonry unfilled". The Elmhurst leading-code map previously
|
||||
# only knew "S" and "C"; "CU" fell through to None, which made the
|
||||
# cascade default to U=0.25 instead of the worksheet's lodged U=0.50.
|
||||
# The fix adds "CU" → SAP10 wall_construction code 4 (WALL_CAVITY),
|
||||
# which `u_party_wall` resolves to U=0.50 — matching the worksheet's
|
||||
# §3 `Party walls Main … 0.50` row.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.sap_building_parts[0].party_wall_construction == 4
|
||||
|
||||
|
||||
def test_summary_001479_ext2_floor_is_exposed_to_external_air() -> None:
|
||||
# Arrange — cert 001479 Ext2 §9 lodges "Location: E To external air"
|
||||
# — a cantilevered exposed timber floor (the upper-storey extension
|
||||
# over the back garden). The worksheet's §3 row `Exposed floor Ext2
|
||||
# … 1.92, 1.20, 1.20` pins this as U=1.20 via Table 20. Pre-slice the
|
||||
# mapper only routed "U Above unheated space" through `is_exposed_
|
||||
# floor=True`; "E To external air" fell through to the BS EN ISO
|
||||
# 13370 ground-floor cascade, dropping the lodged exposure entirely.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
ext2 = epc.sap_building_parts[2]
|
||||
assert ext2.floor_type == "To external air"
|
||||
assert ext2.sap_floor_dimensions[0].is_exposed_floor is True
|
||||
|
||||
|
||||
def test_summary_001479_ext2_sloping_ceiling_roof_uninsulated_for_pre_1950() -> None:
|
||||
# Arrange — cert 001479 Ext2 §8 lodges "Type: PS Pitched, sloping
|
||||
# ceiling" + "Insulation Thickness: As Built" + age band C (1930-49).
|
||||
# Original 1930s construction had no sloping-ceiling insulation;
|
||||
# worksheet §3 `External roof Ext2 … 2.30` pins U=2.30 (uninsulated
|
||||
# Table 16 row 0). Pre-slice the mapper passed thickness=None through,
|
||||
# routing to `u_roof`'s pitched-roof Table 18 col 1 default (0.40 for
|
||||
# age C, assumes loft-joist retrofit) — wrong geometry for PS.
|
||||
# Ext1's PS roof at age M leaves thickness=None (modern build,
|
||||
# cascade default U=0.15 matches worksheet).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.sap_building_parts[2].roof_insulation_thickness == 0
|
||||
assert epc.sap_building_parts[1].roof_insulation_thickness is None
|
||||
|
||||
|
||||
def test_summary_001479_secondary_heating_routes_mains_gas_fuel() -> None:
|
||||
# Arrange — cert 001479 §14.1 Main Heating2 lodges "Secondary Heating
|
||||
# Code: SAP code 605, Flush fitting live effect gas fire, sealed to
|
||||
# chimney". The Summary surfaces only the SAP code (605); the fuel
|
||||
# type 26 (mains gas) must be derived from the code range so the
|
||||
# `_fuel_cost` orchestrator's `secondary_high_rate_gbp_per_kwh`
|
||||
# picks up Table 32's gas tariff (£0.0348/kWh) rather than the
|
||||
# default standard-electricity tariff (£0.132/kWh). Worksheet line
|
||||
# (242) "Space heating - secondary … 3.4800 70.5022" confirms gas
|
||||
# pricing.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
|
||||
# Assert
|
||||
assert epc.sap_heating.secondary_heating_type == 605
|
||||
assert epc.sap_heating.secondary_fuel_type == 26
|
||||
|
||||
|
||||
def test_summary_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 001479 (Summary_001479.pdf / P960-0001-001479.pdf)
|
||||
# is the first cohort cert with a real GOV.UK EPB API counterpart
|
||||
# (cert ref 0535-9020-6509-0821-6222). Worksheet PDF line "SAP value"
|
||||
# lodges unrounded SAP **69.0094** (rating C 69, also the API-
|
||||
# published integer). This is the load-bearing forcing function for
|
||||
# the API↔Elmhurst parity workstream: any drift from 1e-4 means a
|
||||
# mapper gap, not a calculator bug — the cohort 6 cert cascades all
|
||||
# reproduce Elmhurst exactly at 1e-4 on hand-built fixtures.
|
||||
#
|
||||
# Source-data caveat (documented for future debuggers): Summary §3
|
||||
# lodges Ext1 age band as "M 2023 onwards"; the worksheet header
|
||||
# records "Ext1: L". Likely assessor data-entry inconsistency. The
|
||||
# mapper trusts the Summary (its source of truth); accept whatever
|
||||
# residual the M vs L disagreement produces.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_001479_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 — 1e-4 pin, no widening, no xfail (project memory
|
||||
# `feedback_zero_error_strict`).
|
||||
worksheet_unrounded_sap = 69.0094
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
def test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly() -> None:
|
||||
# Arrange — cert 001479 has both an Elmhurst Summary PDF and a GOV.UK
|
||||
# EPB API JSON (ref 0535-9020-6509-0821-6222). The Summary cascade
|
||||
# already pins at worksheet's 69.0094 ± 1e-4 above; this test is the
|
||||
# Layer 4 production-path gate: API JSON → from_api_response →
|
||||
# cert_to_inputs → calculate_sap_from_inputs must also hit 69.0094
|
||||
# at 1e-4. Identical inputs must produce identical outputs; the
|
||||
# calculator is deterministic, so any drift is a mapper coverage gap.
|
||||
doc = json.loads(_API_001479_JSON.read_text())
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
# Assert — 1e-4 pin against the worksheet's continuous SAP. ±0.5 is
|
||||
# the API-only fallback (project memory `feedback_api_tolerance_1e_
|
||||
# minus_4`); when the worksheet is available, identical-inputs-must-
|
||||
# produce-identical-outputs is the bar.
|
||||
worksheet_unrounded_sap = 69.0094
|
||||
assert abs(result.sap_score_continuous - worksheet_unrounded_sap) < 1e-4
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Mapper-vs-hand-built EpcPropertyData diff tests
|
||||
# ============================================================================
|
||||
# The 6 cohort hand-builts (_elmhurst_worksheet_NNNNNN.build_epc) are the
|
||||
# 100%-correct calculator-input ground truth — each cascades to its
|
||||
# worksheet PDF's lodged SAP at 1e-4. The chain tests above only assert
|
||||
# cascade-output equivalence; the mapper can pass them by producing a
|
||||
# *different* EpcPropertyData that happens to cascade to the same number.
|
||||
#
|
||||
# These tests pin the missing layer: the mapper's EpcPropertyData must
|
||||
# match the hand-built's load-bearing fields exactly. Every divergence
|
||||
# surfaced here is a mapper coverage gap to close as its own slice.
|
||||
#
|
||||
# "Load-bearing" = the subset of EpcPropertyData fields that drive the
|
||||
# SAP cascade or carry semantic cross-mapper meaning. Cert-metadata
|
||||
# fields (address, registration dates, descriptive EnergyElement lists,
|
||||
# tariff strings) are excluded because they don't change calculator
|
||||
# output and vary by mapper pathway (the API publishes some, the
|
||||
# Elmhurst Summary publishes others) without semantic disagreement.
|
||||
|
||||
# SapWindow sub-fields the cascade doesn't read (descriptive Union[int,
|
||||
# str] codes lodged differently by each mapper). The cascade reads
|
||||
# window_width / window_height / orientation / window_location /
|
||||
# frame_factor / window_transmission_details.{u_value,solar_
|
||||
# transmittance} — those WILL still be diffed; everything else on
|
||||
# SapWindow is metadata and excluded to avoid noise from the int/str
|
||||
# dual encoding (API mapper produces int codes; Elmhurst mapper
|
||||
# surfaces the Summary's lodged strings).
|
||||
_NON_LOAD_BEARING_WINDOW_SUBFIELDS: frozenset[str] = frozenset({
|
||||
"frame_material",
|
||||
"glazing_gap",
|
||||
"window_type",
|
||||
"glazing_type",
|
||||
"window_wall_type",
|
||||
"draught_proofed",
|
||||
"permanent_shutters_present",
|
||||
"permanent_shutters_insulated",
|
||||
})
|
||||
|
||||
|
||||
def _is_excluded_path(path: str) -> bool:
|
||||
"""Return True for paths the diff should silently skip — non-cascade-
|
||||
affecting Union[int, str] encoding differences between the API and
|
||||
Elmhurst mapper outputs that cohort hand-built fixtures don't pin."""
|
||||
if path.startswith("sap_windows[") and "]." in path:
|
||||
suffix = path.split("].", 1)[1]
|
||||
if suffix in _NON_LOAD_BEARING_WINDOW_SUBFIELDS:
|
||||
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
|
||||
|
||||
|
||||
_LOAD_BEARING_FIELDS: tuple[str, ...] = (
|
||||
# Cascade-driving structural fields
|
||||
"sap_building_parts",
|
||||
"sap_windows",
|
||||
"sap_roof_windows",
|
||||
"sap_heating",
|
||||
"sap_ventilation",
|
||||
"sap_energy_source",
|
||||
"total_floor_area_m2",
|
||||
# Building-classification fields driving default cascades
|
||||
"dwelling_type",
|
||||
"built_form",
|
||||
"property_type",
|
||||
"country_code",
|
||||
"postcode",
|
||||
# Counts and openings
|
||||
"door_count",
|
||||
"insulated_door_count",
|
||||
"insulated_door_u_value",
|
||||
"habitable_rooms_count",
|
||||
"heated_rooms_count",
|
||||
"wet_rooms_count",
|
||||
"extensions_count",
|
||||
"open_chimneys_count",
|
||||
"blocked_chimneys_count",
|
||||
"extract_fans_count",
|
||||
# Lighting
|
||||
"cfl_fixed_lighting_bulbs_count",
|
||||
"led_fixed_lighting_bulbs_count",
|
||||
"incandescent_fixed_lighting_bulbs_count",
|
||||
"low_energy_fixed_lighting_bulbs_count",
|
||||
"fixed_lighting_outlets_count",
|
||||
"low_energy_fixed_lighting_outlets_count",
|
||||
# HW / appliances
|
||||
"solar_water_heating",
|
||||
"has_hot_water_cylinder",
|
||||
"has_fixed_air_conditioning",
|
||||
"has_conservatory",
|
||||
"has_heated_separate_conservatory",
|
||||
# Envelope drivers
|
||||
"percent_draughtproofed",
|
||||
"mechanical_ventilation",
|
||||
"pressure_test",
|
||||
# Construction-detail flags
|
||||
"addendum",
|
||||
"lzc_energy_sources",
|
||||
"any_unheated_rooms",
|
||||
"number_of_storeys",
|
||||
"sap_flat_details",
|
||||
)
|
||||
|
||||
|
||||
def _diff_load_bearing(
|
||||
mapped: object, hand_built: object, path: str = "",
|
||||
) -> list[str]:
|
||||
"""Recursive field diff; yields one line per leaf divergence between
|
||||
mapped EpcPropertyData and the hand-built fixture. Int/float type
|
||||
differences with the same numeric value are not flagged.
|
||||
|
||||
Strict-pyright posture: arguments typed `object` so each branch
|
||||
narrows via `isinstance` rather than threading `Any` through the
|
||||
recursion (which pyright can't reason about under
|
||||
`strict`/`typeCheckingMode = strict`)."""
|
||||
out: list[str] = []
|
||||
if type(mapped) is not type(hand_built):
|
||||
if not (isinstance(mapped, (int, float)) and isinstance(hand_built, (int, float))):
|
||||
if not _is_excluded_path(path):
|
||||
out.append(
|
||||
f"{path}: TYPE {type(mapped).__name__} vs "
|
||||
f"{type(hand_built).__name__} mapped={mapped!r} "
|
||||
f"handbuilt={hand_built!r}"
|
||||
)
|
||||
return out
|
||||
if dataclasses.is_dataclass(mapped) and not isinstance(mapped, type) \
|
||||
and dataclasses.is_dataclass(hand_built) and not isinstance(hand_built, type):
|
||||
for fld in dataclasses.fields(mapped):
|
||||
out.extend(_diff_load_bearing(
|
||||
getattr(mapped, fld.name),
|
||||
getattr(hand_built, fld.name),
|
||||
f"{path}.{fld.name}" if path else fld.name,
|
||||
))
|
||||
return out
|
||||
if isinstance(mapped, list) and isinstance(hand_built, list):
|
||||
mapped_list = cast("list[object]", mapped)
|
||||
hand_built_list = cast("list[object]", hand_built)
|
||||
if len(mapped_list) != len(hand_built_list):
|
||||
out.append(f"{path}: LEN {len(mapped_list)} vs {len(hand_built_list)}")
|
||||
return out
|
||||
for i, (m_item, h_item) in enumerate(zip(mapped_list, hand_built_list)):
|
||||
out.extend(_diff_load_bearing(m_item, h_item, f"{path}[{i}]"))
|
||||
return out
|
||||
if mapped != hand_built:
|
||||
if not _is_excluded_path(path):
|
||||
out.append(f"{path}: mapped={mapped!r} handbuilt={hand_built!r}")
|
||||
return out
|
||||
|
||||
|
||||
def test_from_elmhurst_site_notes_matches_hand_built_000474() -> None:
|
||||
# Arrange — _elmhurst_worksheet_000474.build_epc() is the canonical
|
||||
# hand-built EpcPropertyData for cert U985-0001-000474; it cascades
|
||||
# to the worksheet PDF's `SAP value 62.2584` at 1e-4 (cohort SAP-
|
||||
# result pin). Routing the corresponding Summary PDF through the
|
||||
# Elmhurst mapper MUST produce a load-bearing-field-equivalent
|
||||
# EpcPropertyData; any divergence is a mapper-coverage gap.
|
||||
#
|
||||
# Tracer-bullet scope: cert 000474 only. Once GREEN, parametrize
|
||||
# over the 5 other cohort fixtures and add cert 001479 (after
|
||||
# `_elmhurst_worksheet_001479` lands at 1e-4 via Slice 62 iteration).
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000474_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
hand_built = _w000474.build_epc()
|
||||
|
||||
# Act
|
||||
diffs: list[str] = []
|
||||
for field_name in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(
|
||||
getattr(mapped, field_name, None),
|
||||
getattr(hand_built, field_name, None),
|
||||
field_name,
|
||||
))
|
||||
|
||||
# Assert
|
||||
assert not diffs, (
|
||||
f"{len(diffs)} load-bearing divergence(s) between mapped and "
|
||||
f"hand-built EpcPropertyData for cohort cert 000474:\n " +
|
||||
"\n ".join(diffs)
|
||||
)
|
||||
|
||||
|
||||
def test_from_elmhurst_site_notes_matches_hand_built_000477() -> None:
|
||||
# Arrange — _elmhurst_worksheet_000477.build_epc() is the canonical
|
||||
# hand-built EpcPropertyData for cert U985-0001-000477 (single-bp
|
||||
# mid-terrace, age band B, RIR with stud walls + party gables, no
|
||||
# extension); it cascades to the worksheet PDF's `SAP value 65.0057`
|
||||
# at 1e-4. Routing the Summary PDF through the Elmhurst mapper MUST
|
||||
# produce a load-bearing-field-equivalent EpcPropertyData; any
|
||||
# divergence is a mapper-coverage gap to close as its own slice.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000477_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
hand_built = _w000477.build_epc()
|
||||
|
||||
# Act
|
||||
diffs: list[str] = []
|
||||
for field_name in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(
|
||||
getattr(mapped, field_name, None),
|
||||
getattr(hand_built, field_name, None),
|
||||
field_name,
|
||||
))
|
||||
|
||||
# Assert
|
||||
assert not diffs, (
|
||||
f"{len(diffs)} load-bearing divergence(s) between mapped and "
|
||||
f"hand-built EpcPropertyData for cohort cert 000477:\n " +
|
||||
"\n ".join(diffs)
|
||||
)
|
||||
|
||||
|
||||
def test_from_elmhurst_site_notes_matches_hand_built_000480() -> None:
|
||||
# Arrange — _elmhurst_worksheet_000480.build_epc() is the canonical
|
||||
# hand-built EpcPropertyData for cert U985-0001-000480 (mid-terrace
|
||||
# with main + 1 extension + 19.83 m² RIR, gas combi); it cascades
|
||||
# to the worksheet PDF's `SAP value 61.2986` at 1e-4. Routing the
|
||||
# Summary PDF through the Elmhurst mapper MUST produce a load-
|
||||
# bearing-field-equivalent EpcPropertyData; any divergence is a
|
||||
# mapper-coverage gap to close as its own slice.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000480_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
hand_built = _w000480.build_epc()
|
||||
|
||||
# Act
|
||||
diffs: list[str] = []
|
||||
for field_name in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(
|
||||
getattr(mapped, field_name, None),
|
||||
getattr(hand_built, field_name, None),
|
||||
field_name,
|
||||
))
|
||||
|
||||
# Assert
|
||||
assert not diffs, (
|
||||
f"{len(diffs)} load-bearing divergence(s) between mapped and "
|
||||
f"hand-built EpcPropertyData for cohort cert 000480:\n " +
|
||||
"\n ".join(diffs)
|
||||
)
|
||||
|
||||
|
||||
def test_from_elmhurst_site_notes_matches_hand_built_000487() -> None:
|
||||
# Arrange — _elmhurst_worksheet_000487.build_epc() is the canonical
|
||||
# hand-built EpcPropertyData for cert U985-0001-000487 (Enclosed
|
||||
# Mid-Terrace, main + 1 extension + 21.03 m² RIR with explicit-U
|
||||
# gable_wall_external, gas combi, 1 electric shower, 1.43 m²
|
||||
# timber-frame alt wall on the extension); it cascades to the
|
||||
# worksheet PDF's `SAP value 61.6431` at 1e-4. Routing the Summary
|
||||
# PDF through the Elmhurst mapper MUST produce a load-bearing-
|
||||
# field-equivalent EpcPropertyData; any divergence is a mapper-
|
||||
# coverage gap to close as its own slice.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000487_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
hand_built = _w000487.build_epc()
|
||||
|
||||
# Act
|
||||
diffs: list[str] = []
|
||||
for field_name in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(
|
||||
getattr(mapped, field_name, None),
|
||||
getattr(hand_built, field_name, None),
|
||||
field_name,
|
||||
))
|
||||
|
||||
# Assert
|
||||
assert not diffs, (
|
||||
f"{len(diffs)} load-bearing divergence(s) between mapped and "
|
||||
f"hand-built EpcPropertyData for cohort cert 000487:\n " +
|
||||
"\n ".join(diffs)
|
||||
)
|
||||
|
||||
|
||||
def test_from_elmhurst_site_notes_matches_hand_built_000490() -> None:
|
||||
# Arrange — _elmhurst_worksheet_000490.build_epc() is the canonical
|
||||
# hand-built EpcPropertyData for cert U985-0001-000490 (End-Terrace,
|
||||
# main + 1 extension, gas combi + gas-secondary; sheltered_sides=1
|
||||
# per RdSAP §S5); it cascades to the worksheet PDF's `SAP value
|
||||
# 57.3979` at 1e-4. Routing the Summary PDF through the Elmhurst
|
||||
# mapper MUST produce a load-bearing-field-equivalent
|
||||
# EpcPropertyData; any divergence is a mapper-coverage gap.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000490_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
hand_built = _w000490.build_epc()
|
||||
|
||||
# Act
|
||||
diffs: list[str] = []
|
||||
for field_name in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(
|
||||
getattr(mapped, field_name, None),
|
||||
getattr(hand_built, field_name, None),
|
||||
field_name,
|
||||
))
|
||||
|
||||
# Assert
|
||||
assert not diffs, (
|
||||
f"{len(diffs)} load-bearing divergence(s) between mapped and "
|
||||
f"hand-built EpcPropertyData for cohort cert 000490:\n " +
|
||||
"\n ".join(diffs)
|
||||
)
|
||||
|
||||
|
||||
def test_from_elmhurst_site_notes_matches_hand_built_000516() -> None:
|
||||
# Arrange — _elmhurst_worksheet_000516.build_epc() is the canonical
|
||||
# hand-built EpcPropertyData for cert U985-0001-000516 (Mid-Terrace,
|
||||
# main + 19.02 m² RIR, 5 vertical windows + 1 roof window which the
|
||||
# mapper routes to `sap_roof_windows` per `U > 3.0` discrimination);
|
||||
# it cascades to the worksheet PDF's `SAP value 62.7937` at 1e-4.
|
||||
# Routing the Summary PDF through the Elmhurst mapper MUST produce
|
||||
# a load-bearing-field-equivalent EpcPropertyData.
|
||||
pages = _summary_pdf_to_textract_style_pages(_SUMMARY_000516_PDF)
|
||||
site_notes = ElmhurstSiteNotesExtractor(pages).extract()
|
||||
mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(site_notes)
|
||||
hand_built = _w000516.build_epc()
|
||||
|
||||
# Act
|
||||
diffs: list[str] = []
|
||||
for field_name in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(
|
||||
getattr(mapped, field_name, None),
|
||||
getattr(hand_built, field_name, None),
|
||||
field_name,
|
||||
))
|
||||
|
||||
# Assert
|
||||
assert not diffs, (
|
||||
f"{len(diffs)} load-bearing divergence(s) between mapped and "
|
||||
f"hand-built EpcPropertyData for cohort cert 000516:\n " +
|
||||
"\n ".join(diffs)
|
||||
)
|
||||
|
|
@ -9,3 +9,25 @@ class Epc(Enum):
|
|||
E = "E"
|
||||
F = "F"
|
||||
G = "G"
|
||||
|
||||
@classmethod
|
||||
def from_sap_score(cls, score: int) -> "Epc":
|
||||
"""Map a SAP10 energy rating (1-100) to its EPC band.
|
||||
|
||||
Thresholds are the standard SAP10 boundaries: A 92+, B 81-91, C 69-80,
|
||||
D 55-68, E 39-54, F 21-38, G 1-20. Scores below 21 (including 0 and
|
||||
negatives, which should not occur in practice) fall through to G.
|
||||
"""
|
||||
if score >= 92:
|
||||
return cls.A
|
||||
if score >= 81:
|
||||
return cls.B
|
||||
if score >= 69:
|
||||
return cls.C
|
||||
if score >= 55:
|
||||
return cls.D
|
||||
if score >= 39:
|
||||
return cls.E
|
||||
if score >= 21:
|
||||
return cls.F
|
||||
return cls.G
|
||||
|
|
|
|||
5762
datatypes/epc/domain/epc_codes.csv
Normal file
5762
datatypes/epc/domain/epc_codes.csv
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,67 @@
|
|||
from dataclasses import dataclass
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import List, Optional, Union
|
||||
from enum import Enum
|
||||
from typing import Final, List, Optional, Union
|
||||
|
||||
from datatypes.epc.domain.epc import Epc
|
||||
|
||||
|
||||
_API_EXTENSION = re.compile(r"^Extension\s+(\d+)$")
|
||||
|
||||
|
||||
class BuildingPartIdentifier(Enum):
|
||||
"""Canonical identifier for a SAP building part.
|
||||
|
||||
Replaces bare-string matching on `SapBuildingPart.identifier`. The
|
||||
enum *values* match the site-notes / database shape ("main",
|
||||
"extension_1" .. "extension_4"); boundary mappers (gov-EPC API,
|
||||
site notes) construct these via the `from_api_string` / `extension`
|
||||
classmethods so consumers can dispatch with `is` instead of fragile
|
||||
string equality.
|
||||
|
||||
RdSAP10 §1.2 caps extensions at 4 per dwelling, so EXTENSION_1..4
|
||||
are enumerated explicitly; anything else falls to OTHER so callers
|
||||
can still iterate safely.
|
||||
|
||||
P6.1 — first slice of the strict-typing P6 work documented in
|
||||
HANDOVER_SYSTEMATIC_REVIEW §2.5.
|
||||
"""
|
||||
|
||||
MAIN = "main"
|
||||
EXTENSION_1 = "extension_1"
|
||||
EXTENSION_2 = "extension_2"
|
||||
EXTENSION_3 = "extension_3"
|
||||
EXTENSION_4 = "extension_4"
|
||||
OTHER = "other"
|
||||
|
||||
@classmethod
|
||||
def from_api_string(
|
||||
cls, api_identifier: Optional[str]
|
||||
) -> "BuildingPartIdentifier":
|
||||
"""Map a gov-EPC API `BuildingPart.identifier` to its canonical
|
||||
member. "Main Dwelling" → MAIN; "Extension N" → EXTENSION_N
|
||||
(for N in 1..4). `None` (permitted by the 21_0_1 schema) and
|
||||
anything unrecognised fall to OTHER.
|
||||
"""
|
||||
if api_identifier == "Main Dwelling":
|
||||
return cls.MAIN
|
||||
if api_identifier is not None:
|
||||
match = _API_EXTENSION.match(api_identifier)
|
||||
if match is not None:
|
||||
return cls.extension(int(match.group(1)))
|
||||
return cls.OTHER
|
||||
|
||||
@classmethod
|
||||
def extension(cls, n: int) -> "BuildingPartIdentifier":
|
||||
"""Canonical identifier for the Nth extension. RdSAP10 §1.2
|
||||
caps at 4; numbers outside 1..4 fall to OTHER."""
|
||||
try:
|
||||
return cls(f"extension_{n}")
|
||||
except ValueError:
|
||||
return cls.OTHER
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnergyElement:
|
||||
description: str
|
||||
|
|
@ -12,6 +69,18 @@ class EnergyElement:
|
|||
environmental_efficiency_rating: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Addendum:
|
||||
"""Optional cert-level addendum carrying construction-detail flags.
|
||||
|
||||
Present on ~43% of real RdSAP certs (stone-walls / system-build / a list of
|
||||
numeric improvement codes the assessor wanted to call out).
|
||||
"""
|
||||
stone_walls: Optional[bool] = None
|
||||
system_build: Optional[bool] = None
|
||||
addendum_numbers: Optional[List[int]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstantaneousWwhrs:
|
||||
wwhrs_index_number1: Optional[int] = None
|
||||
|
|
@ -69,6 +138,21 @@ class SapHeating:
|
|||
secondary_fuel_type: Optional[int] = None
|
||||
secondary_heating_type: Optional[Union[int, str]] = None # int from API; str from site notes
|
||||
cylinder_insulation_thickness_mm: Optional[int] = None
|
||||
# SAP10 hot-water demand inputs from sap_heating.
|
||||
number_baths: Optional[int] = None
|
||||
number_baths_wwhrs: Optional[int] = None
|
||||
# Per SAP10.2 Appendix J (p.81) step 1a: Noutlets includes electric
|
||||
# showers in the count for Nshower; step 2a routes Nbath through the
|
||||
# "shower also present" branch (0.13N + 0.19) when ANY shower is
|
||||
# lodged — including electric. Modelled separately from mixer outlets
|
||||
# because electric showers don't draw warm water from the system.
|
||||
electric_shower_count: Optional[int] = None
|
||||
# PCDF mixer-shower lodgement (count of outlets that DO draw warm
|
||||
# water from the main HW system). When set, overrides the heuristic
|
||||
# default of 1 vented outlet @ 7 L/min used by `_mixer_shower_flow_
|
||||
# rates_from_cert`. Most certs lodge only count; the standard
|
||||
# vented-system flow rate from Table J4 (7 L/min) is the default.
|
||||
mixer_shower_count: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -84,6 +168,11 @@ class SapVentilation:
|
|||
passive_vents_count: Optional[int] = None
|
||||
flueless_gas_fires_count: Optional[int] = None
|
||||
ventilation_in_pcdf_database: Optional[bool] = None
|
||||
# SAP10.2 §2 cert lodgements not previously surfaced on this type.
|
||||
sheltered_sides: Optional[int] = None # (19) — cert assessor lodge, 0..4
|
||||
has_suspended_timber_floor: Optional[bool] = None # (12) gate
|
||||
suspended_timber_floor_sealed: Optional[bool] = None
|
||||
has_draught_lobby: Optional[bool] = None # (13) gate (overrides .draught_lobby for §2 cascade)
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -93,6 +182,29 @@ class WindowTransmissionDetails:
|
|||
solar_transmittance: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoofWindow:
|
||||
"""RdSAP10 worksheet roof window — feeds §3 (27a) heat transmission
|
||||
and §6 (82) solar gain. Heat-transmission contribution is A × U_eff
|
||||
where U_eff applies the SAP10.2 §3.2 curtain resistance (R=0.04
|
||||
m²K/W) to `u_value_raw`. Roof windows draw their U-value from RdSAP
|
||||
10 Table 24 (p.50/113) "Roof window" column (e.g. double-glazed roof
|
||||
window U=3.4 vs 2.8 for standard).
|
||||
|
||||
Solar fields (orientation, pitch, g_perpendicular, frame_factor)
|
||||
feed `solar_gains_from_cert` — defaults match the modal RdSAP roof
|
||||
window (45° pitch, manufacturer-default DG g⊥=0.76, PVC FF=0.70,
|
||||
N-facing) and are intended to be overridden per-fixture.
|
||||
"""
|
||||
|
||||
area_m2: float
|
||||
u_value_raw: float # RdSAP10 Table 24 roof-window column, pre-curtain.
|
||||
orientation: int = 1 # SAP10.2 code: 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW.
|
||||
pitch_deg: float = 45.0
|
||||
g_perpendicular: float = 0.76
|
||||
frame_factor: float = 0.70
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
frame_material: Optional[str]
|
||||
|
|
@ -137,6 +249,19 @@ class PhotovoltaicSupply:
|
|||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhotovoltaicArray:
|
||||
"""One measured PV array: peak power (kW), pitch, orientation (SAP octant
|
||||
1-8), and overshading code. Populated on EpcPropertyData when the EPC has
|
||||
measured PV configuration; `photovoltaic_supply` carries the fallback
|
||||
`percent_roof_area` estimate when the surveyor could not confirm details.
|
||||
"""
|
||||
peak_power: float
|
||||
pitch: int
|
||||
orientation: int
|
||||
overshading: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapEnergySource:
|
||||
mains_gas: bool
|
||||
|
|
@ -150,6 +275,7 @@ class SapEnergySource:
|
|||
|
||||
pv_connection: Optional[Union[int, str]] = None # int from API; str from site notes
|
||||
photovoltaic_supply: Optional[PhotovoltaicSupply] = None
|
||||
photovoltaic_arrays: Optional[List[PhotovoltaicArray]] = None
|
||||
wind_turbine_details: Optional[WindTurbineDetails] = None
|
||||
pv_batteries: Optional[PvBatteries] = None
|
||||
|
||||
|
|
@ -164,12 +290,75 @@ class SapFloorDimension:
|
|||
floor: Optional[int] = None
|
||||
floor_insulation: Optional[int] = None
|
||||
floor_construction: Optional[int] = None
|
||||
# RdSAP10 §5.13 Table 20: True when this floor is open to outside air
|
||||
# (exposed) or sits over enclosed unheated space (semi-exposed) — e.g.
|
||||
# the lowest floor of an extension that hangs off the main from the
|
||||
# first storey upward. False means a ground floor (on soil), the
|
||||
# default path through the BS EN ISO 13370 / Table 19 cascade.
|
||||
is_exposed_floor: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SapRoomInRoofSurface:
|
||||
"""One surface lodged via the RdSAP10 §3.10 Detailed measurement path.
|
||||
|
||||
Each RR can carry up to two of each surface kind (flat ceiling,
|
||||
sloping ceiling, stud wall, gable wall) per spec Figure 4. The U-value
|
||||
is resolved from Table 17 when `insulation_thickness_mm` is set, or
|
||||
Table 18 col (4) age-band default otherwise.
|
||||
|
||||
RdSAP10 Table 4 (p.22) "U-values of gable-end and other walls in RR"
|
||||
distinguishes four gable types. We model the two we've seen lodged in
|
||||
the U985 corpus:
|
||||
- "gable_wall" — party (U = 0.25 W/m²K per Table 4 row 2)
|
||||
- "gable_wall_external" — exposed gable (U = "as common wall" per
|
||||
Table 4 row 1; when assessor lodges a measured U on the surface,
|
||||
`u_value` overrides the cascade)
|
||||
The other two Table 4 variants ("sheltered" R=0.5 of external, and
|
||||
"connected to heated space" U=0) are not yet seen in the corpus.
|
||||
"""
|
||||
|
||||
kind: str # "slope" | "flat_ceiling" | "stud_wall" | "gable_wall" | "gable_wall_external"
|
||||
area_m2: float
|
||||
insulation_thickness_mm: Optional[int] = None
|
||||
insulation_type: Optional[str] = None # "mineral_wool" / "eps" / "pur" / "pir"
|
||||
# Assessor-lodged U override (W/m²K). Used by `gable_wall_external`
|
||||
# when the cert measures U directly (cf. 000487 Gable Wall 2 at
|
||||
# U=0.86 on line 29). When None, the cascade falls back to the main-
|
||||
# wall U via Table 4 "as common wall".
|
||||
u_value: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
# RdSAP10 §3.9.2 Simplified Type 2 — RR built into a roof space that
|
||||
# has continuous common walls outside the RR boundaries. The space is
|
||||
# treated as Room-in-Roof when the height of accessible common walls
|
||||
# is < 1.8 m (otherwise it counts as a separate storey).
|
||||
common_wall_length_m: Optional[float] = None
|
||||
common_wall_height_m: Optional[float] = None
|
||||
# Optional gable lengths/heights for the Type 2 quadratic correction:
|
||||
# A_gable = L × (0.25 + H) − Σ ((H − H_common_wall_i)² / 2)
|
||||
# If absent, the gable contribution is 0 (Simplified Type 1).
|
||||
gable_1_length_m: Optional[float] = None
|
||||
gable_1_height_m: Optional[float] = None
|
||||
gable_2_length_m: Optional[float] = None
|
||||
gable_2_height_m: Optional[float] = None
|
||||
# RdSAP10 §3.10 Detailed measurement path. When `detailed_surfaces` is
|
||||
# set, each entry contributes A × U directly and the Simplified A_RR
|
||||
# formula is bypassed. The storey-below roof area still deducts
|
||||
# `floor_area` per §3.9.
|
||||
detailed_surfaces: Optional[List[SapRoomInRoofSurface]] = None
|
||||
|
||||
|
||||
# RdSAP10 wall_construction integer encoding. The gov-EPC API doesn't publish
|
||||
# the mapping; established empirically from a 50k 2026-bulk sweep — code 6
|
||||
# co-occurs with `walls[].description = "Basement wall"` in 88% of cases at
|
||||
# a 0.18% false-positive rate, so we treat it as the canonical basement-wall
|
||||
# signal.
|
||||
BASEMENT_WALL_CONSTRUCTION_CODE: Final[int] = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -180,12 +369,26 @@ class SapAlternativeWall:
|
|||
wall_insulation_type: int
|
||||
wall_thickness_measured: str
|
||||
wall_insulation_thickness: Optional[str] = None
|
||||
# Assessor-lodged U-value (W/m²K) — when set, overrides the
|
||||
# Table 6 cascade for this alt sub-area. Lodged directly on the
|
||||
# cert for some constructions (e.g. 000487 Ext1 TimberWallOneLayer
|
||||
# at U=1.90, where the 9-mm-thick single-layer timber wall doesn't
|
||||
# fit the Table 6 buckets cleanly).
|
||||
u_value: Optional[float] = None
|
||||
|
||||
@property
|
||||
def is_basement_wall(self) -> bool:
|
||||
"""True iff this alt sub-area is the dwelling's basement wall —
|
||||
identified by RdSAP10 wall_construction code = 6 (see module
|
||||
constant `BASEMENT_WALL_CONSTRUCTION_CODE`). RdSAP §5.17 / Table 23
|
||||
applies a special U-value lookup to basement walls."""
|
||||
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapBuildingPart:
|
||||
# General
|
||||
identifier: str # e.g. "main", "roof"
|
||||
identifier: BuildingPartIdentifier
|
||||
construction_age_band: str
|
||||
|
||||
# Wall
|
||||
|
|
@ -196,12 +399,12 @@ class SapBuildingPart:
|
|||
int, str
|
||||
] # int from API, str from site notes TODO: make enum/mapping?
|
||||
wall_thickness_measured: bool
|
||||
party_wall_construction: Union[int, str] # TODO: make enum/mapping?
|
||||
party_wall_construction: Optional[Union[int, str]] = (
|
||||
None # TODO: make enum/mapping?
|
||||
)
|
||||
|
||||
# Floor
|
||||
sap_floor_dimensions: List[
|
||||
SapFloorDimension
|
||||
] # Not included in site notes; should this be optional?
|
||||
sap_floor_dimensions: List[SapFloorDimension] = field(default_factory=list)
|
||||
|
||||
# Optional
|
||||
building_part_number: Optional[int] = (
|
||||
|
|
@ -224,6 +427,7 @@ class SapBuildingPart:
|
|||
floor_u_value_known: Optional[bool] = None
|
||||
|
||||
roof_construction: Optional[int] = None
|
||||
roof_construction_type: Optional[str] = None # str from site notes e.g. "PS Pitched, sloping ceiling"
|
||||
roof_insulation_location: Optional[Union[int, str]] = (
|
||||
None # TODO: make enum/mapping?
|
||||
)
|
||||
|
|
@ -232,6 +436,29 @@ class SapBuildingPart:
|
|||
)
|
||||
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
||||
|
||||
@property
|
||||
def main_wall_is_basement(self) -> bool:
|
||||
"""True iff this part's primary wall (not an alt sub-area) is the
|
||||
basement wall — happens when the whole part sits below grade.
|
||||
Empirically 54 of 67k parts in the 2026 sweep; rare but real."""
|
||||
return self.wall_construction == BASEMENT_WALL_CONSTRUCTION_CODE
|
||||
|
||||
@property
|
||||
def has_basement(self) -> bool:
|
||||
"""True iff this part carries a basement wall — either as its
|
||||
main wall (`main_wall_is_basement`) or as an alt sub-area
|
||||
(`SapAlternativeWall.is_basement_wall`). When true, RdSAP §5.17 /
|
||||
Table 23 governs both the basement-wall U-value AND the entire
|
||||
ground floor's U-value for this part (per user-confirmed
|
||||
convention: basement-wall presence ⇒ whole floor=0 is basement
|
||||
floor)."""
|
||||
if self.main_wall_is_basement:
|
||||
return True
|
||||
return any(
|
||||
alt is not None and alt.is_basement_wall
|
||||
for alt in (self.sap_alternative_wall_1, self.sap_alternative_wall_2)
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class WindowsTransmissionDetails:
|
||||
|
|
@ -250,6 +477,22 @@ class SapFlatDetails:
|
|||
unheated_corridor_length_m: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenewableHeatIncentive:
|
||||
"""The RHI block on the EPC — annual baseline kWh per end-use, plus SAP-estimated
|
||||
impact of common insulation measures.
|
||||
|
||||
Mapped 1:1 from the gov EPC API's `renewable_heat_incentive` object. Source of
|
||||
baseline `space_heating_kwh` and `hot_water_kwh` for SAP10 properties (used as ML
|
||||
training targets per ADR-0007).
|
||||
"""
|
||||
space_heating_kwh: float
|
||||
water_heating_kwh: float
|
||||
impact_of_loft_insulation_kwh: Optional[float] = None
|
||||
impact_of_cavity_insulation_kwh: Optional[float] = None
|
||||
impact_of_solid_wall_insulation_kwh: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpcPropertyData:
|
||||
# General
|
||||
|
|
@ -327,6 +570,10 @@ class EpcPropertyData:
|
|||
main_heating_controls: Optional[EnergyElement] = (
|
||||
None # site notes has heating_and_hot_water.main_heating.controls: str - doesn't map to EnergyElement
|
||||
)
|
||||
# Air-tightness EnergyElement (description + ratings) — kept as input even though
|
||||
# ratings are derived, because the `.description` text categorizes the building's
|
||||
# permeability class when no pressure test was carried out.
|
||||
air_tightness: Optional[EnergyElement] = None
|
||||
current_energy_efficiency_band: Optional[Epc] = None # not available in site notes?
|
||||
environmental_impact_current: Optional[int] = None
|
||||
heating_cost_current: Optional[float] = None
|
||||
|
|
@ -352,17 +599,28 @@ class EpcPropertyData:
|
|||
potential_energy_efficiency_band: Optional[Epc] = (
|
||||
None # not available in site notes
|
||||
)
|
||||
# renewable_heat_incentive: Optional[Any] = None # Not sure what this is, skip for now
|
||||
renewable_heat_incentive: Optional[RenewableHeatIncentive] = None
|
||||
draughtproofed_door_count: Optional[int] = None
|
||||
mechanical_vent_duct_type: Optional[int] = None
|
||||
windows_transmission_details: Optional[WindowsTransmissionDetails] = None
|
||||
multiple_glazed_propertion: Optional[int] = None
|
||||
multiple_glazed_proportion: Optional[int] = None
|
||||
extract_fans_count: Optional[int] = None
|
||||
# Optional cert-level addendum + LZC source codes.
|
||||
addendum: Optional[Addendum] = None
|
||||
lzc_energy_sources: Optional[List[int]] = None
|
||||
# RdSAP10 §3 line (27a) — roof windows cut into a storey-below roof.
|
||||
# Distinct from `sap_windows` (vertical, line (27)) because Table 24
|
||||
# has a separate roof-window U-value column. None when the dwelling
|
||||
# has no roof windows; for cert-cascade fixtures the bootstrap path
|
||||
# lodges per-window area + raw U.
|
||||
sap_roof_windows: Optional[List[SapRoofWindow]] = None
|
||||
calculation_software_version: Optional[str] = None # Do we care about this?
|
||||
mechanical_vent_duct_placement: Optional[int] = None
|
||||
mechanical_vent_duct_insulation: Optional[int] = None
|
||||
pressure_test_certificate_number: Optional[int] = None
|
||||
mechanical_ventilation_index_number: Optional[int] = None
|
||||
mechanical_vent_measured_installation: Optional[str] = None
|
||||
mechanical_vent_duct_insulation_level: Optional[int] = None
|
||||
co2_emissions_current_per_floor_area: Optional[int] = None
|
||||
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
|
||||
sap_flat_details: Optional[SapFlatDetails] = None
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
98
datatypes/epc/domain/tests/test_building_part_identifier.py
Normal file
98
datatypes/epc/domain/tests/test_building_part_identifier.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Tests for `BuildingPartIdentifier` — the strictly-typed identifier
|
||||
that replaces bare-string matching on `SapBuildingPart.identifier`.
|
||||
|
||||
Two boundary factories convert raw inputs to canonical members:
|
||||
- `BuildingPartIdentifier.from_api_string` (gov-EPC API)
|
||||
- `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.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import BuildingPartIdentifier
|
||||
|
||||
|
||||
class TestFromApiString:
|
||||
"""The gov-EPC API returns "Main Dwelling" and "Extension N"; the
|
||||
21_0_1 schema also permits `None`. All map to canonical members."""
|
||||
|
||||
def test_main_dwelling_becomes_main(self) -> None:
|
||||
# Arrange / Act
|
||||
identifier = BuildingPartIdentifier.from_api_string("Main Dwelling")
|
||||
|
||||
# Assert
|
||||
assert identifier is BuildingPartIdentifier.MAIN
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_string, expected",
|
||||
[
|
||||
("Extension 1", BuildingPartIdentifier.EXTENSION_1),
|
||||
("Extension 2", BuildingPartIdentifier.EXTENSION_2),
|
||||
("Extension 3", BuildingPartIdentifier.EXTENSION_3),
|
||||
("Extension 4", BuildingPartIdentifier.EXTENSION_4),
|
||||
],
|
||||
)
|
||||
def test_extension_n_becomes_extension_n(
|
||||
self, api_string: str, expected: BuildingPartIdentifier
|
||||
) -> None:
|
||||
# Arrange / Act
|
||||
identifier = BuildingPartIdentifier.from_api_string(api_string)
|
||||
|
||||
# Assert
|
||||
assert identifier is expected
|
||||
|
||||
def test_none_becomes_other(self) -> None:
|
||||
# Arrange — the 21_0_1 schema permits `identifier: Optional[str]`.
|
||||
# Act
|
||||
identifier = BuildingPartIdentifier.from_api_string(None)
|
||||
|
||||
# Assert
|
||||
assert identifier is BuildingPartIdentifier.OTHER
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"api_string", ["", "roof", "garage", "Extension", "Main", "Extension 5"]
|
||||
)
|
||||
def test_unrecognised_becomes_other(self, api_string: str) -> None:
|
||||
# Arrange — "Extension 5" is intentionally OTHER per RdSAP10 §1.2
|
||||
# (max 4 extensions); bare "Extension" with no digit likewise.
|
||||
# Act
|
||||
identifier = BuildingPartIdentifier.from_api_string(api_string)
|
||||
|
||||
# Assert
|
||||
assert identifier is BuildingPartIdentifier.OTHER
|
||||
|
||||
|
||||
class TestExtensionFactory:
|
||||
"""`extension(n)` is the site-notes-side constructor — surveyors
|
||||
record extensions by integer id; this maps id→canonical member."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"n, expected",
|
||||
[
|
||||
(1, BuildingPartIdentifier.EXTENSION_1),
|
||||
(2, BuildingPartIdentifier.EXTENSION_2),
|
||||
(3, BuildingPartIdentifier.EXTENSION_3),
|
||||
(4, BuildingPartIdentifier.EXTENSION_4),
|
||||
],
|
||||
)
|
||||
def test_valid_extension_number_returns_member(
|
||||
self, n: int, expected: BuildingPartIdentifier
|
||||
) -> None:
|
||||
# Arrange / Act
|
||||
identifier = BuildingPartIdentifier.extension(n)
|
||||
|
||||
# Assert
|
||||
assert identifier is expected
|
||||
|
||||
@pytest.mark.parametrize("n", [0, 5, 99, -1])
|
||||
def test_out_of_range_falls_to_other(self, n: int) -> None:
|
||||
# Arrange — RdSAP10 §1.2 caps at 4; out-of-range numbers should
|
||||
# not crash the mapper, they should classify as OTHER.
|
||||
# Act
|
||||
identifier = BuildingPartIdentifier.extension(n)
|
||||
|
||||
# Assert
|
||||
assert identifier is BuildingPartIdentifier.OTHER
|
||||
|
|
@ -253,6 +253,60 @@ class TestFromRdSapSchema21_0_0:
|
|||
def test_property_type(self, result: EpcPropertyData) -> None:
|
||||
assert result.property_type == "0"
|
||||
|
||||
def test_renewable_heat_incentive(self, result: EpcPropertyData) -> None:
|
||||
# Arrange — schema-21.0.0 sample JSON loaded via fixture
|
||||
|
||||
# Act
|
||||
rhi = result.renewable_heat_incentive
|
||||
|
||||
# Assert
|
||||
assert rhi is not None
|
||||
assert rhi.space_heating_kwh == 13120.0
|
||||
assert rhi.water_heating_kwh == 2285.0
|
||||
assert rhi.impact_of_loft_insulation_kwh == -2114.0
|
||||
assert rhi.impact_of_cavity_insulation_kwh == -122.0
|
||||
assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0
|
||||
|
||||
def test_photovoltaic_arrays_none_when_unmeasured(
|
||||
self, result: EpcPropertyData
|
||||
) -> None:
|
||||
# Arrange — fixture has the unmeasured-PV shape
|
||||
# (photovoltaic_supply.none_or_no_details.percent_roof_area = 0)
|
||||
|
||||
# Act
|
||||
es = result.sap_energy_source
|
||||
|
||||
# Assert
|
||||
assert es.photovoltaic_arrays is None
|
||||
assert es.photovoltaic_supply is not None
|
||||
|
||||
def test_photovoltaic_arrays_populated_when_measured(self) -> None:
|
||||
# Arrange — load the schema-21.0.0 fixture and override
|
||||
# sap_energy_source.photovoltaic_supply with the modern list-of-arrays
|
||||
# shape carried by SAP10 EPCs with measured PV.
|
||||
data = load("21_0_0.json")
|
||||
data["sap_energy_source"]["photovoltaic_supply"] = [
|
||||
[{"pitch": 2, "peak_power": 2.04, "orientation": 4, "overshading": 1}],
|
||||
[{"pitch": 2, "peak_power": 1.86, "orientation": 8, "overshading": 2}],
|
||||
]
|
||||
schema = from_dict(RdSapSchema21_0_0, data)
|
||||
|
||||
# Act
|
||||
result = EpcPropertyDataMapper.from_rdsap_schema_21_0_0(schema)
|
||||
|
||||
# Assert
|
||||
arrays = result.sap_energy_source.photovoltaic_arrays
|
||||
assert arrays is not None
|
||||
assert len(arrays) == 2
|
||||
assert arrays[0].peak_power == 2.04
|
||||
assert arrays[0].pitch == 2
|
||||
assert arrays[0].orientation == 4
|
||||
assert arrays[0].overshading == 1
|
||||
assert arrays[1].peak_power == 1.86
|
||||
assert arrays[1].orientation == 8
|
||||
# photovoltaic_supply is None when the measured shape is present
|
||||
assert result.sap_energy_source.photovoltaic_supply is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema 21.0.1 (most comprehensive — full field coverage)
|
||||
|
|
@ -313,7 +367,14 @@ class TestFromRdSapSchema21_0_1:
|
|||
assert result.inspection_date == date(2025, 4, 4)
|
||||
|
||||
def test_total_floor_area(self, result: EpcPropertyData) -> None:
|
||||
assert result.total_floor_area_m2 == 55.0
|
||||
# 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
|
||||
|
||||
# --- property flags ---
|
||||
|
||||
|
|
@ -532,3 +593,107 @@ class TestFromRdSapSchema21_0_1:
|
|||
|
||||
def test_party_wall_length(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].sap_floor_dimensions[0].party_wall_length_m == 7.9
|
||||
|
||||
# --- room-in-roof (sap_room_in_roof.room_in_roof_type_1) ---
|
||||
|
||||
def test_flat_roof_insulation_thickness_flows_through_on_building_part(
|
||||
self, result: EpcPropertyData
|
||||
) -> None:
|
||||
# Arrange — schema-21.0.1 lodges flat_roof_insulation_thickness
|
||||
# on SapBuildingPart as a categorical code (e.g. "AB" for "As
|
||||
# Built"). EpcPropertyData.SapBuildingPart declares the field;
|
||||
# without mapper passthrough the flat-roof U-value cascade has
|
||||
# no insulation signal to use.
|
||||
|
||||
# Act
|
||||
v = result.sap_building_parts[0].flat_roof_insulation_thickness
|
||||
|
||||
# Assert
|
||||
assert v == "AB"
|
||||
|
||||
def test_sap_room_in_roof_gable_lengths_extracted_from_room_in_roof_type_1(
|
||||
self, result: EpcPropertyData
|
||||
) -> None:
|
||||
# Arrange — schema-21.0.1 lodges Simplified Type 1 gable lengths
|
||||
# under sap_room_in_roof.room_in_roof_type_1. The cascade requires
|
||||
# them on EpcPropertyData.SapRoomInRoof.gable_1_length_m /
|
||||
# gable_2_length_m for the §3.9.2 area cascade. Without this the
|
||||
# length data is silently dropped at deserialization.
|
||||
|
||||
# Act
|
||||
rir = result.sap_building_parts[0].sap_room_in_roof
|
||||
|
||||
# Assert
|
||||
assert rir is not None
|
||||
assert rir.gable_1_length_m == 6.4
|
||||
assert rir.gable_2_length_m == 6.4
|
||||
|
||||
# --- ventilation (sap_ventilation) ---
|
||||
|
||||
def test_sap_ventilation_extract_fans_count_flows_through_to_calculator_input(
|
||||
self, result: EpcPropertyData
|
||||
) -> None:
|
||||
# Arrange — fixture lodges `extract_fans_count: 2` at the cert root;
|
||||
# cert_to_inputs reads it via epc.sap_ventilation.extract_fans_count,
|
||||
# so the mapper must surface it on the SapVentilation slice.
|
||||
|
||||
# Act
|
||||
sv = result.sap_ventilation
|
||||
|
||||
# Assert
|
||||
assert sv is not None
|
||||
assert sv.extract_fans_count == 2
|
||||
|
||||
def test_percent_draughtproofed_flows_through_to_calculator_input(
|
||||
self, result: EpcPropertyData
|
||||
) -> None:
|
||||
# Arrange — fixture lodges `percent_draughtproofed: 100` at the
|
||||
# cert root. cert_to_inputs reads it via epc.percent_draughtproofed
|
||||
# for the §2 ventilation cascade (window draught loss). Without
|
||||
# this the cascade defaults to 0 — treats every cert as fully
|
||||
# draughty, over-counting infiltration.
|
||||
|
||||
# Act
|
||||
v = result.percent_draughtproofed
|
||||
|
||||
# Assert
|
||||
assert v == 100
|
||||
|
||||
def test_ventilation_completeness_all_seven_vent_fields_flow_through(
|
||||
self, result: EpcPropertyData
|
||||
) -> None:
|
||||
# Arrange — schema-21.0.1 carries seven vent / draught fields the
|
||||
# cert→inputs cascade reads for the §2 infiltration calculation.
|
||||
# Without these the calc treats the dwelling as flue-free / vent-
|
||||
# free / no draught lobby, under-counting infiltration ACH.
|
||||
# blocked_chimneys is top-level; the other 6 live on SapVentilation.
|
||||
|
||||
# Act
|
||||
sv = result.sap_ventilation
|
||||
|
||||
# Assert
|
||||
assert result.blocked_chimneys_count == 1
|
||||
assert sv is not None
|
||||
assert sv.open_flues_count == 1
|
||||
assert sv.closed_flues_count == 1
|
||||
assert sv.boiler_flues_count == 1
|
||||
assert sv.other_flues_count == 1
|
||||
assert sv.passive_vents_count == 2
|
||||
assert sv.has_draught_lobby is True
|
||||
|
||||
# --- renewable heat incentive (RHI) ---
|
||||
|
||||
def test_renewable_heat_incentive(self, result: EpcPropertyData) -> None:
|
||||
# Arrange — schema-21.0.1 sample JSON loaded via fixture
|
||||
|
||||
# Act
|
||||
rhi = result.renewable_heat_incentive
|
||||
|
||||
# Assert
|
||||
assert rhi is not None
|
||||
assert rhi.space_heating_kwh == 13120.0
|
||||
assert rhi.water_heating_kwh == 2285.0
|
||||
assert rhi.impact_of_loft_insulation_kwh == -2114.0
|
||||
assert rhi.impact_of_cavity_insulation_kwh == -122.0
|
||||
assert rhi.impact_of_solid_wall_insulation_kwh == -3560.0
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Any, Dict
|
|||
import pytest
|
||||
|
||||
from datatypes.epc.domain.epc_property_data import (
|
||||
BuildingPartIdentifier,
|
||||
EpcPropertyData,
|
||||
InstantaneousWwhrs,
|
||||
MainHeatingDetail,
|
||||
|
|
@ -211,7 +212,7 @@ class TestFromSiteNotesExample1:
|
|||
assert len(result.sap_building_parts) == 1
|
||||
|
||||
def test_building_part_identifier(self, result: EpcPropertyData) -> None:
|
||||
assert result.sap_building_parts[0].identifier == "main"
|
||||
assert result.sap_building_parts[0].identifier is BuildingPartIdentifier.MAIN
|
||||
|
||||
def test_construction_age_band(self, result: EpcPropertyData) -> None:
|
||||
# main_building.age_range: "I: 1996 - 2002" → letter "I"
|
||||
|
|
@ -464,7 +465,7 @@ class TestFromSiteNotesExample1:
|
|||
# Building parts
|
||||
sap_building_parts=[
|
||||
SapBuildingPart(
|
||||
identifier="main",
|
||||
identifier=BuildingPartIdentifier.MAIN,
|
||||
construction_age_band="I",
|
||||
wall_construction="Cavity",
|
||||
wall_insulation_type="As built",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ def _coerce(value: Any, hint: Any) -> Any:
|
|||
for arg in non_none_args:
|
||||
if dataclasses.is_dataclass(arg) and isinstance(value, dict):
|
||||
return _from_dict_impl(arg, value)
|
||||
# Then try list types — covers Union[Dataclass, list[...]] polymorphism
|
||||
# where a single JSON key can carry either a wrapper dict or a list of items.
|
||||
if isinstance(value, list):
|
||||
for arg in non_none_args:
|
||||
if typing.get_origin(arg) is list:
|
||||
return _coerce(value, arg)
|
||||
# All remaining args are primitives — return value as-is
|
||||
return value
|
||||
|
||||
|
|
|
|||
|
|
@ -61,10 +61,10 @@ class SapHeating:
|
|||
cylinder_size: int
|
||||
water_heating_code: int
|
||||
water_heating_fuel: int
|
||||
instantaneous_wwhrs: InstantaneousWwhrs
|
||||
main_heating_details: List[MainHeatingDetail]
|
||||
immersion_heating_type: Union[int, str]
|
||||
has_fixed_air_conditioning: str
|
||||
instantaneous_wwhrs: Optional[InstantaneousWwhrs] = None
|
||||
shower_outlets: Optional[ShowerOutlets] = None
|
||||
cylinder_insulation_type: Optional[int] = None
|
||||
cylinder_thermostat: Optional[str] = None
|
||||
|
|
@ -99,13 +99,28 @@ class PhotovoltaicSupply:
|
|||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhotovoltaicArray:
|
||||
"""Measured-PV array (peak_power, pitch, orientation, overshading).
|
||||
|
||||
Modern SAP10 EPCs with measured PV carry `photovoltaic_supply` as a nested
|
||||
list (`list[list[PhotovoltaicArray]]`) rather than the legacy wrapper dict
|
||||
`PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply
|
||||
accepts either shape.
|
||||
"""
|
||||
peak_power: float
|
||||
pitch: int
|
||||
orientation: int
|
||||
overshading: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapEnergySource:
|
||||
mains_gas: str
|
||||
meter_type: int
|
||||
pv_connection: int
|
||||
pv_battery_count: int
|
||||
photovoltaic_supply: PhotovoltaicSupply
|
||||
photovoltaic_supply: Union[PhotovoltaicSupply, List[List[PhotovoltaicArray]]]
|
||||
wind_turbines_count: int
|
||||
wind_turbine_details: WindTurbineDetails
|
||||
gas_smart_meter_present: str
|
||||
|
|
@ -151,11 +166,26 @@ class SapFloorDimension:
|
|||
floor_construction: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofType1:
|
||||
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
|
||||
|
||||
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
|
||||
full enum not yet mapped). `gable_wall_length_*` is the run of the
|
||||
external gable in metres. Heights are NOT lodged here — the cascade
|
||||
applies the §3.9.1 default storey height (2.45 m)."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
gable_wall_length_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
"""Room-in-roof details. insulation and roof_room_connected removed in schema 21.0.0."""
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
room_in_roof_type_1: Optional[RoomInRoofType1] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ class EnergyElement:
|
|||
|
||||
@dataclass
|
||||
class Addendum:
|
||||
addendum_numbers: List[int]
|
||||
stone_walls: Optional[str] = None
|
||||
system_build: Optional[str] = None
|
||||
addendum_numbers: Optional[List[int]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -27,7 +27,7 @@ class ShowerOutlet:
|
|||
|
||||
@dataclass
|
||||
class ShowerOutlets:
|
||||
shower_outlet: ShowerOutlet
|
||||
shower_outlet: Optional[ShowerOutlet] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -43,12 +43,12 @@ class MainHeatingDetail:
|
|||
has_fghrs: str # TODO: make bool
|
||||
main_fuel_type: int
|
||||
heat_emitter_type: int
|
||||
emitter_temperature: Union[int, str]
|
||||
main_heating_number: int
|
||||
main_heating_control: int
|
||||
main_heating_category: int
|
||||
main_heating_fraction: int
|
||||
main_heating_data_source: int
|
||||
emitter_temperature: Optional[Union[int, str]] = None
|
||||
boiler_flue_type: Optional[int] = None
|
||||
fan_flue_present: Optional[str] = None # TODO: make bool
|
||||
boiler_ignition_type: Optional[int] = None
|
||||
|
|
@ -62,11 +62,16 @@ class SapHeating:
|
|||
cylinder_size: int
|
||||
water_heating_code: int
|
||||
water_heating_fuel: int
|
||||
instantaneous_wwhrs: InstantaneousWwhrs
|
||||
main_heating_details: List[MainHeatingDetail]
|
||||
immersion_heating_type: Union[int, str]
|
||||
has_fixed_air_conditioning: str
|
||||
shower_outlets: Optional[ShowerOutlets] = None
|
||||
instantaneous_wwhrs: Optional[InstantaneousWwhrs] = None
|
||||
# Real-API certs carry shower_outlets as a list, not the synthetic single-object form;
|
||||
# accept both shapes so older fixtures keep parsing.
|
||||
shower_outlets: Optional[Union[ShowerOutlets, List[ShowerOutlets]]] = None
|
||||
# SAP10 hot-water demand inputs.
|
||||
number_baths: Optional[int] = None
|
||||
number_baths_wwhrs: Optional[int] = None
|
||||
cylinder_insulation_type: Optional[int] = None
|
||||
cylinder_thermostat: Optional[str] = None
|
||||
secondary_fuel_type: Optional[int] = None
|
||||
|
|
@ -81,7 +86,9 @@ class PvBattery:
|
|||
|
||||
@dataclass
|
||||
class PvBatteries:
|
||||
pv_battery: PvBattery
|
||||
# Real-API certs carry pv_batteries as a list (similar to shower_outlets);
|
||||
# the older synthetic fixture used a single-object wrapper.
|
||||
pv_battery: Optional[PvBattery] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -97,7 +104,22 @@ class PhotovoltaicSupplyNoneOrNoDetails:
|
|||
|
||||
@dataclass
|
||||
class PhotovoltaicSupply:
|
||||
none_or_no_details: PhotovoltaicSupplyNoneOrNoDetails
|
||||
none_or_no_details: Optional[PhotovoltaicSupplyNoneOrNoDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhotovoltaicArray:
|
||||
"""Measured-PV array (peak_power, pitch, orientation, overshading).
|
||||
|
||||
Modern SAP10 EPCs with measured PV carry `photovoltaic_supply` as a nested
|
||||
list (`list[list[PhotovoltaicArray]]`) rather than the legacy wrapper dict
|
||||
`PhotovoltaicSupply`. The Union type on SapEnergySource.photovoltaic_supply
|
||||
accepts either shape. Some certs wrap the scalars in Measurement dicts.
|
||||
"""
|
||||
peak_power: Union[Measurement, int, float]
|
||||
pitch: Union[Measurement, int]
|
||||
orientation: Union[Measurement, int]
|
||||
overshading: Union[Measurement, int]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -105,15 +127,15 @@ class SapEnergySource:
|
|||
mains_gas: str
|
||||
meter_type: int
|
||||
pv_connection: int
|
||||
pv_battery_count: int
|
||||
photovoltaic_supply: PhotovoltaicSupply
|
||||
photovoltaic_supply: Union[PhotovoltaicSupply, List[List[PhotovoltaicArray]]]
|
||||
wind_turbines_count: int
|
||||
wind_turbine_details: WindTurbineDetails
|
||||
gas_smart_meter_present: str
|
||||
is_dwelling_export_capable: str
|
||||
wind_turbines_terrain_type: int
|
||||
electricity_smart_meter_present: str
|
||||
pv_batteries: Optional[PvBatteries] = None
|
||||
pv_battery_count: Optional[int] = None
|
||||
wind_turbine_details: Optional[WindTurbineDetails] = None
|
||||
pv_batteries: Optional[Union[PvBatteries, List[PvBatteries]]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -125,37 +147,54 @@ class WindowTransmissionDetails:
|
|||
|
||||
@dataclass
|
||||
class SapWindow:
|
||||
pvc_frame: str
|
||||
glazing_gap: int
|
||||
orientation: int
|
||||
window_type: int
|
||||
frame_factor: float
|
||||
glazing_type: int
|
||||
window_width: float
|
||||
window_height: float
|
||||
# Real-API certs sometimes carry a Measurement dict for dimensions, not a plain float.
|
||||
window_width: Union[Measurement, int, float]
|
||||
window_height: Union[Measurement, int, float]
|
||||
draught_proofed: str # TODO: make bool
|
||||
window_location: int
|
||||
window_wall_type: int
|
||||
permanent_shutters_present: str # TODO: make bool
|
||||
window_transmission_details: WindowTransmissionDetails
|
||||
permanent_shutters_insulated: str
|
||||
pvc_frame: Optional[str] = None
|
||||
glazing_gap: Optional[int] = None
|
||||
frame_factor: Optional[float] = None
|
||||
window_transmission_details: Optional[WindowTransmissionDetails] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapFloorDimension:
|
||||
floor: int
|
||||
room_height: Measurement
|
||||
total_floor_area: Measurement
|
||||
party_wall_length: Union[Measurement, int]
|
||||
heat_loss_perimeter: Measurement
|
||||
# Real-API certs sometimes carry plain int/float instead of a Measurement object.
|
||||
room_height: Union[Measurement, int, float]
|
||||
total_floor_area: Union[Measurement, int, float]
|
||||
party_wall_length: Union[Measurement, int, float]
|
||||
heat_loss_perimeter: Union[Measurement, int, float]
|
||||
floor_insulation: Optional[int] = None
|
||||
floor_construction: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofType1:
|
||||
"""RdSAP §3.9.1 Simplified Type 1 RR — gable lengths only.
|
||||
|
||||
`gable_wall_type_*` is the Table 4 gable variant (0 = external, etc.;
|
||||
full enum not yet mapped). `gable_wall_length_*` is the run of the
|
||||
external gable in metres. Heights are NOT lodged here — the cascade
|
||||
applies the §3.9.1 default storey height (2.45 m)."""
|
||||
gable_wall_type_1: Optional[int] = None
|
||||
gable_wall_type_2: Optional[int] = None
|
||||
gable_wall_length_1: Optional[float] = None
|
||||
gable_wall_length_2: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SapRoomInRoof:
|
||||
floor_area: Union[int, float]
|
||||
construction_age_band: str
|
||||
room_in_roof_type_1: Optional[RoomInRoofType1] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -170,19 +209,19 @@ class SapAlternativeWall:
|
|||
|
||||
@dataclass
|
||||
class SapBuildingPart:
|
||||
identifier: str
|
||||
wall_dry_lined: str
|
||||
floor_heat_loss: int
|
||||
roof_construction: int
|
||||
wall_construction: int
|
||||
building_part_number: int
|
||||
sap_floor_dimensions: List[SapFloorDimension]
|
||||
wall_insulation_type: int
|
||||
construction_age_band: str
|
||||
party_wall_construction: Union[int, str]
|
||||
wall_thickness_measured: str
|
||||
roof_insulation_location: Union[int, str]
|
||||
roof_insulation_thickness: Union[str, int]
|
||||
identifier: Optional[str] = None
|
||||
wall_dry_lined: Optional[str] = None
|
||||
floor_heat_loss: Optional[int] = None
|
||||
roof_construction: Optional[int] = None
|
||||
wall_construction: Optional[int] = None
|
||||
building_part_number: Optional[int] = None
|
||||
sap_floor_dimensions: Optional[List[SapFloorDimension]] = None
|
||||
wall_insulation_type: Optional[int] = None
|
||||
construction_age_band: Optional[str] = None
|
||||
party_wall_construction: Optional[Union[int, str]] = None
|
||||
wall_thickness_measured: Optional[str] = None
|
||||
roof_insulation_location: Optional[Union[int, str]] = None
|
||||
roof_insulation_thickness: Optional[Union[str, int]] = None
|
||||
sap_room_in_roof: Optional[SapRoomInRoof] = None
|
||||
sap_alternative_wall_1: Optional[SapAlternativeWall] = None
|
||||
sap_alternative_wall_2: Optional[SapAlternativeWall] = None
|
||||
|
|
@ -276,7 +315,6 @@ class RdSapSchema21_0_1:
|
|||
assessment_type: str
|
||||
completion_date: str
|
||||
inspection_date: str
|
||||
wet_rooms_count: int
|
||||
extensions_count: int
|
||||
measurement_type: int
|
||||
total_floor_area: int
|
||||
|
|
@ -287,7 +325,6 @@ class RdSapSchema21_0_1:
|
|||
sap_energy_source: SapEnergySource
|
||||
secondary_heating: EnergyElement
|
||||
sap_building_parts: List[SapBuildingPart]
|
||||
open_chimneys_count: int
|
||||
solar_water_heating: str
|
||||
habitable_room_count: int
|
||||
heating_cost_current: float
|
||||
|
|
@ -300,10 +337,8 @@ class RdSapSchema21_0_1:
|
|||
has_hot_water_cylinder: str
|
||||
heating_cost_potential: float
|
||||
hot_water_cost_current: float
|
||||
insulated_door_u_value: float
|
||||
mechanical_ventilation: int
|
||||
percent_draughtproofed: int
|
||||
suggested_improvements: List[SuggestedImprovement]
|
||||
co2_emissions_potential: float
|
||||
energy_rating_potential: int
|
||||
lighting_cost_potential: float
|
||||
|
|
@ -311,31 +346,51 @@ class RdSapSchema21_0_1:
|
|||
hot_water_cost_potential: float
|
||||
renewable_heat_incentive: RenewableHeatIncentive
|
||||
draughtproofed_door_count: int
|
||||
mechanical_vent_duct_type: int
|
||||
windows_transmission_details: WindowsTransmissionDetails
|
||||
cfl_fixed_lighting_bulbs_count: int
|
||||
energy_consumption_current: int
|
||||
has_fixed_air_conditioning: str
|
||||
multiple_glazed_proportion: int
|
||||
calculation_software_version: str
|
||||
energy_consumption_potential: int
|
||||
environmental_impact_current: int
|
||||
led_fixed_lighting_bulbs_count: int
|
||||
mechanical_vent_duct_placement: int
|
||||
mechanical_vent_duct_insulation: int
|
||||
potential_energy_efficiency_band: str
|
||||
pressure_test_certificate_number: int
|
||||
mechanical_ventilation_index_number: int
|
||||
co2_emissions_current_per_floor_area: int
|
||||
current_energy_efficiency_band: str
|
||||
environmental_impact_potential: int
|
||||
low_energy_fixed_lighting_bulbs_count: int
|
||||
mechanical_vent_duct_insulation_level: int
|
||||
mechanical_vent_measured_installation: str
|
||||
incandescent_fixed_lighting_bulbs_count: int
|
||||
# Fields below are present in some certs but absent in many real-world responses;
|
||||
# see datatypes/epc/schema/tests/fixtures/21_0_1_real.json for a representative cert.
|
||||
air_tightness: Optional[EnergyElement] = None
|
||||
extract_fans_count: Optional[int] = None
|
||||
wet_rooms_count: Optional[int] = None
|
||||
open_chimneys_count: Optional[int] = None
|
||||
# Ventilation / draught completeness — surfaced into SapVentilation
|
||||
# (or EpcPropertyData top-level for chimney counts) so the §2 cascade
|
||||
# gets the real flue / vent / draught lobby state instead of zeros.
|
||||
blocked_chimneys_count: Optional[int] = None
|
||||
open_flues_count: Optional[int] = None
|
||||
closed_flues_count: Optional[int] = None
|
||||
boilers_flues_count: Optional[int] = None
|
||||
other_flues_count: Optional[int] = None
|
||||
psv_count: Optional[int] = None
|
||||
has_draught_lobby: Optional[str] = None # "true" / "false" / "unknown"
|
||||
insulated_door_u_value: Optional[float] = None
|
||||
suggested_improvements: Optional[List[SuggestedImprovement]] = None
|
||||
mechanical_vent_duct_type: Optional[int] = None
|
||||
windows_transmission_details: Optional[WindowsTransmissionDetails] = None
|
||||
cfl_fixed_lighting_bulbs_count: Optional[int] = None
|
||||
multiple_glazed_proportion: Optional[int] = None
|
||||
led_fixed_lighting_bulbs_count: Optional[int] = None
|
||||
mechanical_vent_duct_placement: Optional[int] = None
|
||||
mechanical_vent_duct_insulation: Optional[int] = None
|
||||
pressure_test_certificate_number: Optional[int] = None
|
||||
mechanical_ventilation_index_number: Optional[int] = None
|
||||
low_energy_fixed_lighting_bulbs_count: Optional[int] = None
|
||||
mechanical_vent_duct_insulation_level: Optional[int] = None
|
||||
mechanical_vent_measured_installation: Optional[str] = None
|
||||
sap_flat_details: Optional[SapFlatDetails] = None
|
||||
addendum: Optional[Addendum] = None
|
||||
address_line_2: Optional[str] = None
|
||||
has_heated_separate_conservatory: Optional[str] = None
|
||||
fixed_lighting_outlets_count: Optional[int] = None
|
||||
low_energy_fixed_lighting_outlets_count: Optional[int] = None
|
||||
# LZC (low-carbon) energy-source codes flagged on the cert.
|
||||
lzc_energy_sources: Optional[List[int]] = None
|
||||
|
|
|
|||
20
datatypes/epc/schema/tests/fixtures/21_0_1.json
vendored
20
datatypes/epc/schema/tests/fixtures/21_0_1.json
vendored
|
|
@ -126,10 +126,20 @@
|
|||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"floor_heat_loss": 7,
|
||||
"sap_room_in_roof": {"floor_area": 100, "construction_age_band": "B"},
|
||||
"sap_room_in_roof": {
|
||||
"floor_area": 100,
|
||||
"construction_age_band": "B",
|
||||
"room_in_roof_type_1": {
|
||||
"gable_wall_type_1": 0,
|
||||
"gable_wall_type_2": 0,
|
||||
"gable_wall_length_1": 6.4,
|
||||
"gable_wall_length_2": 6.4
|
||||
}
|
||||
},
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"flat_roof_insulation_thickness": "AB",
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
|
|
@ -154,6 +164,14 @@
|
|||
}
|
||||
],
|
||||
"open_chimneys_count": 1,
|
||||
"extract_fans_count": 2,
|
||||
"blocked_chimneys_count": 1,
|
||||
"open_flues_count": 1,
|
||||
"closed_flues_count": 1,
|
||||
"boilers_flues_count": 1,
|
||||
"other_flues_count": 1,
|
||||
"psv_count": 2,
|
||||
"has_draught_lobby": "true",
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 5,
|
||||
"heating_cost_current": 365.98,
|
||||
|
|
|
|||
309
datatypes/epc/schema/tests/fixtures/21_0_1_real.json
vendored
Normal file
309
datatypes/epc/schema/tests/fixtures/21_0_1_real.json
vendored
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
{
|
||||
"uprn": 0,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "(another dwelling above)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Solid brick, as built, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 1,
|
||||
"environmental_efficiency_rating": 1
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "SE22 9QF",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "LONDON",
|
||||
"built_form": "NR",
|
||||
"created_at": "2026-03-10 00:03:32",
|
||||
"door_count": 1,
|
||||
"region_code": 17,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 0,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 17973
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.09,
|
||||
"window_height": 1.75,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.99,
|
||||
"window_height": 0.89,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.7,
|
||||
"window_height": 0.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Address Matched",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Ground-floor flat",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 2,
|
||||
"address_line_1": "<scrubbed>",
|
||||
"address_line_2": "<scrubbed>",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2026-03-10",
|
||||
"inspection_date": "2026-03-05",
|
||||
"extensions_count": 0,
|
||||
"measurement_type": 1,
|
||||
"sap_flat_details": {
|
||||
"level": 1,
|
||||
"top_storey": "N",
|
||||
"storey_count": 4,
|
||||
"flat_location": 0,
|
||||
"heat_loss_corridor": 0
|
||||
},
|
||||
"total_floor_area": 27,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"heated_room_count": 1,
|
||||
"registration_date": "2026-03-10",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "false"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"extract_fans_count": 1,
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 3,
|
||||
"wall_construction": 3,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.4,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 26.78,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 10.52,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 10.52,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "A",
|
||||
"party_wall_construction": 0,
|
||||
"wall_thickness_measured": "N",
|
||||
"roof_insulation_location": "ND",
|
||||
"roof_insulation_thickness": "ND",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 1,
|
||||
"heating_cost_current": {
|
||||
"value": 355,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 1.1,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 71,
|
||||
"lighting_cost_current": {
|
||||
"value": 22,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 228,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 128,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 91,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a37,500 - \u00a311,000",
|
||||
"improvement_type": "Q",
|
||||
"improvement_details": {
|
||||
"improvement_number": 7
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 76,
|
||||
"environmental_impact_rating": 83
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 34,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 58
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 77,
|
||||
"environmental_impact_rating": 85
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 0.7,
|
||||
"energy_rating_potential": 77,
|
||||
"lighting_cost_potential": {
|
||||
"value": 22,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 131,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 1653.36,
|
||||
"space_heating_existing_dwelling": 2797.73
|
||||
},
|
||||
"draughtproofed_door_count": 1,
|
||||
"energy_consumption_current": 229,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0334",
|
||||
"energy_consumption_potential": 148,
|
||||
"environmental_impact_current": 77,
|
||||
"current_energy_efficiency_band": "C",
|
||||
"environmental_impact_potential": 85,
|
||||
"led_fixed_lighting_bulbs_count": 5,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 41,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
|
|
@ -378,3 +378,25 @@ class TestRdSapSchema21_0_1:
|
|||
|
||||
def test_incandescent_bulb_count(self, epc: RdSapSchema21_0_1) -> None:
|
||||
assert epc.incandescent_fixed_lighting_bulbs_count == 0
|
||||
|
||||
|
||||
class TestRdSapSchema21_0_1AgainstRealApiCert:
|
||||
"""Regression guard: a real cert (PII-scrubbed) from the gov bulk JSON must parse.
|
||||
|
||||
Previously the dataclass was driven by the synthetic `21_0_1.json` fixture, which
|
||||
coincidentally contained every optional field. Real-API certs omit many of them,
|
||||
so the dataclass annotations have to allow Optional/missing on those fields.
|
||||
This test fails the moment a now-Optional field is accidentally re-marked required.
|
||||
"""
|
||||
|
||||
def test_real_cert_parses_via_from_dict(self) -> None:
|
||||
# Arrange
|
||||
real_doc = load("21_0_1_real.json")
|
||||
|
||||
# Act
|
||||
epc = from_dict(RdSapSchema21_0_1, real_doc)
|
||||
|
||||
# Assert
|
||||
assert epc.schema_type == "RdSAP-Schema-21.0.1"
|
||||
assert epc.sap_heating is not None
|
||||
assert len(epc.sap_windows) > 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
|
|
@ -51,6 +51,22 @@ class BuildingPartDimensions:
|
|||
floors: List[FloorDimension]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AlternativeWall:
|
||||
"""RdSAP §S5 Alternative Wall — a sub-area of the building part's
|
||||
gross wall that has a different construction (e.g. a small 1.43 m²
|
||||
timber-frame panel on an otherwise cavity-walled extension). Up to
|
||||
two alternative walls per bp; Elmhurst lodges them in §7's "1st/2nd
|
||||
Extension" subsection under the "Alternative Wall N <field>" prefix."""
|
||||
|
||||
area_m2: float
|
||||
wall_type: str # e.g. "TI Timber Frame"
|
||||
insulation: str # e.g. "A As Built"
|
||||
thickness_unknown: bool
|
||||
thickness_mm: Optional[int]
|
||||
u_value_known: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class WallDetails:
|
||||
wall_type: str # e.g. "CA Cavity"
|
||||
|
|
@ -58,6 +74,10 @@ class WallDetails:
|
|||
thickness_unknown: bool
|
||||
u_value_known: bool
|
||||
party_wall_type: str # e.g. "U Unable to determine"
|
||||
# `alternative_walls` carries up to two alt sub-areas per bp.
|
||||
alternative_walls: List["AlternativeWall"] = field(
|
||||
default_factory=lambda: [] # type: ignore[reportUnknownLambdaType]
|
||||
)
|
||||
thickness_mm: Optional[int] = None
|
||||
|
||||
|
||||
|
|
@ -78,6 +98,40 @@ class FloorDetails:
|
|||
default_u_value: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoofSurface:
|
||||
"""One sub-element of a §3.10 Detailed Room-in-Roof assessment:
|
||||
Flat Ceiling / Stud Wall / Slope / Gable Wall / Common Wall.
|
||||
|
||||
Each is lodged with a Length × Height pair plus insulation /
|
||||
insulation-type / gable-type / measured-U fields. Absent surfaces
|
||||
are still lodged at 0×0 (e.g. a Flat Ceiling with no flat-roof
|
||||
portion) and filtered out in the mapper."""
|
||||
|
||||
name: str # e.g. "Flat Ceiling 1", "Stud Wall 2", "Gable Wall 1"
|
||||
length_m: float
|
||||
height_m: float
|
||||
insulation: str # "As Built" | "None" | "100 mm" | ""
|
||||
insulation_type: Optional[str] # e.g. "Mineral or EPS"
|
||||
gable_type: Optional[str] # "Party" | "Sheltered" | "Connected to heated space"
|
||||
default_u_value: Optional[float]
|
||||
u_value_known: bool
|
||||
u_value: float # assessor-measured U-value (0.00 when not known)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoomInRoof:
|
||||
"""§8.1 Rooms in Roof — Main-property entry only (extensions never
|
||||
carry RR in the observed corpus). `surfaces` lists all 5 RdSAP §3.10
|
||||
detailed-assessment kinds in document order; 0×0 entries are kept so
|
||||
the mapper sees the complete table shape."""
|
||||
|
||||
floor_area_m2: float
|
||||
construction_age_band: Optional[str]
|
||||
assessment: str # "Detailed" | "Simplified Type 1" | "Simplified Type 2"
|
||||
surfaces: List[RoomInRoofSurface]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Window:
|
||||
width_m: float
|
||||
|
|
@ -140,6 +194,11 @@ class MainHeating:
|
|||
None # e.g. "17742 Potterton, Promax 33 Combi ErP, 88.30%"
|
||||
)
|
||||
heat_pump_age: Optional[str] = None
|
||||
# Section 14.0 also lodges a secondary heating system (when one is
|
||||
# installed). The SAP code is the integer the cascade reads via
|
||||
# `SapHeating.secondary_heating_type` to apply the Table 11
|
||||
# secondary-fraction split; None when no secondary is lodged.
|
||||
secondary_heating_sap_code: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -184,6 +243,21 @@ class Renewables:
|
|||
hydro_electricity_generated_kwh: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExtensionPart:
|
||||
"""Additional building part on a multi-bp cert (e.g. "1st Extension",
|
||||
"2nd Extension" on the Elmhurst Summary PDF). Mirrors the per-bp
|
||||
fabric fields the main dwelling carries at the top-level
|
||||
ElmhurstSiteNotes."""
|
||||
|
||||
name: str # e.g. "1st Extension", "2nd Extension"
|
||||
construction_age_band: str # e.g. "B 1900-1929" (may differ from main)
|
||||
dimensions: BuildingPartDimensions
|
||||
walls: WallDetails
|
||||
roof: RoofDetails
|
||||
floor: FloorDetails
|
||||
|
||||
|
||||
@dataclass
|
||||
class ElmhurstSiteNotes:
|
||||
surveyor_info: SurveyorInfo
|
||||
|
|
@ -245,3 +319,17 @@ class ElmhurstSiteNotes:
|
|||
|
||||
# Sections 16.0–22.0
|
||||
renewables: Renewables
|
||||
|
||||
# Additional building parts beyond the main dwelling. The singular
|
||||
# `dimensions`, `walls`, `roof`, `floor`, and `construction_age_band`
|
||||
# fields above describe the "Main" property; each ExtensionPart in
|
||||
# this list describes a discrete extension with its own age band,
|
||||
# dimensions, and fabric details. Empty list = single-bp cert
|
||||
# (preserves backward compatibility with the existing fixture).
|
||||
extensions: List[ExtensionPart] = field(default_factory=lambda: []) # type: ignore[reportUnknownLambdaType]
|
||||
|
||||
# §8.1 Rooms in Roof — Main property only in the observed corpus.
|
||||
# When None the dwelling has no RR storey (a 2-storey house with a
|
||||
# cold loft instead of a room-in-roof). The mapper translates the
|
||||
# surface table into a `SapRoomInRoof` attached to the Main bp.
|
||||
room_in_roof: Optional[RoomInRoof] = None
|
||||
|
|
|
|||
10
docs/adr/0001-two-source-paths.md
Normal file
10
docs/adr/0001-two-source-paths.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Two source paths for a Property, not layered precedence
|
||||
|
||||
For modelling a Property we considered a strict layered precedence stack — `patches > site_notes > energy_assessment > epc > predicted` — with per-field provenance tracking. We rejected that in favour of **two strictly disjoint source paths**: a Property is modelled either from its Site Notes alone, or from the public EPC with Landlord Overrides applied on top. Site Notes are committed to being full-coverage by the domain ([CONTEXT.md](../../CONTEXT.md): _Site Notes_), so once we have them the EPC is irrelevant; conversely, Landlord Overrides are only meaningful when the EPC is the source of physical state.
|
||||
|
||||
The trade-off: layered precedence is more flexible (it tolerates a partial Site Notes survey by falling through to EPC for missing fields), but mixed-source data muddles the audit trail and undermines the "if we surveyed it, trust the survey" promise. The two-path model gives a cleaner derivation rule and an unambiguous source-of-truth per Property, at the cost of treating survey gaps as a survey-quality bug rather than a fallback signal. A Recency Tie-Break covers the one case where both exist: the newer of the two wins.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Reversing this means rewriting `Property.effective_epc` and every service that reads it. Hard to roll back once 12 services depend on the two-path shape.
|
||||
- Future addition of a third path (e.g. partial-survey) is a real change, not just a config tweak — flag it as an ADR if proposed.
|
||||
14
docs/adr/0002-property-aggregate-root.md
Normal file
14
docs/adr/0002-property-aggregate-root.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# `Property` is the aggregate root, not `EpcPropertyData`
|
||||
|
||||
The Ara modelling pipeline produces nine slices of per-property data (EPC, geospatial, solar, baseline performance, recommendations, optimised package, etc.). We considered making `EpcPropertyData` — the rich RdSAP-21-style EPC schema — the centrepiece, with other data hanging off it. We rejected that and introduced a new **`Property` aggregate root** that holds identity, all source data (EPC, Site Notes, Landlord Overrides), enrichments, and modelling outputs as named fields. Services take `Property` (or `Properties`) and return them with one slice populated.
|
||||
|
||||
Two reasons drove this:
|
||||
1. **Geospatial, solar, recommendations, and overrides are peers to the EPC**, not properties of it. Putting them on `EpcPropertyData` conflates physical-state schema with modelling-run state.
|
||||
2. **A typed `ModellingContext` dict-bag (the obvious alternative)** is exactly what the current legacy `Property` class became — 1259 lines of accumulated stuff, hard to read, hard to test, hard to extend. Named fields on a dataclass force the type system to keep us honest.
|
||||
|
||||
The cost is more domain types up front (`Property`, `Properties`, `PropertyIdentity`, `BaselinePerformance`, `OptimisedPackage`, etc.) and the discipline of one service writing one slice. The benefit is that every service has a single job and every test injects fake repos against a small, named structure.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every service signature accepts or returns `Property` / `Properties`. Refactoring later means touching all of them.
|
||||
- `EpcPropertyData` stays a pure physical-state schema (defined in [datatypes/epc/domain/epc_property_data.py](../../datatypes/epc/domain/epc_property_data.py)) — no modelling outputs or run state on it.
|
||||
13
docs/adr/0003-strict-ingestion-modelling-separation.md
Normal file
13
docs/adr/0003-strict-ingestion-modelling-separation.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Strict separation between Ingestion and Modelling
|
||||
|
||||
Data flows one way only: **Ingestion → Repos → Modelling**. Modelling services never make external HTTP calls; Ingestion services never run business logic. If Modelling needs fresh data, it sees a stale record in a repo and returns; the caller (a refresh orchestrator or the FE) decides whether to ingest first. We considered allowing modelling services to call fetchers directly on cache miss — convenient — and rejected it.
|
||||
|
||||
The trade-off is that modelling cannot "self-heal" by going to the gov EPC API when it finds stale data. The benefit is that modelling becomes a deterministic function of repository state: same Property in the repos, same modelling output. That is the property that makes modelling unit-testable against fakes (no DB, no network, no ML lambda), reproducible, and debuggable. It also enables a per-property UI flow where fetched data is shown to the user for review and possible override **before** modelling runs.
|
||||
|
||||
Under the rushed timeline this constraint is more valuable, not less. Mixing fetchers into services is the easy thing to do when shipping fast; once it's done it's hard to extract.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every modelling service depends only on Repos (and other Services / domain logic). No HTTP libraries in the modelling import graph.
|
||||
- A `RefreshOrchestrator` is the only thing that calls Ingestion then Modelling in sequence; nothing else may.
|
||||
- "Modelling is stale, refetch in-line" is a forbidden pattern — surface staleness, do not silently repair it.
|
||||
13
docs/adr/0004-baseline-performance-lodged-effective-pair.md
Normal file
13
docs/adr/0004-baseline-performance-lodged-effective-pair.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# `BaselinePerformance` stores both lodged and effective values
|
||||
|
||||
A Property's current performance has two states we care about: the rating that was lodged on the government register (the "lodged" SAP / band / carbon / heat) and the rating produced by the modelling pipeline against the current Effective EPC (the "effective" values, which may have been rebaselined by ML when the EPC was pre-SAP10 or when Landlord Overrides / Site Notes changed physical state). We considered storing a single set of values — the rebaselined-if-needed-otherwise-lodged figures — and rejected that. Both are stored as a pair on every `BaselinePerformance`, equal when no rebaselining trigger fires.
|
||||
|
||||
The pair lets the FE show "this is what the gov register says vs this is the SAP10-equivalent we modelled against" side by side without a second query, and keeps the audit trail clean: a user looking at a property's plan can see exactly which figure drove the recommendation pipeline. Storing only one set forces a downstream consumer to recompute the missing one from raw EPC fields when it needs both, which is the kind of derivation creep we want to keep out of the FE.
|
||||
|
||||
The cost is a wider row + the discipline that **every** `BaselinePerformance` populates both halves, even when they're equal. Annual kWh, fuel split and bills are not paired — they are always derived deterministically by `EpcEnergyDerivationService` against the Effective state, because the EPC's recorded cost fields use fuel rates pinned to the inspection date and the UCL correction depends on the modelled band.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Schema migration: `property_details_epc` (or its successor) carries 8 fields instead of 4 for the SAP-equivalent block.
|
||||
- Reversing this means rewriting every consumer that has learned to read both values. Hard to roll back once the FE depends on the pair.
|
||||
- The rebaseline trigger has two reasons (`pre_sap10`, `physical_state_changed`, or `both`) — store the reason alongside so we know *why* a property was rebaselined when debugging.
|
||||
14
docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md
Normal file
14
docs/adr/0005-multi-phase-scenarios-per-phase-recompute.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Multi-phase scenarios with per-phase recompute against rolling state
|
||||
|
||||
The Scenario aggregate becomes ordered phases: each phase has a measure-type allowlist, an optional budget, and an optional goal. The `ModellingPipeline` walks the phases in order; for each phase it (1) generates candidate recommendations restricted to the phase's measure types, (2) re-runs `ImpactPredictionService` against the **rolling** Effective EPC state (baseline for phase 1; post-phase-1 for phase 2; etc.), (3) optimises within the phase's budget/goal, (4) applies the selected package and rolls the state forward. We considered scoring all measures once against the baseline and slicing the scored list by phase, and rejected that.
|
||||
|
||||
Per-phase recompute makes phase ordering load-bearing in the optimisation, not decorative. Installing fabric measures before a heat pump materially changes the heat pump's SAP impact; a single-pass-against-baseline pipeline forces that fact into the optimiser as a hard rule rather than a derived effect, and any cross-measure interaction we don't know to encode becomes silent error. The cost is ML calls scaling with `N_phases × N_scenarios × N_candidate_measures` per property — multi-phase scenarios pay their own ML bill, single-phase scenarios cost the same as today (the loop body runs once).
|
||||
|
||||
A single-phase Scenario is `phases: [<one ScenarioPhase>]` with all measure types allowed and the full budget on it. There is no special-case path for single-phase — the pipeline always loops. This avoids two code paths and lets the FE evolve from single-phase to multi-phase without rewiring the backend.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `Plan` carries `phases: list[PlanPhase]` rather than a flat `OptimisedPackage`. Every consumer of plan output (FE, exports, downstream reports) reads phases.
|
||||
- The optimiser must accept rolling-state input rather than only baseline state — a generalisation of today's single-shot pass.
|
||||
- ML cost can be controlled at the scenario layer: keeping a scenario single-phase is the lever for "score once, optimise once" if cost becomes a problem.
|
||||
- Open future change: SAP impact of a measure is not strictly additive even within a phase. The current per-measure scoring + linear optimisation approximates this. A future iteration may pre-define candidate packages and ML-score whole packages, accepting combinatorial cost for accuracy. Track in PRD §15.
|
||||
23
docs/adr/0006-deterministic-kwh-no-baseline-ml.md
Normal file
23
docs/adr/0006-deterministic-kwh-no-baseline-ml.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Baseline kWh and bills are deterministic — no ML on the kWh side
|
||||
|
||||
**Status: Superseded by [ADR-0007](0007-kwh-as-ml-target.md).** The premise here — that baseline kWh can be derived from SAP physics alone — held when the gov EPC API did not expose per-end-use kWh. The New EPC API exposes `renewable_heat_incentive.space_heating_existing_dwelling` and `.water_heating` directly, removing the need for ML on the *baseline* side; meanwhile *post-measure* kWh prediction is reintroduced as an ML target to avoid per-band UCL discontinuities at measure-application time. See ADR-0007 for the replacement design.
|
||||
|
||||
---
|
||||
|
||||
Annual kWh, fuel split, and bills are produced by `EpcEnergyDerivationService` via SAP physics + UCL per-band correction (Few et al. 2023) + per-fuel rates from `FuelRatesRepo`. There is no ML lambda on the kWh path — neither for baseline derivation nor for per-recommendation kWh impact. We considered keeping a kWh ML lambda (the current `model_engine` has two — one pre-recommendation, one post-optimisation) and rejected both.
|
||||
|
||||
The forcing facts:
|
||||
1. The new gov EPC API exposes `energy_consumption_current` (kWh/m², primary) and per-end-use cost fields for the regulated portion of energy use. The decomposition into heating / hot water / lighting that the gov website displays is computed downstream from SAP — SAP itself defines the proportional split deterministically given heating + hot water fuel codes and floor area.
|
||||
2. The EPC's recorded cost fields use fuel rates pinned to the inspection date, so we discard them and recompute bills from delivered kWh × current `FuelRatesRepo` rate + standing charges + SEG credits.
|
||||
3. The UCL correction (Few et al.) is an empirical correction on **total annual PEUI**, not on heating-vs-hot-water split — but applied per-band, post-decomposition. The existing `AnnualBillSavings.adjust_energy_to_metered` already ports the per-band gradients/intercepts from Table 3 of the paper.
|
||||
4. Per-recommendation kWh delta is derivable from the SAP delta predicted by `ImpactPredictionService` + heating-system fuel + COP — no separate ML call needed.
|
||||
|
||||
ML is reserved for SAP / carbon / heat demand — the quantities where the physical model is partial and the ML lambda earns its keep. The kWh pipeline is fully deterministic and reproducible, which makes it unit-testable against fakes without an ML lambda, and lets us refresh bills without re-running ML (a fuel-rate update or a new Defra carbon factor publishes new bill figures without touching the modelling lambdas).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The pre-recommendation kWh ML lambda (`KWH_MODEL_PREFIXES` in [model_api.py](../../backend/ml_models/api.py)) is retired — no consumer in the new pipeline.
|
||||
- `EpcEnergyDerivationService` becomes a fat deterministic service: SAP physics + UCL + FuelRates lookup + primary-to-delivered conversion. Long but readable.
|
||||
- Site Notes have no `energy_consumption_current` field (PasHub does not produce one). The deterministic SAP-physics path handles this case naturally — same code, different source of regulated PEUI.
|
||||
- UCL paper scope (gas-heated, no PV, England + Wales, SAP 2012+) is silently extrapolated to all properties by the current code. Whether to keep silent extrapolation or stratify (no correction for non-gas / PV) is flagged for the per-service grill.
|
||||
- Adding back a kWh ML lambda later is a real change, not a config tweak — flag it as an ADR if proposed.
|
||||
57
docs/adr/0007-kwh-as-ml-target.md
Normal file
57
docs/adr/0007-kwh-as-ml-target.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Space heating and hot water kWh are ML targets; UCL is folded into training labels
|
||||
|
||||
**Status: Accepted.** Supersedes [ADR-0006](0006-deterministic-kwh-no-baseline-ml.md).
|
||||
|
||||
The EPC ML Transform predicts **six targets**: `sap_score`, `co2_emissions`, `peui_raw`, `peui_ucl`, `space_heating_kwh`, `hot_water_kwh`. Two of these (`space_heating_kwh`, `hot_water_kwh`) were explicitly excluded from ML by ADR-0006. We reverse that decision for two independent reasons, the second of which was the deciding factor.
|
||||
|
||||
## Why baseline kWh becomes an ML target
|
||||
|
||||
The premise of ADR-0006 was that baseline kWh has no clean source in the gov data and must be derived deterministically from SAP physics + UCL correction. That premise no longer holds:
|
||||
|
||||
1. The New EPC API exposes `renewable_heat_incentive.space_heating_existing_dwelling` and `renewable_heat_incentive.water_heating` directly as integers (kWh/yr delivered) on every SAP10 certificate. For a SAP10-baseline property, baseline kWh is a lookup, not a derivation — no SAP-physics port required.
|
||||
2. **But** for the *Rebaselining* path (pre-SAP10 EPCs being scored against SAP10 methodology) and for *post-measure* impact prediction (the state after a measure is installed), no recorded kWh exists. The choice there is: derive deterministically (the ADR-0006 stance), or predict via ML alongside SAP / carbon / heat. Reason (2) below resolves this in favour of ML.
|
||||
|
||||
## Why UCL is folded into training labels rather than applied at runtime
|
||||
|
||||
The UCL per-band correction (Few et al. 2023) is a piecewise-linear function of PEUI keyed on EPC band. Applied at runtime, post-prediction, it produces a **discontinuity at band boundaries**: when a simulated package of measures pushes a property from band D into band C, the per-band slope/intercept switches discontinuously, and the UCL-adjusted kWh can move in the opposite direction to the underlying PEUI prediction. This was observed in practice on the legacy `model_engine`.
|
||||
|
||||
Folding UCL into the training labels — i.e. computing UCL-corrected PEUI per training row using the row's recorded band, then fitting the model on the corrected target — means the trained model emits metered-equivalent PEUI directly. There is no per-band switching at inference. The discontinuity disappears. The model learns a smooth function over the feature space.
|
||||
|
||||
The same logic motivates ML prediction of space heating and hot water kWh post-measure: deterministic derivation from a SAP-delta would reintroduce a similar band-boundary artefact at every step where heating efficiency or fuel changes. A single ML model emitting kWh directly is smooth across measure transitions.
|
||||
|
||||
## Scope of the reversal
|
||||
|
||||
| Quantity | ADR-0006 stance | ADR-0007 stance |
|
||||
|---|---|---|
|
||||
| Baseline SAP / carbon / heat demand | ML (unchanged) | ML (unchanged) |
|
||||
| Baseline PEUI (`peui_raw`) | Read from EPC; UCL-corrected at runtime | Read from EPC at baseline; ML target with UCL-corrected variant (`peui_ucl`) at training time |
|
||||
| Baseline space heating kWh | Deterministic from SAP physics + UCL | Read from EPC for SAP10 baselines; ML for Rebaselining + post-measure |
|
||||
| Baseline hot water kWh | Deterministic from SAP physics + UCL | Read from EPC for SAP10 baselines; ML for Rebaselining + post-measure |
|
||||
| Post-measure space heating kWh delta | Derived from SAP delta + heating fuel/COP | ML target (predicted directly post-measure) |
|
||||
| Post-measure hot water kWh delta | Derived from SAP delta | ML target (predicted directly post-measure) |
|
||||
| Fuel split, bills | Deterministic from kWh × Fuel Rates (unchanged) | Deterministic from kWh × Fuel Rates (unchanged) |
|
||||
| Carbon factors → CO2 emissions | Deterministic from kWh × Carbon Factors (unchanged at runtime) | Deterministic from kWh × Carbon Factors (unchanged at runtime); ML target also separately for Rebaselining |
|
||||
| UCL correction application point | Runtime, post-prediction, per band | Training time, folded into PEUI labels per row's recorded band |
|
||||
|
||||
## Dual PEUI training targets
|
||||
|
||||
We train two PEUI variants — `peui_raw` (the EPC's `energy_consumption_current` directly) and `peui_ucl` (the same value with the row's recorded-band UCL correction pre-applied). At v0.1.0 we compare both empirically. The variant with better held-out MAPE wins; the loser is dropped at v0.2.0.
|
||||
|
||||
## Label coupling, not classical leakage
|
||||
|
||||
The UCL transform uses the row's recorded SAP-derived band to compute the PEUI label, and SAP score is itself an ML target. This couples the two targets at the label level. It is **not** classical leakage (the band is not in the feature set; the model never reads it as input). The PEUI prediction is independent of the SAP prediction at inference. We accept the coupling as the price of avoiding the band-boundary discontinuity, consistent with our explicit "park target-independence" decision — the six targets are predicted independently and small cross-target inconsistencies are tolerated for v1.
|
||||
|
||||
Practical safeguard: `energy_rating_current` and any other SAP-score-derived field (e.g. `current_energy_efficiency_band`) are **excluded from the feature set** in the EPC ML Transform, to avoid an entirely separate target-leakage path on the SAP prediction.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `EpcEnergyDerivationService` is no longer the source of baseline kWh. Its remaining job is the deterministic step from kWh + Fuel Rates → fuel split + bills, and kWh + Carbon Factors → CO2 emissions. UCL is removed from its runtime path; the `AnnualBillSavings.adjust_energy_to_metered` port that ADR-0006 anticipated does not happen — UCL moves into the training-side EPC ML Transform.
|
||||
- The EPC ML Transform owns both feature definitions *and* the per-row UCL label transformation. It is the single artefact tying SAP-band semantics into the training data; cross-repo consumers (AutoGluon) see only post-transform parquet.
|
||||
- `FuelRatesRepo`, `CarbonFactorsRepo`, and `HeatingSystemAssumptionsRepo` survive but their `HeatingSystemAssumptionsRepo` consumers shrink — the SAP-physics-decomposition path that ADR-0006 envisaged is unused.
|
||||
- Adding more ML targets later (lighting kWh, appliance kWh, cooking kWh) becomes a feature-additive change rather than an architectural one — the precedent of "kWh as ML target" is now established.
|
||||
|
||||
## What this ADR does not change
|
||||
|
||||
- Per-recommendation **cost** delta is still deterministic, from kWh delta × current Fuel Rates.
|
||||
- Bills surfaced to the UI are always current-rate, never pinned to EPC inspection-date rates.
|
||||
- `EpcEnergyDerivationService` is preserved as the bills/fuel-split service; only its responsibility shrinks.
|
||||
109
docs/adr/0008-physics-as-feature.md
Normal file
109
docs/adr/0008-physics-as-feature.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
# Physics-derived features in the EPC ML Transform; v16.0.0 schema bump
|
||||
|
||||
**Status: Accepted.** Extends the physics-coupling pattern from [ADR-0007](0007-kwh-as-ml-target.md) — which folded the UCL band correction into training *labels* — to the *feature* side: the EPC ML Transform v16.0.0 ships engineered features that reproduce parts of the SAP10.2 worksheet (envelope conduction, heating seasonal efficiency, fuel-cost ECF) and feeds them to the model alongside the raw cert fields.
|
||||
|
||||
The motivating problem is that the v15.x baseline reaches MAPE 3.8% on `sap_score` and tails (SAP<40, SAP>85) carry disproportionate error. The model has access to the raw inputs that drive SAP — wall construction, age band, heating-system code, areas — but composes them into a SAP score from scratch via tree splits. We close that gap by giving the model the same intermediate quantities the SAP10.2 calculator uses internally.
|
||||
|
||||
## Why physics-as-feature is not classical leakage
|
||||
|
||||
In the Rebaselining use case (see CONTEXT.md and ADR-0007), the model approximates the SAP10.2 calculator. The labels (`sap_score`, kWh targets, CO2) are outputs of that calculator computed by approved assessors. The features include the physical inputs to it. Engineering features that reproduce the calculator's internal quantities — envelope heat loss, seasonal efficiency, predicted fuel cost, log10(ECF) — is not classical leakage because:
|
||||
|
||||
1. None of these features reads the label. They read cert fabric/heating fields the SAP10.2 calculator also reads.
|
||||
2. At inference time we have those same cert fields (from Site Notes or from the public EPC + Landlord Overrides). We do not have the SAP score itself.
|
||||
3. The physics features expose intermediate calculation results to the model so it does not have to rediscover them via tree splits. This is the feature-side analogue of the label-side coupling already accepted in ADR-0007.
|
||||
|
||||
The tautology bound is therefore the SAP10.2 worksheet itself: a feature that computes a quantity also present on the worksheet is acceptable; a feature that reads the EPC's recorded SAP score (`energy_rating_current`) is not. That latter exclusion is preserved from ADR-0007.
|
||||
|
||||
## Depth of physics: "Mid", not "Deep"
|
||||
|
||||
Three points on the spectrum were considered:
|
||||
|
||||
- **Shallow** — only the raw building-physics intermediates (envelope heat loss W/K, seasonal efficiency, predicted kWh). Model learns kWh→cost→SAP unaided.
|
||||
- **Mid** — Shallow plus the cost reconstruction (`predicted_total_fuel_cost_gbp`, `predicted_ecf`, `predicted_log10_ecf`). Model still has to apply the piecewise SAP rating transform.
|
||||
- **Deep** — Mid plus `predicted_sap_score` with the SAP10.2 §20.1 piecewise log/linear formula pre-applied. Model learns residual only.
|
||||
|
||||
We accept **Mid**. Reasons:
|
||||
|
||||
1. The piecewise SAP rating constants (`SAP = 117 − 121·log10(ECF)` if ECF≥3.5 else `100 − 13.95·ECF`, deflator 0.42) are BRE's, version-bound to SAP10.2. Baking them into a feature means a future SAP10.3 release requires re-deriving features and re-training. Baking them only into the model's learned transform keeps the data layer SAP-version-agnostic.
|
||||
2. `predicted_log10_ecf` is monotonic with `sap_score` by construction. Tree-based models fit monotonic transforms with a small number of splits. The kink at ECF=3.5 is one extra split. We give up almost nothing in accuracy.
|
||||
3. `predicted_sap_score` would clip at high-ECF properties (the log term can push SAP < 1; the formula expects a clamp). `predicted_log10_ecf` has no such pathology.
|
||||
4. We can escalate to Deep in a later slice if Mid leaves residual MAPE above target.
|
||||
|
||||
## Cost reconstruction scope: heating + DHW + lighting
|
||||
|
||||
Total cost in the SAP rating sums: space heating, DHW, lighting, pumps/fans, secondary heating, minus PV credit. We include the first three; we omit the rest:
|
||||
|
||||
- Pumps/fans and secondary heating contribute small (~2–5%) bias that is approximately constant across rows. Tree models learn a constant offset trivially.
|
||||
- PV credit requires a monthly solar simulation (Tables 6, 6d, 6e) — multi-day implementation surface. PV-heavy properties (a small fraction of the high-SAP tail) get a small per-row bias the model can mostly absorb via the PV-fabric features already in v15.x.
|
||||
- Lighting cost share varies materially by heating fuel and floor area; omitting it would create a fuel-mix-conditional bias that is harder to learn. So lighting goes in.
|
||||
|
||||
If a future slice (17+) shows the high-SAP tail still bad after Mid + Lighting lands, the PV monthly simulation gets its own slice.
|
||||
|
||||
## Heat-demand approximation: crude annual
|
||||
|
||||
`predicted_space_heating_kwh` and `predicted_hot_water_kwh` are computed as:
|
||||
|
||||
- `space ≈ envelope_heat_loss_w_per_k × HDH_region × 0.001 / efficiency_main`, where `HDH_region` is heating degree hours per year per SAP region (~22 rows, ~53,000 K·h/yr for the UK average).
|
||||
- `hot_water ≈ 4.18 × Vd × (55 − 12) × 365 × 0.001 / efficiency_water`, with `Vd = 25 × N_occupants + 36` and `N_occupants` defaulted from total floor area per SAP10.2 Appendix J.
|
||||
|
||||
We deliberately do not port SAP10.2's monthly heat balance with solar/internal gains and utilization factors. The crude calculation has 10–30% per-row bias driven by row-correlated factors (solar gains, infiltration, occupancy). The model already sees those factors directly — envelope_heat_loss, region, occupancy proxies — so it can learn the bias as a band-conditional correction without re-deriving the underlying physics. If slice 16h's per-decile residuals (see ADR-0007 baseline + slice 15e tooling) show the crude approximation underperforming, the SAP §3 utilization-factor refinement gets its own slice.
|
||||
|
||||
## Default U-value imputation: cascade
|
||||
|
||||
U-value lookups (Tables 6–10 walls, 16/17/18 roofs, 19+EN ISO 13370 floors, 20 upper floors, 24 windows, 26 doors, 21 thermal-bridging factor) are wrapped in helpers that cascade-default missing fields the same way RdSAP10 §6 does:
|
||||
|
||||
1. Use the cert value if known.
|
||||
2. Fall back to the age-band-typical construction (e.g. cavity for ≥1930, solid brick for pre-1930).
|
||||
3. Fall back to country-typical.
|
||||
4. Final fallback: a mid-band default (1.5 W/m²K for walls).
|
||||
|
||||
`envelope_heat_loss_w_per_k` is therefore never null. The information about "this row had sparse fabric data" is already encoded in the correlated null pattern on the raw fabric features that survive into v16.
|
||||
|
||||
## Extensions: sum-over-all, expose extension_1 only
|
||||
|
||||
`envelope_heat_loss_w_per_k` sums over the main dwelling and every extension (`extension_1`, `extension_2`, `extension_3+`) regardless of how many are present, using each part's own age band and construction. The 250k corpus has:
|
||||
|
||||
| Building parts | Share | Per-extension feature support |
|
||||
|---|---|---|
|
||||
| 1 (main only) | 63.0% | — |
|
||||
| 2 (main + extension_1) | 25.3% | `extension_1_*` populated |
|
||||
| 3+ | 11.7% | aggregate captures, no per-part visibility |
|
||||
|
||||
So `extension_1_*` (renamed from v15.x `secondary_dwelling_*`) fires on 37% of certs and is worth carrying as discrete features. `extension_2_*` would fire on only 11.7% and adds clutter; we drop it. Any heat-loss contribution from extension_2+ flows through the `envelope_heat_loss_w_per_k` aggregate.
|
||||
|
||||
## v16.0.0: a MAJOR feature-schema bump
|
||||
|
||||
Per [ADR-0007](0007-kwh-as-ml-target.md) versioning policy: removing or renaming columns is MAJOR. Slice 16f renames every `secondary_dwelling_*` column to `extension_1_*`. The new physics features (envelope_heat_loss, predicted_*, predicted_ecf, predicted_log10_ecf, etc.) are MINOR additions on their own but ride with the rename in one cut. Result: v15.x → v16.0.0.
|
||||
|
||||
### Cross-repo cutover
|
||||
|
||||
The scoring lambda's tag must match the transform version. The AutoGluon training repo references the v15.x parquet schema. v16.0.0 lands as a coordinated deploy:
|
||||
|
||||
1. Slice 16a–h ships in this repo; v16 parquet generated locally.
|
||||
2. AutoGluon repo updates column references (`secondary_dwelling_*` → `extension_1_*`; consume new physics columns).
|
||||
3. New model artifact tagged v16.0.0.
|
||||
4. Scoring lambda deployed with v16.0.0 tag concurrent with the new artifact.
|
||||
5. v15 lambda retired.
|
||||
|
||||
Until step 4, the live v15 lambda continues serving v15 features against the v15 model. There is no intermediate state where one component is v16 and another v15.
|
||||
|
||||
## Tail-error treatment: LightGBM objective switch, not sample weights
|
||||
|
||||
Slice 16g switches the `sap_score` and `peui_ucl` LightGBM objective from the default `regression` (MSE) to `mape`. The reasoning is that the v15.x training loop reports MAPE while optimising MSE — a known mismatch that under-weights tail rows (a 2-point error at SAP=20 contributes the same squared loss as the same error at SAP=80 but is 4× more visible in MAPE). The `mape` objective applies gradient ∝ 1/|y|, directly compensating.
|
||||
|
||||
Sample-weight schemes (band-bucket reweighting) are deferred. If slice 16h's per-decile residuals show the tails still problematic after the objective switch, weights layer in as 16i. The `co2_emissions` target retains the MSE default because some rows have ~zero CO2 (heavy PV); the `mape` objective destabilises near zero. Per-target objective is configured at training time, not baked into the transform.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The EPC ML Transform owns more domain logic. It now contains the RdSAP10 U-value tables (Tables 6–10, 15–20, 24, 26), the SAP10.2 efficiency lookup (Table 4a), and the Table 32 fuel-price map. These are versioned with the transform; an upstream SAP/RdSAP revision is a transform bump.
|
||||
- The training repo (this repo) and the AutoGluon repo are tightly coupled at parquet column names. Renames are MAJOR bumps with the cutover discipline above. Adding columns is MINOR.
|
||||
- `predicted_log10_ecf` is approximately monotonic with `sap_score` by construction. Down-stream consumers should not treat it as an independent signal.
|
||||
- The physics features are deterministic given cert fields. If two rows have identical fabric+heating+geometry, their `envelope_heat_loss_w_per_k`, `predicted_total_fuel_cost_gbp`, and `predicted_log10_ecf` are identical. The model's residual must therefore explain SAP differences arising from non-deterministic cert calculator nuance (assessor variability, rounding, solar/utilization factors we did not port).
|
||||
- A SAP10.3 release would invalidate the SAP10.2 fuel prices, efficiencies, and rating-formula constants used here. Treat such a release as a transform MAJOR bump with new lookup tables, not a hot-fix.
|
||||
|
||||
## What this ADR does not change
|
||||
|
||||
- The set of ML targets remains the six from ADR-0007: `sap_score`, `co2_emissions`, `peui_raw`, `peui_ucl`, `space_heating_kwh`, `hot_water_kwh`. The new features ride alongside the existing v15 features; nothing in the target set moves.
|
||||
- `energy_rating_current` and any SAP-band-derived field remain excluded from features per ADR-0007.
|
||||
- The `EpcEnergyDerivationService` runtime path is unaffected. Bills and fuel splits remain deterministic from kWh × current Fuel Rates.
|
||||
- The 250k 2025+2026 SAP10 RdSAP corpus continues to be the training set; v16 is a column-schema change, not a data-source change.
|
||||
153
docs/adr/0009-deterministic-sap-calculator.md
Normal file
153
docs/adr/0009-deterministic-sap-calculator.md
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Deterministic SAP 10.3 calculator alongside the ML model; ML becomes a residual learner
|
||||
|
||||
**Status: Accepted.** Builds on [ADR-0007](0007-kwh-as-ml-target.md) (the SAP10 calculator is the ground truth ML approximates) and [ADR-0008](0008-physics-as-feature.md) (we already ship ~30% of a calculator as physics features). Decision point: do we keep grinding ML accuracy on `sap_score`, or do we *write the calculator* and have ML predict its residual?
|
||||
|
||||
## Grill outcomes (2026-05-17)
|
||||
|
||||
Seven open questions resolved through a `/grill-with-docs` session before Session A. Each lands a binding scope decision for the implementation:
|
||||
|
||||
| # | 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. |
|
||||
| 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. |
|
||||
| 5 | Secondary-heating allocation | **SAP 10.2/10.3 Table 11** keyed on main heating type. RdSAP doesn't redefine the fraction — it identifies the type only. Forcing rule: when main is micro-CHP and Table N9 says non-zero secondary heat with no secondary specified, assume portable electric heaters. |
|
||||
| 6 | Validation cohort | **Stratified random of 1000 certs**; report MAE per stratum. Session A success criterion = MAE ≤ 1.0 SAP-point on the **typical subset** (excluding sap_score ≤ 5, sap_score ≥ 100, multi-heating, conservatory, RIR). Global MAE reported alongside for honesty. |
|
||||
| 7 | `MeasureOverrides` shape | **Rejected as phantom mid-layer.** `Sap10Calculator.calculate(epc) -> SapResult` takes a single immutable cert. A separate **MeasureApplicator** service translates Optimised Package → cert-field changes, returning the "ending state snapshot" EpcPropertyData that Plan Phase already persists. Three pure functions in chain: applicator → calculator → result. |
|
||||
|
||||
## 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 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.
|
||||
|
||||
## Problem
|
||||
|
||||
After six slices of physics-feature work (18b/18c/18d/20a/20a.1) the ML model is at sap_score MAPE 3.63%, MAE 1.86 globally; per-decile MAE 3.86 (d0) and 2.25 (d9). Each new slice now nudges d0 MAE by ~0.05. User's target is MAE ≤ 0.5 across all bands. The remaining error is dominated by:
|
||||
|
||||
1. **Catastrophic tail noise** — d0 has 3.3% of rows with `sap_score ≤ 20` (heritage / abandoned / data-anomaly homes). MAE on those rows is structurally large because the model's prediction floor is ~30 even for the worst inputs.
|
||||
2. **Calculator nuance the physics features can't reach** — monthly heat balance with solar/internal gains and utilisation factor, full SAP §J hot-water variants, PCDB heat-pump overrides, dual-fuel allocation, conservatory modes, room-in-roof handling. Each of these is a deterministic line in the SAP10.3 spec but we model it via tree splits over input fields.
|
||||
|
||||
These cannot be closed by another tree feature. They require executing the calculator.
|
||||
|
||||
## 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/`.
|
||||
|
||||
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.
|
||||
|
||||
## Why now
|
||||
|
||||
1. **SAP 10.3 just dropped (Jan 2026).** Building against the new spec means the calculator outputs match assessor software for any cert lodged from 2026 onward. Building against SAP 10.2 (March 2025) now would need re-derivation later.
|
||||
2. **The retrofit-simulation use case demands transparency.** Surveyors, building physicists, and homeowners need to see exactly which physics line — wall U×A, ventilation ACH, solar gain on south-facing windows — contributes how much heat-loss/cost. Tree-model attribution doesn't supply that. Calculator does.
|
||||
3. **30% of the calculator is already shipped.** `rdsap_uvalues.py` (Tables 6–10, 15–20, 24, 26), `sap_efficiencies.py` (Tables 4a, 4b, 32), `envelope.py` (Σ U·A + thermal bridging), partial `ventilation.py` (slice 20a tracer), partial `demand.py` (annual heat balance), `ecf.py` (Total fuel cost, ECF, log10ECF), PV credit (slice 17a), SAP §J hot-water port (slice 17b). The pivot is mostly re-platforming, not new physics.
|
||||
4. **ML residual learning has a clean home for the noise.** The catastrophic-tail rows the calculator gets wrong (data anomalies, mis-described systems) are exactly where ML *should* live, because they're not closed-form solvable. Calculator + residual head is a cleaner split of responsibility than "ML approximates the deterministic spec".
|
||||
|
||||
## Scope of the calculator (Session A)
|
||||
|
||||
A full SAP 10.3 worksheet plus the data-extraction rules from RdSAP 10 Appendix S. Module organisation:
|
||||
|
||||
```
|
||||
domain/sap10_calculator/
|
||||
__init__.py # Sap10Calculator entry point + SapResult dataclass
|
||||
worksheet/
|
||||
dimensions.py # §1
|
||||
ventilation.py # §2 + Table 5 + Appendix Q
|
||||
heat_transmission.py # §3 + Appendix K (thermal bridging) + Tables 6–10/15–20/24/26
|
||||
hot_water.py # §4 + Appendix J + Appendix G (FGHRS/WWHRS/PV-diverters)
|
||||
internal_gains.py # §5 + Appendix L (lighting)
|
||||
solar_gains.py # §6 + Tables 6d/6e
|
||||
mean_temperature.py # §7
|
||||
climate.py # §8 + Appendix U (region-from-postcode, monthly external temp/wind/solar)
|
||||
space_heating.py # §9 + Appendices A/B/D/E/N (heating systems, efficiency, heat pumps)
|
||||
fuel_cost.py # §12 + Table 32 (fuel prices) + Appendix M (PV/wind/hydro generation)
|
||||
energy_cost_rating.py # §13 + the SAP score formula
|
||||
co2_primary_energy.py # §14 (emissions + primary energy)
|
||||
fee.py # §11 Fabric Energy Efficiency
|
||||
tables/
|
||||
table_4a_4b.py # heating-system seasonal efficiency
|
||||
table_5.py # ventilation rate components
|
||||
table_6.py # monthly external temp by region
|
||||
table_6d.py # monthly solar flux by orientation by region
|
||||
table_32.py # fuel prices
|
||||
table_R.py # reference values (Appendix R)
|
||||
rdsap/
|
||||
appendix_s.py # cert → calculator input mapping
|
||||
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.
|
||||
|
||||
## Sap10Calculator interface
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class SapResult:
|
||||
sap_score: float
|
||||
energy_cost_rating: float # alias for sap_score before band lookup
|
||||
sap_band: str # A-G
|
||||
co2_emissions_kgco2_per_m2: float
|
||||
peui_raw_kwh_per_m2: float
|
||||
peui_ucl_kwh_per_m2: float
|
||||
space_heating_kwh_per_yr: float
|
||||
hot_water_kwh_per_yr: float
|
||||
monthly_breakdown: MonthlyBreakdown
|
||||
intermediate: dict[str, float] # every named worksheet quantity, for traceability
|
||||
|
||||
class Sap10Calculator:
|
||||
def __init__(self, climate: ClimateData, pcdb: Optional[PcdbLookup] = None) -> None: ...
|
||||
def calculate(self, epc: EpcPropertyData) -> SapResult: ...
|
||||
```
|
||||
|
||||
`intermediate` carries every named SAP10.3 worksheet variable (envelope conduction W/K, ventilation rate, solar gains by month, utilisation factor, heat-pump SCOP, ECF, ...) so consumers can drill down. This replaces ADR-0008's physics-as-feature columns for retrofit-simulation consumers; the ML pipeline keeps generating them as features until the residual head is trained and validated.
|
||||
|
||||
## Validation
|
||||
|
||||
Two corpora:
|
||||
|
||||
1. **Calculator-vs-cert parity (Session B).** Run the calculator over 1000 randomly-sampled RdSAP-10 certs from `data/ml_training/runs/2025_2026_n250000_v18a/data.parquet`. Compare `Sap10Calculator.calculate(epc).sap_score` to the cert's `energy_rating_current`. Target: MAE ≤ 1.0 on 95% of certs; outliers investigated case-by-case to find spec-interpretation gaps or PCDB requirements.
|
||||
2. **Residual ML head (Session C+).** Train LightGBM on `actual_sap − calculator_sap` as the target. Validate that residual MAE is materially smaller than the current 1.86 global / 3.86 d0. If residual MAE on d0 falls below 0.5, the calculator + residual approach hits the user's target.
|
||||
|
||||
We do **not** retire the existing ML pipeline until both validations pass.
|
||||
|
||||
## What this ADR does *not* change
|
||||
|
||||
- **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`.
|
||||
- **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
|
||||
|
||||
The newer spec replaces tables we already ship:
|
||||
|
||||
- Table 4a/4b (heating efficiencies) — likely identical, verify on read.
|
||||
- Table 32 (fuel prices) — almost certainly different, re-derive from Appendix in 10.3.
|
||||
- Table 6d (solar flux) — likely identical (climate data).
|
||||
- Energy cost rating formula constants — unchanged in 10.3 vs 10.2 unless DESNZ updated the deflator.
|
||||
|
||||
Re-derivation work is bounded — a few hundred numbers across tables — and the `*_table_*.py` modules already have a clean shape for the cutover.
|
||||
|
||||
## Session plan (carried from HANDOFF §High-value next slices)
|
||||
|
||||
- **Session A (3–4 hrs):** Implement ventilation per §2 (replacing the slice-20a tracer), 12-month heat balance per §6 + §8 + Appendix U, solar gains per §6 + Table 6d, internal gains per §5 + Appendix L, utilisation factor per §6.4, mean internal temperature per §7. End of Session A: `Sap10Calculator.calculate(epc) -> SapResult` runs on typical certs.
|
||||
- **Session B (3–4 hrs):** Edge cases — conservatory modes, room-in-roof handling, multi-heating allocation, dual fuel, secondary heating fraction (Appendix A). Run parity validation across 1000 certs. Iterate on spec-interpretation gaps. End of Session B: 95% of typical certs within 1 SAP-point of cert value.
|
||||
- **Session C (2–3 hrs):** PCDB integration for boiler + heat-pump overrides (Appendices D, N). Residual-head training on `actual_sap − calculator_sap`. ADR-0010 if any non-trivial calculator/ML hybrid pattern emerges that ADR-0009 didn't anticipate.
|
||||
|
||||
## Caveats
|
||||
|
||||
- **Spec interpretation will need product input.** 5–10 questions per session on edge cases: multi-heating split logic, secondary heating threshold rules, PCDB-vs-Table-4b precedence, etc. These are not in the spec text and are real business decisions.
|
||||
- **No reference BRE Python port is currently known.** If one surfaces, porting accelerates. If not, every line of the calculator is implemented from the spec PDF directly, with tests.
|
||||
- **PCDB (Product Characteristics Database).** SAP 10.3 references the PCDB throughout for boiler/HP efficiency overrides. Without PCDB integration, calculator carries ~1 SAP-point penalty on PCDB-listed equipment. Defer to Session C.
|
||||
- **The current ML pipeline keeps running through all three sessions.** No deprecation until residual validation lands. The branch `ara-backend-design-prd` (current ML grind) and the calculator work proceed in parallel.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
154
docs/adr/0010-sap10-calculator-spec-target-and-validation.md
Normal file
154
docs/adr/0010-sap10-calculator-spec-target-and-validation.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# Retarget Sap10Calculator to SAP 10.2 (14-03-2025); delete cert-calibration; validate on a spec-version-locked cohort
|
||||
|
||||
**Status: Accepted.** Supersedes the spec-version target, the PCDB sequencing, and the cert-calibration layer of [ADR-0009](0009-deterministic-sap-calculator.md). Adds strict typing of `EpcPropertyData` (P6) and a worksheet-faithful structural principle for the `domain/sap/worksheet/*` modules — both new concerns ADR-0009 didn't address. All other ADR-0009 decisions stand (Calculated SAP10 Performance as a glossary term, MeasureApplicator/Sap10Calculator chain, MCS boolean default-false, global thermal-bridging y factor, Table 27 living-area fraction, Table 11 secondary-heating allocation, MeasureOverrides rejection).
|
||||
|
||||
## 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 ~10–25 % 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.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Active spec target is **SAP 10.2 (14-03-2025)**, not SAP 10.3
|
||||
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
### 2. `table_12_cert_calibration` is deleted
|
||||
|
||||
The cert-calibration table is bug-masking. Its prices are pre-March-2025 SAP values fit against the average cert in a mixed-version corpus, with downstream-component bugs absorbed into the fit. Removing it forces upstream errors to surface where they live, in the component that owns them, instead of being silently compensated for by a price tweak.
|
||||
|
||||
This includes the `cert_calibration_e7_codes` extension that routes codes 191–196 (direct-electric) and 691–696 (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.
|
||||
|
||||
### 3. Validation Cohort is filtered to a single spec-version window
|
||||
|
||||
Probe MAE against the full 250k-cert corpus measures both calculator correctness *and* the spec-version drift across certs lodged at different times. Without separating them, every spec-correctness improvement is noisy.
|
||||
|
||||
The **Validation Cohort** is the subset of corpus certs with `inspection_date ≥ 2025-07-01` — chosen to allow ~4 months past the 14-March-2025 SAP 10.2 amendment for commercial assessor software to roll out the new tables. Filtering to this cohort yields a probe where every cert was lodged on the same spec version the calculator targets. MAE on the Validation Cohort is the only metric used for spec-sweep go/no-go.
|
||||
|
||||
This requires re-extracting the training parquet to include `inspection_date` (currently dropped by the ETL — 202 columns, none of them dates). That extraction is a prerequisite slice.
|
||||
|
||||
### 4. PCDB integration is promoted from Session C to a prerequisite
|
||||
|
||||
ADR-0009 deferred PCDB to Session C and shipped a `NoOpPcdbLookup` stub. The handover's own measurements show PCDB absence accounts for ~19 SAP points of MAE on heat-pump certs (Table 4a fallback SCOP 2.30 vs typical PCDB 2.80–3.50) and most per-cert variance on the 78 % of gas-boiler certs lodging `main_heating_data_source=1` (category-default 0.80 vs typical PCDB 0.88–0.94). The handover's rationale for deferral ("cert-cal absorbs PCDB gaps") collapses with decision (2).
|
||||
|
||||
PCDB lookup against `main_heating_index_number` is built before the section-by-section sweep starts. Data source: https://www.ncm-pcdb.org.uk — CSV exports of boilers and heat pumps. Per-product fields needed: seasonal efficiency, secondary efficiency, output kW, flow-temperature curve (heat pumps). The `NoOpPcdbLookup` seam from ADR-0009 grill outcome #1 is the integration point; the stub returns None and the calculator falls back to Table 4a only when the cert lodges no `main_heating_index_number` or the PCDB has no matching record.
|
||||
|
||||
## Verification infrastructure (also prerequisites)
|
||||
|
||||
Three pieces of infrastructure are built before the section sweep so per-section verification has unambiguous signal:
|
||||
|
||||
1. **Trace mode populated.** ADR-0009 specced `SapResult.intermediate: dict[str, float]` and it was never built. Every named SAP 10.2 worksheet variable (heat transfer coefficient, mean internal temperature, monthly solar gains, utilisation factor, ECF, etc.) is exposed on `intermediate` so any single cert can be diffed against a hand-computed value, a BRE worked example, or a future Elmhurst reference trace.
|
||||
2. **BRE worked-example unit tests.** SAP 10.2 spec appendices and RdSAP 10 worked examples are transcribed as fixtures keyed on per-intermediate expected values, not aggregate SAP score. These replace the 7 cert-based golden fixtures (which contained compensating errors per the handover §10). The cert fixtures are retired.
|
||||
3. **Strict typing of `EpcPropertyData` via canonical domain enums.** Bare `str` and `Union[int, str]` fields (the latter because the gov API gives ints and Site Notes give strings) cascade defensive type-handling into every consumer — the calculator's `dimensions.py:74-82` is Khalim's documented example. The domain holds one canonical enum per field, derived from `datatypes/epc/domain/epc_codes.csv` (union of keys across schema versions, hand-authored). The API mapper and Site Notes mapper each adapt their raw input to the canonical enum. Repo-wide test compatibility is a hard constraint — every consumer of `EpcPropertyData` (calculator, ML pipeline, recommendations, ETL) continues working after the typing pass. Pyright `strict` mode stays clean.
|
||||
|
||||
These map to prerequisites P5 (trace mode + BRE fixtures) and P6 (strict typing) in the handover §2.5.
|
||||
|
||||
## Worksheet-faithful structure (sweep-time principle)
|
||||
|
||||
Each `domain/sap/worksheet/*.py` module must mirror the SAP 10.2 worksheet structure for its section — function names reference their worksheet-line origin (e.g. `heat_transfer_coefficient` aligns with worksheet line (40)), compound calculations split into one function per line where possible, defensive type-handling replaced by typed-enum dispatch. This is not a prerequisite slice; the refactor lands as part of each section's sweep slice, verified by the BRE worked examples (which assert per-intermediate values).
|
||||
|
||||
## Consequences
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- **Build versioned Table 12 (pre/post 14-March-2025) keyed on `inspection_date` and validate across the full corpus.** Rejected as more work for no signal benefit during the spec sweep — the filtered cohort gets us to a clean probe faster. A versioned table is still future work if Calculated SAP10 Performance ever needs to reproduce historical cert SAP for products that compare against Lodged Performance directly.
|
||||
- **Keep cert-cal during the sweep and re-derive at the end** (the handover's prescription). Rejected for the reasons in decision (2): the cert-cal layer corrupts the signal during the sweep, which is precisely when the signal needs to be cleanest.
|
||||
- **Pay for an Elmhurst license, lock fixtures to its output.** Held in reserve. BRE worked examples are free and spec-derived; an Elmhurst trace would add value as a per-component reference but is not a prerequisite.
|
||||
|
||||
## Amendment — §10a Fuel costs (2026-05-21)
|
||||
|
||||
Decision 1's "active spec target is SAP 10.2 (14-03-2025)" is narrowed for the §10a Fuel-costs block: **cost prices for §10a and §10b are sourced from RdSAP10 Table 32 (PDF page 95)**, not SAP 10.2 Table 12. RdSAP10 §19.1 is explicit: *"The SAP rating for RdSAP 10 is to be calculated using Table 32 prices (not Table 12) for section 10a and 10b."*
|
||||
|
||||
CO2 emission factors and primary-energy factors remain SAP 10.2 Table 12 per RdSAP10 §19.2 (the values are identical across the two tables; the columns are duplicated in Table 32 for completeness but Table 12 is the canonical authoritative source the calculator continues to import).
|
||||
|
||||
### Why the amendment exists
|
||||
|
||||
The §10a slice 1+2 rewrite (commits `0f255165`, `adfa7f60` on branch `ara-backend-design-prd`) surfaced two structural bugs that the pre-amendment Table-12-only path was masking:
|
||||
|
||||
1. **Wrong table.** Table 12 unit prices were 5–55% off Table 32 per carrier (mains gas 3.64 vs 3.48, heating oil 4.94 vs 7.64, std electricity 16.49 vs 13.19, off-peak 9.40 vs 5.50, PV export 5.59 vs 13.19). Table 32 is what cert assessor software computes against; comparing our Table-12-driven SAP scores against PDF references was an apples-to-oranges check.
|
||||
2. **Missing (251) standing charges.** Table 12 note (a) (and the identical Table 32 note (a)) gates additional standing charges into the SAP-rating ECF: gas standing added when gas is used for space/water heating; off-peak electricity standing added when an off-peak meter is in use; standard-electricity standing always omitted. Pre-amendment the calculator applied zero standing charges — equivalent to ignoring £92–£120/yr per gas-heated dwelling.
|
||||
|
||||
The 000490 Elmhurst fixture had a recorded -12.5% cost gap (£706 vs £807 PDF) that ADR-0010 §3 Validation Cohort framing attributed to "pre-amendment spec-version drift". The §10a rewrite shows the gap was wrong-table + missing-standing-charges — a real calculator regression, not corpus drift. Post-§10a 000490 closes to within ~4% of PDF cost and SAP rating ceiling tightens 6 → 2.
|
||||
|
||||
### 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.
|
||||
- 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).
|
||||
|
||||
### Deferred work (named in §10a slice 3)
|
||||
|
||||
- §4 HW worksheet tightening + Appendix L lighting predictor — **next ticket**.
|
||||
- Table 12a high-rate-fraction wiring for off-peak electric mains (`Table12aSystem` cert→row mapping). Currently the cert→precompute path returns a zero `FuelCostResult` sentinel for off-peak certs, deferring to the legacy scalar `_*_fuel_cost_gbp_per_kwh` heuristic.
|
||||
- Table 13 immersion / HP-DHW WH high-rate fractions.
|
||||
- Off-peak per-row (230a)..(230g) Table 12a split for pumps/fans (spec line 8076).
|
||||
- (247a) Instant electric shower kWh routing.
|
||||
- (252) per-row Appendix M/N split (PV / wind / hydro / micro-CHP) — currently single `pv_credit_gbp` scalar.
|
||||
- (253)/(254) Appendix Q routes.
|
||||
- Drop the legacy scalar `space_heating_fuel_cost_gbp_per_kwh` / `hot_water_fuel_cost_gbp_per_kwh` / `other_fuel_cost_gbp_per_kwh` / `secondary_heating_fuel_cost_gbp_per_kwh` / `pv_export_credit_gbp_per_kwh` fields from `CalculatorInputs` once the ~33-occurrence synthetic-test corpus migrates to `fuel_cost=...`.
|
||||
|
||||
## Amendment — Appendix L lighting (2026-05-22)
|
||||
|
||||
The cost-side `inputs.lighting_kwh_per_yr` is sourced from the spec-faithful Appendix L L1-L11 cascade (via `InternalGainsResult.lighting_kwh_per_yr`), **not** from the legacy `predicted_lighting_kwh` heuristic. Replaces the `9.3 × TFA × (1 − bulb-share-reduction)` linear approximation with the same cascade that drives §5 (67) gains, so the cost side and the gains side share one source of truth.
|
||||
|
||||
### Why the amendment exists
|
||||
|
||||
The Appendix L cascade was already implemented spec-faithfully for the §5 internal-gains side (validated across all 6 Elmhurst fixtures at ≤0.6% on LINE_67 monthly W tuples), but `cert_to_inputs` populated the cost-side `inputs.lighting_kwh_per_yr` from a separate heuristic that over-counted ~3× on the Elmhurst cohort (528 vs 140 kWh on 000474). The +9.2% total fuel cost residual on 000474 was dominated by this single component.
|
||||
|
||||
Two engine bugs surfaced during the wire-up:
|
||||
|
||||
1. **Cosine modulation integral.** The L1-L9 formula yields a "continuous" annual `E_L`. The SAP10.2 worksheet at line (232) lodges `Σ(L11 monthly distribution)`, which differs from the continuous formula by the discrete integration factor `Σ(n_m × [1 + 0.5cos(2π(m − 0.2)/12)]) / 365 = 0.998539`. Pre-fix `annual_lighting_kwh` returned the continuous value → uniform +0.146% bias across all 6 fixtures. Post-fix sums the monthly distribution directly.
|
||||
2. **Cert EPC under-lodgement.** `_w000474.build_epc()` + `_w000490.build_epc()` did not pass `low_energy_fixed_lighting_bulbs_count` or `sap_windows` to `make_minimal_sap10_epc`. The §5 LINE_67 fixture conformance tests poke these at the test level, but the e2e `Sap10Calculator().calculate(epc)` path bypasses that. Without them, the cascade fell through to L5b (185 × TFA lm) + L8c (21.3 lm/W) + `C_daylight = 1.433` no-bonus — producing ~317 kWh on 000474 instead of 139.9452. Fixed by passing the existing fixture constants (`SECTION_5_BULB_COUNT_LEL` + `SECTION_6_VERTICAL_WINDOWS`) through.
|
||||
|
||||
### Consequences
|
||||
|
||||
- **000474 e2e SAP integer closes to delta=0** (62 = PDF 62; continuous 62.1664 vs 62.2584, Δ 0.09). First Elmhurst fixture to hit the rdsap engine integration gate. Test ceilings tightened 3 → 0 (integer) and 3.5 → 0.5 (continuous).
|
||||
- **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.
|
||||
|
||||
### 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.
|
||||
- **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)
|
||||
|
||||
The post-Appendix-L 000490 residual (SAP delta +6, cost -£104) closed in four micro-cycles after a per-component diagnostic walk down the spec cascade. Five engine pieces landed end-to-end:
|
||||
|
||||
1. **Secondary heating cascade** (`607e52a3`): cert lodges SAP code 691 (Electricity Electric Panel, 100% efficiency); build_epc wasn't passing it through. Closes -£104 on 000490.
|
||||
2. **Ventilation cert lodgement** (`af6fcfb1`): `SapVentilation` schema gains 4 new fields (`sheltered_sides`, `has_suspended_timber_floor`, `suspended_timber_floor_sealed`, `has_draught_lobby`). `cert_to_inputs` now reads them. Removes a long-standing `sheltered_sides=2` hardcode + 4 TODOs. All 6 fixtures' (25)m monthly effective ACH closes to U985 PDF at abs=1e-3 (72 assertions).
|
||||
3. **Table 4f gas-combi pumps_fans** (`b536b46a`): keyed by `main_heating_category`. Category 2 (gas boilers) → 115 kWh pump + 45 kWh flue fan = 160 kWh/yr. Other categories still on the legacy 130 sentinel.
|
||||
4. **SAP 10.2 rating constants** (`a41ac6bd`): `worksheet/rating.py` was using SAP 10.3 constants (deflator 0.36, slope 16.21/120.5). Per ADR-0010 §1 active spec target IS SAP 10.2 (14-03-2025). Restored SAP 10.2 values: **deflator 0.42**, linear branch slope **13.95**, log branch intercept **117**, log slope **121**. The two errors were near-cancelling for the Elmhurst combi-gas cohort (low-cost dwellings on the linear branch).
|
||||
5. **000477 build_epc lodgement (partial — Table 3c blocker)** (`960419a9`): mirrors the Appendix L slice 2 fix on 000477 (lodge windows + bulbs + PCDB index + secondary 691 + number_baths=0). Closes 000477 SAP delta from +6 to +1. Remaining +1 blocked by Table 3c (next ticket).
|
||||
|
||||
### Consequences
|
||||
|
||||
- **000474 + 000490 both hit SAP integer delta=0**. First two Elmhurst fixtures across the rdsap engine integration gate. 685 tests pass + 1 xfail (000477 pending Table 3c).
|
||||
- **Per-component pins now landed**: lighting kWh, monthly infiltration ACH, secondary heating fuel, pumps_fans, plus the pre-existing §4 HW + §5 + §6 + §7 + §8 + §10a sections.
|
||||
- 000477 cost residual -3.5% remaining is the Table 3c 600-kWh-overshoot on combi-loss.
|
||||
- 000480/000487/000516 still at SAP delta +11/+12 because their build_epc lodgement is also incomplete (mirror the 000477 fix). Their PCDB records (16839/18119/18118) also have `separate_dhw_tests=2` for sustain models → Table 3c blocker.
|
||||
|
||||
### Deferred work (named in cohort slice 5)
|
||||
|
||||
- **Table 3c two-profile combi-loss override** — Next ticket. SAP10.2 Appendix J §J3. Blocks 000477/000480/000487/000516 closure.
|
||||
- **Build_epc lodgement on 000480/000487/000516** — Same pattern as 000477 (windows + bulbs + PCDB index + secondary 691 + number_baths). Lands with the Table 3c ticket since SAP closure requires both.
|
||||
- **RdSAP API integration test** — End-state validation gate. User generating exotic fixtures to pressure-test first.
|
||||
- **§12a CO2 + §13a PE per-component pins** — Engine produces `result.co2_kg_per_yr` and `result.primary_energy_kwh_per_m2`. Not yet validated against U985 (272) + (282) for any fixture.
|
||||
- **PCDF field-position audit**: parser reads F2 from fields[55]. PCDB 18118 raw row has 13.729 at index 52 — unclear which field that maps to per BRE PCDF Spec §7.11. Verify before assuming F2=0 is the lodged value.
|
||||
143
domain/sap10_calculator/README.md
Normal file
143
domain/sap10_calculator/README.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# SAP calculation domain
|
||||
|
||||
Per-section worksheet calculators for SAP 10.2 / RdSAP 10. Each file mirrors a numbered section of the spec; tests live alongside under `worksheet/tests/` and `tests/`.
|
||||
|
||||
```
|
||||
sap/
|
||||
├── calculator.py # top-level orchestrator → SapResult
|
||||
├── worksheet/
|
||||
│ ├── dimensions.py # §1 Overall dwelling dimensions
|
||||
│ ├── ventilation.py # §2 Ventilation rate (+ RdSAP10 §4.1)
|
||||
│ ├── heat_transmission.py # §3 Heat losses & HLP
|
||||
│ ├── ... # §4 onward
|
||||
│ └── tests/
|
||||
│ ├── _xlsx_loader.py
|
||||
│ ├── _elmhurst_fixtures.py # registry of Elmhurst conformance fixtures
|
||||
│ ├── _elmhurst_worksheet_NNNNNN.py # one per worksheet pair
|
||||
│ └── test_*.py
|
||||
├── rdsap/ # cert → SapInputs cascade (RdSAP10 §5)
|
||||
└── 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`.
|
||||
|
||||
**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`.
|
||||
|
||||
## Adding a new Elmhurst conformance fixture
|
||||
|
||||
Each Elmhurst fixture is a real-cert ground-truth: we encode the cert as `EpcPropertyData`, then assert our §1/§2/§3 output matches the lodged worksheet line-by-line. The fixtures act as a regression net for every cert-shape variation (RR, extension, party-wall code, sheltered sides, …) we've seen in the wild.
|
||||
|
||||
### Input: one PDF pair per cert
|
||||
|
||||
The assessor exports two PDFs from Elmhurst's RdSAP tool:
|
||||
|
||||
1. **`Summary_NNNNNN.pdf`** — the assessor's `RdSAP Inputs` form: property type, age band, dimensions, walls, roof, floors, windows, heating, ventilation. This is what we encode as `EpcPropertyData`.
|
||||
2. **`UXXX-XXXX-NNNNNN.pdf`** — the calculator's full worksheet output: every populated line ref `(1a)..(486)` for the Energy Rating, EPC Costs, and Improved Dwelling variants. The Energy Rating variant (the first section) is canonical for line-ref tests.
|
||||
|
||||
`NNNNNN` is the cert's `Full RefNo` — both PDFs must match. Always capture from the **Energy Rating** section, not EPC Costs (the latter uses slightly different wind speeds for the BEDF fuel-price calc).
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Drop a new fixture module** at `worksheet/tests/_elmhurst_worksheet_NNNNNN.py`. Copy the closest existing fixture as a starting template:
|
||||
- 3-storey with room-in-roof → start from `_elmhurst_worksheet_000487.py` (RR + extension + alt wall) or `_elmhurst_worksheet_000477.py` (RR main-only)
|
||||
- 2-storey with extension(s) → `_elmhurst_worksheet_000474.py` (Main + 2 ext, no RR) or `_elmhurst_worksheet_000480.py` (Main + 1 ext, with RR)
|
||||
|
||||
2. **Mirror the Summary PDF into `build_epc()`** — one `SapBuildingPart` per Main/Extension. Field-by-field correspondence; the docstring at the top of the fixture should call out the source PDF date and the cert's distinguishing features.
|
||||
|
||||
3. **Capture every populated worksheet line** as `LINE_NN_*` module-level constants. The cascade pin test (`test_section_cascade_pins.py`) parametrizes over `ALL_FIXTURES` and asserts each line individually at `abs=1e-4` against the actual `<section>_from_cert(epc)` output. Capture every line, scalar and monthly, all the way through §12 — the strict-pin sweep is the work in progress.
|
||||
|
||||
4. **Register the fixture** in `_elmhurst_fixtures.py`: add the import and append the module to `ALL_FIXTURES`.
|
||||
|
||||
5. **Run the conformance tests**:
|
||||
```
|
||||
python -m pytest domain/sap10_calculator/worksheet/tests/ \
|
||||
-k elmhurst --no-cov -v
|
||||
```
|
||||
Each fixture appears 3× (one parametrize per section), pytest id = the cert ref number.
|
||||
|
||||
### Mapping the Summary PDF to `EpcPropertyData`
|
||||
|
||||
| Summary field | `EpcPropertyData` location | Notes |
|
||||
|---|---|---|
|
||||
| `Property type` | `epc.property_type` via `make_minimal_sap10_epc(...)` | drives mid/end/detached defaults |
|
||||
| `Date Built` (per part) | `SapBuildingPart.construction_age_band` | one-letter A..M |
|
||||
| `Storeys` | NOT a stored field — sum across `sap_floor_dimensions` + 1 if RR | §2 (9) uses dwelling *height*, not Σ across parts (LINE_9_STOREYS captures this) |
|
||||
| `Floor Area` / `Room Height` / `Heat Loss Wall Perimeter` / `Party Wall Length` | one `SapFloorDimension` per storey of the part | see *Storey height convention* below |
|
||||
| `Walls.Type` | `wall_construction` | 3=solid brick, 4=cavity, 5=timber frame, 6=system built |
|
||||
| `Walls.Insulation` | `wall_insulation_type` | 4=as-built; 2=filled cavity |
|
||||
| `Party Wall Type` | `party_wall_construction` | see *Party wall U mapping* below |
|
||||
| `Roof.Type/Insulation/Thickness` | top-level `epc.roofs[0]` `EnergyElement` | RdSAP cascade reads description string |
|
||||
| `Floors.Type/Insulation` | top-level `epc.floors[0]` | similar pattern |
|
||||
| `Rooms in Roof` block | `SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=...)` | see *Room-in-roof handling* |
|
||||
| `Total Number of Doors` | `door_count=` on `make_minimal_sap10_epc` | |
|
||||
| `Windows` table (each W×H + area) | one `SapWindow` per row in `epc.sap_windows`, with per-window `u_value` lodged when the cert names a U-value (mixed-glazing fixtures need this for the per-window curtain-resistance transform — slice 22). `make_window(..., u_value=...)` is the canonical helper. | |
|
||||
| `Intermittent fans` | fixture constant `INTERMITTENT_FANS` (consumed by §2 test) | |
|
||||
| `Draught Lobby` / `Draught Proofing %` | fixture constants `HAS_DRAUGHT_LOBBY`, `WINDOW_PCT_DRAUGHT_PROOFED` | |
|
||||
| `Sheltered Sides` | fixture constant `LINE_19_SHELTERED_SIDES` (also asserted) | |
|
||||
| `Mechanical Ventilation` | fixture constant `MV_KIND` | default `MechanicalVentilationKind.NATURAL` |
|
||||
|
||||
### Worksheet lines to capture
|
||||
|
||||
From the Energy Rating section's `1. Overall dwelling characteristics`:
|
||||
- `LINE_4_TFA_M2` ← line `(4)` Total floor area
|
||||
- `LINE_5_VOLUME_M3` ← line `(5)` Dwelling volume
|
||||
|
||||
From `2. Ventilation rate`:
|
||||
- Scalars: `LINE_8` through `LINE_21` — every `(N)` line, including the pressure-test override `(18)` and shelter `(19)/(20)/(21)`
|
||||
- Monthly tuples: `LINE_22_WIND_SPEED_M_S`, `LINE_22A_WIND_FACTOR`, `LINE_22B_WIND_ADJUSTED_ACH`, `LINE_25_EFFECTIVE_ACH` — twelve floats Jan..Dec
|
||||
|
||||
From `3. Heat losses and heat loss parameter`:
|
||||
- `LINE_31_TOTAL_EXTERNAL_AREA_M2` ← `(31)` Σ A external elements (excludes party wall)
|
||||
- `LINE_33_FABRIC_HEAT_LOSS_W_PER_K` ← `(33)` Σ (A × U) without bridging
|
||||
- `LINE_36_THERMAL_BRIDGING_W_PER_K` ← `(36)` = y × (31)
|
||||
- `LINE_37_TOTAL_FABRIC_HEAT_LOSS_W_PER_K` ← `(37)` = (33) + (36)
|
||||
|
||||
All four §3 aggregates are now pinned by `test_section_cascade_pins.py::test_section_3_line_refs_match_pdf` at `abs=1e-4`. RR detailed surfaces lodged via `SapRoomInRoof.detailed_surfaces` (slices 13–23) close the room-in-roof breakdown end-to-end for every fixture with detailed §3.10 lodgement (000477, 000480, 000516; 000487 still has the U=0.86 external-gable variant pending spec input).
|
||||
|
||||
## Gotchas
|
||||
|
||||
### Storey height convention (`SapFloorDimension.room_height_m`)
|
||||
The worksheet's `(2x)` height column includes a +0.25 m floor-structure allowance on every storey **above the lowest**:
|
||||
- floor=0 (lowest): internal room height as measured
|
||||
- floor=1 / floor=2 / …: internal room height + 0.25
|
||||
|
||||
So a 2.91 m upper-storey internal height appears on the worksheet as 3.16 m. Mirror the worksheet number into the fixture, not the surveyor's tape measurement.
|
||||
|
||||
### Room-in-roof
|
||||
- §1 RdSAP `2.45 m` storey-height convention is hardcoded in `dimensions.py` regardless of any height the RR cert input claims. The worksheet line `(2d)` for an RR storey shows 2.45.
|
||||
- We encode it as `SapBuildingPart.sap_room_in_roof = SapRoomInRoof(floor_area=..., detailed_surfaces=[...])`, NOT as a third `SapFloorDimension`. The dimensions calculator treats the RR as +1 storey, +floor_area to TFA, +floor_area × 2.45 to volume.
|
||||
- §3.10 Detailed RR is implemented (slices 13, 16, 23). `SapRoomInRoofSurface` carries `kind` ∈ {`slope`, `flat_ceiling`, `stud_wall`, `gable_wall`}, `area_m2`, optional `insulation_thickness_mm` + `insulation_type`. Slope/flat_ceiling/stud_wall route to roof per Table 17; gable_wall routes to party at U=0.25 per Table 4 "as common wall". The U=0.86 "external gable" variant (000487) is NOT yet implemented — open ticket.
|
||||
- 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`:
|
||||
- `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
|
||||
|
||||
Cross-check against the worksheet's `Party walls Main` row in §3 — that's the authoritative U for the cert.
|
||||
|
||||
### Sheltered sides drives shelter factor
|
||||
`(19)` varies per cert and the chain `(20) = 1 - 0.075 × (19)`, `(21) = (18) × (20)` propagates through every monthly `(22b)/(25)`. Read straight from the cert's `Sheltered Sides` field; not derivable from property type alone.
|
||||
|
||||
### `(12)` suspended-timber-floor quirk
|
||||
Some Elmhurst certs list a suspended timber floor on the inputs but lodge `(12) = 0.0` in the worksheet. Mirror the worksheet, not the cert input: set `HAS_SUSPENDED_TIMBER_FLOOR=False` to get `(12)=0`. The `SUSPENDED_TIMBER_FLOOR_SEALED` flag only switches between `0.2` (unsealed) and `0.1` (sealed); it does not zero out the contribution. The `=True/=False` mapping in `ventilation.py:185`:
|
||||
|
||||
| `has_suspended_timber_floor` | `..._sealed` | resulting `(12)` |
|
||||
|---|---|---|
|
||||
| `False` | (any) | `0.0` |
|
||||
| `True` | `False` | `0.2` |
|
||||
| `True` | `True` | `0.1` |
|
||||
|
||||
### Effective monthly ACH `(25)` formula
|
||||
Not equal to `(22b)` when `(22b) < 1.0`:
|
||||
|
||||
```
|
||||
(25) = (22b) if (22b) ≥ 1.0
|
||||
(25) = 0.5 + (22b)² × 0.5 otherwise
|
||||
```
|
||||
|
||||
Don't try to compute it — read both `(22b)` and `(25)` straight off the worksheet and assert on both. The formula's here just so you recognise why they differ on tightly-sealed homes.
|
||||
|
||||
### Wind speeds: Energy Rating vs EPC Costs
|
||||
The same cert prints two `Wind speed (22)` tables — one in `CALCULATION OF ENERGY RATING`, one in `CALCULATION OF EPC COSTS, EMISSIONS AND PRIMARY ENERGY`. They differ (the latter is the BEDF-prices variant). Always capture from the Energy Rating section; that's what `ventilation_from_inputs(...)` calibrates against. The non-regional Table U2 default values are `5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7`.
|
||||
626
domain/sap10_calculator/calculator.py
Normal file
626
domain/sap10_calculator/calculator.py
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
"""SAP 10.2 calculator orchestrator.
|
||||
|
||||
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 cert→inputs mapping lives in
|
||||
`domain.sap10_calculator.rdsap.cert_to_inputs`. Splitting the two keeps orchestration
|
||||
testable against synthetic inputs without dragging in cert-shape
|
||||
assumptions.
|
||||
|
||||
Per-month worksheet flow (§§5-13):
|
||||
1. External temp / wind / horizontal solar from `monthly_external_
|
||||
temp_c_override` tuple if set (postcode demand cascade), else
|
||||
Appendix U Tables U1-U3 by region.
|
||||
2. Internal gains (§5 + Appendix L) given TFA and month.
|
||||
3. Solar gains (§6 + Appendix U §U3.2) summed over the window list.
|
||||
4. HLC = HLC_T (already supplied) + HLC_V = ach × volume × 0.33.
|
||||
5. Thermal time constant τ = TMP × TFA / (3.6 × HLC) for utilisation η.
|
||||
6. Mean internal temperature (§7 + Table 9b/9c) and utilisation factor
|
||||
(Table 9a) — supplied as monthly tuples from cert_to_inputs.
|
||||
7. Useful space-heating requirement (Table 9c step 10).
|
||||
8. Delivered fuel kWh = Q_heat / main-heating efficiency.
|
||||
|
||||
Annual aggregation:
|
||||
- ECF = Table 12 deflator × total cost / (TFA + 45); SAP rating from
|
||||
§13 piecewise log/linear (slice 23 — constants pinned by ADR-0010).
|
||||
- CO2 per end-use uses per-end-use factors on CalculatorInputs:
|
||||
gas end-uses (main, hot water) use the annual Table 12 factor;
|
||||
electricity end-uses (secondary, pumps/fans, lighting, electric
|
||||
shower) use the Σ(kWh_m × Table 12d_m) / Σ kWh_m effective annual.
|
||||
- Primary Energy: same shape with Table 12 / Table 12e factors.
|
||||
- Environmental Impact Rating from §14 (log/linear on CO2/m²).
|
||||
|
||||
The factor-per-end-use machinery is the slice-32/33 closure of the U985
|
||||
Block 2 (demand cascade) §12 / §13a line refs. See
|
||||
`worksheet/tests/test_section_cascade_pins.py` for the conformance suite.
|
||||
|
||||
Reference: SAP 10.2 specification (14-03-2025) §§5-14 (pages 23-44),
|
||||
Tables 9a/9b/9c (pages 183-185), Table 12/12a/12d/12e (pages 191-195),
|
||||
Appendix L + U. RdSAP10 Table 32 (p.95) for fuel prices/CO2/PE factors.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
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 (
|
||||
ECF_LOG_THRESHOLD,
|
||||
ENERGY_COST_DEFLATOR,
|
||||
FLOOR_AREA_OFFSET_M2,
|
||||
energy_cost_factor,
|
||||
sap_rating,
|
||||
sap_rating_integer,
|
||||
)
|
||||
|
||||
|
||||
_AIR_HEAT_CAPACITY_WH_PER_M3_K: Final[float] = 0.33
|
||||
_TIME_CONSTANT_DIVISOR_KJ_TO_WH: Final[float] = 3.6
|
||||
|
||||
# §9a default — used as `CalculatorInputs.energy_requirements` default for
|
||||
# synthetic constructions that bypass cert_to_inputs. All-zero fuel; the
|
||||
# calculator's read path falls through to the existing inline q/η math.
|
||||
_ZERO_ENERGY_REQUIREMENTS_RESULT: Final[EnergyRequirementsResult] = EnergyRequirementsResult(
|
||||
secondary_heating_fraction=0.0,
|
||||
main_heating_total_fraction=1.0,
|
||||
main_2_of_main_fraction=0.0,
|
||||
main_1_of_total_fraction=1.0,
|
||||
main_2_of_total_fraction=0.0,
|
||||
main_1_efficiency_pct=100.0,
|
||||
main_2_efficiency_pct=0.0,
|
||||
secondary_efficiency_pct=100.0,
|
||||
cooling_seer=0.0,
|
||||
main_1_fuel_monthly_kwh=(0.0,) * 12,
|
||||
main_2_fuel_monthly_kwh=(0.0,) * 12,
|
||||
secondary_fuel_monthly_kwh=(0.0,) * 12,
|
||||
main_1_fuel_kwh_per_yr=0.0,
|
||||
main_2_fuel_kwh_per_yr=0.0,
|
||||
secondary_fuel_kwh_per_yr=0.0,
|
||||
cooling_fuel_kwh_per_yr=0.0,
|
||||
)
|
||||
|
||||
# §10a default — used as `CalculatorInputs.fuel_cost` default for synthetic
|
||||
# constructions that bypass cert_to_inputs. All-zero cost; calculator
|
||||
# delegation falls through to the existing inline cost math when this is
|
||||
# the default (slice 2a doesn't yet route through `inputs.fuel_cost`).
|
||||
_ZERO_FUEL_COST_RESULT: Final[FuelCostResult] = FuelCostResult(
|
||||
main_1_high_rate_fraction=1.0,
|
||||
main_1_low_rate_fraction=0.0,
|
||||
main_1_high_rate_cost_gbp=0.0,
|
||||
main_1_low_rate_cost_gbp=0.0,
|
||||
main_1_other_fuel_cost_gbp=0.0,
|
||||
main_1_total_cost_gbp=0.0,
|
||||
main_2_high_rate_fraction=1.0,
|
||||
main_2_low_rate_fraction=0.0,
|
||||
main_2_high_rate_cost_gbp=0.0,
|
||||
main_2_low_rate_cost_gbp=0.0,
|
||||
main_2_other_fuel_cost_gbp=0.0,
|
||||
main_2_total_cost_gbp=0.0,
|
||||
secondary_high_rate_fraction=1.0,
|
||||
secondary_low_rate_fraction=0.0,
|
||||
secondary_high_rate_cost_gbp=0.0,
|
||||
secondary_low_rate_cost_gbp=0.0,
|
||||
secondary_other_fuel_cost_gbp=0.0,
|
||||
secondary_total_cost_gbp=0.0,
|
||||
water_high_rate_fraction=1.0,
|
||||
water_low_rate_fraction=0.0,
|
||||
water_high_rate_cost_gbp=0.0,
|
||||
water_low_rate_cost_gbp=0.0,
|
||||
water_other_fuel_cost_gbp=0.0,
|
||||
instant_shower_cost_gbp=0.0,
|
||||
space_cooling_cost_gbp=0.0,
|
||||
pumps_fans_cost_gbp=0.0,
|
||||
lighting_cost_gbp=0.0,
|
||||
additional_standing_charges_gbp=0.0,
|
||||
pv_credit_gbp=0.0,
|
||||
appendix_q_saved_gbp=0.0,
|
||||
appendix_q_used_gbp=0.0,
|
||||
total_cost_gbp=0.0,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalculatorInputs:
|
||||
"""Synthetic SAP 10.2 calculator inputs. The cert→inputs mapper
|
||||
(S-A7b) produces one of these from an `EpcPropertyData`.
|
||||
|
||||
Fuel-cost fields are per-end-use because SAP §12 / Table 32 charges
|
||||
different tariffs for space heating vs hot water vs lighting/pumps
|
||||
depending on the dwelling's tariff (e.g. Economy-7 charges space
|
||||
heating at the off-peak rate but lighting at standard). For single-
|
||||
tariff dwellings the three fields are equal.
|
||||
"""
|
||||
|
||||
dimensions: Dimensions
|
||||
heat_transmission: HeatTransmission
|
||||
# SAP10.2 (25)m — effective monthly air-change rate (12-tuple Jan..Dec).
|
||||
# Per-month because ventilation HLC varies with wind speed (Table U2)
|
||||
# and MV mode (§2 lines 24a-d). Constant-monthly inputs work too:
|
||||
# pass `(ach,) * 12` to model a single rate across all months.
|
||||
monthly_infiltration_ach: tuple[float, ...]
|
||||
# SAP10.2 (73)m — total internal gains W per month (Jan..Dec).
|
||||
# Per-month because lighting/appliances cosine-modulate and pumps/fans
|
||||
# zero out in summer per Table 5a. Produced by §5 orchestrator
|
||||
# `internal_gains_from_cert` (called from cert_to_inputs).
|
||||
internal_gains_monthly_w: tuple[float, ...]
|
||||
# SAP10.2 (83)m — total solar gains W per month (Jan..Dec). Produced
|
||||
# by §6 orchestrator `solar_gains_from_cert` upstream; the calculator
|
||||
# only indexes into it per month, no recomputation here.
|
||||
solar_gains_monthly_w: tuple[float, ...]
|
||||
# SAP10.2 (93)m — adjusted mean internal temperature °C per month, and
|
||||
# (94)m — utilisation factor (whole-dwelling Ti) per month. Both come
|
||||
# from §7 orchestrator `mean_internal_temperature_monthly` upstream.
|
||||
# The calculator stops iterating η in _solve_month — Table 9c is a
|
||||
# sequential chain (steps 1-9), not a fixed-point loop.
|
||||
mean_internal_temp_monthly_c: tuple[float, ...]
|
||||
utilisation_factor_monthly: tuple[float, ...]
|
||||
# SAP10.2 (98c)m — total space heating requirement kWh per month from
|
||||
# §8 orchestrator `space_heating_monthly_kwh`. Includes the spec summer
|
||||
# clamp (Jun..Sep = 0). Calculator stops calling the per-month leaf
|
||||
# `monthly_heat_requirement_kwh` directly; just indexes here.
|
||||
space_heating_monthly_kwh: tuple[float, ...]
|
||||
region: int
|
||||
control_type: int
|
||||
responsiveness: float
|
||||
living_area_fraction: float
|
||||
control_temperature_adjustment_c: float
|
||||
thermal_mass_parameter_kj_per_m2_k: float
|
||||
main_heating_efficiency: float
|
||||
hot_water_kwh_per_yr: float
|
||||
pumps_fans_kwh_per_yr: float
|
||||
lighting_kwh_per_yr: float
|
||||
space_heating_fuel_cost_gbp_per_kwh: float
|
||||
hot_water_fuel_cost_gbp_per_kwh: float
|
||||
other_fuel_cost_gbp_per_kwh: float
|
||||
co2_factor_kg_per_kwh: float
|
||||
# Pre-computed monthly external temperature (°C). When provided, the
|
||||
# calculator's per-month solve uses this directly instead of looking up
|
||||
# `external_temperature_c(region, month)`. Set by cert_to_inputs from
|
||||
# either UK-average (rating cascade) or PCDB postcode (demand cascade).
|
||||
monthly_external_temp_c_override: Optional[tuple[float, ...]] = None
|
||||
# Per-end-use effective CO2 factors. For electricity end-uses with
|
||||
# known monthly kWh distribution, cert_to_inputs computes the days-
|
||||
# weighted average Table 12d factor: Σ(kWh_m × CO2_m) / Σ(kWh_m). Gas
|
||||
# end-uses keep the annual factor. Default None → calculator falls
|
||||
# back to the global `co2_factor_kg_per_kwh` (legacy synthetic path).
|
||||
main_heating_co2_factor_kg_per_kwh: Optional[float] = None
|
||||
secondary_heating_co2_factor_kg_per_kwh: Optional[float] = None
|
||||
hot_water_co2_factor_kg_per_kwh: Optional[float] = None
|
||||
pumps_fans_co2_factor_kg_per_kwh: Optional[float] = None
|
||||
lighting_co2_factor_kg_per_kwh: Optional[float] = None
|
||||
electric_shower_kwh_per_yr: float = 0.0
|
||||
electric_shower_co2_factor_kg_per_kwh: Optional[float] = None
|
||||
# Primary energy factors per end-use (Table 12 "Primary energy factor"
|
||||
# column). Used by §14 to derive the cert's `energy_consumption_current`
|
||||
# (which is PRIMARY energy per m²). For a single-fuel dwelling all
|
||||
# three collapse to the same value.
|
||||
space_heating_primary_factor: float = 1.0
|
||||
hot_water_primary_factor: float = 1.0
|
||||
# Standard-electricity PE factor per RdSAP10 Table 32 (p.95) / SAP10.2
|
||||
# Table 12 = 1.501. Table 12e (p.195) provides monthly overrides — see
|
||||
# the per-end-use PE factor fields below for the monthly cascade.
|
||||
other_primary_factor: float = 1.501
|
||||
# Per-end-use effective PE factors. For electricity end-uses with known
|
||||
# monthly kWh distribution, cert_to_inputs computes the days-weighted
|
||||
# Table 12e factor Σ(kWh_m × PE_m) / Σ(kWh_m). Gas end-uses keep the
|
||||
# annual Table 12 factor. None → calculator falls back to the global
|
||||
# `space_heating_primary_factor` / `hot_water_primary_factor` /
|
||||
# `other_primary_factor` (legacy synthetic path).
|
||||
secondary_heating_primary_factor: Optional[float] = None
|
||||
pumps_fans_primary_factor: Optional[float] = None
|
||||
lighting_primary_factor: Optional[float] = None
|
||||
electric_shower_primary_factor: Optional[float] = None
|
||||
# Generation offsets — applied as a cost credit against the ECF
|
||||
# numerator. SAP 10.2 Appendix M: PV self-consumption + export
|
||||
# collapse to a single credit at the export rate (Table 12 code 60).
|
||||
pv_generation_kwh_per_yr: float = 0.0
|
||||
pv_export_credit_gbp_per_kwh: float = 0.0
|
||||
# Secondary heating — SAP 10.2 Table 11 routes a fraction of space
|
||||
# heating demand to a secondary system (0.10 for gas/oil/solid main
|
||||
# systems; 0.15-0.20 for electric room/storage heaters). Fraction
|
||||
# 0.0 disables secondary handling (default for ports that don't yet
|
||||
# split heating).
|
||||
secondary_heating_fraction: float = 0.0
|
||||
secondary_heating_efficiency: float = 1.0
|
||||
secondary_heating_fuel_cost_gbp_per_kwh: float = 0.0
|
||||
# SAP10.2 (107)m — space cooling requirement kWh per month from §8c
|
||||
# orchestrator `space_cooling_monthly_kwh`. Includes spec Jun-Aug
|
||||
# inclusion mask + 1-kWh clamp. Default (0,)*12 for backwards
|
||||
# compatibility — every cert without `has_fixed_air_conditioning`
|
||||
# collapses cooling to zero.
|
||||
space_cooling_monthly_kwh: tuple[float, ...] = (0.0,) * 12
|
||||
# SAP10.2 (109) — Fabric Energy Efficiency precomputed by cert_to_inputs
|
||||
# via `fabric_energy_efficiency_kwh_per_m2_yr` from the §8/§8c results.
|
||||
# Default 0.0 for backwards compatibility — synthetic CalculatorInputs
|
||||
# constructions without cert_to_inputs leave it unset.
|
||||
fabric_energy_efficiency_kwh_per_m2_yr: float = 0.0
|
||||
# SAP10.2 §9a — per-system energy requirements (201)..(221) precomputed
|
||||
# by cert_to_inputs via `space_heating_fuel_monthly_kwh`. Calculator
|
||||
# reads `main_1_fuel_monthly_kwh` and `secondary_fuel_monthly_kwh` for
|
||||
# per-month fuel attribution; existing `main_heating_efficiency` /
|
||||
# `secondary_heating_efficiency` / `secondary_heating_fraction` fields
|
||||
# are now redundant inputs (kept for backwards compat + audit).
|
||||
energy_requirements: EnergyRequirementsResult = field(
|
||||
default_factory=lambda: _ZERO_ENERGY_REQUIREMENTS_RESULT
|
||||
)
|
||||
# SAP10.2 §10a — fuel-cost line refs (240)..(255) precomputed by
|
||||
# cert_to_inputs via `fuel_cost(...)`. Default zero result so non-
|
||||
# cert constructions keep working through the inline cost math
|
||||
# (calculator routes through `inputs.fuel_cost.total_cost_gbp` only
|
||||
# when the precompute lodges a non-zero `total_cost_gbp`).
|
||||
fuel_cost: FuelCostResult = field(
|
||||
default_factory=lambda: _ZERO_FUEL_COST_RESULT
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyEntry:
|
||||
"""Per-month worksheet outputs for downstream audit. SAP 10.2 §§5-9."""
|
||||
|
||||
month: int
|
||||
external_temp_c: float
|
||||
internal_temp_c: float
|
||||
internal_gains_w: float
|
||||
solar_gains_w: float
|
||||
heat_loss_rate_w: float
|
||||
utilisation_factor: float
|
||||
space_heat_requirement_kwh: float
|
||||
main_heating_fuel_kwh: float
|
||||
secondary_heating_fuel_kwh: float = 0.0
|
||||
space_cool_requirement_kwh: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SapResult:
|
||||
"""Calculator output. `sap_score` is the rounded RdSAP-style integer
|
||||
(1-100+); `sap_score_continuous` keeps the un-rounded value for
|
||||
sensitivity analysis."""
|
||||
|
||||
sap_score: int
|
||||
sap_score_continuous: float
|
||||
ecf: float
|
||||
total_fuel_cost_gbp: float
|
||||
co2_kg_per_yr: float
|
||||
space_heating_kwh_per_yr: float
|
||||
space_cooling_kwh_per_yr: float
|
||||
fabric_energy_efficiency_kwh_per_m2_yr: float
|
||||
main_heating_fuel_kwh_per_yr: float
|
||||
main_2_heating_fuel_kwh_per_yr: float
|
||||
secondary_heating_fuel_kwh_per_yr: float
|
||||
space_cooling_fuel_kwh_per_yr: float
|
||||
hot_water_kwh_per_yr: float
|
||||
pumps_fans_kwh_per_yr: float
|
||||
lighting_kwh_per_yr: float
|
||||
primary_energy_kwh_per_yr: float
|
||||
primary_energy_kwh_per_m2: float
|
||||
monthly: tuple[MonthlyEntry, ...]
|
||||
intermediate: dict[str, float]
|
||||
|
||||
|
||||
def _time_constant_h(*, tmp_kj_per_m2_k: float, tfa_m2: float, hlc_w_per_k: float) -> float:
|
||||
if hlc_w_per_k <= 0:
|
||||
return float("inf")
|
||||
return tmp_kj_per_m2_k * tfa_m2 / (_TIME_CONSTANT_DIVISOR_KJ_TO_WH * hlc_w_per_k)
|
||||
|
||||
|
||||
def _solve_month(
|
||||
*,
|
||||
inputs: CalculatorInputs,
|
||||
month: int,
|
||||
hlc_w_per_k: float,
|
||||
time_constant_h: float,
|
||||
heat_loss_parameter: float,
|
||||
) -> MonthlyEntry:
|
||||
t_ext = (
|
||||
inputs.monthly_external_temp_c_override[month - 1]
|
||||
if inputs.monthly_external_temp_c_override is not None
|
||||
else external_temperature_c(inputs.region, month)
|
||||
)
|
||||
g_int = inputs.internal_gains_monthly_w[month - 1]
|
||||
g_sol = inputs.solar_gains_monthly_w[month - 1]
|
||||
|
||||
# SAP 10.2 §7 Table 9c is a sequential chain (steps 1-9); the §7
|
||||
# orchestrator computes (93)m and (94)m upstream and the calculator
|
||||
# consumes them by index. No fixed-point iteration here.
|
||||
_ = time_constant_h # τ now lives inside the §7 orchestrator
|
||||
_ = heat_loss_parameter
|
||||
t_int = inputs.mean_internal_temp_monthly_c[month - 1]
|
||||
eta = inputs.utilisation_factor_monthly[month - 1]
|
||||
loss_rate_w = max(0.0, hlc_w_per_k * (t_int - t_ext))
|
||||
|
||||
# SAP 10.2 §8 — (98c)m precomputed upstream by `space_heating_monthly_kwh`
|
||||
# (includes Table 9c summer clamp Jun..Sep). Calculator indexes directly.
|
||||
q_heat = inputs.space_heating_monthly_kwh[month - 1]
|
||||
# SAP 10.2 §9a — (211)m/(215)m precomputed upstream by
|
||||
# `space_heating_fuel_monthly_kwh`. Calculator stops doing q/η inline.
|
||||
fuel_main = inputs.energy_requirements.main_1_fuel_monthly_kwh[month - 1]
|
||||
fuel_secondary = inputs.energy_requirements.secondary_fuel_monthly_kwh[month - 1]
|
||||
|
||||
# SAP 10.2 §8c — (107)m precomputed upstream by `space_cooling_monthly_kwh`
|
||||
# (includes Jun-Aug inclusion mask + post-f_C × f_intermittent clamp).
|
||||
q_cool = inputs.space_cooling_monthly_kwh[month - 1]
|
||||
|
||||
return MonthlyEntry(
|
||||
month=month,
|
||||
external_temp_c=t_ext,
|
||||
internal_temp_c=t_int,
|
||||
internal_gains_w=g_int,
|
||||
solar_gains_w=g_sol,
|
||||
heat_loss_rate_w=loss_rate_w,
|
||||
utilisation_factor=eta,
|
||||
space_heat_requirement_kwh=q_heat,
|
||||
main_heating_fuel_kwh=fuel_main,
|
||||
secondary_heating_fuel_kwh=fuel_secondary,
|
||||
space_cool_requirement_kwh=q_cool,
|
||||
)
|
||||
|
||||
|
||||
def calculate_sap_from_inputs(inputs: CalculatorInputs) -> SapResult:
|
||||
"""Run SAP 10.2 §§5-13 monthly loop on synthetic inputs; return a
|
||||
typed `SapResult`. Cert-shape mapping is the job of `cert_to_inputs`
|
||||
(S-A7b); this entry point is pure physics."""
|
||||
tfa = inputs.dimensions.total_floor_area_m2
|
||||
volume = inputs.dimensions.volume_m3
|
||||
transmission_hlc = inputs.heat_transmission.total_w_per_k
|
||||
|
||||
# SAP10.2 §3 line (38): ventilation HLC = 0.33 × (25)m × volume —
|
||||
# monthly because (25)m varies with Table U2 wind. HLC, HLP, and the
|
||||
# time constant τ all become 12-tuples.
|
||||
monthly_hlc_v = tuple(
|
||||
ach * volume * _AIR_HEAT_CAPACITY_WH_PER_M3_K
|
||||
for ach in inputs.monthly_infiltration_ach
|
||||
)
|
||||
monthly_hlc = tuple(transmission_hlc + hv for hv in monthly_hlc_v)
|
||||
monthly_hlp = tuple(h / tfa if tfa > 0 else 0.0 for h in monthly_hlc)
|
||||
monthly_tau_h = tuple(
|
||||
_time_constant_h(
|
||||
tmp_kj_per_m2_k=inputs.thermal_mass_parameter_kj_per_m2_k,
|
||||
tfa_m2=tfa,
|
||||
hlc_w_per_k=h,
|
||||
)
|
||||
for h in monthly_hlc
|
||||
)
|
||||
|
||||
monthly = tuple(
|
||||
_solve_month(
|
||||
inputs=inputs,
|
||||
month=m,
|
||||
hlc_w_per_k=monthly_hlc[m - 1],
|
||||
time_constant_h=monthly_tau_h[m - 1],
|
||||
heat_loss_parameter=monthly_hlp[m - 1],
|
||||
)
|
||||
for m in range(1, 13)
|
||||
)
|
||||
|
||||
space_heating_kwh = sum(e.space_heat_requirement_kwh for e in monthly)
|
||||
space_cooling_kwh = sum(e.space_cool_requirement_kwh for e in monthly)
|
||||
main_fuel_kwh = sum(e.main_heating_fuel_kwh for e in monthly)
|
||||
secondary_fuel_kwh = sum(e.secondary_heating_fuel_kwh for e in monthly)
|
||||
delivered_fuel_kwh = (
|
||||
main_fuel_kwh
|
||||
+ secondary_fuel_kwh
|
||||
+ inputs.hot_water_kwh_per_yr
|
||||
+ inputs.pumps_fans_kwh_per_yr
|
||||
+ inputs.lighting_kwh_per_yr
|
||||
)
|
||||
# SAP10.2 §10a Fuel costs — line refs (240)..(255) precomputed by
|
||||
# cert_to_inputs._fuel_cost via the worksheet/fuel_cost orchestrator
|
||||
# (Table 32 prices, Table 12a fractions, Table 12 note (a) standing-
|
||||
# charge gating). Calculator unpacks the precompute when populated;
|
||||
# synthetic-test CalculatorInputs constructions that leave the slot
|
||||
# at its zero default still use the legacy inline cost math (scalar
|
||||
# cost fields × kWh). That legacy path is slated for removal once
|
||||
# the synthetic test corpus migrates to `fuel_cost=` (future ticket).
|
||||
if inputs.fuel_cost is not _ZERO_FUEL_COST_RESULT and (
|
||||
inputs.fuel_cost.total_cost_gbp != 0.0
|
||||
or inputs.fuel_cost.additional_standing_charges_gbp != 0.0
|
||||
):
|
||||
fuel_cost_result = inputs.fuel_cost
|
||||
total_cost = fuel_cost_result.total_cost_gbp
|
||||
main_heating_cost = (
|
||||
fuel_cost_result.main_1_total_cost_gbp
|
||||
+ fuel_cost_result.main_2_total_cost_gbp
|
||||
)
|
||||
secondary_heating_cost = fuel_cost_result.secondary_total_cost_gbp
|
||||
hot_water_cost = (
|
||||
fuel_cost_result.water_high_rate_cost_gbp
|
||||
+ fuel_cost_result.water_low_rate_cost_gbp
|
||||
+ fuel_cost_result.water_other_fuel_cost_gbp
|
||||
)
|
||||
pumps_fans_cost = fuel_cost_result.pumps_fans_cost_gbp
|
||||
lighting_cost = fuel_cost_result.lighting_cost_gbp
|
||||
pv_credit = -fuel_cost_result.pv_credit_gbp
|
||||
else:
|
||||
pv_credit = inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh
|
||||
main_heating_cost = main_fuel_kwh * inputs.space_heating_fuel_cost_gbp_per_kwh
|
||||
secondary_heating_cost = (
|
||||
secondary_fuel_kwh * inputs.secondary_heating_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
hot_water_cost = (
|
||||
inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
pumps_fans_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
|
||||
lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
|
||||
total_cost = max(
|
||||
0.0,
|
||||
main_heating_cost
|
||||
+ secondary_heating_cost
|
||||
+ hot_water_cost
|
||||
+ pumps_fans_cost
|
||||
+ lighting_cost
|
||||
- pv_credit,
|
||||
)
|
||||
ecf = energy_cost_factor(total_cost_gbp=total_cost, total_floor_area_m2=tfa)
|
||||
sap_int = sap_rating_integer(ecf=ecf)
|
||||
sap_cont = sap_rating(ecf=ecf)
|
||||
co2_factor = inputs.co2_factor_kg_per_kwh
|
||||
# Per-end-use effective CO2 factors (Table 12d monthly cascade for
|
||||
# electricity, annual for gas). cert_to_inputs supplies these from
|
||||
# monthly kWh × monthly Table 12d factors; synthetic constructions
|
||||
# without per-end-use values fall back to the legacy single factor.
|
||||
main_co2_factor = inputs.main_heating_co2_factor_kg_per_kwh or co2_factor
|
||||
secondary_co2_factor = inputs.secondary_heating_co2_factor_kg_per_kwh or co2_factor
|
||||
hot_water_co2_factor = inputs.hot_water_co2_factor_kg_per_kwh or co2_factor
|
||||
pumps_fans_co2_factor = inputs.pumps_fans_co2_factor_kg_per_kwh or co2_factor
|
||||
lighting_co2_factor = inputs.lighting_co2_factor_kg_per_kwh or co2_factor
|
||||
electric_shower_co2_factor = (
|
||||
inputs.electric_shower_co2_factor_kg_per_kwh or co2_factor
|
||||
)
|
||||
main_heating_co2 = main_fuel_kwh * main_co2_factor
|
||||
secondary_heating_co2 = secondary_fuel_kwh * secondary_co2_factor
|
||||
hot_water_co2 = inputs.hot_water_kwh_per_yr * hot_water_co2_factor
|
||||
pumps_fans_co2 = inputs.pumps_fans_kwh_per_yr * pumps_fans_co2_factor
|
||||
lighting_co2 = inputs.lighting_kwh_per_yr * lighting_co2_factor
|
||||
electric_shower_co2 = (
|
||||
inputs.electric_shower_kwh_per_yr * electric_shower_co2_factor
|
||||
)
|
||||
co2 = (
|
||||
main_heating_co2
|
||||
+ secondary_heating_co2
|
||||
+ hot_water_co2
|
||||
+ pumps_fans_co2
|
||||
+ lighting_co2
|
||||
+ electric_shower_co2
|
||||
)
|
||||
|
||||
# Per-end-use effective PE factors. Same shape as the CO2 cascade:
|
||||
# electricity end-uses use Table 12e (p.195) monthly factors weighted
|
||||
# by per-month kWh; gas end-uses use the annual Table 12 / Table 32
|
||||
# PE factor. Defaults fall back to the legacy single-factor path so
|
||||
# synthetic CalculatorInputs constructions keep working.
|
||||
secondary_primary_factor = (
|
||||
inputs.secondary_heating_primary_factor
|
||||
if inputs.secondary_heating_primary_factor is not None
|
||||
else inputs.space_heating_primary_factor
|
||||
)
|
||||
pumps_fans_primary_factor = (
|
||||
inputs.pumps_fans_primary_factor
|
||||
if inputs.pumps_fans_primary_factor is not None
|
||||
else inputs.other_primary_factor
|
||||
)
|
||||
lighting_primary_factor = (
|
||||
inputs.lighting_primary_factor
|
||||
if inputs.lighting_primary_factor is not None
|
||||
else inputs.other_primary_factor
|
||||
)
|
||||
electric_shower_primary_factor = (
|
||||
inputs.electric_shower_primary_factor
|
||||
if inputs.electric_shower_primary_factor is not None
|
||||
else inputs.other_primary_factor
|
||||
)
|
||||
space_heating_primary_kwh = (
|
||||
main_fuel_kwh * inputs.space_heating_primary_factor
|
||||
+ secondary_fuel_kwh * secondary_primary_factor
|
||||
)
|
||||
hot_water_primary_kwh = inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor
|
||||
other_primary_kwh = (
|
||||
inputs.pumps_fans_kwh_per_yr * pumps_fans_primary_factor
|
||||
+ inputs.lighting_kwh_per_yr * lighting_primary_factor
|
||||
+ inputs.electric_shower_kwh_per_yr * electric_shower_primary_factor
|
||||
)
|
||||
# PV offsets primary energy at the export PEF (Table 32 code 60 =
|
||||
# 0.501 — half the import PEF since exported kWh isn't subject to the
|
||||
# full grid-loss multiplier).
|
||||
pv_primary_offset_kwh = inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor
|
||||
primary_energy_kwh = max(
|
||||
0.0,
|
||||
space_heating_primary_kwh
|
||||
+ hot_water_primary_kwh
|
||||
+ other_primary_kwh
|
||||
- pv_primary_offset_kwh,
|
||||
)
|
||||
primary_energy_per_m2 = primary_energy_kwh / tfa if tfa > 0 else 0.0
|
||||
|
||||
ht = inputs.heat_transmission
|
||||
intermediate: dict[str, float] = {
|
||||
"tfa_m2": inputs.dimensions.total_floor_area_m2,
|
||||
"volume_m3": inputs.dimensions.volume_m3,
|
||||
"storey_count": float(inputs.dimensions.storey_count),
|
||||
"walls_w_per_k": ht.walls_w_per_k,
|
||||
"roof_w_per_k": ht.roof_w_per_k,
|
||||
"floor_w_per_k": ht.floor_w_per_k,
|
||||
"party_walls_w_per_k": ht.party_walls_w_per_k,
|
||||
"windows_w_per_k": ht.windows_w_per_k,
|
||||
"roof_windows_w_per_k": ht.roof_windows_w_per_k,
|
||||
"doors_w_per_k": ht.doors_w_per_k,
|
||||
"thermal_bridging_w_per_k": ht.thermal_bridging_w_per_k,
|
||||
# Annual means for the back-compat single-float audit dict; full
|
||||
# monthly arrays are available via the upstream VentilationResult.
|
||||
"infiltration_ach": sum(inputs.monthly_infiltration_ach) / 12.0,
|
||||
"infiltration_w_per_k": sum(monthly_hlc_v) / 12.0,
|
||||
"heat_transfer_coefficient_w_per_k": sum(monthly_hlc) / 12.0,
|
||||
"heat_loss_parameter_w_per_m2k": sum(monthly_hlp) / 12.0,
|
||||
"time_constant_h": sum(monthly_tau_h) / 12.0,
|
||||
"internal_gains_annual_avg_w": sum(e.internal_gains_w for e in monthly) / 12.0,
|
||||
"mean_internal_temp_annual_avg_c": sum(e.internal_temp_c for e in monthly) / 12.0,
|
||||
"useful_space_heating_kwh_per_yr": space_heating_kwh,
|
||||
"main_heating_cost_gbp": main_heating_cost,
|
||||
"secondary_heating_cost_gbp": secondary_heating_cost,
|
||||
"hot_water_cost_gbp": hot_water_cost,
|
||||
"pumps_fans_cost_gbp": pumps_fans_cost,
|
||||
"lighting_cost_gbp": lighting_cost,
|
||||
"pv_export_credit_gbp": pv_credit,
|
||||
"ecf": ecf,
|
||||
"deflator": ENERGY_COST_DEFLATOR,
|
||||
"delivered_fuel_kwh_per_yr": delivered_fuel_kwh,
|
||||
"co2_factor_kg_per_kwh": co2_factor,
|
||||
"main_heating_co2_kg_per_yr": main_heating_co2,
|
||||
"secondary_heating_co2_kg_per_yr": secondary_heating_co2,
|
||||
"hot_water_co2_kg_per_yr": hot_water_co2,
|
||||
"pumps_fans_co2_kg_per_yr": pumps_fans_co2,
|
||||
"lighting_co2_kg_per_yr": lighting_co2,
|
||||
"space_heating_pe_kwh_per_m2": space_heating_primary_kwh / tfa if tfa > 0 else 0.0,
|
||||
"hot_water_pe_kwh_per_m2": hot_water_primary_kwh / tfa if tfa > 0 else 0.0,
|
||||
"other_pe_kwh_per_m2": other_primary_kwh / tfa if tfa > 0 else 0.0,
|
||||
"pv_pe_offset_kwh_per_m2": pv_primary_offset_kwh / tfa if tfa > 0 else 0.0,
|
||||
"floor_area_offset_m2": FLOOR_AREA_OFFSET_M2,
|
||||
"ecf_log_threshold": ECF_LOG_THRESHOLD,
|
||||
}
|
||||
|
||||
return SapResult(
|
||||
sap_score=sap_int,
|
||||
sap_score_continuous=sap_cont,
|
||||
ecf=ecf,
|
||||
total_fuel_cost_gbp=total_cost,
|
||||
co2_kg_per_yr=co2,
|
||||
space_heating_kwh_per_yr=space_heating_kwh,
|
||||
space_cooling_kwh_per_yr=space_cooling_kwh,
|
||||
fabric_energy_efficiency_kwh_per_m2_yr=inputs.fabric_energy_efficiency_kwh_per_m2_yr,
|
||||
main_heating_fuel_kwh_per_yr=main_fuel_kwh,
|
||||
main_2_heating_fuel_kwh_per_yr=inputs.energy_requirements.main_2_fuel_kwh_per_yr,
|
||||
secondary_heating_fuel_kwh_per_yr=secondary_fuel_kwh,
|
||||
space_cooling_fuel_kwh_per_yr=inputs.energy_requirements.cooling_fuel_kwh_per_yr,
|
||||
hot_water_kwh_per_yr=inputs.hot_water_kwh_per_yr,
|
||||
pumps_fans_kwh_per_yr=inputs.pumps_fans_kwh_per_yr,
|
||||
lighting_kwh_per_yr=inputs.lighting_kwh_per_yr,
|
||||
primary_energy_kwh_per_yr=primary_energy_kwh,
|
||||
primary_energy_kwh_per_m2=primary_energy_per_m2,
|
||||
monthly=monthly,
|
||||
intermediate=intermediate,
|
||||
)
|
||||
|
||||
|
||||
class Sap10Calculator:
|
||||
"""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.
|
||||
|
||||
Separating mapping (cert-shape rules, RdSAP defaults) from the
|
||||
physics orchestration (`calculate_sap_from_inputs`) lets either side
|
||||
be tested without dragging in the other — and lets product code that
|
||||
already has a populated `CalculatorInputs` (e.g. a future
|
||||
MeasureApplicator that emits modified inputs) skip the mapper.
|
||||
"""
|
||||
|
||||
def calculate(self, epc: "EpcPropertyData") -> SapResult:
|
||||
from domain.sap10_calculator.rdsap.cert_to_inputs import cert_to_inputs
|
||||
|
||||
return calculate_sap_from_inputs(cert_to_inputs(epc))
|
||||
0
domain/sap10_calculator/climate/__init__.py
Normal file
0
domain/sap10_calculator/climate/__init__.py
Normal file
180
domain/sap10_calculator/climate/appendix_u.py
Normal file
180
domain/sap10_calculator/climate/appendix_u.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""SAP 10.2 Appendix U — climate data lookups.
|
||||
|
||||
Source: BRE, *The Government's Standard Assessment Procedure for Energy
|
||||
Rating of Dwellings, SAP 10.2* (14-03-2025), Appendix U.
|
||||
|
||||
Three monthly tables across 22 SAP climate regions (index 0 = UK average,
|
||||
1-21 = named regions per Table U6 postcode mapping):
|
||||
|
||||
- Table U1: Mean external temperature (°C)
|
||||
- Table U2: Wind speed (m/s)
|
||||
- Table U3: Mean global solar irradiance on a horizontal plane (W/m²)
|
||||
plus monthly solar declination (°)
|
||||
|
||||
Month is 1-12 (January = 1). Region indices map to the SAP 10.2 region
|
||||
names; lookup helpers raise `ValueError` on out-of-range inputs so callers
|
||||
can fail fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from domain.sap10_calculator.tables.pcdb.postcode_weather import PostcodeClimate
|
||||
|
||||
|
||||
# Table U1 — Mean external temperature (°C), 22 regions × 12 months.
|
||||
# Row order: region 0 (UK average) first, then regions 1-21 in spec order.
|
||||
_TABLE_U1: Final[tuple[tuple[float, ...], ...]] = (
|
||||
(4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2), # 0 UK average
|
||||
(5.1, 5.6, 7.4, 9.9, 13.0, 16.0, 17.9, 17.8, 15.2, 11.6, 8.0, 5.1), # 1 Thames
|
||||
(5.0, 5.4, 7.1, 9.5, 12.6, 15.4, 17.4, 17.5, 15.0, 11.7, 8.1, 5.2), # 2 South East England
|
||||
(5.4, 5.7, 7.3, 9.6, 12.6, 15.4, 17.3, 17.3, 15.0, 11.8, 8.4, 5.5), # 3 Southern England
|
||||
(6.1, 6.4, 7.5, 9.3, 11.9, 14.5, 16.2, 16.3, 14.6, 11.8, 9.0, 6.4), # 4 South West England
|
||||
(4.9, 5.3, 7.0, 9.3, 12.2, 15.0, 16.7, 16.7, 14.4, 11.1, 7.8, 4.9), # 5 Severn Wales / Severn England
|
||||
(4.3, 4.8, 6.6, 9.0, 11.8, 14.8, 16.6, 16.5, 14.0, 10.5, 7.1, 4.2), # 6 Midlands
|
||||
(4.7, 5.2, 6.7, 9.1, 12.0, 14.7, 16.4, 16.3, 14.1, 10.7, 7.5, 4.6), # 7 West Pennines Wales / West Pennines England
|
||||
(3.9, 4.3, 5.6, 7.9, 10.7, 13.2, 14.9, 14.8, 12.8, 9.7, 6.6, 3.7), # 8 North West England / South West Scotland
|
||||
(4.0, 4.5, 5.8, 7.9, 10.4, 13.3, 15.2, 15.1, 13.1, 9.7, 6.6, 3.7), # 9 Borders Scotland / Borders England
|
||||
(4.0, 4.6, 6.1, 8.3, 10.9, 13.8, 15.8, 15.6, 13.5, 10.1, 6.7, 3.8), # 10 North East England
|
||||
(4.3, 4.9, 6.5, 8.9, 11.7, 14.6, 16.6, 16.4, 14.1, 10.6, 7.1, 4.2), # 11 East Pennines
|
||||
(4.7, 5.2, 7.0, 9.5, 12.5, 15.4, 17.6, 17.6, 15.0, 11.4, 7.7, 4.7), # 12 East Anglia
|
||||
(5.0, 5.3, 6.5, 8.3, 11.2, 13.7, 15.3, 15.3, 13.5, 10.7, 7.8, 5.2), # 13 Wales
|
||||
(4.0, 4.4, 5.6, 7.9, 10.4, 13.0, 14.5, 14.4, 12.5, 9.3, 6.5, 3.8), # 14 West Scotland
|
||||
(3.6, 4.0, 5.4, 7.7, 10.1, 12.9, 14.6, 14.5, 12.5, 9.2, 6.1, 3.2), # 15 East Scotland
|
||||
(3.3, 3.6, 5.0, 7.1, 9.3, 12.2, 14.0, 13.9, 12.0, 8.8, 5.7, 2.9), # 16 North East Scotland
|
||||
(3.1, 3.2, 4.4, 6.6, 8.9, 11.4, 13.2, 13.1, 11.3, 8.2, 5.4, 2.7), # 17 Highland
|
||||
(5.2, 5.0, 5.8, 7.6, 9.7, 11.8, 13.4, 13.6, 12.1, 9.6, 7.3, 5.2), # 18 Western Isles
|
||||
(4.4, 4.2, 5.0, 7.0, 8.9, 11.2, 13.1, 13.2, 11.7, 9.1, 6.6, 4.3), # 19 Orkney
|
||||
(4.6, 4.1, 4.7, 6.5, 8.3, 10.5, 12.4, 12.8, 11.4, 8.8, 6.5, 4.6), # 20 Shetland
|
||||
(4.8, 5.2, 6.4, 8.4, 10.9, 13.5, 15.0, 14.9, 13.1, 10.0, 7.2, 4.7), # 21 Northern Ireland
|
||||
)
|
||||
|
||||
|
||||
# Table U2 — Wind speed (m/s), 22 regions × 12 months.
|
||||
_TABLE_U2: Final[tuple[tuple[float, ...], ...]] = (
|
||||
(5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7), # 0 UK average
|
||||
(4.2, 4.0, 4.0, 3.7, 3.7, 3.3, 3.4, 3.2, 3.3, 3.5, 3.5, 3.8), # 1 Thames
|
||||
(4.8, 4.5, 4.4, 3.9, 3.9, 3.6, 3.7, 3.5, 3.7, 4.0, 4.1, 4.4), # 2 South East England
|
||||
(5.1, 4.7, 4.6, 4.3, 4.3, 4.0, 4.0, 3.9, 4.0, 4.5, 4.4, 4.7), # 3 Southern England
|
||||
(6.0, 5.6, 5.6, 5.0, 5.0, 4.4, 4.4, 4.3, 4.7, 5.4, 5.5, 5.9), # 4 South West England
|
||||
(4.9, 4.6, 4.7, 4.3, 4.3, 3.8, 3.8, 3.7, 3.8, 4.3, 4.3, 4.6), # 5 Severn Wales / Severn England
|
||||
(4.5, 4.5, 4.4, 3.9, 3.8, 3.4, 3.3, 3.3, 3.5, 3.8, 3.9, 4.1), # 6 Midlands
|
||||
(4.8, 4.7, 4.6, 4.2, 4.1, 3.7, 3.7, 3.7, 3.7, 4.2, 4.3, 4.5), # 7 West Pennines Wales / West Pennines England
|
||||
(5.2, 5.2, 5.0, 4.4, 4.3, 3.9, 3.7, 3.7, 4.1, 4.6, 4.8, 4.7), # 8 North West England / South West Scotland
|
||||
(5.2, 5.2, 5.0, 4.4, 4.1, 3.8, 3.5, 3.5, 3.9, 4.2, 4.6, 4.7), # 9 Borders Scotland / Borders England
|
||||
(5.3, 5.2, 5.0, 4.3, 4.2, 3.9, 3.6, 3.6, 4.1, 4.3, 4.6, 4.8), # 10 North East England
|
||||
(5.1, 5.0, 4.9, 4.4, 4.3, 3.8, 3.8, 3.7, 4.0, 4.3, 4.5, 4.7), # 11 East Pennines
|
||||
(4.9, 4.8, 4.7, 4.2, 4.2, 3.7, 3.8, 3.8, 4.0, 4.2, 4.3, 4.5), # 12 East Anglia
|
||||
(6.5, 6.2, 5.9, 5.2, 5.1, 4.7, 4.5, 4.5, 5.0, 5.7, 6.0, 6.0), # 13 Wales
|
||||
(6.2, 6.2, 5.9, 5.2, 4.9, 4.7, 4.3, 4.3, 4.9, 5.4, 5.7, 5.4), # 14 West Scotland
|
||||
(5.7, 5.8, 5.7, 5.0, 4.8, 4.6, 4.1, 4.1, 4.7, 5.0, 5.2, 5.0), # 15 East Scotland
|
||||
(5.7, 5.8, 5.7, 5.0, 4.6, 4.4, 4.0, 4.1, 4.6, 5.2, 5.3, 5.1), # 16 North East Scotland
|
||||
(6.5, 6.8, 6.4, 5.7, 5.1, 5.1, 4.6, 4.5, 5.3, 5.8, 6.1, 5.7), # 17 Highland
|
||||
(8.3, 8.4, 7.9, 6.6, 6.1, 5.6, 5.6, 5.6, 6.3, 7.3, 7.7, 7.5), # 18 Western Isles
|
||||
(7.9, 8.3, 7.9, 7.1, 6.2, 6.1, 5.5, 5.6, 6.4, 7.3, 7.8, 7.3), # 19 Orkney
|
||||
(9.5, 9.4, 8.7, 7.5, 6.6, 6.4, 5.7, 6.0, 7.2, 8.5, 8.9, 8.5), # 20 Shetland
|
||||
(5.4, 5.3, 5.0, 4.7, 4.5, 4.1, 3.9, 3.7, 4.2, 4.6, 5.0, 5.0), # 21 Northern Ireland
|
||||
)
|
||||
|
||||
|
||||
_REGION_COUNT: Final[int] = 22
|
||||
_MONTHS_PER_YEAR: Final[int] = 12
|
||||
|
||||
|
||||
def _validate_month(month: int) -> None:
|
||||
if not 1 <= month <= _MONTHS_PER_YEAR:
|
||||
raise ValueError(f"month must be 1..12 (January = 1), got {month}")
|
||||
|
||||
|
||||
def _validate(region: int, month: int) -> None:
|
||||
if not 0 <= region < _REGION_COUNT:
|
||||
raise ValueError(
|
||||
f"region must be 0..{_REGION_COUNT - 1} (SAP climate region; "
|
||||
f"0 = UK average), got {region}"
|
||||
)
|
||||
_validate_month(month)
|
||||
|
||||
|
||||
def external_temperature_c(
|
||||
region_or_climate: "int | PostcodeClimate", month: int
|
||||
) -> float:
|
||||
"""Mean external temperature (°C) per month. Accepts either a SAP region
|
||||
index (0..21) for the Appendix U fallback tables, or a `PostcodeClimate`
|
||||
record for postcode-specific demand-cascade values from PCDB Table 172."""
|
||||
if isinstance(region_or_climate, PostcodeClimate):
|
||||
_validate_month(month)
|
||||
return region_or_climate.monthly_external_temp_c[month - 1]
|
||||
_validate(region_or_climate, month)
|
||||
return _TABLE_U1[region_or_climate][month - 1]
|
||||
|
||||
|
||||
def wind_speed_m_per_s(
|
||||
region_or_climate: "int | PostcodeClimate", month: int
|
||||
) -> float:
|
||||
"""Mean wind speed (m/s) per month. Accepts either a SAP region index
|
||||
(0..21) or a `PostcodeClimate` record."""
|
||||
if isinstance(region_or_climate, PostcodeClimate):
|
||||
_validate_month(month)
|
||||
return region_or_climate.monthly_wind_speed_m_per_s[month - 1]
|
||||
_validate(region_or_climate, month)
|
||||
return _TABLE_U2[region_or_climate][month - 1]
|
||||
|
||||
|
||||
# Table U3 — Mean global solar irradiance on a horizontal plane (W/m²),
|
||||
# 22 regions × 12 months. Used (with Table U3 declination + per-window
|
||||
# orientation/pitch) to derive surface flux for solar-gains calculation
|
||||
# (SAP 10.2 §6.1).
|
||||
_TABLE_U3: Final[tuple[tuple[float, ...], ...]] = (
|
||||
(26, 54, 96, 150, 192, 200, 189, 157, 115, 66, 33, 21), # 0 UK average
|
||||
(30, 56, 98, 157, 195, 217, 203, 173, 127, 73, 39, 24), # 1 Thames
|
||||
(32, 59, 104, 170, 208, 231, 216, 182, 133, 77, 41, 25), # 2 South East England
|
||||
(35, 62, 109, 172, 209, 235, 217, 185, 138, 80, 44, 27), # 3 Southern England
|
||||
(36, 63, 111, 174, 210, 233, 204, 182, 136, 78, 44, 28), # 4 South West England
|
||||
(32, 59, 105, 167, 201, 226, 206, 175, 130, 74, 40, 25), # 5 Severn Wales / Severn England
|
||||
(28, 55, 97, 153, 191, 208, 194, 163, 121, 69, 35, 23), # 6 Midlands
|
||||
(24, 51, 95, 152, 191, 203, 186, 152, 115, 65, 31, 20), # 7 West Pennines Wales / West Pennines England
|
||||
(23, 51, 95, 157, 200, 203, 194, 156, 113, 62, 30, 19), # 8 North West England / South West Scotland
|
||||
(23, 50, 92, 151, 200, 196, 187, 153, 111, 61, 30, 18), # 9 Borders Scotland / Borders England
|
||||
(25, 51, 95, 152, 196, 198, 190, 156, 115, 64, 32, 20), # 10 North East England
|
||||
(26, 54, 96, 150, 192, 200, 189, 157, 115, 66, 33, 21), # 11 East Pennines
|
||||
(30, 58, 101, 165, 203, 220, 206, 173, 128, 74, 39, 24), # 12 East Anglia
|
||||
(29, 57, 104, 164, 205, 220, 199, 167, 120, 68, 35, 22), # 13 Wales
|
||||
(19, 46, 88, 148, 196, 193, 185, 150, 101, 55, 25, 15), # 14 West Scotland
|
||||
(21, 46, 89, 146, 198, 191, 183, 150, 106, 57, 27, 15), # 15 East Scotland
|
||||
(19, 45, 89, 143, 194, 188, 177, 144, 101, 54, 25, 14), # 16 North East Scotland
|
||||
(17, 43, 85, 145, 189, 185, 170, 139, 98, 51, 22, 12), # 17 Highland
|
||||
(16, 41, 87, 155, 205, 206, 185, 148, 101, 51, 21, 11), # 18 Western Isles
|
||||
(14, 39, 84, 143, 205, 201, 178, 145, 100, 50, 19, 9), # 19 Orkney
|
||||
(12, 34, 79, 135, 196, 190, 168, 144, 90, 46, 16, 7), # 20 Shetland
|
||||
(24, 52, 96, 155, 201, 198, 183, 150, 107, 61, 30, 18), # 21 Northern Ireland
|
||||
)
|
||||
|
||||
|
||||
def horizontal_solar_irradiance_w_per_m2(
|
||||
region_or_climate: "int | PostcodeClimate", month: int,
|
||||
) -> float:
|
||||
"""Mean global solar irradiance on a horizontal plane (W/m²). Accepts
|
||||
either a SAP region index (0..21) or a `PostcodeClimate` record. The
|
||||
starting point for the per-orientation surface-flux calculation in
|
||||
SAP 10.2 §6.1."""
|
||||
if isinstance(region_or_climate, PostcodeClimate):
|
||||
_validate_month(month)
|
||||
return region_or_climate.monthly_horizontal_solar_w_per_m2[month - 1]
|
||||
_validate(region_or_climate, month)
|
||||
return float(_TABLE_U3[region_or_climate][month - 1])
|
||||
|
||||
|
||||
# Table U3 footer — Solar declination (°), region-independent (function of
|
||||
# month only). Used together with site latitude and the surface tilt to
|
||||
# convert horizontal irradiance to per-orientation surface flux.
|
||||
_SOLAR_DECLINATION: Final[tuple[float, ...]] = (
|
||||
-20.7, -12.8, -1.8, 9.8, 18.8, 23.1, 21.2, 13.7, 2.9, -8.7, -18.4, -23.0,
|
||||
)
|
||||
|
||||
|
||||
def solar_declination_deg(month: int) -> float:
|
||||
"""Solar declination angle (°) for the given month. SAP 10.2 Appendix U
|
||||
Table U3 footer — independent of region."""
|
||||
_validate_month(month)
|
||||
return _SOLAR_DECLINATION[month - 1]
|
||||
0
domain/sap10_calculator/climate/tests/__init__.py
Normal file
0
domain/sap10_calculator/climate/tests/__init__.py
Normal file
148
domain/sap10_calculator/climate/tests/test_appendix_u.py
Normal file
148
domain/sap10_calculator/climate/tests/test_appendix_u.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Tests for SAP 10.3 Appendix U climate-data lookups.
|
||||
|
||||
Reference: SAP 10.3 specification (DESNZ/BRE, 13-01-2026), Appendix U:
|
||||
Table U1 mean external temperature, Table U2 wind speed, Table U3 mean
|
||||
global solar irradiance on a horizontal plane and monthly solar declination.
|
||||
22 regions (0 = UK average, 1-21 = SAP climate regions) by 12 months.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.climate.appendix_u import (
|
||||
external_temperature_c,
|
||||
horizontal_solar_irradiance_w_per_m2,
|
||||
solar_declination_deg,
|
||||
wind_speed_m_per_s,
|
||||
)
|
||||
|
||||
|
||||
def test_external_temperature_uk_average_january_returns_table_u1_value() -> None:
|
||||
# Arrange — SAP 10.3 Appendix U Table U1: Region 0 (UK average), January.
|
||||
|
||||
# Act
|
||||
result = external_temperature_c(region=0, month=1)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(4.3, abs=0.05)
|
||||
|
||||
|
||||
def test_external_temperature_thames_july_returns_named_region_value() -> None:
|
||||
# Arrange — Table U1: Region 1 (Thames), July. Hotter than the UK average
|
||||
# in summer — sanity check that named regions diverge from region 0.
|
||||
|
||||
# Act
|
||||
result = external_temperature_c(region=1, month=7)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(17.9, abs=0.05)
|
||||
|
||||
|
||||
def test_wind_speed_uk_average_january_returns_table_u2_value() -> None:
|
||||
# Arrange — Table U2 row 0 (UK average) column Jan -> 5.1 m/s. Used by the
|
||||
# SAP infiltration calc (worksheet lines 9-16).
|
||||
|
||||
# Act
|
||||
result = wind_speed_m_per_s(region=0, month=1)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(5.1, abs=0.05)
|
||||
|
||||
|
||||
def test_horizontal_solar_irradiance_uk_average_july_returns_table_u3_value() -> None:
|
||||
# Arrange — Table U3 row 0 (UK average) column Jul -> 189 W/m². Peak month
|
||||
# for global horizontal irradiance in the UK.
|
||||
|
||||
# Act
|
||||
result = horizontal_solar_irradiance_w_per_m2(region=0, month=7)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(189.0, abs=0.5)
|
||||
|
||||
|
||||
def test_horizontal_solar_irradiance_southern_england_brighter_than_shetland() -> None:
|
||||
# Arrange — Table U3 row 3 (Southern England) Jun -> 235, row 20 (Shetland)
|
||||
# Jun -> 190. Higher-latitude regions get less June irradiance.
|
||||
|
||||
# Act
|
||||
south = horizontal_solar_irradiance_w_per_m2(region=3, month=6)
|
||||
shetland = horizontal_solar_irradiance_w_per_m2(region=20, month=6)
|
||||
|
||||
# Assert
|
||||
assert south == pytest.approx(235.0, abs=0.5)
|
||||
assert shetland == pytest.approx(190.0, abs=0.5)
|
||||
assert south > shetland
|
||||
|
||||
|
||||
def test_solar_declination_winter_solstice_returns_table_u3_value() -> None:
|
||||
# Arrange — Table U3 footer "Solar declination" row: December = -23.0°.
|
||||
# Declination is region-independent (function only of month).
|
||||
|
||||
# Act
|
||||
result = solar_declination_deg(month=12)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(-23.0, abs=0.05)
|
||||
|
||||
|
||||
def test_solar_declination_summer_solstice_positive_value() -> None:
|
||||
# Arrange — Table U3 footer: June declination = +23.1°.
|
||||
|
||||
# Act
|
||||
result = solar_declination_deg(month=6)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(23.1, abs=0.05)
|
||||
|
||||
|
||||
def test_external_temperature_out_of_range_region_raises_value_error() -> None:
|
||||
# Arrange — there are 22 regions (0-21); 22 is the first invalid index.
|
||||
# The callers (postcode resolver in particular) should fail fast on a
|
||||
# bad region rather than silently aliasing to row 0 or wrapping around.
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="region"):
|
||||
external_temperature_c(region=22, month=1)
|
||||
with pytest.raises(ValueError, match="region"):
|
||||
external_temperature_c(region=-1, month=1)
|
||||
|
||||
|
||||
def test_region_21_northern_ireland_returns_table_u1_value() -> None:
|
||||
# Arrange — region 21 (Northern Ireland) is the last valid region. Catches
|
||||
# off-by-one errors in the region-bound check (would otherwise reject 21).
|
||||
# Table U1 row 21 July -> 15.0 °C.
|
||||
|
||||
# Act
|
||||
result = external_temperature_c(region=21, month=7)
|
||||
|
||||
# Assert
|
||||
assert result == pytest.approx(15.0, abs=0.05)
|
||||
|
||||
|
||||
def test_out_of_range_month_raises_value_error_on_every_lookup() -> None:
|
||||
# Arrange — months are 1..12. Month 0 and month 13 must reject across
|
||||
# all four climate lookups, including the region-independent declination.
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
external_temperature_c(region=0, month=0)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
wind_speed_m_per_s(region=0, month=13)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
horizontal_solar_irradiance_w_per_m2(region=0, month=0)
|
||||
with pytest.raises(ValueError, match="month"):
|
||||
solar_declination_deg(month=13)
|
||||
|
||||
|
||||
def test_wind_speed_shetland_january_higher_than_thames() -> None:
|
||||
# Arrange — Table U2 row 20 (Shetland), the windiest UK region by a wide
|
||||
# margin: 9.5 m/s in January vs Thames 4.2 m/s. Sanity check the table is
|
||||
# populated for the upper region indices, not silently aliasing to row 0.
|
||||
|
||||
# Act
|
||||
shetland = wind_speed_m_per_s(region=20, month=1)
|
||||
thames = wind_speed_m_per_s(region=1, month=1)
|
||||
|
||||
# Assert
|
||||
assert shetland == pytest.approx(9.5, abs=0.05)
|
||||
assert thames == pytest.approx(4.2, abs=0.05)
|
||||
assert shetland > thames
|
||||
194
domain/sap10_calculator/docs/HANDOVER_NEXT.md
Normal file
194
domain/sap10_calculator/docs/HANDOVER_NEXT.md
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
# Handover — API → SAP integration test
|
||||
|
||||
The SAP 10.2 / RdSAP 10 calculator is **closed**: 930/930 pin tests
|
||||
green against the 6 Elmhurst U985 worksheet PDFs (Rating cascade for
|
||||
SAP rating + EI rating; Demand cascade for EPC Current Carbon +
|
||||
Current Primary Energy). Architecture + public API live in
|
||||
[`SAP_CALCULATOR.md`](./SAP_CALCULATOR.md) — **read that first.**
|
||||
|
||||
Your job: build an integration test that runs **API request → cert →
|
||||
SAP scoring** end-to-end against this calculator, using the 6 Elmhurst
|
||||
fixtures as the strongest test case in the repo.
|
||||
|
||||
---
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
A test (probably under `backend/` somewhere, exact location TBD by
|
||||
the codebase shape) that:
|
||||
|
||||
1. Spins up the API (FastAPI or whatever the http surface is).
|
||||
2. Sends a request with a representative `EpcPropertyData` payload
|
||||
(use one of the 6 Elmhurst fixtures' `build_epc()` outputs as the
|
||||
reference, or send the upstream JSON shape if that's the boundary).
|
||||
3. Receives the 4 EPC-facing outputs back through whatever endpoint the
|
||||
API exposes them on (or invokes the SAP scoring code path the API
|
||||
would use internally).
|
||||
4. Asserts the 4 outputs match the fixture's lodged values at the
|
||||
stated tolerance:
|
||||
- `sap_score` (integer, exact match)
|
||||
- `ei_rating` (integer, exact match)
|
||||
- `current_carbon_kg` (`abs=1e-4` against `DEMAND_LINE_272_TOTAL_CO2`)
|
||||
- `current_pe_kwh` (`abs=1e-4` against `DEMAND_LINE_286_TOTAL_PE`)
|
||||
|
||||
Parametrise the test over all 6 fixtures so any regression in the
|
||||
plumbing fails loudly.
|
||||
|
||||
---
|
||||
|
||||
## What's in the box
|
||||
|
||||
### Public API (the only thing you need from the SAP module)
|
||||
|
||||
```python
|
||||
from domain.sap10_calculator.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
|
||||
```
|
||||
|
||||
See `SAP_CALCULATOR.md` §2 for the recommended `dwelling_outputs(epc)`
|
||||
function shape — copy-paste it as your reference scoring path.
|
||||
|
||||
### Fixture cohort (the most comprehensive test case in the repo)
|
||||
|
||||
6 real-world certs with full PDF ground-truth:
|
||||
|
||||
| Fixture | TFA | Notable cert-shape features |
|
||||
|---|---|---|
|
||||
| `_elmhurst_worksheet_000474` | 56.79 | Main + 2 ext, gas combi, no secondary |
|
||||
| `_elmhurst_worksheet_000477` | 77.58 | RR main-only, electric secondary |
|
||||
| `_elmhurst_worksheet_000480` | 84.41 | Main + ext + RR, electric secondary |
|
||||
| `_elmhurst_worksheet_000487` | 81.57 | RR + ext + alt-wall, **electric shower** |
|
||||
| `_elmhurst_worksheet_000490` | 66.06 | Main + ext |
|
||||
| `_elmhurst_worksheet_000516` | 90.54 | Main only |
|
||||
|
||||
Each fixture exposes:
|
||||
- `build_epc() -> EpcPropertyData` — encode the cert as our domain type
|
||||
- `LINE_*` — rating-cascade worksheet expected values (Block 1)
|
||||
- `DEMAND_LINE_*` — demand-cascade worksheet expected values (Block 2)
|
||||
- `SAP_VALUE_CONTINUOUS` / `LINE_258_SAP_RATING_INTEGER` — SAP rating
|
||||
- `LINE_274_EI_RATING_INTEGER` — EI rating
|
||||
|
||||
Expected EPC outputs per fixture:
|
||||
|
||||
| | sap_score | ei_rating | current_carbon_kg | current_pe_kwh |
|
||||
|---|---|---|---|---|
|
||||
| 000474 | 62 | 60 | 3104.1222 | 16931.7227 |
|
||||
| 000477 | 65 | 69 | 2879.7824 | 16545.4543 |
|
||||
| 000480 | 61 | 65 | 3479.1552 | 19953.4189 |
|
||||
| 000487 | 62 | 69 | 3005.2667 | 17755.3174 |
|
||||
| 000490 | 57 | 61 | 3250.1703 | 18583.7962 |
|
||||
| 000516 | 63 | 66 | 3501.4376 | 20087.8232 |
|
||||
|
||||
---
|
||||
|
||||
## What you'll need to investigate
|
||||
|
||||
The SAP calculator side is a pure-Python function chain — easy. The API
|
||||
side is what you need to map out:
|
||||
|
||||
1. **Where does cert data enter the system?** Find the FastAPI / Django
|
||||
/ whatever endpoint that accepts cert input. Look under `backend/`
|
||||
for routers.
|
||||
2. **What's the request payload shape?** Is it `EpcPropertyData` JSON
|
||||
directly, or a different upstream representation that gets mapped?
|
||||
Check `datatypes/epc/domain/mapper.py` — the mapper from various
|
||||
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
|
||||
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
|
||||
surface (per-section breakdown for retrofit modelling, etc.).
|
||||
|
||||
If the API doesn't yet expose SAP scoring, the integration test scope
|
||||
might include adding the endpoint. Confirm scope with the user before
|
||||
expanding.
|
||||
|
||||
---
|
||||
|
||||
## Workflow conventions (from the SAP cleanup work)
|
||||
|
||||
- **AAA tests** — `# Arrange / # Act / # Assert` headers on every new
|
||||
test.
|
||||
- **One slice = one commit** with Co-Authored-By trailer.
|
||||
- **`pytest.approx(..., abs=1e-4)` for the EPC outputs** — same bar as
|
||||
the SAP cascade tests. The 4 expected values above are at 4 d.p. so
|
||||
abs=1e-4 is the floor.
|
||||
- **Don't widen tolerances.** If a pin fails, it's a real bug (probably
|
||||
in the API plumbing, since the calculator is closed).
|
||||
|
||||
---
|
||||
|
||||
## Files to read on day 1
|
||||
|
||||
| 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 |
|
||||
| `backend/` (explore) | API entry points |
|
||||
| [`datatypes/epc/domain/mapper.py`](../../datatypes/epc/domain/mapper.py) | Schema → EpcPropertyData mappers |
|
||||
|
||||
---
|
||||
|
||||
## Quick orient
|
||||
|
||||
```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 \
|
||||
--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 (
|
||||
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
|
||||
epc = w.build_epc()
|
||||
pc = local_climate_for_cert(epc)
|
||||
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
|
||||
env_rating = environmental_section_from_cert(epc)
|
||||
env_demand = environmental_section_from_cert(epc, postcode_climate=pc)
|
||||
pe_demand = primary_energy_section_from_cert(epc, postcode_climate=pc)
|
||||
print(f'SAP: {rating.sap_score}') # 62 (UK-avg)
|
||||
print(f'EI: {env_rating.ei_rating_integer}') # 60 (UK-avg)
|
||||
print(f'Carbon: {env_demand.total_co2_kg_per_yr:.4f} kg/yr') # 3104.1222 (postcode)
|
||||
print(f'PE: {pe_demand.total_pe_kwh_per_yr:.4f} kWh/yr') # 16931.7227 (postcode)
|
||||
"
|
||||
```
|
||||
|
||||
**Important:** SAP rating and EI rating use UK-average climate; Current
|
||||
Carbon and Current Primary Energy use postcode climate. Don't read EI
|
||||
from the demand-cascade `environmental_section_from_cert` — that's a
|
||||
postcode-conditions EI value, not what the EPC publishes.
|
||||
|
||||
---
|
||||
|
||||
## What's NOT in scope
|
||||
|
||||
- **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
|
||||
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
|
||||
cover SAP + EI + Carbon + PE; Fuel Bill is a follow-up.
|
||||
- **The Demand-SAP "improved dwelling" cascade.** That's Block 3 of the
|
||||
U985 worksheet (retrofit-applied SAP rating). Out of scope.
|
||||
|
||||
Good luck. The SAP side is solid; this is purely a plumbing exercise.
|
||||
301
domain/sap10_calculator/docs/NEXT_AGENT_PROMPT.md
Normal file
301
domain/sap10_calculator/docs/NEXT_AGENT_PROMPT.md
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
# Handover — API mapper at 1e-4 on cert 001479; investigating goldens
|
||||
|
||||
You are picking up branch `ara-backend-design-prd`. The cert 001479 API
|
||||
path now hits the worksheet's continuous SAP 69.0094 **at < 1e-4**
|
||||
(Slice 95). Layer 4 production goal is MET. Remaining work: investigate
|
||||
golden cert residual outliers (especially cert 0240's -15 SAP) and
|
||||
process any new (Summary + API) cert pairs the user sources.
|
||||
|
||||
## The end goal (re-confirmed by the user)
|
||||
|
||||
> **Production goal: `API JSON → EpcPropertyDataMapper.from_api_
|
||||
> response → SAP10 calculator → SAP rating` must match the SAP value
|
||||
> the calculator emitted at lodge time to within 1e-4.**
|
||||
>
|
||||
> The acceptance tolerance is **1e-4 against the worksheet's
|
||||
> continuous SAP value**, not ±0.5 against the published integer.
|
||||
> ±0.5 only applies when no worksheet is available (the 8 cohort
|
||||
> golden certs we have as API-only); when we have both API + worksheet
|
||||
> (cert 001479), the 1e-4 bar is the bar.
|
||||
|
||||
The earlier handover stated ±0.5 — that was wrong. The user
|
||||
emphasised this twice: the calc is mechanical, identical inputs must
|
||||
produce identical outputs, so when we have the continuous worksheet
|
||||
value we should hit it exactly. See the conversation thread that led
|
||||
to Slice 87.
|
||||
|
||||
## Validation layers (current state)
|
||||
|
||||
```
|
||||
Layer 4: API mapper cascade SAP = worksheet SAP at 1e-4 (production goal)
|
||||
└── Layer 3: API mapper EpcPropertyData ≡ Elmhurst mapper EpcPropertyData
|
||||
└── Layer 2: Elmhurst-mapped EpcPropertyData → cascade SAP = worksheet SAP at 1e-4
|
||||
└── Layer 1: hand-built EpcPropertyData → cascade SAP = worksheet SAP at 1e-4
|
||||
```
|
||||
|
||||
| Layer | Status |
|
||||
|---|---|
|
||||
| **1 — hand-built cascade pin** | ✅ 6 cohort certs (000474, 000477, 000480, 000487, 000490, 000516) GREEN at 1e-4; cert 001479 hand-built skeleton (Slice 62) still RED (2 of 11 pins green, hand-built has its own bugs — orthogonal to the production path) |
|
||||
| **2 — Elmhurst-mapped path** | ✅ **Cert 001479 GREEN at 1e-4** (Slice 89); cohort: 2 GREEN (000477, 000516), 4 RED (000474, 000480, 000487, 000490 — Elmhurst U985 worksheets violate the RdSAP 10 §5 (12) spec; orthogonal to the production goal) |
|
||||
| **3 — API-mapped ≡ Elmhurst-mapped (field-level)** | 🟡 Cascade outputs match at 1e-4 (Slice 95); field-level diff test not yet written but lower priority since cascade-output gate exists |
|
||||
| **4 — API path cascade SAP** | ✅ **Cert 001479 GREEN at 1e-4** (Slice 95). `test_api_001479_full_chain_sap_matches_worksheet_pdf_exactly` formalises the gate. 8 other golden certs pinned at residual-from-integer at tolerance 0 |
|
||||
|
||||
## Cumulative API SAP delta progression (cert 001479)
|
||||
|
||||
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
|
||||
series of API-mapper coverage gaps that all needed fixing for the
|
||||
spec rule's premise to be met. Each slice closed one gap:
|
||||
|
||||
| Slice | Fix | API SAP delta |
|
||||
|---|---|---|
|
||||
| baseline | broken party wall enum, no descriptive strings | **+3.0752** |
|
||||
| 87 | RdSAP 10 §5 (12) spec rule + Elmhurst-mapper switch to None | — |
|
||||
| 88 | thread `bp.floor_construction_type` into `u_floor` cascade | — |
|
||||
| 89 | PS pitched-sloping-ceiling roof area `÷ cos(30°)` (added `roof_construction_type` field on `SapBuildingPart`) | — |
|
||||
| 90 | API `party_wall_construction` enum → SAP10 `u_party_wall` codes (1→3 Solid, 2→4 Cavity, etc.) | +1.5298 |
|
||||
| 91 | descriptive strings via int→str lookups (`floor_construction_type`, `roof_construction_type`) + pre-1950 PS sloping → thickness=0 + per-bp roof description fix | +1.0970 |
|
||||
| 92 | upper-floor `room_height_m += 0.25` + `is_exposed_floor` from `floor_heat_loss==1` + `floor_insulation_thickness="NI"→None` | +1.0022 |
|
||||
| 93 | `window_transmission_details` from `glazing_type` int (code 3 → U=2.8/g=0.76, code 13 → U=1.4/g=0.72) | +1.1846 |
|
||||
| 94 | `sheltered_sides` from API `built_form` + `floor_type` from `floor_heat_loss==7` | +0.0006 |
|
||||
| 95 | API mapper `total_floor_area_m2` = Σ per-bp dims (worksheet-precise 68.51 not lodged-rounded 69) + RdSAP 10 §15 p.66 window 2dp area rounding in solar_gains/internal_gains | **< 1e-4** |
|
||||
|
||||
Fabric breakdown for cert 001479 API path is now COMPLETELY EXACT
|
||||
(all 6 components match worksheet to 4 d.p.):
|
||||
|
||||
| Component | Cascade | Worksheet target |
|
||||
|---|---|---|
|
||||
| walls | 39.7652 | 39.7652 ✓ |
|
||||
| party walls | 17.0700 | 17.0700 ✓ |
|
||||
| roof | 10.3438 | 10.3438 ✓ |
|
||||
| floor | 23.1705 | 23.1705 ✓ |
|
||||
| windows | 43.5962 | 43.5962 ✓ |
|
||||
| doors | 5.5500 | 5.5500 ✓ |
|
||||
| **fabric total** | **139.4957** | **139.4957 ✓** |
|
||||
|
||||
## What's left (queue, in priority order)
|
||||
|
||||
### 1. Close cert 001479's residual 0.0006 SAP gap (1-3 slices)
|
||||
|
||||
The remaining gap is non-fabric. Diff against the Summary path's
|
||||
intermediate cascade values (which lands at 1e-4 GREEN):
|
||||
|
||||
```
|
||||
Σ internal_gains_monthly_w: API 5339.27 Sum 5313.55 delta +25.72
|
||||
Σ solar_gains_monthly_w: API 5510.10 Sum 5508.60 delta +1.50
|
||||
Σ mean_internal_temp_monthly_c: API 214.87 Sum 213.51 delta +1.35
|
||||
Σ monthly_infiltration_ach: API 8.95 Sum 10.91 delta -1.96
|
||||
hot_water_kwh_per_yr: API 2365.00 Sum 2358.31 delta +6.69
|
||||
```
|
||||
|
||||
Specifically:
|
||||
- **Infiltration is still under by ~2 ACH/year**. The (12) spec rule
|
||||
applies on both paths now (after Slice 87), so it's something else
|
||||
— possibly `has_draught_lobby` (API=None, Summary=False; cascade
|
||||
treats both as False so it shouldn't matter; verify) or `(13)
|
||||
draught_lobby_ach`. Or storey count. Probe with
|
||||
`ventilation_from_cert(api_mapped)` vs `ventilation_from_cert(sum_
|
||||
mapped)`.
|
||||
- **HW kWh +6.7** suggests a small Appendix J §1a occupancy
|
||||
difference, or a different Tcold series, or shower outlets.
|
||||
- **Internal gains +25.7 W·months** — probably a pumps_fans count or
|
||||
lighting bulb count mismatch.
|
||||
|
||||
Run the diff probe (the one from the conversation) to localise:
|
||||
```bash
|
||||
PYTHONPATH=/workspaces/model:/workspaces/model/packages/domain/src python -c "
|
||||
from backend.documents_parser.tests.test_summary_pdf_mapper_chain import _diff_load_bearing, _LOAD_BEARING_FIELDS, _summary_pdf_to_textract_style_pages
|
||||
from backend.documents_parser.elmhurst_extractor import ElmhurstSiteNotesExtractor
|
||||
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_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()
|
||||
sum_mapped = EpcPropertyDataMapper.from_elmhurst_site_notes(sn)
|
||||
diffs = []
|
||||
for f in _LOAD_BEARING_FIELDS:
|
||||
diffs.extend(_diff_load_bearing(getattr(api_mapped, f, None), getattr(sum_mapped, f, None), f))
|
||||
print(f'{len(diffs)} load-bearing divergences')
|
||||
for d in diffs[:40]: print(f' {d}')
|
||||
"
|
||||
```
|
||||
|
||||
(NB: the original `_diff_load_bearing` was written for cohort
|
||||
diff tests; the helper signature is `mapped, hand_built, path` — pass
|
||||
api_mapped as `mapped` and sum_mapped as `hand_built` to surface API
|
||||
gaps.)
|
||||
|
||||
### 2. Layer 3 — write the API ≡ Elmhurst diff test (1 slice)
|
||||
|
||||
Add `test_from_api_response_matches_from_elmhurst_site_notes_001479`
|
||||
in `backend/documents_parser/tests/test_summary_pdf_mapper_chain.py`,
|
||||
mirroring the cohort `test_from_elmhurst_site_notes_matches_hand_
|
||||
built_NNNNNN` pattern. Use `_diff_load_bearing` with `_LOAD_BEARING_
|
||||
FIELDS`. This formalises Layer 3 as a 1e-4 gate (zero load-bearing
|
||||
divergences between the two mapper outputs).
|
||||
|
||||
This test will start RED with the residual diffs from step 1; closing
|
||||
those slices brings it to GREEN.
|
||||
|
||||
### 3. More cert pairs (user is sourcing — pause for new data)
|
||||
|
||||
The user has agreed to source 2-3 more (Elmhurst worksheet + GOV.UK
|
||||
API JSON) pairs to validate the mapper isn't 001479-overfit.
|
||||
Suggested diversity:
|
||||
|
||||
- **Detached + RR** (would fix cert 0240's -14 residual which has a
|
||||
Type-1 RR the mapper doesn't extract).
|
||||
- **Mid-terrace with cavity-filled party walls** (API party_wall_
|
||||
construction=3 → spec U=0.2; currently mapped to SAP10 code 4
|
||||
which gives U=0.5; needs cascade extension at
|
||||
`u_party_wall`).
|
||||
- **Flat / maisonette** (party wall U=0 path; cert 9390 is one but
|
||||
no worksheet).
|
||||
- **Different age band** (E, J, K, L) to exercise the (12) spec
|
||||
rule's age boundaries.
|
||||
|
||||
Each new pair lands as a 1e-4 cascade-pin test. Pattern: ~3-5 new
|
||||
mapper bugs per cert pair (similar to Slice 87-94 on 001479). Each
|
||||
becomes its own slice. Stage by name; one slice = one commit.
|
||||
|
||||
### 4. Investigate goldens with shifted residuals after Slices 87-95
|
||||
|
||||
Slices 87-94 shifted residuals on 7 of 10 API-only golden certs;
|
||||
Slice 95 (precise TFA + window 2dp area rounding) shifted 5 more
|
||||
(0240, 6035, 8135, 2130, 0390-2254). All residuals are re-pinned.
|
||||
Current outliers and what we now know:
|
||||
|
||||
- **0240** (-15 SAP, +17.8 PE): Detached age J + RR + 11 windows. The
|
||||
earlier handover claim of "RR mapper gap" is **partly stale**:
|
||||
- `room_in_roof_type_1.gable_wall_length_1/2` ARE extracted by the
|
||||
21.0.1 mapper (see mapper.py:1349-1369 — must have landed in
|
||||
Slices 71-86). Cert 0240's RR cascades through with floor_area=
|
||||
83.2, gables 6.4 + 6.4, age J → U_RR = 0.30 W/m²K.
|
||||
- `'Roof room(s), insulated (assumed)'` description NOT parsed —
|
||||
but the spec basis for parsing it is unclear: age J's Table 18
|
||||
col(4) default already models insulation (U=0.30), and unlike
|
||||
the regular-roof "insulated (assumed)" → 50 mm bucket rule
|
||||
(RdSAP §5.11.4), no equivalent rule for RR has been identified.
|
||||
- The -15 SAP residual is a mix, not a single RR gap. Subsystem
|
||||
breakdown for cert 0240 (via cert_to_inputs cascade):
|
||||
- walls 22.95, party 0, roof 76.93 (incl RR ~18.5), floor 29.43,
|
||||
windows 41.55, doors 11.10, bridging 39.64; total HLC 221.6 W/K
|
||||
- **windows_w_per_k = 41.55 is the most leverageable**: 11
|
||||
windows × 18.28 m² × U_default ≈ 2.27 W/m²K. Cert lodges
|
||||
`glazing_type=2` for all windows but Slice 93's
|
||||
`_API_GLAZING_TYPE_TO_TRANSMISSION` only covers codes 3 and 13;
|
||||
surfacing code 2 would land a measurable U (likely ~1.8-2.0)
|
||||
and close several W/K of fabric loss.
|
||||
- Other potential gains: BP[0] non-RR ceiling lodges "Pitched,
|
||||
400+ mm loft insulation" (should U ~0.10); verify cascade
|
||||
gives it that.
|
||||
- **Net**: cert 0240 is not a single-slice fix; it's 3-5
|
||||
progressive mapper improvements (glazing_type 2 surfacing,
|
||||
possibly more glazing codes, possibly RR description nuance).
|
||||
- **0390-2954** (-6 SAP, -26.5 PE): large detached F (TFA 360), oil
|
||||
PCDB-listed. Undocumented. PE going more negative than SAP suggests
|
||||
the cost cascade is hitting harder than energy — possibly oil
|
||||
price/efficiency interaction.
|
||||
- **6035** (-6 SAP, +49.5 PE): mid-terrace age A + RR. Probably has
|
||||
the same glazing_type-default-U issue as 0240 plus an age-A-
|
||||
specific gap.
|
||||
|
||||
### 5. (deferred) Cohort chain test RED triage
|
||||
|
||||
4 cohort chain tests (000474, 000480, 000487, 000490) are RED
|
||||
because the Elmhurst U985 worksheets emit (12) values that don't
|
||||
follow RdSAP 10 §5 — see the conversation re: identical Summary §9
|
||||
lodgements producing different worksheet (12) for cohort 000477 vs
|
||||
000480. The cascade is now spec-correct; the Elmhurst tool isn't.
|
||||
Options: (a) mark as known-Elmhurst-non-spec, (b) add per-cert
|
||||
override field, (c) wait for more cert pairs to confirm pattern.
|
||||
**Not blocking the production goal.**
|
||||
|
||||
## Key conventions (project memory)
|
||||
|
||||
- **AAA test convention** — every new test uses literal `# Arrange /
|
||||
# Act / # Assert` headers.
|
||||
- **`abs(diff) <= tol`** not `pytest.approx` (strict-pyright partial-
|
||||
unknown).
|
||||
- **One slice = one commit** — stage by name (`git add <path>`).
|
||||
- **1e-4 tolerance** for the worksheet-comparable paths (Elmhurst
|
||||
Summary + API both have worksheets for cert 001479). No widening,
|
||||
no xfail.
|
||||
- **Strict pyright net-zero** per file. Baselines: `mapper.py` 33,
|
||||
`heat_transmission.py` 13, `cert_to_inputs.py` 35,
|
||||
`epc_property_data.py` 0.
|
||||
- **Spec citation in commit messages** — when a slice implements a
|
||||
spec rule, quote the spec text (RdSAP 10 page reference). User
|
||||
asked us to confirm against docs.
|
||||
|
||||
## Cached artefacts
|
||||
|
||||
- `domain/sap10_calculator/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` —
|
||||
Elmhurst site-notes PDF for cert 001479.
|
||||
- `sap worksheets/lodged example/P960-0001-001479.pdf` — Domna's
|
||||
worksheet output for cert 001479 (Continuous SAP 69.0094).
|
||||
- `sap worksheets/U985-0001-NNNNNN.pdf` × 6 — cohort Elmhurst
|
||||
worksheets (000474, 000477, 000480, 000487, 000490, 000516).
|
||||
- `sap worksheets/U985-0001-NNNNNN.txt` × 6 — text exports of above.
|
||||
|
||||
## Recent slice history (Slices 87-95, current branch)
|
||||
|
||||
```
|
||||
f502db8c Slice 95: API mapper TFA from per-bp dims + window area 2dp rounding — cert 001479 to 1e-4
|
||||
03203418 Slice 94: API mapper sheltered_sides + floor_type — cert 001479 to 1e-3
|
||||
7281b7b3 Slice 93: API mapper window_transmission_details from glazing_type
|
||||
8e752e57 Slice 92: API mapper floor dimensions (SAP +0.25m + exposed-floor + NI→None)
|
||||
2cebba28 Slice 91: API mapper descriptive strings + roof description per-bp fix
|
||||
fbbdca49 Slice 90: API mapper translates party_wall_construction → SAP10 enum
|
||||
006e9842 Slice 89: PS pitched-sloping-ceiling roof area uses inclined surface
|
||||
c40679d1 Slice 88: thread bp.floor_construction_type into u_floor cascade
|
||||
aff331ff Slice 87: implement RdSAP 10 §5 (12) spec rule for suspended timber floor
|
||||
2d3355ee Slice 86: 1:1 windows expansion in cohort 000516 (2 → 5 entries)
|
||||
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`
|
||||
before this rewrite).
|
||||
|
||||
## First action
|
||||
|
||||
1. Confirm branch state — Slice 95 (`f502db8c`) closed cert 001479 to
|
||||
< 1e-4 (was +0.0006 after Slice 94). Layer 4 is GREEN.
|
||||
2. Run the full sweep:
|
||||
```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 \
|
||||
--no-cov -q
|
||||
```
|
||||
Expect **99 passed / 19 failed**. All 19 failures pre-existing:
|
||||
9× hand-built 001479 skeleton (`test_sap_result_pin[001479-*]`),
|
||||
6× cohort diff (`test_from_elmhurst_site_notes_matches_hand_built_*`),
|
||||
4× cohort chain (000474/000480/000487/000490 — Elmhurst non-spec).
|
||||
3. Production goal is met for cert 001479. Next work focuses on the
|
||||
golden cert residual outliers (§4 above) and new (Summary + API)
|
||||
cert pairs from the user. The diff-probe methodology from Slice 95
|
||||
(cascade-component diff API vs Summary path; localise; fix mapper)
|
||||
works for any new (Summary + API) pair — worksheet not required
|
||||
when Summary path is established as canonical.
|
||||
4. Don't lose sight of Layer 4: **API → SAP within 1e-4 of worksheet
|
||||
continuous on cert 001479** is the production goal. **MET as of
|
||||
Slice 95** — `test_api_001479_full_chain_sap_matches_worksheet_pdf_
|
||||
exactly` formalises this gate.
|
||||
|
||||
The user is sourcing more cert pairs in parallel; when they arrive,
|
||||
each one will surface ~3-5 mapper bugs along the same pattern as
|
||||
Slices 87-95. The diagnostic methodology (diff Summary-mapper vs
|
||||
API-mapper; localise by cascade component; fix the API mapper to
|
||||
mirror the Summary's surfacing) works for any new (Summary + API)
|
||||
pair — worksheet not required when Summary path is canonical (cert
|
||||
001479 proves it is).
|
||||
375
domain/sap10_calculator/docs/SAP_CALCULATOR.md
Normal file
375
domain/sap10_calculator/docs/SAP_CALCULATOR.md
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
# SAP 10.2 / RdSAP 10 calculator — module overview
|
||||
|
||||
Deterministic, bit-faithful replication of the RdSAP10 calculation engine.
|
||||
Validated against the 6 Elmhurst U985 worksheet PDFs at **abs=1e-4 on
|
||||
every line ref** for both the Rating cascade (UK-average climate, used
|
||||
for the published SAP rating + EI rating) and the Demand cascade
|
||||
(postcode climate via PCDB Table 172, used for the EPC's published
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 1. Public API
|
||||
|
||||
Three entry points, all in `domain.sap10_calculator.rdsap.cert_to_inputs`:
|
||||
|
||||
```python
|
||||
from domain.sap10_calculator.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
|
||||
```
|
||||
|
||||
### 1.1 Rating cascade — `cert_to_inputs(epc)`
|
||||
|
||||
Produces a `CalculatorInputs` aggregate with UK-average climate. Feed it
|
||||
to `calculate_sap_from_inputs(inputs)` to get a `SapResult`:
|
||||
|
||||
```python
|
||||
inputs = cert_to_inputs(epc)
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
result.sap_score # int — published SAP rating (1-100+)
|
||||
result.sap_score_continuous # float — un-rounded
|
||||
result.ecf # Energy Cost Factor
|
||||
result.total_fuel_cost_gbp # Rating-cascade cost (NOT the EPC's Fuel Bill)
|
||||
```
|
||||
|
||||
Per SAP10.2 Appendix U (p.124) only the SAP rating and EI rating use
|
||||
UK-average weather. Everything else (emissions, primary energy, fuel
|
||||
bill) the EPC publishes comes from the demand cascade below.
|
||||
|
||||
### 1.2 Demand cascade — `cert_to_demand_inputs(epc)`
|
||||
|
||||
Same physics, postcode-district climate from PCDB Table 172:
|
||||
|
||||
```python
|
||||
inputs = cert_to_demand_inputs(epc)
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
result.co2_kg_per_yr # EPC's "Current Carbon" (tonnes/year ÷ 1000)
|
||||
result.primary_energy_kwh_per_yr # EPC's "Current Primary Energy"
|
||||
```
|
||||
|
||||
Falls back to UK-average climate when `epc.postcode` is missing or the
|
||||
district is not in Table 172 (rural postcodes → no PCDB match).
|
||||
|
||||
### 1.3 Section helpers — `<section>_section_from_cert(epc, postcode_climate=...)`
|
||||
|
||||
Each U985 worksheet section has a typed dataclass + a `_section_from_cert`
|
||||
helper. Use these for explicit line-ref pinning or to compose your own
|
||||
flow. The `postcode_climate` kwarg selects rating (None) vs demand
|
||||
(PostcodeClimate) cascade.
|
||||
|
||||
| Helper | Returns | Pins |
|
||||
|---|---|---|
|
||||
| `dimensions_from_cert(epc)` | `Dimensions` | §1 (1)..(5) |
|
||||
| `ventilation_from_cert(epc, postcode_climate=...)` | `VentilationResult` | §2 (6a)..(25)m |
|
||||
| `heat_transmission_section_from_cert(epc)` | `HeatTransmission` | §3 (26)..(37) |
|
||||
| `water_heating_section_from_cert(epc)` | `WaterHeatingResult` | §4 (42)..(65)m |
|
||||
| `internal_gains_section_from_cert(epc)` | `InternalGainsResult` | §5 (66)..(73) |
|
||||
| `solar_gains_section_from_cert(epc, postcode_climate=...)` | `SolarGainsResult` | §6 (74)..(83) |
|
||||
| `mean_internal_temperature_section_from_cert(epc, postcode_climate=...)` | `MeanInternalTemperatureResult` | §7 (85)..(94) |
|
||||
| `space_heating_section_from_cert(epc, postcode_climate=...)` | `SpaceHeatingResult` | §8 (95)..(99) |
|
||||
| `space_cooling_section_from_cert(epc, postcode_climate=...)` | `SpaceCoolingResult` | §8c (100)..(108) |
|
||||
| `fabric_energy_efficiency_from_cert(epc)` | `float` | §8f (109) |
|
||||
| `energy_requirements_section_from_cert(epc, postcode_climate=...)` | `EnergyRequirementsResult` | §9a (201)..(221) |
|
||||
| `fuel_cost_section_from_cert(epc, postcode_climate=...)` | `FuelCostResult` | §10a (240)..(255) |
|
||||
| `sap_rating_section_from_cert(epc)` | `SapRatingSection` | §11a (256)..(258) — UK-avg only |
|
||||
| `environmental_section_from_cert(epc, postcode_climate=...)` | `EnvironmentalSection` | §12 (261)..(274) |
|
||||
| `primary_energy_section_from_cert(epc, postcode_climate=...)` | `PrimaryEnergySection` | §13a (275)..(286) |
|
||||
|
||||
---
|
||||
|
||||
## 2. The simulator use case
|
||||
|
||||
The calculator is built for "what-if" analysis — modify cert inputs (e.g.
|
||||
upgrade wall insulation), re-run, observe the delta. The shape:
|
||||
|
||||
```python
|
||||
import dataclasses
|
||||
from domain.sap10_calculator.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
|
||||
|
||||
def dwelling_outputs(epc):
|
||||
"""The 4 EPC-facing outputs for any cert.
|
||||
|
||||
SAP and EI ratings use UK-average climate per Appendix U; Current
|
||||
Carbon and Current Primary Energy use postcode climate from PCDB
|
||||
Table 172."""
|
||||
pc = local_climate_for_cert(epc)
|
||||
rating = calculate_sap_from_inputs(cert_to_inputs(epc))
|
||||
env_rating = environmental_section_from_cert(epc) # UK-avg
|
||||
env_demand = environmental_section_from_cert(epc, postcode_climate=pc)
|
||||
pe_demand = primary_energy_section_from_cert(epc, postcode_climate=pc)
|
||||
return {
|
||||
"sap_rating": rating.sap_score, # UK-avg
|
||||
"ei_rating": env_rating.ei_rating_integer if env_rating else None, # UK-avg
|
||||
"current_carbon_kg": env_demand.total_co2_kg_per_yr if env_demand else None, # postcode
|
||||
"current_pe_kwh": pe_demand.total_pe_kwh_per_yr if pe_demand else None, # postcode
|
||||
}
|
||||
|
||||
# Baseline
|
||||
baseline = dwelling_outputs(epc)
|
||||
|
||||
# Counterfactual — fill the cavity
|
||||
upgraded_walls = [
|
||||
dataclasses.replace(w, insulation_thickness_mm=50, wall_insulation_type=2)
|
||||
for w in epc.walls
|
||||
]
|
||||
modified_epc = dataclasses.replace(epc, walls=upgraded_walls)
|
||||
upgraded = dwelling_outputs(modified_epc)
|
||||
|
||||
print({k: upgraded[k] - baseline[k] for k in baseline}) # impact
|
||||
```
|
||||
|
||||
Absolute values match the EPC; deltas reflect the modelled retrofit.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
Two cascades stacked on a shared physics core:
|
||||
|
||||
```
|
||||
cert: EpcPropertyData
|
||||
│
|
||||
┌──────────────────────────┼──────────────────────────┐
|
||||
│ │
|
||||
cert_to_inputs(epc) cert_to_demand_inputs(epc)
|
||||
(UK-avg climate, region 0) (postcode climate via PCDB Table 172)
|
||||
│ │
|
||||
▼ ▼
|
||||
CalculatorInputs (rating) CalculatorInputs (demand)
|
||||
│ │
|
||||
▼ ▼
|
||||
calculate_sap_from_inputs(inputs) calculate_sap_from_inputs(inputs)
|
||||
│ │
|
||||
▼ ▼
|
||||
SapResult (rating) SapResult (demand)
|
||||
• sap_score • co2_kg_per_yr (EPC value)
|
||||
• sap_score_continuous • primary_energy_kwh_per_yr
|
||||
• ecf • space_heating_kwh_per_yr
|
||||
• total_fuel_cost_gbp • main_heating_fuel_kwh_per_yr
|
||||
• (more, all at postcode climate)
|
||||
```
|
||||
|
||||
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`:
|
||||
`external_temperature_c`, `wind_speed_m_per_s`,
|
||||
`horizontal_solar_irradiance_w_per_m2`, plus `_latitude_deg` in
|
||||
`worksheet/solar_gains.py`.
|
||||
|
||||
### Per-end-use CO2 and PE factors
|
||||
|
||||
For the demand cascade's CO2 (§12) and PE (§13a) line refs:
|
||||
|
||||
- Gas end-uses (main heating, water heating with a gas boiler) use the
|
||||
annual Table 12 / Table 32 (RdSAP10) factor — gas factors don't vary
|
||||
monthly.
|
||||
- Electricity end-uses (secondary heater, pumps/fans, lighting, electric
|
||||
shower, secondary heating with electric resistance) use the
|
||||
Σ(kWh_m × Table 12d_m) / Σ kWh_m **effective annual** factor — a
|
||||
Days-weighted average of the monthly factor by the per-end-use
|
||||
monthly kWh distribution. Same shape for PE (Table 12e).
|
||||
|
||||
This is the slice-32 / slice-33 mechanism. See `_effective_monthly_factor`
|
||||
in `cert_to_inputs.py` for the helper and the per-end-use factor fields
|
||||
on `CalculatorInputs`.
|
||||
|
||||
---
|
||||
|
||||
## 4. File map
|
||||
|
||||
```
|
||||
domain/sap10_calculator/
|
||||
├── calculator.py # Top-level orchestrator (CalculatorInputs → SapResult)
|
||||
├── README.md # Fixture authoring cookbook
|
||||
├── rdsap/
|
||||
│ └── cert_to_inputs.py # EpcPropertyData → CalculatorInputs (both cascades)
|
||||
├── worksheet/ # Per-section physics modules (§1..§13a)
|
||||
│ ├── dimensions.py # §1
|
||||
│ ├── ventilation.py # §2
|
||||
│ ├── heat_transmission.py # §3
|
||||
│ ├── water_heating.py # §4
|
||||
│ ├── internal_gains.py # §5
|
||||
│ ├── solar_gains.py # §6
|
||||
│ ├── mean_internal_temperature.py # §7
|
||||
│ ├── space_heating.py # §8
|
||||
│ ├── space_cooling.py # §8c
|
||||
│ ├── fabric_energy_efficiency.py # §8f
|
||||
│ ├── energy_requirements.py # §9a
|
||||
│ ├── fuel_cost.py # §10a
|
||||
│ ├── rating.py # §11a + §14 EI rating equations
|
||||
│ ├── utilisation_factor.py # Table 9a η helper
|
||||
│ └── tests/
|
||||
│ ├── _elmhurst_worksheet_NNNNNN.py # 6 conformance fixtures
|
||||
│ ├── _elmhurst_fixtures.py # ALL_FIXTURES registry
|
||||
│ ├── test_section_cascade_pins.py # THE conformance suite
|
||||
│ └── test_e2e_elmhurst_sap_score.py # Top-level SapResult pins
|
||||
├── climate/
|
||||
│ └── appendix_u.py # Tables U1/U2/U3 (UK-avg + 22 regions)
|
||||
└── tables/
|
||||
├── table_12.py # Fuel prices, CO2 factors, PE factors (annual + Table 12d/12e monthly)
|
||||
├── table_12a.py # Off-peak high-rate fractions
|
||||
├── table_32.py # RdSAP10 fuel prices (Table 32)
|
||||
└── pcdb/
|
||||
├── postcode_weather.py # PCDB Table 172 (postcode-district weather)
|
||||
├── parser.py # PCDB row parsers
|
||||
└── (other PCDB tables)
|
||||
|
||||
domain/sap10_calculator/docs/specs/
|
||||
├── 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)
|
||||
├── SAP_CALCULATOR.md # this file
|
||||
└── pcdb_table_*.jsonl # PCDB extracts per table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Validation
|
||||
|
||||
### The 6 Elmhurst U985 fixtures
|
||||
|
||||
Each fixture is a real-cert ground-truth captured from Elmhurst Energy's
|
||||
RdSAP tool. The pair of PDFs (`Summary_NNNNNN.pdf` cert + `U985-0001-
|
||||
NNNNNN.pdf` worksheet) gives us:
|
||||
|
||||
- A full `EpcPropertyData` encoding (the `Summary` → fixture's `build_epc()`)
|
||||
- Every populated worksheet line ref `(1a)..(286)` to 4 d.p. (the
|
||||
`U985-...` PDF → fixture's `LINE_*` / `DEMAND_LINE_*` constants)
|
||||
|
||||
The fixtures span the cert-shape variations we've seen in the wild:
|
||||
1-2 extensions, room-in-roof present/absent, electric shower present,
|
||||
party-wall code variations, suspended timber floor quirks, etc.
|
||||
|
||||
| Fixture | TFA | Notes |
|
||||
|---|---|---|
|
||||
| 000474 | 56.79 | Main + 2 extensions, gas combi |
|
||||
| 000477 | 77.58 | RR main-only, gas combi |
|
||||
| 000480 | 84.41 | Main + 1 extension + RR |
|
||||
| 000487 | 81.57 | RR + extension + alt wall, **electric shower** |
|
||||
| 000490 | 66.06 | Main + 1 extension |
|
||||
| 000516 | 90.54 | Main only, gas combi |
|
||||
|
||||
### Pin scoreboard
|
||||
|
||||
```
|
||||
RATING CASCADE (UK-avg climate)
|
||||
§1 12/12 §2 96/96 §3 24/24 §4 54/54 §5 54/54 §6 12/12
|
||||
§7 60/60 §8 36/36 §8c 42/42 §8f 6/6 §9a 72/72 §10a 192/192
|
||||
§11a 24/24 §12 84/84
|
||||
rating Σ = 768/768
|
||||
|
||||
DEMAND CASCADE (postcode climate)
|
||||
D§12 54/54 D§13a 36/36
|
||||
demand Σ = 90/90
|
||||
|
||||
E2E SapResult pins
|
||||
sap_score, ecf, fuel_cost, co2, kwh fields 66/66
|
||||
monthly_infiltration_ach 6/6
|
||||
e2e Σ = 72/72
|
||||
|
||||
GRAND TOTAL = 930/930
|
||||
```
|
||||
|
||||
### How to run
|
||||
|
||||
```bash
|
||||
# Full SAP calculator suite (cascade pins + e2e + helpers)
|
||||
python -m pytest domain/sap10_calculator/ --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 \
|
||||
--no-cov --no-header --tb=no -q
|
||||
```
|
||||
|
||||
### Hard rules
|
||||
|
||||
These are non-negotiable per `[[feedback-zero-error-strict]]` /
|
||||
`[[feedback-e2e-validation-philosophy]]`:
|
||||
|
||||
- `abs=1e-4` on every pin. **No `rel=…` tolerances, no widening, no xfail.**
|
||||
- A failing pin is a real calculator bug or fixture defect — diagnose
|
||||
before relaxing.
|
||||
- Audit the fixture against the PDF **first** when a cascade pin fails
|
||||
(many lodgements have been incomplete).
|
||||
- `_round_half_up` at §15 RdSAP boundaries — never Python's banker's
|
||||
`round()`.
|
||||
- Cascade pins walk the real cert→inputs cascade end-to-end. Don't
|
||||
isolate sections using PDF values as inputs.
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
for the step-by-step cookbook. Summary:
|
||||
|
||||
1. Drop a fixture module at `worksheet/tests/_elmhurst_worksheet_NNNNNN.py`
|
||||
2. Mirror the `Summary_NNNNNN.pdf` into `build_epc()`
|
||||
3. Capture every populated worksheet line as `LINE_*` (Block 1, rating
|
||||
cascade) + `DEMAND_LINE_*` (Block 2, demand cascade) constants
|
||||
4. Register in `_elmhurst_fixtures.py`
|
||||
5. Pins should all pass; if they don't, audit the fixture before
|
||||
blaming the calculator.
|
||||
|
||||
---
|
||||
|
||||
## 7. Spec references at hand
|
||||
|
||||
```
|
||||
SAP 10.2 (14-03-2025):
|
||||
§7 Mean internal temperature p.28-32
|
||||
§13 SAP rating equations p.38-39
|
||||
§14 EI rating + Primary Energy p.43-44
|
||||
Appendix J §2a Nbath p.81
|
||||
Appendix J §8 electric shower p.82
|
||||
Table J4 (shower flow/power) p.83
|
||||
Table J5 (behavioural fbeh) p.83
|
||||
Table 3a/3b/3c (HW combi loss) p.160-162
|
||||
Table 9a/9b/9c (heating + utilisation) p.183-185
|
||||
Table 12 (price/CO2/PEF annual) p.191
|
||||
Table 12a (off-peak high-rate) p.191-192
|
||||
Table 12d (monthly CO2 for electricity) p.194
|
||||
Table 12e (monthly PE for electricity) p.195
|
||||
Appendix U §U1/U2/U3 (region tables) p.124-127
|
||||
Appendix U paragraph 1 (rating vs demand) p.124
|
||||
|
||||
RdSAP 10 (10-06-2025):
|
||||
§3.1 precision rule p.16
|
||||
§3.6 wall area p.19
|
||||
§3.7.1 window area p.20
|
||||
§3.8 roof area (max-floor) p.20
|
||||
§3.9 RR simplified p.21
|
||||
§3.10 RR detailed p.21
|
||||
Table 4 (RR gable walls) p.22
|
||||
§5.12 + Table 19 floor U p.46
|
||||
§5.13 + Table 20 exposed floor p.47
|
||||
§5.17 + Table 23 basement p.48
|
||||
§5.18 curtain wall p.48
|
||||
Table 24 (window U) p.50
|
||||
§9.2 + Table 27 living area p.52
|
||||
§15 rounding rules p.66
|
||||
§19.2 RdSAP10 CO2/PE = SAP10.2 Table 12 p.94
|
||||
Table 32 (fuel prices, CO2, PEF) p.95
|
||||
Table 11 (secondary fraction) p.188
|
||||
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
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
0
domain/sap10_calculator/rdsap/__init__.py
Normal file
0
domain/sap10_calculator/rdsap/__init__.py
Normal file
2467
domain/sap10_calculator/rdsap/cert_to_inputs.py
Normal file
2467
domain/sap10_calculator/rdsap/cert_to_inputs.py
Normal file
File diff suppressed because it is too large
Load diff
0
domain/sap10_calculator/rdsap/tests/__init__.py
Normal file
0
domain/sap10_calculator/rdsap/tests/__init__.py
Normal file
536
domain/sap10_calculator/rdsap/tests/fixtures/golden/0240-0200-5706-2365-8010.json
vendored
Normal file
536
domain/sap10_calculator/rdsap/tests/fixtures/golden/0240-0200-5706-2365-8010.json
vendored
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
{
|
||||
"uprn": 10008625005,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 400+ mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Pitched, insulated (assumed)",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Roof room(s), insulated (assumed)",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Sandstone, as built, insulated (assumed)",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Solid, insulated (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "LE15 9LB",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"post_town": "OAKHAM",
|
||||
"psv_count": 0,
|
||||
"built_form": 1,
|
||||
"created_at": "2026-04-27 19:21:35",
|
||||
"door_count": 2,
|
||||
"region_code": 6,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
},
|
||||
{
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
},
|
||||
{
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
}
|
||||
],
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 28,
|
||||
"cylinder_thermostat": "N",
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 28,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "N",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 1,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 51,
|
||||
"sap_main_heating_code": 130,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 2
|
||||
},
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 28,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "N",
|
||||
"heat_emitter_type": 2,
|
||||
"emitter_temperature": 4,
|
||||
"main_heating_number": 2,
|
||||
"main_heating_control": 2110,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 49,
|
||||
"sap_main_heating_code": 130,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 2
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1.4,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.3,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1.2,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.3,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1.6,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.3,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 2.5,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 2,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1.4,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.3,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, oil",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
{
|
||||
"description": "Boiler and underfloor heating, oil",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Detached house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "36a Main Street",
|
||||
"address_line_2": "Belton In Rutland",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2026-04-27",
|
||||
"inspection_date": "2026-04-27",
|
||||
"extensions_count": 1,
|
||||
"measurement_type": 1,
|
||||
"open_flues_count": 0,
|
||||
"total_floor_area": 202,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 2,
|
||||
"heated_room_count": 7,
|
||||
"other_flues_count": 0,
|
||||
"registration_date": "2026-04-27",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "N",
|
||||
"meter_type": 3,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "true"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"closed_flues_count": 0,
|
||||
"extract_fans_count": 0,
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"floor_heat_loss": 7,
|
||||
"sap_room_in_roof": {
|
||||
"floor_area": 83.2,
|
||||
"room_in_roof_type_1": {
|
||||
"gable_wall_type_1": 0,
|
||||
"gable_wall_type_2": 0,
|
||||
"gable_wall_length_1": 6.4,
|
||||
"gable_wall_length_2": 6.4
|
||||
},
|
||||
"construction_age_band": "J"
|
||||
},
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 2,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": 2.28,
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": 97.72,
|
||||
"party_wall_length": 0,
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": 36.45
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "J",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "N",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "400mm+",
|
||||
"wall_insulation_thickness": "NI"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 5,
|
||||
"wall_construction": 2,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": 2.28,
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": 20.61,
|
||||
"party_wall_length": 0,
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": 13.45
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "J",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "N",
|
||||
"roof_insulation_location": 4,
|
||||
"roof_insulation_thickness": "NI",
|
||||
"wall_insulation_thickness": "NI"
|
||||
}
|
||||
],
|
||||
"boilers_flues_count": 0,
|
||||
"open_chimneys_count": 0,
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 7,
|
||||
"heating_cost_current": {
|
||||
"value": 1376,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 6.0,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 73,
|
||||
"lighting_cost_current": {
|
||||
"value": 127,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
{
|
||||
"description": "Time and temperature zone control",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
}
|
||||
],
|
||||
"blocked_chimneys_count": 0,
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 1376,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 288,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 85,
|
||||
"schema_version_current": "LIG-21.0",
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": 317,
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 78,
|
||||
"environmental_impact_rating": 70
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 5.7,
|
||||
"energy_rating_potential": 78,
|
||||
"lighting_cost_potential": {
|
||||
"value": 127,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "LIG-21.0",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 288,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2842.82,
|
||||
"space_heating_existing_dwelling": 13254.52
|
||||
},
|
||||
"draughtproofed_door_count": 0,
|
||||
"energy_consumption_current": 122,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "10.2.2.0",
|
||||
"energy_consumption_potential": 113,
|
||||
"environmental_impact_current": 69,
|
||||
"cfl_fixed_lighting_bulbs_count": 0,
|
||||
"current_energy_efficiency_band": "C",
|
||||
"environmental_impact_potential": 70,
|
||||
"led_fixed_lighting_bulbs_count": 8,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 30,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
480
domain/sap10_calculator/rdsap/tests/fixtures/golden/0300-2747-7640-2526-2135.json
vendored
Normal file
480
domain/sap10_calculator/rdsap/tests/fixtures/golden/0300-2747-7640-2526-2135.json
vendored
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
{
|
||||
"uprn": 44097668,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 270 mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, filled cavity",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
},
|
||||
"addendum": {
|
||||
"addendum_numbers": [
|
||||
15
|
||||
]
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "ME8 6SU",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "GILLINGHAM",
|
||||
"built_form": 2,
|
||||
"created_at": "2026-04-23 15:22:04",
|
||||
"door_count": 1,
|
||||
"region_code": 14,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
},
|
||||
{
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 2
|
||||
}
|
||||
],
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"secondary_fuel_type": 26,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 0,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 17992
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"secondary_heating_type": 605,
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 4,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 2.45,
|
||||
"window_height": 1.32,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 2.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.11,
|
||||
"window_height": 2.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.04,
|
||||
"window_height": 2.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.45,
|
||||
"window_height": 1.32,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 2,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.45,
|
||||
"window_height": 0.6,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 4,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.32,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 4,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.58,
|
||||
"window_height": 1.32,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.28,
|
||||
"window_height": 0.6,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.19,
|
||||
"window_height": 0.6,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Semi-detached house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "36 Milsted Road",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2026-04-23",
|
||||
"inspection_date": "2026-04-23",
|
||||
"extensions_count": 1,
|
||||
"measurement_type": 1,
|
||||
"open_flues_count": 1,
|
||||
"total_floor_area": 526,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"has_draught_lobby": "true",
|
||||
"heated_room_count": 5,
|
||||
"registration_date": "2026-04-23",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "false"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "Room heaters, mains gas",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"extract_fans_count": 1,
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 280,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.36,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 456.97,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 6.29,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 13.49,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.44,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 43.83,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 6.24,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 20.29,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 2,
|
||||
"construction_age_band": "D",
|
||||
"party_wall_construction": 0,
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "270mm",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 280,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 1,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.36,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 25.21,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 5.71,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 12.74,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 2,
|
||||
"construction_age_band": "D",
|
||||
"party_wall_construction": 0,
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 6,
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI",
|
||||
"flat_roof_insulation_thickness": "AB"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 7,
|
||||
"heating_cost_current": {
|
||||
"value": 3418,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 10,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 78,
|
||||
"lighting_cost_current": {
|
||||
"value": 250,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 3177,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 303,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 241,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a3900 - \u00a31,200",
|
||||
"improvement_type": "A2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 45
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 79,
|
||||
"environmental_impact_rating": 75
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 314,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 81,
|
||||
"environmental_impact_rating": 76
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 9.3,
|
||||
"energy_rating_potential": 81,
|
||||
"lighting_cost_potential": {
|
||||
"value": 250,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 303,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2791.63,
|
||||
"space_heating_existing_dwelling": 35376.05
|
||||
},
|
||||
"draughtproofed_door_count": 1,
|
||||
"energy_consumption_current": 106,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0342",
|
||||
"energy_consumption_potential": 96,
|
||||
"environmental_impact_current": 74,
|
||||
"current_energy_efficiency_band": "C",
|
||||
"environmental_impact_potential": 76,
|
||||
"led_fixed_lighting_bulbs_count": 15,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "B",
|
||||
"co2_emissions_current_per_floor_area": 19,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
520
domain/sap10_calculator/rdsap/tests/fixtures/golden/0390-2254-6420-2126-5561.json
vendored
Normal file
520
domain/sap10_calculator/rdsap/tests/fixtures/golden/0390-2254-6420-2126-5561.json
vendored
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
{
|
||||
"uprn": 100032081004,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 300 mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Flat, no insulation",
|
||||
"energy_efficiency_rating": 1,
|
||||
"environmental_efficiency_rating": 1
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, as built, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 2,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
},
|
||||
"addendum": {
|
||||
"cavity_fill_recommended": "true"
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Good lighting efficiency",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"postcode": "LN12 2PT",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "MABLETHORPE",
|
||||
"built_form": 3,
|
||||
"created_at": "2026-03-31 19:45:46",
|
||||
"door_count": 2,
|
||||
"region_code": 3,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 0,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 18119
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 2,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.96,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 2,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.96,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 2,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.97,
|
||||
"window_height": 1.18,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 2,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.48,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 2,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.45,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.96,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.96,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.97,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.98,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 12,
|
||||
"orientation": 4,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.97,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "End-terrace house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "7 Sutton Road",
|
||||
"address_line_2": "Trusthorpe",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2026-03-31",
|
||||
"inspection_date": "2026-02-24",
|
||||
"extensions_count": 1,
|
||||
"measurement_type": 1,
|
||||
"total_floor_area": 80,
|
||||
"transaction_type": 5,
|
||||
"conservatory_type": 1,
|
||||
"heated_room_count": 4,
|
||||
"registration_date": "2026-03-31",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "true",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "true"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"extract_fans_count": 2,
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 280,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.33,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 34.69,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 7.08,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 11.82,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.29,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 34.69,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 7.08,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 16.88,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "D",
|
||||
"party_wall_construction": 0,
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "300mm",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 280,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 1,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.23,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 10.76,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 10.16,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "D",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 6,
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI",
|
||||
"flat_roof_insulation_thickness": "AB"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 4,
|
||||
"heating_cost_current": {
|
||||
"value": 1083,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 3.5,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 65,
|
||||
"lighting_cost_current": {
|
||||
"value": 56,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"blocked_chimneys_count": 2,
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 758,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 155,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 61,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a3900 - \u00a31,200",
|
||||
"improvement_type": "A2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 45
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 67,
|
||||
"environmental_impact_rating": 65
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 208,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a3900 - \u00a31,500",
|
||||
"improvement_type": "B",
|
||||
"improvement_details": {
|
||||
"improvement_number": 6
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 72,
|
||||
"environmental_impact_rating": 72
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"typical_saving": {
|
||||
"value": 57,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 58
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 74,
|
||||
"environmental_impact_rating": 74
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"typical_saving": {
|
||||
"value": 225,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 78,
|
||||
"environmental_impact_rating": 75
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 2.4,
|
||||
"energy_rating_potential": 78,
|
||||
"lighting_cost_potential": {
|
||||
"value": 56,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"alternative_improvements": [
|
||||
{
|
||||
"improvement": {
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 59,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"improvement_type": "Q2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 55
|
||||
},
|
||||
"improvement_category": 6,
|
||||
"energy_performance_rating": 74,
|
||||
"environmental_impact_rating": 74
|
||||
}
|
||||
}
|
||||
],
|
||||
"hot_water_cost_potential": {
|
||||
"value": 155,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2080.04,
|
||||
"space_heating_existing_dwelling": 12615.25
|
||||
},
|
||||
"draughtproofed_door_count": 2,
|
||||
"energy_consumption_current": 241,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0335",
|
||||
"energy_consumption_potential": 156,
|
||||
"environmental_impact_current": 62,
|
||||
"current_energy_efficiency_band": "D",
|
||||
"environmental_impact_potential": 75,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 44,
|
||||
"low_energy_fixed_lighting_bulbs_count": 9,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
619
domain/sap10_calculator/rdsap/tests/fixtures/golden/0390-2954-3640-2196-4175.json
vendored
Normal file
619
domain/sap10_calculator/rdsap/tests/fixtures/golden/0390-2954-3640-2196-4175.json
vendored
Normal file
|
|
@ -0,0 +1,619 @@
|
|||
{
|
||||
"uprn": 10022970979,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, insulated (assumed)",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, as built, partial insulation (assumed)",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"addendum": {
|
||||
"addendum_numbers": [
|
||||
15
|
||||
],
|
||||
"cavity_fill_recommended": "true"
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "BB7 3JG",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"post_town": "CLITHEROE",
|
||||
"built_form": 1,
|
||||
"created_at": "2026-05-06 08:21:43",
|
||||
"door_count": 3,
|
||||
"region_code": 19,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 2,
|
||||
"cylinder_size": 3,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
}
|
||||
],
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 28,
|
||||
"cylinder_thermostat": "Y",
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 28,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "N",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": "NA",
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 9005
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"cylinder_insulation_type": 1,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"cylinder_insulation_thickness": 50
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.8,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.7,
|
||||
"window_height": 1.9,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.4,
|
||||
"window_height": 1.3,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.4,
|
||||
"window_height": 1.3,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.7,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.8,
|
||||
"window_height": 1.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.8,
|
||||
"window_height": 1.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.8,
|
||||
"window_height": 1.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.8,
|
||||
"window_height": 1.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, oil",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Detached house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "Easington House",
|
||||
"address_line_2": "Eaves Hall Lane",
|
||||
"address_line_3": "West Bradford",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2026-05-06",
|
||||
"inspection_date": "2026-04-14",
|
||||
"extensions_count": 0,
|
||||
"measurement_type": 1,
|
||||
"total_floor_area": 360,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"has_draught_lobby": "true",
|
||||
"heated_room_count": 8,
|
||||
"registration_date": "2026-05-06",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "N",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "false"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 580,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 5,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.6,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 180.22,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 55.83,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.7,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 180.22,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 55.83,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "F",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 4,
|
||||
"roof_insulation_thickness": "ND",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 8,
|
||||
"heating_cost_current": {
|
||||
"value": 3165,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 15,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 60,
|
||||
"lighting_cost_current": {
|
||||
"value": 152,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"has_hot_water_cylinder": "true",
|
||||
"heating_cost_potential": {
|
||||
"value": 2427,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 274,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 446,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a3900 - \u00a31,500",
|
||||
"improvement_type": "B",
|
||||
"improvement_details": {
|
||||
"improvement_number": 6
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 65,
|
||||
"environmental_impact_rating": 58
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 138,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 58
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 67,
|
||||
"environmental_impact_rating": 60
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"typical_saving": {
|
||||
"value": 171,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a32,200 - \u00a33,500",
|
||||
"improvement_type": "I",
|
||||
"improvement_details": {
|
||||
"improvement_number": 20
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 69,
|
||||
"environmental_impact_rating": 62
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"typical_saving": {
|
||||
"value": 259,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 71,
|
||||
"environmental_impact_rating": 63
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 11,
|
||||
"energy_rating_potential": 71,
|
||||
"lighting_cost_potential": {
|
||||
"value": 152,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"alternative_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 262,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"improvement_type": "Q2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 55
|
||||
},
|
||||
"improvement_category": 6,
|
||||
"energy_performance_rating": 68,
|
||||
"environmental_impact_rating": 62
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 88,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"improvement_type": "J2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 54
|
||||
},
|
||||
"improvement_category": 6,
|
||||
"energy_performance_rating": 72,
|
||||
"environmental_impact_rating": 96
|
||||
}
|
||||
],
|
||||
"hot_water_cost_potential": {
|
||||
"value": 257,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 3292.08,
|
||||
"space_heating_existing_dwelling": 39282.1
|
||||
},
|
||||
"draughtproofed_door_count": 3,
|
||||
"energy_consumption_current": 165,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0344",
|
||||
"energy_consumption_potential": 125,
|
||||
"environmental_impact_current": 52,
|
||||
"current_energy_efficiency_band": "D",
|
||||
"environmental_impact_potential": 63,
|
||||
"led_fixed_lighting_bulbs_count": 25,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 41,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
513
domain/sap10_calculator/rdsap/tests/fixtures/golden/0535-9020-6509-0821-6222.json
vendored
Normal file
513
domain/sap10_calculator/rdsap/tests/fixtures/golden/0535-9020-6509-0821-6222.json
vendored
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
{
|
||||
"address_line_1": "67 Howick Park Drive",
|
||||
"address_line_2": "Penwortham",
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"assessment_type": "RdSAP",
|
||||
"built_form": 2,
|
||||
"calculation_software_version": "5.02r0316",
|
||||
"cfl_fixed_lighting_bulbs_count": 6,
|
||||
"co2_emissions_current": 2.5,
|
||||
"co2_emissions_current_per_floor_area": 37,
|
||||
"co2_emissions_potential": 2.2,
|
||||
"completion_date": "2025-10-31",
|
||||
"conservatory_type": 1,
|
||||
"country_code": "ENG",
|
||||
"created_at": "2025-10-31 17:58:26",
|
||||
"current_energy_efficiency_band": "C",
|
||||
"door_count": 1,
|
||||
"draughtproofed_door_count": 0,
|
||||
"dwelling_type": "Semi-detached house",
|
||||
"energy_consumption_current": 201,
|
||||
"energy_consumption_potential": 171,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 69,
|
||||
"energy_rating_potential": 76,
|
||||
"environmental_impact_current": 68,
|
||||
"environmental_impact_potential": 72,
|
||||
"extensions_count": 2,
|
||||
"extract_fans_count": 2,
|
||||
"floors": [
|
||||
{
|
||||
"description": "Suspended, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
{
|
||||
"description": "Suspended, insulated (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"habitable_room_count": 4,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heated_room_count": 4,
|
||||
"heating_cost_current": {
|
||||
"currency": "GBP",
|
||||
"value": 763
|
||||
},
|
||||
"heating_cost_potential": {
|
||||
"currency": "GBP",
|
||||
"value": 705
|
||||
},
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"currency": "GBP",
|
||||
"value": 155
|
||||
},
|
||||
"hot_water_cost_potential": {
|
||||
"currency": "GBP",
|
||||
"value": 155
|
||||
},
|
||||
"incandescent_fixed_lighting_bulbs_count": 0,
|
||||
"inspection_date": "2025-10-29",
|
||||
"insulated_door_count": 0,
|
||||
"language_code": 1,
|
||||
"led_fixed_lighting_bulbs_count": 17,
|
||||
"lighting": {
|
||||
"description": "Good lighting efficiency",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"lighting_cost_current": {
|
||||
"currency": "GBP",
|
||||
"value": 46
|
||||
},
|
||||
"lighting_cost_potential": {
|
||||
"currency": "GBP",
|
||||
"value": 46
|
||||
},
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"measurement_type": 1,
|
||||
"mechanical_ventilation": 0,
|
||||
"multiple_glazed_proportion": 100,
|
||||
"percent_draughtproofed": 90,
|
||||
"post_town": "PRESTON",
|
||||
"postcode": "PR1 0LX",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"region_code": 19,
|
||||
"registration_date": "2025-10-31",
|
||||
"renewable_heat_incentive": {
|
||||
"space_heating_existing_dwelling": 7428.0,
|
||||
"water_heating": 2074.92
|
||||
},
|
||||
"report_type": 2,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 300 mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Pitched, insulated",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
}
|
||||
],
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"building_part_number": 1,
|
||||
"construction_age_band": "C",
|
||||
"floor_heat_loss": 7,
|
||||
"floor_insulation_thickness": "NI",
|
||||
"identifier": "Main Dwelling",
|
||||
"party_wall_construction": 2,
|
||||
"roof_construction": 4,
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "300mm",
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"floor_construction": 2,
|
||||
"floor_insulation": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"quantity": "metres",
|
||||
"value": 11.99
|
||||
},
|
||||
"party_wall_length": {
|
||||
"quantity": "metres",
|
||||
"value": 6.94
|
||||
},
|
||||
"room_height": {
|
||||
"quantity": "metres",
|
||||
"value": 2.39
|
||||
},
|
||||
"total_floor_area": {
|
||||
"quantity": "square metres",
|
||||
"value": 30.45
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"quantity": "metres",
|
||||
"value": 13.55
|
||||
},
|
||||
"party_wall_length": {
|
||||
"quantity": "metres",
|
||||
"value": 6.94
|
||||
},
|
||||
"room_height": {
|
||||
"quantity": "metres",
|
||||
"value": 2.28
|
||||
},
|
||||
"total_floor_area": {
|
||||
"quantity": "square metres",
|
||||
"value": 30.77
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_construction": 4,
|
||||
"wall_dry_lined": "N",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"wall_insulation_type": 2,
|
||||
"wall_thickness": 280,
|
||||
"wall_thickness_measured": "Y"
|
||||
},
|
||||
{
|
||||
"building_part_number": 2,
|
||||
"construction_age_band": "M",
|
||||
"floor_heat_loss": 7,
|
||||
"floor_insulation_thickness": "NI",
|
||||
"identifier": "Extension 1",
|
||||
"party_wall_construction": "NA",
|
||||
"roof_construction": 8,
|
||||
"roof_insulation_location": 7,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"floor_construction": 2,
|
||||
"floor_insulation": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"quantity": "metres",
|
||||
"value": 6.67
|
||||
},
|
||||
"party_wall_length": {
|
||||
"quantity": "metres",
|
||||
"value": 0
|
||||
},
|
||||
"room_height": {
|
||||
"quantity": "metres",
|
||||
"value": 2.48
|
||||
},
|
||||
"total_floor_area": {
|
||||
"quantity": "square metres",
|
||||
"value": 5.37
|
||||
}
|
||||
}
|
||||
],
|
||||
"sloping_ceiling_insulation_thickness": "AB",
|
||||
"wall_construction": 4,
|
||||
"wall_dry_lined": "N",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"wall_insulation_type": 4,
|
||||
"wall_thickness": 280,
|
||||
"wall_thickness_measured": "Y"
|
||||
},
|
||||
{
|
||||
"building_part_number": 3,
|
||||
"construction_age_band": "C",
|
||||
"floor_heat_loss": 1,
|
||||
"floor_insulation_thickness": "NI",
|
||||
"identifier": "Extension 2",
|
||||
"party_wall_construction": "NA",
|
||||
"roof_construction": 8,
|
||||
"roof_insulation_location": 7,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"floor_construction": 2,
|
||||
"floor_insulation": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"quantity": "metres",
|
||||
"value": 2.81
|
||||
},
|
||||
"party_wall_length": {
|
||||
"quantity": "metres",
|
||||
"value": 0
|
||||
},
|
||||
"room_height": {
|
||||
"quantity": "metres",
|
||||
"value": 2.1
|
||||
},
|
||||
"total_floor_area": {
|
||||
"quantity": "square metres",
|
||||
"value": 1.92
|
||||
}
|
||||
}
|
||||
],
|
||||
"sloping_ceiling_insulation_thickness": "AB",
|
||||
"wall_construction": 4,
|
||||
"wall_dry_lined": "N",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"wall_insulation_type": 2,
|
||||
"wall_thickness": 280,
|
||||
"wall_thickness_measured": "Y"
|
||||
}
|
||||
],
|
||||
"sap_energy_source": {
|
||||
"electricity_smart_meter_present": "true",
|
||||
"gas_smart_meter_present": "true",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"pv_connection": 0,
|
||||
"wind_turbines_count": 0,
|
||||
"wind_turbines_terrain_type": 2
|
||||
},
|
||||
"sap_heating": {
|
||||
"cylinder_size": 1,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"immersion_heating_type": "NA",
|
||||
"main_heating_details": [
|
||||
{
|
||||
"boiler_flue_type": 2,
|
||||
"central_heating_pump_age": 0,
|
||||
"emitter_temperature": 0,
|
||||
"fan_flue_present": "Y",
|
||||
"has_fghrs": "N",
|
||||
"heat_emitter_type": 1,
|
||||
"main_fuel_type": 26,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_fraction": 1,
|
||||
"main_heating_index_number": 17507,
|
||||
"main_heating_number": 1
|
||||
}
|
||||
],
|
||||
"number_baths": 0,
|
||||
"number_baths_wwhrs": 0,
|
||||
"secondary_fuel_type": 26,
|
||||
"secondary_heating_type": 605,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_outlet": {
|
||||
"shower_outlet_type": 1,
|
||||
"shower_wwhrs": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 8,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.32,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 2.53
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 2,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.3,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 0.56
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_type": 13,
|
||||
"orientation": 4,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"window_height": 2.11,
|
||||
"window_location": 1,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 3.02
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 8,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.53,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 1.99
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 2,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.28,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 1.04
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 2,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.28,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 0.55
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 2,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.16,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 0.85
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 4,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.29,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 1.65
|
||||
},
|
||||
{
|
||||
"draught_proofed": "true",
|
||||
"glazing_gap": 12,
|
||||
"glazing_type": 3,
|
||||
"orientation": 1,
|
||||
"permanent_shutters_insulated": "N",
|
||||
"permanent_shutters_present": "N",
|
||||
"pvc_frame": "true",
|
||||
"window_height": 1.32,
|
||||
"window_location": 0,
|
||||
"window_type": 1,
|
||||
"window_wall_type": 1,
|
||||
"window_width": 1.29
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"schema_version_original": "21.0.1",
|
||||
"secondary_heating": {
|
||||
"description": "Room heaters, mains gas",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"solar_water_heating": "N",
|
||||
"status": "entered",
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"energy_performance_rating": 71,
|
||||
"environmental_impact_rating": 71,
|
||||
"improvement_category": 5,
|
||||
"improvement_details": {
|
||||
"improvement_number": 57
|
||||
},
|
||||
"improvement_type": "W1",
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"currency": "GBP",
|
||||
"value": 58
|
||||
}
|
||||
},
|
||||
{
|
||||
"energy_performance_rating": 76,
|
||||
"environmental_impact_rating": 72,
|
||||
"improvement_category": 5,
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_type": "U",
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"currency": "GBP",
|
||||
"value": 196
|
||||
}
|
||||
}
|
||||
],
|
||||
"tenure": 3,
|
||||
"total_floor_area": 69,
|
||||
"transaction_type": 15,
|
||||
"uprn": 100010634425,
|
||||
"uprn_source": "Energy Assessor",
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, filled cavity",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
{
|
||||
"description": "Cavity wall, as built, insulated (assumed)",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
}
|
||||
],
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
}
|
||||
453
domain/sap10_calculator/rdsap/tests/fixtures/golden/2130-1033-4050-5007-8395.json
vendored
Normal file
453
domain/sap10_calculator/rdsap/tests/fixtures/golden/2130-1033-4050-5007-8395.json
vendored
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
{
|
||||
"uprn": 100030334057,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 300 mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Pitched, 100 mm loft insulation",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Solid brick, as built, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 1,
|
||||
"environmental_efficiency_rating": 1
|
||||
},
|
||||
{
|
||||
"description": "Solid brick, with internal insulation",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Suspended, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 3,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
},
|
||||
"addendum": {
|
||||
"addendum_numbers": [
|
||||
8
|
||||
]
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "DE22 3RW",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "DERBY",
|
||||
"psv_count": 0,
|
||||
"built_form": 3,
|
||||
"created_at": "2025-07-24 16:36:27",
|
||||
"door_count": 2,
|
||||
"region_code": 6,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_outlet": {
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"cylinder_thermostat": "N",
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 1,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 17505
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": {
|
||||
"value": 0.95,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.75,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 8,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": {
|
||||
"value": 0.95,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.7,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 4,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": {
|
||||
"value": 1,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.7,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 4,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": {
|
||||
"value": 0.95,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 1.7,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": {
|
||||
"value": 0.7,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 0.9,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": {
|
||||
"value": 0.65,
|
||||
"quantity": "m"
|
||||
},
|
||||
"window_height": {
|
||||
"value": 0.5,
|
||||
"quantity": "m"
|
||||
},
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "End-terrace house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "5 Lynton Street",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2025-07-24",
|
||||
"inspection_date": "2025-07-18",
|
||||
"extensions_count": 1,
|
||||
"measurement_type": 1,
|
||||
"open_flues_count": 0,
|
||||
"total_floor_area": 64,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"heated_room_count": 4,
|
||||
"other_flues_count": 0,
|
||||
"registration_date": "2025-07-24",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 2,
|
||||
"photovoltaic_supply": [
|
||||
[
|
||||
{
|
||||
"pitch": 2,
|
||||
"peak_power": 2.04,
|
||||
"orientation": 4,
|
||||
"overshading": 1
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"pitch": 2,
|
||||
"peak_power": 2.04,
|
||||
"orientation": 8,
|
||||
"overshading": 2
|
||||
}
|
||||
]
|
||||
],
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "true",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "true"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"closed_flues_count": 0,
|
||||
"extract_fans_count": 1,
|
||||
"lzc_energy_sources": [
|
||||
11
|
||||
],
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 230,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 3,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": 2.64,
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": 28.2,
|
||||
"party_wall_length": 8.27,
|
||||
"floor_construction": 2,
|
||||
"heat_loss_perimeter": 13.38
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": 2.58,
|
||||
"total_floor_area": 28.2,
|
||||
"party_wall_length": 8.27,
|
||||
"heat_loss_perimeter": 15.09
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "B",
|
||||
"party_wall_construction": 1,
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "300mm",
|
||||
"wall_insulation_thickness_measured": 100
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 5,
|
||||
"wall_construction": 3,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": 2.5,
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": 7.21,
|
||||
"party_wall_length": 0,
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": 9.88
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 3,
|
||||
"construction_age_band": "B",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "N",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "100mm",
|
||||
"wall_insulation_thickness": "measured",
|
||||
"wall_insulation_thickness_measured": 100,
|
||||
"wall_insulation_thermal_conductivity": 1
|
||||
}
|
||||
],
|
||||
"boilers_flues_count": 0,
|
||||
"open_chimneys_count": 0,
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 4,
|
||||
"heating_cost_current": {
|
||||
"value": 939,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 2.7,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 82,
|
||||
"lighting_cost_current": {
|
||||
"value": 45,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"blocked_chimneys_count": 0,
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 591,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 161,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"schema_version_current": "LIG-21.0",
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": 290,
|
||||
"indicative_cost": "\u00a37,500 - \u00a311,000",
|
||||
"improvement_type": "Q",
|
||||
"improvement_details": {
|
||||
"improvement_number": 7
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 91,
|
||||
"environmental_impact_rating": 76
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": 58,
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W1",
|
||||
"improvement_details": {
|
||||
"improvement_number": 57
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 92,
|
||||
"environmental_impact_rating": 79
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 1.6,
|
||||
"energy_rating_potential": 92,
|
||||
"lighting_cost_potential": {
|
||||
"value": 45,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "LIG-21.0",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 161,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2166.19,
|
||||
"space_heating_existing_dwelling": 10128.81
|
||||
},
|
||||
"draughtproofed_door_count": 2,
|
||||
"energy_consumption_current": 232,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "10.2.2.0",
|
||||
"energy_consumption_potential": 138,
|
||||
"environmental_impact_current": 65,
|
||||
"cfl_fixed_lighting_bulbs_count": 0,
|
||||
"current_energy_efficiency_band": "B",
|
||||
"environmental_impact_potential": 79,
|
||||
"led_fixed_lighting_bulbs_count": 9,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "A",
|
||||
"co2_emissions_current_per_floor_area": 43,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
495
domain/sap10_calculator/rdsap/tests/fixtures/golden/6035-7729-2309-0879-2296.json
vendored
Normal file
495
domain/sap10_calculator/rdsap/tests/fixtures/golden/6035-7729-2309-0879-2296.json
vendored
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
{
|
||||
"uprn": 100050911226,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 300 mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Roof room(s), limited insulation (assumed)",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Solid brick, with internal insulation",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
{
|
||||
"description": "Solid brick, as built, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 1,
|
||||
"environmental_efficiency_rating": 1
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Suspended, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Below average lighting efficiency",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"postcode": "S10 1EA",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "SHEFFIELD",
|
||||
"built_form": 4,
|
||||
"created_at": "2025-11-11 13:03:41",
|
||||
"door_count": 2,
|
||||
"region_code": 3,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 2,
|
||||
"cylinder_size": 1,
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 1,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"sap_main_heating_code": 104,
|
||||
"central_heating_pump_age": 2,
|
||||
"main_heating_data_source": 2
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.6,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.3,
|
||||
"window_height": 1.8,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 1.19,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.6,
|
||||
"window_height": 1.4,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.8,
|
||||
"window_height": 0.6,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 0.8,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 0.8,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 4,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2.12,
|
||||
"window_height": 1.8,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Mid-terrace house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "43 Barber Road",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2025-11-11",
|
||||
"inspection_date": "2025-11-11",
|
||||
"extensions_count": 1,
|
||||
"measurement_type": 1,
|
||||
"total_floor_area": 128,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"heated_room_count": 6,
|
||||
"registration_date": "2025-11-11",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "false"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 340,
|
||||
"floor_heat_loss": 7,
|
||||
"sap_room_in_roof": {
|
||||
"floor_area": 29.75,
|
||||
"room_in_roof_type_1": {
|
||||
"gable_wall_type_1": 1,
|
||||
"gable_wall_type_2": 0,
|
||||
"gable_wall_length_1": 4.65,
|
||||
"gable_wall_length_2": 4.65
|
||||
},
|
||||
"construction_age_band": "A"
|
||||
},
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 3,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.78,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 41.73,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 7.92,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 2,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 15.99,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.78,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 41.73,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 15.84,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 8.32,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 3,
|
||||
"construction_age_band": "A",
|
||||
"party_wall_construction": 1,
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "300mm",
|
||||
"wall_insulation_thickness": "100mm",
|
||||
"floor_insulation_thickness": "NI"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 240,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 3,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.82,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 7.21,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 8.31,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.86,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 7.21,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 8.31,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "A",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "300mm",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 6,
|
||||
"heating_cost_current": {
|
||||
"value": 1285,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 4.5,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 70,
|
||||
"lighting_cost_current": {
|
||||
"value": 103,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 1031,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 217,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 174,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a37,500 - \u00a311,000",
|
||||
"improvement_type": "Q",
|
||||
"improvement_details": {
|
||||
"improvement_number": 7
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 73,
|
||||
"environmental_impact_rating": 71
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 79,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W1",
|
||||
"improvement_details": {
|
||||
"improvement_number": 57
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 75,
|
||||
"environmental_impact_rating": 73
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"typical_saving": {
|
||||
"value": 227,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 78,
|
||||
"environmental_impact_rating": 74
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 3.5,
|
||||
"energy_rating_potential": 78,
|
||||
"lighting_cost_potential": {
|
||||
"value": 103,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 217,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2660.85,
|
||||
"space_heating_existing_dwelling": 14828.94
|
||||
},
|
||||
"draughtproofed_door_count": 2,
|
||||
"energy_consumption_current": 191,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"sap_deselected_improvements": [
|
||||
"X"
|
||||
],
|
||||
"calculation_software_version": "5.02r0328",
|
||||
"energy_consumption_potential": 147,
|
||||
"environmental_impact_current": 67,
|
||||
"current_energy_efficiency_band": "C",
|
||||
"environmental_impact_potential": 74,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 35,
|
||||
"low_energy_fixed_lighting_bulbs_count": 9,
|
||||
"incandescent_fixed_lighting_bulbs_count": 2
|
||||
}
|
||||
761
domain/sap10_calculator/rdsap/tests/fixtures/golden/7536-3827-0600-0600-0276.json
vendored
Normal file
761
domain/sap10_calculator/rdsap/tests/fixtures/golden/7536-3827-0600-0600-0276.json
vendored
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
{
|
||||
"uprn": 200002992553,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 100 mm loft insulation",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
{
|
||||
"description": "Pitched, insulated",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, filled cavity",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
{
|
||||
"description": "Cavity wall, as built, insulated (assumed)",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "To unheated space, insulated",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
{
|
||||
"description": "Solid, insulated (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "S10 5PT",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "SHEFFIELD",
|
||||
"built_form": 1,
|
||||
"created_at": "2026-04-04 16:58:11",
|
||||
"door_count": 2,
|
||||
"region_code": 3,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_outlet": {
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"secondary_fuel_type": 29,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 0,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 17679
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"secondary_heating_type": 691,
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 2.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 2.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 1.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 2.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 2.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 2.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.9,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.9,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.9,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 2,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.9,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.6,
|
||||
"window_height": 2.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.5,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.5,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2.4,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.9,
|
||||
"window_height": 1.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 2,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.9,
|
||||
"window_height": 1.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 2,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 1.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 7,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.4,
|
||||
"window_height": 0.9,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 1.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2.1,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.5,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.5,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2.4,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.4,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.4,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": "16+",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Detached house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "588 Manchester Road",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2026-04-04",
|
||||
"inspection_date": "2026-03-30",
|
||||
"extensions_count": 2,
|
||||
"measurement_type": 1,
|
||||
"total_floor_area": 152,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"heated_room_count": 6,
|
||||
"registration_date": "2026-04-04",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "true",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "true"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "Room heaters, electric",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"extract_fans_count": 1,
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 250,
|
||||
"floor_heat_loss": 2,
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.24,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 2,
|
||||
"total_floor_area": {
|
||||
"value": 17.28,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 2,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 16.8,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.4,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 50.98,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 15.47,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 2,
|
||||
"room_height": {
|
||||
"value": 2.31,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 50.98,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 23.4,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 2,
|
||||
"construction_age_band": "D",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "100mm",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 300,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 8,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.4,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 7.44,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 8.6,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.74,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 19.37,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 12.45,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "L",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 7,
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI",
|
||||
"sloping_ceiling_insulation_thickness": "AB"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 2",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 250,
|
||||
"floor_heat_loss": 3,
|
||||
"roof_construction": 8,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 3,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.15,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 6.05,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 9.17,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 2,
|
||||
"construction_age_band": "F",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 7,
|
||||
"wall_insulation_thickness": "NI",
|
||||
"sloping_ceiling_insulation_thickness": "AB"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 6,
|
||||
"heating_cost_current": {
|
||||
"value": 1949,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 4.7,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 68,
|
||||
"lighting_cost_current": {
|
||||
"value": 71,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 1949,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 214,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 241,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 71,
|
||||
"environmental_impact_rating": 71
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 4.6,
|
||||
"energy_rating_potential": 71,
|
||||
"lighting_cost_potential": {
|
||||
"value": 71,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 214,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2569.77,
|
||||
"space_heating_existing_dwelling": 17628.58
|
||||
},
|
||||
"draughtproofed_door_count": 2,
|
||||
"energy_consumption_current": 177,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0335",
|
||||
"energy_consumption_potential": 168,
|
||||
"environmental_impact_current": 70,
|
||||
"cfl_fixed_lighting_bulbs_count": 1,
|
||||
"current_energy_efficiency_band": "D",
|
||||
"environmental_impact_potential": 71,
|
||||
"led_fixed_lighting_bulbs_count": 51,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 31,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
535
domain/sap10_calculator/rdsap/tests/fixtures/golden/8135-1728-8500-0511-3296.json
vendored
Normal file
535
domain/sap10_calculator/rdsap/tests/fixtures/golden/8135-1728-8500-0511-3296.json
vendored
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
{
|
||||
"uprn": 45004128,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "Pitched, 300 mm loft insulation",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
{
|
||||
"description": "Flat, insulated",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, filled cavity",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "Suspended, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
{
|
||||
"description": "Solid, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 3,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Good lighting efficiency",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"postcode": "SR2 9DP",
|
||||
"hot_water": {
|
||||
"description": "From main system",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
},
|
||||
"post_town": "SUNDERLAND",
|
||||
"built_form": 2,
|
||||
"created_at": "2025-08-22 10:51:20",
|
||||
"door_count": 2,
|
||||
"region_code": 1,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 26,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 26,
|
||||
"boiler_flue_type": 2,
|
||||
"fan_flue_present": "Y",
|
||||
"heat_emitter_type": 1,
|
||||
"emitter_temperature": 0,
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2106,
|
||||
"main_heating_category": 2,
|
||||
"main_heating_fraction": 1,
|
||||
"central_heating_pump_age": 0,
|
||||
"main_heating_data_source": 1,
|
||||
"main_heating_index_number": 17702
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 6,
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 0.2,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 6,
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2.5,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 6,
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2.8,
|
||||
"window_height": 1.5,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 6,
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 1.2,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.8,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"glazing_gap": 6,
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 3,
|
||||
"window_width": 2,
|
||||
"window_height": 1.8,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 6,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.2,
|
||||
"window_height": 0.3,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.8,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.3,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.3,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.8,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.2,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.3,
|
||||
"window_height": 1.2,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.3,
|
||||
"window_height": 1.1,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 1,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Boiler and radiators, mains gas",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Semi-detached house",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 0,
|
||||
"address_line_1": "338 Leechmere Road",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2025-08-22",
|
||||
"inspection_date": "2025-08-19",
|
||||
"extensions_count": 1,
|
||||
"measurement_type": 1,
|
||||
"total_floor_area": 102,
|
||||
"transaction_type": 8,
|
||||
"conservatory_type": 1,
|
||||
"heated_room_count": 6,
|
||||
"registration_date": "2025-08-22",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "false"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"extract_fans_count": 3,
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 300,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 4,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.39,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 37.8,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 7,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 2,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 10.8,
|
||||
"quantity": "metres"
|
||||
}
|
||||
},
|
||||
{
|
||||
"floor": 1,
|
||||
"room_height": {
|
||||
"value": 2.39,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 37.8,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 7,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 17.8,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 2,
|
||||
"construction_age_band": "C",
|
||||
"party_wall_construction": 0,
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 2,
|
||||
"roof_insulation_thickness": "300mm",
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI"
|
||||
},
|
||||
{
|
||||
"identifier": "Extension 1",
|
||||
"wall_dry_lined": "N",
|
||||
"wall_thickness": 300,
|
||||
"floor_heat_loss": 7,
|
||||
"roof_construction": 1,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 2,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.1,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_insulation": 1,
|
||||
"total_floor_area": {
|
||||
"value": 26.16,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"floor_construction": 1,
|
||||
"heat_loss_perimeter": {
|
||||
"value": 17.2,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 2,
|
||||
"construction_age_band": "G",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": 6,
|
||||
"wall_insulation_thickness": "NI",
|
||||
"floor_insulation_thickness": "NI",
|
||||
"flat_roof_insulation_thickness": "AB"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 6,
|
||||
"heating_cost_current": {
|
||||
"value": 1023,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 3.4,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 72,
|
||||
"lighting_cost_current": {
|
||||
"value": 62,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Programmer, room thermostat and TRVs",
|
||||
"energy_efficiency_rating": 4,
|
||||
"environmental_efficiency_rating": 4
|
||||
}
|
||||
],
|
||||
"blocked_chimneys_count": 1,
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 913,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 178,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 88,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 62,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W1",
|
||||
"improvement_details": {
|
||||
"improvement_number": 57
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 73,
|
||||
"environmental_impact_rating": 71
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"typical_saving": {
|
||||
"value": 47,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a35,000 - \u00a310,000",
|
||||
"improvement_type": "W2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 58
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 74,
|
||||
"environmental_impact_rating": 73
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"typical_saving": {
|
||||
"value": 225,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a38,000 - \u00a310,000",
|
||||
"improvement_type": "U",
|
||||
"improvement_details": {
|
||||
"improvement_number": 34
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 78,
|
||||
"environmental_impact_rating": 74
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 3.0,
|
||||
"energy_rating_potential": 78,
|
||||
"lighting_cost_potential": {
|
||||
"value": 62,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"hot_water_cost_potential": {
|
||||
"value": 178,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2380.15,
|
||||
"space_heating_existing_dwelling": 11731.4
|
||||
},
|
||||
"draughtproofed_door_count": 0,
|
||||
"energy_consumption_current": 184,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0304",
|
||||
"energy_consumption_potential": 154,
|
||||
"environmental_impact_current": 70,
|
||||
"current_energy_efficiency_band": "C",
|
||||
"environmental_impact_potential": 74,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 34,
|
||||
"low_energy_fixed_lighting_bulbs_count": 22,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
373
domain/sap10_calculator/rdsap/tests/fixtures/golden/9390-2722-3520-2105-8715.json
vendored
Normal file
373
domain/sap10_calculator/rdsap/tests/fixtures/golden/9390-2722-3520-2105-8715.json
vendored
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
{
|
||||
"uprn": 100040554202,
|
||||
"roofs": [
|
||||
{
|
||||
"description": "(another dwelling above)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"description": "Cavity wall, as built, no insulation (assumed)",
|
||||
"energy_efficiency_rating": 2,
|
||||
"environmental_efficiency_rating": 2
|
||||
}
|
||||
],
|
||||
"floors": [
|
||||
{
|
||||
"description": "(another dwelling below)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
}
|
||||
],
|
||||
"status": "entered",
|
||||
"tenure": 1,
|
||||
"window": {
|
||||
"description": "Fully double glazed",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"addendum": {
|
||||
"high_exposure": "true",
|
||||
"cavity_fill_recommended": "true"
|
||||
},
|
||||
"lighting": {
|
||||
"description": "Excellent lighting efficiency",
|
||||
"energy_efficiency_rating": 5,
|
||||
"environmental_efficiency_rating": 5
|
||||
},
|
||||
"postcode": "TQ1 2ND",
|
||||
"hot_water": {
|
||||
"description": "Community scheme",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
},
|
||||
"post_town": "TORQUAY",
|
||||
"built_form": "NR",
|
||||
"created_at": "2025-12-02 19:41:06",
|
||||
"door_count": 1,
|
||||
"region_code": 15,
|
||||
"report_type": 2,
|
||||
"sap_heating": {
|
||||
"number_baths": 1,
|
||||
"cylinder_size": 1,
|
||||
"shower_outlets": [
|
||||
{
|
||||
"shower_outlet": {
|
||||
"shower_wwhrs": 1,
|
||||
"shower_outlet_type": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"number_baths_wwhrs": 0,
|
||||
"water_heating_code": 901,
|
||||
"water_heating_fuel": 20,
|
||||
"main_heating_details": [
|
||||
{
|
||||
"has_fghrs": "N",
|
||||
"main_fuel_type": 20,
|
||||
"heat_emitter_type": 0,
|
||||
"emitter_temperature": "NA",
|
||||
"main_heating_number": 1,
|
||||
"main_heating_control": 2307,
|
||||
"main_heating_category": 6,
|
||||
"main_heating_fraction": 1,
|
||||
"sap_main_heating_code": 301,
|
||||
"main_heating_data_source": 2,
|
||||
"community_heat_distribution_type": 6
|
||||
}
|
||||
],
|
||||
"immersion_heating_type": "NA",
|
||||
"has_fixed_air_conditioning": "false"
|
||||
},
|
||||
"sap_version": 10.2,
|
||||
"sap_windows": [
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.88,
|
||||
"window_height": 1.27,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.85,
|
||||
"window_height": 2.05,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.26,
|
||||
"window_height": 1.27,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 3,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 2.18,
|
||||
"window_height": 2.07,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 5,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 0.81,
|
||||
"window_height": 2.07,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.26,
|
||||
"window_height": 0.96,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
},
|
||||
{
|
||||
"pvc_frame": "true",
|
||||
"orientation": 1,
|
||||
"window_type": 1,
|
||||
"glazing_type": 2,
|
||||
"window_width": 1.87,
|
||||
"window_height": 0.96,
|
||||
"draught_proofed": "true",
|
||||
"window_location": 0,
|
||||
"window_wall_type": 1,
|
||||
"permanent_shutters_present": "N",
|
||||
"permanent_shutters_insulated": "N"
|
||||
}
|
||||
],
|
||||
"schema_type": "RdSAP-Schema-21.0.1",
|
||||
"uprn_source": "Energy Assessor",
|
||||
"country_code": "ENG",
|
||||
"main_heating": [
|
||||
{
|
||||
"description": "Community scheme",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"air_tightness": {
|
||||
"description": "(not tested)",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"dwelling_type": "Mid-floor flat",
|
||||
"language_code": 1,
|
||||
"pressure_test": 4,
|
||||
"property_type": 2,
|
||||
"address_line_1": "39 Ridgeway Heights",
|
||||
"address_line_2": "Ridgeway Road",
|
||||
"assessment_type": "RdSAP",
|
||||
"completion_date": "2025-12-02",
|
||||
"inspection_date": "2025-12-02",
|
||||
"extensions_count": 0,
|
||||
"measurement_type": 1,
|
||||
"sap_flat_details": {
|
||||
"level": 2,
|
||||
"top_storey": "N",
|
||||
"storey_count": 7,
|
||||
"flat_location": 2,
|
||||
"heat_loss_corridor": 0
|
||||
},
|
||||
"total_floor_area": 75,
|
||||
"transaction_type": 1,
|
||||
"conservatory_type": 1,
|
||||
"has_draught_lobby": "true",
|
||||
"heated_room_count": 3,
|
||||
"registration_date": "2025-12-02",
|
||||
"sap_energy_source": {
|
||||
"mains_gas": "Y",
|
||||
"meter_type": 2,
|
||||
"pv_connection": 0,
|
||||
"photovoltaic_supply": {
|
||||
"none_or_no_details": {
|
||||
"percent_roof_area": 0
|
||||
}
|
||||
},
|
||||
"wind_turbines_count": 0,
|
||||
"gas_smart_meter_present": "false",
|
||||
"is_dwelling_export_capable": "false",
|
||||
"wind_turbines_terrain_type": 2,
|
||||
"electricity_smart_meter_present": "false"
|
||||
},
|
||||
"secondary_heating": {
|
||||
"description": "None",
|
||||
"energy_efficiency_rating": 0,
|
||||
"environmental_efficiency_rating": 0
|
||||
},
|
||||
"sap_building_parts": [
|
||||
{
|
||||
"identifier": "Main Dwelling",
|
||||
"wall_dry_lined": "Y",
|
||||
"wall_thickness": 310,
|
||||
"floor_heat_loss": 6,
|
||||
"roof_construction": 3,
|
||||
"wall_construction": 4,
|
||||
"building_part_number": 1,
|
||||
"sap_floor_dimensions": [
|
||||
{
|
||||
"floor": 0,
|
||||
"room_height": {
|
||||
"value": 2.35,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"total_floor_area": {
|
||||
"value": 75.17,
|
||||
"quantity": "square metres"
|
||||
},
|
||||
"party_wall_length": {
|
||||
"value": 0,
|
||||
"quantity": "metres"
|
||||
},
|
||||
"heat_loss_perimeter": {
|
||||
"value": 38.22,
|
||||
"quantity": "metres"
|
||||
}
|
||||
}
|
||||
],
|
||||
"wall_insulation_type": 4,
|
||||
"construction_age_band": "D",
|
||||
"party_wall_construction": "NA",
|
||||
"wall_thickness_measured": "Y",
|
||||
"roof_insulation_location": "ND",
|
||||
"roof_insulation_thickness": "ND",
|
||||
"wall_insulation_thickness": "NI"
|
||||
}
|
||||
],
|
||||
"solar_water_heating": "N",
|
||||
"habitable_room_count": 3,
|
||||
"heating_cost_current": {
|
||||
"value": 557,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"insulated_door_count": 0,
|
||||
"co2_emissions_current": 2.8,
|
||||
"energy_rating_average": 60,
|
||||
"energy_rating_current": 67,
|
||||
"lighting_cost_current": {
|
||||
"value": 48,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"main_heating_controls": [
|
||||
{
|
||||
"description": "Flat rate charging, TRVs",
|
||||
"energy_efficiency_rating": 3,
|
||||
"environmental_efficiency_rating": 3
|
||||
}
|
||||
],
|
||||
"has_hot_water_cylinder": "false",
|
||||
"heating_cost_potential": {
|
||||
"value": 373,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"hot_water_cost_current": {
|
||||
"value": 242,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"mechanical_ventilation": 0,
|
||||
"percent_draughtproofed": 100,
|
||||
"suggested_improvements": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 184,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"indicative_cost": "\u00a3900 - \u00a31,500",
|
||||
"improvement_type": "B",
|
||||
"improvement_details": {
|
||||
"improvement_number": 6
|
||||
},
|
||||
"improvement_category": 5,
|
||||
"energy_performance_rating": 74,
|
||||
"environmental_impact_rating": 73
|
||||
}
|
||||
],
|
||||
"co2_emissions_potential": 2.1,
|
||||
"energy_rating_potential": 74,
|
||||
"lighting_cost_potential": {
|
||||
"value": 48,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"schema_version_original": "21.0.1",
|
||||
"alternative_improvements": [
|
||||
{
|
||||
"improvement": {
|
||||
"sequence": 1,
|
||||
"typical_saving": {
|
||||
"value": 74,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"improvement_type": "Q2",
|
||||
"improvement_details": {
|
||||
"improvement_number": 55
|
||||
},
|
||||
"improvement_category": 6,
|
||||
"energy_performance_rating": 77,
|
||||
"environmental_impact_rating": 77
|
||||
}
|
||||
}
|
||||
],
|
||||
"hot_water_cost_potential": {
|
||||
"value": 242,
|
||||
"currency": "GBP"
|
||||
},
|
||||
"renewable_heat_incentive": {
|
||||
"water_heating": 2571.36,
|
||||
"space_heating_existing_dwelling": 4808.84
|
||||
},
|
||||
"draughtproofed_door_count": 1,
|
||||
"energy_consumption_current": 205,
|
||||
"has_fixed_air_conditioning": "false",
|
||||
"multiple_glazed_proportion": 100,
|
||||
"calculation_software_version": "5.02r0328",
|
||||
"energy_consumption_potential": 152,
|
||||
"environmental_impact_current": 63,
|
||||
"cfl_fixed_lighting_bulbs_count": 1,
|
||||
"current_energy_efficiency_band": "D",
|
||||
"environmental_impact_potential": 73,
|
||||
"led_fixed_lighting_bulbs_count": 10,
|
||||
"has_heated_separate_conservatory": "false",
|
||||
"potential_energy_efficiency_band": "C",
|
||||
"co2_emissions_current_per_floor_area": 38,
|
||||
"incandescent_fixed_lighting_bulbs_count": 0
|
||||
}
|
||||
1015
domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py
Normal file
1015
domain/sap10_calculator/rdsap/tests/test_cert_to_inputs.py
Normal file
File diff suppressed because it is too large
Load diff
316
domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py
Normal file
316
domain/sap10_calculator/rdsap/tests/test_golden_fixtures.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"""Per-cert pinned-residual tests for a small set of corpus certs.
|
||||
|
||||
Each fixture records the calc's current SAP / PE / CO2 residual vs the
|
||||
cert's lodged values, pinned at a tight absolute tolerance. The shape:
|
||||
|
||||
EpcPropertyDataMapper.from_api_response(cert_json)
|
||||
→ cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
→ calculate_sap_from_inputs(inputs) # SAP + PE
|
||||
→ environmental_section_from_cert(epc, postcode_climate=...) # CO2
|
||||
|
||||
For each cert we assert the residual (calc − lodged) sits within
|
||||
±_SAP_ABS_TOLERANCE / ±_PE_ABS_TOLERANCE_KWH_PER_M2 /
|
||||
±_CO2_ABS_TOLERANCE_TONNES of the recorded `expected_*_resid`. Any
|
||||
mapper or calculator change that shifts a residual beyond the
|
||||
absolute tolerance fires loudly — the author either tightens the pin
|
||||
(improvement) or documents the regression (drift to investigate).
|
||||
|
||||
Residuals are non-zero because of known mapper gaps documented in the
|
||||
per-cert `notes:` field — e.g. cert 0240's RR `room_in_roof_type_1`
|
||||
extraction (gable lengths + "50mm retrofit" parsing) is the −12 SAP /
|
||||
+0.3 t CO2 driver on that fixture. As those gaps close, the pins
|
||||
tighten toward zero.
|
||||
|
||||
Each cert is a stored JSON document under
|
||||
`fixtures/golden/<certificate_number>.json` — frozen at extraction time
|
||||
so test results are reproducible without bulk-zip access.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
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 (
|
||||
SAP_10_2_SPEC_PRICES,
|
||||
cert_to_demand_inputs,
|
||||
cert_to_inputs,
|
||||
)
|
||||
|
||||
_FIXTURES_DIR = Path(__file__).parent / "fixtures" / "golden"
|
||||
|
||||
# Per-cert pin tolerances. SAP is rounded to int so residuals shift in
|
||||
# whole numbers; PE and CO2 are continuous so float comparison applies.
|
||||
# These are absolute distances from the per-cert `expected_*_resid` —
|
||||
# the residual itself can be large (known mapper gaps), what we pin is
|
||||
# its stability under refactors of unrelated code paths.
|
||||
_SAP_ABS_TOLERANCE = 0
|
||||
_PE_ABS_TOLERANCE_KWH_PER_M2 = 0.01
|
||||
_CO2_ABS_TOLERANCE_TONNES = 0.001
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _GoldenExpectation:
|
||||
"""Recorded SAP / PE / CO2 residuals (calc − lodged) at the time of
|
||||
fixture capture, plus short cert-shape notes so anyone debugging a
|
||||
regression knows what kind of cert this is without re-reading the
|
||||
JSON."""
|
||||
|
||||
cert_number: str
|
||||
actual_sap: int
|
||||
expected_sap_resid: int
|
||||
expected_pe_resid_kwh_per_m2: float
|
||||
expected_co2_resid_tonnes_per_yr: float
|
||||
notes: str
|
||||
|
||||
|
||||
_EXPECTATIONS: tuple[_GoldenExpectation, ...] = (
|
||||
_GoldenExpectation(
|
||||
cert_number="0240-0200-5706-2365-8010",
|
||||
actual_sap=73,
|
||||
expected_sap_resid=-15,
|
||||
expected_pe_resid_kwh_per_m2=+17.8450,
|
||||
expected_co2_resid_tonnes_per_yr=+1.0097,
|
||||
notes=(
|
||||
"Detached house, TFA 118, age J, oil boiler PCDB-listed + PV + "
|
||||
"RR on BP[0]. Mapper DOES extract sap_room_in_roof.room_in_roof_"
|
||||
"type_1.gable_wall_length_1/2 (mapper.py:1349) and applies "
|
||||
"U_RR_J=0.30 via u_rr_default_all_elements — the earlier "
|
||||
"handover claim of 'gable_wall_lengths not extracted' is stale. "
|
||||
"Subsystem diff against the cascade: walls 22.95 / roof 76.93 / "
|
||||
"floor 29.43 / windows 41.55 / doors 11.10 / bridging 39.64 "
|
||||
"(total HLC 221.6 W/K). Biggest leverage is windows: 11 windows "
|
||||
"× 18.28 m² × U_default≈2.27 because cert lodges glazing_type=2 "
|
||||
"and Slice 93's _API_GLAZING_TYPE_TO_TRANSMISSION only covers "
|
||||
"codes 3 and 13. Surfacing code 2 → measurable U≈1.8-2.0 would "
|
||||
"close several W/K. Other candidates: BP[0] non-RR ceiling lodges "
|
||||
"'Pitched, 400+ mm loft insulation' — verify cascade U; possibly "
|
||||
"RR description-implied insulation nuance (spec basis unclear "
|
||||
"for RR — unlike regular roofs which have the §5.11.4 50mm rule)."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="0300-2747-7640-2526-2135",
|
||||
actual_sap=78,
|
||||
expected_sap_resid=+1,
|
||||
expected_pe_resid_kwh_per_m2=+1.0093,
|
||||
expected_co2_resid_tonnes_per_yr=-0.8321,
|
||||
notes=(
|
||||
"Large semi-detached, TFA 526, age D, gas boiler PCDB-listed "
|
||||
"(no Table 4b code). Cert lodges open_flues_count=1 + "
|
||||
"has_draught_lobby=true + mains-gas secondary (SAP code 605 / "
|
||||
"fuel 26). Slice 58 cascade routed secondary fuel cost through "
|
||||
"the lodged fuel_type (rather than hardcoding the electric "
|
||||
"tariff), tightening this cert's SAP residual −7 → +2 — the "
|
||||
"biggest single SAP improvement on the golden cohort to date."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="0390-2954-3640-2196-4175",
|
||||
actual_sap=60,
|
||||
expected_sap_resid=-6,
|
||||
expected_pe_resid_kwh_per_m2=-26.4584,
|
||||
expected_co2_resid_tonnes_per_yr=-2.5618,
|
||||
notes="Large detached, TFA 360, age F, oil PCDB-listed. Cert lodges has_draught_lobby=true.",
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="6035-7729-2309-0879-2296",
|
||||
actual_sap=70,
|
||||
expected_sap_resid=-6,
|
||||
expected_pe_resid_kwh_per_m2=+49.5139,
|
||||
expected_co2_resid_tonnes_per_yr=+1.1423,
|
||||
notes=(
|
||||
"Mid-terrace, TFA 128, age A, gas combi Table 4b code 104. "
|
||||
"Slice 59 per-bp window apportionment tightens all 3 "
|
||||
"residuals: SAP -5 → -4, PE +36.15 → +34.02, CO2 +0.81 → "
|
||||
"+0.76 (2 of 8 windows route to Ext1 with ins_type 4 vs "
|
||||
"Main ins_type 3, lowering Ext1's net wall U-loss)."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="7536-3827-0600-0600-0276",
|
||||
actual_sap=68,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=-3.4482,
|
||||
expected_co2_resid_tonnes_per_yr=-0.0907,
|
||||
notes=(
|
||||
"Detached + 2 extensions, TFA 152. Multi-age bps (Main=D, "
|
||||
"Ext1=L, Ext2=F). Slice 59 (per-bp window apportionment) and "
|
||||
"Slice 60 (dwelling-wide thermal bridging y from primary bp's "
|
||||
"age band, not per-bp) jointly tightened: SAP +4 → +3, PE "
|
||||
"-27.17 → -22.53, CO2 -0.72 → -0.60."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="8135-1728-8500-0511-3296",
|
||||
actual_sap=72,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=-2.4072,
|
||||
expected_co2_resid_tonnes_per_yr=-0.0195,
|
||||
notes=(
|
||||
"Semi-detached, TFA 102, age C, gas PCDB-listed. Cert lodges "
|
||||
"blocked_chimneys_count=1. Slice 59 per-bp window apportionment "
|
||||
"tightens PE -16.98 → -16.51 and CO2 -0.30 → -0.29; SAP "
|
||||
"residual unchanged at +1."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="2130-1033-4050-5007-8395",
|
||||
actual_sap=82,
|
||||
expected_sap_resid=+1,
|
||||
expected_pe_resid_kwh_per_m2=-38.1666,
|
||||
expected_co2_resid_tonnes_per_yr=+0.3047,
|
||||
notes=(
|
||||
"End-terrace + 1 extension, TFA 64, gas combi PCDB index 17505, "
|
||||
"postcode DE22 (PCDB Table 172 match), PV: 2× 2.04 kWp arrays "
|
||||
"(SE + NW, overshading 1 + 2). Slices 45a/b/c implement SAP10.2 "
|
||||
"Appendix M per-array yield with the real Appendix U3.3 S(orient, "
|
||||
"p) integral + Table M1 ZPV, and split rating (UK-avg climate) "
|
||||
"from demand (DE22 PCDB Table 172 climate). Net effect: SAP "
|
||||
"residual +9 → +3, PE residual −69.57 → −51.90 vs the prior "
|
||||
"lump-sum 850 × total_kWp. The remaining −51.90 PE drift sits "
|
||||
"outside the PV cascade — candidates include the dwelling-use "
|
||||
"vs export β-factor split (Appendix M §3) and the secondary "
|
||||
"heating credit, both untouched so far."
|
||||
),
|
||||
),
|
||||
_GoldenExpectation(
|
||||
cert_number="0390-2254-6420-2126-5561",
|
||||
actual_sap=65,
|
||||
expected_sap_resid=+0,
|
||||
expected_pe_resid_kwh_per_m2=+1.6962,
|
||||
expected_co2_resid_tonnes_per_yr=+0.0639,
|
||||
notes=(
|
||||
"End-terrace + 1 extension, TFA 80, gas combi PCDB index 18119, "
|
||||
"no PV, no secondary, postcode LN12 (PCDB Table 172 match). "
|
||||
"Cleanest bread-and-butter cert in the cohort and the first to "
|
||||
"hit SAP = exact lodged value (post Slice 41 vent-completeness "
|
||||
"sweep — cert lodges blocked_chimneys_count=2 which reduces "
|
||||
"infiltration vs the pre-fix zero default). PE / CO2 residuals "
|
||||
"are now small enough that the remaining drivers are likely "
|
||||
"lighting efficacy (schema-21 doesn't carry led_/cfl bulb "
|
||||
"counts for this cert) + boiler PCDB winter efficiency lookup."
|
||||
),
|
||||
),
|
||||
# Retired early at P2.2: 9390-2722-3520-2105-8715 (mid-floor flat,
|
||||
# heat network cat 6 sap_code 301). Drifted to SAP residual -7
|
||||
# under SAP 10.2 spec prices because cert-cal had absorbed
|
||||
# heat-network DLF + Table 12c interactions on this cert. Cert JSON
|
||||
# remains in fixtures/golden/ as reference data per ADR-0010 §10;
|
||||
# will be subsumed by a BRE worked-example fixture covering the
|
||||
# heat-network path during P5.
|
||||
)
|
||||
|
||||
|
||||
def _load_cert(cert_number: str) -> dict[str, Any]:
|
||||
"""Load one frozen cert document from the fixtures directory."""
|
||||
path = _FIXTURES_DIR / f"{cert_number}.json"
|
||||
with open(path) as f:
|
||||
return json.load(f) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expectation",
|
||||
_EXPECTATIONS,
|
||||
ids=lambda e: e.cert_number,
|
||||
)
|
||||
def test_golden_cert_residual_matches_pin(expectation: _GoldenExpectation) -> None:
|
||||
# Arrange — load the frozen cert JSON, map to EpcPropertyData, run
|
||||
# the calculator end-to-end via two cascades:
|
||||
# - cert_to_inputs → UK-average climate → SAP rating (per SAP10.2
|
||||
# Appendix U: only SAP + EI use UK-avg);
|
||||
# - cert_to_demand_inputs → postcode climate (PCDB Table 172) →
|
||||
# PE + CO2 (per the same Appendix U: everything the EPC publishes
|
||||
# as "Current X" uses postcode-specific weather).
|
||||
# The single public interface `calculate_sap_from_inputs` surfaces
|
||||
# all three outputs on SapResult; no section helpers required.
|
||||
doc = _load_cert(expectation.cert_number)
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
|
||||
# Act
|
||||
rating = calculate_sap_from_inputs(
|
||||
cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
demand = calculate_sap_from_inputs(
|
||||
cert_to_demand_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
)
|
||||
|
||||
sap_resid = rating.sap_score - expectation.actual_sap
|
||||
pe_resid = demand.primary_energy_kwh_per_m2 - doc["energy_consumption_current"]
|
||||
co2_resid = demand.co2_kg_per_yr / 1000 - doc["co2_emissions_current"]
|
||||
|
||||
# Assert — each residual sits within an absolute tolerance of the
|
||||
# recorded pin. Shifts beyond tolerance fire loudly: tighten the pin
|
||||
# (improvement) or document the regression (drift to investigate).
|
||||
assert abs(sap_resid - expectation.expected_sap_resid) <= _SAP_ABS_TOLERANCE, (
|
||||
f"SAP residual {sap_resid:+d} drifted from pin "
|
||||
f"{expectation.expected_sap_resid:+d} (tolerance ±{_SAP_ABS_TOLERANCE}). "
|
||||
f"Notes: {expectation.notes}"
|
||||
)
|
||||
assert abs(pe_resid - expectation.expected_pe_resid_kwh_per_m2) <= _PE_ABS_TOLERANCE_KWH_PER_M2, (
|
||||
f"PE residual {pe_resid:+.4f} kWh/m² drifted from pin "
|
||||
f"{expectation.expected_pe_resid_kwh_per_m2:+.4f} "
|
||||
f"(tolerance ±{_PE_ABS_TOLERANCE_KWH_PER_M2}). Notes: {expectation.notes}"
|
||||
)
|
||||
assert abs(co2_resid - expectation.expected_co2_resid_tonnes_per_yr) <= _CO2_ABS_TOLERANCE_TONNES, (
|
||||
f"CO2 residual {co2_resid:+.4f} t/yr drifted from pin "
|
||||
f"{expectation.expected_co2_resid_tonnes_per_yr:+.4f} "
|
||||
f"(tolerance ±{_CO2_ABS_TOLERANCE_TONNES}). Notes: {expectation.notes}"
|
||||
)
|
||||
|
||||
|
||||
# Cert 0390 lodges Firebird Boilers S 150-200 oil boiler at PCDB index_number
|
||||
# 9005 (Table 105 winter eff 86.4%). End-to-end mapper → cert_to_inputs chain
|
||||
# must surface that PCDB winter efficiency on `inputs.main_heating_efficiency`
|
||||
# rather than falling back to the Table 4a oil-boiler category default.
|
||||
_PCDB_CHAIN_EXPECTATIONS: tuple[tuple[str, int, float], ...] = (
|
||||
("0390-2954-3640-2196-4175", 9005, 0.864), # Firebird oil PCDB-listed
|
||||
("7536-3827-0600-0600-0276", 17679, None), # Vaillant gas PCDB-listed
|
||||
("0300-2747-7640-2526-2135", 17992, None), # gas PCDB-listed
|
||||
("8135-1728-8500-0511-3296", 17702, None), # gas PCDB-listed
|
||||
("0390-2254-6420-2126-5561", 18119, None), # LN12 gas combi PCDB-listed
|
||||
("2130-1033-4050-5007-8395", 17505, None), # DE22 gas combi PCDB-listed + PV
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cert_number, expected_pcdb_id, expected_winter_eff",
|
||||
_PCDB_CHAIN_EXPECTATIONS,
|
||||
ids=lambda v: v if isinstance(v, str) else "",
|
||||
)
|
||||
def test_api_to_domain_mapper_preserves_main_heating_index_number(
|
||||
cert_number: str, expected_pcdb_id: int, expected_winter_eff: float | None
|
||||
) -> None:
|
||||
"""The full API JSON → EpcPropertyData → CalculatorInputs chain must
|
||||
preserve `main_heating_index_number` end-to-end so the PCDB precedence
|
||||
cascade (Appendix D2.1) fires correctly. Pins:
|
||||
|
||||
1. EpcPropertyDataMapper.from_api_response surfaces the PCDB pointer
|
||||
on `sap_heating.main_heating_details[0].main_heating_index_number`.
|
||||
2. cert_to_inputs resolves Table 105 record by that ID and applies the
|
||||
winter efficiency to `inputs.main_heating_efficiency`.
|
||||
|
||||
Schema versions ≥17_1 carry the field on their dataclass; schema 17_0
|
||||
hardcodes None in the mapper (the field didn't exist in that schema's
|
||||
EPC API contract). The 4 corpus golden certs are all post-17_1.
|
||||
"""
|
||||
# Arrange
|
||||
doc = _load_cert(cert_number)
|
||||
|
||||
# Act
|
||||
epc = EpcPropertyDataMapper.from_api_response(doc)
|
||||
inputs = cert_to_inputs(epc, prices=SAP_10_2_SPEC_PRICES)
|
||||
|
||||
# Assert
|
||||
main = epc.sap_heating.main_heating_details[0]
|
||||
assert main.main_heating_index_number == expected_pcdb_id
|
||||
if expected_winter_eff is not None:
|
||||
assert inputs.main_heating_efficiency == pytest.approx(
|
||||
expected_winter_eff, abs=1e-3
|
||||
)
|
||||
0
domain/sap10_calculator/tables/__init__.py
Normal file
0
domain/sap10_calculator/tables/__init__.py
Normal file
82
domain/sap10_calculator/tables/pcdb/__init__.py
Normal file
82
domain/sap10_calculator/tables/pcdb/__init__.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""BRE Product Characteristics Database (PCDB) lookups.
|
||||
|
||||
The PCDB (pcdb10.dat) lists manufacturer-declared performance data for
|
||||
heating and ventilation equipment, keyed by an integer "Index Number"
|
||||
that RdSAP certs lodge in `MainHeatingDetail.main_heating_index_number`.
|
||||
Where a cert references a PCDB record, SAP 10.2 Appendix D2.1 mandates
|
||||
that the PCDB winter seasonal efficiency overrides the Table 4b
|
||||
category default — closing most of the cert-vs-rating efficiency gap
|
||||
documented in [ADR-0010 §4](../../../../../../../docs/adr/0010-sap10-calculator-spec-target-and-validation.md#4-pcdb-integration-is-promoted-from-session-c-to-a-prerequisite).
|
||||
|
||||
Public surface:
|
||||
|
||||
- `gas_oil_boiler_record(pcdb_id)`: Table 105 lookup.
|
||||
- `GasOilBoilerRecord`: typed record dataclass.
|
||||
- `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/`.
|
||||
|
||||
Reference: BRE PCDB pcdb10.dat (April 2026 revision); SAP 10.2
|
||||
specification (14-03-2025) Appendix D2.1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Final, Optional
|
||||
|
||||
from domain.sap10_calculator.tables.pcdb.parser import GasOilBoilerRecord
|
||||
|
||||
__all__ = ["GasOilBoilerRecord", "gas_oil_boiler_record"]
|
||||
|
||||
|
||||
_PCDB_DATA_DIR: Final[Path] = Path(__file__).resolve().parent / "data"
|
||||
_TABLE_105_JSONL: Final[Path] = (
|
||||
_PCDB_DATA_DIR / "pcdb_table_105_gas_oil_boilers.jsonl"
|
||||
)
|
||||
|
||||
|
||||
def _load_table_105() -> dict[int, GasOilBoilerRecord]:
|
||||
"""Read the Table 105 NDJSON at import time and build a by-pcdb-id
|
||||
dict. ~5MB / ~4000 rows; one-off ~50ms cost. The Python runtime
|
||||
caches the dict so repeated lookups are O(1)."""
|
||||
records_by_id: dict[int, GasOilBoilerRecord] = {}
|
||||
with _TABLE_105_JSONL.open(encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
data = json.loads(line)
|
||||
record = GasOilBoilerRecord(
|
||||
pcdb_id=data["pcdb_id"],
|
||||
brand_name=data["brand_name"],
|
||||
model_name=data["model_name"],
|
||||
model_qualifier=data["model_qualifier"],
|
||||
winter_efficiency_pct=data["winter_efficiency_pct"],
|
||||
summer_efficiency_pct=data["summer_efficiency_pct"],
|
||||
comparative_hot_water_efficiency_pct=data["comparative_hot_water_efficiency_pct"],
|
||||
output_kw_max=data["output_kw_max"],
|
||||
final_year_of_manufacture=data["final_year_of_manufacture"],
|
||||
subsidiary_type=data.get("subsidiary_type"),
|
||||
store_type=data.get("store_type"),
|
||||
separate_dhw_tests=data.get("separate_dhw_tests"),
|
||||
rejected_energy_proportion_r1=data.get("rejected_energy_proportion_r1"),
|
||||
loss_factor_f1_kwh_per_day=data.get("loss_factor_f1_kwh_per_day"),
|
||||
loss_factor_f2_kwh_per_day=data.get("loss_factor_f2_kwh_per_day"),
|
||||
rejected_factor_f3_per_litre=data.get("rejected_factor_f3_per_litre"),
|
||||
raw=tuple(data["raw"]),
|
||||
)
|
||||
records_by_id[record.pcdb_id] = record
|
||||
return records_by_id
|
||||
|
||||
|
||||
_TABLE_105_BY_ID: Final[dict[int, GasOilBoilerRecord]] = _load_table_105()
|
||||
|
||||
|
||||
def gas_oil_boiler_record(pcdb_id: int) -> Optional[GasOilBoilerRecord]:
|
||||
"""Table 105 lookup by `main_heating_index_number`. Returns None when
|
||||
the cert's index number is not in Table 105 — caller falls back to
|
||||
Table 4a/4b category defaults via `seasonal_efficiency(...)`."""
|
||||
return _TABLE_105_BY_ID.get(pcdb_id)
|
||||
23783
domain/sap10_calculator/tables/pcdb/data/pcdb10.dat
Normal file
23783
domain/sap10_calculator/tables/pcdb/data/pcdb10.dat
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,174 @@
|
|||
{"pcdb_id": 691001, "raw": ["691001", "300900", "1", "2014/Jan/16 11:54", "SAP Illustrative Products", "Illustrative Boiler", "Independent", "Wood logs", "", "2011", "current", "20", "3", "1", "2", "1", "15", "15", "15", "", "82", "2", "", "", "", "", "", "", "", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 691002, "raw": ["691002", "300900", "1", "2014/Jan/16 11:48", "SAP Illustrative Products", "Illustrative Boiler", "Wood pellet stove", "Wood pellets", "", "2011", "current", "23", "2", "2", "2", "3", "15", "15", "15", "", "82", "2", "", "", "", "", "", "", "", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 691003, "raw": ["691003", "300900", "1", "2014/Jan/16 11:48", "SAP Illustrative Products", "Illustrative Boiler", "Wood pellet boiler", "Wood pellets", "", "2011", "current", "23", "3", "2", "2", "3", "15", "15", "15", "", "83", "2", "", "", "", "", "", "", "", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700005, "raw": ["700005", "000066", "0", "2013/Oct/24 09:02", "Aga", "Aga", "Much Wenlock", "", "", "2007", "current", "20", "2", "1", "1", "1", "4.7", "4.7", "", "", "70.4", "2", "10.7", "4.7", "2.3", "", "", "", "0", "1", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700006, "raw": ["700006", "000066", "0", "2013/Oct/24 09:02", "Aga", "Aga", "Much Wenlock", "", "", "2007", "current", "12", "2", "1", "1", "1", "4.5", "4.5", "", "", "67.5", "2", "9.1", "4.5", "1.8", "", "", "", "0", "1", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700019, "raw": ["700019", "000048", "0", "2012/Oct/18 11:38", "Grant Engineering (UK)", "Grant", "Spira", "9-36", "", "2011", "current", "23", "3", "1", "2", "3", "12.9", "35.8", "12.9", "", "88.4", "2", "", "", "", "", "", "", "0", "2", "295", "11", "50", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700020, "raw": ["700020", "000048", "0", "2012/Oct/18 11:38", "Grant Engineering (UK)", "Grant", "Spira", "6-26", "", "2011", "current", "23", "3", "1", "2", "3", "8", "27.5", "8", "", "89.5", "2", "", "", "", "", "", "", "0", "2", "295", "11", "44", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700021, "raw": ["700021", "000047", "0", "2014/Dec/01 16:47", "Firebird Heating Solutions Ltd", "Firebird", "16\" Inset Backboiler Stove", "", "", "2012", "current", "20", "2", "3", "2", "1", "7.3", "7.3", "", "", "67", "2", "", "7.3", "2.5", "", "", "", "0", "1", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700022, "raw": ["700022", "000047", "0", "2014/Dec/01 16:49", "Firebird Heating Solutions Ltd", "Firebird", "16\" Inset Backboiler Stove", "", "", "2012", "current", "12", "2", "3", "2", "1", "8.3", "8.3", "", "", "73.4", "2", "", "8.36", "3.6", "", "", "", "0", "1", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700023, "raw": ["700023", "000047", "0", "2015/Feb/04 11:01", "Firebird Heating Solutions Ltd", "Firebird", "18\" Inset Backboiler Stove", "", "", "2012", "current", "20", "2", "3", "2", "1", "6.9", "6.9", "", "", "68.7", "2", "", "6.9", "3.6", "", "", "", "0", "1", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700024, "raw": ["700024", "000047", "0", "2014/Dec/01 16:50", "Firebird Heating Solutions Ltd", "Firebird", "18\" Inset Backboiler Stove", "", "", "2012", "current", "12", "2", "3", "2", "1", "12.1", "12.1", "", "", "77.4", "2", "", "12.1", "3.6", "", "", "", "0", "1", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700026, "raw": ["700026", "000274", "0", "2013/Sep/04 10:49", "Eko-Vimar Orlanski", "Angus", "Super 18kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "7", "18", "1", "", "80.3", "2", "20.7", "18.75", "0", "", "", "", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700027, "raw": ["700027", "000274", "0", "2013/Sep/04 10:49", "Eko-Vimar Orlanski", "Angus", "Super 25kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "10", "25", "10", "", "80.0", "2", "30", "24.6", "0", "", "", "", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700028, "raw": ["700028", "000274", "0", "2013/Sep/04 10:50", "Eko-Vimar Orlanski", "Angus", "Super 40kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "16", "40", "16", "", "80.0", "2", "44.13", "36.2", "0", "", "", "", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700029, "raw": ["700029", "000274", "0", "2013/Sep/04 10:50", "Eko-Vimar Orlanski", "Angus", "Super 60kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "24", "60", "24", "", "77.7", "2", "69.84", "55.65", "0", "", "", "", "0", "2", "100", "100", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700030, "raw": ["700030", "000274", "0", "2013/Sep/04 10:50", "Eko-Vimar Orlanski", "Angus", "Orligno 200 18kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "7", "18", "7", "", "81.4", "2", "23.22", "19.05", "0", "11.15", "9", "0", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700031, "raw": ["700031", "000274", "0", "2013/Sep/04 10:50", "Eko-Vimar Orlanski", "Angus", "Orligno 200 25kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "10", "25", "10", "", "81.4", "2", "32.19", "26.35", "0", "15.31", "12.4", "0", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700032, "raw": ["700032", "000274", "0", "2015/Mar/26 16:48", "Eko-Vimar Orlanski", "Angus", "Orligno 500 25kW", "", "", "1984", "current", "23", "3", "1", "2", "2", "7", "25", "7", "", "83.2", "2", "29.5", "24.6", "0", "8.2", "6.8", "0", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700033, "raw": ["700033", "000274", "0", "2013/Sep/04 10:50", "Eko-Vimar Orlanski", "Angus", "Orligno 200 40kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "16", "40", "16", "", "80.0", "2", "44.13", "36.2", "0", "", "", "", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700034, "raw": ["700034", "000274", "0", "2013/Sep/04 10:50", "Eko-Vimar Orlanski", "Angus", "Orligno 200 60kW", "", "", "1984", "current", "20", "3", "1", "2", "1", "24", "60", "24", "", "77.7", "2", "69.84", "55.65", "0", "", "", "", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700035, "raw": ["700035", "000277", "0", "2013/Sep/09 16:19", "MCZ Group SpA", "RED", "Compact", "18", "", "2012", "current", "23", "3", "1", "2", "3", "3.8", "17", "3.8", "", "83.8", "1", "19.7", "17", "0", "4.67", "3.8", "0", "0", "2", "180", "2.5", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700036, "raw": ["700036", "000277", "0", "2013/Sep/09 16:19", "MCZ Group SpA", "RED", "Compact", "24", "", "2012", "current", "23", "3", "1", "2", "3", "3.8", "22.1", "3.8", "", "82.2", "1", "26.63", "22.1", "0", "4.67", "3.8", "0", "0", "2", "180", "2.5", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700037, "raw": ["700037", "000277", "0", "2013/Sep/09 16:19", "MCZ Group SpA", "RED", "Compact", "35", "", "2012", "current", "23", "3", "1", "2", "3", "8.10", "32", "8.10", "", "84.1", "1", "37.48", "32", "0", "9.78", "8.1", "0", "0", "2", "190", "2.5", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700038, "raw": ["700038", "000277", "0", "2013/Sep/09 16:19", "MCZ Group SpA", "RED", "Logika", "25", "", "2012", "current", "23", "3", "1", "2", "3", "8.3", "24.8", "8.3", "", "86.2", "1", "28.05", "24.8", "0", "9.89", "8.3", "0", "0", "2", "180", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700039, "raw": ["700039", "000277", "0", "2013/Sep/09 16:19", "MCZ Group SpA", "RED", "Logika", "35", "", "2012", "current", "23", "3", "1", "2", "3", "8.3", "32.1", "8.3", "", "85.7", "1", "36.73", "32.1", "0", "9.89", "8.3", "0", "0", "2", "180", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700041, "raw": ["700041", "000279", "0", "2013/Nov/18 09:11", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "15", "", "2011", "current", "23", "3", "3", "2", "3", "3.0", "16.1", "3.0", "", "77.8", "2", "19.46", "16.1", "0", "4.25", "3.1", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700042, "raw": ["700042", "000279", "0", "2013/Nov/18 09:12", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "28", "", "2012", "current", "23", "3", "3", "2", "3", "8", "27.7", "8", "", "81.9", "2", "33.7", "27.7", "0", "9.8", "8", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700043, "raw": ["700043", "000279", "0", "2013/Nov/18 09:12", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "55", "", "2013", "current", "23", "3", "3", "2", "3", "15.7", "54.8", "15.7", "", "79.8", "2", "67", "54.8", "0", "20.2", "15.7", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700044, "raw": ["700044", "000279", "0", "2013/Nov/18 09:12", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "85", "", "2012", "current", "23", "3", "3", "2", "3", "26.8", ">70kW", "26.8", "", "82.9", "2", "103.36", "87.5", "0", "33", "26.8", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700045, "raw": ["700045", "000279", "0", "2013/Nov/18 09:13", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "100", "", "2012", "current", "23", "3", "3", "2", "3", "30", ">70kW", "30", "", "82.2", "2", "152.15", "126.5", "0", "33", "26.8", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700046, "raw": ["700046", "000279", "0", "2013/Nov/18 09:13", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "125", "", "2013", "current", "23", "3", "3", "2", "3", "27", ">70kW", "27", "", "82.2", "2", "152.16", "126.5", "0", "33", "26.8", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700047, "raw": ["700047", "000279", "0", "2015/Feb/17 11:31", "Wood Energy Solutions", "Wood Energy Solutions", "E-Compact", "199", "", "2013", "current", "23", "3", "3", "2", "3", "57", ">70kW", "57", "", "81.3", "2", "249.16", "203.9", "0", "70.46", "56.9", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700048, "raw": ["700048", "000280", "0", "2013/Nov/18 09:27", "Wood Energy Solutions", "Highland Biomass Solutions", "Bio-Flame", "15", "", "2011", "current", "23", "3", "3", "2", "3", "3.0", "16.1", "3.0", "", "77.8", "2", "19.46", "16.1", "0", "4.25", "3.1", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700049, "raw": ["700049", "000218", "0", "2013/Nov/18 09:25", "Wood Energy Solutions", "Turco", "Woodsman", "16", "", "2011", "current", "23", "3", "3", "2", "3", "3.0", "16.1", "3.0", "", "77.8", "2", "19.46", "16.1", "0", "4.25", "3.1", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700050, "raw": ["700050", "000218", "0", "2013/Nov/18 09:26", "Wood Energy Solutions", "Turco", "Woodsman", "28", "", "2012", "current", "23", "3", "3", "2", "3", "8.0", "27.7", "8", "", "81.9", "2", "33.7", "27.7", "0", "9.8", "8", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700052, "raw": ["700052", "000062", "0", "2013/Nov/18 09:29", "Wood Energy Solutions", "Trianco", "Greenflame", "15", "", "2011", "current", "23", "3", "3", "2", "3", "3.0", "16.1", "3.0", "", "77.8", "2", "19.46", "16.1", "0", "4.25", "3.1", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700053, "raw": ["700053", "000062", "0", "2014/Jun/24 09:01", "Wood Energy Solutions", "Trianco", "Greenflame", "28", "", "2012", "current", "23", "3", "3", "2", "3", "8", "27.7", "8", "", "81.9", "2", "33.7", "27.7", "0", "9.8", "8", "0", "0", "2", "400", "170", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700055, "raw": ["700055", "000062", "0", "2014/Jun/24 09:01", "TR Engineering Ltd", "Trianco", "Greenflame ECO 25kW", "", "", "2013", "current", "23", "3", "1", "2", "3", "26.92", "26.92", "", "", "82.7", "2", "32.7", "26.9", "0", "8.9", "7.4", "0", "0", "2", "220", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700056, "raw": ["700056", "000062", "0", "2014/Jun/24 09:01", "TR Engineering Ltd", "Trianco", "Greenflame ECO 15kW", "", "", "2013", "current", "23", "3", "1", "2", "3", "16.10", "16.10", "", "", "82.6", "2", "19.5", "16.1", "0", "5.3", "4.4", "0", "0", "2", "220", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700057, "raw": ["700057", "000062", "0", "2014/Jun/24 09:01", "TR Engineering Ltd", "Trianco", "Greenflame ECO 10kW", "", "", "2013", "current", "23", "3", "1", "2", "3", "10.54", "10.54", "", "", "82.9", "2", "12.7", "10.5", "0", "3.4", "2.8", "0", "0", "2", "210", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700058, "raw": ["700058", "000062", "0", "2014/Jun/24 09:01", "TR Engineering Ltd", "Trianco", "Greenflame ECO 40kW", "", "", "2013", "current", "23", "3", "1", "2", "3", "38.43", "38.43", "", "", "83.1", "2", "46.4", "38.4", "0", "12.8", "10.7", "0", "0", "2", "390", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700059, "raw": ["700059", "000062", "0", "2014/Jun/24 09:01", "TR Engineering Ltd", "Trianco", "Greenflame ECO 60kW", "", "", "2013", "current", "23", "3", "1", "2", "3", "63.7", "63.7", "", "", "79.6", "2", "80", "63.7", "0", "22.4", "17.8", "0", "0", "2", "400", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700060, "raw": ["700060", "000286", "0", "2014/Jun/30 08:55", "Herz Energietechnik Gmbh", "Herz", "Pelletstar", "10", "", "", "current", "23", "3", "1", "2", "3", "12.4", "12.4", "3.4", "", "83.4", "2", "14.8", "12.4", "0", "4.1", "3.4", "0", "0", "2", "67", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700061, "raw": ["700061", "000286", "0", "2014/Jun/30 08:55", "Herz Energietechnik Gmbh", "Herz", "Pelletstar", "20", "", "", "current", "23", "3", "1", "2", "3", "21.2", "21.2", "6.2", "", "83.0", "2", "25.1", "21.2", "0", "7.6", "6.2", "0", "0", "2", "79", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700062, "raw": ["700062", "000286", "0", "2014/Jun/30 08:55", "Herz Energietechnik Gmbh", "Herz", "Pelletstar", "30", "", "", "current", "23", "3", "1", "2", "3", "28.2", "28.2", "6.2", "", "82.5", "2", "33.8", "28.2", "0", "7.6", "6.2", "0", "0", "2", "108", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700063, "raw": ["700063", "000286", "0", "2014/Jun/30 08:55", "Herz Energietechnik Gmbh", "Herz", "Pelletstar", "45", "", "", "current", "23", "3", "1", "2", "3", "46.5", "46.5", "10.1", "", "84.4", "2", "54.9", "46.5", "0", "12", "10.1", "0", "0", "2", "160", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700064, "raw": ["700064", "000286", "0", "2014/Jun/30 08:55", "Herz Energietechnik Gmbh", "Herz", "Pelletstar", "60", "", "", "current", "23", "3", "1", "2", "3", "60.7", "60.7", "10.1", "", "84.1", "2", "72.2", "60.7", "0", "12", "10.1", "0", "0", "2", "183", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700065, "raw": ["700065", "000287", "0", "2014/Jul/25 08:09", "ETA Heiztechnik GmbH", "ETA", "Hack 20", "", "", "", "current", "21", "3", "1", "2", "3", "5.9", "19.9", "", "", "85.1", "2", "23.5", "19.9", "0", "6.9", "5.9", "0", "0", "2", "129", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700066, "raw": ["700066", "000287", "0", "2014/Jul/25 08:10", "ETA Heiztechnik GmbH", "ETA", "Hack 25", "", "", "", "current", "23", "3", "1", "2", "3", "7.1", "26.1", "", "", "83.6", "2", "30.5", "26.1", "0", "8.7", "7.1", "0", "0", "2", "98", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700067, "raw": ["700067", "000287", "0", "2014/Jul/25 08:10", "ETA Heiztechnik GmbH", "ETA", "Hack 25", "", "", "", "current", "21", "3", "1", "2", "3", "7.7", "26.0", "", "", "84.3", "2", "31.3", "26.3", "0", "9.1", "7.7", "0", "0", "2", "147", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700068, "raw": ["700068", "000287", "0", "2014/Jul/25 08:10", "ETA Heiztechnik GmbH", "ETA", "Hack 35", "", "", "", "current", "21", "3", "1", "2", "3", "10.5", "35.0", "", "", "83.6", "2", "", "", "", "", "", "", "0", "2", "195", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700069, "raw": ["700069", "000287", "0", "2014/Jul/25 08:11", "ETA Heiztechnik GmbH", "ETA", "Hack 35", "", "", "", "current", "23", "3", "1", "2", "3", "10.5", "35.0", "", "", "83.4", "2", "", "", "", "", "", "", "0", "2", "112", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700070, "raw": ["700070", "000287", "0", "2014/Jul/25 08:11", "ETA Heiztechnik GmbH", "ETA", "Hack 45", "", "", "", "current", "21", "3", "1", "2", "3", "13.5", "45.0", "", "", "82.9", "2", "", "", "", "", "", "", "0", "2", "254", "13.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700071, "raw": ["700071", "000287", "0", "2014/Jul/25 08:11", "ETA Heiztechnik GmbH", "ETA", "Hack 45", "", "", "", "current", "23", "3", "1", "2", "3", "13.5", "45", "", "", "83.3", "2", "", "", "", "", "", "", "0", "2", "123", "13.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700072, "raw": ["700072", "000287", "0", "2014/Jul/25 08:12", "ETA Heiztechnik GmbH", "ETA", "Hack 50", "", "", "", "current", "21", "3", "1", "2", "3", "14.4", "46.5", "0", "", "82.5", "2", "56.2", "46.5", "0", "17.5", "14.4", "0", "0", "2", "254", "13.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700073, "raw": ["700073", "000287", "0", "2014/Jul/25 08:12", "ETA Heiztechnik GmbH", "ETA", "Hack 50", "", "", "", "current", "23", "3", "1", "2", "3", "14.2", "49.3", "", "", "83.3", "2", "59", "49.3", "0", "17.1", "14.2", "0", "0", "2", "123", "13.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700074, "raw": ["700074", "000287", "0", "2014/Jul/25 08:12", "ETA Heiztechnik GmbH", "ETA", "Hack 70", "", "", "", "current", "21", "3", "1", "2", "3", "21.0", "70.0", "", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "292", "14.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700075, "raw": ["700075", "000287", "0", "2014/Jul/25 08:12", "ETA Heiztechnik GmbH", "ETA", "Hack 70", "", "", "", "current", "23", "3", "1", "2", "3", "21.0", "70.0", "", "", "83.8", "2", "", "", "", "", "", "", "0", "2", "157", "14.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700078, "raw": ["700078", "000287", "0", "2014/Jul/25 08:13", "ETA Heiztechnik GmbH", "ETA", "Pellets Compact PC 20", "", "", "", "current", "23", "3", "3", "2", "3", "6", "20", "", "", "85.7", "2", "", "", "", "", "", "", "0", "2", "90", "12.6", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700079, "raw": ["700079", "000287", "0", "2014/Jul/25 08:14", "ETA Heiztechnik GmbH", "ETA", "Pellets Compact PC 25", "", "", "", "current", "23", "3", "3", "2", "3", "7.3", "25.1", "", "", "85.2", "2", "29", "25.1", "0", "8.7", "7.3", "0", "0", "2", "101", "12.6", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700080, "raw": ["700080", "000287", "0", "2014/Jul/25 08:57", "ETA Heiztechnik GmbH", "ETA", "Pellets Compact PC 32", "", "", "", "current", "23", "3", "3", "2", "3", "7.3", "31.7", "", "", "84.0", "2", "36.8", "31.7", "0", "8.7", "7.3", "", "0", "2", "142", "12.6", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700081, "raw": ["700081", "000287", "0", "2014/Jul/25 08:17", "ETA Heiztechnik GmbH", "ETA", "PE-K 35", "", "", "", "current", "23", "3", "1", "2", "3", "9.4", "35.0", "", "", "84.5", "2", "", "", "", "", "", "", "0", "2", "159", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700082, "raw": ["700082", "000287", "0", "2014/Dec/22 10:07", "ETA Heiztechnik GmbH", "ETA", "PE-K 45", "", "", "", "current", "23", "3", "1", "2", "3", "13.5", "45.0", "", "", "84.8", "2", "", "", "", "", "", "", "0", "2", "153", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700083, "raw": ["700083", "000287", "0", "2014/Jul/25 08:17", "ETA Heiztechnik GmbH", "ETA", "PE-K 50", "", "", "", "current", "23", "3", "1", "2", "3", "14.1", "48.7", "", "", "84.9", "2", "57.4", "48.7", "0", "16.6", "14.1", "0", "0", "2", "153", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700084, "raw": ["700084", "000287", "0", "2014/Jul/25 08:18", "ETA Heiztechnik GmbH", "ETA", "PE-K 70", "", "", "", "current", "23", "3", "1", "2", "3", "21", "70", "", "", "84.5", "2", "", "", "", "", "", "", "0", "2", "190", "10.0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700085, "raw": ["700085", "000287", "0", "2014/Jul/25 08:18", "ETA Heiztechnik GmbH", "ETA", "Pellets Unit PU 7", "", "", "", "current", "23", "3", "3", "2", "3", "2.3", "8.2", "", "", "82.4", "2", "9.6", "8.2", "0", "2.9", "2.3", "0", "0", "2", "61", "11.6", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700086, "raw": ["700086", "000287", "0", "2014/Jul/25 08:18", "ETA Heiztechnik GmbH", "ETA", "Pellets Unit PU 11", "", "", "", "current", "23", "3", "3", "2", "3", "2.3", "11.2", "", "", "81.8", "2", "13.3", "11.2", "0", "2.9", "2.3", "0", "0", "2", "63", "11.6", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700087, "raw": ["700087", "000287", "0", "2014/Jul/25 08:58", "ETA Heiztechnik GmbH", "ETA", "Pellets Unit PU 15", "", "", "", "current", "23", "3", "3", "2", "3", "4.4", "15", "", "", "85.8", "2", "17.6", "15", "0", "5.1", "4.4", "0", "0", "2", "95", "12.3", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700088, "raw": ["700088", "000287", "0", "2014/Jul/25 08:19", "ETA Heiztechnik GmbH", "ETA", "SH 20", "", "", "", "current", "20", "3", "1", "2", "1", "10.4", "20.7", "", "", "85.6", "2", "24.5", "20.7", "0", "12", "10.4", "0", "0", "2", "69", "10.8", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700089, "raw": ["700089", "000287", "0", "2014/Jul/25 08:19", "ETA Heiztechnik GmbH", "ETA", "SH 30", "", "", "", "current", "20", "3", "1", "2", "1", "15.2", "29", "", "", "85.6", "2", "34.2", "29", "0", "17.6", "15.2", "0", "0", "2", "69", "10.8", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700090, "raw": ["700090", "000287", "0", "2014/Jul/25 08:20", "ETA Heiztechnik GmbH", "ETA", "SH 40", "", "", "", "current", "20", "3", "1", "2", "1", "20", "40", "", "", "84.9", "2", "", "", "", "", "", "", "0", "2", "87", "10.8", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700091, "raw": ["700091", "000287", "0", "2014/Jul/25 08:20", "ETA Heiztechnik GmbH", "ETA", "SH 50", "", "", "", "current", "20", "3", "1", "2", "1", "20", "49.9", "", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "87", "10.8", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700092, "raw": ["700092", "000287", "0", "2014/Jul/25 08:21", "ETA Heiztechnik GmbH", "ETA", "SH 60", "", "", "", "current", "20", "3", "1", "2", "1", "20.2", "60.9", "", "", "83.3", "2", "73.3", "60.5", "0", "30", "25.2", "0", "0", "2", "87", "10.8", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700097, "raw": ["700097", "000288", "0", "2014/Aug/18 10:17", "Solarfocus GmbH", "Solarfocus", "pellet top 45", "", "", "2014", "current", "23", "3", "3", "2", "3", "44.9", "44.9", "", "", "86.0", "2", "", "", "", "", "", "", "0", "2", "114", "10", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700098, "raw": ["700098", "000288", "0", "2014/Aug/18 10:17", "Solarfocus GmbH", "Solarfocus", "pellet top 35", "", "", "2012", "current", "23", "3", "3", "2", "3", "34.47", "34.47", "", "", "85.9", "2", "40.02", "34.47", "0", "12.09", "10.36", "0", "0", "2", "106", "4", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700099, "raw": ["700099", "000289", "0", "2018/Nov/12 08:43", "Windhager", "Windhager", "BioWIN 2 Exklusiv", "BWE 102", "", "2013", "current", "23", "3", "1", "2", "3", "10.2", "10.2", "", "", "84.3", "2", "11.9", "10.2", "0", "3.5", "2.9", "0", "0", "2", "28", "6", "21", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700100, "raw": ["700100", "000289", "0", "2015/Feb/12 10:53", "Windhager", "Windhager", "Biowin 2 Exklusiv", "BWE 152", "", "2013", "current", "23", "3", "1", "2", "3", "15.1", "15.1", "", "", "85.5", "2", "17.7", "15.1", "0", "4.9", "4.2", "0", "0", "2", "33", "6", "32", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700101, "raw": ["700101", "000289", "0", "2015/Feb/12 10:53", "Windhager", "Windhager", "Biowin 2 Exklusiv", "BWE 212", "", "2013", "current", "23", "3", "1", "2", "3", "26.0", "26.0", "", "", "85.5", "2", "30.4", "26", "0", "8.9", "7.6", "0", "0", "2", "48", "6", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700102, "raw": ["700102", "000289", "0", "2015/Feb/12 10:53", "Windhager", "Windhager", "Biowin 2 Exklusiv", "BWE 262", "", "2013", "current", "23", "3", "1", "2", "3", "26.0", "26.0", "", "", "85.5", "2", "30.4", "26", "0", "8.9", "7.6", "0", "0", "2", "48", "6", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700103, "raw": ["700103", "000289", "0", "2015/Feb/12 10:53", "Windhager", "Windhager", "Biowin 2 Premium", "BWP 102", "", "2013", "current", "23", "3", "1", "2", "3", "10.2", "10.2", "0", "", "84.3", "2", "11.9", "10.2", "0", "3.5", "2.9", "0", "0", "2", "28", "6", "21", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700104, "raw": ["700104", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Premium", "BWP 152", "", "2013", "current", "23", "3", "1", "2", "3", "15.1", "15.1", "0", "", "85.5", "2", "17.7", "15.1", "0", "4.9", "4.2", "0", "0", "2", "33", "6", "32", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700105, "raw": ["700105", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Premium", "BWP 212", "", "2013", "current", "23", "3", "1", "2", "3", "26.0", "26.0", "0", "", "85.5", "2", "30.4", "26", "0", "8.9", "7.6", "0", "0", "2", "48", "6", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700106, "raw": ["700106", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Premium", "BWP 262", "", "2013", "current", "23", "3", "1", "2", "3", "26.0", "26.0", "0", "", "85.5", "2", "30.4", "26", "0", "8.9", "7.6", "0", "0", "2", "48", "6", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700107, "raw": ["700107", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Klassik", "BWK 102", "", "2013", "current", "23", "3", "1", "2", "3", "10.2", "10.2", "", "", "84.3", "2", "11.9", "10.2", "0", "3.5", "2.9", "0", "0", "2", "28", "6", "21", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700108, "raw": ["700108", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Klassik", "BWK 152", "", "2013", "current", "23", "3", "1", "2", "3", "15.1", "15.1", "", "", "85.5", "2", "17.7", "15.1", "0", "4.9", "4.2", "0", "0", "2", "33", "6", "32", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700109, "raw": ["700109", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Klassik", "BWK 212", "", "2013", "current", "23", "3", "1", "2", "3", "26.0", "26.0", "", "", "85.5", "2", "30.4", "26", "0", "8.9", "7.6", "0", "0", "2", "48", "6", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700110, "raw": ["700110", "000289", "0", "2015/Feb/12 10:54", "Windhager", "Windhager", "Biowin 2 Klassik", "BWK 262", "", "2013", "current", "23", "3", "1", "2", "3", "26.0", "26.0", "0", "", "85.5", "2", "30.4", "26", "0", "8.9", "7.6", "0", "0", "2", "48", "6", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700111, "raw": ["700111", "000063", "0", "2014/Nov/06 11:01", "Warmflow Engineering", "Warmflow", "WS 18", "Wood Pellet Boiler", "", "2014", "current", "23", "3", "3", "2", "3", "17.3", "17.3", "", "", "83.7", "2", "20.5", "17.3", "0", "4.7", "3.9", "0", "0", "2", "38", "9", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700112, "raw": ["700112", "000063", "0", "2014/Nov/06 11:01", "Warmflow Engineering", "Warmflow", "WP18", "Wood Pellet Boiler", "", "2014", "current", "23", "3", "3", "2", "3", "17.3", "17.3", "", "", "83.7", "2", "20.5", "17.3", "0", "4.7", "3.9", "0", "0", "2", "38", "9", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700113, "raw": ["700113", "000290", "0", "2014/Nov/14 11:18", "Biotech Energietecnik GmbH", "Biotech", "Top Light M", "", "", "2006", "current", "23", "3", "1", "2", "4", "15.5", "15.5", "4.5", "", "85.8", "1", "18.12", "15.5", "0", "5.23", "4.5", "0", "0", "2", "1020", "80", "60", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700114, "raw": ["700114", "000290", "0", "2014/Nov/14 11:18", "Biotech Energietecnik GmbH", "Biotech", "PZ 25 RL", "", "", "2004", "current", "23", "3", "1", "2", "4", "25", "25", "6.7", "", "86.5", "1", "28.85", "25", "0", "7.76", "6.7", "0", "0", "2", "1020", "80", "95", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700115, "raw": ["700115", "000290", "0", "2014/Nov/14 11:18", "Biotech Energietecnik GmbH", "Biotech", "PZ 35 RL", "", "", "2004", "current", "23", "3", "1", "2", "4", "35", "35", "8.3", "", "86.6", "1", "41.17", "35", "0", "9.42", "8.3", "0", "0", "2", "1020", "50", "130", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700118, "raw": ["700118", "000290", "0", "2014/Nov/14 11:18", "Biotech Energietecnik GmbH", "Biotech", "Top Light M MBW", "", "", "2010", "current", "23", "3", "1", "2", "4", "15.5", "15.5", "4.5", "", "85.8", "1", "18.12", "15.5", "0", "5.23", "4.5", "0", "0", "2", "1020", "80", "60", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700119, "raw": ["700119", "000290", "0", "2014/Nov/14 11:15", "Biotech Energietecnik GmbH", "Biotech", "PZ 65 RL", "", "", "2009", "current", "23", "3", "1", "2", "4", "64.7", "64.7", "19", "", "86.5", "1", "75.3", "64.7", "0", "21.83", "19", "0", "0", "2", "1020", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700120, "raw": ["700120", "000290", "0", "2014/Nov/14 11:15", "Biotech Energietecnik GmbH", "Biotech", "PZ 8 RL", "", "", "2006", "current", "23", "3", "1", "2", "4", "13.5", "13.5", "2.0", "", "87.3", "1", "15.68", "13.5", "0", "2.26", "2", "0", "0", "2", "1020", "50", "50", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700121, "raw": ["700121", "000290", "0", "2014/Nov/14 11:14", "Biotech Energietecnik GmbH", "Biotech", "Top Light", "", "", "2005", "current", "23", "3", "1", "2", "4", "8.6", "8.6", "2.4", "", "84.7", "1", "10.16", "8.6", "0", "2.83", "2.4", "0", "0", "2", "1020", "50", "35", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700122, "raw": ["700122", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF 2 V 35", "", "", "2011", "current", "23", "3", "1", "2", "3", "34.5", "34.5", "", "", "87.1", "2", "39.6", "34.5", "0", "11.6", "10.1", "0", "0", "2", "130", "112", "67.7", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700123, "raw": ["700123", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 35", "", "", "2011", "current", "23", "3", "1", "2", "3", "34.5", "34.5", "", "", "87.1", "2", "39.6", "34.5", "0", "11.6", "10.1", "0", "0", "2", "130", "112", "67.7", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700124, "raw": ["700124", "000291", "0", "2014/Nov/13 13:14", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 35", "", "", "2011", "current", "23", "3", "1", "2", "3", "34.5", "34.5", "", "", "87.1", "2", "39.6", "34.5", "0", "11.6", "10.1", "0", "0", "2", "130", "112", "67.7", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700125, "raw": ["700125", "000291", "0", "2014/Nov/13 13:14", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 30", "", "", "2011", "current", "23", "3", "1", "2", "3", "30.0", "30.0", "", "", "86.2", "2", "", "", "", "", "", "", "0", "2", "130", "101", "58", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700126, "raw": ["700126", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 30", "", "", "2011", "current", "23", "3", "1", "2", "3", "30.0", "30.0", "", "", "86.2", "2", "", "", "", "", "", "", "0", "2", "130", "101", "58", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700127, "raw": ["700127", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 V 30", "", "", "2011", "current", "23", "3", "1", "2", "3", "30", "30", "", "", "86.2", "2", "", "", "", "", "", "", "0", "2", "130", "101", "58", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700128, "raw": ["700128", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 25", "", "", "2011", "current", "23", "3", "1", "2", "3", "25.0", "25.0", "", "", "85.4", "2", "", "", "", "", "", "", "0", "2", "130", "93", "48.4", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700129, "raw": ["700129", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 25", "", "", "2011", "current", "23", "3", "1", "2", "3", "25", "25", "", "", "85.4", "2", "", "", "", "", "", "", "0", "2", "130", "93", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700130, "raw": ["700130", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 V 25", "", "", "2011", "current", "23", "3", "1", "2", "3", "25.0", "25.0", "", "", "85.4", "2", "", "", "", "", "", "", "0", "2", "130", "93", "48.4", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700131, "raw": ["700131", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 22", "", "", "2011", "current", "23", "3", "1", "2", "3", "22.0", "22.0", "", "", "84.9", "2", "25.4", "22", "0", "7.7", "6.4", "0", "0", "2", "130", "83", "42.6", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700132, "raw": ["700132", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 22", "", "", "2011", "current", "23", "3", "1", "2", "3", "22.0", "22.0", "", "", "84.9", "2", "25.4", "22", "0", "7.7", "6.4", "0", "0", "2", "130", "83", "42.6", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700133, "raw": ["700133", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 V 22", "", "", "2011", "current", "23", "3", "1", "2", "3", "22.0", "22.0", "", "", "84.9", "2", "25.4", "22", "0", "7.7", "6.4", "0", "0", "2", "130", "83", "42.6", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700134, "raw": ["700134", "000291", "0", "2014/Nov/13 13:13", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 V 12", "", "", "2011", "current", "23", "3", "1", "2", "3", "11.6", "11.6", "", "", "83.7", "2", "13.5", "11.6", "0", "4.3", "3.5", "0", "0", "2", "130", "66", "23.2", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700135, "raw": ["700135", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 12", "", "", "2011", "current", "23", "3", "1", "2", "3", "11.6", "11.6", "", "", "83.7", "2", "13.5", "11.6", "0", "4.3", "3.5", "0", "0", "2", "130", "66", "23.2", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700136, "raw": ["700136", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 12", "", "", "2011", "current", "23", "3", "1", "2", "3", "11.6", "11.6", "", "", "83.7", "2", "13.5", "11.6", "0", "4.3", "3.5", "0", "0", "2", "130", "66", "23.2", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700137, "raw": ["700137", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 15", "", "", "2011", "current", "23", "3", "1", "2", "3", "15", "15", "0", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "130", "72", "29", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700138, "raw": ["700138", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 V 15", "", "", "2011", "current", "23", "3", "1", "2", "3", "15", "15", "", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "130", "72", "29", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700139, "raw": ["700139", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 15", "", "", "2011", "current", "23", "3", "1", "2", "3", "15.0", "15.0", "", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "130", "72", "29", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700140, "raw": ["700140", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 GS 8", "", "", "2011", "current", "23", "3", "1", "2", "3", "8.4", "8.4", "", "", "83.4", "2", "10", "8.4", "0", "2.9", "2.4", "0", "0", "2", "130", "6", "15.5", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700141, "raw": ["700141", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 V 8", "", "", "2011", "current", "23", "3", "1", "2", "3", "8.4", "8.4", "0", "", "83.4", "2", "10", "8.4", "0", "2.9", "2.4", "0", "0", "2", "130", "60", "15.5", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700142, "raw": ["700142", "000291", "0", "2014/Nov/13 13:12", "KWB Kraft und W rme aus Biomasse GmbH", "KWB Easyfire", "EF2 S 8", "", "", "2011", "current", "23", "3", "1", "2", "3", "8.4", "8.4", "", "", "83.4", "2", "10", "8.4", "0", "2.9", "2.4", "0", "0", "2", "130", "60", "15.5", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700153, "raw": ["700153", "000274", "0", "2015/Jun/10 10:07", "Eko-Vimar Orlanski", "Angus", "Orligno 400 16kW", "", "", "2012", "current", "23", "3", "1", "2", "2", "15.07", "15.07", "", "", "81.6", "2", "18.5", "15.1", "0", "5.03", "4.1", "0", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700154, "raw": ["700154", "000274", "0", "2015/Jun/10 10:05", "Eko-Vimar Orlanski", "Angus", "Orligno 400 30kW", "", "", "2012", "current", "23", "3", "1", "2", "2", "29.9", "29.9", "", "", "84.2", "2", "35.8", "29.9", "0", "9.3", "7.9", "0", "0", "2", "50", "50", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700155, "raw": ["700155", "000289", "0", "2015/May/20 12:53", "Windhager", "Windhager", "LogWIN Premium", "LWP300", "", "2008", "current", "20", "3", "1", "2", "1", "31.1", "31.1", "", "", "83.3", "2", "37.14", "31.1", "", "16.15", "13.4", "", "0", "2", "58", "7", "70", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700156, "raw": ["700156", "000289", "0", "2015/May/20 12:54", "Windhager", "Windhager", "LogWIN Premium", "LWP360", "", "2008", "current", "20", "3", "1", "2", "1", "35.6", "35.6", "", "", "83.3", "2", "", "", "", "", "", "", "0", "2", "66", "7", "110", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700157, "raw": ["700157", "000289", "0", "2015/May/20 12:56", "Windhager", "Windhager", "LogWIN Premium", "LWP500", "", "2008", "current", "20", "3", "1", "2", "1", "49.7", "49.7", "", "", "83.2", "2", "60.55", "49.7", "", "28.13", "23.7", "", "0", "2", "66", "7", "110", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700158, "raw": ["700158", "000289", "0", "2015/May/20 14:13", "Windhager", "Windhager", "BioWIN XL Exklusiv", "BWE 600", "", "", "current", "23", "3", "1", "2", "3", "59.3", "59.3", "", "", "82.0", "2", "72.3", "59.3", "", "22", "18", "", "0", "2", "156", "7", "126", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700160, "raw": ["700160", "000289", "0", "2015/Jun/03 09:49", "Windhager", "Windhager", "BioWIN XL Exklusiv", "BWE 450", "", "", "current", "23", "3", "1", "2", "3", "45", "45", "", "", "81.8", "2", "", "", "", "", "", "", "0", "2", "156", "7", "126", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700161, "raw": ["700161", "000289", "0", "2018/Nov/12 08:45", "Windhager", "Windhager", "BioWIN XL Exklusiv", "BWE 350", "", "", "current", "23", "3", "1", "2", "3", "34.9", "34.9", "", "", "80.9", "2", "42.1", "34.9", "", "12.4", "10", "", "0", "2", "103", "7", "81", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700162, "raw": ["700162", "000289", "0", "2015/May/20 12:58", "Windhager", "Windhager", "LogWIN Premium", "LWP180", "", "2008", "current", "20", "3", "1", "2", "1", "17.8", "17.8", "", "", "83.0", "2", "21.4", "17.8", "", "16.2", "13.4", "", "0", "2", "47", "7", "40", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700163, "raw": ["700163", "000289", "0", "2015/May/20 12:59", "Windhager", "Windhager", "LogWIN Premium", "LWP250", "", "2008", "current", "20", "3", "1", "2", "1", "25", "25", "", "", "83.2", "2", "", "", "", "", "", "", "0", "2", "58", "7", "70", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700164, "raw": ["700164", "000289", "0", "2015/May/26 14:13", "Windhager", "Windhager", "FireWIN Exklusiv", "FWE090", "", "2008", "current", "23", "2", "3", "2", "3", "7.8", "7.8", "", "", "86.8", "2", "10.5", "7.8", "1.3", "5.5", "4", "0.8", "0", "2", "50", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700165, "raw": ["700165", "000289", "0", "2015/May/26 14:13", "Windhager", "Windhager", "FireWIN Klassik/Premium", "FWK/P 090", "", "2008", "current", "23", "2", "3", "2", "3", "7.8", "7.8", "", "", "86.8", "2", "10.5", "7.8", "1.3", "5.5", "4", "0.8", "0", "2", "50", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700166, "raw": ["700166", "000289", "0", "2015/May/26 14:13", "Windhager", "Windhager", "FireWIN Exklusiv", "FWE120", "", "2008", "current", "23", "2", "3", "2", "3", "10.6", "10.6", "", "", "86.7", "2", "14.1", "10.6", "1.5", "5.5", "4", "0.8", "0", "2", "57", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700167, "raw": ["700167", "000289", "0", "2015/May/26 14:13", "Windhager", "Windhager", "FireWIN Klassik/Premium", "FWK/P 120", "", "2008", "current", "23", "2", "3", "2", "3", "10.6", "10.6", "", "", "86.7", "2", "14.1", "10.6", "1.5", "5.5", "4", "0.8", "0", "2", "57", "7", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700168, "raw": ["700168", "000289", "0", "2015/May/26 14:14", "Windhager", "Windhager", "LogWIN Klassik", "LWK180", "", "2013", "current", "20", "3", "1", "2", "1", "19.4", "19.4", "", "", "80.4", "2", "23.5", "19.4", "", "", "", "", "0", "2", "92", "7", "40", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700170, "raw": ["700170", "000289", "0", "2015/May/26 14:14", "Windhager", "Windhager", "LogWIN Klassik", "LWK300", "", "2013", "current", "20", "3", "1", "2", "1", "30", "30", "", "", "82.0", "2", "36.7", "30.3", "", "18.7", "15.2", "", "0", "2", "96", "7", "70", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700171, "raw": ["700171", "000289", "0", "2015/May/26 14:14", "Windhager", "Windhager", "LogWIN Klassik", "LWK250", "", "2013", "current", "20", "3", "1", "2", "1", "25", "25", "", "", "81.3", "2", "", "", "", "", "", "", "0", "0", "96", "7", "70", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700172, "raw": ["700172", "000296", "0", "2015/Jul/16 15:44", "Ariterm A B", "Ariterm", "Biomatic +20", "", "", "2009", "current", "23", "3", "1", "2", "3", "20", "20", "", "", "88.5", "2", "", "", "", "", "", "", "0", "2", "40", "4", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700173, "raw": ["700173", "000296", "0", "2015/Jul/16 15:44", "Ariterm A B", "Ariterm", "Biomatic +40", "", "", "2011", "current", "23", "3", "1", "2", "3", "40", "40", "", "", "90", "2", "", "", "", "", "", "", "0", "2", "60", "4", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700174, "raw": ["700174", "000289", "0", "2015/Jul/13 08:58", "Windhager", "Windhager", "BioWIN Exclusiv", "BWE 100", "", "2004", "2013", "23", "3", "1", "2", "3", "10.2", "10.2", "", "", "83.5", "2", "12.09", "10.2", "0", "3.63", "3", "0", "0", "2", "46", "7", "21", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700175, "raw": ["700175", "000289", "0", "2015/Jul/13 08:58", "Windhager", "Windhager", "BioWIN Exclusiv", "BWE 150", "", "2004", "2013", "23", "3", "1", "2", "3", "15.2", "15.2", "", "", "83.9", "2", "18.02", "15.2", "0", "5.27", "4.4", "0", "0", "2", "58", "7", "32", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700176, "raw": ["700176", "000289", "0", "2015/Jul/13 08:58", "Windhager", "Windhager", "BioWIN Exclusiv", "BWE 210", "", "2004", "2013", "23", "3", "1", "2", "3", "21", "21", "", "", "84.2", "2", "", "", "", "", "", "", "0", "2", "110", "7", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700177, "raw": ["700177", "000289", "0", "2015/Jul/13 08:59", "Windhager", "Windhager", "BioWIN Exclusiv", "BWE 260", "", "2004", "2013", "23", "3", "1", "2", "3", "25.9", "25.9", "", "", "84.5", "2", "30.55", "25.9", "0", "8.9", "7.5", "0", "0", "2", "110", "7", "56", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700180, "raw": ["700180", "000308", "0", "2018/Aug/17 11:43", "Klover Srl", "Klover", "Diva", "", "DV", "2007", "current", "23", "2", "1", "2", "3", "13.9", "13.9", "4.9", "", "81.2", "2", "22.97", "13.9", "4.6", "5.98", "3.9", "1", "0", "2", "300", "3.2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700181, "raw": ["700181", "000308", "0", "2018/May/23 11:47", "Klover Srl", "Klover", "Ecompact 250", "", "EC25", "2017", "current", "23", "3", "1", "2", "3", "23.3", "23.3", "6.5", "", "81.9", "2", "27.9", "23.3", "0", "8.1", "6.5", "0", "0", "2", "431", "3.2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700182, "raw": ["700182", "000308", "0", "2018/May/23 11:48", "Klover Srl", "Klover", "Ecompact 290", "", "EC29", "2017", "current", "23", "3", "1", "2", "3", "26.76", "26.76", "6.58", "", "83.5", "2", "32", "26.76", "0", "7.9", "6.58", "0", "0", "2", "431", "3.2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700183, "raw": ["700183", "000308", "0", "2018/Aug/17 11:55", "Klover Srl", "Klover", "Smart 80", "", "SM80", "2012", "current", "23", "2", "1", "2", "3", "19.1", "19.1", "6.7", "", "81.4", "2", "28.22", "19.1", "3.5", "8.1", "5.5", "1.2", "0", "2", "300", "3.2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700184, "raw": ["700184", "000308", "0", "2018/Aug/17 11:42", "Klover Srl", "Klover", "Smart 120", "", "SM120", "2011", "current", "23", "2", "1", "2", "3", "14.6", "14.6", "5.7", "", "85.1", "2", "22.8", "14.6", "4.5", "6.6", "4.5", "1.2", "0", "2", "300", "3.2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700185, "raw": ["700185", "000308", "0", "2021/Apr/15 17:28", "Klover Srl", "Klover", "Ecompact 150", "", "ECO150", "2017", "current", "23", "3", "1", "2", "3", "14.6", "14.6", "4.2", "", "84.6", "2", "18.6", "14.6", "0", "5.3", "4.2", "0", "0", "2", "430", "2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700186, "raw": ["700186", "000308", "0", "2021/Apr/15 17:28", "Klover Srl", "Klover", "Ecompact 190", "", "ECO190", "", "current", "23", "3", "1", "2", "3", "18.2", "18.2", "4.2", "", "84.4", "2", "23.4", "18.2", "0", "5.3", "4.2", "0", "0", "2", "430", "2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700187, "raw": ["700187", "000308", "0", "2022/Aug/25 15:42", "Klover Srl", "Klover", "BELVEDERE 18", "BV16", "", "2017", "current", "23", "2", "1", "2", "3", "18.4", "18.4", "4.9", "", "89.6", "2", "20.9", "13.9", "4.6", "5.4", "3.9", "1", "0", "2", "300", "3.2", "", "", "", "", "", "", "700180", ""]}
|
||||
{"pcdb_id": 700188, "raw": ["700188", "000308", "0", "2023/May/12 15:42", "Klover Srl", "Klover", "THERMOAURA", "HA", "", "2019", "current", "23", "2", "1", "2", "3", "15", "15", "4.3", "", "87.5", "2", "13.5", "11.7", "3.3", "4.8", "3", "1.3", "0", "2", "56", "3", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700189, "raw": ["700189", "000308", "0", "2022/Sep/20 14:00", "Klover Srl", "Klover", "PELLET BOILER 24", "PB24-A0001", "", "2013", "2017", "23", "3", "1", "2", "3", "17.6", "17.6", "5.2", "", "74.9", "2", "22.86", "17.6", "0", "7.14", "5.2", "0", "0", "2", "98", "2", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700191, "raw": ["700191", "020234", "0", "2024/Dec/05 10:10", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 7", "", "", "", "current", "23", "3", "3", "2", "3", "7", "7", "", "", "84.3", "2", "7.96", "6.83", "0", "2.43", "2.01", "0", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700192, "raw": ["700192", "020234", "0", "2024/Dec/05 10:10", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 10", "", "", "", "current", "23", "3", "3", "2", "3", "10", "10", "", "", "84.3", "1", "10.97", "9.42", "0", "2.43", "2.01", "0", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700194, "raw": ["700194", "020234", "0", "2024/Dec/05 10:11", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 15", "", "", "", "current", "23", "3", "3", "2", "3", "15", "15", "", "", "86.3", "2", "16.37", "14.3", "", "", "4.5", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700195, "raw": ["700195", "020234", "0", "2024/Dec/05 10:11", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 20", "", "", "", "current", "23", "3", "3", "2", "3", "20", "20", "", "", "85.7", "2", "21.98", "18.9", "", "5.27", "4.5", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700196, "raw": ["700196", "020234", "0", "2024/Dec/05 10:11", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 25", "", "", "", "current", "23", "3", "3", "2", "3", "25", "25", "", "", "85.8", "2", "29.12", "24.9", "", "8.3", "7.14", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700197, "raw": ["700197", "020234", "0", "2024/Dec/05 10:11", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 30", "", "", "", "current", "23", "3", "3", "2", "3", "30", "30", "", "", "85.9", "2", "", "", "", "", "", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700198, "raw": ["700198", "020234", "0", "2024/Dec/05 10:11", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "PE1 Pellet 35", "", "", "", "current", "23", "3", "3", "2", "3", "35", "35", "", "", "85.9", "2", "38.68", "33.2", "", "8.3", "7.14", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700200, "raw": ["700200", "020234", "0", "2023/Nov/15 08:32", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "S4 Turbo 60", "", "", "2007", "current", "20", "3", "1", "2", "1", "60", "60", "", "", "85.6", "2", "65.35", "56.47", "0", "32.84", "27.88", "0", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700201, "raw": ["700201", "020234", "0", "2023/Nov/15 08:33", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "S4 Turbo 22", "", "", "", "current", "20", "3", "1", "2", "1", "22", "22", "", "", "84.5", "2", "25.53", "22.13", "0", "", "", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700202, "raw": ["700202", "020234", "0", "2023/Nov/15 08:33", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "S4 Turbo 28", "", "", "", "current", "20", "3", "1", "2", "1", "28", "28", "", "", "83", "2", "", "", "", "", "", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700203, "raw": ["700203", "020234", "0", "2023/Nov/15 08:33", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "S4 Turbo 34", "", "", "", "current", "20", "3", "1", "2", "1", "34", "34", "", "", "82", "2", "41", "34.5", "0", "", "", "", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700204, "raw": ["700204", "020234", "0", "2023/Nov/15 08:34", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "S4 Turbo 40", "", "", "", "current", "20", "3", "1", "2", "1", "40", "40", "", "", "84.3", "2", "44.69", "37.64", "0", "22.43", "18.94", "0", "0", "2", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700205, "raw": ["700205", "020234", "0", "2023/Nov/15 08:34", "Froling Heizkessel und Bahaelterbau GmbH", "Froling", "S4 Turbo 50", "", "", "", "current", "20", "3", "1", "2", "1", "50", "50", "", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "0", "0", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700214, "raw": ["700214", "000287", "0", "2024/Mar/19 13:53", "ETA Heiztechnik GmbH", "ETA", "Pellets Compact ETA PC 40", "", "", "2014", "current", "23", "3", "2", "2", "3", "40", "40", "", "", "84.4", "2", "", "", "", "", "", "", "0", "2", "121", "11", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700215, "raw": ["700215", "000287", "0", "2024/Mar/19 13:54", "ETA Heiztechnik GmbH", "ETA", "Pellets Compact ETA PC 45", "", "", "2014", "current", "23", "3", "2", "2", "3", "45", "45", "", "", "84.1", "2", "", "", "", "", "", "", "0", "2", "121", "11", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700220, "raw": ["700220", "000287", "0", "2024/Mar/19 13:55", "ETA Heiztechnik GmbH", "ETA", "ETA eHack 25", "", "", "2015", "current", "23", "3", "2", "2", "3", "25.4", "25.4", "7.6", "", "85.3", "2", "29.45", "25.4", "0", "9.01", "7.6", "0", "0", "2", "63", "12", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700221, "raw": ["700221", "000287", "0", "2024/Mar/19 13:55", "ETA Heiztechnik GmbH", "ETA", "ETA eHack 25", "", "", "2015", "current", "21", "3", "2", "2", "3", "25.4", "25.4", "7.6", "", "85.3", "2", "29.45", "25.4", "0", "9.01", "7.6", "0", "0", "2", "63", "12", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700222, "raw": ["700222", "000315", "0", "2026/Jan/26 09:19", "Hargassner", "Hargassner", "Nano-PK 6", "", "", "", "current", "23", "3", "3", "2", "3", "6.6", "6.6", "", "", "88.5", "2", "8.22", "7.08", "0.09", "2.14", "1.85", "0.07", "0", "2", "29", "7", "10", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700223, "raw": ["700223", "000315", "0", "2026/Jan/26 09:20", "Hargassner", "Hargassner", "Nano-PK 9", "", "", "", "current", "23", "3", "3", "2", "3", "9", "9", "", "", "89.1", "2", "", "", "", "", "", "", "0", "2", "29", "7", "11", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700224, "raw": ["700224", "000315", "0", "2026/Jan/26 09:29", "Hargassner", "Hargassner", "Nano-PK 12", "", "", "", "current", "23", "3", "3", "2", "3", "12", "12", "", "", "89.5", "2", "", "", "", "", "", "", "0", "2", "31", "7", "14", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700225, "raw": ["700225", "000315", "0", "2026/Jan/26 09:34", "Hargassner", "Hargassner", "Neo-HV 20", "", "", "", "current", "20", "3", "3", "2", "1", "25.4", "25.4", "", "", "85.2", "2", "29.06", "24.44", "0.27", "15.14", "12.65", "0.28", "0", "2", "32", "6.3", "30", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700226, "raw": ["700226", "000315", "0", "2026/Jan/26 09:36", "Hargassner", "Hargassner", "Smart-Duo 17", "", "", "", "current", "20", "3", "3", "2", "1", "17", "17", "", "", "85.5", "2", "20.66", "17.7", "0.42", "", "", "", "0", "2", "40", "8", "25", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700227, "raw": ["700227", "000315", "0", "2026/Jan/26 09:39", "Hargassner", "Hargassner", "Smart-PK 17", "", "", "", "current", "23", "3", "3", "2", "3", "17", "17", "", "", "87.1", "2", "", "", "", "", "", "", "0", "2", "37", "2", "22", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 700228, "raw": ["700228", "000315", "0", "2026/Jan/26 09:31", "Hargassner", "Hargassner", "Smart-Duo 17", "", "", "", "current", "23", "3", "3", "2", "3", "17", "17", "", "", "89.7", "2", "20.33", "17.6", "0.48", "6.04", "5", "0.46", "0", "2", "30", "8", "24", "", "", "", "", "", "", ""]}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{"pcdb_id": 692001, "raw": ["692001", "300900", "1", "2011/Sep/08 16:45", "5.03", "SAP Illustrative Products", "Illustrative Micro-CHP", "Ex 1", "Gas", "2011", "current", "1", "", "2", "2", "1", "3", "", "54.7", "-0.096", "", "", "14", "14", "11", "2", "7", "0.5", "81.9", "-0.045", "1", "83.2", "-0.056", "1.5", "82.4", "-0.065", "2", "79.7", "-0.07", "3", "73.9", "-0.079", "6", "58.7", "-0.094", "10", "57.0", "-0.085"]}
|
||||
{"pcdb_id": 40001, "raw": ["040001", "000005", "0", "2019/Oct/11 10:23", "7", "Baxi Heating UK Ltd", "Baxi", "Ecogen 24/1.0", "", "2009", "current", "1", "", "2", "2", "1", "3", "", "48.3", "-0.146", "", "", "24", "24", "11", "2", "7", "0.5", "84.1", "-0.044", "1", "85.2", "-0.059", "1.5", "85.7", "-0.082", "2", "83.9", "-0.106", "3", "80", "-0.142", "6", "74.5", "-0.167", "10", "67.4", "-0.168"]}
|
||||
{"pcdb_id": 40005, "raw": ["040005", "000267", "0", "2019/Oct/11 10:24", "7", "Efficient Home Energy", "EHE", "Whispergen EU1", "", "2010", "current", "1", "", "2", "2", "1", "3", "", "61.1", "-0.045", "", "", "14", "14", "11", "2", "7", "0.5", "80.7", "-0.072", "1", "82.1", "-0.077", "1.5", "83.8", "-0.078", "2", "84.4", "-0.068", "3", "84.2", "-0.048", "6", "79.4", "-0.033", "10", "69.2", "-0.031"]}
|
||||
{"pcdb_id": 40009, "raw": ["040009", "000267", "0", "2019/Oct/11 10:25", "7", "Efficient Home Energy", "EHE", "Whispergen EU1A", "", "2011", "current", "1", "", "2", "2", "1", "3", "", "62.2", "-0.066", "", "", "12.5", "12.5", "11", "2", "7", "0.5", "80.2", "-0.082", "1", "81.9", "-0.088", "1.5", "83.8", "-0.091", "2", "84.3", "-0.083", "3", "83.9", "-0.066", "6", "79.8", "-0.053", "10", "70.9", "-0.05"]}
|
||||
{"pcdb_id": 40010, "raw": ["040010", "000005", "0", "2019/Oct/11 10:25", "7", "Baxi Heating UK", "Baxi", "Ecogen System", "", "2014", "current", "1", "", "2", "3", "1", "3", "", "48.3", "-0.146", "", "", "24", "24", "11", "2", "7", "0.5", "84.2", "-0.043", "1", "85.7", "-0.055", "1.5", "86.9", "-0.072", "2", "86.1", "-0.087", "3", "83.7", "-0.109", "6", "78.6", "-0.125", "10", "70.8", "-0.126"]}
|
||||
{"pcdb_id": 40013, "raw": ["040013", "000302", "0", "2019/Oct/11 10:25", "7", "Flow Products Ltd", "FLOW", "Flow 14H/1.0", "", "2010", "current", "1", "", "2", "2", "1", "3", "", "71.4", "0.015", "", "", "12.8", "12.8", "11", "2", "7", "0.5", "84.5", "-0.018", "1", "86", "-0.025", "1.5", "87.3", "-0.03", "2", "87.3", "-0.03", "3", "86.4", "-0.024", "6", "85.6", "-0.013", "10", "85.6", "0.003"]}
|
||||
{"pcdb_id": 40014, "raw": ["040014", "000033", "0", "2020/Aug/12 16:45", "7.02", "Viessmann", "Viessmann", "Vitovalor", "300-P", "2017", "current", "1", "", "2", "2", "1", "1", "", "36.62", "-0.736", "", "", "20", "20", "11", "1", "7", "0.5", "91.2", "-0.033", "1", "88.5", "-0.047", "1.5", "85", "-0.066", "2", "81.5", "-0.084", "3", "74.9", "-0.112", "6", "61.1", "-0.129", "10", "62.9", "-0.094"]}
|
||||
{"pcdb_id": 40017, "raw": ["040017", "000033", "0", "2020/Aug/12 17:05", "7.02", "Viessmann", "Viessmann", "VITOVALOR PT2", "E11T", "2019", "current", "1", "", "2", "2", "1", "1", "", "39.302", "-0.457", "", "", "11.4", "11.4", "11", "1", "7", "0.5", "78.2", "-0.094", "1", "67", "-0.186", "1.5", "58.2", "-0.299", "2", "52.6", "-0.393", "3", "45.1", "-0.54", "6", "36", "-0.659", "10", "36.8", "-0.56"]}
|
||||
{"pcdb_id": 40019, "raw": ["040019", "000033", "0", "2020/Aug/12 17:08", "7.02", "Viessmann", "Viessmann", "VITOVALOR PT2", "E19T", "2019", "current", "1", "", "2", "2", "1", "1", "", "39.302", "-0.457", "", "", "19", "19", "11", "1", "7", "0.5", "78.2", "-0.094", "1", "67", "-0.186", "1.5", "58.2", "-0.299", "2", "52.6", "-0.393", "3", "45.1", "-0.54", "6", "36", "-0.659", "10", "36.8", "-0.56"]}
|
||||
{"pcdb_id": 40020, "raw": ["040020", "000033", "0", "2020/Aug/12 17:08", "7.02", "Viessmann", "Viessmann", "VITOVALOR PT2", "E25T", "2019", "current", "1", "", "2", "2", "1", "1", "", "39.302", "-0.457", "", "", "24.5", "24.5", "11", "1", "7", "0.5", "78.2", "-0.094", "1", "67", "-0.186", "1.5", "58.2", "-0.299", "2", "52.6", "-0.393", "3", "45.1", "-0.54", "6", "36", "-0.659", "10", "36.8", "-0.56"]}
|
||||
{"pcdb_id": 40022, "raw": ["040022", "000033", "0", "2020/Aug/12 17:12", "7.02", "Viessmann", "Viessmann", "VITOVALOR PT2", "E32T", "2019", "current", "1", "", "2", "2", "1", "1", "", "39.302", "-0.457", "", "", "30.8", "30.8", "11", "1", "7", "0.5", "78.2", "-0.094", "1", "67", "-0.186", "1.5", "58.2", "-0.299", "2", "52.6", "-0.393", "3", "45.1", "-0.54", "6", "36", "-0.659", "10", "36.8", "-0.56"]}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
{"pcdb_id": 60049, "raw": ["060049", "020006", "0", "2021/Nov/26 10:09", "Zenex Technologies Ltd", "Zenex", "GasSaver", "GS-1", "2006", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.072", "0.072", "", "0", "", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60050, "raw": ["060050", "020006", "0", "2021/Nov/26 10:09", "Zenex Technologies Ltd", "Zenex", "GasSaver", "GS-1", "2006", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.068", "0.068", "", "0", "", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60051, "raw": ["060051", "020025", "0", "2021/Nov/12 15:51", "Ravenheat Manufacturing Ltd", "Ravenheat", "EnergyCatcher", "A0-1", "2008", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.104", "0.104", "", "0", "", "6", "0", "0", "0.104", "0", "0", "0.1041", "0", "200", "0.5", "0.1936", "-0.5", "0.5", "0.1937", "-0.5", "1000", "0.9", "0.1967", "-1.2", "0.9", "0.1968", "-1.2", "2000", "2.8", "0.1914", "-8.4", "2.8", "0.1915", "-8.4", "4000", "3.4", "0.19", "-10.4", "3.4", "0.1901", "-10.4", "20000", "4", "0.1945", "-13.2", "4", "0.1946", "-13.2", ""]}
|
||||
{"pcdb_id": 60052, "raw": ["060052", "020025", "0", "2021/Nov/12 15:51", "Ravenheat Manufacturing Ltd", "Ravenheat", "EnergyCatcher", "A0-1", "2008", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.099", "0.099", "", "0", "", "6", "0", "0", "0.0988", "0", "0", "0.0989", "0", "200", "0.5", "0.1839", "-0.5", "0.5", "0.184", "-0.5", "1000", "0.9", "0.1869", "-1.1", "0.9", "0.187", "-1.1", "2000", "2.7", "0.1818", "-8", "2.7", "0.1819", "-8", "4000", "3.2", "0.1805", "-9.9", "3.2", "0.1806", "-9.9", "20000", "3.8", "0.1848", "-12.5", "3.8", "0.1849", "-12.5", ""]}
|
||||
{"pcdb_id": 60053, "raw": ["060053", "020025", "0", "2021/Nov/26 10:06", "Ravenheat Manufacturing Ltd", "Ravenheat", "EnergyCatcher", "B1-1", "2008", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.119", "0.119", "", "0", "", "6", "0", "0", "0.1189", "0", "0", "0.1193", "0", "200", "-0.1", "0.1944", "0", "-0.1", "0.1947", "0", "1000", "1.7", "0.1922", "-5.8", "1.7", "0.1926", "-5.8", "2000", "3.3", "0.1931", "-12.4", "3.3", "0.1935", "-12.4", "4000", "1.9", "0.2136", "-7", "1.9", "0.214", "-7", "20000", "0.1", "0.2494", "-0.6", "0.1", "0.2498", "-0.6", ""]}
|
||||
{"pcdb_id": 60054, "raw": ["060054", "020025", "0", "2021/Nov/26 10:06", "Ravenheat Manufacturing Ltd", "Ravenheat", "EnergyCatcher", "B1-1", "2008", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.113", "0.113", "", "0", "", "6", "0", "0", "0.113", "0", "0", "0.1133", "0", "200", "-0.1", "0.1847", "0", "-0.1", "0.185", "0", "1000", "1.6", "0.1826", "-5.5", "1.6", "0.183", "-5.5", "2000", "3.1", "0.1834", "-11.8", "3.1", "0.1838", "-11.8", "4000", "1.8", "0.2029", "-6.7", "1.8", "0.2033", "-6.7", "20000", "0.1", "0.2369", "-0.6", "0.1", "0.2373", "-0.6", ""]}
|
||||
{"pcdb_id": 60055, "raw": ["060055", "020006", "0", "2021/Nov/26 12:04", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-25", "2008", "current", "1", "1", "E", "0", "3", "24", "24", "0.6", "0.072", "0.072", "0", "0", "", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60056, "raw": ["060056", "020006", "0", "2021/Nov/26 12:05", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-25", "2008", "current", "2", "1", "E", "0", "3", "24", "24", "0.6", "0.0684", "0.0684", "0", "0", "", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60057, "raw": ["060057", "020006", "0", "2021/Nov/26 12:04", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-50", "2008", "current", "1", "1", "E", "0", "3", "50", "50", "1.1", "0.072", "0.072", "0", "0", "", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60058, "raw": ["060058", "020006", "0", "2021/Nov/26 12:06", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-50", "2008", "current", "2", "1", "E", "0", "3", "50", "50", "1.1", "0.0684", "0.0684", "0", "0", "", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60059, "raw": ["060059", "020029", "0", "2021/Nov/26 12:04", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-25", "2008", "current", "1", "1", "E", "0", "3", "24", "24", "0.6", "0.072", "0.072", "0", "0", "", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60060, "raw": ["060060", "020029", "0", "2021/Nov/26 12:06", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-25", "2008", "current", "2", "1", "E", "0", "3", "24", "24", "0.6", "0.0684", "0.0684", "0", "0", "", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60061, "raw": ["060061", "020029", "0", "2021/Nov/26 12:05", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-50", "2008", "current", "1", "1", "E", "0", "3", "50", "50", "1.1", "0.072", "0.072", "0", "0", "", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60062, "raw": ["060062", "020029", "0", "2021/Nov/26 12:06", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-50", "2008", "current", "2", "1", "E", "0", "3", "50", "50", "1.1", "0.0684", "0.0684", "0", "0", "", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60063, "raw": ["060063", "020006", "0", "2021/Nov/26 12:05", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-25-PV1", "2008", "current", "1", "1", "E", "0", "3", "24", "24", "0.6", "0.072", "0.072", "0", "1", "0.07", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60064, "raw": ["060064", "020006", "0", "2021/Nov/26 12:06", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-25-PV1", "2008", "current", "2", "1", "E", "0", "3", "24", "24", "0.6", "0.0684", "0.0684", "0", "1", "0.07", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60065, "raw": ["060065", "020006", "0", "2021/Nov/26 12:05", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-50-PV1", "2008", "current", "1", "1", "E", "0", "3", "50", "50", "1.1", "0.072", "0.072", "0", "1", "0.07", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60066, "raw": ["060066", "020006", "0", "2021/Nov/26 12:06", "Zenex Technologies Ltd", "Zenex", "SuperFlow", "SF-50-PV1", "2008", "current", "2", "1", "E", "0", "3", "50", "50", "1.1", "0.0684", "0.0684", "0", "1", "0.07", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60067, "raw": ["060067", "020029", "0", "2021/Nov/26 12:05", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-25-PV1", "2008", "current", "1", "1", "E", "0", "3", "24", "24", "0.6", "0.072", "0.072", "0", "1", "0.07", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60068, "raw": ["060068", "020029", "0", "2021/Nov/26 12:06", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-25-PV1", "2008", "current", "2", "1", "E", "0", "3", "24", "24", "0.6", "0.0684", "0.0684", "0", "1", "0.07", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60069, "raw": ["060069", "020029", "0", "2021/Nov/26 12:05", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-50-PV1", "2008", "current", "1", "1", "E", "0", "3", "50", "50", "1.1", "0.072", "0.072", "0", "1", "0.07", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60070, "raw": ["060070", "020029", "0", "2021/Nov/26 12:06", "Alpha Therm Ltd", "Alpha", "FlowSmart", "FS-50-PV1", "2008", "current", "2", "1", "E", "0", "3", "50", "50", "1.1", "0.0684", "0.0684", "0", "1", "0.07", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60072, "raw": ["060072", "020068", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Muelink & Grol", "ECOFLO", "60-100", "2011", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.106", "0.105", "", "0", "", "6", "0", "0", "0.1063", "0", "0", "0.1049", "0", "200", "0.3", "0.1896", "-0.1", "0.3", "0.1883", "-0.1", "1000", "0.8", "0.1915", "-1", "0.8", "0.1902", "-1", "2000", "2.5", "0.1869", "-7.3", "2.5", "0.1856", "-7.3", "4000", "2.8", "0.1872", "-8.1", "2.8", "0.186", "-8.1", "20000", "3.2", "0.1923", "-10.1", "3.2", "0.191", "-10.1", ""]}
|
||||
{"pcdb_id": 60073, "raw": ["060073", "020068", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Muelink & Grol", "ECOFLO", "60-100", "2011", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.101", "0.1", "", "0", "", "6", "0", "0", "0.101", "0", "0", "0.0997", "0", "200", "0.3", "0.1801", "-0.1", "0.3", "0.1789", "-0.1", "1000", "0.8", "0.1819", "-1", "0.8", "0.1807", "-1", "2000", "2.4", "0.1776", "-6.9", "2.4", "0.1763", "-6.9", "4000", "2.7", "0.1778", "-7.7", "2.7", "0.1767", "-7.7", "20000", "3", "0.1827", "-9.6", "3", "0.1815", "-9.6", ""]}
|
||||
{"pcdb_id": 60074, "raw": ["060074", "020033", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Glow-worm", "PFGHRD/1", "60/100", "2013", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.106", "0.105", "", "0", "", "6", "0", "0", "0.1063", "0", "0", "0.1049", "0", "200", "0.3", "0.1896", "-0.1", "0.3", "0.1883", "-0.1", "1000", "0.8", "0.1915", "-1", "0.8", "0.1902", "-1", "2000", "2.5", "0.1869", "-7.3", "2.5", "0.1856", "-7.3", "4000", "2.8", "0.1872", "-8.1", "2.8", "0.186", "-8.1", "20000", "3.2", "0.1923", "-10.1", "3.2", "0.191", "-10.1", ""]}
|
||||
{"pcdb_id": 60075, "raw": ["060075", "020033", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Glow-worm", "PFGHRD/1", "60/100", "2013", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.101", "0.1", "", "0", "", "6", "0", "0", "0.101", "0", "0", "0.0997", "0", "200", "0.3", "0.1801", "-0.1", "0.3", "0.1789", "-0.1", "1000", "0.8", "0.1819", "-1", "0.8", "0.1807", "-1", "2000", "2.4", "0.1776", "-6.9", "2.4", "0.1763", "-6.9", "4000", "2.7", "0.1778", "-7.7", "2.7", "0.1767", "-7.7", "20000", "3", "0.1827", "-9.6", "3", "0.1815", "-9.6", ""]}
|
||||
{"pcdb_id": 60076, "raw": ["060076", "020033", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Vaillant", "PFGHRD/1", "60/100", "2013", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.106", "0.105", "", "0", "", "6", "0", "0", "0.1063", "0", "0", "0.1049", "0", "200", "0.3", "0.1896", "-0.1", "0.3", "0.1883", "-0.1", "1000", "0.8", "0.1915", "-1", "0.8", "0.1902", "-1", "2000", "2.5", "0.1869", "-7.3", "2.5", "0.1856", "-7.3", "4000", "2.8", "0.1872", "-8.1", "2.8", "0.186", "-8.1", "20000", "3.2", "0.1923", "-10.1", "3.2", "0.191", "-10.1", ""]}
|
||||
{"pcdb_id": 60077, "raw": ["060077", "020033", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Vaillant", "PFGHRD/1", "60/100", "2013", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.101", "0.1", "", "0", "", "6", "0", "0", "0.101", "0", "0", "0.0997", "0", "200", "0.3", "0.1801", "-0.1", "0.3", "0.1789", "-0.1", "1000", "0.8", "0.1819", "-1", "0.8", "0.1807", "-1", "2000", "2.4", "0.1776", "-6.9", "2.4", "0.1763", "-6.9", "4000", "2.7", "0.1778", "-7.7", "2.7", "0.1767", "-7.7", "20000", "3", "0.1827", "-9.6", "3", "0.1815", "-9.6", ""]}
|
||||
{"pcdb_id": 60078, "raw": ["060078", "020088", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Vokera", "Fuelsaver", "FS1", "2013", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.106", "0.105", "", "0", "", "6", "0", "0", "0.1063", "0", "0", "0.1049", "0", "200", "0.3", "0.1896", "-0.1", "0.3", "0.1883", "-0.1", "1000", "0.8", "0.1915", "-1", "0.8", "0.1902", "-1", "2000", "2.5", "0.1869", "-7.3", "2.5", "0.1856", "-7.3", "4000", "2.8", "0.1872", "-8.1", "2.8", "0.186", "-8.1", "20000", "3.2", "0.1923", "-10.1", "3.2", "0.191", "-10.1", ""]}
|
||||
{"pcdb_id": 60079, "raw": ["060079", "020088", "0", "2021/Nov/26 09:59", "Muelink & Grol B.V.", "Vokera", "Fuelsaver", "FS1", "2013", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.101", "0.1", "", "0", "", "6", "0", "0", "0.101", "0", "0", "0.0997", "0", "200", "0.3", "0.1801", "-0.1", "0.3", "0.1789", "-0.1", "1000", "0.8", "0.1819", "-1", "0.8", "0.1807", "-1", "2000", "2.4", "0.1776", "-6.9", "2.4", "0.1763", "-6.9", "4000", "2.7", "0.1778", "-7.7", "2.7", "0.1767", "-7.7", "20000", "3", "0.1827", "-9.6", "3", "0.1815", "-9.6", ""]}
|
||||
{"pcdb_id": 60080, "raw": ["060080", "020051", "0", "2021/Nov/26 13:52", "Bosch Thermotechnology Ltd", "Worcester", "Greenstar Xtra", "2015", "2015", "current", "1", "1", "CSK", "0", "1", "0", "0", "", "0.102", "0.102", "", "0", "", "0", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 60081, "raw": ["060081", "020051", "0", "2021/Nov/26 13:52", "Bosch Thermotechnology Ltd", "Worcester", "Greenstar Xtra", "2015", "2015", "current", "2", "1", "CSK", "0", "1", "0", "0", "", "0.097", "0.097", "", "0", "", "0", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 60082, "raw": ["060082", "020029", "0", "2021/Nov/26 09:50", "Alpha Therm Ltd", "Alpha", "Gas-Saver", "GS-2-ALPCD", "2016", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.088", "0.088", "0", "0", "", "6", "0", "0", "0.0885", "0", "0", "0.0882", "0", "200", "2.5", "0.1915", "-6", "2.5", "0.1913", "-6", "1000", "6", "0.1934", "-17.9", "5.9", "0.1933", "-17.9", "2000", "10.3", "0.1946", "-36.9", "10.3", "0.1944", "-36.9", "4000", "10.8", "0.2047", "-39.3", "10.8", "0.2045", "-39.3", "20000", "11.5", "0.2297", "-44", "11.5", "0.2295", "-44", ""]}
|
||||
{"pcdb_id": 60083, "raw": ["060083", "020029", "0", "2021/Nov/26 09:50", "Alpha Therm Ltd", "Alpha", "Gas-Saver", "GS-2-ALPCD", "2016", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.084", "0.084", "0", "0", "", "6", "0", "0", "0.0841", "0", "0", "0.0838", "0", "200", "2.4", "0.1819", "-5.7", "2.4", "0.1817", "-5.7", "1000", "5.7", "0.1837", "-17", "5.6", "0.1836", "-17", "2000", "9.8", "0.1849", "-35.1", "9.8", "0.1847", "-35.1", "4000", "10.3", "0.1945", "-37.3", "10.3", "0.1943", "-37.3", "20000", "10.9", "0.2182", "-41.8", "10.9", "0.218", "-41.8", ""]}
|
||||
{"pcdb_id": 60084, "raw": ["060084", "020029", "0", "2021/Nov/26 09:50", "Alpha Therm Ltd", "Alpha", "Intec", "30GS/40GS+GasSaver-GS-1", "2011", "current", "1", "1", "C", "1", "2", "0", "0", "", "0.072", "0.072", "", "0", "", "6", "0", "0", "0.0717", "0", "0", "0.0717", "0", "200", "1.1", "0.1799", "-1.9", "1.1", "0.1799", "-1.9", "1000", "4.5", "0.1726", "-13.2", "4.5", "0.1726", "-13.2", "2000", "6", "0.1807", "-19.8", "6", "0.1807", "-19.8", "4000", "8.7", "0.1801", "-31.7", "8.7", "0.1801", "-31.7", "20000", "9.7", "0.2003", "-37", "9.7", "0.2003", "-37", ""]}
|
||||
{"pcdb_id": 60085, "raw": ["060085", "020029", "0", "2021/Nov/26 09:50", "Alpha Therm Ltd", "Alpha", "Intec", "30GS/40GS+GasSaver-GS-1", "2011", "current", "2", "1", "C", "1", "2", "0", "0", "", "0.068", "0.068", "", "0", "", "6", "0", "0", "0.0681", "0", "0", "0.0681", "0", "200", "1", "0.1709", "-1.8", "1", "0.1709", "-1.8", "1000", "4.3", "0.164", "-12.5", "4.3", "0.164", "-12.5", "2000", "5.7", "0.1717", "-18.8", "5.7", "0.1717", "-18.8", "4000", "8.3", "0.1711", "-30.1", "8.3", "0.1711", "-30.1", "20000", "9.2", "0.1903", "-35.2", "9.2", "0.1903", "-35.2", ""]}
|
||||
{"pcdb_id": 60086, "raw": ["060086", "020029", "0", "2021/Nov/26 10:43", "Alpha Therm Ltd", "Alpha", "Gas-Saver", "40GS2+GasSaver-GS2-ALPCD", "2016", "current", "1", "1", "C", "1", "2", "0", "0", "", "0", "0", "0", "0", "", "6", "0", "0", "0", "0", "0", "0", "0", "200", "2.7", "0.113", "-6.6", "2.7", "0.113", "-6.6", "1000", "6.5", "0.1152", "-19.6", "6.5", "0.1152", "-19.6", "2000", "11.3", "0.1164", "-40.5", "11.3", "0.1164", "-40.5", "4000", "11.9", "0.1275", "-43.1", "11.9", "0.1275", "-43.1", "20000", "12.6", "0.1549", "-48.2", "12.6", "0.1549", "-48.2", ""]}
|
||||
{"pcdb_id": 60087, "raw": ["060087", "020029", "0", "2021/Nov/26 10:43", "Alpha Therm Ltd", "Alpha", "Gas-Saver", "40GS2+GasSaver-GS2-ALPCD", "2016", "current", "2", "1", "C", "1", "2", "0", "0", "", "0", "0", "0", "0", "", "6", "0", "0", "0", "0", "0", "0", "0", "200", "2.6", "0.1074", "-6.3", "2.6", "0.1074", "-6.3", "1000", "6.2", "0.1094", "-18.6", "6.2", "0.1094", "-18.6", "2000", "10.7", "0.1106", "-38.5", "10.7", "0.1106", "-38.5", "4000", "11.3", "0.1211", "-40.9", "11.3", "0.1211", "-40.9", "20000", "12", "0.1472", "-45.8", "12", "0.1472", "-45.8", ""]}
|
||||
{"pcdb_id": 60088, "raw": ["060088", "020006", "0", "2021/Nov/26 09:57", "Canetis Technologies Ltd", "Canetis", "GasSaver", "GS-2", "2018", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.09", "0.09", "", "0", "", "6", "0", "0", "0.0896", "0", "0", "0.0898", "0", "200", "2.5", "0.1925", "-6", "2.5", "0.1927", "-6", "1000", "5.9", "0.1945", "-17.8", "5.9", "0.1947", "-17.9", "2000", "10.3", "0.1956", "-36.8", "10.3", "0.1958", "-36.8", "4000", "10.8", "0.2057", "-39.3", "10.8", "0.2059", "-39.3", "20000", "11.5", "0.2307", "-43.9", "11.5", "0.2308", "-43.9", ""]}
|
||||
{"pcdb_id": 60089, "raw": ["060089", "020006", "0", "2021/Nov/26 09:57", "Canetis Technologies Ltd", "Canetis", "GasSaver", "GS-2", "2018", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.086", "0.086", "", "0", "", "6", "0", "0", "0.0851", "0", "0", "0.0853", "0", "200", "2.4", "0.1829", "-5.7", "2.4", "0.1831", "-5.7", "1000", "5.6", "0.1848", "-16.9", "5.6", "0.185", "-17", "2000", "9.8", "0.1858", "-35", "9.8", "0.186", "-35", "4000", "10.3", "0.1954", "-37.3", "10.3", "0.1956", "-37.3", "20000", "10.9", "0.2192", "-41.7", "10.9", "0.2193", "-41.7", ""]}
|
||||
{"pcdb_id": 60090, "raw": ["060090", "020101", "0", "2021/Nov/26 09:53", "Baxi Heating Limited UK", "Baxi", "Assure", "FGHR1", "2021", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.13", "0.13", "", "0", "", "6", "0", "0", "0.1299", "0", "0", "0.13", "0", "200", "1.3", "0.1835", "-0.4", "1.3", "0.1836", "-0.4", "1000", "5.1", "0.181", "-11.4", "5.1", "0.1811", "-11.4", "2000", "10.6", "0.1716", "-32.1", "10.6", "0.1716", "-32.1", "4000", "11.8", "0.1815", "-36.9", "11.8", "0.1816", "-36.9", "20000", "13.1", "0.2152", "-42.6", "13.1", "0.2153", "-42.6", ""]}
|
||||
{"pcdb_id": 60091, "raw": ["060091", "020101", "0", "2021/Nov/26 09:53", "Baxi Heating Limited UK", "Baxi", "Assure", "FGHR1", "2021", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.124", "0.124", "", "0", "", "6", "0", "0", "0.1234", "0", "0", "0.1235", "0", "200", "1.2", "0.1743", "-0.4", "1.2", "0.1744", "-0.4", "1000", "4.8", "0.172", "-10.8", "4.8", "0.172", "-10.8", "2000", "10.1", "0.163", "-30.5", "10.1", "0.163", "-30.5", "4000", "11.2", "0.1724", "-35.1", "11.2", "0.1725", "-35.1", "20000", "12.4", "0.2044", "-40.5", "12.4", "0.2045", "-40.5", ""]}
|
||||
{"pcdb_id": 694001, "raw": ["694001", "300900", "1", "2021/Nov/26 14:44", "SAP Illustrative Products", "Illustrative FGHRS", "FGHRS", "Gas", "2011", "current", "1", "1", "RCSK", "0", "2", "0", "0", "", "0.103", "0.102", "", "0", "", "6", "0", "0", "0.103", "0", "0", "0.102", "0", "200", "0.890", "0.189", "-1.50", "0.890", "0.189", "-1.50", "1000", "2.720", "0.190", "-7.12", "2.710", "0.189", "-7.13", "2000", "5.330", "0.187", "-17.57", "5.330", "0.187", "-17.57", "4000", "5.840", "0.193", "-19.65", "5.840", "0.192", "-19.65", "20000", "6.270", "0.209", "-22.15", "6.270", "0.208", "-22.15"]}
|
||||
{"pcdb_id": 694002, "raw": ["694002", "300900", "1", "2021/Nov/26 14:44", "SAP Illustrative Products", "Illustrative FGHRS", "FGHRS", "LPG", "2011", "current", "2", "1", "RCSK", "0", "2", "0", "0", "", "0.098", "0.097", "", "0", "", "6", "0", "0", "0.098", "0", "0", "0.097", "0", "200", "0.850", "0.180", "-1.43", "0.850", "0.180", "-1.43", "1000", "2.600", "0.180", "-6.77", "2.590", "0.180", "-6.78", "2000", "5.080", "0.178", "-16.69", "5.080", "0.178", "-16.69", "4000", "5.570", "0.183", "-18.67", "5.570", "0.183", "-18.67", "20000", "5.930", "0.199", "-21.06", "5.930", "0.198", "-21.06"]}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
{"pcdb_id": 695001, "raw": ["695001", "300900", "1", "2017/Apr/10 17:02", "SAP Illustrative Products", "Illustrative WWHRS", "Instantaneous WWHRS", "System A", "2012", "current", "", "1", "A", "", "", "0.953", "", "", "", "0", "5", "5.0", "56.9", "7.0", "48.6", "9.0", "42.3", "11.0", "37.5", "13.0", "33.7"]}
|
||||
{"pcdb_id": 695002, "raw": ["695002", "300900", "1", "2017/Apr/10 17:02", "SAP Illustrative Products", "Illustrative WWHRS", "Instantaneous WWHRS", "System B", "2012", "current", "", "1", "B", "", "", "0.925", "", "", "", "0", "5", "5.0", "42.8", "7.0", "37.0", "9.0", "32.7", "11.0", "29.3", "13.0", "26.7"]}
|
||||
{"pcdb_id": 695003, "raw": ["695003", "300900", "1", "2017/Apr/10 17:02", "SAP Illustrative Products", "Illustrative WWHRS", "Instantaneous WWHRS", "System C", "2012", "current", "", "1", "C", "", "", "0.923", "", "", "", "0", "5", "5.0", "42.8", "7.0", "37.0", "9.0", "32.7", "11.0", "29.3", "13.0", "26.7"]}
|
||||
{"pcdb_id": 695004, "raw": ["695004", "300900", "1", "2017/Apr/10 17:02", "SAP Illustrative Products", "Illustrative WWHRS", "Storage WWHRS", "C1", "2012", "current", "", "2", "", "1", "45.0", "0.922", "120", "35", "120", "0.1", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 695005, "raw": ["695005", "300900", "1", "2017/Apr/10 17:02", "SAP Illustrative Products", "Illustrative WWHRS", "Storage WWHRS", "S1", "2012", "current", "", "2", "", "2", "45.0", "0.922", "102", "32", "110", "0.202", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 80003, "raw": ["080003", "020064", "0", "2015/Aug/17 12:52", "Hei-tech b.v.", "Showersave", "Recoh-vert RV3", "System A", "2011", "2017", "", "1", "A", "", "", "0.974", "", "", "", "0", "5", "5", "79.3", "7", "73.2", "9", "68", "11", "63.5", "13", "59.5"]}
|
||||
{"pcdb_id": 80004, "raw": ["080004", "020064", "0", "2023/Jul/18 08:32", "Hei-tech b.v.", "Showersave", "Recoh-vert RV3", "System B", "2011", "2017", "", "1", "B", "", "", "0.959", "", "", "", "0", "5", "5", "63.4", "7", "59.3", "9", "55.4", "11", "52", "13", "49.1"]}
|
||||
{"pcdb_id": 80005, "raw": ["080005", "020064", "0", "2015/Aug/17 12:52", "Hei-tech b.v.", "Showersave", "Recoh-vert RV3", "System C", "2011", "2017", "", "1", "C", "", "", "0.968", "", "", "", "0", "5", "5", "67.4", "7", "63.4", "9", "59.7", "11", "56.3", "13", "53.2"]}
|
||||
{"pcdb_id": 80006, "raw": ["080006", "020064", "0", "2015/Aug/17 12:52", "ITHO UK Ltd", "ITHO", "SHRU 50", "System A", "2012", "current", "", "1", "A", "", "", "0.96", "", "", "", "0", "5", "5", "62.5", "7", "54.3", "9", "48", "11", "43.1", "13", "39"]}
|
||||
{"pcdb_id": 80007, "raw": ["080007", "020064", "0", "2015/Aug/17 12:52", "ITHO UK Ltd", "ITHO", "SHRU 60", "System A", "2012", "current", "", "1", "A", "", "", "0.974", "", "", "", "0", "5", "5", "74.3", "7", "67.4", "9", "61.7", "11", "56.8", "13", "52.7"]}
|
||||
{"pcdb_id": 80008, "raw": ["080008", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-24", "System A", "2011", "current", "", "1", "A", "", "", "0.933", "", "", "", "0", "5", "5", "34.3", "7", "27.1", "9", "22.5", "11", "19.2", "13", "16.7"]}
|
||||
{"pcdb_id": 80009, "raw": ["080009", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-30", "System A", "2011", "current", "", "1", "A", "", "", "0.936", "", "", "", "0", "5", "5", "44.1", "7", "36", "9", "30.5", "11", "26.4", "13", "23.3"]}
|
||||
{"pcdb_id": 80010, "raw": ["080010", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-36", "System A", "2011", "current", "", "1", "A", "", "", "0.933", "", "", "", "0", "5", "5", "47.7", "7", "39.4", "9", "33.6", "11", "29.3", "13", "25.9"]}
|
||||
{"pcdb_id": 80011, "raw": ["080011", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-42", "System A", "2011", "current", "", "1", "A", "", "", "0.933", "", "", "", "0", "5", "5", "53.6", "7", "45.2", "9", "39.1", "11", "34.5", "13", "30.8"]}
|
||||
{"pcdb_id": 80012, "raw": ["080012", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-48", "System A", "2011", "current", "", "1", "A", "", "", "0.928", "", "", "", "0", "5", "5", "54.2", "7", "45.8", "9", "39.6", "11", "34.9", "13", "31.2"]}
|
||||
{"pcdb_id": 80013, "raw": ["080013", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-54", "System A", "2011", "current", "", "1", "A", "", "", "0.926", "", "", "", "0", "5", "5", "58.1", "7", "49.7", "9", "43.5", "11", "38.6", "13", "34.8"]}
|
||||
{"pcdb_id": 80014, "raw": ["080014", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-60", "System A", "2011", "current", "", "1", "A", "", "", "0.929", "", "", "", "0", "5", "5", "63.3", "7", "55.2", "9", "48.9", "11", "43.9", "13", "39.8"]}
|
||||
{"pcdb_id": 80015, "raw": ["080015", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-66", "System A", "2011", "current", "", "1", "A", "", "", "0.927", "", "", "", "0", "5", "5", "64.2", "7", "56.1", "9", "49.9", "11", "44.9", "13", "40.8"]}
|
||||
{"pcdb_id": 80016, "raw": ["080016", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-72", "System A", "2011", "current", "", "1", "A", "", "", "0.925", "", "", "", "0", "5", "5", "68.9", "7", "61.3", "9", "55.2", "11", "50.2", "13", "46"]}
|
||||
{"pcdb_id": 80017, "raw": ["080017", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-84", "System A", "2011", "current", "", "1", "A", "", "", "0.921", "", "", "", "0", "5", "5", "71.3", "7", "63.9", "9", "58", "11", "53", "13", "48.8"]}
|
||||
{"pcdb_id": 80018, "raw": ["080018", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-96", "System A", "2011", "current", "", "1", "A", "", "", "0.917", "", "", "", "0", "5", "5", "75.3", "7", "68.6", "9", "62.9", "11", "58.1", "13", "54"]}
|
||||
{"pcdb_id": 80019, "raw": ["080019", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-108", "System A", "2011", "current", "", "1", "A", "", "", "0.913", "", "", "", "0", "5", "5", "77.7", "7", "71.3", "9", "65.9", "11", "61.3", "13", "57.2"]}
|
||||
{"pcdb_id": 80020, "raw": ["080020", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R2-120", "System A", "2011", "current", "", "1", "A", "", "", "0.907", "", "", "", "0", "5", "5", "77.4", "7", "71", "9", "65.5", "11", "60.9", "13", "56.8"]}
|
||||
{"pcdb_id": 80021, "raw": ["080021", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-24", "System A", "2011", "current", "", "1", "A", "", "", "0.936", "", "", "", "0", "5", "5", "42.8", "7", "34.8", "9", "29.4", "11", "25.4", "13", "22.3"]}
|
||||
{"pcdb_id": 80022, "raw": ["080022", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-30", "System A", "2011", "current", "", "1", "A", "", "", "0.933", "", "", "", "0", "5", "5", "49.2", "7", "40.9", "9", "35", "11", "30.6", "13", "27.2"]}
|
||||
{"pcdb_id": 80023, "raw": ["080023", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-36", "System A", "2011", "current", "", "1", "A", "", "", "0.935", "", "", "", "0", "5", "5", "54.9", "7", "46.5", "9", "40.4", "11", "35.7", "13", "31.9"]}
|
||||
{"pcdb_id": 80024, "raw": ["080024", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-42", "System A", "2011", "current", "", "1", "A", "", "", "0.933", "", "", "", "0", "5", "5", "59.3", "7", "51", "9", "44.7", "11", "39.8", "13", "35.9"]}
|
||||
{"pcdb_id": 80025, "raw": ["080025", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-48", "System A", "2011", "current", "", "1", "A", "", "", "0.932", "", "", "", "0", "5", "5", "63.6", "7", "55.5", "9", "49.3", "11", "44.3", "13", "40.2"]}
|
||||
{"pcdb_id": 80026, "raw": ["080026", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-54", "System A", "2011", "current", "", "1", "A", "", "", "0.925", "", "", "", "0", "5", "5", "65.3", "7", "57.3", "9", "51.1", "11", "46.1", "13", "41.9"]}
|
||||
{"pcdb_id": 80027, "raw": ["080027", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-60", "System A", "2011", "current", "", "1", "A", "", "", "0.927", "", "", "", "0", "5", "5", "69.8", "7", "62.3", "9", "56.3", "11", "51.3", "13", "47.1"]}
|
||||
{"pcdb_id": 80028, "raw": ["080028", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-66", "System A", "2011", "current", "", "1", "A", "", "", "0.921", "", "", "", "0", "5", "5", "70.6", "7", "63.2", "9", "57.2", "11", "52.2", "13", "48"]}
|
||||
{"pcdb_id": 80029, "raw": ["080029", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-72", "System A", "2011", "current", "", "1", "A", "", "", "0.922", "", "", "", "0", "5", "5", "74", "7", "67.1", "9", "61.3", "11", "56.5", "13", "52.3"]}
|
||||
{"pcdb_id": 80030, "raw": ["080030", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-84", "System A", "2011", "current", "", "1", "A", "", "", "0.912", "", "", "", "0", "5", "5", "75.2", "7", "68.4", "9", "62.8", "11", "58", "13", "53.9"]}
|
||||
{"pcdb_id": 80031, "raw": ["080031", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-96", "System A", "2011", "current", "", "1", "A", "", "", "0.908", "", "", "", "0", "5", "5", "79", "7", "72.8", "9", "67.6", "11", "63", "13", "59.1"]}
|
||||
{"pcdb_id": 80032, "raw": ["080032", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-108", "System A", "2011", "current", "", "1", "A", "", "", "0.902", "", "", "", "0", "5", "5", "80.3", "7", "74.4", "9", "69.3", "11", "64.9", "13", "61"]}
|
||||
{"pcdb_id": 80033, "raw": ["080033", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R3-120", "System A", "2011", "current", "", "1", "A", "", "", "0.896", "", "", "", "0", "5", "5", "81.4", "7", "75.8", "9", "70.9", "11", "66.6", "13", "62.8"]}
|
||||
{"pcdb_id": 80034, "raw": ["080034", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-18", "System A", "2011", "current", "", "1", "A", "", "", "0.929", "", "", "", "0", "5", "5", "36.2", "7", "28.9", "9", "24", "11", "20.5", "13", "17.9"]}
|
||||
{"pcdb_id": 80035, "raw": ["080035", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-24", "System A", "2011", "current", "", "1", "A", "", "", "0.932", "", "", "", "0", "5", "5", "48.5", "7", "40.2", "9", "34.3", "11", "30", "13", "26.6"]}
|
||||
{"pcdb_id": 80036, "raw": ["080036", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-30", "System A", "2011", "current", "", "1", "A", "", "", "0.93", "", "", "", "0", "5", "5", "57.2", "7", "48.9", "9", "42.6", "11", "37.8", "13", "34"]}
|
||||
{"pcdb_id": 80037, "raw": ["080037", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-36", "System A", "2011", "current", "", "1", "A", "", "", "0.926", "", "", "", "0", "5", "5", "60.4", "7", "52.2", "9", "45.9", "11", "41", "13", "37"]}
|
||||
{"pcdb_id": 80038, "raw": ["080038", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-42", "System A", "2011", "current", "", "1", "A", "", "", "0.922", "", "", "", "0", "5", "5", "64.6", "7", "56.5", "9", "50.3", "11", "45.3", "13", "41.2"]}
|
||||
{"pcdb_id": 80039, "raw": ["080039", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-48", "System A", "2011", "current", "", "1", "A", "", "", "0.909", "", "", "", "0", "5", "5", "60.7", "7", "52.4", "9", "46.1", "11", "41.2", "13", "37.2"]}
|
||||
{"pcdb_id": 80040, "raw": ["080040", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-54", "System A", "2011", "current", "", "1", "A", "", "", "0.915", "", "", "", "0", "5", "5", "70", "7", "62.5", "9", "56.5", "11", "51.5", "13", "47.3"]}
|
||||
{"pcdb_id": 80041, "raw": ["080041", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-60", "System A", "2011", "current", "", "1", "A", "", "", "0.914", "", "", "", "0", "5", "5", "73.9", "7", "66.9", "9", "61.1", "11", "56.2", "13", "52.1"]}
|
||||
{"pcdb_id": 80042, "raw": ["080042", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-66", "System A", "2011", "current", "", "1", "A", "", "", "0.911", "", "", "", "0", "5", "5", "75.7", "7", "69", "9", "63.3", "11", "58.6", "13", "54.5"]}
|
||||
{"pcdb_id": 80043, "raw": ["080043", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-72", "System A", "2011", "current", "", "1", "A", "", "", "0.904", "", "", "", "0", "5", "5", "77.8", "7", "71.4", "9", "66", "11", "61.4", "13", "57.4"]}
|
||||
{"pcdb_id": 80044, "raw": ["080044", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-84", "System A", "2011", "current", "", "1", "A", "", "", "0.899", "", "", "", "0", "5", "5", "79.3", "7", "73.2", "9", "68", "11", "63.5", "13", "59.5"]}
|
||||
{"pcdb_id": 80045, "raw": ["080045", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-96", "System A", "2011", "current", "", "1", "A", "", "", "0.892", "", "", "", "0", "5", "5", "81.4", "7", "75.8", "9", "70.9", "11", "66.6", "13", "62.8"]}
|
||||
{"pcdb_id": 80046, "raw": ["080046", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-108", "System A", "2011", "current", "", "1", "A", "", "", "0.884", "", "", "", "0", "5", "5", "82.5", "7", "77.1", "9", "72.4", "11", "68.2", "13", "64.4"]}
|
||||
{"pcdb_id": 80047, "raw": ["080047", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "R4-120", "System A", "2011", "current", "", "1", "A", "", "", "0.879", "", "", "", "0", "5", "5", "84.8", "7", "79.9", "9", "75.6", "11", "71.7", "13", "68.2"]}
|
||||
{"pcdb_id": 80048, "raw": ["080048", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-30", "System A", "2011", "current", "", "1", "A", "", "", "0.898", "", "", "", "0", "5", "5", "38.1", "7", "30.5", "9", "25.5", "11", "21.8", "13", "19.1"]}
|
||||
{"pcdb_id": 80049, "raw": ["080049", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-36", "System A", "2011", "current", "", "1", "A", "", "", "0.903", "", "", "", "0", "5", "5", "46.4", "7", "38.2", "9", "32.5", "11", "28.3", "13", "25"]}
|
||||
{"pcdb_id": 80050, "raw": ["080050", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-48", "System A", "2011", "current", "", "1", "A", "", "", "0.903", "", "", "", "0", "5", "5", "57.5", "7", "49.2", "9", "42.9", "11", "38.1", "13", "34.3"]}
|
||||
{"pcdb_id": 80051, "raw": ["080051", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-60", "System A", "2011", "current", "", "1", "A", "", "", "0.898", "", "", "", "0", "5", "5", "63", "7", "54.8", "9", "48.6", "11", "43.6", "13", "39.5"]}
|
||||
{"pcdb_id": 80052, "raw": ["080052", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-72", "System A", "2011", "current", "", "1", "A", "", "", "0.89", "", "", "", "0", "5", "5", "65.6", "7", "57.7", "9", "51.5", "11", "46.5", "13", "42.3"]}
|
||||
{"pcdb_id": 80053, "raw": ["080053", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-84", "System A", "2011", "current", "", "1", "A", "", "", "0.888", "", "", "", "0", "5", "5", "70.8", "7", "63.4", "9", "57.4", "11", "52.5", "13", "48.3"]}
|
||||
{"pcdb_id": 80054, "raw": ["080054", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-96", "System A", "2011", "current", "", "1", "A", "", "", "0.883", "", "", "", "0", "5", "5", "74.2", "7", "67.3", "9", "61.5", "11", "56.7", "13", "52.5"]}
|
||||
{"pcdb_id": 80055, "raw": ["080055", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-108", "System A", "2011", "current", "", "1", "A", "", "", "0.877", "", "", "", "0", "5", "5", "76", "7", "69.4", "9", "63.8", "11", "59.1", "13", "55"]}
|
||||
{"pcdb_id": 80056, "raw": ["080056", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C3-120", "System A", "2011", "current", "", "1", "A", "", "", "0.874", "", "", "", "0", "5", "5", "79", "7", "72.9", "9", "67.6", "11", "63.1", "13", "59.1"]}
|
||||
{"pcdb_id": 80057, "raw": ["080057", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-30", "System A", "2011", "current", "", "1", "A", "", "", "0.896", "", "", "", "0", "5", "5", "44.2", "7", "36.2", "9", "30.6", "11", "26.5", "13", "23.4"]}
|
||||
{"pcdb_id": 80058, "raw": ["080058", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-36", "System A", "2011", "current", "", "1", "A", "", "", "0.895", "", "", "", "0", "5", "5", "50.8", "7", "42.4", "9", "36.4", "11", "31.9", "13", "28.4"]}
|
||||
{"pcdb_id": 80059, "raw": ["080059", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-42", "System A", "2011", "current", "", "1", "A", "", "", "0.894", "", "", "", "0", "5", "5", "56.8", "7", "48.4", "9", "42.2", "11", "37.4", "13", "33.6"]}
|
||||
{"pcdb_id": 80060, "raw": ["080060", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-48", "System A", "2011", "current", "", "1", "A", "", "", "0.889", "", "", "", "0", "5", "5", "59.2", "7", "50.9", "9", "44.6", "11", "39.7", "13", "35.8"]}
|
||||
{"pcdb_id": 80061, "raw": ["080061", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-54", "System A", "2011", "current", "", "1", "A", "", "", "0.891", "", "", "", "0", "5", "5", "65", "7", "57.1", "9", "50.8", "11", "45.8", "13", "41.7"]}
|
||||
{"pcdb_id": 80062, "raw": ["080062", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-60", "System A", "2011", "current", "", "1", "A", "", "", "0.885", "", "", "", "0", "5", "5", "66.1", "7", "58.2", "9", "52", "11", "47", "13", "42.9"]}
|
||||
{"pcdb_id": 80063, "raw": ["080063", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-66", "System A", "2011", "current", "", "1", "A", "", "", "0.881", "", "", "", "0", "5", "5", "68", "7", "60.3", "9", "54.1", "11", "49.1", "13", "45"]}
|
||||
{"pcdb_id": 80064, "raw": ["080064", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-72", "System A", "2011", "current", "", "1", "A", "", "", "0.885", "", "", "", "0", "5", "5", "74.4", "7", "67.4", "9", "61.7", "11", "56.9", "13", "52.7"]}
|
||||
{"pcdb_id": 80065, "raw": ["080065", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-78", "System A", "2011", "current", "", "1", "A", "", "", "0.879", "", "", "", "0", "5", "5", "74.9", "7", "68.1", "9", "62.4", "11", "57.6", "13", "53.4"]}
|
||||
{"pcdb_id": 80066, "raw": ["080066", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-84", "System A", "2011", "current", "", "1", "A", "", "", "0.878", "", "", "", "0", "5", "5", "77", "7", "70.6", "9", "65.1", "11", "60.4", "13", "56.3"]}
|
||||
{"pcdb_id": 80067, "raw": ["080067", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-90", "System A", "2011", "current", "", "1", "A", "", "", "0.87", "", "", "", "0", "5", "5", "76.3", "7", "69.6", "9", "64.1", "11", "59.3", "13", "55.3"]}
|
||||
{"pcdb_id": 80068, "raw": ["080068", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-96", "System A", "2011", "current", "", "1", "A", "", "", "0.868", "", "", "", "0", "5", "5", "77.9", "7", "71.5", "9", "66.2", "11", "61.5", "13", "57.5"]}
|
||||
{"pcdb_id": 80069, "raw": ["080069", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-108", "System A", "2011", "current", "", "1", "A", "", "", "0.865", "", "", "", "0", "5", "5", "81.6", "7", "76", "9", "71.1", "11", "66.8", "13", "63"]}
|
||||
{"pcdb_id": 80070, "raw": ["080070", "020063", "0", "2015/Aug/17 12:52", "RenewABILITY Energy Inc.", "Power-pipe", "C4-120", "System A", "2011", "current", "", "1", "A", "", "", "0.858", "", "", "", "0", "5", "5", "83.6", "7", "78.4", "9", "73.9", "11", "69.8", "13", "66.2"]}
|
||||
{"pcdb_id": 80071, "raw": ["080071", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Drain+", "System A", "2012", "current", "", "1", "A", "", "", "0.975", "", "", "", "0", "5", "5", "67.2", "7", "59.4", "9", "53.2", "11", "48.2", "13", "44.1"]}
|
||||
{"pcdb_id": 80072, "raw": ["080072", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Pipe+", "System A", "2012", "2014", "", "1", "A", "", "", "0.974", "", "", "", "0", "5", "5", "74.2", "7", "67.2", "9", "61.5", "11", "56.6", "13", "52.5"]}
|
||||
{"pcdb_id": 80073, "raw": ["080073", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Propipe+", "System A", "2012", "2014", "", "1", "A", "", "", "0.975", "", "", "", "0", "5", "5", "81", "7", "75.3", "9", "70.4", "11", "66", "13", "62.2"]}
|
||||
{"pcdb_id": 80074, "raw": ["080074", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Retrofit+", "System A", "2012", "2017", "", "1", "A", "", "", "0.979", "", "", "", "0", "5", "5", "33.2", "7", "26.2", "9", "21.6", "11", "18.4", "13", "16"]}
|
||||
{"pcdb_id": 80075, "raw": ["080075", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Tray+", "System A", "2012", "current", "", "1", "A", "", "", "0.968", "", "", "", "0", "5", "5", "61.4", "7", "53.2", "9", "46.9", "11", "42", "13", "38"]}
|
||||
{"pcdb_id": 80076, "raw": ["080076", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Drain+", "System B", "2012", "current", "", "1", "B", "", "", "0.96", "", "", "", "0", "5", "5", "54.9", "7", "49", "9", "44.2", "11", "40.5", "13", "37.3"]}
|
||||
{"pcdb_id": 80077, "raw": ["080077", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Pipe+", "System B", "2012", "current", "", "1", "B", "", "", "0.96", "", "", "", "0", "5", "5", "60", "7", "54.9", "9", "50.5", "11", "46.9", "13", "43.7"]}
|
||||
{"pcdb_id": 80078, "raw": ["080078", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Propipe+", "System B", "2012", "current", "", "1", "B", "", "", "0.962", "", "", "", "0", "5", "5", "64.5", "7", "60.7", "9", "57.2", "11", "53.9", "13", "51"]}
|
||||
{"pcdb_id": 80079, "raw": ["080079", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Retrofit+", "System B", "2012", "current", "", "1", "B", "", "", "0.968", "", "", "", "0", "5", "5", "28.9", "7", "23.4", "9", "19.8", "11", "17.2", "13", "15.2"]}
|
||||
{"pcdb_id": 80080, "raw": ["080080", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Tray+", "System B", "2012", "2014", "", "1", "B", "", "", "0.95", "", "", "", "0", "5", "5", "50.5", "7", "44.2", "9", "39.5", "11", "35.6", "13", "32.6"]}
|
||||
{"pcdb_id": 80081, "raw": ["080081", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Drain+", "System C", "2012", "current", "", "1", "C", "", "", "0.97", "", "", "", "0", "5", "5", "59.1", "7", "53.2", "9", "48.3", "11", "44.1", "13", "40.7"]}
|
||||
{"pcdb_id": 80082, "raw": ["080082", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Pipe+", "System C", "2012", "current", "", "1", "C", "", "", "0.969", "", "", "", "0", "5", "5", "64", "7", "59.1", "9", "54.8", "11", "50.9", "13", "47.6"]}
|
||||
{"pcdb_id": 80083, "raw": ["080083", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Propipe+", "System C", "2012", "current", "", "1", "C", "", "", "0.969", "", "", "", "0", "5", "5", "68.5", "7", "64.8", "9", "61.4", "11", "58.2", "13", "55.3"]}
|
||||
{"pcdb_id": 80084, "raw": ["080084", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Retrofit+", "System C", "2012", "current", "", "1", "C", "", "", "0.977", "", "", "", "0", "5", "5", "31.3", "7", "25", "9", "20.8", "11", "17.8", "13", "15.6"]}
|
||||
{"pcdb_id": 80085, "raw": ["080085", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Tray+", "System C", "2012", "current", "", "1", "C", "", "", "0.963", "", "", "", "0", "5", "5", "54.7", "7", "48.2", "9", "43", "11", "38.9", "13", "35.4"]}
|
||||
{"pcdb_id": 80086, "raw": ["080086", "020014", "0", "2023/Jul/18 08:32", "ITHO UK Ltd", "ITHO", "SHRU 50", "System B", "2012", "current", "", "1", "B", "", "", "0.937", "", "", "", "0", "5", "5", "51.3", "7", "45.1", "9", "40.3", "11", "36.5", "13", "33.4"]}
|
||||
{"pcdb_id": 80087, "raw": ["080087", "020014", "0", "2023/Jul/18 08:32", "ITHO UK Ltd", "ITHO", "SHRU 60", "System B", "2012", "current", "", "1", "B", "", "", "0.96", "", "", "", "0", "5", "5", "60.1", "7", "55", "9", "50.7", "11", "47", "13", "43.9"]}
|
||||
{"pcdb_id": 80088, "raw": ["080088", "020014", "0", "2015/Aug/17 12:52", "ITHO UK Ltd", "ITHO", "SHRU 50", "System C", "2012", "current", "", "1", "C", "", "", "0.953", "", "", "", "0", "5", "5", "55.5", "7", "49.1", "9", "44", "11", "39.8", "13", "36.3"]}
|
||||
{"pcdb_id": 80089, "raw": ["080089", "020014", "0", "2015/Aug/17 12:52", "ITHO UK Ltd", "ITHO", "SHRU 60", "System C", "2012", "current", "", "1", "C", "", "", "0.969", "", "", "", "0", "5", "5", "64.2", "7", "59.3", "9", "54.9", "11", "51.1", "13", "47.8"]}
|
||||
{"pcdb_id": 80090, "raw": ["080090", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "Recoup", "Pipe+ HE", "System A", "2012", "current", "", "1", "A", "", "", "0.975", "", "", "", "0", "5", "5", "77.8", "7", "71.5", "9", "66.1", "11", "61.5", "13", "57.5"]}
|
||||
{"pcdb_id": 80091, "raw": ["080091", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "Recoup", "Tray+ DSS-S2", "System A", "2012", "current", "", "1", "A", "", "", "0.979", "", "", "", "0", "5", "5", "64.7", "7", "56.7", "9", "50.4", "11", "45.4", "13", "41.3"]}
|
||||
{"pcdb_id": 80092, "raw": ["080092", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "Recoup", "Pipe+ HE", "System B", "2012", "current", "", "1", "B", "", "", "0.962", "", "", "", "0", "5", "5", "62.5", "7", "58.1", "9", "54", "11", "50.5", "13", "47.4"]}
|
||||
{"pcdb_id": 80093, "raw": ["080093", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "Recoup", "Tray+ DSS-S2", "System B", "2012", "current", "", "1", "B", "", "", "0.966", "", "", "", "0", "5", "5", "52.9", "7", "46.9", "9", "42.1", "11", "38.3", "13", "35.2"]}
|
||||
{"pcdb_id": 80094, "raw": ["080094", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "Recoup", "Pipe+ HE", "System C", "2012", "current", "", "1", "C", "", "", "0.97", "", "", "", "0", "5", "5", "66.5", "7", "62.2", "9", "58.3", "11", "54.8", "13", "51.6"]}
|
||||
{"pcdb_id": 80095, "raw": ["080095", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "Recoup", "Tray+ DSS-S2", "System C", "2012", "current", "", "1", "C", "", "", "0.975", "", "", "", "0", "5", "5", "57.2", "7", "51", "9", "45.9", "11", "41.8", "13", "38.3"]}
|
||||
{"pcdb_id": 80096, "raw": ["080096", "020086", "0", "2013/Jul/17 10:42", "Reaqua Systems Ltd", "reAqua", "reAqua+", "80001", "2013", "current", "", "2", "", "1", "36.3", "1", "85", "22", "85", "0.2273", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 80097, "raw": ["080097", "020086", "0", "2013/Jul/17 10:42", "Reaqua Systems Ltd", "reAqua", "reAqua+", "080001-L", "2013", "current", "", "2", "", "1", "36.4", "1", "85", "22", "85", "0.2317", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 80098, "raw": ["080098", "020086", "0", "2013/Jul/17 10:43", "Reaqua Systems Ltd", "reAqua", "reAqua+", "80002", "2013", "current", "", "2", "", "1", "36.6", "1", "85", "22", "85", "0.2446", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 80099, "raw": ["080099", "020086", "0", "2013/Jul/17 10:43", "Reaqua Systems Ltd", "reAqua", "reAqua+", "080002-L", "2013", "current", "", "2", "", "1", "36.7", "1", "85", "22", "85", "0.249", "", "", "", "", "", "", "", "", "", "", ""]}
|
||||
{"pcdb_id": 80100, "raw": ["080100", "020142", "0", "2015/Aug/17 12:52", "ZYPHO SA", "Zypho", "Z6DWUK", "System A", "2014", "current", "", "1", "A", "", "", "0.98", "", "", "", "0", "5", "5", "38.9", "7", "31.2", "9", "26.1", "11", "22.4", "13", "19.7"]}
|
||||
{"pcdb_id": 80101, "raw": ["080101", "020142", "0", "2023/Jul/18 08:32", "ZYPHO SA", "Zypho", "Z6DWUK", "System B", "2014", "current", "", "1", "B", "", "", "0.969", "", "", "", "0", "5", "5", "33.3", "7", "27.4", "9", "23.4", "11", "20.5", "13", "18.2"]}
|
||||
{"pcdb_id": 80102, "raw": ["080102", "020142", "0", "2015/Aug/17 12:52", "ZYPHO SA", "Zypho", "Z6DWUK", "System C", "2014", "current", "", "1", "C", "", "", "0.977", "", "", "", "0", "5", "5", "36.2", "7", "29.5", "9", "24.9", "11", "21.5", "13", "19"]}
|
||||
{"pcdb_id": 80103, "raw": ["080103", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "R2-84", "System B", "2015", "current", "", "1", "B", "", "", "0.876", "", "", "", "0", "5", "5", "57.9", "7", "52.4", "9", "47.9", "11", "44.1", "13", "40.9"]}
|
||||
{"pcdb_id": 80104, "raw": ["080104", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "R4-60", "System B", "2015", "current", "", "1", "B", "", "", "0.865", "", "", "", "0", "5", "5", "59.7", "7", "54.6", "9", "50.3", "11", "46.5", "13", "43.3"]}
|
||||
{"pcdb_id": 80105, "raw": ["080105", "020101", "0", "2015/Oct/01 10:48", "ITHO UK Ltd", "Megaflo", "SHRU 60", "System A", "2015", "current", "", "1", "A", "", "", "0.974", "", "", "", "0", "5", "5", "74.3", "7", "67.4", "9", "61.7", "11", "56.8", "13", "52.7"]}
|
||||
{"pcdb_id": 80106, "raw": ["080106", "020101", "0", "2023/Jul/18 08:32", "ITHO UK Ltd", "Megaflo", "SHRU 60", "System B", "2015", "current", "", "1", "B", "", "", "0.96", "", "", "", "0", "5", "5", "60.1", "7", "55", "9", "50.7", "11", "47", "13", "43.9"]}
|
||||
{"pcdb_id": 80107, "raw": ["080107", "020101", "0", "2015/Oct/01 10:48", "ITHO UK Ltd", "Megaflo", "SHRU 60", "System C", "2015", "current", "", "1", "C", "", "", "0.969", "", "", "", "0", "5", "5", "64.2", "7", "59.3", "9", "54.9", "11", "51.1", "13", "47.8"]}
|
||||
{"pcdb_id": 80108, "raw": ["080108", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "RECOUP", "Drain+ Compact", "System A", "2015", "current", "", "1", "A", "", "", "0.978", "", "", "", "0", "5", "5", "56.4", "7", "48", "9", "41.8", "11", "37", "13", "33.2"]}
|
||||
{"pcdb_id": 80109, "raw": ["080109", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "RECOUP", "Pipe+ HF", "System A", "2015", "current", "", "1", "A", "", "", "0.972", "", "", "", "0", "5", "5", "70.1", "7", "62.6", "9", "56.6", "11", "51.6", "13", "47.4"]}
|
||||
{"pcdb_id": 80110, "raw": ["080110", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "RECOUP", "Drain+ Compact", "System B", "2015", "current", "", "1", "B", "", "", "0.965", "", "", "", "0", "5", "5", "46.6", "7", "40.3", "9", "35.5", "11", "31.9", "13", "28.9"]}
|
||||
{"pcdb_id": 80111, "raw": ["080111", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "RECOUP", "Pipe+ HF", "System B", "2015", "current", "", "1", "B", "", "", "0.957", "", "", "", "0", "5", "5", "57", "7", "51.4", "9", "46.8", "11", "43", "13", "39.8"]}
|
||||
{"pcdb_id": 80112, "raw": ["080112", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "RECOUP", "Drain+ Compact", "System C", "2015", "current", "", "1", "C", "", "", "0.974", "", "", "", "0", "5", "5", "50.8", "7", "44", "9", "38.7", "11", "34.6", "13", "31.3"]}
|
||||
{"pcdb_id": 80113, "raw": ["080113", "020075", "0", "2017/Oct/19 12:54", "Recoup Energy Solutions Ltd", "RECOUP", "Pipe+ HF", "System C", "2015", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5", "61.2", "7", "55.6", "9", "50.9", "11", "46.9", "13", "43.5"]}
|
||||
{"pcdb_id": 80114, "raw": ["080114", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "R2-60", "System B", "2016", "current", "", "1", "B", "", "", "0.888", "", "", "", "0", "5", "5", "51.9", "7", "45.8", "9", "40.9", "11", "37.2", "13", "34"]}
|
||||
{"pcdb_id": 80115, "raw": ["080115", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "R4-84", "System B", "2016", "current", "", "1", "B", "", "", "0.842", "", "", "", "0", "5", "5", "63.5", "7", "59.3", "9", "55.4", "11", "52", "13", "49.1"]}
|
||||
{"pcdb_id": 80116, "raw": ["080116", "020064", "0", "2017/Apr/10 17:02", "Q-Blue B.V.", "Showersave", "QB1-21", "System A", "2017", "current", "", "1", "A", "", "", "0.973", "", "", "", "0", "5", "5", "78.7", "7", "72.5", "9", "67.2", "11", "62.7", "13", "58.7"]}
|
||||
{"pcdb_id": 80117, "raw": ["080117", "020064", "0", "2017/Apr/10 17:02", "Q-Blue B.V.", "Showersave", "QB1-21C", "System A", "2017", "current", "", "1", "A", "", "", "0.973", "", "", "", "0", "5", "5", "79.3", "7", "73.2", "9", "68", "11", "63.5", "13", "59.6"]}
|
||||
{"pcdb_id": 80118, "raw": ["080118", "020064", "0", "2023/Jul/18 08:32", "Q-Blue B.V.", "Showersave", "QB1-21", "System B", "2017", "current", "", "1", "B", "", "", "0.958", "", "", "", "0", "5", "5", "63", "7", "58.7", "9", "54.9", "11", "51.4", "13", "48.4"]}
|
||||
{"pcdb_id": 80119, "raw": ["080119", "020064", "0", "2023/Jul/18 08:32", "Q-Blue B.V.", "Showersave", "QB1-21C", "System B", "2017", "current", "", "1", "B", "", "", "0.959", "", "", "", "0", "5", "5", "63.5", "7", "59.3", "9", "55.4", "11", "52", "13", "49.1"]}
|
||||
{"pcdb_id": 80120, "raw": ["080120", "020064", "0", "2017/Apr/10 17:02", "Q-Blue B.V.", "Showersave", "QB1-21", "System C", "2017", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5", "67", "7", "62.9", "9", "59.1", "11", "55.7", "13", "52.6"]}
|
||||
{"pcdb_id": 80121, "raw": ["080121", "020064", "0", "2017/Apr/10 17:02", "Q-Blue B.V.", "Showersave", "QB1-21C", "System C", "2017", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5", "67.4", "7", "63.4", "9", "59.7", "11", "56.3", "13", "53.3"]}
|
||||
{"pcdb_id": 80122, "raw": ["080122", "020101", "0", "2017/Apr/26 11:20", "EIDT S.A.", "Megaflo", "Horizontal SHRU IZI 8kW 7036160", "System A", "2017", "current", "", "1", "A", "", "", "0.933", "", "", "", "0", "5", "5", "43.9", "7", "35.9", "9", "30.3", "11", "26.3", "13", "23.2"]}
|
||||
{"pcdb_id": 80123, "raw": ["080123", "020101", "0", "2017/Apr/26 11:20", "EIDT S.A.", "Megaflo", "Horizontal SHRU STD 8kW 7036150", "System A", "2017", "current", "", "1", "A", "", "", "0.929", "", "", "", "0", "5", "5", "43.9", "7", "35.9", "9", "30.3", "11", "26.3", "13", "23.2"]}
|
||||
{"pcdb_id": 80124, "raw": ["080124", "020101", "0", "2023/Jul/18 08:32", "EIDT S.A.", "Megaflo", "Horizontal SHRU IZI 8kW 7036160", "System B", "2017", "current", "", "1", "B", "", "", "0.901", "", "", "", "0", "5", "5", "37.2", "7", "31", "9", "26.7", "11", "23.5", "13", "21"]}
|
||||
{"pcdb_id": 80125, "raw": ["080125", "020101", "0", "2023/Jul/18 08:32", "EIDT S.A.", "Megaflo", "Horizontal SHRU STD 8kW 7036150", "System B", "2017", "current", "", "1", "B", "", "", "0.895", "", "", "", "0", "5", "5", "37.2", "7", "31", "9", "26.7", "11", "23.5", "13", "21"]}
|
||||
{"pcdb_id": 80126, "raw": ["080126", "020101", "0", "2017/Apr/26 11:20", "EIDT S.A.", "Megaflo", "Horizontal SHRU IZI 8kW 7036160", "System C", "2017", "current", "", "1", "C", "", "", "0.933", "", "", "", "0", "5", "5", "40.5", "7", "33.6", "9", "28.7", "11", "25", "13", "22.2"]}
|
||||
{"pcdb_id": 80127, "raw": ["080127", "020101", "0", "2017/Apr/26 11:20", "EIDT S.A.", "Megaflo", "Horizontal SHRU STD 8kW 7036150", "System C", "2017", "current", "", "1", "C", "", "", "0.928", "", "", "", "0", "5", "5", "40.5", "7", "33.6", "9", "28.7", "11", "25", "13", "22.2"]}
|
||||
{"pcdb_id": 80128, "raw": ["080128", "020064", "0", "2017/Sep/04 09:44", "Q-Blue B.V.", "Showersave", "QB1-21D", "System A", "2017", "current", "", "1", "A", "", "", "0.961", "", "", "", "0", "5", "5", "83.5", "7", "78.3", "9", "73.8", "11", "69.7", "13", "66.1"]}
|
||||
{"pcdb_id": 80129, "raw": ["080129", "020064", "0", "2017/Aug/17 15:55", "Joulia SA", "Showersave", "Linear Drain J3-630-3P", "System A", "2017", "current", "", "1", "A", "", "", "0.984", "", "", "", "0", "5", "5", "41.9", "7", "34", "9", "28.6", "11", "24.7", "13", "21.7"]}
|
||||
{"pcdb_id": 80130, "raw": ["080130", "020064", "0", "2017/Aug/17 15:55", "Joulia SA", "Showersave", "Linear Drain J3-630-5P", "System A", "2017", "current", "", "1", "A", "", "", "0.984", "", "", "", "0", "5", "5", "55.1", "7", "46.7", "9", "40.5", "11", "35.8", "13", "32.1"]}
|
||||
{"pcdb_id": 80131, "raw": ["080131", "020064", "0", "2023/Jul/18 08:32", "Q-Blue B.V.", "Showersave", "QB1-21D", "System B", "2017", "current", "", "1", "B", "", "", "0.939", "", "", "", "0", "5", "5", "65.9", "7", "62.8", "9", "59.7", "11", "56.8", "13", "54"]}
|
||||
{"pcdb_id": 80132, "raw": ["080132", "020064", "0", "2023/Jul/18 08:32", "Joulia SA", "Showersave", "Linear Drain J3-630-3P", "System B", "2017", "current", "", "1", "B", "", "", "0.975", "", "", "", "0", "5", "5", "35.6", "7", "29.6", "9", "25.4", "11", "22.3", "13", "19.9"]}
|
||||
{"pcdb_id": 80133, "raw": ["080133", "020064", "0", "2023/Jul/18 08:32", "Joulia SA", "Showersave", "Linear Drain J3-630-5P", "System B", "2017", "current", "", "1", "B", "", "", "0.974", "", "", "", "0", "5", "5", "45.7", "7", "39.3", "9", "34.5", "11", "30.9", "13", "28.1"]}
|
||||
{"pcdb_id": 80134, "raw": ["080134", "020064", "0", "2017/Sep/04 09:44", "Q-Blue B.V.", "Showersave", "QB1-21D", "System C", "2017", "current", "", "1", "C", "", "", "0.951", "", "", "", "0", "5", "5", "69.8", "7", "66.8", "9", "63.8", "11", "60.9", "13", "58.3"]}
|
||||
{"pcdb_id": 80135, "raw": ["080135", "020064", "0", "2017/Aug/17 15:55", "Joulia SA", "Showersave", "Linear Drain J3-630-3P", "System C", "2017", "current", "", "1", "C", "", "", "0.982", "", "", "", "0", "5", "5", "38.9", "7", "32", "9", "27.2", "11", "23.6", "13", "20.9"]}
|
||||
{"pcdb_id": 80136, "raw": ["080136", "020064", "0", "2017/Aug/17 15:55", "Joulia SA", "Showersave", "Linear Drain J3-630-5P", "System C", "2017", "current", "", "1", "C", "", "", "0.981", "", "", "", "0", "5", "5", "49.8", "7", "42.9", "9", "37.7", "11", "33.6", "13", "30.3"]}
|
||||
{"pcdb_id": 80137, "raw": ["080137", "020075", "0", "2017/Oct/19 12:54", "Dutch Solar Systems BV", "RECOUP", "Easyfit+", "System A", "2017", "current", "", "1", "A", "", "", "0.972", "", "", "", "0", "5", "5", "63", "7", "54.9", "9", "48.6", "11", "43.6", "13", "39.6"]}
|
||||
{"pcdb_id": 80138, "raw": ["080138", "020075", "0", "2017/Oct/19 12:54", "Dutch Solar Systems BV", "RECOUP", "Drain+ Duo", "System A", "2017", "current", "", "1", "A", "", "", "0.974", "", "", "", "0", "5", "5", "59.9", "7", "51.6", "9", "45.3", "11", "40.4", "13", "36.5"]}
|
||||
{"pcdb_id": 80139, "raw": ["080139", "020075", "0", "2017/Nov/06 09:38", "Dutch Solar Systems BV", "RECOUP", "Drain+ Duo HE", "System A", "2017", "current", "", "1", "A", "", "", "0.966", "", "", "", "0", "5", "5", "74.3", "7", "67.3", "9", "61.6", "11", "56.7", "13", "52.6"]}
|
||||
{"pcdb_id": 80140, "raw": ["080140", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Easyfit+", "System B", "2017", "current", "", "1", "B", "", "", "0.956", "", "", "", "0", "5", "5", "51.7", "7", "45.5", "9", "40.7", "11", "37", "13", "33.9"]}
|
||||
{"pcdb_id": 80141, "raw": ["080141", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Drain+ Duo", "System B", "2017", "current", "", "1", "B", "", "", "0.96", "", "", "", "0", "5", "5", "49.3", "7", "43", "9", "38.3", "11", "34.5", "13", "31.5"]}
|
||||
{"pcdb_id": 80142, "raw": ["080142", "020075", "0", "2023/Jul/18 08:32", "Dutch Solar Systems BV", "RECOUP", "Drain+ Duo HE", "System B", "2017", "current", "", "1", "B", "", "", "0.946", "", "", "", "0", "5", "5", "60.1", "7", "54.9", "9", "50.6", "11", "46.9", "13", "43.8"]}
|
||||
{"pcdb_id": 80143, "raw": ["080143", "020075", "0", "2017/Oct/19 12:54", "Dutch Solar Systems BV", "RECOUP", "Easyfit+", "System C", "2017", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5", "55.9", "7", "49.6", "9", "44.5", "11", "40.3", "13", "36.8"]}
|
||||
{"pcdb_id": 80144, "raw": ["080144", "020075", "0", "2017/Oct/19 12:54", "Dutch Solar Systems BV", "RECOUP", "Drain+ Duo", "System C", "2017", "current", "", "1", "C", "", "", "0.97", "", "", "", "0", "5", "5", "53.5", "7", "46.9", "9", "41.7", "11", "37.5", "13", "34.1"]}
|
||||
{"pcdb_id": 80145, "raw": ["080145", "020075", "0", "2017/Nov/06 09:38", "Dutch Solar Systems BV", "RECOUP", "Drain+ Duo HE", "System C", "2017", "current", "", "1", "C", "", "", "0.959", "", "", "", "0", "5", "5", "64.1", "7", "59.2", "9", "54.8", "11", "51.1", "13", "47.7"]}
|
||||
{"pcdb_id": 80146, "raw": ["080146", "020075", "0", "2020/Mar/19 18:00", "Recoup Energy Solutions Ltd", "Recoup", "Pipe HEX", "System A", "2019", "current", "", "1", "A", "", "", "0.972", "", "", "", "0", "5", "5", "79.3", "7", "73.3", "9", "68.1", "11", "63.6", "13", "59.6"]}
|
||||
{"pcdb_id": 80147, "raw": ["080147", "020075", "0", "2020/Mar/19 18:00", "Recoup Energy Solutions Ltd", "Recoup", "Pipe HEX-Rd", "System A", "2019", "current", "", "1", "A", "", "", "0.973", "", "", "", "0", "5", "5", "74.7", "7", "67.8", "9", "62.1", "11", "57.3", "13", "53.2"]}
|
||||
{"pcdb_id": 80148, "raw": ["080148", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "Recoup", "Pipe HEX", "System B", "2019", "current", "", "1", "B", "", "", "0.957", "", "", "", "0", "5", "5", "63.5", "7", "59.3", "9", "55.4", "11", "52.1", "13", "49.1"]}
|
||||
{"pcdb_id": 80149, "raw": ["080149", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "Recoup", "Pipe HEX-Rd", "System B", "2019", "current", "", "1", "B", "", "", "0.958", "", "", "", "0", "5", "5", "60.4", "7", "55.3", "9", "51", "11", "47.3", "13", "44.2"]}
|
||||
{"pcdb_id": 80150, "raw": ["080150", "020075", "0", "2020/Mar/19 18:00", "Recoup Energy Solutions Ltd", "Recoup", "Pipe HEX", "System C", "2019", "current", "", "1", "C", "", "", "0.966", "", "", "", "0", "5", "5", "67.4", "7", "63.4", "9", "59.7", "11", "56.4", "13", "53.3"]}
|
||||
{"pcdb_id": 80151, "raw": ["080151", "020075", "0", "2020/Mar/19 18:00", "Recoup Energy Solutions Ltd", "Recoup", "Pipe HEX-Rd", "System C", "2019", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5", "64.4", "7", "59.6", "9", "55.3", "11", "51.5", "13", "48.2"]}
|
||||
{"pcdb_id": 80152, "raw": ["080152", "020063", "0", "2020/Apr/16 16:25", "RenewABILITY Energy Inc.", "Power-Pipe", "E2-36", "System A", "2004", "current", "", "1", "A", "", "", "0.953", "", "", "", "0", "5", "5", "56.9", "7", "48.6", "9", "42.3", "11", "37.5", "13", "33.7"]}
|
||||
{"pcdb_id": 80153, "raw": ["080153", "020063", "0", "2020/Apr/16 16:25", "RenewABILITY Energy Inc.", "Power-Pipe", "E2-60", "System A", "2004", "current", "", "1", "A", "", "", "0.95", "", "", "", "0", "5", "5", "69.4", "7", "61.8", "9", "55.7", "11", "50.7", "13", "46.6"]}
|
||||
{"pcdb_id": 80154, "raw": ["080154", "020063", "0", "2020/Apr/16 16:25", "RenewABILITY Energy Inc.", "Power-Pipe", "E2-84", "System A", "2004", "current", "", "1", "A", "", "", "0.947", "", "", "", "0", "5", "5", "77", "7", "70.5", "9", "65", "11", "60.3", "13", "56.2"]}
|
||||
{"pcdb_id": 80155, "raw": ["080155", "020063", "0", "2020/Apr/16 16:25", "RenewABILITY Energy Inc.", "Power-Pipe", "X2-36", "System A", "2004", "current", "", "1", "A", "", "", "0.951", "", "", "", "0", "5", "5", "51.1", "7", "42.7", "9", "36.7", "11", "32.2", "13", "28.6"]}
|
||||
{"pcdb_id": 80156, "raw": ["080156", "020063", "0", "2020/Apr/16 16:25", "RenewABILITY Energy Inc.", "Power-Pipe", "X2-60", "System A", "2004", "current", "", "1", "A", "", "", "0.948", "", "", "", "0", "5", "5", "65.4", "7", "57.5", "9", "51.3", "11", "46.2", "13", "42.1"]}
|
||||
{"pcdb_id": 80157, "raw": ["080157", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "E2-36", "System B", "2004", "current", "", "1", "B", "", "", "0.925", "", "", "", "0", "5", "5", "47.1", "7", "40.7", "9", "36", "11", "32.2", "13", "29.4"]}
|
||||
{"pcdb_id": 80158, "raw": ["080158", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "E2-60", "System B", "2004", "current", "", "1", "B", "", "", "0.922", "", "", "", "0", "5", "5", "56.4", "7", "50.8", "9", "46.2", "11", "42.4", "13", "39.2"]}
|
||||
{"pcdb_id": 80159, "raw": ["080159", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "E2-84", "System B", "2004", "current", "", "1", "B", "", "", "0.918", "", "", "", "0", "5", "5", "61.9", "7", "57.3", "9", "53.1", "11", "49.6", "13", "46.5"]}
|
||||
{"pcdb_id": 80160, "raw": ["080160", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "X2-36", "System B", "2004", "current", "", "1", "B", "", "", "0.921", "", "", "", "0", "5", "5", "42.6", "7", "36.2", "9", "31.7", "11", "28.2", "13", "25.4"]}
|
||||
{"pcdb_id": 80161, "raw": ["080161", "020063", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "X2-60", "System B", "2004", "current", "", "1", "B", "", "", "0.919", "", "", "", "0", "5", "5", "53.5", "7", "47.5", "9", "42.8", "11", "38.9", "13", "35.8"]}
|
||||
{"pcdb_id": 80162, "raw": ["080162", "020062", "0", "2020/Apr/16 16:25", "RenewABILITY Energy Inc.", "Power-Pipe", "X2-84", "System A", "2004", "current", "", "1", "A", "", "", "0.945", "", "", "", "0", "5", "5", "73.6", "7", "66.5", "9", "60.7", "11", "55.9", "13", "51.7"]}
|
||||
{"pcdb_id": 80163, "raw": ["080163", "020062", "0", "2023/Jul/18 08:32", "RenewABILITY Energy Inc.", "Power-Pipe", "X2-84", "System B", "2004", "current", "", "1", "B", "", "", "0.915", "", "", "", "0", "5", "5", "59.5", "7", "54.3", "9", "49.9", "11", "46.2", "13", "43.1"]}
|
||||
{"pcdb_id": 80164, "raw": ["080164", "020142", "0", "2020/Jun/23 16:50", "ZYPHO SA", "Zypho", "iZi 30", "System A", "2020", "current", "", "1", "A", "", "", "0.979", "", "", "", "0", "5", "5", "43.7", "7", "35.7", "9", "30.2", "11", "26.1", "13", "23"]}
|
||||
{"pcdb_id": 80165, "raw": ["080165", "020142", "0", "2020/Jun/23 16:50", "ZYPHO SA", "Zypho", "iZi 40", "System A", "2020", "current", "", "1", "A", "", "", "0.983", "", "", "", "0", "5", "5", "49.4", "7", "41.1", "9", "35.1", "11", "30.7", "13", "27.3"]}
|
||||
{"pcdb_id": 80166, "raw": ["080166", "020142", "0", "2020/Jun/23 16:50", "ZYPHO SA", "Zypho", "PiPe 65", "System A", "2020", "current", "", "1", "A", "", "", "0.958", "", "", "", "0", "5", "5", "76.5", "7", "69.9", "9", "64.4", "11", "59.7", "13", "55.6"]}
|
||||
{"pcdb_id": 80167, "raw": ["080167", "020142", "0", "2023/Jul/18 08:32", "ZYPHO SA", "Zypho", "iZi 30", "System B", "2020", "current", "", "1", "B", "", "", "0.967", "", "", "", "0", "5", "5", "37.1", "7", "30.9", "9", "26.6", "11", "23.4", "13", "20.9"]}
|
||||
{"pcdb_id": 80168, "raw": ["080168", "020142", "0", "2023/Jul/18 08:32", "ZYPHO SA", "Zypho", "iZi 40", "System B", "2020", "current", "", "1", "B", "", "", "0.973", "", "", "", "0", "5", "5", "41.3", "7", "35", "9", "30.5", "11", "27", "13", "24.3"]}
|
||||
{"pcdb_id": 80169, "raw": ["080169", "020142", "0", "2023/Jul/18 08:32", "ZYPHO SA", "Zypho", "PiPe 65", "System B", "2020", "current", "", "1", "B", "", "", "0.937", "", "", "", "0", "5", "5", "61.6", "7", "56.9", "9", "52.7", "11", "49.2", "13", "46.1"]}
|
||||
{"pcdb_id": 80170, "raw": ["080170", "020142", "0", "2020/Jun/23 16:50", "ZYPHO SA", "Zypho", "iZi 30", "System C", "2020", "current", "", "1", "C", "", "", "0.976", "", "", "", "0", "5", "5", "40.4", "7", "33.5", "9", "28.6", "11", "24.9", "13", "22.1"]}
|
||||
{"pcdb_id": 80171, "raw": ["080171", "020142", "0", "2020/Jun/23 16:50", "ZYPHO SA", "Zypho", "iZi 40", "System C", "2020", "current", "", "1", "C", "", "", "0.981", "", "", "", "0", "5", "5", "45.1", "7", "38.1", "9", "33", "11", "29", "13", "26"]}
|
||||
{"pcdb_id": 80172, "raw": ["080172", "020142", "0", "2020/Jun/23 16:50", "ZYPHO SA", "Zypho", "PiPe 65", "System C", "2020", "current", "", "1", "C", "", "", "0.95", "", "", "", "0", "5", "5", "65.6", "7", "61.1", "9", "57", "11", "53.4", "13", "50.2"]}
|
||||
{"pcdb_id": 80173, "raw": ["080173", "020101", "0", "2020/Sep/23 10:30", "Baxi Heating UK Ltd", "Baxi", "Assure HSHRU", "System A", "2020", "current", "", "1", "A", "", "", "0.979", "", "", "", "0", "5", "5", "43.7", "7", "35.7", "9", "30.2", "11", "26.1", "13", "23"]}
|
||||
{"pcdb_id": 80174, "raw": ["080174", "020101", "0", "2023/Jul/18 08:32", "Baxi Heating UK Ltd", "Baxi", "Assure HSHRU", "System B", "2020", "current", "", "1", "B", "", "", "0.967", "", "", "", "0", "5", "5", "37.1", "7", "30.9", "9", "26.6", "11", "23.4", "13", "20.9"]}
|
||||
{"pcdb_id": 80175, "raw": ["080175", "020101", "0", "2020/Sep/23 10:30", "Baxi Heating UK Ltd", "Baxi", "Assure HSHRU", "System C", "2020", "current", "", "1", "C", "", "", "0.976", "", "", "", "0", "5", "5", "40.4", "7", "33.5", "9", "28.6", "11", "24.9", "13", "22.1"]}
|
||||
{"pcdb_id": 80176, "raw": ["080176", "020101", "0", "2020/Sep/23 10:30", "Baxi Heating UK Ltd", "Baxi", "Assure VSHRU", "System A", "2020", "current", "", "1", "A", "", "", "0.958", "", "", "", "0", "5", "5", "76.5", "7", "69.9", "9", "64.4", "11", "59.7", "13", "55.6"]}
|
||||
{"pcdb_id": 80177, "raw": ["080177", "020101", "0", "2023/Jul/18 08:32", "Baxi Heating UK Ltd", "Baxi", "Assure VSHRU", "System B", "2020", "current", "", "1", "B", "", "", "0.937", "", "", "", "0", "5", "5", "61.6", "7", "56.9", "9", "52.7", "11", "49.2", "13", "46.1"]}
|
||||
{"pcdb_id": 80178, "raw": ["080178", "020101", "0", "2020/Sep/23 10:30", "Baxi Heating UK Ltd", "Baxi", "Assure VSHRU", "System C", "2020", "current", "", "1", "C", "", "", "0.95", "", "", "", "0", "5", "5", "65.6", "7", "61.1", "9", "57", "11", "53.4", "13", "50.2"]}
|
||||
{"pcdb_id": 80179, "raw": ["080179", "020171", "0", "2021/May/26 20:38", "Kohler Mira", "Mira Showers", "HeatCapture", "System A", "2012", "current", "", "1", "A", "", "", "0.972", "", "", "", "0", "5", "5", "75.5", "7", "68.8", "9", "63.1", "11", "58.4", "13", "54.2"]}
|
||||
{"pcdb_id": 80180, "raw": ["080180", "020171", "0", "2023/Jul/18 08:32", "Kohler Mira", "Mira Showers", "HeatCapture", "System B", "2012", "current", "", "1", "B", "", "", "0.957", "", "", "", "0", "5", "5", "60.9", "7", "56", "9", "51.8", "11", "48.2", "13", "45"]}
|
||||
{"pcdb_id": 80181, "raw": ["080181", "020171", "0", "2021/May/26 20:38", "Kohler Mira", "Mira Showers", "HeatCapture", "System C", "2012", "current", "", "1", "C", "", "", "0.966", "", "", "", "0", "5", "5", "65", "7", "60.2", "9", "56", "11", "52.3", "13", "49.1"]}
|
||||
{"pcdb_id": 80182, "raw": ["080182", "020064", "0", "2021/Jun/15 09:44", "Q-Blue B.V.", "Showersave", "Blue QB1-21D", "System A", "2020", "current", "", "1", "A", "", "", "0.946", "", "", "", "0", "5", "5", "83.5", "7", "78.3", "9", "73.8", "11", "69.7", "13", "66.1"]}
|
||||
{"pcdb_id": 80183, "raw": ["080183", "020064", "0", "2021/Jun/15 09:44", "Q-Blue B.V.", "Showersave", "Blue QB1-21", "System A", "2020", "current", "", "1", "A", "", "", "0.958", "", "", "", "0", "5", "5", "78.7", "7", "72.5", "9", "67.2", "11", "62.7", "13", "58.7"]}
|
||||
{"pcdb_id": 80184, "raw": ["080184", "020064", "0", "2023/Jul/18 08:32", "Q-Blue B.V.", "Showersave", "Blue QB1-21D", "System B", "2020", "current", "", "1", "B", "", "", "0.918", "", "", "", "0", "5", "5", "65.9", "7", "62.8", "9", "59.7", "11", "56.8", "13", "54"]}
|
||||
{"pcdb_id": 80185, "raw": ["080185", "020064", "0", "2023/Jul/18 08:32", "Q-Blue B.V.", "Showersave", "Blue QB1-21", "System B", "2020", "current", "", "1", "B", "", "", "0.937", "", "", "", "0", "5", "5", "63", "7", "58.7", "9", "54.9", "11", "51.4", "13", "48.4"]}
|
||||
{"pcdb_id": 80186, "raw": ["080186", "020064", "0", "2021/Jun/15 09:44", "Q-Blue B.V.", "Showersave", "Blue QB1-21D", "System C", "2020", "current", "", "1", "C", "", "", "0.933", "", "", "", "0", "5", "5", "69.8", "7", "66.8", "9", "63.8", "11", "60.9", "13", "58.3"]}
|
||||
{"pcdb_id": 80187, "raw": ["080187", "020064", "0", "2021/Jun/15 09:44", "Q-Blue B.V.", "Showersave", "Blue QB1-21", "System C", "2020", "current", "", "1", "C", "", "", "0.949", "", "", "", "0", "5", "5", "67", "7", "62.9", "9", "59.1", "11", "55.7", "13", "52.6"]}
|
||||
{"pcdb_id": 80188, "raw": ["080188", "020064", "0", "2021/Aug/17 14:00", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-21 Cyclone", "System A", "2021", "current", "", "1", "A", "", "", "0.973", "", "", "", "0", "5", "5", "79.7", "7", "73.7", "9", "68.6", "11", "64.1", "13", "60.2"]}
|
||||
{"pcdb_id": 80189, "raw": ["080189", "020064", "0", "2023/Jul/18 08:32", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-21 Cyclone", "System B", "2021", "current", "", "1", "B", "", "", "0.959", "", "", "", "0", "5", "5", "63.7", "7", "59.6", "9", "55.9", "11", "52.5", "13", "49.5"]}
|
||||
{"pcdb_id": 80190, "raw": ["080190", "020064", "0", "2021/Aug/17 14:00", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-21 Cyclone", "System C", "2021", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5", "67.7", "7", "63.8", "9", "60.1", "11", "56.8", "13", "53.8"]}
|
||||
{"pcdb_id": 80191, "raw": ["080191", "020075", "0", "2022/Nov/30 14:21", "Recoup Energy Solutions Ltd", "Recoup", "Pipe Active", "System A", "", "current", "", "1", "A", "", "", "0.948", "", "", "", "0", "5", "5", "79.3", "7", "73.3", "9", "68.1", "11", "63.6", "13", "59.6"]}
|
||||
{"pcdb_id": 80192, "raw": ["080192", "020075", "0", "2023/Jul/18 08:32", "Recoup Energy Solutions Ltd", "Recoup", "Pipe Active", "System B", "", "current", "", "1", "B", "", "", "0.922", "", "", "", "0", "5", "5", "63.5", "7", "59.3", "9", "55.4", "11", "52.1", "13", "49.1"]}
|
||||
{"pcdb_id": 80193, "raw": ["080193", "020075", "0", "2022/Nov/30 14:21", "Recoup Energy Solutions Ltd", "Recoup", "Pipe Active", "System C", "", "current", "", "1", "C", "", "", "0.937", "", "", "", "0", "5", "5", "67.4", "7", "63.4", "9", "59.7", "11", "56.4", "13", "53.3"]}
|
||||
{"pcdb_id": 80194, "raw": ["080194", "020142", "0", "2022/Dec/21 12:56", "ZYPHO SA", "Zypho", "Slim DW50", "System A", "2022", "current", "", "1", "A", "", "", "0.987", "", "", "", "0", "5", "5", "65.9", "7", "58", "9", "51.8", "11", "46.8", "13", "42.6"]}
|
||||
{"pcdb_id": 80195, "raw": ["080195", "020142", "0", "2022/Dec/21 12:56", "ZYPHO SA", "Zypho", "PiPe 75", "System A", "2022", "current", "", "1", "A", "", "", "0.975", "", "", "", "0", "5", "5", "82", "7", "76.5", "9", "71.6", "11", "67.4", "13", "63.6"]}
|
||||
{"pcdb_id": 80196, "raw": ["080196", "020142", "0", "2023/Jul/18 08:32", "ZYPHO SA", "Zypho", "Slim DW50", "System B", "2022", "current", "", "1", "B", "", "", "0.979", "", "", "", "0", "5", "5", "53.9", "7", "47.9", "9", "43.1", "11", "39.4", "13", "36.2"]}
|
||||
{"pcdb_id": 80197, "raw": ["080197", "020142", "0", "2023/Jul/18 08:32", "ZYPHO SA", "Zypho", "PiPe 75", "System B", "2022", "current", "", "1", "B", "", "", "0.962", "", "", "", "0", "5", "5", "65", "7", "61.6", "9", "58.1", "11", "55", "13", "52.1"]}
|
||||
{"pcdb_id": 80198, "raw": ["080198", "020142", "0", "2022/Dec/21 12:56", "ZYPHO SA", "Zypho", "Slim DW50", "System C", "2022", "current", "", "1", "C", "", "", "0.984", "", "", "", "0", "5", "5", "58.1", "7", "52.1", "9", "47.1", "11", "42.9", "13", "39.4"]}
|
||||
{"pcdb_id": 80199, "raw": ["080199", "020142", "0", "2022/Dec/21 12:56", "ZYPHO SA", "Zypho", "PiPe 75", "System C", "2022", "current", "", "1", "C", "", "", "0.969", "", "", "", "0", "5", "5", "69", "7", "65.6", "9", "62.3", "11", "59.2", "13", "56.4"]}
|
||||
{"pcdb_id": 80200, "raw": ["080200", "020064", "0", "2024/Apr/12 11:56", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-10XE", "System A", "2012", "current", "", "1", "A", "", "", "0.978", "", "", "", "0", "5", "5.0", "65.9", "7.0", "58.0", "9.0", "51.8", "11.0", "46.7", "13.0", "42.6"]}
|
||||
{"pcdb_id": 80201, "raw": ["080201", "020064", "0", "2024/Apr/12 11:56", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-21XE", "System A", "2012", "current", "", "1", "A", "", "", "0.973", "", "", "", "0", "5", "5.0", "79.8", "7.0", "73.8", "9.0", "68.7", "11.0", "64.2", "13.0", "60.3"]}
|
||||
{"pcdb_id": 80202, "raw": ["080202", "020064", "0", "2024/Apr/12 11:56", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-10XE", "System B", "2012", "current", "", "1", "B", "", "", "0.965", "", "", "", "0", "5", "5.0", "53.9", "7.0", "47.9", "9.0", "43.1", "11.0", "39.3", "13.0", "36.2"]}
|
||||
{"pcdb_id": 80203, "raw": ["080203", "020064", "0", "2024/Apr/12 11:56", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-21XE", "System B", "2012", "current", "", "1", "B", "", "", "0.958", "", "", "", "0", "5", "5.0", "63.7", "7.0", "59.7", "9.0", "56.0", "11.0", "52.6", "13.0", "49.6"]}
|
||||
{"pcdb_id": 80204, "raw": ["080204", "020064", "0", "2024/Apr/12 11:56", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-10XE", "System C", "2012", "current", "", "1", "C", "", "", "0.974", "", "", "", "0", "5", "5.0", "58.1", "7.0", "52.0", "9.0", "47.0", "11.0", "42.9", "13.0", "39.4"]}
|
||||
{"pcdb_id": 80205, "raw": ["080205", "020064", "0", "2024/Apr/12 11:56", "Building Products Distributors Ltd", "Showersave", "Showersave QB1-21XE", "System C", "2012", "current", "", "1", "C", "", "", "0.967", "", "", "", "0", "5", "5.0", "67.7", "7.0", "63.8", "9.0", "60.2", "11.0", "56.9", "13.0", "53.8"]}
|
||||
{"pcdb_id": 80206, "raw": ["080206", "020171", "0", "2025/Jan/31 10:04", "Kohler Mira Ltd", "Recoup", "Heatdeck", "System A", "2012", "current", "", "1", "A", "", "", "0.938", "", "", "", "0", "5", "5.0", "53.9", "7.0", "45.5", "9.0", "39.4", "11.0", "34.7", "13.0", "31.0"]}
|
||||
{"pcdb_id": 80207, "raw": ["080207", "020171", "0", "2025/Jan/31 10:04", "Kohler Mira Ltd", "Recoup", "Heatdeck", "System B", "2012", "current", "", "1", "B", "", "", "0.904", "", "", "", "0", "5", "5.0", "44.8", "7.0", "38.4", "9.0", "33.7", "11.0", "30.1", "13.0", "27.3"]}
|
||||
{"pcdb_id": 80208, "raw": ["080208", "020171", "0", "2025/Jan/31 10:04", "Kohler Mira Ltd", "Recoup", "Heatdeck", "System C", "2012", "current", "", "1", "C", "", "", "0.929", "", "", "", "0", "5", "5.0", "48.8", "7.0", "41.9", "9.0", "36.7", "11.0", "32.6", "13.0", "29.3"]}
|
||||
10070
domain/sap10_calculator/tables/pcdb/data/pcdb_table_362_heat_pumps.jsonl
Normal file
10070
domain/sap10_calculator/tables/pcdb/data/pcdb_table_362_heat_pumps.jsonl
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,58 @@
|
|||
{"pcdb_id": 697101, "raw": ["697101", "300900", "1", "2013/Oct/19 15:13", "SAP Illustrative Products", "Illustrative Storage Heater", "medium", "", "2013", "current", "12.0", "1000", "", "50", "1"]}
|
||||
{"pcdb_id": 230001, "raw": ["230001", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Dimplex", "Quantum", "QM 070", "2013", "current", "11.61", "700", "630", "46", "1"]}
|
||||
{"pcdb_id": 230002, "raw": ["230002", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Dimplex", "Quantum", "QM 100", "2013", "current", "15.42", "1000", "880", "49", "1"]}
|
||||
{"pcdb_id": 230003, "raw": ["230003", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Dimplex", "Quantum", "QM 125", "2013", "current", "19.45", "1250", "1130", "52", "1"]}
|
||||
{"pcdb_id": 230004, "raw": ["230004", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Dimplex", "Quantum", "QM 150", "2013", "current", "23.10", "1500", "1380", "54", "1"]}
|
||||
{"pcdb_id": 230005, "raw": ["230005", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Creda", "Quantum", "CQH 070", "2014", "current", "11.61", "700", "630", "46", "1"]}
|
||||
{"pcdb_id": 230006, "raw": ["230006", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Creda", "Quantum", "CQH 100", "2014", "current", "15.42", "1000", "880", "49", "1"]}
|
||||
{"pcdb_id": 230007, "raw": ["230007", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Creda", "Quantum", "CQH 125", "2014", "current", "19.45", "1250", "1130", "52", "1"]}
|
||||
{"pcdb_id": 230008, "raw": ["230008", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Creda", "Quantum", "CQH 150", "2014", "current", "23.10", "1500", "1380", "54", "1"]}
|
||||
{"pcdb_id": 230009, "raw": ["230009", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Heatstore", "Quantum", "HSDQ 070", "2014", "current", "11.61", "700", "630", "46", "1"]}
|
||||
{"pcdb_id": 230010, "raw": ["230010", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Heatstore", "Quantum", "HSDQ 100", "2014", "current", "15.42", "1000", "880", "49", "1"]}
|
||||
{"pcdb_id": 230011, "raw": ["230011", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Heatstore", "Quantum", "HSDQ 125", "2014", "current", "19.45", "1250", "1130", "52", "1"]}
|
||||
{"pcdb_id": 230012, "raw": ["230012", "020046", "0", "2014/May/21 12:40", "GDC Group Ltd", "Heatstore", "Quantum", "HSDQ 150", "2014", "current", "23.10", "1500", "1350", "54", "1"]}
|
||||
{"pcdb_id": 230013, "raw": ["230013", "020046", "0", "2016/May/19 10:40", "GDC Group Ltd", "Dimplex", "Quantum", "QM 050", "2016", "current", "7.20", "500", "385", "45", "1"]}
|
||||
{"pcdb_id": 230014, "raw": ["230014", "020114", "0", "2018/Apr/16 13:33", "Elnur SA", "Gabarron", "Ecombi HHR", "ECOHHR20", "2017", "current", "12.2", "800", "550", "49", "1"]}
|
||||
{"pcdb_id": 230015, "raw": ["230015", "020114", "0", "2018/Apr/16 13:33", "Elnur SA", "Gabarron", "Ecombi HHR", "ECOHHR30", "2017", "current", "18.3", "1200", "820", "50", "1"]}
|
||||
{"pcdb_id": 230016, "raw": ["230016", "020114", "0", "2018/Apr/16 13:33", "Elnur SA", "Gabarron", "Ecombi HHR", "ECOHHR40", "2017", "current", "24.4", "1600", "1100", "51", "1"]}
|
||||
{"pcdb_id": 230017, "raw": ["230017", "020065", "0", "2019/Feb/25 15:50", "Stiebel Eltron UK Ltd", "STIEBEL ELTRON", "SHF 2000", "", "2019", "current", "16", "1000", "350", "47", "1"]}
|
||||
{"pcdb_id": 230018, "raw": ["230018", "020065", "0", "2019/Feb/25 15:50", "Stiebel Eltron UK Ltd", "STIEBEL ELTRON", "SHS 2400", "", "2019", "current", "19.2", "1", "800", "46", "1"]}
|
||||
{"pcdb_id": 230019, "raw": ["230019", "020065", "0", "2019/Feb/25 15:50", "Stiebel Eltron UK Ltd", "STIEBEL ELTRON", "SHS 3600", "", "2019", "current", "28.8", "1800", "1200", "49", "1"]}
|
||||
{"pcdb_id": 230020, "raw": ["230020", "020065", "0", "2019/Feb/25 15:50", "Stiebel Eltron UK Ltd", "STIEBEL ELTRON", "SHF 3000", "", "2019", "current", "24", "1500", "500", "52", "1"]}
|
||||
{"pcdb_id": 230021, "raw": ["230021", "020065", "0", "2019/Feb/25 15:50", "Stiebel Eltron UK Ltd", "STIEBEL ELTRON", "SHS 3000", "", "2019", "current", "24", "1500", "1000", "48", "1"]}
|
||||
{"pcdb_id": 230022, "raw": ["230022", "020046", "0", "2019/Oct/30 11:30", "Dimplex", "Dimplex", "Quantum", "QM050RF", "2019", "current", "7.2", "500", "340", "45", "1"]}
|
||||
{"pcdb_id": 230023, "raw": ["230023", "020046", "0", "2019/Oct/30 11:30", "Dimplex", "Dimplex", "Quantum", "QM070RF", "2019", "current", "10.9", "700", "520", "46", "1"]}
|
||||
{"pcdb_id": 230024, "raw": ["230024", "020046", "0", "2019/Oct/30 11:30", "Dimplex", "Dimplex", "Quantum", "QM100RF", "2019", "current", "15.42", "1000", "880", "49", "1"]}
|
||||
{"pcdb_id": 230025, "raw": ["230025", "020046", "0", "2019/Oct/30 11:30", "Dimplex", "Dimplex", "Quantum", "QM125RF", "2019", "current", "19.3", "1250", "920", "52", "1"]}
|
||||
{"pcdb_id": 230026, "raw": ["230026", "020046", "0", "2019/Oct/30 11:30", "Dimplex", "Dimplex", "Quantum", "QM150RF", "2019", "current", "23.1", "1500", "1100", "54", "1"]}
|
||||
{"pcdb_id": 230027, "raw": ["230027", "020046", "0", "2020/Feb/27 12:00", "Dimplex", "Heatstore", "Dynamic HHR", "HSDHHR050", "2019", "current", "7.2", "500", "340", "45", "1"]}
|
||||
{"pcdb_id": 230028, "raw": ["230028", "020046", "0", "2020/Feb/27 12:00", "Dimplex", "Heatstore", "Dynamic HHR", "HSDHHR070", "2019", "current", "10.9", "700", "520", "46", "1"]}
|
||||
{"pcdb_id": 230029, "raw": ["230029", "020046", "0", "2020/Feb/27 12:00", "Dimplex", "Heatstore", "Dynamic HHR", "HSDHHR100", "2019", "current", "15.42", "1000", "880", "49", "1"]}
|
||||
{"pcdb_id": 230030, "raw": ["230030", "020046", "0", "2020/Feb/27 12:00", "Dimplex", "Heatstore", "Dynamic HHR", "HSDHHR125", "2019", "current", "19.3", "1250", "920", "52", "1"]}
|
||||
{"pcdb_id": 230031, "raw": ["230031", "020046", "0", "2020/Feb/27 12:00", "Dimplex", "Heatstore", "Dynamic HHR", "HSDHHR150", "2019", "current", "23.1", "1500", "1100", "54", "1"]}
|
||||
{"pcdb_id": 230032, "raw": ["230032", "020147", "0", "2021/Sep/29 11:00", "Electrorad U.K. Ltd", "Electrorad", "Thermastore HHR", "HHR165", "2021", "current", "11.55", "725", "625", "51", "1"]}
|
||||
{"pcdb_id": 230033, "raw": ["230033", "020147", "0", "2021/Sep/29 11:00", "Electrorad U.K. Ltd", "Electrorad", "Thermastore HHR", "HHR255", "2021", "current", "17.85", "1115", "950", "49", "1"]}
|
||||
{"pcdb_id": 230034, "raw": ["230034", "020147", "0", "2021/Sep/29 11:00", "Electrorad U.K. Ltd", "Electrorad", "Thermastore HHR", "HHR340", "2021", "current", "23.80", "1500", "1275", "52", "1"]}
|
||||
{"pcdb_id": 230035, "raw": ["230035", "020114", "0", "2021/Nov/29 13:33", "Elnur SA", "Gabarron", "SOLARHHR", "SOLARHHR20", "2017", "current", "12.2", "800", "550", "49", "1"]}
|
||||
{"pcdb_id": 230036, "raw": ["230036", "020114", "0", "2021/Nov/29 13:33", "Elnur SA", "Gabarron", "SOLARHHR", "SOLARHHR30", "2017", "current", "18.3", "1200", "820", "50", "1"]}
|
||||
{"pcdb_id": 230037, "raw": ["230037", "020114", "0", "2021/Nov/29 13:33", "Elnur SA", "Gabarron", "SOLARHHR", "SOLARHHR40", "2017", "current", "24.4", "1600", "1100", "51", "1"]}
|
||||
{"pcdb_id": 230038, "raw": ["230038", "020114", "0", "2024/Jun/14 13:33", "Elnur SA", "Gabarron", "Ecombi HHR", "ECOHHR10", "2023", "current", "6.1", "400", "270", "50", "1"]}
|
||||
{"pcdb_id": 230039, "raw": ["230039", "020114", "0", "2021/Jun/14 13:33", "Elnur SA", "Gabarron", "SOLARHHR", "SOLARHHR10", "2023", "current", "6.1", "400", "270", "50", "1"]}
|
||||
{"pcdb_id": 230040, "raw": ["230040", "020250", "0", "2024/Jul/22 13:33", "Haverland", "Haverland", "Eco-Joule-1", "SHV1700HHR", "2023", "current", "12.75", "850", "600", "55", "1"]}
|
||||
{"pcdb_id": 230041, "raw": ["230041", "020250", "0", "2024/Jul/22 13:33", "Haverland", "Haverland", "Eco-Joule-1", "SHV2250HHR", "2023", "current", "18.31", "1275", "900", "51", "1"]}
|
||||
{"pcdb_id": 230042, "raw": ["230042", "020250", "0", "2024/Jul/22 13:33", "Haverland", "Haverland", "Eco-Joule-1", "SHV3400HHR", "2023", "current", "24.36", "1700", "1200", "53", "1"]}
|
||||
{"pcdb_id": 230043, "raw": ["230043", "020147", "0", "2024/Aug/29 11:00", "Electrorad U.K. Ltd", "Fischer", "Fischer Elektrostore", "HHR16", "2021", "current", "11.55", "725", "625", "51", "1"]}
|
||||
{"pcdb_id": 230044, "raw": ["230044", "020147", "0", "2024/Aug/29 11:00", "Electrorad U.K. Ltd", "Fischer", "Fischer Elektrostore", "HHR25", "2021", "current", "17.85", "1115", "950", "49", "1"]}
|
||||
{"pcdb_id": 230045, "raw": ["230045", "020147", "0", "2024/Aug/29 11:00", "Electrorad U.K. Ltd", "Fischer", "Fischer Elektrostore", "HHR34", "2021", "current", "23.8", "1500", "1275", "52", "1"]}
|
||||
{"pcdb_id": 230046, "raw": ["230046", "020270", "0", "2025/Jan/27 13:00", "Ecostrad Ltd", "Ecostrad Ltd", "Magma HHR Storage Heater", "E-Magma-HHR-SH-17", "2024", "current", "15.5", "850", "500", "45", "1"]}
|
||||
{"pcdb_id": 230047, "raw": ["230047", "020270", "0", "2025/Jan/27 13:00", "Ecostrad Ltd", "Ecostrad Ltd", "Magma HHR Storage Heater", "E-Magma-HHR-SH-26", "2024", "current", "23.2", "1300", "750", "48", "1"]}
|
||||
{"pcdb_id": 230048, "raw": ["230048", "020270", "0", "2025/Jan/27 13:00", "Ecostrad Ltd", "Ecostrad Ltd", "Magma HHR Storage Heater", "E-Magma-HHR-SH-34", "2024", "current", "30.9", "1700", "750", "49", "1"]}
|
||||
{"pcdb_id": 230049, "raw": ["230049", "020114", "0", "2025/Apr/01 13:00", "Elnur SA", "Gabarron", "HHR PLUS", "HHR10 PLUS", "2024", "current", "6.1", "400", "270", "50", "1"]}
|
||||
{"pcdb_id": 230050, "raw": ["230050", "020114", "0", "2025/Apr/01 13:00", "Elnur SA", "Gabarron", "HHR PLUS", "HHR20 PLUS", "2024", "current", "12.2", "800", "550", "49", "1"]}
|
||||
{"pcdb_id": 230051, "raw": ["230051", "020114", "0", "2025/Apr/01 13:00", "Elnur SA", "Gabarron", "HHR PLUS", "HHR30 PLUS", "2024", "current", "18.3", "1200", "820", "50", "1"]}
|
||||
{"pcdb_id": 230052, "raw": ["230052", "020114", "0", "2025/Apr/01 13:00", "Elnur SA", "Gabarron", "HHR PLUS", "HHR40 PLUS", "2024", "current", "24.4", "1600", "1100", "51", "1"]}
|
||||
{"pcdb_id": 230053, "raw": ["230053", "020301", "0", "2026/Apr/14 13:00", "INDUSTRIAS ROYAL TERMIC S.L.", "ONYX", "SHOXI330", "3300W", "2026", "current", "23.1", "1500", "1100", "55", "1"]}
|
||||
{"pcdb_id": 230054, "raw": ["230054", "020301", "0", "2026/Apr/14 13:00", "INDUSTRIAS ROYAL TERMIC S.L.", "ONYX", "SHOXI222", "2220W", "2026", "current", "15.5", "1000", "740", "47", "1"]}
|
||||
{"pcdb_id": 230055, "raw": ["230055", "020301", "0", "2026/Apr/14 13:00", "INDUSTRIAS ROYAL TERMIC S.L.", "ONYX", "SHOXI102", "1020W", "2026", "current", "7.2", "500", "340", "51", "1"]}
|
||||
{"pcdb_id": 230056, "raw": ["230056", "020301", "0", "2026/Apr/14 13:00", "INDUSTRIAS ROYAL TERMIC S.L.", "ONYX", "SHOXI276", "2760W", "2026", "current", "19.3", "1250", "920", "54", "1"]}
|
||||
{"pcdb_id": 230057, "raw": ["230057", "020301", "0", "2026/Apr/14 13:00", "INDUSTRIAS ROYAL TERMIC S.L.", "ONYX", "SHOXI156", "1560W", "2026", "current", "10.9", "700", "520", "49", "1"]}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{"pcdb_id": 400001, "raw": ["400001", "300903", "0", "2021/Aug/09 11:54", "", "SAP Default products", "HIU", "Indirect HIU", "2021", "current", "1", "1.44", "", "", "", "", ""]}
|
||||
{"pcdb_id": 400002, "raw": ["400002", "300903", "0", "2021/Aug/09 11:54", "", "SAP Default products", "HIU", "Direct HIU", "2021", "current", "2", "1.44", "", "", "", "", ""]}
|
||||
{"pcdb_id": 400003, "raw": ["400003", "020101", "0", "2025/Mar/05 11:31", "Baxi Heating UK Ltd", "Baxi", " AquaHeat", "HI / HWI 4/50", "2024", "current", "1", "0.88", "26", "", "0.07"]}
|
||||
{"pcdb_id": 400004, "raw": ["400004", "020101", "0", "2025/Mar/05 11:31", "Baxi Heating UK Ltd", "Baxi", " AquaHeat", "HI / HWI 14/50", "2024", "current", "1", "0.88", "26", "", "0.07"]}
|
||||
{"pcdb_id": 400005, "raw": ["400005", "020051", "0", "2025/May/30 11:00", "Bosch Thermotechnik GmbH", "Bosch", "Flow 8500", "40 H", "2023", "current", "1", "0.77", "28", "", "0.06"]}
|
||||
{"pcdb_id": 400006, "raw": ["400006", "020051", "0", "2025/May/30 11:00", "Bosch Thermotechnik GmbH", "Bosch", "Flow 8500", "50 H", "2023", "current", "1", "0.63", "28", "", "0.06"]}
|
||||
{"pcdb_id": 400007, "raw": ["400007", "020051", "0", "2025/May/30 11:00", "Bosch Thermotechnik GmbH", "Bosch", "Flow 8500", "60 H", "2023", "current", "1", "0.8", "29", "", "0.06"]}
|
||||
{"pcdb_id": 400008, "raw": ["400008", "020255", "0", "2025/May/30 11:00", "YGHP", "YGHP", "Indirect V2", "199P35007", "2023", "current", "1", "0.78", "28", "", "0.04"]}
|
||||
{"pcdb_id": 400009, "raw": ["400009", "020101", "0", "2025/Jul/31 11:00", "Baxi Heating UK Ltd", "Baxi", " AquaHeat", "HD / HWI 12/50", "2025", "current", "2", "0.55", "27", "", "0.06"]}
|
||||
{"pcdb_id": 400010, "raw": ["400010", "020294", "0", "2025/Sep/12 11:00", "Switch2", "Switch2", " ICON Connected HIU", "", "2024", "current", "1", "0.9", "27", "", "0.06"]}
|
||||
{"pcdb_id": 400011, "raw": ["400011", "300903", "0", "2025/Oct/01 11:00", "", "SAP 10 3 Default products", "HIU", "Indirect HIU", "2025", "current", "1", "0.8", "", "", "", "", ""]}
|
||||
{"pcdb_id": 400012, "raw": ["400012", "020177", "0", "2025/Oct/31 11:00", "Modutherm", "Modutherm", "MTA Plus Twin 40-70", "", "2022", "current", "1", "0.74", "26", "", "0.03"]}
|
||||
{"pcdb_id": 400013, "raw": ["400013", "020031", "0", "2025/Oct/31 11:00", "Cetetherm", "Cetetherm", "Pioneer", "", "2023", "current", "1", "0.88", "25", "", "0.07"]}
|
||||
{"pcdb_id": 400014, "raw": ["400014", "020257", "0", "2025/Dec/10 11:00", "Intatec", "Intatec", "Hiper II", "", "2023", "current", "1", "0.87", "30", "", " 0.03"]}
|
||||
82
domain/sap10_calculator/tables/pcdb/etl.py
Normal file
82
domain/sap10_calculator/tables/pcdb/etl.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""ETL: parse BRE PCDB pcdb10.dat into per-table JSON files.
|
||||
|
||||
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`.
|
||||
|
||||
Reference: BRE PCDB pcdb10.dat (April 2026 revision).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from domain.sap10_calculator.tables.pcdb.parser import (
|
||||
GasOilBoilerRecord,
|
||||
RawPcdbRecord,
|
||||
parse_table_105,
|
||||
parse_table_raw,
|
||||
)
|
||||
|
||||
|
||||
_TABLE_105_OUTPUT_FILENAME: str = "pcdb_table_105_gas_oil_boilers.jsonl"
|
||||
# Tables ingested as `RawPcdbRecord` (pcdb_id + raw) — per-field typing is
|
||||
# deferred to follow-up slices when the cert-side wiring for each table
|
||||
# lands.
|
||||
_RAW_TABLES: dict[str, str] = {
|
||||
"122": "pcdb_table_122_solid_fuel_boilers.jsonl",
|
||||
"143": "pcdb_table_143_micro_cogen.jsonl",
|
||||
"313": "pcdb_table_313_flue_gas_heat_recovery.jsonl",
|
||||
"353": "pcdb_table_353_waste_water_heat_recovery.jsonl",
|
||||
"362": "pcdb_table_362_heat_pumps.jsonl",
|
||||
"391": "pcdb_table_391_high_heat_retention_storage_heaters.jsonl",
|
||||
"506": "pcdb_table_506_heat_interface_units.jsonl",
|
||||
}
|
||||
|
||||
|
||||
def _gas_oil_record_to_jsonable(record: GasOilBoilerRecord) -> dict[str, object]:
|
||||
"""Serialise a typed Table 105 record into a JSON-safe dict."""
|
||||
serialisable = asdict(record)
|
||||
serialisable["raw"] = list(record.raw)
|
||||
return serialisable
|
||||
|
||||
|
||||
def _raw_record_to_jsonable(record: RawPcdbRecord) -> dict[str, object]:
|
||||
"""Serialise a generic raw PCDB record into a JSON-safe dict."""
|
||||
return {"pcdb_id": record.pcdb_id, "raw": list(record.raw)}
|
||||
|
||||
|
||||
def _write_ndjson(*, output_path: Path, records: list[dict[str, object]]) -> None:
|
||||
"""Newline-delimited JSON: one record per line, no top-level array,
|
||||
no indent. Diffs are line-granular when records are added/changed."""
|
||||
lines = [json.dumps(record, ensure_ascii=False) for record in records]
|
||||
output_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def run_etl(*, source: Path, output_dir: Path) -> None:
|
||||
"""Read `source` (pcdb10.dat), parse Table 105 (typed) plus the raw
|
||||
tables enumerated in `_RAW_TABLES`, and write one newline-delimited
|
||||
JSON file (`.jsonl`) per table under `output_dir/`. Idempotent;
|
||||
record order preserves source order for diff-friendliness."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
dat_text = source.read_text(encoding="latin-1")
|
||||
|
||||
_write_ndjson(
|
||||
output_path=output_dir / _TABLE_105_OUTPUT_FILENAME,
|
||||
records=[_gas_oil_record_to_jsonable(r) for r in parse_table_105(dat_text)],
|
||||
)
|
||||
for table_id, filename in _RAW_TABLES.items():
|
||||
_write_ndjson(
|
||||
output_path=output_dir / filename,
|
||||
records=[_raw_record_to_jsonable(r) for r in parse_table_raw(dat_text, table_id)],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover — manual ETL invocation
|
||||
data_dir = Path(__file__).resolve().parent / "data"
|
||||
run_etl(
|
||||
source=data_dir / "pcdb10.dat",
|
||||
output_dir=data_dir,
|
||||
)
|
||||
176
domain/sap10_calculator/tables/pcdb/parser.py
Normal file
176
domain/sap10_calculator/tables/pcdb/parser.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Per-table row parsers for BRE PCDB pcdb10.dat records.
|
||||
|
||||
Each PCDB table has its own CSV-shaped record format documented by BRE
|
||||
(format codes in `$<table>,<format>,...` headers of pcdb10.dat). Field
|
||||
positions are reverse-engineered from sample records and cross-checked
|
||||
against ground-truth records published at https://www.ncm-pcdb.org.uk.
|
||||
|
||||
The parsers expose two layers per record:
|
||||
- Typed high-confidence fields (pcdb_id, manufacturer, model, winter/
|
||||
summer efficiency, etc.) named per BRE's web entry vocabulary.
|
||||
- The full raw row as a tuple of strings, for forensics on undecoded
|
||||
fields and audit trails when BRE bumps the format version.
|
||||
|
||||
Reference: BRE PCDB pcdb10.dat April 2026; user-verified web records.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_optional_float(value: str) -> Optional[float]:
|
||||
"""Empty PCDB fields are blank strings, not 'null'. Treat blank or
|
||||
non-numeric (e.g. '>70kW' range indicator on output-power fields) as
|
||||
None — the raw value is preserved on the record's `raw` tuple."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_optional_int(value: str) -> Optional[int]:
|
||||
"""Some PCDB fields carry status strings ('obsolete', 'discontinued')
|
||||
where a year would otherwise live. Treat any non-numeric value as
|
||||
missing rather than erroring — the status is preserved on `raw`."""
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GasOilBoilerRecord:
|
||||
"""SAP 10.2 Appendix D2.1 PCDB record — Table 105 (Gas and Oil Boilers).
|
||||
|
||||
Field positions verified against the ncm-pcdb.org.uk web entry for
|
||||
pcdb_id 000098 (Baxi Heating Wm 20/3rs): winter eff = 66.0%, summer
|
||||
eff = 56.0%, comparative HW = 40.8%, output 5.86 kW, final-year 1990.
|
||||
"""
|
||||
|
||||
pcdb_id: int
|
||||
brand_name: str
|
||||
model_name: str
|
||||
model_qualifier: str
|
||||
winter_efficiency_pct: Optional[float]
|
||||
summer_efficiency_pct: Optional[float]
|
||||
comparative_hot_water_efficiency_pct: Optional[float]
|
||||
output_kw_max: Optional[float]
|
||||
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`
|
||||
# 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
|
||||
# schedules: 0=none, 1=schedule 2 only (profile M → Table 3b row 1),
|
||||
# 2=schedules 2 and 3 (profiles M+L → Table 3c), 3=schedules 2 and 1
|
||||
# (profiles M+S → Table 3c). Field 55 (r2) is lodged but explicitly
|
||||
# excluded from SAP assessments ("only r1") so it is not surfaced.
|
||||
# PCDF Spec Rev 6b field 16 (0-idx 15): 0=normal, 1=integral FGHRS,
|
||||
# 2=combined HP+boiler, 3=combined HP+boiler+FGHRS. Gates the Table
|
||||
# 3b/3c row selection — only `subsidiary_type=0` exercises the
|
||||
# "Instantaneous with non-storage FGHRS or without FGHRS" row 1.
|
||||
subsidiary_type: Optional[int]
|
||||
# PCDF Spec Rev 6b field 39 (0-idx 38): 0=not storage combi, 1=primary
|
||||
# water store, 2=secondary store, 3=CPSU. Gates storage-combi rows in
|
||||
# Table 3b/3c (deferred until a fixture exercises).
|
||||
store_type: Optional[int]
|
||||
separate_dhw_tests: Optional[int]
|
||||
rejected_energy_proportion_r1: Optional[float]
|
||||
loss_factor_f1_kwh_per_day: Optional[float]
|
||||
loss_factor_f2_kwh_per_day: Optional[float]
|
||||
rejected_factor_f3_per_litre: Optional[float]
|
||||
raw: tuple[str, ...]
|
||||
|
||||
|
||||
_TABLE_HEADER_PREFIX: str = "$"
|
||||
_COMMENT_PREFIX: str = "#"
|
||||
_TABLE_105_HEADER_ID: str = "105"
|
||||
|
||||
|
||||
def _walk_table_records(dat_text: str, table_id: str) -> list[str]:
|
||||
"""Yield record rows inside the named PCDB table section.
|
||||
|
||||
The .dat file demarcates each table with a `$<id>,<format>,...` header
|
||||
on its own line. Records run from that header until the next `$<id>`
|
||||
header or end-of-input. `#`-prefixed lines are comments; blank lines
|
||||
are skipped too.
|
||||
"""
|
||||
inside_target_table = False
|
||||
rows: list[str] = []
|
||||
for raw_line in dat_text.splitlines():
|
||||
line = raw_line.rstrip("\r")
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith(_COMMENT_PREFIX):
|
||||
continue
|
||||
if stripped.startswith(_TABLE_HEADER_PREFIX):
|
||||
inside_target_table = stripped[1:].split(",", 1)[0] == table_id
|
||||
continue
|
||||
if inside_target_table:
|
||||
rows.append(line)
|
||||
return rows
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawPcdbRecord:
|
||||
"""Untyped PCDB record — pcdb_id keyed lookup + raw row for future
|
||||
per-table typed refinement. Used for tables (122/143/362/391/313/353/
|
||||
506) where field positions have not yet been ground-truth verified."""
|
||||
|
||||
pcdb_id: int
|
||||
raw: tuple[str, ...]
|
||||
|
||||
|
||||
def parse_table_raw(dat_text: str, table_id: str) -> list[RawPcdbRecord]:
|
||||
"""Generic positional walker: extract pcdb_id + raw row for any PCDB
|
||||
table, no per-field decoding. Future typed parsers (e.g. Table 362
|
||||
heat pumps) refine specific fields without changing this contract.
|
||||
"""
|
||||
rows = _walk_table_records(dat_text, table_id)
|
||||
return [
|
||||
RawPcdbRecord(pcdb_id=int(fields[0]), raw=fields)
|
||||
for row in rows
|
||||
for fields in (tuple(row.split(",")),)
|
||||
]
|
||||
|
||||
|
||||
def parse_table_105(dat_text: str) -> list[GasOilBoilerRecord]:
|
||||
"""Walk a PCDB dat string, yielding parsed Table 105 (Gas and Oil
|
||||
Boilers) records via `parse_table_105_row`."""
|
||||
return [parse_table_105_row(row) for row in _walk_table_records(dat_text, _TABLE_105_HEADER_ID)]
|
||||
|
||||
|
||||
def parse_table_105_row(row: str) -> GasOilBoilerRecord:
|
||||
"""Decode one Table 105 (Gas and Oil Boilers) record row into a typed
|
||||
record. Field positions (1-indexed): 1 pcdb_id, 6 brand_name,
|
||||
7 model_name, 8 model_qualifier, 11 final_year, 23 output_kw_max,
|
||||
26 winter_efficiency_pct, 27 summer_efficiency_pct, 29 comparative
|
||||
hot water efficiency. Trailing fields preserved verbatim in `raw`."""
|
||||
fields = tuple(row.rstrip("\r\n").split(","))
|
||||
return GasOilBoilerRecord(
|
||||
pcdb_id=int(fields[0]),
|
||||
brand_name=fields[5],
|
||||
model_name=fields[6],
|
||||
model_qualifier=fields[7],
|
||||
final_year_of_manufacture=_parse_optional_int(fields[10]),
|
||||
output_kw_max=_parse_optional_float(fields[22]),
|
||||
winter_efficiency_pct=_parse_optional_float(fields[25]),
|
||||
summer_efficiency_pct=_parse_optional_float(fields[26]),
|
||||
comparative_hot_water_efficiency_pct=_parse_optional_float(fields[28]),
|
||||
subsidiary_type=_parse_optional_int(fields[15]),
|
||||
store_type=_parse_optional_int(fields[38]),
|
||||
separate_dhw_tests=_parse_optional_int(fields[47]),
|
||||
rejected_energy_proportion_r1=_parse_optional_float(fields[50]),
|
||||
loss_factor_f1_kwh_per_day=_parse_optional_float(fields[51]),
|
||||
loss_factor_f2_kwh_per_day=_parse_optional_float(fields[55]),
|
||||
rejected_factor_f3_per_litre=_parse_optional_float(fields[56]),
|
||||
raw=fields,
|
||||
)
|
||||
130
domain/sap10_calculator/tables/pcdb/postcode_weather.py
Normal file
130
domain/sap10_calculator/tables/pcdb/postcode_weather.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""PCDB Table 172 — postcode-district weather data.
|
||||
|
||||
Per SAP 10.2 Appendix U (p.124): "Weather data for each postcode district
|
||||
are taken from the PCDB and are used when the postcode district is known;
|
||||
in other cases the data from Tables U1 to U4 are used." Table 172 is the
|
||||
PCDB delivery format. ~3138 districts × monthly (temp, wind, solar).
|
||||
|
||||
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`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Final, Optional
|
||||
|
||||
|
||||
_PCDB_DAT_PATH: Final[Path] = (
|
||||
Path(__file__).resolve().parent / "data" / "pcdb10.dat"
|
||||
)
|
||||
_TABLE_172_TAG: Final[str] = "$172"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PostcodeClimate:
|
||||
"""Per-postcode-district monthly weather. Months are Jan..Dec (12-tuples).
|
||||
|
||||
`region` is the fallback SAP climate region index (1-21) for this
|
||||
district — used when callers want to mix in region-only tables like
|
||||
U3.2 (solar transformations) that haven't been delivered per postcode.
|
||||
"""
|
||||
|
||||
area: str # e.g. "BD"
|
||||
district: int # e.g. 3
|
||||
region: int # SAP region 1-21 (for fallbacks)
|
||||
country: int # 1-5 country/jurisdiction code
|
||||
height_m: float # district elevation (m)
|
||||
latitude_deg: float # district centroid
|
||||
longitude_deg: float # district centroid
|
||||
monthly_external_temp_c: tuple[float, ...] # T(1..12) °C
|
||||
monthly_wind_speed_m_per_s: tuple[float, ...] # W(1..12) m/s
|
||||
monthly_horizontal_solar_w_per_m2: tuple[float, ...] # R(1..12) W/m²
|
||||
|
||||
|
||||
def _parse_table_172_rows(dat_text: str) -> dict[tuple[str, int], PostcodeClimate]:
|
||||
"""Parse Table 172 (Postcodes) rows from the PCDB data file text into a
|
||||
`{(area, district): PostcodeClimate}` lookup."""
|
||||
out: dict[tuple[str, int], PostcodeClimate] = {}
|
||||
in_table = False
|
||||
for line in dat_text.splitlines():
|
||||
if line.startswith(_TABLE_172_TAG):
|
||||
in_table = True
|
||||
continue
|
||||
if not in_table:
|
||||
continue
|
||||
if line.startswith("$"):
|
||||
break # next table starts
|
||||
if line.startswith("#") or not line.strip():
|
||||
continue
|
||||
parts = line.split(",")
|
||||
if len(parts) < 45:
|
||||
continue
|
||||
area = parts[0].strip().upper()
|
||||
try:
|
||||
district = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
temps = tuple(float(parts[9 + i]) for i in range(12))
|
||||
winds = tuple(float(parts[21 + i]) for i in range(12))
|
||||
solars = tuple(float(parts[33 + i]) for i in range(12))
|
||||
out[(area, district)] = PostcodeClimate(
|
||||
area=area,
|
||||
district=district,
|
||||
region=int(parts[3]),
|
||||
country=int(parts[4]),
|
||||
height_m=float(parts[6]),
|
||||
latitude_deg=float(parts[7]),
|
||||
longitude_deg=float(parts[8]),
|
||||
monthly_external_temp_c=temps,
|
||||
monthly_wind_speed_m_per_s=winds,
|
||||
monthly_horizontal_solar_w_per_m2=solars,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _postcode_climate_table() -> dict[tuple[str, int], PostcodeClimate]:
|
||||
"""Cached load of Table 172. Called lazily on first postcode lookup."""
|
||||
# PCDB delivery uses latin-1 (degree symbols, etc.) — not UTF-8.
|
||||
return _parse_table_172_rows(_PCDB_DAT_PATH.read_text(encoding="latin-1"))
|
||||
|
||||
|
||||
def _split_postcode(postcode: str) -> Optional[tuple[str, int]]:
|
||||
"""Split a UK postcode into (area, district). "BD3 7XY" → ("BD", 3),
|
||||
"bd19 3tf" → ("BD", 19). Returns None when the format is unrecognised.
|
||||
|
||||
UK postcode structure: outward = 1-2 letter area + 1-2 digit district,
|
||||
optionally followed by a letter (e.g. "EC1A"). For Table 172 the
|
||||
district sub-letter is dropped — only the numeric part is used."""
|
||||
if not postcode:
|
||||
return None
|
||||
outward = postcode.strip().split()[0].upper()
|
||||
i = 0
|
||||
while i < len(outward) and outward[i].isalpha():
|
||||
i += 1
|
||||
area = outward[:i]
|
||||
rest = outward[i:]
|
||||
j = 0
|
||||
while j < len(rest) and rest[j].isdigit():
|
||||
j += 1
|
||||
if not area or j == 0:
|
||||
return None
|
||||
return area, int(rest[:j])
|
||||
|
||||
|
||||
def postcode_climate(postcode: Optional[str]) -> Optional[PostcodeClimate]:
|
||||
"""Look up postcode-district weather from PCDB Table 172. Returns None
|
||||
when postcode is missing, format unrecognised, or district not in the
|
||||
table (callers fall back to Appendix U region tables)."""
|
||||
if postcode is None:
|
||||
return None
|
||||
key = _split_postcode(postcode)
|
||||
if key is None:
|
||||
return None
|
||||
return _postcode_climate_table().get(key)
|
||||
282
domain/sap10_calculator/tables/table_12.py
Normal file
282
domain/sap10_calculator/tables/table_12.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"""SAP 10.2 (14-03-2025 amendment) Table 12 — fuel prices, CO2 emission
|
||||
factors, primary energy factors.
|
||||
|
||||
Sourced verbatim from BRE, *The Government's Standard Assessment
|
||||
Procedure for Energy Rating of Dwellings, SAP 10.2* (14-03-2025), page
|
||||
189 (Table 12). Keys are the SAP 10.2/10.3 fuel code numbers — they
|
||||
remained stable across the 10.2 → 10.3 jump.
|
||||
|
||||
The calculator targets SAP 10.2 per ADR-0010 because no SAP-10.3-lodged
|
||||
certs exist in the corpus to validate against. SAP 10.3 differs from
|
||||
SAP 10.2 mainly on CO2 factors (grid electricity 0.136 → 0.086 kg/kWh,
|
||||
−37%; mains gas 0.210 → 0.214 kg/kWh, +2%); prices and primary energy
|
||||
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`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final, Optional
|
||||
|
||||
|
||||
# SAP 10.3 Table 12 — unit price in pence per kWh.
|
||||
UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = {
|
||||
# Gas fuels
|
||||
1: 3.64, # mains gas
|
||||
2: 6.74, # bulk LPG
|
||||
3: 9.46, # bottled LPG (main heating)
|
||||
5: 11.20, # bottled LPG (secondary)
|
||||
9: 3.64, # LPG SC11F
|
||||
7: 6.74, # biogas (including anaerobic digestion)
|
||||
# Liquid fuels
|
||||
4: 4.94, # heating oil
|
||||
71: 6.79, # bio-liquid HVO
|
||||
73: 6.79, # bio-liquid FAME
|
||||
75: 5.49, # B30K
|
||||
76: 47.0, # bioethanol
|
||||
# Solid fuels
|
||||
11: 5.58, # house coal
|
||||
15: 4.19, # anthracite
|
||||
12: 5.91, # manufactured smokeless fuel
|
||||
20: 5.12, # wood logs
|
||||
22: 6.91, # wood pellets (secondary)
|
||||
23: 6.25, # wood pellets (main)
|
||||
21: 3.72, # wood chips
|
||||
10: 4.77, # dual fuel
|
||||
# Electricity
|
||||
30: 16.49, # standard tariff
|
||||
32: 19.60, # 7-hour tariff (high rate)
|
||||
31: 9.40, # 7-hour tariff (low rate / off-peak)
|
||||
34: 20.54, # 10-hour tariff (high rate)
|
||||
33: 12.27, # 10-hour tariff (low rate)
|
||||
38: 17.41, # 18-hour tariff (high rate)
|
||||
40: 14.17, # 18-hour tariff (low rate)
|
||||
35: 14.04, # 24-hour heating tariff
|
||||
60: 5.59, # electricity sold to grid, PV
|
||||
36: 5.59, # electricity sold to grid, other
|
||||
# 39 "electricity, any tariff" carries N/A unit price — used only to
|
||||
# identify the fuel for a system; cost data comes from a paired
|
||||
# standard / off-peak code.
|
||||
# Heat networks
|
||||
51: 4.44, 52: 4.44, 53: 4.44, 54: 4.44,
|
||||
55: 4.44, 56: 4.44, 57: 4.44, 58: 4.44,
|
||||
41: 4.44, # heat from electric heat pump
|
||||
42: 4.44, # heat recovered from waste combustion
|
||||
43: 4.44, # heat from boilers - biomass
|
||||
44: 4.44, # heat from boilers - biogas
|
||||
45: 3.11, # high grade heat recovered from process
|
||||
46: 3.11, # heat recovered from geothermal / natural processes
|
||||
48: 3.11, # heat from CHP
|
||||
49: 3.11, # low grade heat recovered from process
|
||||
50: 0.0, # electricity for pumping in distribution network
|
||||
47: 3.11, # heat recovered from power station
|
||||
}
|
||||
_DEFAULT_P_PER_KWH: Final[float] = 3.64 # fall back to mains gas
|
||||
|
||||
|
||||
# SAP 10.2 Table 12 — annual-average CO2 emission factor in kg CO2-
|
||||
# equivalent per kWh of delivered energy. For ELECTRICITY end-uses,
|
||||
# Table 12d (above) overrides this annual factor with monthly values per
|
||||
# the spec text on p.194; the value here is the legacy fallback when
|
||||
# monthly distribution isn't available.
|
||||
# SAP 10.2 Table 12d (p.194) — monthly variation in CO2 emission factors
|
||||
# for electricity. The spec text: "Where electricity is the fuel used, the
|
||||
# relevant set of factors in the table below should be used to calculate
|
||||
# the monthly CO2 emissions INSTEAD of the annual average factor given in
|
||||
# Table 12." So for ratings, electricity end-uses use Σ(kWh_m × CO2_m)
|
||||
# rather than annual_kwh × annual_factor.
|
||||
CO2_KG_PER_KWH_MONTHLY: Final[dict[int, tuple[float, ...]]] = {
|
||||
# Standard tariff (default electricity)
|
||||
30: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# 7-hour tariff
|
||||
32: (0.171, 0.168, 0.161, 0.150, 0.138, 0.125, 0.117, 0.118, 0.128, 0.143, 0.158, 0.171),
|
||||
31: (0.143, 0.141, 0.135, 0.126, 0.116, 0.105, 0.098, 0.099, 0.107, 0.120, 0.133, 0.144),
|
||||
# 10-hour tariff
|
||||
34: (0.168, 0.165, 0.159, 0.148, 0.136, 0.124, 0.115, 0.116, 0.126, 0.141, 0.156, 0.168),
|
||||
33: (0.155, 0.153, 0.146, 0.137, 0.126, 0.114, 0.106, 0.107, 0.116, 0.130, 0.144, 0.155),
|
||||
# 18-hour tariff (matches standard tariff)
|
||||
38: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
40: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# 24-hour heating tariff
|
||||
35: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# Electricity sold to grid (PV)
|
||||
60: (0.196, 0.190, 0.175, 0.153, 0.129, 0.106, 0.092, 0.093, 0.110, 0.138, 0.169, 0.197),
|
||||
# Electricity sold to grid, other
|
||||
36: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# Electricity, any tariff
|
||||
39: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# Heat from electric heat pump
|
||||
41: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# Low-grade heat recovered from process
|
||||
49: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
# Electricity for pumping in distribution network
|
||||
50: (0.163, 0.160, 0.153, 0.143, 0.132, 0.120, 0.111, 0.112, 0.122, 0.136, 0.151, 0.163),
|
||||
}
|
||||
|
||||
|
||||
def co2_monthly_factors_kg_per_kwh(fuel_code: int | None) -> Optional[tuple[float, ...]]:
|
||||
"""SAP 10.2 Table 12d (p.194) monthly CO2 factors for electricity. Returns
|
||||
None for non-electricity fuels (use the annual `co2_factor_kg_per_kwh`)."""
|
||||
if fuel_code is None:
|
||||
return None
|
||||
if fuel_code in CO2_KG_PER_KWH_MONTHLY:
|
||||
return CO2_KG_PER_KWH_MONTHLY[fuel_code]
|
||||
return None
|
||||
|
||||
|
||||
# SAP 10.2 Table 12e (p.195) — monthly variation in PE (primary energy)
|
||||
# emission factors for electricity. Spec text: "Where electricity is the
|
||||
# fuel used, the relevant set of factors in the table below should be
|
||||
# used to calculate the monthly primary energy instead the annual average
|
||||
# factor given in Table 12." Same shape as Table 12d (CO2): electricity
|
||||
# end-uses use Σ(kWh_m × PE_m); gas/non-electricity fuels keep the
|
||||
# annual Table 12 PE factor.
|
||||
PE_FACTOR_MONTHLY: Final[dict[int, tuple[float, ...]]] = {
|
||||
# Standard tariff
|
||||
30: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
# 7-hour tariff
|
||||
32: (1.635, 1.626, 1.600, 1.562, 1.518, 1.471, 1.440, 1.443, 1.479, 1.535, 1.591, 1.637),
|
||||
31: (1.521, 1.512, 1.488, 1.453, 1.411, 1.368, 1.339, 1.342, 1.376, 1.428, 1.480, 1.522),
|
||||
# 10-hour tariff
|
||||
34: (1.625, 1.615, 1.590, 1.552, 1.507, 1.462, 1.430, 1.433, 1.470, 1.525, 1.580, 1.626),
|
||||
33: (1.571, 1.561, 1.537, 1.500, 1.457, 1.413, 1.382, 1.386, 1.421, 1.474, 1.528, 1.572),
|
||||
# 18-hour tariff (matches standard tariff)
|
||||
38: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
40: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
# 24-hour heating tariff
|
||||
35: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
# Electricity sold to grid (PV) — note (i): deducted, low PE factor
|
||||
60: (0.715, 0.697, 0.645, 0.567, 0.478, 0.389, 0.330, 0.336, 0.405, 0.513, 0.623, 0.718),
|
||||
# Electricity sold to grid, other
|
||||
36: (0.602, 0.593, 0.568, 0.530, 0.487, 0.441, 0.410, 0.413, 0.449, 0.504, 0.558, 0.604),
|
||||
# Electricity, any tariff
|
||||
39: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
# Heat from electric heat pump
|
||||
41: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
# Low-grade heat recovered from process
|
||||
49: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
# Electricity for pumping in distribution network
|
||||
50: (1.602, 1.593, 1.568, 1.530, 1.487, 1.441, 1.410, 1.413, 1.449, 1.504, 1.558, 1.604),
|
||||
}
|
||||
|
||||
|
||||
def pe_monthly_factors_kwh_per_kwh(
|
||||
fuel_code: int | None,
|
||||
) -> Optional[tuple[float, ...]]:
|
||||
"""SAP 10.2 Table 12e (p.195) monthly PE factors for electricity. Returns
|
||||
None for non-electricity fuels (use the annual `primary_energy_factor`)."""
|
||||
if fuel_code is None:
|
||||
return None
|
||||
if fuel_code in PE_FACTOR_MONTHLY:
|
||||
return PE_FACTOR_MONTHLY[fuel_code]
|
||||
return None
|
||||
|
||||
|
||||
CO2_KG_PER_KWH: Final[dict[int, float]] = {
|
||||
# Gas fuels
|
||||
1: 0.210,
|
||||
2: 0.241, 3: 0.241, 5: 0.241, 9: 0.241,
|
||||
7: 0.024,
|
||||
# Liquid fuels
|
||||
4: 0.298,
|
||||
71: 0.036, 73: 0.018,
|
||||
75: 0.214, 76: 0.105,
|
||||
# Solid fuels
|
||||
11: 0.395, 15: 0.395, 12: 0.366,
|
||||
20: 0.028, 22: 0.053, 23: 0.053, 21: 0.023,
|
||||
10: 0.087,
|
||||
# Electricity — all grid tariffs use the same annual-average CO2 factor.
|
||||
30: 0.136, 31: 0.136, 32: 0.136, 33: 0.136, 34: 0.136, 35: 0.136,
|
||||
38: 0.136, 40: 0.136, 39: 0.136, 60: 0.136, 36: 0.136,
|
||||
# Heat networks
|
||||
51: 0.210, 52: 0.241, 53: 0.298, 54: 0.375, 55: 0.269,
|
||||
56: 0.298, 57: 0.036, 58: 0.018,
|
||||
41: 0.136, 42: 0.015, 43: 0.029, 44: 0.024,
|
||||
45: 0.015, 46: 0.011, 47: 0.011, 48: 0.136, 49: 0.136,
|
||||
50: 0.0,
|
||||
}
|
||||
_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 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,
|
||||
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
|
||||
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
|
||||
26: 1, 27: 2, 28: 4, 29: 30,
|
||||
}
|
||||
|
||||
|
||||
def unit_price_p_per_kwh(fuel_code: int | None) -> float:
|
||||
"""Unit price (p/kWh) for the given fuel code. Accepts either a
|
||||
Table 12 code or a gov API main_fuel_type / water_heating_fuel
|
||||
enum; translates the latter via `API_FUEL_TO_TABLE_12`. Unknown →
|
||||
mains gas (3.64 p/kWh)."""
|
||||
if fuel_code is None:
|
||||
return _DEFAULT_P_PER_KWH
|
||||
if fuel_code in UNIT_PRICE_P_PER_KWH:
|
||||
return UNIT_PRICE_P_PER_KWH[fuel_code]
|
||||
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
|
||||
if translated is not None and translated in UNIT_PRICE_P_PER_KWH:
|
||||
return UNIT_PRICE_P_PER_KWH[translated]
|
||||
return _DEFAULT_P_PER_KWH
|
||||
|
||||
|
||||
# SAP 10.2 Table 12 "Primary energy factor" column. The cert's
|
||||
# `energy_consumption_current` field (PEUI) is delivered energy times
|
||||
# this factor per fuel, summed across end-uses, divided by TFA.
|
||||
PRIMARY_ENERGY_FACTOR: Final[dict[int, float]] = {
|
||||
# Gas
|
||||
1: 1.130,
|
||||
2: 1.141, 3: 1.141, 5: 1.133, 9: 1.163,
|
||||
7: 1.286,
|
||||
# Liquid
|
||||
4: 1.180,
|
||||
71: 1.180, 73: 1.180, 75: 1.136, 76: 1.472,
|
||||
# Solid
|
||||
11: 1.064, 15: 1.064, 12: 1.261, 20: 1.046,
|
||||
22: 1.325, 23: 1.325, 21: 1.046, 10: 1.049,
|
||||
# Electricity — all grid tariffs same PEF.
|
||||
30: 1.501, 31: 1.501, 32: 1.501, 33: 1.501, 34: 1.501, 35: 1.501,
|
||||
38: 1.501, 40: 1.501, 39: 1.501, 60: 0.501, 36: 0.501,
|
||||
# Heat networks (sample — main values; less common)
|
||||
51: 1.130, 52: 1.141, 53: 1.180, 54: 1.064, 55: 1.180,
|
||||
56: 1.180, 57: 1.180, 58: 1.180,
|
||||
41: 1.501, 42: 0.063, 43: 1.037, 44: 1.286,
|
||||
45: 0.051, 46: 0.051, 47: 0.063, 48: 1.501, 49: 1.501,
|
||||
50: 0.0,
|
||||
}
|
||||
_DEFAULT_PEF: Final[float] = 1.130 # mains gas baseline
|
||||
|
||||
|
||||
def primary_energy_factor(fuel_code: int | None) -> float:
|
||||
"""Primary energy factor for the given fuel code, accepting either
|
||||
Table 12 code or gov API enum (translated). Unknown → mains gas
|
||||
(1.13)."""
|
||||
if fuel_code is None:
|
||||
return _DEFAULT_PEF
|
||||
if fuel_code in PRIMARY_ENERGY_FACTOR:
|
||||
return PRIMARY_ENERGY_FACTOR[fuel_code]
|
||||
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
|
||||
if translated is not None and translated in PRIMARY_ENERGY_FACTOR:
|
||||
return PRIMARY_ENERGY_FACTOR[translated]
|
||||
return _DEFAULT_PEF
|
||||
|
||||
|
||||
def co2_factor_kg_per_kwh(fuel_code: int | None) -> float:
|
||||
"""CO2 emission factor (kg CO2e/kWh) for the given fuel code, with
|
||||
the same accept-either-API-or-Table-12-code translation as
|
||||
`unit_price_p_per_kwh`. Unknown → mains gas (0.214)."""
|
||||
if fuel_code is None:
|
||||
return _DEFAULT_CO2_KG_PER_KWH
|
||||
if fuel_code in CO2_KG_PER_KWH:
|
||||
return CO2_KG_PER_KWH[fuel_code]
|
||||
translated = API_FUEL_TO_TABLE_12.get(fuel_code)
|
||||
if translated is not None and translated in CO2_KG_PER_KWH:
|
||||
return CO2_KG_PER_KWH[translated]
|
||||
return _DEFAULT_CO2_KG_PER_KWH
|
||||
204
domain/sap10_calculator/tables/table_12a.py
Normal file
204
domain/sap10_calculator/tables/table_12a.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""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-
|
||||
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.
|
||||
|
||||
Two grids:
|
||||
- Grid 1: space + water heating systems × tariff → (SH_frac, WH_frac)
|
||||
- Grid 2: other electricity uses × tariff → fraction
|
||||
|
||||
For STANDARD tariff (no off-peak split) every lookup returns 1.0 —
|
||||
all consumption at the unit price.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Final
|
||||
|
||||
|
||||
class Table12aSystem(Enum):
|
||||
"""Table 12a row label (System column) for the space + water heating
|
||||
fractions grid. Each member maps to a row of PDF page 191. Three
|
||||
rows that require external sources (Electric CPSU → Appendix F;
|
||||
Immersion / HP-DHW-only → Table 13) are reachable via lookup but
|
||||
raise `NotImplementedError` until a fixture exercises them."""
|
||||
|
||||
INTEGRATED_STORAGE_DIRECT = "integrated_storage_direct"
|
||||
OTHER_STORAGE_HEATERS = "other_storage_heaters"
|
||||
ELECTRIC_DRY_CORE_OR_WATER_STORAGE = "electric_dry_core_or_water_storage"
|
||||
DIRECT_ACTING_ELECTRIC_BOILER = "direct_acting_electric_boiler"
|
||||
ELECTRIC_CPSU = "electric_cpsu"
|
||||
UNDERFLOOR_HEATING = "underfloor_heating"
|
||||
GSHP_APP_N = "gshp_app_n"
|
||||
GSHP_OTHER = "gshp_other"
|
||||
GSHP_OTHER_OFF_PEAK_IMMERSION = "gshp_other_off_peak_immersion"
|
||||
GSHP_OTHER_NO_IMMERSION = "gshp_other_no_immersion"
|
||||
ASHP_APP_N = "ashp_app_n"
|
||||
ASHP_OTHER = "ashp_other"
|
||||
ASHP_OTHER_OFF_PEAK_IMMERSION = "ashp_other_off_peak_immersion"
|
||||
ASHP_OTHER_NO_IMMERSION = "ashp_other_no_immersion"
|
||||
OTHER_DIRECT_ACTING_ELECTRIC = "other_direct_acting_electric"
|
||||
IMMERSION_OR_HP_DHW_ONLY = "immersion_or_hp_dhw_only"
|
||||
|
||||
|
||||
class OtherUse(Enum):
|
||||
"""Table 12a Grid 2 row label — "Other electricity uses" sub-table.
|
||||
Maps end-uses (pumps/fans/lighting/PV-credit) to their off-peak
|
||||
high-rate fraction. Pumps + lighting + locally-generated electricity
|
||||
use ALL_OTHER_USES; mechanical-ventilation fans use the dedicated
|
||||
FANS_FOR_MECH_VENT row."""
|
||||
|
||||
FANS_FOR_MECH_VENT = "fans_for_mech_vent"
|
||||
ALL_OTHER_USES = "all_other_uses"
|
||||
|
||||
|
||||
class Tariff(Enum):
|
||||
"""Electricity tariff column in Table 12a. TEN_HOUR is in the spec
|
||||
but unreachable from RdSAP10 cert flow (meter_type enum 1..5 has no
|
||||
10-hour code) — kept for worksheet-shape fidelity."""
|
||||
|
||||
STANDARD = "standard"
|
||||
SEVEN_HOUR = "7-hour"
|
||||
TEN_HOUR = "10-hour"
|
||||
EIGHTEEN_HOUR = "18-hour"
|
||||
TWENTY_FOUR_HOUR = "24-hour"
|
||||
|
||||
|
||||
# RdSAP cert `meter_type` integer enum → Table 12a tariff column.
|
||||
# String forms accepted by lower-casing + stripping.
|
||||
_METER_INT_TO_TARIFF: Final[dict[int, Tariff]] = {
|
||||
1: Tariff.SEVEN_HOUR, # Dual
|
||||
2: Tariff.STANDARD, # Single
|
||||
3: Tariff.STANDARD, # Unknown (per Q11b — spec-faithful)
|
||||
4: Tariff.TWENTY_FOUR_HOUR, # Dual (24 hour)
|
||||
5: Tariff.EIGHTEEN_HOUR, # Off-peak 18 hour
|
||||
}
|
||||
|
||||
_METER_STR_TO_INT: Final[dict[str, int]] = {
|
||||
"single": 2,
|
||||
"standard": 2,
|
||||
"dual": 1,
|
||||
"dual (24 hour)": 4,
|
||||
"off-peak 18 hour": 5,
|
||||
"unknown": 3,
|
||||
"": 3,
|
||||
}
|
||||
|
||||
|
||||
# Table 12a Grid 1 SH column — high-rate fraction by (system, tariff).
|
||||
# Only spec-listed (system, tariff) pairs appear; combos not in the
|
||||
# table raise NotImplementedError at lookup time. Sourced verbatim from
|
||||
# SAP10.2 PDF page 191.
|
||||
_SH_HIGH_RATE_FRACTION: Final[dict[tuple[Table12aSystem, Tariff], float]] = {
|
||||
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR): 0.20,
|
||||
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR): 0.00,
|
||||
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR): 0.00,
|
||||
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR): 0.00,
|
||||
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR): 0.90,
|
||||
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR): 0.50,
|
||||
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR): 0.90,
|
||||
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR): 0.50,
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR): 0.80,
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR): 0.80,
|
||||
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR): 0.70,
|
||||
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR): 0.60,
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR): 0.80,
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR): 0.80,
|
||||
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR): 0.90,
|
||||
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR): 0.60,
|
||||
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR): 1.00,
|
||||
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR): 0.50,
|
||||
}
|
||||
|
||||
|
||||
# Table 12a Grid 1 WH column. Only heat-pump WH rows carry off-peak
|
||||
# fractions in scope A; Electric CPSU (Appendix F) and immersion /
|
||||
# HP-DHW (Table 13) raise on lookup until those slices land.
|
||||
_WH_HIGH_RATE_FRACTION: Final[dict[tuple[Table12aSystem, Tariff], float]] = {
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR): 0.70,
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR): 0.70,
|
||||
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR): 0.17,
|
||||
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR): 0.17,
|
||||
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR): 0.70,
|
||||
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR): 0.70,
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR): 0.70,
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR): 0.70,
|
||||
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR): 0.17,
|
||||
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR): 0.17,
|
||||
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR): 0.70,
|
||||
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR): 0.70,
|
||||
}
|
||||
|
||||
|
||||
def water_heating_high_rate_fraction(
|
||||
system: Table12aSystem, tariff: Tariff
|
||||
) -> float:
|
||||
"""Table 12a Grid 1 WH column lookup. Returns the fraction of water-
|
||||
heating consumption billed at the high rate. STANDARD tariff → 1.0
|
||||
(passthrough). Heat-pump WH rows return spec fractions. Immersion /
|
||||
HP-DHW-only (Table 13) and Electric CPSU (Appendix F) raise."""
|
||||
if tariff is Tariff.STANDARD:
|
||||
return 1.0
|
||||
fraction = _WH_HIGH_RATE_FRACTION.get((system, tariff))
|
||||
if fraction is None:
|
||||
raise NotImplementedError((system, tariff))
|
||||
return fraction
|
||||
|
||||
|
||||
def space_heating_high_rate_fraction(
|
||||
system: Table12aSystem, tariff: Tariff
|
||||
) -> float:
|
||||
"""Table 12a Grid 1 SH column lookup. Returns the fraction of space-
|
||||
heating consumption billed at the high rate. STANDARD tariff has no
|
||||
off-peak split, so every system returns 1.0 (passthrough). Spec-
|
||||
listed off-peak (system, tariff) pairs return the published
|
||||
fraction; unlisted pairs (incl. Electric CPSU → Appendix F and
|
||||
immersion / HP-DHW → Table 13) raise."""
|
||||
if tariff is Tariff.STANDARD:
|
||||
return 1.0
|
||||
fraction = _SH_HIGH_RATE_FRACTION.get((system, tariff))
|
||||
if fraction is None:
|
||||
raise NotImplementedError((system, tariff))
|
||||
return fraction
|
||||
|
||||
|
||||
# Table 12a Grid 2 — "Other electricity uses" sub-table.
|
||||
_OTHER_USE_HIGH_RATE_FRACTION: Final[dict[tuple[OtherUse, Tariff], float]] = {
|
||||
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR): 0.71,
|
||||
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR): 0.58,
|
||||
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR): 0.90,
|
||||
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR): 0.80,
|
||||
}
|
||||
|
||||
|
||||
def other_use_high_rate_fraction(use: OtherUse, tariff: Tariff) -> float:
|
||||
"""Table 12a Grid 2 lookup — fraction of an "other electricity use"
|
||||
consumption billed at the high rate. STANDARD → 1.0. 18-hour /
|
||||
24-hour tariffs aren't in Grid 2; the spec implicitly applies the
|
||||
same logic via Grid 1 for those tariffs, so this lookup raises for
|
||||
them."""
|
||||
if tariff is Tariff.STANDARD:
|
||||
return 1.0
|
||||
fraction = _OTHER_USE_HIGH_RATE_FRACTION.get((use, tariff))
|
||||
if fraction is None:
|
||||
raise NotImplementedError((use, tariff))
|
||||
return fraction
|
||||
|
||||
|
||||
def tariff_from_meter_type(meter_type: object) -> Tariff:
|
||||
"""Resolve the RdSAP cert `meter_type` field to a Table 12a tariff
|
||||
column. Unknown / missing → STANDARD (no off-peak split applied)
|
||||
per the Q11b spec-faithful policy."""
|
||||
if meter_type is None:
|
||||
return Tariff.STANDARD
|
||||
if isinstance(meter_type, int):
|
||||
return _METER_INT_TO_TARIFF.get(meter_type, Tariff.STANDARD)
|
||||
if isinstance(meter_type, str):
|
||||
code = _METER_STR_TO_INT.get(meter_type.strip().lower())
|
||||
if code is None:
|
||||
return Tariff.STANDARD
|
||||
return _METER_INT_TO_TARIFF[code]
|
||||
return Tariff.STANDARD
|
||||
205
domain/sap10_calculator/tables/table_32.py
Normal file
205
domain/sap10_calculator/tables/table_32.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""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`,
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final, Optional
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
|
||||
|
||||
_DEFAULT_P_PER_KWH: Final[float] = 3.48 # fall back to mains gas
|
||||
|
||||
|
||||
# RdSAP10 Table 32 — unit price in pence per kWh, sourced verbatim from
|
||||
# PDF page 95.
|
||||
UNIT_PRICE_P_PER_KWH: Final[dict[int, float]] = {
|
||||
# Gas fuels
|
||||
1: 3.48, # mains gas
|
||||
2: 7.60, # bulk LPG
|
||||
3: 10.30, # bottled LPG (main heating)
|
||||
5: 12.19, # bottled LPG (secondary)
|
||||
9: 3.48, # LPG SC11F
|
||||
7: 7.60, # biogas (including anaerobic digestion)
|
||||
# Liquid fuels
|
||||
4: 7.64, # heating oil
|
||||
71: 7.64, # bio-liquid HVO
|
||||
73: 5.44, # bio-liquid FAME
|
||||
75: 6.10, # B30K
|
||||
76: 47.0, # bioethanol
|
||||
# Solid fuels
|
||||
11: 3.67, # house coal
|
||||
15: 3.64, # anthracite
|
||||
12: 4.61, # manufactured smokeless fuel
|
||||
20: 4.23, # wood logs
|
||||
22: 5.81, # wood pellets (secondary)
|
||||
23: 5.26, # wood pellets (main)
|
||||
21: 3.07, # wood chips
|
||||
10: 3.99, # dual fuel
|
||||
# Electricity
|
||||
30: 13.19, # standard tariff
|
||||
32: 15.29, # 7-hour tariff (high rate)
|
||||
31: 5.50, # 7-hour tariff (low rate / off-peak)
|
||||
34: 14.68, # 10-hour tariff (high rate)
|
||||
33: 7.50, # 10-hour tariff (low rate)
|
||||
38: 13.67, # 18-hour tariff (high rate)
|
||||
40: 7.41, # 18-hour tariff (low rate)
|
||||
35: 6.61, # 24-hour heating tariff
|
||||
60: 13.19, # electricity sold to grid, PV
|
||||
# Heat networks
|
||||
51: 4.24, 52: 4.24, 53: 4.24, 54: 4.24,
|
||||
55: 4.24, 56: 4.24, 57: 4.24, 58: 4.24,
|
||||
41: 4.24, # heat from electric heat pump
|
||||
42: 4.24, # heat recovered from waste combustion
|
||||
43: 4.24, # heat from boilers - biomass
|
||||
44: 4.24, # heat from boilers - biogas
|
||||
45: 2.97, # heat recovered from power station
|
||||
46: 2.97, # low grade heat recovered from process
|
||||
47: 2.97, # heat recovered from geothermal / natural
|
||||
48: 2.97, # heat from CHP
|
||||
49: 2.97, # high grade heat recovered from process
|
||||
}
|
||||
|
||||
|
||||
# Gov EPC API main_fuel_type / water_heating_fuel → RdSAP10 Table 32 fuel
|
||||
# code. Same shape as `table_12.API_FUEL_TO_TABLE_12` — the API enum is
|
||||
# unchanged across SAP10.2 ↔ RdSAP10.
|
||||
API_FUEL_TO_TABLE_32: Final[dict[int, int]] = {
|
||||
0: 30, 1: 1, 2: 2, 3: 3, 4: 4, 5: 15, 6: 20, 7: 23, 8: 21, 9: 10,
|
||||
10: 30, 11: 42, 12: 43, 13: 44, 14: 11, 15: 12, 16: 22, 17: 9,
|
||||
18: 75, 19: 76, 20: 51, 21: 52, 22: 53, 23: 55, 24: 54, 25: 41,
|
||||
26: 1, 27: 2, 28: 4, 29: 30,
|
||||
}
|
||||
|
||||
|
||||
# RdSAP10 Table 32 — annual standing charge in £/yr per Table 32 fuel
|
||||
# code. Only fuels with a published standing charge appear here;
|
||||
# unlisted codes default to £0/yr. Application of these charges to
|
||||
# (251) is gated by Table 12 note (a).
|
||||
STANDING_CHARGE_GBP_PER_YR: Final[dict[int, float]] = {
|
||||
# Gas fuels
|
||||
1: 120.0, # mains gas
|
||||
2: 70.0, # bulk LPG
|
||||
9: 120.0, # LPG SC11F
|
||||
7: 70.0, # biogas
|
||||
# Electricity (high-rate codes carry the off-peak meter standing)
|
||||
30: 54.0, # standard tariff
|
||||
32: 24.0, # 7-hour high rate
|
||||
34: 23.0, # 10-hour high rate
|
||||
38: 40.0, # 18-hour high rate
|
||||
35: 70.0, # 24-hour heating tariff
|
||||
# Heat networks — Table 32 note (l): include half (£60/yr) if only
|
||||
# DHW provided by heat network. Raw row carries £120/yr.
|
||||
51: 120.0,
|
||||
}
|
||||
|
||||
|
||||
def unit_price_p_per_kwh(fuel_code: Optional[int]) -> float:
|
||||
"""Unit price (p/kWh) for the given fuel code. Accepts either a
|
||||
Table 32 code or a gov API `main_fuel_type` / `water_heating_fuel`
|
||||
enum; translates the latter via `API_FUEL_TO_TABLE_32`. Unknown →
|
||||
mains gas (3.48 p/kWh)."""
|
||||
if fuel_code is None:
|
||||
return _DEFAULT_P_PER_KWH
|
||||
if fuel_code in UNIT_PRICE_P_PER_KWH:
|
||||
return UNIT_PRICE_P_PER_KWH[fuel_code]
|
||||
translated = API_FUEL_TO_TABLE_32.get(fuel_code)
|
||||
if translated is not None and translated in UNIT_PRICE_P_PER_KWH:
|
||||
return UNIT_PRICE_P_PER_KWH[translated]
|
||||
return _DEFAULT_P_PER_KWH
|
||||
|
||||
|
||||
def standing_charge_gbp(fuel_code: Optional[int]) -> float:
|
||||
"""Annual standing charge (£/yr) for the given Table 32 fuel code.
|
||||
Fuels without a published standing charge return 0.0. Application
|
||||
to (251) is gated by `additional_standing_charges_gbp` per Table 12
|
||||
note (a)."""
|
||||
if fuel_code is None:
|
||||
return 0.0
|
||||
if fuel_code in STANDING_CHARGE_GBP_PER_YR:
|
||||
return STANDING_CHARGE_GBP_PER_YR[fuel_code]
|
||||
# Only translate via API enum when fuel_code isn't already a known
|
||||
# Table 32 code — wood logs (Table 32 code 20) collides with the API
|
||||
# enum value 20 (heat networks) and must not be translated.
|
||||
if fuel_code in UNIT_PRICE_P_PER_KWH:
|
||||
return 0.0
|
||||
translated = API_FUEL_TO_TABLE_32.get(fuel_code)
|
||||
if translated is not None and translated in STANDING_CHARGE_GBP_PER_YR:
|
||||
return STANDING_CHARGE_GBP_PER_YR[translated]
|
||||
return 0.0
|
||||
|
||||
|
||||
# Gas Table 32 codes (after API enum translation).
|
||||
_GAS_FUEL_CODES: Final[frozenset[int]] = frozenset({1, 2, 3, 5, 9, 7})
|
||||
|
||||
# Electricity Table 32 codes (after API enum translation).
|
||||
_ELECTRIC_FUEL_CODES: Final[frozenset[int]] = frozenset(
|
||||
{30, 31, 32, 33, 34, 35, 38, 40, 60}
|
||||
)
|
||||
|
||||
# Off-peak tariff → high-rate Table 32 code (the row carrying the
|
||||
# off-peak meter standing per Table 32 PDF page 95).
|
||||
_OFF_PEAK_STANDING_CODE: Final[dict[Tariff, int]] = {
|
||||
Tariff.SEVEN_HOUR: 32,
|
||||
Tariff.TEN_HOUR: 34,
|
||||
Tariff.EIGHTEEN_HOUR: 38,
|
||||
Tariff.TWENTY_FOUR_HOUR: 35,
|
||||
}
|
||||
|
||||
|
||||
def _to_table_32_code(fuel_code: Optional[int]) -> Optional[int]:
|
||||
"""Normalise a fuel code (Table 32 or API enum) to its Table 32 form."""
|
||||
if fuel_code is None:
|
||||
return None
|
||||
if fuel_code in UNIT_PRICE_P_PER_KWH:
|
||||
return fuel_code
|
||||
return API_FUEL_TO_TABLE_32.get(fuel_code)
|
||||
|
||||
|
||||
def _is_gas_code(fuel_code: Optional[int]) -> bool:
|
||||
code = _to_table_32_code(fuel_code)
|
||||
return code is not None and code in _GAS_FUEL_CODES
|
||||
|
||||
|
||||
def _is_electric_code(fuel_code: Optional[int]) -> bool:
|
||||
code = _to_table_32_code(fuel_code)
|
||||
return code is not None and code in _ELECTRIC_FUEL_CODES
|
||||
|
||||
|
||||
def additional_standing_charges_gbp(
|
||||
*,
|
||||
main_fuel_code: Optional[int],
|
||||
water_heating_fuel_code: Optional[int],
|
||||
tariff: Tariff,
|
||||
) -> float:
|
||||
"""SAP rating (regulated) standing-charge total for (251), gated per
|
||||
Table 12 note (a):
|
||||
|
||||
- Std electricity standing → omitted
|
||||
- Off-peak electricity standing → added if either main heating or
|
||||
hot water uses off-peak electricity. Standing lives on the high-
|
||||
rate Table 32 code for the tariff in use.
|
||||
- Gas standing → added if gas is used for space (main or secondary)
|
||||
or water heating.
|
||||
"""
|
||||
total = 0.0
|
||||
if _is_gas_code(main_fuel_code) or _is_gas_code(water_heating_fuel_code):
|
||||
# Pick whichever gas code is in use, preferring main heating.
|
||||
gas_code = main_fuel_code if _is_gas_code(main_fuel_code) else water_heating_fuel_code
|
||||
total += standing_charge_gbp(gas_code)
|
||||
if tariff is not Tariff.STANDARD and (
|
||||
_is_electric_code(main_fuel_code) or _is_electric_code(water_heating_fuel_code)
|
||||
):
|
||||
off_peak_code = _OFF_PEAK_STANDING_CODE.get(tariff)
|
||||
if off_peak_code is not None:
|
||||
total += standing_charge_gbp(off_peak_code)
|
||||
return total
|
||||
0
domain/sap10_calculator/tests/__init__.py
Normal file
0
domain/sap10_calculator/tests/__init__.py
Normal file
360
domain/sap10_calculator/tests/test_bre_worked_examples.py
Normal file
360
domain/sap10_calculator/tests/test_bre_worked_examples.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
"""SAP 10.2 worksheet trace — comprehensive `intermediate` lock for a
|
||||
synthetic baseline dwelling.
|
||||
|
||||
**Provenance.** The SAP 10.2 worksheet template (pages 131–148 of
|
||||
domain/sap10_calculator/docs/specs/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
|
||||
surface later, only the expected numbers need updating, not this file's
|
||||
structure.
|
||||
|
||||
**Scope.** Locks every key currently published on `SapResult.intermediate`
|
||||
to the matching worksheet item, plus the top-level rating/emission/PE
|
||||
fields that mirror items (257)/(272)/(286)/(287). Per-month decomposition
|
||||
((38)_m, (66)_m, (74)_m–(82)_m, etc.) lives on `result.monthly` and is
|
||||
checked elsewhere; this file is the annual/aggregate trace.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.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 (
|
||||
mean_internal_temperature_monthly,
|
||||
)
|
||||
from domain.sap10_calculator.worksheet.space_heating import space_heating_monthly_kwh
|
||||
|
||||
|
||||
def _baseline_dwelling() -> CalculatorInputs:
|
||||
"""Synthetic 100 m² semi-detached gas-boiler dwelling, UK-average
|
||||
climate. Mirrors the baseline used in `tests/test_calculator.py` so
|
||||
both trace suites exercise the same canonical fixture. All values
|
||||
chosen to land near a real RdSAP cert (HLC ~150 W/K, τ ~100 h,
|
||||
SAP ~70)."""
|
||||
dim = Dimensions(
|
||||
total_floor_area_m2=100.0,
|
||||
volume_m3=250.0,
|
||||
storey_count=2,
|
||||
avg_storey_height_m=2.5,
|
||||
ground_floor_area_m2=50.0,
|
||||
ground_floor_perimeter_m=30.0,
|
||||
top_floor_area_m2=50.0,
|
||||
gross_wall_area_m2=150.0,
|
||||
party_wall_area_m2=50.0,
|
||||
)
|
||||
ht = HeatTransmission(
|
||||
walls_w_per_k=60.0,
|
||||
roof_w_per_k=20.0,
|
||||
floor_w_per_k=20.0,
|
||||
party_walls_w_per_k=0.0,
|
||||
windows_w_per_k=25.0,
|
||||
roof_windows_w_per_k=0.0,
|
||||
doors_w_per_k=5.0,
|
||||
thermal_bridging_w_per_k=20.0,
|
||||
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
|
||||
total_external_element_area_m2=200.0, # synthetic placeholder
|
||||
total_w_per_k=150.0,
|
||||
)
|
||||
internal_gains_monthly_w = (450.0,) * 12
|
||||
solar_gains_monthly_w = (
|
||||
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
|
||||
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
|
||||
)
|
||||
ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13))
|
||||
total_gains_monthly_w = tuple(
|
||||
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
|
||||
)
|
||||
htc_monthly_w_per_k = tuple(
|
||||
ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12)
|
||||
)
|
||||
mit_result = mean_internal_temperature_monthly(
|
||||
monthly_external_temp_c=ext_temp_monthly_c,
|
||||
monthly_total_gains_w=total_gains_monthly_w,
|
||||
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
|
||||
thermal_mass_parameter_kj_per_m2_k=250.0,
|
||||
total_floor_area_m2=dim.total_floor_area_m2,
|
||||
control_type=2,
|
||||
responsiveness=1.0,
|
||||
living_area_fraction=0.30,
|
||||
)
|
||||
space_heating_result = space_heating_monthly_kwh(
|
||||
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
|
||||
monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly,
|
||||
monthly_external_temperature_c=ext_temp_monthly_c,
|
||||
monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly,
|
||||
monthly_total_gains_w=total_gains_monthly_w,
|
||||
total_floor_area_m2=dim.total_floor_area_m2,
|
||||
)
|
||||
return CalculatorInputs(
|
||||
dimensions=dim,
|
||||
heat_transmission=ht,
|
||||
monthly_infiltration_ach=(0.7,) * 12,
|
||||
internal_gains_monthly_w=internal_gains_monthly_w,
|
||||
solar_gains_monthly_w=solar_gains_monthly_w,
|
||||
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
|
||||
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
|
||||
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
|
||||
region=0,
|
||||
control_type=2,
|
||||
responsiveness=1.0,
|
||||
living_area_fraction=0.30,
|
||||
control_temperature_adjustment_c=0.0,
|
||||
thermal_mass_parameter_kj_per_m2_k=250.0,
|
||||
main_heating_efficiency=0.85,
|
||||
hot_water_kwh_per_yr=2400.0,
|
||||
pumps_fans_kwh_per_yr=100.0,
|
||||
lighting_kwh_per_yr=600.0,
|
||||
space_heating_fuel_cost_gbp_per_kwh=0.07,
|
||||
hot_water_fuel_cost_gbp_per_kwh=0.07,
|
||||
other_fuel_cost_gbp_per_kwh=0.07,
|
||||
co2_factor_kg_per_kwh=0.21,
|
||||
)
|
||||
|
||||
|
||||
def test_baseline_dwelling_worksheet_trace() -> None:
|
||||
# Arrange — see module docstring for provenance. The baseline is a
|
||||
# 100 m² gas-boiler dwelling matching tests/test_calculator.py.
|
||||
inputs = _baseline_dwelling()
|
||||
ht = inputs.heat_transmission
|
||||
tfa = inputs.dimensions.total_floor_area_m2
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
inter = result.intermediate
|
||||
|
||||
# Assert — §1 Dimensions ----------------------------------------------
|
||||
# (4) Total floor area = Σ(1a..1n)
|
||||
assert inter["tfa_m2"] == pytest.approx(100.0, rel=1e-12)
|
||||
# (5) Dwelling volume = Σ(3a..3n)
|
||||
assert inter["volume_m3"] == pytest.approx(250.0, rel=1e-12)
|
||||
# (9) Number of storeys n_s — exposed as a float for trace uniformity
|
||||
assert inter["storey_count"] == pytest.approx(2.0, rel=1e-12)
|
||||
|
||||
# Assert — §3 Heat transmission (A×U per element) ---------------------
|
||||
# (29a) external wall A×U
|
||||
assert inter["walls_w_per_k"] == pytest.approx(60.0, rel=1e-12)
|
||||
# (30) roof A×U
|
||||
assert inter["roof_w_per_k"] == pytest.approx(20.0, rel=1e-12)
|
||||
# (28a)/(28b) floor A×U (ground/exposed combined here)
|
||||
assert inter["floor_w_per_k"] == pytest.approx(20.0, rel=1e-12)
|
||||
# (32) party wall A×U
|
||||
assert inter["party_walls_w_per_k"] == pytest.approx(0.0, rel=1e-12)
|
||||
# (27) window A×U (effective U per §3.2)
|
||||
assert inter["windows_w_per_k"] == pytest.approx(25.0, rel=1e-12)
|
||||
# (26)+(26a) door A×U
|
||||
assert inter["doors_w_per_k"] == pytest.approx(5.0, rel=1e-12)
|
||||
# (36) linear thermal bridges Σ(L×Ψ); calculator uses the y-factor
|
||||
# shortcut (36) = 0.20 × (31) per the spec's default-bridging clause
|
||||
assert inter["thermal_bridging_w_per_k"] == pytest.approx(20.0, rel=1e-12)
|
||||
|
||||
# Assert — §2 Ventilation ---------------------------------------------
|
||||
# (16) infiltration rate (sheltered) — input air-changes-per-hour
|
||||
assert inter["infiltration_ach"] == pytest.approx(0.7, rel=1e-12)
|
||||
# (38)/m equivalent in W/K: 0.33 × ach × volume — annual-equivalent
|
||||
# value (calculator carries the same scalar across months when wind
|
||||
# adjustment is disabled in trace mode)
|
||||
expected_infiltration_w_per_k = 0.33 * 0.7 * 250.0
|
||||
assert inter["infiltration_w_per_k"] == pytest.approx(
|
||||
expected_infiltration_w_per_k, rel=1e-9
|
||||
)
|
||||
|
||||
# Assert — §3 aggregates ----------------------------------------------
|
||||
# (37) total fabric heat loss = Σ(26..32) + (36) + (36a). With the
|
||||
# baseline's HT.total_w_per_k already summing element-level A×U,
|
||||
# (37) equals that sum.
|
||||
fabric_plus_bridges_w_per_k = ht.total_w_per_k # (33) + (36) effectively
|
||||
# (39) Heat transfer coefficient HTC = (37) + (38)/m
|
||||
expected_htc = fabric_plus_bridges_w_per_k + expected_infiltration_w_per_k
|
||||
assert inter["heat_transfer_coefficient_w_per_k"] == pytest.approx(
|
||||
expected_htc, rel=1e-9
|
||||
)
|
||||
# (40) Heat loss parameter HLP = (39) / (4)
|
||||
expected_hlp = expected_htc / tfa
|
||||
assert inter["heat_loss_parameter_w_per_m2k"] == pytest.approx(
|
||||
expected_hlp, rel=1e-9
|
||||
)
|
||||
# τ time constant — Table 9b: τ_hours = TMP × TFA / (3.6 × HTC).
|
||||
# The 3.6 converts kJ → W·h (TMP is kJ/m²·K, HTC is W/K).
|
||||
expected_tau_h = (
|
||||
inputs.thermal_mass_parameter_kj_per_m2_k * tfa / (3.6 * expected_htc)
|
||||
)
|
||||
assert inter["time_constant_h"] == pytest.approx(expected_tau_h, rel=1e-9)
|
||||
|
||||
# Assert — §5 internal gains + §7 mean internal temp (annual averages)
|
||||
# (73) total internal gains; (93) adjusted mean internal temp.
|
||||
# Both are positive for a heated dwelling at UK-average climate; exact
|
||||
# values depend on Table 5 monthly profiles, so locked by sanity bound
|
||||
# not a closed-form expression.
|
||||
assert inter["internal_gains_annual_avg_w"] > 0
|
||||
assert 17.0 < inter["mean_internal_temp_annual_avg_c"] < 21.0
|
||||
|
||||
# Assert — §8 Space heating -------------------------------------------
|
||||
# (98c) total space heating requirement kWh/yr — exposed as the
|
||||
# top-level SapResult.space_heating_kwh_per_yr too. Positive for a
|
||||
# heated dwelling; the test_calculator suite already locks the chain
|
||||
# via direction tests (zero-HTC, doubled-efficiency, colder-region).
|
||||
assert inter["useful_space_heating_kwh_per_yr"] == pytest.approx(
|
||||
result.space_heating_kwh_per_yr, rel=1e-12
|
||||
)
|
||||
assert inter["useful_space_heating_kwh_per_yr"] > 0
|
||||
|
||||
# Assert — §10/12 Fuel costs per end-use ------------------------------
|
||||
# (240e), (242e), (247), (249-split), (250) — per-end-use costs in £/yr.
|
||||
expected_main_cost = (
|
||||
result.main_heating_fuel_kwh_per_yr
|
||||
* inputs.space_heating_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
assert inter["main_heating_cost_gbp"] == pytest.approx(
|
||||
expected_main_cost, rel=1e-9
|
||||
)
|
||||
# (242e) secondary-heating cost — zero for baseline (no secondary)
|
||||
assert inter["secondary_heating_cost_gbp"] == pytest.approx(0.0, abs=1e-9)
|
||||
# (247) water heating cost
|
||||
expected_hw_cost = (
|
||||
inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
assert inter["hot_water_cost_gbp"] == pytest.approx(
|
||||
expected_hw_cost, rel=1e-9
|
||||
)
|
||||
# (249) split — pumps/fans at "other" tariff
|
||||
expected_pf_cost = (
|
||||
inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
assert inter["pumps_fans_cost_gbp"] == pytest.approx(
|
||||
expected_pf_cost, rel=1e-9
|
||||
)
|
||||
# (250) lighting cost at "other" tariff
|
||||
expected_light_cost = (
|
||||
inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
assert inter["lighting_cost_gbp"] == pytest.approx(
|
||||
expected_light_cost, rel=1e-9
|
||||
)
|
||||
# (252) PV export credit — zero for baseline (no PV)
|
||||
assert inter["pv_export_credit_gbp"] == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
# Assert — §13 Energy cost rating -------------------------------------
|
||||
# (256) energy cost deflator (Table 12 = 0.42 per SAP 10.2).
|
||||
assert inter["deflator"] == pytest.approx(0.42, rel=1e-12)
|
||||
# (257) ECF = [(255) × (256)] / [(4) + 45.0]
|
||||
floor_area_offset_m2 = 45.0 # baked into (257) formula
|
||||
expected_ecf = (
|
||||
result.total_fuel_cost_gbp * 0.42 / (tfa + floor_area_offset_m2)
|
||||
)
|
||||
assert inter["ecf"] == pytest.approx(expected_ecf, rel=1e-9)
|
||||
assert inter["ecf"] == pytest.approx(result.ecf, rel=1e-12)
|
||||
# The 45 m² offset and ECF=3.5 log/linear regime boundary — spec
|
||||
# constants from §13 worksheet (257) and equations (8)/(9)
|
||||
assert inter["floor_area_offset_m2"] == pytest.approx(45.0, rel=1e-12)
|
||||
assert inter["ecf_log_threshold"] == pytest.approx(3.5, rel=1e-12)
|
||||
|
||||
# Assert — §14 CO2 emissions (per end-use; aggregate is (272)) --------
|
||||
# (261) main heating CO2 = (211) × emission factor
|
||||
assert inter["co2_factor_kg_per_kwh"] == pytest.approx(
|
||||
inputs.co2_factor_kg_per_kwh, rel=1e-12
|
||||
)
|
||||
assert inter["main_heating_co2_kg_per_yr"] == pytest.approx(
|
||||
result.main_heating_fuel_kwh_per_yr * inputs.co2_factor_kg_per_kwh,
|
||||
rel=1e-9,
|
||||
)
|
||||
# (263) secondary CO2; (264) hot water; (267) pumps/fans; (268) lighting
|
||||
assert inter["secondary_heating_co2_kg_per_yr"] == pytest.approx(
|
||||
result.secondary_heating_fuel_kwh_per_yr
|
||||
* inputs.co2_factor_kg_per_kwh,
|
||||
rel=1e-9,
|
||||
)
|
||||
assert inter["hot_water_co2_kg_per_yr"] == pytest.approx(
|
||||
inputs.hot_water_kwh_per_yr * inputs.co2_factor_kg_per_kwh, rel=1e-9
|
||||
)
|
||||
assert inter["pumps_fans_co2_kg_per_yr"] == pytest.approx(
|
||||
inputs.pumps_fans_kwh_per_yr * inputs.co2_factor_kg_per_kwh, rel=1e-9
|
||||
)
|
||||
assert inter["lighting_co2_kg_per_yr"] == pytest.approx(
|
||||
inputs.lighting_kwh_per_yr * inputs.co2_factor_kg_per_kwh, rel=1e-9
|
||||
)
|
||||
# (272) total CO2 — sum of the five end-use components reconciles
|
||||
# with the top-level co2_kg_per_yr field.
|
||||
co2_sum = (
|
||||
inter["main_heating_co2_kg_per_yr"]
|
||||
+ inter["secondary_heating_co2_kg_per_yr"]
|
||||
+ inter["hot_water_co2_kg_per_yr"]
|
||||
+ inter["pumps_fans_co2_kg_per_yr"]
|
||||
+ inter["lighting_co2_kg_per_yr"]
|
||||
)
|
||||
assert co2_sum == pytest.approx(result.co2_kg_per_yr, rel=1e-9)
|
||||
# (238) delivered fuel kWh/yr — the input to (272)/(286) chains
|
||||
expected_delivered = (
|
||||
result.main_heating_fuel_kwh_per_yr
|
||||
+ result.secondary_heating_fuel_kwh_per_yr
|
||||
+ result.hot_water_kwh_per_yr
|
||||
+ result.pumps_fans_kwh_per_yr
|
||||
+ result.lighting_kwh_per_yr
|
||||
)
|
||||
assert inter["delivered_fuel_kwh_per_yr"] == pytest.approx(
|
||||
expected_delivered, rel=1e-9
|
||||
)
|
||||
|
||||
# Assert — §14 Primary energy per end-use, per m² ---------------------
|
||||
# (275)+(276) space heating PE / (4); (278) hot water PE / (4);
|
||||
# (281)+(282) other PE / (4); (283)/(4) PV PE offset.
|
||||
expected_sh_pe = (
|
||||
(result.main_heating_fuel_kwh_per_yr
|
||||
+ result.secondary_heating_fuel_kwh_per_yr)
|
||||
* inputs.space_heating_primary_factor
|
||||
/ tfa
|
||||
)
|
||||
expected_hw_pe = (
|
||||
inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor / tfa
|
||||
)
|
||||
expected_other_pe = (
|
||||
(inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr)
|
||||
* inputs.other_primary_factor
|
||||
/ tfa
|
||||
)
|
||||
expected_pv_pe_offset = (
|
||||
inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor / tfa
|
||||
)
|
||||
assert inter["space_heating_pe_kwh_per_m2"] == pytest.approx(
|
||||
expected_sh_pe, rel=1e-9
|
||||
)
|
||||
assert inter["hot_water_pe_kwh_per_m2"] == pytest.approx(
|
||||
expected_hw_pe, rel=1e-9
|
||||
)
|
||||
assert inter["other_pe_kwh_per_m2"] == pytest.approx(
|
||||
expected_other_pe, rel=1e-9
|
||||
)
|
||||
assert inter["pv_pe_offset_kwh_per_m2"] == pytest.approx(
|
||||
expected_pv_pe_offset, rel=1e-9
|
||||
)
|
||||
# (287) dwelling PE rate = (286) / (4). Reconciled via the per-m²
|
||||
# components, floored at 0 (Appendix M PV offset cannot drive PE
|
||||
# negative).
|
||||
expected_total_pe_per_m2 = max(
|
||||
0.0,
|
||||
expected_sh_pe
|
||||
+ expected_hw_pe
|
||||
+ expected_other_pe
|
||||
- expected_pv_pe_offset,
|
||||
)
|
||||
assert result.primary_energy_kwh_per_m2 == pytest.approx(
|
||||
expected_total_pe_per_m2, rel=1e-9
|
||||
)
|
||||
|
||||
# Assert — top-level rating sanity ------------------------------------
|
||||
# (258) SAP rating — integer-clamped via §13 equations (8)/(9). Tests
|
||||
# in test_calculator already lock the curve direction (higher
|
||||
# efficiency, colder region, doubled HTC). Here we just sanity-check
|
||||
# the integer/continuous pair stays consistent for the baseline.
|
||||
assert 1 <= result.sap_score <= 100
|
||||
assert abs(round(result.sap_score_continuous) - result.sap_score) <= 1
|
||||
667
domain/sap10_calculator/tests/test_calculator.py
Normal file
667
domain/sap10_calculator/tests/test_calculator.py
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
"""Tests for the synthetic-input Sap10 calculator orchestrator.
|
||||
|
||||
The orchestrator drives SAP 10.3's 12-month heat-balance loop from a
|
||||
`CalculatorInputs` aggregate (geometry, envelope, ventilation, climate,
|
||||
heating + the running-cost lines hot-water/pumps-fans/lighting). It
|
||||
returns a typed `SapResult` carrying the SAP score, the cost/CO2 totals,
|
||||
and a 12-entry `monthly` breakdown so downstream consumers can audit
|
||||
month-by-month physics.
|
||||
|
||||
Tests use synthetic inputs (not cert-derived) so that orchestration
|
||||
behaviour is verified independently of the cert→inputs mapper (S-A7b).
|
||||
|
||||
Reference: SAP 10.3 specification (13-01-2026) §§5-13 + Table 9c (the
|
||||
worksheet step list) + Table 12 (Energy Cost Deflator 0.36).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.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 (
|
||||
mean_internal_temperature_monthly,
|
||||
)
|
||||
from domain.sap10_calculator.worksheet.space_heating import space_heating_monthly_kwh
|
||||
|
||||
|
||||
def _baseline_inputs() -> CalculatorInputs:
|
||||
"""Reference dwelling for orchestrator tests — a 100 m² semi-detached
|
||||
gas-boiler home in UK-average climate. Numbers chosen to land roughly
|
||||
where a real RdSAP cert would: HLC ~150 W/K, τ ~100 h, SAP ~70."""
|
||||
dim = Dimensions(
|
||||
total_floor_area_m2=100.0,
|
||||
volume_m3=250.0,
|
||||
storey_count=2,
|
||||
avg_storey_height_m=2.5,
|
||||
ground_floor_area_m2=50.0,
|
||||
ground_floor_perimeter_m=30.0,
|
||||
top_floor_area_m2=50.0,
|
||||
gross_wall_area_m2=150.0,
|
||||
party_wall_area_m2=50.0,
|
||||
)
|
||||
ht = HeatTransmission(
|
||||
walls_w_per_k=60.0,
|
||||
roof_w_per_k=20.0,
|
||||
floor_w_per_k=20.0,
|
||||
party_walls_w_per_k=0.0,
|
||||
windows_w_per_k=25.0,
|
||||
roof_windows_w_per_k=0.0,
|
||||
doors_w_per_k=5.0,
|
||||
thermal_bridging_w_per_k=20.0,
|
||||
fabric_heat_loss_w_per_k=130.0, # 60+20+20+0+25+5
|
||||
total_external_element_area_m2=200.0, # synthetic placeholder
|
||||
total_w_per_k=150.0,
|
||||
)
|
||||
internal_gains_monthly_w = (450.0,) * 12
|
||||
solar_gains_monthly_w = (
|
||||
70.1510, 118.4419, 161.4420, 202.5589, 231.7608, 232.9177,
|
||||
223.3279, 200.6543, 175.3023, 130.5274, 83.7805, 60.2212,
|
||||
)
|
||||
ext_temp_monthly_c = tuple(external_temperature_c(0, m) for m in range(1, 13))
|
||||
total_gains_monthly_w = tuple(
|
||||
internal_gains_monthly_w[m] + solar_gains_monthly_w[m] for m in range(12)
|
||||
)
|
||||
htc_monthly_w_per_k = tuple(
|
||||
ht.total_w_per_k + 0.33 * dim.volume_m3 * 0.7 for _ in range(12)
|
||||
)
|
||||
mit_result = mean_internal_temperature_monthly(
|
||||
monthly_external_temp_c=ext_temp_monthly_c,
|
||||
monthly_total_gains_w=total_gains_monthly_w,
|
||||
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
|
||||
thermal_mass_parameter_kj_per_m2_k=250.0,
|
||||
total_floor_area_m2=dim.total_floor_area_m2,
|
||||
control_type=2,
|
||||
responsiveness=1.0,
|
||||
living_area_fraction=0.30,
|
||||
)
|
||||
space_heating_result = space_heating_monthly_kwh(
|
||||
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
|
||||
monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly,
|
||||
monthly_external_temperature_c=ext_temp_monthly_c,
|
||||
monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly,
|
||||
monthly_total_gains_w=total_gains_monthly_w,
|
||||
total_floor_area_m2=dim.total_floor_area_m2,
|
||||
)
|
||||
return CalculatorInputs(
|
||||
dimensions=dim,
|
||||
heat_transmission=ht,
|
||||
monthly_infiltration_ach=(0.7,) * 12,
|
||||
# Synthetic baseline internal gains: 450 W constant. Real
|
||||
# per-month variation lives in §5 orchestrator output; tracer
|
||||
# tests don't need the modulation to verify the SAP loop.
|
||||
internal_gains_monthly_w=internal_gains_monthly_w,
|
||||
# Hand-computed solar (S + N 4 m² panes, g⊥=0.63 FF=0.7 Z=0.77,
|
||||
# UK-avg region 0, vertical) — captured from §6 leaves at HEAD.
|
||||
solar_gains_monthly_w=solar_gains_monthly_w,
|
||||
# §7 (93)m + (94)m precomputed from the orchestrator above so the
|
||||
# baseline reflects spec-correct sequential per-zone η.
|
||||
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
|
||||
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
|
||||
# §8 (98c)m precomputed from the orchestrator above.
|
||||
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
|
||||
region=0,
|
||||
control_type=2,
|
||||
responsiveness=1.0,
|
||||
living_area_fraction=0.30,
|
||||
control_temperature_adjustment_c=0.0,
|
||||
thermal_mass_parameter_kj_per_m2_k=250.0,
|
||||
main_heating_efficiency=0.85,
|
||||
hot_water_kwh_per_yr=2400.0,
|
||||
pumps_fans_kwh_per_yr=100.0,
|
||||
lighting_kwh_per_yr=600.0,
|
||||
space_heating_fuel_cost_gbp_per_kwh=0.07,
|
||||
hot_water_fuel_cost_gbp_per_kwh=0.07,
|
||||
other_fuel_cost_gbp_per_kwh=0.07,
|
||||
co2_factor_kg_per_kwh=0.21,
|
||||
)
|
||||
|
||||
|
||||
def test_calculator_consumes_solar_gains_monthly_w_field_for_per_month_solar() -> None:
|
||||
# Arrange — replace the baseline inputs' solar with an explicit known
|
||||
# 12-tuple. The §6 orchestrator produces this upstream; the calculator
|
||||
# must just look it up, not recompute from the legacy `windows` field.
|
||||
# 100 W constant solar everywhere — distinct enough that any leftover
|
||||
# _solar_gains_w(windows, ...) recomputation would land elsewhere.
|
||||
explicit_solar = (100.0,) * 12
|
||||
inputs = replace(
|
||||
_baseline_inputs(), solar_gains_monthly_w=explicit_solar,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
for monthly in result.monthly:
|
||||
assert monthly.solar_gains_w == 100.0
|
||||
|
||||
|
||||
def test_calculator_consumes_space_heating_monthly_kwh_field() -> None:
|
||||
# Arrange — replace baseline inputs' space heating with an explicit known
|
||||
# 12-tuple. The §8 orchestrator produces this upstream; the calculator
|
||||
# must just look it up, not call monthly_heat_requirement_kwh inline.
|
||||
# 500 kWh constant per month — distinct enough that any leftover inline
|
||||
# computation would land elsewhere.
|
||||
explicit_space_heating = (500.0,) * 12
|
||||
inputs = replace(
|
||||
_baseline_inputs(), space_heating_monthly_kwh=explicit_space_heating,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
for monthly in result.monthly:
|
||||
assert monthly.space_heat_requirement_kwh == 500.0
|
||||
|
||||
|
||||
def test_calculator_consumes_mean_internal_temp_and_utilisation_monthly_fields() -> None:
|
||||
# Arrange — replace baseline inputs' MIT + η with explicit known 12-tuples.
|
||||
# The §7 orchestrator produces these upstream; the calculator must just
|
||||
# look them up, not iterate or recompute. 18.0 °C MIT + 0.8 η constant
|
||||
# everywhere — distinct enough that any leftover iteration would drift.
|
||||
explicit_mit = (18.0,) * 12
|
||||
explicit_eta = (0.8,) * 12
|
||||
inputs = replace(
|
||||
_baseline_inputs(),
|
||||
mean_internal_temp_monthly_c=explicit_mit,
|
||||
utilisation_factor_monthly=explicit_eta,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
for monthly in result.monthly:
|
||||
assert monthly.internal_temp_c == 18.0
|
||||
assert monthly.utilisation_factor == 0.8
|
||||
|
||||
|
||||
def test_calculator_returns_twelve_month_breakdown_and_plausible_sap_score() -> None:
|
||||
# Arrange — baseline 100 m² gas-boiler dwelling in UK-average climate.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert — sanity, not exact: tracer bullet that the 12-month loop runs
|
||||
# end-to-end and lands in a believable SAP band for the inputs.
|
||||
assert isinstance(result, SapResult)
|
||||
assert len(result.monthly) == 12
|
||||
assert 1 <= result.sap_score <= 100
|
||||
assert result.space_heating_kwh_per_yr > 0
|
||||
assert result.total_fuel_cost_gbp > 0
|
||||
assert result.ecf > 0
|
||||
# The "main_heating_fuel + hot_water + pumps_fans + lighting" totals
|
||||
# must reconcile with the cost line through the fuel unit cost.
|
||||
expected_fuel = (
|
||||
result.main_heating_fuel_kwh_per_yr
|
||||
+ result.hot_water_kwh_per_yr
|
||||
+ result.pumps_fans_kwh_per_yr
|
||||
+ result.lighting_kwh_per_yr
|
||||
)
|
||||
assert result.total_fuel_cost_gbp == pytest.approx(
|
||||
expected_fuel * inputs.space_heating_fuel_cost_gbp_per_kwh, rel=1e-6
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_exposes_dimensions_intermediates() -> None:
|
||||
# Arrange — P5 trace mode: `result.intermediate` must surface the
|
||||
# worksheet-named dimensions variables for per-section diffing
|
||||
# against BRE worked examples and hand calcs (ADR-0010 / handover §11).
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
assert result.intermediate["tfa_m2"] == inputs.dimensions.total_floor_area_m2
|
||||
assert result.intermediate["volume_m3"] == inputs.dimensions.volume_m3
|
||||
assert result.intermediate["storey_count"] == float(inputs.dimensions.storey_count)
|
||||
|
||||
|
||||
def test_calculate_exposes_heat_transmission_intermediates() -> None:
|
||||
# Arrange — P5 trace mode: the 7 fabric W/K components must surface on
|
||||
# `intermediate` so section-§5 sweep slices can diff per-component
|
||||
# against BRE worked examples.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
ht = inputs.heat_transmission
|
||||
assert result.intermediate["walls_w_per_k"] == ht.walls_w_per_k
|
||||
assert result.intermediate["roof_w_per_k"] == ht.roof_w_per_k
|
||||
assert result.intermediate["floor_w_per_k"] == ht.floor_w_per_k
|
||||
assert result.intermediate["party_walls_w_per_k"] == ht.party_walls_w_per_k
|
||||
assert result.intermediate["windows_w_per_k"] == ht.windows_w_per_k
|
||||
assert result.intermediate["doors_w_per_k"] == ht.doors_w_per_k
|
||||
assert result.intermediate["thermal_bridging_w_per_k"] == ht.thermal_bridging_w_per_k
|
||||
|
||||
|
||||
def test_calculate_exposes_ventilation_intermediates() -> None:
|
||||
# Arrange — P5 trace mode: infiltration ach (the cert-derived input) and
|
||||
# the derived ventilation heat-loss W/K must surface so §4 / Table 4g
|
||||
# sweep slices can diff per-cert against the spec formula
|
||||
# HLC_V = ACH × volume × 0.33 (SAP 10.2 §4.1).
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
|
||||
assert result.intermediate["infiltration_ach"] == pytest.approx(annual_mean_ach, rel=1e-12)
|
||||
expected_hlc_v = annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
|
||||
assert result.intermediate["infiltration_w_per_k"] == pytest.approx(
|
||||
expected_hlc_v, rel=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_exposes_hlc_hlp_and_annual_averages() -> None:
|
||||
# Arrange — P5 trace mode: HLC (W/K), HLP (W/m²K), time constant, and
|
||||
# annual-average internal gains + mean internal temperature surface on
|
||||
# `intermediate`. These are the worksheet-line aggregates §7 / §13
|
||||
# depend on; the annual averages let sweep slices verify monthly-loop
|
||||
# outputs without re-computing the 12-month sum themselves.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
annual_mean_ach = sum(inputs.monthly_infiltration_ach) / 12.0
|
||||
expected_hlc = (
|
||||
inputs.heat_transmission.total_w_per_k
|
||||
+ annual_mean_ach * inputs.dimensions.volume_m3 * 0.33
|
||||
)
|
||||
expected_hlp = expected_hlc / inputs.dimensions.total_floor_area_m2
|
||||
assert result.intermediate["heat_transfer_coefficient_w_per_k"] == pytest.approx(
|
||||
expected_hlc, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["heat_loss_parameter_w_per_m2k"] == pytest.approx(
|
||||
expected_hlp, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["time_constant_h"] > 0.0
|
||||
|
||||
avg_gains = sum(e.internal_gains_w for e in result.monthly) / 12.0
|
||||
avg_mit = sum(e.internal_temp_c for e in result.monthly) / 12.0
|
||||
assert result.intermediate["internal_gains_annual_avg_w"] == pytest.approx(
|
||||
avg_gains, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["mean_internal_temp_annual_avg_c"] == pytest.approx(
|
||||
avg_mit, rel=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_exposes_useful_space_heating_kwh() -> None:
|
||||
# Arrange — P5 trace mode: useful space heating kWh/yr (§9 / Table 9c
|
||||
# step 10) surfaces on `intermediate` keyed by worksheet name. Mirrors
|
||||
# `space_heating_kwh_per_yr` on the top-level result so spec sweep
|
||||
# slices can refer to the worksheet name regardless of `SapResult`
|
||||
# field renames.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
assert result.intermediate["useful_space_heating_kwh_per_yr"] == pytest.approx(
|
||||
result.space_heating_kwh_per_yr, rel=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_exposes_per_end_use_fuel_costs() -> None:
|
||||
# Arrange — P5 trace mode: per-end-use fuel costs (§12 / Table 12) break
|
||||
# out on `intermediate` so the §12 sweep can diff main vs hot water vs
|
||||
# pumps/fans vs lighting individually rather than against the bundled
|
||||
# `total_fuel_cost_gbp`. Secondary heating cost is also surfaced even
|
||||
# though §11 omitted it — the field exists on the calculator and is a
|
||||
# named worksheet variable.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
main_cost = (
|
||||
result.main_heating_fuel_kwh_per_yr * inputs.space_heating_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
secondary_cost = (
|
||||
result.secondary_heating_fuel_kwh_per_yr
|
||||
* inputs.secondary_heating_fuel_cost_gbp_per_kwh
|
||||
)
|
||||
hot_water_cost = inputs.hot_water_kwh_per_yr * inputs.hot_water_fuel_cost_gbp_per_kwh
|
||||
pumps_cost = inputs.pumps_fans_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
|
||||
lighting_cost = inputs.lighting_kwh_per_yr * inputs.other_fuel_cost_gbp_per_kwh
|
||||
|
||||
assert result.intermediate["main_heating_cost_gbp"] == pytest.approx(main_cost, rel=1e-9)
|
||||
assert result.intermediate["secondary_heating_cost_gbp"] == pytest.approx(
|
||||
secondary_cost, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["hot_water_cost_gbp"] == pytest.approx(hot_water_cost, rel=1e-9)
|
||||
assert result.intermediate["pumps_fans_cost_gbp"] == pytest.approx(pumps_cost, rel=1e-9)
|
||||
assert result.intermediate["lighting_cost_gbp"] == pytest.approx(lighting_cost, rel=1e-9)
|
||||
|
||||
|
||||
def test_calculate_exposes_ecf_and_deflator() -> None:
|
||||
# Arrange — P5 trace mode: ECF (the rating denominator) and the §13
|
||||
# Table 12 deflator (0.42 per SAP 10.2) surface on `intermediate`.
|
||||
# ECF mirrors the top-level field; deflator is the only fixed
|
||||
# worksheet constant the SAP rating depends on, so naming it lets
|
||||
# future rating-equation sweep slices reference it explicitly.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
assert result.intermediate["ecf"] == pytest.approx(result.ecf, rel=1e-9)
|
||||
assert result.intermediate["deflator"] == pytest.approx(0.42, rel=1e-12)
|
||||
|
||||
|
||||
def test_calculate_exposes_co2_chain() -> None:
|
||||
# Arrange — P5 trace mode: CO2 = delivered_fuel × co2_factor. Both
|
||||
# inputs surface on `intermediate` so the top-level co2_kg_per_yr is
|
||||
# auditable. Delivered fuel is the sum of every end-use kWh; the
|
||||
# factor mirrors the SAP10 inputs.co2_factor_kg_per_kwh.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
expected_delivered = (
|
||||
result.main_heating_fuel_kwh_per_yr
|
||||
+ result.secondary_heating_fuel_kwh_per_yr
|
||||
+ result.hot_water_kwh_per_yr
|
||||
+ result.pumps_fans_kwh_per_yr
|
||||
+ result.lighting_kwh_per_yr
|
||||
)
|
||||
assert result.intermediate["delivered_fuel_kwh_per_yr"] == pytest.approx(
|
||||
expected_delivered, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["co2_factor_kg_per_kwh"] == pytest.approx(
|
||||
inputs.co2_factor_kg_per_kwh, rel=1e-12
|
||||
)
|
||||
assert (
|
||||
result.intermediate["delivered_fuel_kwh_per_yr"]
|
||||
* result.intermediate["co2_factor_kg_per_kwh"]
|
||||
) == pytest.approx(result.co2_kg_per_yr, rel=1e-9)
|
||||
|
||||
|
||||
def test_calculate_exposes_primary_energy_breakdown() -> None:
|
||||
# Arrange — P5 trace mode: primary energy splits across three PEFs
|
||||
# (space-heating, hot-water, other) and a PV offset at the other-PEF
|
||||
# (Appendix M). The §11 sketch in HANDOVER_SYSTEMATIC_REVIEW lists
|
||||
# these as `_kwh_per_m2` because primary energy enters the rating
|
||||
# equation per-floor-area; absolute values are recoverable via tfa_m2.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
tfa = inputs.dimensions.total_floor_area_m2
|
||||
space_heating_pe = (
|
||||
(result.main_heating_fuel_kwh_per_yr + result.secondary_heating_fuel_kwh_per_yr)
|
||||
* inputs.space_heating_primary_factor
|
||||
/ tfa
|
||||
)
|
||||
hot_water_pe = inputs.hot_water_kwh_per_yr * inputs.hot_water_primary_factor / tfa
|
||||
other_pe = (
|
||||
(inputs.pumps_fans_kwh_per_yr + inputs.lighting_kwh_per_yr)
|
||||
* inputs.other_primary_factor
|
||||
/ tfa
|
||||
)
|
||||
pv_offset_pe = inputs.pv_generation_kwh_per_yr * inputs.other_primary_factor / tfa
|
||||
|
||||
assert result.intermediate["space_heating_pe_kwh_per_m2"] == pytest.approx(
|
||||
space_heating_pe, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["hot_water_pe_kwh_per_m2"] == pytest.approx(
|
||||
hot_water_pe, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["other_pe_kwh_per_m2"] == pytest.approx(other_pe, rel=1e-9)
|
||||
assert result.intermediate["pv_pe_offset_kwh_per_m2"] == pytest.approx(
|
||||
pv_offset_pe, rel=1e-9
|
||||
)
|
||||
expected_total_per_m2 = max(
|
||||
0.0, space_heating_pe + hot_water_pe + other_pe - pv_offset_pe
|
||||
)
|
||||
assert result.primary_energy_kwh_per_m2 == pytest.approx(
|
||||
expected_total_per_m2, rel=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_exposes_per_end_use_co2() -> None:
|
||||
# Arrange — P5 trace mode: §11 sketch lists "primary energy AND CO2
|
||||
# per end-use". The calculator applies a single co2_factor_kg_per_kwh
|
||||
# to total delivered fuel (no PV deduction on CO2 in the current
|
||||
# implementation), so per-end-use CO2 is fuel_kwh × factor and the
|
||||
# five components sum exactly to the top-level co2_kg_per_yr.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
factor = inputs.co2_factor_kg_per_kwh
|
||||
assert result.intermediate["main_heating_co2_kg_per_yr"] == pytest.approx(
|
||||
result.main_heating_fuel_kwh_per_yr * factor, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["secondary_heating_co2_kg_per_yr"] == pytest.approx(
|
||||
result.secondary_heating_fuel_kwh_per_yr * factor, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["hot_water_co2_kg_per_yr"] == pytest.approx(
|
||||
result.hot_water_kwh_per_yr * factor, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["pumps_fans_co2_kg_per_yr"] == pytest.approx(
|
||||
result.pumps_fans_kwh_per_yr * factor, rel=1e-9
|
||||
)
|
||||
assert result.intermediate["lighting_co2_kg_per_yr"] == pytest.approx(
|
||||
result.lighting_kwh_per_yr * factor, rel=1e-9
|
||||
)
|
||||
breakdown_sum = (
|
||||
result.intermediate["main_heating_co2_kg_per_yr"]
|
||||
+ result.intermediate["secondary_heating_co2_kg_per_yr"]
|
||||
+ result.intermediate["hot_water_co2_kg_per_yr"]
|
||||
+ result.intermediate["pumps_fans_co2_kg_per_yr"]
|
||||
+ result.intermediate["lighting_co2_kg_per_yr"]
|
||||
)
|
||||
assert breakdown_sum == pytest.approx(result.co2_kg_per_yr, rel=1e-9)
|
||||
|
||||
|
||||
def test_calculate_exposes_pv_export_credit() -> None:
|
||||
# Arrange — P5 trace mode: total_fuel_cost_gbp = sum(per-end-use
|
||||
# costs) − pv_export_credit, floored at 0. The PV credit is the only
|
||||
# missing term linking the P5.6 per-end-use cost breakdown to the
|
||||
# top-level total. Set non-zero PV values so the credit is meaningful.
|
||||
inputs = replace(
|
||||
_baseline_inputs(),
|
||||
pv_generation_kwh_per_yr=1000.0,
|
||||
pv_export_credit_gbp_per_kwh=0.05,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
expected_credit = (
|
||||
inputs.pv_generation_kwh_per_yr * inputs.pv_export_credit_gbp_per_kwh
|
||||
)
|
||||
assert result.intermediate["pv_export_credit_gbp"] == pytest.approx(
|
||||
expected_credit, rel=1e-12
|
||||
)
|
||||
gross_cost = (
|
||||
result.intermediate["main_heating_cost_gbp"]
|
||||
+ result.intermediate["secondary_heating_cost_gbp"]
|
||||
+ result.intermediate["hot_water_cost_gbp"]
|
||||
+ result.intermediate["pumps_fans_cost_gbp"]
|
||||
+ result.intermediate["lighting_cost_gbp"]
|
||||
)
|
||||
assert max(0.0, gross_cost - expected_credit) == pytest.approx(
|
||||
result.total_fuel_cost_gbp, rel=1e-9
|
||||
)
|
||||
|
||||
|
||||
def test_calculate_exposes_rating_equation_spec_constants() -> None:
|
||||
# Arrange — P5 trace mode: the §13 ECF denominator carries a 45 m²
|
||||
# floor-area offset (Table 12) and the SAP rating splits between a
|
||||
# linear and a log regime at ECF = 3.5. Surfacing both on
|
||||
# `intermediate` documents the equation alongside the already-exposed
|
||||
# ecf + deflator (P5.7), so the SAP rating curve is fully auditable.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
assert result.intermediate["floor_area_offset_m2"] == pytest.approx(45.0, rel=1e-12)
|
||||
assert result.intermediate["ecf_log_threshold"] == pytest.approx(3.5, rel=1e-12)
|
||||
|
||||
|
||||
def test_higher_main_heating_efficiency_reduces_fuel_use() -> None:
|
||||
# Arrange — Direction check: doubling the boiler efficiency must halve
|
||||
# the main-heating fuel kWh, holding everything else constant.
|
||||
base = _baseline_inputs()
|
||||
high_eff = replace(base, main_heating_efficiency=base.main_heating_efficiency * 2.0)
|
||||
|
||||
# Act
|
||||
r_base = calculate_sap_from_inputs(base)
|
||||
r_high = calculate_sap_from_inputs(high_eff)
|
||||
|
||||
# Assert
|
||||
assert r_base.space_heating_kwh_per_yr == pytest.approx(
|
||||
r_high.space_heating_kwh_per_yr, rel=1e-6
|
||||
)
|
||||
assert r_high.main_heating_fuel_kwh_per_yr == pytest.approx(
|
||||
r_base.main_heating_fuel_kwh_per_yr / 2.0, rel=1e-6
|
||||
)
|
||||
assert r_high.sap_score >= r_base.sap_score
|
||||
|
||||
|
||||
def _baseline_with_region(region: int) -> CalculatorInputs:
|
||||
"""Rebuild baseline with a different climate region. Recomputes the
|
||||
§7 + §8 orchestrators because they depend on external temperatures,
|
||||
which vary per region in Appendix U Table U1."""
|
||||
base = _baseline_inputs()
|
||||
ext_temp_monthly_c = tuple(external_temperature_c(region, m) for m in range(1, 13))
|
||||
htc_monthly = base.heat_transmission.total_w_per_k + 0.33 * base.dimensions.volume_m3 * 0.7
|
||||
htc_monthly_w_per_k = (htc_monthly,) * 12
|
||||
total_gains_monthly_w = tuple(
|
||||
base.internal_gains_monthly_w[m] + base.solar_gains_monthly_w[m] for m in range(12)
|
||||
)
|
||||
mit_result = mean_internal_temperature_monthly(
|
||||
monthly_external_temp_c=ext_temp_monthly_c,
|
||||
monthly_total_gains_w=total_gains_monthly_w,
|
||||
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
|
||||
thermal_mass_parameter_kj_per_m2_k=base.thermal_mass_parameter_kj_per_m2_k,
|
||||
total_floor_area_m2=base.dimensions.total_floor_area_m2,
|
||||
control_type=base.control_type,
|
||||
responsiveness=base.responsiveness,
|
||||
living_area_fraction=base.living_area_fraction,
|
||||
)
|
||||
space_heating_result = space_heating_monthly_kwh(
|
||||
monthly_heat_transfer_coefficient_w_per_k=htc_monthly_w_per_k,
|
||||
monthly_internal_temperature_c=mit_result.adjusted_mean_internal_temp_monthly,
|
||||
monthly_external_temperature_c=ext_temp_monthly_c,
|
||||
monthly_utilisation_factor=mit_result.utilisation_factor_whole_monthly,
|
||||
monthly_total_gains_w=total_gains_monthly_w,
|
||||
total_floor_area_m2=base.dimensions.total_floor_area_m2,
|
||||
)
|
||||
return replace(
|
||||
base,
|
||||
region=region,
|
||||
mean_internal_temp_monthly_c=mit_result.adjusted_mean_internal_temp_monthly,
|
||||
utilisation_factor_monthly=mit_result.utilisation_factor_whole_monthly,
|
||||
space_heating_monthly_kwh=space_heating_result.total_space_heating_monthly_kwh,
|
||||
)
|
||||
|
||||
|
||||
def test_colder_climate_region_increases_space_heating_demand() -> None:
|
||||
# Arrange — Direction check: same dwelling in Shetland (region 20) must
|
||||
# require more space-heating kWh than in Thames (region 1) because the
|
||||
# external-temperature column in Table U1 is consistently lower.
|
||||
thames = _baseline_with_region(1)
|
||||
shetland = _baseline_with_region(20)
|
||||
|
||||
# Act
|
||||
r_thames = calculate_sap_from_inputs(thames)
|
||||
r_shetland = calculate_sap_from_inputs(shetland)
|
||||
|
||||
# Assert
|
||||
assert r_shetland.space_heating_kwh_per_yr > r_thames.space_heating_kwh_per_yr
|
||||
|
||||
|
||||
def test_zero_heat_transmission_collapses_space_heating_to_zero() -> None:
|
||||
# Arrange — When HLC = 0 (perfect envelope) and there's no ventilation
|
||||
# heat loss, no month can have a positive loss rate, so space heating
|
||||
# must be zero across the year. (98c)m is therefore (0,)*12 — the §8
|
||||
# orchestrator value-clamps on useful_loss ≤ 0.
|
||||
base = _baseline_inputs()
|
||||
no_loss = replace(
|
||||
base,
|
||||
heat_transmission=HeatTransmission(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
||||
monthly_infiltration_ach=(0.0,) * 12,
|
||||
space_heating_monthly_kwh=(0.0,) * 12,
|
||||
)
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(no_loss)
|
||||
|
||||
# Assert
|
||||
assert result.space_heating_kwh_per_yr == 0.0
|
||||
assert result.main_heating_fuel_kwh_per_yr == 0.0
|
||||
|
||||
|
||||
def test_ecf_uses_table_12_energy_cost_deflator() -> None:
|
||||
# Arrange — §13 Equation (7): ECF = 0.42 × cost / (TFA + 45) per
|
||||
# SAP 10.2 Table 12. The orchestrator must report an ECF that
|
||||
# reconciles with this formula given the cost it reported.
|
||||
inputs = _baseline_inputs()
|
||||
|
||||
# Act
|
||||
result = calculate_sap_from_inputs(inputs)
|
||||
|
||||
# Assert
|
||||
expected_ecf = (
|
||||
0.42
|
||||
* result.total_fuel_cost_gbp
|
||||
/ (inputs.dimensions.total_floor_area_m2 + 45.0)
|
||||
)
|
||||
assert result.ecf == pytest.approx(expected_ecf, rel=1e-6)
|
||||
|
||||
|
||||
def test_split_tariff_charges_space_heating_at_off_peak_rate() -> None:
|
||||
# Arrange — Economy-7 dwelling: storage-heater space heating at the
|
||||
# 7h-low rate (~5.5 p/kWh), everything else on standard (13.19 p/kWh).
|
||||
# Verifies the split-tariff cost line aggregates correctly per SAP §12.
|
||||
base = _baseline_inputs()
|
||||
e7 = replace(
|
||||
base,
|
||||
space_heating_fuel_cost_gbp_per_kwh=0.055,
|
||||
hot_water_fuel_cost_gbp_per_kwh=0.1319,
|
||||
other_fuel_cost_gbp_per_kwh=0.1319,
|
||||
)
|
||||
|
||||
# Act
|
||||
r_e7 = calculate_sap_from_inputs(e7)
|
||||
|
||||
# Assert
|
||||
expected_cost = (
|
||||
r_e7.main_heating_fuel_kwh_per_yr * 0.055
|
||||
+ r_e7.hot_water_kwh_per_yr * 0.1319
|
||||
+ (r_e7.pumps_fans_kwh_per_yr + r_e7.lighting_kwh_per_yr) * 0.1319
|
||||
)
|
||||
assert r_e7.total_fuel_cost_gbp == pytest.approx(expected_cost, rel=1e-6)
|
||||
323
domain/sap10_calculator/tests/test_pcdb_etl.py
Normal file
323
domain/sap10_calculator/tests/test_pcdb_etl.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""Tests for the BRE PCDB (pcdb10.dat) ETL parser.
|
||||
|
||||
The PCDB is a multi-table comma-separated data file published by BRE.
|
||||
Each table has its own format (`$<table_id>,<format>,...`) and its own
|
||||
field schema. This module verifies that the per-table parsers decode
|
||||
records into typed dicts matching ground-truth records the user
|
||||
verified against https://www.ncm-pcdb.org.uk.
|
||||
|
||||
Reference: BRE Product Characteristics Database — pcdb10.dat (April 2026).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.tables.pcdb.etl import run_etl
|
||||
from domain.sap10_calculator.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"
|
||||
)
|
||||
|
||||
|
||||
# Verified by user against ncm-pcdb.org.uk: Baxi Heating Wm 20/3rs.
|
||||
_BAXI_98_RAW: str = (
|
||||
"000098,000005,0,2010/Sep/13 17:03,Baxi Heating,Baxi Heating,Wm,20/3rs,"
|
||||
"4107739,,1990,1,0,0,1,0,,,1,2,1,5.86,5.86,,,66.0,56.0,,40.8,,3,,,0,2,0,"
|
||||
",,0,,0,,0,,,,,0,,,,,,,,,,,,,0000,,,,,,,,,,,,,,,"
|
||||
)
|
||||
|
||||
# Verified by ground-truth arithmetic against PDF Σ(61) = 337.19 for 000474
|
||||
# Elmhurst fixture (Vaillant ecoTEC pro 28 VUW GB 286/5-3, pcdb_id 16839):
|
||||
# Table 3b row 1 → Σ(61) = (45) × r1 × fu + F1 × 365
|
||||
# = 1680.84 × 0.0025 × 1.0 + 0.91251 × 365 = 337.27.
|
||||
# Combi-loss fields (BRE PCDF Spec v1.0 §7.11 fields 48/51/52/56/57):
|
||||
# separate_dhw_tests = 1 (one test, profile M → Table 3b)
|
||||
# rejected_energy_proportion_r1 = 0.0025
|
||||
# loss_factor_f1_kwh_per_day = 0.91251
|
||||
# loss_factor_f2 / rejected_factor_f3 = blank (Table 3c not used)
|
||||
_VAILLANT_16839_RAW: str = (
|
||||
"016839,000031,0,2019/Mar/04 10:28,Vaillant,Vaillant,ecoTEC pro 28,"
|
||||
"VUW GB 286/5-3,GC 47-044-45,2005,2015,1,2,1,2,0,,,2,2,2,24.4,24.4,,,"
|
||||
"88.7,87.0,,75.1,,2,,,104,1,2,105,2,0,,,,0,,,,,1,7.012,0.133,0.0025,"
|
||||
"0.91251,,,,,,1,1,,0045,,,,,,,,,89.0,98.0,,,,,96.3"
|
||||
)
|
||||
|
||||
|
||||
def test_table_105_parser_extracts_baxi_98_known_fields() -> None:
|
||||
"""Decode the user-verified Baxi 000098 Wm 20/3rs record. Field positions
|
||||
cross-checked against the ncm-pcdb.org.uk web entry: pcdb_id 98 = Baxi
|
||||
Heating brand "Baxi Heating", model "Wm", qualifier "20/3rs", SAP winter
|
||||
seasonal efficiency 66.0%, SAP summer seasonal efficiency 56.0%,
|
||||
comparative hot water 40.8%, output 5.86 kW, final year 1990."""
|
||||
# Arrange
|
||||
raw_row = _BAXI_98_RAW
|
||||
|
||||
# Act
|
||||
record = parse_table_105_row(raw_row)
|
||||
|
||||
# Assert
|
||||
assert record.pcdb_id == 98
|
||||
assert record.brand_name == "Baxi Heating"
|
||||
assert record.model_name == "Wm"
|
||||
assert record.model_qualifier == "20/3rs"
|
||||
assert record.winter_efficiency_pct == 66.0
|
||||
assert record.summer_efficiency_pct == 56.0
|
||||
assert record.comparative_hot_water_efficiency_pct == 40.8
|
||||
assert record.output_kw_max == 5.86
|
||||
assert record.final_year_of_manufacture == 1990
|
||||
|
||||
|
||||
# (raw_row, expected fields). Three additional user-verified records — same
|
||||
# field positions, different manufacturers + output power + final year.
|
||||
_POTTERTON_619_RAW: str = (
|
||||
"000619,000034,0,2010/Sep/13 17:03,Potterton Myson,Potterton Myson,"
|
||||
"Flamingo 2,cf20/30,4160516,,1986,1,0,0,1,0,,,1,1,1,8.8,8.8,,,66.0,56.0,"
|
||||
",40.8,,3,,,0,2,0,,,0,,0,,0,,,,,0,,,,,,,,,,,,,0000,,,,,,,,,,,,,,,"
|
||||
)
|
||||
_SAUNIER_732_RAW: str = (
|
||||
"000732,000035,0,2010/Sep/13 17:03,Saunier Duval,Saunier Duval,500,30c,"
|
||||
"4192007,,1992,1,0,0,1,0,,,1,1,1,8.8,8.8,,,66.0,56.0,,40.8,,3,,,0,2,0,"
|
||||
",,0,,0,,0,,,,,0,,,,,,,,,,,,,0000,,,,,,,,,,,,,,,"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_row, expected",
|
||||
[
|
||||
(
|
||||
_POTTERTON_619_RAW,
|
||||
{
|
||||
"pcdb_id": 619,
|
||||
"brand_name": "Potterton Myson",
|
||||
"model_name": "Flamingo 2",
|
||||
"model_qualifier": "cf20/30",
|
||||
"output_kw_max": 8.8,
|
||||
"final_year_of_manufacture": 1986,
|
||||
},
|
||||
),
|
||||
(
|
||||
_SAUNIER_732_RAW,
|
||||
{
|
||||
"pcdb_id": 732,
|
||||
"brand_name": "Saunier Duval",
|
||||
"model_name": "500",
|
||||
"model_qualifier": "30c",
|
||||
"output_kw_max": 8.8,
|
||||
"final_year_of_manufacture": 1992,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_table_105_parser_extracts_other_user_verified_records(
|
||||
raw_row: str, expected: dict[str, object]
|
||||
) -> None:
|
||||
"""Confirms field positions hold across distinct manufacturers + output
|
||||
powers + final years. All three records ship with the same 66/56/40.8
|
||||
SAP-default efficiency — they're the same "estimated (ie SAP default)"
|
||||
PCDB rows used to verify the parser's shape against ncm-pcdb.org.uk."""
|
||||
# Arrange
|
||||
# Act
|
||||
record = parse_table_105_row(raw_row)
|
||||
|
||||
# Assert
|
||||
for key, value in expected.items():
|
||||
assert getattr(record, key) == value, f"field {key}"
|
||||
|
||||
|
||||
def test_table_105_parser_extracts_separate_dhw_tests_profile_flag() -> None:
|
||||
"""BRE PCDF Spec v1.0 §7.11 field 48 (0-indexed 47) "Separate DHW
|
||||
tests" encodes the profile-flag for PCDB Table 3b/3c combi-loss
|
||||
selection: 0 = none / not applicable, 1 = one test profile M
|
||||
(Table 3b), 2 = two tests profiles M+L (Table 3c), 3 = two tests
|
||||
profiles M+S (Table 3c). 16839 lodges flag=1 → Table 3b path."""
|
||||
# Arrange
|
||||
raw_row = _VAILLANT_16839_RAW
|
||||
|
||||
# Act
|
||||
record = parse_table_105_row(raw_row)
|
||||
|
||||
# Assert
|
||||
assert record.separate_dhw_tests == 1
|
||||
|
||||
|
||||
def test_table_105_parser_extracts_table_3b_3c_combi_loss_coefficients() -> None:
|
||||
"""BRE PCDF Spec v1.0 §7.11 fields 51 / 52 / 56 / 57 (0-indexed
|
||||
50 / 51 / 55 / 56) carry the Table 3b/3c combi-loss coefficients:
|
||||
rejected energy r1, loss factor F1 (Table 3b), loss factor F2
|
||||
(Table 3c), rejected factor F3 (Table 3c, can be negative).
|
||||
16839 lodges profile M only, so F2/F3 are absent (blank). Cross-
|
||||
verified by arithmetic: Σ(61) = (45) × r1 × fu + F1 × 365
|
||||
= 1680.84 × 0.0025 × 1.0 + 0.91251 × 365 = 337.27 kWh/yr against
|
||||
the 000474 worksheet's PDF pin Σ(61) = 337.19 (Δ 0.02%)."""
|
||||
# Arrange
|
||||
raw_row = _VAILLANT_16839_RAW
|
||||
|
||||
# Act
|
||||
record = parse_table_105_row(raw_row)
|
||||
|
||||
# Assert
|
||||
assert record.rejected_energy_proportion_r1 == 0.0025
|
||||
assert record.loss_factor_f1_kwh_per_day == 0.91251
|
||||
assert record.loss_factor_f2_kwh_per_day is None
|
||||
assert record.rejected_factor_f3_per_litre is None
|
||||
|
||||
|
||||
def test_table_105_parser_leaves_combi_loss_fields_none_for_sap_default_boilers() -> None:
|
||||
"""Baxi 000098 is a SAP-default boiler (no EN 13203-2 / OPS 26 tests),
|
||||
so the Table 3b/3c combi-loss fields are blank in pcdb10.dat. The
|
||||
parser exposes them as None to signal Table 3a fallback (the
|
||||
pre-§4-HW default 600 kWh/yr behaviour)."""
|
||||
# Arrange
|
||||
raw_row = _BAXI_98_RAW
|
||||
|
||||
# Act
|
||||
record = parse_table_105_row(raw_row)
|
||||
|
||||
# Assert
|
||||
assert record.separate_dhw_tests == 0
|
||||
assert record.rejected_energy_proportion_r1 is None
|
||||
assert record.loss_factor_f1_kwh_per_day is None
|
||||
assert record.loss_factor_f2_kwh_per_day is None
|
||||
assert record.rejected_factor_f3_per_litre is None
|
||||
|
||||
|
||||
def test_parse_table_105_walks_section_skipping_headers_and_comments() -> None:
|
||||
"""The .dat file demarcates each table with a `$<id>,<format>,...`
|
||||
header line, intersperses `#`-prefixed comments, and ends the table
|
||||
with a `# ... end of Table <id>` marker before the next section. The
|
||||
walker yields parsed records only for rows inside the Table 105
|
||||
section, ignoring comments, headers, and rows from other tables."""
|
||||
# Arrange
|
||||
dat_section = (
|
||||
"# noise before\n"
|
||||
"$105,211,2,2025,11,28,2\n"
|
||||
"# Table 105 (Gas and Oil Boilers) follows ...\n"
|
||||
"#\n"
|
||||
f"{_BAXI_98_RAW}\n"
|
||||
f"{_POTTERTON_619_RAW}\n"
|
||||
"#\n"
|
||||
"# ... end of Table 105 Format 211\n"
|
||||
"#\n"
|
||||
"$362,360,1,2025,11,28,1\n"
|
||||
"ignored,record,from,heat,pump,table\n"
|
||||
)
|
||||
|
||||
# Act
|
||||
records = parse_table_105(dat_section)
|
||||
|
||||
# Assert
|
||||
assert [r.pcdb_id for r in records] == [98, 619]
|
||||
assert records[0].brand_name == "Baxi Heating"
|
||||
assert records[1].brand_name == "Potterton Myson"
|
||||
|
||||
|
||||
def test_parse_table_105_extracts_user_verified_records_from_real_pcdb_dat() -> None:
|
||||
"""End-to-end against the real BRE pcdb10.dat (7.9 MB, ~23k lines,
|
||||
CRLF endings). Cross-references all four ground-truth records the user
|
||||
verified against ncm-pcdb.org.uk — surfaces any drift between the
|
||||
parser's field positions and real-world data."""
|
||||
# Arrange — BRE PCDB ships in latin-1 (cp1252 superset; manufacturer
|
||||
# addresses occasionally carry non-ASCII characters such as the degree
|
||||
# sign).
|
||||
dat_text = _PCDB_DAT_PATH.read_text(encoding="latin-1")
|
||||
|
||||
# Act
|
||||
records = parse_table_105(dat_text)
|
||||
by_id = {r.pcdb_id: r for r in records}
|
||||
|
||||
# Assert
|
||||
assert by_id[98].brand_name == "Baxi Heating"
|
||||
assert by_id[98].model_name == "Wm"
|
||||
assert by_id[98].model_qualifier == "20/3rs"
|
||||
assert by_id[98].winter_efficiency_pct == 66.0
|
||||
assert by_id[98].summer_efficiency_pct == 56.0
|
||||
assert by_id[98].comparative_hot_water_efficiency_pct == 40.8
|
||||
assert by_id[98].final_year_of_manufacture == 1990
|
||||
assert by_id[619].brand_name == "Potterton Myson"
|
||||
assert by_id[619].winter_efficiency_pct == 66.0
|
||||
assert by_id[732].brand_name == "Saunier Duval"
|
||||
assert by_id[732].winter_efficiency_pct == 66.0
|
||||
|
||||
|
||||
def test_run_etl_writes_table_105_jsonl_with_decoded_and_raw_fields(tmp_path: Path) -> None:
|
||||
"""End-to-end ETL: read the real pcdb10.dat, parse Table 105, write a
|
||||
newline-delimited JSON file (`.jsonl`). Each line is one record; reader
|
||||
parses line-by-line. Verifies the decoded fields and that the raw row
|
||||
is preserved alongside."""
|
||||
# Arrange
|
||||
import json
|
||||
|
||||
output_dir = tmp_path / "pcdb_json"
|
||||
|
||||
# Act
|
||||
run_etl(source=_PCDB_DAT_PATH, output_dir=output_dir)
|
||||
|
||||
# Assert
|
||||
table_105_jsonl = output_dir / "pcdb_table_105_gas_oil_boilers.jsonl"
|
||||
assert table_105_jsonl.exists()
|
||||
records = [
|
||||
json.loads(line)
|
||||
for line in table_105_jsonl.read_text().splitlines()
|
||||
if line
|
||||
]
|
||||
by_id = {r["pcdb_id"]: r for r in records}
|
||||
assert by_id[98]["brand_name"] == "Baxi Heating"
|
||||
assert by_id[98]["winter_efficiency_pct"] == 66.0
|
||||
assert by_id[98]["summer_efficiency_pct"] == 56.0
|
||||
assert by_id[98]["raw"][0] == "000098" # raw[0] = pcdb_id (left-padded)
|
||||
|
||||
|
||||
def test_parse_table_raw_extracts_heat_pump_records_from_real_pcdb_dat() -> None:
|
||||
"""Generic positional walker against Table 362 (Heat Pumps). Per-field
|
||||
typing is deferred to a future slice once heat-pump records are ground-
|
||||
truth verified; for now the parser only commits to pcdb_id + raw row.
|
||||
Asserts the walker handles a table other than 105 and produces non-
|
||||
empty output with the expected shape."""
|
||||
# Arrange
|
||||
dat_text = _PCDB_DAT_PATH.read_text(encoding="latin-1")
|
||||
|
||||
# Act
|
||||
records = parse_table_raw(dat_text, table_id="362")
|
||||
|
||||
# Assert
|
||||
assert len(records) > 0
|
||||
first = records[0]
|
||||
assert isinstance(first.pcdb_id, int)
|
||||
assert first.pcdb_id > 0
|
||||
assert first.raw[0].lstrip("0") == str(first.pcdb_id) or first.raw[0] == "000000"
|
||||
assert len(first.raw) > 1 # multi-field row
|
||||
|
||||
|
||||
def test_run_etl_writes_all_eight_pcdb_table_jsonl_files(tmp_path: Path) -> None:
|
||||
"""Per the user-chosen scope-D ingestion: slice 1 produces JSONL for
|
||||
all 8 PCDB tables of interest (105 typed; 122/143/313/353/362/391/506
|
||||
as untyped pcdb_id + raw). Per-table typed refinement is the job of
|
||||
follow-up slices when their cert-side wiring lands."""
|
||||
# Arrange
|
||||
expected_filenames = {
|
||||
"pcdb_table_105_gas_oil_boilers.jsonl",
|
||||
"pcdb_table_122_solid_fuel_boilers.jsonl",
|
||||
"pcdb_table_143_micro_cogen.jsonl",
|
||||
"pcdb_table_313_flue_gas_heat_recovery.jsonl",
|
||||
"pcdb_table_353_waste_water_heat_recovery.jsonl",
|
||||
"pcdb_table_362_heat_pumps.jsonl",
|
||||
"pcdb_table_391_high_heat_retention_storage_heaters.jsonl",
|
||||
"pcdb_table_506_heat_interface_units.jsonl",
|
||||
}
|
||||
output_dir = tmp_path / "pcdb_json"
|
||||
|
||||
# Act
|
||||
run_etl(source=_PCDB_DAT_PATH, output_dir=output_dir)
|
||||
|
||||
# Assert
|
||||
written = {p.name for p in output_dir.iterdir()}
|
||||
assert expected_filenames.issubset(written)
|
||||
41
domain/sap10_calculator/tests/test_pcdb_lookup.py
Normal file
41
domain/sap10_calculator/tests/test_pcdb_lookup.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Tests for the runtime PCDB lookup module.
|
||||
|
||||
The lookup loads pcdb_table_105_gas_oil_boilers.jsonl at import time and
|
||||
caches it as a by-pcdb-id dict. Callers (cert_to_inputs) invoke
|
||||
`gas_oil_boiler_record(pcdb_id)` to obtain the typed record or None when
|
||||
the ID is not in the PCDB.
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_gas_oil_boiler_record_returns_verified_baxi_98() -> None:
|
||||
"""Baxi Heating Wm 20/3rs (user-verified against ncm-pcdb.org.uk):
|
||||
winter SAP seasonal efficiency 66.0%, summer 56.0%, comparative HW
|
||||
40.8%. Lookup by `main_heating_index_number = 98` returns the typed
|
||||
record."""
|
||||
# Arrange
|
||||
# Act
|
||||
record = gas_oil_boiler_record(98)
|
||||
|
||||
# Assert
|
||||
assert record is not None
|
||||
assert record.brand_name == "Baxi Heating"
|
||||
assert record.model_name == "Wm"
|
||||
assert record.winter_efficiency_pct == 66.0
|
||||
assert record.summer_efficiency_pct == 56.0
|
||||
|
||||
|
||||
def test_gas_oil_boiler_record_returns_none_for_unknown_pcdb_id() -> None:
|
||||
"""`main_heating_index_number` values not in Table 105 return None so
|
||||
`cert_to_inputs` can fall back to the Table 4a/4b category default."""
|
||||
# Arrange
|
||||
# Act
|
||||
record = gas_oil_boiler_record(99999999)
|
||||
|
||||
# Assert
|
||||
assert record is None
|
||||
87
domain/sap10_calculator/tests/test_postcode_weather.py
Normal file
87
domain/sap10_calculator/tests/test_postcode_weather.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Tests for the PCDB Table 172 (postcode weather) lookup module.
|
||||
|
||||
The lookup parses pcdb10.dat at first use and caches it as a
|
||||
`{(area, district): PostcodeClimate}` dict. Callers invoke
|
||||
`postcode_climate(postcode_str)` to obtain the per-district monthly
|
||||
weather (temp, wind, solar) used by the demand-side cascade for EPC
|
||||
emissions / primary energy.
|
||||
|
||||
Reference: BRE PCDB pcdb10.dat Table 172 (Postcodes).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from domain.sap10_calculator.tables.pcdb.postcode_weather import (
|
||||
PostcodeClimate,
|
||||
postcode_climate,
|
||||
)
|
||||
|
||||
|
||||
def test_postcode_climate_returns_bd3_record() -> None:
|
||||
"""Bradford district 3 (BD3) is the postcode for Elmhurst fixture 000474.
|
||||
Verified against U985 Block 2 wind speed (5.2, 5.2, 5.0, ..., 4.9) which
|
||||
is the EPC demand-cascade climate."""
|
||||
# Arrange
|
||||
# Act
|
||||
climate = postcode_climate("bd3 8aq")
|
||||
|
||||
# Assert
|
||||
assert climate is not None
|
||||
assert climate.area == "BD"
|
||||
assert climate.district == 3
|
||||
assert climate.region == 11 # East Pennines
|
||||
# Block 2 of U985-0001-000474.txt: Wind speed
|
||||
# 5.2 5.2 5.0 4.4 4.3 3.9 4.0 3.8 4.1 4.4 4.6 4.9 (22)
|
||||
assert climate.monthly_wind_speed_m_per_s == (
|
||||
5.2, 5.2, 5.0, 4.4, 4.3, 3.9, 4.0, 3.8, 4.1, 4.4, 4.6, 4.9,
|
||||
)
|
||||
|
||||
|
||||
def test_postcode_climate_parses_mixed_case() -> None:
|
||||
"""Postcode is normalised to upper-case so "bd3 8aq" and "BD3 8AQ" hit
|
||||
the same record."""
|
||||
# Arrange
|
||||
lower = "bd4 7jr"
|
||||
upper = "BD4 7JR"
|
||||
|
||||
# Act
|
||||
a = postcode_climate(lower)
|
||||
b = postcode_climate(upper)
|
||||
|
||||
# Assert
|
||||
assert a is not None
|
||||
assert b is not None
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_postcode_climate_handles_two_digit_district() -> None:
|
||||
"""Two-digit district numbers ("BD19") parse correctly — the digit
|
||||
consumption walks past the alpha prefix and grabs all digits."""
|
||||
# Arrange
|
||||
# Act
|
||||
climate = postcode_climate("bd19 3tf")
|
||||
|
||||
# Assert
|
||||
assert climate is not None
|
||||
assert climate.area == "BD"
|
||||
assert climate.district == 19
|
||||
|
||||
|
||||
def test_postcode_climate_returns_none_for_unknown_postcode() -> None:
|
||||
"""Postcodes with no Table 172 entry (e.g. synthetic test data) yield
|
||||
None so callers can fall back to UK-average climate."""
|
||||
# Arrange
|
||||
# Act
|
||||
result = postcode_climate("ZZ99 9ZZ")
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_postcode_climate_returns_none_for_malformed() -> None:
|
||||
"""Empty or letter-only postcodes return None rather than raising."""
|
||||
# Arrange
|
||||
# Act
|
||||
# Assert
|
||||
assert postcode_climate("") is None
|
||||
assert postcode_climate(None) is None
|
||||
assert postcode_climate("XYZ") is None
|
||||
82
domain/sap10_calculator/tests/test_table_12.py
Normal file
82
domain/sap10_calculator/tests/test_table_12.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""SAP 10.2 (14-03-2025 amendment) Table 12 value-correctness tests.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
against SAP 10.3 (13-01-2026) values by mistake. ADR-0010 retargets the
|
||||
calculator to SAP 10.2 (14-03-2025) until the corpus migrates, so the
|
||||
CO2 column was corrected during P2.4. These tests lock the corrected
|
||||
values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.tables.table_12 import (
|
||||
co2_factor_kg_per_kwh,
|
||||
primary_energy_factor,
|
||||
unit_price_p_per_kwh,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fuel_code, expected_co2_kg_per_kwh, fuel_name",
|
||||
[
|
||||
# Most-common cases first — gas + electricity dominate the corpus.
|
||||
(1, 0.210, "mains gas"),
|
||||
(30, 0.136, "standard tariff electricity"),
|
||||
# Sanity: heating oil is unchanged between SAP 10.2 and SAP 10.3.
|
||||
(4, 0.298, "heating oil"),
|
||||
# Off-peak electricity tariffs all share the annual-average factor.
|
||||
(31, 0.136, "7-hour low rate electricity"),
|
||||
(35, 0.136, "24-hour heating tariff"),
|
||||
# Bulk LPG — SAP 10.2 says 0.241 (file had 0.24 rounded).
|
||||
(2, 0.241, "bulk LPG"),
|
||||
],
|
||||
)
|
||||
def test_co2_factor_matches_sap_10_2_table_12(
|
||||
fuel_code: int, expected_co2_kg_per_kwh: float, fuel_name: str
|
||||
) -> None:
|
||||
# Arrange — table_12.co2_factor_kg_per_kwh is the only CO2 source
|
||||
# in the calculator pipeline (see cert_to_inputs._co2_factor_kg_per_kwh).
|
||||
# Act
|
||||
actual = co2_factor_kg_per_kwh(fuel_code)
|
||||
|
||||
# Assert
|
||||
assert actual == pytest.approx(expected_co2_kg_per_kwh, abs=1e-6), (
|
||||
f"{fuel_name} (code {fuel_code}): expected SAP 10.2 CO2 factor "
|
||||
f"{expected_co2_kg_per_kwh}, got {actual}. See SAP 10.2 PDF p.189."
|
||||
)
|
||||
|
||||
|
||||
def test_default_co2_factor_is_mains_gas_baseline() -> None:
|
||||
# Arrange — unknown fuel codes fall back to mains gas (the SAP 10.2
|
||||
# convention; see table_12._DEFAULT_CO2_KG_PER_KWH).
|
||||
# Act
|
||||
actual = co2_factor_kg_per_kwh(None)
|
||||
|
||||
# Assert
|
||||
assert actual == pytest.approx(0.210, abs=1e-6)
|
||||
|
||||
|
||||
def test_mains_gas_unit_price_unchanged_at_sap_10_2_value() -> None:
|
||||
# Arrange — sanity: prices were already SAP 10.2-correct before P2.4.
|
||||
# This locks that we didn't accidentally regress them while fixing CO2.
|
||||
# Act
|
||||
actual = unit_price_p_per_kwh(1)
|
||||
|
||||
# Assert
|
||||
assert actual == pytest.approx(3.64, abs=1e-6)
|
||||
|
||||
|
||||
def test_standard_electricity_primary_energy_factor_unchanged() -> None:
|
||||
# Arrange — sanity: PE factor for electricity is 1.501 in both SAP
|
||||
# 10.2 and SAP 10.3; locks that P2.4 didn't touch the PEF column.
|
||||
# Act
|
||||
actual = primary_energy_factor(30)
|
||||
|
||||
# Assert
|
||||
assert actual == pytest.approx(1.501, abs=1e-6)
|
||||
253
domain/sap10_calculator/tests/test_table_12a.py
Normal file
253
domain/sap10_calculator/tests/test_table_12a.py
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
"""SAP 10.2 Table 12a — high-rate fractions for off-peak tariffs.
|
||||
|
||||
Locks the `Tariff` enum, the `tariff_from_meter_type` cert resolver,
|
||||
and the per-system / per-use high-rate-fraction lookups against the
|
||||
published SAP10.2 specification at
|
||||
`domain/sap10_calculator/docs/specs/sap-10-2-full-specification-2025-03-14.pdf`, page 191.
|
||||
|
||||
RdSAP10 §19.1 cross-references Table 12a in SAP10.2 for off-peak
|
||||
splitting — the table itself is not duplicated in the RdSAP10 PDF.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import (
|
||||
OtherUse,
|
||||
Table12aSystem,
|
||||
Tariff,
|
||||
other_use_high_rate_fraction,
|
||||
space_heating_high_rate_fraction,
|
||||
tariff_from_meter_type,
|
||||
water_heating_high_rate_fraction,
|
||||
)
|
||||
|
||||
|
||||
def test_tariff_enum_has_five_members() -> None:
|
||||
"""Table 12a columns: standard (no off-peak split), 7-hour, 10-hour,
|
||||
18-hour, 24-hour. Worksheet-shape fidelity: TEN_HOUR is included for
|
||||
spec completeness even though RdSAP10 meter_type enum (1..5) doesn't
|
||||
route to it — see ADR-0010 §3 unreachable-branch policy."""
|
||||
# Arrange
|
||||
# Act
|
||||
members = set(Tariff)
|
||||
|
||||
# Assert
|
||||
assert members == {
|
||||
Tariff.STANDARD,
|
||||
Tariff.SEVEN_HOUR,
|
||||
Tariff.TEN_HOUR,
|
||||
Tariff.EIGHTEEN_HOUR,
|
||||
Tariff.TWENTY_FOUR_HOUR,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"meter_type, expected",
|
||||
[
|
||||
# RdSAP cert meter_type string forms
|
||||
("Single", Tariff.STANDARD),
|
||||
("Standard", Tariff.STANDARD),
|
||||
("Dual", Tariff.SEVEN_HOUR),
|
||||
("Dual (24 hour)", Tariff.TWENTY_FOUR_HOUR),
|
||||
("Off-peak 18 hour", Tariff.EIGHTEEN_HOUR),
|
||||
# Per Q11b: "Unknown" maps to STANDARD (no off-peak heuristic).
|
||||
("Unknown", Tariff.STANDARD),
|
||||
("", Tariff.STANDARD),
|
||||
# Numeric forms (cert sometimes lodges integers per S-B9 finding)
|
||||
(2, Tariff.STANDARD),
|
||||
(1, Tariff.SEVEN_HOUR),
|
||||
(4, Tariff.TWENTY_FOUR_HOUR),
|
||||
(5, Tariff.EIGHTEEN_HOUR),
|
||||
(3, Tariff.STANDARD),
|
||||
# None / missing → STANDARD
|
||||
(None, Tariff.STANDARD),
|
||||
],
|
||||
)
|
||||
def test_tariff_from_meter_type_maps_cert_codes(
|
||||
meter_type: object, expected: Tariff
|
||||
) -> None:
|
||||
"""RdSAP cert `meter_type` field carries either a string or an int
|
||||
enum (1..5). Per Q11b grilling: "Unknown" (code 3) maps to STANDARD
|
||||
rather than the legacy off-peak heuristic — spec-faithful since
|
||||
RdSAP10 has no rule for unresolved tariffs."""
|
||||
# Arrange
|
||||
# Act
|
||||
tariff = tariff_from_meter_type(meter_type)
|
||||
|
||||
# Assert
|
||||
assert tariff is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"system, tariff, expected_fraction, label",
|
||||
[
|
||||
# Integrated storage+direct (storage heaters 408, underfloor 422/423)
|
||||
(Table12aSystem.INTEGRATED_STORAGE_DIRECT, Tariff.SEVEN_HOUR, 0.20, "integrated 408/422/423 7-hr"),
|
||||
# Other storage heaters
|
||||
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.SEVEN_HOUR, 0.00, "other storage 7-hr"),
|
||||
(Table12aSystem.OTHER_STORAGE_HEATERS, Tariff.TWENTY_FOUR_HOUR, 0.00, "other storage 24-hr"),
|
||||
# Electric dry core / water storage boiler / Electricaire
|
||||
(Table12aSystem.ELECTRIC_DRY_CORE_OR_WATER_STORAGE, Tariff.SEVEN_HOUR, 0.00, "electric dry core 7-hr"),
|
||||
# Direct-acting electric boiler
|
||||
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.SEVEN_HOUR, 0.90, "direct-acting boiler 7-hr"),
|
||||
(Table12aSystem.DIRECT_ACTING_ELECTRIC_BOILER, Tariff.TEN_HOUR, 0.50, "direct-acting boiler 10-hr"),
|
||||
# Underfloor heating (above insulation / timber / below floor)
|
||||
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.SEVEN_HOUR, 0.90, "underfloor 7-hr"),
|
||||
(Table12aSystem.UNDERFLOOR_HEATING, Tariff.TEN_HOUR, 0.50, "underfloor 10-hr"),
|
||||
# Ground/water source heat pump — Appendix N calculated
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "GSHP App N 7-hr"),
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.80, "GSHP App N 10-hr"),
|
||||
# GSHP otherwise
|
||||
(Table12aSystem.GSHP_OTHER, Tariff.SEVEN_HOUR, 0.70, "GSHP otherwise 7-hr"),
|
||||
(Table12aSystem.GSHP_OTHER, Tariff.TEN_HOUR, 0.60, "GSHP otherwise 10-hr"),
|
||||
# Air source heat pump — Appendix N
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.80, "ASHP App N 7-hr"),
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.80, "ASHP App N 10-hr"),
|
||||
# ASHP otherwise
|
||||
(Table12aSystem.ASHP_OTHER, Tariff.SEVEN_HOUR, 0.90, "ASHP otherwise 7-hr"),
|
||||
(Table12aSystem.ASHP_OTHER, Tariff.TEN_HOUR, 0.60, "ASHP otherwise 10-hr"),
|
||||
# Other direct-acting electric (incl secondary)
|
||||
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.SEVEN_HOUR, 1.00, "other direct-acting 7-hr"),
|
||||
(Table12aSystem.OTHER_DIRECT_ACTING_ELECTRIC, Tariff.TEN_HOUR, 0.50, "other direct-acting 10-hr"),
|
||||
],
|
||||
)
|
||||
def test_space_heating_high_rate_fraction_matches_table_12a_grid_1(
|
||||
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
|
||||
) -> None:
|
||||
"""Table 12a Grid 1 SH column, verbatim from SAP10.2 PDF page 191.
|
||||
Each (system, tariff) pair pinned to its published high-rate
|
||||
fraction. Tariff columns not listed for a row (e.g. integrated
|
||||
storage at 10-hr) are out-of-domain and raise — covered separately."""
|
||||
# Arrange
|
||||
# Act
|
||||
fraction = space_heating_high_rate_fraction(system, tariff)
|
||||
|
||||
# Assert
|
||||
assert fraction == expected_fraction, (
|
||||
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
|
||||
)
|
||||
|
||||
|
||||
def test_space_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
|
||||
"""STANDARD tariff = no off-peak split. Every system bills 100% at
|
||||
the (single) unit price, so high-rate fraction collapses to 1.0.
|
||||
This is the passthrough path every gas-heated fixture in scope A
|
||||
will exercise."""
|
||||
# Arrange
|
||||
# System choice is irrelevant on STANDARD — pick a representative one.
|
||||
system = Table12aSystem.OTHER_STORAGE_HEATERS
|
||||
|
||||
# Act
|
||||
fraction = space_heating_high_rate_fraction(system, Tariff.STANDARD)
|
||||
|
||||
# Assert
|
||||
assert fraction == 1.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"system, tariff, expected_fraction, label",
|
||||
[
|
||||
# Heat-pump WH (App N + otherwise) — same fractions for 7-hr / 10-hr
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "GSHP App N WH 7-hr"),
|
||||
(Table12aSystem.GSHP_APP_N, Tariff.TEN_HOUR, 0.70, "GSHP App N WH 10-hr"),
|
||||
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "GSHP other off-peak immersion 7-hr"),
|
||||
(Table12aSystem.GSHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "GSHP other off-peak immersion 10-hr"),
|
||||
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "GSHP other no immersion 7-hr"),
|
||||
(Table12aSystem.GSHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "GSHP other no immersion 10-hr"),
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.SEVEN_HOUR, 0.70, "ASHP App N WH 7-hr"),
|
||||
(Table12aSystem.ASHP_APP_N, Tariff.TEN_HOUR, 0.70, "ASHP App N WH 10-hr"),
|
||||
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.SEVEN_HOUR, 0.17, "ASHP other off-peak immersion 7-hr"),
|
||||
(Table12aSystem.ASHP_OTHER_OFF_PEAK_IMMERSION, Tariff.TEN_HOUR, 0.17, "ASHP other off-peak immersion 10-hr"),
|
||||
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.SEVEN_HOUR, 0.70, "ASHP other no immersion 7-hr"),
|
||||
(Table12aSystem.ASHP_OTHER_NO_IMMERSION, Tariff.TEN_HOUR, 0.70, "ASHP other no immersion 10-hr"),
|
||||
],
|
||||
)
|
||||
def test_water_heating_high_rate_fraction_matches_table_12a_grid_1(
|
||||
system: Table12aSystem, tariff: Tariff, expected_fraction: float, label: str
|
||||
) -> None:
|
||||
"""Table 12a Grid 1 WH column, verbatim from SAP10.2 PDF page 191.
|
||||
Heat-pump WH carries 0.70 high-rate by default (or 0.17 when paired
|
||||
with off-peak immersion). Immersion / HP-DHW-only WH (Table 13) and
|
||||
Electric CPSU (Appendix F) are out-of-scope until a fixture lands."""
|
||||
# Arrange
|
||||
# Act
|
||||
fraction = water_heating_high_rate_fraction(system, tariff)
|
||||
|
||||
# Assert
|
||||
assert fraction == expected_fraction, (
|
||||
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
|
||||
)
|
||||
|
||||
|
||||
def test_water_heating_high_rate_fraction_returns_one_for_standard_tariff() -> None:
|
||||
"""STANDARD-tariff passthrough — water heating bills 100% at the
|
||||
single rate."""
|
||||
# Arrange
|
||||
system = Table12aSystem.ASHP_OTHER_NO_IMMERSION
|
||||
|
||||
# Act
|
||||
fraction = water_heating_high_rate_fraction(system, Tariff.STANDARD)
|
||||
|
||||
# Assert
|
||||
assert fraction == 1.0
|
||||
|
||||
|
||||
def test_water_heating_high_rate_fraction_for_immersion_raises() -> None:
|
||||
"""`IMMERSION_OR_HP_DHW_ONLY` sources its fraction from Table 13,
|
||||
which lives in a separate spec section. Defer until first immersion
|
||||
fixture lands (per Q5 deferred list)."""
|
||||
# Arrange
|
||||
system = Table12aSystem.IMMERSION_OR_HP_DHW_ONLY
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(NotImplementedError):
|
||||
water_heating_high_rate_fraction(system, Tariff.SEVEN_HOUR)
|
||||
|
||||
|
||||
def test_water_heating_high_rate_fraction_for_electric_cpsu_raises() -> None:
|
||||
"""`ELECTRIC_CPSU` sources its fraction from Appendix F. Defer until
|
||||
first CPSU fixture lands."""
|
||||
# Arrange
|
||||
system = Table12aSystem.ELECTRIC_CPSU
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(NotImplementedError):
|
||||
water_heating_high_rate_fraction(system, Tariff.TEN_HOUR)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"use, tariff, expected_fraction, label",
|
||||
[
|
||||
(OtherUse.FANS_FOR_MECH_VENT, Tariff.SEVEN_HOUR, 0.71, "fans 7-hr"),
|
||||
(OtherUse.FANS_FOR_MECH_VENT, Tariff.TEN_HOUR, 0.58, "fans 10-hr"),
|
||||
(OtherUse.ALL_OTHER_USES, Tariff.SEVEN_HOUR, 0.90, "all other 7-hr"),
|
||||
(OtherUse.ALL_OTHER_USES, Tariff.TEN_HOUR, 0.80, "all other 10-hr"),
|
||||
],
|
||||
)
|
||||
def test_other_use_high_rate_fraction_matches_table_12a_grid_2(
|
||||
use: OtherUse, tariff: Tariff, expected_fraction: float, label: str
|
||||
) -> None:
|
||||
"""Table 12a Grid 2 (PDF page 191) — "Other electricity uses" sub-
|
||||
table for fans/MV vs all-other-uses-and-locally-generated. Lighting
|
||||
+ pumps + locally-generated PV credit all bill via ALL_OTHER_USES."""
|
||||
# Arrange
|
||||
# Act
|
||||
fraction = other_use_high_rate_fraction(use, tariff)
|
||||
|
||||
# Assert
|
||||
assert fraction == expected_fraction, (
|
||||
f"{label}: expected high-rate fraction {expected_fraction}, got {fraction}"
|
||||
)
|
||||
|
||||
|
||||
def test_other_use_high_rate_fraction_returns_one_for_standard_tariff() -> None:
|
||||
"""STANDARD passthrough."""
|
||||
# Arrange
|
||||
use = OtherUse.ALL_OTHER_USES
|
||||
|
||||
# Act
|
||||
fraction = other_use_high_rate_fraction(use, Tariff.STANDARD)
|
||||
|
||||
# Assert
|
||||
assert fraction == 1.0
|
||||
314
domain/sap10_calculator/tests/test_table_32.py
Normal file
314
domain/sap10_calculator/tests/test_table_32.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""RdSAP10 Table 32 value-correctness tests.
|
||||
|
||||
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`,
|
||||
page 95 (Table 32).
|
||||
|
||||
RdSAP10 §19.1: "The SAP rating for RdSAP 10 is to be calculated using
|
||||
Table 32 prices (not Table 12) for section 10a and 10b." ADR-0010
|
||||
amended to target RdSAP10 for §10a cost following the §10a rewrite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.sap10_calculator.tables.table_12a import Tariff
|
||||
from domain.sap10_calculator.tables.table_32 import (
|
||||
additional_standing_charges_gbp,
|
||||
standing_charge_gbp,
|
||||
unit_price_p_per_kwh,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fuel_code, expected_p_per_kwh, fuel_name",
|
||||
[
|
||||
# Gas fuels
|
||||
(1, 3.48, "mains gas"),
|
||||
(2, 7.60, "bulk LPG"),
|
||||
(3, 10.30, "bottled LPG (main heating)"),
|
||||
(5, 12.19, "bottled LPG (secondary)"),
|
||||
(9, 3.48, "LPG subject to Special Condition 11F"),
|
||||
(7, 7.60, "biogas (including anaerobic digestion)"),
|
||||
# Liquid fuels
|
||||
(4, 7.64, "heating oil"),
|
||||
(71, 7.64, "bio-liquid HVO"),
|
||||
(73, 5.44, "bio-liquid FAME"),
|
||||
(75, 6.10, "B30K"),
|
||||
(76, 47.0, "bioethanol"),
|
||||
# Solid fuels
|
||||
(11, 3.67, "house coal"),
|
||||
(15, 3.64, "anthracite"),
|
||||
(12, 4.61, "manufactured smokeless fuel"),
|
||||
(20, 4.23, "wood logs"),
|
||||
(22, 5.81, "wood pellets (secondary)"),
|
||||
(23, 5.26, "wood pellets (main)"),
|
||||
(21, 3.07, "wood chips"),
|
||||
(10, 3.99, "dual fuel"),
|
||||
# Electricity
|
||||
(30, 13.19, "standard tariff"),
|
||||
(32, 15.29, "7-hour high rate"),
|
||||
(31, 5.50, "7-hour low rate"),
|
||||
(34, 14.68, "10-hour high rate"),
|
||||
(33, 7.50, "10-hour low rate"),
|
||||
(38, 13.67, "18-hour high rate"),
|
||||
(40, 7.41, "18-hour low rate"),
|
||||
(35, 6.61, "24-hour heating tariff"),
|
||||
(60, 13.19, "electricity sold to grid, PV"),
|
||||
# Heat networks — 4.24 p/kWh for the "4.24 group"
|
||||
(51, 4.24, "heat from boilers – mains gas"),
|
||||
(52, 4.24, "heat from boilers – LPG"),
|
||||
(53, 4.24, "heat from boilers – oil"),
|
||||
(54, 4.24, "heat from boilers – coal"),
|
||||
(55, 4.24, "heat from boilers – B30K"),
|
||||
(56, 4.24, "heat from boilers oil/biodiesel"),
|
||||
(57, 4.24, "heat from boilers HVO"),
|
||||
(58, 4.24, "heat from boilers FAME"),
|
||||
(41, 4.24, "heat from electric heat pump"),
|
||||
(42, 4.24, "heat recovered from waste combustion"),
|
||||
(43, 4.24, "heat from boilers – biomass"),
|
||||
(44, 4.24, "heat from boilers – biogas"),
|
||||
# Heat networks 2.97 p/kWh group
|
||||
(45, 2.97, "heat recovered from power station"),
|
||||
(46, 2.97, "low grade heat recovered from process"),
|
||||
(47, 2.97, "heat recovered from geothermal / natural"),
|
||||
(48, 2.97, "heat from CHP"),
|
||||
(49, 2.97, "high grade heat recovered from process"),
|
||||
],
|
||||
)
|
||||
def test_table_32_unit_prices_match_rdsap10_pdf_page_95(
|
||||
fuel_code: int, expected_p_per_kwh: float, fuel_name: str
|
||||
) -> None:
|
||||
"""RdSAP10 Table 32 unit prices, sourced verbatim from PDF page 95.
|
||||
These differ from SAP10.2 Table 12 by carrier (mains gas 3.64→3.48,
|
||||
heating oil 4.94→7.64, std electricity 16.49→13.19, etc.) — see
|
||||
`tables/table_32.py` docstring for the spec citation."""
|
||||
# Arrange
|
||||
# Act
|
||||
actual = unit_price_p_per_kwh(fuel_code)
|
||||
|
||||
# Assert
|
||||
assert actual == expected_p_per_kwh, (
|
||||
f"{fuel_name} (code {fuel_code}): expected Table 32 price "
|
||||
f"{expected_p_per_kwh} p/kWh, got {actual}"
|
||||
)
|
||||
|
||||
|
||||
def test_mains_gas_unit_price_is_3_48_p_per_kwh() -> None:
|
||||
"""RdSAP10 Table 32 (PDF page 95) lists mains gas at 3.48 p/kWh. The
|
||||
SAP 10.2 Table 12 value (3.64 p/kWh) is ~5% higher; switching to
|
||||
Table 32 is part of the §10a rewrite per ADR-0010 amendment."""
|
||||
# Arrange
|
||||
# Table 32 fuel code 1 = mains gas.
|
||||
fuel_code = 1
|
||||
|
||||
# Act
|
||||
price = unit_price_p_per_kwh(fuel_code)
|
||||
|
||||
# Assert
|
||||
assert price == 3.48
|
||||
|
||||
|
||||
def test_unit_price_translates_api_fuel_enum_via_api_fuel_to_table_32() -> None:
|
||||
"""Cert payloads carry the gov API `main_fuel_type` enum (e.g. 0 =
|
||||
electricity), not Table 32 codes directly. `unit_price_p_per_kwh`
|
||||
accepts either form and translates the API enum via
|
||||
`API_FUEL_TO_TABLE_32`. The API enum stays stable across SAP10.2 ↔
|
||||
RdSAP10 so the mapping is the same shape as `table_12.API_FUEL_TO_TABLE_12`.
|
||||
|
||||
API enum 0 → Table 32 code 30 (standard electricity, 13.19 p/kWh).
|
||||
Picked because it's distinct from the default mains gas fallback
|
||||
(3.48), so the test actually exercises the translation path."""
|
||||
# Arrange
|
||||
api_main_fuel_type_electricity = 0
|
||||
|
||||
# Act
|
||||
price = unit_price_p_per_kwh(api_main_fuel_type_electricity)
|
||||
|
||||
# Assert
|
||||
assert price == 13.19
|
||||
|
||||
|
||||
def test_unit_price_defaults_to_mains_gas_when_code_is_none() -> None:
|
||||
"""Mirrors `table_12.unit_price_p_per_kwh` behaviour: unknown / missing
|
||||
fuel codes fall back to mains gas. cert_to_inputs occasionally has to
|
||||
resolve a price for a cert with a missing main_fuel_type."""
|
||||
# Arrange
|
||||
fuel_code = None
|
||||
|
||||
# Act
|
||||
price = unit_price_p_per_kwh(fuel_code)
|
||||
|
||||
# Assert
|
||||
assert price == 3.48
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fuel_code, expected_standing_gbp, fuel_name",
|
||||
[
|
||||
# Gas fuels with standing charge
|
||||
(1, 120.0, "mains gas"),
|
||||
(2, 70.0, "bulk LPG"),
|
||||
(9, 120.0, "LPG subject to Special Condition 11F"),
|
||||
(7, 70.0, "biogas"),
|
||||
# Liquid + solid have no standing charge
|
||||
(4, 0.0, "heating oil"),
|
||||
(11, 0.0, "house coal"),
|
||||
(20, 0.0, "wood logs"),
|
||||
# Electricity tariffs
|
||||
(30, 54.0, "standard tariff"),
|
||||
(32, 24.0, "7-hour high rate"),
|
||||
(34, 23.0, "10-hour high rate"),
|
||||
(38, 40.0, "18-hour high rate"),
|
||||
(35, 70.0, "24-hour heating tariff"),
|
||||
# Low-rate codes themselves carry no standing — the high-rate row
|
||||
# carries the off-peak meter standing per Table 32 note (a).
|
||||
(31, 0.0, "7-hour low rate"),
|
||||
(33, 0.0, "10-hour low rate"),
|
||||
(40, 0.0, "18-hour low rate"),
|
||||
# PV export is a credit code — no standing
|
||||
(60, 0.0, "electricity sold to grid PV"),
|
||||
# Heat networks
|
||||
(51, 120.0, "heat networks default (note (l))"),
|
||||
],
|
||||
)
|
||||
def test_standing_charges_match_rdsap10_table_32_pdf_page_95(
|
||||
fuel_code: int, expected_standing_gbp: float, fuel_name: str
|
||||
) -> None:
|
||||
"""RdSAP10 Table 32 standing-charge column, PDF page 95. Only fuels
|
||||
with a published charge are pinned to non-zero; the rest return 0.0.
|
||||
Heat networks share the £120/yr default per note (l) — DHW-only on
|
||||
heat network would carry half (£60/yr) but that's an `additional_
|
||||
standing_charges_gbp` concern, not raw-row data."""
|
||||
# Arrange
|
||||
# Act
|
||||
actual = standing_charge_gbp(fuel_code)
|
||||
|
||||
# Assert
|
||||
assert actual == expected_standing_gbp, (
|
||||
f"{fuel_name} (code {fuel_code}): expected standing £{expected_standing_gbp}/yr, "
|
||||
f"got £{actual}/yr"
|
||||
)
|
||||
|
||||
|
||||
def test_mains_gas_standing_charge_is_120_gbp_per_yr() -> None:
|
||||
"""RdSAP10 Table 32 (PDF page 95) lists mains gas at £120/yr standing
|
||||
charge. Table 12 note (a) gates this into (251) when gas is used for
|
||||
space or water heating — applies to all 6 gas-heated fixtures and
|
||||
is the dominant missing line behind the 000490 cost gap."""
|
||||
# Arrange
|
||||
fuel_code = 1
|
||||
|
||||
# Act
|
||||
standing = standing_charge_gbp(fuel_code)
|
||||
|
||||
# Assert
|
||||
assert standing == 120.0
|
||||
|
||||
|
||||
# Table 12 note (a) — for SAP rating / regulated:
|
||||
# - Std electricity standing → omitted
|
||||
# - Off-peak electricity standing → added if any off-peak in use
|
||||
# - Gas standing → added if gas used for space or water heating
|
||||
# `additional_standing_charges_gbp` applies this gating to (251).
|
||||
|
||||
|
||||
def test_additional_standing_charges_includes_gas_when_gas_main_heating() -> None:
|
||||
"""Note (a) clause: gas standing is added when gas is used for space
|
||||
heating (main or secondary) or water heating. 6-fixture corpus all
|
||||
hit this clause — mains gas main + mains gas HW → £120/yr."""
|
||||
# Arrange
|
||||
main_fuel_code = 1 # mains gas
|
||||
water_heating_fuel_code = 1 # mains gas
|
||||
tariff = Tariff.STANDARD
|
||||
|
||||
# Act
|
||||
standing = additional_standing_charges_gbp(
|
||||
main_fuel_code=main_fuel_code,
|
||||
water_heating_fuel_code=water_heating_fuel_code,
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert standing == 120.0
|
||||
|
||||
|
||||
def test_additional_standing_charges_omits_std_electricity_standing() -> None:
|
||||
"""Note (a) clause: standard-electricity standing (£54/yr code 30)
|
||||
is omitted from the SAP rating ECF. Direct-acting electric main +
|
||||
immersion HW on standard tariff → £0/yr."""
|
||||
# Arrange
|
||||
main_fuel_code = 30 # std electricity
|
||||
water_heating_fuel_code = 30 # std electricity
|
||||
tariff = Tariff.STANDARD
|
||||
|
||||
# Act
|
||||
standing = additional_standing_charges_gbp(
|
||||
main_fuel_code=main_fuel_code,
|
||||
water_heating_fuel_code=water_heating_fuel_code,
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert standing == 0.0
|
||||
|
||||
|
||||
def test_additional_standing_charges_adds_off_peak_electricity_standing() -> None:
|
||||
"""Note (a) clause: off-peak electricity standing (£24/yr code 32 for
|
||||
E7 high rate) is added whenever an off-peak tariff is in use. The
|
||||
standing lives on the high-rate Table 32 code per the table layout."""
|
||||
# Arrange
|
||||
main_fuel_code = 32 # 7-hour high rate
|
||||
water_heating_fuel_code = 32
|
||||
tariff = Tariff.SEVEN_HOUR
|
||||
|
||||
# Act
|
||||
standing = additional_standing_charges_gbp(
|
||||
main_fuel_code=main_fuel_code,
|
||||
water_heating_fuel_code=water_heating_fuel_code,
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert standing == 24.0
|
||||
|
||||
|
||||
def test_additional_standing_charges_includes_gas_when_only_water_heating_uses_gas() -> None:
|
||||
"""Note (a) "or water heating" clause: gas HW with non-gas main still
|
||||
triggers the gas standing charge. Direct-acting electric main + gas
|
||||
HW on standard tariff → £120/yr (gas) + £0/yr (std elec)."""
|
||||
# Arrange
|
||||
main_fuel_code = 30 # std electricity
|
||||
water_heating_fuel_code = 1 # mains gas
|
||||
tariff = Tariff.STANDARD
|
||||
|
||||
# Act
|
||||
standing = additional_standing_charges_gbp(
|
||||
main_fuel_code=main_fuel_code,
|
||||
water_heating_fuel_code=water_heating_fuel_code,
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert standing == 120.0
|
||||
|
||||
|
||||
def test_additional_standing_charges_zero_for_oil_only() -> None:
|
||||
"""Heating oil has no standing charge in Table 32. Oil main + oil HW
|
||||
on standard tariff → £0/yr (note (a) gas rule doesn't fire; std elec
|
||||
omitted regardless)."""
|
||||
# Arrange
|
||||
main_fuel_code = 4 # heating oil
|
||||
water_heating_fuel_code = 4 # heating oil
|
||||
tariff = Tariff.STANDARD
|
||||
|
||||
# Act
|
||||
standing = additional_standing_charges_gbp(
|
||||
main_fuel_code=main_fuel_code,
|
||||
water_heating_fuel_code=water_heating_fuel_code,
|
||||
tariff=tariff,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert standing == 0.0
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue