pytelem major work

This commit is contained in:
saji 2023-09-19 14:17:22 -05:00
parent c772d6e95f
commit 2d634d863d
20 changed files with 1806 additions and 1332 deletions

View file

@ -274,16 +274,21 @@ func (x *xBeeService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
}
logger.Info("connected to local xbee", "addr", x.session.LocalAddr())
writeJSON := json.NewEncoder(x.session)
xbeePackets := make(chan skylab.BusEvent)
// these are the ways we send/recieve data. we could swap for binary format
// TODO: buffering and/or binary encoding instead of json which is horribly ineffective.
xbeeTxer := json.NewEncoder(x.session)
xbeeRxer := json.NewDecoder(x.session)
// xbeePackets := make(chan skylab.BusEvent)
// background task to read json packets off of the xbee and send them to the
go func() {
decoder := json.NewDecoder(x.session)
for {
var p skylab.BusEvent
err := decoder.Decode(&p)
err := xbeeRxer.Decode(&p)
if err != nil {
logger.Error("failed to decode xbee packet")
}
broker.Publish("xbee", p)
}
}()
for {
@ -293,7 +298,7 @@ func (x *xBeeService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
return
case msg := <-rxCh:
logger.Info("got msg", "msg", msg)
writeJSON.Encode(msg)
xbeeTxer.Encode(msg)
if err != nil {
logger.Warn("error writing to xbee", "err", err)
}
@ -321,6 +326,7 @@ func (h *httpService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
r := gotelem.TelemRouter(logger, broker, db)
/// TODO: use custom port if specified
http.ListenAndServe(":8080", r)
return
}

View file

@ -35,6 +35,7 @@ const (
// CanFilter is a basic filter for masking out data. It has an Inverted flag
// which indicates opposite behavior (reject all packets that match Id and Mask).
// The filter matches when (packet.Id & filter.Mask) == filter.Id
// TODO: is this needed anymore since we are using firmware based version instead?
type CanFilter struct {
Id uint32
Mask uint32

View file

@ -101,7 +101,9 @@ func apiV1(broker *Broker, db *db.TelemDb) chi.Router {
})
r.Get("/stats", func(w http.ResponseWriter, r *http.Request) {}) // v1 api stats (calls, clients, xbee connected, meta health ok)
r.Get("/stats", func(w http.ResponseWriter, r *http.Request) {
}) // v1 api stats (calls, clients, xbee connected, meta health ok)
return r
}

View file

@ -40,7 +40,10 @@ func Slogger(sl *slog.Logger) func(next http.Handler) http.Handler {
}()
// embed the logger and the attrs for later items in the chain.
r = r.WithContext(context.WithValue(r.Context(), SloggerAttrsKey, attrs))
ctx := context.WithValue(r.Context(), SloggerAttrsKey, attrs)
ctx = context.WithValue(ctx, SloggerLogKey, logger)
// push it to the request and serve the next handler
r = r.WithContext(ctx)
next.ServeHTTP(ww, r)
}
@ -64,3 +67,5 @@ func AddSlogAttr(r *http.Request, attr slog.Attr) {
attrs = append(attrs, attr)
}
// TODO: write rest of functions

View file

@ -1,20 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JupyterPersistentConnectionParameters">
<option name="knownRemoteServers">
<list>
<JupyterConnectionParameters>
<option name="authType" value="notebook" />
<option name="token" value="5a7fb936e2f1eafcdefbb7fa3ea339000213214ae7e35195" />
<option name="urlString" value="http://127.0.0.1:8888" />
<authParams2>
<map>
<entry key="token" value="5a7fb936e2f1eafcdefbb7fa3ea339000213214ae7e35195" />
</map>
</authParams2>
</JupyterConnectionParameters>
</list>
</option>
<option name="moduleParameters">
<map>
<entry key="$PROJECT_DIR$/.idea/py.iml">

View file

@ -1,4 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="executionMode" value="BINARY" />
<option name="pathToExecutable" value="/usr/bin/black" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (py)" project-jdk-type="Python SDK" />
<component name="PyPackaging">
<option name="earlyReleasesAsUpgrades" value="true" />
</component>
</project>

View file

@ -2,7 +2,7 @@
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="jdk" jdkName="Poetry (py)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">

View file

@ -44,7 +44,7 @@ def rip_and_tear(fname: Path, features: list[PlotFeature]):
for path in data[j['name']].keys():
d = glom.glom(j['data'], path)
ts = j['ts']
ts = j['ts'] - 1688756556040
data[j['name']][path].append([ts, d])
# TODO: numpy the last list???
return data
@ -56,7 +56,7 @@ if __name__ == "__main__":
PlotFeature("wsr_phase_current", ["phase_b_current"]),
PlotFeature("wsr_motor_current_vector", ["iq"]),
PlotFeature("wsr_motor_voltage_vector", ["vq"]),
PlotFeature("wsr_bus_measurement", ["bus_current"])
PlotFeature("wsr_velocity", ["motor_velocity"])
]
logs_path = Path("../../logs/")
logfile = logs_path / "RETIME_7-2-hillstart.txt"

