From 61233671f7a2a226463e721063d1883c1e5a360e Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Mon, 28 Apr 2025 21:50:23 +0200
Subject: [PATCH 01/27] Update documentation to fix list layout
---
eitprocessing/datahandling/sequence.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/eitprocessing/datahandling/sequence.py b/eitprocessing/datahandling/sequence.py
index 6358d8f5f..e52709721 100644
--- a/eitprocessing/datahandling/sequence.py
+++ b/eitprocessing/datahandling/sequence.py
@@ -186,6 +186,7 @@ def data(self) -> _DataAccess:
`sequence.data.add(obj)`).
Other dict-like behaviour is also supported:
+
- `label in sequence.data` to check whether an object with a label exists;
- `del sequence.data[label]` to remove an object from the sequence based on the label;
- `for label in sequence.data` to iterate over the labels;
From d39a81500b3a08c6c787fe1c5be87538899fc062 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Wed, 30 Apr 2025 14:35:39 +0200
Subject: [PATCH 02/27] Fix pixels being ignored when absolute value negative
Pixels are now skipped when they have no amplitude, i.e. when the standard deviation equals 0.
---
eitprocessing/features/pixel_breath.py | 41 ++++++++++++++------------
1 file changed, 22 insertions(+), 19 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 213118c6a..989cf2236 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -147,25 +147,28 @@ def find_pixel_breaths(
for row, col in itertools.product(range(n_rows), range(n_cols)):
mean_tiv = mean_tiv_pixel[row, col]
- if np.any(pixel_impedance[:, row, col] > 0):
- if mean_tiv < 0:
- start_func, middle_func = np.argmax, np.argmin
- else:
- start_func, middle_func = np.argmin, np.argmax
-
- outsides = self._find_extreme_indices(pixel_impedance, indices_breath_middles, row, col, start_func)
- starts = outsides[:-1]
- ends = outsides[1:]
- middles = self._find_extreme_indices(pixel_impedance, outsides, row, col, middle_func)
- # TODO discuss; this block of code is implemented to prevent noisy pixels from breaking the code.
- # Quick solve is to make entire breath object None if any breath in a pixel does not have
- # consecutive start, middle and end.
- # However, this might cause problems elsewhere.
- if (starts >= middles).any() or (middles >= ends).any():
- pixel_breath = None
- else:
- pixel_breath = self._construct_breaths(starts, middles, ends, time)
- pixel_breaths[:, row, col] = pixel_breath
+ if np.std(pixel_impedance[:, row, col]) == 0:
+ # pixel has no amplitude
+ continue
+
+ if mean_tiv < 0:
+ start_func, middle_func = np.argmax, np.argmin
+ else:
+ start_func, middle_func = np.argmin, np.argmax
+
+ outsides = self._find_extreme_indices(pixel_impedance, indices_breath_middles, row, col, start_func)
+ starts = outsides[:-1]
+ ends = outsides[1:]
+ middles = self._find_extreme_indices(pixel_impedance, outsides, row, col, middle_func)
+ # TODO discuss; this block of code is implemented to prevent noisy pixels from breaking the code.
+ # Quick solve is to make entire breath object None if any breath in a pixel does not have
+ # consecutive start, middle and end.
+ # However, this might cause problems elsewhere.
+ if (starts >= middles).any() or (middles >= ends).any():
+ pixel_breath = None
+ else:
+ pixel_breath = self._construct_breaths(starts, middles, ends, time)
+ pixel_breaths[:, row, col] = pixel_breath
intervals = [(breath.start_time, breath.end_time) for breath in continuous_breaths.values]
From 07f0c9044256e1f7df4e37a7405b0e77d98fb50d Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Wed, 30 Apr 2025 14:36:17 +0200
Subject: [PATCH 03/27] Add boolean option to allow pixels with negative
amplitude
---
eitprocessing/features/pixel_breath.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 989cf2236..28b6b61e8 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -50,6 +50,7 @@ def find_pixel_breaths(
sequence: Sequence | None = None,
store: bool | None = None,
result_label: str = "pixel_breaths",
+ allow_negative_amplitude: bool = False,
) -> IntervalData:
"""Find pixel breaths in the data.
@@ -151,7 +152,7 @@ def find_pixel_breaths(
# pixel has no amplitude
continue
- if mean_tiv < 0:
+ if allow_negative_amplitude and mean_tiv < 0:
start_func, middle_func = np.argmax, np.argmin
else:
start_func, middle_func = np.argmin, np.argmax
From 367d3e082f6bc4a79e01caaf7212e02b4ea0f881 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 16:55:48 +0200
Subject: [PATCH 04/27] Make allow_negative_amplitude class attribute
---
eitprocessing/features/pixel_breath.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 28b6b61e8..75335bec5 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -42,6 +42,7 @@ class PixelBreath:
"""
breath_detection_kwargs: dict = field(default_factory=dict)
+ allow_negative_amplitude: bool = False
def find_pixel_breaths(
self,
@@ -50,7 +51,6 @@ def find_pixel_breaths(
sequence: Sequence | None = None,
store: bool | None = None,
result_label: str = "pixel_breaths",
- allow_negative_amplitude: bool = False,
) -> IntervalData:
"""Find pixel breaths in the data.
@@ -152,7 +152,7 @@ def find_pixel_breaths(
# pixel has no amplitude
continue
- if allow_negative_amplitude and mean_tiv < 0:
+ if self.allow_negative_amplitude and mean_tiv < 0:
start_func, middle_func = np.argmax, np.argmin
else:
start_func, middle_func = np.argmin, np.argmax
From 98ef67efebfe3c6b6f533c2de9b0359c6438a347 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 16:56:05 +0200
Subject: [PATCH 05/27] Make allow_negative_amplitude True by default
---
eitprocessing/features/pixel_breath.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 75335bec5..6fa51c998 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -42,7 +42,7 @@ class PixelBreath:
"""
breath_detection_kwargs: dict = field(default_factory=dict)
- allow_negative_amplitude: bool = False
+ allow_negative_amplitude: bool = True
def find_pixel_breaths(
self,
From 8382461f46b8c0b721f80b07f88e97e6e6ba9440 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 17:49:06 +0200
Subject: [PATCH 06/27] Replace breath_detection_kwargs in TIV and PixelBreath
with BreathDetection object
---
eitprocessing/features/pixel_breath.py | 17 ++---------
.../parameters/tidal_impedance_variation.py | 30 ++++++++++++-------
2 files changed, 23 insertions(+), 24 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 6fa51c998..8663e3262 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -30,18 +30,10 @@ class PixelBreath:
```
Args:
- breath_detection_kwargs (dict): A dictionary of keyword arguments for breath detection.
- The available keyword arguments are:
- minimum_duration: minimum expected duration of breaths, defaults to 2/3 of a second
- averaging_window_duration: duration of window used for averaging the data, defaults to 15 seconds
- averaging_window_function: function used to create a window for averaging the data, defaults to np.blackman
- amplitude_cutoff_fraction: fraction of the median amplitude below which breaths are removed, defaults to 0.25
- invalid_data_removal_window_length: window around invalid data in which breaths are removed, defaults to 0.5
- invalid_data_removal_percentile: the nth percentile of values used to remove outliers, defaults to 5
- invalid_data_removal_multiplier: the multiplier used to remove outliers, defaults to 4
+ breath_detection (BreathDetection): BreathDetection object to use for detecing breaths.
"""
- breath_detection_kwargs: dict = field(default_factory=dict)
+ breath_detection: BreathDetection = field(default_factory=BreathDetection)
allow_negative_amplitude: bool = True
def find_pixel_breaths(
@@ -104,9 +96,7 @@ def find_pixel_breaths(
msg = "To store the result a Sequence dataclass must be provided."
raise ValueError(msg)
- bd_kwargs = self.breath_detection_kwargs.copy()
- breath_detection = BreathDetection(**bd_kwargs)
- continuous_breaths = breath_detection.find_breaths(continuous_data)
+ continuous_breaths = self.breath_detection.find_breaths(continuous_data)
indices_breath_middles = np.searchsorted(
eit_data.time,
@@ -182,7 +172,6 @@ def find_pixel_breaths(
values=list(
pixel_breaths,
), ## TODO: change back to pixel_breaths array when IntervalData works with 3D array
- parameters=self.breath_detection_kwargs,
derived_from=[eit_data],
)
if store:
diff --git a/eitprocessing/parameters/tidal_impedance_variation.py b/eitprocessing/parameters/tidal_impedance_variation.py
index 09119d29a..4d8075fe9 100644
--- a/eitprocessing/parameters/tidal_impedance_variation.py
+++ b/eitprocessing/parameters/tidal_impedance_variation.py
@@ -1,7 +1,7 @@
import itertools
from dataclasses import dataclass, field
from functools import singledispatchmethod
-from typing import Literal, NoReturn
+from typing import Final, Literal, NoReturn
import numpy as np
@@ -15,19 +15,35 @@
from eitprocessing.features.pixel_breath import PixelBreath
from eitprocessing.parameters import ParameterCalculation
+_SENTINAL_PIXEL_BREATH: Final = PixelBreath()
+
+
+def _return_sentinal_pixel_breath() -> PixelBreath:
+ # Returns a sential of a PixelBreath, which only exists to signal that the default value for pixel_breath was used.
+ return _SENTINAL_PIXEL_BREATH
+
@dataclass
class TIV(ParameterCalculation):
"""Compute the tidal impedance variation (TIV) per breath."""
method: Literal["extremes"] = "extremes"
- breath_detection_kwargs: dict = field(default_factory=dict)
+ breath_detection: BreathDetection = field(default_factory=BreathDetection)
+
+ # The default is a sentinal that will be replaced in __post_init__
+ pixel_breath: PixelBreath = field(default_factory=_return_sentinal_pixel_breath)
def __post_init__(self) -> None:
if self.method != "extremes":
msg = f"Method {self.method} is not implemented. The method must be 'extremes'."
raise NotImplementedError(msg)
+ if self.pixel_breath is _SENTINAL_PIXEL_BREATH:
+ # If no value was provided at initialization, PixelBreath should use the same BreathDetection object as TIV.
+ # However, a default factory cannot be used because it can't access self.breath_detection. The sentinal
+ # object is replaced here (only if pixel_breath was not provided) with the correct BreathDetection object.
+ self.pixel_breath = PixelBreath(breath_detection=self.breath_detection)
+
@singledispatchmethod
def compute_parameter(
self,
@@ -101,7 +117,6 @@ def compute_continuous_parameter(
category="impedance difference",
time=[breath.middle_time for breath in breaths.values if breath is not None],
description="Tidal impedance variation determined on continuous data",
- parameters=self.breath_detection_kwargs,
derived_from=[continuous_data],
values=tiv_values,
)
@@ -213,7 +228,6 @@ def compute_pixel_parameter(
category="impedance difference",
time=list(all_pixels_breath_timings),
description="Tidal impedance variation determined on pixel impedance",
- parameters=self.breath_detection_kwargs,
derived_from=[eit_data],
values=list(all_pixels_tiv_values.astype(float)),
)
@@ -224,9 +238,7 @@ def compute_pixel_parameter(
return tiv_container
def _detect_breaths(self, data: ContinuousData) -> IntervalData:
- bd_kwargs = self.breath_detection_kwargs.copy()
- breath_detection = BreathDetection(**bd_kwargs)
- return breath_detection.find_breaths(data)
+ return self.breath_detection.find_breaths(data)
def _detect_pixel_breaths(
self,
@@ -235,9 +247,7 @@ def _detect_pixel_breaths(
sequence: Sequence,
store: bool,
) -> IntervalData:
- bd_kwargs = self.breath_detection_kwargs.copy()
- pi = PixelBreath(breath_detection_kwargs=bd_kwargs)
- return pi.find_pixel_breaths(
+ return self.pixel_breath.find_pixel_breaths(
eit_data,
continuous_data,
result_label="pixel breaths",
From 0dfee24e7402819e89f20164842658e618a6b13b Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 17:49:31 +0200
Subject: [PATCH 07/27] Add documentation for new argument
---
eitprocessing/features/pixel_breath.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 8663e3262..4e4f5ec17 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -31,6 +31,7 @@ class PixelBreath:
Args:
breath_detection (BreathDetection): BreathDetection object to use for detecing breaths.
+ allow_negative_amplitude (bool): whether to asume out-of-phase pixels have negative amplitude instead.
"""
breath_detection: BreathDetection = field(default_factory=BreathDetection)
From ab67491149ece61bc484c03ac9eccf9f3a3e0ad7 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 17:50:19 +0200
Subject: [PATCH 08/27] Fix linting issues
---
eitprocessing/features/pixel_breath.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 4e4f5ec17..7a9edf825 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -180,7 +180,7 @@ def find_pixel_breaths(
return pixel_breaths_container
- def _construct_breaths(self, start: list, middle: list, end: list, time: np.ndarray) -> list:
+ def _construct_breaths(self, start: list[int], middle: list[int], end: list[int], time: np.ndarray) -> list:
breaths = [Breath(time[s], time[m], time[e]) for s, m, e in zip(start, middle, end, strict=True)]
# First and last breath are not detected by definition (need two breaths to find one breath)
return [None, *breaths, None]
From 67da634ec2c5cf55c2b0d0ce4201386517b8aede Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 17:50:43 +0200
Subject: [PATCH 09/27] Add exception for providing wrong TIV method
---
eitprocessing/parameters/tidal_impedance_variation.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/eitprocessing/parameters/tidal_impedance_variation.py b/eitprocessing/parameters/tidal_impedance_variation.py
index 4d8075fe9..fb3569000 100644
--- a/eitprocessing/parameters/tidal_impedance_variation.py
+++ b/eitprocessing/parameters/tidal_impedance_variation.py
@@ -1,4 +1,5 @@
import itertools
+import sys
from dataclasses import dataclass, field
from functools import singledispatchmethod
from typing import Final, Literal, NoReturn
@@ -284,6 +285,13 @@ def _calculate_tiv_values(
mean_outer_values = data[[start_indices, end_indices]].mean(axis=0)
end_inspiratory_values = data[middle_indices]
tiv_values = end_inspiratory_values - mean_outer_values
+ else:
+ msg = f"`tiv_method` ({tiv_method}) not valid."
+ exc = ValueError(msg)
+ if sys.version_info >= (3, 11):
+ exc.add_note("Valid value for `tiv_method` are 'inspiratory', 'expiratory' and 'mean'.")
+ raise exc
+
if tiv_timing == "pixel":
tiv_values = [None, *tiv_values, None]
From 7b35415997ba63947bde1f71cb3bb02df61c4ed8 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 17:50:53 +0200
Subject: [PATCH 10/27] Update tests
---
tests/test_parameter_tiv.py | 25 ++++++++++++++++++++-----
tests/test_pixel_breath.py | 11 ++++++-----
2 files changed, 26 insertions(+), 10 deletions(-)
diff --git a/tests/test_parameter_tiv.py b/tests/test_parameter_tiv.py
index d6183fb5d..6dafda1e0 100644
--- a/tests/test_parameter_tiv.py
+++ b/tests/test_parameter_tiv.py
@@ -13,6 +13,7 @@
from eitprocessing.datahandling.sequence import Sequence
from eitprocessing.datahandling.sparsedata import SparseData
from eitprocessing.parameters.tidal_impedance_variation import TIV
+from tests.test_breath_detection import Breath, BreathDetection
environment = Path(
os.environ.get(
@@ -144,7 +145,7 @@ def test_tiv_initialization():
"""Test that TIV initializes correctly with default parameters."""
tiv = TIV()
assert tiv.method == "extremes"
- assert tiv.breath_detection_kwargs == {}
+ assert tiv.breath_detection == BreathDetection()
def test_compute_parameter_type_error():
@@ -508,23 +509,37 @@ def test_with_data(draeger1: Sequence, timpel1: Sequence, pytestconfig: pytest.C
[
({"amplitude_cutoff_fraction": 0.3, "minimum_duration": 5.0}, ValueError), # too long duration
({"amplitude_cutoff_fraction": 2, "minimum_duration": 5.0}, ValueError), # too high amplitude cutoff
- ({"minimum_amplitude": 2, "minimum_duration": 5.0}, TypeError), # unexpected keyword minimum_amplitude
],
)
def test_detect_pixel_breaths_with_invalid_bd_kwargs(
bd_kwargs: dict,
- expected_error: ValueError,
+ expected_error: type[Exception],
mock_eit_data: EITData,
mock_continuous_data: ContinuousData,
mock_sequence: Sequence,
):
"""Test detect_pixel_breaths with invalid bd_kwargs that raise errors."""
- tiv = TIV(breath_detection_kwargs=bd_kwargs)
+ tiv = TIV(breath_detection=BreathDetection(**bd_kwargs))
with pytest.raises(expected_error):
tiv._detect_pixel_breaths(mock_eit_data, mock_continuous_data, mock_sequence, store=False)
+@pytest.mark.parametrize(
+ ("bd_kwargs", "expected_error"),
+ [
+ ({"minimum_amplitude": 2, "minimum_duration": 5.0}, TypeError), # unexpected keyword minimum_amplitude
+ ],
+)
+def test_detect_pixel_breaths_with_invalid_bd_kwargs_(
+ bd_kwargs: dict,
+ expected_error: type[Exception],
+):
+ """Test detect_pixel_breaths with invalid bd_kwargs that raise errors."""
+ with pytest.raises(expected_error):
+ _ = TIV(breath_detection=BreathDetection(**bd_kwargs))
+
+
@pytest.mark.parametrize(
("bd_kwargs"),
[
@@ -540,7 +555,7 @@ def test_detect_pixel_breaths_with_valid_bd_kwargs(
mock_sequence: Sequence,
):
"""Test detect_pixel_breaths with valid bd_kwargs that return expected results."""
- tiv = TIV(breath_detection_kwargs=bd_kwargs)
+ tiv = TIV(breath_detection=BreathDetection(**bd_kwargs))
result = tiv._detect_pixel_breaths(mock_eit_data, mock_continuous_data, mock_sequence, store=False)
test_result = np.stack(result.values)
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index ac3aebca3..174f00271 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -15,6 +15,7 @@
from eitprocessing.datahandling.sequence import Sequence
from eitprocessing.datahandling.sparsedata import SparseData
from eitprocessing.features.pixel_breath import PixelBreath
+from tests.test_breath_detection import BreathDetection
environment = Path(
os.environ.get(
@@ -241,10 +242,10 @@ def test_store_result_with_errors(
request: pytest.FixtureRequest,
store_input: bool,
sequence_fixture: str,
- expected_exception: ValueError | RuntimeError,
+ expected_exception: type[ValueError | RuntimeError],
):
"""Test storing results when errors are expected."""
- pi = PixelBreath(breath_detection_kwargs={"minimum_duration": 0.01}) # Ensure that breaths are detected
+ pi = PixelBreath(breath_detection=BreathDetection(minimum_duration=0.01)) # Ensure that breaths are detected
sequence = request.getfixturevalue(sequence_fixture)
@@ -271,7 +272,7 @@ def test_store_result_success(
sequence_fixture: str,
):
"""Test storing results when no errors are expected."""
- pi = PixelBreath(breath_detection_kwargs={"minimum_duration": 0.01}) # Ensure that breaths are detected
+ pi = PixelBreath(breath_detection=BreathDetection(minimum_duration=0.01)) # Ensure that breaths are detected
sequence = request.getfixturevalue(sequence_fixture)
@@ -303,7 +304,7 @@ def test_with_custom_mean_pixel_tiv(
"eitprocessing.parameters.tidal_impedance_variation.TIV.compute_pixel_parameter",
side_effect=mock_function,
):
- pi = PixelBreath(breath_detection_kwargs={"minimum_duration": 0.01})
+ pi = PixelBreath(breath_detection=BreathDetection(minimum_duration=0.01))
result = pi.find_pixel_breaths(mock_eit_data, mock_continuous_data)
@@ -321,7 +322,7 @@ def test_with_custom_mean_pixel_tiv(
def test_with_zero_impedance(mock_zero_eit_data: EITData, mock_continuous_data: ContinuousData):
- pi = PixelBreath(breath_detection_kwargs={"minimum_duration": 0.01})
+ pi = PixelBreath(breath_detection=BreathDetection(minimum_duration=0.01))
breath_container = pi.find_pixel_breaths(mock_zero_eit_data, mock_continuous_data)
test_result = np.stack(breath_container.values)
assert np.all(np.vectorize(lambda x: x is None)(test_result[:, 1, 1]))
From a26e490413192014f5bd7e395f9beac127ada4a5 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 18:21:22 +0200
Subject: [PATCH 11/27] Re-add breath_detection_kwargs argument with
DeprecationWarning
---
eitprocessing/features/pixel_breath.py | 32 +++++++++++++++++--
.../parameters/tidal_impedance_variation.py | 29 +++++++++++++++--
tests/test_parameter_tiv.py | 11 +++++++
tests/test_pixel_breath.py | 12 +++++++
4 files changed, 79 insertions(+), 5 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 7a9edf825..c979b6f7f 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -1,16 +1,28 @@
import itertools
+import warnings
from collections.abc import Callable
-from dataclasses import dataclass, field
+from dataclasses import InitVar, dataclass, field
+from typing import Final
import numpy as np
+from eitprocessing.datahandling import breath
from eitprocessing.datahandling.breath import Breath
from eitprocessing.datahandling.continuousdata import ContinuousData
from eitprocessing.datahandling.eitdata import EITData
from eitprocessing.datahandling.intervaldata import IntervalData
from eitprocessing.datahandling.sequence import Sequence
+from eitprocessing.features import breath_detection
from eitprocessing.features.breath_detection import BreathDetection
+_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
+
+
+def _return_sentinal_breath_detection() -> BreathDetection:
+ # Returns a sential of a BreathDetection, which only exists to signal that the default value for breath_detection
+ # was used.
+ return _SENTINAL_BREATH_DETECTION
+
@dataclass
class PixelBreath:
@@ -34,9 +46,25 @@ class PixelBreath:
allow_negative_amplitude (bool): whether to asume out-of-phase pixels have negative amplitude instead.
"""
- breath_detection: BreathDetection = field(default_factory=BreathDetection)
+ breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
+ breath_detection_kwargs: InitVar[dict | None] = None
allow_negative_amplitude: bool = True
+ def __post_init__(self, breath_detection_kwargs: dict | None):
+ if breath_detection_kwargs is not None:
+ if self.breath_detection is not _SENTINAL_BREATH_DETECTION:
+ msg = (
+ "`breath_detection_kwargs` is deprecated, and can't be used at the same time as `breath_detection`."
+ )
+ raise TypeError(msg)
+
+ self.breath_detection = BreathDetection(**breath_detection_kwargs)
+ warnings.warn(
+ "`breath_detection_kwargs` is deprecated and will be removed soon. "
+ "Replace with `breath_detection=BreathDetection(**breath_detection_kwargs)`.",
+ DeprecationWarning,
+ )
+
def find_pixel_breaths(
self,
eit_data: EITData,
diff --git a/eitprocessing/parameters/tidal_impedance_variation.py b/eitprocessing/parameters/tidal_impedance_variation.py
index fb3569000..c476ebbc0 100644
--- a/eitprocessing/parameters/tidal_impedance_variation.py
+++ b/eitprocessing/parameters/tidal_impedance_variation.py
@@ -1,6 +1,7 @@
import itertools
import sys
-from dataclasses import dataclass, field
+import warnings
+from dataclasses import InitVar, dataclass, field
from functools import singledispatchmethod
from typing import Final, Literal, NoReturn
@@ -17,6 +18,7 @@
from eitprocessing.parameters import ParameterCalculation
_SENTINAL_PIXEL_BREATH: Final = PixelBreath()
+_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
def _return_sentinal_pixel_breath() -> PixelBreath:
@@ -24,21 +26,42 @@ def _return_sentinal_pixel_breath() -> PixelBreath:
return _SENTINAL_PIXEL_BREATH
+def _return_sentinal_breath_detection() -> BreathDetection:
+ # Returns a sential of a BreathDetection, which only exists to signal that the default value for breath_detection
+ # was used.
+ return _SENTINAL_BREATH_DETECTION
+
+
@dataclass
class TIV(ParameterCalculation):
"""Compute the tidal impedance variation (TIV) per breath."""
method: Literal["extremes"] = "extremes"
- breath_detection: BreathDetection = field(default_factory=BreathDetection)
+ breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
+ breath_detection_kwargs: InitVar[dict | None] = None
# The default is a sentinal that will be replaced in __post_init__
pixel_breath: PixelBreath = field(default_factory=_return_sentinal_pixel_breath)
- def __post_init__(self) -> None:
+ def __post_init__(self, breath_detection_kwargs: dict | None) -> None:
if self.method != "extremes":
msg = f"Method {self.method} is not implemented. The method must be 'extremes'."
raise NotImplementedError(msg)
+ if breath_detection_kwargs is not None:
+ if self.breath_detection is not _SENTINAL_BREATH_DETECTION:
+ msg = (
+ "`breath_detection_kwargs` is deprecated, and can't be used at the same time as `breath_detection`."
+ )
+ raise TypeError(msg)
+
+ self.breath_detection = BreathDetection(**breath_detection_kwargs)
+ warnings.warn(
+ "`breath_detection_kwargs` is deprecated and will be removed soon. "
+ "Replace with `breath_detection=BreathDetection(**breath_detection_kwargs)`.",
+ DeprecationWarning,
+ )
+
if self.pixel_breath is _SENTINAL_PIXEL_BREATH:
# If no value was provided at initialization, PixelBreath should use the same BreathDetection object as TIV.
# However, a default factory cannot be used because it can't access self.breath_detection. The sentinal
diff --git a/tests/test_parameter_tiv.py b/tests/test_parameter_tiv.py
index 6dafda1e0..3d8fb8f53 100644
--- a/tests/test_parameter_tiv.py
+++ b/tests/test_parameter_tiv.py
@@ -148,6 +148,17 @@ def test_tiv_initialization():
assert tiv.breath_detection == BreathDetection()
+def test_depricated():
+ with pytest.warns(DeprecationWarning):
+ _ = TIV(breath_detection_kwargs={})
+
+ with pytest.raises(TypeError):
+ _ = TIV(breath_detection=BreathDetection(), breath_detection_kwargs={})
+
+ bd_kwargs = {"minimum_duration": 10, "averaging_window_duration": 100.0}
+ assert TIV(breath_detection_kwargs=bd_kwargs).breath_detection == BreathDetection(**bd_kwargs)
+
+
def test_compute_parameter_type_error():
"""Test that compute_parameter raises TypeError for unsupported data types."""
tiv = TIV()
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index 174f00271..b21ad4863 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -7,6 +7,7 @@
import numpy as np
import pytest
+from build.lib.eitprocessing.datahandling import breath
from eitprocessing.datahandling.breath import Breath
from eitprocessing.datahandling.continuousdata import ContinuousData
from eitprocessing.datahandling.datacollection import DataCollection
@@ -184,6 +185,17 @@ def _mock(*_args, **_kwargs) -> np.ndarray:
return _mock
+def test_depricated():
+ with pytest.warns(DeprecationWarning):
+ _ = PixelBreath(breath_detection_kwargs={})
+
+ with pytest.raises(TypeError):
+ _ = PixelBreath(breath_detection=BreathDetection(), breath_detection_kwargs={})
+
+ bd_kwargs = {"minimum_duration": 10, "averaging_window_duration": 100.0}
+ assert PixelBreath(breath_detection_kwargs=bd_kwargs).breath_detection == BreathDetection(**bd_kwargs)
+
+
def test__compute_breaths():
"""Test _compute_breaths helper function."""
time = np.array([0, 1, 2, 3, 4])
From 480ba5b8c1d9fff123e92e5817ff7e8fbdad7aad Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Sun, 4 May 2025 18:24:48 +0200
Subject: [PATCH 12/27] Remove unused imports
---
eitprocessing/features/pixel_breath.py | 2 --
tests/test_parameter_tiv.py | 2 +-
tests/test_pixel_breath.py | 1 -
3 files changed, 1 insertion(+), 4 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index c979b6f7f..550a83e33 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -6,13 +6,11 @@
import numpy as np
-from eitprocessing.datahandling import breath
from eitprocessing.datahandling.breath import Breath
from eitprocessing.datahandling.continuousdata import ContinuousData
from eitprocessing.datahandling.eitdata import EITData
from eitprocessing.datahandling.intervaldata import IntervalData
from eitprocessing.datahandling.sequence import Sequence
-from eitprocessing.features import breath_detection
from eitprocessing.features.breath_detection import BreathDetection
_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
diff --git a/tests/test_parameter_tiv.py b/tests/test_parameter_tiv.py
index 3d8fb8f53..29c6a21bc 100644
--- a/tests/test_parameter_tiv.py
+++ b/tests/test_parameter_tiv.py
@@ -13,7 +13,7 @@
from eitprocessing.datahandling.sequence import Sequence
from eitprocessing.datahandling.sparsedata import SparseData
from eitprocessing.parameters.tidal_impedance_variation import TIV
-from tests.test_breath_detection import Breath, BreathDetection
+from tests.test_breath_detection import BreathDetection
environment = Path(
os.environ.get(
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index b21ad4863..16e3c2d0b 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -7,7 +7,6 @@
import numpy as np
import pytest
-from build.lib.eitprocessing.datahandling import breath
from eitprocessing.datahandling.breath import Breath
from eitprocessing.datahandling.continuousdata import ContinuousData
from eitprocessing.datahandling.datacollection import DataCollection
From fb89764a57c5636110443f2b1da70682c5499508 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Wed, 7 May 2025 09:44:03 +0200
Subject: [PATCH 13/27] Replace breath_detection_kwargs in EELI with
breath_detection
---
eitprocessing/parameters/eeli.py | 36 +++++++++++++++++++++++++-------
1 file changed, 29 insertions(+), 7 deletions(-)
diff --git a/eitprocessing/parameters/eeli.py b/eitprocessing/parameters/eeli.py
index 6622760d2..7565f0c2c 100644
--- a/eitprocessing/parameters/eeli.py
+++ b/eitprocessing/parameters/eeli.py
@@ -1,5 +1,6 @@
-from dataclasses import dataclass, field
-from typing import Literal, get_args
+import warnings
+from dataclasses import InitVar, dataclass, field
+from typing import Final, Literal, get_args
import numpy as np
@@ -10,15 +11,38 @@
from eitprocessing.features.breath_detection import BreathDetection
from eitprocessing.parameters import ParameterCalculation
+_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
+
+
+def _return_sentinal_breath_detection() -> BreathDetection:
+ # Returns a sential of a BreathDetection, which only exists to signal that the default value for breath_detection
+ # was used.
+ return _SENTINAL_BREATH_DETECTION
+
@dataclass
class EELI(ParameterCalculation):
"""Compute the end-expiratory lung impedance (EELI) per breath."""
+ breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
method: Literal["breath_detection"] = "breath_detection"
- breath_detection_kwargs: dict = field(default_factory=dict)
+ breath_detection_kwargs: InitVar[dict | None] = None
+
+ def __post_init__(self, breath_detection_kwargs: dict | None):
+ if breath_detection_kwargs is not None:
+ if self.breath_detection is not _SENTINAL_BREATH_DETECTION:
+ msg = (
+ "`breath_detection_kwargs` is deprecated, and can't be used at the same time as `breath_detection`."
+ )
+ raise TypeError(msg)
+
+ self.breath_detection = BreathDetection(**breath_detection_kwargs)
+ warnings.warn(
+ "`breath_detection_kwargs` is deprecated and will be removed soon. "
+ "Replace with `breath_detection=BreathDetection(**breath_detection_kwargs)`.",
+ DeprecationWarning,
+ )
- def __post_init__(self):
_methods = get_args(EELI.__dataclass_fields__["method"].type)
if self.method not in _methods:
msg = f"Method {self.method} is not valid. Use any of {', '.join(_methods)}"
@@ -66,9 +90,7 @@ def compute_parameter(
check_category(continuous_data, "impedance", raise_=True)
- bd_kwargs = self.breath_detection_kwargs.copy()
- breath_detection = BreathDetection(**bd_kwargs)
- breaths = breath_detection.find_breaths(continuous_data)
+ breaths = self.breath_detection.find_breaths(continuous_data)
if not len(breaths):
time = np.array([], dtype=float)
From 189600ae6af94312f05827a20b65bf916a46e9ed Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Wed, 7 May 2025 09:44:09 +0200
Subject: [PATCH 14/27] Update EELI tests
---
tests/test_eeli.py | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/tests/test_eeli.py b/tests/test_eeli.py
index 0c7e85f75..e411e7760 100644
--- a/tests/test_eeli.py
+++ b/tests/test_eeli.py
@@ -93,6 +93,16 @@ def test_eeli_values(repeat_n: int): # noqa: ARG001
assert np.array_equal(eeli_values, valley_values[1:])
+def test_bd_init():
+ assert EELI(breath_detection_kwargs={"minimum_duration": 0}) == EELI(
+ breath_detection=BreathDetection(minimum_duration=0)
+ )
+ with pytest.warns(DeprecationWarning):
+ EELI(breath_detection_kwargs={"minimum_duration": 0})
+ with pytest.raises(TypeError):
+ EELI(breath_detection_kwargs={"minimum_duration": 0}, breath_detection=BreathDetection(minimum_duration=0))
+
+
def test_with_data(draeger1: Sequence, pytestconfig: pytest.Config):
if pytestconfig.getoption("--cov"):
pytest.skip("Skip with option '--cov' so other tests can cover 100%.")
From 62ced5ff2bb1b9f9b88fb74caef7f6e28dab567b Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Wed, 7 May 2025 22:53:19 +0200
Subject: [PATCH 15/27] Update pixel_breath to consider phase shift
---
eitprocessing/features/pixel_breath.py | 35 +++++++++++++++++++++++---
1 file changed, 32 insertions(+), 3 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 550a83e33..1038a7d62 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -5,6 +5,7 @@
from typing import Final
import numpy as np
+from scipy import signal
from eitprocessing.datahandling.breath import Breath
from eitprocessing.datahandling.continuousdata import ContinuousData
@@ -14,6 +15,7 @@
from eitprocessing.features.breath_detection import BreathDetection
_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
+MAX_XCORR_LAG = 0.75
def _return_sentinal_breath_detection() -> BreathDetection:
@@ -47,6 +49,7 @@ class PixelBreath:
breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
breath_detection_kwargs: InitVar[dict | None] = None
allow_negative_amplitude: bool = True
+ correct_for_phase_shift: bool = True
def __post_init__(self, breath_detection_kwargs: dict | None):
if breath_detection_kwargs is not None:
@@ -63,7 +66,7 @@ def __post_init__(self, breath_detection_kwargs: dict | None):
DeprecationWarning,
)
- def find_pixel_breaths(
+ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
self,
eit_data: EITData,
continuous_data: ContinuousData,
@@ -162,6 +165,8 @@ def find_pixel_breaths(
pixel_breaths = np.full((len(continuous_breaths), n_rows, n_cols), None)
+ lags = signal.correlation_lags(len(continuous_data), len(continuous_data), mode="same")
+
for row, col in itertools.product(range(n_rows), range(n_cols)):
mean_tiv = mean_tiv_pixel[row, col]
@@ -171,10 +176,34 @@ def find_pixel_breaths(
if self.allow_negative_amplitude and mean_tiv < 0:
start_func, middle_func = np.argmax, np.argmin
+ lagged_indices_breath_middles = indices_breath_middles
else:
start_func, middle_func = np.argmin, np.argmax
- outsides = self._find_extreme_indices(pixel_impedance, indices_breath_middles, row, col, start_func)
+ cd = continuous_data.values
+ cd -= np.nanmean(cd)
+ pi = pixel_impedance[:, row, col]
+ pi -= np.nanmean(pixel_impedance[:, row, col])
+
+ if self.correct_for_phase_shift:
+ # search for maximum cross correlation within MAX_XCORR_LAG times the average
+ # duration of a breath
+ xcorr = signal.correlate(cd, pi, mode="same")
+ max_lag = MAX_XCORR_LAG * np.mean(np.diff(indices_breath_middles))
+ lag_range = (lags > -max_lag) & (lags < max_lag)
+ # TODO: if this does not work, implement robust peak detection
+ lag = lags[lag_range][np.argmax(xcorr[lag_range])]
+ # positive lag: pixel inflates later than summed
+
+ # shift search area
+ lagged_indices_breath_middles = indices_breath_middles - lag
+ lagged_indices_breath_middles = lagged_indices_breath_middles[
+ (lagged_indices_breath_middles >= 0) & (lagged_indices_breath_middles < len(cd))
+ ]
+ else:
+ lagged_indices_breath_middles = indices_breath_middles
+
+ outsides = self._find_extreme_indices(pixel_impedance, lagged_indices_breath_middles, row, col, start_func)
starts = outsides[:-1]
ends = outsides[1:]
middles = self._find_extreme_indices(pixel_impedance, outsides, row, col, middle_func)
@@ -242,5 +271,5 @@ def _find_extreme_indices(
are located for each time segment.
"""
return np.array(
- [function(pixel_impedance[times[i] : times[i + 1], row, col]) + times[i] for i in range(len(times) - 1)],
+ [function(pixel_impedance[t1:t2, row, col]) + t1 for t1, t2 in itertools.pairwise(times)],
)
From adb9e10459df149a08c409513599f6e555c4cd86 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Thu, 8 May 2025 10:08:35 +0200
Subject: [PATCH 16/27] Copy potentially non-writeable array
---
eitprocessing/features/pixel_breath.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 1038a7d62..63d16397e 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -180,9 +180,9 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
else:
start_func, middle_func = np.argmin, np.argmax
- cd = continuous_data.values
+ cd = np.copy(continuous_data.values)
cd -= np.nanmean(cd)
- pi = pixel_impedance[:, row, col]
+ pi = np.copy(pixel_impedance[:, row, col])
pi -= np.nanmean(pixel_impedance[:, row, col])
if self.correct_for_phase_shift:
From 70423ce672e109d2b4004254575996997f944730 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Thu, 8 May 2025 15:04:13 +0200
Subject: [PATCH 17/27] Fix grammar issues
---
eitprocessing/features/pixel_breath.py | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 63d16397e..b8bbdad4e 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -14,14 +14,14 @@
from eitprocessing.datahandling.sequence import Sequence
from eitprocessing.features.breath_detection import BreathDetection
-_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
+_SENTINEL_BREATH_DETECTION: Final = BreathDetection()
MAX_XCORR_LAG = 0.75
-def _return_sentinal_breath_detection() -> BreathDetection:
+def _return_sentinel_breath_detection() -> BreathDetection:
# Returns a sential of a BreathDetection, which only exists to signal that the default value for breath_detection
# was used.
- return _SENTINAL_BREATH_DETECTION
+ return _SENTINEL_BREATH_DETECTION
@dataclass
@@ -42,18 +42,18 @@ class PixelBreath:
```
Args:
- breath_detection (BreathDetection): BreathDetection object to use for detecing breaths.
allow_negative_amplitude (bool): whether to asume out-of-phase pixels have negative amplitude instead.
+ breath_detection (BreathDetection): BreathDetection object to use for detecting breaths.
"""
- breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
+ breath_detection: BreathDetection = field(default_factory=_return_sentinel_breath_detection)
breath_detection_kwargs: InitVar[dict | None] = None
allow_negative_amplitude: bool = True
correct_for_phase_shift: bool = True
def __post_init__(self, breath_detection_kwargs: dict | None):
if breath_detection_kwargs is not None:
- if self.breath_detection is not _SENTINAL_BREATH_DETECTION:
+ if self.breath_detection is not _SENTINEL_BREATH_DETECTION:
msg = (
"`breath_detection_kwargs` is deprecated, and can't be used at the same time as `breath_detection`."
)
From aa651863d0c129741ce7a498dfa3bba4b241a125 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Thu, 8 May 2025 15:04:25 +0200
Subject: [PATCH 18/27] Make PixelBreath keyword-only
---
eitprocessing/features/pixel_breath.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index b8bbdad4e..222e19e6d 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -24,7 +24,7 @@ def _return_sentinel_breath_detection() -> BreathDetection:
return _SENTINEL_BREATH_DETECTION
-@dataclass
+@dataclass(kw_only=True)
class PixelBreath:
"""Algorithm for detecting timing of pixel breaths in pixel impedance data.
From cc1518aed0b90fe94c1fbe1beaf90b560b0de70f Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Fri, 9 May 2025 22:12:28 +0200
Subject: [PATCH 19/27] Replace competing boolean arguments with mode argument
---
eitprocessing/features/pixel_breath.py | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 222e19e6d..cb2283cc2 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -2,7 +2,7 @@
import warnings
from collections.abc import Callable
from dataclasses import InitVar, dataclass, field
-from typing import Final
+from typing import Final, Literal
import numpy as np
from scipy import signal
@@ -42,14 +42,13 @@ class PixelBreath:
```
Args:
- allow_negative_amplitude (bool): whether to asume out-of-phase pixels have negative amplitude instead.
breath_detection (BreathDetection): BreathDetection object to use for detecting breaths.
+ phase_correction_mode: How to resolve pixels that are out-of-phase. Defaults to "negative amplitude".
"""
breath_detection: BreathDetection = field(default_factory=_return_sentinel_breath_detection)
breath_detection_kwargs: InitVar[dict | None] = None
- allow_negative_amplitude: bool = True
- correct_for_phase_shift: bool = True
+ phase_correction_mode: Literal["negative amplitude", "phase shift", "none"] | None = "negative amplitude"
def __post_init__(self, breath_detection_kwargs: dict | None):
if breath_detection_kwargs is not None:
@@ -167,6 +166,20 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
lags = signal.correlation_lags(len(continuous_data), len(continuous_data), mode="same")
+ match self.phase_correction_mode:
+ case "negative amplitude":
+ allow_negative_amplitude = True
+ correct_for_phase_shift = None
+ case "phase shift":
+ allow_negative_amplitude = False
+ correct_for_phase_shift = True
+ case "none" | None:
+ allow_negative_amplitude = False
+ correct_for_phase_shift = False
+ case _:
+ msg = f"Unknown phase correction mode ({self.phase_correction_mode})."
+ raise ValueError(msg)
+
for row, col in itertools.product(range(n_rows), range(n_cols)):
mean_tiv = mean_tiv_pixel[row, col]
@@ -174,7 +187,7 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
# pixel has no amplitude
continue
- if self.allow_negative_amplitude and mean_tiv < 0:
+ if allow_negative_amplitude and mean_tiv < 0:
start_func, middle_func = np.argmax, np.argmin
lagged_indices_breath_middles = indices_breath_middles
else:
@@ -185,7 +198,7 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
pi = np.copy(pixel_impedance[:, row, col])
pi -= np.nanmean(pixel_impedance[:, row, col])
- if self.correct_for_phase_shift:
+ if correct_for_phase_shift:
# search for maximum cross correlation within MAX_XCORR_LAG times the average
# duration of a breath
xcorr = signal.correlate(cd, pi, mode="same")
From cee67e3a4583e58176df628f20fd4989fe2ae6ad Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Fri, 9 May 2025 22:13:21 +0200
Subject: [PATCH 20/27] Update documentation to match new PixelBreath workings
---
eitprocessing/features/pixel_breath.py | 60 ++++++++++++++------------
1 file changed, 32 insertions(+), 28 deletions(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index cb2283cc2..1b87e72c3 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -33,6 +33,16 @@ class PixelBreath:
of inspiration and expiration. These points are then used to find the start/end of pixel
inspiration/expiration in pixel impedance data.
+ Some pixel breaths may be phase shifted (inflation starts and ends later compared to others, e.g., due to pendelluft
+ or late airway opening). Other pixel breaths may have a negative amplitude (impedance decreases during inspiration,
+ e.g., due to pleural effusion or reconstruction artifacts). It is not always possible to determine whether a pixel
+ is out of phase or has a negative amplitude. PixelBreath has three different phase correction modes. In 'negative
+ amplitude' mode (default), pixels that have a decrease in amplitude between the start and end of globally defined
+ inspiration, will have a negative amplitude and smaller phase shift. In 'phase shift' mode, all pixel breaths will
+ have positive amplitudes, but can have large phase shifts. In 'none'/`None` mode, all pixels are assumed to be
+ within rouglhy -90 to 90 degrees of phase. Note that the 'none' mode can lead to unexpected results, such as
+ ultra-short (down to 2 frames) or very long breaths.
+
Example:
```
>>> pi = PixelBreath()
@@ -75,36 +85,29 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
) -> IntervalData:
"""Find pixel breaths in the data.
- This method finds the pixel start/end of inspiration/expiration
- based on the start/end of inspiration/expiration as detected
- in the continuous data.
-
- If pixel impedance is in phase (within 180 degrees) with the continuous data,
- the start of breath of that pixel is defined as the local minimum between
- two end-inspiratory points in the continuous signal.
- The end of expiration of that pixel is defined as the local minimum between two
- consecutive end-inspiratory points in the continuous data.
- The end of inspiration of that pixel is defined as the local maximum between
- the start of inspiration and end of expiration of that pixel.
-
- If pixel impedance is out of phase with the continuous signal,
- the start of inspiration of that pixel is defined as the local maximum between
- two end-inspiration points in the continuous data.
- The end of expiration of that pixel is defined as the local maximum between two
- consecutive end-inspiratory points in the continuous data.
- The end of inspiration of that pixel is defined as the local minimum between
- the start of inspiration and end of expiration of that pixel.
-
- Pixel breaths are constructed as a valley-peak-valley combination,
- representing the start of inspiration, the end of inspiration/start of
- expiration, and end of expiration.
+ This method finds the pixel start/end of inspiration/expiration based on the start/end of inspiration/expiration
+ as detected in the continuous data.
+
+ For most pixels, the start of a breath (start inspiration) is the valley between the middles (start of
+ expiration) of the globally defined breaths on either side. The end of a pixel breath is the start of the next
+ pixel breath. The middle of the pixel breath is the peak between the start and end of the pixel breath.
+
+ If the pixel is out of phase or has negative amplitude, the definition of the breath depends on the phase
+ correction mode. In 'negative amplitude' mode, the start of a breath is the peak between the middles of the
+ globally defined breaths on either side, while the middle of the pixel breath is the valley of the start and end
+ of the pixel breath. In 'phase shift' mode, first the phase shift between the pixel impedance and global
+ impedance is determined as the highest crosscorrelation between the signals near a phase shift of 0. The start
+ of breath is the valley between the phase shifted middles of the globally defined breaths on either side.
+
+ Pixel breaths are constructed as a valley-peak-valley combination, representing the start of inspiration, the
+ end of inspiration/start of expiration, and end of expiration.
Args:
- eit_data: EITData to apply the algorithm to
- continuous_data: ContinuousData to use for global breath detection
+ eit_data: EITData to apply the algorithm to.
+ continuous_data: ContinuousData to use for global breath detection.
result_label: label of the returned IntervalData object, defaults to `'pixel_breaths'`.
- sequence: optional, Sequence that contains the object to detect pixel breaths in,
- and/or to store the result in.
+ sequence: optional, Sequence that contains the object to detect pixel breaths in, and/or to store the result
+ in.
store: whether to store the result in the sequence, defaults to `True` if a Sequence if provided.
Returns:
@@ -205,8 +208,9 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
max_lag = MAX_XCORR_LAG * np.mean(np.diff(indices_breath_middles))
lag_range = (lags > -max_lag) & (lags < max_lag)
# TODO: if this does not work, implement robust peak detection
- lag = lags[lag_range][np.argmax(xcorr[lag_range])]
+
# positive lag: pixel inflates later than summed
+ lag = lags[lag_range][np.argmax(xcorr[lag_range])]
# shift search area
lagged_indices_breath_middles = indices_breath_middles - lag
From de4db425b75ec072b67d7a300d66696e84074a34 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Fri, 9 May 2025 22:14:07 +0200
Subject: [PATCH 21/27] Fix existing test with short data resulting in all None
values
---
tests/test_pixel_breath.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index 16e3c2d0b..d8921e613 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -168,7 +168,7 @@ def none_sequence():
def mock_compute_pixel_parameter(mean: int):
- def _mock(*_args, **_kwargs) -> np.ndarray:
+ def _mock(*_args, **_kwargs) -> SparseData:
return SparseData(
label="mock_sparse_data",
name="Tidal impedance variation",
@@ -347,12 +347,13 @@ def test_with_data(draeger1: Sequence, timpel1: Sequence, pytestconfig: pytest.C
draeger1 = copy.deepcopy(draeger1)
timpel1 = copy.deepcopy(timpel1)
for sequence in draeger1, timpel1:
- ssequence = sequence[0:500]
+ ssequence = sequence
pi = PixelBreath()
eit_data = ssequence.eit_data["raw"]
cd = ssequence.continuous_data["global_impedance_(raw)"]
pixel_breaths = pi.find_pixel_breaths(eit_data, cd)
test_result = np.stack(pixel_breaths.values)
+ assert not np.all(test_result == None)
_, n_rows, n_cols = test_result.shape
for row, col in itertools.product(range(n_rows), range(n_cols)):
From 3178504e6ae5943cd75f23f19dda20cf7b7b6c11 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Fri, 9 May 2025 22:14:29 +0200
Subject: [PATCH 22/27] Add test for different phase corrections modes
---
tests/test_pixel_breath.py | 41 ++++++++++++++++++++++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index d8921e613..e063bee10 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -380,3 +380,44 @@ def test_with_data(draeger1: Sequence, timpel1: Sequence, pytestconfig: pytest.C
for breath in filtered_values:
# Test whether the indices are in the proper order within a breath
assert breath.start_time < breath.middle_time < breath.end_time
+
+
+def test_phase_modes(draeger1: Sequence, pytestconfig: pytest.Config):
+ if pytestconfig.getoption("--cov"):
+ pytest.skip("Skip with option '--cov' so other tests can cover 100%.")
+
+ ssequence = draeger1
+ eit_data = ssequence.eit_data["raw"]
+
+ # reduce the pixel set to middly 'well-behaved' pixels with positive TIV
+ eit_data.pixel_impedance = eit_data.pixel_impedance[:, 10:23, 10:23]
+
+ # flip a single pixel, so the differences between algorithms becomes predictable
+ eit_data.pixel_impedance[:, 6, 6] = -eit_data.pixel_impedance[:, 6, 6]
+
+ cd = ssequence.continuous_data["global_impedance_(raw)"]
+
+ # replace the 'global' data with the sum of the middly pixels
+ cd.values = np.sum(eit_data.pixel_impedance, axis=(1, 2))
+
+ pb_negative_amplitude = PixelBreath(phase_correction_mode="negative amplitude").find_pixel_breaths(eit_data, cd)
+ pb_phase_shift = PixelBreath(phase_correction_mode="phase shift").find_pixel_breaths(eit_data, cd)
+
+ # results are not compared, other than for length; just make sure it runs
+ pb_none = PixelBreath(phase_correction_mode="none").find_pixel_breaths(eit_data, cd)
+
+ assert len(pb_negative_amplitude) == len(pb_phase_shift) == len(pb_none)
+
+ # all breaths, except for the first and last, should have been detected
+ assert not np.any(np.array(pb_negative_amplitude.values)[1:-1] == None)
+ assert not np.any(np.array(pb_phase_shift.values)[1:-1] == None)
+
+ same_pixel_timing = np.array(pb_negative_amplitude.values) == np.array(pb_phase_shift.values)
+ assert not np.all(same_pixel_timing)
+ assert not np.any(same_pixel_timing[1:-1, 6, 6]) # the single flipped pixel
+ assert np.all(same_pixel_timing[1:-1, :6, :]) # all pixels in the rows above match
+ assert np.all(same_pixel_timing[1:-1, 7:, :]) # all pixels in the rows below match
+ assert np.all(same_pixel_timing[1:-1, :, :6]) # all pixels in the columns to the left match
+ assert np.all(same_pixel_timing[1:-1, :, 7:]) # all pixels in the columns to the right match
+ assert np.all(same_pixel_timing[0, :, :]) # all first values match, because they are all None
+ assert np.all(same_pixel_timing[-1, :, :]) # all last values match, because they are all None
From b21065314fe1998f966f8c60ab8aaec9fb158742 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Fri, 9 May 2025 22:32:00 +0200
Subject: [PATCH 23/27] Fix linting issues
---
tests/test_pixel_breath.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index e063bee10..a78608a20 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -353,7 +353,7 @@ def test_with_data(draeger1: Sequence, timpel1: Sequence, pytestconfig: pytest.C
cd = ssequence.continuous_data["global_impedance_(raw)"]
pixel_breaths = pi.find_pixel_breaths(eit_data, cd)
test_result = np.stack(pixel_breaths.values)
- assert not np.all(test_result == None)
+ assert not np.all(test_result == None) # noqa: E711
_, n_rows, n_cols = test_result.shape
for row, col in itertools.product(range(n_rows), range(n_cols)):
@@ -409,8 +409,8 @@ def test_phase_modes(draeger1: Sequence, pytestconfig: pytest.Config):
assert len(pb_negative_amplitude) == len(pb_phase_shift) == len(pb_none)
# all breaths, except for the first and last, should have been detected
- assert not np.any(np.array(pb_negative_amplitude.values)[1:-1] == None)
- assert not np.any(np.array(pb_phase_shift.values)[1:-1] == None)
+ assert not np.any(np.array(pb_negative_amplitude.values)[1:-1] == None) # noqa: E711
+ assert not np.any(np.array(pb_phase_shift.values)[1:-1] == None) # noqa: E711
same_pixel_timing = np.array(pb_negative_amplitude.values) == np.array(pb_phase_shift.values)
assert not np.all(same_pixel_timing)
From 06e0c574c710e960fd866048f2a84e8cd08633a3 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Mon, 12 May 2025 16:15:10 +0200
Subject: [PATCH 24/27] Fix sentinel spelling
---
eitprocessing/parameters/eeli.py | 10 +++++-----
.../parameters/tidal_impedance_variation.py | 20 +++++++++----------
2 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/eitprocessing/parameters/eeli.py b/eitprocessing/parameters/eeli.py
index 7565f0c2c..abc6dbd99 100644
--- a/eitprocessing/parameters/eeli.py
+++ b/eitprocessing/parameters/eeli.py
@@ -11,26 +11,26 @@
from eitprocessing.features.breath_detection import BreathDetection
from eitprocessing.parameters import ParameterCalculation
-_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
+_SENTINEL_BREATH_DETECTION: Final = BreathDetection()
-def _return_sentinal_breath_detection() -> BreathDetection:
+def _sentinel_breath_detection() -> BreathDetection:
# Returns a sential of a BreathDetection, which only exists to signal that the default value for breath_detection
# was used.
- return _SENTINAL_BREATH_DETECTION
+ return _SENTINEL_BREATH_DETECTION
@dataclass
class EELI(ParameterCalculation):
"""Compute the end-expiratory lung impedance (EELI) per breath."""
- breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
+ breath_detection: BreathDetection = field(default_factory=_sentinel_breath_detection)
method: Literal["breath_detection"] = "breath_detection"
breath_detection_kwargs: InitVar[dict | None] = None
def __post_init__(self, breath_detection_kwargs: dict | None):
if breath_detection_kwargs is not None:
- if self.breath_detection is not _SENTINAL_BREATH_DETECTION:
+ if self.breath_detection is not _SENTINEL_BREATH_DETECTION:
msg = (
"`breath_detection_kwargs` is deprecated, and can't be used at the same time as `breath_detection`."
)
diff --git a/eitprocessing/parameters/tidal_impedance_variation.py b/eitprocessing/parameters/tidal_impedance_variation.py
index c476ebbc0..315df03b8 100644
--- a/eitprocessing/parameters/tidal_impedance_variation.py
+++ b/eitprocessing/parameters/tidal_impedance_variation.py
@@ -17,19 +17,19 @@
from eitprocessing.features.pixel_breath import PixelBreath
from eitprocessing.parameters import ParameterCalculation
-_SENTINAL_PIXEL_BREATH: Final = PixelBreath()
-_SENTINAL_BREATH_DETECTION: Final = BreathDetection()
+_SENTINEL_PIXEL_BREATH: Final = PixelBreath()
+_SENTINEL_BREATH_DETECTION: Final = BreathDetection()
-def _return_sentinal_pixel_breath() -> PixelBreath:
+def _sentinel_pixel_breath() -> PixelBreath:
# Returns a sential of a PixelBreath, which only exists to signal that the default value for pixel_breath was used.
- return _SENTINAL_PIXEL_BREATH
+ return _SENTINEL_PIXEL_BREATH
-def _return_sentinal_breath_detection() -> BreathDetection:
+def _sentinel_breath_detection() -> BreathDetection:
# Returns a sential of a BreathDetection, which only exists to signal that the default value for breath_detection
# was used.
- return _SENTINAL_BREATH_DETECTION
+ return _SENTINEL_BREATH_DETECTION
@dataclass
@@ -37,11 +37,11 @@ class TIV(ParameterCalculation):
"""Compute the tidal impedance variation (TIV) per breath."""
method: Literal["extremes"] = "extremes"
- breath_detection: BreathDetection = field(default_factory=_return_sentinal_breath_detection)
+ breath_detection: BreathDetection = field(default_factory=_sentinel_breath_detection)
breath_detection_kwargs: InitVar[dict | None] = None
# The default is a sentinal that will be replaced in __post_init__
- pixel_breath: PixelBreath = field(default_factory=_return_sentinal_pixel_breath)
+ pixel_breath: PixelBreath = field(default_factory=_sentinel_pixel_breath)
def __post_init__(self, breath_detection_kwargs: dict | None) -> None:
if self.method != "extremes":
@@ -49,7 +49,7 @@ def __post_init__(self, breath_detection_kwargs: dict | None) -> None:
raise NotImplementedError(msg)
if breath_detection_kwargs is not None:
- if self.breath_detection is not _SENTINAL_BREATH_DETECTION:
+ if self.breath_detection is not _SENTINEL_BREATH_DETECTION:
msg = (
"`breath_detection_kwargs` is deprecated, and can't be used at the same time as `breath_detection`."
)
@@ -62,7 +62,7 @@ def __post_init__(self, breath_detection_kwargs: dict | None) -> None:
DeprecationWarning,
)
- if self.pixel_breath is _SENTINAL_PIXEL_BREATH:
+ if self.pixel_breath is _SENTINEL_PIXEL_BREATH:
# If no value was provided at initialization, PixelBreath should use the same BreathDetection object as TIV.
# However, a default factory cannot be used because it can't access self.breath_detection. The sentinal
# object is replaced here (only if pixel_breath was not provided) with the correct BreathDetection object.
From 9237110eb244a01213ef8f9cda428c041def3499 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Mon, 12 May 2025 16:34:44 +0200
Subject: [PATCH 25/27] Fix selecting short time with too few breaths
---
tests/test_parameter_tiv.py | 9 +++------
1 file changed, 3 insertions(+), 6 deletions(-)
diff --git a/tests/test_parameter_tiv.py b/tests/test_parameter_tiv.py
index 29c6a21bc..9d4f6a980 100644
--- a/tests/test_parameter_tiv.py
+++ b/tests/test_parameter_tiv.py
@@ -491,16 +491,13 @@ def test_with_data(draeger1: Sequence, timpel1: Sequence, pytestconfig: pytest.C
# Iterate over both sequences (draeger1 and timpel1)
for sequence in draeger1, timpel1:
- # Select a subset of the sequence for testing (first 500 samples)
- ssequence = sequence[0:500]
-
# Initialize the TIV object
tiv = TIV()
- eit_data = ssequence.eit_data["raw"]
- cd = ssequence.continuous_data["global_impedance_(raw)"]
+ eit_data = sequence.eit_data["raw"]
+ cd = sequence.continuous_data["global_impedance_(raw)"]
result_continuous = tiv.compute_continuous_parameter(cd, tiv_method="inspiratory")
- result_pixel = tiv.compute_pixel_parameter(eit_data, cd, ssequence)
+ result_pixel = tiv.compute_pixel_parameter(eit_data, cd, sequence)
arr_result_continuous = np.stack(result_continuous.values)
arr_result_pixel = np.stack(result_pixel.values)
From 0c495b451aa37f0010e6fd947c2d15820329f97a Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Mon, 12 May 2025 16:40:36 +0200
Subject: [PATCH 26/27] Fix runtime warning for empty slices
---
eitprocessing/features/pixel_breath.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/eitprocessing/features/pixel_breath.py b/eitprocessing/features/pixel_breath.py
index 1b87e72c3..a7a6a65a9 100644
--- a/eitprocessing/features/pixel_breath.py
+++ b/eitprocessing/features/pixel_breath.py
@@ -199,7 +199,8 @@ def find_pixel_breaths( # noqa: C901, PLR0912, PLR0915
cd = np.copy(continuous_data.values)
cd -= np.nanmean(cd)
pi = np.copy(pixel_impedance[:, row, col])
- pi -= np.nanmean(pixel_impedance[:, row, col])
+ if not np.all(np.isnan(pi)):
+ pi -= np.nanmean(pixel_impedance[:, row, col])
if correct_for_phase_shift:
# search for maximum cross correlation within MAX_XCORR_LAG times the average
From 12be56736028244c2ac46a605482db663ddb2a62 Mon Sep 17 00:00:00 2001
From: Peter Somhorst
Date: Mon, 12 May 2025 16:41:12 +0200
Subject: [PATCH 27/27] Catch or fix deprecation warnings in tests
---
tests/test_eeli.py | 10 ++++++----
tests/test_parameter_tiv.py | 6 ++++--
tests/test_pixel_breath.py | 5 +++--
tests/test_sequence_data.py | 1 +
4 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/tests/test_eeli.py b/tests/test_eeli.py
index e411e7760..73f3e2b6b 100644
--- a/tests/test_eeli.py
+++ b/tests/test_eeli.py
@@ -86,7 +86,8 @@ def test_eeli_values(repeat_n: int): # noqa: ARG001
values=data,
sample_frequency=sample_frequency,
)
- eeli = EELI(breath_detection_kwargs={"minimum_duration": 0})
+ with pytest.warns(DeprecationWarning):
+ eeli = EELI(breath_detection_kwargs={"minimum_duration": 0})
eeli_values = eeli.compute_parameter(cd).values
assert len(eeli_values) == expected_n_breaths
@@ -94,9 +95,10 @@ def test_eeli_values(repeat_n: int): # noqa: ARG001
def test_bd_init():
- assert EELI(breath_detection_kwargs={"minimum_duration": 0}) == EELI(
- breath_detection=BreathDetection(minimum_duration=0)
- )
+ with pytest.warns(DeprecationWarning):
+ assert EELI(breath_detection_kwargs={"minimum_duration": 0}) == EELI(
+ breath_detection=BreathDetection(minimum_duration=0)
+ )
with pytest.warns(DeprecationWarning):
EELI(breath_detection_kwargs={"minimum_duration": 0})
with pytest.raises(TypeError):
diff --git a/tests/test_parameter_tiv.py b/tests/test_parameter_tiv.py
index 9d4f6a980..07c1b13b2 100644
--- a/tests/test_parameter_tiv.py
+++ b/tests/test_parameter_tiv.py
@@ -148,7 +148,7 @@ def test_tiv_initialization():
assert tiv.breath_detection == BreathDetection()
-def test_depricated():
+def test_deprecated():
with pytest.warns(DeprecationWarning):
_ = TIV(breath_detection_kwargs={})
@@ -156,7 +156,9 @@ def test_depricated():
_ = TIV(breath_detection=BreathDetection(), breath_detection_kwargs={})
bd_kwargs = {"minimum_duration": 10, "averaging_window_duration": 100.0}
- assert TIV(breath_detection_kwargs=bd_kwargs).breath_detection == BreathDetection(**bd_kwargs)
+
+ with pytest.warns(DeprecationWarning):
+ assert TIV(breath_detection_kwargs=bd_kwargs).breath_detection == BreathDetection(**bd_kwargs)
def test_compute_parameter_type_error():
diff --git a/tests/test_pixel_breath.py b/tests/test_pixel_breath.py
index a78608a20..0fe3d2855 100644
--- a/tests/test_pixel_breath.py
+++ b/tests/test_pixel_breath.py
@@ -184,7 +184,7 @@ def _mock(*_args, **_kwargs) -> SparseData:
return _mock
-def test_depricated():
+def test_deprecated():
with pytest.warns(DeprecationWarning):
_ = PixelBreath(breath_detection_kwargs={})
@@ -192,7 +192,8 @@ def test_depricated():
_ = PixelBreath(breath_detection=BreathDetection(), breath_detection_kwargs={})
bd_kwargs = {"minimum_duration": 10, "averaging_window_duration": 100.0}
- assert PixelBreath(breath_detection_kwargs=bd_kwargs).breath_detection == BreathDetection(**bd_kwargs)
+ with pytest.warns(DeprecationWarning):
+ assert PixelBreath(breath_detection_kwargs=bd_kwargs).breath_detection == BreathDetection(**bd_kwargs)
def test__compute_breaths():
diff --git a/tests/test_sequence_data.py b/tests/test_sequence_data.py
index 38b905341..deea26952 100644
--- a/tests/test_sequence_data.py
+++ b/tests/test_sequence_data.py
@@ -17,6 +17,7 @@ def create_continuous_data_object():
category="other",
time=np.array([]),
values=np.array([]),
+ sample_frequency=20,
)