Getting started tutorial

Here is a quick overview of physio

import numpy as np
import matplotlib.pyplot as plt
from pathlib import Path
import physio

Reading data

physio aims to process physiological signals in NumPy format. Therefore, the first step is to load your data into a NumPy array. physio provides utility functions for reading certain formats, such as:

  • micromed

  • brainvision

For example, cardiac activity recorded by a BrainVision system with the channel name “ECG” can be read using read_one_channel() by specifying:

  • the path to the file (“/path/to/the/file.vhdr”)

  • the format (“brainvision”)

  • the channel name (“ECG”).

For this tutorial, we will use internal files already stored in NumPy format for demonstration purposes. Let’s load them and plot a sample.

raw_resp = np.load('resp1_airflow.npy') # load respi
raw_ecg = np.load('ecg1.npy') # load ecg
srate = 1000. # our example signals have been recorded at 1000 Hz

times = np.arange(raw_resp.size) / srate # build time vector

linewidth = 0.8
fig, axs = plt.subplots(nrows=2, sharex=True)
ax = axs[0]
ax.plot(times, raw_resp, lw = linewidth)
ax.set_title('Raw respiratory airflow signal')
ax.set_ylabel('Amplitude (AU)')
ax.set_ylim(-1750, -1500)

ax = axs[1]
ax.plot(times, raw_ecg, lw = linewidth)
ax.set_title('Raw ECG (electrocardiogram)')
ax.set_ylabel('Amplitude (V)')
ax.set_ylim(0.012, 0.015)

ax.set_xlabel('Time (s)')
ax.set_xlim(185, 225)
Raw respiratory airflow signal, Raw ECG (electrocardiogram)
(185.0, 225.0)

Analyze respiration

compute_respiration() is a high-level wrapper function that simplifies respiratory signal analysis. To use this function, you must provide:

  • raw_resp : the raw respiratory signal as a NumPy array.

  • srate : the sampling rate of the respiratory signal

  • parameter_preset : a string specifying the type of respiratory data, which determines the set of parameters used for processing. Can be one of: human_airflow, human_co2, human_belt, rat_plethysmo, or rat_etisens_belt.

When called, compute_respiration() performs the following:

  • Preprocesses the respiratory signal (returns a NumPy array: resp)

  • Computes cycle-by-cycle features (returns a pd.DataFrame: resp_cycles)

Warning: The orientation of the raw_resp trace is important (multiply it by -1 for reversing it if necessary). Inspiration must point downward for human_airflow or human_co2, because downward deflections are interpreted by compute_respiration() as inspiration. For rat_plethysmo or rat_etisens_belt, the inspiration–expiration transition must point upward.

resp, resp_cycles = physio.compute_respiration(raw_resp, srate, parameter_preset = 'human_airflow') # set 'human_airflow' as preset because example resp is an airflow from humans

linewidth = 0.9
fig, ax = plt.subplots()
ax.plot(times, raw_resp, lw = linewidth, label = 'raw resp signal')
ax.plot(times, resp, lw = linewidth, label = 'preprocessed resp signal')
ax.set_ylabel('Amplitude (AU)')
ax.set_ylim(-1750, -1500)
ax.set_xlim(185, 225)
ax.set_xlabel('Time (s)')
ax.set_title('From raw to preprocessed respiratory airflow signal')
ax.legend(loc = 'upper right')
From raw to preprocessed respiratory airflow signal
<matplotlib.legend.Legend object at 0x70afab407690>

Respiration cycles and features

resp_cycles is a pd.DataFrame where each row corresponds to a respiratory cycle. Columns contain cycle-specific features such as duration, amplitude, volume, etc … See Respiration tutorial for a complete description of the columns.

print(resp_cycles.shape)
print(resp_cycles.columns)

columns = ['cycle_duration', 'inspi_volume', 'expi_volume', 'total_amplitude'] # selection of some columns / metrics
colors = ['tab:blue','darkorange','green','red'] # set colors
units = ['s','AU','AU','AU'] # set units

fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(8, 6))
fig.suptitle(f'Distribution of some metrics from N = {resp_cycles.shape[0]} detected cycles')
for ax, col, color, unit in zip(axs.flatten(), columns, colors, units):
    ax.hist(resp_cycles[col], bins=30, color=color, edgecolor = 'k')
    ax.set_title(col)
    ax.set_xlabel(f'{col} ({unit})')
    ax.set_ylabel("Count")

