Repository: nilearn/nilearn

Issue:
[BUG] plot_event cannot handle events with 0 duration
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Operating system

- [X] Linux
- [ ] Mac
- [ ] Windows

### Operating system version

For example one of the following:
- Linux Ubuntu 22.04
- Mac OS Version 12 "monterey"
- Windows 11


### Python version

- [X] 3.11
- [ ] 3.10
- [ ] 3.9
- [ ] 3.8
- [ ] 3.7

### nilearn version

0.10.2.dev

### Expected behavior

events with zero duration should be displayed by "delta functions"

### Current behavior & error messages

![Figure_2](https://github.com/nilearn/nilearn/assets/6961185/09c2f29e-c47d-4811-8788-c33d39f09136)

compare to when setting event duration to 1 second

![Figure_1](https://github.com/nilearn/nilearn/assets/6961185/5f413f21-504d-40f6-9daa-20b310823be8)


### Steps and code to reproduce bug

```python
from nilearn.plotting import plot_event
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

onsets = [
    0.0,   2.4,   8.7,   11.4,  15.0,  18.0,  20.7,  23.7,  26.7,  29.7
]
trial_types = ["foo" for _ in onsets]

# with duration = 1 sec
durations = np.ones_like(onsets)
events = pd.DataFrame(
    {
        "trial_type": trial_types,
        "onset": onsets,
        "duration": durations,
    }
)
plot_event(events)
plt.show()

# with duration = 0 sec
durations = np.zeros_like(onsets)
events = pd.DataFrame(
    {
        "trial_type": trial_types,
        "onset": onsets,
        "duration": durations,
    }
)
plot_event(events)
plt.show()
```



Discussion:
I don't really see how we should handle events with null duration. Strictly speaking, these events have not taken place. I don't see how we should display them ?
Well... to the extend that they are convolved and will be included in the design matrix (see below) I think that we should probably display them somehow, no?

```python
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

from nilearn.glm.first_level import make_first_level_design_matrix
from nilearn.plotting import plot_design_matrix

onsets = [0.0, 2.4, 11.4, 18.0, 20.7, 23.7, 26.7, 29.7]
trial_types = ["foo" for _ in onsets]

# with duration = 0 sec
durations = np.zeros_like(onsets)
events = pd.DataFrame(
    {
        "trial_type": trial_types,
        "onset": onsets,
        "duration": durations,
    }
)

tr = 1.0
n_scans = 40
frame_times = np.arange(n_scans) * tr

X1 = make_first_level_design_matrix(
    frame_times,
    events,
    drift_model="polynomial",
    drift_order=3,
    hrf_model="spm",
)

fig, (ax1, ax2) = plt.subplots(figsize=(10, 6), nrows=1, ncols=2)
plot_design_matrix(X1, ax=ax1)
ax1.set_title("Event-related design matrix", fontsize=12)
plt.plot(X1["foo"])
ax2.set_title("predicted time course of 'foo'", fontsize=12)
plt.show()
```

![Figure_1](https://github.com/nilearn/nilearn/assets/6961185/13637b2e-9eba-40df-af27-ac2e12398e06)


Oops, this is a bug, we should get a warning and no foo regressor created :-(
Typically events with durations of zero are modeled as stick functions in tools like SPM, right? What about plotting them as vertical lines?
> Typically events with durations of zero are modeled as stick functions in tools like SPM, right? What about plotting them as vertical lines?

Yes this is my prior experience with 0 duration events as well hence why I raised the issue because it did not match my expectation.

Do we know what AFNI and FSL do?

It'd good to know if "most" softwares behave in a similar fashion and try to match that behavior.

If there is no consensus then... let's add to the pile of fun ways in which softwares may give different results... But let's burn that bridge when we get to it.


The problem is that when you convolve these events with an hrf, you need to give them a duration, which is arbitrary (would be something like repetition_time / oversampling factor). And we're making silently a decision on data. So, I agree that we should try and match other software's behavior to minimize surprise, but I'm wary about arbitrary and undisclosed decisions  
> (would be something like repetition_time / oversampling factor).

pretty sure this is what SPM does

I suspect that some duration is already given to those zero duration event in nilearn: we may just need to make that more explicit. 
I asked for feedback on neurostars.

apparently (old) FSL behaves the same way.
Yes, you're right
https://github.com/nilearn/nilearn/blob/main/nilearn/glm/first_level/hemodynamic_models.py#L431
If I has to do it from scratch I wouldn't do that, but I think it's better to remain bw-compatible, this will probably better meet user's expectations. But this requires a warning !

To get back to the initial discussion,  I agree with @tsalo 's suggestion.
OK 
agreed on the warning and using stick functions

Note to self: also check how are NaN and 'n/a' in events.tsv handled
Given that this seems closely related to #2399 (if this issue is still relevant) I could try to tackle both in one PR. 
@bthirion 
while working on this in #3943 I decided to test a few things to see if some errors or warnings would be thrown.

Adding the BIDS "status" for those: more for reference

- design with NaN onsets or NaN durations (technically disallowed in BIDS but it seems this is not caught by the BIDS validator): no warning, no error 

In my opinion:
- events with NaN onsets or durations: they should be dropped and at least throw a warning / maybe an error?

Should be an error IMHO.
OK that sounds safer indeed

Resolving patch:
diff --git a/examples/04_glm_first_level/plot_write_events_file.py b/examples/04_glm_first_level/plot_write_events_file.py
--- a/examples/04_glm_first_level/plot_write_events_file.py
+++ b/examples/04_glm_first_level/plot_write_events_file.py
@@ -19,11 +19,10 @@
 
 print(__doc__)
 
-#########################################################################
+# %%
 # Define the onset times in seconds. These are typically extracted from
 # the stimulation software used, but we will use hardcoded values in this
 # example.
-import numpy as np
 
 # fmt: off
 onsets = [
@@ -37,9 +36,8 @@
     264.0, 266.7, 269.7, 275.4, 278.4, 284.4, 288.0, 291.0, 293.4, 296.7,
 ]
 # fmt: on
-onsets = np.array(onsets)
 
-#########################################################################
+# %%
 # Associated trial types: these are numbered between 0 and 9, hence
 # corresponding to 10 different conditions.
 
@@ -51,9 +49,8 @@
     7, 1, 0, 0, 4, 1, 9, 8, 4, 9, 9
 ]
 # fmt: on
-trial_type_idx = np.array(trial_type_idx)
 
-#########################################################################
+# %%
 # We may want to map these indices to explicit condition names.
 # For that, we define a list of 10 strings.
 condition_ids = [
@@ -71,13 +68,21 @@
 
 trial_types = [condition_ids[i] for i in trial_type_idx]
 
-#########################################################################
+# %%
 # We must also define a duration (required by :term:`BIDS` conventions).
-# In this case, all trials lasted one second.
-
-durations = np.ones_like(onsets)
-
-#########################################################################
+# In this case, all trials lasted one second,
+# except for button response
+# that are modeled as events with instantaneous duration (0 second).
+
+null_duration_trials = [2, 3, 4, 5]
+durations = []
+for i in trial_type_idx:
+    if i in null_duration_trials:
+        durations.append(0)
+    else:
+        durations.append(1)
+
+# %%
 # Form a pandas DataFrame from this information.
 import pandas as pd
 
@@ -89,11 +94,11 @@
     }
 )
 
-#########################################################################
+# %%
 # Take a look at the new DataFrame
 events
 
-#########################################################################
+# %%
 # Export them to a tsv file.
 from pathlib import Path
 
@@ -104,7 +109,7 @@
 events.to_csv(tsvfile, sep="\t", index=False)
 print(f"The event information has been saved to {tsvfile}")
 
-#########################################################################
+# %%
 # Optionally, the events can be visualized using the
 # :func:`~nilearn.plotting.plot_event` function.
 import matplotlib.pyplot as plt
diff --git a/nilearn/glm/first_level/experimental_paradigm.py b/nilearn/glm/first_level/experimental_paradigm.py
--- a/nilearn/glm/first_level/experimental_paradigm.py
+++ b/nilearn/glm/first_level/experimental_paradigm.py
@@ -17,13 +17,15 @@
 import pandas as pd
 from pandas.api.types import is_numeric_dtype
 
-VALID_FIELDS = {"onset", "duration", "trial_type", "modulation"}
-
 
 def check_events(events):
     """Test that the events data describes a valid experimental paradigm.
 
-    It is valid if the events data  has an 'onset' key.
+    It is valid if the events data has ``'onset'`` and ``'duration'`` keys
+    with numeric non NaN values.
+
+    This function also handles duplicate events
+    by summing their modulation if they have one.
 
     Parameters
     ----------
@@ -47,6 +49,34 @@ def check_events(events):
         Per-event modulation, (in seconds)
         defaults to ones(n_events) when no duration is provided.
 
+    Raises
+    ------
+    TypeError
+        If the events data is not a pandas DataFrame.
+
+    ValueError
+        If the events data has:
+
+            - no ``'onset'`` or ``'duration'`` column,
+            - has non numeric values
+              in the ``'onset'`` or ``'duration'`` columns
+            - has nan values in the ``'onset'`` or ``'duration'`` columns.
+
+    Warns
+    -----
+    UserWarning
+        If the events data:
+
+            - has no ``'trial_type'`` column,
+            - has any event with a duration equal to 0,
+            - contains columns other than ``'onset'``, ``'duration'``,
+              ``'trial_type'`` or ``'modulation'``,
+            - contains duplicated events, meaning event with same:
+
+                - ``'trial_type'``
+                - ``'onset'``
+                - ``'duration'``
+
     """
     # Check that events is a Pandas DataFrame
     if not isinstance(events, pd.DataFrame):
@@ -54,76 +84,120 @@ def check_events(events):
             "Events should be a Pandas DataFrame. "
             f"A {type(events)} was provided instead."
         )
+
+    events = _check_columns(events)
+
+    events_copy = events.copy()
+
+    events_copy = _handle_missing_trial_types(events_copy)
+
+    _check_null_duration(events_copy)
+
+    _check_unexpected_columns(events_copy)
+
+    events_copy = _handle_modulation(events_copy)
+
+    cleaned_events = _handle_duplicate_events(events_copy)
+
+    trial_type = cleaned_events["trial_type"].values
+    onset = cleaned_events["onset"].values
+    duration = cleaned_events["duration"].values
+    modulation = cleaned_events["modulation"].values
+    return trial_type, onset, duration, modulation
+
+
+def _check_columns(events):
     # Column checks
     for col_name in ["onset", "duration"]:
         if col_name not in events.columns:
             raise ValueError(
                 f"The provided events data has no {col_name} column."
             )
+        if events[col_name].isnull().any():
+            raise ValueError(
+                f"The following column must not contain nan values: {col_name}"
+            )
+        # Make sure we have a numeric type for duration
+        if not is_numeric_dtype(events[col_name]):
+            try:
+                events = events.astype({col_name: float})
+            except ValueError as e:
+                raise ValueError(
+                    f"Could not cast {col_name} to float in events data."
+                ) from e
+    return events
+
+
+def _handle_missing_trial_types(events):
+    if "trial_type" not in events.columns:
+        warnings.warn(
+            "'trial_type' column not found in the given events data."
+        )
+        events["trial_type"] = "dummy"
+    return events
 
-    # Make a copy of the dataframe
-    events_copy = events.copy()
 
-    # Handle missing trial types
-    if "trial_type" not in events_copy.columns:
+def _check_null_duration(events):
+    conditions_with_null_duration = events["trial_type"][
+        events["duration"] == 0
+    ].unique()
+    if len(conditions_with_null_duration) > 0:
         warnings.warn(
-            "'trial_type' column not found in the given events data."
+            "The following conditions contain events with null duration:\n"
+            f"{', '.join(conditions_with_null_duration)}."
         )
-        events_copy["trial_type"] = "dummy"
 
-    # Handle modulation
-    if "modulation" in events_copy.columns:
+
+def _handle_modulation(events):
+    if "modulation" in events.columns:
         print(
             "A 'modulation' column was found in "
             "the given events data and is used."
         )
     else:
-        events_copy["modulation"] = 1
+        events["modulation"] = 1
+    return events
+
 
+VALID_FIELDS = {"onset", "duration", "trial_type", "modulation"}
+
+
+def _check_unexpected_columns(events):
     # Warn for each unexpected column that will
     # not be used afterwards
-    unexpected_columns = set(events_copy.columns).difference(VALID_FIELDS)
-    for unexpected_column in unexpected_columns:
+    unexpected_columns = list(set(events.columns).difference(VALID_FIELDS))
+    if unexpected_columns:
         warnings.warn(
-            f"Unexpected column '{unexpected_column}' in events data. "
-            "It will be ignored."
+            "The following unexpected columns "
+            "in events data will be ignored: "
+            f"{', '.join(unexpected_columns)}"
         )
 
-    # Make sure we have a numeric type for duration
-    if not is_numeric_dtype(events_copy["duration"]):
-        try:
-            events_copy = events_copy.astype({"duration": float})
-        except ValueError:
-            raise ValueError(
-                "Could not cast duration to float in events data."
-            )
 
-    # Handle duplicate events
-    # Two events are duplicates if they have the same:
-    #   - trial type
-    #   - onset
-    COLUMN_DEFINING_EVENT_IDENTITY = ["trial_type", "onset", "duration"]
+# Two events are duplicates if they have the same:
+#   - trial type
+#   - onset
+#   - duration
+COLUMN_DEFINING_EVENT_IDENTITY = ["trial_type", "onset", "duration"]
+
+# Duplicate handling strategy
+# Sum the modulation values of duplicate events
+STRATEGY = {"modulation": "sum"}
 
-    # Duplicate handling strategy
-    # Sum the modulation values of duplicate events
-    STRATEGY = {"modulation": "sum"}
 
+def _handle_duplicate_events(events):
     cleaned_events = (
-        events_copy.groupby(COLUMN_DEFINING_EVENT_IDENTITY, sort=False)
+        events.groupby(COLUMN_DEFINING_EVENT_IDENTITY, sort=False)
         .agg(STRATEGY)
         .reset_index()
     )
 
     # If there are duplicates, give a warning
-    if len(cleaned_events) != len(events_copy):
+    if len(cleaned_events) != len(events):
         warnings.warn(
             "Duplicated events were detected. "
             "Amplitudes of these events will be summed. "
             "You might want to verify your inputs."
         )
 
-    trial_type = cleaned_events["trial_type"].values
-    onset = cleaned_events["onset"].values
-    duration = cleaned_events["duration"].values
-    modulation = cleaned_events["modulation"].values
-    return trial_type, onset, duration, modulation
+    return cleaned_events
diff --git a/nilearn/plotting/matrix_plotting.py b/nilearn/plotting/matrix_plotting.py
--- a/nilearn/plotting/matrix_plotting.py
+++ b/nilearn/plotting/matrix_plotting.py
@@ -520,17 +520,29 @@ def plot_event(model_event, cmap=None, output_file=None, **fig_kwargs):
 
     for idx_run, event_df in enumerate(model_event):
         for _, event in event_df.iterrows():
+            ymin = (idx_run + 0.25) / n_runs
+            ymax = (idx_run + 0.75) / n_runs
             event_onset = event["onset"]
             event_end = event["onset"] + event["duration"]
             color = cmap.colors[cmap_dictionary[event["trial_type"]]]
 
-            ax.axvspan(
-                event_onset,
-                event_end,
-                ymin=(idx_run + 0.25) / n_runs,
-                ymax=(idx_run + 0.75) / n_runs,
-                facecolor=color,
-            )
+            if event["duration"] != 0:
+                ax.axvspan(
+                    event_onset,
+                    event_end,
+                    ymin=ymin,
+                    ymax=ymax,
+                    facecolor=color,
+                )
+
+            # events will 0 duration are plotted as lines
+            else:
+                ax.axvline(
+                    event_onset,
+                    ymin=ymin,
+                    ymax=ymax,
+                    color=color,
+                )
 
     handles = []
     for label, idx in cmap_dictionary.items():


Test patch:
diff --git a/nilearn/glm/tests/_utils.py b/nilearn/glm/tests/_utils.py
--- a/nilearn/glm/tests/_utils.py
+++ b/nilearn/glm/tests/_utils.py
@@ -9,14 +9,14 @@ def _conditions():
 
 
 def _onsets():
-    return [30, 70, 100, 10, 30, 90, 30, 40, 60]
+    return [0, 70, 100, 10, 30, 90, 30, 40, 60]
 
 
 def _durations():
     return np.ones(len(_onsets()))
 
 
-def _modulated_event_paradigm():
+def modulated_event_paradigm():
     events = pd.DataFrame(
         {
             "trial_type": _conditions(),
@@ -28,7 +28,7 @@ def _modulated_event_paradigm():
     return events
 
 
-def _block_paradigm():
+def block_paradigm():
     events = pd.DataFrame(
         {
             "trial_type": _conditions(),
@@ -39,7 +39,7 @@ def _block_paradigm():
     return events
 
 
-def _modulated_block_paradigm():
+def modulated_block_paradigm():
     durations = 5 + 5 * _rng().uniform(size=len(_onsets()))
     modulation = 1 + _rng().uniform(size=len(_onsets()))
     events = pd.DataFrame(
@@ -53,7 +53,7 @@ def _modulated_block_paradigm():
     return events
 
 
-def _spm_paradigm(block_duration):
+def spm_paradigm(block_duration):
     frame_times = np.linspace(0, 99, 100)
     conditions = ["c0", "c0", "c0", "c1", "c1", "c1", "c2", "c2", "c2"]
     onsets = [30, 50, 70, 10, 30, 80, 30, 40, 60]
@@ -64,7 +64,7 @@ def _spm_paradigm(block_duration):
     return events, frame_times
 
 
-def _design_with_null_duration():
+def design_with_null_durations():
     durations = _durations()
     durations[2] = 0
     durations[5] = 0
@@ -79,7 +79,7 @@ def _design_with_null_duration():
     return events
 
 
-def _design_with_nan_duration():
+def design_with_nan_durations():
     durations = _durations()
     durations[2] = np.nan
     durations[5] = np.nan
@@ -94,11 +94,57 @@ def _design_with_nan_duration():
     return events
 
 
-def _duplicate_events_paradigm():
+def design_with_nan_onsets():
+    onsets = _onsets()
+    onsets[2] = np.nan
+    onsets[5] = np.nan
+    onsets[8] = np.nan
+    events = pd.DataFrame(
+        {
+            "trial_type": _conditions(),
+            "onset": onsets,
+            "duration": _durations(),
+        }
+    )
+    return events
+
+
+def design_with_negative_onsets():
+    onsets = _onsets()
+    onsets[0] = -32
+    events = pd.DataFrame(
+        {
+            "trial_type": _conditions(),
+            "onset": onsets,
+            "duration": _durations(),
+        }
+    )
+    return events
+
+
+def design_with_negative_durations():
+    durations = _durations()
+    durations[1] = -5
+    events = pd.DataFrame(
+        {
+            "trial_type": _conditions(),
+            "onset": _onsets(),
+            "duration": durations,
+        }
+    )
+    return events
+
+
+def duplicate_events_paradigm():
     conditions = ["c0", "c0", "c0", "c0", "c1", "c1"]
     onsets = [10, 30, 70, 70, 10, 30]
     durations = [1.0, 1.0, 1.0, 1.0, 1.0, 1]
     events = pd.DataFrame(
-        {"trial_type": conditions, "onset": onsets, "duration": durations}
+        {
+            "trial_type": conditions,
+            "onset": onsets,
+            "duration": durations,
+            "modulation": np.ones(len(onsets)),
+        }
     )
     return events
diff --git a/nilearn/glm/tests/test_dmtx.py b/nilearn/glm/tests/test_dmtx.py
--- a/nilearn/glm/tests/test_dmtx.py
+++ b/nilearn/glm/tests/test_dmtx.py
@@ -25,10 +25,11 @@
 )
 
 from ._utils import (
-    _block_paradigm,
-    _modulated_block_paradigm,
-    _modulated_event_paradigm,
-    _spm_paradigm,
+    block_paradigm,
+    design_with_negative_onsets,
+    modulated_block_paradigm,
+    modulated_event_paradigm,
+    spm_paradigm,
 )
 
 # load the spm file to test cosine basis
@@ -173,9 +174,9 @@ def test_design_matrix_basic_paradigm_glover_hrf(frame_times):
             8,
         ),
         (basic_paradigm(), "glover + derivative", "polynomial", 3, 0.01, 10),
-        (_block_paradigm(), "glover", "polynomial", 1, 0.01, 5),
-        (_block_paradigm(), "glover", "polynomial", 3, 0.01, 7),
-        (_block_paradigm(), "glover + derivative", "polynomial", 3, 0.01, 10),
+        (block_paradigm(), "glover", "polynomial", 1, 0.01, 5),
+        (block_paradigm(), "glover", "polynomial", 3, 0.01, 7),
+        (block_paradigm(), "glover + derivative", "polynomial", 3, 0.01, 10),
     ],
 )
 def test_design_matrix(
@@ -238,7 +239,7 @@ def test_design_matrix_FIR_basic_paradigm(
 
 def test_design_matrix_FIR_block(frame_times):
     # test FIR models on block designs
-    bp = _block_paradigm()
+    bp = block_paradigm()
     X, _ = design_matrix_light(
         frame_times,
         bp,
@@ -295,7 +296,7 @@ def test_design_matrix_FIR_time_shift(frame_times):
 
 @pytest.mark.parametrize(
     "events, idx_offset",
-    [(_modulated_event_paradigm(), 1), (_modulated_block_paradigm(), 3)],
+    [(modulated_event_paradigm(), 1), (modulated_block_paradigm(), 3)],
 )
 def test_design_matrix_scaling(events, idx_offset, frame_times):
     X, _ = design_matrix_light(
@@ -312,7 +313,7 @@ def test_design_matrix_scaling(events, idx_offset, frame_times):
 
 def test_design_matrix_scaling_FIR_model(frame_times):
     # Test the effect of scaling on a FIR model
-    events = _modulated_event_paradigm()
+    events = modulated_event_paradigm()
     hrf_model = "FIR"
     X, _ = design_matrix_light(
         frame_times,
@@ -331,7 +332,7 @@ def test_design_matrix20(n_frames):
     frame_times = np.arange(
         0, n_frames
     )  # was 127 in old version of _cosine_drift
-    events = _modulated_event_paradigm()
+    events = modulated_event_paradigm()
     X, _ = design_matrix_light(
         frame_times, events, hrf_model="glover", drift_model="cosine"
     )
@@ -412,7 +413,7 @@ def test_csv_io(tmp_path, frame_times):
     # test the csv io on design matrices
     DM = make_first_level_design_matrix(
         frame_times,
-        events=_modulated_event_paradigm(),
+        events=modulated_event_paradigm(),
         hrf_model="glover",
         drift_model="polynomial",
         drift_order=3,
@@ -433,7 +434,7 @@ def test_csv_io(tmp_path, frame_times):
 def test_compare_design_matrix_to_spm(block_duration, array):
     # Check that the nistats design matrix is close enough to the SPM one
     # (it cannot be identical, because the hrf shape is different)
-    events, frame_times = _spm_paradigm(block_duration=block_duration)
+    events, frame_times = spm_paradigm(block_duration=block_duration)
     X1 = make_first_level_design_matrix(frame_times, events, drift_model=None)
     _, matrix, _ = check_design_matrix(X1)
 
@@ -453,3 +454,13 @@ def test_create_second_level_design():
     assert_array_equal(design, expected_design)
     assert len(design.columns) == 2
     assert len(design) == 2
+
+
+def test_designs_with_negative_onsets_warning(frame_times):
+    with pytest.warns(
+        UserWarning,
+        match="Some stimulus onsets are earlier than",
+    ):
+        make_first_level_design_matrix(
+            events=design_with_negative_onsets(), frame_times=frame_times
+        )
diff --git a/nilearn/glm/tests/test_paradigm.py b/nilearn/glm/tests/test_paradigm.py
--- a/nilearn/glm/tests/test_paradigm.py
+++ b/nilearn/glm/tests/test_paradigm.py
@@ -16,10 +16,13 @@
 from nilearn.glm.first_level import check_events
 
 from ._utils import (
-    _block_paradigm,
-    _duplicate_events_paradigm,
-    _modulated_block_paradigm,
-    _modulated_event_paradigm,
+    block_paradigm,
+    design_with_nan_durations,
+    design_with_nan_onsets,
+    design_with_null_durations,
+    duplicate_events_paradigm,
+    modulated_block_paradigm,
+    modulated_event_paradigm,
 )
 
 
@@ -92,7 +95,11 @@ def test_check_events_warnings():
     # An unexpected field is provided
     events["foo"] = np.zeros(len(events))
     with pytest.warns(
-        UserWarning, match="Unexpected column 'foo' in events data."
+        UserWarning,
+        match=(
+            "The following unexpected columns "
+            "in events data will be ignored: foo"
+        ),
     ):
         trial_type2, onset2, duration2, modulation2 = check_events(events)
 
@@ -107,7 +114,7 @@ def test_duplicate_events():
     duplicate events.
 
     """
-    events = _duplicate_events_paradigm()
+    events = duplicate_events_paradigm()
 
     # Check that a warning is given to the user
     with pytest.warns(UserWarning, match="Duplicated events were detected."):
@@ -131,9 +138,9 @@ def write_events(events, tmpdir):
 @pytest.mark.parametrize(
     "events",
     [
-        _block_paradigm(),
-        _modulated_event_paradigm(),
-        _modulated_block_paradigm(),
+        block_paradigm(),
+        modulated_event_paradigm(),
+        modulated_block_paradigm(),
         basic_paradigm(),
     ],
 )
@@ -143,3 +150,27 @@ def test_read_events(events, tmp_path):
     read_paradigm = pd.read_table(csvfile)
 
     assert (read_paradigm["onset"] == events["onset"]).all()
+
+
+def test_check_events_warnings_null_duration():
+    """Test that events with null duration throw a warning."""
+    with pytest.warns(
+        UserWarning,
+        match="The following conditions contain events with null duration",
+    ):
+        check_events(design_with_null_durations())
+
+
+@pytest.mark.parametrize(
+    "design",
+    [
+        design_with_nan_durations,
+        design_with_nan_onsets,
+    ],
+)
+def test_check_events_nan_designs(design):
+    """Test that events with nan values."""
+    with pytest.raises(
+        ValueError, match=("The following column must not contain nan values:")
+    ):
+        check_events(design())
diff --git a/nilearn/plotting/tests/test_matrix_plotting.py b/nilearn/plotting/tests/test_matrix_plotting.py
--- a/nilearn/plotting/tests/test_matrix_plotting.py
+++ b/nilearn/plotting/tests/test_matrix_plotting.py
@@ -7,6 +7,7 @@
 from nilearn.glm.first_level.design_matrix import (
     make_first_level_design_matrix,
 )
+from nilearn.glm.tests._utils import design_with_null_durations
 from nilearn.plotting.matrix_plotting import (
     plot_contrast_matrix,
     plot_design_matrix,
@@ -258,3 +259,7 @@ def test_show_contrast_matrix(tmp_path):
     assert ax is None
     plot_contrast_matrix(contrast, dmtx, output_file=tmp_path / "contrast.pdf")
     assert (tmp_path / "contrast.pdf").exists()
+
+
+def test_show_event_plot_duration_0():
+    plot_event(design_with_null_durations())