ndscope 0.22.3__tar.gz → 0.22.4__tar.gz

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.
Files changed (124) hide show
  1. {ndscope-0.22.3 → ndscope-0.22.4}/CHANGELOG.md +1 -0
  2. {ndscope-0.22.3 → ndscope-0.22.4}/PKG-INFO +2 -2
  3. ndscope-0.22.4/install_wheel +11 -0
  4. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/__version__.py +3 -3
  5. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/cache.py +1 -1
  6. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/export.py +103 -0
  7. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/plot.py +19 -3
  8. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/scope.py +65 -21
  9. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/scope.ui +11 -1
  10. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/template.py +6 -1
  11. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/PKG-INFO +2 -2
  12. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/requires.txt +1 -1
  13. ndscope-0.22.4/ndscope.egg-info/scm_version.json +8 -0
  14. {ndscope-0.22.3 → ndscope-0.22.4}/pyproject.toml +1 -1
  15. ndscope-0.22.3/install_wheel +0 -3
  16. ndscope-0.22.3/ndscope.egg-info/scm_version.json +0 -8
  17. {ndscope-0.22.3 → ndscope-0.22.4}/COPYING +0 -0
  18. {ndscope-0.22.3 → ndscope-0.22.4}/COPYING-GPL-3 +0 -0
  19. {ndscope-0.22.3 → ndscope-0.22.4}/MANIFEST.in +0 -0
  20. {ndscope-0.22.3 → ndscope-0.22.4}/NEWS.md +0 -0
  21. {ndscope-0.22.3 → ndscope-0.22.4}/README.md +0 -0
  22. {ndscope-0.22.3 → ndscope-0.22.4}/RELEASE.md +0 -0
  23. {ndscope-0.22.3 → ndscope-0.22.4}/bad.sh +0 -0
  24. {ndscope-0.22.3 → ndscope-0.22.4}/bad2.yaml +0 -0
  25. {ndscope-0.22.3 → ndscope-0.22.4}/bad_legend.yaml +0 -0
  26. {ndscope-0.22.3 → ndscope-0.22.4}/bad_ndscope.yaml +0 -0
  27. {ndscope-0.22.3 → ndscope-0.22.4}/bad_times.yaml +0 -0
  28. {ndscope-0.22.3 → ndscope-0.22.4}/big.yaml +0 -0
  29. {ndscope-0.22.3 → ndscope-0.22.4}/biglive.yaml +0 -0
  30. {ndscope-0.22.3 → ndscope-0.22.4}/calib.sh +0 -0
  31. {ndscope-0.22.3 → ndscope-0.22.4}/calib.yaml +0 -0
  32. {ndscope-0.22.3 → ndscope-0.22.4}/calib_arrakis.sh +0 -0
  33. {ndscope-0.22.3 → ndscope-0.22.4}/calib_arrakis.yaml +0 -0
  34. {ndscope-0.22.3 → ndscope-0.22.4}/dev.sh +0 -0
  35. {ndscope-0.22.3 → ndscope-0.22.4}/dts1 +0 -0
  36. {ndscope-0.22.3 → ndscope-0.22.4}/live.sh +0 -0
  37. {ndscope-0.22.3 → ndscope-0.22.4}/live.yaml +0 -0
  38. {ndscope-0.22.3 → ndscope-0.22.4}/manual_tests/manual_test.yaml +0 -0
  39. {ndscope-0.22.3 → ndscope-0.22.4}/manual_tests/manual_test_live.yaml +0 -0
  40. {ndscope-0.22.3 → ndscope-0.22.4}/manual_tests/ndscope-test.txt +0 -0
  41. {ndscope-0.22.3 → ndscope-0.22.4}/manual_tests/old_manual_test.yaml +0 -0
  42. {ndscope-0.22.3 → ndscope-0.22.4}/manual_tests/test_results_2025_10_23.txt +0 -0
  43. {ndscope-0.22.3 → ndscope-0.22.4}/maturin.sh +0 -0
  44. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/__init__.py +0 -0
  45. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/__main__.py +0 -0
  46. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/_qt.py +0 -0
  47. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/axis.py +0 -0
  48. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/axisCtrlTemplate.ui +0 -0
  49. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/base_plot.py +0 -0
  50. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/channel_config.ui +0 -0
  51. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/channel_config_dialog.ui +0 -0
  52. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/channel_list.ui +0 -0
  53. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/channel_select.py +0 -0
  54. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/channel_select_dialog.ui +0 -0
  55. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/configuration/__init__.py +0 -0
  56. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/configuration/configuration.py +0 -0
  57. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/const.py +0 -0
  58. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/crosshair.py +0 -0
  59. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/cursors.py +0 -0
  60. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/data.py +0 -0
  61. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/data_source_determination.py +0 -0
  62. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/dialog.py +0 -0
  63. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/dialog_nds_auth.ui +0 -0
  64. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/dialog_nds_ontape.ui +0 -0
  65. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/dtt/__init__.py +0 -0
  66. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/dtt/channel_trend_id.py +0 -0
  67. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/dtt/dtt.py +0 -0
  68. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/exceptions.py +0 -0
  69. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/kerberos.py +0 -0
  70. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/legend.py +0 -0
  71. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/markers.py +0 -0
  72. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/math_config.py +0 -0
  73. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/math_config.ui +0 -0
  74. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/__init__.py +0 -0
  75. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/dtt_messages.py +0 -0
  76. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/has_issues.py +0 -0
  77. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/message_windows/__init__.py +0 -0
  78. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/message_windows/log_list.py +0 -0
  79. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/message_windows/message_window.py +0 -0
  80. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/messages/messages.py +0 -0
  81. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/nds.py +0 -0
  82. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/plotMenu.py +0 -0
  83. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/primary_view.py +0 -0
  84. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/result.py +0 -0
  85. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/ruff.toml +0 -0
  86. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/scatterPlotMenu.py +0 -0
  87. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/scatter_plot.py +0 -0
  88. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/__init__.py +0 -0
  89. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test.yaml +0 -0
  90. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test0.yaml +0 -0
  91. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test1-1.yaml +0 -0
  92. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test1-2.yaml +0 -0
  93. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test1-3.yaml +0 -0
  94. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test1.yaml +0 -0
  95. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test2.stp +0 -0
  96. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test2.yaml +0 -0
  97. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test3.txt +0 -0
  98. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test3.yaml +0 -0
  99. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test4.xml +0 -0
  100. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/templates/test4.yaml +0 -0
  101. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_cache.py +0 -0
  102. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_cli.py +0 -0
  103. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_data_flow.py +0 -0
  104. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_layout.py +0 -0
  105. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_plot.py +0 -0
  106. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_util.py +0 -0
  107. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/test/test_window.py +0 -0
  108. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/trigger.py +0 -0
  109. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope/util.py +0 -0
  110. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/SOURCES.txt +0 -0
  111. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/dependency_links.txt +0 -0
  112. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/entry_points.txt +0 -0
  113. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/scm_file_list.json +0 -0
  114. {ndscope-0.22.3 → ndscope-0.22.4}/ndscope.egg-info/top_level.txt +0 -0
  115. {ndscope-0.22.3 → ndscope-0.22.4}/newdaq.sh +0 -0
  116. {ndscope-0.22.3 → ndscope-0.22.4}/past.sh +0 -0
  117. {ndscope-0.22.3 → ndscope-0.22.4}/past.yaml +0 -0
  118. {ndscope-0.22.3 → ndscope-0.22.4}/past2.yaml +0 -0
  119. {ndscope-0.22.3 → ndscope-0.22.4}/pidcheck.py +0 -0
  120. {ndscope-0.22.3 → ndscope-0.22.4}/setup.cfg +0 -0
  121. {ndscope-0.22.3 → ndscope-0.22.4}/teststand_arrakis.sh +0 -0
  122. {ndscope-0.22.3 → ndscope-0.22.4}/teststand_arrakis.yaml +0 -0
  123. {ndscope-0.22.3 → ndscope-0.22.4}/trend.sh +0 -0
  124. {ndscope-0.22.3 → ndscope-0.22.4}/trend.yaml +0 -0
