diff --git a/examples/EIT-16-256-Ch.ipynb b/examples/EIT-16-256-Ch.ipynb index b506d94..b553f88 100644 --- a/examples/EIT-16-256-Ch.ipynb +++ b/examples/EIT-16-256-Ch.ipynb @@ -106,6 +106,7 @@ "setup = EitMeasurementSetup(\n", " burst_count=1,\n", " n_el=n_el,\n", + " # exc_freq={'f_min': 125_000, 'f_max':1_000_000, 'steps':10, 'sweep': \"LOG\"},\n", " exc_freq=125_000,\n", " framerate=3,\n", " amplitude=0.01,\n", @@ -380,7 +381,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "sciopy", "language": "python", "name": "python3" }, diff --git a/examples/ISX-3.ipynb b/examples/ISX-3.ipynb index d91edc8..2be0959 100644 --- a/examples/ISX-3.ipynb +++ b/examples/ISX-3.ipynb @@ -34,6 +34,7 @@ "import cmath\n", "\n", "import matplotlib.pyplot as plt\n", + "import numpy as np\n", "\n", "from sciopy import ISX_3, EisMeasurementSetup, available_serial_ports" ] @@ -373,16 +374,13 @@ }, "outputs": [], "source": [ - "mux_data = {}\n", - "\n", "for name, (counter, reference, working_sense, working) in mux_routings.items():\n", " isx.SetExtensionPortChannel(counter, reference, working_sense, working)\n", - " isx.GetExtensionPortChannel() # Read back the active C/R/WS/W routing.\n", - " points = isx.StartStopMeasurement()\n", - " if not points:\n", - " raise RuntimeError(f\"No measurements received for {name}.\")\n", - " mux_data[name] = points\n", - " print(f\"{name}: received {len(points)} points\")" + "\n", + "points = isx.StartStopMeasurement()\n", + "\n", + "mux_data = {f\"routing {i}\": c\n", + " for i, c in enumerate(np.array_split(points, len( mux_routings.keys())), start=1)}" ] }, { @@ -438,7 +436,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "sciopy", "language": "python", "name": "python3" }, diff --git a/sciopy/EIT_16_32_64_128.py b/sciopy/EIT_16_32_64_128.py index e89af4d..bf20496 100644 --- a/sciopy/EIT_16_32_64_128.py +++ b/sciopy/EIT_16_32_64_128.py @@ -5,6 +5,7 @@ from .com_util import ( clTbt_dp, clTbt_sp, + clTbt_u16, ) import numpy as np @@ -264,32 +265,109 @@ def update_FrameRate(self, framerate): ) self.print_msg = False - def update_ExcitationFrequency(self, exc_freq): + def update_ExcitationFrequencies( + self, + f_min: float, + f_max: float = None, + f_count: int = 1, + f_scale: str = "lin", + ): """ - update_ExcitationFrequencies _summary_ + Configures a single excitation frequency or a full frequency sweep. + + Sends the "Excitation Frequencies" option (0x04) of the "Set + Measurement Setup" command (0xB0), as specified by the Sciospec + System Message Protocol for the EIT-16/32/64/128 device family: + + [CT] 0x0C 0x04 [f_min] [f_max] [f_count] [f_scale] [CT] + + f_min, f_max : IEEE-754 single precision float, big-endian + (4 bytes each). Minimum / maximum excitation + frequency in Hz (100 Hz to 1 MHz). + f_count : unsigned 16-bit integer, big-endian (2 bytes). + Number of frequency points measured between + f_min and f_max (inclusive). f_count=1 selects a + single-frequency measurement at f_min. + f_scale : 1 byte. 0x00 = linear, 0x01 = logarithmic + distribution of the frequency points between + f_min and f_max. Parameters ---------- - exc_freq int - frequency to be set from 100 Hz to 1 MHz - """ + f_min : float + Start (or only) excitation frequency in Hz. + f_max : float, optional + Stop excitation frequency in Hz. Defaults to `f_min`, i.e. a + single-frequency measurement. + f_count : int, optional + Number of frequency points in the sweep. Defaults to 1. + f_scale : str, optional + ``"lin"`` for a linear sweep or ``"log"`` for a logarithmic + sweep. Defaults to ``"lin"``. Ignored when `f_count` is 1. + + Side Effects + ------------ + - Updates `self.setup` (exc_freq, exc_freq_max, n_freq, freq_scale) + and re-syncs the message parser's frame layout, when a + measurement setup is present, so subsequently received frames are + parsed with the right number of frequency points. + - Sends the configuration command to the connected device. + """ + scale_options = {"lin": 0x00, "log": 0x01} + if f_scale not in scale_options: + raise ValueError(f"Unknown f_scale {f_scale!r}; expected 'lin' or 'log'.") + if f_count < 1: + raise ValueError("f_count must be >= 1.") + if f_max is None: + f_max = f_min + if f_count > 1 and f_max <= f_min: + raise ValueError("f_max must be greater than f_min when f_count > 1.") + + if self.setup is not None: + self.setup.exc_freq = f_min + self.setup.exc_freq_max = f_max + self.setup.n_freq = f_count + self.setup.freq_scale = f_scale + if self.cMessageParser is not None: + # Re-sync the parser's frame layout (iNumFreqSettings etc.) + # with the new sweep configuration. + self.cMessageParser.set_measurement_setup(self.setup) + # Set frequencies: - # [CT] 0C 04 [fmin] [fmax] [fcount] [ftype] [CT] + # [CT] 0C 04 [fmin] [fmax] [fcount] [fscale] [CT] self.print_msg = True - f_min = clTbt_sp(exc_freq) - f_max = clTbt_sp(exc_freq) - f_count = [0, 1] - f_type = [0] # linear/log - # bytearray self.write_command_string( bytearray( list( - np.concatenate([[176, 12, 4], f_min, f_max, f_count, f_type, [176]]) + np.concatenate( + [ + [176, 12, 4], + clTbt_sp(f_min), + clTbt_sp(f_max), + clTbt_u16(f_count), + [scale_options[f_scale]], + [176], + ] + ) ) ) ) self.print_msg = False + def update_ExcitationFrequency(self, exc_freq): + """ + Sets a single excitation frequency (no sweep). + + Kept for backward compatibility; equivalent to + ``update_ExcitationFrequencies(f_min=exc_freq, f_count=1)``. + + Parameters + ---------- + exc_freq : int or float + frequency to be set from 100 Hz to 1 MHz + """ + self.update_ExcitationFrequencies(f_min=exc_freq, f_count=1) + def SetMeasurementSetup(self, setup: EitMeasurementSetup): """ Configures the ScioSpec device measurement setup according to the provided EitMeasurementSetup dataclass. @@ -385,19 +463,15 @@ def SetMeasurementSetup(self, setup: EitMeasurementSetup): list(np.concatenate([[176, 5, 3], clTbt_sp(setup.framerate), [176]])) ) ) - # Set frequencies: - # [CT] 0C 04 [fmin] [fmax] [fcount] [ftype] [CT] - f_min = clTbt_sp(setup.exc_freq) - f_max = clTbt_sp(setup.exc_freq) - f_count = [0, 1] - f_type = [0] # linear/log - # bytearray - self.write_command_string( - bytearray( - list( - np.concatenate([[176, 12, 4], f_min, f_max, f_count, f_type, [176]]) - ) - ) + # Set excitation frequencies (single frequency, or a full sweep when + # setup.n_freq > 1): + self.update_ExcitationFrequencies( + f_min=setup.exc_freq, + f_max=( + setup.exc_freq_max if setup.exc_freq_max is not None else setup.exc_freq + ), + f_count=setup.n_freq, + f_scale=setup.freq_scale, ) # Set injection config @@ -503,6 +577,10 @@ def StartStopMeasurement( bDeleteData: bool = False, sSavePath: str = "C/", bResultsFolder=False, + f_min: float = None, + f_max: float = None, + f_count: int = None, + f_scale: str = "lin", ): """ Starts and stops a measurement process using the configured serial protocol (HS or FS). @@ -524,9 +602,24 @@ def StartStopMeasurement( bSaveData=True, measured data is saved and then removed from RAM sSavePath (str): Specifies the sPath where the measured data is saved. bResultsFolder (bool): Specifies if additionally a folder in sSavePath is created to store the data in + f_min (float, optional): When given, (re)configures the device's excitation + frequency/sweep via `update_ExcitationFrequencies` before starting the + measurement. Omit to measure with whatever sweep is already configured + (e.g. via `SetMeasurementSetup`). + f_max (float, optional): Stop frequency of the sweep in Hz. Defaults to + `f_min` (single-frequency measurement) when `f_min` is given but + `f_max` is not. + f_count (int, optional): Number of frequency points in the sweep. Defaults + to `self.setup.n_freq` (or 1) when `f_min` is given but `f_count` is not. + f_scale (str, optional): "lin" or "log" distribution of the sweep's + frequency points. Defaults to "lin". Returns: list or matrix: The measurement data in the format specified by `return_as`. + When a frequency sweep (`f_count` / `setup.n_freq` > 1) is active and + `return_as="pot_mat"`, the returned matrix has shape + (n_frames, n_excitations, n_freq, n_el); otherwise it keeps the previous + (n_frames, n_excitations, n_el) shape. """ if self.cMessageParser is None: @@ -536,6 +629,16 @@ def StartStopMeasurement( if return_as not in {"hex", "pot_mat", "eitframe"}: raise ValueError("return_as must be 'hex', 'pot_mat' or 'eitframe'.") + if f_min is not None: + # (Re-)configure a single frequency or a full sweep right before + # the measurement starts. + self.update_ExcitationFrequencies( + f_min=f_min, + f_max=f_max, + f_count=f_count if f_count is not None else (self.setup.n_freq or 1), + f_scale=f_scale, + ) + # Start measurement self.cMessageParser.clear_out_data() sCurrentPath = make_results_folder( diff --git a/sciopy/ISX_3.py b/sciopy/ISX_3.py index 98b6b8b..0ef561c 100644 --- a/sciopy/ISX_3.py +++ b/sciopy/ISX_3.py @@ -568,7 +568,9 @@ def GetFE_Settings(self): return self.send_command(0xB1) def SetExtensionPortChannel(self, counter, reference, working_sense, working): - """Configure C, R, WS, and W selections on the extension port.""" + """Configure C, R, WS, and W selections on the extension port. + It adds to the current config instead of creating a new extension port channel. + """ return self.send_command(0xB2, [counter, reference, working_sense, working]) def GetExtensionPortChannel(self): diff --git a/sciopy/com_util.py b/sciopy/com_util.py index 30e2c63..5a069fe 100644 --- a/sciopy/com_util.py +++ b/sciopy/com_util.py @@ -68,6 +68,19 @@ def clTbt_dp(val: float) -> list: return [int(ele) for ele in struct.pack(">d", val)] +def clTbt_u16(val: int) -> list: + """ + clTbt_u16 converts an unsigned integer to a list of 2 big-endian bytes. + + Used e.g. for the frequency-point count of the "Excitation Frequencies" + measurement setup option (0xB0 0x0C 0x04), which the device expects as + an unsigned 16-bit big-endian integer. + """ + if not 0 <= val <= 0xFFFF: + raise ValueError(f"val must fit in an unsigned 16-bit integer, got {val}.") + return [int(bt) for bt in struct.pack(">H", val)] + + def reshape_full_message_in_bursts(lst: list, ssms: EitMeasurementSetup) -> np.ndarray: """ Takes the full message buffer and splits this message depeding on the measurement configuration into the diff --git a/sciopy/sciopy_dataclasses.py b/sciopy/sciopy_dataclasses.py index 400fef2..7958b14 100644 --- a/sciopy/sciopy_dataclasses.py +++ b/sciopy/sciopy_dataclasses.py @@ -13,12 +13,25 @@ class EitMeasurementSetup: Attributes: burst_count (int): Number of bursts per measurement cycle. n_el (int): Number of electrodes used in the measurement. - exc_freq (int or float): Excitation frequency in Hz. + exc_freq (int or float): Excitation frequency in Hz. Acts as the start + (minimum) frequency of a sweep when ``n_freq`` > 1. framerate (int or float): Frame rate of the measurement in Hz. amplitude (int or float): Amplitude of the excitation signal. inj_skip (int or list): Electrode(s) to skip during current injection. gain (int): Amplifier gain setting. adc_range (int): Analog-to-digital converter range setting. + mea_mode (str): Measurement mode, see `update_measurement_mode`. + mea_mode_boundary (str): Channel-group boundary behavior, see + `update_measurement_mode`. + exc_freq_max (int, float or None): Stop (maximum) frequency of a + frequency sweep in Hz. Ignored when ``n_freq`` is 1. Defaults to + ``exc_freq`` (single-frequency measurement) when left as ``None``. + n_freq (int): Number of frequency points measured between + ``exc_freq`` and ``exc_freq_max`` (inclusive). ``1`` selects a + single-frequency measurement at ``exc_freq``. + freq_scale (str): Distribution of frequency points across the sweep, + either ``"lin"`` (linear) or ``"log"`` (logarithmic). Ignored + when ``n_freq`` is 1. """ burst_count: int @@ -31,7 +44,9 @@ class EitMeasurementSetup: adc_range: int mea_mode: str = "singleended" mea_mode_boundary: str = "internal" - # TBD: lin/log/sweep + exc_freq_max: Union[int, float, None] = None + n_freq: int = 1 + freq_scale: str = "lin" @dataclass @@ -225,7 +240,9 @@ class EITFrame: ---------- n_el = Number of used electrodes excitation_stgs : np.array [[int1, int2]] , Features the [ESout, ESin] injection electrodes - frequency_stgs : List[str] # todo + frequency_stgs : np.array of the excitation frequencies used in this frame [Hz], + e.g. [f] for a single-frequency measurement or + [f_min, ..., f_max] (length n_freq) for a frequency sweep. timestamp1 : int Timestamp of the very first measured channel group in this frame, milli seconds? timestamp2 : int Timestamp of the very last measured channel group in this frame, milli seconds? timestamp_pc : int Timestamp of the receiving computer for further data synchronisation from datetime.now(). @@ -238,7 +255,7 @@ class EITFrame: n_el: int # Number of used electrodes excitation_stgs: npt.NDArray[int] # Num Excitation Settings X 2 - frequency_stgs: npt.NDArray[int] # List of Frequency-Sweep Settings, + frequency_stgs: npt.NDArray[float] # Excitation frequencies of the sweep [Hz] timestamp1: int # [ms] timestamp2: int timestamp_pc: int diff --git a/sciopy/usb_message_parser.py b/sciopy/usb_message_parser.py index d4d336d..1037c07 100644 --- a/sciopy/usb_message_parser.py +++ b/sciopy/usb_message_parser.py @@ -188,7 +188,7 @@ def set_measurement_setup(self, setup: EitMeasurementSetup): if setup is not None: self.iMaxChannelGroups = setup.n_el // 16 self.iNumExcitationSettings = setup.n_el # todo should be independently set - self.iNumFreqSettings = 1 # todo + self.iNumFreqSettings = getattr(setup, "n_freq", None) or 1 self.iLenDataperFrame = ( self.iMaxChannelGroups * 16 @@ -218,16 +218,46 @@ def reset_new_data_frame(self): self.CurrentFrame = EITFrame( n_el=self.setup.n_el, excitation_stgs=np.zeros((self.iNumExcitationSettings, 2), dtype=int), - frequency_stgs=np.zeros((self.iNumFreqSettings,), dtype=int), - # todo fill in setup freq settings + frequency_stgs=self.get_frequency_list(), timestamp1=0, timestamp2=0, timestamp_pc=0, ppcData=np.zeros( - self.iMaxChannelGroups * 16 * self.iNumExcitationSettings, dtype=complex + self.iMaxChannelGroups + * 16 + * self.iNumExcitationSettings + * self.iNumFreqSettings, + dtype=complex, ), ) + # ---------------------------------------------------------------------------------------------------------------- # + def get_frequency_list(self) -> np.ndarray: + """ + Returns the excitation frequencies [Hz] of the currently configured + setup, distributed between `setup.exc_freq` and `setup.exc_freq_max` + according to `setup.n_freq` and `setup.freq_scale` (see + `EIT_16_32_64_128.update_ExcitationFrequencies`). + + A single-frequency setup (n_freq == 1, the default) returns a + one-element array containing `setup.exc_freq`. + """ + if self.setup is None: + return np.zeros((self.iNumFreqSettings,), dtype=float) + + f_min = self.setup.exc_freq + n_freq = getattr(self.setup, "n_freq", None) or 1 + if n_freq <= 1: + return np.array([f_min], dtype=float) + + f_max = getattr(self.setup, "exc_freq_max", None) + if f_max is None: + f_max = f_min + f_scale = getattr(self.setup, "freq_scale", "lin") + if f_scale == "log": + return np.logspace(np.log10(f_min), np.log10(f_max), n_freq) + return np.linspace(f_min, f_max, n_freq) + # ---------------------------------------------------------------------------------------------------------------- # def clear_out_data(self): """ @@ -441,9 +471,14 @@ def interpret_data_input( ] self.iInjIndex += 1 - # FREQUENCY ROW is set through eitsetup - # TODO input not the number of the frequency row, but all injected frequencies, beforehand - # self.CurrentFrame.frequency_stgs = self.iNumFreqSettings + # FREQUENCY ROW: the injected frequencies are already filled in + # from the measurement setup (see reset_new_data_frame / + # get_frequency_list). `freq_group` (1-indexed) tells us which + # row of that sweep this particular message belongs to; the + # device sends channel-group/excitation/frequency messages in + # nested order (excitation outer, frequency inner, see + # EITFrame docstring), so appending payloads sequentially below + # already lays them out correctly for `get_data_as_matrix`. # TIMESTAMP if self.iSaveCounter == 0: @@ -504,17 +539,28 @@ def make_results_folder(bCreateResultsFolder: bool, bSaveData: bool, sSavePath: # -------------------------------------------------------------------------------------------------------------------- # def get_data_as_matrix(FrameList): """ - List of EITFrames to be reshaped into matrix of [Number frames, num injection settings, n_el] + List of EITFrames to be reshaped into a matrix. + Args: FrameList: List of EITFrames to be reshaped into matrix Returns: - np.array of eit data of shape [Number frames, num injection settings, n_el] + np.array of eit data. + - Single-frequency frames (len(f.frequency_stgs) <= 1, the default): + shape [Number frames, num injection settings, n_el], unchanged + from previous behavior. + - Frequency-sweep frames (len(f.frequency_stgs) > 1): shape + [Number frames, num injection settings, num frequencies, n_el], + per the excitation-outer/frequency-inner/channel-innermost + ordering documented on `EITFrame`. """ result = [] for f in FrameList: - L = len(f.ppcData) // len(f.excitation_stgs) - result.append(np.reshape(f.ppcData, (len(f.excitation_stgs), L))) + n_exc = len(f.excitation_stgs) + n_freq = max(len(f.frequency_stgs), 1) + L = len(f.ppcData) // (n_exc * n_freq) + mat = np.reshape(f.ppcData, (n_exc, n_freq, L)) + result.append(mat if n_freq > 1 else mat[:, 0, :]) return np.array(result) diff --git a/tests/test_frequency_sweep.py b/tests/test_frequency_sweep.py new file mode 100644 index 0000000..ece448c --- /dev/null +++ b/tests/test_frequency_sweep.py @@ -0,0 +1,193 @@ +from unittest.mock import Mock + +import numpy as np +import pytest + +from sciopy.com_util import clTbt_sp, clTbt_u16 +from sciopy.EIT_16_32_64_128 import EIT_16_32_64_128 +from sciopy.sciopy_dataclasses import EITFrame, EitMeasurementSetup +from sciopy.usb_message_parser import MessageParser, get_data_as_matrix + + +def test_update_excitation_frequencies_builds_valid_sweep_command(): + device = EIT_16_32_64_128(16) + device.write_command_string = Mock() + + device.update_ExcitationFrequencies( + f_min=1_000.0, f_max=100_000.0, f_count=5, f_scale="log" + ) + + expected = bytearray( + [0xB0, 12, 4] + + clTbt_sp(1_000.0) + + clTbt_sp(100_000.0) + + clTbt_u16(5) + + [0x01, 0xB0] + ) + device.write_command_string.assert_called_once_with(expected) + + +def test_update_excitation_frequency_stays_backward_compatible(): + device = EIT_16_32_64_128(16) + device.write_command_string = Mock() + + device.update_ExcitationFrequency(50_000) + + expected = bytearray( + [0xB0, 12, 4] + clTbt_sp(50_000) + clTbt_sp(50_000) + [0, 1] + [0x00, 0xB0] + ) + device.write_command_string.assert_called_once_with(expected) + + +def test_update_excitation_frequencies_rejects_invalid_sweep(): + device = EIT_16_32_64_128(16) + device.write_command_string = Mock() + + with pytest.raises(ValueError): + device.update_ExcitationFrequencies(f_min=1_000.0, f_max=1_000.0, f_count=3) + + with pytest.raises(ValueError): + device.update_ExcitationFrequencies(f_min=1_000.0, f_scale="bogus") + + +def test_set_measurement_setup_uses_excitation_frequencies_sweep(monkeypatch): + device = EIT_16_32_64_128(16) + device.cMessageParser = Mock() + device.write_command_string = Mock() + device.send_message = Mock() + + setup = EitMeasurementSetup( + burst_count=1, + n_el=16, + exc_freq=1_000.0, + framerate=10, + amplitude=0.001, + inj_skip=0, + gain=1, + adc_range=1, + exc_freq_max=10_000.0, + n_freq=3, + freq_scale="lin", + ) + device.SetMeasurementSetup(setup) + + expected = bytearray( + [0xB0, 12, 4] + + clTbt_sp(1_000.0) + + clTbt_sp(10_000.0) + + clTbt_u16(3) + + [0x00, 0xB0] + ) + assert expected in [ + call.args[0] for call in device.write_command_string.call_args_list + ] + + +def test_start_stop_measurement_can_configure_sweep_inline(): + device = EIT_16_32_64_128(16) + device.setup = EitMeasurementSetup( + burst_count=1, + n_el=16, + exc_freq=1_000.0, + framerate=10, + amplitude=0.001, + inj_skip=0, + gain=1, + adc_range=1, + ) + device.cMessageParser = Mock() + device.cMessageParser.read_usb_till_timeout.return_value = [] + device.write_command_string = Mock() + device.send_message = Mock() + + device.StartStopMeasurement(f_min=1_000.0, f_max=10_000.0, f_count=4, f_scale="log") + + expected = bytearray( + [0xB0, 12, 4] + + clTbt_sp(1_000.0) + + clTbt_sp(10_000.0) + + clTbt_u16(4) + + [0x01, 0xB0] + ) + device.write_command_string.assert_called_once_with(expected) + assert device.setup.n_freq == 4 + + +def test_message_parser_sizes_frame_for_frequency_sweep(): + setup = EitMeasurementSetup( + burst_count=1, + n_el=16, + exc_freq=1_000.0, + framerate=10, + amplitude=0.001, + inj_skip=0, + gain=1, + adc_range=1, + exc_freq_max=10_000.0, + n_freq=3, + freq_scale="lin", + ) + parser = MessageParser(device=None, eitsetup=setup, devicetype="FS") + + assert parser.iNumFreqSettings == 3 + assert len(parser.CurrentFrame.ppcData) == ( + parser.iMaxChannelGroups * 16 * parser.iNumExcitationSettings * 3 + ) + np.testing.assert_allclose( + parser.CurrentFrame.frequency_stgs, [1_000.0, 5_500.0, 10_000.0] + ) + + +def test_message_parser_defaults_to_single_frequency(): + setup = EitMeasurementSetup( + burst_count=1, + n_el=16, + exc_freq=1_000.0, + framerate=10, + amplitude=0.001, + inj_skip=0, + gain=1, + adc_range=1, + ) + parser = MessageParser(device=None, eitsetup=setup, devicetype="FS") + + assert parser.iNumFreqSettings == 1 + np.testing.assert_allclose(parser.CurrentFrame.frequency_stgs, [1_000.0]) + + +def test_get_data_as_matrix_keeps_old_shape_for_single_frequency(): + n_exc, n_el = 16, 16 + frame = EITFrame( + n_el=16, + excitation_stgs=np.zeros((n_exc, 2), dtype=int), + frequency_stgs=np.array([1_000.0]), + timestamp1=0, + timestamp2=0, + timestamp_pc=0, + ppcData=np.arange(n_exc * n_el, dtype=complex), + ) + + matrix = get_data_as_matrix([frame]) + + assert matrix.shape == (1, n_exc, n_el) + + +def test_get_data_as_matrix_adds_frequency_axis_for_sweep(): + n_exc, n_freq, n_el = 16, 3, 16 + frame = EITFrame( + n_el=16, + excitation_stgs=np.zeros((n_exc, 2), dtype=int), + frequency_stgs=np.array([1_000.0, 5_500.0, 10_000.0]), + timestamp1=0, + timestamp2=0, + timestamp_pc=0, + ppcData=np.arange(n_exc * n_freq * n_el, dtype=complex), + ) + + matrix = get_data_as_matrix([frame]) + + assert matrix.shape == (1, n_exc, n_freq, n_el) + # excitation outer, frequency middle, channel inner (see EITFrame docstring) + assert matrix[0, 0, 0, 0] == 0 + assert matrix[0, 0, 1, 0] == n_el + assert matrix[0, 1, 0, 0] == n_freq * n_el