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()) logger.Info("connected to local xbee", "addr", x.session.LocalAddr())
writeJSON := json.NewEncoder(x.session) // these are the ways we send/recieve data. we could swap for binary format
xbeePackets := make(chan skylab.BusEvent) // 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() { go func() {
decoder := json.NewDecoder(x.session)
for { for {
var p skylab.BusEvent var p skylab.BusEvent
err := decoder.Decode(&p) err := xbeeRxer.Decode(&p)
if err != nil { if err != nil {
logger.Error("failed to decode xbee packet") logger.Error("failed to decode xbee packet")
} }
broker.Publish("xbee", p)
} }
}() }()
for { for {
@ -293,7 +298,7 @@ func (x *xBeeService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
return return
case msg := <-rxCh: case msg := <-rxCh:
logger.Info("got msg", "msg", msg) logger.Info("got msg", "msg", msg)
writeJSON.Encode(msg) xbeeTxer.Encode(msg)
if err != nil { if err != nil {
logger.Warn("error writing to xbee", "err", err) 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) r := gotelem.TelemRouter(logger, broker, db)
/// TODO: use custom port if specified
http.ListenAndServe(":8080", r) http.ListenAndServe(":8080", r)
return return
} }

View file

@ -35,6 +35,7 @@ const (
// CanFilter is a basic filter for masking out data. It has an Inverted flag // 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). // which indicates opposite behavior (reject all packets that match Id and Mask).
// The filter matches when (packet.Id & filter.Mask) == filter.Id // 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 { type CanFilter struct {
Id uint32 Id uint32
Mask 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 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. // 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) next.ServeHTTP(ww, r)
} }
@ -64,3 +67,5 @@ func AddSlogAttr(r *http.Request, attr slog.Attr) {
attrs = append(attrs, attr) attrs = append(attrs, attr)
} }
// TODO: write rest of functions

View file

@ -1,20 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="JupyterPersistentConnectionParameters"> <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"> <option name="moduleParameters">
<map> <map>
<entry key="$PROJECT_DIR$/.idea/py.iml"> <entry key="$PROJECT_DIR$/.idea/py.iml">

View file

@ -1,4 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <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="ProjectRootManager" version="2" project-jdk-name="Poetry (py)" project-jdk-type="Python SDK" />
<component name="PyPackaging">
<option name="earlyReleasesAsUpgrades" value="true" />
</component>
</project> </project>

View file

@ -2,7 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" /> <orderEntry type="jdk" jdkName="Poetry (py)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
<component name="TemplatesService"> <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(): for path in data[j['name']].keys():
d = glom.glom(j['data'], path) d = glom.glom(j['data'], path)
ts = j['ts'] ts = j['ts'] - 1688756556040
data[j['name']][path].append([ts, d]) data[j['name']][path].append([ts, d])
# TODO: numpy the last list??? # TODO: numpy the last list???
return data return data
@ -56,7 +56,7 @@ if __name__ == "__main__":
PlotFeature("wsr_phase_current", ["phase_b_current"]), PlotFeature("wsr_phase_current", ["phase_b_current"]),
PlotFeature("wsr_motor_current_vector", ["iq"]), PlotFeature("wsr_motor_current_vector", ["iq"]),
PlotFeature("wsr_motor_voltage_vector", ["vq"]), PlotFeature("wsr_motor_voltage_vector", ["vq"]),
PlotFeature("wsr_bus_measurement", ["bus_current"]) PlotFeature("wsr_velocity", ["motor_velocity"])
] ]
logs_path = Path("../../logs/") logs_path = Path("../../logs/")
logfile = logs_path / "RETIME_7-2-hillstart.txt" 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" numpy = "^1.24.3"
aiohttp = "^3.8.4" aiohttp = "^3.8.4"
pyside6 = "^6.5.0" pyside6 = "^6.5.0"
pydantic = "^1.10.9" pydantic = "^2"
pyyaml = "^6.0" pyyaml = "^6.0"
jinja2 = "^3.1.2" jinja2 = "^3.1.2"
pyqtgraph = "^0.13.3" pyqtgraph = "^0.13.3"

View file

@ -1,6 +1,14 @@
from functools import cached_property
import aiohttp import aiohttp
import orjson import orjson
import threading 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 # connect to websocket - create thread that handles JSON events
class TelemetryServer(QObject): class TelemetryServer(QObject):
@ -9,11 +17,23 @@ class TelemetryServer(QObject):
conn_url: str conn_url: str
"Something like http://<some_ip>:8082" "Something like http://<some_ip>:8082"
callbacks: Dict[str, Signal]
def __init__(self, url: str, parent=None): def __init__(self, url: str, parent=None):
super().__init__(parent) super().__init__(parent)
self.conn_url = url 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: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self.setMaximumWidth # self.setMaximumWidth()
layout = QGridLayout() layout = QGridLayout()
layout.setRowStretch(0, 80) layout.setRowStretch(0, 80)
layout.setRowStretch(1, 20) layout.setRowStretch(1, 20)

View file

