Repository: pybamm-team/PyBaMM

Issue:
Time-based events in experiments
**Summary**
Allow the definition of experiments based on actions happening at a given time which would be useful to simulate real usage scenarios. For example:
```python3
"On 2021-01-01 at 08:00 discharge at 1C until 2.5 V",
"On 2021-01-01 at 17:00 charge at C/3 until 4.2 V",
"Hold at 4.2 V until C/20",
...
```
The idea is that the experiment would add the necessary resting periods before and after the first action (triggered by the "On ..." statement) but would run the third one immediately after the second one (no "On ..." statement).

What are your thoughts? Still not clear to me what would be the best way to pass dates and times (e.g do we want to work with absolute dates, or can we also set up "On day 1..."?

 **Motivation**
This would allow the simulation of real usage scenarios for electric vehicles.


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b90679049..496787a58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
 
 - Enable multithreading in IDAKLU solver ([#2947](https://github.com/pybamm-team/PyBaMM/pull/2947))
 - If a solution contains cycles and steps, the cycle number and step number are now saved when `solution.save_data()` is called ([#2931](https://github.com/pybamm-team/PyBaMM/pull/2931))
+- Experiments can now be given a `start_time` to define when each step should be triggered ([#2616](https://github.com/pybamm-team/PyBaMM/pull/2616)) 
 
 ## Optimizations
 
@@ -38,9 +39,10 @@
 ## Features
 
 - Added verbose logging to `pybamm.print_citations()` and citation tags for the `pybamm.Citations` class so that users can now see where the citations were registered when running simulations ([#2862](https://github.com/pybamm-team/PyBaMM/pull/2862))
+- Updated to casadi 3.6, which required some changes to the casadi integrator ([#2859](https://github.com/pybamm-team/PyBaMM/pull/2859))
 - PyBaMM is now natively supported on Apple silicon chips (`M1/M2`) ([#2435](https://github.com/pybamm-team/PyBaMM/pull/2435))
 - PyBaMM is now supported on Python `3.10` and `3.11` ([#2435](https://github.com/pybamm-team/PyBaMM/pull/2435))
-- Updated to casadi 3.6, which required some changes to the casadi integrator. ([#2859](https://github.com/pybamm-team/PyBaMM/pull/2859))
+
 
 ## Optimizations
 
@@ -48,10 +50,10 @@
 
 ## Bug fixes
 
+- Initial conditions for sensitivity equations calculated correctly ([#2920](https://github.com/pybamm-team/PyBaMM/pull/2920))
 - Parameter sets can now contain the key "chemistry", and will ignore its value (this previously would give errors in some cases) ([#2901](https://github.com/pybamm-team/PyBaMM/pull/2901))
-- Fixed a bug in the discretisation of initial conditions of a scaled variable ([#2856](https://github.com/pybamm-team/PyBaMM/pull/2856))
 - Fixed keyerror on "all" when getting sensitivities from IDAKLU solver([#2883](https://github.com/pybamm-team/PyBaMM/pull/2883))
-- Initial conditions for sensitivity equations calculated correctly ([#2920](https://github.com/pybamm-team/PyBaMM/pull/2920))
+- Fixed a bug in the discretisation of initial conditions of a scaled variable ([#2856](https://github.com/pybamm-team/PyBaMM/pull/2856))
 
 ## Breaking changes
 
@@ -73,7 +75,7 @@
 
 - Fix non-deteministic outcome of some tests in the test suite ([#2844](https://github.com/pybamm-team/PyBaMM/pull/2844))
 - Fixed excessive RAM consumption when running multiple simulations ([#2823](https://github.com/pybamm-team/PyBaMM/pull/2823))
-- Fixed use of last_state as starting_solution in Simulation.solve() ([#2822](https://github.com/pybamm-team/PyBaMM/pull/2822))
+- Fixed use of `last_state` as `starting_solution` in `Simulation.solve()` ([#2822](https://github.com/pybamm-team/PyBaMM/pull/2822))
 - Fixed a bug where variable bounds could not contain `InputParameters` ([#2795](https://github.com/pybamm-team/PyBaMM/pull/2795))
 - Improved `model.latexify()` to have a cleaner and more readable output ([#2764](https://github.com/pybamm-team/PyBaMM/pull/2764))
 - Fixed electrolyte conservation in the case of concentration-dependent transference number ([#2758](https://github.com/pybamm-team/PyBaMM/pull/2758))
diff --git a/examples/notebooks/experiments-start-time.ipynb b/examples/notebooks/experiments-start-time.ipynb
new file mode 100644
index 000000000..c083be3f1
--- /dev/null
+++ b/examples/notebooks/experiments-start-time.ipynb
@@ -0,0 +1,247 @@
+{
+ "cells": [
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "id": "regional-bedroom",
+   "metadata": {},
+   "source": [
+    "# Experiments with `start_time`"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "id": "quantitative-radar",
+   "metadata": {},
+   "source": [
+    "This notebook introduces functionality for simulating user case in which the experiment steps are triggered at a certain point in time."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "novel-spectacular",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "\n",
+      "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.0.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m23.1.2\u001b[0m\n",
+      "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n",
+      "Note: you may need to restart the kernel to use updated packages.\n"
+     ]
+    }
+   ],
+   "source": [
+    "%pip install pybamm -q    # install PyBaMM if it is not installed\n",
+    "import pybamm"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "id": "11c87da1",
+   "metadata": {},
+   "source": [
+    "Let's start defining a model to illustrate this functionality, in this case we choose the SPM"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "96b62a7f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "model = pybamm.lithium_ion.SPM()"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "id": "f388d538",
+   "metadata": {},
+   "source": [
+    "Usually we define an experiment such that each step is triggered when the previous step is completed. For example, in this case we do a 1C discharge for 20 minutes and then a C/3 charge for 10 minutes. The charge step starts after 20 minutes, i.e. once the discharge step is finished."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "eba027c8",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "5516f708a0264190a51d1811064afd82",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "interactive(children=(FloatSlider(value=0.0, description='t', max=1800.0, step=18.0), Output()), _dom_classes=…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "text/plain": [
+       "<pybamm.plotting.quick_plot.QuickPlot at 0x7f3194e00eb0>"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "experiment = pybamm.Experiment([\"Discharge at 1C for 20 minutes\", \"Charge at C/3 for 10 minutes\"])\n",
+    "sim = pybamm.Simulation(model, experiment=experiment)\n",
+    "sim.solve()\n",
+    "sim.plot()"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "id": "0fdfced4",
+   "metadata": {},
+   "source": [
+    "However, if we want to represent a realistic user case we might certain experiments to be run at a certain time instead, even if that means cutting short the previous step. In this case we can pass a starting time as a keyword argument in the `pybamm.step.string` method. The `start_time` should be passed as a `datetime.datetime` object."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "171550ac",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "application/vnd.jupyter.widget-view+json": {
+       "model_id": "4832134ba802437e957ccdd5072d6d93",
+       "version_major": 2,
+       "version_minor": 0
+      },
+      "text/plain": [
+       "interactive(children=(FloatSlider(value=0.0, description='t', max=2.5, step=0.025), Output()), _dom_classes=('…"
+      ]
+     },
+     "metadata": {},
+     "output_type": "display_data"
+    },
+    {
+     "data": {
+      "text/plain": [
+       "<pybamm.plotting.quick_plot.QuickPlot at 0x7f31943d3760>"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "s = pybamm.step.string\n",
+    "\n",
+    "experiment = pybamm.Experiment(\n",
+    "    [\n",
+    "        s(\"Discharge at 1C for 1 hour\", start_time=\"Day 1 08:00:00\"),\n",
+    "        s(\"Charge at C/3 for 10 minutes\", start_time=\"Day 1 08:30:00\"),\n",
+    "        s(\"Discharge at C/2 for 30 minutes\", start_time=\"Day 1 09:00:00\"),\n",
+    "        s(\"Rest for 1 hour\"),\n",
+    "    ]\n",
+    ")\n",
+    "sim = pybamm.Simulation(model, experiment=experiment)\n",
+    "sim.solve()\n",
+    "sim.plot()"
+   ]
+  },
+  {
+   "attachments": {},
+   "cell_type": "markdown",
+   "id": "edfa4c9f",
+   "metadata": {},
+   "source": [
+    "In the example above, we note that the first step (1C discharge) is cut short as the second step (C/3 charge) start time occurs before the end of the first step. On the other hand, an additional resting period is added after the second step as the third step (C/2 discharge) start time is 20 minutes later than the end of the second step. The final step does not have a start time so it is triggered immediately after the previous step. Note that if the argument `start_time` is used in an experiment, the first step should always have a `start_time`, otherwise the solver will throw an error."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "driven-sensitivity",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "[1] Joel A. E. Andersson, Joris Gillis, Greg Horn, James B. Rawlings, and Moritz Diehl. CasADi – A software framework for nonlinear optimization and optimal control. Mathematical Programming Computation, 11(1):1–36, 2019. doi:10.1007/s12532-018-0139-4.\n",
+      "[2] Charles R. Harris, K. Jarrod Millman, Stéfan J. van der Walt, Ralf Gommers, Pauli Virtanen, David Cournapeau, Eric Wieser, Julian Taylor, Sebastian Berg, Nathaniel J. Smith, and others. Array programming with NumPy. Nature, 585(7825):357–362, 2020. doi:10.1038/s41586-020-2649-2.\n",
+      "[3] Scott G. Marquis, Valentin Sulzer, Robert Timms, Colin P. Please, and S. Jon Chapman. An asymptotic derivation of a single particle model with electrolyte. Journal of The Electrochemical Society, 166(15):A3693–A3706, 2019. doi:10.1149/2.0341915jes.\n",
+      "[4] Peyman Mohtat, Suhak Lee, Jason B Siegel, and Anna G Stefanopoulou. Towards better estimability of electrode-specific state of health: decoding the cell expansion. Journal of Power Sources, 427:101–111, 2019.\n",
+      "[5] Valentin Sulzer, Scott G. Marquis, Robert Timms, Martin Robinson, and S. Jon Chapman. Python Battery Mathematical Modelling (PyBaMM). Journal of Open Research Software, 9(1):14, 2021. doi:10.5334/jors.309.\n",
+      "[6] Pauli Virtanen, Ralf Gommers, Travis E. Oliphant, Matt Haberland, Tyler Reddy, David Cournapeau, Evgeni Burovski, Pearu Peterson, Warren Weckesser, Jonathan Bright, and others. SciPy 1.0: fundamental algorithms for scientific computing in Python. Nature Methods, 17(3):261–272, 2020. doi:10.1038/s41592-019-0686-2.\n",
+      "[7] Andrew Weng, Jason B Siegel, and Anna Stefanopoulou. Differential voltage analysis for battery manufacturing process control. arXiv preprint arXiv:2303.07088, 2023.\n",
+      "\n"
+     ]
+    }
+   ],
+   "source": [
+    "pybamm.print_citations()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "593ae90b",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.17"
+  },
+  "toc": {
+   "base_numbering": 1,
+   "nav_menu": {},
+   "number_sections": true,
+   "sideBar": true,
+   "skip_h1_title": false,
+   "title_cell": "Table of Contents",
+   "title_sidebar": "Contents",
+   "toc_cell": false,
+   "toc_position": {},
+   "toc_section_display": true,
+   "toc_window_display": true
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "612adcc456652826e82b485a1edaef831aa6d5abc680d008e93d513dd8724f14"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pybamm/experiment/experiment.py b/pybamm/experiment/experiment.py
index 3c6f7e979..edcaeb8f5 100644
--- a/pybamm/experiment/experiment.py
+++ b/pybamm/experiment/experiment.py
@@ -68,6 +68,11 @@ class Experiment:
             termination,
         )
 
+        self.datetime_formats = [
+            "Day %j %H:%M:%S",
+            "%Y-%m-%d %H:%M:%S",
+        ]
+
         operating_conditions_cycles = []
         for cycle in operating_conditions:
             # Check types and convert to list
@@ -78,9 +83,9 @@ class Experiment:
         self.operating_conditions_cycles = operating_conditions_cycles
         self.cycle_lengths = [len(cycle) for cycle in operating_conditions_cycles]
 
-        operating_conditions_steps_unprocessed = [
-            cond for cycle in operating_conditions_cycles for cond in cycle
-        ]
+        operating_conditions_steps_unprocessed = self._set_next_start_time(
+            [cond for cycle in operating_conditions_cycles for cond in cycle]
+        )
 
         # Convert strings to pybamm.step._Step objects
         # We only do this once per unique step, do avoid unnecessary conversions
@@ -89,11 +94,7 @@ class Experiment:
         for step in unique_steps_unprocessed:
             if isinstance(step, str):
                 processed_steps[step] = pybamm.step.string(step)
-            elif not isinstance(step, pybamm.step._Step):
-                raise TypeError(
-                    "Operating conditions should be strings or _Step objects"
-                )
-            else:
+            elif isinstance(step, pybamm.step._Step):
                 processed_steps[step] = step
 
         # Save the processed unique steps and the processed operating conditions
@@ -103,6 +104,17 @@ class Experiment:
             processed_steps[step] for step in operating_conditions_steps_unprocessed
         ]
 
+        self.initial_start_time = self.operating_conditions_steps[0].start_time
+
+        if (
+            self.operating_conditions_steps[0].end_time is not None
+            and self.initial_start_time is None
+        ):
+            raise ValueError(
+                "When using experiments with `start_time`, the first step must have a "
+                "`start_time`."
+            )
+
         self.termination_string = termination
         self.termination = self.read_termination(termination)
 
@@ -182,3 +194,27 @@ class Experiment:
                     break
 
         return cycles
+
+    def _set_next_start_time(self, operating_conditions):
+        if all(isinstance(i, str) for i in operating_conditions):
+            return operating_conditions
+
+        end_time = None
+        next_start_time = None
+
+        for op in reversed(operating_conditions):
+            if isinstance(op, str):
+                op = pybamm.step.string(op)
+            elif not isinstance(op, pybamm.step._Step):
+                raise TypeError(
+                    "Operating conditions should be strings or _Step objects"
+                )
+
+            op.next_start_time = next_start_time
+            op.end_time = end_time
+
+            next_start_time = op.start_time
+            if next_start_time:
+                end_time = next_start_time
+
+        return operating_conditions
diff --git a/pybamm/simulation.py b/pybamm/simulation.py
index d7c5dd1a3..0315bd814 100644
--- a/pybamm/simulation.py
+++ b/pybamm/simulation.py
@@ -8,6 +8,7 @@ import copy
 import warnings
 import sys
 from functools import lru_cache
+from datetime import timedelta
 import tqdm
 
 
@@ -256,6 +257,23 @@ class Simulation:
             )
             self.experiment_unique_steps_to_model[repr(op)] = parameterised_model
 
+        # Set up rest model if experiment has start times
+        if self.experiment.initial_start_time:
+            new_model = self.model.new_copy()
+            # Update parameter values
+            new_parameter_values = self.parameter_values.copy()
+            self._original_temperature = new_parameter_values["Ambient temperature [K]"]
+            new_parameter_values.update(
+                {"Current function [A]": 0, "Ambient temperature [K]": "[input]"},
+                check_already_exists=False,
+            )
+            parameterised_model = new_parameter_values.process_model(
+                new_model, inplace=False
+            )
+            self.experiment_unique_steps_to_model[
+                "Rest for padding"
+            ] = parameterised_model
+
     def update_new_model_events(self, new_model, op):
         for term in op.termination:
             if term["type"] == "current":
@@ -672,7 +690,23 @@ class Simulation:
                     # Use 1-indexing for printing cycle number as it is more
                     # human-intuitive
                     op_conds = self.experiment.operating_conditions_steps[idx]
-                    dt = op_conds.duration
+
+                    start_time = current_solution.t[-1]
+
+                    # If step has an end time, dt must take that into account
+                    if op_conds.end_time:
+                        dt = min(
+                            op_conds.duration,
+                            (
+                                op_conds.end_time
+                                - (
+                                    self.experiment.initial_start_time
+                                    + timedelta(seconds=float(start_time))
+                                )
+                            ).total_seconds(),
+                        )
+                    else:
+                        dt = op_conds.duration
                     op_conds_str = str(op_conds)
                     model = self.op_conds_to_built_models[repr(op_conds)]
                     solver = self.op_conds_to_built_solvers[repr(op_conds)]
@@ -681,7 +715,6 @@ class Simulation:
                     logs["step operating conditions"] = op_conds_str
                     callbacks.on_step_start(logs)
 
-                    start_time = current_solution.t[-1]
                     kwargs["inputs"] = {
                         **user_inputs,
                         "start time": start_time,
@@ -715,6 +748,51 @@ class Simulation:
                             # Otherwise, just stop this cycle
                             break
 
+                    step_termination = step_solution.termination
+
+                    # Add a padding rest step if necessary
+                    if op_conds.next_start_time is not None:
+                        rest_time = (
+                            op_conds.next_start_time
+                            - (
+                                self.experiment.initial_start_time
+                                + timedelta(seconds=float(step_solution.t[-1]))
+                            )
+                        ).total_seconds()
+                        if rest_time > pybamm.settings.step_start_offset:
+                            start_time = step_solution.t[-1]
+                            # Let me know if you have a better name
+                            op_conds_str = "Rest for padding"
+                            model = self.op_conds_to_built_models[op_conds_str]
+                            solver = self.op_conds_to_built_solvers[op_conds_str]
+
+                            logs["step number"] = (step_num, cycle_length)
+                            logs["step operating conditions"] = op_conds_str
+                            callbacks.on_step_start(logs)
+
+                            ambient_temp = (
+                                op_conds.temperature or self._original_temperature
+                            )
+                            kwargs["inputs"] = {
+                                **user_inputs,
+                                "Ambient temperature [K]": ambient_temp,
+                                "start time": start_time,
+                            }
+                            # Make sure we take at least 2 timesteps
+                            # The period is hardcoded to 10 minutes, the user can
+                            # always override it by adding a rest step
+                            npts = max(int(round(rest_time / 600)) + 1, 2)
+
+                            step_solution_with_rest = solver.step(
+                                step_solution,
+                                model,
+                                rest_time,
+                                npts=npts,
+                                save=False,
+                                **kwargs,
+                            )
+                            step_solution += step_solution_with_rest
+
                     steps.append(step_solution)
 
                     cycle_solution = cycle_solution + step_solution
@@ -726,8 +804,8 @@ class Simulation:
                     # Only allow events specified by experiment
                     if not (
                         isinstance(step_solution, pybamm.EmptySolution)
-                        or step_solution.termination == "final time"
-                        or "[experiment]" in step_solution.termination
+                        or step_termination == "final time"
+                        or "[experiment]" in step_termination
                     ):
                         callbacks.on_experiment_infeasible(logs)
                         feasible = False
diff --git a/pybamm/solvers/solution.py b/pybamm/solvers/solution.py
index 278355f7f..d25453927 100644
--- a/pybamm/solvers/solution.py
+++ b/pybamm/solvers/solution.py
@@ -789,7 +789,7 @@ class EmptySolution:
             return self.copy()
 
     def copy(self):
-        return EmptySolution(self.termination)
+        return EmptySolution(termination=self.termination, t=self.t)
 
 
 def make_cycle_solution(step_solutions, esoh_solver=None, save_this_cycle=True):
diff --git a/pybamm/step/_steps_util.py b/pybamm/step/_steps_util.py
index 6f1e436fb..879461b73 100644
--- a/pybamm/step/_steps_util.py
+++ b/pybamm/step/_steps_util.py
@@ -3,6 +3,7 @@
 #
 import pybamm
 import numpy as np
+from datetime import datetime
 
 _examples = """
 
@@ -50,6 +51,8 @@ class _Step:
         the value should be a valid temperature string, e.g. "25 oC".
     tags : str or list, optional
         A string or list of strings indicating the tags associated with the step.
+    datetime : str or datetime, optional
+        A string or list of strings indicating the tags associated with the step.
     description : str, optional
         A description of the step.
     """
@@ -63,6 +66,7 @@ class _Step:
         period=None,
         temperature=None,
         tags=None,
+        start_time=None,
         description=None,
     ):
         self.type = typ
@@ -79,6 +83,8 @@ class _Step:
             self.args += f", temperature={temperature}"
         if tags:
             self.args += f", tags={tags}"
+        if start_time:
+            self.args += f", start_time={start_time}"
         if description:
             self.args += f", description={description}"
 
@@ -138,6 +144,13 @@ class _Step:
             tags = [tags]
         self.tags = tags
 
+        if start_time is None or isinstance(start_time, datetime):
+            self.start_time = start_time
+        else:
+            raise TypeError("`start_time` should be a datetime.datetime object")
+        self.next_start_time = None
+        self.end_time = None
+
     def __str__(self):
         if self.description is not None:
             return self.description
@@ -164,11 +177,17 @@ class _Step:
             "period": self.period,
             "temperature": self.temperature,
             "tags": self.tags,
+            "start_time": self.start_time,
             "description": self.description,
         }
 
     def __eq__(self, other):
-        return isinstance(other, _Step) and self.__repr__() == other.__repr__()
+        return (
+            isinstance(other, _Step)
+            and self.__repr__() == other.__repr__()
+            and self.next_start_time == other.next_start_time
+            and self.end_time == other.end_time
+        )
 
     def __hash__(self):
         return hash(repr(self))


Test patch:
diff --git a/tests/unit/test_experiments/test_experiment.py b/tests/unit/test_experiments/test_experiment.py
index 554f46302..23548be43 100644
--- a/tests/unit/test_experiments/test_experiment.py
+++ b/tests/unit/test_experiments/test_experiment.py
@@ -2,6 +2,7 @@
 # Test the base experiment class
 #
 from tests import TestCase
+from datetime import datetime
 import pybamm
 import unittest
 
@@ -27,6 +28,7 @@ class TestExperiment(TestCase):
                     "description": "Discharge at C/20 for 0.5 hours",
                     "termination": [],
                     "tags": [],
+                    "start_time": None,
                 },
                 {
                     "value": -0.2,
@@ -37,6 +39,7 @@ class TestExperiment(TestCase):
                     "description": "Charge at C/5 for 45 minutes",
                     "termination": [],
                     "tags": [],
+                    "start_time": None,
                 },
                 {
                     "value": 0.05,
@@ -47,6 +50,7 @@ class TestExperiment(TestCase):
                     "description": "Discharge at C/20 for 0.5 hours",
                     "termination": [],
                     "tags": [],
+                    "start_time": None,
                 },
                 {
                     "value": -0.2,
@@ -57,6 +61,7 @@ class TestExperiment(TestCase):
                     "description": "Charge at C/5 for 45 minutes",
                     "termination": [],
                     "tags": [],
+                    "start_time": None,
                 },
             ],
         )
@@ -167,6 +172,52 @@ class TestExperiment(TestCase):
         self.assertEqual(experiment.search_tag("tag5"), [3])
         self.assertEqual(experiment.search_tag("no_tag"), [])
 
+    def test_no_initial_start_time(self):
+        s = pybamm.step.string
+        with self.assertRaisesRegex(ValueError, "first step must have a `start_time`"):
+            pybamm.Experiment(
+                [
+                    s("Rest for 1 hour"),
+                    s("Rest for 1 hour", start_time=datetime(2023, 1, 1, 8, 0)),
+                ]
+            )
+
+    def test_set_next_start_time(self):
+        # Defined dummy experiment to access _set_next_start_time
+        experiment = pybamm.Experiment(["Rest for 1 hour"])
+        raw_op = [
+            pybamm.step._Step(
+                "current", 1, duration=3600, start_time=datetime(2023, 1, 1, 8, 0)
+            ),
+            pybamm.step._Step(
+                "current", 1, duration=3600, start_time=datetime(2023, 1, 1, 12, 0)
+            ),
+            pybamm.step._Step("current", 1, duration=3600, start_time=None),
+            pybamm.step._Step(
+                "current", 1, duration=3600, start_time=datetime(2023, 1, 1, 15, 0)
+            ),
+        ]
+        processed_op = experiment._set_next_start_time(raw_op)
+
+        expected_next = [
+            datetime(2023, 1, 1, 12, 0),
+            None,
+            datetime(2023, 1, 1, 15, 0),
+            None,
+        ]
+
+        expected_end = [
+            datetime(2023, 1, 1, 12, 0),
+            datetime(2023, 1, 1, 15, 0),
+            datetime(2023, 1, 1, 15, 0),
+            None,
+        ]
+
+        for next, end, op in zip(expected_next, expected_end, processed_op):
+            # useful form for debugging
+            self.assertEqual(op.next_start_time, next)
+            self.assertEqual(op.end_time, end)
+
 
 if __name__ == "__main__":
     print("Add -v for more debug output")
diff --git a/tests/unit/test_experiments/test_experiment_steps.py b/tests/unit/test_experiments/test_experiment_steps.py
index 29f42b275..53b61d637 100644
--- a/tests/unit/test_experiments/test_experiment_steps.py
+++ b/tests/unit/test_experiments/test_experiment_steps.py
@@ -4,6 +4,7 @@
 import pybamm
 import unittest
 import numpy as np
+from datetime import datetime
 
 
 class TestExperimentSteps(unittest.TestCase):
@@ -16,6 +17,9 @@ class TestExperimentSteps(unittest.TestCase):
         self.assertEqual(step.period, None)
         self.assertEqual(step.temperature, None)
         self.assertEqual(step.tags, [])
+        self.assertEqual(step.start_time, None)
+        self.assertEqual(step.end_time, None)
+        self.assertEqual(step.next_start_time, None)
 
         step = pybamm.step._Step(
             "voltage",
@@ -25,6 +29,7 @@ class TestExperimentSteps(unittest.TestCase):
             period="1 minute",
             temperature=298.15,
             tags="test",
+            start_time=datetime(2020, 1, 1, 0, 0, 0),
         )
         self.assertEqual(step.type, "voltage")
         self.assertEqual(step.value, 1)
@@ -33,6 +38,7 @@ class TestExperimentSteps(unittest.TestCase):
         self.assertEqual(step.period, 60)
         self.assertEqual(step.temperature, 298.15)
         self.assertEqual(step.tags, ["test"])
+        self.assertEqual(step.start_time, datetime(2020, 1, 1, 0, 0, 0))
 
         step = pybamm.step._Step("current", 1, temperature="298K")
         self.assertEqual(step.temperature, 298)
@@ -172,7 +178,6 @@ class TestExperimentSteps(unittest.TestCase):
         ]
 
         for step, expected in zip(steps, expected_result):
-            print(step)
             actual = pybamm.step.string(step).to_dict()
             for k in expected.keys():
                 # useful form for debugging
@@ -248,6 +253,17 @@ class TestExperimentSteps(unittest.TestCase):
         with self.assertRaisesRegex(ValueError, "time units must be"):
             pybamm.step.string("Discharge at 1 A for 2 years")
 
+    def test_start_times(self):
+        # Test start_times
+        step = pybamm.step._Step(
+            "current", 1, duration=3600, start_time=datetime(2020, 1, 1, 0, 0, 0)
+        )
+        self.assertEqual(step.start_time, datetime(2020, 1, 1, 0, 0, 0))
+
+        # Test bad start_times
+        with self.assertRaisesRegex(TypeError, "`start_time` should be"):
+            pybamm.step._Step("current", 1, duration=3600, start_time="bad start_time")
+
 
 if __name__ == "__main__":
     print("Add -v for more debug output")
diff --git a/tests/unit/test_experiments/test_simulation_with_experiment.py b/tests/unit/test_experiments/test_simulation_with_experiment.py
index 0efc0cc51..460d82c8c 100644
--- a/tests/unit/test_experiments/test_simulation_with_experiment.py
+++ b/tests/unit/test_experiments/test_simulation_with_experiment.py
@@ -7,6 +7,7 @@ import pybamm
 import numpy as np
 import os
 import unittest
+from datetime import datetime
 
 
 class TestSimulationExperiment(TestCase):
@@ -576,6 +577,69 @@ class TestSimulationExperiment(TestCase):
         sim = pybamm.Simulation(model, experiment=experiment)
         sim.solve()
 
+    def test_padding_rest_model(self):
+        model = pybamm.lithium_ion.SPM()
+
+        # Test no padding rest model if there are no start_times
+        experiment = pybamm.Experiment(["Rest for 1 hour"])
+        sim = pybamm.Simulation(model, experiment=experiment)
+        sim.build_for_experiment()
+        self.assertNotIn(
+            "Rest for padding", sim.experiment_unique_steps_to_model.keys()
+        )
+
+        # Test padding rest model exists if there are start_times
+        experiment = pybamm.step.string(
+            "Rest for 1 hour", start_time=datetime(1, 1, 1, 8, 0, 0)
+        )
+        sim = pybamm.Simulation(model, experiment=experiment)
+        sim.build_for_experiment()
+        self.assertIn("Rest for padding", sim.experiment_unique_steps_to_model.keys())
+        # Check at least there is an input parameter (temperature)
+        self.assertGreater(
+            len(sim.experiment_unique_steps_to_model["Rest for padding"].parameters), 0
+        )
+        # Check the model is the same
+        self.assertIsInstance(
+            sim.experiment_unique_steps_to_model["Rest for padding"],
+            pybamm.lithium_ion.SPM,
+        )
+
+    def test_run_time_stamped_experiment(self):
+        model = pybamm.lithium_ion.SPM()
+
+        # Test experiment is cut short if next_start_time is early
+        experiment = pybamm.Experiment(
+            [
+                pybamm.step.string(
+                    "Discharge at 0.5C for 1 hour",
+                    start_time=datetime(2023, 1, 1, 8, 0, 0),
+                ),
+                pybamm.step.string(
+                    "Rest for 1 hour", start_time=datetime(2023, 1, 1, 8, 30, 0)
+                ),
+            ]
+        )
+        sim = pybamm.Simulation(model, experiment=experiment)
+        sol = sim.solve(calc_esoh=False)
+        self.assertEqual(sol["Time [s]"].entries[-1], 5400)
+
+        # Test padding rest is added if time stamp is late
+        experiment = pybamm.Experiment(
+            [
+                pybamm.step.string(
+                    "Discharge at 0.5C for 1 hour",
+                    start_time=datetime(2023, 1, 1, 8, 0, 0),
+                ),
+                pybamm.step.string(
+                    "Rest for 1 hour", start_time=datetime(2023, 1, 1, 10, 0, 0)
+                ),
+            ]
+        )
+        sim = pybamm.Simulation(model, experiment=experiment)
+        sol = sim.solve(calc_esoh=False)
+        self.assertEqual(sol["Time [s]"].entries[-1], 10800)
+
 
 if __name__ == "__main__":
     print("Add -v for more debug output")