14

How can I annotate a range of my data? E.g., say the data from x = 5 to x = 10 is larger than some cut-off, how could I indicate that on the graph. If I was annotating by hand, I would just draw a large bracket above the range and write my annotation above the bracket.

The closest I've seen is using arrowstyle='<->' and connectionstyle='bar', to make two arrows pointing to the edges of your data with a line connecting their tails. But that doesn't quite do the right thing; the text that you enter for the annotation will end up under one of the arrows, rather than above the bar.

Here is my attempt, along with it's results:

annotate(' ', xy=(1,.5),  xycoords='data',
            xytext=(190, .5), textcoords='data',
            arrowprops=dict(arrowstyle="<->",
                            connectionstyle="bar",
                            ec="k",
                            shrinkA=5, shrinkB=5,
                            )
            )

Annotation attempt

Another problem with my attempted solution is that the squared shape of the annotating bracket does not really make it clear that I am highlighting a range (unlike, e.g., a curly brace). But I suppose that's just being nitpicky at this point.

Trenton McKinney
  • 56,955
  • 33
  • 144
  • 158
ari
  • 4,269
  • 5
  • 24
  • 33
  • use two annotations, one with text, but no arrow, and one with the arrow, but no text. Also see `axvspan` http://matplotlib.org/api/axes_api.html#matplotlib.axes.Axes.axvspan – tacaswell Aug 22 '13 at 16:43
  • It is also best to show what you have tried (with a code snippet). – tacaswell Aug 22 '13 at 16:44
  • @tcaswell I thought about using two annotations, but that would involve manually positioning the text, and having to manually update both if, say, the range moves. It seems like it would be a common enough problem that a more optimal solution would exist. – ari Aug 22 '13 at 16:48

6 Answers6

11

As mentioned in this answer, you can construct curly brackets with sigmoidal functions. Below is a function that adds curly brackets just above the x-axis. The curly brackets it produces should look the same regardless of the axes limits, as long as the figure width and height don't vary.

import numpy as np
import matplotlib.pyplot as plt

