Skip to main content

UCP Simulation Backend for Design Optimization

Date: 2026-04-03 Status: Draft

Problem

Design optimization currently converts UCP protocol YAML to a PyBaMM Experiment object via ucp_to_pybamm_experiment(), then runs it through pybamm.Simulation.solve(). This conversion is lossy (no dynamic loops, conditionals, goto, EIS handling), fragile (recent bugs around expression parsing and cycle structure), and slower than necessary (no model caching between steps).

Solution

Add a UCPSimulation adapter class that implements the same .solve(inputs=...) interface as iwp.Simulation, but internally uses UCP’s native build_protocol_simulation() / run_protocol_simulation(). The adapter returns ucp_solution.timeseries_solution — a real pybamm.Solution built from internal state vectors — so all existing metrics, actions, constraints, and cycle slicing work unchanged. Additionally, block EIS steps in design optimization experiments since timeseries_solution excludes them and they are not meaningful in optimization contexts.

Architecture

UCPSimulation Adapter

A new class in ionworkspipeline that duck-types iwp.Simulation for the subset of the interface that DesignObjective uses:
class UCPSimulation:
    """Simulation adapter using UCP's native protocol runner.

    Provides the same .solve(inputs=...) interface as iwp.Simulation but runs the
    protocol through UCP's step-by-step engine, which supports dynamic loops,
    conditionals, input parameter expressions, and aggressive model caching.

    Returns ucp_solution.timeseries_solution — a real pybamm.Solution built from
    internal state vectors — so all existing metrics work unchanged.
    """

    def __init__(self, model, parameter_values, protocol_yaml, solver=None, **kwargs):
        # Parse UCP protocol from YAML string
        # Build simulation environment once (model discretization, caching)
        self._sim_env = build_protocol_simulation(
            protocol=protocol_yaml,
            model=model,
            parameter_values=parameter_values,
            **kwargs,
        )

    def solve(self, inputs=None, initial_soc=None, save_at_cycles=None, **kwargs):
        # Run protocol step-by-step through UCP engine
        ucp_solution = run_protocol_simulation(self._sim_env, inputs=inputs)
        # Build pybamm.Solution with cycle structure from UCP step metadata
        return self._build_solution_with_cycles(ucp_solution)

Cycle Structure on the Returned Solution

UCPSolution.timeseries_solution builds a flat pybamm.Solution with .sub_solutions (one per timeseries step) but .cycles is empty — PyBaMM only populates cycles when solved via pybamm.Simulation with a pybamm.Experiment. UCPSimulation.solve() fixes this by building cycle boundaries from UCP’s step metadata. Each StepSolution already has a .cycle_count field. The adapter groups sub-solutions by cycle count and constructs a pybamm.Solution per cycle:
def _build_solution_with_cycles(self, ucp_solution):
    pybamm_sol = ucp_solution.timeseries_solution

    # Group timeseries step indices by cycle_count
    cycle_groups = {}  # cycle_count -> [step_indices]
    step_idx = 0
    for step_sol in ucp_solution.steps:
        if not step_sol.is_timeseries:
            continue
        cycle_groups.setdefault(step_sol.cycle_count, []).append(step_idx)
        step_idx += 1

    # Build a pybamm.Solution per cycle from subsets of the flat arrays
    cycles = []
    for cycle_count in sorted(cycle_groups):
        indices = cycle_groups[cycle_count]
        cycle_sol = pybamm.Solution(
            all_ts=[pybamm_sol.all_ts[i] for i in indices],
            all_ys=[pybamm_sol.all_ys[i] for i in indices],
            all_yps=[pybamm_sol.all_yps[i] for i in indices],
            all_models=[pybamm_sol.all_models[i] for i in indices],
            all_inputs=[pybamm_sol.all_inputs[i] for i in indices],
            termination="final time",
        )
        cycles.append(cycle_sol)

    pybamm_sol.cycles = cycles
    return pybamm_sol
This gives full cycle support:
  • sol.cycles[c] — real pybamm.Solution with full interpolation
  • sol.cycles[c].sub_solutions — steps within that cycle
  • save_at_cycles — works as expected
  • CycleSlicer, IterativeMetric — work unchanged