fig.tight_layout()

resp_cycles
Distribution of some metrics from N = 30 detected cycles, cycle_duration, inspi_volume, expi_volume, total_amplitude
(30, 21)
Index(['inspi_index', 'expi_index', 'next_inspi_index', 'inspi_time',
       'expi_time', 'next_inspi_time', 'cycle_duration', 'inspi_duration',
       'expi_duration', 'cycle_freq', 'cycle_ratio', 'inspi_volume',
       'expi_volume', 'total_amplitude', 'inspi_amplitude', 'expi_amplitude',
       'inspi_peak_index', 'expi_peak_index', 'inspi_peak_time',
       'expi_peak_time', 'total_volume'],
      dtype='str')
inspi_index expi_index next_inspi_index inspi_time expi_time next_inspi_time cycle_duration inspi_duration expi_duration cycle_freq cycle_ratio inspi_volume expi_volume total_amplitude inspi_amplitude expi_amplitude inspi_peak_index expi_peak_index inspi_peak_time expi_peak_time total_volume
0 963 3849 8755 0.963 3.849 8.755 7.792 2.886 4.906 0.128337 0.370380 163.426788 127.997642 195.890794 93.101770 102.789023 2993 4969 2.993 4.969 291.424431
1 8755 12470 16629 8.755 12.470 16.629 7.874 3.715 4.159 0.127000 0.471806 127.912620 120.766841 204.261166 111.836892 92.424274 9640 13479 9.640 13.479 248.679461
2 16629 20085 25589 16.629 20.085 25.589 8.960 3.456 5.504 0.111607 0.385714 117.322121 108.021751 178.600929 64.548937 114.051992 18014 20995 18.014 20.995 225.343872
3 25589 28826 35467 25.589 28.826 35.467 9.878 3.237 6.641 0.101235 0.327698 82.220884 98.005772 125.979297 46.007447 79.971850 26607 30576 26.607 30.576 180.226656
4 35467 39250 45631 35.467 39.250 45.631 10.164 3.783 6.381 0.098386 0.372196 101.588255 117.184560 127.910587 47.063516 80.847071 37283 41144 37.283 41.144 218.772816
5 45631 48992 55914 45.631 48.992 55.914 10.283 3.361 6.922 0.097248 0.326850 82.862181 102.441040 132.522182 44.166802 88.355380 46392 50698 46.392 50.698 185.303221
6 55914 59772 67325 55.914 59.772 67.325 11.411 3.858 7.553 0.087635 0.338095 120.667807 102.270537 138.805077 58.638983 80.166094 57002 61809 57.002 61.809 222.938344
7 67325 70801 78023 67.325 70.801 78.023 10.698 3.476 7.222 0.093475 0.324921 79.043735 94.684808 123.666977 45.443175 78.223802 68674 72750 68.674 72.750 173.728543
8 78023 81420 86030 78.023 81.420 86.030 8.007 3.397 4.610 0.124891 0.424254 106.575911 134.553459 183.326805 61.358012 121.968793 78917 82834 78.917 82.834 241.129370
9 86030 89213 94623 86.030 89.213 94.623 8.593 3.183 5.410 0.116374 0.370418 72.404075 89.801492 153.248258 44.234641 109.013617 86990 90996 86.990 90.996 162.205566
10 94623 97972 104423 94.623 97.972 104.423 9.800 3.349 6.451 0.102041 0.341735 636.536901 447.267064 485.434574 308.901992 176.532582 96491 100891 96.491 100.891 1083.803965
11 104423 108683 114252 104.423 108.683 114.252 9.829 4.260 5.569 0.101740 0.433411 111.393347 125.823770 173.558532 61.310891 112.247642 105392 110518 105.392 110.518 237.217117
12 114252 117877 124700 114.252 117.877 124.700 10.448 3.625 6.823 0.095712 0.346956 85.802485 130.310932 176.487994 42.373720 134.114274 115469 119319 115.469 119.319 216.113417
13 124700 127879 134461 124.700 127.879 134.461 9.761 3.179 6.582 0.102449 0.325684 99.368718 127.697288 180.930699 59.422501 121.508199 125935 129999 125.935 129.999 227.066006
14 134461 138178 145780 134.461 138.178 145.780 11.319 3.717 7.602 0.088347 0.328386 91.107038 105.601792 120.530583 46.826341 73.704242 135851 140396 135.851 140.396 196.708830
15 145780 149124 156359 145.780 149.124 156.359 10.579 3.344 7.235 0.094527 0.316098 54.976681 90.049016 107.621249 29.892126 77.729124 146325 151674 146.325 151.674 145.025697
16 156359 159809 165930 156.359 159.809 165.930 9.571 3.450 6.121 0.104482 0.360464 81.043434 92.559758 111.249025 46.559908 64.689117 157165 161764 157.165 161.764 173.603192
17 165930 169247 175415 165.930 169.247 175.415 9.485 3.317 6.168 0.105430 0.349710 87.795188 92.158942 116.860425 48.317537 68.542888 166838 171542 166.838 171.542 179.954129
18 175415 178225 184867 175.415 178.225 184.867 9.452 2.810 6.642 0.105798 0.297292 56.670496 73.249906 98.581205 33.366724 65.214481 175990 180364 175.990 180.364 129.920402
19 184867 188712 195179 184.867 188.712 195.179 10.312 3.845 6.467 0.096974 0.372867 68.358008 104.624509 131.036618 40.379954 90.656664 185838 190757 185.838 190.757 172.982517
20 195179 199483 205152 195.179 199.483 205.152 9.973 4.304 5.669 0.100271 0.431565 81.222469 105.898526 108.186857 51.809933 56.376923 196397 201509 196.397 201.509 187.120995
21 205152 209074 214162 205.152 209.074 214.162 9.010 3.922 5.088 0.110988 0.435294 106.968152 111.748564 110.797636 47.691581 63.106055 206634 210793 206.634 210.793 218.716716
22 214162 217778 223497 214.162 217.778 223.497 9.335 3.616 5.719 0.107124 0.387359 96.273488 110.557056 131.741321 55.426862 76.314458 215470 220553 215.470 220.553 206.830544
23 223497 227737 233353 223.497 227.737 233.353 9.856 4.240 5.616 0.101461 0.430195 101.875446 120.943199 124.177712 57.961557 66.216154 224499 229680 224.499 229.680 222.818645
24 233353 237747 242949 233.353 237.747 242.949 9.596 4.394 5.202 0.104210 0.457899 111.570671 142.748661 151.433624 55.897519 95.536105 234310 239786 234.310 239.786 254.319332
25 242949 247667 253240 242.949 247.667 253.240 10.291 4.718 5.573 0.097172 0.458459 147.507051 171.319857 165.743584 72.974277 92.769307 243912 249696 243.912 249.696 318.826908
26 253240 257182 263116 253.240 257.182 263.116 9.876 3.942 5.934 0.101256 0.399149 125.281328 132.788731 138.701424 61.828053 76.873371 254007 259468 254.007 259.468 258.070059
27 263116 266749 273102 263.116 266.749 273.102 9.986 3.633 6.353 0.100140 0.363809 92.320485 112.500656 142.799229 61.377405 81.421824 263675 269279 263.675 269.279 204.821141
28 273102 277759 284946 273.102 277.759 284.946 11.844 4.657 7.187 0.084431 0.393195 106.627863 137.735911 122.342380 45.115735 77.226645 274201 279953 274.201 279.953 244.363774
29 284946 288731 295014 284.946 288.731 295.014 10.068 3.785 6.283 0.099325 0.375944 81.769137 107.776736 118.126812 41.632810 76.494002 285985 291162 285.985 291.162 189.545873


