enode-host 0.1.0__py3-none-any.whl → 0.1.2__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.
enode_host/view.py CHANGED
@@ -13,6 +13,8 @@
13
13
  ## PLEASE DO *NOT* EDIT THIS FILE!
14
14
  ###########################################################################
15
15
 
16
+ import os
17
+ import sys
16
18
  import wx
17
19
  import wx.xrc
18
20
  import wx.grid
@@ -28,9 +30,23 @@ from matplotlib.backends.backend_wxagg import \
28
30
  NavigationToolbar2WxAgg as NavigationToolbar
29
31
  from matplotlib.figure import Figure
30
32
  import matplotlib.dates as mdates
33
+ import numpy as np
31
34
  import wx.grid as gridlib
35
+ def _env_truthy(name: str) -> bool:
36
+ value = os.environ.get(name)
37
+ if not value:
38
+ return False
39
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
40
+
41
+
42
+ if sys.platform.startswith("linux"):
43
+ os.environ.setdefault("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
44
+
32
45
  try:
33
- import wx.html2 as webview
46
+ if _env_truthy("ENODE_DISABLE_WEBVIEW"):
47
+ webview = None
48
+ else:
49
+ import wx.html2 as webview
34
50
  except ImportError:
35
51
  webview = None
36
52
  try:
@@ -52,6 +68,7 @@ try:
52
68
  PPS_AGE_LIMIT_SEC,
53
69
  PPS_AGE_UPDATE_MS,
54
70
  PPS_COL,
71
+ BAT_COL,
55
72
  )
56
73
  except ImportError:
57
74
  from constants import (
@@ -62,6 +79,7 @@ except ImportError:
62
79
  PPS_AGE_LIMIT_SEC,
63
80
  PPS_AGE_UPDATE_MS,
64
81
  PPS_COL,
82
+ BAT_COL,
65
83
  )
66
84
  import pandas as pd
67
85
 
@@ -147,6 +165,8 @@ class TableUpdater:
147
165
  self.grid.SetColSize(self.col_index[RSSI_COL], 40)
148
166
  self.grid.SetColSize(self.col_index["Children"], 70)
149
167
  self.grid.SetColSize(self.col_index[PPS_COL], 40)
168
+ if BAT_COL in self.col_index:
169
+ self.grid.SetColSize(self.col_index[BAT_COL], 50)
150
170
  self.grid.SetColSize(self.col_index["CMD"], 200)
