| name | ecm-fitting |
| description | Fit Equivalent Circuit Models (ECM) to battery cycling data and save the result as a Parameterized Model. Use when the user wants to parameterize an ECM, fit R0/RC pairs, fit OCV from cycling data, or turn measurements/files into a parameterized model. Triggers: "ECM", "equivalent circuit", "RC pair", "R0", "fit OCV", "ECM parameterization". |
ECM Parameterization
Fit an Equivalent Circuit Model (R0 + N RC pairs, plus optional OCV)
to cycling data and persist the result as a Parameterized Model.
When to use
- Build a fast surrogate from raw cycling data.
- Need an OCV curve plus R0 and RC dynamics from a pulse / drive cycle test.
- Save a fitted ECM to a project so it can be used in simulations.
Operation type
Authenticated fits run as background jobs on the platform's worker
fleet (typically 10–60 s). Each fit_* call submits a job and returns
a handle immediately; use wait_for_completion to block until the
fit is ready.
The unauthenticated demo endpoint (fit_from_example) is synchronous
and rate-limited.
Three input modes
| Method | Source | Auth | Returns |
|---|
client.ecm.fit_from_example(...) | Built-in demo dataset | optional | FitResults (sync) |
client.ecm.fit_from_file(...) | Local file upload | required | EcmFitJob (async) |
client.ecm.fit_from_measurements(...) | Measurements stored in the platform | required | EcmFitJob (async) |
The active organization is resolved from the API key — no
organization_id needs to be passed.
Quick start: fit from measurements
from ionworks import Ionworks
client = Ionworks()
fit_job = client.ecm.fit_from_measurements({
"measurements": [
{"id": "meas-id-1"},
{"id": "meas-id-2", "start_step": 5, "end_step": 50, "initial_soc": 0.95},
],
"ecm_options": {
"num_rcs": 2,
"fit_ocv": True,
},
})
print(fit_job.job_id)
result = client.ecm.wait_for_completion(fit_job, timeout=300)
print(f"RMSE: {result.rmse_mV:.2f} mV")
print(f"OCV provided: {result.ocv_provided}")
print(f"RC pairs: {len(result.rc_pairs)}")
result is a FitResults model with these arrays:
time, data_voltage, model_voltage — downsampled traces for plotting
soc, ocv, r0 — 200-point parameter grids over SOC
rc_pairs[i].r, .c, .tau — per-RC-pair curves over SOC
rmse_mV, num_rcs, ocv_provided — fit metadata
Fit from a local file
Accepts CSV, parquet, and any cycler format that ionworksdata can detect.
fit_job = client.ecm.fit_from_file(
"data/pulse_test.csv",
num_rcs=2,
fit_ocv=True,
initial_soc=0.95,
capacity=4.85,
)
result = client.ecm.wait_for_completion(fit_job)
You can also pass an open binary stream:
with open("data/pulse_test.parquet", "rb") as f:
fit_job = client.ecm.fit_from_file(f, num_rcs=3)
result = client.ecm.wait_for_completion(fit_job)
To inspect a file before fitting (auto-detect format, get the
time-series back without running the fit):
preview = client.ecm.detect_and_read("data/pulse_test.csv")
Polling manually
wait_for_completion is the recommended path. If you need lower-level
access (e.g. driving your own progress UI), poll the job directly:
fit_job = client.ecm.fit_from_measurements(
{"measurements": [{"id": "m-1"}]}
)
while True:
job = client.job.get(fit_job.job_id)
if job.is_terminal:
break
time.sleep(2)
if job.is_failed:
raise RuntimeError(job.error)
from ionworks import FitResults
result = FitResults(**job.result)
Fit from a built-in example (no auth)
examples = client.ecm.list_examples()
example_id = examples[0]["id"]
data = client.ecm.get_example_data(example_id)
result = client.ecm.fit_from_example(example_id, num_rcs=2)
The public /ecm/fit endpoint is rate-limited (60/min). RC-pair
parameters are only included for authenticated users whose organization
has ECM results access enabled.
Save fit as a Parameterized Model
Once you have a FitResults, persist it inside a project:
saved = client.ecm.save_to_project(
name="ECM 2RC — pulse test",
cell_spec_id="cell-spec-id",
fit_results=result,
description="Fitted from May 2026 pulse test",
)
print(saved.parameterized_model_id)
Model names must be unique per cell spec — a duplicate name raises an
IonworksError with error code: 409. Pick a descriptive, distinct
name up front (include the data source / RC count, e.g.
"ECM 2RC — UDDS (LG HG2 18650)"). If you do hit a 409, don't blindly
retry: tell the user a model with that name already exists and either reuse
it or save under a clearly different name — don't silently append a
throwaway timestamp.
The new Parameterized Model can then be used as the
parameterized_model in client.simulation.protocol(...).
Validate on held-out data
client.ecm.validate(...) re-simulates a fitted ECM forward on a
held-out trace and returns the model-vs-data comparison plus error
metrics. It runs synchronously — no job, and no validation
pipeline — using the same forward engine the fit uses internally, so it
is the right tool for ECM held-out validation (do not route ECM
through iws.Validation / a pipeline).
Provide exactly one held-out source (measurement_id or example_id)
and exactly one model source (fit_results or parameterized_model_id):
val = client.ecm.validate(
measurement_id="held-out-meas-id",
fit_results=result,
)
print(f"Validation RMSE: {val.rmse_mV:.2f} mV "
f"(MAE {val.mae_mV:.2f}, Max {val.max_mV:.2f})")
val = client.ecm.validate(
measurement_id="held-out-meas-id",
parameterized_model_id=saved.parameterized_model_id,
)
ValidationResults carries the aligned, downsampled traces for a
two-panel plot — time, data_voltage, model_voltage (overlay) and
residual_mV (model − data) — plus rmse_mV / mae_mV / max_mV, the
initial_soc and capacity_Ah (a per-segment list) used for the SOC
integration, and a model_source label.
Optional arguments: start_step / end_step (filter the held-out
measurement), initial_soc (the held-out trace's starting SOC —
recovered from the trace's first voltage via the fitted OCV(SOC) curve
when omitted, which assumes the trace starts near rest; pass it
explicitly otherwise), and capacity (overrides the fit/model capacity,
which is the default — it is never re-estimated from the held-out trace).
Choosing num_rcs
| RC pairs | When to use |
|---|
| 0 | Pure R0 + OCV (no transient dynamics) |
| 1 | Single time constant — short pulses, simple chemistries |
| 2 | Default — captures fast + slow transients (recommended) |
| 3+ | Complex relaxation behaviour; risk of overfitting |
Fitting capacity from an OCV(SoC) curve
If you have a known OCV curve (e.g. from a separate slow-rate
characterisation), pass it via ecm_options.ocv_soc_curve and the
fitter will skip OCV fitting altogether — using the curve to interpolate
OCV and (optionally) co-optimise capacity:
fit_job = client.ecm.fit_from_measurements({
"measurements": [{"id": "meas-id-1"}],
"ecm_options": {
"num_rcs": 2,
"ocv_soc_curve": {
"soc": [0.0, 0.1, 0.2, ..., 1.0],
"ocv": [3.0, 3.2, 3.4, ..., 4.2],
},
"bounds_capacity": {"lo": 4.0, "hi": 6.0},
},
})
Constraints:
ocv_soc_curve.soc must be strictly increasing and lie in [0, 1].
ocv_soc_curve.soc and .ocv must have the same length (≥ 2).
bounds_capacity is only consulted when capacity is None;
it requires hi > lo.
- Mutually exclusive with input data carrying an
Open-circuit voltage [V] column — pass one or the other.
Validation runs locally in the SDK before the request hits the wire, so
mismatched-length arrays or non-monotonic SOC values raise
ValueError immediately.
Multi-measurement fits with per-segment SOC seeds
When you concatenate multiple measurements into one fit, each segment can
carry its own initial_soc so the fitter starts each segment from the
right state of charge — useful when measurements are recorded across
different SoC ranges (e.g. one capacity check at full charge plus one
discharge profile starting at 80 %):
fit_job = client.ecm.fit_from_measurements({
"measurements": [
{"id": "meas-fully-charged", "initial_soc": 1.00},
{"id": "meas-partial-discharge", "initial_soc": 0.80},
],
"ecm_options": {"num_rcs": 2, "capacity": 4.85},
})
When initial_soc is omitted on a measurement and an
ocv_soc_curve is provided in ecm_options, the service auto-seeds
soc0 for that segment by inverting
V[s] = OCV(soc0) − I[s]·R0(soc0) after a warm-up fit. Without a
curve, single-measurement runs fall back to coulomb-counting.
Tuning knot resolution
For challenging traces (long relaxations, multiple time scales), bump the
SOC-knot resolution:
"ecm_options": {
"num_rcs": 3,
"num_knots": 21,
"num_knots_r0": 7,
"knot_schedule": [3, 5, 9, 21],
"clamp_boundary_knots": False,
}
knot_schedule must be a strictly increasing list of positive ints
ending at num_knots. Defaults are auto-derived when omitted.
When fit_ocv=False
Set fit_ocv=False only if your input data already has an
Open-circuit voltage [V] column (e.g. a pre-run pseudo-OCV
characterisation). The fitter will use that directly and only
optimize R0 + RC parameters.
For raw drive cycles, leave fit_ocv=True (the default).
End-to-end example
from ionworks import Ionworks
client = Ionworks()
fit_job = client.ecm.fit_from_measurements({
"measurements": [{"id": "meas-1"}, {"id": "meas-2"}],
"ecm_options": {"num_rcs": 2},
})
result = client.ecm.wait_for_completion(fit_job, timeout=300)
print(f"RMSE: {result.rmse_mV:.2f} mV")
saved = client.ecm.save_to_project(
name="ECM 2RC — bench A",
cell_spec_id="cs-123",
fit_results=result,
)
sim = client.simulation.protocol({
"parameterized_model": saved.parameterized_model_id,
"protocol_experiment": {
"protocol": "...",
"name": "1C discharge check",
},
})
client.simulation.wait_for_completion(sim.simulation_id)
Error handling
from ionworks import IonworksError
try:
fit_job = client.ecm.fit_from_measurements(
{"measurements": [{"id": "bad-id"}]}
)
result = client.ecm.wait_for_completion(fit_job, timeout=60)
except ValueError as e:
print(f"Config error: {e}")
except TimeoutError:
print("Fit did not finish in time")
except IonworksError as e:
print(f"API error ({e.status_code}): {e}")
Pass raise_on_failure=False to wait_for_completion if you'd rather get
None back than an exception on failure — then fetch the job directly via
client.job.get(job_id) to inspect the error.