Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions src/nwb2bids/bids_models/_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,26 @@ def _infer_scalar_field(


class Channel(BaseMetadataModel):
channel_name: str
reference: str
name: str
electrode_name: str
type: str = "N/A"
unit: str = "V"
units: str = "V"
sampling_frequency: float | None = None
low_cutoff: float | None = None
high_cutoff: float | None = None
reference: str | None = None
notch: str | None = None
channel_label: str | None = None
stream_id: str | None = None
description: str | None = None
software_filter_types: str | None = None
status: typing.Literal["good", "bad"] | None = None
status_description: str | None = None
gain: float | None = None
time_offset: float | None = None
time_reference_channels: str | None = None
ground: str | None = None
# recording_mode: str | None = None # TODO: icephys only
recording_mode: str | None = None
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIUC this is only meaninful in icephys, should we keep some comment?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are many fields which are subtly used in only one or the other; these are expressed or inferred below in the initializer from NWB contents but not validated outside that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(So I'd rather not leave comments and do something more formal in a follow-up if that is something you feel strongly about checking)



class ChannelTable(BaseMetadataContainerModel):
Expand Down Expand Up @@ -129,18 +134,14 @@ def from_nwbfiles(cls, nwbfiles: list[pydantic.InstanceOf[pynwb.NWBFile]]) -> ty

channels = [
Channel(
channel_name=(
f"ch{channel_name.values[0]}"
name=(
f"ch{channel_name.values[0].zfill(3)}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably pedantic, but if there were a non-numeric channel name, should we still do this? ie "A1" -> "ch0A1"? Should we validate that its only digits? And is 3 digits enough to be future proof?

Copy link
Collaborator Author

@CodyCBakerPhD CodyCBakerPhD Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough in the first case of the ternary

The second case however, is guaranteed to be an integer, and zfill 3 simply matches the convention of the BEP examples (beyond that number of digits can happen in modern multi-probe Neuropixels, but I think at that point it looks fine, e.g., ch1024)

if (channel_name := electrode.get("channel_name", None)) is not None
else f"ch{electrode.index[0]}"
),
reference=(
f"contact{contact_ids.values[0]}" # TODO: do a deep dive into edge cases of this reference
if (contact_ids := electrode.get("contact_ids", None)) is not None
else f"e{electrode.index[0]}"
else f"ch{str(electrode.index[0]).zfill(3)}"
),
electrode_name=f"e{str(electrode.index[0]).zfill(3)}",
type="N/A", # TODO: in dedicated follow-up, could classify LFP based on container
unit="V",
units="V",
sampling_frequency=sampling_frequency,
# channel_label: str | None = None # TODO: only support with additional metadata
stream_id=stream_id,
Expand All @@ -161,7 +162,6 @@ def from_nwbfiles(cls, nwbfiles: list[pydantic.InstanceOf[pynwb.NWBFile]]) -> ty
for neurodata_object in nwbfile.acquisition.values()
if isinstance(neurodata_object, pynwb.icephys.PatchClampSeries)
]
# TODO: handle intracellular_recordings case
electrode_name_to_series = collections.defaultdict(list)
for series in icephys_series:
electrode_name_to_series[series.electrode.name].append(series)
Expand Down Expand Up @@ -197,18 +197,17 @@ def from_nwbfiles(cls, nwbfiles: list[pydantic.InstanceOf[pynwb.NWBFile]]) -> ty

channels = [
Channel(
channel_name=electrode.name,
reference="n/a", # TODO: think about if/how this could be any other value
name=electrode.name,
electrode_name=electrode.name,
type=electrode_name_to_type.get(electrode.name, "n/a"),
unit="V",
units="V",
sampling_frequency=electrode_name_to_sampling_frequency.get(electrode.name, None),
# channel_label: str | None = None # TODO: only support with additional metadata
stream_id=electrode_name_to_stream_ids.get(electrode.name, None),
# description: str | None = None # TODO: only support with additional metadata
# status: typing.Literal["good", "bad"] | None = None # TODO: only support with additional metadata
# status_description: str | None = None # TODO: only support with additional metadata
gain=electrode_name_to_gain.get(electrode.name, None),
# time_offset=
# time_reference_channels: str | None = None # TODO: only support with additional metadata
# ground: str | None = None # TODO: only support with additional metadata
recording_mode=type_to_recording_mode[electrode_name_to_type.get(electrode.name, "n/a")],
Expand Down
14 changes: 9 additions & 5 deletions src/nwb2bids/bids_models/_electrodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,19 @@
class Electrode(BaseMetadataModel):
name: str
probe_name: str
hemisphere: str = "N/A"
x: float = numpy.nan
y: float = numpy.nan
z: float = numpy.nan
hemisphere: str = "N/A"
impedance: float = numpy.nan # in kOhms
shank_id: str = "N/A"
size: float | None = None # in square micrometers
electrode_shape: str | None = None
material: str | None = None
location: str | None = None
pipette_solution: str | None = None
internal_pipette_diameter: float | None = None # in micrometers
external_pipette_diameter: float | None = None # in micrometers

def __eq__(self, other: typing_extensions.Self) -> bool:
if not isinstance(other, Electrode):
Expand Down Expand Up @@ -122,15 +128,13 @@ def from_nwbfiles(cls, nwbfiles: list[pydantic.InstanceOf[pynwb.NWBFile]]) -> ty
x=getattr(electrode, "x", numpy.nan),
y=getattr(electrode, "y", numpy.nan),
z=getattr(electrode, "z", numpy.nan),
# impedance= # Impedance must be in kOhms for BEP32 but NWB specifies Ohms
# shank_id=
# TODO: pretty much only through additional metadata
# impedance=
# size=
# electrode_shape=
# material=
# location=
location=getattr(electrode, "location", None),
# TODO: some icephys specific ones (would NOT use the ecephys electrode table anyway...)
# Probably better off in a designated model
# pipette_solution=
# internal_pipette_diameter=
# external_pipette_diameter=
Expand Down
22 changes: 21 additions & 1 deletion src/nwb2bids/bids_models/_probes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,27 @@
class Probe(BaseMetadataModel):
probe_name: str
type: str = "n/a"
AP: float | None = None
ML: float | None = None
DV: float | None = None
AP_angle: float | None = None
ML_angle: float | None = None
manufacturer: str | None = None
model: str | None = None
device_serial_number: str | None = None
electrode_count: int | None = None
width: float | None = None # in millimeters
height: float | None = None # in millimeters
depth: float | None = None
rotation_angle: float | None = None
coordinate_reference_point: str | None = None
anatomical_reference_point: str | None = None
hemisphere: typing.Literal["L", "R"] | None = None
associated_brain_region: str | None = None
associated_brain_region_id: str | None = None
associated_brain_region_quality_type: str | None = None
reference_atlas: str | None = None
material: str | None = None


class ProbeTable(BaseMetadataContainerModel):
Expand Down Expand Up @@ -89,7 +109,7 @@ def from_nwbfiles(cls, nwbfiles: list[pydantic.InstanceOf[pynwb.NWBFile]]) -> ty
probes = [
Probe(
probe_name=device.name,
type="n/a", # TODO
type="n/a", # TODO via additional metadata
manufacturer=device.manufacturer,
description=device.description,
# TODO: handle more extra custom columns
Expand Down
3 changes: 3 additions & 0 deletions src/nwb2bids/testing/_mocks/_tutorials.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,19 @@ def _generate_icephys_file(*, nwbfile_path: pathlib.Path, subject_id: str = "001
name="patch01",
description="This is an example icephys electrode used for demonstration purposes.",
device=probe1,
location="VISp2/3",
)
electrode2 = nwbfile.create_icephys_electrode(
name="patch02",
description="This is an example icephys electrode used for demonstration purposes.",
device=probe2,
location="VISp2/3",
)
electrode3 = nwbfile.create_icephys_electrode(
name="sharp01",
description="This is an example icephys electrode used for demonstration purposes.",
device=probe3,
location="PL5",
)

# Icephys series
Expand Down
40 changes: 20 additions & 20 deletions tests/integration/test_convert_nwb_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ def test_ecephys_tutorial_convert_nwb_dataset(
)
electrodes_tsv_lines = electrodes_tsv_file_path.read_text().splitlines()
expected_electrodes_tsv_lines = [
"name\tprobe_name\themisphere\tx\ty\tz\timpedance\tshank_id\tlocation",
"name\tprobe_name\tx\ty\tz\themisphere\timpedance\tshank_id\tlocation",
"e000\tExampleProbe\tN/A\tN/A\tN/A\tN/A\t150.0\tExampleShank\thippocampus",
"e001\tExampleProbe\tN/A\tN/A\tN/A\tN/A\t150.0\tExampleShank\thippocampus",
"e002\tExampleProbe\tN/A\tN/A\tN/A\tN/A\t150.0\tExampleShank\thippocampus",
Expand All @@ -167,15 +167,15 @@ def test_ecephys_tutorial_convert_nwb_dataset(
channels_tsv_file_path = temporary_bids_directory / "sub-001" / "ses-A" / "ecephys" / "sub-001_ses-A_channels.tsv"
channels_tsv_lines = channels_tsv_file_path.read_text().splitlines()
expected_channels_tsv_lines = [
"channel_name\treference\ttype\tunit\tsampling_frequency\tstream_id\tgain",
"ch0\te0\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch1\te1\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch2\te2\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch3\te3\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch4\te4\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch5\te5\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch6\te6\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch7\te7\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"name\telectrode_name\ttype\tunits\tsampling_frequency\tstream_id\tgain",
"ch000\te000\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch001\te001\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch002\te002\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch003\te003\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch004\te004\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch005\te005\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch006\te006\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
"ch007\te007\tN/A\tV\t30000.0\tExampleElectricalSeries\t3.02734375e-06",
]
assert channels_tsv_lines == expected_channels_tsv_lines

Expand Down Expand Up @@ -237,7 +237,7 @@ def test_ecephys_minimal_convert_nwb_dataset(
)
electrodes_tsv_lines = electrodes_tsv_file_path.read_text().splitlines()
expected_electrodes_tsv_lines = [
"name\tprobe_name\themisphere\tx\ty\tz\timpedance\tshank_id\tlocation",
"name\tprobe_name\tx\ty\tz\themisphere\timpedance\tshank_id\tlocation",
"e000\tExampleProbe\tN/A\tN/A\tN/A\tN/A\tN/A\tExampleShank\tunknown",
"e001\tExampleProbe\tN/A\tN/A\tN/A\tN/A\tN/A\tExampleShank\tunknown",
"e002\tExampleProbe\tN/A\tN/A\tN/A\tN/A\tN/A\tExampleShank\tunknown",
Expand All @@ -252,15 +252,15 @@ def test_ecephys_minimal_convert_nwb_dataset(
channels_tsv_file_path = temporary_bids_directory / "sub-001" / "ses-A" / "ecephys" / "sub-001_ses-A_channels.tsv"
channels_tsv_lines = channels_tsv_file_path.read_text().splitlines()
expected_channels_tsv_lines = [
"channel_name\treference\ttype\tunit",
"ch0\te0\tN/A\tV",
"ch1\te1\tN/A\tV",
"ch2\te2\tN/A\tV",
"ch3\te3\tN/A\tV",
"ch4\te4\tN/A\tV",
"ch5\te5\tN/A\tV",
"ch6\te6\tN/A\tV",
"ch7\te7\tN/A\tV",
"name\telectrode_name\ttype\tunits",
"ch000\te000\tN/A\tV",
"ch001\te001\tN/A\tV",
"ch002\te002\tN/A\tV",
"ch003\te003\tN/A\tV",
"ch004\te004\tN/A\tV",
"ch005\te005\tN/A\tV",
"ch006\te006\tN/A\tV",
"ch007\te007\tN/A\tV",
]
assert channels_tsv_lines == expected_channels_tsv_lines

Expand Down
6 changes: 3 additions & 3 deletions tests/unit/test_remote_dataset_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ def test_remote_dataset_converter_metadata_extraction(temporary_bids_directory:
assert session_metadata.channel_table is not None
assert len(session_metadata.channel_table.channels) == 65
assert session_metadata.channel_table.channels[0] == nwb2bids.bids_models.Channel(
channel_name="ch0",
reference="e0",
name="ch000",
electrode_name="e000",
type="N/A",
unit="V",
units="V",
sampling_frequency=1250.0,
stream_id="LFP",
gain=1.9499999999999999e-07,
Expand Down
Loading