Skip to content

Coupled GITT-EIS parameterisation - #975

Open
Ombrini wants to merge 31 commits into
pybop-team:developfrom
Ombrini:gitt-eis
Open

Coupled GITT-EIS parameterisation#975
Ombrini wants to merge 31 commits into
pybop-team:developfrom
Ombrini:gitt-eis

Conversation

@Ombrini

@Ombrini Ombrini commented Aug 12, 2026

Copy link
Copy Markdown

Description

Adds coupled GITT-EIS parameterisation: the ability to fit a model against a
time-domain voltage trace and impedance spectra measured at chosen points along that
trace, in a single optimisation.
The motivating case is a GITT experiment with a spectrum acquired at the end of each
pulse, where the relaxation constrains transport and the charge-transfer arc constrains
kinetics — two parameters that are hard to separate from either measurement alone.

Fixes # (issue)

How it works

The design leans on the fact that the cost layer is domain-agnostic but length-strict.
So both data types live in one pybop.Dataset on the Time [s] domain:

  • Time [s], Current [A], Voltage [V] — full length n_time
  • 2 x n_frequency real-valued impedance variables, also length n_time, non-zero only
    at the rows where a spectrum was acquired.

Real and imaginary components are separate real variables rather than one complex
variable. Their names come from a single helper so the dataset and the
simulator cannot disagree:

pybop.get_impedance_variables(frequencies)   # -> "Impedance real [Ohm] (1000 Hz)", ...
pybop.parse_impedance_variables(variables)   # the inverse, used by the plotting

EISSimulator gains a protocol argument. It infers the acquisition times from the rows
where the impedance variables are non-zero, and per evaluation it solves the time-domain
trajectory once, then linearises about the state at each of those times. Because
everything shares the Time [s] domain, the two cost measures combine with the existing
pybop.WeightedCost, which provides the relative weighting — no new cost class:

simulator = pybop.pybamm.EISSimulator(
    model, parameter_values=parameter_values, protocol=dataset, f_eval=frequencies
)
voltage_cost = pybop.SumSquaredError(dataset, target=["Voltage [V]"])
impedance_cost = pybop.SumSquaredError(dataset, target=impedance_variables)
cost = pybop.WeightedCost(voltage_cost, impedance_cost, weights=[1.0, 1e3])
problem = pybop.Problem(simulator, cost)

Bug fixes included

Input parameters were stacked in the wrong order (pre-existing). PyBaMM orders a built
model's input parameters alphabetically (base_solver.py, ordered_inputs_names = sorted(...)), but the EIS code stacked them in the caller's dict order:

casadi.vertcat(*inputs.values())

With one fitted parameter this cannot go wrong, which is why it went unnoticed. With two
whose dict order is not alphabetical, the values are swapped and the model is evaluated at
the wrong point — in the new example, diffusivity and the exchange-current prefactor were
exchanged. Values are now sorted by name before stacking.

Note: the same pattern exists in PyBaMM's own EISSimulation._build_matrix_problem, so it
is worth reporting upstream.

WeightedCost sub-costs lost their individual targets. Problem.__init__ calls
set_target(None), which fell through to target or self._target, where self._target is
the flattened union of the sub-cost targets. Its first element is a string, so it was
broadcast to every cost, and each cost then evaluated all targets. Now target is None keeps
each cost's own target.

Also in this PR

  • Build once, not per evaluation. _initialise_eis_matrices previously re-ran
    solver.set_up() and rebuilt the mass matrix and forcing vector on every evaluation,
    although neither depends on the inputs or the operating point. These are now set up once,
    mirroring Simulator._set_up_solution_method.
  • set_up_for_eis no longer mutates the caller's model — it works on model.new_copy(),
    matching PyBaMM's EISSimulation. Previously a user's SPM() came back with two extra
    algebraic equations.
  • Surface-form validation. Without "surface form": "differential" the double layer is
    absent and the computed impedance is silently meaningless (a near-pure resistance). This
    now raises, as PyBaMM's EISSimulation does. This caught an existing test
    (test_fitting_problem_eis) that was asserting against such a spectrum.
  • Plotting. pybop.plot.problem now recognises impedance variables and draws them as
    one figure of Nyquist subplots — one per acquisition, equal aspect ratio — instead of one
    near-empty time series per variable. Adds equal_aspect to the plot backends.
  • Removals. examples/scripts/battery_parameterisation/operando_eis.py is deleted: it
    dated from an earlier attempt, used the previous output shape, and is superseded by
    gitt_eis.py. Simulator.time_data, added in the same earlier attempt and referenced
    nowhere, is also removed.
  • pybop.pybamm.EISSimulator.set_output_variables is deliberately a no-op, with a comment
    explaining why: restricting the solver to output variables makes PyBaMM stop returning the
    state vector (sol.y comes back with shape (0, N)), and the state vector is exactly what
    the linearisation needs.

