Repository: pybamm-team/PyBaMM

Issue:
Adding termination events to experiment class
Hey, 

in reference to #1796 I wanted to open this issue for future termination events in the experiment class.
It would be nice to set a voltage limit to end the experiment completely. I was also thinking a termination event after each step inside the cycle might be useful.

First one would be helpful for example in GITT experiments, when you're trying to cycle pulses until a voltage limit. Second one could be used when they are multiple instructions like a cycling procedure and a pulsetest.

```
experiment_gitt = pybamm.Experiment(
        [("GITT Pulse")]*60, termination = "Voltage limit"
        )

experiment_aging = pybamm.Experiment(
        [
        ("Pulsetest")+
        ("Cycling procedure")*400, termination = ""
        ]*5, termination = ""
        )
```







Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eac368992..f06bc259c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## Bug fixes
 
+-   Experiments can be set to terminate when a voltage is reached (across all steps) ([#1832](https://github.com/pybamm-team/PyBaMM/pull/1832))
 -   Fixed bug in expression tree simplification ([#1831](https://github.com/pybamm-team/PyBaMM/pull/1831))
 -   Solid tortuosity is now correctly calculated with Bruggeman coefficient of the respective electrode ([#1773](https://github.com/pybamm-team/PyBaMM/pull/1773))
 
diff --git a/pybamm/experiments/experiment.py b/pybamm/experiments/experiment.py
index ee06ed521..fec96abf8 100644
--- a/pybamm/experiments/experiment.py
+++ b/pybamm/experiments/experiment.py
@@ -459,10 +459,13 @@ class Experiment:
                         "Capacity termination must be given in the form "
                         "'80%', '4Ah', or '4A.h'"
                     )
+            elif term.endswith("V"):
+                end_discharge_V = term.split("V")[0]
+                termination_dict["voltage"] = (float(end_discharge_V), "V")
             else:
                 raise ValueError(
-                    "Only capacity can be provided as a termination reason, "
-                    "e.g. '80% capacity' or '4 Ah capacity'"
+                    "Only capacity or voltage can be provided as a termination reason, "
+                    "e.g. '80% capacity', '4 Ah capacity', or '2.5 V'"
                 )
         return termination_dict
 
diff --git a/pybamm/simulation.py b/pybamm/simulation.py
index baef45c1f..a9a8a374c 100644
--- a/pybamm/simulation.py
+++ b/pybamm/simulation.py
@@ -842,6 +842,8 @@ class Simulation:
             else:
                 esoh_sim = None
 
+            voltage_stop = self.experiment.termination.get("voltage")
+
             idx = 0
             num_cycles = len(self.experiment.cycle_lengths)
             feasible = True  # simulation will stop if experiment is infeasible
@@ -983,6 +985,22 @@ class Simulation:
                         )
                         break
 
+                # Check voltage stop
+                if voltage_stop is not None:
+                    min_voltage = np.min(cycle_solution["Battery voltage [V]"].data)
+                    if min_voltage > voltage_stop[0]:
+                        pybamm.logger.notice(
+                            f"Minimum voltage is now {min_voltage:.3f} V "
+                            f"(will stop at {voltage_stop[0]:.3f} V)"
+                        )
+                    else:
+                        pybamm.logger.notice(
+                            "Stopping experiment since minimum voltage "
+                            f"({min_voltage:.3f} V) "
+                            f"is below stopping voltage ({voltage_stop[0]:.3f} V)."
+                        )
+                        break
+
             if self.solution is not None and len(all_cycle_solutions) > 0:
                 self.solution.cycles = all_cycle_solutions
                 self.solution.set_summary_variables(all_summary_variables)


Test patch:
diff --git a/tests/unit/test_experiments/test_experiment.py b/tests/unit/test_experiments/test_experiment.py
index b91645460..6e3eb5a3f 100644
--- a/tests/unit/test_experiments/test_experiment.py
+++ b/tests/unit/test_experiments/test_experiment.py
@@ -339,6 +339,7 @@ class TestExperiment(unittest.TestCase):
             ["Discharge at 1 C for 20 seconds"], termination="80.7% capacity"
         )
         self.assertEqual(experiment.termination, {"capacity": (80.7, "%")})
+
         experiment = pybamm.Experiment(
             ["Discharge at 1 C for 20 seconds"], termination="80.7 % capacity"
         )
@@ -348,11 +349,24 @@ class TestExperiment(unittest.TestCase):
             ["Discharge at 1 C for 20 seconds"], termination="4.1Ah capacity"
         )
         self.assertEqual(experiment.termination, {"capacity": (4.1, "Ah")})
+
         experiment = pybamm.Experiment(
             ["Discharge at 1 C for 20 seconds"], termination="4.1 A.h capacity"
         )
         self.assertEqual(experiment.termination, {"capacity": (4.1, "Ah")})
 
+        experiment = pybamm.Experiment(
+            ["Discharge at 1 C for 20 seconds"], termination="3V"
+        )
+        self.assertEqual(experiment.termination, {"voltage": (3, "V")})
+
+        experiment = pybamm.Experiment(
+            ["Discharge at 1 C for 20 seconds"], termination=["3V", "4.1Ah capacity"]
+        )
+        self.assertEqual(
+            experiment.termination, {"voltage": (3, "V"), "capacity": (4.1, "Ah")}
+        )
+
         with self.assertRaisesRegex(ValueError, "Only capacity"):
             experiment = pybamm.Experiment(
                 ["Discharge at 1 C for 20 seconds"], termination="bla bla capacity bla"
diff --git a/tests/unit/test_experiments/test_simulation_with_experiment.py b/tests/unit/test_experiments/test_simulation_with_experiment.py
index bd3de80c8..5bb94236f 100644
--- a/tests/unit/test_experiments/test_simulation_with_experiment.py
+++ b/tests/unit/test_experiments/test_simulation_with_experiment.py
@@ -204,7 +204,7 @@ class TestSimulationExperiment(unittest.TestCase):
         pybamm.set_logging_level("WARNING")
         self.assertEqual(sim._solution, None)
 
-    def test_run_experiment_termination(self):
+    def test_run_experiment_termination_capacity(self):
         # with percent
         experiment = pybamm.Experiment(
             [
@@ -247,6 +247,28 @@ class TestSimulationExperiment(unittest.TestCase):
         # all but the last value should be above the termination condition
         np.testing.assert_array_less(5.04, C[:-1])
 
+    def test_run_experiment_termination_voltage(self):
+        # with percent
+        experiment = pybamm.Experiment(
+            [
+                ("Discharge at 0.5C for 10 minutes", "Rest for 10 minutes"),
+            ]
+            * 5,
+            termination="4V",
+        )
+        model = pybamm.lithium_ion.SPM()
+        param = pybamm.ParameterValues("Chen2020")
+        sim = pybamm.Simulation(model, experiment=experiment, parameter_values=param)
+        sol = sim.solve()
+        # Only two cycles should be completed, only 2nd cycle should go below 4V
+        np.testing.assert_array_less(
+            4, np.min(sol.cycles[0]["Terminal voltage [V]"].data)
+        )
+        np.testing.assert_array_less(
+            np.min(sol.cycles[1]["Terminal voltage [V]"].data), 4
+        )
+        self.assertEqual(len(sol.cycles), 2)
+
     def test_save_at_cycles(self):
         experiment = pybamm.Experiment(
             [