@ -1,56 +1,83 @@
import random
import sys import sys
import logging import logging
import pyqtgraph.parametertree import pyqtgraph.parametertree
from PySide6 import QtWidgets, QtCore 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 ( from PySide6.QtWidgets import (
QApplication, QApplication,
QWidget, QWidget,
QMainWindow, QMainWindow,
QTreeView, QTreeView,
QDockWidget, QDockWidget, QToolBar, QPlainTextEdit,
) )
from gui_log import QLogHandler
from pytelem.widgets.smart_display import SmartDisplay
from bms import BMSOverview from bms import BMSOverview
class QtLogger(logging.Handler, QObject):
appendLog = QtCore.Signal(str)
def __init__(self, parent): class DataStore(QObject):
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:
"""Stores all packets and timestamps for display and logging. """Stores all packets and timestamps for display and logging.
Queries the upstreams for the packets as they come in as well as historical""" Queries the upstreams for the packets as they come in as well as historical"""
def __init__(self, remote): def __init__(self, remote):
pass super().__init__()
class MainApp(QMainWindow): class MainApp(QMainWindow):
new_data = Signal(float)
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("Hey there") self.setWindowTitle("pyview")
layout = QtWidgets.QVBoxLayout() layout = QtWidgets.QVBoxLayout()
mb = self.menuBar()
self.WindowMenu = mb.addMenu("Windows")
bms = BMSOverview() bms = BMSOverview()
dw = QDockWidget('bms', self) packet_tree = QDockWidget('Packet Tree', self)
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dw) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, packet_tree)
dw.setWidget(PacketTree()) packet_tree.setWidget(PacketTreeView())
self.setCentralWidget(bms) 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 PacketTreeView(QWidget):
class PacketTree(QWidget):
"""PacketView is a widget that shows a tree of packets as well as properties on them when selected.""" """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): def __init__(self, parent: QtWidgets.QWidget | None = None):
@ -69,6 +96,10 @@ class PacketTree(QWidget):
self.setLayout(layout) self.setLayout(layout)
class SolverView(QWidget):
"""Main Solver Widget/Window"""
if __name__ == "__main__": if __name__ == "__main__":
app = QApplication(sys.argv) app = QApplication(sys.argv)
main_window = MainApp() 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(): def fsolve_discrete():
... """Forward compute a route segment."""
def dist_to_pos(dist: float): 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 ATM_MOLAR_MASS = 0.0289644 # kg/mol
STANDARD_TEMP = 288.15 # K STANDARD_TEMP = 288.15 # K
STANDARD_PRES = 101325.0 # Pa 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)) v = v_0 + d_psi * np.cos(np.deg2rad(epsilon))
alpha = np.arctan2(np.sin(np.radians(sun_longitude)) * alpha = np.arctan2(np.sin(np.deg2rad(sun_longitude)) *
np.cos(np.radians(epsilon)) - np.cos(np.deg2rad(epsilon)) -
np.tan(np.radians(beta)) * np.tan(np.deg2rad(beta)) *
np.sin(np.radians(epsilon)), np.sin(np.deg2rad(epsilon)),
np.cos(np.radians(sun_longitude))) np.cos(np.deg2rad(sun_longitude)))
alpha_deg = np.rad2deg(alpha) % 360 alpha_deg = np.rad2deg(alpha) % 360
delta = np.arcsin( delta = np.arcsin(
np.sin(np.radians(beta)) * np.sin(np.deg2rad(beta)) *
np.cos(np.radians(epsilon)) + np.cos(np.deg2rad(epsilon)) +
np.cos(np.radians(beta)) * np.cos(np.deg2rad(beta)) *
np.sin(np.radians(epsilon)) * np.sin(np.deg2rad(epsilon)) *
np.cos(np.radians(sun_longitude)) np.cos(np.deg2rad(sun_longitude))
) )
delta_deg = np.rad2deg(delta) % 360 delta_deg = np.rad2deg(delta) % 360
@ -612,4 +612,5 @@ def solar_position(timestamp, latitude, longitude, elevation):
alpha_prime = alpha_deg + d_alpha 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)), 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))) 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 pathlib import Path
from typing import Callable, Iterable, NewType, TypedDict, List, Protocol, Union, Set 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 from enum import Enum
import yaml import yaml
import jinja2 import jinja2
@ -77,35 +77,33 @@ class SkylabField(BaseModel):
"the name of the field. must be alphanumeric and underscores" "the name of the field. must be alphanumeric and underscores"
type: FieldType type: FieldType
"the type of the field" "the type of the field"
units: str | None units: str | None = None
"optional descriptor of the unit representation" "optional descriptor of the unit representation"
conversion: float | None conversion: float | None = None
"optional conversion factor to be applied when parsing" "optional conversion factor to be applied when parsing"
bits: List[_Bits] | None bits: List[_Bits] | None = None
"if the type if a bitfield, " "if the type if a bitfield, "
@validator("bits") @model_validator(mode='after')
def bits_must_exist_if_bitfield(cls, v, values): def bits_must_exist_if_bitfield(self) -> 'SkylabField':
if v is None and "type" in values and values["type"] is FieldType.Bitfield: if self.bits is None and self.type == FieldType.Bitfield:
raise ValueError("bits are not present on bitfield type") raise ValueError("bits are not present on bitfield type")
if ( if self.bits is not None and self.type != FieldType.Bitfield:
v is not None
and "type" in values
and values["type"] is not FieldType.Bitfield
):
raise ValueError("bits are present on non-bitfield type") raise ValueError("bits are present on non-bitfield type")
return v return self
@validator("name") @field_validator("name")
def name_valid_string(cls, v: str): @classmethod
def name_valid_string(cls, v: str) -> str:
if not re.match(r"^[A-Za-z0-9_]+$", v): if not re.match(r"^[A-Za-z0-9_]+$", v):
return ValueError("invalid name") raise ValueError("invalid name")
return v return v
@validator("name") @field_validator("name")
def name_nonzero_length(cls, v: str): @classmethod
def name_nonzero_length(cls, v: str) -> str:
if len(v) == 0: if len(v) == 0:
return ValueError("name cannot be empty string") raise ValueError("name cannot be empty string")
return v return v
@ -120,11 +118,11 @@ class SkylabPacket(BaseModel):
"""Represents a CAN packet. Contains SkylabFields with information on the structure of the data.""" """Represents a CAN packet. Contains SkylabFields with information on the structure of the data."""
name: str name: str
description: str | None description: str | None = None
id: int id: int
endian: Endian endian: Endian
repeat: int | None repeat: int | None = None
offset: int | None offset: int | None = None
data: List[SkylabField] data: List[SkylabField]
# @validator("data") # @validator("data")
@ -134,31 +132,37 @@ class SkylabPacket(BaseModel):
# return ValueError("Total packet size cannot exceed 8 bytes") # return ValueError("Total packet size cannot exceed 8 bytes")
# return v # return v
@validator("id") @field_validator("id")
@classmethod
def id_non_negative(cls, v: int) -> int: def id_non_negative(cls, v: int) -> int:
if v < 0: if v < 0:
raise ValueError("id must be above zero") raise ValueError("id must be above zero")
return v return v
@validator("name") @field_validator("name")
@classmethod
def name_valid_string(cls, v: str) -> str: def name_valid_string(cls, v: str) -> str:
if not re.match(r"^[A-Za-z0-9_]+$", v): if not re.match(r"^[A-Za-z0-9_]+$", v):
raise ValueError("invalid name", v) raise ValueError("invalid name", v)
return v return v
@validator("name") @field_validator("name")
@classmethod
def name_nonzero_length(cls, v: str) -> str: def name_nonzero_length(cls, v: str) -> str:
if len(v) == 0: if len(v) == 0:
raise ValueError("name cannot be empty string") raise ValueError("name cannot be empty string")
return v return v
@validator("offset") # TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
def offset_must_have_repeat(cls, v: int | None, values) -> int | None: # Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
if v is not None and "repeat" in values and values["repeat"] is not None: @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") raise ValueError("field with offset must have repeat defined")
return v return v
@validator("repeat") @field_validator("repeat")
@classmethod
def repeat_gt_one(cls, v: int | None): def repeat_gt_one(cls, v: int | None):
if v is not None and v <= 1: if v is not None and v <= 1:
raise ValueError("repeat must be strictly greater than one") raise ValueError("repeat must be strictly greater than one")
@ -180,13 +184,15 @@ class SkylabBoard(BaseModel):
receive: List[str] receive: List[str]
"The packets received by this board." "The packets received by this board."
@validator("name") @field_validator("name")
@classmethod
def name_valid_string(cls, v: str): def name_valid_string(cls, v: str):
if not re.match(r"^[A-Za-z0-9_]+$", v): if not re.match(r"^[A-Za-z0-9_]+$", v):
return ValueError("invalid name", v) return ValueError("invalid name", v)
return v return v
@validator("name") @field_validator("name")
@classmethod
def name_nonzero_length(cls, v: str): def name_nonzero_length(cls, v: str):
if len(v) == 0: if len(v) == 0:
return ValueError("name cannot be empty string") return ValueError("name cannot be empty string")
@ -201,13 +207,15 @@ class SkylabBus(BaseModel):
extended_id: bool extended_id: bool
"If the bus uses extended ids" "If the bus uses extended ids"
@validator("name") @field_validator("name")
@classmethod
def name_valid_string(cls, v: str): def name_valid_string(cls, v: str):
if not re.match(r"^[A-Za-z0-9_]+$", v): if not re.match(r"^[A-Za-z0-9_]+$", v):
return ValueError("invalid name", v) return ValueError("invalid name", v)
return v return v
@validator("baud_rate") @field_validator("baud_rate")
@classmethod
def baud_rate_supported(cls, v: int): def baud_rate_supported(cls, v: int):
if v not in [125000, 250000, 500000, 750000, 1000000]: if v not in [125000, 250000, 500000, 750000, 1000000]:
raise ValueError("unsupported baud rate", v) 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 = []