@@ -10,6 +10,7 @@
10
10
  - Added system to place markers on plots, then jump between markers.
11
11
  - Fix bug where arrakis data could be timestamped wrong
12
12
  - Add --arrakis (choose default Arrakis data source) and --arrakis-server (specify an arrakis server) options.
13
+ - Added raw data export to WAV sound files (one file per channel), a "play" button on the export tab that plays the visible data, and the export "show" button now opens files with the OS default application on Linux, Mac and Windows.
13
14
 
14
15
  ## 0.22.0 - 2026-08-24
15
16
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndscope
3
- Version: 0.22.3
3
+ Version: 0.22.4
4
4
  Summary: Next-generation NDS time series plotting
5
5
  Author-email: Jameson Graef Rollins <jameson.rollins@ligo.org>, Patrick Thomas <patrick.thomas@ligo.org>, Erik von Reis <erik.vonreis@ligo.org>
6
6
  Maintainer-email: Jameson Graef Rollins <jameson.rollins@ligo.org>, Patrick Thomas <patrick.thomas@ligo.org>, Erik von Reis <erik.vonreis@ligo.org>
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: COPYING
19
19
  License-File: COPYING-GPL-3
20
20
  Requires-Dist: cairosvg
21
- Requires-Dist: dttlib>=0.11.3
21
+ Requires-Dist: dttlib<0.13.0,>=0.12.2
22
22
  Requires-Dist: gpstime
