-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathspectrogram.py
More file actions
358 lines (281 loc) · 14.7 KB
/
Copy pathspectrogram.py
File metadata and controls
358 lines (281 loc) · 14.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
import os
import glob
import pickle
import datetime
import numpy as np
import polars as pl
from tqdm import tqdm
import plotly.graph_objects as go
from sklearn.cluster import DBSCAN
from scipy.signal import spectrogram
from sklearn.pipeline import make_pipeline
from plotly_resampler import FigureResampler
from sklearn.preprocessing import StandardScaler
"""
Notes:
It may be possible to optimize this by not parsing the entire datetime column to datetime and just grabbing the
first two values to figure out the sampling frequency, saving runtime.
I have determined that we want < 3 second temporal resolution on the spectrogram because the data switches between
high frequency and low frequency within 3 seconds. You can't get both perfect time resolution and perfect frequency
resolution. This is the uncertainty principle (just like quantum mechanics). You have to trade frequency resolution
for time resolution. In our case, since we have data sampled so frequently, we can get pretty good resolution in
both domains. We'll sacrifice some frequency resolution to have the temporal resolution that we want.
See this link https://www.youtube.com/watch?v=MBnnXbOM5S4 for more details.
"""
########################################################################################################################
# Inputs
########################################################################################################################
plot_original_data = True # Plot the original data using Plotly resampler
debug_plots = True # Plot optional plots to see what the script is doing behind the scenes
plot_all_clusters = True # Plot the final results
data_folder = r"."
files = glob.glob(os.path.join(data_folder, "example_data*.csv"))
time_col = "Time" # The column name for the time
y_col = "Signal" # The column name for the dependent data to be clustered
frequency_ranges = { # The different frequency ranges to be identified
# "name": (min, max) in Hz
"Low": (0, 5),
"High": (25, 45)
}
"""
This is how many seconds we want between times we determine the frequency. The smaller this is, the longer the run time.
See the note above.
"""
temporal_resolution = 0.75 # seconds
"""
Eps is the greediness of the clustering. Larger eps means larger clusters. Too large and all the data will be one
cluster. Too small and all the data will be considered noise. Min samples is the minimum number of points in the
spectrogram space (see the dominant frequencies plot in debug mode) for a cluster to be considered a cluster.
"""
clustering_parameters = {"eps": 0.17, "min_samples": 50}
########################################################################################################################
########################################################################################################################
# Main
########################################################################################################################
########################################################################################################################
# ----------------------------------------------------------------------------------------------------------------------
#%% Lazily Read the CSVs
# ----------------------------------------------------------------------------------------------------------------------
# Wrap the code with tqdm to add a progress bar
with tqdm(total=1, desc="Loading Data") as pbar:
# Originally this was done lazily since the CSV files had superfluous columns, and the data
# was split between multiple CSV files, each with 1e6 data points. Now it isn't necessary...
df = pl.scan_csv(files, try_parse_dates=True).collect()
pbar.update(1) # Update the progress bar
# Need to convert to Numpy because Polars still isn't widely supported in the Python ecosystem
with tqdm(total=1, desc="Converting Data to Numpy") as pbar:
y = np.squeeze(df.select(df.columns[1]).to_numpy())
pbar.update(1) # Update the progress bar
# Optionally plot the raw data
if plot_original_data:
with tqdm(total=1, desc="Plotting Original Data. This may take a while...") as pbar:
x = np.squeeze(df.select(df.columns[0]).to_numpy()) # Get the time column in numpy
# I don't have enough data to merit using this, but when I did here was how to implement it
# fig = FigureResampler(go.Figure())
# fig.add_trace(
# go.Scattergl(
# name='Vertical Displacement',
# showlegend=True
# ),
# hf_x=x,
# hf_y=y,
# )
# pbar.update(1)
# fig.show_dash()
fig = go.Figure()
fig.add_trace(
go.Scattergl(
x=x,
y=y,
name='Vertical Displacement',
showlegend=True
),
)
pbar.update(1)
fig.show(renderer="browser")
# ----------------------------------------------------------------------------------------------------------------------
#%% Calculate the Spectrogram
# ----------------------------------------------------------------------------------------------------------------------
with tqdm(total=1, desc="Calculating Spectrogram") as pbar:
# First determine the sampling frequency (fs)
# Convert to float by dividing a time delta by another TD
fs = datetime.timedelta(seconds=1) / (df[time_col].gather([1])[0] - df[time_col].gather([0])[0])
# Determine the samples per window and window overlap to get the desired temporal resolution
# https://dsp.stackexchange.com/questions/42428/understanding-overlapping-in-stft
# I did the math to determine the formula for nperseg and noverlap Assume we want 12.5% overlap (default from Scipy)
overlap_fraction = 0.125
nperseg = int(temporal_resolution * fs / (1-overlap_fraction))
noverlap = int(overlap_fraction*nperseg)
f, t, Sxx = spectrogram(y, fs, nperseg=nperseg, noverlap=noverlap, scaling="spectrum")
# Zoom the data to the range we care about (below max detection frequency)
max_detection_frequency = np.max(np.array(list(frequency_ranges.values())))
idx = f < max_detection_frequency*1.1
f, Sxx = f[idx], Sxx[idx, :]
pbar.update(1)
# Create a Plotly heatmap
if debug_plots:
# Plot this on a log scale because the magnitudes are very, very, very different...
fig = go.Figure(data=go.Heatmap(z=10*np.log10(Sxx), x=t, y=f, colorscale="Viridis"))
# Set axis labels
fig.update_layout(
xaxis_title='Time [sec]',
yaxis_title='Frequency [Hz]',
title='Spectrogram: Spectral Power in DB',
)
fig.show(renderer="browser")
fig.write_html("./plots/spectrogram.html")
# ----------------------------------------------------------------------------------------------------------------------
#%% Identify Dominant Frequency in the Spectrogram
# ----------------------------------------------------------------------------------------------------------------------
# This is fast, don't even time this
dominant_frequencies = f[np.argmax(Sxx, axis=0)]
if debug_plots:
fig = go.Figure(data=go.Scatter(x=t, y=dominant_frequencies, mode='markers'))
# Set axis labels
fig.update_layout(
xaxis_title='Time [sec]',
yaxis_title='Dominant Frequency [Hz]',
title='Dominant Frequency Plot'
)
fig.show(renderer="browser")
fig.write_html("./plots/dominant_frequency_plot.html")
# ----------------------------------------------------------------------------------------------------------------------
#%% Temporally Cluster the Dominant Frequencies
# ----------------------------------------------------------------------------------------------------------------------
with tqdm(total=1, desc="Temporally Clustering Dominant Frequencies") as pbar:
pipeline = make_pipeline(StandardScaler(), DBSCAN(**clustering_parameters))
X = np.column_stack((t, dominant_frequencies)) # Cluster with time as a factor
cluster_labels = pipeline.fit_predict(X)
# Identify the cluster frequencies
cluster_frequencies = {}
for cluster_label in sorted(np.unique(cluster_labels)):
cluster_frequency = np.mean(dominant_frequencies[cluster_labels == cluster_label])
cluster_frequencies[cluster_label] = cluster_frequency
# Label the frequency as the correct type
cluster_types = {}
for cluster_label, cluster_frequency in cluster_frequencies.items():
if cluster_label == -1: # -1 is the noise cluster
cluster_types[cluster_label] = "Noise"
else:
# Search for the first frequency range that the cluster frequency falls in and set that as the label
for i, (cluster_type, (f_min, f_max)) in enumerate(frequency_ranges.items()):
if f_min <= cluster_frequency <= f_max:
cluster_types[cluster_label] = cluster_type
break
if i == len(frequency_ranges):
raise ValueError("A frequency was detected that does not fall within the desired frequency ranges.")
pbar.update(1)
if debug_plots:
# Create a scatter plot with different colors for each cluster
fig = go.Figure()
for cluster_label in np.unique(cluster_labels):
cluster_points = X[cluster_labels == cluster_label]
fig.add_trace(
go.Scatter(
x=cluster_points[:, 0],
y=cluster_points[:, 1],
mode='markers',
name=(
f'Cluster {cluster_label}: Freq = {cluster_frequencies[cluster_label]:.2f}'
if cluster_label != -1 else "Noise"
)
)
)
# Set axis labels
fig.update_layout(
xaxis_title='Time [sec]',
yaxis_title='Dominant Frequency [Hz]',
title='Dominant Frequency Clusters'
)
fig.show(renderer="browser")
fig.write_html("./plots/clustered_dominant_frequencies.html")
# ----------------------------------------------------------------------------------------------------------------------
#%% Identify the Start and End of the Clusters
# ----------------------------------------------------------------------------------------------------------------------
with tqdm(total=1, desc="Labeling Clusters") as pbar:
cluster_times = {}
for cluster_label in np.unique(cluster_labels):
cluster_time = (t[cluster_labels == cluster_label])
cluster_times[cluster_label] = (np.min(cluster_time), np.max(cluster_time))
# ----------------------------------------------------------------------------------------------------------------------
#%% Label the Original Data by Cluster
# ----------------------------------------------------------------------------------------------------------------------
test_start_time = df[time_col].gather([1])[0] # Get the start time of the test
df = df.lazy() # Return to lazy execution mode
df = df.with_columns(pl.lit(-1).alias("Cluster")) # Add a cluster label column with the default value of -1
df = df.with_columns(pl.lit("Noise").alias("Cluster Type"))
# Loop over each cluster
for cluster_label, (start_time, end_time) in cluster_times.items():
# Skip the noise cluster
if cluster_label == -1:
continue
# Start and end time are in seconds, referenced to 0. We want them to be relative to the start of the dataframe
# and as datetime objects so that we can add them to the start time
cluster_start_time = test_start_time + datetime.timedelta(seconds=start_time)
cluster_end_time = test_start_time + datetime.timedelta(seconds=end_time)
# Label data points within the cluster time range with the cluster label
mask = (pl.col(time_col) >= cluster_start_time) & (pl.col(time_col) <= cluster_end_time)
df = df.with_columns([
pl.when(mask).then(pl.lit(cluster_label)).otherwise(pl.col("Cluster")).alias("Cluster"),
pl.when(mask)
.then(
pl.lit(cluster_types[cluster_label]))
.otherwise(
pl.col("Cluster Type")
)
.alias("Cluster Type")
])
df = df.collect()
pbar.update(1)
# ----------------------------------------------------------------------------------------------------------------------
#%% Plot the Final Results
# ----------------------------------------------------------------------------------------------------------------------
if plot_all_clusters:
with tqdm(total=len(cluster_times), desc="Plotting Final Results. This may take a while...") as pbar:
# Use the following if you have a looooot of data. I didn't and
# I wanted to save my final plot, which you can't do with plotly resampler
# fig = FigureResampler(go.Figure())
# for cluster_label in cluster_times:
# fig.add_trace(
# go.Scattergl(
# name=(
# f'Cluster {cluster_label}: {cluster_types[cluster_label]} Freq = '
# f'{cluster_frequencies[cluster_label]:.2f}'
# if cluster_label != -1 else "Noise"
# ),
# showlegend=True,
# mode="markers",
# ),
# hf_x=np.squeeze(df.filter(pl.col("Cluster") == cluster_label).select(time_col).to_numpy()),
# hf_y=np.squeeze(df.filter(pl.col("Cluster") == cluster_label).select(y_col).to_numpy()),
# )
# pbar.update(1)
# fig.update_layout(
# xaxis_title=time_col,
# yaxis_title=y_col,
# title='Final Identified Clusters'
# )
# fig.show_dash()
fig = go.Figure()
for cluster_label in cluster_times:
fig.add_trace(
go.Scattergl(
x=np.squeeze(df.filter(pl.col("Cluster") == cluster_label).select(time_col).to_numpy()),
y=np.squeeze(df.filter(pl.col("Cluster") == cluster_label).select(y_col).to_numpy()),
name=(
f'Cluster {cluster_label}: {cluster_types[cluster_label]} Freq = '
f'{cluster_frequencies[cluster_label]:.2f}'
if cluster_label != -1 else "Noise"
),
showlegend=True,
mode="lines+markers" if cluster_label != -1 else "markers",
),
)
pbar.update(1)
fig.update_layout(
xaxis_title=time_col,
yaxis_title=y_col,
title='Final Identified Clusters'
)
fig.show(renderer="browser")
fig.write_html("./plots/clustered_data.html")