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/model.py CHANGED
@@ -69,6 +69,7 @@ try:
69
69
  SPEED_COL,
70
70
  RSSI_COL,
71
71
  PPS_COL,
72
+ BAT_COL,
72
73
  LEVEL_COL,
73
74
  RT_STREAM_HD5_DIR,
74
75
  RT_STREAM_MERGED_DIR,
@@ -82,6 +83,7 @@ except ImportError:
82
83
  SPEED_COL,
83
84
  RSSI_COL,
84
85
  PPS_COL,
86
+ BAT_COL,
85
87
  LEVEL_COL,
86
88
  RT_STREAM_HD5_DIR,
87
89
  RT_STREAM_MERGED_DIR,
@@ -163,6 +165,7 @@ class Model():
163
165
  RSSI_COL,
164
166
  'Children',
165
167
  PPS_COL,
168
+ BAT_COL,
166
169
  'CMD',
167
170
  #'Children_nodeIDs', # invisable in table
168
171
  'PPS-time', # invisable in table
@@ -184,7 +187,7 @@ class Model():
184
187
  self.full_range_xlim = []
185
188
  self.merge_full_range = False
186
189
  self.psder = {} # {1:psd_recursive.PSD_Recursive(1024, 62.5), ...}
187
- self.psd_xdata = [] # frequency = 1d ndarray
190
+ self.psd_xdata = {} # per-node frequency arrays {node_id: 1d ndarray}
188
191
  self.psd_ydata = {} # psd amplitude {1:ndarray}
189
192
  self.merged_timehistory_xdata = {}
190
193
  self.merged_timehistory_ydata = {}
@@ -200,6 +203,11 @@ class Model():
200
203
  self.data_queue = pyqueue.Queue(maxsize=5000)
201
204
  self.speed_bytes = {}
202
205
  self.node_fs = {}
206
+ self.node_fs_known = {}
207
+ self.psd_base_nfft = 1024
208
+ self.psd_min_nfft = 256
209
+ self.psd_max_nfft = 16384
210
+ self.psd_target_df = None
203
211
  self.speed_last_calc = datetime.datetime.now(datetime.timezone.utc)
204
212
  self.timespan_length = 30
205
213
  self._pps_count = 0
@@ -233,6 +241,7 @@ class Model():
233
241
  self.parse_node_nums_txt()
234
242
  self.init_mesh_status_data()
235
243
  self.init_other_data()
244
+ self._apply_sampling_rate_from_status()
236
245
 
237
246
  # Thread for framed connection reports
238
247
  self.conn_report_thread = threading.Thread(target=self._conn_report_worker, daemon=True)
@@ -341,10 +350,74 @@ class Model():
341
350
  if prev is not None and abs(prev - fs) < 1e-6:
342
351
  return
343
352
  self.node_fs[node_id] = fs
353
+ self.node_fs_known[node_id] = True
344
354
  if node_id in self.resampler:
345
355
  self.resampler[node_id].set_fs(fs)
346
356
  if node_id in self.psder:
347
357
  self.psder[node_id].set_fs(fs)
