0

Setup

I am trying to create a Matplotlib figure with the following "properties":

  • A specific size (W x H) with no padding around the figure, meaning that a tight bounding box should be of exactly the figure size
  • Two dependent axis, primary on the left, secondary on the right
  • Data on the primary axis should be displayed above data on the secondary axis. I have achieved this by changing the zorder of the primary axis to a higher number than the secondary axis, once both axes are defined
  • Both horizontal and vertical grids should be displayed below data on both axes. I have achieved this by turning on the grid of the secondary axis, which is exactly of the same size as the primary axis, and the dependent tick marks on both axis are such that the grid lines of both axes are aligned

Problem

The Python code below creates the figure that meets all of my requirements except that the secondary (right) axis labels are cropped, and I cannot find a way to fix this. Does anybody know how to modify my code or suggest a way to have the secondary labels fully appear in the figure while still maintaining the "tight" bounding box?

import numpy as np
import matplotlib.pyplot as plt

def main():
    # Data definition (toy example)
    xticks = [0, 0.5, 1, 1.5, 2, 2.0, 2.5, 3]
    xlim = [0, 3]
    pyticks = [2.8, 3.5, 4.2, 5.0, 5.7, 6.4, 7.2, 7.9, 8.6, 9.4, 10.1, 10.8]
    syticks = [5.6, 6.0, 6.4, 6.9, 7.3, 7.8, 8.2, 8.7, 9.1, 9.6, 10, 10.4]
    prim_indep = np.array([0, 1, 2, 3])
    prim_dep = np.array([3.5, 5.7, 10.1, 8.7])
    sec_indep = np.array([0.5, 1, 1.5])
    sec_dep = np.array([10, 9.0, 6.0])
    # Figure creation
    fig = plt.figure(figsize=(6.4, 4.8))
    axis_prim = plt.subplot(111)
    setup_axis(axis_prim, xticks, pyticks, xlim)
    plt.tight_layout(pad=0, w_pad=0, h_pad=2)
    axis_sec = fig.add_axes(axis_prim.get_position(), frameon=False)
    setup_axis(axis_sec, xticks, syticks, xlim)
    axis_sec.yaxis.set_label_position('right')
    axis_sec.yaxis.set_ticks_position('right')
    axis_sec.tick_params(axis='x', which='both', length=0, labelbottom='off')
    axis_sec.xaxis.grid(True, which='both', zorder=2)
    axis_sec.yaxis.grid(True, which='both', zorder=2)
    axis_prim.set_zorder(axis_sec.get_zorder()+1)
    axis_prim.patch.set_visible(False)
    # Plot data
    axis_prim.plot(prim_indep, prim_dep, color='k', linewidth=3)
    plot_marker(axis_prim, prim_indep, prim_dep, 'k')
    axis_sec.plot(sec_indep, sec_dep, color='b', linestyle='--', linewidth=3)
    plot_marker(axis_sec, sec_indep, sec_dep, 'b')
    #
    fig.savefig('test.png', pad_inches=0)
    plt.close('all')

def setup_axis(axis, xticks, yticks, xlim):
    """Specify axis features"""
    axis.tick_params(axis='x', which='major', labelsize=14, zorder=4)
    axis.tick_params(axis='y', which='major', labelsize=14, zorder=4)
    axis.xaxis.set_ticks(xticks)
    axis.yaxis.set_ticks(yticks)
    axis.set_xlim(xlim)
    axis.set_axisbelow(True)

def plot_marker(axis, indep, dep, color):
    """Plot marker with "standard" sizing"""
    axis.plot(
        indep, dep,
        color=color, linestyle='', linewidth=0, marker='o',
        markeredgecolor=color, markersize=9,
        markeredgewidth=3, markerfacecolor='w',
    )

if __name__ == '__main__':
    main()

The above code produces the following figure:

Image file produces by code immediately above

[EDIT] My answer

The solution I found was to use the rect argument of the tight_layout function.The figure is drawn twice:

  • During the first drawing pass no rect value is passed to tight_layout, the purpose of this drawing pass is to calculate the distance between the right edge of the secondary dependent axis tick labels and the right edge of the last independent axis tick label. The latter coordinate is where the bonding box of the figure ends (cropping the secondary axis labels)
  • The second drawing pass is to actually adjust the tight_layout bounding box right edge by the amount obtained in the first drawing pass (scaled to data units) via the rect argument. The figure is saved after this drawing pass

