Source code for osl_dynamics.utils.topoplots

"""Classes and functions to make topoplots."""

from typing import List, Optional, Union

import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1 import make_axes_locatable
from scipy.interpolate import griddata

from osl_dynamics.files.scanner import layouts


[docs] def available_layouts() -> List[str]: layout_names = [file.stem for file in sorted(layouts.glob("*.lay"))] return layout_names
[docs] def get_layout(layout_name: str) -> str: layout_names = available_layouts() if layout_name not in layout_names: raise FileNotFoundError(f"{layout_name} not found.") layout = layouts / f"{layout_name}.lay" return str(layout)
[docs] def available_outlines() -> List[str]: outline_names = [file.stem for file in sorted(layouts.glob("*.outline"))] return outline_names
[docs] def get_outline(outline_name: str) -> str: outline_names = available_outlines() if outline_name not in outline_names: raise FileNotFoundError(f"{outline_name} not found.") outline = layouts / f"{outline_name}.outline" return str(outline)
[docs] class Topology: """Topology class. Parameters ---------- layout : str Path to layout file. """ def __init__(self, layout: str) -> None:
[docs] self.layout = None
[docs] self.outline = None
layout_filename = get_layout(layout) self.read_lay(layout_filename) try: outline_filename = get_outline(layout) self.read_outline(outline_filename) except FileNotFoundError: pass @property
[docs] def width(self): """Dynamically calculate the bounding width of the topology.""" return self.max_x - self.min_x
@property
[docs] def height(self): """Dynamically calculate the bounding height of the topology.""" return self.max_y - self.min_y
@property
[docs] def sensor_positions(self): return self.layout.loc[self.layout["present"], ["x", "y"]].to_numpy()
@property
[docs] def sensor_deltas(self): return self.layout.loc[self.layout["present"], ["width", "height"]].to_numpy()
@property
[docs] def sensor_names(self): return self.layout.loc[self.layout["present"], "channel_name"].to_numpy()
@property
[docs] def min_x(self): return self.layout.loc[self.layout["present"], "x"].min()
@property
[docs] def min_y(self): return self.layout.loc[self.layout["present"], "y"].min()
@property
[docs] def max_x(self): return self.layout.loc[self.layout["present"], "x"].max()
@property
[docs] def max_y(self): return self.layout.loc[self.layout["present"], "y"].max()
@property
[docs] def channel_ids(self): return self.layout.loc[self.layout["present"], "channel_id"].to_numpy()
[docs] def read_lay(self, filename: str) -> None: """Read .lay topology files Every line in a .lay file represents a sensor. The data is delimited by whitespace. The columns are: channel ID, X, Y, width, height, name. Parameters ---------- filename: str The location of a .lout file """ layout = pd.read_csv(filename, header=None, sep=r"\s+") layout.columns = ["channel_id", "x", "y", "width", "height", "channel_name"] layout["present"] = True self.layout = layout
[docs] def read_outline(self, filename: str) -> None: with open(filename) as f: self.outline = [ np.array( [line.split("\t") for line in outline.splitlines() if line != ""] ).astype(float) for outline in f.read().split("-" * 10) ]
[docs] def keep_channels(self, channel_names: List[str]) -> None: """Remove channels which aren't present in channel_names Remove any channels which don't correspond to on in the names provided. This is probably a strong case for using Pandas for data storage. Parameters ---------- channel_names: list of str A list of channel names which are present in the data. All others are removed. """ self.layout.loc[~self.layout["channel_name"].isin(channel_names), "present"] = ( False )
[docs] def plot_data( self, data: Union[np.ndarray, list], plot_boxes: bool = False, show_names: bool = False, title: Optional[str] = None, show_deleted_sensors: bool = False, colorbar: bool = True, axis: Optional[plt.Axes] = None, cmap: Union[str, matplotlib.colors.ListedColormap] = "plasma", n_contours: int = 10, ) -> plt.Figure: """Interpolate the data in sensor-space and plot it. Given a data vector which corresponds to each sensor in the topology, resample the data by interpolating over a grid. Use this data to create a contour plot. Also display the sensor locations and head shape. Parameters ---------- data : numpy.array or list A vector with data corresponding to each sensor. plot_boxes : bool, optional Plot boxes to display the height and width of sensors, rather than just the centers. show_names : bool, optional Display channel names. title : str, optional Title for plot. show_deleted_sensors : bool, optional Plot the sensors which have been deleted, in red. colorbar : bool, optional Display colorbar axis : matplotlib.pyplot.Axes, optional matplotlib axis to plot on. cmap : str or matplotlib.colors.ListedColormap, optional Colourmap to use in plot. Defaults to matplotlib's plasma. n_contours : int, optional Number of contours to use in plot. Returns ------- fig : matplotlib.figure Figure. """ if axis is None: fig, axis = plt.subplots(figsize=(15, 15)) else: fig = axis.get_figure() axis.set_aspect("equal") # Create a grid over the bounding area of the Topology. grid_x, grid_y = np.mgrid[ self.min_x : self.max_x : 500j, self.min_y : self.max_y : 500j ] # Interpolate the data over the new grid. grid_z = griddata( points=self.sensor_positions, values=data, xi=(grid_x, grid_y), method="cubic", ) # Create a filled contour plot over the interpolated data. contour_plot = axis.contourf( grid_x, grid_y, grid_z, cmap=cmap, alpha=0.7, levels=n_contours ) if self.outline: for o in self.outline: axis.plot(*o.T, c="k") axis.set_ylim(self.min_y - 0.1, self.max_y + 0.1) axis.set_xlim(self.min_x - 0.1, self.max_x + 0.1) # Draw boxes to represent sensors. if plot_boxes: for xy, (dx, dy) in zip(self.sensor_positions, self.sensor_deltas): rect = plt.Rectangle( xy - 0.5 * np.array([dx, dy]), dx, dy, facecolor="none", edgecolor="black", ) axis.add_patch(rect) if show_deleted_sensors: deleted_positions = self.layout.loc[ ~self.layout["present"], ["x", "y"] ].to_numpy() deleted_deltas = self.layout.loc[ ~self.layout["present"], ["width", "height"] ].to_numpy() for xy, (dx, dy) in zip(deleted_positions, deleted_deltas): rect = plt.Rectangle( xy - 0.5 * np.array([dx, dy]), dx, dy, facecolor="none", edgecolor="red", ) axis.add_patch(rect) if show_names: for xy, name in zip(self.sensor_positions, self.sensor_names): axis.annotate(name, xy) if show_deleted_sensors: deleted_positions = self.layout.loc[ ~self.layout["present"], ["x", "y"] ].to_numpy() deleted_names = self.layout.loc[~self.layout["present"], "channel_name"] for xy, name in zip(deleted_positions, deleted_names): axis.annotate(name, xy, color="red") # Hide axis spines. plt.setp(plt.gca().spines.values(), visible=False) axis.set_xticks([]) axis.set_yticks([]) if colorbar: divider = make_axes_locatable(axis) cax = divider.append_axes("right", size="5%", pad=0.05) plt.colorbar(contour_plot, cax=cax) cax.set_title("fT") if title: axis.set_title(title, fontsize=20) # Plot sensor positions. axis.scatter( self.sensor_positions[:, 0], self.sensor_positions[:, 1], c="k", s=5, ) if show_deleted_sensors: deleted_positions = self.layout.loc[ ~self.layout["present"], ["x", "y"] ].to_numpy() axis.scatter(*deleted_positions.T, color="tab:red", zorder=100) fig.patch.set_facecolor("white") return fig