Generated banner for Pybamm
Generated logo for Pybamm

Pybamm

K-DenseCommunityCreated by K-Dense Inc.

  • Skill
  • Workflow
  • Science and research
  • MIT
  • Low risk
  • Has scripts

From the author

Simulates lithium-ion battery charge, discharge and rest experiments with PyBaMM, records parameter-set provenance, checks mesh and solver sensitivity, and compares predicted voltage curves with measured cycling data. Use for SPM or DFN electrochemical battery modeling, C-rate protocols, voltage cutoffs, parameter studies and numerical validation of battery simulations.
— the description by K-Dense Inc.

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a structured workflow for simulating single-cell lithium-ion battery experiments using PyBaMM. It supports SPM and DFN electrochemical models to simulate charge, discharge, and rest protocols. The tool automates the execution of simulations, performs numerical sensitivity analysis by comparing results across different solver tolerances and mesh resolutions, and generates detailed reports including parameter provenance and voltage curve comparisons against measured data.

What it does

  • Simulates battery charge, discharge, and rest experiments using SPM or DFN models.
  • Validates simulation protocols against defined JSON contracts.
  • Performs numerical sensitivity analysis by varying solver tolerances and mesh density.
  • Compares predicted voltage curves against user-supplied cycling data.
  • Records parameter-set provenance and simulation metadata.
  • Exports detailed reports including voltage residuals and event-time differences.

When to use it

  • When you need to model single-cell battery behavior under specific current protocols.
  • When comparing electrochemical model predictions (SPM/DFN) against experimental cycling data.
  • When performing numerical validation to ensure simulation results are independent of mesh and solver settings.
  • When you need to track parameter-set snapshots after SOC initialization.

Advantages

  • Automates the comparison between baseline, high-tolerance, and refined-mesh simulations.
  • Provides clear metrics for voltage RMSE, MAE, and bias.
  • Ensures reproducibility by snapshotting parameters after SOC initialization.
  • Strict protocol validation prevents common configuration errors.

Usage examples

“Run a simulation for a 0.5C discharge protocol using the Chen2020 parameter set.”
The tool executes the simulation, generates a baseline curve, and outputs a report including parameter snapshots and numerical validation results.
“Compare my measured cycling data in measured.csv against an SPM model prediction.”
The tool runs the SPM simulation, calculates voltage residuals against the provided CSV, and generates a measurement-residuals.csv file.

Install

  1. Download the skill as a ZIP:

    https://autogineer.com/agent-skills/k-dense-ai/pybamm/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip pybamm.zip -d .agents/skills

Documentation by K-Dense Inc.

PyBaMM battery experiments

When to use

Use this skill to model single-cell constant-current charge/discharge and rest, examine voltage and charge trajectories, or compare SPM/DFN predictions to cycling measurements. The helper runs real PyBaMM experiments and three numerical resolutions; it does not control a battery cycler or establish an operating envelope for hardware.

Runtime and tested case

uv venv --python 3.12 battery-env
uv pip install --python battery-env/bin/python pybamm==26.9.0.0 pybammsolvers==0.10.0

This release requires pybammsolvers>=0.10.0, NumPy 2 or newer, and CasADi 3.8.1. The tested environment used Python 3.12.10, NumPy 2.5.3 and SciPy 1.18.1. IDAKLU is the recommended solver; CasadiSolver and ScipySolver are deprecated in this release. Refer to the 26.9.0.0 manual below, since latest can describe unreleased APIs.

The included assets/chen2020-protocol.json is a synthetic isothermal 298.15-K SPM case: 80% initial SOC, discharge at 0.5C for 600 s, rest for 120 s, charge at 0.5C for 600 s. Chen2020 supplies an LG M50 parameterization with 5-Ah nominal capacity; here 0.5C means 2.5 A. This is an executable reference example, not a claim that an arbitrary user's cell has those parameters. The helper disables PyBaMM usage telemetry unless the caller has already explicitly configured that variable.

