Source code for nwb2bids.bids_models._electrodes

import json
import pathlib
import typing

import numpy
import pandas
import pydantic
import pynwb
import typing_extensions

from ._model_utils import _build_json_sidecar
from ..bids_models._base_metadata_model import BaseMetadataContainerModel, BaseMetadataModel
from ..notifications import Notification

_NULL_LOCATION_PLACEHOLDERS = {"", "unknown", "no location", "N/A"}


[docs] class Electrode(BaseMetadataModel): name: str = pydantic.Field( description="Name of the electrode contact point.", title="Electrode name", ) probe_name: str = pydantic.Field( description=( "A unique identifier of the probe, can be identical with the device_serial_number. " "The value MUST match a probe_name entry in the corresponding *_probes.tsv file, linking this electrode " "to its associated probe. For electrodes not associated with a probe, use n/a." ), title="Probe name", ) x: float = pydantic.Field( description=( "Recorded position along the x-axis. When no space-<label> entity is used in the filename, " "the position along the local width-axis relative to the probe origin " "(see coordinate_reference_point in *_probes.tsv) in micrometers (um). " "When a space-<label> entity is used in the filename, the position relative to the origin of the " "coordinate system along the first axis. Units are specified by MicroephysCoordinateUnits in the " "corresponding *_coordsystem.json file." ), title="x", default=numpy.nan, ) y: float = pydantic.Field( description=( "Recorded position along the y-axis. When no space-<label> entity is used in the filename, " "the position along the local height-axis relative to the probe origin " "(see coordinate_reference_point in *_probes.tsv) in micrometers (um). " "When a space-<label> entity is used in the filename, the position relative to the origin of the " "coordinate system along the second axis. Units are specified by MicroephysCoordinateUnits in the " "corresponding *_coordsystem.json file." ), title="y", default=numpy.nan, ) z: float = pydantic.Field( description=( "Recorded position along the z-axis. For 2D electrode localizations, " "this SHOULD be a column of n/a values. " "When no space-<label> entity is used in the filename, the position along the local depth-axis relative to " "the probe origin (see coordinate_reference_point in *_probes.tsv) in micrometers (um). " "When a space-<label> entity is used in the filename, the position relative to the origin of the " "coordinate system along the third axis. Units are specified by MicroephysCoordinateUnits in the " "corresponding *_coordsystem.json file. For 2D electrode localizations (for example, when the " "coordinate system is Pixels), this SHOULD be a column of n/a values." ), title="z", default=numpy.nan, ) hemisphere: typing.Literal["L", "R", "n/a"] = pydantic.Field( description="The hemisphere in which the electrode is placed.", title="Hemisphere", default="n/a" ) impedance: float = pydantic.Field( description="Impedance of the electrode, units MUST be in kOhm.", title="Impedance", default=numpy.nan ) shank_id: str = pydantic.Field( description=( "A unique identifier to specify which shank of the probe the electrode is on. " "This is useful for spike sorting when the electrodes are on a multi-shank probe." ), title="Shank ID", default="n/a", ) size: float | None = pydantic.Field( description="Surface area of the electrode, units MUST be in um^2.", title="Size", default=None, ) electrode_shape: str | None = pydantic.Field( description="Description of the shape of the electrode (for example, square, circle).", title="Electrode shape", default=None, ) material: str | None = pydantic.Field( description="Material of the electrode (for example, Tin, Ag/AgCl, Gold).", title="Material", default=None, ) location: str | None = pydantic.Field( description="An indication on the location of the electrode (for example, cortical layer 3, CA1).", title="Location", default=None, ) pipette_solution: str | None = pydantic.Field( description="The solution used to fill the pipette.", title="Pipette solution", default=None, ) internal_pipette_diameter: float | None = pydantic.Field( description="The internal diameter of the pipette in micrometers.", title="Internal pipette diameter", default=None, ) external_pipette_diameter: float | None = pydantic.Field( description="The external diameter of the pipette in micrometers.", title="External pipette diameter", default=None, ) def __eq__(self, other: typing.Any) -> bool: if not isinstance(other, Electrode): return False self_dump = self.model_dump() other_dump = other.model_dump() if self_dump.keys() != other_dump.keys(): return False for key in self_dump: self_val = self_dump[key] other_val = other_dump[key] # Handle NaN comparison (NaN == NaN should be true) if isinstance(self_val, float) and isinstance(other_val, float): if numpy.isnan(self_val) and numpy.isnan(other_val): continue if self_val != other_val: return False return True
[docs] class ElectrodeTable(BaseMetadataContainerModel): electrodes: list[Electrode] modality: typing.Literal["ecephys", "icephys"] @pydantic.computed_field @property def notifications(self) -> list[Notification]: """ All notifications from contained session converters. These can accumulate over time based on which instance methods have been called. """ notifications = [notification for electrode in self.electrodes for notification in electrode.notifications] notifications.sort( key=lambda notification: (-notification.category.value, -notification.severity.value, notification.title) ) return notifications
[docs] @classmethod @pydantic.validate_call def from_nwbfiles(cls, nwbfiles: list[pydantic.InstanceOf[pynwb.NWBFile]]) -> typing_extensions.Self | None: if len(nwbfiles) > 1: message = "Conversion of multiple NWB files per session is not yet supported." raise NotImplementedError(message) nwbfile = nwbfiles[0] has_ecephys_electrodes = nwbfile.electrodes is not None has_icephys_electrodes = any(nwbfile.icephys_electrodes) if not has_ecephys_electrodes and not has_icephys_electrodes: return None if has_ecephys_electrodes and has_icephys_electrodes: message = ( "Converting electrode metadata when there are both ecephys and icephys types " "has not yet been implemented." ) raise NotImplementedError(message) modality = "ecephys" if has_ecephys_electrodes else "icephys" if modality == "ecephys": electrodes = [ Electrode( # Leading 'e' and padding are by convention from BEP32 examples name=f"e{str(electrode.index[0]).zfill(3)}", probe_name=electrode.group.iloc[0].device.name, # TODO: hemisphere determination from additional metadata or possible lookup from a location map x=electrode.x.iloc[0] if "x" in electrode else numpy.nan, y=electrode.y.iloc[0] if "y" in electrode else numpy.nan, z=electrode.z.iloc[0] if "z" in electrode else numpy.nan, # Impedance must be in kOhms for BEP32 but NWB specifies Ohms impedance=( eimp_in_ohms / 1e3 if "imp" in electrode and not pandas.isna(eimp_in_ohms := electrode.imp.iloc[0]) else numpy.nan ), shank_id=electrode.group.iloc[0].name, # TODO: pretty much only through additional metadata # size= # electrode_shape= # material= location=( "n/a" if (val := str(electrode.location.values[0])) in _NULL_LOCATION_PLACEHOLDERS else val ), # TODO: add extra columns ) for electrode in nwbfile.electrodes ] else: electrodes = [ Electrode( name=electrode.name, probe_name=electrode.device.name, # TODO: hemisphere, location, impedance may all have to be specified through custom columns x=getattr(electrode, "x", numpy.nan), y=getattr(electrode, "y", numpy.nan), z=getattr(electrode, "z", numpy.nan), # TODO: pretty much only through additional metadata # impedance= # size= # electrode_shape= # material= location=getattr(electrode, "location", None), # TODO: some icephys specific ones (would NOT use the ecephys electrode table anyway...) # pipette_solution= # internal_pipette_diameter= # external_pipette_diameter= # TODO: add extra columns ) for electrode in nwbfile.icephys_electrodes.values() ] return cls(electrodes=electrodes, modality=modality)
[docs] @pydantic.validate_call def to_tsv(self, file_path: str | pathlib.Path) -> None: """ Write the electrode data to a TSV file. Parameters ---------- file_path : path The path to the output TSV file. """ data = [] for electrode in self.electrodes: model_dump = electrode.model_dump() data.append(model_dump) data_frame = pandas.DataFrame(data=data) # Many columns are 'required' by BEP32 but are not always present in the source files or known at all required_columns = ["x", "y", "z", "impedance"] for column in required_columns: data_frame[column] = data_frame[column].fillna(value="n/a") data_frame = data_frame.dropna(axis=1, how="all") data_frame.to_csv(file_path, sep="\t", index=False)
[docs] @pydantic.validate_call def to_json(self, file_path: str | pathlib.Path) -> None: """ Save the electrode information to a JSON file. Parameters ---------- file_path : path The path to the output JSON file. """ file_path = pathlib.Path(file_path) json_content = _build_json_sidecar(models=self.electrodes) if "hemisphere" in json_content: json_content["hemisphere"]["Levels"] = {"L": "left", "R": "right"} with file_path.open(mode="w") as file_stream: json.dump(obj=json_content, fp=file_stream, indent=4)