From ionworks
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".
How this skill is triggered — by the user, by Claude, or both
Slash command
/ionworks:ecm-fittingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Fit an Equivalent Circuit Model (R0 + N RC pairs, plus optional OCV)
Fit an Equivalent Circuit Model (R0 + N RC pairs, plus optional OCV) to cycling data and persist the result as a Parameterized Model.
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.
| 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.
from ionworks import Ionworks
client = Ionworks()
# 1. Submit the fit (returns immediately)
fit_job = client.ecm.fit_from_measurements({
"measurements": [
# Each entry can carry per-measurement step + initial_soc filters.
{"id": "meas-id-1"},
{"id": "meas-id-2", "start_step": 5, "end_step": 50, "initial_soc": 0.95},
],
"ecm_options": {
"num_rcs": 2, # 0–5 RC pairs (default 2)
"fit_ocv": True, # set False if data has an OCV column
# Optional shared hyperparameters:
# "capacity": 4.85,
# "ocv_soc_curve": {"soc": [...], "ocv": [...]},
# "bounds_capacity": {"lo": 4.0, "hi": 6.0},
# "num_knots": 21, "num_knots_r0": 7,
# "knot_schedule": [3, 5, 9, 21],
# "clamp_boundary_knots": True, "clamp_max_ratio": 10.0,
},
})
print(fit_job.job_id)
# 2. Wait for the worker to finish
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 plottingsoc, ocv, r0 — 200-point parameter grids over SOCrc_pairs[i].r, .c, .tau — per-RC-pair curves over SOCrmse_mV, num_rcs, ocv_provided — fit metadataAccepts 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, # optional
capacity=4.85, # optional, in Ah
)
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")
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)
examples = client.ecm.list_examples()
example_id = examples[0]["id"]
# Optional: peek at the data
data = client.ecm.get_example_data(example_id)
# This call is synchronous (rate-limited demo endpoint)
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.
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)
The new Parameterized Model can then be used as the
parameterized_model in client.simulation.protocol(...).
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 |
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],
},
# Optional: search bounds for capacity (Ah). Only used when
# capacity is None — when omitted, capacity defaults are used.
"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.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.
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.
For challenging traces (long relaxations, multiple time scales), bump the SOC-knot resolution:
"ecm_options": {
"num_rcs": 3,
"num_knots": 21, # alpha + default beta resolution
"num_knots_r0": 7, # SOC knots for R0(SoC)
"knot_schedule": [3, 5, 9, 21], # multi-resolution refinement schedule
# If R_rc(soc)=alpha/beta is built symbolically downstream, disable
# boundary clamping (it costs fit quality at unobserved boundary knots).
"clamp_boundary_knots": False,
# Or relax the clamp ratio to preserve more SoC variation:
# "clamp_max_ratio": 20.0,
}
knot_schedule must be a strictly increasing list of positive ints
ending at num_knots. Defaults are auto-derived when omitted.
fit_ocv=FalseSet 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).
from ionworks import Ionworks
client = Ionworks()
# 1. Submit + wait for fit
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")
# 2. Save as a Parameterized Model
saved = client.ecm.save_to_project(
name="ECM 2RC — bench A",
cell_spec_id="cs-123",
fit_results=result,
)
# 3. Use it in a simulation
sim = client.simulation.protocol({
"parameterized_model": saved.parameterized_model_id,
"protocol_experiment": {
"protocol": "...", # UCP YAML
"name": "1C discharge check",
},
})
client.simulation.wait_for_completion(sim.simulation_id)
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:
# Local validation failure (bad config shape)
print(f"Config error: {e}")
except TimeoutError:
print("Fit did not finish in time")
except IonworksError as e:
# Server-side error (missing columns, no rows, fit failure, auth, etc.)
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.
npx claudepluginhub ionworks/ionworks-skills --plugin ionworksGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.