Skip to content

Commit 18c3d0e

Browse files
Populated JSON sidecars (#301)
* add JSON for probes * add JSON for electrodes and channels * add comments * pr review * pr review * Apply suggestions from code review Co-authored-by: Austin Macdonald <austin@dartmouth.edu> * pr review * pr review * fix pre-commit --------- Co-authored-by: Austin Macdonald <austin@dartmouth.edu>
1 parent ce7ff3b commit 18c3d0e

6 files changed

Lines changed: 610 additions & 76 deletions

File tree

src/nwb2bids/bids_models/_channels.py

Lines changed: 149 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pynwb
1010
import typing_extensions
1111

12+
from ._model_utils import _build_json_sidecar
1213
from ..bids_models._base_metadata_model import BaseMetadataContainerModel, BaseMetadataModel
1314
from ..notifications import Notification
1415

@@ -40,26 +41,149 @@ def _infer_scalar_field(
4041

4142

4243
class Channel(BaseMetadataModel):
43-
name: str
44-
electrode_name: str
45-
type: str = "n/a"
46-
units: str = "V"
47-
sampling_frequency: float | None = None
48-
low_cutoff: float | None = None
49-
high_cutoff: float | None = None
50-
reference: str | None = None
51-
notch: str | None = None
52-
channel_label: str | None = None
53-
stream_id: str | None = None
54-
description: str | None = None
55-
software_filter_types: str | None = None
56-
status: typing.Literal["good", "bad"] | None = None
57-
status_description: str | None = None
58-
gain: float | None = None
59-
time_offset: float | None = None
60-
time_reference_channel: str | None = None
61-
ground: str | None = None
62-
recording_mode: str | None = None
44+
name: str = pydantic.Field(
45+
description="Label of the channel.",
46+
title="Channel name",
47+
)
48+
electrode_name: str = pydantic.Field(
49+
description=(
50+
"Name of the electrode contact point. The value MUST match a name entry in the corresponding "
51+
"*_electrodes.tsv file, linking this channel to its associated electrode contact point. "
52+
"For channels not associated with an electrode, use n/a."
53+
),
54+
title="Electrode name",
55+
)
56+
type: str = pydantic.Field(
57+
description=(
58+
"Type of channel; MUST use the channel types listed below. Note that the type MUST be in upper-case."
59+
),
60+
title="Type",
61+
default="n/a",
62+
)
63+
units: str = pydantic.Field(
64+
description=(
65+
"Physical unit of the value represented in this channel, for example, V for Volt, or fT/cm for femto Tesla "
66+
"per centimeter (see Units)."
67+
),
68+
title="Units",
69+
default="V",
70+
)
71+
sampling_frequency: float | None = pydantic.Field(
72+
description="Sampling rate of the channel in Hz.",
73+
title="Sampling frequency",
74+
default=None,
75+
)
76+
low_cutoff: float | None = pydantic.Field(
77+
description=(
78+
"Frequencies used for the high-pass filter applied to the channel in Hz. If no high-pass "
79+
"filter applied, use n/a."
80+
),
81+
title="Low cutoff",
82+
default=None,
83+
)
84+
high_cutoff: float | None = pydantic.Field(
85+
description=(
86+
"Frequencies used for the low-pass filter applied to the channel in Hz. "
87+
"If no low-pass filter applied, use n/a. Note that hardware anti-aliasing in A/D conversion of "
88+
"all MEG/EEG/EMG electronics applies a low-pass filter; specify its frequency here if applicable."
89+
),
90+
title="High cutoff",
91+
default=None,
92+
)
93+
reference: str | None = pydantic.Field(
94+
description=(
95+
"The reference for the given channel. When the reference is an electrode in *_electrodes.tsv, "
96+
'use the name of that electrode. If a corresponding electrode is not applicable, use "n/a".'
97+
),
98+
title="Reference",
99+
default=None,
100+
)
101+
notch: str | None = pydantic.Field(
102+
description=(
103+
"Frequencies used for the notch filter applied to the channel, in Hz. "
104+
"If notch filters are applied at multiple frequencies, these frequencies MAY be specified as a list, "
105+
"for example, [60, 120, 180]. If no notch filter was applied, use n/a."
106+
),
107+
title="Notch",
108+
default=None,
109+
)
110+
channel_label: str | None = pydantic.Field(
111+
description=(
112+
"Human readable identifier. Use this name to specify the content of signals not generated by electrodes. "
113+
"For example, 'DAQ internal synchronization signals', 'behavioral signals', 'behavioral cues'."
114+
),
115+
title="Channel label",
116+
default=None,
117+
)
118+
stream_id: str | None = pydantic.Field(
119+
description="Data stream of the recording the signal.",
120+
title="Stream ID",
121+
default=None,
122+
)
123+
description: str | None = pydantic.Field(
124+
description="Brief free-text description of the channel, or other information of interest.",
125+
title="Description",
126+
default=None,
127+
)
128+
software_filter_types: str | None = pydantic.Field(
129+
description=(
130+
"The types of software filters applied to this channel. "
131+
"The Levels for this column SHOULD be defined in the accompanying *_channels.json file, "
132+
"mapping each filter type key to its description. Use n/a if no software filters were "
133+
"applied to this channel."
134+
),
135+
title="Software filter types",
136+
default=None,
137+
)
138+
status: typing.Literal["good", "bad", "n/a"] | None = pydantic.Field(
139+
description=(
140+
"Data quality observed on the channel. "
141+
"A channel is considered bad if its data quality is compromised by excessive noise. "
142+
"If quality is unknown, then a value of n/a may be used. Description of noise type SHOULD "
143+
"be provided in [status_description]."
144+
),
145+
title="Status",
146+
default=None,
147+
)
148+
status_description: str | None = pydantic.Field(
149+
description=(
150+
"Freeform text description of noise or artifact affecting data quality on the channel. "
151+
"It is meant to explain why the channel was declared bad in the status column."
152+
),
153+
title="Status description",
154+
default=None,
155+
)
156+
gain: float | None = pydantic.Field(
157+
description=(
158+
"Amplification factor applied from signal detection at the electrode to the signal stored in the data file."
159+
" If no gain factor is provided it is assumed to be 1."
160+
),
161+
title="Gain",
162+
default=None,
163+
)
164+
time_offset: float | None = pydantic.Field(
165+
description="Time shift between signal of this channel to a reference channel in seconds.",
166+
title="Time offset",
167+
default=None,
168+
)
169+
time_reference_channel: str | None = pydantic.Field(
170+
description="Name of the channel that is used for time alignment of signals.",
171+
title="Time reference channel",
172+
default=None,
173+
)
174+
ground: str | None = pydantic.Field(
175+
description=(
176+
"Information on the ground. For example, 'chamber screw', 'head post', 'ear clip'. "
177+
"Only should be used to optionally override the global ground in the _ecephys.json or _icephys.json file."
178+
),
179+
title="Ground",
180+
default=None,
181+
)
182+
recording_mode: str | None = pydantic.Field(
183+
description="The mode of recording for patch clamp datasets (for example, voltage clamp, current clamp).",
184+
title="Recording mode",
185+
default=None,
186+
)
63187

64188

65189
class ChannelTable(BaseMetadataContainerModel):
@@ -246,9 +370,9 @@ def to_json(self, file_path: str | pathlib.Path) -> None:
246370
file_path : path
247371
The path to the output JSON file.
248372
"""
373+
file_path = pathlib.Path(file_path)
374+
375+
json_content = _build_json_sidecar(models=self.channels)
376+
249377
with file_path.open(mode="w") as file_stream:
250-
json.dump(
251-
obj=dict(), # TODO
252-
fp=file_stream,
253-
indent=4,
254-
)
378+
json.dump(obj=json_content, fp=file_stream, indent=4)

src/nwb2bids/bids_models/_electrodes.py

Lines changed: 106 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,113 @@
88
import pynwb
99
import typing_extensions
1010

11+
from ._model_utils import _build_json_sidecar
1112
from ..bids_models._base_metadata_model import BaseMetadataContainerModel, BaseMetadataModel
1213
from ..notifications import Notification
1314

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

1617

1718
class Electrode(BaseMetadataModel):
18-
name: str
19-
probe_name: str
20-
x: float = numpy.nan
21-
y: float = numpy.nan
22-
z: float = numpy.nan
23-
hemisphere: str = "n/a"
24-
impedance: float = numpy.nan # in kOhms
25-
shank_id: str = "n/a"
26-
size: float | None = None # in square micrometers
27-
electrode_shape: str | None = None
28-
material: str | None = None
29-
location: str | None = None
30-
pipette_solution: str | None = None
31-
internal_pipette_diameter: float | None = None # in micrometers
32-
external_pipette_diameter: float | None = None # in micrometers
19+
name: str = pydantic.Field(
20+
description="Name of the electrode contact point.",
21+
title="Electrode name",
22+
)
23+
probe_name: str = pydantic.Field(
24+
description=(
25+
"A unique identifier of the probe, can be identical with the device_serial_number. "
26+
"The value MUST match a probe_name entry in the corresponding *_probes.tsv file, linking this electrode "
27+
"to its associated probe. For electrodes not associated with a probe, use n/a."
28+
),
29+
title="Probe name",
30+
)
31+
x: float = pydantic.Field(
32+
description=(
33+
"Recorded position along the x-axis. When no space-<label> entity is used in the filename, "
34+
"the position along the local width-axis relative to the probe origin "
35+
"(see coordinate_reference_point in *_probes.tsv) in micrometers (um). "
36+
"When a space-<label> entity is used in the filename, the position relative to the origin of the "
37+
"coordinate system along the first axis. Units are specified by MicroephysCoordinateUnits in the "
38+
"corresponding *_coordsystem.json file."
39+
),
40+
title="x",
41+
default=numpy.nan,
42+
)
43+
y: float = pydantic.Field(
44+
description=(
45+
"Recorded position along the y-axis. When no space-<label> entity is used in the filename, "
46+
"the position along the local height-axis relative to the probe origin "
47+
"(see coordinate_reference_point in *_probes.tsv) in micrometers (um). "
48+
"When a space-<label> entity is used in the filename, the position relative to the origin of the "
49+
"coordinate system along the second axis. Units are specified by MicroephysCoordinateUnits in the "
50+
"corresponding *_coordsystem.json file."
51+
),
52+
title="y",
53+
default=numpy.nan,
54+
)
55+
z: float = pydantic.Field(
56+
description=(
57+
"Recorded position along the z-axis. For 2D electrode localizations, "
58+
"this SHOULD be a column of n/a values. "
59+
"When no space-<label> entity is used in the filename, the position along the local depth-axis relative to "
60+
"the probe origin (see coordinate_reference_point in *_probes.tsv) in micrometers (um). "
61+
"When a space-<label> entity is used in the filename, the position relative to the origin of the "
62+
"coordinate system along the third axis. Units are specified by MicroephysCoordinateUnits in the "
63+
"corresponding *_coordsystem.json file. For 2D electrode localizations (for example, when the "
64+
"coordinate system is Pixels), this SHOULD be a column of n/a values."
65+
),
66+
title="z",
67+
default=numpy.nan,
68+
)
69+
hemisphere: typing.Literal["L", "R", "n/a"] = pydantic.Field(
70+
description="The hemisphere in which the electrode is placed.", title="Hemisphere", default="n/a"
71+
)
72+
impedance: float = pydantic.Field(
73+
description="Impedance of the electrode, units MUST be in kOhm.", title="Impedance", default=numpy.nan
74+
)
75+
shank_id: str = pydantic.Field(
76+
description=(
77+
"A unique identifier to specify which shank of the probe the electrode is on. "
78+
"This is useful for spike sorting when the electrodes are on a multi-shank probe."
79+
),
80+
title="Shank ID",
81+
default="n/a",
82+
)
83+
size: float | None = pydantic.Field(
84+
description="Surface area of the electrode, units MUST be in um^2.",
85+
title="Size",
86+
default=None,
87+
)
88+
electrode_shape: str | None = pydantic.Field(
89+
description="Description of the shape of the electrode (for example, square, circle).",
90+
title="Electrode shape",
91+
default=None,
92+
)
93+
material: str | None = pydantic.Field(
94+
description="Material of the electrode (for example, Tin, Ag/AgCl, Gold).",
95+
title="Material",
96+
default=None,
97+
)
98+
location: str | None = pydantic.Field(
99+
description="An indication on the location of the electrode (for example, cortical layer 3, CA1).",
100+
title="Location",
101+
default=None,
102+
)
103+
pipette_solution: str | None = pydantic.Field(
104+
description="The solution used to fill the pipette.",
105+
title="Pipette solution",
106+
default=None,
107+
)
108+
internal_pipette_diameter: float | None = pydantic.Field(
109+
description="The internal diameter of the pipette in micrometers.",
110+
title="Internal pipette diameter",
111+
default=None,
112+
)
113+
external_pipette_diameter: float | None = pydantic.Field(
114+
description="The external diameter of the pipette in micrometers.",
115+
title="External pipette diameter",
116+
default=None,
117+
)
33118

34119
def __eq__(self, other: typing.Any) -> bool:
35120
if not isinstance(other, Electrode):
@@ -184,9 +269,10 @@ def to_json(self, file_path: str | pathlib.Path) -> None:
184269
"""
185270
file_path = pathlib.Path(file_path)
186271

272+
json_content = _build_json_sidecar(models=self.electrodes)
273+
274+
if "hemisphere" in json_content:
275+
json_content["hemisphere"]["Levels"] = {"L": "left", "R": "right"}
276+
187277
with file_path.open(mode="w") as file_stream:
188-
json.dump(
189-
obj=dict(), # TODO
190-
fp=file_stream,
191-
indent=4,
192-
)
278+
json.dump(obj=json_content, fp=file_stream, indent=4)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import collections
2+
import typing
3+
4+
import pydantic
5+
import pydantic_core
6+
7+
8+
def _get_present_fields(models: typing.Sequence[pydantic.BaseModel]) -> set[str]:
9+
"""Return a set of field names that are present or required in the model."""
10+
present_non_additional_fields = {
11+
field
12+
for model in models
13+
for field, value in model.model_dump().items()
14+
if field in model.model_fields
15+
and (
16+
value is not None
17+
or model.model_fields.get(field, pydantic.fields.FieldInfo()).default is pydantic_core.PydanticUndefined
18+
)
19+
}
20+
return present_non_additional_fields
21+
22+
23+
def _build_json_sidecar(models: typing.Sequence[pydantic.BaseModel]) -> dict[str, dict[str, typing.Any]]:
24+
"""Build a JSON sidecar dictionary from the provided models."""
25+
present_non_additional_fields = _get_present_fields(models=models)
26+
27+
reference_model = list(models)[0]
28+
json_content: dict[str, dict[str, typing.Any]] = collections.defaultdict(dict)
29+
for field in present_non_additional_fields:
30+
if title := getattr(reference_model.model_fields[field], "title"):
31+
json_content[field]["LongName"] = title
32+
if description := getattr(reference_model.model_fields[field], "description"):
33+
json_content[field]["Description"] = description
34+
35+
return json_content

0 commit comments

Comments
 (0)