In [1]:
%matplotlib notebook

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.widgets import Slider

def cartesian_product(*arrays):
    return np.array(np.meshgrid(*arrays)).T.reshape(-1, len(arrays))

def graph(
    graphs=[{
        'x': None, 'y': None,
        'u': None, 'v': None,
        'fn': None, 'eqn': None
    }],
    xlim=None, ylim=None, title=None,
    fn_params=None, fn_sliders=None, normalize=False,
    show_legend=True, equal_aspect=False,
    **plot_args
):
    '''
    Can graph scatter plots, vector fields, functions f(x),
    implicit equations f(x,y)=g(x,y), and approximate
    binary relations of {(x,y): f(x,y)==True}
    
    Graphing scatter plot requires
        x: numpy.ndarray
        y: numpy.ndarray
        u=None
        v=None
        fn=None
        eqn=None
    
    Graphing vector field requires
        x: numpy.ndarray
        y: numpy.ndarray
        u: function of x, y
        v: function of x, y
        fn=None
        eqn=None
    
    Graphing f(x) requires
        x: numpy.ndarray
        y=None
        u=None
        v=None
        fn: function of x
        eqn=None

    Graphing f(x,y)=g(x,y) requires
        x: numpy.ndarray
        y: numpy.ndarray
        u=None
        v=None
        fn=None
        eqn: {'lhs': f, 'rhs': g}

    Graphing binary relation requires
        x: numpy.ndarray
        y: numpy.ndarray
        u=None
        v=None
        fn: binary relation (boolean function) on x and y
        eqn=None
    
    fn_params (optional): dictionary of numpy.ndarrays
    fn_sliders (optional): list of dictionary parameters:
        {'name', 'min', 'max', 'value'}
    
    functions u, v, fn, eqn['lhs'], eqn['rhs'] should handle additional
    parameters if defined in fn_params and fn_sliders
    '''
    def init(ax):
        if xlim is not None: ax.set_xlim(xlim)
        if ylim is not None: ax.set_ylim(ylim)
        if title is not None: ax.set_title(title)
        ax.grid(True, which='both')
        # set the x-spine (see below for more info on `set_position`)
        ax.spines['left'].set_position('zero')
        # turn off the right spine/ticks
        ax.spines['right'].set_color('none')
        ax.yaxis.tick_left()
        # set the y-spine
        ax.spines['bottom'].set_position('zero')
        # turn off the top spine/ticks
        ax.spines['top'].set_color('none')
        ax.xaxis.tick_bottom()
    
    fig, ax = plt.subplots()
    if equal_aspect: fig.gca().set_aspect('equal', adjustable='box')
    init(ax)
    param_list = []
    MODE_SCATTER, MODE_VECTOR, MODE_FN, MODE_IMPLICIT, MODE_RELATION = 1, 2, 3, 4, 5
    for g in graphs:
        g['plots'] = []
        if g['u'] is None and g['v'] is None and g['fn'] is None and g['eqn'] is None:
            g['mode'] = MODE_SCATTER
        elif g['fn'] is None and g['eqn'] is None:
            g['mode'] = MODE_VECTOR
        elif g['y'] is None and g['u'] is None and g['v'] is None and g['eqn'] is None:
            g['mode'] = MODE_FN
        elif g['u'] is None and g['v'] is None and g['fn'] is None:
            g['mode'] = MODE_IMPLICIT
        elif g['u'] is None and g['v'] is None and g['eqn'] is None:
            g['mode'] = MODE_RELATION
        else: raise Exception(
            'invalid operating mode, see parameter requirements'
        )
        if g['mode'] == MODE_SCATTER:
            g['plot'], = ax.plot(g['x'], g['y'], '.')
            return
        elif g['mode'] == MODE_VECTOR:
            g['X'], g['Y'] = np.meshgrid(g['x'], g['y'])
        elif g['mode'] == MODE_IMPLICIT:
            g['X'], g['Y'] = np.meshgrid(g['x'], g['y'])
            g['lhs'], g['rhs'] = g['eqn']['lhs'], g['eqn']['rhs']
        elif g['mode'] == MODE_RELATION:
            g['domain'] = cartesian_product(g['x'], g['y'])

    if fn_sliders is None: args = {}
    else: args = {slider['name']: slider['value'] for slider in fn_sliders}
    for g in graphs:
        if g['mode'] == MODE_VECTOR:
            U = g['u'](g['X'], g['Y'], **args)
            V = g['v'](g['X'], g['Y'], **args)
            if normalize:
                N = np.sqrt(U**2 + V**2)
                U, V = U/N, V/N
            plot = ax.quiver(g['X'], g['Y'], U, V, **plot_args)
            g['plots'].append(plot)
        elif fn_params is None:
            param_list = [{}]
            if g['mode'] == MODE_FN:
                plot, = ax.plot(g['x'], g['fn'](x, **args), '-')
            elif g['mode'] == MODE_IMPLICIT:
                lhs = g['lhs'](g['X'], g['Y'], **args)
                rhs = g['rhs'](g['X'], g['Y'], **args)
                plot = ax.contour(g['X'], g['Y'], lhs - rhs, [0])
            elif g['mode'] == MODE_RELATION:
                x_data, y_data = [], []
                for pt in g['domain']:
                    if g['fn'](x=pt[0], y=pt[1], **args):
                        x_data.append(pt[0])
                        y_data.append(pt[1])
                plot, = ax.plot(x_data, y_data, '.')
            g['plots'].append(plot)
        else:
            if len(fn_params) == 1:
                k = list(fn_params.keys())[0]
                param_list = [{k: v} for v in fn_params[k]]
            else:
                param_space = cartesian_product(*[v for k, v in fn_params.items()])
                for row in param_space:
                    params = {}
                    for k, v in zip(fn_params, row):
                        params[k] = v
                    param_list.append(params)
            for params in param_list:
                label = ""
                for k, v in params.items():
                    label += str(k) + '=' + str(v) + ','
                if g['mode'] == MODE_FN:
                    plot, = ax.plot(
                        g['x'], g['fn'](g['x'], **params, **args),
                        '-', label=label
                    )
                elif g['mode'] == MODE_IMPLICIT:
                    lhs = g['lhs'](g['X'], g['Y'], **params, **args)
                    rhs = g['rhs'](g['X'], g['Y'], **params, **args)
                    plot = ax.contour(g['X'], g['Y'], lhs - rhs,[0])
                elif g['mode'] == MODE_RELATION:
                    x_data, y_data = [], []
                    for pt in g['domain']:
                        if g['fn'](x=pt[0], y=pt[1], **params, **args):
                            x_data.append(pt[0])
                            y_data.append(pt[1])
                    plot, = ax.plot(x_data, y_data, '.', label=label)
                g['plots'].append(plot)
            if show_legend: ax.legend()

    if fn_sliders is not None:
        plt.subplots_adjust(bottom=len(fn_sliders)*0.06)
        sliders = {}
        for i, s in enumerate(fn_sliders):
            axis = plt.axes([0.2, i*0.05+0.01, 0.5, 0.03])
            sliders[s['name']] = Slider(
                axis, s['name'], s['min'], s['max'], s['value'],
                valstep=0.01*(s['max']-s['min'])
            )

        def update(val):
            for k, slider in sliders.items():
                args[k] = slider.val
            for g in graphs:
                if g['mode'] == MODE_FN:
                    for i, params in enumerate(param_list):
                        g['plots'][i].set_ydata(g['fn'](g['x'], **params, **args))
                elif g['mode'] == MODE_VECTOR:
                    U = g['u'](g['X'], g['Y'], **args)
                    V = g['v'](g['X'], g['Y'], **args)
                    if normalize:
                        N = np.sqrt(U**2 + V**2)
                        U, V = U/N, V/N
                    g['plots'][0].set_UVC(U, V)
                elif g['mode'] == MODE_IMPLICIT:
                    for i, params in enumerate(param_list):
                        for contour in g['plots'][i].collections:
                            contour.remove()
                        lhs = g['lhs'](g['X'], g['Y'], **params, **args)
                        rhs = g['rhs'](g['X'], g['Y'], **params, **args)
                        g['plots'][i] = ax.contour(g['X'], g['Y'], lhs - rhs, [0])
                elif g['mode'] == MODE_RELATION:
                    for i, params in enumerate(param_list):
                        x_data, y_data = [], []
                        for pt in g['domain']:
                            if g['fn'](x=pt[0], y=pt[1], **params, **args):
                                x_data.append(pt[0])
                                y_data.append(pt[1])
                        g['plots'][i].set_xdata(x_data)
                        g['plots'][i].set_ydata(y_data)
                fig.canvas.draw_idle()

        for k, slider in sliders.items():
            slider.on_changed(update)
        return sliders # need sliders in global scope to receive updates