Workflow

  1. Establish the cell chemistry, geometry, nominal capacity, initial state, temperature and current-sign convention. Use an appropriate parameter set and explain its source. Distinguish a paper's fitted parameters from measurements of this particular cell. Do not transplant degradation parameters without checking their meaning and applicable conditions.

  2. Convert the requested protocol to the JSON contract in references/protocol-and-comparison.md. Positive simulation current discharges; negative current charges. Every step has a finite duration. A specified voltage cutoff can end it earlier; the report records actual termination times. C-rates use the selected set's nominal capacity. A change in that capacity changes current.

  3. Choose SPM when its reduced transport assumptions are adequate; use DFN when resolving electrolyte/electrode transport matters. The helper's tested models are isothermal and exclude aging, mechanics, plating and pack control. Increasing rate can invalidate SPM predictions even if numerical convergence is excellent.

  4. Run the helper. It validates protocol fields, rejects unknown or overridden-by-protocol parameter inputs, uses IDAKLU, and snapshots the base parameters after SOC initialization. Keep the protocol with that snapshot: experiment steps supply their own currents. Infeasible or skipped steps are errors, rather than silently presenting a partial protocol as complete.

  5. Read the two numerical comparisons separately: baseline versus tighter tolerances isolates solver error; tight tolerances on the original versus doubled mesh isolates discretization. Compare voltage differences and event-time differences against the accuracy the question needs. Refine again when these are too large; one doubling does not prove convergence.

  6. If measurements are available, check current, time origin, temperature, SOC and capacity before interpreting residuals. Supply matching seconds, volts and amps. The helper reports voltage RMSE/MAE/bias and current RMSE, preserving residuals. A small voltage error under a mismatched input current does not validate the model. This workflow compares curves; it does not claim to identify unique kinetic parameters from voltage alone.

Run and inspect

From the skill directory, point battery-env/bin/python at the environment created above:

battery-env/bin/python scripts/simulate_battery.py assets/chen2020-protocol.json \
  --output battery-reference

# measured.csv is user data with time_s,voltage_V,current_A columns.
battery-env/bin/python scripts/simulate_battery.py protocol.json \
  --measured measured.csv --mesh-points 30 --output battery-comparison

The first command was executed as written with an external output location. The second uses illustrative user filenames; the measurement path was exercised against a frozen synthetic reference curve in the tests. Output directories must be new.

ArtifactInterpretation
curve.csvBaseline time, step, voltage, current and net discharge capacity
tight-tolerance.csvSame mesh, tighter solver
refined-mesh.csvDoubled mesh with tighter solver
parameters.jsonBase parameters after SOC initialization; step currents remain in the protocol
report.jsonProtocol/checksum, package versions, parameter source, numerical comparisons and terminations
measurement-residuals.csvPrediction minus measurement and current mismatch, when measurements were supplied

The reference case conserved integrated charge: 600 s at 2.5 A yielded 0.4166667 Ah, then equal charge returned net discharge capacity to zero. Voltage stayed within the Chen2020 limits in this case. Tightening tolerances changed voltage by about 1 microvolt; doubling mesh from 20 to 40 points changed it by about 2.17 mV, so claiming sub-millivolt mesh accuracy would be unjustified. A separate real DFN test stopped at the requested 3.9-V event and verified its charge integral. Native tests also exercise charge cutoff, infeasible discharge, capacity-to-current conversion, and replay of the exported SOC-adjusted parameters without reinitializing SOC. The frozen reference is numerical regression evidence, not measured-cell validation.

Primary references

The linked release manuals, bundled helper, parameter serialization and optional DataLoader recipe in the reference were verified against PyBaMM 26.9.0.0.

Files

scripts/simulate_battery.py

"""Run a validated constant-current/rest battery experiment with numerical checks."""
from __future__ import annotations

import argparse
import csv
import hashlib
import importlib.metadata
import json
import math
import os
from pathlib import Path

# Simulation is local; do not send usage telemetry when the library imports.
os.environ.setdefault("PYBAMM_DISABLE_TELEMETRY", "true")