All these metrics highly depend on the quality of detection of the start points of inspiration and expiration, which can be verified by visual inspection:

inspi_ind = resp_cycles['inspi_index'].values # get index of inspiration start points
expi_ind = resp_cycles['expi_index'].values # get index of expiration start points

fig, ax = plt.subplots()
ax.plot(times, resp)
ax.scatter(times[inspi_ind], resp[inspi_ind], color='green', label = 'inspiration start')
ax.scatter(times[expi_ind], resp[expi_ind], color='red', label = 'expiration start')
ax.set_xlim(185, 225)
ax.set_xlabel('Time (s)')
ax.set_ylabel('Amplitude (AU)')
ax.set_ylim(-1750, -1500)
ax.set_title('Visual inspection of resp cycle detection')
ax.legend(loc = 'upper right')
Visual inspection of resp cycle detection
<matplotlib.legend.Legend object at 0x70afaaf71790>

Analyze ECG

compute_ecg() is a high-level wrapper function that simplifies ECG signal analysis. To use this function, you must provide:

  • raw_ecg : the raw ECG signal as a NumPy array

  • srate : the sampling rate of the ECG signal

  • parameter_preset : a string specifying the type of ECG data, which determines the set of parameters used for processing. Can be one of: human_ecg or rat_ecg.

