Module koma.plot

Visualization module

All functions related to the plotting of results from OMA methods.

Expand source code
"""
##############################################
Visualization module
##############################################
All functions related to the plotting of results from OMA methods.
"""
import plotly.graph_objects as go 
from plotly.subplots import make_subplots
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import colors as mcolors
from matplotlib.backend_bases import MouseButton
from matplotlib import cm

class FDDPlotter:
    def __init__(self, U, D, f=None, lines=None, colors=None, ax=None, 
                 num=None, active_settings={}, deactive_settings={}, picked_settings={},
                 vline_settings= {}):
        self._U = U
        self._D = D
        self.line_ixs = np.array(lines)
        self.index = 0
        self.lines = [None]*len(self.line_ixs)
        self.n_lines = len(self.line_ixs)
        self.active_settings = {'linewidth': 2.0, 'alpha':1.0} | active_settings
        self.deactive_settings = {'linewidth': 1.0, 'alpha': 0.5}  | deactive_settings
        self._picked = []
        self.picked_dots = []
        self.vline_settings = dict(ls='-', lw=0.5) | vline_settings
        self.title_format = 'ix = {index}'

        if f is None:
            self.f = np.arange(self.D.shape[2])
        else:
            self.f = f

        if ax is None:
            plt.figure(num).clf()
            self.fig, self.ax = plt.subplots(num=num)
        else:
            plt.sca(ax)
            self.ax = ax
            self.fig = plt.gcf()

        if colors is None:
            self.colors = list(mcolors.TABLEAU_COLORS.values())
        else:
            self.colors = colors

    @property
    def picked(self):
        if len(self._picked) == 0:
            return self._picked
        else:
            _picked = np.array(self._picked)
            sort_ix = np.argsort(_picked[:,1])
            return _picked[sort_ix, :]
    
    @property
    def current_line(self):
        return self.lines[self.index]

    def get_fig(self, show=True, block=True):
        for l in range(self.n_lines):
            self.lines[l], = self.ax.plot(self.f, self.D[l, l, :]/np.max(self.D[l, l, :]), color=self.colors[l], linewidth=1.0, label=f'Singular line {self.line_ixs[l]}')
        
        self.ax.legend(frameon=False)
        self.vline = self.ax.axvline(np.nan, **self.vline_settings)
        self.vline.set_color(self.current_line.get_color())

        self.fig.canvas.mpl_connect('scroll_event', self.on_scroll)
        self.fig.canvas.mpl_connect('button_press_event', self.on_click)   
        self.fig.canvas.mpl_connect('motion_notify_event', self.on_move)
        self.fig.canvas.mpl_connect('key_press_event', self.on_keyboard)
        self.fig.canvas.mpl_connect('close_event', self.on_close)
        
        if block:
            self.title_format = 'ix = {index} (Press enter when selection is done)'
            
        self.update()   

        if show:
            plt.show()
            if block:
                self.fig.canvas.start_event_loop()

        return self.fig

    def on_click(self, event):
        if event.inaxes and event.button is MouseButton.LEFT and self.fig.canvas.manager.toolbar.mode.value == '':
            current_line = self.current_line
            color = current_line.get_color()
            ix_sel = np.argmin(np.abs(event.xdata - self.f))

            self._picked.append([self.index, ix_sel])
            self.picked_dots.append(self.ax.plot(self.f[ix_sel], current_line.get_ydata()[ix_sel], marker='o',
                            markersize=7, markerfacecolor=color, markeredgecolor='red')[0])

        elif event.inaxes and event.button is MouseButton.RIGHT:
            ix_sel = np.argmin(np.abs(event.xdata - self.f))
            picked_mat = np.array(self._picked)
            valid_ix = np.where((picked_mat[:,0] == self.index))[0]
            if len(valid_ix)>0:
                row_ix = np.argmin(np.abs(ix_sel-picked_mat[valid_ix, 1]))
                ix_remove = valid_ix[row_ix]

                self._picked.pop(ix_remove)
                self.picked_dots[ix_remove].remove()
                self.picked_dots.pop(ix_remove)

        self.fig.canvas.draw()

    def on_close(self, event):
        self.fig.canvas.stop_event_loop()

    def on_keyboard(self, event):
        if event.key == 'enter':
            self.title_format = 'ix = {index}'
            self.update()
            self.fig.canvas.stop_event_loop()

    def on_move(self, event):
        if event.inaxes:
            ix_sel = np.argmin(np.abs(event.xdata-self.f))
            self.vline.set_xdata(self.f[ix_sel])
            self.fig.canvas.draw()

    def on_scroll(self, event):
        increment = 1 if event.button == 'up' else -1
        self.index = np.clip(self.index + increment, 0, self.n_lines-1)
        self.vline.set_color(self.current_line.get_color())
        self.update()

    def update(self):
        self.ax.set_title(self.title_format.format(index = self.line_ixs[self.index]))
        for ix,line in enumerate(self.lines):
            if ix==self.index:
                line.set(**self.active_settings)
            else:
                line.set(**self.deactive_settings) 

        self.fig.canvas.draw()

    @property
    def D(self):
        return self._D[np.ix_(self.line_ixs, self.line_ixs, np.arange(self._D.shape[2]))]

    @property
    def U(self):
        return self._U[np.ix_(np.arange(self._U.shape[0]), self.line_ixs, np.arange(self._U.shape[2]))]

    @property
    def freq(self):
        if len(self.picked)>0:
            ixs = self.picked[:, 1]
            return self.f[ixs]

    @property
    def phi(self):
        if len(self.picked)>0:
            
            phi = np.zeros([self.U.shape[0], len(self._picked)]).astype(complex)
            for ix,pick in enumerate(self.picked):
                phi[:, ix] = self.U[:, pick[0], pick[1]]

            return phi


