pytelem major work
This commit is contained in:
parent
c772d6e95f
commit
2d634d863d
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
1
frame.go
1
frame.go
|
@ -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
|
||||||
|
|
4
http.go
4
http.go
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
|
@ -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">
|
||||||
|
|
|
@ -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
2582
py/poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -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"
|
||||||
|
|
|
@ -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"""
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
20
py/pytelem/gui_log.py
Normal 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)
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
|
|
@ -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)
|
||||||
|
|
0
py/pytelem/widgets/bms.py
Normal file
0
py/pytelem/widgets/bms.py
Normal file
0
py/pytelem/widgets/packet_tree.py
Normal file
0
py/pytelem/widgets/packet_tree.py
Normal file
145
py/pytelem/widgets/smart_display.py
Normal file
145
py/pytelem/widgets/smart_display.py
Normal 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 = []
|
Loading…
Reference in a new issue