151
171
  for hidden in [
152
172
  "PPS-time",
@@ -226,6 +246,7 @@ class PlotUpdater:
226
246
  self.timehistory_lines = {}
227
247
  self.psd_lines = {}
228
248
  self._psd_legend_labels = {}
249
+ self.psd_auto_x = True
229
250
 
230
251
  def init_plot(self):
231
252
  self.timehistory_lines = {}
@@ -247,7 +268,8 @@ class PlotUpdater:
247
268
  self.axes1.set_xlabel('Time')
248
269
  self.axes1.set_ylabel('Acceleration (g)')
249
270
  self.axes1.grid(True)
250
- self.axes1.set_ylim(-2, 2)
271
+ if not self.view.time_y_auto:
272
+ self.axes1.set_ylim(*self.view.time_y_limits)
251
273
  self.axes1.tick_params(axis='x', labelbottom=True, pad=2)
252
274
  self.axes1.legend()
253
275
  self.view.m_fig.figure.autofmt_xdate(rotation=0, ha='right')
@@ -265,11 +287,16 @@ class PlotUpdater:
265
287
  self.axes2.set_xlabel('Frequency (Hz)')
266
288
  self.axes2.set_ylabel('ASD (g/sq(Hz))')
267
289
  self.axes2.grid(True)
268
- self.axes2.set_ylim(1e-6, 1e1)
290
+ if not self.view.psd_y_auto:
291
+ self.axes2.set_ylim(*self.view.psd_y_limits)
269
292
  self.axes2.set_xlim(0, 25)
293
+ self.psd_auto_x = True
294
+ self.axes2.callbacks.connect('xlim_changed', self._on_psd_xlim_changed)
270
295
  self.axes2.legend()
271
296
 
272
297
  def figure_update(self):
298
+ min_time_y = None
299
+ max_time_y = None
273
300
  for _, row in self.model.mesh_status_data.iterrows():
274
301
  node_id = row['nodeID']
275
302
  for idx, ch in enumerate(['X', 'Y', 'Z']):
@@ -278,30 +305,74 @@ class PlotUpdater:
278
305
  with self.model.plot_mutex[node_id]:
279
306
  if len(self.model.timehistory_xdata[node_id]) > 1:
280
307
  self.timehistory_lines[node_id_ch].set_xdata(self.model.timehistory_xdata[node_id])
281
- self.timehistory_lines[node_id_ch].set_ydata(self.model.timehistory_ydata[node_id][:, idx])
308
+ series = self.model.timehistory_ydata[node_id][:, idx]
309
+ self.timehistory_lines[node_id_ch].set_ydata(series)
310
+ if self.view.time_y_auto and series is not None and len(series) > 0:
311
+ finite = series[np.isfinite(series)]
312
+ if finite.size:
313
+ smin = float(finite.min())
314
+ smax = float(finite.max())
315
+ min_time_y = smin if min_time_y is None else min(min_time_y, smin)
316
+ max_time_y = smax if max_time_y is None else max(max_time_y, smax)
282
317
  if self.model.timehistory_xlim:
283
318
  self.axes1.set_xlim(self.model.timehistory_xlim)
319
+ if self.view.time_y_auto and min_time_y is not None and max_time_y is not None:
320
+ if min_time_y == max_time_y:
321
+ pad = 1.0 if min_time_y == 0 else abs(min_time_y) * 0.1
322
+ else:
323
+ pad = (max_time_y - min_time_y) * 0.05
324
+ self.axes1.set_ylim(min_time_y - pad, max_time_y + pad)
284
325
  try:
285
326
  self.view.m_fig.figure.canvas.draw()
286
327
  except Exception:
287
328
  raise
288
329
 
330
+ max_f = None
331
+ min_psd_y = None
332
+ max_psd_y = None
289
333
  for _, row in self.model.mesh_status_data.iterrows():
290
334
  node_id = row['nodeID']
335
+ f = self.model.psd_xdata.get(node_id)
291
336
  for idx, ch in enumerate(['X', 'Y', 'Z']):
292
337
  node_id_ch = row['Node ID'] + ch
293
338
  if node_id_ch in self.psd_lines and node_id in self.model.psd_ydata:
294
339
  with self.model.plot_mutex[node_id]:
295
- self.psd_lines[node_id_ch].set_xdata(self.model.psd_xdata)
296
- self.psd_lines[node_id_ch].set_ydata(self.model.psd_ydata[node_id][:, idx])
297
-
298
- if len(self.model.psd_xdata) > 0:
299
- self.axes2.set_xlim(0, round(self.model.psd_xdata[-1]))
340
+ if f is not None and len(f) > 0:
341
+ self.psd_lines[node_id_ch].set_xdata(f)
342
+ last_f = f[-1]
343
+ if max_f is None or last_f > max_f:
344
+ max_f = last_f
345
+ series = self.model.psd_ydata[node_id][:, idx]
346
+ self.psd_lines[node_id_ch].set_ydata(series)
347
+ if self.view.psd_y_auto and series is not None and len(series) > 0:
348
+ finite = series[np.isfinite(series)]
349
+ finite = finite[finite > 0]
350
+ if finite.size:
351
+ smin = float(finite.min())
352
+ smax = float(finite.max())
353
+ min_psd_y = smin if min_psd_y is None else min(min_psd_y, smin)
354
+ max_psd_y = smax if max_psd_y is None else max(max_psd_y, smax)
355
+
356
+ if self.psd_auto_x and max_f is not None:
357
+ self.axes2.set_xlim(0, round(max_f))
358
+ if self.view.psd_y_auto and min_psd_y is not None and max_psd_y is not None:
359
+ low = min_psd_y * 0.8
360
+ high = max_psd_y * 1.2
361
+ if low <= 0:
362
+ low = min_psd_y * 0.5
363
+ if low > 0 and high > low:
364
+ self.axes2.set_ylim(low, high)
300
365
  try:
301
366
  self.view.m_fig.figure.canvas.draw()
302
367
  except Exception:
303
368
  raise
304
369
 
370
+ def _on_psd_xlim_changed(self, _axes):
371
+ # Disable auto-scaling after user zoom/pan.
372
+ if not self.psd_auto_x:
373
+ return
374
+ self.psd_auto_x = False
375
+
305
376
  def update_psd_legend(self):
306
377
  if not self.psd_lines:
307
378
  return
@@ -348,6 +419,7 @@ class MergedPlotUpdater:
348
419
  self.axes2 = view.axes2_merged
349
420
  self.timehistory_lines = {}
350
421
  self.psd_lines = {}
422
+ self.psd_auto_x = True
351
423
 
352
424
  def _node_label(self, node_id):
353
425
  return str(node_id).upper()
@@ -370,7 +442,8 @@ class MergedPlotUpdater:
370
442
  self.axes1.set_xlabel('Time')
371
443
  self.axes1.set_ylabel('Acceleration (g)')
372
444
  self.axes1.grid(True)
373
- self.axes1.set_ylim(-2, 2)
445
+ if not self.view.time_y_auto:
446
+ self.axes1.set_ylim(*self.view.time_y_limits)
374
447
  self.axes1.tick_params(axis='x', labelbottom=True, pad=2)
375
448
  self.axes1.legend()
376
449
  self.view.m_fig_merged.figure.autofmt_xdate(rotation=0, ha='right')
@@ -387,11 +460,16 @@ class MergedPlotUpdater:
387
460
  self.axes2.set_xlabel('Frequency (Hz)')
388
461
  self.axes2.set_ylabel('ASD (g/sq(Hz))')
389
462
  self.axes2.grid(True)
390
- self.axes2.set_ylim(1e-6, 1e1)
463
+ if not self.view.psd_y_auto:
464
+ self.axes2.set_ylim(*self.view.psd_y_limits)
391
465
  self.axes2.set_xlim(0, 25)
466
+ self.psd_auto_x = True
467
+ self.axes2.callbacks.connect('xlim_changed', self._on_psd_xlim_changed)
392
468
  self.axes2.legend()
393
469
 
394
470
  def figure_update(self):
471
+ min_time_y = None
472
+ max_time_y = None
395
473
  for node_id in self.model.merged_node_ids:
396
474
  node_label = self._node_label(node_id)
397
475
  xdata = self.model.merged_timehistory_xdata.get(node_id)
@@ -403,14 +481,30 @@ class MergedPlotUpdater:
403
481
  line = self.timehistory_lines.get(label)
404
482
  if line is not None:
405
483
  line.set_xdata(xdata)
406
- line.set_ydata(ydata[:, idx])
484
+ series = ydata[:, idx]
485
+ line.set_ydata(series)
486
+ if self.view.time_y_auto and series is not None and len(series) > 0:
487
+ finite = series[np.isfinite(series)]
488
+ if finite.size:
489
+ smin = float(finite.min())
490
+ smax = float(finite.max())
491
+ min_time_y = smin if min_time_y is None else min(min_time_y, smin)
492
+ max_time_y = smax if max_time_y is None else max(max_time_y, smax)
407
493
  if self.model.merged_timehistory_xlim:
408
494
  self.axes1.set_xlim(self.model.merged_timehistory_xlim)
495
+ if self.view.time_y_auto and min_time_y is not None and max_time_y is not None:
496
+ if min_time_y == max_time_y:
497
+ pad = 1.0 if min_time_y == 0 else abs(min_time_y) * 0.1
498
+ else:
499
+ pad = (max_time_y - min_time_y) * 0.05
500
+ self.axes1.set_ylim(min_time_y - pad, max_time_y + pad)
409
501
  try:
410
502
  self.view.m_fig_merged.figure.canvas.draw()
411
503
  except Exception:
412
504
  raise
413
505
 
506
+ min_psd_y = None
507
+ max_psd_y = None
414
508
  f = self.model.merged_psd_xdata
415
509
  if f is None:
416
510
  f = []
@@ -424,15 +518,37 @@ class MergedPlotUpdater:
424
518
  line = self.psd_lines.get(label)
425
519
  if line is not None:
426
520
  line.set_xdata(f)
427
- line.set_ydata(ydata[:, idx])
428
-
429
- if len(f) > 0:
521
+ series = ydata[:, idx]
522
+ line.set_ydata(series)
523
+ if self.view.psd_y_auto and series is not None and len(series) > 0:
524
+ finite = series[np.isfinite(series)]
525
+ finite = finite[finite > 0]
526
+ if finite.size:
527
+ smin = float(finite.min())
528
+ smax = float(finite.max())
529
+ min_psd_y = smin if min_psd_y is None else min(min_psd_y, smin)
530
+ max_psd_y = smax if max_psd_y is None else max(max_psd_y, smax)
531
+
532
+ if self.psd_auto_x and len(f) > 0:
430
533
  self.axes2.set_xlim(0, round(f[-1]))
534
+ if self.view.psd_y_auto and min_psd_y is not None and max_psd_y is not None:
535
+ low = min_psd_y * 0.8
536
+ high = max_psd_y * 1.2
537
+ if low <= 0:
538
+ low = min_psd_y * 0.5
539
+ if low > 0 and high > low:
540
+ self.axes2.set_ylim(low, high)
431
541
  try:
432
542
  self.view.m_fig_merged.figure.canvas.draw()
433
543
  except Exception:
434
544
  raise
435
545
 
546
+ def _on_psd_xlim_changed(self, _axes):
547
+ # Disable auto-scaling after user zoom/pan.
548
+ if not self.psd_auto_x:
549
+ return
550
+ self.psd_auto_x = False
551
+
436
552
  ###########################################################################
437
553
  ## Class View
438
554
  ###########################################################################
@@ -447,12 +563,19 @@ class View(wx.Frame):
447
563
  self.model = self.controller.model
448
564
  if self.model is not None:
449
565
  self.timespan_length = self.model.timespan_length
566
+ else:
567
+ self.timespan_length = 30
450
568
  self.m_table = None
451
569
  self.table_updater = None
452
570
  self.plot_updater = None
453
571
  self.merged_plot_updater = None
454
572
  self.PPS_outdate_check = True
455
573
  self.channel_selection = [False, True, False]
574
+ self.time_y_auto = False
575
+ self.time_y_limits = (-2.0, 2.0)
576
+ self.psd_y_auto = False
577
+ self.psd_y_limits = (1e-6, 1e1)
578
+ self._load_plot_options()
456
579
 
457
580
  self.SetSizeHints( wx.DefaultSize, wx.DefaultSize )
458
581
 
@@ -479,17 +602,14 @@ class View(wx.Frame):
479
602
  self.m_panel_status = wx.Panel(self.m_left_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL)
480
603
  self.m_panel_map = wx.Panel(self.m_left_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL)
481
604
  self.m_panel_plots = wx.Panel(self.m_left_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL)
482
- self.m_panel_merged_plots = wx.Panel(self.m_left_notebook, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL)
483
605
  self.m_left_notebook.AddPage(self.m_panel_nodes, _(u"Nodes"), False)
484
606
  self.m_left_notebook.AddPage(self.m_panel_status, _(u"Mesh"), True)
485
607
  self.m_left_notebook.AddPage(self.m_panel_map, _(u"Map"), False)
486
- self.m_left_notebook.AddPage(self.m_panel_plots, _(u"RT-Plot"), False)
487
- self.m_left_notebook.AddPage(self.m_panel_merged_plots, _(u"Merged Plot"), False)
608
+ self.m_left_notebook.AddPage(self.m_panel_plots, _(u"Plots"), False)
488
609
  self.tab_index_nodes = 0
489
610
  self.tab_index_status = 1
490
611
  self.tab_index_map = 2
491
612
  self.tab_index_rt_plots = 3
492
- self.tab_index_merged_plots = 4
493
613
 
494
614
  # Sensor Nodes definition tab
495
615
  nodes_sizer = wx.BoxSizer(wx.VERTICAL)
@@ -505,7 +625,7 @@ class View(wx.Frame):
505
625
  self.controller.model.options.get('acc_nums_txt', '[1:3]'),
506
626
  wx.DefaultPosition,
507
627
  wx.DefaultSize,
508
- 0,
628
+ wx.TE_PROCESS_ENTER,
509
629
  )
