Metadata-Version: 2.4
Name: pytesdaqx-scope
Version: 0.1.0
Summary: Live pulse viewer and dIdV scope for pytesdaqx
Author: TESSERACT DAQ developers
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Physics
Requires-Python: <3.13,>=3.11
Description-Content-Type: text/markdown
Requires-Dist: pytesdaqx
Requires-Dist: numpy<2.0,>=1.26
Requires-Dist: scipy
Requires-Dist: matplotlib
Requires-Dist: qetpy
Requires-Dist: astropy
Requires-Dist: PySide6
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-qt; extra == "dev"
Requires-Dist: ruff; extra == "dev"

# pytesdaqx-scope

Live pulse viewer / dIdV scope for [pytesdaqx](../pytesdaqx), migrated and
refactored from the pulse viewer in `pytesdaq-obsolete`.

## A Qt styling gotcha worth knowing about

A "bare declaration" stylesheet - `widget.setStyleSheet(f"background-color:
{color};")` with no selector - silently breaks QComboBox popup styling for
*any* descendant combobox, anywhere in that widget's subtree, even if the
popup styling itself lives in a completely separate stylesheet (app-level or
on the combobox directly). Reproduced with a minimal repro outside this
codebase before fixing it here. Always use `theme.scoped_background(name,
color)` / `theme.scoped_text_color(name, color)` (with
`widget.setObjectName(name)` first) instead of a bare declaration - see
`gui/theme.py` and its usages in `main_window.py`/`display_panel.py`/
`tools_window.py`/`channel_panel.py` for the pattern.

## Status

Under active development. Current layout:

