3

Is there a plot function available in Python that is same as MATLAB's stackedplot()? stackedplot() in MATLAB can line plot several variables with the same X axis and are stacked vertically. Additionally, there is a scope in this plot that shows the value of all variables for a given X just by moving the cursor (please see the attached plot). I have been able to generate stacked subplots in Python with no issues, however, not able to add a scope like this that shows the value of all variables by moving the cursor. Is this feature available in Python?

This is a plot using MATLAB's stackedplot():

Matlab stacked plot with scope

import pandas as pd
import numpy as np
from datetime import datetime, date, time
import matplotlib.pyplot as plt
import matplotlib
import matplotlib.transforms as transforms
import mplcursors
from collections import Counter
import collections

def flatten(x):
    result = []
    for el in x:
        if isinstance(x, collections.Iterable) and not isinstance(el, str):
            result.extend(flatten(el))
        else:
            result.append(el)
    return result

def shared_scope(sel):
    sel.annotation.set_visible(False)  # hide the default annotation created by mplcursors
    x = sel.target[0]
    for ax in axes:
        for plot in plotStore:
            da = plot.get_ydata()
            if type(da[0]) is np.datetime64: #pd.Timestamp
                yData = matplotlib.dates.date2num(da) # to numerical values
                vals = np.interp(x, plot.get_xdata(), yData)
                dates = matplotlib.dates.num2date(vals) # to matplotlib dates
                y = datetime.strftime(dates,'%Y-%m-%d %H:%M:%S') # to strings
                annot = ax.annotate(f'{y:.30s}', (x, vals), xytext=(15, 10), textcoords='offset points',
                            bbox=dict(facecolor='tomato', edgecolor='black', boxstyle='round', alpha=0.5))
                sel.extras.append(annot)
            else:
                y = np.interp(x, plot.get_xdata(), plot.get_ydata())      
                annot = ax.annotate(f'{y:.2f}', (x, y), xytext=(15, 10), textcoords='offset points', arrowprops=dict(arrowstyle="->",connectionstyle="angle,angleA=0,angleB=90,rad=10"),
                            bbox=dict(facecolor='tomato', edgecolor='black', boxstyle='round', alpha=0.5))
                sel.extras.append(annot)
        vline = ax.axvline(x, color='k', ls=':')
        sel.extras.append(vline)
    trans = transforms.blended_transform_factory(axes[0].transData, axes[0].transAxes)
    text1 = axes[0].text(x, 1.01, f'{x:.2f}', ha='center', va='bottom', color='blue', clip_on=False, transform=trans)
    sel.extras.append(text1)
        
   
# Data to plot
data = pd.DataFrame(columns = ['timeOfSample','Var1','Var2'])
data.timeOfSample = ['2020-05-10 09:09:02','2020-05-10 09:09:39','2020-05-10 09:40:07','2020-05-10 09:40:45','2020-05-12 09:50:45']
data['timeOfSample'] = pd.to_datetime(data['timeOfSample'])
data.Var1 = [10,50,100,5,25]
data.Var2 = [20,55,70,60,50]
variables = ['timeOfSample',['Var1','Var2']] # variables to plot - Var1 and Var2 to share a plot

nPlot = len(variables)   
dataPts = np.arange(0, len(data[variables[0]]), 1) # x values for plots
plotStore = [0]*len(flatten(variables)) # to store all the plots for annotation purposes later

fig, axes = plt.subplots(nPlot,1,sharex=True)

k=0
for i in range(nPlot):
    if np.size(variables[i])==1:
        yData = data[variables[i]]   
        line, = axes[i].plot(dataPts,yData,label = variables[i]) 
        plotStore[k]=line
        k = k+1
    else:
        for j in range(np.size(variables[i])): 
            yData = data[variables[i][j]]        
            line, = axes[i].plot(dataPts,yData,label = variables[i][j])             
            plotStore[k]=line
            k = k+1  
    axes[i].set_ylabel(variables[i])


cursor = mplcursors.cursor(plotStore, hover=True)
cursor.connect('add', shared_scope)
plt.xlabel('Samples')
plt.show()
Trenton McKinney
  • 56,955
  • 33
  • 144
  • 158