23
23
  Requires-Dist: h5py
24
24
  Requires-Dist: matplotlib
@@ -0,0 +1,11 @@
1
+ #!/bin/bash
2
+
3
+ pyver="$(python3 --version | grep -oP '\d+\.\d+' | tr -d '.')"
4
+ wheelpath="../dttlib/wheels/*${pyver}*.whl"
5
+ wheelcount=$( ls ${wheelpath} 2> /dev/null | wc -l )
6
+ if [[ $wheelcount -gt 0 ]]
7
+ then
8
+ pip install "$(ls -tr ${wheelpath} )" --force-reinstall
9
+ else
10
+ echo "no wheel file found for python${pyver}"
11
+ fi
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.22.3'
22
- __version_tuple__ = version_tuple = (0, 22, 3)
21
+ __version__ = version = '0.22.4'
22
+ __version_tuple__ = version_tuple = (0, 22, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g4ca9d3562'
24
+ __commit_id__ = commit_id = 'g3d3f44107'
@@ -808,7 +808,7 @@ class DataCache(QtCore.QObject):
808
808
 
809
809
  def guess_invalid_channel(self, msg: str) -> str:
810
810
  """Transform 'Invalid channel name' messages into something more helpful"""
811
- if "Invalid channel" in msg:
811
+ if "Invalid channel" in msg or ("Unavailable," in msg and "channel(s)" in msg):
812
812
  channels = self.channel_names()
813
813
  suspect = None
814
814
  if len(channels) == 1:
@@ -1,5 +1,9 @@
1
+ import logging
1
2
  import os
2
3
  import io
4
+ import re
5
+ import subprocess
6
+ import sys
3
7
  from collections import OrderedDict
4
8
  from typing import Optional, Tuple
5
9
  import xml.etree.ElementTree as ET
@@ -8,6 +12,7 @@ import dttlib
8
12
  import yaml
9
13
  import numpy as np
10
14
  from qtpy import QtCore
15
+ from qtpy.QtGui import QDesktopServices
11
16
  from pyqtgraph import exporters
12
17
  import h5py
13
18
 
@@ -22,6 +27,7 @@ except:
22
27
  cairosvg = None
23
28
 
24
29
  from scipy.io import savemat
30
+ from scipy.io import wavfile
25
31
  import matplotlib.pyplot as plt
26
32
 
27
33
  from .data import DataBufferDict
@@ -29,12 +35,15 @@ from .template import get_channel_color
29
35
 
30
36
  ##################################################
31
37
 
38
+ logger = logging.getLogger(__name__)
39
+
32
40
  EXPORT_FILTERS = [
33
41
  ("scene to Portable Network Graphic (*.png)", [".png"]),
34
42
  ("scene to Scalable Vector Graphic (*.svg)", [".svg"]),
35
43
  ("scene to Portable Document Format (*.pdf)", [".pdf"]),
36
44
  ("data to Hierarchical Data Format (*.hdf5 *.h5)", [".hdf5", ".h5"]),
37
45
  ("data to MATLAB Binary Format (*.mat)", [".mat"]),
46
+ ("data to WAV sound files, one per channel (*.wav)", [".wav"]),
38
47
  ("template to YAML (*.yaml *.yml)", [".yaml", ".yml"]),
39
48
  ]
40
49
 
@@ -83,6 +92,28 @@ def export_dialog(dialog_func, parent, path):
83
92
  return path
84
93
 
85
94
 
95
+ def open_file(path) -> bool:
96
+ """Open file with the OS default application (Linux, Mac, Windows)
97
+
98
+ Returns True if the request was handed to the OS. Success does
99
+ not guarantee the application started, only that it was launched.
100
+
101
+ """
102
+ if QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(os.path.abspath(path))):
103
+ return True
104
+ # fall back to native launchers if Qt couldn't find a handler
105
+ try:
106
+ if sys.platform.startswith("win"):
107
+ os.startfile(path) # type: ignore
108
+ elif sys.platform == "darwin":
109
+ subprocess.Popen(["open", path])
110
+ else:
111
+ subprocess.Popen(["xdg-open", path])
112
+ except (OSError, AttributeError):
113
+ return False
114
+ return True
115
+
116
+
86
117
  def _write_file(data, path):
