Fit the model to your cell#
This tutorial continues Link measured data to the twin. Your P45B twin ends that tutorial at version 6 knowing an uncomfortable fact: as battwin simulates it, its model misses the measured discharge by 77 mV RMSE — partly the runtime’s own single-OCV projection, partly what any constant-current trace hides. Whatever the cause, the twin links the evidence to fix it. In this tutorial the twin closes the gap itself: PyBOP fits a model parameter against the measurement the twin links, and the calibrated model joins the version chain — ending with the comparison figure below.
As before, every output shown is from a real run (2026-08-10, battwin 0.4.0, PyBOP 26.3).
1. Install the fit extra#
pip install "battwin[fit]"
This pulls in PyBOP, the battery optimisation and parameterisation library (it shares the PyBaMM stack the sim extra uses).
2. Fit R0 from the linked measurement#
Save as fit_model.py:
import json
from battwin import ModelBinding, ValidityWindow, load, save
from battwin.fit import fit_thevenin, read_bdf
v6 = load("p45b.v6.twin.json")
vendor = v6.models[0]
measured = next(d for d in v6.data if d.role == "characterization")
result = fit_thevenin(
vendor.inline, # the twin's own model binding...
read_bdf(measured.uri), # ...fitted to the twin's own linked data
fit=["R0 [Ohm]"],
initial_soc=1.0,
ambient_celsius=25.0,
source_data=measured.uri,
)
for w in result.warnings:
print("warning:", w)
print(f"R0 [Ohm]: {result.ecm_ps['Parameterisation']['User-defined']['pybop']['initial']['R0 [Ohm]']:.4g}"
f" -> {result.fitted['R0 [Ohm]']:.4g}")
print(f"rmse over the measured discharge: {result.initial_rmse_volt*1000:.1f} mV -> {result.rmse_volt*1000:.1f} mV")
$ python fit_model.py
warning: R0 [Ohm]: table value replaced by a fitted constant (start 0.00658944 from mid-SoC at 25 degC)
R0 [Ohm]: 0.006589 -> 0.01865
rmse over the measured discharge: 77.6 mV -> 55.6 mV
Three things in that output deserve attention:
The warning is the format being honest. The vendor document carries R0 as a 2-D table over SoC and temperature; a fit against one discharge cannot reproduce a surface, so the fitted document carries a constant R0 instead, and battwin says so out loud. Everything else (the OCV branches, the RC tables) stays untouched.
The fitted value is diagnosable, not noise. R0 moved from the vendor’s 6.6 mΩ to 18.7 mΩ, landing well inside the fit bounds. Decomposed against Dickinson et al.’s parameters, that value is almost entirely accounted for: ~10.7 mΩ is the DC lumping of the R1/R2 branches into one constant (the vendor’s total 25 °C resistance is 17.3 mΩ), and most of the rest (~6.5 mΩ ≈ 29 mV ÷ 4.5 A) compensates battwin’s averaged-OCV hysteresis projection, which reads high on a discharge. Little is left over for aging. A fit will happily absorb your model’s biases along with the cell’s physics; knowing which is which takes exactly the kind of source comparison this bullet just did.
Why only R0? A smooth constant-current discharge cannot separate series resistance from the slow RC branches — ask it to fit all five R/C parameters and the optimiser happily slides R0 to a bound while inflating R2, trading one for the other. Full identification wants dynamic data (pulses, GITT — which the About:Energy release also contains).
fit_theveninwill fit whatever you name; naming only what your data can identify is your job. See Fit ECM parameters.
3. Commit the calibrated model as version 7#
The twin should carry both models — the generic vendor set and the cell-specific calibration — under distinct names. Append to fit_model.py:
v7 = v6.next_version(
models=[
vendor, # wholesale replacement: carry the vendor binding forward
ModelBinding(
kind="custom",
name="p45b-2rc-ecm (R0 fitted to bench 1C discharge)",
inline=result.ecm_ps,
validity=ValidityWindow(
temperature_celsius=(20.0, 30.0), # fitted at 25 degC only
state_of_charge=(0.0, 1.0),
),
),
]
)
save(v7, "p45b.v7.twin.json")
print(v7.version.number, v7.version.changed)
Note the narrower validity window: the constant R0 was identified from 25 °C data, so the calibrated binding claims 20–30 °C, not the vendor set’s 10–60 °C. Declaring where a model is trusted is exactly what validity is for.
$ python fit_model.py
...
7 ['models']
$ battwin validate --shacl p45b.v7.twin.json
ok p45b.v7.twin.json
$ battwin diff p45b.v6.twin.json p45b.v7.twin.json
versions: 6 -> 7
changed sections: models
version chain: intact (b.previous == hash(a))
$ battwin show p45b.v7.twin.json
Molicel INR2170-P45B (id: urn:bte:molicel-inr2170-p45b:2026-08-10)
BTE 0.1.1 | version 7 <- sha256:107dad06e4a...
battinfo record: https://w3id.org/battinfo/spec/ycek-4qa3-d4v3-rm6r
models: p45b-2rc-ecm (About:Energy base set) [custom], p45b-2rc-ecm (R0 fitted to bench 1C discharge) [custom]
state (2026-08-10): SoC 0%, SoH 95%
data links: 2
The fitted document also carries its own accountability: fit_thevenin records the optimiser, the initial and fitted values, the residual, and — because we passed source_data — the URI of the dataset it was calibrated against, in the document’s User-defined section. Anyone holding version 7 can see not just that R0 is 18.7 mΩ but where that number came from.
4. Check the calibration#
Rerun the 1C experiment with the fitted binding (exactly as in Simulate the twin’s ECM, using v7.models[1]) and compare all three curves:
vendor set vs measured: RMSE 76.9 mV
fitted set vs measured: RMSE 55.6 mV
Measured vs the vendor parameter set vs the calibrated model, for the same 1C discharge. Fitting one parameter against the linked measurement cut the voltage error from 77 to 56 mV RMSE.#
The calibrated model hugs the measured curve through the bulk of the discharge, where the as-simulated vendor set reads consistently high (that offset is mostly the averaged-OCV projection sitting above the discharge branch the cell actually follows). The remaining error is concentrated at the end-of-discharge knee, and fitting resistance can never fix that part: the knee timing is a capacity effect (the measured cell delivered 4.29 Ah against the model’s 4.5), which is the same story the twin’s SoH snapshot already tells. Each version of this twin has been telling one consistent story from a different angle.
5. What you just demonstrated#
The full loop, in seven hash-chained documents: the twin was identified from a registry, given a state, bound to an open model, simulated, confronted with measurement, judged (77 mV, SoH 95%), and finally calibrated to the physical cell it mirrors — with the generic and fitted models carried side by side, each declaring where it is valid and where its numbers came from. That is the difference between a parameter file and a digital twin: the twin accumulates evidence about one specific battery, verifiably.
For the task-shaped version of fitting, see Fit ECM parameters; for the API, battwin.fit.