2582
py/poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ imgui-bundle = "^0.8.5"
numpy = "^1.24.3"
aiohttp = "^3.8.4"
pyside6 = "^6.5.0"
pydantic = "^1.10.9"
pydantic = "^2"
pyyaml = "^6.0"
jinja2 = "^3.1.2"
pyqtgraph = "^0.13.3"

View file

@ -1,6 +1,14 @@
from functools import cached_property
import aiohttp
import orjson
import threading
from typing import Dict
from PySide6.QtCore import QObject, Signal, Slot
from pytelem.skylab import SkylabFile
# connect to websocket - create thread that handles JSON events
class TelemetryServer(QObject):
@ -9,11 +17,23 @@ class TelemetryServer(QObject):
conn_url: str
"Something like http://<some_ip>:8082"
callbacks: Dict[str, Signal]
def __init__(self, url: str, parent=None):
super().__init__(parent)
self.conn_url = url
NewPacket = Signal(object)
"""Signal that is emitted when a new packet is received in realtime. Contains the packet itself"""
@cached_property
def schema(self) -> SkylabFile:
"""Gets the Packet Schema from the server"""
pass
@Slot()
def connect(self):
"""Attempt to connect to server"""
def query(self, queryparams):
"""Query the historical data and store the result in the datastore"""

View file

@ -56,7 +56,7 @@ class BMSOverview(QWidget):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setMaximumWidth
# self.setMaximumWidth()
layout = QGridLayout()
layout.setRowStretch(0, 80)
layout.setRowStretch(1, 20)

View file

@ -1,56 +1,83 @@
import random
import sys
import logging
import pyqtgraph.parametertree
from PySide6 import QtWidgets, QtCore
from PySide6.QtCore import QDir, Qt, QObject
from PySide6.QtCore import QDir, Qt, QObject, Slot, Signal, QTimer
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QWidget,
QMainWindow,
QTreeView,
QDockWidget,
QDockWidget, QToolBar, QPlainTextEdit,
)
from gui_log import QLogHandler
from pytelem.widgets.smart_display import SmartDisplay
from bms import BMSOverview
class QtLogger(logging.Handler, QObject):
appendLog = QtCore.Signal(str)
def __init__(self, parent):
super().__init__()
QtCore.QObject.__init__(self)
self.widget = QtWidgets.QPlainTextEdit(parent)
self.widget.setReadOnly(True)
self.appendLog.connect(self.widget.appendPlainText)
def emit(self, record):
msg = self.format(record)
self.appendLog.emit(msg)
class DataStore:
class DataStore(QObject):
"""Stores all packets and timestamps for display and logging.
Queries the upstreams for the packets as they come in as well as historical"""
def __init__(self, remote):
pass
super().__init__()
class MainApp(QMainWindow):
new_data = Signal(float)
def __init__(self):
super().__init__()
self.setWindowTitle("Hey there")
self.setWindowTitle("pyview")
layout = QtWidgets.QVBoxLayout()
mb = self.menuBar()
self.WindowMenu = mb.addMenu("Windows")
bms = BMSOverview()
dw = QDockWidget('bms', self)
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dw)
dw.setWidget(PacketTree())
self.setCentralWidget(bms)
packet_tree = QDockWidget('Packet Tree', self)
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, packet_tree)
packet_tree.setWidget(PacketTreeView())
packet_tree.hide()
self.ShowPacketTree = packet_tree.toggleViewAction()
self.WindowMenu.addAction(self.ShowPacketTree)
log_dock = QDockWidget('Application Log', self)
self.qlogger = QLogHandler()
self.log_box = QPlainTextEdit()
self.log_box.setReadOnly(True)
log_dock.setWidget(self.log_box)
self.qlogger.bridge.log.connect(self.log_box.appendPlainText)
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, log_dock)
self.logger = logging.Logger("Main")
self.logger.addHandler(self.qlogger)
self.logger.info("hi there!")
self.ShowLog = log_dock.toggleViewAction()
self.ShowLog.setShortcut("CTRL+L")
self.WindowMenu.addAction(self.ShowLog)
self.display = SmartDisplay(self, "test")
self.new_data.connect(self.display.update_value)
# start a qtimer to generate random data.
self.timer = QTimer(parent=self)
self.timer.timeout.connect(self.__random_data)
# self.__random_data.connect(self.timer.timeout)
self.timer.start(100)
self.setCentralWidget(self.display)
@Slot()
def __random_data(self):
# emit random data to the new_data
yay = random.normalvariate(10, 1)
self.logger.info(yay)
self.new_data.emit(yay)
class PacketTree(QWidget):
class PacketTreeView(QWidget):
"""PacketView is a widget that shows a tree of packets as well as properties on them when selected."""
def __init__(self, parent: QtWidgets.QWidget | None = None):
@ -69,6 +96,10 @@ class PacketTree(QWidget):
self.setLayout(layout)
class SolverView(QWidget):
"""Main Solver Widget/Window"""
if __name__ == "__main__":
app = QApplication(sys.argv)
main_window = MainApp()

