Commit 421e26d1 authored by Thomas Shaffer's avatar Thomas Shaffer

Merge branch 'master' of https://git.ligo.org/cds/ndscope

parents 4321c3bf 2fc45933
pydv2: Next-genertion NDS time series plotting
ndscope: Next-genertion NDS time series plotting
Copyright 2018 Jameson Graef Rollins and the California Institute of Technology
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
......
PROJECT = ndscope
QTVERS = 4
$(PROJECT)/designer.py: %.py: %.ui
pyuic$(QTVERS) $< -o $@
gui:
designer $(PROJECT)/designer.ui
......@@ -11,16 +11,15 @@ channels simultaneously with intuitive mouse pan/zoom support.
### Features:
* online and offline modes
* fast online data with adjustable lookback window
* mouse pan and zoom, with background auto-fetch of new data
* auto-transition to second/minute trend data at appropriate zoom levels
* triggering, for true oscilloscope behavior
* cursors and crosshair in both time and Y axes
* load layouts from YAML definition files
* "striptool mode", including direct reading of StripTool .stp files, and auto-backfill on startup
* both NDS1 and NDS2 protocols supports
* both python2 and python3 compatible
* save/load layout templates in YAML
* "StripTool mode", including direct reading of StripTool .stp files, and auto-backfill on startup
* NDS2 and NDS1 protocols supports
* python2/3 compatible
## Issues
......@@ -28,17 +27,22 @@ Please report issues to the [gitlab issue tracker](https://git.ligo.org/cds/ndsc
## Requirements
These packages are required to run `ndscope`:
Package requirements for `ndscope` (Debian package names):
* [pyqtgraph](http://pyqtgraph.org/)
* [python3-pyqtgraph](http://pyqtgraph.org/)
* [python3-nds2-client](https://git.ligo.org/nds/nds2-client)
* python3-pyqt4
* python3-pyqt5
* [python3-gpstime](https://git.ligo.org/cds/gpstime)
* python3-dateutil
* python3-yaml
The following packages are used for development purposes
The following packages are used for development purposes:
* pyqt4-dev-tools
* qt4-designer
* setuptools_scm
* pyqt5-dev-tools
* qt5-designer
* python3-setuptools_scm
* pytest3-pytest
`ndscope` is available for Debian 9 via the [LIGO CDSSoft Debian archive](https://git.ligo.org/cds-packaging/docs/wikis/home):
> deb http://apt.ligo-wa.caltech.edu/debian stretch main
#!/usr/bin/python3
from ndscope import __main__
__main__.main()
#!/bin/bash -ex
ROOT=$(dirname $0)
export PYTHONPATH=$(cd "$ROOT"/.. && pwd):$PYTHONPATH
exec kernprof -l -v "$ROOT"/ndscope "$@"
ndscope.png

279 KB | W: | H:

ndscope.png

96.5 KB | W: | H:

ndscope.png
ndscope.png
ndscope.png
ndscope.png
  • 2-up
  • Swipe
  • Onion skin
from pkg_resources import get_distribution, DistributionNotFound
try:
__version__ = get_distribution(__name__).version
except DistributionNotFound:
__version__ = '???'
from .version import version as __version__
except ImportError:
__version__ = '?.?.?'
This diff is collapsed.
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>200</width>
<height>164</height>
</rect>
</property>
<property name="maximumSize">
<size>
<width>200</width>
<height>16777215</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<property name="margin">
<number>2</number>
</property>
<property name="spacing">
<number>0</number>
</property>
<item row="2" column="2" colspan="2">
<widget class="QSpinBox" name="autoPercentSpin">
<property name="enabled">
<bool>true</bool>
</property>
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Percent of data to be visible when auto-scaling. It may be useful to decrease this value for data with spiky noise.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="suffix">
<string>%</string>
</property>
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>100</number>
</property>
<property name="singleStep">
<number>1</number>
</property>
<property name="value">
<number>100</number>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QRadioButton" name="autoRadio">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Automatically resize this axis whenever the displayed data is changed.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Auto</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QRadioButton" name="manualRadio">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Set the range for this axis manually. This disables automatic scaling. &lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Manual</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QLineEdit" name="minText">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Minimum value to display for this axis.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>0</string>
</property>
</widget>
</item>
<item row="1" column="3">
<widget class="QLineEdit" name="maxText">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Maximum value to display for this axis.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>0</string>
</property>
</widget>
</item>
<item row="5" column="0" colspan="4">
<widget class="QCheckBox" name="invertCheck">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Inverts the display of this axis. (+y points downward instead of upward)&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Invert Axis</string>
</property>
</widget>
</item>
<item row="6" column="0" colspan="4">
<widget class="QCheckBox" name="mouseCheck">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Enables mouse interaction (panning, scaling) for this axis.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Mouse Enabled</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item row="3" column="2" colspan="2">
<widget class="QCheckBox" name="visibleOnlyCheck">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;When checked, the axis will only auto-scale to data that is visible along the orthogonal axis.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Visible Data Only</string>
</property>
</widget>
</item>
<item row="4" column="2" colspan="2">
<widget class="QCheckBox" name="autoPanCheck">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;When checked, the axis will automatically pan to center on the current data, but the scale along this axis will not change.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Auto Pan Only</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
import os
import re
from dateutil.tz import tzutc, tzlocal
try:
HOSTPORT = os.getenv('NDSSERVER').split(',')[0].split(':')
except AttributeError:
HOSTPORT = [None]
HOST = HOSTPORT[0]
try:
PORT = int(HOSTPORT[1])
except IndexError:
PORT = 31200
# date/time formatting for GPS conversion
if os.getenv('DATETIME_TZ') == 'LOCAL':
DATETIME_TZ = tzlocal()
......@@ -10,13 +22,27 @@ else:
# FIXME: why does '%c' without explicit TZ give very wrong values??
#DATETIME_FMT = '%c'
DATETIME_FMT = '%a %b %d %Y %H:%M:%S %Z'
DATETIME_FMT_OFFLINE = '%Y-%m-%d %H:%M:%S %Z'
DATETIME_FMT_OFFLINE = '%Y/%m/%d %H:%M:%S %Z'
# thresholds in seconds where requests translate to second and minute
# trends
TREND_THRESHOLD_S = 120
TREND_THRESHOLD_M = 3600
# 4,915,200 points for 16kHz data
TREND_THRESHOLD_S = 300
# 259,200 points for 3*1Hz data
TREND_THRESHOLD_M = 86400
# transformations for the time axis. will transform when span is x10
# larger than the scale factor. should be ordered largest to
# smallest
TIME_AXIS_TRANSFORMS = [
(1, 'seconds'),
(60, 'minutes'),
(3600, 'hours'),
(86400, 'days'),
(604800, 'weeks'),
(2592000, 'months'),
(31536000, 'years'),
]
# percentage of full span to add as additional padding when fetching
......@@ -25,6 +51,17 @@ DATA_SPAN_PADDING = 0.5
# number of lookback bytes available per channel
# 2**22: 4194304
# one week of 16 Hz: 4838400
# 60s of 16k Hz: 7864320
# 60s of 16k Hz: 7864320
# 2**23: 8388608
DATA_LOOKBACK_LIMIT_BYTES = 2**22
# regular expression to match channel strings
CHANNEL_REGEXP = '^([a-zA-Z0-9-]+:)?[a-zA-Z0-9-_\.]+$'
CHANNEL_RE = re.compile(CHANNEL_REGEXP)
# timeout for NDS error messages in the status bar
STATUS_ERROR_TIMEOUT = 5000
......@@ -2,113 +2,78 @@
from __future__ import division
#import pyqtgraph as pg
from . import pghacks as pg
from PyQt4.QtCore import Qt, pyqtSlot
try:
from qtpy.QtCore import Qt
except ImportError:
from PyQt5.QtCore import Qt
import numpy as np
from . import util
from . import const
LABEL_FILL = (0, 0, 0, 200)
class Crosshair:
__slots__ = ['scope', 'hline', 'label',
'plots', 'active_plot', 'last_pos']
__slots__ = [
'__weakref__',
'hline', 'vline', 'label',
'active_plot',
]
pen = pg.mkPen(style=Qt.DotLine)
def __init__(self, scope):
def __init__(self):
"""crosshair needs scope to get t0 value"""
self.scope = scope
self.hline = pg.InfiniteLine(
angle=0,
pen=self.pen,
movable=False,
)
self.vline = pg.InfiniteLine(
angle=90,
pen=self.pen,
movable=False,
)
self.label = pg.TextItem(
anchor=(1, 1),
fill=LABEL_FILL,
)
self.plots = []
self.active_plot = None
self.last_pos = None
def add_plot(self, plot):
vline = pg.InfiniteLine(
angle=90,
pen=self.pen,
movable=False,
)
self.plots.append((plot, vline))
def set_active_plot(self, plot):
if plot == self.active_plot:
return
if self.active_plot:
self.active_plot.removeItem(self.hline)
self.active_plot.removeItem(self.vline)
self.active_plot.removeItem(self.label)
# plot.sigYRangeChanged.disconnect()
self.active_plot = None
if plot:
plot.addItem(self.hline)
plot.addItem(self.vline)
plot.addItem(self.label)
# plot.sigYRangeChanged.connect(self.update_hline)
self.active_plot = plot
def enable(self):
for plot, vline in self.plots:
plot.addItem(vline)
# FIXME: crosshair does not behave well when auto-range is
# enabled, since the plot registers the mouse as being in
# the scene even when it's out of the view, which causes
# it to auto-range to try to bring the lines into view.
# Maybe a way to fix this is to first verify that the
# mouse position is within the view before registering it.
# plot.disableAutoRange()
def disable(self):
self.set_active_plot(None)
for plot, vline in self.plots:
plot.removeItem(vline)
#plot.enableAutoRange()
@pyqtSlot('PyQt_PyObject')
def update(self, event):
# using signal proxy unfortunately turns the original
# arguments into a tuple pos = event
pos = event[0]
self.last_pos = pos
# FIXME: have to do one pass through plots first to remove
# hline from the previous active plot if it's moved, otherwise
# hline is potentially added to multiple plots simultaneously,
# and hline position gets updated in plot where it's currently
# active, causing the auto-scalling to go haywire. is there a
# better way?
for plot, vline in self.plots:
if plot.sceneBoundingRect().contains(pos):
self.set_active_plot(plot)
break
for plot, vline in self.plots:
x = plot.vb.mapSceneToView(pos).x()
vline.setPos(x)
if plot.sceneBoundingRect().contains(pos):
y = plot.vb.mapSceneToView(pos).y()
self.hline.setPos(y)
self.label.setPos(x, y)
self.label.setText(
'{:f}, {:g}'.format(self.scope.t0+x, y)
)
# @pyqtSlot()
# def update_hline(self):
# if self.last_pos:
# y = self.hline_plot.vb.mapSceneToView(self.last_pos).y()
# print(self.last_pos, y)
# self.hline.setPos(y)
def update(self, x, y, t):
self.hline.setPos(y)
self.vline.setPos(x)
self.label.setPos(x, y)
greg = util.gpstime_str_greg(util.gpstime_parse(t), '%Y/%m/%d %H:%M:%S %Z')
label = '''<table>
<tr><td rowspan="2" valign="middle">T=</td><td>{:0.3f}</td></tr>
<tr><td>{}</td></tr>
<tr><td>Y=</td><td>{:g}</td></tr>
</table></nobr>'''.format(t, greg, y)
self.label.setHtml(label)
class TCursors:
__slots__ = ['plots']
__slots__ = [
'__weakref__',
'plots',
]
def __init__(self):
self.plots = []
......@@ -170,13 +135,11 @@ class TCursors:
diff.setValue(diff_pos)
diff.label.setHtml(label)
@pyqtSlot('PyQt_PyObject')
def update_line(self, line):
value = line.value()
index = line._cursor_index
self.set_value(index, value)
@pyqtSlot()
def reset(self):
try:
plot = self.plots[0][0]
......@@ -188,7 +151,10 @@ class TCursors:
class YCursors:
__slots__ = ['Y1', 'Y2', 'diff', 'plot']
__slots__ = [
'__weakref__',
'Y1', 'Y2', 'diff', 'plot',
]
def __init__(self):
pen = pg.mkPen(style=Qt.DashLine)
......@@ -239,7 +205,6 @@ class YCursors:
self.plot = plot
self.reset()
@pyqtSlot('PyQt_PyObject')
def update_line(self, line):
value = line.value()
line.label.setText('{:g}'.format(value))
......@@ -250,7 +215,6 @@ class YCursors:
label = u'ΔY={:g}'.format(vdiff)
self.diff.label.setText(label)
@pyqtSlot()
def reset(self):
if not self.plot:
return
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
from __future__ import division, unicode_literals
import os
import io
import sys
import copy
try:
import yaml
except ImportError:
pass
import traceback
import numpy as np
import logging
##########
TEMPLATE = {
'window-title': '',
'black-on-white': False,
'time-window': None,
'plots': [],
}
PLOT = {
'channels': {},
'row': 0,
'col': 0,
'colspan': 1,
'yrange': 'auto',
}
CURVE = {
'color': None,
'width': 1,
'scale': 1,
'offset': 0,
}
DEFAULT_PEN_COLORS = [
'#1f77b4',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
]
COLOR_INDEX = 0
def _random_color():
c = [int(i) for i in list(np.random.rand(3)*255)]
return '#{:02x}{:02x}{:02x}'.format(*c)
def get_pen_color():
global COLOR_INDEX
try:
c = DEFAULT_PEN_COLORS[COLOR_INDEX]
except IndexError:
return _random_color()
COLOR_INDEX += 1
return c
##########
# create new bare template/plot/curve, populated with defaults
def _new_template(**kwargs):
t = copy.copy(TEMPLATE)
t.update(**kwargs)
return t
def _new_plot(**kwargs):
t = copy.copy(PLOT)
t.update(**kwargs)
return t
def _new_curve(**kwargs):
t = copy.copy(CURVE)
t.update(**kwargs)
if not t['color']:
t['color'] = get_pen_color()
return t
##########
# create specified layout from list of {channel: curve} dicts
def convert_layout(template, targ):
"""convert table layout to grid/stack/single"""
channels = []
for plot in template['plots']:
for chan in plot['channels']:
channels.append(chan)
if targ == 'grid':
layout = _convert_grid(channels)
elif targ == 'stack':
layout = _convert_stack(channels)
elif targ == 'single':
layout = _convert_single(channels)
else:
raise ValueError("unknown layout: {}".format(targ))
template['plots'] = layout
def _convert_grid(channels):
num = len(channels)
rows = int(np.ceil(np.sqrt(num)))
cols = int(np.ceil(float(num)/rows))
layout = []
r = 0
c = 0
for i, chan in enumerate(channels):
layout.append(
_new_plot(
channels=[chan],
row=r,
col=c,
))
c += 1
if c == cols:
c = 0
r += 1
return layout
def _convert_stack(channels):
layout = []
for i, chan in enumerate(channels):
layout.append(
_new_plot(
channels=[chan],
row=i,
)
)
return layout
def _convert_single(channels):
layout = [
_new_plot(
channels=channels,
)
]
return layout
##########
class TemplateError(Exception):
pass
def validate_template(template):
try:
time_window = template.get('time-window')
if time_window:
try:
template['time-window'] = [float(t) for t in template['time-window']]
except TypeError:
template['time-window'] = float(template['time-window'])
for plot in template['plots']:
channels = plot['channels']
if isinstance(channels, dict):
plot['channels'] = [{chan: curve} for chan, curve in channels.items()]
else:
plot['channels'] = [dict(chan.items()) for chan in channels]
except:
raise TemplateError("error parsing template")
def load_template(path):
"""load template from path or stdin (if path == '-')
Could be template file or channel table description.
"""
if path == '-':
ext = ''
f = sys.stdin
else:
ext = os.path.splitext(path)[1]
f = io.open(path, 'r', encoding='utf-8')
data = io.StringIO(f.read())
f.close()
if ext == '':
template = None
ltype = None
for func in [
template_from_yaml,
template_from_stp,
template_from_txt,
]:
data.seek(0)
try:
logging.debug("template try: {}".format(func.__name__))
template, ltype = func(data)
break
except:
logging.debug(traceback.format_exc(0))
continue
if template is None:
raise TemplateError("Could not parse template.")
elif ext in ['.yaml', '.yml']:
template, ltype = template_from_yaml(data)
elif ext == '.stp':
template, ltype = template_from_stp(data)
elif ext == '.txt':
template, ltype = template_from_txt(data)
else:
raise TemplateError("Unknown layout format '{}'".format(ext))
validate_template(template)
if 'window-title' not in template or not template['window-title']:
template['window-title'] = os.path.basename(os.path.splitext(path)[0])
return template, ltype
##########
def template_from_chans(chan_layout):
"""create template from channel table description
"""
layout = []
channels = []
chans = []