DashPVA is a modular analysis and visualization platform for X-ray experiments at synchrotron beamlines. It connects to area detectors and EPICS process variables through PvaPy and distributes data through a pipeline of computation and display nodes.
src/dashpva/
cli.py # Click CLI entry point (command: DashPVA)
settings.py # Single source of truth for ALL runtime config
gui/
__init__.py # configure_app() loads theme.qss
theme.qss # ALL widget styles live here
theme_colors.py
viewer/
area_det/ # Area detector live viewer
scan_view.py # Scan monitor
hkl_3d_slice_window.py
phase_fitter.py
pyFAI_analysis.py
...
workflow/
workflow.py # PVA Workflow UI + Metadata Associator
utils/
hdf5_writer.py # All h5py write logic
hdf5_handler.py # Delegator — no h5py write logic of its own
pva_reader.py
ca_monitor_registry.py
...
consumers/ # pvapy HPC consumer processes
database/ # SQLAlchemy models + profile DB
scripts/ # One-off seed scripts
src/dashpva/settings.py is the single source of truth. It resolves constants from the active configuration source, selected by the Workflow dialog (settings.set_locator() + settings.reload()).
The database (DB profile) is the primary config source. TOML is secondary — supported for legacy configs and quick one-off use, but new features and defaults should be built around the DB profile model. When both sources can satisfy a need, prefer the DB path.
Rules:
- Read config as
settings.HKL,settings.ROI,settings.METADATA_CA,settings.TOML_FILE, etc. - Use
settings.ensure_path()when a file path is needed (handles TOML and DB → temp file). - If config is missing, show a
QMessageBox.warningdirecting the user to the Workflow dialog — never open aQFileDialogas a config fallback. - Build file paths from
settings.PROJECT_ROOT; do not recompute the root withPath(__file__).parent...chains in modules. HDF5_STRUCTURE(the NeXus schema constant) lives insettings.py, not in any handler.
import dashpva.settings as app_settings
path = app_settings.PROJECT_ROOT / "outputs" / "result.h5"
toml = app_settings.ensure_path()
channels = app_settings.METADATA_CAAll widget styling goes in src/dashpva/gui/theme.qss. Never call widget.setStyleSheet(...) in Python for cosmetic styles.
To target a widget, give it an object name or dynamic property:
widget.setObjectName("scanStatus") # → QLabel#scanStatus { ... }
widget.setProperty("highlight", True) # → QLabel[highlight="true"] { ... }The one documented exception is bayesian_viewer.py, which applies its own Catppuccin palette intentionally via inline setStyleSheet.
Input alignment. All input widgets (QSpinBox, QDoubleSpinBox, QLineEdit, editable combo boxes) must right-align their text — set alignment to Qt::AlignRight|Qt::AlignVCenter in the .ui (QSS cannot align a spin box's editor). Give numeric fields a fixed width too, so the row does not shift as the digit count changes.
All actual h5py write logic lives in utils/hdf5_writer.py. utils/hdf5_handler.py is a pure delegator — add no write logic there. Use settings.HDF5_STRUCTURE for the NeXus NX_class schema.
Motor positions are written with resolved axis labels (ETA, MU, CHI, PHI) only. Raw PV strings are never stored; PVs without a TOML axis-label mapping are skipped.
Window geometry and dock state are persisted across sessions using QSettings("DashPVA", "<ViewerName>"). Use a module-level factory to get the handle:
from PyQt5.QtCore import QSettings
def _settings() -> QSettings:
return QSettings("DashPVA", "Viewer")Read in __init__ (after uic.loadUi) and write in closeEvent:
# restore
s = _settings()
if geom := s.value("window_geom"):
self.restoreGeometry(geom)
# save
def closeEvent(self, event):
s = _settings()
s.setValue("window_geom", self.saveGeometry())
super().closeEvent(event)For dock layout, bump a module-level _DOCK_STATE_VERSION integer whenever the dock set changes — restoreState silently rejects mismatched versions.
Seed scripts — QSettings stores UI state only. Application-level defaults (paths, profile JSON blobs, DB sections) are seeded into dashpva.db via the scripts in src/dashpva/scripts/. If you need to add or change a default value in the database, edit the appropriate seed script there:
seed_settings_defaults_sql.py— app-level settings tree (paths, consumer names, APP_DATA sections)seed_profile_defaults_sql.py— per-profile JSON blob (__data__)
Both scripts are idempotent and safe to re-run.
All standalone viewer windows must inherit from BaseWindow (src/dashpva/viewer/core/base_window.py), not raw QMainWindow. BaseWindow provides logging, the status bar, File/Windows/Documentation menus, dock-toggle wiring, and update_status(message, level).
-
Module — create
src/dashpva/viewer/<name>/<name>.py(subpackage) orsrc/dashpva/viewer/<name>.pyfor simpler views. -
Class — inherit from
BaseWindowand pass the UI file name and a human-readable viewer name:from dashpva.viewer.core.base_window import BaseWindow class MyView(BaseWindow): def __init__(self): super().__init__(ui_file_name="my_view.ui", viewer_name="My View") # logger is already available as self.logger via BaseWindow self._setup_docks() self._connect_signals()
Use
QDialog(notBaseWindow) only for secondary modal/modeless panels that are not top-level application windows. -
UI file — place the Qt Designer
.uifile atsrc/dashpva/gui/<name>.ui.BaseWindowloads it automatically whenui_file_nameis passed. -
__main__block — every viewer module must be runnable directly:if __name__ == "__main__": import sys from PyQt5.QtWidgets import QApplication from dashpva.gui import configure_app app = QApplication(sys.argv) configure_app(app) window = MyView() window.show() sys.exit(app.exec_())
-
CLI command — register a
@cli.command()insrc/dashpva/cli.pythat launches the module as a subprocess:@cli.command() def myview(): """Launch My View — one-line description.""" click.echo("Running My View") exit_code = subprocess.run( [sys.executable, "-m", "dashpva.viewer.<name>.<name>"] ).returncode sys.exit(exit_code)
-
Settings — import
dashpva.settings as app_settingsfor any config. Never hardcode paths or PV names in the viewer module. -
Documentation —
BaseWindowalready provides a Documentation entry in the menu bar that opensDocumentationDialog(viewer/documentation/dialog.py). Do not add your own documentation action, menu, or dialog. To add docs for a viewer, drop the file at:src/dashpva/viewer/<name>/doc/README.md # Markdown (converted to HTML) src/dashpva/viewer/<name>/doc/index.html # preferred when presentThe dialog auto-discovers either file next to the viewer's module. For a non-standard location, set
self.doc_path = "<absolute or relative path>"on the viewer instance aftersuper().__init__(...).
This applies to every viewer (Workbench, Area Detector, HKL, Scan, …), not just the Workbench. Each viewer window is a thin coordinator, not a god object: it instantiates components, wires cross-cutting signals, and holds only genuinely shared state. Every dock and workspace owns the functionality specific to its concern; domain logic must live in the component, never as a blob inside the window (or the workspace).
Rules (follow these in all viewers):
- Docks own their panel. A dock computes and formats its own content.
Info2DDockowns 2D-info presentation (dims, current-frame details, pixel readout),Info3DDockowns 3D info (HKL/volume/slice),ROICalcDockowns ROI math, the Area Detector's waterfall/stats docks own their own analysis, etc. A change to "how 2D info is shown" touchesinfo_2d_dock.pyalone. - Workspaces own their viewer, not their docks.
Workspace2Downs the 2D image view, controls, playback, levels, hover;Workspace3Downs the 3D view. A workspace may notify a dock that data changed (coordination), but must not contain that dock's presentation/analysis logic. Keep dock-specific behavior out of the workspace. - Components reach shared state via
self.main_window.<x>(e.g.main_window.roi_manager,main_window.get_current_frame_data()) — they do not duplicate it or reach into another component's internals. - The window exposes thin forwards for its own data, so components need no edits when logic is relocated. Example:
WorkbenchWindowprovides read-only@propertyforwards (image_view,plot_item,frame_spinbox,current_2d_data) and passthrough methods (display_2d_data,clear_2d_plot,get_current_frame_data,start_playback,open_roi_2d_plot_dock) that delegate toself.tab_2d. This is the template: when a component moves out of the window, add a guarded forward rather than editing every caller.
When extending any viewer, put the code in the owning component. If it needs window-owned data, add/extend a forward — don't grow the window or push dock logic into a workspace.
All dockable panels must inherit from BaseDock (src/dashpva/viewer/core/docks/base_dock.py), not raw QDockWidget. BaseDock.setup() handles addDockWidget, sets a namespaced objectName for saveState/restoreState, and registers a toggle action under the Windows menu automatically.
Subclass pattern:
from PyQt5.QtCore import Qt
from dashpva.viewer.core.docks.base_dock import BaseDock
class MyDock(BaseDock):
def __init__(self, main_window, show: bool = True):
super().__init__(
title="My Panel",
main_window=main_window,
segment_name="analysis", # groups this dock under Windows > Analysis
dock_area=Qt.RightDockWidgetArea,
show=show,
)
self._build_content() # build widgets after super().__init__
def _build_content(self):
...
self.setWidget(my_widget)Instantiate and arrange in the viewer's __init__:
self.info_dock = InfoDock(main_window=self)
self.plot_dock = PlotDock(main_window=self)
self.detail_dock = DetailDock(main_window=self, show=False) # hidden by default
# layout: split vertically, then tab
self.splitDockWidget(self.info_dock, self.plot_dock, Qt.Vertical)
self.tabifyDockWidget(self.info_dock, self.detail_dock)
self.info_dock.raise_() # bring the default tab forwardKey rules:
- A dock owns the logic for its own concern (see Workbench architecture above). Compute/format the dock's content inside the dock, pulling shared data via
main_windowforwards — do not put dock-specific logic in the viewer window. segment_namegroups related docks under a Windows submenu (e.g."2d","3d","analysis"). Use a consistent name across docks that belong together.- Bump the module-level
_DOCK_STATE_VERSIONinteger in the viewer whenever the dock set changes —restoreStatesilently rejects mismatched versions. - Use
splitDockWidget/tabifyDockWidget/resizeDocksafter all docks are created to establish the initial layout. - Save and restore geometry/state via
QSettingsincloseEvent/__init__(see UI persistence section).
This project uses ruff. Before producing a commit message, run:
ruff check src/dashpva/Fix any issues first, then write the message.
One-line, prefix-tagged, lowercase. No body, no period, no AI-generated footer.
Format: <prefix>: <short description>
Prefixes: fix:, add:, feat:, remove:, rename:, change:, revert:, Refactor (capitalized, for large restructurings), ruff check: (lint-only commits).
Multiple unrelated changes → separate each prefix with a blank line:
remove: Fusion style from area_det main — was breaking macOS dark-mode palette
fix: theme.qss dock-header comment after Fusion drop
add: roi overlay color selector
remove: lines must include a brief why (— replaced by X, — superseded by Y). Other prefixes describe the change itself and need no extra justification.
Scope the message to staged changes only (git diff --cached).
Canonical release procedure — run every version bump in this order:
pyproject.toml→ bumpversion = "X.Y.Z".uv lock→ regenerates theuv.lockroot entry toX.Y.Z. Commitpyproject.toml+uv.locktogether.- PR → merge to
main. uv sync(orinstall.sh --update) so__version__reflects it locally.git tag vX.Y.Z <main-commit> && git push origin vX.Y.Z.gh release create vX.Y.Z --title vX.Y.Z --notes "…"(latest, non-draft) — this is what triggers the in-app "update available."- Verify: a
DashPVArun showsvX.Y.Z+ "Up to date";gh release listshows it as Latest.
- Change only what was asked. Do not bundle adjacent cleanups, refactors, or style fixes into a scoped request. Mention adjacent issues in chat as follow-up suggestions instead.
- No leftover scraps. After any change, remove every import, helper, constant, comment, dead branch, or orphaned file the change made obsolete.
grep -rnthe old symbol to confirm zero leftover references. - Minimal comments. Prefer no comment. If a comment is genuinely needed (hidden constraint, upstream bug workaround, non-obvious invariant), keep it to 1–2 lines maximum. Never write multi-line explanatory blocks alongside a fix.
- No inline config. All configuration constants belong in
settings.py; never hardcode paths, PV names, or thresholds in viewer or utility modules. - Commit messages in chat. When asked for a commit message, output it as text in the response. Do not run
git commitunless the user explicitly asks to commit. If you are working in your own fork, you may commit and push freely — this rule applies only when working directly in the upstream repo.