358
+ self._recompute_psd_nfft()
359
+
360
+ def _compute_psd_target_df(self):
361
+ fs_values = [fs for node_id, fs in self.node_fs.items() if self.node_fs_known.get(node_id)]
362
+ if not fs_values:
363
+ return None
364
+ min_fs = min(fs_values)
365
+ if min_fs <= 0:
366
+ return None
367
+ return min_fs / float(self.psd_base_nfft)
368
+
369
+ def _choose_nfft(self, fs, target_df):
370
+ if target_df is None or target_df <= 0:
371
+ return self.psd_base_nfft
372
+ nfft = int(round(fs / target_df))
373
+ if nfft < self.psd_min_nfft:
374
+ nfft = self.psd_min_nfft
375
+ elif nfft > self.psd_max_nfft:
376
+ nfft = self.psd_max_nfft
377
+ return nfft
378
+
379
+ def _recompute_psd_nfft(self):
380
+ target_df = self._compute_psd_target_df()
381
+ if target_df is None:
382
+ return
383
+ if self.psd_target_df is None or abs(self.psd_target_df - target_df) > 1e-9:
384
+ self.psd_target_df = target_df
385
+ self._ui_log(
386
+ f"[psd] target df={target_df:.6f} Hz (base_nfft={self.psd_base_nfft})"
387
+ )
388
+ for node_id, fs in self.node_fs.items():
389
+ if not self.node_fs_known.get(node_id):
390
+ continue
391
+ nfft = self._choose_nfft(fs, target_df)
392
+ if self.psd_nfft.get(node_id) == nfft:
393
+ continue
394
+ self.psd_nfft[node_id] = nfft
395
+ self.psder[node_id] = psd_recursive.PSD_Recursive(nfft, fs)
396
+ self.psd_xdata.pop(node_id, None)
397
+ self.psd_ydata.pop(node_id, None)
398
+ df = fs / float(nfft)
399
+ self._ui_log(f"[psd] node={node_id} fs={fs:.3f} nfft={nfft} df={df:.6f} Hz")
400
+
401
+ def _update_observed_fs(self, node_id, ts_list):
402
+ if not ts_list or len(ts_list) < 2:
403
+ return
404
+ duration = (ts_list[-1] - ts_list[0]).total_seconds()
405
+ if duration <= 0:
406
+ return
407
+ fs_est = (len(ts_list) - 1) / duration
408
+ if fs_est <= 0:
409
+ return
410
+ fs_nom = self.node_fs.get(node_id)
411
+ if fs_nom:
412
+ ratio = fs_est / fs_nom
413
+ if ratio < 0.5 or ratio > 1.5:
414
+ return
415
+ prev = self.node_fs_obs.get(node_id)
416
+ if prev is None:
417
+ self.node_fs_obs[node_id] = fs_est
418
+ else:
419
+ alpha = 0.1
420
+ self.node_fs_obs[node_id] = prev * (1 - alpha) + fs_est * alpha
348
421
 
349
422
  def load_config(self):
350
423
  config = configparser.ConfigParser()
@@ -356,7 +429,19 @@ class Model():
356
429
  'tmp_nums_txt': '[]',
357
430
  'str_nums_txt': '[]',
358
431
  'veh_nums_txt': '[]',
359
- 'option2': 'default_value2'
432
+ 'option2': 'default_value2',
433
+ 'log_file_pps': 'true',
434
+ 'log_file_conn_rpt': 'true',
435
+ 'log_terminal_pps': 'true',
436
+ 'log_terminal_conn_rpt': 'false',
437
+ 'plot_channel_x': 'false',
438
+ 'plot_channel_y': 'true',
439
+ 'plot_channel_z': 'false',
440
+ 'plot_timespan': '30',
441
+ 'plot_time_y_auto': 'false',
442
+ 'plot_time_ylim': '[-2.0, 2.0]',
443
+ 'plot_psd_y_auto': 'false',
444
+ 'plot_psd_ylim': '[1e-06, 10.0]',
360
445
  }
361
446
  return
362
447
 
@@ -367,7 +452,19 @@ class Model():
367
452
  'tmp_nums_txt': config.get('Settings', 'tmp_nums_txt', fallback='[]'),
368
453
  'str_nums_txt': config.get('Settings', 'str_nums_txt', fallback='[]'),
369
454
  'veh_nums_txt': config.get('Settings', 'veh_nums_txt', fallback='[]'),
370
- 'option2': config.get('Settings', 'option2', fallback='default_value2')
455
+ 'option2': config.get('Settings', 'option2', fallback='default_value2'),
456
+ 'log_file_pps': config.get('Logging', 'file_pps', fallback='true'),
457
+ 'log_file_conn_rpt': config.get('Logging', 'file_conn_rpt', fallback='true'),
458
+ 'log_terminal_pps': config.get('Logging', 'terminal_pps', fallback='true'),
459
+ 'log_terminal_conn_rpt': config.get('Logging', 'terminal_conn_rpt', fallback='false'),
460
+ 'plot_channel_x': config.get('Plot', 'channel_x', fallback='false'),
461
+ 'plot_channel_y': config.get('Plot', 'channel_y', fallback='true'),
462
+ 'plot_channel_z': config.get('Plot', 'channel_z', fallback='false'),
463
+ 'plot_timespan': config.get('Plot', 'timespan', fallback='30'),
464
+ 'plot_time_y_auto': config.get('Plot', 'time_y_auto', fallback='false'),
465
+ 'plot_time_ylim': config.get('Plot', 'time_ylim', fallback='[-2.0, 2.0]'),
466
+ 'plot_psd_y_auto': config.get('Plot', 'psd_y_auto', fallback='false'),
467
+ 'plot_psd_ylim': config.get('Plot', 'psd_ylim', fallback='[1e-06, 10.0]'),
371
468
  }
