0
\$\begingroup\$

Dear Python VIS community.

Imagine the following situation:

You ran an experiment in an earth system model (ESM) where you altered some input parameters relative to a control run of the same ESM. Now you look at the surface air temperature and since you have this information for both your experiment and your control run, you can compare the two data sets. Say you want to look at the seasonal difference in air temperature between the experiment and the control (i.e. experiment - contorl = difference). This would result in four maps, one for each season (Winter, Spring, Summer, Autumn, defined as "DJF", "MAM", "JJA", "SON").

import matplotlib.pyplot as plt
import matplotlib.colors as mcol
import matplotlib.cbook as cbook
import matplotlib.colors as colors
import numpy as np
import sys 

winter = np.random.random((96,144)) 
winter[0:48,:] = winter[0:48,:] * 3
winter[48:,:] = winter[48:,:] * -6

spring = np.random.random((96,144)) 
spring[0:48,:] = spring[0:48,:] * 4
spring[48:,:] = spring[48:,:] * -6

summer = np.random.random((96,144)) 
summer[0:48,:] = summer[0:48,:] * 10
summer[48:,:] = summer[48:,:] * -2

autumn = np.random.random((96,144)) 
autumn[0:48,:] = autumn[0:48,:] * 4
autumn[48:,:] = autumn[48:,:] * -7

Cool data! But how do you visualize this using matplotlib? Okay you decide for filled contours from matplotlib. This will yield a map including a colorbar for each season. Now that is cool and all, but you would like to have the four maps in subplots (easy!, see Figure 1).

fig, axes = plt.subplots(2, 2)
axes = axes.flatten()
seasons = zip(axes, [winter, spring, summer, autumn])
for pair in seasons:
    im = pair[0].contourf(pair[1])
    plt.colorbar(im, ax=pair[0])

Figure 1. Suplots with individual colorbar.