20
py/pytelem/gui_log.py Normal file
View file

@ -0,0 +1,20 @@
import sys
import logging
from PySide6.QtCore import QObject, Slot, Signal
from PySide6.QtWidgets import QPlainTextEdit
class Bridge(QObject):
log = Signal(str)
class QLogHandler(logging.Handler):
bridge = Bridge()
def __init__(self):
super().__init__()
def emit(self, record):
msg = self.format(record)
self.bridge.log.emit(msg)

View file

@ -24,14 +24,14 @@ from numba import jit
def fsolve_discrete():
...
"""Forward compute a route segment."""
def dist_to_pos(dist: float):
"convert a distance along the race path to a position in 3d space"
"""convert a distance along the race path to a position in 3d space"""
### All units are BASE SI (no prefix except for kilogram)
# All units are BASE SI (no prefix except for kilogram)
ATM_MOLAR_MASS = 0.0289644 # kg/mol
STANDARD_TEMP = 288.15 # K
STANDARD_PRES = 101325.0 # Pa
@ -583,18 +583,18 @@ def solar_position(timestamp, latitude, longitude, elevation):
v = v_0 + d_psi * np.cos(np.deg2rad(epsilon))
alpha = np.arctan2(np.sin(np.radians(sun_longitude)) *
np.cos(np.radians(epsilon)) -
np.tan(np.radians(beta)) *
np.sin(np.radians(epsilon)),
np.cos(np.radians(sun_longitude)))
alpha = np.arctan2(np.sin(np.deg2rad(sun_longitude)) *
np.cos(np.deg2rad(epsilon)) -
np.tan(np.deg2rad(beta)) *
np.sin(np.deg2rad(epsilon)),
np.cos(np.deg2rad(sun_longitude)))
alpha_deg = np.rad2deg(alpha) % 360
delta = np.arcsin(
np.sin(np.radians(beta)) *
np.cos(np.radians(epsilon)) +
np.cos(np.radians(beta)) *
np.sin(np.radians(epsilon)) *
np.cos(np.radians(sun_longitude))
np.sin(np.deg2rad(beta)) *
np.cos(np.deg2rad(epsilon)) +
np.cos(np.deg2rad(beta)) *
np.sin(np.deg2rad(epsilon)) *
np.cos(np.deg2rad(sun_longitude))
)
delta_deg = np.rad2deg(delta) % 360
@ -612,4 +612,5 @@ def solar_position(timestamp, latitude, longitude, elevation):
alpha_prime = alpha_deg + d_alpha
delta_prime = np.arctan2((np.sin(delta) - y * np.sin(np.radians(xi_deg))) * np.cos(np.radians(d_alpha)),
np.cos(delta) - x * np.sin(np.radians(xi_deg)) * np.cos(np.radians(h)))
topo_local_hour_angle_deg = h - d_alpha
h_prime = h - d_alpha
e_0 = np.arcsin(np.sin(latitude) * np.sin(delta) + np.cos(latitude) * np.cos(delta_prime))