When called, compute_ecg() performs the following:

  • Preprocesses the ECG signal (returns a NumPy array: ecg). By default, ecg is normalized.

  • Detects R peaks (returns a pd.DataFrame: ecg_peaks)

Warning: The orientation of the raw_ecg trace is important (multiply it by -1 for reversing it if necessary). R peaks must point upward for the highest probability of detection by compute_ecg(). Sometimes the R and S peaks of the QRS complex are equivalent, so orientation does not matter, because either the R or S peak will be detected and used to mark heartbeats.

ecg, ecg_peaks = physio.compute_ecg(raw_ecg, srate, parameter_preset = 'human_ecg') # set 'human_ecg' as preset because example ecg is from human

r_peak_ind = ecg_peaks['peak_index'].values # get index of R peaks

linewidth = 0.8
fig, axs = plt.subplots(nrows=2, sharex=True)
ax = axs[0]
ax.plot(times, raw_ecg, lw = linewidth)
ax.scatter(times[r_peak_ind], raw_ecg[r_peak_ind], color="#C3CB22", label = 'R peak')
ax.set_ylim(0.012, 0.015)
ax.set_ylabel('Amplitude (V)')
ax.set_title('R peak detection, plotted on raw ECG')
ax.legend(loc = 'upper right')

ax = axs[1]
ax.plot(times, ecg, lw = linewidth)
ax.scatter(times[r_peak_ind], ecg[r_peak_ind], color='#A8AE27', label = 'R peak')
ax.set_xlim(185, 225)
ax.set_ylim(-35, 50)
ax.set_ylabel('Amplitude (AU)')
ax.set_title('R peak detection, plotted on preprocessed ECG')
ax.legend(loc = 'upper right')

ax.set_xlabel('Time (s)')
R peak detection, plotted on raw ECG, R peak detection, plotted on preprocessed ECG
Text(0.5, 23.52222222222222, 'Time (s)')

Heart Rate Variability metrics (time-domain)

compute_ecg_metrics() is a high-level wrapper function that computes time-domain Heart Rate Variability (HRV) metrics from previously detected R peaks (ecg_peaks). To use this function, you must provide the previously detected R peaks, ecg_peaks:

  • ecg_peaks : pd.DataFrame, output of the function compute_ecg()

We can visualize these metrics and the RR interval distribution. See ECG tutorial for a complete description of the metrics.

ecg_metrics = physio.compute_ecg_metrics(ecg_peaks) # ecg_metrics = a pd.Series containing HRV time domain results

peak_times = ecg_peaks['peak_time'].values # get R peak times of detection
rri_s = np.diff(peak_times) # compute RR intervals in seconds
rri_ms = rri_s * 1000 # seconds to milliseconds

fig, ax = plt.subplots()
ax.hist(rri_ms, bins=np.arange(500, 1400, 25), edgecolor = 'k') # plot distribution of RR intervals
ax.axvline(ecg_metrics['HRV_Mean'], color='orange', label='Mean RRi') # vertical line at the mean RRi
ax.axvline(ecg_metrics['HRV_Median'], color='violet', label='Median RRi') # vertical line at the median RRi
ax.axvspan(ecg_metrics['HRV_Median'] - 2*ecg_metrics['HRV_Mad'], ecg_metrics['HRV_Median'] + 2*ecg_metrics['HRV_Mad'], color = 'orange', alpha = 0.1, label = 'Median +/- 2 * Median Absolute Deviation') # vertical span at the med + 2*mad
ax.axvspan(ecg_metrics['HRV_Mean'] - 2*ecg_metrics['HRV_SD'], ecg_metrics['HRV_Mean'] + 2*ecg_metrics['HRV_SD'], color = 'violet', alpha = 0.1, label = 'Mean +/- 2 * Standard-Deviation') # vertical span at the mean + 2*sd
ax.set_xlabel('RR interval (ms)')
ax.set_ylabel('Count')
ax.set_ylim(-5, 90)
ax.legend(loc = 'upper center', ncols = 2)
ax.set_title('Distribution or RR intervals + Heart Rate Variability metrics')
print(ecg_metrics)
Distribution or RR intervals + Heart Rate Variability metrics
HRV_Mean         734.444717
HRV_SD           145.936125
HRV_Median       678.000000
HRV_Mad          115.642973
HRV_CV             0.198703
HRV_MCV            0.170565
HRV_Asymmetry    -56.444717
HRV_RMSSD        103.553299
dtype: float64