372
469
 
373
470
  def save_config(self):
@@ -381,6 +478,22 @@ class Model():
381
478
  'veh_nums_txt': self.options['veh_nums_txt'],
382
479
  'option2': self.options['option2']
383
480
  }
481
+ config['Logging'] = {
482
+ 'file_pps': self.options.get('log_file_pps', 'true'),
483
+ 'file_conn_rpt': self.options.get('log_file_conn_rpt', 'true'),
484
+ 'terminal_pps': self.options.get('log_terminal_pps', 'true'),
485
+ 'terminal_conn_rpt': self.options.get('log_terminal_conn_rpt', 'false'),
486
+ }
487
+ config['Plot'] = {
488
+ 'channel_x': self.options.get('plot_channel_x', 'false'),
489
+ 'channel_y': self.options.get('plot_channel_y', 'true'),
490
+ 'channel_z': self.options.get('plot_channel_z', 'false'),
491
+ 'timespan': self.options.get('plot_timespan', '30'),
492
+ 'time_y_auto': self.options.get('plot_time_y_auto', 'false'),
493
+ 'time_ylim': self.options.get('plot_time_ylim', '[-2.0, 2.0]'),
494
+ 'psd_y_auto': self.options.get('plot_psd_y_auto', 'false'),
495
+ 'psd_ylim': self.options.get('plot_psd_ylim', '[1e-06, 10.0]'),
496
+ }
384
497
 
385
498
  with open(CONFIG_FILE, 'w') as configfile:
386
499
  config.write(configfile)
@@ -451,6 +564,7 @@ class Model():
451
564
  RSSI_COL:[''],
452
565
  'Children':[''],
453
566
  PPS_COL:[''],
567
+ BAT_COL:[''],
454
568
  'CMD':[''],
455
569
  #'Children_nodeIDs': [''],
456
570
  'PPS-time':[''],
@@ -478,7 +592,7 @@ class Model():
478
592
  self.full_range_xlim = []
479
593
  self.merge_full_range = False
480
594
  self.psder.clear()
481
- self.psd_xdata = []
595
+ self.psd_xdata = {}
482
596
  self.psd_ydata.clear()
483
597
  self.speed_bytes = {}
484
598
  self.speed_last_value = {}
@@ -486,6 +600,9 @@ class Model():
486
600
  self.speed_hist = {}
487
601
  self.speed_window_sec = 5.0
488
602
  self.speed_last_calc = datetime.datetime.now(datetime.timezone.utc)
603
+ self.node_fs_obs = {}
604
+ self.psd_nfft = {}
605
+ self.node_fs_known = {}
489
606
 
490
607
  for index, row in self.mesh_status_data.iterrows():
491
608
  nodeID = row['nodeID']
@@ -496,14 +613,26 @@ class Model():
496
613
  self.resampler[nodeID] = resampling.Resampling(50)
497
614
  self.psder[nodeID] = psd_recursive.PSD_Recursive(1024, 50)
498
615
  self.node_fs[nodeID] = 50
616
+ self.node_fs_obs[nodeID] = None
617
+ self.psd_nfft[nodeID] = 1024
618
+ self.node_fs_known[nodeID] = False
499
619
  self.speed_bytes[nodeID] = 0
500
620
  self.speed_last_value[nodeID] = None
501
621
  self.speed_last_rx[nodeID] = None
502
622
  self.speed_hist[nodeID] = deque()
503
623
  self.pps_flash_until[nodeID] = None
624
+
625
+ def _apply_sampling_rate_from_status(self):
626
+ for _, row in self.mesh_status_data.iterrows():
627
+ node_id = row.get("nodeID") or row.get("Node ID")
628
+ if not node_id:
629
+ continue
630
+ fs = self._extract_fs_from_daq_label(row.get("DAQ Mode", ""))
631
+ if fs:
632
+ self._update_sampling_rate(node_id, fs)
504
633
 
505
634
 