View file

@ -1,114 +0,0 @@
import time
import numpy as np
from imgui_bundle import implot, imgui_knobs, imgui, immapp, hello_imgui
import aiohttp
import orjson
# Fill x and y whose plot is a heart
vals = np.arange(0, np.pi * 2, 0.01)
x = np.power(np.sin(vals), 3) * 16
y = 13 * np.cos(vals) - 5 * np.cos(2 * vals) - 2 * np.cos(3 * vals) - np.cos(4 * vals)
# Heart pulse rate and time tracking
phase = 0
t0 = time.time() + 0.2
heart_pulse_rate = 80
class PacketState:
"""PacketState is the state representation for a packet. It contains metadata about the packet
as well as a description of the packet fields. Also contains a buffer.
"""
def render_tree(self):
"""Render the Tree view entry for the packet. Only called if the packet is shown."""
pass
def render_graphs(self):
pass
def __init__(self, name: str, description: str | None = None):
self.name = name
self.description = description
# take the data fragment and create internal data representing it.
boards = {
"bms": {
"bms_measurement": {
"description": "Voltages for main battery and aux pack",
"id": 0x10,
"data": {
"battery_voltage": 127.34,
"aux_voltage": 23.456,
"current": 1.23,
},
},
"battery_status": {
"description": "Status bits for the battery",
"id": 0x11,
"data": {
"battery_state": {
"startup": True,
"precharge": False,
"discharging": False,
"lv_only": False,
"charging": False,
"wall_charging": False,
"killed": False,
}, # repeat for rest fo fields
},
},
}
}
def gui():
global heart_pulse_rate, phase, t0, x, y
# Make sure that the animation is smooth
hello_imgui.get_runner_params().fps_idling.enable_idling = False
t = time.time()
phase += (t - t0) * heart_pulse_rate / (np.pi * 2)
k = 0.8 + 0.1 * np.cos(phase)
t0 = t
imgui.show_demo_window()
main_window_flags: imgui.WindowFlags = imgui.WindowFlags_.no_collapse.value
imgui.begin("my application", p_open=None, flags=main_window_flags)
imgui.text("Bloat free code")
if implot.begin_plot("Heart", immapp.em_to_vec2(21, 21)):
implot.plot_line("", x * k, y * k)
implot.end_plot()
for board_name, board_packets in boards.items():
if imgui.tree_node(board_name):
for packet_name in board_packets:
if imgui.tree_node(packet_name):
# display description if hovered
pkt = board_packets[packet_name]
if imgui.is_item_hovered():
imgui.set_tooltip(pkt["description"])
imgui.text(f"0x{pkt['id']:03X}")
imgui.tree_pop()
imgui.tree_pop()
imgui.end() # my application
_, heart_pulse_rate = imgui_knobs.knob("Pulse", heart_pulse_rate, 30, 180)
# class State:
# def __init__(self):
#
# def gui(self):
if __name__ == "__main__":
immapp.run(
gui,
window_size=(300, 450),
window_title="Hello!",
with_implot=True,
fps_idle=0,
) # type: ignore

View file

