Skip to content

Calibration

A FastModel has hundreds of parameters, most of them settled by the YAML (areas, orientations, the optical fits of the glazing) or not identifiable from a few air temperatures. Calibrator adjusts groups of parameters instead, each by one decision variable:

Group Parameters Mode, range
envelope_conductance conductances of the wall, floor and roof layers (G[i]), of the glazing gaps and frames factor 0.25-4
envelope_capacity capacities of the layers (C[i]) and of the panes factor 0.25-4
air_capacity the heat capacity factor of the air of each zone (mSenFac, furniture) factor 1-10
infiltration the air changes per hour of each zone (ACH, 0 in Trano) value 0-3 1/h
boiler_efficiency the nominal efficiency of the boilers (from the fuel meter) factor 0.7-1.1

OPTIONAL_GROUPS adds the solar transmittance of the windows and the capacity of the radiators, which the air temperatures hardly identify on their own. A ParameterGroup is a set of regular expressions on the parameter names, a mode (scale: the Trano values times a common factor; value: one common value) and bounds; only the parameters the equations depend on are adjusted (the conductances and capacities trano computed from the layers, not the layer data). The heat output of the radiators and the performance maps of the heat pumps are settled when rumoca translates the model and cannot be calibrated.

from mpctrano import Calibrator, CalibrationSettings

calibrator = Calibrator(model, CalibrationSettings(temperature_std=0.1, meter_std=0.05))
calibrated, result = calibrator.fit(training, validation)
result.factors       # the value of each group
result.metrics()     # RMSE and MAE of the air temperatures, training and validation, before and after

The problem is solved with IPOPT by one of two methods (CalibrationSettings.method, auto by default):

  • collocation, for a model with control inputs: the Radau collocation of the model over the whole period, the parameters symbolic. The states of every step are decision variables bound by the dynamics, so that a long period and poor initial parameters stay well conditioned, and the unmeasured states (walls, water) are free at the start.
  • shooting, for a closed-loop model (the controllers of the YAML inside, no control input): the groups are the only decision variables; the model is simulated with CVODES inside the objective from the state its own controllers reach before the data (spin_up), and IPOPT gets the gradient from the adjoint sensitivities of the integrator, with a quasi-Newton (L-BFGS) Hessian. The thermostats and hysteresis of such a model make its collocated dynamics non-smooth (IPOPT then crawls without converging), where a simulation follows them in a second.

The objective weighs the errors of the air temperatures (temperature_std), of the energy metered over each step (meter_std) and the deviation of each group from the Trano value (prior_std, regularization). The validation period is simulated from where the training ends.

On the boiler house of the tests, with the conductances x1.3, the capacities x0.8, the air capacity x3, 0.5 air changes per hour and the boiler efficiency x0.9, four days recover x1.35, x0.80, x2.74, 0.47 and x0.904 in about 30 s; the air temperature error falls from 4.5 K to 0.06 K (0.07 K on two validation days). With a single day, the conductances and the infiltration trade with each other: both are losses to the outdoor air.

evaluate(model, data, x0) and simulate_fit(model, data, x0) give the open-loop fit of any model; Project.save() writes the calibration to <stem>.calibration.json and Project.load_calibrated() reads it back.

Smoothing the controllers

The thermostats, hysteresis and limiters of a Trano FastModel are steep smooth switches (trano >= 0.59 exposes their widths dU, dY, dMax, dT, dtSet and the switching time tau as parameters). CVODES follows them, but each switch that moves by a sample under a parameter change makes the objective jumpy at the 0.1 % scale, which is what forces the wide finite differences of the shooting method. FastModel.smooth_controllers(width_factor=5.0, switching_time=600.0) returns a copy whose transitions are five times wider and whose hysteresis switches over ten minutes; calibrate that copy and apply the factors to the sharp model.

On the single-zone house of the tests (truth k = 0.06 W/(m.K), known infiltration, three days of data with 0.1 K of noise, four cores):

Controllers Smoothed truth vs sharp data Shooting Collocation
sharp (trano) 0.09 K (the noise) 211 s, 17 it, k = 0.0594 no convergence
x1, 600 s 0.23 K 22 s, 8 it, k = 0.0531 no convergence
x5, 600 s 0.14 K 14 s, 7 it, k = 0.0594 no convergence
x10, 600 s 0.27 K 11 s, 7 it, k = 0.0448 72 s, 48 it, k = 0.0448
x20, 600 s 0.42 K 9 s, 5 it, k = 0.025 130 s, 37 it, k = 0.025 (the bound)

The switching time is what smooths the objective (the sharp model and x1 differ only by it); the width is what costs fidelity, and from x10 on it smears the thermostat band enough to bias the conductances low. Collocation converges only on controllers too smooth to be faithful, so the shooting method on the x5, 600 s copy is the setting to use.

Parameters declared in the YAML

The YAML of the building can declare the parameters to calibrate itself, so that it stays the single source of truth: a numeric field written as a mapping with its nominal value and its bounds, instead of the bare number Trano expects.

material:
  - id: ROCKWOOL:CAL
    thermal_conductivity: {value: 0.04, parameter: true, min: 0.025, max: 0.08}
constructions:
  - id: CAVITYWALL:CAL
    layers:
      - material: ROCKWOOL:CAL
        thickness: {value: 0.1, parameter: true, min: 0.04, max: 0.2}
spaces:
  - id: SPACE:001
    variant: infiltration
    parameters:
      ach: {value: 0.3, parameter: true, min: 0.05, max: 1.5}

CalibrationSpec reads the file, addresses the fields by path (the convention of trano.runtime: material[ROCKWOOL:CAL].thermal_conductivity, spaces[SPACE:001].parameters.ach) and writes the flat copy Trano reads. Which parameters of the CasADi model a field moves is measured, not assumed: the model is regenerated with the field raised by 20 % and the exponent of every parameter that moved is read (a conductivity moves the conductances of its layers with the exponent 1, a thickness moves them with -1 and the node capacities with a small exponent). The calibration then adjusts the field within its bounds and every dependent parameter with it (YamlGroup); the factors of fields that move the same parameter compound (a conductivity and a thickness both move the conductance of the insulation layer).

from mpctrano import CalibrationSettings, CalibrationSpec, Calibrator, generate_closed_loop_data, spin_up

spec = CalibrationSpec.from_yaml("house.yaml")
model = spec.model("FastModel")                     # the closed loop: the controllers of the YAML, no control input
groups = spec.groups(model, "FastModel")            # one regeneration per declared field
calibrated, result = Calibrator(model, CalibrationSettings(groups=groups)).fit(data)
result.factors                                      # {"material[ROCKWOOL:CAL].thermal_conductivity": 0.058, ...}

The data of a closed-loop model holds no control: the disturbances, the measured air temperatures and the meter readings are enough, as a monitoring system that logs the sensors and the meters but not the valves records. generate_closed_loop_data records such data on a true building, for instance the YAML with other values of its parameters (spec.model("FastModel", {"...ach": 0.6})), with sensor and meter noise, which is how the tests check that the calibration recovers them.

Trano's own tools do not read the annotations (its validation rejects unknown keys); mpctrano writes the flat copy before calling it. A value / parameter / min / max convention in Trano's schema would make the annotated file valid everywhere.