def draw_brace(ax, xspan, text):
    """Draws an annotated brace on the axes."""
    xmin, xmax = xspan
    xspan = xmax - xmin
    ax_xmin, ax_xmax = ax.get_xlim()
    xax_span = ax_xmax - ax_xmin
    ymin, ymax = ax.get_ylim()
    yspan = ymax - ymin
    resolution = int(xspan/xax_span*100)*2+1 # guaranteed uneven
    beta = 300./xax_span # the higher this is, the smaller the radius

    x = np.linspace(xmin, xmax, resolution)
    x_half = x[:resolution//2+1]
    y_half_brace = (1/(1.+np.exp(-beta*(x_half-x_half[0])))
                    + 1/(1.+np.exp(-beta*(x_half-x_half[-1]))))
    y = np.concatenate((y_half_brace, y_half_brace[-2::-1]))
    y = ymin + (.05*y - .01)*yspan # adjust vertical position

    ax.autoscale(False)
    ax.plot(x, y, color='black', lw=1)

    ax.text((xmax+xmin)/2., ymin+.07*yspan, text, ha='center', va='bottom')

ax = plt.gca()
ax.plot(range(10))
draw_brace(ax, (0, 8), 'large brace')
draw_brace(ax, (8, 9), 'small brace')

Output:

enter image description here

Joooeey
  • 3,394
  • 1
  • 35
  • 49
  • Nice! Just a thing I noticed: `ax.autoscale(False)` should be called once, outside of `draw_brace`. – Guimoute Jul 14 '20 at 10:43
11

I modified Joooeey's answer to allow to change the vertical position of braces:

def draw_brace(ax, xspan, yy, text):
    """Draws an annotated brace on the axes."""
    xmin, xmax = xspan
    xspan = xmax - xmin
    ax_xmin, ax_xmax = ax.get_xlim()
    xax_span = ax_xmax - ax_xmin

    ymin, ymax = ax.get_ylim()
    yspan = ymax - ymin
    resolution = int(xspan/xax_span*100)*2+1 # guaranteed uneven
    beta = 300./xax_span # the higher this is, the smaller the radius

    x = np.linspace(xmin, xmax, resolution)
    x_half = x[:int(resolution/2)+1]
    y_half_brace = (1/(1.+np.exp(-beta*(x_half-x_half[0])))
                    + 1/(1.+np.exp(-beta*(x_half-x_half[-1]))))
    y = np.concatenate((y_half_brace, y_half_brace[-2::-1]))
    y = yy + (.05*y - .01)*yspan # adjust vertical position

    ax.autoscale(False)
    ax.plot(x, y, color='black', lw=1)

    ax.text((xmax+xmin)/2., yy+.07*yspan, text, ha='center', va='bottom')
ax = plt.gca()
ax.plot(range(10))
draw_brace(ax, (0, 8), -0.5, 'large brace')
draw_brace(ax, (8, 9), 3, 'small brace')

Output:

matplotlib braces

Also note that in Joooeey's answer, line

x_half = x[:resolution/2+1]

should be

x_half = x[:int(resolution/2)+1]

Otherwise, the number that the script tries to use as index here is a float.

Finally, note that right now the brace will not show up if you move it out of bounds. You need to add parameter clip_on=False, like this:

ax.plot(x, y, color='black', lw=1, clip_on=False)
guzey
  • 111
  • 1
  • 3
6

You can just wrap it all up in a function:

def add_range_annotation(ax, start, end, txt_str, y_height=.5, txt_kwargs=None, arrow_kwargs=None):
    """
    Adds horizontal arrow annotation with text in the middle

    Parameters
    ----------
    ax : matplotlib.Axes
        The axes to draw to

    start : float
        start of line

    end : float
        end of line

    txt_str : string
        The text to add

    y_height : float
        The height of the line

    txt_kwargs : dict or None
        Extra kwargs to pass to the text

    arrow_kwargs : dict or None
        Extra kwargs to pass to the annotate

    Returns
    -------
    tuple
        (annotation, text)
    """

    if txt_kwargs is None:
        txt_kwargs = {}
    if arrow_kwargs is None:
        # default to your arrowprops
        arrow_kwargs = {'arrowprops':dict(arrowstyle="<->",
                            connectionstyle="bar",
                            ec="k",
                            shrinkA=5, shrinkB=5,
                            )}

    trans = ax.get_xaxis_transform()

    ann = ax.annotate('', xy=(start, y_height),
                        xytext=(end, y_height),
                        transform=trans,
                        **arrow_kwargs)
    txt = ax.text((start + end) / 2,
                  y_height + .05,
                  txt_str,
                  **txt_kwargs)


    if plt.isinteractive():
        plt.draw()
    return ann, txt

Alternately,

start, end = .6, .8
ax.axvspan(start, end, alpha=.2, color='r')
trans = ax.get_xaxis_transform()
ax.text((start + end) / 2, .5, 'test', transform=trans)
tacaswell
  • 84,579
  • 22
  • 210
  • 199
  • It's not a common enough operation to get its own method? – ari Aug 22 '13 at 18:05
  • I don't know of an existing one, but I just wrote one ;) If you could provide some feed back on using thing, I'll suggest adding this to the library as a built in. – tacaswell Aug 22 '13 at 18:18
  • They are both good options, thanks for sharing them. The function you provided is already sufficient for most cases, but if you are looking for ways to improve it, I could think of some suggestions. – ari Aug 26 '13 at 01:17
  • Here are some suggestions: - allow for arbitrary rotation (easily done with a start and end points getting their own xy coordinates) - other shapes besides square brackets. I tried doing this by changing `connectionstyle`, but that didn't work. Could be done by using two arrows instead of one connected arrow. - center the text by default - perhaps extend the `AxesSubplot` class instead of using a function; this would allow it to act more like the `annotate` method--no need to pass the axis. Again, your method already works for most cases; these would just be icing. – ari Aug 26 '13 at 01:33
  • I don't know how to put a list in the comments, sorry. – ari Aug 26 '13 at 01:39
  • @ari https://gist.github.com/tacaswell/6337457 comment and suggest changes on that gist – tacaswell Aug 26 '13 at 01:45
  • and the plan would be to submit this as an `Axes` method. All you need to do is replace `ax` with `self`. You can also directly monkey-patch this in to `Axes` as-is. – tacaswell Aug 26 '13 at 01:48
  • Ah, ok--submitting as an Axes method makes way more sense than whatever it is I said. – ari Aug 26 '13 at 02:52
  • @tacaswell In the provided function, the arrowheads are clumsy, (in my usage) the text is drawn haphazardly, and there's no line connecting the text to the bar connector as there would be with the `-[` `arrowstyle`. It would really be nice to have an annotation style simply consisting of a curly brace '}' (that could be rotated), which could be annotated with text associated with the central point of the brace. This would allow ranges to be annotated easily. – marisano Dec 29 '17 at 20:42