Since you want to look at the difference, you can tell that 0 will be important, because your temperature can either be the same (x=0), or it can be regionally cooler (x < 0) or warmer (0 < x).
In order to have the value zero (0) exactly split the colormap you decide for a diverging colormap (based on this example: https://matplotlib.org/3.2.0/gallery/userdemo/colormap_normalizations_diverging.html#sphx-glr-gallery-userdemo-colormap-normalizations-diverging-py). Perfect you think?

colors_low = plt.cm.RdBu_r(np.linspace(0, 0.3, 256))
colors_high = plt.cm.RdBu_r(np.linspace(0.5, 1, 256))
all_colors = np.vstack((colors_low, colors_high))
segmented_map = colors.LinearSegmentedColormap.from_list('RdBu_r', all_colors)
divnorm = colors.TwoSlopeNorm(vmin=-7, vcenter=0, vmax=10)

fig, axes = plt.subplots(2, 2)
axes = axes.flatten()
seasons = zip(axes, [winter, spring, summer, autumn])
for pair in seasons:
    im = pair[0].contourf(pair[1], cmap=segmented_map, norm=divnorm, vmin=-7, vmax=10)
    plt.colorbar(im, ax=pair[0])

Figure 2. Diverging colormap with 0 in as the center.

Not quite, because although you supply contourf() with the overall vmin and vmax and your derived colormap, the levels (i.e. ticks) in the colorbar are not the same for the four plots ("WHY?!?", you scream)!

Aha you find that you need to supply the same levels to contourf() in all four subplots (based on this: https://stackoverflow.com/questions/53641644/set-colorbar-range-with-contourf). But how do you exploit the functionality of contourf, that automatically choses appropriate contour levels?

You think that you could invisibly plot the four individual images, and then extract the color levels from each (im = contourf(), im.levels, https://matplotlib.org/3.3.1/api/contour_api.html#matplotlib.contour.QuadContourSet) and from this create a unique set of levels that combines the maximum and minimum from the extracted color levels.

# -1- Create the pseudo-figures for extraction of color levels:
fig, axes = plt.subplots(2, 2)
axes = axes.flatten()
seasons = zip(axes, [winter, spring, summer, autumn])
c_levels = []
for pair in seasons:
    im = pair[0].contourf(pair[1], cmap=segmented_map, norm=divnorm, vmin=-7, vmax=10)
    c_levels.append(im.levels)
# -1.1- Clear the figure
plt.clf()

# -2- Find the colorbars with the most levels below and above 0:
lower = sys.maxsize
lower_i = 0
higher = -1 * sys.maxsize
higher_i = 0
for i, c_level in enumerate(c_levels):
    if np.min(c_level) < lower: # extract the index for the array with the minimum value 
        lower = np.min(c_level)
        lower_i = i 
    if np.max(c_level) > higher: # extract the index for the array with the maximum value 
        higher = np.max(c_level)
        higher_i = i

# -3- Create the custom color levels as a combination of the minimum and maximum found above
custom_c_levels = []
for level in c_levels[lower_i]:
    if level <= 0: # define the levels for the negative section, including 0
        custom_c_levels.append(level)
for level in c_levels[higher_i]:
    if level > 0: # define the levels for the positive section, excluding 0
        custom_c_levels.append(level)
custom_c_levels = np.array(custom_c_levels)

# -4- create the new normalization to go along with the new color levels
v_min = custom_c_levels[0]
v_max = custom_c_levels[-1]
divnorm = divnorm = colors.TwoSlopeNorm(vmin=v_min, vcenter=0, vmax=v_max)

# -5- plot the figures
fig, axes = plt.subplots(2, 2)
axes = axes.flatten()
seasons = zip(axes, [winter, spring, summer, autumn])
for pair in seasons:
    im = pair[0].contourf(pair[1], levels=custom_c_levels, cmap=segmented_map, norm=divnorm, vmin=v_min, vmax=v_max)

# -6- Get the positions of the lower right and lower left plot
left, bottom, width, height = axes[3].get_position().bounds
first_plot_left = axes[2].get_position().bounds[0]

# -7- the width of the colorbar 
width = left - first_plot_left + width

# -8- Add axes to the figure, to place the color bar in
colorbar_axes = plt.axes([first_plot_left, bottom - 0.15, width, 0.03])

# -9- Add the colour bar
cbar = plt.colorbar(im, colorbar_axes, orientation='horizontal')

# -10- Label the colour bar and add ticks
cbar.set_label("Custom Colorbar")

Figure 3. Final figure, including correct contours and single colorbar.

And it works!

But I'm sure the community is able to do that in a much more straight forward kind of way.

Challenge accepted? Then I would really appreciate if you could show me how to do that in a more simple approach.

ps: I give random data because I think it is more reasonable for the exercise (not using additional libraries such as iris and cartopy). However you can imagine that we are actually looking at a world map with land surface temperature ;).

\$\endgroup\$
4
  • \$\begingroup\$ Interesting question. The colorbars end up uniformly distributed out from 0, so why not just find the global min and max values, take their absolute values, and generate a fixed number of ticks from those, using something like np.linspace? I'm not super familiar with making contour plots and not sure what its auto-coloring does that's more complicated than just that? \$\endgroup\$
    – scnerd
    Commented Dec 7, 2020 at 14:56
  • \$\begingroup\$ The goal is basically that the contours (i.e. the colors) are the same for the positive as well as the negative case. In this case a contour matches a change in 1.5 no matter if positive or negative. Your idea has potential. I started out initially with something like that but couldn't figure out a nice approach that would generally work. I will give it another thought. Thank you @scnerd \$\endgroup\$ Commented Dec 8, 2020 at 7:25
  • \$\begingroup\$ The problem is basically that it's not straight forward to use np.linspace if you desire an optimal range below and above 0. E.g. imagine the min is -15 and the max 10, and you would like to have "nice" contours. What could then be an approach to define a nice amount of ticks automatically? E.g. np.linspace(-15,10,?). \$\endgroup\$ Commented Dec 8, 2020 at 7:33
  • \$\begingroup\$ That's what I meant by "out from 0", I was suggesting pos_ticks = np.linspace(0, 10, x) and neg_ticks = np.linspace(0, -15, x), then just concatenate those two lists. Whether or not those two x's are the same thing may or may not matter depending on how you build the colormap \$\endgroup\$
    – scnerd
    Commented Dec 8, 2020 at 19:32

1 Answer 1

1
\$\begingroup\$

I conducted some research into the way the color levels are chosen in contourf in the matplotlib python code (https://github.com/matplotlib/matplotlib/blob/master/lib/matplotlib/contour.py#L1050). They use a matplotlib ticker MaxNLocator() instance to define the color levels, here is its documentation https://matplotlib.org/3.3.3/api/ticker_api.html#matplotlib.ticker.MaxNLocator.

Then I found this documentation https://matplotlib.org/3.1.1/gallery/ticks_and_spines/tick-locators.html.

This yields the following working solution that is more straight forward than the presented approach (based on the data and colormap initialized above):

import matplotlib.ticker as ticker 

# -1- Find the global min and max values:
gl_min = np.min(np.stack((winter, spring, summer, autumn))) 
gl_max = np.max(np.stack((winter, spring, summer, autumn)))

# -2- Create a simple plot, where the xaxis is using the found global min and max
fig, ax = plt.subplots()  
ax.set_xlim(gl_min, gl_max)

# -3- Use the MaxNLocator() to get the contours levels
ax.xaxis.set_major_locator(ticker.MaxNLocator())
custom_c_levels = ax.get_xticks()

# -4- Clear the figure
plt.clf()

# -5- Create the diverging norm
divnorm = colors.TwoSlopeNorm(vmin=gl_min, vcenter=0, vmax=gl_max)

# -6- plot the figures
fig, axes = plt.subplots(2, 2)
axes = axes.flatten()
seasons = zip(axes, [winter, spring, summer, autumn])
for pair in seasons:
    im = pair[0].contourf(pair[1], levels=custom_c_levels, cmap=segmented_map, norm=divnorm, vmin=gl_min, vmax=gl_max)

# -7- Get the positions of the lower right and lower left plot
left, bottom, width, height = axes[3].get_position().bounds
first_plot_left = axes[2].get_position().bounds[0]

# -8- the width of the colorbar 
width = left - first_plot_left + width

# -9- Add axes to the figure, to place the color bar in
colorbar_axes = plt.axes([first_plot_left, bottom - 0.15, width, 0.03])

# -10- Add the color bar
cbar = plt.colorbar(im, colorbar_axes, orientation='horizontal')

# -12- Label the color bar and add ticks
cbar.set_label("Custom Colorbar")

# -13- Show the figure
plt.show()

Figure 1 | Final working example.

The process is simple: First we have to find the global min and max value (i.e. across all data sets). Then we create a figure and set its limits to the found min/max values. Then we tell the axis that we want to use the MaxNLocator(). Then we can easily extract the found labels and use them as our custom contour levels.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.