Repository: pybamm-team/PyBaMM
Pull request #4770 (2025-01-16 18:47:29)

add operator to experiment terminations
# Description

Adds operator for experiment terminations. While this won't functionally change many experiments, it enables experiments to more closely mirror cell testing protocols.

Fixes # (issue)

## Type of change

Please add a line in the relevant section of [CHANGELOG.md](https://github.com/pybamm-team/PyBaMM/blob/develop/CHANGELOG.md) to document the change (include PR #) - note reverse order of PR #s. If necessary, also add to the list of breaking changes.

- [ ] New feature (non-breaking change which adds functionality)
- [ ] Optimization (back-end change that speeds up the code)
- [ ] Bug fix (non-breaking change which fixes an issue)

# Key checklist:

- [ ] No style issues: `$ pre-commit run` (or `$ nox -s pre-commit`) (see [CONTRIBUTING.md](https://github.com/pybamm-team/PyBaMM/blob/develop/CONTRIBUTING.md#installing-and-using-pre-commit) for how to set this up to run automatically when committing locally, in just two lines of code)
- [ ] All tests pass: `$ python -m pytest` (or `$ nox -s tests`)
- [ ] The documentation builds: `$ python -m pytest --doctest-plus src` (or `$ nox -s doctests`)

You can run integration tests, unit tests, and doctests together at once, using `$ nox -s quick`.

## Further checks:

- [ ] Code is commented, particularly in hard-to-understand areas
- [ ] Tests added that prove fix is effective or that feature works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4770?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.66%. Comparing base [(`1ac422a`)](https://app.codecov.io/gh/pybamm-team/PyBaMM/commit/1ac422a06edcee7e444a5a2f6b80e9c45055a035?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team) to head [(`cac53d4`)](https://app.codecov.io/gh/pybamm-team/PyBaMM/commit/cac53d47c11b908fc2dfc1cdfd5ff6be3bb8db1d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff            @@
##           develop    #4770   +/-   ##
========================================
  Coverage    98.66%   98.66%           
========================================
  Files          303      303           
  Lines        23227    23248   +21     
========================================
+ Hits         22916    22937   +21     
  Misses         311      311           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pybamm-team/PyBaMM/pull/4770?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pybamm-team).

aabills: > Good idea including this, do most cyclers ask for a direction or do they figure it out automatically?

Neware and Maccor, the only ones I've dealt with, have "charge" or "discharge" built into the steps and they also have these limits specified with operators.

The reason for this is so that they can go to a "safer" state in case of a short or something. This of course is not relevant for pybamm, but there's not a great way to filter the safety limits without catching the other limits. 
aabills: this idaklu error doesn't look related to this pr
kratman: > this idaklu error doesn't look related to this pr

Yeah it is a flaky test, not sure why that happens, but I have not really been able to reproduce it locally. Re-triggering it should make it pass. It is on my to-do list

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58fafc076..9f01feeef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,8 @@
 # [Unreleased](https://github.com/pybamm-team/PyBaMM/)
 
+## Features
+- Added Operators to current and voltage termination events. ([#4770](https://github.com/pybamm-team/PyBaMM/pull/4770))
+
 # [v25.1.0](https://github.com/pybamm-team/PyBaMM/tree/v25.1.0) - 2025-01-14
 
 ## Features
diff --git a/src/pybamm/experiment/step/__init__.py b/src/pybamm/experiment/step/__init__.py
index 1810844d3..b2613a50d 100644
--- a/src/pybamm/experiment/step/__init__.py
+++ b/src/pybamm/experiment/step/__init__.py
@@ -1,5 +1,5 @@
 from .steps import *
 from .base_step import BaseStep, BaseStepExplicit, BaseStepImplicit
-from .step_termination import *
+from .step_termination import BaseTermination, CurrentTermination, VoltageTermination, CustomTermination, CrateTermination, _read_termination
 
 __all__ = ['base_step', 'step_termination', 'steps']
diff --git a/src/pybamm/experiment/step/base_step.py b/src/pybamm/experiment/step/base_step.py
index 8dd829935..b2f92d4df 100644
--- a/src/pybamm/experiment/step/base_step.py
+++ b/src/pybamm/experiment/step/base_step.py
@@ -67,8 +67,14 @@ class BaseStep:
         tags=None,
         start_time=None,
         description=None,
-        direction=None,
+        direction: str | None = None,
     ):
+        potential_directions = ["charge", "discharge", "rest", None]
+        if direction not in potential_directions:
+            raise ValueError(
+                f"Invalid direction: {direction}. Must be one of {potential_directions}"
+            )
+        self.input_duration = duration
         self.input_duration = duration
         self.input_value = value
         # Check if drive cycle
@@ -386,11 +392,11 @@ class BaseStep:
             init_curr = self.value
         sign = np.sign(init_curr)
         if sign == 0:
-            return "Rest"
+            return "rest"
         elif sign > 0:
-            return "Discharge"
+            return "discharge"
         else:
-            return "Charge"
+            return "charge"
 
     def record_tags(
         self,
diff --git a/src/pybamm/experiment/step/step_termination.py b/src/pybamm/experiment/step/step_termination.py
index d4ffbe1aa..a17bae87f 100644
--- a/src/pybamm/experiment/step/step_termination.py
+++ b/src/pybamm/experiment/step/step_termination.py
@@ -14,8 +14,11 @@ class BaseTermination:
         The value at which the event is triggered
     """
 
-    def __init__(self, value):
+    def __init__(self, value, operator=None):
         self.value = value
+        if operator not in ["<", ">", None]:
+            raise ValueError(f"Invalid operator: {operator}")
+        self.operator = operator
 
     def get_event(self, variables, step):
         """
@@ -67,9 +70,19 @@ class CurrentTermination(BaseTermination):
         """
         See :meth:`BaseTermination.get_event`
         """
+        operator = self.operator
+        if operator == ">":
+            expr = self.value - variables["Current [A]"]
+            event_string = f"Current [A] > {self.value} [A] [experiment]"
+        elif operator == "<":
+            expr = variables["Current [A]"] - self.value
+            event_string = f"Current [A] < {self.value} [A] [experiment]"
+        else:
+            expr = abs(variables["Current [A]"]) - self.value
+            event_string = f"abs(Current [A]) < {self.value} [A] [experiment]"
         event = pybamm.Event(
-            "Current cut-off [A] [experiment]",
-            abs(variables["Current [A]"]) - self.value,
+            event_string,
+            expr,
         )
         return event
 
@@ -89,24 +102,48 @@ class VoltageTermination(BaseTermination):
         # figure out whether the voltage event is greater than the starting
         # voltage (charge) or less (discharge) and set the sign of the
         # event accordingly
-        direction = step.direction.capitalize()
-        if direction == "Charge":
+        operator = self.operator
+        if operator is None:
+            direction = step.direction
+            if direction == "charge":
+                operator = ">"
+            elif direction == "discharge":
+                operator = "<"
+            else:
+                # No event for rest steps
+                return None
+
+        if operator == ">":
             sign = -1
-        elif direction == "Discharge":
+        else:
+            # operator can only be "<" or ">"
             sign = 1
-        elif direction == "Rest":
-            # No event for rest steps
-            return None
 
         # Event should be positive at initial conditions for both
         # charge and discharge
         event = pybamm.Event(
-            f"{direction} voltage cut-off [V] [experiment]",
+            f"Voltage {operator} {self.value} [V] [experiment]",
             sign * (variables["Battery voltage [V]"] - self.value),
         )
         return event
 
 
+class Voltage:
+    def __gt__(self, value):
+        return VoltageTermination(value, operator=">")
+
+    def __lt__(self, value):
+        return VoltageTermination(value, operator="<")
+
+
+class Current:
+    def __gt__(self, value):
+        return CurrentTermination(value, operator=">")
+
+    def __lt__(self, value):
+        return CurrentTermination(value, operator="<")
+
+
 class CustomTermination(BaseTermination):
     """
     Define a custom termination event using a function. This can be used to create an


Test patch:
diff --git a/tests/unit/test_experiments/test_experiment_steps.py b/tests/unit/test_experiments/test_experiment_steps.py
index 67f63032b..e79b18d47 100644
--- a/tests/unit/test_experiments/test_experiment_steps.py
+++ b/tests/unit/test_experiments/test_experiment_steps.py
@@ -307,3 +307,42 @@ class TestExperimentSteps:
 
         with pytest.raises(ValueError, match="control must be"):
             pybamm.step.CustomStepImplicit(custom_step_voltage, control="bla")
+
+    def test_bad_direction(self):
+        with pytest.raises(ValueError, match="Invalid direction"):
+            pybamm.step.Voltage(4.1, direction="foo")
+
+    def test_steps_with_operators(self):
+        # voltage
+        step = pybamm.step.voltage(1, duration=3600)
+        termination_lt_4_1 = pybamm.step.VoltageTermination(4.1, operator="<")
+        termination_gt_4_1 = pybamm.step.VoltageTermination(4.1, operator=">")
+        variables = {"Battery voltage [V]": 4.2}
+        event_lt_4_1 = termination_lt_4_1.get_event(variables, step)
+        np.testing.assert_allclose(event_lt_4_1.expression, 4.2 - 4.1)
+        event_gt_4_1 = termination_gt_4_1.get_event(variables, step)
+        np.testing.assert_allclose(event_gt_4_1.expression, 4.1 - 4.2)
+
+        # current
+        step = pybamm.step.current(1, duration=3600)
+        termination_lt_0_05 = pybamm.step.CurrentTermination(0.05, operator="<")
+        termination_gt_0_05 = pybamm.step.CurrentTermination(0.05, operator=">")
+        variables = {"Current [A]": 0.06}
+        event_lt_0_05 = termination_lt_0_05.get_event(variables, step)
+        np.testing.assert_allclose(event_lt_0_05.expression, 0.06 - 0.05)
+        event_gt_0_05 = termination_gt_0_05.get_event(variables, step)
+        np.testing.assert_allclose(event_gt_0_05.expression, 0.05 - 0.06)
+
+        # error
+        with pytest.raises(ValueError, match="Invalid operator"):
+            pybamm.step.CurrentTermination(0.05, operator="=")
+
+        # operator overloading
+        termination_lt_0_05_oo = pybamm.step.step_termination.Current() < 0.05
+        termination_gt_0_05_oo = pybamm.step.step_termination.Current() > 0.05
+        assert termination_lt_0_05_oo == termination_gt_0_05
+        assert termination_gt_0_05_oo == termination_gt_0_05
+        termination_lt_4_1_oo = pybamm.step.step_termination.Voltage() < 4.1
+        termination_gt_4_1_oo = pybamm.step.step_termination.Voltage() > 4.1
+        assert termination_lt_4_1_oo == termination_gt_4_1
+        assert termination_gt_4_1_oo == termination_gt_4_1
diff --git a/tests/unit/test_experiments/test_simulation_with_experiment.py b/tests/unit/test_experiments/test_simulation_with_experiment.py
index 8b56b519d..ba64c6f10 100644
--- a/tests/unit/test_experiments/test_simulation_with_experiment.py
+++ b/tests/unit/test_experiments/test_simulation_with_experiment.py
@@ -33,10 +33,10 @@ class TestSimulationExperiment:
             steps[1].basic_repr()
         ]  # CC charge
         model_V = sim.experiment_unique_steps_to_model[steps[2].basic_repr()]  # CV hold
-        assert "Current cut-off [A] [experiment]" in [
+        assert "abs(Current [A]) < 0.05 [A] [experiment]" in [
             event.name for event in model_V.events
         ]
-        assert "Charge voltage cut-off [V] [experiment]" in [
+        assert "Voltage > 4.1 [V] [experiment]" in [
             event.name for event in model_I.events
         ]