87
118
  with open(path, "wb") as f:
88
119
  f.write(data)
@@ -270,10 +301,82 @@ def export_data_mat(ddict: DataBufferDict, path, start, end, **kwargs):
270
301
  savemat(path, out)
271
302
 
272
303
 
304
+ # lowest sample rate (Hz) written to a WAV header. Slower channels
305
+ # are sped up by a power of two so the result is audible.
306
+ WAV_MIN_RATE_HZ = 8000
307
+
308
+
309
+ def _wav_speedup(rate_hz: float) -> int:
310
+ """Power of two playback speed-up needed to reach WAV_MIN_RATE_HZ"""
311
+ factor = 1
312
+ while rate_hz * factor < WAV_MIN_RATE_HZ:
313
+ factor *= 2
314
+ return factor
315
+
316
+
317
+ def _wav_samples(data) -> Optional[np.ndarray]:
318
+ """Convert channel data to float32 audio: mean removed, peak of 1.
319
+
320
+ Returns None if there is nothing to hear (empty, constant, or
321
+ non-finite data).
322
+
323
+ """
324
+ data = np.asarray(data, dtype=np.float64).ravel()
325
+ data = data[np.isfinite(data)]
326
+ if data.size == 0:
327
+ return None
328
+ data = data - data.mean()
329
+ peak = np.max(np.abs(data))
330
+ if peak == 0:
331
+ return None
332
+ return (data / peak).astype(np.float32)
333
+
334
+
335
+ def _wav_filename(path, id) -> str:
336
+ base, ext = os.path.splitext(path)
337
+ chan = re.sub(r"[^A-Za-z0-9_.-]", "_", str(id.name))
338
+ stat = re.sub(r"[^A-Za-z0-9_.-]", "_", str(id.trend_stat.data_name()))
339
+ return f"{base}_{chan}_{stat}{ext}"
340
+
341
+
342
+ def export_data_wav(
343
+ ddict: DataBufferDict,
344
+ path,
345
+ start: dttlib.PipInstant,
346
+ end: dttlib.PipInstant,
347
+ **kwargs,
348
+ ) -> list:
349
+ """Save each channel to its own WAV file
350
+
351
+ Files are named <path base>_<channel>_<stat>.wav. Each channel
352
+ is written at its native sample rate, with the mean removed and
353
+ scaled to full range. Channels with sample rates too low to hear
354
+ are sped up by a power of two (see WAV_MIN_RATE_HZ). Returns the
355
+ list of paths written.
356
+
357
+ """
358
+ if ddict is None:
359
+ raise ValueError("No data to export")
360
+ written = []
361
+ for id, datad in ddict.items():
362
+ samples = _wav_samples(_get_data_slice(datad.original_struct, start, end))
363
+ if samples is None:
364
+ logger.info("wav export: no audible data for %s, skipping", id.name)
365
+ continue
366
+ rate = int(round(datad.sample_rate * _wav_speedup(datad.sample_rate)))
367
+ fpath = _wav_filename(path, id)
368
+ wavfile.write(fpath, rate, samples)
369
+ written.append(fpath)
370
+ if not written:
371
+ raise ValueError("No audible data to export")
372
+ return written
373
+
374
+
273
375
  DATA_EXPORT_FUNCS = {
274
376
  ".hdf5": export_data_hdf5,
275
377
  ".h5": export_data_hdf5,
276
378
  ".mat": export_data_mat,
379
+ ".wav": export_data_wav,
277
380
  }