In [2]:
graph(
    [{
        'x': np.linspace(-5, 5, 30),
        'y': np.linspace(-5, 5, 30),
        'u': lambda x, y: np.ones(x.size).reshape(x.shape),
        'v': lambda x, y: y+x**2,
        'fn': None,
        'eqn': None
    },
    {
        'x': np.linspace(-5, 5, 100),
        'y': None, 'u': None, 'v': None,
        'fn': lambda x, C: C*np.exp(x)-x**2-2*x-2,
        'eqn': None
    }],
    normalize=True,
    ylim=(-5, 5),
    title=r'$\frac{\mathrm{d}y}{\mathrm{d}x}=y+x^2$',
    fn_params={'C': np.linspace(-10, 10, 9)},
    angles='xy', scale_units='xy', scale=3
)
In [3]:
graph(
    [{
        'x': np.linspace(-5, 5, 30),
        'y': np.linspace(-5, 5, 30),
        'u': lambda x, y: np.ones(x.size).reshape(x.shape),
        'v': lambda x, y: x*y-x**2,
        'fn': None,
        'eqn': None
    }],
    normalize=True,
    title=r'$\frac{\mathrm{d}y}{\mathrm{d}x}=xy-x^2$',
    angles='xy', scale_units='xy', scale=3
)
In [4]:
graph(
    [{
        'x': np.linspace(-10, 10, 20),
        'y': np.linspace(-10, 10, 20),
        'u': lambda x, y, a, ye: np.ones(x.size).reshape(x.shape),
        'v': lambda x, y, a, ye: a*(y-ye),
        'fn': None,
        'eqn': None
    },
    {
        'x': np.linspace(-10, 10, 100),
        'y': None, 'u': None, 'v': None,
        'fn': lambda x, C, a, ye: ye+C*np.exp(a*x),
        'eqn': None
    }],
    normalize=True,
    ylim=(-10, 10),
    title=r'$\frac{\mathrm{d}y}{\mathrm{d}x}=a(y-y_e)$',
    fn_params={'C': np.linspace(-5, 5, 9)},
    fn_sliders=[
        {'name': 'a', 'min': -5, 'max': 5, 'value': 0.4},
        {'name': 'ye', 'min': -5, 'max': 5, 'value': 0}
    ],
    angles='xy', scale_units='xy', scale=1
)
Out[4]:
{'a': <matplotlib.widgets.Slider at 0x7fbade220d00>,
 'ye': <matplotlib.widgets.Slider at 0x7fbade264ee0>}