Key details:
  • initial_soc: UCP protocols encode initial state in their global config (initial_state_type / initial_state_value). When using the UCP backend, parse_objectives() must NOT extract initial SOC from the experiment config and inject it into parameters (which it currently does at lines 145-160). Otherwise initial SOC is applied twice — once by parse_objectives into parameter_values and again by run_protocol_simulation from the protocol’s global_variables. The fix: skip the initial-state extraction in parse_objectives() when backend="ucp", letting UCP handle it exclusively from the protocol YAML.
  • build_protocol_simulation is called once in build(). run_protocol_simulation is called on every optimizer iteration in _run(). UCP’s FastPybammSimulation caches step-specific built models across iterations.
  • Solver kwargs: build_protocol_simulation accepts **kwargs that are passed to the solver. _set_up_ucp_simulation should extract solver-related kwargs from simulation_kwargs (e.g., tolerances) and forward them, matching the existing behavior of set_up_simulation in BaseObjective.

Known Limitation: UCP-Computed Columns

UCP’s DataFrame output includes computed columns (Charge capacity [A.h], Charge energy [W.h]) that are calculated via trapezoidal integration of current/power. These are not available through timeseries_solution because they are not PyBaMM state variables. However, PyBaMM’s own state variables — Discharge capacity [A.h], Discharge energy [W.h], Throughput capacity [A.h] — are in the y state vector and fully available via sol["Discharge capacity [A.h]"]. These are the variables that ionworkspipeline metrics reference. The UCP-specific charge/discharge split columns are a DataFrame convenience not used by the optimization system.

Backend Selection in DesignObjective

In DesignObjective.build(), select the simulation backend based on an options["backend"] field, with fallback to the IONWORKS_SIMULATION_BACKEND environment variable:
def build(self, parameter_values):
    # ... existing variable processing (unchanged) ...

    backend = self.options.get("backend") or os.environ.get(
        "IONWORKS_SIMULATION_BACKEND", "pybamm"
    )

    if backend == "ucp":
        self.simulation = self._set_up_ucp_simulation(model, parameter_values)
    else:
        self.simulation = self.set_up_simulation(model, parameter_values)
_set_up_ucp_simulation extracts the experiment YAML string from self.options["simulation_kwargs"]["experiment"] (before it gets parsed to a PyBaMM Experiment), creates a UCPSimulation, and returns it. Important: When backend="ucp", the experiment string must be preserved as a raw YAML string and NOT converted to a pybamm.Experiment by parse_experiment(). The approach: in parse_options() (objectives.py line ~298), check if options_config.get("backend") == "ucp" before the parse_experiment() call. If so, skip the conversion and leave the experiment as a raw string in simulation_kwargs["experiment"]. This is a small, contained change — parse_options() already inspects simulation_kwargs contents, so adding a backend check is natural. The raw string is then available for _set_up_ucp_simulation to pass directly to UCPSimulation. _validate_solution_steps handling: DesignObjective._validate_solution_steps() calls self._get_experiment_steps() which accesses experiment.steps — this fails on a raw YAML string. When backend="ucp", set self._validate_against_experiment_steps = False in build() before creating the simulation. UCP’s own protocol validation (step count, step types) is sufficient — the PyBaMM-specific step count check is not meaningful for UCP runs. to_config() round-trip: DesignObjective.to_config() checks hasattr(experiment, "steps") — a raw YAML string doesn’t have .steps, so the string passes through as-is. This is correct behavior. The backend field in options also survives serialization since options is a plain dict. Checkpoint/restart of UCP-backed objectives works without additional changes.

Config Passthrough

The backend option flows through the existing config path with no schema changes:
  1. API request: Add optional backend field to the optimization request (or use env var only — no API change needed for MVP).
  2. Backend converter (design_optimization_converter.py): If a backend field is present in the request, include "backend": "ucp" in each objective’s options dict.
  3. parse_objectives(): Passes options through to DesignObjective.__init__() — no changes needed since options is dict[str, Any].
  4. DesignObjective.build(): Reads self.options["backend"] as described above.

EIS Validation