510
630
  nodes_grid.Add(self.m_textCtrl_acc, 1, wx.EXPAND)
511
631
 
@@ -517,7 +637,7 @@ class View(wx.Frame):
517
637
  self.controller.model.options.get('tmp_nums_txt', '[]'),
518
638
  wx.DefaultPosition,
519
639
  wx.DefaultSize,
520
- 0,
640
+ wx.TE_PROCESS_ENTER,
521
641
  )
522
642
  nodes_grid.Add(self.m_textCtrl_tmp, 1, wx.EXPAND)
523
643
 
@@ -529,7 +649,7 @@ class View(wx.Frame):
529
649
  self.controller.model.options.get('str_nums_txt', '[]'),
530
650
  wx.DefaultPosition,
531
651
  wx.DefaultSize,
532
- 0,
652
+ wx.TE_PROCESS_ENTER,
533
653
  )
534
654
  nodes_grid.Add(self.m_textCtrl_str, 1, wx.EXPAND)
535
655
 
@@ -541,7 +661,7 @@ class View(wx.Frame):
541
661
  self.controller.model.options.get('veh_nums_txt', '[]'),
542
662
  wx.DefaultPosition,
543
663
  wx.DefaultSize,
544
- 0,
664
+ wx.TE_PROCESS_ENTER,
545
665
  )
546
666
  nodes_grid.Add(self.m_textCtrl_veh, 1, wx.EXPAND)
547
667
 
@@ -573,6 +693,7 @@ class View(wx.Frame):
573
693
  self.m_map = webview.WebView.New(self.m_panel_map)
574
694
  map_sizer.Add(self.m_map, 1, wx.ALL | wx.EXPAND, 5)
575
695
  self.m_map_loaded = False
696
+ self._map_nodes = set()
576
697
  self.m_map.Bind(webview.EVT_WEBVIEW_LOADED, self.on_map_loaded)
577
698
  self._load_map()
578
699
  else:
