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, )