def stabplot(lambd, orders, phi=None, model=None, freq_range=None, frequency_unit='rad/s', damped_freq=False, psd_freq=None, psd_y=None, psd_plot_scale='log', 
    renderer=None, pole_settings=None, selected_pole_settings=None, to_clipboard='none', return_ix=False):
    """
    Generate plotly-based stabilization plot from output from find_stable_poles. This is still beta!

    Arguments
    ---------------------------
    lambd : double
        array with complex-valued eigenvalues
    orders : int
        corresponding order for each pole in `lambd`
    phi : optional, double
        matrix where each column is complex-valued eigenvector corresponding to lambd
    model : optional, double
        model object which is required input for plotting phi (based on geometry definition of system, constructed by `Model` class)
    freq_range : double, optional
        list of min and max values used for frequency axis
    frequency_unit : {'rad/s', 'Hz', 's'}, optional
        what frequency unit to use ('s' or 'period' enforces period rather than frequency) 
    damped_freq : False, optional
        whether or not to use damped frequency (or period) values in plot (False enforces undamped freqs)
    psd_freq : double, optional
        [not yet implemented] frequency values of plot to overlay, typically spectrum of data
    psd_y : double, optional
        [not yet implemented] function values of plot to overlay, typically spectrum of data
    psd_plot_scale: {'log', 'linear'}, optional
        how to plot the overlaid PSD (linear or logarithmic y-scale)
    renderer : None (render no plot - manually render output object), optional
        how to plot figure, refer plotly documentation for details 
        ('svg', 'browser', 'notebook', 'notebook_connected', are examples - 
        use 'default' to give default and None to avoid plot)
    to_clipboard : {'df', 'ix', 'none'}, optional
        update clipboard every time a pole is added, keeping selected indices or table
        'df' is not operational yet
    return_ix : False, optional
        whether or not to return second variable with indices - this is updated as more poles are selected
        

    Returns
    ---------------------------
    fig : obj
        plotly figure object

        
    Notes
    ----------------------------
    By hovering a point, the following data about the point will be given in tooltip:
        
         * Natural frequency / period in specified unit (damped or undamped)
         * Order 
         * Critical damping ratio in % (xi)
         * Index of pole (corresponding to inputs lambda_stab and order_stab)
    """


    # Treat input settings
    if pole_settings is None: pole_settings = {}
    if selected_pole_settings is None: selected_pole_settings = {}

    unsel_settings = {'color':'#a9a9a9', 'size':6, 'opacity':0.6}
    unsel_settings.update(**pole_settings)

    current_settings = listify_each_dict_entry(unsel_settings, len(lambd))

    sel_settings = {'color':'#cd5c5c', 'size':10, 'opacity':1.0, 'line': {'color': '#000000', 'width': 0}}
    sel_settings.update(**selected_pole_settings)

    select_status = np.zeros(len(lambd), dtype=bool)
    
    # Create suffix and frequency value depending on whether damped freq. is requested or not
    if damped_freq:
        dampedornot = 'd'
        omega = np.abs(np.imag(lambd))
    else:
        dampedornot = 'n'
        omega = np.abs(lambd)

    # Create frequency/period axis and corresponding labels
    if frequency_unit == 'rad/s':
        x = omega
        xlabel = f'$\omega_{dampedornot} \; [{frequency_unit}]$'
        tooltip_name = f'\omega_{dampedornot}'
        frequency_unit = 'rad/s'
    elif frequency_unit.lower() == 'hz':
        x = omega/(2*np.pi)
        xlabel = f'$f_{dampedornot} \; [{frequency_unit}]$'
        tooltip_name = f'f_{dampedornot}'
        frequency_unit = 'Hz'
    elif (frequency_unit.lower() == 's') or (frequency_unit.lower() == 'period'):
        x = (2*np.pi)/omega
        xlabel = f'Period, $T_{dampedornot} \; [{frequency_unit}]$'
        tooltip_name = f'T_{dampedornot}'
        frequency_unit = 's'
    
    # Damping ratio and index to hover
    xi = -np.real(lambd)/np.abs(lambd)
    text = [f'xi = {xi_i*100:.2f}% <br> ix = {ix}' for ix, xi_i in enumerate(xi)]     # rewrite xi as %, and make string
    htemplate = f'{tooltip_name}' + ' = %{x:.3f} ' + f'{frequency_unit}<br>n =' + ' %{y}' +'<br> %{text}'

    # Construct dataframe and create scatter trace     
    poles = pd.DataFrame({'freq': x, 'order':orders})
    scatter_trace =  go.Scattergl(
                x=poles['freq'], y=poles['order'], mode='markers', name='',
                hovertemplate = htemplate, text=text,
                marker=current_settings)
    
    scatter_trace['name'] = 'Poles'

    # PSD overlay trace
    overlay_trace = go.Scatter(x=psd_freq, y=psd_y, mode='lines', name='PSD', 
                               hoverinfo='skip', line={'color':'#ffdab9'})
    
    # Create figure object, add traces and adjust labels and axes
    # fig = go.FigureWidget(scatter_trace)
    fig = make_subplots(rows=2, cols=1, specs=[[{"type": "xy", "secondary_y": True}],
           [{"type": "table"}]])
    fig.layout.hovermode = 'closest'
    fig.add_trace(scatter_trace, secondary_y=False, row=1, col=1)
    
    if psd_freq is not None:
        fig.add_trace(overlay_trace, secondary_y=True, row=1, col=1)
        fig.update_yaxes(title_text="PSD", secondary_y=True, type=psd_plot_scale)
        fig['layout']['yaxis2']['showgrid'] = False
    
    fig.layout['xaxis']['title'] = xlabel
    fig.layout['yaxis']['title'] = '$n$'

    if freq_range is not None:
        fig.layout['xaxis']['range'] = freq_range
        
    fig['layout']['yaxis']['range'] = [0.05, np.max(orders)*1.1]

    # Renderer (refer to plotly documentation for details)
    if renderer is 'default':
        import plotly.io as pio
        renderer = pio.renderers.default

    df = pd.DataFrame(columns=['ix','x','xi'])
    pd.options.display.float_format = '{:,.2f}'.format
    
    fig.add_trace(
        go.Table(header=
                     dict(values=['Pole index', xlabel, r'$\xi [\%]$'],
                          fill_color='paleturquoise',
                          align='left'),
                 cells=
                     dict(values=[],fill_color='lavender',
               align='left')), row=2, col=1)
    
    fig.update_layout(
          height=1000,
          showlegend=False
          )

    fig = go.FigureWidget(fig)  #convert to widget
    
    # Callback function for selection poles
    ix = np.arange(0, len(lambd))
    ix_sel = []
            
    def update_table():
        df = pd.DataFrame(data={'ix': ix[select_status], 
                                'freq': x[select_status], 
                                'xi':100*xi[select_status]})
        
        df = df.sort('freq')
        
        if len(fig.data)==2:
            sel_ix = 1
        else:
            sel_ix = 2
        
        fig.data[sel_ix].cells.values=[df.ix, df.freq, df.xi]
    
    
    def toggle_pole_selection(trace, clicked_point, selector):
        def export_df():
            df.to_clipboard(index=False)
        
        def export_ix_list():
            import pyperclip #requires pyperclip
            ix_str = '[' + ', '.join(str(i) for i in ix_sel) + ']'
            pyperclip.copy(ix_str)

        for i in clicked_point.point_inds:
            if select_status[i]:
                for key in current_settings:
                    current_settings[key][i] = unsel_settings[key]
            else:
                 for key in current_settings:
                    current_settings[key][i] = sel_settings[key]       
                    
            select_status[i] = not select_status[i]     #swap status

            with fig.batch_update():
                trace.marker = current_settings
        
        update_table()
        ix_sel = ix[select_status]

        if to_clipboard == 'ix':
            export_ix_list()
        elif to_clipboard == 'df':
            export_df()

    fig.data[0].on_click(toggle_pole_selection)    

    if renderer == 'browser_legacy':
        from plotly.offline import plot
        plot(fig, include_mathjax='cdn')
    elif renderer is not None:
        fig.show(renderer=renderer, include_mathjax='cdn')
    
    if return_ix:
        return fig, ix_sel
    else:
        return fig


