UCP Simulation Backend for Design Optimization
Date: 2026-04-03 Status: DraftProblem
Design optimization currently converts UCP protocol YAML to a PyBaMMExperiment 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 aUCPSimulation 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 inionworkspipeline that duck-types iwp.Simulation for the subset of the
interface that DesignObjective uses:
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:
sol.cycles[c]— realpybamm.Solutionwith full interpolationsol.cycles[c].sub_solutions— steps within that cyclesave_at_cycles— works as expectedCycleSlicer,IterativeMetric— work unchanged
initial_soc: UCP protocols encode initial state in theirglobalconfig (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 intoparameters(which it currently does at lines 145-160). Otherwise initial SOC is applied twice — once byparse_objectivesintoparameter_valuesand again byrun_protocol_simulationfrom the protocol’sglobal_variables. The fix: skip the initial-state extraction inparse_objectives()whenbackend="ucp", letting UCP handle it exclusively from the protocol YAML.build_protocol_simulationis called once inbuild().run_protocol_simulationis called on every optimizer iteration in_run(). UCP’sFastPybammSimulationcaches step-specific built models across iterations.- Solver kwargs:
build_protocol_simulationaccepts**kwargsthat are passed to the solver._set_up_ucp_simulationshould extract solver-related kwargs fromsimulation_kwargs(e.g., tolerances) and forward them, matching the existing behavior ofset_up_simulationinBaseObjective.
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
InDesignObjective.build(), select the simulation backend based on an options["backend"]
field, with fallback to the IONWORKS_SIMULATION_BACKEND environment variable:
_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
Thebackend option flows through the existing config path with no schema changes:
- API request: Add optional
backendfield to the optimization request (or use env var only — no API change needed for MVP). - Backend converter (
design_optimization_converter.py): If abackendfield is present in the request, include"backend": "ucp"in each objective’soptionsdict. parse_objectives(): Passesoptionsthrough toDesignObjective.__init__()— no changes needed sinceoptionsisdict[str, Any].DesignObjective.build(): Readsself.options["backend"]as described above.
EIS Validation
EIS steps are not supported in design optimization. Validation happens at two levels: Frontend (blocks submit): InhandleCreateOptimization in optimization-new-with-template-container.tsx, check each
objective’s experiment string with a simple regex before submission:
convert_to_datafit_job_params() in design_optimization_converter.py, apply the same
check before job submission:
What Changes
| Component | Change |
|---|---|
ionworkspipeline — new UCPSimulation class | New 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.py | Pass through backend option, validate no EIS |
| Frontend optimization form | Add EIS regex check before submit |
What Doesn’t Change
DesignObjective._run()— unchanged, callsself.simulation.solve(inputs=...)- All metrics and actions — unchanged, receive
pybamm.Solution DataFitJobProcessor— unchangedDataFitoptimizer loop — unchanged- Frontend form structure — unchanged (just one validation added)
Testing
- Unit:
UCPSimulation.solve()— returns validpybamm.Solutionwith expected variables,.sub_solutions, and.cycles - Unit: cycle structure —
.cyclescorrectly groups steps byStepSolution.cycle_count; each cycle has its own.sub_solutions;save_at_cyclesworks - Unit: EIS validation — frontend regex and backend check both reject protocols with EIS steps, accept protocols without
- Unit: backend selection —
DesignObjective.build()createsUCPSimulationwhenbackend="ucp",iwp.Simulationwhenbackend="pybamm" - Unit: env var fallback —
IONWORKS_SIMULATION_BACKEND=ucpselects UCP when no explicit option is set - Unit:
_validate_solution_stepsdisabled — confirm it is set toFalsefor UCP backend and does not interfere with simulation results - Unit:
to_config()round-trip — serialize and deserialize a UCP-backed objective, verifybackendfield and raw experiment YAML survive - Unit: initial SOC not double-applied — verify
parse_objectives()skips initial state extraction whenbackend="ucp" - Integration: end-to-end optimization — run a design optimization with
backend="ucp"and verify it produces equivalent results tobackend="pybamm" - Regression — existing
backend="pybamm"path works identically
Risks and Mitigations
timeseries_solutionfidelity: Thepybamm.Solutionbuilt from UCP state vectors should be equivalent to one frompybamm.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"inparse_options()to skipparse_experiment(). Small, contained change. - Initial SOC double application: Addressed by skipping initial-state extraction in
parse_objectives()whenbackend="ucp". UCP handles it from the protocol YAML. - Cycle structure:
UCPSimulation.solve()builds.cyclesby grouping sub-solutions usingStepSolution.cycle_count. Each cycle is a realpybamm.Solutionwith full interpolation.save_at_cycles,CycleSlicer, andIterativeMetricall work. - UCP-computed columns:
Charge capacity [A.h]andCharge energy [W.h](UCP DataFrame columns) are not available viatimeseries_solution. PyBaMM’s ownDischarge capacity [A.h]andDischarge energy [W.h]state variables are available and are what ionworkspipeline metrics use. _validate_solution_stepsincompatibility: Disabled for UCP backend. UCP’s own protocol validation is sufficient.- Converter uses
HTTPException: The existingdesign_optimization_converter.pyraisesHTTPExceptiondirectly, violating the project’s layered architecture rules. The new EIS validation usesBadRequestErrorcorrectly. Cleaning up existingHTTPExceptionusage is out of scope but noted as tech debt.