@ -5,7 +5,7 @@ import re
from pathlib import Path
from typing import Callable, Iterable, NewType, TypedDict, List, Protocol, Union, Set
from pydantic import BaseModel, validator
from pydantic import field_validator, BaseModel, validator, model_validator
from enum import Enum
import yaml
import jinja2
@ -77,35 +77,33 @@ class SkylabField(BaseModel):
"the name of the field. must be alphanumeric and underscores"
type: FieldType
"the type of the field"
units: str | None
units: str | None = None
"optional descriptor of the unit representation"
conversion: float | None
conversion: float | None = None
"optional conversion factor to be applied when parsing"
bits: List[_Bits] | None
bits: List[_Bits] | None = None
"if the type if a bitfield, "
@validator("bits")
def bits_must_exist_if_bitfield(cls, v, values):
if v is None and "type" in values and values["type"] is FieldType.Bitfield:
@model_validator(mode='after')
def bits_must_exist_if_bitfield(self) -> 'SkylabField':
if self.bits is None and self.type == FieldType.Bitfield:
raise ValueError("bits are not present on bitfield type")
if (
v is not None
and "type" in values
and values["type"] is not FieldType.Bitfield
):
if self.bits is not None and self.type != FieldType.Bitfield:
raise ValueError("bits are present on non-bitfield type")
return v
return self
@validator("name")
def name_valid_string(cls, v: str):
@field_validator("name")
@classmethod
def name_valid_string(cls, v: str) -> str:
if not re.match(r"^[A-Za-z0-9_]+$", v):
return ValueError("invalid name")
raise ValueError("invalid name")
return v
@validator("name")
def name_nonzero_length(cls, v: str):
@field_validator("name")
@classmethod
def name_nonzero_length(cls, v: str) -> str:
if len(v) == 0:
return ValueError("name cannot be empty string")
raise ValueError("name cannot be empty string")
return v
@ -120,11 +118,11 @@ class SkylabPacket(BaseModel):
"""Represents a CAN packet. Contains SkylabFields with information on the structure of the data."""
name: str
description: str | None
description: str | None = None
id: int
endian: Endian
repeat: int | None
offset: int | None
repeat: int | None = None
offset: int | None = None
data: List[SkylabField]
# @validator("data")
@ -134,31 +132,37 @@ class SkylabPacket(BaseModel):
# return ValueError("Total packet size cannot exceed 8 bytes")
# return v
@validator("id")
@field_validator("id")
@classmethod
def id_non_negative(cls, v: int) -> int:
if v < 0:
raise ValueError("id must be above zero")
return v
@validator("name")
@field_validator("name")
@classmethod
def name_valid_string(cls, v: str) -> str:
if not re.match(r"^[A-Za-z0-9_]+$", v):
raise ValueError("invalid name", v)
return v
@validator("name")
@field_validator("name")
@classmethod
def name_nonzero_length(cls, v: str) -> str:
if len(v) == 0:
raise ValueError("name cannot be empty string")
return v
@validator("offset")
def offset_must_have_repeat(cls, v: int | None, values) -> int | None:
if v is not None and "repeat" in values and values["repeat"] is not None:
# TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
@model_validator(mode='after')
def offset_must_have_repeat(self) -> "SkylabPacket":
if self.offset is not None and self.repeat is not None:
raise ValueError("field with offset must have repeat defined")
return v
@validator("repeat")
@field_validator("repeat")
@classmethod
def repeat_gt_one(cls, v: int | None):
if v is not None and v <= 1:
raise ValueError("repeat must be strictly greater than one")
@ -180,13 +184,15 @@ class SkylabBoard(BaseModel):
receive: List[str]
"The packets received by this board."
@validator("name")
@field_validator("name")
@classmethod
def name_valid_string(cls, v: str):
if not re.match(r"^[A-Za-z0-9_]+$", v):
return ValueError("invalid name", v)
return v
@validator("name")
@field_validator("name")
@classmethod
def name_nonzero_length(cls, v: str):
if len(v) == 0:
return ValueError("name cannot be empty string")
@ -201,13 +207,15 @@ class SkylabBus(BaseModel):
extended_id: bool
"If the bus uses extended ids"
@validator("name")
@field_validator("name")
@classmethod
def name_valid_string(cls, v: str):
if not re.match(r"^[A-Za-z0-9_]+$", v):
return ValueError("invalid name", v)
return v
@validator("baud_rate")
@field_validator("baud_rate")
@classmethod
def baud_rate_supported(cls, v: int):
if v not in [125000, 250000, 500000, 750000, 1000000]:
raise ValueError("unsupported baud rate", v)

View file

View file

View file