AGN
  • 33
  • 5
  • Note that MATLAB figures are UI elements one can interact with, rather than just a plain old "image", as you'd get from your photo camera so to speak. Making plots interactive could be quite difficult. – Adriaan Aug 11 '20 at 12:58
  • 2
    @Adriaan. You can do very similar things in matplotlib. – Mad Physicist Aug 11 '20 at 13:48
  • The first plot in the subplots is a time plot with time on the Y axis. The issue is that the time string annotation doesn't show up on this plot when hovering. The other numerical subplots don't have any issues with showing the annotations. – AGN Aug 18 '20 at 17:36
  • 1
    On my system (Windows, Pycharm, matplotlib 3.3.1. everything seems to work well, including hovering over the time plot and showing the annotations. – JohanC Aug 18 '20 at 20:02
  • Mine is Windows, Spyder (3.3.3), and Matplotlib 3.3.0. The time string annotation used to work fine for me with the earlier version of the code where all the annotations were bundled together in one box – AGN Aug 19 '20 at 15:15
  • Is there anyway to step through the function shared_scope(sel) while debugging? – AGN Aug 21 '20 at 17:31

1 Answers1

2

mplcursors can be used to create annotations while hovering, moving texts and vertical bars. sel.extras.append(...) helps to automatically hide the elements that aren't needed anymore.

import matplotlib.pyplot as plt
import matplotlib.transforms as transforms
import mplcursors
import numpy as np

def shared_scope(sel):
    x = sel.target[0]
    annotation_text = f'x: {x:.2f}'
    for ax, plot in zip(axes, all_plots):
        y = np.interp(x, plot.get_xdata(), plot.get_ydata())
        annotation_text += f'\n{plot.get_label()}: {y:.2f}'
        vline = ax.axvline(x, color='k', ls=':')
        sel.extras.append(vline)
    sel.annotation.set_text(annotation_text)
    trans = transforms.blended_transform_factory(axes[0].transData, axes[0].transAxes)
    text1 = axes[0].text(x, 1.01, f'{x:.2f}', ha='center', va='bottom', color='blue', clip_on=False, transform=trans)
    sel.extras.append(text1)

fig, axes = plt.subplots(figsize=(15, 10), nrows=3, sharex=True)
y1 = np.random.uniform(-1, 1, 100).cumsum()
y2 = np.random.uniform(-1, 1, 100).cumsum()
y3 = np.random.uniform(-1, 1, 100).cumsum()
all_y = [y1, y2, y3]
all_labels = ['Var1', 'Var2', 'Var3']
all_plots = [ax.plot(y, label=label)[0]
             for ax, y, label in zip(axes, all_y, all_labels)]
for ax, label in zip(axes, all_labels):
    ax.set_ylabel(label)
cursor = mplcursors.cursor(all_plots, hover=True)
cursor.connect('add', shared_scope)

plt.show()

example plot

Here is a version with separate annotations per subplot:

import matplotlib.pyplot as plt
import matplotlib.transforms as transforms
import mplcursors
import numpy as np

def shared_scope(sel):
    sel.annotation.set_visible(False)  # hide the default annotation created by mplcursors
    x = sel.target[0]
    for ax, plot in zip(axes, all_plots):
        y = np.interp(x, plot.get_xdata(), plot.get_ydata())
        vline = ax.axvline(x, color='k', ls=':')
        sel.extras.append(vline)
        annot = ax.annotate(f'{y:.2f}', (x, y), xytext=(5, 0), textcoords='offset points',
                            bbox=dict(facecolor='tomato', edgecolor='black', boxstyle='round', alpha=0.5))
        sel.extras.append(annot)
    trans = transforms.blended_transform_factory(axes[0].transData, axes[0].transAxes)
    text1 = axes[0].text(x, 1.01, f'{x:.2f}', ha='center', va='bottom', color='blue', clip_on=False, transform=trans)
    sel.extras.append(text1)

fig, axes = plt.subplots(figsize=(15, 10), nrows=3, sharex=True)
y1 = np.random.uniform(-1, 1, 100).cumsum()
y2 = np.random.uniform(-1, 1, 100).cumsum()
y3 = np.random.uniform(-1, 1, 100).cumsum()
all_y = [y1, y2, y3]
all_labels = ['Var1', 'Var2', 'Var3']
all_plots = [ax.plot(y, label=label)[0]
             for ax, y, label in zip(axes, all_y, all_labels)]
for ax, label in zip(axes, all_labels):
    ax.set_ylabel(label)
cursor = mplcursors.cursor(all_plots, hover=True)
cursor.connect('add', shared_scope)

plt.show()

separate annotations per subplot

JohanC
  • 71,591
  • 8
  • 33
  • 66
  • Does this work for you? (In a Jupyter notebook you'd need to include `%matplotlib notebook` instead of `%matplotlib inline` to get interactive plots). – JohanC Aug 11 '20 at 22:17
  • Thank you very much! This is very close to what I want. However, the number of variables in the plot vary based on the need. Is there anyway to automate the contents of the function shared_scope() based on the number of variables in the plot? – AGN Aug 12 '20 at 12:58
  • This solution is awsome! Implemented in my code and it works for all the numeric variables. However, it doesn't work if one of the variables is of the type 'TimeStamp'. Looks like it fails at np.interp() in shared_scope(). – AGN Aug 12 '20 at 22:26
  • 1
    You could use `matplotlib.dates.date2num` to convert your timestamps to numbers. – JohanC Aug 13 '20 at 08:31
  • Thanks!. I modified the code with matplotlib.dates.date2num to plot 'TimeStamp' variables too. – AGN Aug 13 '20 at 18:35
  • Is it possible to show the value of each variables in their respective plot as shown in the Matlab plot above (instead of combining all into one box)? – AGN Aug 15 '20 at 21:34
  • Yes. In my work, it is common that we need to analyze several variables (at times ~12) together. It is easier to analyze if the variables are annotated on their respective plots. I have been trying to modify the code to accomplish this with no luck thus far. – AGN Aug 16 '20 at 06:15
  • I added a version with separate annotations per subplot. – JohanC Aug 16 '20 at 14:59
  • Thank you! Would you happen to know whats wrong with the following line for annotating time strings? 'vals' is the numeric output of the np.interp() function and 'y' is the time string corresponding to 'vals'. annot = ax.annotate(f'{y:.30s}', xy=(x, vals), xytext=(5, 0), textcoords='offset points',bbox=dict(facecolor='tomato', edgecolor='black', boxstyle='round', alpha=0.5)). – AGN Aug 17 '20 at 18:00
  • Maybe something is wrong with `y` or with `vals`. You can't use a point in the format string for strings. Try `f'{y}'` without format. Or `f'{y:>30}'` to right-align into 30 positions. (`(f'{y:<30}'` for left-align). If `y` isn't a string, convert it: `f'{str(y):>30}'`. Or you could edit your question adding a toy example of how you're working with timestamps. – JohanC Aug 17 '20 at 18:28
  • I edited the question to add the code. Please note that I was modifying the code to plot more than one plot in a given subplot. – AGN Aug 18 '20 at 03:27