@@ -586,14 +707,88 @@ class View(wx.Frame):
586
707
  self.m_panel_map.SetSizer(map_sizer)
587
708
 
588
709
  plots_sizer = wx.BoxSizer(wx.VERTICAL)
710
+
711
+ plot_controls_sizer = wx.BoxSizer(wx.HORIZONTAL)
712
+
713
+ self.m_button_clf = wx.Button(self.m_panel_plots, wx.ID_ANY, _(u"Clear Figure"), wx.DefaultPosition, wx.DefaultSize, 0)
714
+ plot_controls_sizer.Add(self.m_button_clf, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
715
+
716
+ plot_controls_sizer.Add(wx.StaticText(self.m_panel_plots, wx.ID_ANY, _(u"Channels:")), 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
717
+ self.m_check_chx = wx.CheckBox(self.m_panel_plots, wx.ID_ANY, _(u"X"))
718
+ self.m_check_chy = wx.CheckBox(self.m_panel_plots, wx.ID_ANY, _(u"Y"))
719
+ self.m_check_chz = wx.CheckBox(self.m_panel_plots, wx.ID_ANY, _(u"Z"))
720
+ self.m_check_chx.SetValue(self.channel_selection[0])
721
+ self.m_check_chy.SetValue(self.channel_selection[1])
722
+ self.m_check_chz.SetValue(self.channel_selection[2])
723
+ plot_controls_sizer.Add(self.m_check_chx, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
724
+ plot_controls_sizer.Add(self.m_check_chy, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
725
+ plot_controls_sizer.Add(self.m_check_chz, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
726
+
727
+ plot_controls_sizer.Add(wx.StaticText(self.m_panel_plots, wx.ID_ANY, _(u"Timespan:")), 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
728
+ self.timespan_choices = [30, 20, 10, 5, 2]
729
+ self.m_choice_timespan = wx.Choice(
730
+ self.m_panel_plots,
731
+ choices=[f"{v} sec" for v in self.timespan_choices],
732
+ )
733
+ if self.timespan_length in self.timespan_choices:
734
+ self.m_choice_timespan.SetSelection(self.timespan_choices.index(self.timespan_length))
735
+ else:
736
+ self.m_choice_timespan.SetSelection(0)
737
+ plot_controls_sizer.Add(self.m_choice_timespan, 0, wx.ALL | wx.ALIGN_CENTER_VERTICAL, 4)
738
+
739
+ plots_sizer.Add(plot_controls_sizer, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.TOP, 5)
740
+
741
+ ylim_sizer = wx.FlexGridSizer(1, 6, 2, 6)
742
+ ylim_sizer.AddGrowableCol(2, 1)
743
+ ylim_sizer.AddGrowableCol(5, 1)
744
+
745
+ self.m_check_time_y_auto = wx.CheckBox(self.m_panel_plots, wx.ID_ANY, _(u"Time Y auto"))
746
+ self.m_check_time_y_auto.SetValue(self.time_y_auto)
747
+ ylim_sizer.Add(self.m_check_time_y_auto, 0, wx.ALIGN_CENTER_VERTICAL)
748
+ ylim_sizer.Add(wx.StaticText(self.m_panel_plots, wx.ID_ANY, _(u"ylim:")), 0, wx.ALIGN_CENTER_VERTICAL)
749
+ self.m_text_time_ylim = wx.TextCtrl(
750
+ self.m_panel_plots,
751
+ wx.ID_ANY,
752
+ f"[{self.time_y_limits[0]}, {self.time_y_limits[1]}]",
753
+ wx.DefaultPosition,
754
+ wx.Size(120, -1),
755
+ wx.TE_PROCESS_ENTER,
756
+ )
757
+ self.m_text_time_ylim.Enable(not self.time_y_auto)
758
+ ylim_sizer.Add(self.m_text_time_ylim, 0, wx.ALIGN_CENTER_VERTICAL)
759
+
760
+ self.m_check_psd_y_auto = wx.CheckBox(self.m_panel_plots, wx.ID_ANY, _(u"ASD Y auto"))
761
+ self.m_check_psd_y_auto.SetValue(self.psd_y_auto)
762
+ ylim_sizer.Add(self.m_check_psd_y_auto, 0, wx.ALIGN_CENTER_VERTICAL)
763
+ ylim_sizer.Add(wx.StaticText(self.m_panel_plots, wx.ID_ANY, _(u"ylim:")), 0, wx.ALIGN_CENTER_VERTICAL)
764
+ self.m_text_psd_ylim = wx.TextCtrl(
765
+ self.m_panel_plots,
766
+ wx.ID_ANY,
767
+ f"[{self.psd_y_limits[0]}, {self.psd_y_limits[1]}]",
768
+ wx.DefaultPosition,
769
+ wx.Size(120, -1),
770
+ wx.TE_PROCESS_ENTER,
771
+ )
772
+ self.m_text_psd_ylim.Enable(not self.psd_y_auto)
773
+ ylim_sizer.Add(self.m_text_psd_ylim, 0, wx.ALIGN_CENTER_VERTICAL)
774
+
775
+ ylim_row = wx.BoxSizer(wx.HORIZONTAL)
776
+ indent = self.m_button_clf.GetBestSize().width + 8
777
+ ylim_row.AddSpacer(indent)
778
+ ylim_row.Add(ylim_sizer, 0, wx.ALIGN_CENTER_VERTICAL)
779
+ plots_sizer.Add(ylim_row, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 5)
589
780
  self.m_plot_panel = wx.Panel(self.m_panel_plots, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL)
590
781
  plots_sizer.Add(self.m_plot_panel, 1, wx.ALL | wx.EXPAND, 5)
591
782
  self.m_panel_plots.SetSizer(plots_sizer)
592
783
 
593
- merged_plots_sizer = wx.BoxSizer(wx.VERTICAL)
594
- self.m_plot_panel_merged = wx.Panel(self.m_panel_merged_plots, wx.ID_ANY, wx.DefaultPosition, wx.DefaultSize, wx.TAB_TRAVERSAL)
595
- merged_plots_sizer.Add(self.m_plot_panel_merged, 1, wx.ALL | wx.EXPAND, 5)
596
- self.m_panel_merged_plots.SetSizer(merged_plots_sizer)
784
+ self.m_check_chx.Bind(wx.EVT_CHECKBOX, self.onChannelToggled)
785
+ self.m_check_chy.Bind(wx.EVT_CHECKBOX, self.onChannelToggled)
786
+ self.m_check_chz.Bind(wx.EVT_CHECKBOX, self.onChannelToggled)
787
+ self.m_choice_timespan.Bind(wx.EVT_CHOICE, self.onTimeSpanChange)
788
+ self.m_check_time_y_auto.Bind(wx.EVT_CHECKBOX, self.on_time_y_auto_toggle)
789
+ self.m_text_time_ylim.Bind(wx.EVT_TEXT_ENTER, self.on_time_ylim_enter)
790
+ self.m_check_psd_y_auto.Bind(wx.EVT_CHECKBOX, self.on_psd_y_auto_toggle)
791
+ self.m_text_psd_ylim.Bind(wx.EVT_TEXT_ENTER, self.on_psd_ylim_enter)
597
792
 
598
793
  bSizer3.Add(self.m_left_notebook, 1, wx.ALL | wx.EXPAND, 5)
599
794
  bSizer1.Add( bSizer3, 1, wx.EXPAND, 5 )
@@ -613,6 +808,7 @@ class View(wx.Frame):
613
808
  "SD Stream Start",
614
809
  "SD Stream Stop",
615
810
  "SD Clear All",
811
+ "Shutdown",
616
812
  ],
617
813
  )
618
814
  cmd_choice_sizer.Add(self.m_choice_OperationMode, 0, wx.ALL | wx.CENTER, 5)
@@ -681,46 +877,6 @@ class View(wx.Frame):
681
877
  self.m_menu_data.AppendSubMenu(self.m_menu_data_clear, _(u"Clear"))
682
878
  self.m_menubar.Append(self.m_menu_data, _(u"&Data"))
683
879
 
684
- self.m_menu_cmd = wx.Menu()
685
- self.m_menu_cmd_sdclear = wx.MenuItem(self.m_menu_cmd, wx.ID_ANY, _(u"SD &Clear"), wx.EmptyString, wx.ITEM_NORMAL)
686
- self.m_menu_cmd.Append(self.m_menu_cmd_sdclear)
687
- self.m_menu_cmd_shutdown = wx.MenuItem(self.m_menu_cmd, wx.ID_ANY, _(u"&Shutdown"), wx.EmptyString, wx.ITEM_NORMAL)
688
- self.m_menu_cmd.Append(self.m_menu_cmd_shutdown)
689
- self.m_menubar.Append(self.m_menu_cmd, _(u"&CMD"))
690
-
691
-
692
- self.m_menu_view = wx.Menu()
693
- self.m_menu_view_clf = wx.MenuItem(self.m_menu_view, wx.ID_ANY, _(u"&Clear Figure"), wx.EmptyString, wx.ITEM_NORMAL)
694
- self.m_menu_view.Append(self.m_menu_view_clf)
695
-
696
- self.m_menu_view_chsel = wx.Menu()
697
- self.m_menu_view_chsel_x = self.m_menu_view_chsel.AppendCheckItem(wx.ID_ANY, "X")
698
- self.m_menu_view_chsel_y = self.m_menu_view_chsel.AppendCheckItem(wx.ID_ANY, "Y")
699
- self.m_menu_view_chsel_z = self.m_menu_view_chsel.AppendCheckItem(wx.ID_ANY, "Z")
700
- self.m_menu_view_chsel_y.Check(True)
701
- self.m_menu_view.AppendSubMenu(self.m_menu_view_chsel, "Channel Selection")
702
- self.Bind(wx.EVT_MENU, self.onChannelToggled, self.m_menu_view_chsel_x)
703
- self.Bind(wx.EVT_MENU, self.onChannelToggled, self.m_menu_view_chsel_y)
704
- self.Bind(wx.EVT_MENU, self.onChannelToggled, self.m_menu_view_chsel_z)
705
-
706
-
707
- self.m_menu_view_timespan = wx.Menu()
708
- self.m_menu_view_timespan_radio_30s = self.m_menu_view_timespan.AppendRadioItem(wx.ID_ANY, "30 sec")
709
- self.m_menu_view_timespan_radio_20s = self.m_menu_view_timespan.AppendRadioItem(wx.ID_ANY, "20 sec")
710
- self.m_menu_view_timespan_radio_10s = self.m_menu_view_timespan.AppendRadioItem(wx.ID_ANY, "10 sec")
711
- self.m_menu_view_timespan_radio_05s = self.m_menu_view_timespan.AppendRadioItem(wx.ID_ANY, "5 sec")
712
- self.m_menu_view_timespan_radio_02s = self.m_menu_view_timespan.AppendRadioItem(wx.ID_ANY, "2 sec")
713
- self.m_menu_view_timespan_radio_30s.Check(True)
714
- self.m_menu_view.AppendSubMenu(self.m_menu_view_timespan, "Timespan Select")
715
- self.Bind(wx.EVT_MENU, self.onTimeSpanChange, self.m_menu_view_timespan_radio_30s)
716
- self.Bind(wx.EVT_MENU, self.onTimeSpanChange, self.m_menu_view_timespan_radio_20s)
717
- self.Bind(wx.EVT_MENU, self.onTimeSpanChange, self.m_menu_view_timespan_radio_10s)
718
- self.Bind(wx.EVT_MENU, self.onTimeSpanChange, self.m_menu_view_timespan_radio_05s)
719
- self.Bind(wx.EVT_MENU, self.onTimeSpanChange, self.m_menu_view_timespan_radio_02s)
720
-
721
-
722
- self.m_menubar.Append(self.m_menu_view, _(u"&View"))
723
-
724
880
  self.m_menu_help = wx.Menu()
725
881
  self.m_menu_help_about = wx.MenuItem(self.m_menu_help, wx.ID_ANY, _(u"&Help"), wx.EmptyString, wx.ITEM_NORMAL)
726
882
  self.m_menu_help.Append(self.m_menu_help_about)
@@ -739,23 +895,25 @@ class View(wx.Frame):
739
895
  self.plot_updater = PlotUpdater(self)
740
896
  self.plot_updater.init_plot()
741
897
  self.m_plot_canvas = self.m_fig.canvas
742
- sizer = wx.BoxSizer(wx.VERTICAL)
743
- sizer.Add(self.m_fig, 1, wx.EXPAND)
744
- self.m_plot_panel.SetSizer(sizer)
745
898
  self.m_plot_canvas.Bind(wx.EVT_LEFT_DOWN, self._focus_plot_canvas)
746
899
 
747
- self.m_fig_merged = Plot(self.m_plot_panel_merged)
900
+ self.m_fig_merged = Plot(self.m_plot_panel)
748
901
  self.axes1_merged = self.m_fig_merged.figure.add_subplot(2, 1, 1)
749
902
  self.axes2_merged = self.m_fig_merged.figure.add_subplot(2, 1, 2)
750
903
  self.m_fig_merged.figure.subplots_adjust(hspace=0.28, bottom=0.08, top=0.95)
751
904
  self.merged_plot_updater = MergedPlotUpdater(self)
752
905
  self.merged_plot_updater.init_plot()
753
906
  self.m_plot_canvas_merged = self.m_fig_merged.canvas
754
- merged_sizer = wx.BoxSizer(wx.VERTICAL)
755
- merged_sizer.Add(self.m_fig_merged, 1, wx.EXPAND)
756
- self.m_plot_panel_merged.SetSizer(merged_sizer)
757
907
  self.m_plot_canvas_merged.Bind(wx.EVT_LEFT_DOWN, self._focus_plot_canvas)
758
908
 
909
+ plot_stack = wx.BoxSizer(wx.VERTICAL)
910
+ plot_stack.Add(self.m_fig, 1, wx.EXPAND)
911
+ plot_stack.Add(self.m_fig_merged, 1, wx.EXPAND)
912
+ self.m_fig_merged.Hide()
913
+ self.m_plot_panel.SetSizer(plot_stack)
914
+ self._plot_stack = plot_stack
915
+ self._active_plot = "rt"
916
+
759
917
  # UPDATE TIMERS
760
918
 
761
919
  self._last_plot_version = 0
@@ -796,14 +954,6 @@ class View(wx.Frame):
796
954
  self.m_left_notebook.Bind(wx.EVT_NOTEBOOK_PAGE_CHANGED, self.on_tab_changed)
797
955
 
798
956
 
799
- self.timespan_map = {
800
- self.m_menu_view_timespan_radio_30s.GetId(): 30,
801
- self.m_menu_view_timespan_radio_20s.GetId(): 20,
802
- self.m_menu_view_timespan_radio_10s.GetId(): 10,
803
- self.m_menu_view_timespan_radio_05s.GetId(): 5,
804
- self.m_menu_view_timespan_radio_02s.GetId(): 2,
805
- }
806
-
807
957
  def mesh_status_data_view(self):
808
958
  if self.table_updater is None:
809
959
  self.table_updater = TableUpdater(self)
@@ -821,6 +971,15 @@ class View(wx.Frame):
821
971
  self.m_textCtrl_tmp,
822
972
  self.m_textCtrl_str,
823
973
  self.m_textCtrl_veh,
974
+ getattr(self, "m_button_clf", None),
975
+ getattr(self, "m_check_chx", None),
976
+ getattr(self, "m_check_chy", None),
977
+ getattr(self, "m_check_chz", None),
978
+ getattr(self, "m_choice_timespan", None),
979
+ getattr(self, "m_check_time_y_auto", None),
980
+ getattr(self, "m_text_time_ylim", None),
981
+ getattr(self, "m_check_psd_y_auto", None),
982
+ getattr(self, "m_text_psd_ylim", None),
824
983
  getattr(self, "m_plot_canvas", None),
825
984
  getattr(self, "m_plot_canvas_merged", None),
826
985
  ):
@@ -900,9 +1059,32 @@ class View(wx.Frame):
900
1059
  return
901
1060
  self.merged_plot_updater.figure_update()
902
1061
 
1062
+ def _set_active_plot(self, mode: str):
1063
+ if mode not in ("rt", "merged"):
1064
+ return
1065
+ if getattr(self, "_active_plot", None) == mode:
1066
+ return
1067
+ self._active_plot = mode
1068
+ if mode == "merged":
1069
+ self.m_fig.Hide()
1070
+ self.m_fig_merged.Show()
1071
+ else:
1072
+ self.m_fig_merged.Hide()
1073
+ self.m_fig.Show()
1074
+ try:
1075
+ self.m_plot_panel.Layout()
1076
+ except Exception:
1077
+ pass
1078
+
1079
+ def show_rt_plots(self):
1080
+ self._set_active_plot("rt")
1081
+ if hasattr(self, "tab_index_rt_plots"):
1082
+ self.m_left_notebook.SetSelection(self.tab_index_rt_plots)
1083
+
903
1084
  def show_merged_plots(self):
904
- if hasattr(self, "tab_index_merged_plots"):
905
- self.m_left_notebook.SetSelection(self.tab_index_merged_plots)
1085
+ self._set_active_plot("merged")
1086
+ if hasattr(self, "tab_index_rt_plots"):
1087
+ self.m_left_notebook.SetSelection(self.tab_index_rt_plots)
906
1088
 
907
1089
  def _focus_plot_canvas(self, event):
908
1090
  try:
@@ -986,14 +1168,12 @@ class View(wx.Frame):
986
1168
 
987
1169
  def on_tab_changed(self, event):
988
1170
  idx = event.GetSelection()
989
- if idx == self.tab_index_rt_plots and hasattr(self, "m_plot_canvas"):
990
- try:
991
- self.m_plot_canvas.SetFocus()
992
- except Exception:
993
- pass
994
- if idx == self.tab_index_merged_plots and hasattr(self, "m_plot_canvas_merged"):
1171
+ if idx == self.tab_index_rt_plots:
1172
+ canvas = self.m_plot_canvas
1173
+ if getattr(self, "_active_plot", "rt") == "merged":
1174
+ canvas = self.m_plot_canvas_merged
995
1175
  try:
996
- self.m_plot_canvas_merged.SetFocus()
1176
+ canvas.SetFocus()
997
1177
  except Exception:
998
1178
  pass
999
1179
  event.Skip()
@@ -1004,10 +1184,24 @@ class View(wx.Frame):
1004
1184
  def map_update(self, event):
1005
1185
  if self.m_map is None or not self.m_map_loaded:
1006
1186
  return
1187
+ connected = set()
1188
+ if self.model is not None:
1189
+ for _, row in self.model.mesh_status_data.iterrows():
1190
+ if row.get("Connection") == "connected":
1191
+ connected.add(row.get("nodeID"))
1192
+
1193
+ for node_id in list(self._map_nodes):
1194
+ if node_id not in connected:
1195
+ safe_id = str(node_id).replace("'", "\\'")
1196
+ self.m_map.RunScript(f"removeMarker('{safe_id}');")
1197
+ self._map_nodes.discard(node_id)
1198
+
1007
1199
  positions = getattr(self.model, "gnss_positions", {})
1008
1200
  if not positions:
1009
1201
  return
1010
1202
  for node_id, pos in positions.items():
1203
+ if node_id not in connected:
1204
+ continue
1011
1205
  if not pos.get("valid"):
1012
1206
  continue
1013
1207
  lat = pos.get("lat")
@@ -1016,6 +1210,7 @@ class View(wx.Frame):
1016
1210
  continue
1017
1211
  safe_id = str(node_id).replace("'", "\\'")
1018
1212
  self.m_map.RunScript(f"updateMarker('{safe_id}', {lat}, {lon});")
1213
+ self._map_nodes.add(node_id)
1019
1214
 
1020
1215
  def _load_map(self):
1021
1216
  if self.m_map is None:
@@ -1043,6 +1238,26 @@ class View(wx.Frame):
1043
1238
  attribution: '&copy; OpenStreetMap contributors'
1044
1239
  }).addTo(map);
1045
1240
  const markers = {};
1241
+ let bounds = L.latLngBounds();
1242
+ let autoFit = true;
1243
+
1244
+ function rebuildBounds() {
1245
+ bounds = L.latLngBounds();
1246
+ let hasMarker = false;
1247
+ for (const key in markers) {
1248
+ if (!Object.prototype.hasOwnProperty.call(markers, key)) continue;
1249
+ const latlng = markers[key].getLatLng();
1250
+ bounds.extend(latlng);
1251
+ hasMarker = true;
1252
+ }
1253
+ if (autoFit && hasMarker && bounds.isValid()) {
1254
+ map.fitBounds(bounds.pad(0.15), {animate: false});
1255
+ }
1256
+ }
1257
+
1258
+ map.on('dragstart zoomstart', () => {
1259
+ autoFit = false;
1260
+ });
1046
1261
  window.updateMarker = function(nodeId, lat, lon) {
1047
1262
  let m = markers[nodeId];
1048
1263
  if (!m) {
@@ -1051,6 +1266,17 @@ class View(wx.Frame):
1051
1266
  markers[nodeId] = m;
1052
1267
  }
1053
1268
  m.setLatLng([lat, lon]);
1269
+ bounds.extend([lat, lon]);
1270
+ if (autoFit && bounds.isValid()) {
1271
+ map.fitBounds(bounds.pad(0.15), {animate: false});
1272
+ }
1273
+ };
1274
+ window.removeMarker = function(nodeId) {
1275
+ const m = markers[nodeId];
1276
+ if (!m) return;
1277
+ map.removeLayer(m);
1278
+ delete markers[nodeId];
1279
+ rebuildBounds();
1054
1280
  };
1055
1281
  </script>
1056
1282
  </body>
@@ -1136,14 +1362,156 @@ class View(wx.Frame):
1136
1362
  return
1137
1363
  self.plot_updater.update_psd_legend()
1138
1364
 
1365
+ def _parse_ylim_text(self, text: str):
1366
+ if text is None:
1367
+ return None
1368
+ raw = text.strip()
1369
+ if not raw:
1370
+ return None
1371
+ if raw.startswith("[") and raw.endswith("]"):
1372
+ raw = raw[1:-1].strip()
1373
+ parts = [p.strip() for p in raw.split(",") if p.strip()]
1374
+ if len(parts) != 2:
1375
+ parts = [p.strip() for p in raw.split() if p.strip()]
1376
+ if len(parts) != 2:
1377
+ return None
1378
+ try:
1379
+ lo = float(parts[0])
1380
+ hi = float(parts[1])
1381
+ except (TypeError, ValueError):
1382
+ return None
1383
+ if lo == hi:
1384
+ return None
1385
+ if lo > hi:
1386
+ lo, hi = hi, lo
1387
+ return lo, hi
1388
+
1389
+ def _parse_bool_option(self, value, default: bool) -> bool:
1390
+ if value is None:
1391
+ return default
1392
+ if isinstance(value, bool):
1393
+ return value
1394
+ text = str(value).strip().lower()
1395
+ if text in {"1", "true", "yes", "y", "on"}:
1396
+ return True
1397
+ if text in {"0", "false", "no", "n", "off"}:
1398
+ return False
1399
+ return default
1400
+
1401
+ def _parse_int_option(self, value, default: int) -> int:
1402
+ if value is None:
1403
+ return default
1404
+ try:
1405
+ return int(str(value).strip())
1406
+ except (TypeError, ValueError):
1407
+ return default
1139
1408
 
1140
- def onChannelToggled(self, event):
1141
-
1409
+ def _load_plot_options(self) -> None:
1410
+ if self.model is None:
1411
+ return
1412
+ options = getattr(self.model, "options", {}) or {}
1142
1413
  self.channel_selection = [
1143
- self.m_menu_view_chsel_x.IsChecked(),
1144
- self.m_menu_view_chsel_y.IsChecked(),
1145
- self.m_menu_view_chsel_z.IsChecked()
1414
+ self._parse_bool_option(options.get("plot_channel_x"), self.channel_selection[0]),
1415
+ self._parse_bool_option(options.get("plot_channel_y"), self.channel_selection[1]),
1416
+ self._parse_bool_option(options.get("plot_channel_z"), self.channel_selection[2]),
1146
1417
  ]
1418
+ self.timespan_length = self._parse_int_option(
1419
+ options.get("plot_timespan"), self.timespan_length
1420
+ )
1421
+ self.model.timespan_length = self.timespan_length
1422
+ self.time_y_auto = self._parse_bool_option(
1423
+ options.get("plot_time_y_auto"), self.time_y_auto
1424
+ )
1425
+ time_ylim = self._parse_ylim_text(options.get("plot_time_ylim", ""))
1426
+ if time_ylim is not None:
1427
+ self.time_y_limits = time_ylim
1428
+ self.psd_y_auto = self._parse_bool_option(
1429
+ options.get("plot_psd_y_auto"), self.psd_y_auto
1430
+ )
1431
+ psd_ylim = self._parse_ylim_text(options.get("plot_psd_ylim", ""))
1432
+ if psd_ylim is not None:
1433
+ self.psd_y_limits = psd_ylim
1434
+
1435
+ def _persist_plot_options(self) -> None:
1436
+ if self.model is None:
1437
+ return
1438
+ options = getattr(self.model, "options", None)
1439
+ if options is None:
1440
+ return
1441
+ options["plot_channel_x"] = "true" if self.channel_selection[0] else "false"
1442
+ options["plot_channel_y"] = "true" if self.channel_selection[1] else "false"
1443
+ options["plot_channel_z"] = "true" if self.channel_selection[2] else "false"
1444
+ options["plot_timespan"] = str(self.timespan_length)
1445
+ options["plot_time_y_auto"] = "true" if self.time_y_auto else "false"
1446
+ options["plot_time_ylim"] = f"[{self.time_y_limits[0]}, {self.time_y_limits[1]}]"
1447
+ options["plot_psd_y_auto"] = "true" if self.psd_y_auto else "false"
1448
+ options["plot_psd_ylim"] = f"[{self.psd_y_limits[0]}, {self.psd_y_limits[1]}]"
1449
+ self.model.save_config()
1450
+
1451
+ def on_time_y_auto_toggle(self, event):
1452
+ self.time_y_auto = self.m_check_time_y_auto.GetValue()
1453
+ self.m_text_time_ylim.Enable(not self.time_y_auto)
1454
+ self._persist_plot_options()
1455
+ self.init_plot()
1456
+ self.figure_update()
1457
+ self.init_merged_plot()
1458
+ self.figure_update_merged()
1459
+
1460
+ def on_psd_y_auto_toggle(self, event):
1461
+ self.psd_y_auto = self.m_check_psd_y_auto.GetValue()
1462
+ self.m_text_psd_ylim.Enable(not self.psd_y_auto)
1463
+ self._persist_plot_options()
1464
+ self.init_plot()
1465
+ self.figure_update()
1466
+ self.init_merged_plot()
1467
+ self.figure_update_merged()
1468
+
1469
+ def on_time_ylim_enter(self, event):
1470
+ lims = self._parse_ylim_text(self.m_text_time_ylim.GetValue())
1471
+ if lims is None:
1472
+ self.append_status_message("[plot] invalid time ylim (use [low, high])")
1473
+ return
1474
+ self.time_y_limits = lims
1475
+ self.m_text_time_ylim.SetValue(f"[{lims[0]}, {lims[1]}]")
1476
+ self._persist_plot_options()
1477
+ if not self.time_y_auto:
1478
+ self.init_plot()
1479
+ self.figure_update()
1480
+ self.init_merged_plot()
1481
+ self.figure_update_merged()
1482
+
1483
+ def on_psd_ylim_enter(self, event):
1484
+ lims = self._parse_ylim_text(self.m_text_psd_ylim.GetValue())
1485
+ if lims is None:
1486
+ self.append_status_message("[plot] invalid ASD ylim (use [low, high])")
1487
+ return
1488
+ self.psd_y_limits = lims
1489
+ self.m_text_psd_ylim.SetValue(f"[{lims[0]}, {lims[1]}]")
1490
+ self._persist_plot_options()
1491
+ if not self.psd_y_auto:
1492
+ self.init_plot()
1493
+ self.figure_update()
1494
+ self.init_merged_plot()
1495
+ self.figure_update_merged()
1496
+
1497
+ def _read_channel_selection(self):
1498
+ if hasattr(self, "m_check_chx"):
1499
+ return [
1500
+ self.m_check_chx.GetValue(),
1501
+ self.m_check_chy.GetValue(),
1502
+ self.m_check_chz.GetValue(),
1503
+ ]
1504
+ if hasattr(self, "m_menu_view_chsel_x"):
1505
+ return [
1506
+ self.m_menu_view_chsel_x.IsChecked(),
1507
+ self.m_menu_view_chsel_y.IsChecked(),
1508
+ self.m_menu_view_chsel_z.IsChecked(),
1509
+ ]
1510
+ return list(self.channel_selection)
1511
+
1512
+ def onChannelToggled(self, event):
1513
+ self.channel_selection = self._read_channel_selection()
1514
+ self._persist_plot_options()
1147
1515
 
1148
1516
  self.init_plot()
1149
1517
  self.figure_update()
@@ -1210,13 +1578,17 @@ class View(wx.Frame):
1210
1578
  event.Skip()
1211
1579
 
1212
1580
  def onTimeSpanChange(self, event):
1213
-
1214
- ts = self.timespan_map.get(event.GetId(), None)
1581
+ ts = None
1582
+ if hasattr(self, "m_choice_timespan"):
1583
+ idx = self.m_choice_timespan.GetSelection()
1584
+ if idx != wx.NOT_FOUND and hasattr(self, "timespan_choices"):
1585
+ ts = self.timespan_choices[idx]
1215
1586
  if ts:
1216
1587
  logger.info(f"Timespan selected: {ts} sec")
1217
1588
  self.timespan_length = ts
1218
1589
  if self.model is not None:
1219
1590
  self.model.timespan_length = ts
1591
+ self._persist_plot_options()
1220
1592
  self.init_plot()
1221
1593
  self.figure_update()
1222
1594
  self.init_merged_plot()
@@ -1228,6 +1600,6 @@ class View(wx.Frame):
1228
1600
  def _append_status_message(self, msg: str) -> None:
1229
1601
  if hasattr(self, "m_statusBar1") and self.m_statusBar1 is not None:
1230
1602
  self.m_statusBar1.SetStatusText(msg)
1231
- print(msg, flush=True)
1603
+ logger.info("%s", msg)
1232
1604
 
1233
1605