@ -0,0 +1,145 @@
# A simple display for numbers with optional trend_data line, histogram, min/max, and rolling average.
from PySide6.QtCore import Qt, Slot, QSize
from PySide6.QtGui import QAction, QFontDatabase
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QLabel, QSizePolicy, QGridLayout
)
import numpy as np
import pyqtgraph as pg
from typing import Optional, List
class _StatsDisplay(QWidget):
"""Helper Widget for the stats display."""
def __init__(self, parent=None):
super().__init__(parent)
# create grid array, minimum size vertically.
layout = QGridLayout(self)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
@Slot(float, float, float)
def update_values(self, new_min: float, new_avg: float, new_max: float):
class SmartDisplay(QWidget):
"""A simple numeric display with optional statistics, trends, and histogram"""
value: float = 0.0
min: float = -float("inf")
max: float = float("inf")
avg: float = 0.0
trend_data: List[float] = []
histogram_data: List[float] = []
# TODO: settable sample count for histogram/trend in right click menu
def __init__(self, parent=None, title: str = None, initial_value: float = None, unit_suffix=None,
show_histogram=False, show_trendline: bool = False, show_stats=False,
histogram_samples=100, trend_samples=30):
super().__init__(parent)
self.trend_samples = trend_samples
self.histogram_samples = histogram_samples
layout = QVBoxLayout(self)
if title is not None:
self.title = title
# create the title label
self.title_widget = QLabel(title, self)
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.title_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
layout.addWidget(self.title_widget)
number_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
number_font.setPointSize(18)
self.value = initial_value
self.suffix = unit_suffix or ""
self.value_widget = QLabel(f"{self.value}{self.suffix}", self)
self.value_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.value_widget.setFont(number_font)
layout.addWidget(self.value_widget)
# histogram widget
self.histogram_widget = pg.PlotWidget(self, title="Histogram")
self.histogram_widget.enableAutoRange()
self.histogram_widget.setVisible(False)
self.histogram_graph = pg.PlotDataItem()
self.histogram_widget.addItem(self.histogram_graph)
layout.addWidget(self.histogram_widget)
# stats display
# trendline display
self.trendline_widget = pg.PlotWidget(self, title="Trend")
self.trendline_widget.enableAutoRange()
self.trendline_widget.setVisible(False)
self.trendline_data = pg.PlotDataItem()
self.trendline_widget.addItem(self.trendline_data)
layout.addWidget(self.trendline_widget)
toggle_histogram = QAction("Show Histogram", self, checkable=True)
toggle_histogram.toggled.connect(self._toggle_histogram)
self.addAction(toggle_histogram)
toggle_trendline = QAction("Show Trendline", self, checkable=True)
toggle_trendline.toggled.connect(self._toggle_trendline)
self.addAction(toggle_trendline)
reset_stats = QAction("Reset Data", self)
reset_stats.triggered.connect(self.reset_data)
self.addAction(reset_stats)
# use the QWidget Actions list as the right click context menu. This is inherited by children.
self.setContextMenuPolicy(Qt.ActionsContextMenu)
def _toggle_histogram(self):
self.histogram_widget.setVisible(not self.histogram_widget.isVisible())
def _toggle_trendline(self):
self.trendline_widget.setVisible(not self.trendline_widget.isVisible())
def _update_view(self):
self.trendline_data.setData(self.trend_data)
self.value_widget.setText(f"{self.value:4g}{self.suffix}")
if self.histogram_widget.isVisible():
hist, bins = np.histogram(self.histogram_data)
self.histogram_graph.setData(bins, hist, stepMode="center")
@Slot(float)
def update_value(self, value: float):
"""Update the value displayed and associated stats."""
self.value = value
# update stats.
if self.value > self.max:
self.max = self.value
if self.value < self.min:
self.min = self.value
# update trend_data data.
self.trend_data.append(value)
if len(self.trend_data) > self.trend_samples:
self.trend_data.pop(0)
# update histogram
self.histogram_data.append(value)
if len(self.histogram_data) > self.histogram_samples:
self.histogram_data.pop(0)
# update average
# noinspection PyTypeChecker
self.avg = np.cumsum(self.trend_data) / len(self.trend_data)
# re-render data.
self._update_view()
@Slot()
def reset_data(self):
"""Resets the existing data (trendline, stats, histogram)"""
self.max = float("inf")
self.min = -float("inf")
self.trend_data = []
self.histogram_data = []