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.
- pneumonitor/__init__.py +0 -0
- pneumonitor/filters.py +54 -0
- pneumonitor/load.py +83 -0
- pneumonitor/plots.py +547 -0
- pneumonitor/preprocess.py +46 -0
- pneumonitor-0.1.1.dist-info/METADATA +152 -0
- pneumonitor-0.1.1.dist-info/RECORD +9 -0
- pneumonitor-0.1.1.dist-info/WHEEL +4 -0
- pneumonitor-0.1.1.dist-info/licenses/LICENSE +21 -0
pneumonitor/__init__.py
ADDED
|
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,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.
|