5

Here is a minor modification to guzey and jooeey's answer to plot the flower braces outside the axes.

def draw_brace(ax, xspan, yy, text):
"""Draws an annotated brace outside the axes."""
    xmin, xmax = xspan
    xspan = xmax - xmin
    ax_xmin, ax_xmax = ax.get_xlim()
    xax_span = ax_xmax - ax_xmin

    ymin, ymax = ax.get_ylim()
    yspan = ymax - ymin
    resolution = int(xspan/xax_span*100)*2+1 # guaranteed uneven
    beta = 300./xax_span # the higher this is, the smaller the radius

    x = np.linspace(xmin, xmax, resolution)
    x_half = x[:int(resolution/2)+1]
    y_half_brace = (1/(1.+np.exp(-beta*(x_half-x_half[0])))
                + 1/(1.+np.exp(-beta*(x_half-x_half[-1]))))
    y = np.concatenate((y_half_brace, y_half_brace[-2::-1]))
    y = yy + (.05*y - .01)*yspan # adjust vertical position

    ax.autoscale(False)
    ax.plot(x, -y, color='black', lw=1, clip_on=False)

    ax.text((xmax+xmin)/2., -yy-.17*yspan, text, ha='center', va='bottom')

    
# Sample code
fmax = 1
fstart = -100
fend = 0
frise = 50
ffall = 20

def S(x):
   if x<=0:
       return 0
   elif x>=1:
       return 1
   else:
       return 1/(1+np.exp((1/(x-1))+(1/x)))

x = np.linspace(700,1000,500)
lam = [fmax*(S((i-880)/60)-S(((i-1000)/25)+1)) for i in x]
fig = plt.figure(1)
ax = fig.add_subplot(111)
plt.plot(x,lam)
plt.xlim([850,1000])
ax.set_aspect(50,adjustable='box')
plt.ylabel('$\lambda$')
plt.xlabel('$x$')
ax.xaxis.set_label_coords(0.5, -0.35)
draw_brace(ax, (900,950),0.2, 'rise')
draw_brace(ax, (980,1000),0.2, 'fall')
plt.text(822,0.95,'$(\lambda_{\mathrm{max}})$')

Sample output

  • This assumes that ylim[0]=0. However it's an easy fix. Replace the `plot` line in `draw_brace` with `ax.plot(x,2*yy-y,color='black',lw=1,clip_on=False)`. Works like a charm for a yaxis with different limits! – D.J. P. May 18 '22 at 23:38
1

a minor modification of the draw_brace of @Joooeey and @guezy to have also the brace upside down

+argument upsidedown