New example

examples/scripts/battery_parameterisation/gitt_eis.py — simulates a GITT experiment,
computes a spectrum about the state at the end of each pulse, assembles the combined
dataset, and recovers the positive-electrode diffusivity and exchange-current prefactor.
The latter is a FunctionParameter in Chen2020 with a hard-coded prefactor, so the example
redefines the function with the prefactor exposed, following
examples/scripts/getting_started/functional_parameters.py.

Type of change

New feature, plus two bug fixes. Not a breaking change to existing user code, with two
caveats worth calling out in review:

  • EISSimulator now raises for models without a surface form, where it previously
    returned a (meaningless) result.
  • PlotBackend gains an abstract method, which breaks any third-party backend subclass.

Important checks:

  • No style issues: ruff check and ruff format clean over pybop/, tests/,
    examples/
  • All tests pass: nox -s tests542 passed, 26 skipped, with one failure that
    also occurs on develop without this branch's changes (see below)
  • The documentation builds: nox -s doctest — passed (1 passed, 549 skipped)
  • Code is commented for hard-to-understand areas
  • Tests added that prove fix is effective or that feature works

New tests (tests/unit/test_simulator.py::TestCoupledEISSimulator):

Test What it pins
test_output_shape_and_zero_padding outputs are length n_time, non-zero only at acquisitions
test_matches_stationary_at_initial_state the spectrum at t=0 equals a stationary simulation's (exact, rtol=1e-10) — fails if the linearisation is wired up wrong
test_builds_once the model and constant matrices are set up once, not per evaluation
test_model_is_not_modified set_up_for_eis copies the model
test_surface_form_required validation raises
test_impedance_variables_round_trip the naming contract inverts, in any order
test_dataset_errors both dataset validation errors

Plus tests/unit/test_cost.py::test_weighted_cost_keeps_distinct_targets for the
WeightedCost fix.

Pre-existing failure, unrelated to this PR

tests/unit/test_pybamm_utils.py::TestPybammUtils::test_simulate_procedure fails with

polars.exceptions.InvalidOperationError: `new` input for `replace` must have the same
length as `old` or have length 1
  col("Event").replace([[0], [0, 1, 2].alias("")])

Confirmed pre-existing: stashing every change on this branch and re-running the test
reproduces the same failure. It looks like a Polars compatibility problem — the same run
emits DeprecationWarning: & on Boolean and Int16 is deprecated and will raise a ComputeError in Polars 2.0 from pybop/applications/utils.py:85. Worth a separate issue.

Test changes required by the surface-form validation

Running the full suite showed the new validation catching two more places that were
computing EIS without a double layer, both fixed here:

  • tests/unit/test_pybamm_utils.py::test_simulate_procedure builds the model for a set of
    procedures that includes an EIS sweep, so it now uses
    SPM(options={"surface form": "differential"}) — matching what
    examples/scripts/synthetic_data/generate_synthetic_data.py already does.
  • tests/integration/models/test_grouped_models.py::test_eis_fitting skips the two model
    configurations without a surface form. Its model_config fixture is shared with tests
    that legitimately use plain models, so the skip is local to the EIS test.

Open questions for review

  • The example's weights=[1.0, 1e3] is a starting point chosen so that the two terms are
    comparable in magnitude, not a derived value.

  • The spectra are placed at the end of each rest (current zero, cell relaxed) rather
    than at the end of the current pulse. A non-zero current at an acquisition time warns
    rather than fails, since the Jacobian is still valid about that operating point — but the
    quasi-steady assumption behind EIS is only approximately met while the cell is still
    relaxing.

  • pybop.plot.problem recognises impedance variables by their name format. That is a
    convention, not a type, so a dataset built without get_impedance_variables will not be
    plotted as Nyquist.

NicolaCourtier and others added 30 commits March 24, 2025 10:33
…ality

- Introduced a new example script for parameter estimation from GITT experiments with operando EIS.
- Enhanced the EISSimulator class to support operando simulations and added methods for handling impedance data.
- Updated the WeightedCost class to maintain distinct targets for different cost functions.
- Added unit tests for the operando EIS simulator to ensure correct functionality and output.
…able handling, and enhance plotting capabilities
…e operando EIS file, and update method names for clarity
@Ombrini
Ombrini marked this pull request as ready for review August 12, 2026 16:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants