Initial implementation

This commit is contained in:
h3cx committed 2026-11-03 10:30:49 +01:00
1 parent 044341e641
commit 0efd2c85a3
17 files changed
+5435 -4

No files matched your search

+3
View File
@@ -8,3 +8,6 @@ wheels/
# Virtual environments # Virtual environments
.venv .venv
# Generated visual review captures
artifacts/
+11 -2
View File
@@ -1,13 +1,19 @@
[project] [project]
name = "dynalab-py" name = "dynalab-py"
version = "0.1.0" version = "0.1.0"
description = "Add your description here" description = "Cross-platform desktop UI for dynalab-core signal acquisition and playback"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"]
authors = [ authors = [
{ name = "Hector van der Aa", email = "hector@h3cx.dev" } { name = "Hector van der Aa", email = "hector@h3cx.dev" }
] ]
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [] dependencies = [
"dynalab-core @ git+https://git.h3cx.dev/Exergie/dynalab-core.git@v0.2.1",
"numpy>=2.5.4",
"pyqtgraph>=0.14.0",
"pyside6>=6.12.0",
]
[project.scripts] [project.scripts]
dynalab-py = "dynalab_py:main" dynalab-py = "dynalab_py:main"
@@ -15,3 +21,6 @@ dynalab-py = "dynalab_py:main"
[build-system] [build-system]
requires = ["uv_build>=0.13.0,<0.14.0"] requires = ["uv_build>=0.13.0,<0.14.0"]
build-backend = "uv_build" build-backend = "uv_build"
[tool.uv.build-backend]
source-include = ["API_BOUNDARIES.md", "src/dynalab_py/icons/*.svg"]
+4 -2
View File
@@ -1,2 +1,4 @@
def main() -> None: """DynaLab desktop application entry point."""
print("Hello from dynalab-py!") from .app import MainWindow, main
__all__ = ["MainWindow", "main"]
+853
View File
@@ -0,0 +1,853 @@
from pathlib import Path
from typing import Any
from uuid import uuid4
from PySide6.QtCore import QByteArray, QSettings, Qt, QTimer
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QMainWindow,
QMessageBox,
QPushButton,
QSpinBox,
QToolBar,
QVBoxLayout,
QWidget,
)
from .core_adapter import CoreAdapter
from .data_panel import DataPanel
from .derive_panel import DerivePanel
from .docking import GridWorkspace, PanelDockWidget
from .instrument_group import InstrumentGroupPanel
from .panels import GaugePanel, GraphPanel, NumericPanel, SignalBrowser
from .session_panel import SessionPanel
from .theme import apply_theme
from .workspace import (
SYSTEM_PANELS,
VISUALIZATION_TYPES,
WorkspaceError,
load_workspace,
new_workspace,
save_workspace,
validate_workspace,
)
PANEL_TITLES = {
"numeric": "Numeric readout", "gauge": "Gauge", "graph": "Time graph",
"numeric_group": "Readout group", "gauge_group": "Gauge group",
**{panel["type"]: panel["title"] for panel in SYSTEM_PANELS},
}
SYSTEM_IDS = {panel["type"]: panel["id"] for panel in SYSTEM_PANELS}
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.settings = QSettings(
QSettings.defaultFormat(), QSettings.Scope.UserScope, "DynaLab", "DynaLab"
)
try:
self.ui_update_hz = max(1, min(60, int(self.settings.value("ui_update_hz", 5))))
except (TypeError, ValueError):
self.ui_update_hz = 5
self.workspace: dict[str, Any] | None = None
self.workspace_path: Path | None = None
self.docks: dict[str, PanelDockWidget] = {}
self.grid: GridWorkspace | None = None
self._recording = False
self.panel_widgets: dict[str, QWidget] = {}
self.core_adapter: CoreAdapter | None = None
self._core_started = False
self._session_ended = False
self._core_busy = False
self._plot_busy = False
self._plot_request_ids: dict[str, list[str]] = {}
self._plot_request_mode: str | None = None
self._session_mode: str | None = None
self._workspace_generation = 0
self._plot_request_generation = 0
self._available_signal_ids: set[str] = set()
self._signal_names: dict[str, str] = {}
self._close_after_stop = False
self.setWindowTitle("DynaLab")
self.resize(1280, 840)
self._build_menus()
self._build_session_toolbar()
self._show_start_screen()
self._refresh_timer = QTimer(self)
self._refresh_timer.setTimerType(Qt.TimerType.PreciseTimer)
self._refresh_timer.timeout.connect(self._refresh_session)
self._playhead_timer = QTimer(self)
self._playhead_timer.setTimerType(Qt.TimerType.PreciseTimer)
self._playhead_timer.timeout.connect(self._refresh_playback_cursors)
self.set_ui_update_rate(self.ui_update_hz)
self._refresh_timer.start()
self._playhead_timer.start()
def _build_menus(self) -> None:
workspace_menu = self.menuBar().addMenu("Workspace")
self.new_action = workspace_menu.addAction("New", self._new_workspace, "Ctrl+N")
self.open_action = workspace_menu.addAction("Open…", self._open_dialog, "Ctrl+O")
self.save_action = workspace_menu.addAction("Save", self._save_workspace, "Ctrl+S")
self.save_action.setEnabled(False)
self.save_as_action = workspace_menu.addAction("Save As…", self._save_as)
self.save_as_action.setEnabled(False)
self.view_menu = self.menuBar().addMenu("View")
self.add_panel_menu = self.menuBar().addMenu("Add panel")
self.add_panel_actions: dict[str, Any] = {}
for panel_type, title in PANEL_TITLES.items():
action = self.add_panel_menu.addAction(title)
action.triggered.connect(lambda _checked=False, kind=panel_type: self.add_panel(kind))
self.add_panel_actions[panel_type] = action
self.add_panel_menu.setEnabled(False)
settings_menu = self.menuBar().addMenu("Settings")
settings_menu.addAction("UI update rate…", self._configure_update_rate)
def set_ui_update_rate(self, hz: int) -> None:
self.ui_update_hz = max(1, min(60, int(hz)))
interval = round(1000 / self.ui_update_hz)
self._refresh_timer.setInterval(interval)
self._playhead_timer.setInterval(interval)
for panel in self.panel_widgets.values():
if isinstance(panel, DataPanel):
panel.set_update_interval(interval)
self.settings.setValue("ui_update_hz", self.ui_update_hz)
def _configure_update_rate(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("UI update rate")
layout = QVBoxLayout(dialog)
explanation = QLabel(
"Refresh values, graphs and playback cursors at this rate.\n"
"Sampling and playback speed are unchanged. Heavy graphs may refresh more slowly."
)
explanation.setWordWrap(True)
layout.addWidget(explanation)
rate = QSpinBox(dialog)
rate.setRange(1, 60)
rate.setSuffix(" Hz")
rate.setAccessibleName("UI update frequency")
rate.setValue(self.ui_update_hz)
layout.addWidget(rate)
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
parent=dialog,
)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
if dialog.exec() == QDialog.DialogCode.Accepted:
self.set_ui_update_rate(rate.value())
def _build_session_toolbar(self) -> None:
host, port = CoreAdapter.default_endpoint()
toolbar = QToolBar("Session", self)
toolbar.setObjectName("sessionToolbar")
toolbar.setMovable(False)
self.addToolBar(toolbar)
brand = QLabel("DynaLab")
brand.setObjectName("brand")
toolbar.addWidget(brand)
toolbar.addWidget(QLabel("Server"))
self.host_input = QLineEdit(self.settings.value("core_host", host, type=str))
self.host_input.setMaximumWidth(150)
self.host_input.setAccessibleName("Core server host")
toolbar.addWidget(self.host_input)
self.port_input = QSpinBox()
self.port_input.setMinimumWidth(85)
self.port_input.setRange(1, 65535)
self.port_input.setValue(self.settings.value("core_port", port, type=int))
self.port_input.setAccessibleName("Core server port")
toolbar.addWidget(self.port_input)
self.start_button = QPushButton("Start session")
self.start_button.setObjectName("primary")
self.start_button.clicked.connect(self._start_core)
toolbar.addWidget(self.start_button)
self.stop_button = QPushButton("Stop session")
self.stop_button.clicked.connect(self._stop_core)
toolbar.addWidget(self.stop_button)
self.mode_selector = QComboBox()
for label, mode in (("Realtime", "realtime"), ("Offline", "offline"), ("Playback", "playback")):
self.mode_selector.addItem(label, mode)
self.mode_selector.setAccessibleName("Session mode")
self.mode_selector.currentIndexChanged.connect(self._change_mode)
toolbar.addSeparator()
toolbar.addWidget(QLabel("Mode"))
toolbar.addWidget(self.mode_selector)
self.session_status = QLabel("Core stopped")
self.session_status.setObjectName("sessionStatus")
toolbar.addWidget(self.session_status)
self._update_session_controls()
def _session_panel_busy(self) -> bool:
return any(
isinstance(panel, (SessionPanel, DataPanel, DerivePanel)) and panel.busy
for panel in self.panel_widgets.values()
)
def _update_session_controls(self) -> None:
editable = not self._core_busy and not self._session_panel_busy()
self.new_action.setEnabled(editable)
self.open_action.setEnabled(editable)
available = self.core_adapter is None and not self._core_busy and not self._session_ended
can_stop = (self.core_adapter is not None and not self._core_busy
and not self._session_panel_busy())
running = can_stop and self._core_started
self.host_input.setEnabled(available)
self.port_input.setEnabled(available)
self.start_button.setEnabled(available)
self.stop_button.setEnabled(can_stop)
self.mode_selector.setEnabled(running and not self._recording)
operation_panels = [p for p in self.panel_widgets.values() if isinstance(p, (SessionPanel, DataPanel, DerivePanel))]
for panel in operation_panels:
panel.setEnabled(not self._core_busy and not any(other.busy for other in operation_panels if other is not panel))
if self.grid is not None:
dock = next((self.docks[panel_id] for panel_id, widget in self.panel_widgets.items() if widget is panel), None)
if dock is not None:
self.grid.fit_panel(dock)
def _start_core(self) -> None:
if self.core_adapter is not None or self._core_busy or self._session_ended:
return
host = self.host_input.text().strip()
try:
if not host:
raise ValueError("Enter a server host before starting Core.")
port = self.port_input.value()
self.core_adapter = CoreAdapter(host=host, port=port)
except Exception as error:
QMessageBox.warning(self, "Could not configure Core", str(error))
return
self.settings.setValue("core_host", host)
self.settings.setValue("core_port", port)
self._core_busy = True
self.session_status.setText("Starting Core…")
self._update_session_controls()
self.core_adapter.run_async(self.core_adapter.start, self._on_core_started)
def _on_core_started(self, _result: Any, error: Exception | None) -> None:
self._core_busy = False
if error is not None:
self._core_started = False
self.session_status.setText("Core failed to start")
self.statusBar().showMessage(f"Core start failed: {error}")
QMessageBox.warning(self, "Could not start Core", str(error))
else:
self._core_started = True
self.statusBar().showMessage("Core session started", 4000)
self._refresh_session()
self._update_session_controls()
if self._close_after_stop:
self._stop_core()
def _stop_core(self) -> None:
if self.core_adapter is None or self._core_busy or self._session_panel_busy():
return
try:
recording = self.core_adapter.snapshot()["recording"]
except Exception as error:
if QMessageBox.question(
self, "Recording state unavailable",
f"Could not confirm recording state: {error}\nEnd the session anyway? Any unsaved data will be lost.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
) != QMessageBox.StandardButton.Yes:
self._close_after_stop = False
return
recording = False
if recording and QMessageBox.question(
self, "Stop active recording?",
"A recording is active. Stop recording and end the session? Save the recording before ending the session if you need to keep it.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
) != QMessageBox.StandardButton.Yes:
self._close_after_stop = False
return
self._workspace_generation += 1
self._core_busy = True
self._refresh_playback_cursors()
self.session_status.setText("Stopping Core…")
self._update_session_controls()
self.core_adapter.run_async(self.core_adapter.stop, self._on_core_stopped)
def _on_core_stopped(self, stopped: Any, error: Exception | None) -> None:
self._core_busy = False
self._core_started = False
self._session_ended = True
self.core_adapter = None
if error is not None:
message = f"Core shutdown failed: {error}"
elif not stopped:
message = "Core did not stop completely. Restart the application before using it again."
else:
message = "Core session stopped. Restart the application to start another session."
self.session_status.setText(message)
self.statusBar().showMessage(message)
self._refresh_session()
self._update_session_controls()
if error is not None or not stopped:
QMessageBox.warning(self, "Core shutdown incomplete", message)
if self._close_after_stop:
self._close_after_stop = False
self.close()
def _change_mode(self, index: int) -> None:
if self.core_adapter is None or self._core_busy or self._session_panel_busy() or index < 0:
return
mode = self.mode_selector.itemData(index)
self._workspace_generation += 1
self._core_busy = True
self._refresh_playback_cursors()
self._update_session_controls()
adapter = self.core_adapter
adapter.run_async(lambda: adapter.set_mode(mode), self._on_mode_changed)
def _on_mode_changed(self, _result: Any, error: Exception | None) -> None:
self._core_busy = False
if error is not None:
self.statusBar().showMessage(f"Mode change failed: {error}")
QMessageBox.warning(self, "Could not change mode", str(error))
self._refresh_session()
self._update_session_controls()
if self._close_after_stop:
self._stop_core()
def _refresh_session(self) -> None:
session_panel = self.panel_widgets.get("dashboard")
data_panel = self.panel_widgets.get("data")
derive_panel = self.panel_widgets.get("derived")
for panel in (session_panel, data_panel, derive_panel):
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
panel.set_adapter(self.core_adapter)
if self.core_adapter is None:
self._session_mode = None
for panel in (session_panel, data_panel, derive_panel):
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
panel.refresh(None)
if "dashboard" in self.docks:
connector_list = self.docks["dashboard"].findChild(QListWidget)
if connector_list is not None:
connector_list.clear()
connector_list.addItem("Start a Core session to view connectors")
if "signals" in self.panel_widgets:
self.panel_widgets["signals"].refresh(None)
self._clear_visualizations()
return
if self._core_busy:
return
try:
snapshot = self.core_adapter.snapshot()
except Exception as error:
self._session_mode = None
self._clear_visualizations()
for panel in (session_panel, data_panel, derive_panel):
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
panel.refresh(None)
self._core_started = False
self._update_session_controls()
self.session_status.setText("Core status unavailable")
self.statusBar().showMessage(f"Could not read Core status: {error}")
return
unit_workers = {unit["worker"]["name"] for unit in snapshot.get("derive_units", [])}
fatal_workers = [worker for worker in snapshot.get("workers", [])
if worker["state"] == "failed" and worker["name"] not in unit_workers]
self._core_started = snapshot["state"] == "started" and not fatal_workers
snapshot["session_usable"] = self._core_started
if snapshot["state"] in {"stopped", "stop_incomplete"}:
self._session_ended = True
for panel in (session_panel, data_panel, derive_panel):
if isinstance(panel, (SessionPanel, DataPanel, DerivePanel)):
panel.refresh(snapshot)
self._update_session_controls()
if self._close_after_stop and not self._session_panel_busy():
self._stop_core()
return
if not self._core_started:
self._clear_visualizations()
details = "; ".join(f"{worker['name']}: {worker.get('error') or 'failed'}" for worker in fatal_workers)
message = ("Session worker failed" if fatal_workers else f"Core {snapshot['state']}") + ". Restart the application."
self.session_status.setText(message)
self.statusBar().showMessage(message + (f" {details}" if details else ""))
return
self._recording = snapshot["recording"]
self._update_session_controls()
mode = snapshot["mode"]
self._session_mode = mode
mode_index = self.mode_selector.findData(mode)
if mode_index >= 0 and mode_index != self.mode_selector.currentIndex():
self.mode_selector.blockSignals(True)
self.mode_selector.setCurrentIndex(mode_index)
self.mode_selector.blockSignals(False)
connectors = snapshot["connectors"]
signals = snapshot["signals"]
self._available_signal_ids = {signal["id"] for signal in signals}
self.session_status.setText(
f"Core {snapshot['state']} · "
f"{'recording' if snapshot['recording'] else 'idle'}"
)
self.statusBar().showMessage(
f"Core {snapshot['state']} · {mode} · {len(connectors)} connectors · {len(signals)} signals"
)
if "dashboard" in self.docks:
connector_list = self.docks["dashboard"].findChild(QListWidget)
if connector_list is not None:
connector_list.clear()
if connectors:
for connector in connectors:
connector_list.addItem(
f"{connector['name']} — {connector['state']} — "
f"{connector['signal_count']} signals"
)
else:
connector_list.addItem("No connectors connected")
if "signals" in self.panel_widgets:
self.panel_widgets["signals"].refresh(signals)
self._refresh_visualizations(signals, mode)
def _refresh_visualizations(self, signals: list[dict[str, Any]], mode: str) -> None:
for panel_id, widget in list(self.panel_widgets.items()):
if isinstance(widget, (NumericPanel, GaugePanel, InstrumentGroupPanel)):
widget.refresh(signals)
self.grid.fit_panel(self.docks[panel_id])
available_ids = {signal["id"] for signal in signals}
visible_graphs: dict[str, list[str]] = {}
union_ids: list[str] = []
for panel_id, widget in list(self.panel_widgets.items()):
if not isinstance(widget, GraphPanel):
continue
widget.set_signals(signals)
selected = [signal_id for signal_id in widget.signal_ids if signal_id in available_ids]
dock = self.docks.get(panel_id)
if not selected:
widget.update_plot({"time_range": None, "data": {}})
elif dock is not None and dock.isVisible():
visible_graphs[panel_id] = selected
for signal_id in selected:
if signal_id not in union_ids:
union_ids.append(signal_id)
self._update_panel_titles(signals)
if not union_ids or self._plot_busy or self.core_adapter is None:
return
self._plot_busy = True
self._plot_request_ids = {panel_id: list(ids) for panel_id, ids in visible_graphs.items()}
self._plot_request_mode = mode
self._plot_request_generation = self._workspace_generation
self._update_session_controls()
adapter = self.core_adapter
adapter.run_async(
lambda: adapter.plot_snapshot(union_ids),
self._on_graph_updated,
)
def _clear_visualizations(self) -> None:
self._available_signal_ids.clear()
browser = self.panel_widgets.get("signals")
if browser is not None:
browser.refresh(None)
for widget in list(self.panel_widgets.values()):
if isinstance(widget, (NumericPanel, GaugePanel, InstrumentGroupPanel)):
widget.refresh([])
elif isinstance(widget, GraphPanel):
widget.set_signals([])
widget.update_plot({"time_range": None, "data": {}})
self._update_panel_titles([])
self._refresh_playback_cursors()
def _refresh_playback_cursors(self) -> None:
graphs = [widget for widget in self.panel_widgets.values() if isinstance(widget, GraphPanel)]
if not graphs:
return
position = None
if self.core_adapter is not None and self._core_started and not self._core_busy and self._session_mode == "playback":
try:
state = self.core_adapter.playback_status()
if state["time_range"] is not None:
position = int(state["position"])
except Exception:
# A failed status read must not leave a stale playhead visible.
position = None
for graph in graphs:
graph.set_playback_position(position)
def _update_panel_titles(self, signals: list[dict[str, Any]]) -> None:
self._signal_names.update({signal["id"]: signal["name"] for signal in signals})
for panel_id, widget in self.panel_widgets.items():
if isinstance(widget, (NumericPanel, GaugePanel)):
selected = [widget.choice.signal_id] if widget.choice.signal_id else []
elif isinstance(widget, GraphPanel):
selected = widget.signal_ids
elif isinstance(widget, InstrumentGroupPanel):
selected = [item["signal_id"] for item in widget.config()["items"] if item["signal_id"]]
else:
continue
title = " · ".join(dict.fromkeys(self._signal_names.get(signal_id, "Unavailable signal") for signal_id in selected))
self.docks[panel_id].set_display_title(title or "Unassigned")
def _on_graph_updated(self, snapshot: Any, error: Exception | None) -> None:
self._plot_busy = False
current_request = (
self.core_adapter is not None
and self._workspace_generation == self._plot_request_generation
and self._session_mode == self._plot_request_mode
)
if error is not None and current_request:
self.statusBar().showMessage(f"Could not update graph: {error}")
elif (
snapshot is not None
and self.core_adapter is not None
and self._session_mode == self._plot_request_mode
and self._workspace_generation == self._plot_request_generation
):
for panel_id, requested_ids in self._plot_request_ids.items():
graph = self.panel_widgets.get(panel_id)
dock = self.docks.get(panel_id)
if not isinstance(graph, GraphPanel) or dock is None or not dock.isVisible():
continue
current = [signal_id for signal_id in graph.signal_ids if signal_id in self._available_signal_ids]
if current == requested_ids:
graph.update_plot(
{
**snapshot,
"data": {signal_id: snapshot.get("data", {}).get(signal_id, []) for signal_id in current},
}
)
self._plot_request_ids = {}
self._plot_request_mode = None
self._update_session_controls()
if self._close_after_stop:
self._stop_core()
def closeEvent(self, event: Any) -> None:
if self.core_adapter is None:
for dock in self.docks.values():
if dock.isFloating():
dock.hide()
event.accept()
return
event.ignore()
self._close_after_stop = True
if not self._core_busy:
self._stop_core()
def _show_start_screen(self) -> None:
self.save_action.setEnabled(False)
self.save_as_action.setEnabled(False)
self.add_panel_menu.setEnabled(False)
page = QWidget()
outer = QVBoxLayout(page)
outer.setContentsMargins(60, 40, 60, 40)
outer.addStretch(1)
card = QWidget()
card.setMaximumWidth(760)
layout = QVBoxLayout(card)
layout.setSpacing(18)
eyebrow = QLabel("DYNALAB / SIGNAL WORKSPACE")
eyebrow.setObjectName("eyebrow")
layout.addWidget(eyebrow)
title = QLabel("Your signals. In focus.")
title.setObjectName("heroTitle")
layout.addWidget(title)
subtitle = QLabel("Acquire, explore and replay your measurements in one workspace.")
subtitle.setObjectName("muted")
subtitle.setWordWrap(True)
layout.addWidget(subtitle)
actions = QHBoxLayout()
new_button = QPushButton("Create workspace")
new_button.setObjectName("primary")
new_button.clicked.connect(self._new_workspace)
actions.addWidget(new_button)
open_button = QPushButton("Open workspace…")
open_button.clicked.connect(self._open_dialog)
actions.addWidget(open_button)
actions.addStretch()
layout.addLayout(actions)
layout.addSpacing(16)
layout.addWidget(QLabel("Recent workspaces"))
recent = QListWidget()
recent.setMinimumHeight(150)
paths = self._recent_workspaces()
for path in paths:
recent.addItem(path)
if not paths:
recent.addItem("No recent workspaces. Create one to get started.")
recent.item(0).setFlags(Qt.ItemFlag.NoItemFlags)
recent.itemDoubleClicked.connect(lambda item: self._open_path(item.text()))
layout.addWidget(recent)
outer.addWidget(card, alignment=Qt.AlignmentFlag.AlignHCenter)
outer.addStretch(2)
self.setCentralWidget(page)
def _recent_workspaces(self) -> list[str]:
paths = self.settings.value("recent_workspaces", [], type=list)
existing = [str(Path(path)) for path in paths if Path(path).is_file()]
if existing != paths:
self.settings.setValue("recent_workspaces", existing)
return existing
def _remember_workspace(self, path: Path) -> None:
recent = [str(path), *[p for p in self._recent_workspaces() if p != str(path)]]
self.settings.setValue("recent_workspaces", recent[:10])
def _new_workspace(self) -> None:
self._apply_workspace(new_workspace(), None)
def _open_dialog(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self, "Open workspace", "", "DynaLab workspace (*.json);;All files (*)"
)
if path:
self._open_path(path)
def _open_path(self, path: str) -> None:
previous, previous_path = self.workspace, self.workspace_path
if previous is not None:
previous["grid_layout"] = self.grid.save_layout()
previous["geometry"] = self.saveGeometry().toBase64().data().decode("ascii")
for panel in previous["panels"]:
panel["visible"] = self.docks[panel["id"]].user_visible
try:
data = load_workspace(path)
self._apply_workspace(data, Path(path))
except (WorkspaceError, OSError, ValueError) as error:
if self.workspace is not previous:
if previous is None:
self._remove_docks()
self.workspace = self.workspace_path = None
self._show_start_screen()
else:
self._apply_workspace(previous, previous_path)
QMessageBox.warning(self, "Could not open workspace", str(error))
def _save_workspace(self) -> None:
if self.workspace_path is None:
self._save_as()
return
self._save_to(self.workspace_path)
def _save_as(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, "Save workspace", "workspace.json", "DynaLab workspace (*.json)"
)
if path:
target = Path(path)
if target.suffix.lower() != ".json":
target = target.with_suffix(".json")
self._save_to(target)
def _save_to(self, path: Path) -> None:
if self.workspace is None:
return
self.workspace["grid_layout"] = self.grid.save_layout()
self.workspace["geometry"] = self.saveGeometry().toBase64().data().decode("ascii")
for panel in self.workspace["panels"]:
panel["visible"] = self.docks[panel["id"]].user_visible
try:
save_workspace(path, self.workspace)
except (OSError, WorkspaceError) as error:
QMessageBox.warning(self, "Could not save workspace", str(error))
return
self.workspace_path = path
self._remember_workspace(path)
self._update_title()
self.statusBar().showMessage(f"Saved {path}", 4000)
def _apply_workspace(self, data: dict[str, Any], path: Path | None) -> None:
validate_workspace(data)
panels = data["panels"]
self._workspace_generation += 1
self._signal_names.clear()
self._remove_docks()
self.workspace = data
self.workspace_path = path
self.grid = GridWorkspace(self)
self.setCentralWidget(self.grid)
geometry = data.get("geometry")
if geometry:
if not self.restoreGeometry(QByteArray.fromBase64(geometry.encode("ascii"))):
raise WorkspaceError("Workspace window geometry could not be restored.")
self.save_action.setEnabled(True)
self.save_as_action.setEnabled(True)
self.add_panel_menu.setEnabled(True)
self.view_menu.clear()
for panel in panels:
self._create_panel_dock(panel)
self._refresh_view_menu()
if data.get("grid_layout") is not None:
self.grid.restore_layout(data["grid_layout"], self.docks)
for panel in panels:
if not panel.get("visible", True):
self.grid.set_panel_visible(self.docks[panel["id"]], False)
self._update_title()
if path is not None:
self._remember_workspace(path)
self._refresh_session()
def _create_panel_dock(self, panel: dict[str, Any]) -> PanelDockWidget:
panel_id = panel["id"]
widget = self._panel_content(panel)
if isinstance(widget, DataPanel):
widget.set_update_interval(round(1000 / self.ui_update_hz))
widget.setObjectName("panelBody")
widget.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
dock = PanelDockWidget(
panel_id,
panel["title"],
self,
True,
)
dock.setObjectName(panel_id)
dock.removeRequested.connect(
lambda current_id=panel_id, current_dock=dock: self.remove_panel(current_id)
if self.docks.get(current_id) is current_dock
else None
)
dock.setWidget(widget)
self.docks[panel_id] = dock
self.panel_widgets[panel_id] = widget
if isinstance(widget, (SessionPanel, DataPanel, DerivePanel)):
widget.busyChanged.connect(lambda _busy: self._update_session_controls())
if isinstance(widget, (NumericPanel, GaugePanel, GraphPanel, InstrumentGroupPanel)):
widget.removeRequested.connect(
lambda current_id=panel_id, current_widget=widget: self.remove_panel(current_id)
if self.panel_widgets.get(current_id) is current_widget
else None
)
if hasattr(widget, "configChanged"):
widget.configChanged.connect(
lambda config, current_id=panel_id, current_widget=widget: self._panel_config_changed(current_id, config)
if self.panel_widgets.get(current_id) is current_widget
else None
)
self.grid.add_panel(dock, panel["type"])
return dock
def _refresh_view_menu(self) -> None:
self.view_menu.clear()
if self.workspace is None:
return
for panel in self.workspace["panels"]:
dock = self.docks.get(panel["id"])
if dock is not None:
self.view_menu.addAction(dock.toggleViewAction())
def add_panel(self, panel_type: str) -> str:
if self.workspace is None:
raise RuntimeError("Create or open a workspace before adding a panel.")
if panel_type not in PANEL_TITLES:
raise ValueError(f"Unsupported panel type: {panel_type!r}")
panel_id = SYSTEM_IDS.get(panel_type, f"{panel_type}-{uuid4()}")
if panel_id in self.docks:
self.grid.set_panel_visible(self.docks[panel_id], True)
self.docks[panel_id].raise_()
return panel_id
existing_titles = {panel["title"] for panel in self.workspace["panels"]}
number = 1
while f"{PANEL_TITLES[panel_type]} {number}" in existing_titles:
number += 1
if panel_type == "graph":
config = {"signal_ids": [], "follow_latest": True, "zero_baseline": False, "right_zero_baseline": False}
elif panel_type.endswith("_group"):
config = {"kind": panel_type.removesuffix("_group"), "items": []}
elif panel_type in VISUALIZATION_TYPES:
config = {"signal_id": None, "decimals": 2}
else:
config = {}
panel = {
"id": panel_id,
"title": PANEL_TITLES[panel_type] if panel_type in SYSTEM_IDS else f"{PANEL_TITLES[panel_type]} {number}",
"type": panel_type,
"config": config,
"visible": True,
}
self.workspace["panels"].append(panel)
dock = self._create_panel_dock(panel)
self._refresh_view_menu()
self._refresh_session()
self.grid.ensureWidgetVisible(dock, 16, 16)
return panel_id
def remove_panel(self, panel_id: str) -> bool:
if self.workspace is None:
return False
panel = next((item for item in self.workspace["panels"] if item["id"] == panel_id), None)
if panel is None:
return False
widget = self.panel_widgets.get(panel_id)
if isinstance(widget, (SessionPanel, DataPanel, DerivePanel)) and widget.busy:
self.statusBar().showMessage("Wait for the panel's current operation before removing it.", 5000)
return False
self.workspace["panels"].remove(panel)
widget = self.panel_widgets.pop(panel_id, None)
dock = self.docks.pop(panel_id, None)
if dock is not None:
self.grid.remove_panel(dock)
dock.hide()
if widget is not None:
widget.setParent(None)
widget.deleteLater()
dock.setParent(None)
dock.deleteLater()
self._refresh_view_menu()
return True
@staticmethod
def _panel_content(panel: dict[str, Any]) -> QWidget:
if panel["type"] == "dashboard":
return SessionPanel()
if panel["type"] == "data":
return DataPanel(panel.get("config", {}))
if panel["type"] == "derive_manager":
return DerivePanel(panel.get("config", {}))
if panel["type"] == "signal_browser":
return SignalBrowser()
if panel["type"] in {"numeric_group", "gauge_group"}:
return InstrumentGroupPanel({"kind": panel["type"].removesuffix("_group"), **panel.get("config", {})})
if panel["type"] == "numeric":
return NumericPanel(panel.get("config", {}))
if panel["type"] == "gauge":
return GaugePanel(panel.get("config", {}))
if panel["type"] == "graph":
return GraphPanel(panel.get("config", {}))
return QLabel(f"{panel['title']} panel\nNot configured yet")
def _panel_config_changed(self, panel_id: str, config: dict[str, Any]) -> None:
if self.workspace is not None:
panel = next((p for p in self.workspace["panels"] if p["id"] == panel_id), None)
if panel is not None:
panel["config"] = config
self._update_panel_titles([])
def _remove_docks(self) -> None:
self.view_menu.clear()
self.add_panel_menu.setEnabled(False)
for dock in self.docks.values():
self.grid.remove_panel(dock)
dock.hide()
dock.setParent(None)
dock.deleteLater()
self.docks = {}
self.panel_widgets = {}
def _update_title(self) -> None:
name = self.workspace_path.name if self.workspace_path else "Untitled workspace"
self.setWindowTitle(f"DynaLab — {name}")
def main() -> None:
app = QApplication([])
apply_theme(app)
window = MainWindow()
window.show()
app.exec()
+285
View File
@@ -0,0 +1,285 @@
from collections.abc import Callable
from math import ceil
from pathlib import Path
from typing import Any
from uuid import UUID
from dynalab_core import Core, CoreConfig
from dynalab_core.errors import CoreError
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
class _CallSignals(QObject):
finished = Signal(object, object)
class _Call(QRunnable):
def __init__(self, method: Callable[[], Any]) -> None:
super().__init__()
self.method = method
self.signals = _CallSignals()
def run(self) -> None:
try:
self.signals.finished.emit(self.method(), None)
except Exception as error:
self.signals.finished.emit(None, error)
class CoreAdapter:
"""Own the Core instance and keep its potentially blocking calls off Qt."""
MAX_DISPLAY_POINTS = 1500
def __init__(self, host: str | None = None, port: int | None = None) -> None:
config: dict[str, Any] = {"playback_mode": "read"}
if host is not None:
config["host"] = host
if port is not None:
config["port"] = port
self._core = Core(CoreConfig(**config))
self._playback_mode = config["playback_mode"]
self._pool = QThreadPool.globalInstance()
self._calls: set[_Call] = set()
@staticmethod
def default_endpoint() -> tuple[str, int]:
config = CoreConfig()
return config.host, config.port
def start(self) -> None:
self._core.start()
def stop(self) -> bool:
recording_error: Exception | None = None
try:
if self._core.get_state() == "started" and self._core.get_recording_state():
self._core.stop_recording()
except Exception as error:
recording_error = error
try:
stopped = self._core.stop()
except Exception as error:
if recording_error is not None:
raise RuntimeError(
f"Could not finalize the active recording ({recording_error}); "
f"Core shutdown also failed ({error})"
) from error
raise
if recording_error is not None:
raise RuntimeError(f"Could not finalize the active recording: {recording_error}") from recording_error
return stopped
def set_mode(self, mode: str) -> None:
self._core.set_mode(mode)
def start_recording(self) -> None:
self._core.start_recording()
def stop_recording(self) -> None:
self._core.stop_recording()
def write_archive(self, path: str | Path, source: str = "recording") -> UUID:
"""Start writing a Core-owned archive and return its operation ID."""
target = Path(path)
operation_id = self._core.write(
target.parent,
target.name,
source=source,
background=True,
)
if operation_id is None:
raise RuntimeError("Core did not return an archive operation ID")
return operation_id
def operation_status(self, operation_id: str | UUID) -> dict[str, Any] | None:
status = self._core.get_operation(UUID(str(operation_id)))
if status is None:
return None
return {
"id": str(status.id),
"name": status.name,
"state": status.state,
"completed": status.completed,
"total": status.total,
"progress": status.progress,
"message": status.message,
"error_code": status.error_code,
"error_message": status.error_message,
}
def playback_mode(self) -> str:
return self._playback_mode
def load_processing(self, path: str | Path | None = None) -> UUID:
input_path = Path(path) if path is not None else None
operation_id = self._core.load_processing_buffer(input_path, background=True)
if operation_id is None:
raise RuntimeError("Core did not return a processing load operation ID")
return operation_id
def reprocess(self) -> UUID:
operation_id = self._core.reprocess(background=True)
if operation_id is None:
raise RuntimeError("Core did not return a reprocess operation ID")
return operation_id
def load_playback(self, path: str | Path | None = None) -> UUID:
input_path = Path(path) if path is not None else None
operation_id = self._core.load_playback_engine(input_path, background=True)
if operation_id is None:
raise RuntimeError("Core did not return a playback load operation ID")
return operation_id
def play(self) -> None:
self._core.play()
def pause(self) -> None:
self._core.pause()
def seek(self, timestamp_ns: int) -> None:
self._core.seek(timestamp_ns)
def playback_status(self) -> dict[str, Any]:
return {
"position": self._core.get_playback_position(),
"playing": self._core.get_playback_state(),
"time_range": self._safe_time_range("playback"),
}
def bind_unit(self, path: str | Path) -> str:
return str(self._core.bind_derive_unit(file=Path(path)))
def unbind_unit(self, unit_id: str | UUID) -> None:
self._core.unbind_derive_unit(UUID(str(unit_id)))
def reload_unit(self, unit_id: str | UUID, path: str | Path) -> dict[str, Any]:
"""Replace a bound unit, reporting when replacement fails after unbind."""
self._core.unbind_derive_unit(UUID(str(unit_id)))
try:
replacement_id = self._core.bind_derive_unit(file=Path(path))
except Exception as error:
return {
"state": "unbound",
"unit_id": None,
"error_code": getattr(error, "code", None),
"error_message": str(error),
}
return {"state": "bound", "unit_id": str(replacement_id)}
def _safe_time_range(self, source: str) -> tuple[int, int] | None:
try:
return self._core.get_time_range(source=source)
except CoreError as error:
if error.code == "no_recording":
return None
raise
def snapshot(self) -> dict[str, Any]:
connectors = self._core.get_connectors()
signals = self._core.get_all_signal_descriptors()
values = self._core.get_all_live_values()
recording = self._core.get_recording_state()
time_ranges = {
source: self._safe_time_range(source)
for source in ("recording", "processing", "playback")
}
return {
"state": self._core.get_state(),
"mode": self._core.get_mode(),
"recording": recording,
"recording_available": not recording and time_ranges["recording"] is not None,
"processing_available": time_ranges["processing"] is not None,
"playback_available": time_ranges["playback"] is not None,
"time_ranges": time_ranges,
"playback_position": self._core.get_playback_position(),
"playback_playing": self._core.get_playback_state(),
"connectors": [
{"name": item.name, "state": item.state, "signal_count": len(item.signal_ids)}
for item in connectors
],
"signals": [
{
"id": str(item.id),
"name": item.name,
"type": item.type,
"unit": item.unit,
"min_value": item.min_value,
"max_value": item.max_value,
"timeout_ms": item.timeout_ms,
"origin": item.origin,
"value": values.get(item.id),
}
for item in signals
],
"derive_units": [
{
"id": str(item.id),
"input_signal_ids": [str(signal_id) for signal_id in item.input_signal_ids],
"output_signal_id": str(item.output_signal_id),
"worker": {
"name": item.worker.name,
"state": item.worker.state,
"error": item.worker.error,
"queue_size": item.worker.queue_size,
"queue_capacity": item.worker.queue_capacity,
"dropped_samples": item.worker.dropped_samples,
},
}
for item in self._core.get_derive_units()
],
"workers": [
{
"name": item.name,
"state": item.state,
"error": item.error,
"queue_size": item.queue_size,
"queue_capacity": item.queue_capacity,
"dropped_samples": item.dropped_samples,
}
for item in self._core.get_worker_statuses()
],
}
def plot_snapshot(self, signal_ids: list[str]) -> dict[str, Any]:
"""Read the full active timeline, bounding only display point counts."""
try:
time_range = self._core.get_time_range()
except CoreError as error:
if error.code == "no_recording":
return {"time_range": None, "data": {}}
raise
if not time_range or not signal_ids:
return {"time_range": time_range, "data": {}}
start_ns, end_ns = time_range
ids = [UUID(signal_id) for signal_id in signal_ids]
data = self._core.get_data_range(ids, start_ns, end_ns)
display_data = {}
for signal_id, samples in data.items():
if len(samples) > self.MAX_DISPLAY_POINTS:
stride = ceil((len(samples) - 1) / (self.MAX_DISPLAY_POINTS - 1))
reduced = samples[::stride]
if reduced[-1] != samples[-1]:
reduced.append(samples[-1])
samples = reduced
display_data[str(signal_id)] = samples
return {
"time_range": time_range,
"data": display_data,
}
def run_async(
self, method: Callable[[], Any], finished: Callable[[Any, Exception | None], None]
) -> None:
call = _Call(method)
self._calls.add(call)
def deliver(result: Any, error: Exception | None) -> None:
try:
finished(result, error)
finally:
call.signals.finished.disconnect(deliver)
self._calls.discard(call)
call.signals.finished.connect(deliver)
self._pool.start(call)
+460
View File
@@ -0,0 +1,460 @@
from pathlib import Path
from typing import Any
from uuid import UUID
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QFileDialog,
QLabel,
QProgressBar,
QPushButton,
QSlider,
QVBoxLayout,
QWidget,
)
class DataPanel(QWidget):
"""Explicit Offline buffer loading, processing, and saving controls."""
busyChanged = Signal(bool)
configChanged = Signal(dict)
def __init__(self, config: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._adapter: Any | None = None
self._config: dict[str, Any] = {}
self._session_usable = False
self._core_state = "stopped"
self._mode = ""
self._recording = False
self._recording_available = False
self._processing_available = False
self._playback_available = False
self._playback_playing = False
self._playback_position = 0
self._playback_range: tuple[int, int] | None = None
self._duration_ns = 0
self._busy = False
self._operation_id: str | None = None
self._operation_kind = ""
self.set_config(config or {})
self.status = QLabel("Start Core and select Offline mode to manage data")
self.status.setObjectName("muted")
self.status.setWordWrap(True)
self.operation = QLabel("")
self.operation.setObjectName("muted")
self.operation.setWordWrap(True)
self.load_archive_button = QPushButton("Load archive…")
self.load_recording_button = QPushButton("Load recording")
self.reprocess_button = QPushButton("Reprocess")
self.save_button = QPushButton("Save processed…")
self.load_processed_button = QPushButton("Load processed result")
self.play_button = QPushButton("Play")
self.pause_button = QPushButton("Pause")
self.position_label = QLabel("Elapsed 0.00 / 0.00 s")
self.position_label.setObjectName("muted")
self.position_label.setWordWrap(True)
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 1000)
self.seek_slider.setToolTip("Seek by elapsed time from the archive's first sample")
self.load_archive_button.clicked.connect(self._choose_archive)
self.load_recording_button.clicked.connect(self._load_recording)
self.reprocess_button.clicked.connect(self._reprocess)
self.save_button.clicked.connect(self._choose_save_path)
self.load_processed_button.clicked.connect(self._load_processed_result)
self.play_button.clicked.connect(self._play)
self.pause_button.clicked.connect(self._pause)
self.seek_slider.sliderReleased.connect(self._seek_playback)
self.seek_slider.actionTriggered.connect(self._slider_action_triggered)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(0)
self.progress.hide()
layout = QVBoxLayout(self)
layout.addWidget(self.status)
layout.addWidget(self.load_archive_button)
layout.addWidget(self.load_recording_button)
layout.addWidget(self.reprocess_button)
layout.addWidget(self.save_button)
layout.addWidget(self.load_processed_button)
layout.addWidget(self.play_button)
layout.addWidget(self.pause_button)
layout.addWidget(self.position_label)
layout.addWidget(self.seek_slider)
layout.addWidget(self.operation)
layout.addWidget(self.progress)
layout.addStretch(1)
self._operation_timer = QTimer(self)
self._operation_timer.setInterval(350)
self._operation_timer.timeout.connect(self._poll_operation)
self._playback_timer = QTimer(self)
self._playback_timer.setTimerType(Qt.TimerType.PreciseTimer)
self._playback_timer.setInterval(200)
self._playback_timer.timeout.connect(self._poll_playback)
self._update_controls()
def set_update_interval(self, interval_ms: int) -> None:
self._playback_timer.setInterval(interval_ms)
def set_config(self, config: dict[str, Any]) -> None:
self._config = {
"last_archive_path": str(config.get("last_archive_path", "")),
"last_save_path": str(config.get("last_save_path", "")),
"last_playback_path": str(config.get("last_playback_path", "")),
}
def get_config(self) -> dict[str, Any]:
return dict(self._config)
def set_adapter(self, adapter: Any | None) -> None:
if adapter is self._adapter:
return
self._operation_timer.stop()
self._playback_timer.stop()
self._adapter = adapter
self._core_state = "stopped"
self._mode = ""
self._recording = False
self._recording_available = False
self._processing_available = False
self._playback_available = False
self._playback_playing = False
self._playback_position = 0
self._playback_range = None
self._duration_ns = 0
self._operation_id = None
self._operation_kind = ""
self.progress.hide()
self.operation.clear()
self._set_busy(False)
self._update_controls()
@property
def busy(self) -> bool:
return self._busy
def refresh(self, snapshot: dict[str, Any] | None) -> None:
self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
if snapshot is None:
self._core_state = "stopped"
self._mode = ""
self._recording = False
self._recording_available = False
self._processing_available = False
self._playback_available = False
self._playback_playing = False
self._playback_position = 0
self._playback_range = None
self._duration_ns = 0
self._playback_timer.stop()
self.status.setText("Start Core and select Offline mode to manage data")
self._update_controls()
return
self._core_state = snapshot.get("state", "")
self._mode = snapshot.get("mode", "")
self._recording = bool(snapshot.get("recording", False))
self._recording_available = bool(snapshot.get("recording_available", False))
self._processing_available = bool(snapshot.get("processing_available", False))
self._playback_available = bool(snapshot.get("playback_available", False))
self._playback_playing = bool(snapshot.get("playback_playing", False))
self._playback_position = int(snapshot.get("playback_position", 0))
time_ranges = snapshot.get("time_ranges", {})
self._playback_range = time_ranges.get("playback")
if self._playback_range:
self._duration_ns = max(0, self._playback_range[1] - self._playback_range[0])
else:
self._duration_ns = 0
if self._core_state == "started" and self._mode == "playback":
if not self._playback_timer.isActive():
self._playback_timer.start()
else:
self._playback_timer.stop()
if self._core_state != "started":
message = f"Core session state: {self._core_state}"
elif self._mode == "playback":
self.status.setText(self._playback_help())
self._update_position()
self._update_controls()
return
elif self._mode != "offline":
message = f"Select Offline mode to manage data · current mode: {self._mode}"
elif self._recording:
message = "Stop recording before loading or processing data"
elif self._processing_available:
message = "Offline processing buffer is loaded"
elif self._recording_available:
message = "No processing buffer loaded · a completed recording is available"
else:
message = "No processing buffer loaded"
self.status.setText(message)
self._update_position()
self._update_controls()
def _set_busy(self, busy: bool) -> None:
if self._busy == busy:
return
self._busy = busy
self.busyChanged.emit(busy)
self._update_controls()
def _update_controls(self) -> None:
running = self._adapter is not None and self._session_usable and self._core_state == "started"
offline = self._mode == "offline"
playback = self._mode == "playback"
enabled = running and offline and not self._recording and not self._busy
self.load_archive_button.setVisible(offline or playback)
self.load_archive_button.setEnabled(
running and (offline or playback) and not self._recording and not self._busy
)
self.load_recording_button.setVisible(offline)
self.load_recording_button.setEnabled(enabled and self._recording_available)
self.reprocess_button.setVisible(offline)
self.reprocess_button.setEnabled(enabled and self._processing_available)
self.save_button.setVisible(offline)
self.save_button.setEnabled(enabled and self._processing_available)
self.load_processed_button.setVisible(playback)
self.load_processed_button.setEnabled(
running and playback and self._processing_available and not self._busy
)
self.play_button.setVisible(playback)
self.play_button.setEnabled(
running and playback and self._playback_available and not self._playback_playing and not self._busy
)
self.pause_button.setVisible(playback)
self.pause_button.setEnabled(
running and playback and self._playback_available and self._playback_playing and not self._busy
)
self.position_label.setVisible(playback)
self.seek_slider.setVisible(playback)
self.seek_slider.setEnabled(
running and playback and self._playback_available and self._duration_ns > 0 and not self._busy
)
def _playback_help(self) -> str:
if not self._playback_available:
return "No playback data loaded · load an archive or processed result"
playback_mode = self._adapter.playback_mode() if self._adapter is not None else "read"
if playback_mode == "read":
return (
"Playback reads recorded derived values from the loaded archive. To regenerate them, "
"switch to Offline, reprocess, then choose Load processed result."
)
return "Playback uses configured derived units for derived signal values."
def _update_position(self) -> None:
duration = self._duration_ns
position = min(duration, max(0, self._playback_position))
self.position_label.setText(
f"Elapsed {position / 1_000_000_000:.2f} / {duration / 1_000_000_000:.2f} s"
)
if not self.seek_slider.isSliderDown():
value = round(1000 * position / duration) if duration else 0
self.seek_slider.setValue(value)
def _poll_playback(self) -> None:
if self._adapter is None or self._core_state != "started" or self._mode != "playback":
self._playback_timer.stop()
return
try:
state = self._adapter.playback_status()
except Exception as error:
self.operation.setText(f"Could not read playback status: {error}")
return
self._playback_position = int(state["position"])
self._playback_playing = bool(state["playing"])
self._update_position()
self._update_controls()
def _slider_action_triggered(self, _action: int) -> None:
QTimer.singleShot(0, self._seek_after_slider_action)
def _seek_after_slider_action(self) -> None:
if not self.seek_slider.isSliderDown():
self._seek_playback()
def _choose_archive(self) -> None:
if self._adapter is None or self._busy or self._mode not in {"offline", "playback"}:
return
playback = self._mode == "playback"
key = "last_playback_path" if playback else "last_archive_path"
previous = self._config[key]
path, _ = QFileDialog.getOpenFileName(
self,
"Load playback archive" if playback else "Load Offline archive",
previous,
"DynaLab archive (*.dlpak);;All files (*)",
)
if not path:
return
self._config[key] = path
self.configChanged.emit(self.get_config())
if playback:
self._begin_operation(
"playback_load",
lambda: self._adapter.load_playback(path),
"Loading playback archive…",
)
else:
self._begin_operation("load", lambda: self._adapter.load_processing(path), "Loading Offline archive…")
def _load_recording(self) -> None:
if self._adapter is None or self._busy or not self._recording_available:
return
self._begin_operation("load", lambda: self._adapter.load_processing(), "Loading recording…")
def _reprocess(self) -> None:
if self._adapter is None or self._busy or not self._processing_available:
return
self._begin_operation("reprocess", self._adapter.reprocess, "Preparing reprocess…")
def _load_processed_result(self) -> None:
if self._adapter is None or self._busy or not self._processing_available or self._mode != "playback":
return
self._begin_operation(
"playback_load",
self._adapter.load_playback,
"Loading processed result into Playback…",
)
def _choose_save_path(self) -> None:
if self._adapter is None or self._busy or not self._processing_available:
return
path, _ = QFileDialog.getSaveFileName(
self,
"Save processed data",
self._config["last_save_path"] or "processed.dlpak",
"DynaLab archive (*.dlpak)",
)
if not path:
return
target = Path(path)
if target.suffix.lower() != ".dlpak":
target = target.with_name(target.name + ".dlpak")
self._config["last_save_path"] = str(target)
self.configChanged.emit(self.get_config())
self._begin_operation(
"save",
lambda: self._adapter.write_archive(target, source="processing"),
"Preparing archive save…",
)
def _begin_operation(self, kind: str, request: Any, initial_message: str) -> None:
if self._adapter is None or self._busy:
return
self._operation_kind = kind
self.operation.setText(initial_message)
self.progress.setValue(0)
self.progress.show()
self._set_busy(True)
adapter = self._adapter
adapter.run_async(request, self._on_operation_started)
def _play(self) -> None:
self._playback_control("play", "Starting playback…")
def _pause(self) -> None:
self._playback_control("pause", "Pausing playback…")
def _playback_control(self, action: str, message: str) -> None:
if self._adapter is None or self._busy or self._mode != "playback" or not self._playback_available:
return
self.operation.setText(message)
self._set_busy(True)
adapter = self._adapter
method = adapter.play if action == "play" else adapter.pause
adapter.run_async(method, self._on_playback_control)
def _on_playback_control(self, _result: Any, error: Exception | None) -> None:
self._set_busy(False)
if error is not None:
self.operation.setText(f"Playback control failed: {error}")
else:
self.operation.clear()
self._update_controls()
def _seek_playback(self) -> None:
if (
self._adapter is None
or self._busy
or self._mode != "playback"
or not self._playback_available
or self._duration_ns <= 0
):
return
# Core.seek() takes elapsed nanoseconds from the archive's first sample.
timestamp_ns = round(self._duration_ns * self.seek_slider.value() / 1000)
self.operation.setText("Seeking…")
self._set_busy(True)
adapter = self._adapter
adapter.run_async(lambda: adapter.seek(timestamp_ns), self._on_playback_control)
def _on_operation_started(self, operation_id: UUID | str | None, error: Exception | None) -> None:
if error is not None:
self._finish_operation(f"Could not {self._operation_verb()}: {error}", success=False)
return
if operation_id is None:
self._finish_operation("Core did not return an operation ID", success=False)
return
self._operation_id = str(operation_id)
self._poll_operation()
if self._operation_id is not None:
self._operation_timer.start()
def _operation_verb(self) -> str:
return {
"load": "load data",
"reprocess": "reprocess data",
"save": "save processed data",
"playback_load": "load playback data",
}.get(self._operation_kind, "complete operation")
def _poll_operation(self) -> None:
if self._adapter is None or self._operation_id is None:
return
try:
status = self._adapter.operation_status(self._operation_id)
except Exception as error:
self._finish_operation(f"Could not read operation status: {error}", success=False)
return
if status is None:
self._finish_operation("Could not read operation status: operation is unavailable", success=False)
return
total = status.get("total", 0)
completed = status.get("completed", 0)
if total:
self.progress.setValue(round(100 * completed / total))
message = status.get("message") or f"{self._operation_kind.title()} in progress…"
self.operation.setText(message)
state = status.get("state")
if state == "completed":
result = {
"load": "Data loaded into the Offline processing buffer",
"reprocess": "Derived signals reprocessed",
"save": f"Saved processed data to {self._config['last_save_path']}",
"playback_load": "Playback data loaded",
}.get(self._operation_kind, "Operation completed")
self._finish_operation(result, success=True)
elif state == "failed":
detail = status.get("error_message") or status.get("error_code") or "unknown error"
self._finish_operation(f"Could not {self._operation_verb()}: {detail}", success=False)
def _finish_operation(self, message: str, *, success: bool) -> None:
kind = self._operation_kind
self._operation_timer.stop()
self._operation_id = None
self._operation_kind = ""
self.operation.setText(message)
self.progress.hide()
if success and kind in {"load", "reprocess"}:
self._processing_available = True
if success and kind == "playback_load":
self._playback_available = True
self._playback_position = 0
self._playback_range = None
self._duration_ns = 0
if not self.seek_slider.isSliderDown():
self.seek_slider.setValue(0)
self._set_busy(False)
self._update_controls()
+390
View File
@@ -0,0 +1,390 @@
"""Workspace-scoped DLUnit library and binding controls."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from PySide6.QtCore import QSignalBlocker, Qt, Signal
from PySide6.QtWidgets import (
QFileDialog,
QGridLayout,
QScrollArea,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from .dlunit_editor import DLUnitEditorDialog
class DerivePanel(QWidget):
busyChanged = Signal(bool)
configChanged = Signal(dict)
def __init__(self, config: dict[str, Any] | None = None, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._adapter: Any | None = None
self._config: dict[str, Any] = {"units": []}
self._signals: list[dict[str, Any]] = []
self._derive_units: list[dict[str, Any]] = []
self._workers: list[dict[str, Any]] = []
self._session_usable = False
self._core_state = "stopped"
self._busy = False
self._active_action = ""
self._feedback = ""
self._base_status = "Start Core to view or bind derived units"
self.unit_list = QListWidget()
self.unit_list.setObjectName("deriveUnitList")
self.unit_list.setMinimumHeight(60)
self.unit_list.setMaximumHeight(150)
self.status_label = QLabel("Start Core to view or bind derived units")
self.status_label.setObjectName("muted")
self.status_label.setWordWrap(True)
self.unit_details = QLabel("Select a unit to inspect its inputs, output, and worker state")
self.unit_details.setObjectName("muted")
self.unit_details.setWordWrap(True)
self.new_button = QPushButton("New unit…")
self.open_button = QPushButton("Open unit…")
self.edit_button = QPushButton("Edit unit…")
self.bind_button = QPushButton("Bind")
self.unbind_button = QPushButton("Unbind")
self.reload_button = QPushButton("Reload saved")
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Derived units"))
layout.addWidget(self.unit_list, 1)
details = QScrollArea()
details.setWidgetResizable(True)
details.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
details.setMinimumHeight(100)
details.setMaximumHeight(170)
self.unit_details.setAlignment(Qt.AlignmentFlag.AlignTop)
details.setWidget(self.unit_details)
layout.addWidget(details)
buttons = QGridLayout()
for row, pair in enumerate(((self.new_button, self.open_button),
(self.edit_button, self.reload_button),
(self.bind_button, self.unbind_button))):
for column, button in enumerate(pair):
buttons.addWidget(button, row, column)
layout.addLayout(buttons)
layout.addWidget(self.status_label)
self.new_button.clicked.connect(self._new_unit)
self.open_button.clicked.connect(self._open_unit)
self.edit_button.clicked.connect(self._edit_unit)
self.bind_button.clicked.connect(self._bind_selected)
self.unbind_button.clicked.connect(self._unbind_selected)
self.reload_button.clicked.connect(self._reload_selected)
self.unit_list.currentItemChanged.connect(self._selection_changed)
self.set_config(config or {})
self._update_controls()
@property
def busy(self) -> bool:
return self._busy
def set_adapter(self, adapter: Any | None) -> None:
if adapter is self._adapter:
self._update_controls()
return
self._adapter = adapter
self._core_state = "stopped"
self._derive_units = []
self._workers = []
changed = False
for unit in self._config["units"]:
if unit["unit_id"] is not None:
unit["unit_id"] = None
changed = True
if changed:
self._emit_config()
self._render_units()
self._update_controls()
def set_config(self, config: dict[str, Any]) -> None:
units: list[dict[str, Any]] = []
for entry in config.get("units", []):
if not isinstance(entry, dict) or not entry.get("path"):
continue
units.append(
{
"path": str(entry["path"]),
"unit_id": str(entry["unit_id"]) if entry.get("unit_id") else None,
"binding_intent": bool(entry.get("binding_intent", False)),
}
)
self._config = {"units": units}
self._render_units()
def get_config(self) -> dict[str, Any]:
return {"units": [dict(item) for item in self._config["units"]]}
def refresh(self, snapshot: dict[str, Any] | None) -> None:
self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
old_ids = {item.get("unit_id") for item in self._config["units"]}
if snapshot is None:
self._core_state = "stopped"
self._signals = []
self._derive_units = []
self._workers = []
for item in self._config["units"]:
item["unit_id"] = None
self._base_status = "Start Core to view or bind derived units"
else:
self._core_state = str(snapshot.get("state", "stopped"))
self._signals = list(snapshot.get("signals", []))
self._derive_units = list(snapshot.get("derive_units", []))
self._workers = list(snapshot.get("workers", []))
live_ids = {str(item.get("id")) for item in self._derive_units}
for item in self._config["units"]:
if item.get("unit_id") not in live_ids:
item["unit_id"] = None
state = snapshot.get("state", "stopped")
if state != "started":
message = f"Core session state: {state}"
elif not self._derive_units and not self._config["units"]:
message = "No derived units are bound or tracked"
else:
message = "Bound units are shown with worker state; opened files remain unbound until Bind"
self._base_status = message
self._render_units()
if not self._feedback:
self.status_label.setText(self._base_status)
if old_ids != {item.get("unit_id") for item in self._config["units"]}:
self._emit_config()
def _emit_config(self) -> None:
self.configChanged.emit(self.get_config())
def _set_busy(self, busy: bool, action: str = "") -> None:
if self._busy == busy:
if busy:
self._active_action = action
return
self._busy = busy
self._active_action = action if busy else ""
self.busyChanged.emit(busy)
self._update_controls()
def _set_feedback(self, message: str) -> None:
self._feedback = message
self.status_label.setText(message)
def _clear_feedback(self) -> None:
self._feedback = ""
self.status_label.setText(self._base_status)
def _selection_changed(self, *_args: Any) -> None:
self._feedback = ""
self.status_label.setText(self._base_status)
self._update_controls()
self._update_details()
def _tracked_item(self, entry: dict[str, Any], bound_id: str | None = None) -> QListWidgetItem:
path = Path(entry["path"])
unit_id = entry.get("unit_id")
bound = next((unit for unit in self._derive_units if str(unit.get("id")) == unit_id), None)
if bound:
worker = bound.get("worker", {})
label = f"{path.name} · bound · {worker.get('state', 'unknown')}"
if worker.get("error"):
label += f" · {worker['error']}"
item = QListWidgetItem(label)
item.setToolTip(self._unit_summary(bound, worker))
else:
label = f"{path.name} · unbound"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, {**entry, "external": False})
if not bound:
item.setToolTip(str(path))
return item
def _unit_summary(self, unit: dict[str, Any], worker: dict[str, Any]) -> str:
signal_names = {str(signal.get("id")): str(signal.get("name", signal.get("id"))) for signal in self._signals}
inputs = [
f"{signal_names.get(str(signal_id), str(signal_id))} [{signal_id}]"
for signal_id in unit.get("input_signal_ids", [])
]
output_id = str(unit.get("output_signal_id", ""))
output = f"{signal_names.get(output_id, output_id)} [{output_id}]"
lines = [f"Inputs: {', '.join(inputs) if inputs else '(none)'}", f"Output: {output}", f"Worker: {worker.get('name', 'unknown')} · {worker.get('state', 'unknown')}"]
if worker.get("error"):
lines.append(f"Error: {worker['error']}")
return "\n".join(lines)
def _render_units(self) -> None:
blocker = QSignalBlocker(self.unit_list)
selected = self.unit_list.currentItem().data(Qt.ItemDataRole.UserRole) if self.unit_list.currentItem() else None
wanted = (selected or {}).get("path") or (selected or {}).get("unit_id")
self.unit_list.clear()
bound_by_id = {str(item.get("id")): item for item in self._derive_units}
seen_ids: set[str] = set()
for entry in self._config["units"]:
unit_id = entry.get("unit_id")
if unit_id and unit_id in bound_by_id:
seen_ids.add(unit_id)
item = self._tracked_item(entry)
self.unit_list.addItem(item)
if wanted and wanted in (entry.get("path"), unit_id):
self.unit_list.setCurrentItem(item)
signal_by_id = {str(signal.get("id")): signal for signal in self._signals}
for unit in self._derive_units:
unit_id = str(unit.get("id"))
if unit_id in seen_ids:
continue
output_id = str(unit.get("output_signal_id"))
signal = signal_by_id.get(output_id, {})
worker = unit.get("worker", {})
output_name = signal.get("name", output_id)
item = QListWidgetItem(f"{output_name} · external Core unit · {worker.get('state', 'unknown')}")
item.setData(Qt.ItemDataRole.UserRole, {"unit_id": unit_id, "path": None, "external": True})
item.setToolTip(self._unit_summary(unit, worker) + "\nCore does not expose this unit's saved path or source")
if worker.get("error"):
item.setToolTip(f"Worker error: {worker['error']}\nPath and source are not exposed by Core")
self.unit_list.addItem(item)
if wanted == unit_id:
self.unit_list.setCurrentItem(item)
del blocker
self._update_controls()
self._update_details()
def _update_details(self) -> None:
selected = self._selected()
if selected is None:
self.unit_details.setText("Select a unit to inspect its inputs, output, and worker state")
return
bound = next((item for item in self._derive_units if str(item.get("id")) == selected.get("unit_id")), None)
if bound is None:
self.unit_details.setText(f"Unit ID: {selected.get('unit_id') or 'not bound'}\nPath: {selected.get('path') or 'not available'}")
return
worker = bound.get("worker", {})
self.unit_details.setText(f"Unit ID: {bound.get('id')}\n{self._unit_summary(bound, worker)}")
def _selected(self) -> dict[str, Any] | None:
item = self.unit_list.currentItem()
return item.data(Qt.ItemDataRole.UserRole) if item else None
def _update_controls(self, *_args: Any) -> None:
selected = self._selected()
entry = next((unit for unit in self._config["units"] if selected and unit["path"] == selected.get("path")), None)
running = self._adapter is not None and self._session_usable and self._core_state == "started" and not self._busy
exists = bool(entry and Path(entry["path"]).is_file())
bound = bool(selected and selected.get("unit_id") and any(str(unit.get("id")) == selected["unit_id"] for unit in self._derive_units))
self.new_button.setEnabled(not self._busy)
self.open_button.setEnabled(not self._busy)
self.edit_button.setEnabled(not self._busy and exists)
self.bind_button.setEnabled(running and exists and not bound)
self.unbind_button.setEnabled(running and bound)
self.reload_button.setEnabled(running and exists and bound and not bool(selected and selected.get("external", False)))
def _editor(self, path: str | Path | None) -> None:
dialog = DLUnitEditorDialog(path, self._signals, self)
if dialog.load_error:
QMessageBox.critical(self, "Could not open DLUnit", dialog.load_error)
return
if path is not None:
opened_path = str(Path(path))
if not any(unit["path"] == opened_path for unit in self._config["units"]):
self._config["units"].append({"path": opened_path, "unit_id": None, "binding_intent": False})
self._render_units()
self._emit_config()
if dialog.exec() != DLUnitEditorDialog.DialogCode.Accepted or not dialog.path:
return
saved_path = str(dialog.path)
entry = next((unit for unit in self._config["units"] if unit["path"] == saved_path), None)
if entry is None:
self._config["units"].append({"path": saved_path, "unit_id": None, "binding_intent": False})
self._render_units()
self._emit_config()
self._set_feedback("Saved to disk; bind or reload explicitly to apply changes in Core")
def _new_unit(self) -> None:
self._clear_feedback()
self._editor(None)
def _open_unit(self) -> None:
self._clear_feedback()
filename, _ = QFileDialog.getOpenFileName(self, "Open DLUnit", "", "DLUnit (*.dlunit)")
if filename:
self._editor(filename)
def _edit_unit(self) -> None:
self._clear_feedback()
selected = self._selected()
if selected and selected.get("path"):
self._editor(selected["path"])
def _run_async(self, action: str, method: Any, done: Any) -> None:
if self._adapter is None or self._busy:
return
self._set_busy(True, action)
def finished(result: Any, error: Exception | None) -> None:
self._set_busy(False)
if error is not None:
self._set_feedback(f"{action} failed: {error}")
return
done(result)
self._render_units()
self._adapter.run_async(method, finished)
def _bind_selected(self) -> None:
selected = self._selected()
if not selected or not selected.get("path"):
return
entry = next((unit for unit in self._config["units"] if unit["path"] == selected["path"]), None)
if entry is None:
return
def completed(unit_id: str) -> None:
entry["unit_id"] = str(unit_id)
entry["binding_intent"] = True
self._set_feedback("Unit bound in Core")
self._emit_config()
self._run_async("Bind", lambda: self._adapter.bind_unit(entry["path"]), completed)
def _unbind_selected(self) -> None:
selected = self._selected()
if not selected or not selected.get("unit_id"):
return
unit_id = selected["unit_id"]
entry = next((unit for unit in self._config["units"] if unit.get("unit_id") == unit_id), None)
def completed(_result: Any) -> None:
if entry is not None:
entry["unit_id"] = None
entry["binding_intent"] = False
self._emit_config()
self._set_feedback("Unit unbound from Core")
self._run_async("Unbind", lambda: self._adapter.unbind_unit(unit_id), completed)
def _reload_selected(self) -> None:
selected = self._selected()
if not selected or selected.get("external") or not selected.get("path") or not selected.get("unit_id"):
return
old_id = selected["unit_id"]
entry = next((unit for unit in self._config["units"] if unit.get("unit_id") == old_id), None)
if entry is None:
return
def completed(result: dict[str, Any]) -> None:
if result.get("state") == "bound":
entry["unit_id"] = str(result["unit_id"])
entry["binding_intent"] = True
self._set_feedback("Saved unit reloaded and bound in Core")
else:
entry["unit_id"] = None
entry["binding_intent"] = True
self._set_feedback(f"Reload failed after unbind; unit is unbound: {result.get('error_message', 'unknown error')}")
self._emit_config()
self._run_async("Reload saved", lambda: self._adapter.reload_unit(old_id, entry["path"]), completed)
+300
View File
@@ -0,0 +1,300 @@
"""Small editor for the documented manifest.json/unit.py DLUnit archive."""
from __future__ import annotations
import json
import os
import re
import tempfile
from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
from zipfile import ZIP_DEFLATED, ZipFile
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QSyntaxHighlighter, QTextCharFormat
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPlainTextEdit,
QSplitter,
QVBoxLayout,
QWidget,
)
class _PythonHighlighter(QSyntaxHighlighter):
def __init__(self, document: Any) -> None:
super().__init__(document)
self._rules: list[tuple[re.Pattern[str], QTextCharFormat]] = []
keyword = QTextCharFormat()
keyword.setForeground(QColor("#c792ea"))
keyword.setFontWeight(QFont.Weight.Bold)
self._rules.extend(
(re.compile(rf"\b{word}\b"), keyword)
for word in ("def", "return", "import", "from", "as", "if", "else", "for", "in", "class", "True", "False", "None")
)
string = QTextCharFormat()
string.setForeground(QColor("#c3e88d"))
self._rules.extend(
((re.compile(r"'(?:[^'\\]|\\.)*'"), string), (re.compile(r'"(?:[^"\\]|\\.)*"'), string))
)
comment = QTextCharFormat()
comment.setForeground(QColor("#91a3b8"))
self._rules.append((re.compile(r"#[^\n]*"), comment))
def highlightBlock(self, text: str) -> None:
for pattern, style in self._rules:
for match in pattern.finditer(text):
self.setFormat(match.start(), match.end() - match.start(), style)
def _descriptor(value: dict[str, Any], *, default_origin: str = "source") -> dict[str, Any]:
result = dict(value)
result.pop("value", None)
result.setdefault("name", "Signal")
result.setdefault("type", "number")
result.setdefault("unit", "")
result.setdefault("min_value", None)
result.setdefault("max_value", None)
result.setdefault("timeout_ms", 2000)
result.setdefault("origin", default_origin)
return result
def read_dlunit(path: str | Path) -> tuple[dict[str, Any], str]:
with ZipFile(path) as archive:
manifest = json.loads(archive.read("manifest.json"))
source = archive.read("unit.py").decode("utf-8")
if not isinstance(manifest, dict):
raise ValueError("manifest.json must contain an object")
return manifest, source
def write_dlunit(path: str | Path, manifest: dict[str, Any], source: str) -> Path:
"""Write the public two-entry archive atomically, preserving extra fields."""
target = Path(path)
if target.suffix.lower() != ".dlunit":
target = target.with_suffix(".dlunit")
target.parent.mkdir(parents=False, exist_ok=True)
temporary: Path | None = None
try:
with tempfile.NamedTemporaryFile(
dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False
) as handle:
temporary = Path(handle.name)
with ZipFile(handle, "w", compression=ZIP_DEFLATED) as archive:
archive.writestr("manifest.json", json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
archive.writestr("unit.py", source)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(target)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
return target
class DLUnitEditorDialog(QDialog):
"""Form editor and plain Python source editor for a single DLUnit file."""
def __init__(
self,
path: str | Path | None = None,
signals: list[dict[str, Any]] | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setWindowTitle("DLUnit editor")
self.resize(900, 650)
self.path = Path(path) if path else None
self.load_error: str | None = None
self._original_manifest: dict[str, Any] = {}
self._original_inputs: dict[str, dict[str, Any]] = {}
self._available = {str(item.get("id")): dict(item) for item in (signals or []) if item.get("id")}
self.trust_warning = QLabel(
"Trusted code only: DynaLab Core executes this Python source when the unit is bound."
)
self.trust_warning.setWordWrap(True)
self.unit_uuid = QLineEdit()
self.input_list = QListWidget()
self.input_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
self.input_list.setToolTip("Inputs are passed to process in this order. Drag to reorder.")
self.input_list.setToolTip("Checked inputs are passed to process() in this order; drag to reorder")
self.input_list.model().rowsMoved.connect(self._renumber_inputs)
self.output_uuid = QLineEdit()
self.output_name = QLineEdit()
self.output_type = QLineEdit("number")
self.output_unit = QLineEdit()
self.output_min = QLineEdit()
self.output_max = QLineEdit()
self.comments = QPlainTextEdit()
self.source = QPlainTextEdit()
self.source.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont))
self.source.setTabStopDistance(self.source.fontMetrics().horizontalAdvance(" ") * 4)
self._highlighter = _PythonHighlighter(self.source.document())
form_widget = QWidget()
form = QFormLayout(form_widget)
form.addRow("Unit UUID", self.unit_uuid)
form.addRow("Inputs (ordered)", self.input_list)
form.addRow("Output UUID", self.output_uuid)
form.addRow("Output name", self.output_name)
form.addRow("Output type", self.output_type)
form.addRow("Output unit", self.output_unit)
form.addRow("Minimum", self.output_min)
form.addRow("Maximum", self.output_max)
form.addRow("Comments", self.comments)
code_widget = QWidget()
code_layout = QVBoxLayout(code_widget)
code_layout.setContentsMargins(0, 0, 0, 0)
code_layout.addWidget(QLabel("Python source (unit.py)"))
code_layout.addWidget(self.source, 1)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(form_widget)
splitter.addWidget(code_widget)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 2)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addWidget(self.trust_warning)
layout.addWidget(splitter, 1)
layout.addWidget(buttons)
if self.path:
self._load(self.path)
else:
self._load_new()
self._populate_inputs()
def _load(self, path: Path) -> None:
try:
manifest, source = read_dlunit(path)
inputs = manifest.get("input_signals", [])
output = manifest.get("return_signal", {})
if not isinstance(inputs, list) or not all(isinstance(item, dict) for item in inputs) or not isinstance(output, dict):
raise ValueError("manifest must contain input_signals and return_signal")
self._original_manifest = manifest
self._original_inputs = {str(item.get("id")): _descriptor(item) for item in inputs}
self.unit_uuid.setText(str(manifest.get("unit_uuid", "")))
output = _descriptor(output, default_origin="derived")
self.output_uuid.setText(output["id"])
self.output_name.setText(str(output["name"]))
self.output_type.setText(str(output["type"]))
self.output_unit.setText(str(output.get("unit") or ""))
self.output_min.setText("" if output.get("min_value") is None else str(output["min_value"]))
self.output_max.setText("" if output.get("max_value") is None else str(output["max_value"]))
self.comments.setPlainText(str(manifest.get("comments", "")))
self.source.setPlainText(source)
except Exception as error:
self.load_error = str(error)
def _load_new(self) -> None:
self.unit_uuid.setText(str(uuid4()))
self.output_uuid.setText(str(uuid4()))
self.output_name.setText("Derived signal")
self._original_manifest = {"unit_uuid": self.unit_uuid.text(), "input_signals": [], "return_signal": {}, "comments": ""}
self.source.setPlainText(
"from dynalab_core.protocols.packets.data import ValueDescriptor\n"
"from dynalab_core.protocols.packets.handshake import SignalDescriptor\n\n"
"def process(result: SignalDescriptor) -> ValueDescriptor:\n"
" return ValueDescriptor(result.id, 0.0, 0)\n"
)
def _populate_inputs(self) -> None:
descriptors = dict(self._original_inputs)
for signal_id, active in self._available.items():
if signal_id not in descriptors:
descriptors[signal_id] = _descriptor(active)
self.input_list.clear()
for index, (signal_id, descriptor) in enumerate(descriptors.items(), start=1):
item = QListWidgetItem(f"{index}. {descriptor.get('name', signal_id)} · {signal_id}")
item.setData(Qt.ItemDataRole.UserRole, descriptor)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Checked if signal_id in self._original_inputs else Qt.CheckState.Unchecked)
if signal_id not in self._available:
item.setToolTip("Unavailable in the current mode; retained from the saved manifest")
self.input_list.addItem(item)
def _renumber_inputs(self, *_args: Any) -> None:
for index in range(self.input_list.count()):
item = self.input_list.item(index)
descriptor = item.data(Qt.ItemDataRole.UserRole)
item.setText(f"{index + 1}. {descriptor.get('name', descriptor.get('id'))} · {descriptor.get('id')}")
@staticmethod
def _number(text: str, label: str) -> float | None:
text = text.strip()
if not text:
return None
try:
return float(text)
except ValueError as error:
raise ValueError(f"{label} must be a number") from error
def _build_manifest(self) -> dict[str, Any]:
unit_id = str(UUID(self.unit_uuid.text().strip()))
output_id = str(UUID(self.output_uuid.text().strip()))
selected: list[dict[str, Any]] = []
for index in range(self.input_list.count()):
item = self.input_list.item(index)
if item.checkState() == Qt.CheckState.Checked:
descriptor = _descriptor(item.data(Qt.ItemDataRole.UserRole))
descriptor["id"] = str(UUID(str(descriptor.get("id", ""))))
if not str(descriptor.get("name", "")).strip() or descriptor.get("type") not in {"number", "binary"}:
raise ValueError("Each input needs a name and a number or binary type")
selected.append(descriptor)
min_value = self._number(self.output_min.text(), "Minimum")
max_value = self._number(self.output_max.text(), "Maximum")
if min_value is not None and max_value is not None and min_value > max_value:
raise ValueError("Minimum must not exceed maximum")
output = _descriptor(self._original_manifest.get("return_signal", {}), default_origin="derived")
output.update(
{
"id": output_id,
"name": self.output_name.text().strip(),
"type": self.output_type.text().strip(),
"unit": self.output_unit.text().strip(),
"min_value": min_value,
"max_value": max_value,
"origin": "derived",
}
)
if not output["name"] or output["type"] not in {"number", "binary"}:
raise ValueError("Output name and a number or binary type are required")
manifest = dict(self._original_manifest)
manifest.update(
{
"unit_uuid": unit_id,
"input_signals": selected,
"return_signal": output,
"comments": self.comments.toPlainText(),
}
)
return manifest
def _save(self) -> None:
try:
manifest = self._build_manifest()
target = self.path
if target is None:
filename, _ = QFileDialog.getSaveFileName(self, "Save DLUnit", "", "DLUnit (*.dlunit)")
if not filename:
return
target = Path(filename)
self.path = write_dlunit(target, manifest, self.source.toPlainText())
except Exception as error:
QMessageBox.warning(self, "Could not save DLUnit", str(error))
return
self._original_manifest = manifest
self.accept()
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8" viewBox="0 0 12 8"><path d="m2 2 4 4 4-4" fill="none" stroke="#aeb2b9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 207 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
<path d="M19.4 13.5a7.8 7.8 0 0 0 0-3l1.6-1.2-1.6-2.8-1.9.7a7.4 7.4 0 0 0-2.6-1.5L14.6 3h-3.2l-.3 2.7a7.4 7.4 0 0 0-2.6 1.5l-1.9-.7L5 9.3l1.6 1.2a7.8 7.8 0 0 0 0 3L5 14.7l1.6 2.8 1.9-.7a7.4 7.4 0 0 0 2.6 1.5l.3 2.7h3.2l.3-2.7a7.4 7.4 0 0 0 2.6-1.5l1.9.7 1.6-2.8-1.6-1.2Z" stroke="#aeb2b9" stroke-width="1.5" stroke-linejoin="round"/>
<circle cx="13" cy="12" r="3" stroke="#aeb2b9" stroke-width="1.7"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

+232
View File
@@ -0,0 +1,232 @@
"""Resizable collections of independent gauge or numeric panels."""
from math import ceil
from pathlib import Path
from typing import Any
from PySide6.QtCore import QSize, Signal
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QGridLayout,
QHBoxLayout,
QLabel,
QLayout,
QMenu,
QPushButton,
QToolButton,
QVBoxLayout,
QWidget,
)
from .panels import GaugePanel, NumericPanel
class InstrumentGroupPanel(QWidget):
"""A grid of independently selectable gauges or numeric readouts."""
configChanged = Signal(dict)
removeRequested = Signal()
minimumSizeChanged = Signal()
_MIN_INSTRUMENT_WIDTH = 190
def __init__(self, config: dict[str, Any]) -> None:
super().__init__()
kind = config.get("kind", "gauge")
if kind not in {"gauge", "numeric"}:
raise ValueError("Instrument group kind must be 'gauge' or 'numeric'.")
self.kind = kind
self._signals: list[dict[str, Any]] = []
self._instruments: list[GaugePanel | NumericPanel] = []
self._columns = 0
self._reported_minimum: QSize | None = None
self.title = QLabel("")
self.title.hide()
self.title.setObjectName("panelHeaderTitle")
self.add_button = QPushButton("+")
self.add_button.setFixedSize(24, 24)
self.add_button.setAccessibleName("Add gauge" if kind == "gauge" else "Add readout")
self.add_button.setToolTip(self.add_button.accessibleName())
self.add_button.clicked.connect(lambda: self._add_instrument({"signal_id": None}))
self.settings_menu = QMenu(self)
self.settings_menu.addAction("Remove group", self.removeRequested.emit)
settings = QToolButton()
settings.setObjectName("panelSettingsButton")
settings.setIcon(QIcon(str(Path(__file__).parent / "icons" / "gear.svg")))
settings.setIconSize(QSize(16, 16))
settings.setAccessibleName("Group settings")
settings.setToolTip("Configure this group")
settings.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
settings.setMenu(self.settings_menu)
settings.setAutoRaise(True)
header = QHBoxLayout()
header.addStretch(1)
header.addWidget(self.add_button)
header.addWidget(settings)
self.grid = QGridLayout()
self.grid.setContentsMargins(0, 0, 0, 0)
self.grid.setSpacing(4)
self.grid.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
self._layout = QVBoxLayout(self)
self._layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
self._layout.addLayout(header)
self._layout.addLayout(self.grid)
self._layout.setContentsMargins(6, 2, 6, 4)
self._layout.setSpacing(2)
for item in config.get("items", []):
if isinstance(item, dict):
self._add_instrument(item, emit=False)
self._arrange()
def config(self) -> dict[str, Any]:
return {
"kind": self.kind,
"items": [panel.config() for panel in self._instruments],
}
def refresh(self, signals: list[dict[str, Any]] | None) -> None:
self._signals = signals or []
for panel in self._instruments:
panel.refresh(self._signals)
self._arrange()
self.updateGeometry()
def minimumSizeHint(self) -> QSize:
width = max(self._available_grid_width(), self._MIN_INSTRUMENT_WIDTH)
columns = self._columns_for_width(width)
rows = ceil(len(self._instruments) / columns) if self._instruments else 0
row_height = self._row_height(width, columns)
margins = self._layout.contentsMargins()
height = margins.top() + margins.bottom() + self._header_height() + self._layout.spacing() + rows * row_height
if rows > 1:
height += (rows - 1) * self.grid.verticalSpacing()
return QSize(self._header_minimum_width(), height)
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._arrange()
def _add_instrument(self, item: dict[str, Any], *, emit: bool = True) -> None:
panel_type = GaugePanel if self.kind == "gauge" else NumericPanel
panel = panel_type(item)
panel.setMinimumWidth(self._MIN_INSTRUMENT_WIDTH)
panel.settings_menu.removeAction(panel.settings_menu.actions()[-1])
panel.settings_menu.addAction("Remove instrument")
panel.settings_menu.actions()[-1].triggered.connect(
lambda _checked=False, child=panel: self._remove_instrument(child)
)
panel.removeRequested.connect(lambda child=panel: self._remove_instrument(child))
panel.configChanged.connect(lambda _config, child=panel: self._instrument_changed(child))
self._instruments.append(panel)
self.grid.addWidget(panel, 0, 0)
if self._signals:
panel.refresh(self._signals)
self._arrange(force_reflow=True, force_minimum_signal=True)
self.updateGeometry()
if emit:
self._emit_config()
def _remove_instrument(self, panel: GaugePanel | NumericPanel) -> None:
if panel not in self._instruments:
return
self._instruments.remove(panel)
self.grid.removeWidget(panel)
panel.deleteLater()
self._arrange(force_reflow=True, force_minimum_signal=True)
self.updateGeometry()
self._emit_config()
def _instrument_changed(self, _panel: GaugePanel | NumericPanel) -> None:
self._emit_config()
def _emit_config(self) -> None:
self.configChanged.emit(self.config())
def _arrange(self, *, force_reflow: bool = False, force_minimum_signal: bool = False) -> None:
columns = self._columns_for_width(self._available_grid_width())
reflowed = force_reflow or columns != self._columns
if reflowed:
while self.grid.count():
self.grid.takeAt(0)
for index, panel in enumerate(self._instruments):
self.grid.addWidget(panel, index // columns, index % columns)
for column in range(max(columns, self.grid.columnCount())):
self.grid.setColumnStretch(column, 1 if column < columns else 0)
self._columns = columns
self._set_row_heights(columns)
minimum = self.minimumSizeHint()
size_changed = minimum != self._reported_minimum
if force_minimum_signal or reflowed or size_changed:
self._reported_minimum = minimum
self.updateGeometry()
self.minimumSizeChanged.emit()
def _available_grid_width(self) -> int:
outer = self._layout.contentsMargins()
inner = self.grid.contentsMargins()
return max(
self._MIN_INSTRUMENT_WIDTH,
self.contentsRect().width()
- outer.left()
- outer.right()
- inner.left()
- inner.right(),
)
def _columns_for_width(self, width: int) -> int:
spacing = max(0, self.grid.horizontalSpacing())
return max(1, (width + spacing) // (self._MIN_INSTRUMENT_WIDTH + spacing))
def _header_minimum_width(self) -> int:
margins = self._layout.contentsMargins()
grid_margins = self.grid.contentsMargins()
header = self._layout.itemAt(0).layout()
header_width = header.sizeHint().width() if header is not None else 0
instrument_width = max(
(panel.minimumSizeHint().width() for panel in self._instruments),
default=self._MIN_INSTRUMENT_WIDTH,
)
grid_width = (
margins.left()
+ margins.right()
+ grid_margins.left()
+ grid_margins.right()
+ instrument_width
)
return max(margins.left() + margins.right() + header_width, grid_width)
def _header_height(self) -> int:
header = self._layout.itemAt(0).layout()
return header.sizeHint().height() if header is not None else 0
def _row_height(self, width: int, columns: int) -> int:
if not self._instruments:
return 0
spacing = max(0, self.grid.horizontalSpacing())
item_width = max(
self._MIN_INSTRUMENT_WIDTH,
(width - max(0, columns - 1) * spacing) // columns,
)
heights = []
for panel in self._instruments:
hinted = max(panel.minimumSizeHint().height(), panel.sizeHint().height())
height_for_width = panel.heightForWidth(item_width)
if height_for_width > 0:
hinted = max(hinted, height_for_width)
heights.append(hinted)
return max(heights, default=0)
def _set_row_heights(self, columns: int) -> None:
for row in range(self.grid.rowCount()):
self.grid.setRowMinimumHeight(row, 0)
if not self._instruments:
return
row_height = self._row_height(self._available_grid_width(), columns)
rows = ceil(len(self._instruments) / columns)
for row in range(rows):
self.grid.setRowMinimumHeight(row, row_height)
+877
View File
@@ -0,0 +1,877 @@
import math
from pathlib import Path
from typing import Any
import pyqtgraph as pg
from PySide6.QtCore import QRectF, QSize, Qt, Signal
from PySide6.QtGui import (
QColor,
QFont,
QFontMetrics,
QIcon,
QPainter,
QPainterPath,
QPen,
)
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMenu,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QVBoxLayout,
QWidget,
QWidgetAction,
)
class _PanelHeader(QWidget):
def __init__(self, title: str, settings_menu: QMenu, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.label = QLabel(title)
self.label.setObjectName("panelHeaderTitle")
self.label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
self._title = title
self.settings = QToolButton()
self.settings.setObjectName("panelSettingsButton")
self.settings.setIcon(QIcon(str(Path(__file__).parent / "icons" / "gear.svg")))
self.settings.setIconSize(QSize(18, 18))
self.settings.setAccessibleName("Panel settings")
self.settings.setToolTip("Configure this panel")
self.settings.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
self.settings.setMenu(settings_menu)
self.settings.setAutoRaise(True)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.label, 1)
layout.addWidget(self.settings)
def set_title(self, title: str) -> None:
self._title = title
self.label.setToolTip(title)
self._elide_title()
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._elide_title()
def _elide_title(self) -> None:
self.label.setText(QFontMetrics(self.label.font()).elidedText(
self._title,
Qt.TextElideMode.ElideRight,
max(0, self.width() - self.settings.sizeHint().width() - 8),
))
def _settings_menu(parent: QWidget, title: str) -> QMenu:
menu = QMenu(parent)
heading = QLabel(title)
heading.setObjectName("panelSettingsTitle")
heading.setContentsMargins(12, 8, 12, 4)
action = QWidgetAction(menu)
action.setDefaultWidget(heading)
menu.addAction(action)
return menu
def _add_settings_widget(menu: QMenu, widget: QWidget) -> None:
action = QWidgetAction(menu)
action.setDefaultWidget(widget)
menu.addAction(action)
class SignalBrowser(QWidget):
def __init__(self) -> None:
super().__init__()
self.signals: list[dict[str, Any]] | None = None
self.search = QLineEdit()
self.search.setPlaceholderText("Search signals…")
self.search.setAccessibleName("Search signals by name or ID")
self.items = QListWidget()
self.items.setWordWrap(True)
layout = QVBoxLayout(self)
layout.addWidget(self.search)
layout.addWidget(self.items, 1)
self.search.textChanged.connect(self._render)
self._render()
def refresh(self, signals: list[dict[str, Any]] | None) -> None:
self.signals = signals
self._render()
def _render(self) -> None:
selected = self.items.currentItem()
selected_id = selected.data(Qt.ItemDataRole.UserRole) if selected is not None else None
self.items.clear()
if self.signals is None:
self.items.addItem("Start a Core session to view signals")
return
query = self.search.text().strip().casefold()
for signal in self.signals:
if query and query not in (signal["name"] + " " + signal["id"]).casefold():
continue
unit = f" {signal['unit']}" if signal["unit"] else ""
current = f"{signal['value']:.6g}{unit}" if signal["value"] is not None else "No current value"
item = QListWidgetItem(f"{signal['name']} — {current}")
item.setData(Qt.ItemDataRole.UserRole, signal["id"])
item.setToolTip(f"{signal['id']} · {signal['type']}")
self.items.addItem(item)
if signal["id"] == selected_id:
self.items.setCurrentItem(item)
if not self.items.count():
self.items.addItem("No matching signals" if query else "No signals available in this mode")
class ArcGauge(QWidget):
"""Read-only gauge with a bounded value fraction in [0, 1]."""
TRACK = QColor("#3a3d42")
ARC = QColor("#b5bec9")
TEXT = QColor("#dedfe1")
MUTED = QColor("#a0a3a8")
def __init__(self) -> None:
super().__init__()
self.fraction: float | None = None
self.minimum: float | None = None
self.maximum: float | None = None
self.value_text = ""
self.unit = ""
self.decimals = 2
self.setMinimumSize(120, 110)
def set_fraction(self, fraction: float | None) -> None:
self.fraction = fraction
self.update()
def set_reading(self, value: float | None, unit: str, minimum: float | None,
maximum: float | None, decimals: int) -> None:
self.minimum, self.maximum = minimum, maximum
self.unit, self.decimals = unit, decimals
self.value_text = "—" if value is None else f"{value:.{decimals}f}"
if value is None or minimum is None or maximum is None or maximum <= minimum:
self.set_fraction(None)
else:
self.set_fraction(min(1.0, max(0.0, (value - minimum) / (maximum - minimum))))
def paintEvent(self, _event: Any) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
diameter = max(1, min(self.width() - 20, self.height() - 28))
circle_x = (self.width() - diameter) / 2
circle_y = max(4, (self.height() - diameter - 18) / 2)
rect = QRectF(circle_x, circle_y, diameter, diameter)
pen = QPen(self.TRACK, max(3, min(5, self.width() / 70)), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
path = QPainterPath()
path.arcMoveTo(rect, 225)
path.arcTo(rect, 225, -270)
painter.drawPath(path)
if self.fraction is not None:
pen.setColor(self.ARC)
painter.setPen(pen)
path = QPainterPath()
path.arcMoveTo(rect, 225)
path.arcTo(rect, 225, -270 * self.fraction)
painter.drawPath(path)
center = rect.center()
radius = diameter / 2
start_point = (center.x() - radius * .7071, center.y() + radius * .7071)
end_point = (center.x() + radius * .7071, center.y() + radius * .7071)
label_width = min(78, diameter * .42)
label_height = 18
label_font = QFont(painter.font())
label_font.setPointSize(max(7, min(10, int(diameter / 28))))
for label, point, alignment in (
("" if self.minimum is None else f"{self.minimum:g}", start_point, Qt.AlignmentFlag.AlignCenter),
("" if self.maximum is None else f"{self.maximum:g}", end_point, Qt.AlignmentFlag.AlignCenter),
):
while label_font.pointSize() > 7 and QFontMetrics(label_font).horizontalAdvance(label) > label_width:
label_font.setPointSize(label_font.pointSize() - 1)
painter.setFont(label_font)
painter.setPen(self.MUTED)
label_rect = QRectF(point[0] - label_width / 2, point[1] + 2, label_width, label_height)
painter.drawText(label_rect, alignment, label)
inner_width = diameter * .76
value_font = QFont(painter.font())
value_font.setPointSize(max(10, min(38, int(diameter * .18))))
value_font.setBold(True)
while value_font.pointSize() > 10 and QFontMetrics(value_font).horizontalAdvance(self.value_text) > inner_width:
value_font.setPointSize(value_font.pointSize() - 1)
unit_font = QFont(label_font)
unit_font.setPointSize(max(7, min(12, int(diameter * .055))))
unit_width = diameter * .62
while self.unit and unit_font.pointSize() > 7 and QFontMetrics(unit_font).horizontalAdvance(self.unit) > unit_width:
unit_font.setPointSize(unit_font.pointSize() - 1)
value_height = QFontMetrics(value_font).height()
unit_height = QFontMetrics(unit_font).height() if self.unit else 0
stack_height = value_height + unit_height + (2 if self.unit else 0)
value_top = center.y() - stack_height / 2
value_rect = QRectF(center.x() - inner_width / 2, value_top, inner_width, value_height)
painter.setFont(value_font)
painter.setPen(self.TEXT)
painter.drawText(value_rect, Qt.AlignmentFlag.AlignCenter, self.value_text)
if self.unit:
painter.setFont(unit_font)
painter.setPen(self.MUTED)
unit_rect = QRectF(center.x() - unit_width / 2, value_rect.bottom() + 2, unit_width, unit_height)
painter.drawText(unit_rect, Qt.AlignmentFlag.AlignCenter, self.unit)
class SignalChoice(QComboBox):
changed = Signal(object)
def __init__(self, signal_id: str | None) -> None:
super().__init__()
self.signal_id = signal_id
self._signature: tuple[Any, ...] | None = None
self.currentIndexChanged.connect(self._emit_change)
def set_signals(self, signals: list[dict[str, Any]]) -> None:
signature = (self.signal_id, tuple((s["id"], s["name"], s["unit"]) for s in signals))
if signature == self._signature:
return
self._signature = signature
self.blockSignals(True)
self.clear()
self.addItem("Select a signal…", None)
available = {signal["id"] for signal in signals}
for signal in signals:
unit = f" ({signal['unit']})" if signal["unit"] else ""
self.addItem(f"{signal['name']}{unit}", signal["id"])
if self.signal_id and self.signal_id not in available:
self.addItem(f"Unavailable signal ({self.signal_id})", self.signal_id)
self.setCurrentIndex(max(0, self.findData(self.signal_id)))
self.blockSignals(False)
def _emit_change(self, index: int) -> None:
self.signal_id = self.itemData(index)
self.changed.emit(self.signal_id)
class NumericPanel(QWidget):
configChanged = Signal(dict)
removeRequested = Signal()
def __init__(self, config: dict[str, Any]) -> None:
super().__init__()
self.decimals = max(0, min(8, int(config.get("decimals", 2))))
self._signals: list[dict[str, Any]] = []
self.choice = SignalChoice(config.get("signal_id"))
self.settings_menu = _settings_menu(self, "Readout settings")
self._add_common_settings()
precision = QWidget()
precision_layout = QHBoxLayout(precision)
precision_layout.setContentsMargins(12, 2, 12, 8)
precision_layout.addWidget(QLabel("Decimal places"))
self.precision = QSpinBox()
self.precision.setRange(0, 8)
self.precision.setValue(self.decimals)
self.precision.valueChanged.connect(self._precision_changed)
precision_layout.addWidget(self.precision)
_add_settings_widget(self.settings_menu, precision)
self.settings_menu.addSeparator()
self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
self.header = _PanelHeader("Select a signal", self.settings_menu)
self.value = QLabel("—")
self.value.setObjectName("readoutValue")
self.value.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.unit = QLabel("")
self.unit.setObjectName("muted")
self.unit.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.unit.setWordWrap(True)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
layout.addWidget(self.header)
layout.addStretch(1)
layout.addWidget(self.value)
layout.addWidget(self.unit)
layout.addStretch(1)
self.choice.changed.connect(self._changed)
def _add_common_settings(self) -> None:
choice_box = QWidget()
choice_layout = QVBoxLayout(choice_box)
choice_layout.setContentsMargins(12, 6, 12, 10)
choice_layout.addWidget(QLabel("Signal"))
choice_layout.addWidget(self.choice)
_add_settings_widget(self.settings_menu, choice_box)
def config(self) -> dict[str, Any]:
return {"signal_id": self.choice.signal_id, "decimals": self.decimals}
def _changed(self, signal_id: str | None) -> None:
self.refresh(self._signals)
self.configChanged.emit(self.config())
def _precision_changed(self, decimals: int) -> None:
self.decimals = decimals
self.configChanged.emit(self.config())
self.refresh(self._signals)
def refresh(self, signals: list[dict[str, Any]]) -> None:
self._signals = signals
self.choice.set_signals(signals)
selected = next((s for s in signals if s["id"] == self.choice.signal_id), None)
self.header.set_title(selected["name"] if selected else (f"Unavailable ({self.choice.signal_id})" if self.choice.signal_id else "Select a signal"))
if selected is None:
self.unit.clear()
self.value.setText("Unavailable" if self.choice.signal_id else "—")
elif selected["value"] is None:
self.unit.setText(selected["unit"] or "")
self.value.setText("No current value")
else:
value = selected["value"]
self.value.setText(f"{value:.{self.decimals}f}" if math.isfinite(value) else "—")
self.unit.setText(selected["unit"] or "")
self._fit_value_font()
def resizeEvent(self, event: Any) -> None:
super().resizeEvent(event)
self._fit_value_font()
def _fit_value_font(self) -> None:
available_width = max(40, self.value.width() - 16)
font = self.value.font()
font.setBold(True)
text = self.value.text()
point_size = max(10, min(48, int(self.width() * .14)))
while point_size > 10:
font.setPointSize(point_size)
if QFontMetrics(font).horizontalAdvance(text) <= available_width:
break
point_size -= 1
self.value.setFont(font)
unit_font = self.unit.font()
unit_font.setPointSize(max(8, min(13, int(self.width() * .055))))
self.unit.setFont(unit_font)
class GaugePanel(QWidget):
configChanged = Signal(dict)
removeRequested = Signal()
def __init__(self, config: dict[str, Any]) -> None:
super().__init__()
self.decimals = max(0, min(8, int(config.get("decimals", 2))))
self._signals: list[dict[str, Any]] = []
self.choice = SignalChoice(config.get("signal_id"))
self.settings_menu = _settings_menu(self, "Gauge settings")
choice_box = QWidget()
choice_layout = QVBoxLayout(choice_box)
choice_layout.setContentsMargins(12, 6, 12, 10)
choice_layout.addWidget(QLabel("Signal"))
choice_layout.addWidget(self.choice)
_add_settings_widget(self.settings_menu, choice_box)
precision = QWidget()
precision_layout = QHBoxLayout(precision)
precision_layout.setContentsMargins(12, 2, 12, 8)
precision_layout.addWidget(QLabel("Decimal places"))
self.precision = QSpinBox()
self.precision.setRange(0, 8)
self.precision.setValue(self.decimals)
self.precision.valueChanged.connect(self._precision_changed)
precision_layout.addWidget(self.precision)
_add_settings_widget(self.settings_menu, precision)
self.settings_menu.addSeparator()
self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
self.header = _PanelHeader("Select a signal", self.settings_menu)
self.gauge = ArcGauge()
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.addWidget(self.header)
layout.addWidget(self.gauge, 1)
self.choice.changed.connect(self._changed)
def _changed(self, signal_id: str | None) -> None:
self.refresh(self._signals)
self.configChanged.emit(self.config())
def config(self) -> dict[str, Any]:
return {"signal_id": self.choice.signal_id, "decimals": self.decimals}
def _precision_changed(self, decimals: int) -> None:
self.decimals = decimals
self.configChanged.emit(self.config())
self.refresh(self._signals)
def refresh(self, signals: list[dict[str, Any]]) -> None:
self._signals = signals
self.choice.set_signals(signals)
selected = next((s for s in signals if s["id"] == self.choice.signal_id), None)
if selected is None:
self.header.set_title(f"Unavailable ({self.choice.signal_id})" if self.choice.signal_id else "Select a signal")
self.gauge.set_reading(None, "", None, None, self.decimals)
return
self.header.set_title(selected["name"])
minimum, maximum = selected["min_value"], selected["max_value"]
if minimum is not None and not math.isfinite(minimum):
minimum = None
if maximum is not None and not math.isfinite(maximum):
maximum = None
value = selected["value"]
if value is not None and not math.isfinite(value):
value = None
self.gauge.set_reading(value, selected["unit"] or "", minimum, maximum, self.decimals)
class GraphPanel(QWidget):
configChanged = Signal(dict)
removeRequested = Signal()
COLORS = ("#88a8c9", "#d2aa7d", "#94b39b", "#b29bc6", "#ce9292", "#a0c0c2")
def __init__(self, config: dict[str, Any]) -> None:
super().__init__()
self.signal_ids = list(config.get("signal_ids", []))
self.follow_latest = bool(config.get("follow_latest", True))
self.zero_baseline = bool(config.get("zero_baseline", False))
self.right_zero_baseline = bool(config.get("right_zero_baseline", False))
self.descriptors: dict[str, dict[str, Any]] = {}
self.signal_signature: tuple[Any, ...] = ()
self.curves: dict[str, pg.PlotDataItem] = {}
self.right_view: pg.ViewBox | None = None
self._last_snapshot: dict[str, Any] | None = None
self._baseline_auto_ranges = {"left": True, "right": True}
self._axis_signal_ids: dict[str, list[str]] = {"left": [], "right": []}
self.legend: QWidget | None = None
self.plot = pg.PlotWidget()
self.plot.setBackground("#202225")
self.plot_item = self.plot.getPlotItem()
self.playback_position_ns: int | None = None
self.playback_cursor = pg.InfiniteLine(
angle=90,
movable=False,
pen=pg.mkPen("#a0a3a8", width=1, style=Qt.PenStyle.DashLine),
)
self.playback_cursor_label = pg.TextItem(
color="#dedfe1", anchor=(0.0, 0.0), fill=(32, 34, 37, 230)
)
self.playback_cursor_label.setFont(QFont("Sans Serif", 8))
self.playback_cursor.setZValue(40)
self.playback_cursor_label.setZValue(50)
self.plot_item.addItem(self.playback_cursor, ignoreBounds=True)
self.plot_item.addItem(self.playback_cursor_label, ignoreBounds=True)
self.playback_cursor.hide()
self.playback_cursor_label.hide()
self.plot_item.vb.sigRangeChanged.connect(lambda *_: self._sync_playback_cursor())
self.plot_item.vb.sigRangeChangedManually.connect(
lambda *_: self._mark_axis_manual("left")
)
self.plot_item.vb.setMouseMode(pg.ViewBox.RectMode)
self.plot_item.setLabel("bottom", "Elapsed time", units="s")
self.plot_item.showGrid(x=True, y=True, alpha=0.25)
self._style_axes()
self.settings_menu = _settings_menu(self, "Graph settings")
self.selection = QListWidget()
self.selection.setMinimumWidth(280)
self.selection.setMaximumHeight(240)
self.selection.itemChanged.connect(self._selection_changed)
self.follow = QCheckBox("Follow latest")
self.follow.setChecked(self.follow_latest)
self.follow.toggled.connect(self._follow_changed)
self.fit_button = QPushButton("Reset to fit")
self.fit_button.clicked.connect(self._fit)
self.zero_baseline_check = QCheckBox("Left axis starts at zero")
self.zero_baseline_check.setChecked(self.zero_baseline)
self.zero_baseline_check.toggled.connect(self._zero_baseline_changed)
self.right_zero_baseline_check = QCheckBox("Right axis starts at zero")
self.right_zero_baseline_check.setChecked(self.right_zero_baseline)
self.right_zero_baseline_check.toggled.connect(self._right_zero_baseline_changed)
selection_box = QWidget()
selection_layout = QVBoxLayout(selection_box)
selection_layout.setContentsMargins(12, 6, 12, 10)
selection_layout.addWidget(QLabel("Signals"))
selection_layout.addWidget(self.selection)
selection_layout.addSpacing(6)
selection_layout.addWidget(self.follow)
selection_layout.addWidget(self.fit_button)
selection_layout.addWidget(self.zero_baseline_check)
selection_layout.addWidget(self.right_zero_baseline_check)
explanation = QLabel("One signal uses its native scale. Two signals use independent axes. Three or more are normalized to 0–1 using descriptor bounds when available, otherwise the visible data range.")
explanation.setObjectName("muted")
explanation.setWordWrap(True)
selection_layout.addWidget(explanation)
_add_settings_widget(self.settings_menu, selection_box)
self.settings_menu.addSeparator()
self.settings_menu.addAction("Remove panel", self.removeRequested.emit)
self.header = _PanelHeader("", self.settings_menu)
self.status = QLabel("Select signals to plot")
self.status.setObjectName("muted")
self.status.setWordWrap(True)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
top_row = QWidget()
top_row_layout = QHBoxLayout(top_row)
top_row_layout.setContentsMargins(0, 0, 0, 0)
top_row_layout.setSpacing(0)
self.legend = QWidget()
self.legend_layout = QGridLayout(self.legend)
self.legend_layout.setContentsMargins(8, 2, 8, 2)
self.legend_layout.setHorizontalSpacing(10)
self.legend_layout.setVerticalSpacing(1)
top_row_layout.addWidget(self.legend, 1)
top_row_layout.addWidget(self.header)
layout.addWidget(top_row)
layout.addWidget(self.plot, 1)
layout.addWidget(self.status)
self.plot_item.vb.sigResized.connect(self._update_right_view)
def _style_axes(self) -> None:
for axis_name in ("bottom", "left", "right"):
axis = self.plot_item.getAxis(axis_name)
axis.setPen("#a0a3a8")
axis.setTextPen("#a0a3a8")
axis.enableAutoSIPrefix(False)
@property
def separate_axes(self) -> bool:
"""Whether the current selection uses the automatic dual-axis mode."""
return len([i for i in self.signal_ids if i in self.descriptors]) == 2
def _config(self) -> dict[str, Any]:
return {
"signal_ids": list(self.signal_ids),
"follow_latest": self.follow_latest,
"zero_baseline": self.zero_baseline,
"right_zero_baseline": self.right_zero_baseline,
}
def config(self) -> dict[str, Any]:
return self._config()
def _emit_config(self) -> None:
self.configChanged.emit(self._config())
def _selection_changed(self, _item: QListWidgetItem) -> None:
self.signal_ids = [
self.selection.item(i).data(Qt.ItemDataRole.UserRole)
for i in range(self.selection.count())
if self.selection.item(i).checkState() == Qt.CheckState.Checked
]
self._emit_config()
self._rebuild_curves()
def _follow_changed(self, follow: bool) -> None:
self.follow_latest = follow
self._emit_config()
def _mark_axis_manual(self, side: str) -> None:
self.follow.setChecked(False)
# The two views share X: a manual zoom must stop either baseline
# from refitting the linked view on the next data refresh.
self._baseline_auto_ranges["left"] = False
self._baseline_auto_ranges["right"] = False
def _baseline_enabled(self, side: str) -> bool:
count = len(self.curves)
if count >= 3:
return False
if side == "left":
return self.zero_baseline
return count == 2 and self.right_zero_baseline
def _zero_baseline_changed(self, enabled: bool) -> None:
self.zero_baseline = enabled
self._emit_config()
self._baseline_auto_ranges["left"] = True
self._reapply_last_snapshot()
def _right_zero_baseline_changed(self, enabled: bool) -> None:
self.right_zero_baseline = enabled
self._emit_config()
self._baseline_auto_ranges["right"] = True
self._reapply_last_snapshot()
def _reapply_last_snapshot(self) -> None:
self._apply_baseline_limits()
if self._last_snapshot is not None:
self.update_plot(self._last_snapshot)
def _apply_baseline_limits(self) -> None:
normalized = len(self.curves) >= 3
for side, view in (("left", self.plot_item.vb), ("right", self.right_view)):
if view is None:
continue
if normalized and side == "left":
view.setLimits(yMin=0, yMax=1)
view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
view.setYRange(0, 1, padding=0)
continue
enabled = self._baseline_enabled(side)
view.setLimits(yMin=0 if enabled else None, yMax=None)
if enabled:
view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
self._fit_zero_baseline(side, view)
else:
view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=True)
def _fit_zero_baseline(self, side: str, view: pg.ViewBox) -> None:
values: list[float] = []
for signal_id in self._axis_signal_ids[side]:
curve = self.curves.get(signal_id)
if curve is None:
continue
_x, y = curve.getData()
if y is not None:
values.extend(float(value) for value in y if value is not None and math.isfinite(value))
positive_max = max((value for value in values if value > 0), default=0.0)
upper = positive_max * 1.05 if positive_max > 0 else 1.0
view.setYRange(0.0, upper, padding=0)
def _fit(self) -> None:
self.follow.setChecked(False)
self._baseline_auto_ranges["left"] = True
self._baseline_auto_ranges["right"] = True
self.plot_item.vb.autoRange()
if self.right_view is not None:
self.right_view.autoRange()
self._apply_baseline_limits()
def set_playback_position(self, position_ns: int | None) -> None:
"""Show the Core playback position as an elapsed-time playhead."""
if position_ns is None:
self.playback_position_ns = None
self._sync_playback_cursor()
return
try:
position_ns = max(0, int(position_ns))
except (TypeError, ValueError, OverflowError):
self.playback_position_ns = None
self._sync_playback_cursor()
return
time_range = self._last_snapshot.get("time_range") if self._last_snapshot else None
if time_range:
duration_ns = max(0, int(time_range[1]) - int(time_range[0]))
position_ns = min(position_ns, duration_ns)
self.playback_position_ns = position_ns
self._sync_playback_cursor()
def _sync_playback_cursor(self) -> None:
"""Keep the playhead above plot items without changing their bounds."""
if self.playback_cursor.scene() is not self.plot_item.scene():
self.plot_item.addItem(self.playback_cursor, ignoreBounds=True)
if self.playback_cursor_label.scene() is not self.plot_item.scene():
self.plot_item.addItem(self.playback_cursor_label, ignoreBounds=True)
snapshot = self._last_snapshot
time_range = snapshot.get("time_range") if snapshot else None
data = snapshot.get("data", {}) if snapshot else {}
has_plot_data = bool(time_range) and any(
data.get(signal_id) for signal_id in self.curves
)
visible = self.playback_position_ns is not None and has_plot_data
if not visible:
self.playback_cursor.hide()
self.playback_cursor_label.hide()
return
duration_ns = max(0, int(time_range[1]) - int(time_range[0]))
self.playback_position_ns = min(self.playback_position_ns, duration_ns)
position_seconds = self.playback_position_ns / 1_000_000_000
self.playback_cursor.setValue(position_seconds)
self.playback_cursor_label.setText(f"{position_seconds:.1f} s")
x_range, y_range = self.plot_item.vb.viewRange()
self.playback_cursor_label.setAnchor((1.0 if position_seconds > sum(x_range) / 2 else 0.0, 0.0))
y_span = y_range[1] - y_range[0]
self.playback_cursor_label.setPos(position_seconds, y_range[1] - y_span * 0.03)
self.playback_cursor.show()
self.playback_cursor_label.show()
def set_signals(self, signals: list[dict[str, Any]]) -> None:
self.descriptors = {signal["id"]: signal for signal in signals}
signature = tuple(
(s["id"], s["name"], s["unit"], s["type"], s["min_value"], s["max_value"])
for s in signals
) + (("selected", tuple(self.signal_ids)),)
if signature == self.signal_signature:
return
self.signal_signature = signature
self.selection.blockSignals(True)
self.selection.clear()
for signal in signals:
unit = f" ({signal['unit']})" if signal["unit"] else ""
item = QListWidgetItem(f"{signal['name']}{unit}")
item.setData(Qt.ItemDataRole.UserRole, signal["id"])
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(
Qt.CheckState.Checked if signal["id"] in self.signal_ids else Qt.CheckState.Unchecked
)
self.selection.addItem(item)
for signal_id in self.signal_ids:
if signal_id not in self.descriptors:
item = QListWidgetItem(f"Unavailable signal ({signal_id})")
item.setData(Qt.ItemDataRole.UserRole, signal_id)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
item.setCheckState(Qt.CheckState.Checked)
self.selection.addItem(item)
self.selection.blockSignals(False)
self._rebuild_curves()
def _rebuild_curves(self) -> None:
while self.legend_layout.count():
item = self.legend_layout.takeAt(0)
if item.widget() is not None:
item.widget().deleteLater()
if self.right_view is not None:
self.plot_item.scene().removeItem(self.right_view)
self.right_view = None
self.plot_item.clear()
self.plot_item.setLabel("bottom", "Elapsed time", units="s")
self.plot_item.setLabel("left", "")
self.plot_item.setLabel("right", "")
self.plot_item.showGrid(x=True, y=True, alpha=0.2)
self._style_axes()
self.curves.clear()
selected = [i for i in self.signal_ids if i in self.descriptors]
dual_axis = len(selected) == 2
normalized = len(selected) >= 3
self.zero_baseline_check.setEnabled(bool(selected) and not normalized)
self.right_zero_baseline_check.setEnabled(dual_axis)
self.status.setText("Select available signals to plot" if not selected else "No samples available for the selected signals")
if not selected:
self._axis_signal_ids = {"left": [], "right": []}
self.plot_item.hideAxis("right")
self._apply_baseline_limits()
self._sync_playback_cursor()
return
self.legend.setVisible(bool(selected))
if len(selected) == 1:
descriptor = self.descriptors[selected[0]]
self.plot_item.setLabel("left", descriptor["name"], units=descriptor["unit"] or "")
self.plot_item.hideAxis("right")
self.plot_item.setLabel("right", "")
elif len(selected) == 2:
left, right = (self.descriptors[i] for i in selected)
self.plot_item.setLabel("left", left["name"], units=left["unit"] or "")
self.plot_item.setLabel("right", right["name"], units=right["unit"] or "")
for axis_name, color in (("left", self.COLORS[0]), ("right", self.COLORS[1])):
axis = self.plot_item.getAxis(axis_name)
axis.setPen(color)
axis.setTextPen(color)
self.plot_item.showAxis("right")
self.right_view = pg.ViewBox()
self.right_view.setMouseMode(pg.ViewBox.RectMode)
self.right_view.enableAutoRange(axis=pg.ViewBox.YAxis, enable=False)
self.right_view.sigRangeChangedManually.connect(
lambda *_: self._mark_axis_manual("right")
)
self.plot_item.scene().addItem(self.right_view)
self.plot_item.getAxis("right").linkToView(self.right_view)
self.right_view.setXLink(self.plot_item.vb)
else:
self.plot_item.setLabel("left", "Normalized (0–1)")
self.plot_item.hideAxis("right")
self._axis_signal_ids = {
"left": selected[:1] if len(selected) == 2 else selected,
"right": selected[1:2] if len(selected) == 2 else [],
}
for index, signal_id in enumerate(selected):
descriptor = self.descriptors[signal_id]
unit = descriptor["unit"] or ""
label = descriptor["name"] + (f" ({unit})" if unit else "")
color = self.COLORS[index % len(self.COLORS)]
if len(selected) == 2 and index == 1:
curve = pg.PlotDataItem(pen=color)
self.right_view.addItem(curve)
else:
curve = self.plot_item.plot(pen=color)
self.curves[signal_id] = curve
legend_label = QLabel(f"● {label}")
legend_label.setWordWrap(True)
legend_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
legend_label.setStyleSheet(f"color: {color};")
legend_label.setToolTip(label)
row, column = divmod(index, 3)
self.legend_layout.addWidget(legend_label, row, column)
self.legend_layout.setColumnStretch(column, 1)
if len(selected) >= 3:
bounds = self._descriptor_bounds(descriptor)
curve.setToolTip(f"{label}: normalized using {bounds[0]:g} to {bounds[1]:g}" if bounds else f"{label}: normalized using visible data range")
self._update_right_view()
self._baseline_auto_ranges["left"] = True
self._baseline_auto_ranges["right"] = True
self._apply_baseline_limits()
if len(selected) != 2:
self.plot_item.hideAxis("right")
self._sync_playback_cursor()
@staticmethod
def _descriptor_bounds(descriptor: dict[str, Any]) -> tuple[float, float] | None:
low, high = descriptor.get("min_value"), descriptor.get("max_value")
if low is None or high is None:
return None
try:
low, high = float(low), float(high)
except (TypeError, ValueError):
return None
return (low, high) if math.isfinite(low) and math.isfinite(high) and high > low else None
@classmethod
def _normalize_series(cls, descriptor: dict[str, Any], values: list[float]) -> list[float]:
"""Map finite samples to [0, 1], retaining gaps as NaN."""
bounds = cls._descriptor_bounds(descriptor)
if bounds is None:
finite = [v for v in values if math.isfinite(v)]
bounds = (min(finite), max(finite)) if finite else None
if bounds is None:
return [math.nan for _ in values]
low, high = bounds
if high == low:
return [0.5 if math.isfinite(value) else math.nan for value in values]
return [min(1.0, max(0.0, (value - low) / (high - low))) if math.isfinite(value) else math.nan for value in values]
def _update_right_view(self) -> None:
if self.right_view is not None:
self.right_view.setGeometry(self.plot_item.vb.sceneBoundingRect())
self.right_view.linkedViewChanged(self.plot_item.vb, self.right_view.XAxis)
def update_plot(self, snapshot: dict[str, Any]) -> None:
self._last_snapshot = snapshot
time_range = snapshot["time_range"]
if not self.signal_ids or not time_range:
self.status.setText("No time range or selected signals available")
self.status.setVisible(True)
for curve in self.curves.values():
curve.setData([], [])
self._sync_playback_cursor()
return
origin, end = time_range
plotted = False
for signal_id, curve in self.curves.items():
samples = snapshot["data"].get(signal_id, [])
x = [(timestamp - origin) / 1_000_000_000 for timestamp, _ in samples]
y = []
for _, value in samples:
try:
y.append(float(value) if value is not None else math.nan)
except (TypeError, ValueError):
y.append(math.nan)
if len(self.curves) >= 3:
y = self._normalize_series(self.descriptors[signal_id], y)
curve.setData(x, y)
plotted |= bool(samples)
for side, view in (("left", self.plot_item.vb), ("right", self.right_view)):
if view is not None and self._baseline_enabled(side) and self._baseline_auto_ranges[side]:
view.setLimits(yMin=0)
self._fit_zero_baseline(side, view)
if self.follow_latest:
end_seconds = (end - origin) / 1_000_000_000
self.plot_item.setXRange(0, max(0.001, end_seconds), padding=0)
self.status.setText("No samples available for the selected signals" if not plotted else "")
self.status.setVisible(not plotted)
self._sync_playback_cursor()
+253
View File
@@ -0,0 +1,253 @@
from pathlib import Path
from typing import Any
from uuid import UUID
from PySide6.QtCore import QTimer, Signal
from PySide6.QtWidgets import (
QFileDialog,
QHBoxLayout,
QLabel,
QListWidget,
QProgressBar,
QPushButton,
QVBoxLayout,
QWidget,
)
class SessionPanel(QWidget):
"""Explicit recording controls and Core-managed recording archive save."""
busyChanged = Signal(bool)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._adapter: Any | None = None
self._mode = "realtime"
self._session_usable = False
self._core_state = "stopped"
self._recording = False
self._recording_available = False
self._busy = False
self._operation_id: str | None = None
self._output_path: Path | None = None
self.status = QLabel("Start a Core session to control recording")
self.status.setObjectName("muted")
self.status.setWordWrap(True)
self.operation = QLabel("")
self.operation.setObjectName("muted")
self.operation.setWordWrap(True)
self.workers = QLabel("Workers: no session")
self.workers.setObjectName("muted")
self.workers.setWordWrap(True)
self.connectors = QListWidget()
self.connectors.setWordWrap(True)
self.connectors.addItem("Start a Core session to view connectors")
self.start_button = QPushButton("Start recording")
self.stop_button = QPushButton("Stop recording")
self.save_button = QPushButton("Save recording…")
self.start_button.clicked.connect(self._start_recording)
self.stop_button.clicked.connect(self._stop_recording)
self.save_button.clicked.connect(self._choose_save_path)
recording_buttons = QHBoxLayout()
recording_buttons.addWidget(self.start_button)
recording_buttons.addWidget(self.stop_button)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(0)
self.progress.hide()
layout = QVBoxLayout(self)
layout.addWidget(self.status)
layout.addWidget(QLabel("Connector status"))
layout.addWidget(self.connectors, 1)
layout.addWidget(self.workers)
layout.addLayout(recording_buttons)
layout.addWidget(self.save_button)
layout.addWidget(self.operation)
layout.addWidget(self.progress)
self._operation_timer = QTimer(self)
self._operation_timer.setInterval(300)
self._operation_timer.timeout.connect(self._poll_operation)
self._update_controls()
@property
def recording(self) -> bool:
return self._recording
@property
def busy(self) -> bool:
return self._busy
def set_adapter(self, adapter: Any | None) -> None:
if adapter is self._adapter:
return
self._operation_timer.stop()
self._adapter = adapter
self._mode = "realtime"
self._core_state = "stopped"
self._recording = False
self._recording_available = False
self._operation_id = None
self._output_path = None
self.progress.hide()
self.operation.clear()
self._set_busy(False)
self._update_controls()
def refresh(self, snapshot: dict[str, Any] | None) -> None:
self._session_usable = bool(snapshot and snapshot.get("session_usable", snapshot.get("state") == "started"))
if snapshot is None:
self.workers.setText("Workers: no session")
self._core_state = "stopped"
self._recording = False
self.status.setText("Start a Core session to control recording")
self._update_controls()
return
workers = snapshot.get("workers", [])
failures = [worker for worker in workers if worker["state"] in {"failed", "blocked"}]
self.workers.setText(
"Worker attention: " + "; ".join(f"{w['name']} · {w['state']} · {w.get('error') or 'no details'}" for w in failures)
if failures else f"Workers: {len(workers)} · no failures reported"
)
self.workers.setToolTip("\n".join(
f"{w['name']}: {w['state']} · queue {w.get('queue_size', 0)}/{w.get('queue_capacity', 0)} · dropped {w.get('dropped_samples', 0)}"
for w in workers
))
previous_recording = self._recording
self._mode = snapshot.get("mode", "")
self._core_state = snapshot.get("state", "")
self._recording = bool(snapshot.get("recording", False))
if "recording_available" in snapshot:
self._recording_available = bool(snapshot["recording_available"])
elif previous_recording and not self._recording:
self._recording_available = True
if self._core_state != "started":
message = f"Core session state: {self._core_state}"
elif self._mode != "realtime":
message = f"Recording is available in Realtime mode · current mode: {self._mode}"
elif self._recording:
message = "Recording in progress"
elif self._recording_available:
message = "Recording stopped and ready to save"
else:
message = "Idle · no completed recording to save"
self.status.setText(message)
self._update_controls()
def _set_busy(self, busy: bool) -> None:
if self._busy == busy:
return
self._busy = busy
self.busyChanged.emit(busy)
self._update_controls()
def _update_controls(self) -> None:
running = self._adapter is not None and self._session_usable and self._core_state == "started"
realtime = self._mode == "realtime"
self.start_button.setEnabled(running and realtime and not self._recording and not self._busy)
self.stop_button.setEnabled(running and self._recording and not self._busy)
self.save_button.setEnabled(
running and self._recording_available and not self._recording and not self._busy
)
def _start_recording(self) -> None:
if self._adapter is None or self._busy or self._mode != "realtime":
return
self.operation.setText("Starting recording…")
self._set_busy(True)
self._adapter.run_async(self._adapter.start_recording, self._on_recording_started)
def _on_recording_started(self, _result: Any, error: Exception | None) -> None:
if error is None:
self._recording = True
self._recording_available = False
self.operation.setText("Recording started")
else:
self.operation.setText(f"Could not start recording: {error}")
self._set_busy(False)
self._update_controls()
def _stop_recording(self) -> None:
if self._adapter is None or self._busy or not self._recording:
return
self.operation.setText("Stopping recording…")
self._set_busy(True)
self._adapter.run_async(self._adapter.stop_recording, self._on_recording_stopped)
def _on_recording_stopped(self, _result: Any, error: Exception | None) -> None:
if error is None:
self._recording = False
self._recording_available = True
self.operation.setText("Recording stopped")
else:
self.operation.setText(f"Could not stop recording: {error}")
self._set_busy(False)
self._update_controls()
def _choose_save_path(self) -> None:
if self._adapter is None or self._busy or not self._recording_available:
return
path, _ = QFileDialog.getSaveFileName(
self,
"Save recording",
"recording.dlpak",
"DynaLab archive (*.dlpak)",
)
if not path:
return
target = Path(path)
if target.suffix.lower() != ".dlpak":
target = target.with_name(target.name + ".dlpak")
self._output_path = target
self.operation.setText(f"Preparing {target.name}…")
self.progress.setValue(0)
self.progress.show()
self._set_busy(True)
adapter = self._adapter
adapter.run_async(lambda: adapter.write_archive(target, source="recording"), self._on_write_started)
def _on_write_started(self, operation_id: UUID | str | None, error: Exception | None) -> None:
if error is not None:
self._finish_write(f"Could not save recording: {error}")
return
if operation_id is None:
self._finish_write("Could not save recording: Core returned no operation ID")
return
self._operation_id = str(operation_id)
self.operation.setText("Saving recording…")
self._poll_operation()
if self._operation_id is not None:
self._operation_timer.start()
def _poll_operation(self) -> None:
if self._adapter is None or self._operation_id is None:
return
try:
status = self._adapter.operation_status(self._operation_id)
except Exception as error:
self._finish_write(f"Could not read save status: {error}")
return
if status is None:
self._finish_write("Could not read save status: operation is unavailable")
return
total = status.get("total", 0)
completed = status.get("completed", 0)
if total:
self.progress.setValue(round(100 * completed / total))
message = status.get("message") or "Saving recording…"
self.operation.setText(message)
if status.get("state") == "completed":
self._finish_write(f"Saved recording to {self._output_path}")
elif status.get("state") == "failed":
detail = status.get("error_message") or status.get("error_code") or "unknown error"
self._finish_write(f"Could not save recording: {detail}")
def _finish_write(self, message: str) -> None:
self._operation_timer.stop()
self._operation_id = None
self.operation.setText(message)
self.progress.hide()
self._set_busy(False)
self._update_controls()
+94
View File
@@ -0,0 +1,94 @@
"""Quiet neutral palette and compact desktop controls."""
from pathlib import Path
from PySide6.QtGui import QColor, QFont, QPalette
from PySide6.QtWidgets import QApplication
def apply_theme(app: QApplication) -> None:
app.setStyle("Fusion")
palette = QPalette()
for role, color in {
QPalette.ColorRole.Window: "#181a1d",
QPalette.ColorRole.WindowText: "#dedfe1",
QPalette.ColorRole.Base: "#202225",
QPalette.ColorRole.AlternateBase: "#26292d",
QPalette.ColorRole.Text: "#dedfe1",
QPalette.ColorRole.Button: "#2b2e33",
QPalette.ColorRole.ButtonText: "#dedfe1",
QPalette.ColorRole.Highlight: "#484e57",
QPalette.ColorRole.HighlightedText: "#ffffff",
QPalette.ColorRole.ToolTipBase: "#292c31",
QPalette.ColorRole.ToolTipText: "#dedfe1",
QPalette.ColorRole.Link: "#bcc5d0",
}.items():
palette.setColor(role, QColor(color))
for role in (QPalette.ColorRole.Text, QPalette.ColorRole.ButtonText):
palette.setColor(QPalette.ColorGroup.Disabled, role, QColor("#70747c"))
app.setPalette(palette)
app.setFont(QFont("Sans Serif", 10))
app.setStyleSheet("""
QWidget { color: #dedfe1; }
QMainWindow, QDialog { background: #181a1d; }
QToolBar { background: #202225; border: 0; spacing: 8px; padding: 6px 8px; }
QMenuBar { background: #181a1d; padding: 0 6px; }
QMenuBar::item { padding: 5px 10px; }
QMenuBar::item:selected, QMenu::item:selected { background: #34383e; }
QMenu { background: #26292d; border: 1px solid #3b3f46; padding: 4px; }
QMenu::item { padding: 5px 20px; }
QDockWidget { border: 0; font-weight: 400; }
QDockWidget::title { padding: 0; margin: 0; border: 0; }
QDockWidget > QWidget, QWidget#panelBody { background: #202225; }
QLabel { background: transparent; }
QLabel#brand { font-size: 14px; font-weight: 600; padding-right: 8px; }
QLabel#eyebrow { color: #a0a3a8; font-size: 10px; letter-spacing: 1px; }
QLabel#heroTitle { font-size: 30px; font-weight: 500; }
QLabel#muted, QLabel#sessionStatus { color: #a0a3a8; }
QLabel#readoutValue { color: #dedfe1; }
QLabel#sessionStatus { padding: 2px; }
QPushButton { background: #2b2e33; border: 1px solid #41454d; border-radius: 3px;
padding: 5px 9px; font-weight: 400; }
QPushButton:hover { background: #34383e; border-color: #696f79; }
QPushButton:pressed { background: #24272b; }
QPushButton:disabled { color: #70747c; background: #222428; border-color: #303339; }
QPushButton#primary { background: #41474f; border-color: #555d68; }
QPushButton#primary:hover { background: #4d545e; }
QPushButton#primary:disabled { background: #292c31; color: #70747c; border-color: #303339; }
QToolButton { border: 0; border-radius: 2px; padding: 2px; }
QToolButton:hover, QToolButton:checked { background: #34383e; }
QToolButton#panelSettingsButton { background: transparent; padding: 2px;
min-width: 20px; min-height: 20px; }
QToolButton#panelSettingsButton:hover, QToolButton#panelSettingsButton:checked { background: #34383e; }
QToolButton#panelSettingsButton::menu-indicator { image: none; width: 0; }
QLabel#panelHeaderTitle { color: #aeb2b9; font-size: 12px; font-weight: 400; }
QLabel#panelSettingsTitle { color: #dedfe1; font-weight: 500; }
QLineEdit, QSpinBox, QComboBox, QPlainTextEdit, QTextEdit {
background: #181a1d; border: 1px solid #3d424a; border-radius: 3px;
padding: 4px 6px; selection-background-color: #484e57; }
QLineEdit:focus, QSpinBox:focus, QComboBox:focus, QPlainTextEdit:focus { border-color: #8c939e; }
QComboBox QAbstractItemView { background: #26292d; selection-background-color: #484e57; }
QListWidget, QTreeWidget, QTableWidget { background: #202225; border: 0;
border-radius: 0; padding: 2px; outline: none; }
QListWidget::item { padding: 5px; }
QListWidget::item:selected { background: #383e46; }
QListWidget::item:hover { background: #2a2e34; }
QTabWidget::pane { border: 0; background: #202225; }
QTabBar::tab { background: #202225; color: #969ba4; padding: 4px 10px;
border: 0; border-bottom: 1px solid transparent; }
QTabBar::tab:selected { color: #dedfe1; border-bottom-color: #a2aab6; background: #292d33; }
QCheckBox { spacing: 5px; }
QCheckBox::indicator { width: 13px; height: 13px; }
QStatusBar { background: #202225; color: #969ba4; border: 0; }
QStatusBar::item { border: 0; }
QScrollBar:vertical { background: #181a1d; width: 8px; }
QScrollBar:horizontal { background: #181a1d; height: 8px; }
QScrollBar::handle { background: #454b54; min-height: 20px; min-width: 20px; border-radius: 2px; }
QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
""")
arrow = (Path(__file__).parent / "icons" / "chevron.svg").as_posix()
app.setStyleSheet(app.styleSheet() + """
QComboBox { padding-right: 24px; }
QComboBox::drop-down { subcontrol-origin: padding; subcontrol-position: top right;
width: 20px; border: 0; }
QComboBox::down-arrow { image: url(ARROW_PATH); width: 10px; height: 7px; }
""".replace("ARROW_PATH", arrow))
+205
View File
@@ -0,0 +1,205 @@
"""Small versioned workspace persistence and validation."""
import base64
import json
from pathlib import Path
from tempfile import NamedTemporaryFile
from typing import Any
VERSION = 6
SYSTEM_PANELS = [
{"id": "dashboard", "title": "Dashboard", "type": "dashboard", "config": {}},
{"id": "data", "title": "Data / playback", "type": "data", "config": {}},
{"id": "signals", "title": "Signal browser", "type": "signal_browser", "config": {}},
{"id": "derived", "title": "Derived units", "type": "derive_manager", "config": {}},
]
VISUALIZATION_TYPES = {"numeric", "gauge", "graph", "numeric_group", "gauge_group"}
SYSTEM_TYPES = {panel["type"] for panel in SYSTEM_PANELS}
SUPPORTED_PANEL_TYPES = SYSTEM_TYPES | VISUALIZATION_TYPES
_SYSTEM_TYPES_BY_ID = {panel["id"]: panel["type"] for panel in SYSTEM_PANELS}
_RESERVED_TYPES_BY_ID = {**_SYSTEM_TYPES_BY_ID, "numeric": "numeric", "gauge": "gauge", "graph": "graph"}
DEFAULT_PANELS = [
*SYSTEM_PANELS,
{"id": "numeric", "title": "Numeric readout", "type": "numeric", "config": {"signal_id": None, "decimals": 2}},
{"id": "gauge", "title": "Gauge", "type": "gauge", "config": {"signal_id": None, "decimals": 2}},
{
"id": "graph",
"title": "Time graph",
"type": "graph",
"config": {"signal_ids": [], "follow_latest": True, "zero_baseline": False, "right_zero_baseline": False},
},
]
class WorkspaceError(ValueError):
pass
def new_workspace() -> dict[str, Any]:
return {"version": VERSION, "panels": []}
def _validate_panels(panels: list[Any]) -> None:
panel_ids: set[str] = set()
for panel in panels:
if (
not isinstance(panel, dict)
or not all(isinstance(panel.get(key), str) and panel[key] for key in ("id", "title", "type"))
or not isinstance(panel.get("config", {}), dict)
or ("visible" in panel and not isinstance(panel["visible"], bool))
):
raise WorkspaceError("Workspace contains an invalid panel definition.")
panel_id = panel["id"]
panel_type = panel["type"]
if panel_id in panel_ids:
raise WorkspaceError("Workspace contains duplicate panel IDs.")
panel_ids.add(panel_id)
if panel_id in _RESERVED_TYPES_BY_ID:
if panel_type != _RESERVED_TYPES_BY_ID[panel_id]:
raise WorkspaceError(f"Reserved panel ID {panel_id!r} has the wrong type.")
elif panel_type not in VISUALIZATION_TYPES:
raise WorkspaceError(f"Unsupported panel type {panel_type!r}.")
config = panel.get("config", {})
if panel_type in {"numeric", "gauge"} and config.get("signal_id") is not None and not isinstance(
config["signal_id"], str
):
raise WorkspaceError("Panel signal selections must be IDs or null.")
if panel_type in {"numeric", "gauge"} and (
type(config.get("decimals", 2)) is not int or not 0 <= config.get("decimals", 2) <= 8
):
raise WorkspaceError("Display decimal precision must be between 0 and 8.")
if panel_type in {"numeric_group", "gauge_group"}:
kind = panel_type.removesuffix("_group")
items = config.get("items", [])
if config.get("kind", kind) != kind or not isinstance(items, list) or any(
not isinstance(item, dict)
or (item.get("signal_id") is not None and not isinstance(item["signal_id"], str))
or type(item.get("decimals", 2)) is not int or not 0 <= item.get("decimals", 2) <= 8
for item in items
):
raise WorkspaceError("Instrument group settings are invalid.")
if panel_type == "derive_manager":
units = config.get("units", [])
if not isinstance(units, list) or any(
not isinstance(unit, dict)
or not isinstance(unit.get("path"), str)
or (unit.get("unit_id") is not None and not isinstance(unit["unit_id"], str))
or not isinstance(unit.get("binding_intent", False), bool)
for unit in units
):
raise WorkspaceError("Derived-unit library settings are invalid.")
if panel_type == "graph" and (
not isinstance(config.get("signal_ids", []), list)
or not all(isinstance(signal_id, str) for signal_id in config.get("signal_ids", []))
or len(config.get("signal_ids", [])) != len(set(config.get("signal_ids", [])))
or not isinstance(config.get("follow_latest", True), bool)
or not isinstance(config.get("zero_baseline", False), bool)
or not isinstance(config.get("right_zero_baseline", False), bool)
):
raise WorkspaceError("Graph panel settings are invalid.")
def _validate_grid(layout: Any, panels: list[dict[str, Any]]) -> None:
def valid_rect(rect: Any) -> bool:
return (
isinstance(rect, list) and len(rect) == 4
and all(type(value) is int and 0 <= value <= 10000 for value in rect)
and rect[2] > 0 and rect[3] > 0
)
if not isinstance(layout, dict) or layout.get("version") != 1 or not isinstance(layout.get("panels"), dict):
raise WorkspaceError("Workspace grid layout is invalid.")
panel_types = {panel["id"]: panel["type"] for panel in panels}
if set(layout["panels"]) != set(panel_types):
raise WorkspaceError("Workspace grid does not match its panel list.")
for panel_id, item in layout["panels"].items():
if not isinstance(item, dict) or item.get("type") != panel_types[panel_id] or not valid_rect(item.get("rect")):
raise WorkspaceError("Workspace grid panel placement is invalid.")
groups = layout.get("groups", [])
if not isinstance(groups, list):
raise WorkspaceError("Workspace tab groups are invalid.")
grouped: set[str] = set()
group_ids: set[str] = set()
for group in groups:
if not isinstance(group, dict):
raise WorkspaceError("Workspace tab group is invalid.")
members = group.get("panels")
group_id = group.get("id")
if (
not isinstance(group_id, str) or not group_id or group_id in group_ids
or not isinstance(members, list) or len(members) < 2
or any(not isinstance(member, str) or member not in panel_types or member in grouped for member in members)
or len(set(members)) != len(members) or not valid_rect(group.get("rect"))
or (group.get("active") is not None and group["active"] not in members)
):
raise WorkspaceError("Workspace tab group is invalid.")
grouped.update(members)
group_ids.add(group_id)
floating = layout.get("floating", {})
if not isinstance(floating, dict) or any(
item not in panel_types or item in grouped for item in floating
):
raise WorkspaceError("Workspace floating panels are invalid.")
if any(
not isinstance(rect, list) or len(rect) != 4
or any(type(value) is not int for value in rect) or rect[2] <= 0 or rect[3] <= 0
for rect in floating.values()
):
raise WorkspaceError("Workspace floating geometry is invalid.")
hidden = layout.get("hidden", [])
if not isinstance(hidden, list) or any(not isinstance(item, str) or item not in panel_types for item in hidden):
raise WorkspaceError("Workspace hidden panels are invalid.")
def validate_workspace(data: dict[str, Any]) -> None:
"""Validate a current workspace before the window mutates its dock state."""
if not isinstance(data, dict) or data.get("version") != VERSION:
raise WorkspaceError(f"Unsupported workspace format (expected version {VERSION}).")
panels = data.get("panels")
if not isinstance(panels, list):
raise WorkspaceError("Workspace must contain a panel list.")
_validate_panels(panels)
if "grid_layout" in data:
_validate_grid(data["grid_layout"], panels)
for key in ("geometry",):
if key in data and data[key] is not None and not isinstance(data[key], str):
raise WorkspaceError(f"Workspace field {key!r} must be encoded text.")
if data.get(key):
try:
base64.b64decode(data[key], validate=True)
except ValueError as error:
raise WorkspaceError(f"Workspace field {key!r} is invalid.") from error
def load_workspace(path: str | Path) -> dict[str, Any]:
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise WorkspaceError(f"Could not open workspace: {error}") from error
validate_workspace(data)
return data
def save_workspace(path: str | Path, data: dict[str, Any]) -> None:
validate_workspace(data)
target = Path(path)
text = json.dumps(data, indent=2) + "\n"
temporary: Path | None = None
try:
with NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=target.parent,
prefix=f".{target.name}.",
suffix=".tmp",
delete=False,
) as stream:
temporary = Path(stream.name)
stream.write(text)
temporary.replace(target)
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
Generated
+388
View File
@@ -2,7 +2,395 @@ version = 1
revision = 5 revision = 5
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dynalab-core"
version = "0.2.0"
source = { git = "https://git.h3cx.dev/Exergie/dynalab-core.git?rev=v0.2.1#db5d509b49c80c362683affab95823c6d80059ab" }
dependencies = [
{ name = "psutil" },
{ name = "psutils" },
{ name = "pydantic" },
]
[[package]] [[package]]
name = "dynalab-py" name = "dynalab-py"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [
{ name = "dynalab-core" },
{ name = "numpy" },
{ name = "pyqtgraph" },
{ name = "pyside6" },
]
[package.metadata]
requires-dist = [
{ name = "dynalab-core", git = "https://git.h3cx.dev/Exergie/dynalab-core.git?rev=v0.2.1" },
{ name = "numpy", specifier = ">=2.5.4" },
{ name = "pyqtgraph", specifier = ">=0.14.0" },
{ name = "pyside6", specifier = ">=6.12.0" },
]
[[package]]
name = "numpy"
version = "2.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" },
{ url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" },
{ url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" },
{ url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" },
{ url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" },
{ url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" },
{ url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" },
{ url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" },
{ url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" },
{ url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" },
{ url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" },
{ url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" },
{ url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" },
{ url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" },
{ url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" },
{ url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" },
{ url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" },
{ url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" },
{ url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" },
{ url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" },
{ url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" },
{ url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" },
{ url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" },
{ url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" },
{ url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" },
{ url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" },
{ url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" },
{ url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" },
{ url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" },
{ url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" },
{ url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" },
{ url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" },
{ url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" },
{ url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" },
{ url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" },
{ url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" },
{ url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" },
{ url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" },
{ url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" },
{ url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" },
{ url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" },
{ url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" },
{ url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" },
{ url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" },
]
[[package]]
name = "psutil"
version = "7.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
{ url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
{ url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
{ url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
{ url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
{ url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
{ url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
{ url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
{ url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
{ url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
{ url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
{ url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
{ url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
{ url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
{ url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
]
[[package]]
name = "psutils"
version = "3.3.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "puremagic" },
{ name = "pypdf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a0/6b/735ab226ab8e2ee6b99182312c7346a2e4bd3e825cfdf127f22ce75e853f/psutils-3.3.17.tar.gz", hash = "sha256:3ff76cc9f9fb535ec553d1591de56d6dbaa0d9f4df86778f6a49731ebd764cdf", size = 1255066, upload-time = "2026-09-07T13:15:05.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/c6/84c07085ebc0130e1bc7f1957e10ad42ea53f2f65177e107a5c76323b78c/psutils-3.3.17-py3-none-any.whl", hash = "sha256:de0289f2eac5966c2481a02cfa6eed0dc0f8e67115356eb44dbab429610f5970", size = 55328, upload-time = "2026-09-07T13:15:02.882Z" },
]
[[package]]
name = "puremagic"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/74/ce5987ab9b8aec4ced06e2723ebb604205c9eb58abdad91453da93166380/puremagic-2.2.0.tar.gz", hash = "sha256:eb4bddf07c177c4b434554b92165b67449f5a51e152b976202d6254498810eef", size = 1189752, upload-time = "2026-04-08T01:39:55.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/81/314320aeffd88dadeac553ff9eb10f54507ab41deccd0c69f6221d254a0a/puremagic-2.2.0-py3-none-any.whl", hash = "sha256:c4f7ed7307f056c787199acfda839555921be1df13abba61e8e6db0c787ae1d0", size = 71971, upload-time = "2026-04-08T01:39:54.169Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
]
[[package]]
name = "pypdf"
version = "6.20.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/c1/da25a099164cf4b210d63b957c902ad687139f4b8c12c20aec7953a4a266/pypdf-6.20.1.tar.gz", hash = "sha256:28f5a9d2fdc2749264612d94e6a58de54c11d730d9f0cabf8ad34117c4942b45", size = 7075352, upload-time = "2026-10-12T16:14:24.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/f8/4cbd09988b4b158260b7e0df38bf16f19e998bf0e257a18661a8da04280e/pypdf-6.20.1-py3-none-any.whl", hash = "sha256:aa5a55ddcffdc5e5ab291d5decb23f6383f4e56f8e3263dc39af41fff03885ad", size = 402665, upload-time = "2026-10-12T16:14:22.556Z" },
]
[[package]]
name = "pyqtgraph"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/36/4c242f81fdcbfa4fb62a5645f6af79191f4097a0577bd5460c24f19cc4ef/pyqtgraph-0.14.0-py3-none-any.whl", hash = "sha256:7abb7c3e17362add64f8711b474dffac5e7b0e9245abdf992e9a44119b7aa4f5", size = 1924755, upload-time = "2025-11-16T19:43:22.251Z" },
]
[[package]]
name = "pyside6"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
{ name = "pyside6-essentials" },
{ name = "pyside6-pdf" },
{ name = "pyside6-webengine" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/db/0722bbb81c3515dd0847fd66a7fb4760b13ad59c89e40b27b362ab5624e2/pyside6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:efea4c2f2aa54403c45de825865a902a1960a1cc9d5aaa92d4ebb8f4c4cd0708", size = 577475, upload-time = "2026-10-08T09:11:15.222Z" },
{ url = "https://files.pythonhosted.org/packages/2f/53/db0cabed1e70d0bc4666b8cbc2611b72bff2f9d2c4813820182621a8c6ee/pyside6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d13ed4d0053cc20f3e4e0fdbf12790a9b7f958d7e22c0843411ecfa8c799e2a9", size = 577709, upload-time = "2026-10-08T09:11:17.345Z" },
{ url = "https://files.pythonhosted.org/packages/4d/8c/dfd4f339285d2e6c31067789b9b7dd2cee26c098ecc78ac7200fb04aa344/pyside6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8920e163f60493e18822d20f1e01c7c4bcf9c1c5cfbc68284788896f692594f8", size = 577708, upload-time = "2026-10-08T09:11:18.705Z" },
{ url = "https://files.pythonhosted.org/packages/a1/da/8e8514fc29ea916c3cd71a524480b77409e19ed12d709e8a5d73be089b81/pyside6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:31a8d2893f2f28ab483b1534dde0438f27af6e234593de30425cb4729052d8f8", size = 578958, upload-time = "2026-10-08T09:11:20.179Z" },
{ url = "https://files.pythonhosted.org/packages/5f/2e/3ebafaf7f4ccea6f6ea9d017f7fc7d9484b79e6b31d1cb92f5f5262055f0/pyside6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:22e2ec6407f32b61955a28f91ab55676e60017210285c28f0c381bfeed389414", size = 562743, upload-time = "2026-10-08T09:11:21.552Z" },
]
[[package]]
name = "pyside6-addons"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/16/c401d6455dbf49df37a8b142883e12d446abf51919c3e0ef5d9570e29160/pyside6_addons-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:1d9bcd294ce1c9829194c7d13b01f771a2254b86cb3462cc3399321d2797fdac", size = 90545550, upload-time = "2026-10-08T09:08:14.778Z" },
{ url = "https://files.pythonhosted.org/packages/ff/3e/0d6c5934de575286ac0699a701d4155048185035b8da0ee78d57a38bc7ef/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4ab38c0017f0453671a9313d3a53ca1c0e378924f54b07e596bcea94a9642391", size = 59192702, upload-time = "2026-10-08T09:08:21.13Z" },
{ url = "https://files.pythonhosted.org/packages/94/14/b6c5683614601448c3ea8f19613ca378db75158ba594b662a8a8041e3e5f/pyside6_addons-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:48cd0b639c06c9f79685f340ccae4319614b66d6efcdf06c2ce3836790d8c46e", size = 63646547, upload-time = "2026-10-08T09:08:26.921Z" },
{ url = "https://files.pythonhosted.org/packages/02/41/ba3d52fe2acd942ebf71eb02b3bdf0897f7f6f4ce8f52289cb50228e48d3/pyside6_addons-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:98d8d62010da6605344cee83e5c96fb846170a1f2d924523f10f5d92078b02b0", size = 40522402, upload-time = "2026-10-08T09:08:31.678Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/860ab764368cd59b1a09cb891a9a4519425395ad1f6c47d83fde908fe603/pyside6_addons-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:3c079cbaad2ad28bc5bfa92fd6bac4ba28d622b825825e3b4220f0682599b2db", size = 35940682, upload-time = "2026-10-08T09:08:35.424Z" },
]
[[package]]
name = "pyside6-essentials"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "shiboken6" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/90/d528f8dd3f9f20712bc719e10c698d27c998e1dd76ed3f33b6fb606d0972/pyside6_essentials-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:0867b709a724db28b161227c7d6ecb5ed10cc09226a53542417ac6dee1308239", size = 116002775, upload-time = "2026-10-08T09:09:54.453Z" },
{ url = "https://files.pythonhosted.org/packages/7b/3c/7597d8dcb36aa8315427635ec6ec9d850ffbad4ce78b9118ac854889f2d3/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:16f4b5e41daf49235ae048458243da9efe5f3d8040f9cc5aa454ce69893d3bd9", size = 84346433, upload-time = "2026-10-08T09:10:02.825Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4e/f4fd4c016719be832ec49d3c87940459e87fd666179a0a199a3998d07916/pyside6_essentials-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:08c5841063fc1df69b7fa1ee405f539e66576a83fc48004be3e2c5e35ccbfa69", size = 83810102, upload-time = "2026-10-08T09:10:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/03/61/9a7e32a37b224f68506785bfb0f288d516098ec71681782d72b695a6c95d/pyside6_essentials-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9a95102aa23c1f86516a30d5a9be37552d324eee7e567eb7508973f1218cb42", size = 82153795, upload-time = "2026-10-08T09:10:15.104Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/43afb3d0e7c25dd09f0eb7e44f1283dadb5fa8d8b517249fe3fde02e6702/pyside6_essentials-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:c76aa689989bf9bb9b45535e0a81c6733294df720b0eed1308fa30d2606b98cc", size = 62073456, upload-time = "2026-10-08T09:10:20.202Z" },
]
[[package]]
name = "pyside6-pdf"
version = "6.12.0.140"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-essentials" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/33/23949c3b48af4b96b618e0502b7313ac52a782615db8dceae5bc2c516831/pyside6_pdf-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:74287989dd24a82ac1604e27a2d4530a0ab71ff03cdf878c16fdbcf5144373fb", size = 8644803, upload-time = "2026-10-08T09:07:36.716Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f0/9b4f3cb0506641ea53210c7b635703f1c2c1e81c27074438105ff118f8e7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0c94370b6b9d65fbfe54f9d6d25df0677db0cdcfd0a842a63958a3603a42007c", size = 2843877, upload-time = "2026-10-08T09:07:39.208Z" },
{ url = "https://files.pythonhosted.org/packages/39/cb/4a8cf3edb3f1b2396935573c02255be75f5605dcc77e9e3a201a6d6f26d7/pyside6_pdf-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:2b63ee1f8ce3d3b2b648be2ebda483dd0fbca8d44c92a6c6f924033ac7fbde8c", size = 2837165, upload-time = "2026-10-08T09:07:40.756Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8a/3f5eab9eb9e6486f0334b100322bbd0aff432dd90ad7977a3910979a163f/pyside6_pdf-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:4971ba1a0ca15e7c29cfd808e9600aeb46b2305dad01456eb09fa3197166268e", size = 2983516, upload-time = "2026-10-08T09:07:42.312Z" },
{ url = "https://files.pythonhosted.org/packages/b2/87/5c29bdaf47bf0b0cee7f467e86ffeb07be5bf8aa2afd2ff6f0785d0515b4/pyside6_pdf-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:ce6e4d858df1eb4e2adcbd8ba212f3611ce60f9978881fe10d417f36013a6ff9", size = 2758117, upload-time = "2026-10-08T09:07:43.936Z" },
]
[[package]]
name = "pyside6-webengine"
version = "6.12.0.140"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyside6-addons" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/37/3b50067bdcba5c22e20a8d7a029c919850c6d94f8398175b330c3b8765ad/pyside6_webengine-6.12.0.140-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:9a1de2da360d1817c08e9400a2a2fc5234beb3022a22123e15355169e183074e", size = 249778242, upload-time = "2026-10-08T09:06:03.602Z" },
{ url = "https://files.pythonhosted.org/packages/ce/e0/cb8b68b962afc253709ed61555d0a4f297fd6786c0e77921d2e951504210/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c041c9921d7c91c9e51a2c027f434d05c3c59baa2c8f17b71dc8194e77791f06", size = 116741921, upload-time = "2026-10-08T09:06:13.408Z" },
{ url = "https://files.pythonhosted.org/packages/1b/34/75944ed126293868fcbcc589fa2254e3310016920e5d32d21c29be021524/pyside6_webengine-6.12.0.140-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:66a842ccb7f06fa7e357f6d4d63bc2739e3cbc1f93583f314e38c43024fe5f74", size = 114053128, upload-time = "2026-10-08T09:06:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/21/0d/260c7c286f2d4e8a49c099152a385414a872f022117f937824ca0a3f38e5/pyside6_webengine-6.12.0.140-cp310-abi3-win_amd64.whl", hash = "sha256:f9b61b1d457a591ee590a6b80515f25a3e61e2576a2a0a70566380f9d9de4ab0", size = 131697708, upload-time = "2026-10-08T09:06:29.606Z" },
{ url = "https://files.pythonhosted.org/packages/76/34/7a2d1d7b95c96adf173c77d1862c2587789f7afb8754430fcfc2c9e4e47a/pyside6_webengine-6.12.0.140-cp310-abi3-win_arm64.whl", hash = "sha256:44d1fac2a057a71b8f947fc5177a95fab8a0ea4e2fda068f223a00fd0193a8c7", size = 65723, upload-time = "2026-10-08T09:06:32.559Z" },
]
[[package]]
name = "shiboken6"
version = "6.12.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/45/11536fba5f50a57671eb14441c6098096f3e57074e54c9d7d66039a5831e/shiboken6-6.12.0-cp310-abi3-macosx_14_0_universal2.whl", hash = "sha256:47105e05baf57d35453e07d240bbe59cb4b8d9565377ea5134ff9fb37d4f5c02", size = 479653, upload-time = "2026-10-08T09:10:41.506Z" },
{ url = "https://files.pythonhosted.org/packages/49/c2/a04fa003b4360e99d8e3fc2829abacf37396ace63ea1cd616402209ec7c6/shiboken6-6.12.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bde565bb0890044b5c7f63808adddede5abc241e9395a2bfed61919884d6240", size = 279268, upload-time = "2026-10-08T09:10:42.982Z" },
{ url = "https://files.pythonhosted.org/packages/97/ca/a4415e6218e0dff764fe2b2d30db2fc8f499a08ace07634232ade3f73adc/shiboken6-6.12.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ff72a72b3277b418902d562b987b33a6f6818bed9fe40a49d634d09bc67f620e", size = 276562, upload-time = "2026-10-08T09:10:44.25Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b6/bee97e7a2edeab72aafabb46ee5adc939790a638d107860b4cad7a20e407/shiboken6-6.12.0-cp310-abi3-win_amd64.whl", hash = "sha256:a1906cb8116869178c64b6bab52623bbc8b4ceed7b30bfc3c9aa0f6e67f6438e", size = 1265476, upload-time = "2026-10-08T09:10:45.627Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d5/5894d35f8a470ca8acff0d1cd8f15b1c94cbfdfe0ce4a6ec0fcf10b95bd1/shiboken6-6.12.0-cp310-abi3-win_arm64.whl", hash = "sha256:da382e68f0815b31b6dc2bce11a465b148c4f42c37b91546a361de1576567d10", size = 1838851, upload-time = "2026-10-08T09:10:47.123Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]