278
381
 
279
382
 
@@ -28,7 +28,7 @@ from . import cursors
28
28
  from . import markers
29
29
  from .dtt.channel_trend_id import ChannelTrendId
30
30
  from .axis import LogAxis
31
- from typing import Any, List, Tuple, Dict, TYPE_CHECKING, Optional
31
+ from typing import Any, List, Tuple, Dict, TYPE_CHECKING, Optional, Set
32
32
  from weakref import WeakSet
33
33
 
34
34
  if TYPE_CHECKING:
@@ -384,6 +384,7 @@ class CurveView:
384
384
  self.args = args
385
385
  self.kwargs = kwargs
386
386
  self.curve.setData(x=x, y=y, *args, **kwargs)
387
+ self.original_t0 = t0
387
388
 
388
389
  def update_t0(self, t0: PipInstant):
389
390
  """
@@ -391,6 +392,8 @@ class CurveView:
391
392
  """
392
393
  delta = self.original_t0 - t0
393
394
  x = self.x + delta.to_seconds()
395
+ self.x[:] = x
396
+ self.original_t0 = t0
394
397
  self.curve.setData(x=x, y=self.y, *self.args, **self.kwargs)
395
398
 
396
399
 
@@ -486,6 +489,7 @@ class NDScopePlot(BasePlot):
486
489
 
487
490
  self.has_t_cursors = True
488
491
  self.has_y_cursors = True
492
+ self.latest_chan_ids: Set[ChannelTrendId] = set()
489
493
 
490
494
  def __del__(self):
491
495
  logger.info("deleting plot %s", str(self))
@@ -819,10 +823,20 @@ class NDScopePlot(BasePlot):
819
823
  self.markers.set_t0(t0)
820
824
  self._set_t_limits(t0.to_gpst_seconds())
821
825
 
826
+ # clear the latest chan_id list only when the trends change
827
+ # so that we collect all chan ids drawn for the current trend
828
+ if (
829
+ self.latest_chan_ids
830
+ and next(iter(self.latest_chan_ids)).trend_stat
831
+ != next(iter(data.buffers.keys())).trend_stat
832
+ ):
833
+ self.latest_chan_ids = set()
822
834
  for chan_id, curve_view in self.curve_view.items():
823
835
  if chan_id not in data:
824
836
  continue
825
837
 
838
+ self.latest_chan_ids.add(chan_id)
839
+
826
840
  cd = data[chan_id]
827
841
 
828
842
  # if cd.is_trend:
@@ -889,8 +903,10 @@ class NDScopePlot(BasePlot):
889
903
  Shift all curves to reflect a new t0
890
904
  needed in case fetch data doesn't ever return anything.