def listify_each_dict_entry(dict_in, n):
    dict_out = dict()
    for key in dict_in:
        dict_out[key] = [dict_in[key]]*n
        
    return dict_out


def plot_argand(phi, ax=None, colors=None, labels=None, **plot_settings):
    if ax is None:
        ax = plt.gca()
        
    if labels is None:
        labels = np.arange(len(phi))
    
    plot_settings = {'width': 0.0, 'linestyle': '-'} | plot_settings
    
    if type(colors) == str:
        colors = cm.get_cmap(colors, len(phi))
        if hasattr(colors, 'colors'):
            colors = colors.colors

    for ix,val in enumerate(phi):
        if colors is not None:
            color = colors[ix]
        else:
            color = None
        
        ax.arrow(0, 0, np.real(val), np.imag(val), color=color, label=labels[ix], **plot_settings)
    
    ymax = np.max(np.abs(ax.get_ylim()))
    xmax = np.max(np.abs(ax.get_xlim()))
    
    xymax = np.max([ymax,xmax])
    
    ax.set_ylim([-xymax, xymax])
    ax.set_xlim([-xymax, xymax])
    return ax

    

Functions

def listify_each_dict_entry(dict_in, n)
def plot_argand(phi, ax=None, colors=None, labels=None, **plot_settings)
def stabplot(lambd, orders, phi=None, model=None, freq_range=None, frequency_unit='rad/s', damped_freq=False, psd_freq=None, psd_y=None, psd_plot_scale='log', renderer=None, pole_settings=None, selected_pole_settings=None, to_clipboard='none', return_ix=False)

