diff --git a/src/nwb2bids/bids_models/_channels.py b/src/nwb2bids/bids_models/_channels.py index 433d9faa..20321c81 100644 --- a/src/nwb2bids/bids_models/_channels.py +++ b/src/nwb2bids/bids_models/_channels.py @@ -9,6 +9,7 @@ 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 @@ -40,26 +41,149 @@ def _infer_scalar_field( class Channel(BaseMetadataModel): - name: str - electrode_name: str - type: str = "n/a" - 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_channel: str | None = None - ground: str | None = None - recording_mode: str | None = None + name: str = pydantic.Field( + description="Label of the channel.", + title="Channel name", + ) + electrode_name: str = pydantic.Field( + description=( + "Name of the electrode contact point. The value MUST match a name entry in the corresponding " + "*_electrodes.tsv file, linking this channel to its associated electrode contact point. " + "For channels not associated with an electrode, use n/a." + ), + title="Electrode name", + ) + type: str = pydantic.Field( + description=( + "Type of channel; MUST use the channel types listed below. Note that the type MUST be in upper-case." + ), + title="Type", + default="n/a", + ) + units: str = pydantic.Field( + description=( + "Physical unit of the value represented in this channel, for example, V for Volt, or fT/cm for femto Tesla " + "per centimeter (see Units)." + ), + title="Units", + default="V", + ) + sampling_frequency: float | None = pydantic.Field( + description="Sampling rate of the channel in Hz.", + title="Sampling frequency", + default=None, + ) + low_cutoff: float | None = pydantic.Field( + description=( + "Frequencies used for the high-pass filter applied to the channel in Hz. If no high-pass " + "filter applied, use n/a." + ), + title="Low cutoff", + default=None, + ) + high_cutoff: float | None = pydantic.Field( + description=( + "Frequencies used for the low-pass filter applied to the channel in Hz. " + "If no low-pass filter applied, use n/a. Note that hardware anti-aliasing in A/D conversion of " + "all MEG/EEG/EMG electronics applies a low-pass filter; specify its frequency here if applicable." + ), + title="High cutoff", + default=None, + ) + reference: str | None = pydantic.Field( + description=( + "The reference for the given channel. When the reference is an electrode in *_electrodes.tsv, " + 'use the name of that electrode. If a corresponding electrode is not applicable, use "n/a".' + ), + title="Reference", + default=None, + ) + notch: str | None = pydantic.Field( + description=( + "Frequencies used for the notch filter applied to the channel, in Hz. " + "If notch filters are applied at multiple frequencies, these frequencies MAY be specified as a list, " + "for example, [60, 120, 180]. If no notch filter was applied, use n/a." + ), + title="Notch", + default=None, + ) + channel_label: str | None = pydantic.Field( + description=( + "Human readable identifier. Use this name to specify the content of signals not generated by electrodes. " + "For example, 'DAQ internal synchronization signals', 'behavioral signals', 'behavioral cues'." + ), + title="Channel label", + default=None, + ) + stream_id: str | None = pydantic.Field( + description="Data stream of the recording the signal.", + title="Stream ID", + default=None, + ) + description: str | None = pydantic.Field( + description="Brief free-text description of the channel, or other information of interest.", + title="Description", + default=None, + ) + software_filter_types: str | None = pydantic.Field( + description=( + "The types of software filters applied to this channel. " + "The Levels for this column SHOULD be defined in the accompanying *_channels.json file, " + "mapping each filter type key to its description. Use n/a if no software filters were " + "applied to this channel." + ), + title="Software filter types", + default=None, + ) + status: typing.Literal["good", "bad", "n/a"] | None = pydantic.Field( + description=( + "Data quality observed on the channel. " + "A channel is considered bad if its data quality is compromised by excessive noise. " + "If quality is unknown, then a value of n/a may be used. Description of noise type SHOULD " + "be provided in [status_description]." + ), + title="Status", + default=None, + ) + status_description: str | None = pydantic.Field( + description=( + "Freeform text description of noise or artifact affecting data quality on the channel. " + "It is meant to explain why the channel was declared bad in the status column." + ), + title="Status description", + default=None, + ) + gain: float | None = pydantic.Field( + description=( + "Amplification factor applied from signal detection at the electrode to the signal stored in the data file." + " If no gain factor is provided it is assumed to be 1." + ), + title="Gain", + default=None, + ) + time_offset: float | None = pydantic.Field( + description="Time shift between signal of this channel to a reference channel in seconds.", + title="Time offset", + default=None, + ) + time_reference_channel: str | None = pydantic.Field( + description="Name of the channel that is used for time alignment of signals.", + title="Time reference channel", + default=None, + ) + ground: str | None = pydantic.Field( + description=( + "Information on the ground. For example, 'chamber screw', 'head post', 'ear clip'. " + "Only should be used to optionally override the global ground in the _ecephys.json or _icephys.json file." + ), + title="Ground", + default=None, + ) + recording_mode: str | None = pydantic.Field( + description="The mode of recording for patch clamp datasets (for example, voltage clamp, current clamp).", + title="Recording mode", + default=None, + ) class ChannelTable(BaseMetadataContainerModel): @@ -246,9 +370,9 @@ def to_json(self, file_path: str | pathlib.Path) -> None: file_path : path The path to the output JSON file. """ + file_path = pathlib.Path(file_path) + + json_content = _build_json_sidecar(models=self.channels) + with file_path.open(mode="w") as file_stream: - json.dump( - obj=dict(), # TODO - fp=file_stream, - indent=4, - ) + json.dump(obj=json_content, fp=file_stream, indent=4) diff --git a/src/nwb2bids/bids_models/_electrodes.py b/src/nwb2bids/bids_models/_electrodes.py index c3ab2448..4729df0d 100644 --- a/src/nwb2bids/bids_models/_electrodes.py +++ b/src/nwb2bids/bids_models/_electrodes.py @@ -8,6 +8,7 @@ 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 @@ -15,21 +16,105 @@ class Electrode(BaseMetadataModel): - name: str - probe_name: str - 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 + 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-