891
905
  """
892
- for curve_view in self.curve_view.values():
893
- curve_view.update_t0(t0)
906
+ for channel_id in self.latest_chan_ids:
907
+ if channel_id in self.curve_view:
908
+ curve_view = self.curve_view[channel_id]
909
+ curve_view.update_t0(t0)
894
910
  self.markers.set_t0(t0)
895
911
 
896
912
  def update_results(self, results: dict[str, "Result"]):
@@ -7,6 +7,7 @@ import copy
7
7
  import logging
8
8
  import os
9
9
  import subprocess
10
+ import tempfile
10
11
  import traceback
11
12
  import heapq
12
13
  from time import sleep
@@ -216,6 +217,11 @@ class NDScope(*load_ui("scope.ui")):
216
217
  # flag to indicate that we are in a base state with no
217
218
  # channels declared
218
219
  self._base = True
220
+ # paths written by multi-file exports, keyed by requested path
221
+ self._export_files: Dict[str, List[str]] = {}
222
+ # scratch directory for play_sound, created on first use and
223
+ # removed when the scope is garbage collected or exits
224
+ self._sound_dir: Optional[tempfile.TemporaryDirectory] = None
219
225
 
220
226
  # FIXME: HACK: this is an attempt to bypass the following bug:
221
227
  #
@@ -412,6 +418,7 @@ class NDScope(*load_ui("scope.ui")):
412
418
  self.exportPath.returnPressed.connect(self._export_save_handler)
413
419
  self.exportSaveButton.clicked.connect(self._export_save_handler)
414
420
  self.exportShowButton.clicked.connect(self._export_show_handler)
421
+ self.exportPlayButton.clicked.connect(self.play_sound)
415
422
  self.exportButton2.clicked.connect(self.snapshot)
416
423
 
417
424
  ##########
@@ -458,6 +465,7 @@ class NDScope(*load_ui("scope.ui")):
458
465
  self.exportShowButton.setIcon(
459
466
  self.style().standardIcon(QStyle.SP_FileDialogContentsView) # type: ignore
460
467
  )
468
+ self.exportPlayButton.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay)) # type: ignore
461
469
  # self.exportButton2.setIcon(self.style().standardIcon(QStyle.SP_DialogSaveButton))
462
470
 
463
471
  ##########
@@ -875,12 +883,13 @@ class NDScope(*load_ui("scope.ui")):
875
883
  if y_range:
876
884
  plot.set_y_range(y_range)
877
885
 
878
- if y_range:
886
+ if x_range:
879
887
  plot.set_x_range(x_range)
880
888
 
881
889
  plot.lock_aspect_ratio(lock_aspect_ratio)
882
890
 
883
891
  self.set_font(self.font())
892
+ plot.showGrid(alpha=self.gridAlphaSelect.value())
884
893
 
885
894
  plot.row_stretch_factor = row_stretch_factor
886
895
  plot.col_stretch_factor = col_stretch_factor
@@ -991,6 +1000,7 @@ class NDScope(*load_ui("scope.ui")):
991
1000
  plot.set_y_range(y_range)
992
1001
 
993
1002
  self.set_font(self.font())
1003
+ plot.showGrid(alpha=self.gridAlphaSelect.value())
994
1004
 
995
1005
  plot.row_stretch_factor = row_stretch_factor
996
1006
  plot.col_stretch_factor = col_stretch_factor
@@ -1441,8 +1451,7 @@ class NDScope(*load_ui("scope.ui")):
1441
1451
  """Stop online mode"""
1442
1452
  logger.info("STOP")
1443
1453
  self.data.online_stop()
1444
- # consider a stop as fetch for the stop range
1445
- self.last_cmd = ("fetch", {"t0": self.t0, "window": self.get_window()})
1454
+ self.fetch(t0=self.t0, window=self.get_window())
1446
1455
 
1447
1456
  def _data_request(self, force=False, snapshot=False, singleshot=False):
1448
1457
  """
@@ -1471,7 +1480,8 @@ class NDScope(*load_ui("scope.ui")):
1471
1480
  elif self.data.online:
1472
1481
  extend = PipDuration.from_sec(0)
1473
1482
  else:
1474
- extend = (end - start) / 2
1483
+ extend = (end - start) / 4
1484
+ self._offline_ui()
1475
1485
 