def validate_protocol(protocol):
    def finite(name, value, low=0, high=math.inf):
        if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not low < value <= high:
            raise ValueError(f"{name} must be finite and in ({low}, {high}]")
    if not isinstance(protocol, dict):
        raise ValueError("Protocol must be a JSON object")
    allowed = {"model", "parameter_set", "initial_soc", "temperature_K", "sample_period_s", "steps", "parameter_overrides"}
    if set(protocol) - allowed:
        raise ValueError(f"Unsupported protocol fields: {sorted(set(protocol) - allowed)}")
    if protocol.get("model") not in {"SPM", "DFN"}:
        raise ValueError("model must be SPM or DFN")
    if not isinstance(protocol.get("parameter_set"), str) or not protocol["parameter_set"]:
        raise ValueError("parameter_set is required")
    finite("initial_soc", protocol.get("initial_soc"), high=1)
    finite("temperature_K", protocol.get("temperature_K"))
    finite("sample_period_s", protocol.get("sample_period_s"))
    steps = protocol.get("steps")
    if not isinstance(steps, list) or not steps:
        raise ValueError("steps must be a nonempty list")
    for index, step in enumerate(steps):
        if not isinstance(step, dict):
            raise ValueError(f"step {index}: must be a JSON object")
        if set(step) - {"kind", "duration_s", "c_rate", "until_voltage_V"}:
            raise ValueError(f"step {index}: unsupported field")
        if step.get("kind") not in {"discharge", "charge", "rest"}:
            raise ValueError(f"step {index}: kind must be discharge, charge or rest")
        finite(f"step {index} duration_s", step.get("duration_s"))
        if protocol["sample_period_s"] > step["duration_s"]:
            raise ValueError("sample_period_s exceeds a step duration")
        if step["kind"] != "rest":
            finite(f"step {index} c_rate", step.get("c_rate"))
            if "until_voltage_V" in step:
                finite(f"step {index} until_voltage_V", step["until_voltage_V"])
        elif "c_rate" in step or "until_voltage_V" in step:
            raise ValueError("Rest steps cannot specify c_rate or voltage cutoff")
    overrides = protocol.get("parameter_overrides", {})
    if not isinstance(overrides, dict):
        raise ValueError("parameter_overrides must be a JSON object")
    for key, value in overrides.items():
        if not isinstance(key, str):
            raise ValueError("Parameter override names must be strings")
        if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
            raise ValueError(f"Override {key}: only finite numeric existing parameters are supported")
    return protocol


