# -*- coding: utf-8 -*-
"""
Created on Tue May 28 10:01:19 2024
@author: user
"""
import warnings
from typing import Optional
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
[docs]
def plot_curves(
data,
t_start=None,
t_delta=None,
resample=None,
rolling=None,
variables_ax1: Optional[list]= None,
variables_ax2: Optional[list]= None,
ylim_ax1 = None,
ylim_ax2 = None,
colorsdict = 'black',
stylesdict = 'solid',
alpha = 1,
cycler_ax1=None,
cycler_ax2=None,
layout = 'constrained',
layout_kwargs: Optional[dict] = None,
title = None,
ylabel_ax1 = None,
ylabel_ax2 = None,
xlabel = None,
annotate_curves=False,
figsize = (10,6),
**kwargs
):
""" Plot curves from a DataFrame. Features:
- Plot curves on primary or secondary axis (specified via "variables_ax1"
and "variables_ax2" or inferred automatically, if given)
- use dictionaries to define colors and styles for variables. This is
useful to ensure that the same physical quantities are plotted in the
same color and the same units / positions of measurement / ... in the
same style in the figure. Or you choose to provide a plt.cycler
directly (which overrides the color and styles dictionaries).
- (optional) select timerange that shall be used for plotting via
t_start and t_range
- (optional) resample data (mean value for period)
- (optional) apply rolling mean on data
- (optional) annotation of curves (this functionality probably needs
improvement for better placement)
Parameters
----------
data : pd.DataFrame
DataFrame with simulation results
t_start : datetime.datetime
starting time of the plot, default=None (then plot will start at
min(data_dict[data_dict.keys()[0]].index()) )
t_delta : datetime.timedelta or str
range of the time axis, either timedelta or string like '7D', '1H';
default=None (then plot will go up to the end of data)
resample : string
Period for resampling the data (mean), default=None, choose a valid
resampling string such as 'D', 'H' or '15T'
rolling : string
Window for rolling mean, default=None, choose a valid window string
such as 'D', 'H' or '15T'
variables_ax1 : list of strings
Variables to be plotted on primary axis, default []
variables_ax2 : list of strings
Variables to be plotted on secondary axis, default []
ylim_ax1 : tuple of numbers
Limits of the primary y-axis.
ylim_ax2 : tuple of numbers
Limits of the secondary y-axis.
colorsdict : dict {'variable_name':'color'} or str
Single color string or dict of colors to be used for plotting the
curves; default='black'
stylesdict : dict {'unit_name':'style'} or str
Single style string or dict of styles to be used for plotting the
curves. Note that matching of unit_names is done from the end of
the variable names; default='black'
alpha : float
Number in [0;1] to indicate transparency of curves.
cycler_ax1 : Cycler
Cycler object for colors and style properties on primary axis. Can
be constructed via matplotlib.pyplot.cycler(color=[...],
line_style=[...]); Using this option overrides colorsdict,
stylesdict and alpha.
cycler_ax2 : Cycler
Cycler object for secondary axis.
layout : str
Layout option for matplotlib.figure.Figure.set_layout_engine().
Default is 'constrained'
layout_kwargs : dict
Additional keyword arguments to be passed to matplotlib.figure.
Figure.set_layout_engine(). Default is {}.
titel : str
Title used as supertitle of the figure. Default is 'None'.
y_label_ax1 : str
Label for the primary axis. Default is 'None', which lets this
function try to infer the label from the variable names.
y_label_ax2 : str
Label for the secondary axis. Default is 'None', which lets this
function try to infer the label from the variable names.
x_label : str
Label for the x-axis of the plot. Default is 'None', which means no
label will be given to the x-axis.
annotate_curves : Boolean
Wether curve labels shall be added as annotation to the curves.
Default=False
figsize : tuple of numbers
Size of the figure. Default is (10,6).
kwargs : dict
additional keyword arguments with standard plot options, default={}
Returns
-------
plot_data : pd.DataFrame
All data in the plot.
fig : matplotlib.figure.Figure
Figure that was drawn
"""
# Set up dataframe with data for plotting
plot_data = pd.DataFrame()
if isinstance(t_delta, str):
t_delta = pd.Timedelta(t_delta)
t_start = data.index.min() if (t_start is None) else max(t_start, data.index.min())
t_end = data.index.max() if (t_delta is None) else min(t_start+t_delta, data.index.max())
# try to place variables two axes, if not explicitely given
# => if exactly two different known physical quantities
# => otherwise: all on first axis + warning if more than 2 quantities
if variables_ax1 is None:
variables_ax1 = []
if variables_ax2 is None:
variables_ax2 = []
if variables_ax1 == [] and variables_ax2 == []:
variables = list(data.columns)
matched = list({
_find_longest_match(_physical_quantities.columns, var) for var in variables
})
if len(matched) == 2:
variables_ax1 = [var for var in variables if var.startswith(matched[0])]
variables_ax2 = [var for var in variables if var.startswith(matched[1])]
else:
variables_ax1 = variables
plot_data=data.loc[t_start:t_end,variables_ax1+variables_ax2]
if len(plot_data.index)<2:
if len(data.index)==2:
print('Data to plot seems to be parameters that are constant over time')
plot_data.loc[t_start,:]=data.iloc[0]
plot_data.loc[t_end,:]=data.iloc[1]
if resample is not None:
plot_data = plot_data.resample(
resample,loffset=pd.tseries.frequencies.to_offset(resample)/2).mean()
if rolling is not None:
plot_data = plot_data.rolling(rolling,closed='neither').mean()
# Bemerkung: Der gleitende Mittelwert ist nur bedingt geeignet, da er einzelne Spitzen
# zwar dämpft aber dennoch enthält! Besser ist resample, denn wenn dynamische Effekte
# sich über einen Tag ausgleichen, sind die Werte dann "glatt"!
# Define Colors
if isinstance(colorsdict, dict):
colorkeys=colorsdict.keys()
if variables_ax1 != []:
colors_ax1 = [colorsdict[_find_longest_match(colorkeys, var)] for var in variables_ax1]
if variables_ax2 != []:
colors_ax2 = [colorsdict[_find_longest_match(colorkeys, var)] for var in variables_ax2]
elif isinstance(colorsdict, str):
if variables_ax1 != []:
colors_ax1 = [colorsdict]*len(variables_ax1)
if variables_ax2 != []:
colors_ax2 = [colorsdict]*len(variables_ax2)
else:
warnings.warn("Parameter 'colorsdict' must be either a dict like {variable:color}" +
" or a string indicating a color. You specified " + str(colorsdict) +
". Default color 'black' is used for all curves.")
if variables_ax1 != []:
colors_ax1 = ['black']*len(variables_ax1)
if variables_ax2 != []:
colors_ax2 = ['black']*len(variables_ax2)
# Define styles
if isinstance(stylesdict, dict):
stylekeys=stylesdict.keys()
if variables_ax1 != []:
styles_ax1 = [(stylesdict[_find_longest_match(stylekeys, variable,mode='end')]
if any(variable.endswith(key) for key in stylesdict.keys())
else 'solid')
for variable in variables_ax1]
if variables_ax2 != []:
styles_ax2 = [(stylesdict[_find_longest_match(stylekeys, variable,mode='end')]
if any(variable.endswith(key) for key in stylesdict.keys())
else 'solid')
for variable in variables_ax2]
elif isinstance(stylesdict, str):
if variables_ax1 != []:
styles_ax1 = [stylesdict]*len(variables_ax1)
if variables_ax2 != []:
styles_ax2 = [stylesdict]*len(variables_ax2)
else:
if variables_ax1 != []:
styles_ax1 = ['solid']*len(variables_ax1)
if variables_ax2 != []:
styles_ax2 = ['solid']*len(variables_ax2)
# infer ylabels (if not given)
ylabel_ax1 = _infer_ylabel(ylabel_ax1, variables_ax1)
ylabel_ax2 = _infer_ylabel(ylabel_ax2, variables_ax2)
# set up figure and axes
fig, ax1 = plt.subplots(figsize=figsize)
if layout is not None:
if layout_kwargs is None:
layout_kwargs = {}
fig.set_layout_engine(layout=layout, **layout_kwargs)
ax2=ax1.twinx()
# plot variables on ax1
if variables_ax1 != []:
if cycler_ax1 is None:
cycler_ax1 = plt.cycler(linestyle = styles_ax1,
color = colors_ax1,
alpha=[alpha]*len(variables_ax1))
ax1.set_prop_cycle(cycler_ax1)
plot_data.plot(y=variables_ax1,ax=ax1,**kwargs) #label=label,
# plot variables on ax2
if variables_ax2!=[]:
if cycler_ax2 is None:
cycler_ax2 = plt.cycler(linestyle = styles_ax2,
color = colors_ax2,
alpha=[alpha]*len(variables_ax2))
ax2.set_prop_cycle(cycler_ax2)
plot_data.plot(y=variables_ax2,ax=ax2,**kwargs) #label=label,
# annotate curves
if annotate_curves:
pos_annotations_ax1 = plot_data.index[int(0.2*len(plot_data))]
pos_annotations_ax2 = plot_data.index[int(0.8*len(plot_data))]
for n, variable in enumerate(variables_ax1):
values_ax1 = plot_data.loc[:,variables_ax1].to_numpy()
ax1.annotate(variable,
xy = (pos_annotations_ax1,plot_data.loc[pos_annotations_ax1,variable]),
xytext = (pos_annotations_ax1,plot_data.loc[pos_annotations_ax1, variable]
+0.1*(values_ax1.max()-values_ax1.min())),
color = colors_ax1[n],
arrowprops={"arrowstyle":"-", "connectionstyle":"arc3", "color" : colors_ax1[n]}
)
for n, variable in enumerate(variables_ax2):
values_ax2 = plot_data.loc[:,variables_ax2].to_numpy()
ax2.annotate(variable,
xy = (pos_annotations_ax2,plot_data.loc[pos_annotations_ax2,variable]),
xytext = (pos_annotations_ax2,plot_data.loc[pos_annotations_ax2, variable]
+0.1*(values_ax2.max()-values_ax2.min())),
color = colors_ax2[n],
arrowprops={"arrowstyle":"-", "connectionstyle":"arc3", "color" : colors_ax2[n]}
)
# set y limits
if ylim_ax1 is not None:
ax1.set_ylim(ylim_ax1)
if ylim_ax2 is not None:
ax2.set_ylim(ylim_ax2)
# add separate legends for primary and secondary y-axis
ax1.legend([line.get_label() for line in ax1.lines],
title='Variables on prim. axis', loc='upper left')
ax2.legend([line.get_label() for line in ax2.lines],
title='Variables on sec. axis', loc='upper right')
# add horizontal labels for primary and secondary y-axis
label_kwargs = {'rotation':'horizontal', 'rotation_mode':"anchor",
'verticalalignment':'baseline', 'ha':'left'}
ax1.set_ylabel(ylabel_ax1, **label_kwargs)
ax1.yaxis.set_label_coords(-0.05, 1.03)
ax2.set_ylabel(ylabel_ax2,**label_kwargs)
ax2.yaxis.set_label_coords(1.05, 1.03)
# add label for x-axis
ax1.set_xlabel(xlabel)
# add title
fig.suptitle(title)
return plot_data, fig
### Helper functions
def _find_longest_match(searchlist, match, mode='start'):
"""
Find element from list, that has longest match with match
Parameters
----------
searchlist : list
list of strings to search longest match in.
match : str
search string.
mode : str
whether to match from 'start' or 'end' of the items in searchlist
Returns
-------
item : str
longest match if any, None otherwise.
"""
searchlist_revsorted = list(searchlist).copy()
searchlist_revsorted.sort(key=len,reverse=True)
for item in searchlist_revsorted:
if mode == 'start':
if match.startswith(item):
return item
elif mode == 'end':
if match.endswith(item):
return item
else:
raise ValueError("'mode' must be one of 'start' or 'end'. You provided '"+mode+"'.")
return None
_physical_quantities = pd.DataFrame(data={
'T' : ['Temperature T in °C', 'r', r'$T_{SL}$', '°C'],
'dT' : [r'temperature difference $\Delta$T in °C', 'r', r'$\DeltaT$', '°C'],
'p' : ['pressure p in bar', 'grey', '$p$', 'bar'],
'dp' : [r'differential pressure $\Delta$p [bar]', 'lightgrey', r'$\Delta$p', 'bar'],
'm_flow' : [r'mass flow $\dot{m}$ in kg/s', 'g', r'$\dot{m}$', 'kg/s'],
'Q_flow' : [r'heat flow $\dot{Q}$ in W', 'orange', r'$\dot{Q}$', 'W'],
},
index=['label', 'color', 'symbol', 'unit'])
def _infer_ylabel(ylabel, variables, ylabel_dict=_physical_quantities.loc['label',:].to_dict()):
"""
Try to infer a proper label, if it is None for y axis from the variables names
Parameters
----------
ylabel : str
ylabel string for this axis
variables : list
list of variables to be plotted on this axis
ylabel_dict : dict
dictionary of predefined labels
Returns
-------
ylabel : str
infered ylabel, if it was None, else ylabel
"""
if (ylabel is None) and len(variables) > 0:
warnings.warn("You did not pass a label name for this axis." +
"Trying to infer a label name, please check if it is correct.")
matches = []
for variable in variables:
this_match = _find_longest_match(ylabel_dict.keys(), variable)
if (this_match is not None) and this_match not in matches:
matches += this_match
if len(matches) == 0:
warnings.warn("Unable to infer label name, returning None.")
return None
if len(matches) > 1:
warnings.warn("Found more than one possible label for primary y-axis,"+
" returning None.")
return None
return matches[0]
return ylabel
# Finally an example
[docs]
def example():
"""
Run examples of the functions defined in this module.
There are two ways of using this example:
- import sattoolbox as stb and call stb.plots.fm_plots.example()
- directly run this file like a script (this works, because "if __name__ == '__main__':"
runs this example)
Returns
-------
None.
"""
colorsdict = {
'T_SL' : 'red',
'T_RL' : 'blue',
'm_flow' : 'green'}
stylesdict = {
'1' : 'solid',
'2' : 'dashed'}
# =============================================================================
# Line Plot Example
# =============================================================================
print("Testing plot_curves...")
print("Creating data")
x = np.linspace(-np.pi, np.pi, 8760)
y_year = np.sin(x)
y_day = np.sin(x*365)
hours = pd.date_range(start="2023-01-01", end="2023-12-31 23:00", freq="1h")
data = pd.DataFrame(data={
"T_SL_1": 70+15*y_year+1*y_day,
"T_SL_2": 60+10*y_year+1*y_day,
"T_RL_1": 40+2*y_year+2*y_day,
"T_RL_2": 20+10*y_year+5*y_day,
'm_flow_1': 10+3*y_year+5*y_day,
'm_flow_2': 11+4*y_year+1*y_day,
}, index=hours)
print("Data creation complete")
print("Creating Example plots")
# example with only minimum settings
plot_curves(data,
colorsdict=colorsdict,
stylesdict=stylesdict)
# example with nearly everything defined
figsize=(6,6)
plot_curves(data,
figsize = figsize,
t_start = data.index[0],
t_delta = "7D",
variables_ax1=['T_SL_1', 'T_SL_2', 'T_RL_1', 'T_RL_2'],
variables_ax2=['m_flow_1', 'm_flow_2'],
colorsdict = colorsdict,
stylesdict = stylesdict,
alpha = 0.7,
title="Example plot with temperatures and mass flows at two different places",
xlabel = 'Time',
ylim_ax1=[0,100], ylim_ax2=[0,20],
ylabel_ax1='Temperature in °C', ylabel_ax2 = 'Mass flow in kg/s',
annotate_curves=True)