EIS steps are not supported in design optimization. Validation happens at two levels: Frontend (blocks submit): In handleCreateOptimization in optimization-new-with-template-container.tsx, check each objective’s experiment string with a simple regex before submission:
const hasEisSteps = (experiment: string) => /\beis\b/i.test(experiment);

for (const obj of objectives) {
    if (hasEisSteps(obj.experiment)) {
        toast.error('EIS steps are not supported in design optimization');
        return;
    }
}
Backend (defense-in-depth): In convert_to_datafit_job_params() in design_optimization_converter.py, apply the same check before job submission:
import re

def _validate_no_eis_steps(experiment_yaml: str) -> None:
    if re.search(r"\beis\b", experiment_yaml, re.IGNORECASE):
        raise BadRequestError(
            "EIS steps are not supported in design optimization experiments"
        )
This gives an immediate 400 error rather than a failed job.

What Changes

ComponentChange
ionworkspipeline — new UCPSimulation classNew file or added to existing simulation module
DesignObjective.build()Add backend selection logic
parse_objectives()Skip parse_experiment() when backend is "ucp"
design_optimization_converter.pyPass through backend option, validate no EIS
Frontend optimization formAdd EIS regex check before submit

What Doesn’t Change

  • DesignObjective._run() — unchanged, calls self.simulation.solve(inputs=...)
  • All metrics and actions — unchanged, receive pybamm.Solution
  • DataFitJobProcessor — unchanged
  • DataFit optimizer loop — unchanged
  • Frontend form structure — unchanged (just one validation added)

Testing

  1. Unit: UCPSimulation.solve() — returns valid pybamm.Solution with expected variables, .sub_solutions, and .cycles
  2. Unit: cycle structure.cycles correctly groups steps by StepSolution.cycle_count; each cycle has its own .sub_solutions; save_at_cycles works
  3. Unit: EIS validation — frontend regex and backend check both reject protocols with EIS steps, accept protocols without
  4. Unit: backend selectionDesignObjective.build() creates UCPSimulation when backend="ucp", iwp.Simulation when backend="pybamm"
  5. Unit: env var fallbackIONWORKS_SIMULATION_BACKEND=ucp selects UCP when no explicit option is set
  6. Unit: _validate_solution_steps disabled — confirm it is set to False for UCP backend and does not interfere with simulation results
  7. Unit: to_config() round-trip — serialize and deserialize a UCP-backed objective, verify backend field and raw experiment YAML survive
  8. Unit: initial SOC not double-applied — verify parse_objectives() skips initial state extraction when backend="ucp"
  9. Integration: end-to-end optimization — run a design optimization with backend="ucp" and verify it produces equivalent results to backend="pybamm"
  10. Regression — existing backend="pybamm" path works identically

Risks and Mitigations

  • timeseries_solution fidelity: The pybamm.Solution built from UCP state vectors should be equivalent to one from pybamm.Simulation.solve(). This is already tested in the UCP package. Integration tests will verify metric values match between backends.
  • Experiment string preservation: Addressed by checking backend="ucp" in parse_options() to skip parse_experiment(). Small, contained change.
  • Initial SOC double application: Addressed by skipping initial-state extraction in parse_objectives() when backend="ucp". UCP handles it from the protocol YAML.
  • Cycle structure: UCPSimulation.solve() builds .cycles by grouping sub-solutions using StepSolution.cycle_count. Each cycle is a real pybamm.Solution with full interpolation. save_at_cycles, CycleSlicer, and IterativeMetric all work.
  • UCP-computed columns: Charge capacity [A.h] and Charge energy [W.h] (UCP DataFrame columns) are not available via timeseries_solution. PyBaMM’s own Discharge capacity [A.h] and Discharge energy [W.h] state variables are available and are what ionworkspipeline metrics use.
  • _validate_solution_steps incompatibility: Disabled for UCP backend. UCP’s own protocol validation is sufficient.
  • Converter uses HTTPException: The existing design_optimization_converter.py raises HTTPException directly, violating the project’s layered architecture rules. The new EIS validation uses BadRequestError correctly. Cleaning up existing HTTPException usage is out of scope but noted as tech debt.