def solve_protocol(protocol, mesh_points=20, rtol=1e-6, atol=1e-8):
    import pybamm
    import numpy as np
    validate_protocol(protocol)
    if not isinstance(mesh_points, int) or mesh_points < 5:
        raise ValueError("mesh_points must be an integer >=5")
    if not (0 < rtol < 1 and 0 < atol < 1):
        raise ValueError("Solver tolerances must be between zero and one")
    parameters = pybamm.ParameterValues(protocol["parameter_set"])
    overrides = protocol.get("parameter_overrides", {})
    unknown = set(overrides) - set(parameters.keys())
    if unknown:
        raise ValueError(f"Unknown parameter overrides: {sorted(unknown)}")
    if {"Ambient temperature [K]", "Initial temperature [K]"} & set(overrides):
        raise ValueError("Set temperature_K instead of temperature overrides")
    if "Current function [A]" in overrides:
        raise ValueError("Set step c_rate instead of Current function [A]; experiment steps replace it")
    if any(key.startswith("Initial concentration in ") and " electrode [" in key for key in overrides):
        raise ValueError("Set initial_soc instead of initial electrode concentrations; SOC initialization replaces them")
    if any(not isinstance(parameters[key], (int, float)) for key in overrides):
        raise ValueError("Only existing numeric parameters may be overridden; preserve functional parameter laws")
    parameters.update(overrides)
    parameters.update({"Ambient temperature [K]": protocol["temperature_K"],
                       "Initial temperature [K]": protocol["temperature_K"]})
    capacity = float(parameters["Nominal cell capacity [A.h]"])
    lower, upper = (float(parameters[key]) for key in ("Lower voltage cut-off [V]", "Upper voltage cut-off [V]"))
    if float(parameters["Number of cells connected in series to make a battery"]) != 1:
        raise ValueError("Only a single cell is supported; battery and cell voltage differ for series strings")
    if not (capacity > 0 and 0 < lower < upper):
        raise ValueError("Invalid nominal capacity or voltage limits in parameter set")
    steps = []
    for step in protocol["steps"]:
        current = 0 if step["kind"] == "rest" else step["c_rate"] * capacity * (1 if step["kind"] == "discharge" else -1)
        cutoff = step.get("until_voltage_V")
        if cutoff is not None and not lower <= cutoff <= upper:
            raise ValueError("Protocol voltage cutoff exceeds the parameter-set limits")
        steps.append(pybamm.step.current(current, duration=step["duration_s"],
                     termination=f"{cutoff} V" if cutoff is not None else None,
                     period=protocol["sample_period_s"], skip_ok=False))
    model = {"SPM": pybamm.lithium_ion.SPM, "DFN": pybamm.lithium_ion.DFN}[protocol["model"]](options={"thermal": "isothermal"})
    mesh = {key: mesh_points for key in ("x_n", "x_s", "x_p", "r_n", "r_p")}
    simulation = pybamm.Simulation(model, parameter_values=parameters,
        experiment=pybamm.Experiment([tuple(steps)]), var_pts=mesh,
        solver=pybamm.IDAKLUSolver(rtol=rtol, atol=atol))
    solution = simulation.solve(initial_soc=protocol["initial_soc"])
    segments = [segment for cycle in solution.cycles for segment in cycle.steps]
    if len(segments) != len(steps) or any(isinstance(s, pybamm.EmptySolution) for s in segments):
        raise RuntimeError("Protocol ended early or skipped a step; do not interpret as a complete experiment")
    rows, terminations = [], []
    for index, segment in enumerate(segments):
        t = np.asarray(segment["Time [s]"].entries)
        v = np.asarray(segment["Voltage [V]"].entries)
        current = np.asarray(segment["Current [A]"].entries)
        charge = np.asarray(segment["Discharge capacity [A.h]"].entries)
        requested = protocol["steps"][index]
        if t[-1] - t[0] < requested["duration_s"] - 1e-5:
            cutoff = requested.get("until_voltage_V")
            if cutoff is None or abs(float(v[-1]) - cutoff) > 1e-4:
                raise RuntimeError(f"Step {index} ended before its duration without reaching the requested cutoff")
        if not np.isfinite(np.column_stack([t, v, current, charge])).all():
            raise RuntimeError("Nonfinite simulation output")
        rows.extend(zip([index] * len(t), t, v, current, charge))
        terminations.append({"step": index, "kind": protocol["steps"][index]["kind"],
                             "start_s": float(t[0]), "end_s": float(t[-1]), "termination": segment.termination})
    return {"solution": solution, "segments": segments, "rows": rows, "terminations": terminations,
            "parameters": simulation.parameter_values.to_json(), "mesh_points": mesh_points, "rtol": rtol, "atol": atol,
            "nominal_capacity_Ah": capacity, "voltage_limits_V": [lower, upper]}


def compare_runs(first, second):
    import numpy as np
    differences = []
    for index, (a, b) in enumerate(zip(first["segments"], second["segments"])):
        duration_a, duration_b = a.t[-1] - a.t[0], b.t[-1] - b.t[0]
        local = np.linspace(0, min(duration_a, duration_b), 101)
        va = a["Voltage [V]"](a.t[0] + local)
        vb = b["Voltage [V]"](b.t[0] + local)
        differences.append({"step": index, "max_voltage_difference_V": float(np.max(np.abs(va-vb))),
                            "end_time_difference_s": float(abs(duration_a-duration_b))})
    return {"max_voltage_difference_V": max(d["max_voltage_difference_V"] for d in differences),
            "steps": differences}


def compare_measurement(rows, measurement_path, residual_path):
    import numpy as np
    with Path(measurement_path).open(newline="") as handle:
        reader = csv.DictReader(handle)
        if reader.fieldnames != ["time_s", "voltage_V", "current_A"]:
            raise ValueError("Measured CSV columns must be time_s,voltage_V,current_A")
        measured = np.asarray([[float(r[k]) for k in reader.fieldnames] for r in reader])
    if measured.ndim != 2 or len(measured) < 2 or not np.isfinite(measured).all() or (np.diff(measured[:, 0]) <= 0).any():
        raise ValueError("Need finite measurements and strictly increasing times")
    simulated = np.asarray(rows)
    if measured[0, 0] < simulated[0, 1] or measured[-1, 0] > simulated[-1, 1]:
        raise ValueError("Measurement times exceed simulated interval; no extrapolation")
    predicted = np.interp(measured[:, 0], simulated[:, 1], simulated[:, 2])
    predicted_current = np.interp(measured[:, 0], simulated[:, 1], simulated[:, 3])
    residual = predicted - measured[:, 1]
    with Path(residual_path).open("w", newline="") as handle:
        writer = csv.writer(handle); writer.writerow(["time_s", "predicted_voltage_V", "measured_voltage_V", "residual_V", "current_difference_A"])
        writer.writerows(zip(measured[:, 0], predicted, measured[:, 1], residual, predicted_current-measured[:, 2]))
    return {"samples": len(measured), "rmse_V": float(np.sqrt(np.mean(residual**2))),
            "mae_V": float(np.mean(np.abs(residual))), "bias_V": float(np.mean(residual)),
            "current_rmse_A": float(np.sqrt(np.mean((predicted_current-measured[:, 2])**2))),
            "measurement_sha256": hashlib.sha256(Path(measurement_path).read_bytes()).hexdigest(),
            "comparison_interval_s": [float(measured[0, 0]), float(measured[-1, 0])]}


def run(protocol_path, output, measured=None, mesh_points=20):
    import pybamm
    protocol_path, output = Path(protocol_path), Path(output)
    protocol = validate_protocol(json.loads(protocol_path.read_text()))
    if output.exists():
        raise FileExistsError(f"Refusing to overwrite {output}")
    baseline = solve_protocol(protocol, mesh_points)
    tight = solve_protocol(protocol, mesh_points, rtol=1e-8, atol=1e-10)
    refined = solve_protocol(protocol, mesh_points * 2, rtol=1e-8, atol=1e-10)
    output.mkdir(parents=True)
    for name, result in (("curve", baseline), ("tight-tolerance", tight), ("refined-mesh", refined)):
        with (output / f"{name}.csv").open("w", newline="") as handle:
            writer = csv.writer(handle)
            writer.writerow(["step", "time_s", "voltage_V", "current_A", "net_discharge_capacity_Ah"])
            writer.writerows(result["rows"])
    (output / "parameters.json").write_text(json.dumps(baseline["parameters"], indent=2) + "\n")
    report = {"pybamm_version": pybamm.__version__, "solver": "IDAKLUSolver", "protocol": protocol,
        "versions": {name: importlib.metadata.version(name) for name in ("pybamm", "pybammsolvers", "numpy", "casadi")},
        "protocol_sha256": hashlib.sha256(protocol_path.read_bytes()).hexdigest(),
        "parameter_set_description": pybamm.parameter_sets.get_docstring(protocol["parameter_set"]),
        "parameter_snapshot_scope": "Base simulation parameters after initial_soc; per-step currents are set by the protocol, not the snapshot Current function [A]",
        "nominal_capacity_Ah": baseline["nominal_capacity_Ah"], "voltage_limits_V": baseline["voltage_limits_V"],
        "mesh_points": mesh_points, "baseline_tolerances": {"rtol": 1e-6, "atol": 1e-8},
        "tight_tolerances": {"rtol": 1e-8, "atol": 1e-10}, "refined_mesh_points": mesh_points * 2,
        "terminations": baseline["terminations"], "solver_sensitivity": compare_runs(baseline, tight),
        "mesh_sensitivity": compare_runs(tight, refined)}
    if measured:
        report["measurement_comparison"] = compare_measurement(baseline["rows"], measured, output / "measurement-residuals.csv")
    (output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
    return report


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("protocol", type=Path)
    parser.add_argument("--output", type=Path, required=True)
    parser.add_argument("--measured", type=Path)
    parser.add_argument("--mesh-points", type=int, default=20)
    args = parser.parse_args()
    print(json.dumps(run(args.protocol, args.output, args.measured, args.mesh_points), indent=2))


if __name__ == "__main__":
    main()

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Python

The author says: Requires Python 3.12 with PyBaMM 26.9.0.0 and pybammsolvers 0.10.0 (IDAKLU). NumPy and CasADi are supplied by PyBaMM. Network is needed for installation and optional upstream dataset retrieval; bundled simulations and CSV comparisons run locally without credentials.

Security

What our scanner noticed· Low risk

  • Instructions that use the network
  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
K-Dense Inc.
Publisher
K-Dense (Community)
Source
K-Dense-AI/scientific-agent-skills · skills/pybamm · commit a25cf9e
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE.md)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by K-Dense Inc. or K-Dense.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download pybamm.zip