pneumonitor 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
pneumonitor/filters.py ADDED
@@ -0,0 +1,54 @@
1
+ import numpy as np
2
+ from scipy.signal import butter, filtfilt, sosfiltfilt, iirnotch
3
+
4
+
5
+ # Low-pass filter
6
+ def lowpass_filter(data, cutoff=100, fs=500, order=4):
7
+ nyquist = 0.5 * fs
8
+ normal_cutoff = cutoff / nyquist
9
+ print("normal_cutoff", normal_cutoff)
10
+ b, a = butter(order, normal_cutoff, btype="low", analog=False)
11
+ return filtfilt(b, a, data)
12
+
13
+
14
+ def bandpass_filter(data, lowcut=0.5, highcut=25, fs=500, order=4):
15
+ # SOS form used instead of the (b, a) transfer-function form: at order 4
16
+ # with a narrow low-frequency band (e.g. 0.05-1 Hz at fs=500), the (b, a)
17
+ # coefficients are numerically unstable and filtfilt silently returns NaNs.
18
+ sos = butter(order, [lowcut, highcut], btype="band", fs=fs, output="sos")
19
+ return sosfiltfilt(sos, data)
20
+
21
+
22
+ def resp_filter(data, fs=500, order=6, low_freq=3 / 60, high_freq=180 / 60):
23
+ data = np.asarray(data, dtype=float)
24
+
25
+ sos = butter(
26
+ order,
27
+ [low_freq, high_freq],
28
+ btype="bandpass",
29
+ fs=fs,
30
+ output="sos",
31
+ )
32
+
33
+ return sosfiltfilt(sos, data)
34
+
35
+
36
+ def notch_filter(data, fs=500, notch_freq=50, notch_quality=30):
37
+ """Applies a 50 Hz notch filter to the input signal.
38
+
39
+ Parameters:
40
+ data (array-like): Input signal.
41
+ fs (float): Sampling frequency in Hz (default: 500 Hz).
42
+ notch_freq (float): Frequency to be notched out (default: 50 Hz).
43
+ notch_quality (float): Quality factor of the notch filter (default: 30).
44
+
45
+ Returns:
46
+ array-like: Filtered signal with 50 Hz removed.
47
+ """
48
+ # Design notch filter
49
+ b_notch, a_notch = iirnotch(notch_freq, notch_quality, fs)
50
+
51
+ # Apply filter using zero-phase filtering
52
+ filtered_data = filtfilt(b_notch, a_notch, data)
53
+
54
+ return filtered_data
pneumonitor/load.py ADDED
@@ -0,0 +1,83 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import pathlib
4
+ import ast, re
5
+
6
+ from typing import Tuple, List
7
+
8
+
9
+ def timestamp_to_seconds(
10
+ df_bio: pd.DataFrame, df_to_transform: pd.DataFrame
11
+ ) -> pd.DataFrame:
12
+ df_to_transform["timestamp[us]"] = (
13
+ df_to_transform["timestamp[us]"] - df_bio["timestamp[us]"].iloc[0]
14
+ ) / 1e6 # Normalize timestamp to seconds
15
+ df_to_transform = df_to_transform.rename(columns={"timestamp[us]": "timestamp[s]"})
16
+ return df_to_transform
17
+
18
+
19
+ # Compute amplitude and phase
20
+ def compute_amplitude_phase(df):
21
+ df["Amplitude"] = np.sqrt(df["BiozI[uV]"] ** 2 + df["BiozQ[uV]"] ** 2)
22
+ df["Phase"] = np.arctan2(df["BiozQ[uV]"], df["BiozI[uV]"]) * (
23
+ 180 / np.pi
24
+ ) # Convert to degrees
25
+ return df
26
+
27
+
28
+ def load_data(
29
+ folder_path: str,
30
+ ) -> Tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame, List[int]]:
31
+
32
+ folder_path = pathlib.Path(folder_path)
33
+ # load ECG and impedance data
34
+ df_bio = pd.read_csv(
35
+ folder_path / "bio.txt",
36
+ delimiter=";",
37
+ names=["timestamp[us]", "ECG[uV]", "BiozI[uV]", "BiozQ[uV]"],
38
+ skiprows=1,
39
+ )
40
+
41
+ # load accelerometer data
42
+ df_acc = pd.read_csv(
43
+ folder_path / "imu.txt",
44
+ delimiter=";",
45
+ names=["timestamp[us]", "X[mq]", "Y[mg]", "Z[mg]"],
46
+ skiprows=1,
47
+ )
48
+
49
+ # load markers
50
+ df_markers = pd.read_csv(
51
+ folder_path / "mark.txt", delimiter=";", names=["timestamp[us]"], skiprows=1
52
+ )
53
+
54
+ # load stats
55
+ df_stats = pd.read_csv(
56
+ folder_path / "stat.txt",
57
+ delimiter=";",
58
+ names=[
59
+ "timestamp[us]",
60
+ "BatLevel[%]",
61
+ "BatVoltage[mV]",
62
+ "BatCurrent[mA]",
63
+ "Status[NONE]",
64
+ ],
65
+ skiprows=1,
66
+ )
67
+
68
+ with open(folder_path / "info.txt") as f:
69
+ for line in f:
70
+ if line.startswith("Errors"):
71
+ errors = ast.literal_eval(re.search(r"\[.*\]", line).group())
72
+ break
73
+ else:
74
+ errors = []
75
+
76
+ df_bio = compute_amplitude_phase(df_bio)
77
+
78
+ df_acc = timestamp_to_seconds(df_bio, df_acc)
79
+ df_markers = timestamp_to_seconds(df_bio, df_markers)
80
+ df_stats = timestamp_to_seconds(df_bio, df_stats)
81
+ df_bio = timestamp_to_seconds(df_bio, df_bio)
82
+
83
+ return df_bio, df_acc, df_markers, df_stats, errors
pneumonitor/plots.py ADDED
@@ -0,0 +1,547 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ import plotly.graph_objects as go
4
+ from plotly.subplots import make_subplots
5
+ import matplotlib.pyplot as plt
6
+
7
+
8
+ def plot_cardio_resp_data(
9
+ df_bio: pd.DataFrame,
10
+ df_markers: pd.DataFrame = None,
11
+ include_raw: bool = True,
12
+ resp_phase: bool = True,
13
+ height: int = 700,
14
+ width: int = 1000,
15
+ additional_signals: list[str] = None,
16
+ ):
17
+
18
+ raw_signals = {
19
+ "ECG_clean[uV]": "ECG[uV]",
20
+ "Amplitude_clean": "Amplitude",
21
+ }
22
+ colors = {
23
+ "ECG_clean[uV]": "red",
24
+ "Amplitude_clean": "blue",
25
+ "ECG[uV]": "purple",
26
+ "Amplitude": "orange",
27
+ }
28
+ signals = ["ECG_clean[uV]", "Amplitude_clean"]
29
+ if additional_signals:
30
+ signals.extend(additional_signals)
31
+ titles = [
32
+ "ECG",
33
+ "Amplitude",
34
+ ]
35
+ if additional_signals:
36
+ titles.extend(additional_signals)
37
+
38
+ # Create figure with subplots
39
+ fig = make_subplots(
40
+ rows=len(signals), cols=1, shared_xaxes=True, vertical_spacing=0.04
41
+ )
42
+
43
+ # Add signal traces
44
+ for i, signal in enumerate(signals):
45
+ row = i + 1
46
+
47
+ fig.add_trace(
48
+ go.Scatter(
49
+ x=df_bio["timestamp[s]"],
50
+ y=df_bio[signal],
51
+ mode="lines",
52
+ name=f"Raw {titles[i]}",
53
+ line=dict(color=colors[signal]) if signal in colors else None,
54
+ # opacity=0.2,
55
+ ),
56
+ row=row,
57
+ col=1,
58
+ )
59
+ if include_raw and signal in raw_signals:
60
+ fig.add_trace(
61
+ go.Scatter(
62
+ x=df_bio["timestamp[s]"],
63
+ y=df_bio[raw_signals[signal]],
64
+ mode="lines",
65
+ name=f"Raw {titles[i]}",
66
+ line=(
67
+ dict(color=colors[raw_signals[signal]])
68
+ if raw_signals[signal] in colors
69
+ else None
70
+ ),
71
+ opacity=0.2,
72
+ ),
73
+ row=row,
74
+ col=1,
75
+ )
76
+ if signal == "ECG_clean[uV]":
77
+ fig.add_trace(
78
+ go.Scatter(
79
+ x=df_bio.loc[df_bio["ECG_R_Peaks"] == 1, "timestamp[s]"],
80
+ y=df_bio.loc[df_bio["ECG_R_Peaks"] == 1, signal],
81
+ mode="markers",
82
+ name="ECG R-peaks",
83
+ marker=dict(color="black", size=6),
84
+ ),
85
+ row=row,
86
+ col=1,
87
+ )
88
+ if resp_phase and signal == "Amplitude_clean" and "RSP_Phase" in df_bio.columns:
89
+ phase = df_bio["RSP_Phase"].values
90
+ timestamps = df_bio["timestamp[s]"].values
91
+ phase_changes = np.where(np.diff(phase.astype(int)) != 0)[0] + 1
92
+ seg_starts = np.concatenate([[0], phase_changes])
93
+ seg_ends = np.concatenate([phase_changes, [len(phase)]])
94
+ for s, e in zip(seg_starts, seg_ends):
95
+ fig.add_vrect(
96
+ x0=timestamps[s],
97
+ x1=timestamps[e - 1],
98
+ fillcolor="green" if phase[s] == 1 else "red",
99
+ opacity=0.15,
100
+ layer="below",
101
+ line_width=0,
102
+ row=row,
103
+ col=1,
104
+ )
105
+
106
+ fig.update_yaxes(title_text=titles[i], row=row, col=1)
107
+
108
+ if df_markers is not None:
109
+ # Add vertical lines for each timestamp from df_markers
110
+ for mark_time in df_markers.iloc[:, 0]:
111
+ for row in range(1, len(signals) + 1):
112
+ fig.add_vline(
113
+ x=mark_time,
114
+ line=dict(color="purple", width=1, dash="dash"),
115
+ row=row,
116
+ col=1,
117
+ )
118
+
119
+ # Update x-axis title
120
+ fig.update_xaxes(title_text="Time [s]", row=len(signals), col=1)
121
+
122
+ # Adjust layout
123
+ fig.update_layout(
124
+ height=height,
125
+ width=width,
126
+ margin=dict(l=50, r=50, t=50, b=50),
127
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
128
+ )
129
+
130
+ fig.show()
131
+ return fig
132
+
133
+
134
+ def plot_accelerometer_data(
135
+ df_acc: pd.DataFrame,
136
+ df_markers: pd.DataFrame = None,
137
+ height: int = 700,
138
+ width: int = 1000,
139
+ ):
140
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.04)
141
+ fig.add_trace(
142
+ go.Scatter(
143
+ x=df_acc["timestamp[s]"],
144
+ y=df_acc["X[mq]"],
145
+ mode="lines",
146
+ name="X",
147
+ opacity=0.5,
148
+ ),
149
+ row=1,
150
+ col=1,
151
+ )
152
+ fig.add_trace(
153
+ go.Scatter(
154
+ x=df_acc["timestamp[s]"],
155
+ y=df_acc["Y[mg]"],
156
+ mode="lines",
157
+ name="Y",
158
+ opacity=0.5,
159
+ ),
160
+ row=1,
161
+ col=1,
162
+ )
163
+ fig.add_trace(
164
+ go.Scatter(
165
+ x=df_acc["timestamp[s]"],
166
+ y=df_acc["Z[mg]"],
167
+ mode="lines",
168
+ name="Z",
169
+ opacity=0.5,
170
+ ),
171
+ row=1,
172
+ col=1,
173
+ )
174
+ fig.add_trace(
175
+ go.Scatter(
176
+ x=df_acc["timestamp[s]"],
177
+ y=df_acc["Acc_Magnitude[g]"],
178
+ mode="lines",
179
+ name="Vector magnitude",
180
+ opacity=0.5,
181
+ ),
182
+ row=2,
183
+ col=1,
184
+ )
185
+ fig.add_trace(
186
+ go.Scatter(
187
+ x=df_acc["timestamp[s]"],
188
+ y=df_acc["Acc_RMS[g]"],
189
+ mode="lines",
190
+ name="RMS",
191
+ line=dict(color="black"),
192
+ ),
193
+ row=2,
194
+ col=1,
195
+ )
196
+
197
+ fig.update_xaxes(title_text="Time [s]", row=2, col=1)
198
+ fig.update_yaxes(title_text="Acceleration X [mg]", row=1, col=1)
199
+ fig.update_yaxes(title_text="Acceleration Y [mg]", row=1, col=1)
200
+ fig.update_yaxes(title_text="Acceleration Z [mg]", row=1, col=1)
201
+ fig.update_yaxes(title_text="Acceleration vector magnitude [g]", row=2, col=1)
202
+
203
+ # Add vertical lines for each timestamp from df_markers
204
+ if df_markers is not None:
205
+ for mark_time in df_markers.iloc[:, 0]:
206
+ for row in range(1, 3):
207
+ fig.add_vline(
208
+ x=mark_time,
209
+ line=dict(color="purple", width=1, dash="dash"),
210
+ row=row,
211
+ col=1,
212
+ )
213
+
214
+ fig.update_layout(
215
+ height=height,
216
+ width=width,
217
+ margin=dict(l=50, r=50, t=50, b=50),
218
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
219
+ )
220
+ fig.show()
221
+ return fig
222
+
223
+
224
+ def plot_cardio_resp_data_mpl(
225
+ df_bio: pd.DataFrame,
226
+ df_markers: pd.DataFrame = None,
227
+ include_raw: bool = True,
228
+ resp_phase: bool = True,
229
+ height: int = 700,
230
+ width: int = 1000,
231
+ additional_signals: list[str] = None,
232
+ ):
233
+ raw_signals = {
234
+ "ECG_clean[uV]": "ECG[uV]",
235
+ "Amplitude_clean": "Amplitude",
236
+ }
237
+ colors = {
238
+ "ECG_clean[uV]": "red",
239
+ "Amplitude_clean": "blue",
240
+ "ECG[uV]": "purple",
241
+ "Amplitude": "orange",
242
+ }
243
+ signals = ["ECG_clean[uV]", "Amplitude_clean"]
244
+ if additional_signals:
245
+ signals.extend(additional_signals)
246
+ titles = ["ECG", "Amplitude"]
247
+ if additional_signals:
248
+ titles.extend(additional_signals)
249
+
250
+ n = len(signals)
251
+ fig, axes = plt.subplots(n, 1, sharex=True, figsize=(width / 100, height / 100))
252
+ if n == 1:
253
+ axes = [axes]
254
+
255
+ for i, (signal, ax) in enumerate(zip(signals, axes)):
256
+ color = colors.get(signal)
257
+ ax.plot(df_bio["timestamp[s]"], df_bio[signal], color=color, label=f"Clean {titles[i]}")
258
+
259
+ if include_raw and signal in raw_signals:
260
+ raw_col = raw_signals[signal]
261
+ raw_color = colors.get(raw_col)
262
+ ax.plot(
263
+ df_bio["timestamp[s]"],
264
+ df_bio[raw_col],
265
+ color=raw_color,
266
+ alpha=0.2,
267
+ label=f"Raw {titles[i]}",
268
+ )
269
+
270
+ if signal == "ECG_clean[uV]":
271
+ peaks = df_bio["ECG_R_Peaks"] == 1
272
+ ax.scatter(
273
+ df_bio.loc[peaks, "timestamp[s]"],
274
+ df_bio.loc[peaks, signal],
275
+ color="black",
276
+ s=20,
277
+ zorder=5,
278
+ label="ECG R-peaks",
279
+ )
280
+
281
+ if resp_phase and signal == "Amplitude_clean" and "RSP_Phase" in df_bio.columns:
282
+ phase = df_bio["RSP_Phase"].values
283
+ timestamps = df_bio["timestamp[s]"].values
284
+ phase_changes = np.where(np.diff(phase.astype(int)) != 0)[0] + 1
285
+ seg_starts = np.concatenate([[0], phase_changes])
286
+ seg_ends = np.concatenate([phase_changes, [len(phase)]])
287
+ for s, e in zip(seg_starts, seg_ends):
288
+ ax.axvspan(
289
+ timestamps[s],
290
+ timestamps[e - 1],
291
+ color="green" if phase[s] == 1 else "red",
292
+ alpha=0.15,
293
+ )
294
+
295
+ if df_markers is not None:
296
+ for mark_time in df_markers.iloc[:, 0]:
297
+ ax.axvline(x=mark_time, color="purple", linewidth=1, linestyle="--")
298
+
299
+ ax.set_ylabel(titles[i])
300
+ ax.legend(loc="upper right", fontsize="small")
301
+
302
+ axes[-1].set_xlabel("Time [s]")
303
+ fig.tight_layout()
304
+ plt.show()
305
+ return fig
306
+
307
+
308
+ def plot_accelerometer_data_mpl(
309
+ df_acc: pd.DataFrame,
310
+ df_markers: pd.DataFrame = None,
311
+ height: int = 700,
312
+ width: int = 1000,
313
+ ):
314
+ fig, axes = plt.subplots(2, 1, sharex=True, figsize=(width / 100, height / 100))
315
+
316
+ for col, label in [("X[mq]", "X"), ("Y[mg]", "Y"), ("Z[mg]", "Z")]:
317
+ axes[0].plot(df_acc["timestamp[s]"], df_acc[col], alpha=0.5, label=label)
318
+
319
+ axes[1].plot(
320
+ df_acc["timestamp[s]"],
321
+ df_acc["Acc_Magnitude[g]"],
322
+ alpha=0.5,
323
+ label="Vector magnitude",
324
+ )
325
+ axes[1].plot(
326
+ df_acc["timestamp[s]"],
327
+ df_acc["Acc_RMS[g]"],
328
+ color="black",
329
+ label="RMS",
330
+ )
331
+
332
+ if df_markers is not None:
333
+ for mark_time in df_markers.iloc[:, 0]:
334
+ for ax in axes:
335
+ ax.axvline(x=mark_time, color="purple", linewidth=1, linestyle="--")
336
+
337
+ axes[0].set_ylabel("Acceleration [mg]")
338
+ axes[1].set_ylabel("Acceleration vector magnitude [g]")
339
+ axes[1].set_xlabel("Time [s]")
340
+ for ax in axes:
341
+ ax.legend(loc="upper right", fontsize="small")
342
+
343
+ fig.tight_layout()
344
+ plt.show()
345
+ return fig
346
+
347
+
348
+ def plot_combined_data(
349
+ df_acc: pd.DataFrame,
350
+ df_bio: pd.DataFrame,
351
+ df_markers: pd.DataFrame = None,
352
+ include_raw: bool = True,
353
+ resp_phase: bool = True,
354
+ height: int = 900,
355
+ width: int = 1000,
356
+ additional_signals: list[str] = None,
357
+ ):
358
+ raw_signals = {
359
+ "ECG_clean[uV]": "ECG[uV]",
360
+ "Amplitude_clean": "Amplitude",
361
+ }
362
+ colors = {
363
+ "ECG_clean[uV]": "red",
364
+ "Amplitude_clean": "blue",
365
+ "ECG[uV]": "purple",
366
+ "Amplitude": "orange",
367
+ }
368
+ bio_signals = ["ECG_clean[uV]", "Amplitude_clean"]
369
+ if additional_signals:
370
+ bio_signals.extend(additional_signals)
371
+ bio_titles = ["ECG", "Amplitude"]
372
+ if additional_signals:
373
+ bio_titles.extend(additional_signals)
374
+
375
+ total_rows = 2 + len(bio_signals)
376
+ fig = make_subplots(rows=total_rows, cols=1, shared_xaxes=True, vertical_spacing=0.03)
377
+
378
+ # Row 1: X, Y, Z axes
379
+ for col, name in [("X[mq]", "X"), ("Y[mg]", "Y"), ("Z[mg]", "Z")]:
380
+ fig.add_trace(
381
+ go.Scatter(x=df_acc["timestamp[s]"], y=df_acc[col], mode="lines", name=name, opacity=0.5),
382
+ row=1, col=1,
383
+ )
384
+ fig.update_yaxes(title_text="Acceleration [mg]", row=1, col=1)
385
+
386
+ # Row 2: magnitude + RMS
387
+ fig.add_trace(
388
+ go.Scatter(x=df_acc["timestamp[s]"], y=df_acc["Acc_Magnitude[g]"], mode="lines", name="Vector magnitude", opacity=0.5),
389
+ row=2, col=1,
390
+ )
391
+ fig.add_trace(
392
+ go.Scatter(x=df_acc["timestamp[s]"], y=df_acc["Acc_RMS[g]"], mode="lines", name="RMS", line=dict(color="black")),
393
+ row=2, col=1,
394
+ )
395
+ fig.update_yaxes(title_text="Magnitude [g]", row=2, col=1)
396
+
397
+ # Rows 3+: cardio resp signals
398
+ for i, signal in enumerate(bio_signals):
399
+ row = 3 + i
400
+ fig.add_trace(
401
+ go.Scatter(
402
+ x=df_bio["timestamp[s]"],
403
+ y=df_bio[signal],
404
+ mode="lines",
405
+ name=f"Clean {bio_titles[i]}",
406
+ line=dict(color=colors[signal]) if signal in colors else None,
407
+ ),
408
+ row=row, col=1,
409
+ )
410
+ if include_raw and signal in raw_signals:
411
+ fig.add_trace(
412
+ go.Scatter(
413
+ x=df_bio["timestamp[s]"],
414
+ y=df_bio[raw_signals[signal]],
415
+ mode="lines",
416
+ name=f"Raw {bio_titles[i]}",
417
+ line=dict(color=colors[raw_signals[signal]]) if raw_signals[signal] in colors else None,
418
+ opacity=0.2,
419
+ ),
420
+ row=row, col=1,
421
+ )
422
+ if signal == "ECG_clean[uV]":
423
+ fig.add_trace(
424
+ go.Scatter(
425
+ x=df_bio.loc[df_bio["ECG_R_Peaks"] == 1, "timestamp[s]"],
426
+ y=df_bio.loc[df_bio["ECG_R_Peaks"] == 1, signal],
427
+ mode="markers",
428
+ name="ECG R-peaks",
429
+ marker=dict(color="black", size=6),
430
+ ),
431
+ row=row, col=1,
432
+ )
433
+ if resp_phase and signal == "Amplitude_clean" and "RSP_Phase" in df_bio.columns:
434
+ phase = df_bio["RSP_Phase"].values
435
+ timestamps = df_bio["timestamp[s]"].values
436
+ phase_changes = np.where(np.diff(phase.astype(int)) != 0)[0] + 1
437
+ seg_starts = np.concatenate([[0], phase_changes])
438
+ seg_ends = np.concatenate([phase_changes, [len(phase)]])
439
+ for s, e in zip(seg_starts, seg_ends):
440
+ fig.add_vrect(
441
+ x0=timestamps[s],
442
+ x1=timestamps[e - 1],
443
+ fillcolor="green" if phase[s] == 1 else "red",
444
+ opacity=0.15,
445
+ layer="below",
446
+ line_width=0,
447
+ row=row, col=1,
448
+ )
449
+ fig.update_yaxes(title_text=bio_titles[i], row=row, col=1)
450
+
451
+ if df_markers is not None:
452
+ for mark_time in df_markers.iloc[:, 0]:
453
+ for row in range(1, total_rows + 1):
454
+ fig.add_vline(x=mark_time, line=dict(color="purple", width=1, dash="dash"), row=row, col=1)
455
+
456
+ fig.update_xaxes(title_text="Time [s]", row=total_rows, col=1)
457
+ fig.update_layout(
458
+ height=height,
459
+ width=width,
460
+ margin=dict(l=50, r=50, t=50, b=50),
461
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
462
+ )
463
+ fig.show()
464
+ return fig
465
+
466
+
467
+ def plot_combined_data_mpl(
468
+ df_acc: pd.DataFrame,
469
+ df_bio: pd.DataFrame,
470
+ df_markers: pd.DataFrame = None,
471
+ include_raw: bool = True,
472
+ include_xyz: bool = True,
473
+ resp_phase: bool = True,
474
+ height: int = 900,
475
+ width: int = 1000,
476
+ additional_signals: list[str] = None,
477
+ ):
478
+ raw_signals = {
479
+ "ECG_clean[uV]": "ECG[uV]",
480
+ "Amplitude_clean": "Amplitude",
481
+ }
482
+ colors = {
483
+ "ECG_clean[uV]": "red",
484
+ "Amplitude_clean": "blue",
485
+ "ECG[uV]": "purple",
486
+ "Amplitude": "orange",
487
+ }
488
+ bio_signals = ["ECG_clean[uV]", "Amplitude_clean"]
489
+ if additional_signals:
490
+ bio_signals.extend(additional_signals)
491
+ bio_titles = ["ECG", "Amplitude"]
492
+ if additional_signals:
493
+ bio_titles.extend(additional_signals)
494
+
495
+ total_rows = 1 + include_xyz*1 + len(bio_signals)
496
+ fig, axes = plt.subplots(total_rows, 1, sharex=True, figsize=(width / 100, height / 100))
497
+ axs_idx = 0
498
+ # Row 0: X, Y, Z axes
499
+ if include_xyz:
500
+ for col, label in [("X[mq]", "X"), ("Y[mg]", "Y"), ("Z[mg]", "Z")]:
501
+ axes[axs_idx].plot(df_acc["timestamp[s]"], df_acc[col], alpha=0.5, label=label)
502
+ axes[axs_idx].set_ylabel("Acceleration [mg]")
503
+ axes[axs_idx].legend(loc="upper right", fontsize="small")
504
+ axs_idx += 1
505
+
506
+ # Row 1: magnitude + RMS
507
+ axes[axs_idx].plot(df_acc["timestamp[s]"], df_acc["Acc_Magnitude[g]"], alpha=0.5, label="Vector magnitude")
508
+ axes[axs_idx].plot(df_acc["timestamp[s]"], df_acc["Acc_RMS[g]"], color="black", label="RMS")
509
+ axes[axs_idx].set_ylabel("Magnitude [g]")
510
+ axes[axs_idx].legend(loc="upper right", fontsize="small")
511
+ axs_idx += 1
512
+
513
+ # Rows 2+: cardio resp signals
514
+ for i, signal in enumerate(bio_signals):
515
+ ax = axes[axs_idx]
516
+ color = colors.get(signal)
517
+ ax.plot(df_bio["timestamp[s]"], df_bio[signal], color=color, label=f"Clean {bio_titles[i]}")
518
+ if include_raw and signal in raw_signals:
519
+ raw_col = raw_signals[signal]
520
+ ax.plot(df_bio["timestamp[s]"], df_bio[raw_col], color=colors.get(raw_col), alpha=0.2, label=f"Raw {bio_titles[i]}")
521
+ if signal == "ECG_clean[uV]":
522
+ peaks = df_bio["ECG_R_Peaks"] == 1
523
+ ax.scatter(df_bio.loc[peaks, "timestamp[s]"], df_bio.loc[peaks, signal], color="black", s=20, zorder=5, label="ECG R-peaks")
524
+ if resp_phase and signal == "Amplitude_clean" and "RSP_Phase" in df_bio.columns:
525
+ phase = df_bio["RSP_Phase"].values
526
+ timestamps = df_bio["timestamp[s]"].values
527
+ phase_changes = np.where(np.diff(phase.astype(int)) != 0)[0] + 1
528
+ seg_starts = np.concatenate([[0], phase_changes])
529
+ seg_ends = np.concatenate([phase_changes, [len(phase)]])
530
+ for s, e in zip(seg_starts, seg_ends):
531
+ ax.axvspan(timestamps[s], timestamps[e - 1], color="green" if phase[s] == 1 else "red", alpha=0.15)
532
+ if df_markers is not None:
533
+ for mark_time in df_markers.iloc[:, 0]:
534
+ ax.axvline(x=mark_time, color="purple", linewidth=1, linestyle="--")
535
+ ax.set_ylabel(bio_titles[i])
536
+ ax.legend(loc="upper right", fontsize="small")
537
+ axs_idx += 1
538
+
539
+ if df_markers is not None:
540
+ for mark_time in df_markers.iloc[:, 0]:
541
+ for ax in axes[:2]:
542
+ ax.axvline(x=mark_time, color="purple", linewidth=1, linestyle="--")
543
+
544
+ axes[-1].set_xlabel("Time [s]")
545
+ fig.tight_layout()
546
+ plt.show()
547
+ return axes, fig
@@ -0,0 +1,46 @@
1
+ import pandas as pd
2
+ import neurokit2 as nk
3
+
4
+
5
+ def _preprocess_cardio(df_bio: pd.DataFrame, sampling_rate: int = 500) -> pd.DataFrame:
6
+ df_bio = df_bio.copy()
7
+ res, _ = nk.ecg_process(df_bio["ECG[uV]"].values, sampling_rate=sampling_rate)
8
+ df_bio["ECG_clean[uV]"] = res["ECG_Clean"]
9
+ df_bio["ECG_R_Peaks"] = res["ECG_R_Peaks"]
10
+ df_bio["ECG_Rate"] = res["ECG_Rate"]
11
+ return df_bio
12
+
13
+
14
+ def _preprocess_resp(df_bio: pd.DataFrame, sampling_rate: int = 500) -> pd.DataFrame:
15
+ df_bio = df_bio.copy()
16
+ res, _ = nk.rsp_process(df_bio["Amplitude"].values, sampling_rate=sampling_rate)
17
+ df_bio["Amplitude_clean"] = res["RSP_Clean"]
18
+ df_bio["RSP_Rate"] = res["RSP_Rate"]
19
+ df_bio["RSP_Peaks"] = res["RSP_Peaks"]
20
+ df_bio["RSP_Phase"] = res["RSP_Phase"]
21
+ return df_bio
22
+
23
+
24
+ def preprocess_cardio_resp(
25
+ df_bio: pd.DataFrame, sampling_rate: int = 500
26
+ ) -> pd.DataFrame:
27
+ df_bio = _preprocess_cardio(df_bio, sampling_rate)
28
+ df_bio = _preprocess_resp(df_bio, sampling_rate)
29
+ return df_bio
30
+
31
+
32
+ def process_imu(
33
+ df_acc: pd.DataFrame, rms_window_sec: int, sampling_rate: int = 500
34
+ ) -> pd.DataFrame:
35
+ df_acc = df_acc.copy()
36
+ df_acc["Acc_Magnitude[g]"] = (
37
+ df_acc["X[mq]"] ** 2 + df_acc["Y[mg]"] ** 2 + df_acc["Z[mg]"] ** 2
38
+ ) ** 0.5 / 1000 # Convert to g
39
+ df_acc["Acc_Magnitude[g]"] = df_acc["Acc_Magnitude[g]"] - 1 # Remove gravity
40
+ window_size = rms_window_sec * sampling_rate
41
+ df_acc["Acc_RMS[g]"] = (
42
+ df_acc["Acc_Magnitude[g]"]
43
+ .rolling(window=window_size, center=True)
44
+ .apply(lambda x: (x**2).mean() ** 0.5, raw=True)
45
+ )
46
+ return df_acc
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: pneumonitor
3
+ Version: 0.1.1
4
+ Summary: Code related to Pneumonitor data processing
5
+ Project-URL: Repository, https://github.com/<your-username>/Pneumonitor
6
+ Author-email: Maciej Rosoł <maciej.rosol@pw.edu.pl>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: matplotlib>=3.10.0
11
+ Requires-Dist: neurokit2>=0.2.12
12
+ Requires-Dist: numpy>=2.4.6
13
+ Requires-Dist: pandas>=3.0.3
14
+ Requires-Dist: plotly>=6.7.0
15
+ Requires-Dist: scipy>=1.17.1
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Pneumonitor
19
+
20
+ Python toolkit for analysing cardiorespiratory data recorded with the **Pneumonitor 4** wearable device. The device simultaneously acquires ECG and impedance pneumography signals, enabling synchronised analysis of cardiac and respiratory activity.
21
+
22
+ ## Project structure
23
+
24
+ ```
25
+ Pneumonitor/
26
+ ├── pneumonitor/
27
+ │ ├── load.py # Data loading and timestamp normalisation
28
+ │ ├── preprocess.py # Signal processing (ECG, respiration, IMU)
29
+ │ ├── filters.py # Digital filter building blocks
30
+ │ └── plots.py # Interactive Plotly visualisations
31
+ ├── <recording_id>/ # One folder per recording (e.g. 00001/)
32
+ │ ├── bio.txt # ECG + bioimpedance (I/Q) at 500 Hz
33
+ │ ├── imu.txt # 3-axis accelerometer at 50 Hz
34
+ │ ├── mark.txt # User-triggered event markers
35
+ │ ├── stat.txt # Battery and device status
36
+ │ ├── imp.txt # Empty in the current version
37
+ │ └── info.txt # Recording metadata and error log
38
+ └── experiments.py # Example analysis notebook (%-cell format)
39
+ ```
40
+
41
+ ## Recording format
42
+
43
+ Each recording folder contains semicolon-delimited text files:
44
+
45
+ | File | Columns | Description |
46
+ |------|---------|-------------|
47
+ | `bio.txt` | `timestamp[us]`, `ECG[uV]`, `BiozI[uV]`, `BiozQ[uV]` | ECG and bioimpedance in-phase / quadrature components |
48
+ | `imu.txt` | `timestamp[us]`, `X[mq]`, `Y[mg]`, `Z[mg]` | 3-axis accelerometer in mg |
49
+ | `mark.txt` | `timestamp[us]` | Timestamps of manual markers |
50
+ | `stat.txt` | `timestamp[us]`, `BatLevel[%]`, `BatVoltage[mV]`, `BatCurrent[mA]`, `Status[NONE]` | Device telemetry |
51
+ | `info.txt` | Key-value pairs | Hardware/firmware version, sample counts, error list |
52
+
53
+ Timestamps are in microseconds from device boot. `load_data()` normalises them to seconds relative to the first biosignal sample.
54
+
55
+ ## Usage
56
+
57
+ ### 1. Load a recording
58
+
59
+ ```python
60
+ from pneumonitor.load import load_data
61
+
62
+ df_bio, df_acc, df_markers, df_stats, errors = load_data('00001')
63
+ ```
64
+
65
+ `df_bio` already contains the derived `Amplitude` (√(I²+Q²)) and `Phase` (arctan2(Q,I)) columns computed from the bioimpedance I/Q pair.
66
+
67
+ ### 2. Process IMU data
68
+
69
+ ```python
70
+ from pneumonitor.preprocess import process_imu
71
+
72
+ df_acc = process_imu(df_acc, rms_window_sec=1, sampling_rate=50)
73
+ ```
74
+
75
+ Adds `Acc_Magnitude[g]` (gravity-removed vector magnitude) and `Acc_RMS[g]` (rolling RMS over the specified window).
76
+
77
+ ### 3. Preprocess cardiorespiratory signals
78
+
79
+ ```python
80
+ from pneumonitor.preprocess import preprocess_cardio_resp
81
+
82
+ df_bio = preprocess_cardio_resp(df_bio, sampling_rate=500)
83
+ ```
84
+
85
+ Uses [NeuroKit2](https://github.com/neuropsychology/NeuroKit) internally and appends the following columns to `df_bio`:
86
+
87
+ | Column | Description |
88
+ |--------|-------------|
89
+ | `ECG_clean[uV]` | Cleaned ECG signal |
90
+ | `ECG_R_Peaks` | Binary mask of R-peak locations |
91
+ | `ECG_Rate` | Instantaneous heart rate (bpm) |
92
+ | `Amplitude_clean` | Cleaned respiratory amplitude |
93
+ | `RSP_Rate` | Instantaneous respiratory rate (bpm) |
94
+ | `RSP_Peaks` | Binary mask of respiration peaks |
95
+ | `RSP_Phase` | Respiratory phase (0 = exhalation, 1 = inhalation) |
96
+
97
+ ### 4. Visualise
98
+
99
+ ```python
100
+ from pneumonitor.plots import plot_cardio_resp_data, plot_accelerometer_data
101
+
102
+ # Cardiorespiratory overview — add extra derived signals as extra subplots
103
+ plot_cardio_resp_data(df_bio, df_markers, include_raw=True,
104
+ additional_signals=['ECG_Rate', 'RSP_Rate'])
105
+
106
+ # Accelerometer overview
107
+ plot_accelerometer_data(df_acc, df_markers)
108
+ ```
109
+
110
+ Both functions return a Plotly `Figure` and call `.show()`. The cardiorespiratory plot shades the respiratory amplitude subplot green (inhalation) / red (exhalation) based on `RSP_Phase`, and overlays R-peak markers on the ECG subplot.
111
+
112
+ ### 5. Filters (optional low-level use)
113
+
114
+ ```python
115
+ from pneumonitor.filters import bandpass_filter, notch_filter, resp_filter
116
+
117
+ ecg_filtered = notch_filter(df_bio['ECG[uV]'].values) # remove 50 Hz mains
118
+ ecg_filtered = bandpass_filter(ecg_filtered, lowcut=0.5, highcut=25)
119
+ resp_filtered = resp_filter(df_bio['Amplitude'].values) # 3–180 bpm bandpass
120
+ ```
121
+
122
+ ## Installation
123
+
124
+ Once published to PyPI, install it into any project with:
125
+
126
+ ```bash
127
+ pip install pneumonitor
128
+ # or
129
+ uv add pneumonitor
130
+ ```
131
+
132
+ ## Development
133
+
134
+ This project uses [uv](https://docs.astral.sh/uv/) for dependency management.
135
+
136
+ ### Installing dependencies
137
+
138
+ Install all dependencies specified in `pyproject.toml`:
139
+
140
+ ```bash
141
+ uv sync
142
+ ```
143
+
144
+ ### Adding new dependencies
145
+
146
+ To add a new package to the project:
147
+
148
+ ```bash
149
+ uv add <package-name>
150
+ ```
151
+
152
+ This will update both `pyproject.toml` and the virtual environment automatically.
@@ -0,0 +1,9 @@
1
+ pneumonitor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pneumonitor/filters.py,sha256=zLNb2zrSaaNFAiApXdLHUmGJbA_cNqawTI4ywznULbE,1760
3
+ pneumonitor/load.py,sha256=Y3ifmqDZM3hyLkCUFI0d8k9nhPL8pmjIAmYPI_rzSPs,2331
4
+ pneumonitor/plots.py,sha256=5abblLpFy5bTZLYabv724YYhFVQqtFmx7XHsKYXzgrE,18326
5
+ pneumonitor/preprocess.py,sha256=55QIrxFznNGv318em5e00Adnf2utxzIT3EPINEPzBNg,1625
6
+ pneumonitor-0.1.1.dist-info/METADATA,sha256=biBoo9gWgRXwUGUy6v2-XUokEM4e6BP_lLuKeNqlgsU,5243
7
+ pneumonitor-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ pneumonitor-0.1.1.dist-info/licenses/LICENSE,sha256=OE_-dlnv6cZcWVE8zz1spqE3pItX8Srfz8XINY192tA,1070
9
+ pneumonitor-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maciej Rosoł
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.