There might be ways of obviating the two drawing passes. The code that produces the figure with the requirements I wanted is shown below, and the "correct" image is below that.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_agg import FigureCanvasAgg

def get_tightbbox_adjustment(fig, axis_prim, axis_sec):
    """
    Calculates distance between rightmsot edge of secondary dependent axis
    tick labels and rightmost edge of rightmost tick label of independent axis
    """
    right_edge = get_edge(fig, axis_sec.yaxis)
    xaxis_edge = get_edge(fig, axis_prim.xaxis)
    return right_edge-xaxis_edge

def bbox(fig, obj):
    """Returns bounding box of an object"""
    renderer = fig.canvas.get_renderer()
    return obj.get_window_extent(renderer=renderer).transformed(
        fig.dpi_scale_trans.inverted()
    )

def get_edge(fig, axis):
    """Find rightmost edge of an axis tick labels"""
    tlabels = [label for label in axis.get_ticklabels()]
    return max([bbox(fig, label).xmax for label in tlabels])

def draw(fsize, save=False, fbbox=None):
    # Data definition (toy example)
    xticks = [0, 0.5, 1, 1.5, 2, 2.0, 2.5, 3]
    xlim = [0, 3]
    pyticks = [2.8, 3.5, 4.2, 5.0, 5.7, 6.4, 7.2, 7.9, 8.6, 9.4, 10.1, 10.8]
    syticks = [5.6, 6.0, 6.4, 6.9, 7.3, 7.8, 8.2, 8.7, 9.1, 9.6, 10, 10.4]
    prim_indep = np.array([0, 1, 2, 3])
    prim_dep = np.array([3.5, 5.7, 10.1, 8.7])
    sec_indep = np.array([0.5, 1, 1.5])
    sec_dep = np.array([10, 9.0, 6.0])
    # Figure creation
    fig = plt.figure(figsize=fsize)
    #
    axis_prim = plt.subplot(1, 1, 1)
    setup_axis(axis_prim, xticks, pyticks, xlim)
    plt.tight_layout(rect=fbbox, pad=0, h_pad=2)
    #
    axis_sec = plt.axes(axis_prim.get_position(), frameon=False)
    setup_axis(axis_sec, xticks, syticks, xlim)
    axis_sec.yaxis.set_label_position('right')
    axis_sec.yaxis.set_ticks_position('right')
    axis_sec.tick_params(axis='x', which='both', length=0, labelbottom='off')
    axis_sec.xaxis.grid(True, which='both', zorder=2)
    axis_sec.yaxis.grid(True, which='both', zorder=2)
    #
    axis_prim.set_zorder(axis_sec.get_zorder()+1)
    axis_prim.patch.set_visible(False)
    # Plot data
    axis_prim.plot(prim_indep, prim_dep, color='k', linewidth=3)
    plot_marker(axis_prim, prim_indep, prim_dep, 'k')
    axis_sec.plot(sec_indep, sec_dep, color='b', linestyle='--', linewidth=3)
    plot_marker(axis_sec, sec_indep, sec_dep, 'b')
    #
    if save:
        fig.savefig('test.png', box='tight', pad_inches=0)
    else:
        FigureCanvasAgg(fig).draw()
    plt.close('all')
    return fig, axis_prim, axis_sec


def main():
    fsize = (6.4, 4.8)
    fig, axis_prim, axis_sec = draw(fsize)
    fbbox = axis_prim.get_position()
    delta = get_tightbbox_adjustment(fig, axis_prim, axis_sec)/fsize[0]
    fbbox = [0, 0, 1-delta, 1]
    draw(fsize, save=True, fbbox=fbbox)

def plot_marker(axis, indep, dep, color):
    """Plot marker with "standard" sizing"""
    axis.plot(
        indep, dep,
        color=color, linestyle='', linewidth=0, marker='o',
        markeredgecolor=color, markersize=9,
        markeredgewidth=3, markerfacecolor='w',
    )

def setup_axis(axis, xticks, yticks, xlim):
    """Specify axis features"""
    axis.tick_params(axis='x', which='major', labelsize=14, zorder=4)
    axis.tick_params(axis='y', which='major', labelsize=14, zorder=4)
    axis.xaxis.set_ticks(xticks)
    axis.yaxis.set_ticks(yticks)
    axis.set_xlim(xlim)
    axis.set_axisbelow(True)

if __name__ == '__main__':
    main()

Image produced by code immediately above

0 Answers0