{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Degraded Battery Cell Design Optimization\n", "\n", "This example demonstrates how to optimize battery cell design parameters using ionworkspipeline. We optimize active material volume fractions to maximize discharge capacity while preventing lithium plating by constraining the anode potential at cycle 50 in the batteries' lifetime.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import ionworkspipeline as iwp\n", "from ionworkspipeline.data_fits.models.metrics import Maximum, Minimum\n", "from ionworkspipeline.data_fits.models.metrics.actions import GreaterThan, Maximize\n", "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import pybamm\n", "from pybamm import Parameter" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Configure DFN Model and Optimization Parameters\n", "\n", "Import Libraries and Dependencies\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Setup model and baseline parameters\n", "model = pybamm.lithium_ion.SPMe(\n", " options={\n", " \"SEI\": \"solvent-diffusion limited\",\n", " \"SEI porosity change\": \"false\",\n", " \"particle mechanics\": \"swelling only\",\n", " \"loss of active material\": \"stress and reaction-driven\",\n", " }\n", ")\n", "\n", "# Initialize parameters and update LAM rate\n", "params_baseline = pybamm.ParameterValues(\"Ai2020\")\n", "params_baseline.update(\n", " {\"Negative electrode LAM constant proportional term [s-1]\": 3e-1 / 3600}\n", ")\n", "params_baseline.update(\n", " {\"Positive electrode LAM constant proportional term [s-1]\": 3e-1 / 3600}\n", ")\n", "\n", "\n", "# Link porosity to active material fraction\n", "for electrode in [\"Positive\", \"Negative\"]:\n", " params_baseline[f\"{electrode} electrode porosity\"] = 1.0 - Parameter(\n", " f\"{electrode} electrode active material volume fraction\"\n", " )\n", "\n", "# Define optimization parameters\n", "parameters = {\n", " \"Positive electrode active material volume fraction\": iwp.Parameter(\n", " \"Positive electrode active material volume fraction\",\n", " initial_value=0.65,\n", " bounds=(0.5, 0.9),\n", " ),\n", " \"Negative electrode active material volume fraction\": iwp.Parameter(\n", " \"Negative electrode active material volume fraction\",\n", " initial_value=0.65,\n", " bounds=(0.5, 0.85),\n", " ),\n", "}\n", "\n", "print(f\"Model configured with {len(parameters)} optimization parameters\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Define Experiment Protocol and Optimization Metrics\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Define experiment protocol\n", "experiment = pybamm.Experiment(\n", " [\n", " (\n", " \"Discharge at 2C until 3.00 V\",\n", " \"Charge at 2C until 4.20 V\",\n", " )\n", " ]\n", " * 50\n", ")\n", "\n", "# Define optimization actions\n", "actions = {\n", " \"Maximize discharge capacity [A.h]\": Maximize(Maximum(\"Discharge capacity [A.h]\")),\n", "}\n", "\n", "# Constraints define bounds that must be satisfied (using GreaterThan/LessThan actions)\n", "constraints = {\n", " \"Anode potential minimum constraint [V]\": GreaterThan(\n", " Minimum(\n", " \"Negative electrode surface potential difference at separator interface [V]\"\n", " ),\n", " value=0.0,\n", " penalty=150.0,\n", " ),\n", "}" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Execute Battery Design Optimization\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Run Validation Simulations\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Setup and run optimization\n", "initial_soc = 1.0\n", "params_for_pipeline = {k: v for k, v in params_baseline.items() if k not in parameters}\n", "\n", "solver = pybamm.IDAKLUSolver(\n", " options={\"num_steps_no_progress\": 1000, \"t_no_progress\": 0.99}\n", ")\n", "objective_options = {\n", " \"model\": model,\n", " \"simulation_kwargs\": {\n", " \"experiment\": experiment,\n", " \"solver\": solver,\n", " },\n", "}\n", "\n", "objective = iwp.objectives.DesignObjective(\n", " options=objective_options,\n", " actions=actions,\n", " constraints=constraints,\n", " save_at_cycles=[50],\n", " validate_against_experiment_steps=False,\n", " parameters={\"Initial SOC [%]\": 100 * initial_soc},\n", ")\n", "\n", "optim = iwp.optimizers.Pints(\n", " max_iterations=10, # Increase for better results\n", " max_unchanged_iterations=30,\n", " max_unchanged_iterations_threshold=1e-4,\n", " log_to_screen=True,\n", ")\n", "\n", "design_datafit = iwp.DataFit(\n", " objective, parameters=parameters, options={\"seed\": 0}, optimizer=optim\n", ")\n", "\n", "print(\"Running optimization...\")\n", "results = design_datafit.run(params_for_pipeline)\n", "\n", "print(f\"\\nOptimization completed! Final cost: {results.costs:.6f}\")\n", "print(\"Optimal parameters:\")\n", "for name in parameters:\n", " print(f\" {name}: {results.parameter_values[name]:.6f}\")" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Helper function to extract metrics from solution\n", "def extract_metrics(solution):\n", " sol_discharge = solution.sub_solutions[0]\n", " discharge_capacity = abs(sol_discharge[\"Discharge capacity [A.h]\"]()[-1])\n", "\n", " # Combine charge steps\n", " charge_solutions = solution.sub_solutions[2:]\n", " sol_charge = charge_solutions[0]\n", " for charge_sol in charge_solutions[1:]:\n", " sol_charge = sol_charge + charge_sol\n", "\n", " anode_potential_series = sol_charge[\n", " \"Negative electrode surface potential difference at separator interface [V]\"\n", " ]()\n", " min_anode_potential = min(anode_potential_series)\n", " mean_anode_potential = anode_potential_series.mean()\n", "\n", " return {\n", " \"discharge_capacity\": discharge_capacity,\n", " \"min_anode_potential\": min_anode_potential,\n", " \"mean_anode_potential\": mean_anode_potential,\n", " \"solution\": solution,\n", " }\n", "\n", "\n", "# Setup baseline parameters for comparison\n", "params_optimal = params_baseline.copy()\n", "params_optimal.update(results.parameter_values.copy())\n", "\n", "params_baseline_original = params_baseline.copy()\n", "# Run simulations for comparison\n", "sim_baseline = iwp.Simulation(\n", " model=model, parameter_values=params_baseline_original, experiment=experiment\n", ")\n", "sim_optimal = iwp.Simulation(\n", " model=model, parameter_values=params_optimal, experiment=experiment\n", ")\n", "\n", "sol_baseline = sim_baseline.solve(initial_soc=initial_soc)\n", "sol_optimal = sim_optimal.solve(initial_soc=initial_soc)\n", "\n", "baseline_metrics = extract_metrics(sol_baseline)\n", "optimal_metrics = extract_metrics(sol_optimal)\n", "\n", "print(\"Validation simulations completed\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Analyze Performance Metrics and Parameter Changes\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Performance comparison and analysis\n", "capacity_improvement = (\n", " (optimal_metrics[\"discharge_capacity\"] - baseline_metrics[\"discharge_capacity\"])\n", " / baseline_metrics[\"discharge_capacity\"]\n", ") * 100\n", "min_anode_improvement = (\n", " optimal_metrics[\"min_anode_potential\"] - baseline_metrics[\"min_anode_potential\"]\n", ")\n", "\n", "print(\"Performance Comparison:\")\n", "print(\"=\" * 50)\n", "print(f\"{'Metric':<35} {'Baseline':<12} {'Optimal':<12} {'Improvement'}\")\n", "print(\"-\" * 70)\n", "print(\n", " f\"{'Discharge Capacity [A.h]':<35} {baseline_metrics['discharge_capacity']:<12.4f} {optimal_metrics['discharge_capacity']:<12.4f} {capacity_improvement:+.2f}%\"\n", ")\n", "print(\n", " f\"{'Min Anode Potential [V]':<35} {baseline_metrics['min_anode_potential']:<12.4f} {optimal_metrics['min_anode_potential']:<12.4f} {min_anode_improvement:+.4f}\"\n", ")\n", "print(\n", " f\"{'Mean Anode Potential [V]':<35} {baseline_metrics['mean_anode_potential']:<12.4f} {optimal_metrics['mean_anode_potential']:<12.4f}\"\n", ")\n", "print(\"=\" * 50)\n", "\n", "print(\"\\nParameter Changes:\")\n", "for name, param in parameters.items():\n", " baseline_val = param.initial_value\n", " optimal_val = results.parameter_values[name]\n", " change = optimal_val - baseline_val\n", " percent_change = (change / baseline_val) * 100\n", " print(f\"{name}: {baseline_val:.6f} → {optimal_val:.6f} ({percent_change:+.2f}%)\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create Comparative Visualizations" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Create comprehensive visualization\n", "fig, axes = plt.subplots(2, 3, figsize=(18, 10))\n", "\n", "# Voltage comparison\n", "t_baseline = baseline_metrics[\"solution\"].t / 3600\n", "t_optimal = optimal_metrics[\"solution\"].t / 3600\n", "\n", "axes[0, 0].plot(\n", " t_baseline,\n", " baseline_metrics[\"solution\"][\"Voltage [V]\"](baseline_metrics[\"solution\"].t),\n", " label=\"Baseline\",\n", " linewidth=2,\n", " alpha=0.8,\n", ")\n", "axes[0, 0].plot(\n", " t_optimal,\n", " optimal_metrics[\"solution\"][\"Voltage [V]\"](optimal_metrics[\"solution\"].t),\n", " label=\"Optimal\",\n", " linewidth=2,\n", " alpha=0.8,\n", " linestyle=\"--\",\n", ")\n", "axes[0, 0].set_xlabel(\"Time [h]\")\n", "axes[0, 0].set_ylabel(\"Voltage [V]\")\n", "axes[0, 0].set_title(\"Cell Voltage: Baseline vs Optimal\")\n", "axes[0, 0].legend()\n", "axes[0, 0].grid(True, alpha=0.3)\n", "\n", "# Discharge capacity\n", "metrics_names = [\n", " \"Discharge\\nCapacity @ Cycle 1\",\n", " f\"Discharge\\nCapacity @ Cycle {len(sol_baseline.cycles)}\",\n", "]\n", "baseline_values = [\n", " baseline_metrics[\"solution\"].cycles[0][\"Discharge capacity [A.h]\"]().max(),\n", " baseline_metrics[\"solution\"].cycles[-1][\"Discharge capacity [A.h]\"]().max(),\n", "]\n", "optimal_values = [\n", " optimal_metrics[\"solution\"].cycles[0][\"Discharge capacity [A.h]\"]().max(),\n", " optimal_metrics[\"solution\"].cycles[-1][\"Discharge capacity [A.h]\"]().max(),\n", "]\n", "\n", "x = np.arange(len(metrics_names))\n", "width = 0.35\n", "\n", "axes[0, 1].bar(\n", " x - width / 2,\n", " baseline_values,\n", " width,\n", " label=\"Baseline\",\n", " alpha=0.8,\n", " color=\"lightblue\",\n", ")\n", "axes[0, 1].bar(\n", " x + width / 2, optimal_values, width, label=\"Optimal\", alpha=0.8, color=\"orange\"\n", ")\n", "\n", "for i, (baseline, optimal) in enumerate(\n", " zip(baseline_values, optimal_values, strict=False)\n", "):\n", " y_offset = baseline * 0.02 if baseline > 0 else 0.005\n", " axes[0, 1].text(\n", " i - width / 2,\n", " baseline + y_offset,\n", " f\"{baseline:.3f}\",\n", " ha=\"center\",\n", " va=\"bottom\",\n", " fontsize=9,\n", " )\n", " axes[0, 1].text(\n", " i + width / 2,\n", " optimal + y_offset,\n", " f\"{optimal:.3f}\",\n", " ha=\"center\",\n", " va=\"bottom\",\n", " fontsize=9,\n", " )\n", "\n", "\n", "axes[0, 1].set_xlabel(\"Capacity [A.h]\")\n", "axes[0, 1].set_ylabel(\"Value\")\n", "axes[0, 1].set_title(\"Degraded Design Optimization\")\n", "axes[0, 1].set_xticks(x)\n", "axes[0, 1].set_xticklabels(metrics_names)\n", "axes[0, 1].set_ylim(top=3.4)\n", "axes[0, 1].legend()\n", "axes[0, 1].grid(True, alpha=0.3)\n", "\n", "# Anode potential during charge\n", "anode_baseline = sol_baseline[\n", " \"Negative electrode surface potential difference at separator interface [V]\"\n", "](sol_baseline.t)\n", "anode_optimal = sol_optimal[\n", " \"Negative electrode surface potential difference at separator interface [V]\"\n", "](sol_optimal.t)\n", "\n", "axes[1, 0].plot(t_baseline, anode_baseline, label=\"Baseline\", linewidth=2, alpha=0.8)\n", "axes[1, 0].plot(\n", " t_optimal,\n", " anode_optimal,\n", " label=\"Optimal\",\n", " linewidth=2,\n", " alpha=0.8,\n", " linestyle=\"--\",\n", ")\n", "axes[1, 0].axhline(y=0.0, color=\"r\", linestyle=\":\", alpha=0.7, label=\"Safety limit\")\n", "axes[1, 0].set_xlabel(\"Charge Time [h]\")\n", "axes[1, 0].set_ylabel(\"Anode - Electrolyte Potential [V]\")\n", "axes[1, 0].set_title(\"Anode Potential During Charge\")\n", "axes[1, 0].legend()\n", "axes[1, 0].grid(True, alpha=0.3)\n", "\n", "# Performance metrics bar chart\n", "metrics_names = [\n", " \"Discharge\\nCapacity [A.h]\",\n", " \"Min Anode\\nPotential [V]\",\n", " \"Mean Anode\\nPotential [V]\",\n", "]\n", "baseline_values = [\n", " baseline_metrics[\"discharge_capacity\"],\n", " baseline_metrics[\"min_anode_potential\"],\n", " baseline_metrics[\"mean_anode_potential\"],\n", "]\n", "optimal_values = [\n", " optimal_metrics[\"discharge_capacity\"],\n", " optimal_metrics[\"min_anode_potential\"],\n", " optimal_metrics[\"mean_anode_potential\"],\n", "]\n", "\n", "x = np.arange(len(metrics_names))\n", "width = 0.35\n", "\n", "axes[1, 1].bar(\n", " x - width / 2,\n", " baseline_values,\n", " width,\n", " label=\"Baseline\",\n", " alpha=0.8,\n", " color=\"lightblue\",\n", ")\n", "axes[1, 1].bar(\n", " x + width / 2, optimal_values, width, label=\"Optimal\", alpha=0.8, color=\"orange\"\n", ")\n", "\n", "for i, (baseline, optimal) in enumerate(\n", " zip(baseline_values, optimal_values, strict=False)\n", "):\n", " y_offset = baseline * 0.02 if baseline > 0 else 0.005\n", " axes[1, 1].text(\n", " i - width / 2,\n", " baseline + y_offset,\n", " f\"{baseline:.3f}\",\n", " ha=\"center\",\n", " va=\"bottom\",\n", " fontsize=9,\n", " )\n", " axes[1, 1].text(\n", " i + width / 2,\n", " optimal + y_offset,\n", " f\"{optimal:.3f}\",\n", " ha=\"center\",\n", " va=\"bottom\",\n", " fontsize=9,\n", " )\n", "\n", "\n", "axes[1, 1].set_xlabel(\"Performance Metrics\")\n", "axes[1, 1].set_ylabel(\"Value\")\n", "axes[1, 1].set_title(\"Performance Metrics Comparison\")\n", "axes[1, 1].set_xticks(x)\n", "axes[1, 1].set_xticklabels(metrics_names)\n", "axes[1, 1].set_ylim(top=3.0)\n", "axes[1, 1].legend()\n", "axes[1, 1].grid(True, alpha=0.3)\n", "\n", "# Parameter comparison bar chart\n", "param_names = list(parameters.keys())\n", "param_labels = [\n", " \"Pos. Active\\nMaterial [-]\",\n", " \"Neg. Active\\nMaterial [-]\",\n", "]\n", "\n", "baseline_param_values = [param.initial_value for param in parameters.values()]\n", "optimal_param_values = [results.parameter_values[name] for name in param_names]\n", "\n", "x_params = np.arange(len(param_names))\n", "width_params = 0.35\n", "\n", "axes[1, 2].bar(\n", " x_params - width_params / 2,\n", " baseline_param_values,\n", " width_params,\n", " label=\"Baseline\",\n", " alpha=0.8,\n", " color=\"lightblue\",\n", ")\n", "axes[1, 2].bar(\n", " x_params + width_params / 2,\n", " optimal_param_values,\n", " width_params,\n", " label=\"Optimal\",\n", " alpha=0.8,\n", " color=\"orange\",\n", ")\n", "\n", "# Add value labels on bars\n", "for i, (baseline, optimal) in enumerate(\n", " zip(baseline_param_values, optimal_param_values, strict=False)\n", "):\n", " y_offset_baseline = baseline * 0.02 if baseline > 0 else 0.005\n", " y_offset_optimal = optimal * 0.02 if optimal > 0 else 0.005\n", "\n", " # Format based on parameter type\n", " if i < 2: # Volume fractions\n", " baseline_text = f\"{baseline:.3f}\"\n", " optimal_text = f\"{optimal:.3f}\"\n", " elif i < 4: # Thicknesses (now in um)\n", " baseline_text = f\"{baseline:.1f}\"\n", " optimal_text = f\"{optimal:.1f}\"\n", " else: # Number of electrodes\n", " baseline_text = f\"{baseline:.0f}\"\n", " optimal_text = f\"{optimal:.0f}\"\n", "\n", " axes[1, 2].text(\n", " i - width_params / 2,\n", " baseline + y_offset_baseline,\n", " baseline_text,\n", " ha=\"center\",\n", " va=\"bottom\",\n", " fontsize=8,\n", " )\n", " axes[1, 2].text(\n", " i + width_params / 2,\n", " optimal + y_offset_optimal,\n", " optimal_text,\n", " ha=\"center\",\n", " va=\"bottom\",\n", " fontsize=8,\n", " )\n", "\n", "axes[1, 2].set_xlabel(\"Optimization Parameters\")\n", "axes[1, 2].set_ylabel(\"Parameter Value\")\n", "axes[1, 2].set_title(\"Parameter Values: Baseline vs Optimal\")\n", "axes[1, 2].set_xticks(x_params)\n", "axes[1, 2].set_ylim(top=1.0)\n", "axes[1, 2].set_xticklabels(param_labels, fontsize=9)\n", "axes[1, 2].legend()\n", "axes[1, 2].grid(True, alpha=0.3)\n", "\n", "# Add upper right subplot for summary statistics\n", "param_changes = []\n", "for _i, (name, param) in enumerate(parameters.items()):\n", " baseline_val = param.initial_value\n", " optimal_val = results.parameter_values[name]\n", " percent_change = ((optimal_val - baseline_val) / baseline_val) * 100\n", " param_changes.append(percent_change)\n", "\n", "axes[0, 2].barh(\n", " range(len(param_names)),\n", " param_changes,\n", " color=[\"orange\" if x > 0 else \"lightblue\" for x in param_changes],\n", " alpha=0.7,\n", ")\n", "axes[0, 2].set_yticks(range(len(param_names)))\n", "axes[0, 2].set_yticklabels(param_labels, fontsize=9)\n", "axes[0, 2].set_xlabel(\"Percentage Change [%]\")\n", "axes[0, 2].set_title(\"Parameter Changes from Baseline\")\n", "axes[0, 2].set_xlim(0, 60)\n", "axes[0, 2].axvline(x=0, color=\"black\", linestyle=\"-\", alpha=0.3)\n", "axes[0, 2].grid(True, alpha=0.3)\n", "\n", "# Add percentage labels\n", "for i, change in enumerate(param_changes):\n", " axes[0, 2].text(\n", " change + (1 if change >= 0 else -1),\n", " i,\n", " f\"{change:+.1f}%\",\n", " va=\"center\",\n", " ha=\"left\" if change >= 0 else \"right\",\n", " fontsize=8,\n", " )\n", "\n", "plt.tight_layout()\n", "plt.show()\n", "\n", "print(\"Optimization analysis completed!\")" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 2 }