55from matplotlib .widgets import SpanSelector
66
77import numpy as np
8+ from scipy import signal
89
910from data_extractor import DataExtractor
1011from searchable_combo_box import SearchableComboBox
@@ -56,9 +57,11 @@ def __init__(self, filename):
5657
5758 self .input_ref = None
5859 self .output_ref = None
59- self .figure = plt .figure (1 )
60+ self .coherence_ref = None
61+ self .coherence_info_text = None
62+ self .figure = plt .figure (figsize = (8 , 6 ), layout = "constrained" )
6063 self .canvas = FigureCanvas (self .figure )
61- self .initPlot ()
64+ self .initPlots ()
6265
6366 layout_v = QVBoxLayout ()
6467
@@ -86,6 +89,10 @@ def __init__(self, filename):
8689 layout_v .addLayout (top_group )
8790 layout_v .addWidget (self .canvas )
8891
92+ self .label_warning = QLabel ("" )
93+ self .label_warning .setStyleSheet ("color: red; font-weight: bold;" )
94+ layout_v .addWidget (self .label_warning )
95+
8996 btn_ok = QPushButton ("Load selection" )
9097 btn_ok .clicked .connect (self .loadSelection )
9198 layout_v .addWidget (btn_ok )
@@ -100,7 +107,7 @@ def __init__(self, filename):
100107 self .browseFiles ()
101108
102109 def loadSelection (self ):
103- if (self .t_start is None and self .t_start is None ) or (self .t_stop > self .t_start ):
110+ if (self .t_start is None and self .t_stop is None ) or (self .t_stop > self .t_start ):
104111 (self .t , self .u , self .y , self .v ) = self .data_extractor .getInputOutputData (self .topics [self .index_u ], self .topics [self .index_y ], self .t_start , self .t_stop )
105112 self .accept ()
106113 else :
@@ -183,10 +190,12 @@ def selectYData(self, index):
183190 def getTrimAirspeed (self ):
184191 return self .data_extractor .getTrimAirspeed ()
185192
186- def initPlot (self ):
193+ def initPlots (self ):
187194 if self .input_ref is None :
188195 self .figure .clear ()
189- self .ax = self .figure .add_subplot (1 ,1 ,1 )
196+
197+ # --- Time series Axes (Top) ---
198+ self .ax = self .figure .add_subplot (2 ,1 ,1 )
190199 color_in = 'tab:blue'
191200 plot_refs = self .ax .plot ([], [], color = color_in )
192201 self .input_ref = plot_refs [0 ]
@@ -207,6 +216,16 @@ def initPlot(self):
207216
208217 self .span = SpanSelector (self .ax_out , self .onselect , 'horizontal' , useblit = False ,
209218 props = dict (alpha = 0.2 , facecolor = 'green' ), interactive = True )
219+
220+ # --- Coherence Plot (Bottom) ---
221+ self .ax_coherence = self .figure .add_subplot (2 , 1 , 2 )
222+ color_coherence = 'tab:grey'
223+ plot_refs = self .ax_coherence .plot ([], [], color = color_coherence )
224+ self .coherence_ref = plot_refs [0 ]
225+ self .ax_coherence .set_title ("Coherence" )
226+ self .ax_coherence .set_xlabel ("Frequency (Hz)" )
227+ self .ax_coherence .set_ylabel ("Coherence" )
228+ self .ax_coherence .set_xscale ('log' )
210229
211230 self .canvas .mpl_connect ('scroll_event' , self .zoom_fun )
212231 self .canvas .draw ()
@@ -241,6 +260,77 @@ def onselect(self, xmin, xmax):
241260 self .ax .set_xlim (self .t_start - 1.0 , self .t_stop + 1.0 )
242261 self .canvas .draw ()
243262
263+ self .plotCoherence ()
264+
265+ def plotCoherence (self ):
266+ if len (self .t ) == 0 or len (self .u ) == 0 or len (self .y ) == 0 :
267+ return
268+
269+ # Use getInputOutputData with selected range
270+ if self .t_start is not None and self .t_stop is not None and self .t_stop > self .t_start :
271+ t_sel , u_sel , y_sel , _ = self .data_extractor .getInputOutputData (
272+ self .topics [self .index_u ], self .topics [self .index_y ],
273+ self .t_start , self .t_stop
274+ )
275+ else :
276+ # If no range selected, just use full duration
277+ t_sel , u_sel , y_sel , _ = self .data_extractor .getInputOutputData (
278+ self .topics [self .index_u ], self .topics [self .index_y ]
279+ )
280+
281+ num_samples = len (t_sel )
282+ duration = t_sel [- 1 ] - t_sel [0 ]
283+
284+ if num_samples < 256 or duration < 5 :
285+ self .label_warning .setText (
286+ f"Increase the window size to at least 5 seconds and 256 samples. "
287+ f"Currently selected: { duration :.2f} seconds, { num_samples } samples."
288+ )
289+ self .label_warning .show ()
290+
291+ self .coherence_ref .set_xdata ([])
292+ self .coherence_ref .set_ydata ([])
293+ return
294+ else :
295+ self .label_warning .hide ()
296+
297+ # Estimate sampling frequency
298+ time_diffs = np .diff (t_sel )
299+ avg_time_diff = np .mean (time_diffs )
300+ if avg_time_diff == 0 :
301+ return
302+ fs = 1 / avg_time_diff
303+
304+ # Choose segment size
305+ nperseg = min (1024 , num_samples // 4 )
306+
307+ # Compute coherence
308+ freq , Cuy = signal .coherence (u_sel , y_sel , fs , nperseg = nperseg )
309+
310+ # Update coherence plot
311+ self .coherence_ref .set_xdata (freq )
312+ self .coherence_ref .set_ydata (Cuy )
313+ self .ax_coherence .set_xlim ([0 , 20 ])
314+ self .ax_coherence .set_ylim ([0 , 1 ])
315+
316+ # Remove previous annotation if it exists
317+ if self .coherence_info_text is not None :
318+ self .coherence_info_text .remove ()
319+ self .coherence_info_text = None
320+
321+ freq_res = fs / nperseg
322+ info_text = (f"Samples: { num_samples } , Duration: { duration :.2f} s, "
323+ f"fs: { fs :.1f} Hz, nperseg: { nperseg } , Δf: { freq_res :.2f} Hz" )
324+
325+ self .coherence_info_text = self .ax_coherence .text (
326+ 0.98 , 0.02 , info_text ,
327+ ha = 'right' , va = 'bottom' ,
328+ transform = self .ax_coherence .transAxes ,
329+ fontsize = 8 , color = 'gray' )
330+
331+ self .canvas .draw ()
332+
333+
244334 def zoom_fun (self , event ):
245335 base_scale = 1.1
246336 # get the current x and y limits
0 commit comments