1476
1486
  self.data.request(
1477
1487
  trend, (start - extend, end + extend), force, snapshot, singleshot
@@ -1612,12 +1622,6 @@ class NDScope(*load_ui("scope.ui")):
1612
1622
 
1613
1623
  # SLOT
1614
1624
  def data_retrieve_done(self, _tag: str, msg: Message | None = None, active=False):
1615
- if self.data.empty:
1616
- self._base_ui()
1617
- elif self.data.online:
1618
- self._online_ui()
1619
- else:
1620
- self._offline_ui()
1621
1625
  if msg is not None:
1622
1626
  error = msg.short_msg
1623
1627
  else:
@@ -2429,7 +2433,8 @@ class NDScope(*load_ui("scope.ui")):
2429
2433
  def export(self, path=None):
2430
2434
  """Export plot scene, data, or template to file.
2431
2435
 
2432
- Supports PNG, SVG, PDF image formats, HDF5 for data, and YAML
2436
+ Supports PNG, SVG, PDF image formats, HDF5, MAT and WAV (one file per
2437
+ channel) for data, and YAML
2433
2438
  for template. A file select dialog will be presented if a
2434
2439
  path is not supplied.
2435
2440
 
@@ -2488,12 +2493,15 @@ class NDScope(*load_ui("scope.ui")):
2488
2493
  return
2489
2494
 
2490
2495
  try:
2491
- export_func(*args, **kwargs)
2496
+ result = export_func(*args, **kwargs)
2492
2497
  except Exception as e:
2493
2498
  logger.info(traceback.format_exc())
2494
2499
  set_error(tag, f"Export failed: {str(e)}")
2495
2500
  return
2496
2501
 
2502
+ if isinstance(result, list):
2503
+ # exporters that write multiple files return their paths
2504
+ self._export_files[path] = result
2497
2505
  ftype = ext[1:].upper()
2498
2506
  set_info(tag, f"Exported {obj} to {ftype}: {path}")
2499
2507
  self._export_complete.emit()
@@ -2505,21 +2513,57 @@ class NDScope(*load_ui("scope.ui")):
2505
2513
  def _export_show_handler(self):
2506
2514
  tag = "export"
2507
2515
  path = self.exportPath.text()
2508
- if not path or not os.path.exists(path):
2516
+ if not path or not (os.path.exists(path) or path in self._export_files):
2509
2517
  set_error(tag, "No path, or no file at path. Export first.")
2510
2518
  return
2511
2519
  ext = os.path.splitext(path)[1]
2512
2520
  if ext in [".hdf5", ".h5"]:
2513
2521
  export.matplot_h5(path)
2514
2522
  else:
2515
- try:
2516
- subprocess.Popen(["xdg-open", path])
2517
- except FileNotFoundError:
2518
- set_error(
2519
- tag,
2520
- "xdg-open binary not found. Please install 'xdg-utils' package.",
2521
- )
2522
- # FIXME: catch open errors even though process backgrounded?
2523
+ # multi-file exports (e.g. wav) open their first file
2524
+ files = self._export_files.get(path)
2525
+ target = files[0] if files else path
2526
+ if not os.path.exists(target):
2527
+ set_error(tag, f"No file at {target}. Export first.")
2528
+ elif not export.open_file(target):
2529
+ set_error(tag, f"Could not open {target} with the default application.")
2530
+ elif files and len(files) > 1:
2531
+ set_info(tag, f"Opened {target} (1 of {len(files)} files)")
2532
+
2533
+ def play_sound(self):
2534
+ """Export visible raw data to a temporary WAV file and play it
2535
+
2536
+ One file is written per channel, but only the first is played.
2537
+
2538
+ """
2539
+ tag = "play"
2540
+ data_block = self.get_current_data_block()
2541
+ if data_block is None:
2542
+ set_error(tag, "No data to play.")
2543
+ return
2544
+ if self._sound_dir is None:
2545
+ self._sound_dir = tempfile.TemporaryDirectory(prefix="ndscope_")
2546
+ window = self.get_window()
2547
+ start = self.t0 + window[0]
2548
+ end = self.t0 + window[1]
2549
+ try:
2550
+ files = export.export_data_wav(
2551
+ data_block,
2552
+ os.path.join(self._sound_dir.name, "temporary.wav"),
2553
+ start,
2554
+ end,
2555
+ )
2556
+ except Exception as e:
2557
+ logger.info(traceback.format_exc())
2558
+ set_error(tag, f"Could not create sound file: {str(e)}")
2559
+ return
2560
+ if not export.open_file(files[0]):
2561
+ set_error(tag, f"Could not open {files[0]} with the default application.")
2562
+ return
2563
+ msg = f"Playing {os.path.basename(files[0])}"
2564
+ if len(files) > 1:
2565
+ msg += f" (1 of {len(files)} channels)"
2566
+ set_info(tag, msg)
2523
2567
 
2524
2568
  def snapshot(self):
2525
2569
  """Take snapshot of scene (PNG) and copy to clipboard"""
@@ -1810,7 +1810,7 @@
1810
1810
  </sizepolicy>
1811
1811
  </property>
1812
1812
  <property name="text">
1813
- <string>Export scene image (PNG, SVG, PDF), data (HDF5, MAT), or layout (YAML).
1813
+ <string>Export scene image (PNG, SVG, PDF), data (HDF5, MAT, WAV), or layout (YAML).
1814
1814
  Format determined by file name extension (e.g. &quot;.png&quot; etc.).</string>
1815
1815
  </property>
1816
1816
  <property name="wordWrap">
@@ -1867,6 +1867,16 @@ Format determined by file name extension (e.g. &quot;.png&quot; etc.).</string>
1867
1867
  </property>
1868
1868
  </widget>
1869
1869
  </item>
1870
+ <item>
1871
+ <widget class="QPushButton" name="exportPlayButton">
1872
+ <property name="toolTip">
1873
+ <string>export visible raw data to a temporary WAV file and play it (first channel only)</string>
1874
+ </property>
1875
+ <property name="text">
1876
+ <string>play</string>
1877
+ </property>
1878
+ </widget>
1879
+ </item>
1870
1880
  </layout>
1871
1881
  </item>
1872
1882
  </layout>
@@ -241,6 +241,7 @@ def get_channel_color(channel: str, color=None):
241
241
 
242
242
  """
243
243
  global _CHANNEL_COLORS
244
+ global _PEN_GEN
244
245
  if color:
245
246
  if isinstance(color, str):
246
247
  if color[0] == "#":
@@ -267,6 +268,10 @@ def get_channel_color(channel: str, color=None):
267
268
  )
