Skip to content

IPMVP Option D — calibrated simulation

CAMBER's other M&V options are inverse models: they regress measured energy on temperature (change-point, TOWT, degree-day) and can only report savings after a measure is implemented. Option D answers the counterfactual — "what would this building use if we fixed the control?" — with a forward building model calibrated to metered data, then run under the corrected control. camber.mandv.rc_model is a dependency-light, clean-room implementation (numpy only; ISO 13790 simple-hourly / ASHRAE inverse-modeling lineage). It completes the IPMVP set: A, B, C, and now D all ship.

Calibrate a forward 1R1C model to metered energy, gate it on G14, then re-run the same model under as-found vs as-corrected control for a modeled saving.

flowchart LR
    oat["oat"] --> cal["calibrate"]
    sched["schedule"] --> cal
    met["metered_energy"] --> cal
    cal -- "grid tau + OLS" --> model["RCModel(ua_eff, gain_eff, tau)"]
    cal --> gate{"G14 gate: CV(RMSE) <= 30%"}
    gate -- "accept" --> run["predict: as-found vs as-corrected"]
    model --> run
    run --> sv["option_d_savings"]
    sv --> out["avoided_energy + G14 FSU band"]
    gate -- "reject" --> none["no saving claimed"]

The model — a 1R1C grey box

RCModel(ua_eff, gain_eff, tau) is a single-zone, single-time-constant thermal model:

  • ua_eff — effective conductance (metered energy per °F of setpoint–OAT gap per hour),
  • gain_eff — effective internal + solar gain offset (metered energy per conditioned hour),
  • tau — the free-float / recovery time-constant (hours).

model.predict(oat, schedule) returns the hourly HVAC energy under a control schedule. During conditioned hours the zone is held at setpoint (energy ∝ ua_eff·(setpoint − OAT) − gain_eff); during setback it free-floats toward outdoor air with time-constant tau, and re-entry adds recovery degree-hours. Because it takes a schedule as input, the same calibrated model can be re-run under a different (as-corrected) control — that's the Option-D capability the inverse models lack.

Build a schedule with daily_schedule(index, occ_setpoint=…, setback_setpoint=…, occ_start=…, occ_end=…) (or pass your own {"setpoint": …, "conditioned": …} arrays).

Identifiability: energy alone can't separate conductance from HVAC efficiency, so the calibrated parameters are the effective combinations — enough for the counterfactual, which only re-runs the schedule.

Calibration — grid τ, OLS the rest, gate on G14

from camber.mandv.rc_model import calibrate

cal = calibrate(oat, schedule, metered_energy)  # -> Calibration(model, fit, accept)
cal.accept  # met the ASHRAE Guideline 14 acceptance gate?
cal.fit.cv_rmse  # hourly CV(RMSE)

Calibration mirrors the change-point fitter (camber.mandv.models): the single nonlinear parameter tau is grid-searched (coarse then refined), and for each tau the linear ua_eff/gain_eff are solved by OLS — no scipy. Acceptance is the existing G14 gate (stats.fit_stats + cv_rmse_max_for("hourly") = CV(RMSE) ≤ 30%). Calibration is deterministic (validation.check_determinism).

Modeled savings — as-found vs as-corrected

from camber.mandv.rc_model import option_d_savings

sv = option_d_savings(cal, oat, as_found_schedule, as_corrected_schedule)
sv.avoided_energy  # modeled avoided energy (None if the calibration failed the gate)
sv.fractional_savings  # avoided / as-found
sv.frac_savings_uncertainty  # ASHRAE G14 Annex-B fractional savings uncertainty
sv.basis  # "IPMVP Option D (calibrated simulation)"  |  a not-claimed note
sv.valid  # calibration met the G14 gate

The savings are the difference of the calibrated model's annual profiles under the two schedules, with a G14 Annex-B uncertainty band from the calibration CV(RMSE). If the calibration fails the acceptance gate, no saving is claimed (valid=False, avoided_energy=None) — the same refuse-to-fabricate posture as fault_economics (costed) and ecm_savings's upper bound. mandv.ecm_savings.modeled_savings is the same call, framed as the pre-implementation counterpart to that upper bound.

Depth — 2R2C, multi-zone, and an EnergyPlus cross-check

The 1R1C model above is the minimal, citable core. Three depth options build on it, each preserving the same honesty invariant (grid the nonlinear time-constant(s), OLS the linear conductances/gains, gate on G14, no scipy) and reusing option_d_savings unchanged.

2R2C — a second thermal-mass state

RC2Model(ua_env, uc_mass, gain_eff, tau_air, tau_mass, w) adds a slow thermal-mass node coupled to the air node. A mass-dominated building keeps drawing recovery energy for hours after re-entry as its mass recharges — a slow tail a single tau cannot fit. Two time-constants (tau_air fast, tau_mass slow) plus an air-exposure weight w are the only nonlinear params; the energy stays linear in (ua_env, uc_mass, gain_eff) given them, so calibrate2(oat, schedule, metered_energy) grids the taus + w and OLS-fits the rest, with p=6 in the G14 gate (the extra params are honestly penalized). On mass-dominated data 2R2C beats 1R1C on CV(RMSE) — that gain is the test that earns its complexity.

Multi-zone — stacked OLS

calibrate_zones(oat, schedules, metered_energy, *, order=1|2) calibrates several zones whose hourly predictions sum to the whole-building meter. schedules is {zone_name: {"setpoint", "conditioned"}}; each zone's basis columns are stacked into one design matrix and a single least-squares fit recovers every zone's linear params, given one shared gridded time-constant set. It returns a MultiZoneModel that flows through option_d_savings unchanged when the as-found / as-corrected arguments are per-zone schedule dicts. Identifiability: whole-building energy under-determines the split of conductance across zones — it's recoverable only when the zones' schedules differ (which breaks the column collinearity) or when each zone is sub-metered (calibrate each zone with calibrate/calibrate2). The values are effective, and the docs say so plainly.

EnergyPlus cross-validator ([energyplus])

camber.interop.energyplus.compare_option_d(...) runs a user-supplied IDF under the as-found and as-corrected control (via eppy), differences the two annual totals, and compares that avoided energy to the grey-box option_d_savings on the same calibration — the "own it, then cross-check" pattern of the pvlib/BETTER bridges. It returns an agreement block (percent difference, within-tolerance, both engines agree the measure saves). eppy is pip-installable behind the [energyplus] extra, but running an IDF also needs an installed EnergyPlus engine — so the runner is injectable (compare_option_d(..., runner=...)) and the comparison logic is fully tested without the engine; the real E+ run is the residual external-validity step, available where E+ is installed.

Honest limitations

  • Grey-box, effective parameters. 1R1C/2R2C recover effective combinations (energy per °F·h), not separately-identified physical conductances and efficiencies — enough for the counterfactual, which only re-runs the schedule, but not a physics claim. Multi-zone splits under-determine without differing schedules or sub-metering (above).
  • Needs metered energy + weather + a control schedule. Real-building accuracy depends on data quality and must pass the G14 gate — a model that doesn't is reported as such and claims nothing.
  • The synthetic-fixture tests prove the method (they recover a known model exactly); they are not a claim about any particular building's fit.