In [5]:
graph(
    [{
        'x': np.linspace(-3, 3, 100),
        'y': np.linspace(-3, 3, 100),
        'u': None, 'v': None, 'fn': None,
        'eqn': {
            'lhs': lambda x, y, C: y**3/3-y,
            'rhs': lambda x, y, C: x**3/3+C
        }
    }],
    title=r'$\frac{y^3}{3}-y=\frac{x^3}{3}+C$',
    fn_params={'C': np.linspace(-5, 5, 7)}
)
No handles with labels found to put in legend.
In [6]:
graph(
    [{
        'x': np.linspace(-10, 10, 1000),
        'y': None, 'u': None, 'v': None,
        'fn': lambda x, C, k, alpha, omega: C*np.exp(-k*x)+k*alpha/(omega**2+k**2)*(
            -omega*np.cos(omega*x)+k*np.sin(omega*x)
        ),
        'eqn': None
    }],
    xlim=(-7, 7), ylim=(-7, 7),
    title=r'$y(t)=Ce^{-kt}+\frac{k\alpha}{\omega^2+k^2}(-\omega\cos{\omega t}+k\sin{\omega t})$',
    fn_params={'C': np.linspace(-5, 5, 5)},
    fn_sliders=[
        {'name': 'k', 'min': -2, 'max': 2, 'value': 1},
        {'name': 'alpha', 'min': -2, 'max': 2, 'value': 1},
        {'name': 'omega', 'min': -2, 'max': 2, 'value': 1}
    ]
)
Out[6]:
{'k': <matplotlib.widgets.Slider at 0x7fbadb390f10>,
 'alpha': <matplotlib.widgets.Slider at 0x7fbadb3c2790>,
 'omega': <matplotlib.widgets.Slider at 0x7fbadbcf3730>}