268
269
  _CHANNEL_COLORS[channel] = rgb
269
270
  elif channel not in _CHANNEL_COLORS:
271
+ if _PEN_GEN is None:
272
+ raise ValueError(
273
+ "reset_colors() must be called once before calling g_channel_color()"
274
+ )
270
275
  color = _PEN_GEN.__next__()
271
276
  _CHANNEL_COLORS[channel] = color
272
277
  return color_RGB_to_24bit(_CHANNEL_COLORS[channel])
@@ -504,6 +509,7 @@ def template_from_chans(chan_layout):
504
509
  chans = []
505
510
  r = 0
506
511
  c = 0
512
+ template = _new_template()
507
513
  for chan in chan_layout + ["."]:
508
514
  if chan in [",", "."]:
509
515
  layout.append(
@@ -522,7 +528,6 @@ def template_from_chans(chan_layout):
522
528
  else:
523
529
  chans.append({chan: _new_curve(chan)})
524
530
  channels.append(chan)
525
- template = _new_template()
526
531
  template["window-title"] = " ".join(channels)
527
532
  template["plots"] = layout
528
533
  logger.debug("created template from channel list")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ndscope
3
- Version: 0.22.3
3
+ Version: 0.22.4
4
4
  Summary: Next-generation NDS time series plotting
5
5
  Author-email: Jameson Graef Rollins <jameson.rollins@ligo.org>, Patrick Thomas <patrick.thomas@ligo.org>, Erik von Reis <erik.vonreis@ligo.org>
6
6
  Maintainer-email: Jameson Graef Rollins <jameson.rollins@ligo.org>, Patrick Thomas <patrick.thomas@ligo.org>, Erik von Reis <erik.vonreis@ligo.org>
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: COPYING
19
19
  License-File: COPYING-GPL-3
20
20
  Requires-Dist: cairosvg
21
- Requires-Dist: dttlib>=0.11.3
21
+ Requires-Dist: dttlib<0.13.0,>=0.12.2
22
22
  Requires-Dist: gpstime
23
23
  Requires-Dist: h5py
24
24
  Requires-Dist: matplotlib
@@ -1,5 +1,5 @@
1
1
  cairosvg
2
- dttlib>=0.11.3
2
+ dttlib<0.13.0,>=0.12.2
3
3
  gpstime
4
4
  h5py
5
5
  matplotlib
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.22.4",
3
+ "distance": 0,
4
+ "node": "g3d3f44107e580970a472e377e6630717e7b55895",
5
+ "dirty": false,
6
+ "branch": "sound_export",
7
+ "node_date": "2026-10-15"
8
+ }
@@ -40,7 +40,7 @@ classifiers=[
40
40
 
41
41
  dependencies = [
42
42
  "cairosvg",
43
- "dttlib>=0.11.3",
43
+ "dttlib >=0.12.2, <0.13.0",
44
44
  "gpstime",
45
45
  "h5py",
46
46
  "matplotlib",
@@ -1,3 +0,0 @@
1
- #!/bin/bash
2
-
3
- pip install $(ls -tr ../dttlib/wheels/*cp311*.whl | tail -n 1) --force-reinstall
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.22.3",
3
- "distance": 0,
4
- "node": "g4ca9d356298b88804a3bfffab2f7f37e415836db",
5
- "dirty": false,
6
- "branch": "master",
7
- "node_date": "2026-09-08"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes