Source code for currentscape.currents

"""Currents class.

As in https://datadryad.org/stash/dataset/doi:10.5061/dryad.d0779mb.
Some functions are based on scripts from the susmentioned article,
that are under the CC0 1.0 Universal (CC0 1.0) Public Domain Dedication license.
"""

# Copyright 2023 Blue Brain Project / EPFL

# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

#     http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# pylint: disable=wrong-import-position, super-with-arguments
import math
import numpy as np

import matplotlib.pyplot as plt

from currentscape.datasets import DataSet
from currentscape.data_processing import (
    reordered_idx,
    reorder,
    remove_zero_arrays,
)
from currentscape.plotting import (
    remove_ticks_and_frame_for_bar_plot,
    set_label,
    apply_labels_ticks_and_lims,
    stackplot_with_bars,
    stackplot_with_fill_between,
    black_line_log_scale,
    get_colors_hatches_lines_lists,
    show_xgridlines,
)
from currentscape.legends import (
    set_legend_with_hatches,
    set_legend_with_hatches_and_linestyles,
    set_legend_with_lines,
    set_legend,
)
from currentscape.mapper import create_mapper


[docs] class CurrentPlottingMixin: """All current plotting methods to be inherited by the Currents class."""
[docs] def plot_sum(self, c, row, rows_tot, positive=True): """Plot outward or inward current sum in log scale. Args: c (dict): config row (int): row of subplot rows_tot (int): total number of subplots in the figure positive (bool): True to plot positive currents subplot. False to plot negative currents subplot """ if positive: curr = self.pos_sum else: curr = self.neg_sum ax = plt.subplot2grid((rows_tot, 1), (row, 0), rowspan=1) ax.fill_between( self.time, curr, color=c["current"]["color"], lw=c["lw"], zorder=2, ) apply_labels_ticks_and_lims( ax, c, self.xticks, [self.time[0], self.time[-1]], list(c["current"]["ylim"]), positive, "current", )
[docs] def plot_shares(self, c, row, rows_tot, cmap): """Plot current percentages. Args: c (dict): config row (int): row of subplot rows_tot (int): total number of subplots in the figure cmap (matplotlib.colors.Colormap): colormap """ # outward currents ax = plt.subplot2grid((rows_tot, 1), (row, 0), rowspan=2) if c["currentscape"]["legacy_method"]: stackplot_with_bars( ax, self.pos_norm, self.idxs, cmap, c, self.N, self.mapper ) else: stackplot_with_fill_between( ax, self.pos_norm, self.idxs, cmap, c, self.N, self.mapper ) # add at black line a the bottom of the plot. # cannot use spines because with subplot_adjust(h=0), # spines overlap on neighboor plot and hide part of the data y_bottom = -c["current"]["black_line_thickness"] / 100.0 ax.fill_between( [self.time[0], self.time[-1]], [y_bottom, y_bottom], color="black", lw=0, ) ylim = [y_bottom, 1] ax.set_ylim(ylim) ax.set_xlim([self.time[0], self.time[-1]]) remove_ticks_and_frame_for_bar_plot(ax) # show x axis gridline if c["show"]["xgridlines"]: show_xgridlines(ax, c, self.xticks, ylim) # labels if c["show"]["ylabels"]: ax.set_ylabel(c["currentscape"]["out_label"], labelpad=c["labelpad"]) # legend # place legend here so that legend top is at the level of share plot top if c["show"]["legend"]: if not c["pattern"]["use"]: set_legend( ax, cmap, c["current"]["names"], c["legend"]["bgcolor"], c["legend"]["ypos"], self.idxs, ) elif c["show"]["all_currents"] and not c["current"]["stackplot"]: set_legend_with_hatches_and_linestyles( ax, cmap, self.mapper, c, self.idxs ) else: set_legend_with_hatches(ax, cmap, self.mapper, c, self.idxs) # inward currents ax = plt.subplot2grid((rows_tot, 1), (row + 2, 0), rowspan=2) if c["currentscape"]["legacy_method"]: stackplot_with_bars( ax, self.neg_norm, self.idxs, cmap, c, self.N, self.mapper ) else: stackplot_with_fill_between( ax, self.neg_norm, self.idxs, cmap, c, self.N, self.mapper ) ylim = [0, 1] ax.set_ylim(ylim) ax.set_xlim([self.time[0], self.time[-1]]) remove_ticks_and_frame_for_bar_plot(ax) # show x axis gridline if c["show"]["xgridlines"]: show_xgridlines(ax, c, self.xticks, ylim) # labels if c["show"]["ylabels"]: ax.set_ylabel(c["currentscape"]["in_label"], labelpad=c["labelpad"])
[docs] def plot_shares_with_imshow(self, c, row, rows_tot, cmap): """Plot current percentage using imshow. Args: c (dict): config row (int): row of subplot rows_tot (int): total number of subplots in the figure cmap (matplotlib.colors.Colormap): colormap """ resy = c["currentscape"]["y_resolution"] black_line_size = int(resy * c["current"]["black_line_thickness"] / 100.0) ax = plt.subplot2grid((rows_tot, 1), (row, 0), rowspan=3) im = ax.imshow( self.image[::1, ::1], interpolation="nearest", aspect="auto", cmap=cmap, ) plt.gca().xaxis.set_major_locator(plt.NullLocator()) plt.gca().yaxis.set_major_locator(plt.NullLocator()) # set limits ylim = [2 * resy + black_line_size, 0] ax.set_ylim(ylim) ax.set_xlim(0, self.x_size) # show x axis gridline if c["show"]["xgridlines"]: show_xgridlines(ax, c, self.xticks_for_imshow(), ylim) # set number of colors # data + black for separating line + white for empty data = (N-1)+1+1 im.set_clim(0, self.N + 1) # remove top and bottom frame that hide some data ax.spines["top"].set_visible(False) ax.spines["bottom"].set_visible(False) # labels if c["show"]["ylabels"]: # cheat to set two labels at the right places for 1 axis set_label( ax, x=0, y=3 * resy / 2, label=c["currentscape"]["in_label"], textsize=c["textsize"], ) set_label( ax, x=0, y=resy / 2, label=c["currentscape"]["out_label"], textsize=c["textsize"], ) # legend if c["show"]["legend"]: set_legend( ax, im.cmap, c["current"]["names"], c["legend"]["bgcolor"], c["legend"]["ypos"], self.idxs, )
[docs] def plot_with_lines(self, ax, selected_currs, c, cmap): """Plot all the positive (or negative) currents with lines. Args: ax (matplotlib.axes): current axis selected_currs (ndarray of ndarrays): positive or negative currents data c (dict): config cmap (matplotlib.colors.Colormap): colormap """ lw = c["lw"] # here, use currs.idxs to have the same colors as in currs.names # can do it because selected_currs have same shape as self (no zero arrays removed) for i, curr in enumerate(selected_currs[self.idxs]): if not np.all(curr == 0): color, _, linestyle = get_colors_hatches_lines_lists( c, i, cmap, self.mapper ) ax.plot(self.time, curr, color=color, ls=linestyle, lw=lw, zorder=2)
[docs] def plot(self, c, row, rows_tot, cmap, positive=True): """Plot all the positive (or negative) currents. Args: c (dict): config row (int): row of subplot rows_tot (int): total number of subplots in the figure cmap (matplotlib.colors.Colormap): colormap positive (bool): True to plot positive currents subplot. False to plot negative currents subplot """ ylim = list(c["current"]["ylim"]) ax = plt.subplot2grid((rows_tot, 1), (row, 0), rowspan=1) # select currents (positive or negative) if positive: selected_currs = self.get_positive_data() else: selected_currs = np.abs(self.get_negative_data()) # plot selected currents, either with stackplot or with lines. if c["current"]["stackplot"]: # create dataset from reordered selected currents sorted_idxs = reordered_idx(selected_currs) sel_currs = DataSet( data=selected_currs[sorted_idxs], names=self.names, time=self.time, xticks=self.xticks, ) sel_currs.idxs = sorted_idxs if c["current"]["legacy_method"]: stackplot_with_bars( ax, sel_currs, self.idxs, cmap, c, self.N, self.mapper, False ) else: stackplot_with_fill_between( ax, sel_currs, self.idxs, cmap, c, self.N, self.mapper, False ) else: self.plot_with_lines(ax, selected_currs, c, cmap) # add black line separating positive currents from negative currents if positive and c["current"]["stackplot"]: ylim = black_line_log_scale( ax, ylim, [self.time[0], self.time[-1]], c["current"]["black_line_thickness"], ) # show legend if not shown in currentscape if not c["show"]["currentscape"] and c["show"]["legend"] and positive: # regular colors if not c["pattern"]["use"]: set_legend( ax, cmap, c["current"]["names"], c["legend"]["bgcolor"], c["legend"]["ypos"], self.idxs, ) # colors & hatches elif c["current"]["stackplot"]: set_legend_with_hatches(ax, cmap, self.mapper, c, self.idxs) # linestyles & hatches elif c["show"]["total_contribution"]: set_legend_with_hatches_and_linestyles( ax, cmap, self.mapper, c, self.idxs ) # linestyles only else: set_legend_with_lines( ax, cmap, self.mapper, c, self.idxs, c["current"]["names"], ) apply_labels_ticks_and_lims( ax, c, self.xticks, [self.time[0], self.time[-1]], ylim, positive, "current", )
[docs] def plot_overall_contribution(self, ax, data, c, cmap): """Plot one pie chart of either positive or negative currents contribution. Args: ax (matplotlib.axes): pie chart axis data (ndarray of ndarrays): positive or negative currents data c (dict): config cmap (matplotlib.colors.Colormap): colormap """ # bar plot in polar coordinates -> pie chart radius = 1 size = 0.5 # process data to be polar coordinates compatible valsnorm = np.sum(data / np.sum(data) * 2 * np.pi, axis=1) valsleft = np.cumsum(np.append(0, valsnorm[:-1])) # current indexes curr_idxs = np.arange(self.N) # set colors and patterns colors, hatches, _ = get_colors_hatches_lines_lists( c, curr_idxs, cmap, self.mapper ) bars = ax.bar( x=valsleft, width=valsnorm, bottom=radius - size, height=size, color=colors, edgecolor=c["pattern"]["color"], linewidth=c["lw"], align="edge", ) # for loop, because hatches cannot be passed as a list if hatches is not None: for bar_, hatch in zip(bars, hatches): bar_.set_hatch(hatch) ax.set_axis_off()
[docs] def plot_overall_contributions(self, c, row, rows_tot, cmap): """Plot positive and negative pie charts of currents summed over the whole simulation. Args: c (dict): config row (int): row of subplot rows_tot (int): total number of subplots in the figure cmap (matplotlib.colors.Colormap): colormap """ textsize = c["textsize"] labelpad = c["labelpad"] # POSITIVE # trick: bar plot in polar coord. to do a pie chart. ax = plt.subplot2grid((rows_tot, 2), (row, 0), rowspan=2, polar=True) # get positive data and reorder them pos_data = self.get_positive_data()[self.idxs] self.plot_overall_contribution(ax, pos_data, c, cmap) # pi is left in x polar coordinates, 1 is height of bars -> outside pie chart # labelpad / 4 : looks close to labelpad in regular plot, but is not rigorous if c["show"]["ylabels"]: set_label(ax, math.pi, 1 + labelpad / 4.0, "outward", textsize) # NEGATIVE # trick: bar plot in polar coord. to do a pie chart. ax = plt.subplot2grid((rows_tot, 2), (row, 1), rowspan=2, polar=True) # get positive data and reorder them neg_data = self.get_negative_data()[self.idxs] self.plot_overall_contribution(ax, neg_data, c, cmap) # pi is left in x polar coordinates, 1 is height of bars -> outside pie chart # labelpad / 4 : looks close to labelpad in regular plot, but is not rigorous if c["show"]["ylabels"]: set_label(ax, math.pi, 1 + labelpad / 4.0, "inward", textsize)
[docs] class Currents(CurrentPlottingMixin, DataSet): """Class containing current data.""" def __init__(self, data, c, time=None): """Constructor. Args: data (list of lists): data all lists are expected to have the same size. c (dict): config time (list): time of the data Attributes: pos_norm (ndarray of ndarrays): norm of positive data neg_norm (ndarray of ndarrays): - norm of negative data pos_sum (ndarray of floats): summed positive data along axis=0 neg_sum (ndarray of floats): summed negative data along axis=0 image (ndarray of ndarrays): currentscape image to be shown with imshow mapper (int): number used to mix colors and patterns """ reorder_ = c["current"]["reorder"] resy = c["currentscape"]["y_resolution"] lw = c["current"]["black_line_thickness"] use_pattern = c["pattern"]["use"] n_patterns = len(c["pattern"]["patterns"]) legacy = c["currentscape"]["legacy_method"] super(Currents, self).__init__( data=data, names=c["current"]["names"], time=time, xticks=c["xaxis"]["xticks"], ) # self.idxs may be modified in the method below self.pos_norm, self.neg_norm, self.pos_sum, self.neg_sum = self.data_processing( reorder_ ) if not legacy or use_pattern: # check this before creating mapper # this change should persist even outside the class if c["colormap"]["n_colors"] > self.N: c["colormap"]["n_colors"] = self.N self.image = None self.mapper = create_mapper(c["colormap"]["n_colors"], n_patterns) else: self.image = self.create_cscape_image(resy, lw) self.mapper = None
[docs] def data_processing(self, reorder_): """Separate into positive and negative currents. Remove 0s arrays. Reorder if asked. Record reordered name indexes. Return the sum and the fraction with its reordered indexes. Args: reorder_ (bool): whether to reorder the currents or not Returns: A tuple (pos_norm, neg_norm, normapos, normaneg), with pos_norm (ndarray of ndarrays): arrays containing norm of positive currents neg_norm (ndarray of ndarrays): arrays containing (-1)* norm of negative currents normapos (ndarray): summed positive currents normaneg (ndarray): summed (absolute values of) negative currents """ cpos = self.get_positive_data() cneg = self.get_negative_data() normapos = np.sum(np.abs(np.asarray(cpos)), axis=0) normaneg = np.sum(np.abs(np.asarray(cneg)), axis=0) # replace 0s by 1s in norma* to avoid 0/0 error. # When norma* is 0, all values it divides are 0s, so even when replaced by 1s, # the result is 0. # We want the result to be 0 in that case because there is no current. cnorm_pos = np.abs(cpos) / [x if x != 0 else 1.0 for x in normapos] cnorm_neg = -(np.abs(cneg) / [x if x != 0 else 1.0 for x in normaneg]) # memory optimisation cpos = None cneg = None if reorder_: cnorm_pos, cnorm_neg, idx_pos, idx_neg = self.reorder_currents_and_names( cnorm_pos, cnorm_neg ) else: cnorm_pos, idx_pos = remove_zero_arrays(cnorm_pos) cnorm_neg, idx_neg = remove_zero_arrays(cnorm_neg) pos_norm = DataSet(cnorm_pos, time=self.time, xticks=self.xticks) pos_norm.idxs = idx_pos neg_norm = DataSet(cnorm_neg, time=self.time, xticks=self.xticks) neg_norm.idxs = idx_neg return ( pos_norm, neg_norm, normapos, normaneg, )
[docs] def reorder_currents_and_names(self, cnorm_pos, cnorm_neg): """Reorder positive currents, negative currents, and current names. Reorder from overall largest contribution to smallest contribution. Reordering the current names (for legend display) is not essential, but is more pleasant to the eye. Args: cnorm_pos (ndarray of ndarrays): arrays containing norm of positive currents cnorm_neg (ndarray of ndarrays): arrays containing (-1)* norm of negative currents Returns: trimmed and reordered positive currents array trimmed and reordered negative currents array indexes that currents (in the pos array) used to have in the original curr array indexes that currents (in the neg array) used to have in the original curr array """ cnorm_pos, idx_pos = reorder(cnorm_pos) cnorm_neg, idx_neg = reorder(cnorm_neg) # for the order of names, just stack the positive and negative order, # then removing duplicates (while conserving order). # also stack arange in case a current is zero for all the sim -> avoid index errors later idx_names = np.concatenate((idx_pos, idx_neg, np.arange(self.N))) _, i = np.unique(idx_names, return_index=True) self.idxs = idx_names[ np.sort(i) ] # pylint: disable=attribute-defined-outside-init return cnorm_pos, cnorm_neg, idx_pos, idx_neg
[docs] def create_black_line(self, resy, lw): """Create a black line to separate inward and outward current shares. Args: resy (int): y-axis resolution (must be high >>1000 or else rounding errors produce white pixels) lw (int): black line (separating the two currentscape plots) thickness in percentage of the plot height. """ line_thickness = int(resy * lw / 100.0) if line_thickness < 1: line = np.full((1, self.x_size), self.N, dtype=np.int8) else: line = np.full((line_thickness, self.x_size), self.N, dtype=np.int8) return line
[docs] def create_cscape_image(self, resy, lw): """Create currentscape image. Args: resy (int): y-axis resolution (must be high >>1000 or else rounding errors produce white pixels) lw (int): black line (separating the two currentscape plots) thickness in percentage of the plot height. """ # idexes to map to re-ordered current names # interchange index and values of self.idxs (name indexes) to create imap # that way, imap[idx] gives index in self.idxs imap = np.zeros(self.N, dtype=int) imap[self.idxs] = np.arange(self.N) times = np.arange(0, self.x_size) # using int8 not to take too much memory impos = np.full((resy, self.x_size), self.N + 1, dtype=np.int8) imneg = np.full((resy, self.x_size), self.N + 1, dtype=np.int8) for t in times: lastpercent = 0 for idx, curr in zip(self.pos_norm.idxs, self.pos_norm.data): if curr[t] > 0: numcurr = imap[idx] percent = int(abs(curr[t]) * (resy)) impos[lastpercent : lastpercent + percent, t] = numcurr lastpercent = lastpercent + percent for t in times: lastpercent = 0 for idx, curr in zip(self.neg_norm.idxs, self.neg_norm.data): if curr[t] < 0: numcurr = imap[idx] percent = int(abs(curr[t]) * (resy)) imneg[lastpercent : lastpercent + percent, t] = numcurr lastpercent = lastpercent + percent # append fake data to produce a black line separating inward & outward # cannot draw with plot, because that would hide data because of adjust (h=0) line = self.create_black_line(resy, lw) return np.vstack((impos, line, imneg))