```
pytesdaqx_scope/
  analysis/       Qt-free analysis pipeline (normalization, PSD, running
                   average, pileup rejection, dIdV pole fitting)

                   normalize() requires norm_list whenever norm_type != NoNorm
                   was actually requested; it used to silently skip the
                   volts-to-amps division when norm_list was missing (e.g. a
                   failed board/file read) and fall straight through to the
                   display unit's scale factor - producing a plausible but
                   wrong number (raw volts read as if it were current,
                   inflated further by uAmps' 1e6) instead of an error.
                   Controller.process_frame() catches this (and any other
                   per-frame analysis error) and reports it via on_status
                   instead of letting it recur silently on every poll tick.

                   didv_fit()'s data_array_truncated/fit_array add the
                   trace's baseline back after qetpy's fit (which works on
                   an AC-only, baseline-subtracted trace internally) -
                   without this they were zero-centered, which only matched
                   the *live* running-average trace (never baseline-
                   subtracted) on the exact frame the fit ran on. Since the
                   fit overlay is kept and redrawn on later frames too (see
                   MainWindow._fit_overlay), the overlay would visibly drift
                   away from the live trace's true DC level afterward.

                   A completed dIdV fit now also pauses the display (like
                   the old GUI): ControlPanel becomes a 3-state Start/Stop/
                   Resume button (yellow while paused), the poll timer stops
                   so the frame holding the fit result stays on screen for
                   inspection/zooming, and clicking Resume drops the overlay
                   and continues - without re-arming the data source, since
                   pausing never told it to stop in the first place.
  file_board_reader.py  FileBoardReader: BoardReader backed by a recording's
                   stored per-channel settings (close_loop_norm, preamp_gain,
                   signal-gen, TES bias) instead of a live board read - the
                   file-replay equivalent of board_reader.py, wired into
                   launcher.py's --file path. Without this, Amps/uAmps
                   selection during file replay had no normalization source
                   at all (NullBoardReader raised, but normalize()'s old
                   silent-skip bug masked that as bad numbers rather than a
                   visible error).
  acquisition/     Data source adapters over pytesdaqx: LiveSource (NI-DAQ,
                   via DAQSession/StreamingAcquisitionLoop) and FileSource
                   (zarr/hdf5 replay, via StreamReader). Both produce a
                   common Frame (raw ADC codes + calibration coefficients).

                   read_frame() returning None is ambiguous by itself: for
                   LiveSource it means "nothing new yet, poll again"; for
                   FileSource it means "recording is over". FileSource
                   exposes is_exhausted to disambiguate - MainWindow checks
                   it after every poll and auto-stops with a clear status
                   message instead of silently going quiet forever (which
                   read exactly like a freeze). FileSource.start() rewinds
                   automatically only when already exhausted, so Stop-then-
                   Start still resumes mid-file rather than always
                   restarting from the top.

                   Both sources support an optional trace_length_ms knob
                   (ControlPanel's "Trace Length [ms]" field), but only where
                   it's physically meaningful: LiveSource.trace_length_supported()
                   is true only for a continuous-mode measurement, since a
                   finite/triggered one (dIdV, threshold) has a fixed record
                   shape - a PXI-locked dIdV trace needs an integer number of
                   signal-generator periods, which a plain "ms" knob could
                   violate. FileSource.trace_length_supported() is true only
                   for a native continuous Zarr stream (raw_shape_model ==
                   "channel_sample"); HDF5 recordings and finite/trace Zarr
                   streams have a fixed length baked into the file itself, so
                   there's nothing to adjust. When supported, FileSource reads
                   fixed-size partitions via read_partition() instead of
                   read_next(); LiveSource folds it into adc.read_block_duration
                   for the next start(). MainWindow re-checks support on every
                   measurement change and disables the control accordingly.
  gui/             PySide6 GUI shell: MainWindow composition root plus
                   ControlPanel (measurement select + start/stop),
                   ChannelPanel, DisplayPanel (waveform/PSD toggle,
                   unit/norm controls, matplotlib canvas, dIdV fit overlay),
                   ToolsPanel (running avg / LP filter / read-from-board /
                   opens ToolsWindow), and ToolsWindow (dIdV pole-model fit
                   + pileup-rejection cuts, in a separate top-level window
                   like the old design). Widgets only emit signals; all
                   wiring to the Controller lives in MainWindow.

                   ChannelPanel is a vertical list (color swatch + checkbox)
                   for the first 8 channels, with a "More Channels..."
                   button opening a scrollable picker dialog for the rest
                   (channel count isn't hardcoded to 24 - it's however many
                   channels are passed in). Long names are middle-elided
                   ("Mv6BandG...insRight", not right-elided, since detector
                   names often differ only in a suffix) with the full name
                   in a tooltip; a fixed-cell button grid (the old AI0-AI7
                   style) doesn't work once names run 20+ characters.
  controller.py    Qt-free orchestrator wiring a DataSource into the
                   analysis pipeline. Board reads (normalization, signal
                   generator, TES bias) go through a BoardReader Protocol,
                   cached and refreshed only on explicit triggers (channel
                   change / norm-type change / user action) since they can
                   be slow.
  board_reader.py  PytesdaqxBoardReader: concrete BoardReader over
                   SQUIDService/TESBiasService. Supports "Open Loop" and
                   "Close Loop" normalization only (OpenLoop PreAmp+FB was
                   dropped - see module docstring); output_fix_gain/
                   preamp_fix_gain are assumed 1.0 until pytesdaqx's config
                   schema grows a place for them (StarCryo SQUID electronics).
  launcher.py      CLI entry point: `pytesdaqx-scope --hardware ... --run-setup
                   ... --acquisition-config ... [--backend nidaq|fake]` for
                   live acquisition, or `pytesdaqx-scope --file <acquisition>`
                   to replay a recorded zarr/hdf5 acquisition. Live vs. file
                   is chosen once at launch (a CLI arg), not a runtime GUI
                   toggle - a deliberate simplification now that the
                   config-driven design resolves channels/measurements from
                   YAML rather than a device combobox.

                   For live acquisition, also builds a
                   `pytesdaqx.control.coordinator.ControlCoordinator` (shared
                   HardwareRegistry with the board reader) and passes it to
                   `LiveSource`. `LiveSource` sequences a dIdV measurement's
                   PXI AC drive correctly: arm the AI task first, *then*
                   start the AO waveform, so the AI's `/ao/StartTrigger`
                   defines phase zero. `start_measurement_tes_ac` is a no-op
                   for measurements without a TES-AC waveform block, so this
                   is always safe to wire up even for background/threshold
                   runs.
```

Qt runs headless via `QT_QPA_PLATFORM=offscreen` in tests and in this
(display-less) dev environment; `pytest-qt`'s `qtbot` fixture drives widget
interaction in tests without a real display.

## Development

```bash
pip install -e ".[dev]"
pytest
```

Try the GUI shell against the fake backend (no hardware required), using
pytesdaqx's example configs:

```bash
QT_QPA_PLATFORM=offscreen pytesdaqx-scope \
  --hardware ../pytesdaqx/examples/configuration/basic/hardware_fake.yaml \
  --run-setup ../pytesdaqx/examples/configuration/basic/run_setup.yaml \
  --acquisition-config ../pytesdaqx/examples/configuration/basic/acquisition_background.yaml \
  --backend fake
```

(Drop `QT_QPA_PLATFORM=offscreen` on a machine with a real display.)