506
- def enqueue_conn_report(self, nodeID, level, parent_mac, self_mac, rssi, acc_model: int = 0, daq_mode: int = 0, daq_on: int = 0, stream_status: int = 0, notify: bool = False):
635
+ def enqueue_conn_report(self, nodeID, level, parent_mac, self_mac, rssi, acc_model: int = 0, daq_mode: int = 0, daq_on: int = 0, stream_status: int = 0, bat_vol: float | None = None, notify: bool = False):
507
636
  try:
508
637
  self.conn_report_queue.put_nowait(
509
638
  {
@@ -516,6 +645,7 @@ class Model():
516
645
  "daq_mode": daq_mode,
517
646
  "daq_on": daq_on,
518
647
  "stream_status": stream_status,
648
+ "bat_vol": bat_vol,
519
649
  "notify": notify,
520
650
  }
521
651
  )
@@ -535,6 +665,7 @@ class Model():
535
665
  daq_mode=item.get("daq_mode", 0),
536
666
  daq_on=item.get("daq_on", 0),
537
667
  stream_status=item.get("stream_status", 0),
668
+ bat_vol=item.get("bat_vol"),
538
669
  notify=item["notify"],
539
670
  )
540
671
 
@@ -643,6 +774,7 @@ class Model():
643
774
  ts_, ys = self.timestamper[nodeID].push_cc_pps(cc, epoch_time)
644
775
  ts = self._time_delay_correction(ts_, node_number)
645
776
  if len(ts) > 0:
777
+ self._update_observed_fs(nodeID, ts)
646
778
  self._ts_count += len(ts)
647
779
  self.plot_dirty = True
648
780
  self.plot_dirty_version += 1
@@ -669,7 +801,11 @@ class Model():
669
801
 
670
802
  if self.psder[nodeID].isUpdated:
671
803
  f, asd = self.psder[nodeID].get_asd()
672
- self.psd_xdata = f
804
+ fs_nom = self.node_fs.get(nodeID)
805
+ fs_obs = self.node_fs_obs.get(nodeID)
806
+ if fs_nom and fs_obs:
807
+ f = f * (fs_obs / fs_nom)
808
+ self.psd_xdata[nodeID] = f
673
809
  self.psd_ydata[nodeID] = asd
674
810
  except (OSError, OverflowError, ValueError):
675
811
  pass
@@ -699,6 +835,7 @@ class Model():
699
835
  RSSI_COL: [''],
700
836
  'Children': [''],
701
837
  PPS_COL: [''],
838
+ BAT_COL: [''],
702
839
  'CMD': [''],
703
840
  'PPS-time': [''],
704
841
  'PPS-flash-time': [''],
@@ -774,6 +911,7 @@ class Model():
774
911
  ts_list, ys = self.timestamper[node_id].push_cc_pps(cc, epoch)
775
912
  ts_list = self._time_delay_correction(ts_list, node_number)
776
913
  if ts_list:
914
+ self._update_observed_fs(node_id, ts_list)
777
915
  with self.plot_mutex[node_id]:
778
916
  self.timehistory_xdata[node_id] += ts_list
779
917
  self.timehistory_ydata[node_id] = append(
@@ -791,7 +929,11 @@ class Model():
791
929
  self.psder[node_id].push(array(yrs), array(trs))
792
930
  if self.psder[node_id].isUpdated:
793
931
  f, asd = self.psder[node_id].get_asd()
794
- self.psd_xdata = f
932
+ fs_nom = self.node_fs.get(node_id)
933
+ fs_obs = self.node_fs_obs.get(node_id)
934
+ if fs_nom and fs_obs:
935
+ f = f * (fs_obs / fs_nom)
936
+ self.psd_xdata[node_id] = f
795
937
  self.psd_ydata[node_id] = asd
796
938
  if min_ts is None or ts_list[0] < min_ts:
797
939
  min_ts = ts_list[0]
@@ -992,7 +1134,7 @@ class Model():
992
1134
  self_mac[5] += 1
993
1135
  return self_mac == parent_mac
994
1136
 
995
- def handle_conn_report(self, nodeID, level, parent_mac, self_mac, rssi, acc_model: int = 0, daq_mode: int = 0, daq_on: int = 0, stream_status: int = 0, notify: bool = True):
1137
+ def handle_conn_report(self, nodeID, level, parent_mac, self_mac, rssi, acc_model: int = 0, daq_mode: int = 0, daq_on: int = 0, stream_status: int = 0, bat_vol: float | None = None, notify: bool = True):
996
1138
  logger.info('Conn Rpt: L={}, par_MAC={}, self_MAC={}, RSSI={}'.format(level, parent_mac, self_mac, rssi))
997
1139
 
998
1140
  condition = self._node_condition(nodeID)
@@ -1020,6 +1162,11 @@ class Model():
1020
1162
  "DAQ": daq_status,
1021
1163
  "Stream": stream_label,
1022
1164
  }
1165
+ if bat_vol is not None:
1166
+ try:
1167
+ fields[BAT_COL] = f"{float(bat_vol):3.1f}"
1168
+ except (TypeError, ValueError):
1169
+ pass
1023
1170
  if initial_connect:
1024
1171
  fields[SPEED_COL] = "0.0"
1025
1172
  self._set_node_fields(
@@ -1088,6 +1235,7 @@ class Model():
1088
1235
  "Parent",
1089
1236
  RSSI_COL,
1090
1237
  "Children",
1238
+ BAT_COL,
1091
1239
  "CMD",
1092
1240
  ]:
1093
1241
  if col in self.mesh_status_data.columns:
@@ -1358,7 +1506,11 @@ class Model():
1358
1506
  def _set_node_fields(self, nodeID, mark_dirty: bool = True, **fields):
1359
1507
  condition = self._node_condition(nodeID)
1360
1508
  if condition is None:
1361
- return False
1509
+ # Ensure late-arriving nodes (e.g., PPS before status) get a row.
1510
+ self._ensure_node_row(nodeID)
1511
+ condition = self._node_condition(nodeID)
1512
+ if condition is None:
1513
+ return False
1362
1514
  for column, value in fields.items():
1363
1515
  self.mesh_status_data.loc[condition, column] = value
1364
1516
  status = self.node_status.get(nodeID)
enode_host/protocol.py CHANGED
@@ -27,6 +27,7 @@ class CommandId(enum.IntEnum):
27
27
  STOP_SD_STREAM = 0x09
28
28
  SD_CLEAR = 0x0A
29
29
  SET_DAQ_MODE = 0x0B
30
+ SHUTDOWN = 0x0C
30
31
 
31
32
 
32
33
  class Mode(enum.IntEnum):
@@ -51,6 +52,7 @@ class StatusReport:
51
52
  daq_mode: int = 0
52
53
  daq_on: int = 0
53
54
  stream_status: int = 0
55
+ bat_vol: Optional[float] = None
54
56
 
55
57
 
56
58
  @dataclass
@@ -154,7 +156,7 @@ def build_set_daq_mode(mode: int) -> bytes:
154
156
 
155
157
 
156
158
  def parse_status(payload: bytes) -> StatusReport:
157
- if len(payload) not in (16, 18, 19, 20):
159
+ if len(payload) not in (16, 18, 19, 20, 24):
158
160
  raise ValueError(f"invalid status payload length {len(payload)}")
159
161
  node_type = payload[0]
160
162
  node_number = payload[1]
@@ -166,6 +168,9 @@ def parse_status(payload: bytes) -> StatusReport:
166
168
  daq_mode = payload[17] if len(payload) >= 18 else 0
167
169
  daq_on = payload[18] if len(payload) >= 19 else 0
168
170
  stream_status = payload[19] if len(payload) >= 20 else 0
171
+ bat_vol = None
172
+ if len(payload) >= 24:
173
+ bat_vol = struct.unpack(">f", payload[20:24])[0]
169
174
  return StatusReport(
170
175
  node_type=node_type,
171
176
  node_number=node_number,
@@ -177,6 +182,7 @@ def parse_status(payload: bytes) -> StatusReport:
177
182
  daq_mode=daq_mode,
178
183
  daq_on=daq_on,
179
184
  stream_status=stream_status,
185
+ bat_vol=bat_vol,
180
186
  )
181
187
 
182
188
 
@@ -295,6 +301,10 @@ def build_stop_daq() -> bytes:
295
301
  return build_command(CommandId.STOP_DAQ)
296
302
 
297
303
 
304
+ def build_shutdown() -> bytes:
305
+ return build_command(CommandId.SHUTDOWN)
306
+
307
+
298
308
  def build_realtime_stream(toggle: Toggle) -> bytes:
299
309
  command_id = (
300
310
  CommandId.START_REALTIME_STREAM if toggle == Toggle.START else CommandId.STOP_REALTIME_STREAM