Generate plotly-based stabilization plot from output from find_stable_poles. This is still beta!

Arguments

lambd : double
array with complex-valued eigenvalues
orders : int
corresponding order for each pole in lambd
phi : optional, double
matrix where each column is complex-valued eigenvector corresponding to lambd
model : optional, double
model object which is required input for plotting phi (based on geometry definition of system, constructed by Model class)
freq_range : double, optional
list of min and max values used for frequency axis
frequency_unit : {'rad/s', 'Hz', 's'}, optional
what frequency unit to use ('s' or 'period' enforces period rather than frequency)
damped_freq : False, optional
whether or not to use damped frequency (or period) values in plot (False enforces undamped freqs)
psd_freq : double, optional
[not yet implemented] frequency values of plot to overlay, typically spectrum of data
psd_y : double, optional
[not yet implemented] function values of plot to overlay, typically spectrum of data
psd_plot_scale : {'log', 'linear'}, optional
how to plot the overlaid PSD (linear or logarithmic y-scale)
renderer : None (render no plot - manually render output object), optional
how to plot figure, refer plotly documentation for details ('svg', 'browser', 'notebook', 'notebook_connected', are examples - use 'default' to give default and None to avoid plot)
to_clipboard : {'df', 'ix', 'none'}, optional
update clipboard every time a pole is added, keeping selected indices or table 'df' is not operational yet
return_ix : False, optional
whether or not to return second variable with indices - this is updated as more poles are selected