Heart Rate Variability metrics (frequency-domain)

compute_hrv_psd() is a high-level wrapper function that computes frequency-domain Heart Rate Variability (HRV) metrics from previously detected R peaks (ecg_peaks). To use this function, you must provide the previously detected R peaks, ecg_peaks:

  • ecg_peaks : pd.DataFrame, output of the function compute_ecg()

Many others parameters can be set for this function (frequency bands, size of the Welch’s window, etc…). See ECG tutorial for more information about these. When called, compute_hrv_psd() performs the following:

  • Compute an instantaneous heart rate vector from ecg peaks and compute a Fourier transform using Welch method (returns two NumPy arrays: psd_freqs and psd giving frequency vector and power vector, respectively).

  • Compute HRV frequency metrics by getting power for each frequency band using trapezoïdal rule (returns a pd.Series: psd_metrics)

We can visualize these metrics and the RR interval distribution. See ECG tutorial for a complete description of the metrics.

frequency_bands = {'lf': (0.04, .15), 'hf' : (0.15, .4)} # set classical cutoffs of low and high frequency bands, in a dictionnary
psd_freqs, psd, psd_metrics = physio.compute_hrv_psd(ecg_peaks=ecg_peaks, frequency_bands=frequency_bands)

print(psd_metrics)
fig, ax = plt.subplots()
ax.plot(psd_freqs, psd)
colors = {'lf': '#B8860B', 'hf' : '#D2691E'}
for name, freq_band in frequency_bands.items():
    ax.axvspan(*freq_band, alpha=0.1, color=colors[name], label=f'{name} : {round(psd_metrics[name], 2)}') # plot one vertical span for each frequency band
ax.set_xlim(0, 0.6)
ax.set_xlabel('Frequency (Hz)')
ax.set_ylabel('Power Spectral Density')
ax.legend(loc = 'upper right')
ax.set_title('HRV Power Spectral Density\n-> Frequency-Domain metrics')
HRV Power Spectral Density -> Frequency-Domain metrics
lf    189.890278
hf     33.775978
dtype: float64

Text(0.5, 1.0, 'HRV Power Spectral Density\n-> Frequency-Domain metrics')

Cyclic deformation

deform_traces_to_cycle_template() is a tool used to deform traces to a cycle template. It works by stretching a trace using linear resampling based on a fixed number of points per cycle. This is helpful to explore if features of a signal are driven by a cyclic phenomenon like respiration.

To use this function, you must provide:

  • data : nd.array. Axis of the time must always be 0, meaning of shape (n_times,…).

  • times : np.array. Time vector of the data. Shape = (n_times,)

  • cycle_times : nd.array with shape (n_cycles, n_segments + 1). Typically, for respiration, cycle_times is an array with 3 columns (inspi_time + expi_time + next_inspi_time) that will make deformation with 2 segments. If cycle_times is 1D, then it is converted to shape (size-1, 2). The end of every cycles must match the start of the next cycle.

  • points_per_cycle : Number of phase points per cycle

  • segment_ratios : None or float or list of float. None if 1 segment. Float or list of float if 2 segments. List of floats if > 2 segments. This is a ratio between 0 and 1 where cycle is divided.

  • output_mode : ‘stacked’ / ‘unstacked’ / ‘unstacked_full’. Format of the outputs. Stacked -> 2D matrix : cycles / points per cycle. Unstacked -> 1D matrix : flattened version of the stacked. Unstacked_full returns extra-outputs.

# Here, we deform the respiratory trace by "itself": the respiratory cycle.
# This leads to an average respiratory template. Importantly, this can be done using one or several segments inside the cycle.

