buildingcalibration
District-scale static calibration, validation and representative-district clustering for the building-energy model family.
It covers the annual (non-hourly) half of the modelling chain:
- Calibration — the annual static calibration loop that fits a building
stock's energy model over
buildingmodel's static inference. - Validation — the Eq. 5 error decomposition of simulated versus measured
district consumption (ORE / Enedis), read through
buildingdata. - Clustering — reduction of a national building stock to representative districts, plus screening of districts whose measured data cannot support calibration.
- Plots — the figures for both stages.
Status
0.2.0is released (tagged and on PyPI).0.1.0shipped the modules extracted frombuilding_eload's core —core/static_simulation,core/validation,core/clustering.py,core/unreliable_districts.py,core/building_loader.py,utils/district_list.py,plots/static_calibration.py,plots/validation.py.0.2.0added the two API items the paper-reproduction workflow was missing —build_representative_districts/write_representative_districtsandrun_validation_sweep/average_over_years— plusplots.clustering. See the CHANGELOG for the full history.
Tutorials
doc/tutorials/ has two worked examples, rebuilt against the current API
(not carried over from building_eload's retired copies):
tutorial_01_static_simulation.ipynb—StaticParameters/StaticSimulation/StaticResultsfor one district.tutorial_03_validation.ipynb— district clustering (cluster_districts,build_representative_districts/write_representative_districts) thenValidationagainst measured consumption, plusrun_validation_sweep/average_over_yearsfor parametric studies.
The clustering steps run on synthetic data with no network needed; steps
that need real BDTOPO/census/ORE/Enedis data or a real dynamic-simulation
run are marked and left unexecuted. building_eload's own
doc/tutorials/tutorial_00_data_download.ipynb and
tutorial_02_dynamic_simulation.ipynb cover reference-data acquisition and
the hourly stage these two hand off to/from.
Quick start
Calibrate one district (static, annual)
from buildingcalibration import StaticParameters, StaticSimulation
parameters = StaticParameters(
n_inference=5, # stochastic building-stock draws to average over
calibration_year=2023,
climate_year=2023,
output_root="results", # -> results/static_simulation/2023/
)
results = StaticSimulation("751010101", parameters).run()
results.save_results()
Nothing else is needed: the building footprints (BDTOPO), the IRIS district
layer and the ORE per-IRIS annual consumption the calibration fits against are
all fetched through buildingdata. Pass calibration_file= (a frame or a path)
to pin a vintage, or building_footprint_folder= to use a local BDTOPO mirror.
Fit three parameters jointly instead of one (issue #1)
The paper's Eq. 4 fits one axis, the heating setpoint. Two more knobs now exist
on buildingmodel's Parameters — energy_use_factor and
second_home_occupation_factor — and sweeping all three exhaustively costs
n³ district runs. joint_calibration=True instead sweeps each axis one at
a time (3n runs), builds a response surface from those arms and solves for
the combination minimising distance-to-measured plus a quadratic penalty on
deviation from the defaults — Tikhonov regularisation, i.e. the MAP estimate
under a Gaussian prior centred on the defaults, which is Eq. 4's C_factor
term generalised from one parameter to three:
from buildingcalibration import StaticParameters, StaticSimulation, default_spec
parameters = StaticParameters(
n_inference=5,
calibration_year=2023,
joint_calibration=True, # opt-in; the default is still Eq. 4
joint_calibration_spec=default_spec(
residual_scale=0.05, # assumed relative error on the measurement
setpoint_spread=1.0, # prior sigma, in degC
energy_use_factor_spread=0.1,
second_home_factor_spread=0.1,
),
)
simulation = StaticSimulation("751010101", parameters)
simulation.run()
print(simulation.joint_fit.describe())
Illustrative output (the numbers below are from the synthetic district in
tests/calibration/test_joint_calibration.py, over-predicting by 15 %, not from
a real run):
Joint calibration fit (3 fitted of 3 axes, data rank 1):
actual_heating_set_point 18.3396 (-0.66 sigma, sensitivity +0.0527/sigma, data share 36%)
energy_use_factor 0.9631 (-0.37 sigma, sensitivity +0.0294/sigma, data share 11%)
second_home_occupation_factor 0.2514 (-0.49 sigma, sensitivity +0.0388/sigma, data share 20%)
relative gap 0.1500 (baseline) -> 0.0356 (fitted)
verification re-run vs surrogate: <recorded by run(), see below>
! The data is one scalar (the district's measured annual total) against 3
parameters, so only 1 direction is constrained by it; the rest of the fitted
combination is the prior. ...
Three things about that output are load-bearing:
- The prior spread is per parameter. A single coefficient would trade the
axes against each other by their raw sensitivities, so the O(0.1) factors —
which move the district total far more per unit than a degree of setpoint
does — absorb the whole residual while the setpoint stays pinned.
sigmamakes "one unit of surprise" mean the same thing on each axis. It is a stated assumption;scan_regularisation_weight()traces the fit/prior trade-off. - Regularisation buys well-posedness, not identifiability. One measured
annual total against three parameters is rank 1: the solve always returns a
stable answer, and everything orthogonal to that one direction is the prior
speaking.
data share(the posterior variance reduction) is the honest number to report next to each fitted value — 0 % means the "fit" returned the prior centre. Reportjoint_fit.warningswith the values or they will be read as measurements. - The fit is verified by re-simulation. The surface composes the
one-at-a-time arms under an explicit assumption about how the heating effects
combine;
run()re-runs the demand chain at the fitted combination and records the discrepancy, which also leaves the shipped tables at the parameters that were actually fitted.refinement_stepsfeeds that residual back as the next Gauss-Newton step, correcting the surface's heating response along the baseline-to-fit direction (off by default: it converges on the synthetic truth of the test suite and has not been re-measured on real districts, and each pass costs one re-run).
Choose how the stochastic draws enter the calibration
With n_inference > 1 the static stage sees a grid point × draw table, and
reducing it to one answer is a modelling choice the two paths used to make
differently without saying so: Eq. 4 picked the single best (point, draw)
row, the joint fit averaged the draws first. draw_aggregation makes the
choice explicit and gives both paths all three options:
draw_aggregation |
what it does | Eq. 4 | joint |
|---|---|---|---|
"select_row" |
the (point, draw) row minimising the objective wins; for the joint fit, the best of one solve per draw. Selects on noise — the reported gap is biased low. |
published default | |
"average_draws" |
average the totals over draws at each point, then rank the grid / fit the surface. The unbiased estimator of the parameter. | default | |
"average_fits" |
calibrate each draw on its own, then average the answers. Also reports their spread across draws (fitted_<axis>_draw_spread, JointFit.draw_spread) — the stock-stochasticity uncertainty on the fit. |
parameters = StaticParameters(
n_inference=10,
calibration_year=2023,
draw_aggregation="average_fits", # on either path
)
Left unset, each path keeps its historical rule, so switching
joint_calibration on or off also switches the draw rule; pass
draw_aggregation explicitly to compare the two paths on the same estimator.
Whatever the strategy, exactly one row of district_calibration is flagged
best_simulation: the carrier, the already-simulated stock the shipped
tables are filtered to, at the swept point nearest the answer and — under the
averaging strategies — on the draw closest to the ensemble mean there. When
the answer is off the grid (a joint fit, or an averaged Eq. 4 setpoint) the
carrier is re-simulated at it, and the tables are tagged with the calibrated
values (StaticSimulation.calibrated_values). The selection is deterministic
at a fixed seed; the joint path's previous rule was not (every draw at the
nearest point tied, and head(1) on an unordered group picked one).
Validate reconstructed load curves against measurements
from buildingcalibration import Validation
validation = Validation(
year=2023,
n_clusters=20,
scope="national",
building_type="residential",
input_path="results/dynamic_simulation/2023", # hourly parquets
output_path="results/validation/2023",
clustering_file=clustering_frame, # frame or path
unreliable_districts=unreliable_iris_frame, # frame, ids or path
)
validation.run()
error = validation.get_error() # paper Eq. 5 terms
The measured Enedis load curves come from buildingdata
(get_enedis_national() / get_enedis_regional()) when
validation_residential_file= is left unset.
Reduce a stock to representative districts
from buildingcalibration import cluster_districts
from buildingcalibration.clustering import screen_unreliable_iris_from_parquet
unreliable = screen_unreliable_iris_from_parquet("enedis_iris_consumption.parquet")
result = cluster_districts(
district_data, # one row per district
n_clusters=20,
feature_columns=["heating_needs", "dhw_needs", "specific_needs"],
unreliable_ids=unreliable["code_iris"], # never pick these as medoids
seed=42,
)
result.medoid_ids # the districts to simulate
result.scaling_weights # count-based multiplier per medoid
Publish the representative-districts table the rest of the chain reads
cluster_districts() answers one cut at a time; the validation stage and the
medoid selectors read a single wide table holding several cuts side by side
(cluster_label_<key> / cluster_medoid_<key> per cut, plus the district
attributes). build_representative_districts() is the converter and
write_representative_districts() the thin writer:
from buildingcalibration import write_representative_districts
from buildingcalibration.clustering import (
cluster_districts,
cluster_key_from_fraction,
representative_districts_file,
)
results = {n: cluster_districts(features, n_clusters=n, seed=42) for n in (10, 20)}
write_representative_districts(
results,
features, # one row per district
representative_districts_file("results/clustering", scope="national"),
)
Key convention — the column suffix is always a positive integer: the
cluster count for a national table (cluster_medoid_20), the integer
percent of districts kept for a regional one (cluster_medoid_5 is 5 %).
Fractions are rejected, not guessed: convert once with
cluster_key_from_fraction(0.05) -> 5. representative_districts_file() is the
single definition of the conventional file name, shared with the readers
(get_representative_districts(), Validation(clustering_path=...)), so a
table can never be written under one name and looked up under another.
Sweep a parameter and decompose the error per variant
from buildingcalibration import average_over_years, run_validation_sweep
frame = run_validation_sweep(
{
f"heating_threshold={value:g}": [
{"year": year, "input_path": f"results/dynamic/{value:g}/{year}"}
for year in (2021, 2022, 2023)
]
for value in (14.0, 15.0, 16.0)
},
common={"n_clusters": 20, "scope": "national", "clustering_file": table,
"unreliable_districts": unreliable},
)
# frame: long, one row per (label, year, metric) -- the full Eq. 5 terms
average_over_years(frame) # one row per (label, metric)
Each case carries its own inputs (frames or explicit paths); the sweep invents no path and writes nothing.
Doctrine: frames first, no path registry
Nothing in this package resolves a filesystem path at import time, and there is
no data/ tree to install:
- Pipeline intermediates — dynamic-simulation results, clustering tables,
the unreliable-district list, output directories — are passed in as in-memory
frames or as explicit paths, and have no default. A missing one raises a
ValueErrornaming the parameter rather than reading from somewhere you did not choose. - External open data — BDTOPO footprints, IRIS districts, ORE annual
consumption, Enedis measured load curves — defaults to a
buildingdatagetter (get_bdtopo(),get_districts(),get_ore(),get_enedis_national()/get_enedis_regional()), which owns the download, the cache and the vintage. Pass a frame or a path to pin a specific vintage instead.
The one exception is the SDES parc résidentiel workbook read by
plots.validation.read_sdes_data(): buildingdata has no getter for it yet, so
it is a required frame-or-path argument (and reading the .xlsx form needs
openpyxl, which is not a declared dependency).
Relationship to building_eload
buildingcalibration and building_eload have no import relationship in
either direction. building_eload becomes a pure hourly dynamic-simulation
library; this package owns the static/annual side. The two are coupled only by
parquet data contracts on disk — a calibrated stock written here is read
there, and vice versa. That seam already existed inside the old monolith; the
split just makes it a package boundary. A structural test
(tests/test_data_path_doctrine.py) enforces both halves of that: no path
registry, and no building_eload import anywhere in the package.
The family
| Package | Role | Host |
|---|---|---|
buildingdata |
dataset access layer: BDTOPO/WFS, ERA5, INSEE census, Enedis/ORE, ELMAS | gitlab.com/energytransition |
buildingmodel |
static inference engine: building-stock physical characteristics and annual demand | gitlab.com/energytransition |
heatpumpmodel |
shared heat-pump seasonal-performance physics (Rogeau et al. 2024) | git.persee |
buildingcalibration |
static calibration, validation, representative-district clustering | git.persee |
building_eload |
hourly dynamic simulation of district electric load | git.persee |
building_eload_paper |
Snakemake reproduction workflow for the published paper; pinned to building_eload==0.4.3 and unaffected by this split |
git.persee |
Install
pip install buildingcalibration
# or, from a checkout:
pip install -e ".[dev]"
Python 3.10–3.13.
Tests
pytest # full suite
pytest -m "not integration" # hermetic subset, what CI runs
The hermetic subset needs no data and no network. The integration tests run
the calibration and validation stages on real districts; they read a local
reference-data tree, <repo>/data by default, overridable with the
BUILDING_ELOAD_DATA environment variable (the name is shared with
building_eload on purpose, so one setting covers both checkouts).
One integration test — tests/integration/test_static_to_dynamic_seam.py,
the cross-package end-to-end seam — additionally needs building_eload
installed. It is an optional test-time requirement only: building_eload is
not a runtime dependency, not part of the dev extra, and the library itself
must never import it (a structural test enforces that). Install it yourself if
you want to run that test; otherwise it skips.
Licence
MIT — see LICENSE.
Metadata
Release files for buildingcalibration 0.6.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| buildingcalibration-0.6.3.tar.gz | 151.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| buildingcalibration-0.6.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 313.5 kB
Release files / buildingcalibration-0.6.3.tar.gz
| Download URL | buildingcalibration-0.6.3.tar.gz |
|---|---|
| Size | 151.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
96925e49d1d5c6a0e7a4e34fd32ff56e346fa2f22a55154f61a2adf1323dd60c
|
|
BLAKE2b-256 checksum How to use checksums |
7c81234289b0e31ed7e8709c3538390e9e869f66d1c144e9a76fae534cc6b67b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / buildingcalibration-0.6.3-py3-none-any.whl
| Download URL | buildingcalibration-0.6.3-py3-none-any.whl |
|---|---|
| Size | 162.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
9d47cc0516695806e3167bef446f1834125736b99e21134d6a7eb57bfd216367
|
|
BLAKE2b-256 checksum How to use checksums |
32c06ad1c052da61722efca83201caad3d06bb3e83f67d2c2c46f92ba15854ec
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|