Returns

fig : obj
plotly figure object

Notes

By hovering a point, the following data about the point will be given in tooltip:

 * Natural frequency / period in specified unit (damped or undamped)
 * Order 
 * Critical damping ratio in % (xi)
 * Index of pole (corresponding to inputs lambda_stab and order_stab)

Classes

class FDDPlotter (U, D, f=None, lines=None, colors=None, ax=None, num=None, active_settings={}, deactive_settings={}, picked_settings={}, vline_settings={})
Expand source code
class FDDPlotter:
    def __init__(self, U, D, f=None, lines=None, colors=None, ax=None, 
                 num=None, active_settings={}, deactive_settings={}, picked_settings={},
                 vline_settings= {}):
        self._U = U
        self._D = D
        self.line_ixs = np.array(lines)
        self.index = 0
        self.lines = [None]*len(self.line_ixs)
        self.n_lines = len(self.line_ixs)
        self.active_settings = {'linewidth': 2.0, 'alpha':1.0} | active_settings
        self.deactive_settings = {'linewidth': 1.0, 'alpha': 0.5}  | deactive_settings
        self._picked = []
        self.picked_dots = []
        self.vline_settings = dict(ls='-', lw=0.5) | vline_settings
        self.title_format = 'ix = {index}'

        if f is None:
            self.f = np.arange(self.D.shape[2])
        else:
            self.f = f

        if ax is None:
            plt.figure(num).clf()
            self.fig, self.ax = plt.subplots(num=num)
        else:
            plt.sca(ax)
            self.ax = ax
            self.fig = plt.gcf()

        if colors is None:
            self.colors = list(mcolors.TABLEAU_COLORS.values())
        else:
            self.colors = colors

    @property
    def picked(self):
        if len(self._picked) == 0:
            return self._picked
        else:
            _picked = np.array(self._picked)
            sort_ix = np.argsort(_picked[:,1])
            return _picked[sort_ix, :]
    
    @property
    def current_line(self):
        return self.lines[self.index]

    def get_fig(self, show=True, block=True):
        for l in range(self.n_lines):
            self.lines[l], = self.ax.plot(self.f, self.D[l, l, :]/np.max(self.D[l, l, :]), color=self.colors[l], linewidth=1.0, label=f'Singular line {self.line_ixs[l]}')
        
        self.ax.legend(frameon=False)
        self.vline = self.ax.axvline(np.nan, **self.vline_settings)
        self.vline.set_color(self.current_line.get_color())

        self.fig.canvas.mpl_connect('scroll_event', self.on_scroll)
        self.fig.canvas.mpl_connect('button_press_event', self.on_click)   
        self.fig.canvas.mpl_connect('motion_notify_event', self.on_move)
        self.fig.canvas.mpl_connect('key_press_event', self.on_keyboard)
        self.fig.canvas.mpl_connect('close_event', self.on_close)
        
        if block:
            self.title_format = 'ix = {index} (Press enter when selection is done)'
            
        self.update()   

        if show:
            plt.show()
            if block:
                self.fig.canvas.start_event_loop()

        return self.fig

    def on_click(self, event):
        if event.inaxes and event.button is MouseButton.LEFT and self.fig.canvas.manager.toolbar.mode.value == '':
            current_line = self.current_line
            color = current_line.get_color()
            ix_sel = np.argmin(np.abs(event.xdata - self.f))

            self._picked.append([self.index, ix_sel])
            self.picked_dots.append(self.ax.plot(self.f[ix_sel], current_line.get_ydata()[ix_sel], marker='o',
                            markersize=7, markerfacecolor=color, markeredgecolor='red')[0])

        elif event.inaxes and event.button is MouseButton.RIGHT:
            ix_sel = np.argmin(np.abs(event.xdata - self.f))
            picked_mat = np.array(self._picked)
            valid_ix = np.where((picked_mat[:,0] == self.index))[0]
            if len(valid_ix)>0:
                row_ix = np.argmin(np.abs(ix_sel-picked_mat[valid_ix, 1]))
                ix_remove = valid_ix[row_ix]

                self._picked.pop(ix_remove)
                self.picked_dots[ix_remove].remove()
                self.picked_dots.pop(ix_remove)

        self.fig.canvas.draw()

    def on_close(self, event):
        self.fig.canvas.stop_event_loop()

    def on_keyboard(self, event):
        if event.key == 'enter':
            self.title_format = 'ix = {index}'
            self.update()
            self.fig.canvas.stop_event_loop()

    def on_move(self, event):
        if event.inaxes:
            ix_sel = np.argmin(np.abs(event.xdata-self.f))
            self.vline.set_xdata(self.f[ix_sel])
            self.fig.canvas.draw()

    def on_scroll(self, event):
        increment = 1 if event.button == 'up' else -1
        self.index = np.clip(self.index + increment, 0, self.n_lines-1)
        self.vline.set_color(self.current_line.get_color())
        self.update()

    def update(self):
        self.ax.set_title(self.title_format.format(index = self.line_ixs[self.index]))
        for ix,line in enumerate(self.lines):
            if ix==self.index:
                line.set(**self.active_settings)
            else:
                line.set(**self.deactive_settings) 

        self.fig.canvas.draw()

    @property
    def D(self):
        return self._D[np.ix_(self.line_ixs, self.line_ixs, np.arange(self._D.shape[2]))]

    @property
    def U(self):
        return self._U[np.ix_(np.arange(self._U.shape[0]), self.line_ixs, np.arange(self._U.shape[2]))]

    @property
    def freq(self):
        if len(self.picked)>0:
            ixs = self.picked[:, 1]
            return self.f[ixs]

    @property
    def phi(self):
        if len(self.picked)>0:
            
            phi = np.zeros([self.U.shape[0], len(self._picked)]).astype(complex)
            for ix,pick in enumerate(self.picked):
                phi[:, ix] = self.U[:, pick[0], pick[1]]

            return phi