# here we have 3 times per cycle so 2 segments :
# segment 1: inspiration to expiration
# segment 2: expiration to next inspiration

cycle_times = resp_cycles[['inspi_time', 'expi_time', 'next_inspi_time']].values # get 3 timepoints per cycle, dividing each cycle in 2 segments

segment_ratios = 0.4 # ratio between 0 and 1 where cycle is divided into 2 segments
deformed_resp_2seg = physio.deform_traces_to_cycle_template(resp, # resp trace to deform
                                                            times, # time vector of resp trace to deform
                                                             cycle_times,  # times of resp cycles, used to strech
                                                             points_per_cycle=40,  # number of points per cycle used for linear resampling
                                                             segment_ratios=segment_ratios, # ratio between 0 and 1 where cycle is divided into 2 segments
                                                             output_mode='stacked' # choose a stacked version of the returned matrix (see docstrings)
                                                             )
print(deformed_resp_2seg.shape, cycle_times.shape)

# here we have 2 times per cycle so 1 segment:
# segment 1: inspiration to next inspiration

cycle_times = resp_cycles[['inspi_time', 'next_inspi_time']].values # get 2 timepoints per cycle, dividing each cycle in 1 segment (so not dividing)
deformed_resp_1seg = physio.deform_traces_to_cycle_template(resp, # resp trace to deform
                                                            times, # time vector of resp trace to deform
                                                             cycle_times,  # times of resp cycles, used to strech
                                                             points_per_cycle=40,  # number of points per cycle used for linear resampling
                                                             segment_ratios=None, # ratio between 0 and 1 where cycle is divided into 2 segments. None in this case because 1 segment
                                                             output_mode='stacked' # choose a stacked version of the returned matrix (see docstrings)
                                                             )
print(deformed_resp_1seg.shape, cycle_times.shape)

fig, axs = plt.subplots(ncols=2, sharey=True, figsize = (8,4))

ax = axs[0]
physio.plot_cyclic_deformation(deformed_resp_2seg, segment_ratios=0.4, two_cycles=False, ax=ax) # using function physio.plot_cyclic_deformation to plot
ax.set_title('Deformation: 2 segments')
ax.set_ylabel('Resp Amplitude (AU)')

ax = axs[1]
physio.plot_cyclic_deformation(deformed_resp_1seg, segment_ratios=None, two_cycles=False, ax=ax) # using function physio.plot_cyclic_deformation to plot
ax.set_title('Deformation: 1 segment')

for ax in axs:
    ax.set_xlabel('Points per cycle (= respiratory phase)')
Deformation: 2 segments, Deformation: 1 segment
(30, 40) (30, 3)
(30, 40) (30, 2)

Cyclic deformation on ECG

Let’s use the same deform_traces_to_cycle_template() tool for ECG trace. We can use a simple time vector: the R peak times. In this case, the ECG trace is converted as a 1-segment.

cycle_times = ecg_peaks['peak_time'].values # one cycle = R to R -> one segment by R to R period
deformed_ecg = physio.deform_traces_to_cycle_template(ecg, # ecg trace to deform
                                                      times, # time vector of ecg trace to deform
                                                      cycle_times, # times of ecg cycles = R to R periods, used to strech
                                                      points_per_cycle=300, # number of points per cycle used for linear resampling (more than for resp because ecg trace has faster dynamics)
                                                      segment_ratios=None, # 1 segment so None
                                                      output_mode='stacked'
                                                      )
print(deformed_ecg.shape, cycle_times.shape)

fig, ax = plt.subplots()
physio.plot_cyclic_deformation(deformed_ecg, two_cycles=True, ax=ax) # set two_cycles = True to see a concatenation of two cyclical deformations, so that we can see the whole PQRST complex
ax.set_title('Two ECG cyclical deformations concatenated:\nnormalized view of the whole PQRST complex')
ax.set_ylabel('Amplitude (AU)')
ax.set_xlabel('Points per cycle * 2')
Two ECG cyclical deformations concatenated: normalized view of the whole PQRST complex
(407, 300) (408,)

Text(0.5, 23.52222222222222, 'Points per cycle * 2')

Total running time of the script: (0 minutes 1.306 seconds)

Gallery generated by Sphinx-Gallery