Repository: mne-tools/mne-python

Issue:
Passing axes to erp plot_joint kills some of the axes
If I want to have two joint plots in one figure (or have the joint plot and some other viz) the easiest would be to pass the axes via `ts_args={'axes': mainfig_ax}` and `topomap_args={'axes': topo_axes}`. Unfortunatelly some of the axes are lost this way and the plot ends up a bit messed up:
![image](https://user-images.githubusercontent.com/8452354/55583506-53ec2400-5722-11e9-9658-ab4c4a5d6b07.png)
The three axes that disappear and get covered by the additional axes (where normally a text similar to "EEG channels (64)" would appear) are actually deleted. Before plotting there are 7 axes, after: 6. `plot_joint` creates additional two axes (one parasite axis for the topo, one axis for the "EEG channels" text) so 3 of the original topo axes are deleted.
I will look into the internals of `plot_joint` in a few minutes to try to understand why that happens but one simple fix would be to add a parameter that turns off plotting of the the text axis.


Discussion:
(and I know - that fat colorbar is ugly)
Theoretically using `title=None` should work, but unfortunatelly I don't see any of the topomaps then:
![image](https://user-images.githubusercontent.com/8452354/55584930-c27eb100-5725-11e9-9c6f-0c0605399390.png)

When `title` is not none [this line](https://github.com/mne-tools/mne-python/blob/f384253d77d46893d91c4ae678ff50f7287cc384/mne/viz/evoked.py#L1390) likely kills the passed axes if they overlap with the new axis added for the title.
The problem with `title=None` can be located [here](https://github.com/mne-tools/mne-python/blob/f384253d77d46893d91c4ae678ff50f7287cc384/mne/viz/evoked.py#L1428). This `subplots_adjust` should be done only if axes were created within the function. I'll submit a PR later today.

Resolving patch:
diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py
--- a/mne/viz/evoked.py
+++ b/mne/viz/evoked.py
@@ -1258,38 +1258,45 @@ def plot_evoked_joint(evoked, times="peaks", title='', picks=None,
                       topomap_args=None):
     """Plot evoked data as butterfly plot and add topomaps for time points.
 
+    .. note:: Axes to plot in can be passed by the user through ``ts_args`` or
+              ``topomap_args``. In that case both ``ts_args`` and
+              ``topomap_args`` axes have to be used. Be aware that when the
+              axes are provided, their position may be slightly modified.
+
     Parameters
     ----------
     evoked : instance of Evoked
         The evoked instance.
     times : float | array of float | "auto" | "peaks"
-        The time point(s) to plot. If "auto", 5 evenly spaced topographies
-        between the first and last time instant will be shown. If "peaks",
+        The time point(s) to plot. If ``"auto"``, 5 evenly spaced topographies
+        between the first and last time instant will be shown. If ``"peaks"``,
         finds time points automatically by checking for 3 local maxima in
-        Global Field Power. Defaults to "peaks".
+        Global Field Power. Defaults to ``"peaks"``.
     title : str | None
-        The title. If `None`, suppress printing channel type. If an empty
-        string, a default title is created. Defaults to ''.
+        The title. If ``None``, suppress printing channel type title. If an
+        empty string, a default title is created. Defaults to ''. If custom
+        axes are passed make sure to set ``title=None``, otherwise some of your
+        axes may be removed during placement of the title axis.
     %(picks_all)s
     exclude : None | list of str | 'bads'
-        Channels names to exclude from being shown. If 'bads', the
-        bad channels are excluded. Defaults to None.
+        Channels names to exclude from being shown. If ``'bads'``, the
+        bad channels are excluded. Defaults to ``None``.
     show : bool
-        Show figure if True. Defaults to True.
+        Show figure if ``True``. Defaults to ``True``.
     ts_args : None | dict
-        A dict of `kwargs` that are forwarded to :meth:`mne.Evoked.plot` to
+        A dict of ``kwargs`` that are forwarded to :meth:`mne.Evoked.plot` to
         style the butterfly plot. If they are not in this dict, the following
         defaults are passed: ``spatial_colors=True``, ``zorder='std'``.
         ``show`` and ``exclude`` are illegal.
-        If None, no customizable arguments will be passed.
-        Defaults to `None`.
+        If ``None``, no customizable arguments will be passed.
+        Defaults to ``None``.
     topomap_args : None | dict
         A dict of `kwargs` that are forwarded to
         :meth:`mne.Evoked.plot_topomap` to style the topomaps.
-        If it is not in this dict, ``outlines='skirt'``
-        will be passed. `show`, `times`, `colorbar` are illegal`
-        If None, no customizable arguments will be passed.
-        Defaults to `None`.
+        If it is not in this dict, ``outlines='skirt'`` will be passed.
+        ``show``, ``times``, ``colorbar`` are illegal.
+        If ``None``, no customizable arguments will be passed.
+        Defaults to ``None``.
 
     Returns
     -------
@@ -1313,20 +1320,20 @@ def plot_evoked_joint(evoked, times="peaks", title='', picks=None,
     if topomap_args is None:
         topomap_args = dict()
 
+    got_axes = False
     illegal_args = {"show", 'times', 'exclude'}
     for args in (ts_args, topomap_args):
         if any((x in args for x in illegal_args)):
             raise ValueError("Don't pass any of {} as *_args.".format(
                 ", ".join(list(illegal_args))))
     if ("axes" in ts_args) or ("axes" in topomap_args):
-        if not ("axes" in ts_args) and ("axes" in topomap_args):
+        if not (("axes" in ts_args) and ("axes" in topomap_args)):
             raise ValueError("If one of `ts_args` and `topomap_args` contains "
                              "'axes', the other must, too.")
-        if "axes" in ts_args:
-            _validate_if_list_of_axes([ts_args["axes"]], 1)
+        _validate_if_list_of_axes([ts_args["axes"]], 1)
         n_topomaps = (3 if times is None else len(times)) + 1
-        if "axes" in topomap_args:
-            _validate_if_list_of_axes(list(topomap_args["axes"]), n_topomaps)
+        _validate_if_list_of_axes(list(topomap_args["axes"]), n_topomaps)
+        got_axes = True
 
     # channel selection
     # simply create a new evoked object with the desired channel selection
@@ -1336,7 +1343,7 @@ def plot_evoked_joint(evoked, times="peaks", title='', picks=None,
 
     # if multiple sensor types: one plot per channel type, recursive call
     if len(ch_types) > 1:
-        if "axes" in ts_args or "axes" in topomap_args:
+        if got_axes:
             raise NotImplementedError(
                 "Currently, passing axes manually (via `ts_args` or "
                 "`topomap_args`) is not supported for multiple channel types.")
@@ -1361,7 +1368,7 @@ def plot_evoked_joint(evoked, times="peaks", title='', picks=None,
     _, times_ts = _check_time_unit(ts_args['time_unit'], times_sec)
 
     # prepare axes for topomap
-    if ("axes" not in topomap_args) or ("axes" not in ts_args):
+    if not got_axes:
         fig, ts_ax, map_ax, cbar_ax = _prepare_joint_axes(len(times_sec),
                                                           figsize=(8.0, 4.2))
     else:
@@ -1425,8 +1432,9 @@ def plot_evoked_joint(evoked, times="peaks", title='', picks=None,
             cbar.locator = locator
         cbar.update_ticks()
 
-    plt.subplots_adjust(left=.1, right=.93, bottom=.14,
-                        top=1. if title is not None else 1.2)
+    if not got_axes:
+        plt.subplots_adjust(left=.1, right=.93, bottom=.14,
+                            top=1. if title is not None else 1.2)
 
     # connection lines
     # draw the connection lines between time series and topoplots


Test patch:
diff --git a/mne/viz/tests/test_evoked.py b/mne/viz/tests/test_evoked.py
--- a/mne/viz/tests/test_evoked.py
+++ b/mne/viz/tests/test_evoked.py
@@ -14,6 +14,7 @@
 from numpy.testing import assert_allclose
 import pytest
 import matplotlib.pyplot as plt
+from matplotlib import gridspec
 
 import mne
 from mne import (read_events, Epochs, read_cov, compute_covariance,
@@ -354,5 +355,31 @@ def test_plot_ctf():
     evoked.plot_joint(times=[0.1])
     mne.viz.plot_compare_evokeds([evoked, evoked])
 
+    # make sure axes position is "almost" unchanged
+    # when axes were passed to plot_joint by the user
+    times = [0.1, 0.2, 0.3]
+    fig = plt.figure()
+
+    # create custom axes for topomaps, colorbar and the timeseries
+    gs = gridspec.GridSpec(3, 7, hspace=0.5, top=0.8)
+    topo_axes = [fig.add_subplot(gs[0, idx * 2:(idx + 1) * 2])
+                 for idx in range(len(times))]
+    topo_axes.append(fig.add_subplot(gs[0, -1]))
+    ts_axis = fig.add_subplot(gs[1:, 1:-1])
+
+    def get_axes_midpoints(axes):
+        midpoints = list()
+        for ax in axes[:-1]:
+            pos = ax.get_position()
+            midpoints.append([pos.x0 + (pos.width * 0.5),
+                              pos.y0 + (pos.height * 0.5)])
+        return np.array(midpoints)
+
+    midpoints_before = get_axes_midpoints(topo_axes)
+    evoked.plot_joint(times=times, ts_args={'axes': ts_axis},
+                      topomap_args={'axes': topo_axes}, title=None)
+    midpoints_after = get_axes_midpoints(topo_axes)
+    assert (np.linalg.norm(midpoints_before - midpoints_after) < 0.1).all()
+
 
 run_tests_if_main()