def draw_brace(ax, xspan, yy, text, upsidedown=False):
    """Draws an annotated brace on the axes."""
    # shamelessly copied from https://stackoverflow.com/questions/18386210/annotating-ranges-of-data-in-matplotlib
    xmin, xmax = xspan
    xspan = xmax - xmin
    ax_xmin, ax_xmax = ax.get_xlim()
    xax_span = ax_xmax - ax_xmin

    ymin, ymax = ax.get_ylim()
    yspan = ymax - ymin
    resolution = int(xspan/xax_span*100)*2+1 # guaranteed uneven
    beta = 300./xax_span # the higher this is, the smaller the radius

    x = np.linspace(xmin, xmax, resolution)
    x_half = x[:int(resolution/2)+1]
    y_half_brace = (1/(1.+np.exp(-beta*(x_half-x_half[0])))
                    + 1/(1.+np.exp(-beta*(x_half-x_half[-1]))))
    if upsidedown:
        y = np.concatenate((y_half_brace[-2::-1], y_half_brace))
    else:
        y = np.concatenate((y_half_brace, y_half_brace[-2::-1]))
    y = yy + (.05*y - .01)*yspan # adjust vertical position

    ax.autoscale(False)
    line = ax.plot(x, y, color='black', lw=1)

    if upsidedown:
        text = ax.text((xmax+xmin)/2., yy+-.07*yspan, text, ha='center', va='bottom',fontsize=7)
    else:
        text = ax.text((xmax+xmin)/2., yy+.07*yspan, text, ha='center', va='bottom',fontsize=7)
    return line, text
Nikaido
  • 4,443
  • 5
  • 30
  • 47
  • This works great. Is there a way to add an option to rotate the curl 90 degrees so that it can indicate ranges on the y axis instead of the x axis? – hallque Feb 13 '23 at 16:01
1

I updated the previous answers to have some of the features I wanted, like an option for a vertical brace, that I wanted to place in multi-plot figures. One still has to futz with the beta_scale parameter sometimes depending on the scale of the data that one is applying this to.

enter image description here

def rotate_point(x, y, angle_rad):
    cos,sin = np.cos(angle_rad),np.sin(angle_rad)
    return cos*x-sin*y,sin*x+cos*y

def draw_brace(ax, span, position, text, text_pos, brace_scale=1.0, beta_scale=300., rotate=False, rotate_text=False):
    '''
        all positions and sizes are in axes units
        span: size of the curl
        position: placement of the tip of the curl
        text: label to place somewhere
        text_pos: position for the label
        beta_scale: scaling for the curl, higher makes a smaller radius
        rotate: true rotates to place the curl vertically
        rotate_text: true rotates the text vertically        
    '''
    # get the total width to help scale the figure
    ax_xmin, ax_xmax = ax.get_xlim()
    xax_span = ax_xmax - ax_xmin
    resolution = int(span/xax_span*100)*2+1 # guaranteed uneven
    beta = beta_scale/xax_span # the higher this is, the smaller the radius
    # center the shape at (0, 0)
    x = np.linspace(-span/2., span/2., resolution)
    # calculate the shape
    x_half = x[:int(resolution/2)+1]
    y_half_brace = (1/(1.+np.exp(-beta*(x_half-x_half[0])))
                + 1/(1.+np.exp(-beta*(x_half-x_half[-1]))))
    y = np.concatenate((y_half_brace, y_half_brace[-2::-1]))
    # put the tip of the curl at (0, 0)
    max_y = np.max(y)    
    min_y = np.min(y)
    y /= (max_y-min_y)
    y *= brace_scale
    y -= max_y
    # rotate the trace before shifting
    if rotate:
        x,y = rotate_point(x, y, np.pi/2)
    # shift to the user's spot   
    x += position[0]        
    y += position[1]
    ax.autoscale(False)
    ax.plot(x, y, color='black', lw=1, clip_on=False)
    # put the text
    ax.text(text_pos[0], text_pos[1], text, ha='center', va='bottom', rotation=90 if rotate_text else 0) 
premes
  • 363
  • 2
  • 8