Instance variables

var D
Expand source code
@property
def D(self):
    return self._D[np.ix_(self.line_ixs, self.line_ixs, np.arange(self._D.shape[2]))]
var U
Expand source code
@property
def U(self):
    return self._U[np.ix_(np.arange(self._U.shape[0]), self.line_ixs, np.arange(self._U.shape[2]))]
var current_line
Expand source code
@property
def current_line(self):
    return self.lines[self.index]
var freq
Expand source code
@property
def freq(self):
    if len(self.picked)>0:
        ixs = self.picked[:, 1]
        return self.f[ixs]
var phi
Expand source code
@property
def phi(self):
    if len(self.picked)>0:
        
        phi = np.zeros([self.U.shape[0], len(self._picked)]).astype(complex)
        for ix,pick in enumerate(self.picked):
            phi[:, ix] = self.U[:, pick[0], pick[1]]

        return phi
var picked
Expand source code
@property
def picked(self):
    if len(self._picked) == 0:
        return self._picked
    else:
        _picked = np.array(self._picked)
        sort_ix = np.argsort(_picked[:,1])
        return _picked[sort_ix, :]

Methods

def get_fig(self, show=True, block=True)
def on_click(self, event)
def on_close(self, event)
def on_keyboard(self, event)
def on_move(self, event)
def on_scroll(self, event)
def update(self)