In [7]:
graph(
    [{
        'x': np.linspace(-3, 3, 50),
        'y': np.linspace(-3, 3, 50),
        'u': None, 'v': None,
        'fn': lambda x, y, a, b: np.isclose(x**3+a*x+b, y**2, rtol=0.2),
        'eqn': None
    },
    {
        'x': np.linspace(-3, 3, 100),
        'y': np.linspace(-3, 3, 100),
        'u': None, 'v': None, 'fn': None,
        'eqn': {
            'lhs': lambda x, y, a, b: y**2,
            'rhs': lambda x, y, a, b: x**3+a*x+b
        }
    }],
    xlim=(-3, 3), ylim=(-3, 3),
    title=r'$y^2=x^3+ax+b$',
    fn_params={'b': np.linspace(-3, 3, 3)},
    fn_sliders=[
        {'name': 'a', 'min': -5, 'max': 5, 'value': -2}
    ]
)
Out[7]:
{'a': <matplotlib.widgets.Slider at 0x7fbadbd8ef70>}
In [8]:
graph(
    [{
        'x': np.linspace(-10, 10, 1000),
        'y': None, 'u': None, 'v': None,
        'fn': lambda x, a, b, c: a*x**2+b*x+c,
        'eqn': None
    }],
    xlim=(-7, 7), ylim=(-7, 7),
    title=r'$y(x)=ax^2+bx+c$',
    fn_params={
        'a': np.linspace(-1, 1, 3),
        'c': np.linspace(-2, 2, 3),
    },
    fn_sliders=[{'name': 'b', 'min': -2, 'max': 2, 'value': 1}]
)
Out[8]:
{'b': <matplotlib.widgets.Slider at 0x7fbadbdcb490>}
In [9]:
graph(
    [{
        'x': np.linspace(-10, 10, 1000),
        'y': None, 'u': None, 'v': None,
        'fn': lambda x, C: (2*x+C) * np.exp(-(x**2)),
        'eqn': None
    }],
    xlim=(-5, 5), ylim=(-5, 5),
    title=r'$y(x)=e^{-x^2}(2x+C)$',
    fn_params={'C': np.linspace(-5, 5, 5)}
)
In [10]:
graph(
    [{
        'x': np.linspace(-10, 10, 1000),
        'y': None, 'u': None, 'v': None,
        'fn': lambda x, C: C*np.exp(-3*x)+x-1/3+2/5*np.exp(2*x)+4/13*(3*np.cos(2*x)+2*np.sin(2*x)),
        'eqn': None
    }],
    xlim=(-5, 5), ylim=(-10, 10),
    title=r'$y(x)=Ce^{-3x}+x-\frac{1}{3}+\frac{2}{5}e^{2x}+\frac{4}{13}(3\cos{2x}+2\sin{2x})$',
    fn_params={'C': np.linspace(-5, 5, 5)}
)