From 79644eb27f2da9bfbac9c7ef62143c2f2c069dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Wed, 6 Aug 2025 07:45:28 +0200 Subject: [PATCH 1/7] added test --- test | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test diff --git a/test b/test new file mode 100644 index 0000000..e69de29 From ce35823bf307c4020c9de95ca1edf02ad5014288 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Wed, 6 Aug 2025 07:56:04 +0200 Subject: [PATCH 2/7] update --- test | 1 + 1 file changed, 1 insertion(+) diff --git a/test b/test index e69de29..7f71f30 100644 --- a/test +++ b/test @@ -0,0 +1 @@ +this is an update From 7f79899ed72e4a537b6135c3eb2d63465ed4de58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Wed, 6 Aug 2025 07:56:58 +0200 Subject: [PATCH 3/7] deleted test file --- test | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test diff --git a/test b/test deleted file mode 100644 index 7f71f30..0000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -this is an update From d4f66d9088326aa130e2a73232ec9cea76d4bb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Wed, 6 Aug 2025 10:07:48 +0200 Subject: [PATCH 4/7] enhance kern to detect exp_time inbetween resp_time and etco2_time, by tracing back co2 form etco2 until zero or resp_time --- src/vitabel/vitals.py | 335 ++++++++++++++++++++++++------------------ 1 file changed, 191 insertions(+), 144 deletions(-) diff --git a/src/vitabel/vitals.py b/src/vitabel/vitals.py index 7e1fb02..c2ae32f 100644 --- a/src/vitabel/vitals.py +++ b/src/vitabel/vitals.py @@ -1503,93 +1503,66 @@ def shocks(self): "Error. Different Defibrillation keys contain different timestamp. Cannot construct single DataFrame from this" ) return None - + ### def compute_etco2_and_ventilations( self, source: Channel | str = "capnography", - mode: Literal['threshold', 'filter'] = 'filter', + mode: Literal["filter", "filter_extended", "threshold"] = "filter", breath_threshold: float = 2, etco2_threshold: float = 3, **kwargs, ): - """Computes end-tidal CO2 (etCO₂) values and timestamps of ventilations from the - capnography waveform, and adds them as labels. - - The capnography signal must be present as a channel named 'capnography'. Two - detection methods are supported: - - - ``'filter'``: An unpublished method by Wolfgang Kern (default). - - ``'threshold'``: The method described by Aramendi et al. in - :cite:`10.1016/j.resuscitation.2016.08.033`. - - Parameters - ---------- - mode - Method to use for detecting ventilations from the CO₂ signal. - breath_threshold - Threshold below which a minimum is identified as a ventilation (default: 2 mmHg). - Used by the ``'filter'`` method. - etco2_threshold - Threshold above which a maximum is identified as an etCO₂ value of an expiration - (default: 3 mmHg). Used by the ``'filter'`` method. """ - # Support legacy parameter name + Computes etCO₂ values and timestamps of ventilations from capnography waveform. + Supports three methods: + - 'filter': Wolfgang Kern (unpublished) + - 'filter_extended': like 'filter' + detects exhalation onset (1st derivative) + - 'threshold': Aramendi et al., 2016 + """ + if 'breaththresh' in kwargs: if breath_threshold is not None: raise TypeError("Cannot specify both 'breath_threshold' and legacy 'breaththresh'") breath_threshold = kwargs.pop('breaththresh') - logger.warning( - "The keyword argument breaththresh is deprecated, " - "use breath_threshold instead" - ) - + logger.warning("The keyword argument breaththresh is deprecated, use breath_threshold instead") + if isinstance(source, str): source = self.get_channel(name=source) - if not isinstance(source, Channel): - raise ValueError( - f"No valid capnography channel specified. Please make sure a channel named '{source}' " - "is added to the collection, or specify a suitable channel via " - "the source argument directly or by name" - ) - - co2_data = source.get_data() # get data - cotime, co = co2_data.time_index, co2_data.data - - freq = np.timedelta64(1, "s") / np.nanmedian(cotime.diff()) - cotime = np.asarray(cotime) - co = np.asarray(co) - if mode == "filter": # Wolfgang Kern's unpublished method + raise ValueError("Invalid capnography channel specified.") + + # Helper: metadata for all labels + metadata = { + "creator": "automatic", + "creation_date": pd.Timestamp.now(), + "creation_mode": mode, + } + + # Load signal + co2_data = source.get_data() + cotime, co = np.asarray(co2_data.time_index), np.asarray(co2_data.data) + freq = np.timedelta64(1, "s") / np.nanmedian(np.diff(cotime)) + + # Mode 1: FILTER (Kern original) + def _run_filter(): but = sgn.butter(4, 1 * 2 / freq, btype="lowpass", output="sos") - co2 = sgn.sosfiltfilt(but, co) # Filter forwarsd and backward - et_index = sgn.find_peaks(co2, distance=1 * freq, height=etco2_threshold)[ - 0 - ] # find peaks of filtered signal as markers for etco2 - resp_index = sgn.find_peaks( - -co2, distance=1 * freq, height=-breath_threshold - )[ # find dips of filtered signal as markers for ventilations - 0 - ] - - etco2time = cotime[et_index] # take elements on this markers + co2 = sgn.sosfiltfilt(but, co) + et_index = sgn.find_peaks(co2, distance=1 * freq, height=etco2_threshold)[0] + resp_index = sgn.find_peaks(-co2, distance=1 * freq, height=-breath_threshold)[0] + etco2time = cotime[et_index] etco2 = co[et_index] resptime = cotime[resp_index] resp_height = co[resp_index] - - # initialize search for other markers + + # --- same correction logic as before --- k = 0 del_resp = [] more_resp_flag = False min_resp_height = np.nan min_resp_index = np.nan - - # look a signal before first ventilation - co2_maxtime = etco2time[(etco2time < resptime[0])] co2_max = etco2[(etco2time < resptime[0])] netco2 = len(co2_maxtime) - - # when there is more than a single maximum before first respiration, take only largest one if netco2 > 1: k_max = np.argmax(co2_max) for j in range(k + k_max, k, -1): @@ -1599,23 +1572,14 @@ def compute_etco2_and_ventilations( etco2 = np.delete(etco2, k + 1) etco2time = np.delete(etco2time, k + 1) k += 1 - - # if there is no maximum - elif netco2 == 0: - pass - # if there is a single maximum - else: + elif netco2 == 1: k += 1 - for i, resp in enumerate(resptime[:-1]): next_resp = resptime[i + 1] - # check maxima until next respiration same as bevfore - co2_maxtime = etco2time[ - (etco2time >= resp) & (etco2time < next_resp) - ] + co2_maxtime = etco2time[(etco2time >= resp) & (etco2time < next_resp)] co2_max = etco2[(etco2time >= resp) & (etco2time < next_resp)] netco2 = len(co2_maxtime) - if netco2 > 1: # take largest one + if netco2 > 1: k_max = np.argmax(co2_max) for j in range(k + k_max, k, -1): etco2 = np.delete(etco2, k) @@ -1637,34 +1601,128 @@ def compute_etco2_and_ventilations( del_resp.append(i) min_resp_height = resp_height[i + 1] min_resp_index = i + 1 - more_resp_flag = True else: del_resp.append(i + 1) min_resp_height = resp_height[i] min_resp_index = i more_resp_flag = True - else: more_resp_flag = False k += 1 - del_resp.sort() for i in del_resp[::-1]: resptime = np.delete(resptime, i) + return resptime, etco2time, etco2 + + ''' + # Mode 2: FILTER EXTENDED + def _run_filter_extended(): + resptime, etco2time, etco2 = _run_filter() + dco = np.gradient(co) + exptime = [] + for r in resptime: + future_ets = etco2time[etco2time > r] + if len(future_ets) == 0: + continue + next_et = future_ets[0] + i_start = np.searchsorted(cotime, r) + i_end = np.searchsorted(cotime, next_et) + if i_end > i_start + 1: + max_slope_idx = i_start + np.argmax(dco[i_start:i_end]) + exptime.append(cotime[max_slope_idx]) + return resptime, etco2time, etco2, exptime + + + # Mode 2: FILTER EXTENDED + def _run_filter_extended(): + resptime, etco2time, etco2 = _run_filter() + dco = np.gradient(co) + exptime = [] + for r in resptime: + future_ets = etco2time[etco2time > r] + if len(future_ets) == 0: + continue + next_et = future_ets[0] + i_start = np.searchsorted(cotime, r) + i_end = np.searchsorted(cotime, next_et) + if i_end > i_start + 1: + # Find first positive slope (initial CO2 rise) + for i in range(i_start, i_end): + if dco[i] > 0: + exptime.append(cotime[i]) + break # only the first rising point + return resptime, etco2time, etco2, exptime + + def _run_filter_extended(): + resptime, etco2time, etco2 = _run_filter() + dco = np.gradient(co) + exptime = [] + + for r in resptime: + future_ets = etco2time[etco2time > r] + if len(future_ets) == 0: + continue + next_et = future_ets[0] + + i_start = np.searchsorted(cotime, r) + i_end = np.searchsorted(cotime, next_et) + if i_end <= i_start + 1: + continue + + for i in range(i_start, i_end): + if dco[i] > 0: + # Candidate point for first rise + co2_rise_start = i + co2_segment = co[co2_rise_start:i_end] + + # Ensure no drop in CO₂ after this point (monotonic increase) + if np.all(np.diff(co2_segment) >= 0): + exptime.append(cotime[co2_rise_start]) + break # Found valid first rise; co2 zero again before etco2 + return resptime, etco2time, etco2, exptime + ''' + + def _run_filter_extended(): + resptime, etco2time, etco2 = _run_filter() + exptime = [] + + for r in resptime: + future_ets = etco2time[etco2time > r] + if len(future_ets) == 0: + continue + next_et = future_ets[0] + + i_et = np.searchsorted(cotime, next_et) + i_r = np.searchsorted(cotime, r) + + # Search backwards from etCO2 peak to find baseline + exp_found = False + for i in range(i_et, i_r, -1): + if co[i] == 0: + exptime.append(cotime[i]) + exp_found = True + break + + if not exp_found: + exptime.append(r) # fallback if no valid base found + + + return resptime, etco2time, etco2, exptime + - elif mode == "threshold": # Aramendi et al., 2016 + + + # Mode 3: THRESHOLD (Aramendi) + def _run_threshold(): but = sgn.butter(4, 10 * 2 / freq, btype="lowpass", output="sos") - co2 = sgn.sosfiltfilt(but, co) # Filter forwarsd and backward + co2 = sgn.sosfiltfilt(but, co) d = freq * (co2[1:] - co2[:-1]) exp_index2 = sgn.find_peaks(d, height=0.35 * freq)[0] ins_index2 = sgn.find_peaks(-d, height=0.45 * freq)[0] - final_flag = False - ins_index3 = [] - exp_index3 = [] - j_ins = 0 - j_exp = 0 + ins_index3, exp_index3 = [], [] + j_ins, j_exp = 0, 0 while not final_flag: ins_index3.append(ins_index2[j_ins]) while exp_index2[j_exp] < ins_index2[j_ins]: @@ -1678,75 +1736,64 @@ def compute_etco2_and_ventilations( if j_ins == len(ins_index2) - 1: final_flag = True break - - resptime = [] - etco2time = [] - etco2 = [] - Th1_list = [5 for i in range(5)] - Th2_list = [0.5 for i in range(5)] - Th3_list = [0 for i in range(5)] - + resptime, etco2time, etco2 = [], [], [] + Th1_list = [5] * 5 + Th2_list = [0.5] * 5 + Th3_list = [0] * 5 k = 0 - for i_ins, i_exp, i_next_ins in zip( - ins_index3[:-1], exp_index3[:-1], ins_index3[1:] - ): + for i_ins, i_exp, i_next_ins in zip(ins_index3[:-1], exp_index3[:-1], ins_index3[1:]): D = (i_exp - i_ins) / freq A_exp = 1 / (i_next_ins - i_exp) * np.sum(co2[i_exp:i_next_ins]) A_ins = 1 / (freq * D) * np.sum(co2[i_ins:i_exp]) A_r = (A_exp - A_ins) / A_exp - S = 1 / freq * np.sum(co2[i_exp : i_exp + int(freq)]) - if len(resptime) > 0: - t_ref = pd.Timedelta( - (cotime[i_exp] - resptime[-1]) - ).total_seconds() - else: - t_ref = 2 # if t_ref >1.5 then it is ok, so 2 does the job - if D > 0.3: - if ( - A_exp > 0.4 * np.mean(Th1_list) - and A_r > np.min([0.7 * np.mean(Th2_list), 0.5]) - and S > 0.4 * np.mean(Th3_list) - ): - if t_ref > 1.5: - resptime.append(cotime[i_exp]) - Th1_list[k] = A_exp - Th2_list[k] = A_r - Th3_list[k] = S - etco2time.append( - cotime[i_exp + np.argmax(co2[i_exp:i_next_ins])] - ) - etco2.append(np.max(co2[i_exp:i_next_ins])) - k += 1 - k = k % 5 - if mode == "threshold" or mode == "filter": - metadata = { - "creator": "automatic", - "creation_date": pd.Timestamp.now(), - "creation_mode": mode, - } - etco2_lab = Label( - "etco2_from_capnography", - time_index=etco2time, - data=etco2, - metadata=metadata, - plotstyle=DEFAULT_PLOT_STYLE.get("etco2_from_capnography", None), - ) - source.attach_label(etco2_lab) - - vent_lab = Label( - "ventilations_from_capnography", - time_index=resptime, + S = 1 / freq * np.sum(co2[i_exp:i_exp + int(freq)]) + t_ref = 2 if len(resptime) == 0 else pd.Timedelta(cotime[i_exp] - resptime[-1]).total_seconds() + if D > 0.3 and A_exp > 0.4 * np.mean(Th1_list) and A_r > min(0.7 * np.mean(Th2_list), 0.5) and S > 0.4 * np.mean(Th3_list): + if t_ref > 1.5: + resptime.append(cotime[i_exp]) + Th1_list[k] = A_exp + Th2_list[k] = A_r + Th3_list[k] = S + etco2time.append(cotime[i_exp + np.argmax(co2[i_exp:i_next_ins])]) + etco2.append(np.max(co2[i_exp:i_next_ins])) + k = (k + 1) % 5 + return resptime, etco2time, etco2 + + # --- RUN MODE --- + if mode == "filter": + resptime, etco2time, etco2 = _run_filter() + exptime = None + elif mode == "filter_extended": + resptime, etco2time, etco2, exptime = _run_filter_extended() + elif mode == "threshold": + resptime, etco2time, etco2 = _run_threshold() + exptime = None + else: + raise ValueError(f"Unknown mode: {mode}") + + # Attach labels + source.attach_label(Label( + "etco2_from_capnography", + time_index=etco2time, + data=etco2, + metadata=metadata, + plotstyle=DEFAULT_PLOT_STYLE.get("etco2_from_capnography", None), + )) + source.attach_label(Label( + "ventilations_from_capnography", + time_index=resptime, + data=None, + metadata=metadata, + plotstyle=DEFAULT_PLOT_STYLE.get("ventilations_from_capnography", None), + )) + if exptime is not None: + source.attach_label(Label( + "exhalation_onsets", + time_index=exptime, data=None, metadata=metadata, - plotstyle=DEFAULT_PLOT_STYLE.get( - "ventilations_from_capnography", None - ), - ) - source.attach_label(vent_lab) - else: - logger.error( - f"mode {mode} not known. Please use either 'filter' or 'threshold' as argument" - ) + plotstyle=DEFAULT_PLOT_STYLE.get("exhalation_onsets", None), + )) def cycle_duration_analysis( self, From 957376dd9efe0289e97627e2bca301269b54be0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Thu, 4 Sep 2025 14:41:25 +0200 Subject: [PATCH 5/7] further development of enhanced kern using peak_prominences but does not work as good as searching backwards till breath_threshold --- src/vitabel/vitals.py | 122 ++++++++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 28 deletions(-) diff --git a/src/vitabel/vitals.py b/src/vitabel/vitals.py index c2ae32f..97278da 100644 --- a/src/vitabel/vitals.py +++ b/src/vitabel/vitals.py @@ -1554,7 +1554,7 @@ def _run_filter(): resptime = cotime[resp_index] resp_height = co[resp_index] - # --- same correction logic as before --- + # --- original correction logic as before --- k = 0 del_resp = [] more_resp_flag = False @@ -1614,9 +1614,59 @@ def _run_filter(): for i in del_resp[::-1]: resptime = np.delete(resptime, i) return resptime, etco2time, etco2 + + # Mode 2: FILTER (KERN) EXTENDED + def _run_filter_extended(): + """ + Extended ventilation detection based on KERN filter. + For each detected respiration (resptime), it locates the + believed start of exhalation in the time-based CO2 waveform. + + Returns: + resptime : original respiration times (from KERN filter) + etco2time : times of etCO2 peaks + etco2 : etCO2 peak values + exptime : computed exhalation onset times (based on breath_threshold of KERN) + """ + + # Step 1: Run original KERN filter to get candidate breath times + resptime, etco2time, etco2 = _run_filter() + + # List to store the detected exhalation start times + exptime = [] + + # Step 2: Loop over each detected respiration + for r in resptime: + # Find the next etCO2 peak that occurs after the current respiration + future_ets = etco2time[etco2time > r] + if len(future_ets) == 0: + # No future peak found (edge case) -> skip this respiration + continue + next_et = future_ets[0] + + # Step 3: Convert times to indices in the raw CO2 waveform + i_et = np.searchsorted(cotime, next_et) # index of etCO2 peak + i_r = np.searchsorted(cotime, r) # index of respiration start + + # Step 4: Search backwards from the etCO2 peak to find the breath_threshold + # (the point just after the CO2 began to rise) + exp_found = False + for i in range(i_et, i_r, -1): + if co[i] <= breath_threshold: + # breath_threshold of waveform found -> mark as exhalation start + exptime.append(cotime[i]) + exp_found = True + break + + # Step 5: Fallback: if no baseline found, use original respiration time + if not exp_found: + exptime.append(r) + + # Step 6: Return results + return resptime, etco2time, etco2, exptime + - ''' - # Mode 2: FILTER EXTENDED + ''' def _run_filter_extended(): resptime, etco2time, etco2 = _run_filter() dco = np.gradient(co) @@ -1681,38 +1731,54 @@ def _run_filter_extended(): exptime.append(cotime[co2_rise_start]) break # Found valid first rise; co2 zero again before etco2 return resptime, etco2time, etco2, exptime - ''' + from scipy.signal import peak_prominences def _run_filter_extended(): + """ + Extended filter: determine exhalation onset using + peak prominences (left base of each etCO2 peak). + """ + # run your baseline filter (already provides peak times & values) resptime, etco2time, etco2 = _run_filter() + + # guard against empty input + if len(etco2) == 0: + return resptime, etco2time, etco2, np.array([]) + + # compute prominences -> gives left base index for each peak + peak_indices = np.arange(len(etco2)) + _, left_bases, _ = peak_prominences(etco2, peak_indices) + + # map left base indices to etco2time + lb_times = np.array([ + etco2time[int(lb)] if 0 <= int(lb) < len(etco2time) else np.nan + for lb in left_bases + ]) + exptime = [] - for r in resptime: - future_ets = etco2time[etco2time > r] - if len(future_ets) == 0: + # find the next etCO2 peak after resp_time + fut = etco2time[etco2time > r] + if len(fut) == 0: + exptime.append(r) # fallback if no peak found continue - next_et = future_ets[0] - - i_et = np.searchsorted(cotime, next_et) - i_r = np.searchsorted(cotime, r) - - # Search backwards from etCO2 peak to find baseline - exp_found = False - for i in range(i_et, i_r, -1): - if co[i] == 0: - exptime.append(cotime[i]) - exp_found = True - break - - if not exp_found: - exptime.append(r) # fallback if no valid base found - + next_et = fut[0] + + # get the index of this etco2 peak + idx = np.searchsorted(etco2time, next_et) + if idx < len(lb_times) and not np.isnan(lb_times[idx]): + lb_t = lb_times[idx] + # ensure onset is within [resp_time, etco2] + if r <= lb_t <= next_et: + exptime.append(lb_t) + else: + exptime.append(r) # fallback + else: + exptime.append(r) # fallback + + return resptime, etco2time, etco2, np.array(exptime) + ''' - return resptime, etco2time, etco2, exptime - - - - # Mode 3: THRESHOLD (Aramendi) def _run_threshold(): but = sgn.butter(4, 10 * 2 / freq, btype="lowpass", output="sos") From d5c4b0757468a3d2622c9fa863e173af9888b65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Thu, 4 Sep 2025 14:56:45 +0200 Subject: [PATCH 6/7] cleaned up code Please enter the commit message for your changes. Lines starting --- src/vitabel/vitals.py | 114 ------------------------------------------ 1 file changed, 114 deletions(-) diff --git a/src/vitabel/vitals.py b/src/vitabel/vitals.py index 97278da..832689e 100644 --- a/src/vitabel/vitals.py +++ b/src/vitabel/vitals.py @@ -1665,120 +1665,6 @@ def _run_filter_extended(): # Step 6: Return results return resptime, etco2time, etco2, exptime - - ''' - def _run_filter_extended(): - resptime, etco2time, etco2 = _run_filter() - dco = np.gradient(co) - exptime = [] - for r in resptime: - future_ets = etco2time[etco2time > r] - if len(future_ets) == 0: - continue - next_et = future_ets[0] - i_start = np.searchsorted(cotime, r) - i_end = np.searchsorted(cotime, next_et) - if i_end > i_start + 1: - max_slope_idx = i_start + np.argmax(dco[i_start:i_end]) - exptime.append(cotime[max_slope_idx]) - return resptime, etco2time, etco2, exptime - - - # Mode 2: FILTER EXTENDED - def _run_filter_extended(): - resptime, etco2time, etco2 = _run_filter() - dco = np.gradient(co) - exptime = [] - for r in resptime: - future_ets = etco2time[etco2time > r] - if len(future_ets) == 0: - continue - next_et = future_ets[0] - i_start = np.searchsorted(cotime, r) - i_end = np.searchsorted(cotime, next_et) - if i_end > i_start + 1: - # Find first positive slope (initial CO2 rise) - for i in range(i_start, i_end): - if dco[i] > 0: - exptime.append(cotime[i]) - break # only the first rising point - return resptime, etco2time, etco2, exptime - - def _run_filter_extended(): - resptime, etco2time, etco2 = _run_filter() - dco = np.gradient(co) - exptime = [] - - for r in resptime: - future_ets = etco2time[etco2time > r] - if len(future_ets) == 0: - continue - next_et = future_ets[0] - - i_start = np.searchsorted(cotime, r) - i_end = np.searchsorted(cotime, next_et) - if i_end <= i_start + 1: - continue - - for i in range(i_start, i_end): - if dco[i] > 0: - # Candidate point for first rise - co2_rise_start = i - co2_segment = co[co2_rise_start:i_end] - - # Ensure no drop in CO₂ after this point (monotonic increase) - if np.all(np.diff(co2_segment) >= 0): - exptime.append(cotime[co2_rise_start]) - break # Found valid first rise; co2 zero again before etco2 - return resptime, etco2time, etco2, exptime - - from scipy.signal import peak_prominences - def _run_filter_extended(): - """ - Extended filter: determine exhalation onset using - peak prominences (left base of each etCO2 peak). - """ - # run your baseline filter (already provides peak times & values) - resptime, etco2time, etco2 = _run_filter() - - # guard against empty input - if len(etco2) == 0: - return resptime, etco2time, etco2, np.array([]) - - # compute prominences -> gives left base index for each peak - peak_indices = np.arange(len(etco2)) - _, left_bases, _ = peak_prominences(etco2, peak_indices) - - # map left base indices to etco2time - lb_times = np.array([ - etco2time[int(lb)] if 0 <= int(lb) < len(etco2time) else np.nan - for lb in left_bases - ]) - - exptime = [] - for r in resptime: - # find the next etCO2 peak after resp_time - fut = etco2time[etco2time > r] - if len(fut) == 0: - exptime.append(r) # fallback if no peak found - continue - next_et = fut[0] - - # get the index of this etco2 peak - idx = np.searchsorted(etco2time, next_et) - if idx < len(lb_times) and not np.isnan(lb_times[idx]): - lb_t = lb_times[idx] - # ensure onset is within [resp_time, etco2] - if r <= lb_t <= next_et: - exptime.append(lb_t) - else: - exptime.append(r) # fallback - else: - exptime.append(r) # fallback - - return resptime, etco2time, etco2, np.array(exptime) - ''' - # Mode 3: THRESHOLD (Aramendi) def _run_threshold(): but = sgn.butter(4, 10 * 2 / freq, btype="lowpass", output="sos") From 31aaa8c7dba86f87974f11d465e1c97c5f9dbb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Furtm=C3=BCller?= Date: Thu, 4 Sep 2025 15:58:44 +0200 Subject: [PATCH 7/7] adding context --- src/vitabel/vitals.py | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/src/vitabel/vitals.py b/src/vitabel/vitals.py index 832689e..69a81dc 100644 --- a/src/vitabel/vitals.py +++ b/src/vitabel/vitals.py @@ -1507,7 +1507,7 @@ def shocks(self): def compute_etco2_and_ventilations( self, source: Channel | str = "capnography", - mode: Literal["filter", "filter_extended", "threshold"] = "filter", + mode: Literal["filter", "filter_onset", "threshold"] = "filter", breath_threshold: float = 2, etco2_threshold: float = 3, **kwargs, @@ -1516,7 +1516,7 @@ def compute_etco2_and_ventilations( Computes etCO₂ values and timestamps of ventilations from capnography waveform. Supports three methods: - 'filter': Wolfgang Kern (unpublished) - - 'filter_extended': like 'filter' + detects exhalation onset (1st derivative) + - 'filter_onset': like 'filter' + detects exhalation onset - 'threshold': Aramendi et al., 2016 """ @@ -1615,21 +1615,24 @@ def _run_filter(): resptime = np.delete(resptime, i) return resptime, etco2time, etco2 - # Mode 2: FILTER (KERN) EXTENDED - def _run_filter_extended(): + # Mode 2: FILTER (KERN) INCL. DETECTION OF EXP ONSET + def _run_filter_onset(): """ - Extended ventilation detection based on KERN filter. - For each detected respiration (resptime), it locates the - believed start of exhalation in the time-based CO2 waveform. - + Enhanced ventilation detection using the KERN filter: + for each detected breath, the exhalation onset is identified + by tracing backward from the etCO₂ peak to the baseline threshold, + constrained between the detected respiration time and the etCO₂ peak. + This corresponds to the physiologically plausible start of expiration + in noisy time-resolved capnogram. + Returns: - resptime : original respiration times (from KERN filter) - etco2time : times of etCO2 peaks - etco2 : etCO2 peak values - exptime : computed exhalation onset times (based on breath_threshold of KERN) + resptime : original respiration times + etco2time : original times of etCO2 peaks + etco2 : original etCO2 peak values + exptime : computed exhalation onset times (based on breath_threshold) """ - # Step 1: Run original KERN filter to get candidate breath times + # Step 1: Run original filter-mode to get candidate breath times resptime, etco2time, etco2 = _run_filter() # List to store the detected exhalation start times @@ -1715,8 +1718,8 @@ def _run_threshold(): if mode == "filter": resptime, etco2time, etco2 = _run_filter() exptime = None - elif mode == "filter_extended": - resptime, etco2time, etco2, exptime = _run_filter_extended() + elif mode == "filter_onset": + resptime, etco2time, etco2, exptime = _run_filter_onset() elif mode == "threshold": resptime, etco2time, etco2 = _run_threshold() exptime = None