Architecture

This page is the spine of the developer guide. The remaining sub-pages assume the reader has absorbed three things from this one: which top-level directory under src/ owns which responsibility, the names of the orchestration singletons that compose a running program, and the threading model that connects them. Subsequent sub-pages reference the directories, the singletons, and the threads by name without re-introducing them.

The design is conventional Qt: a QApplication event loop on the GUI thread, a small set of long-lived QObject orchestrators each on a dedicated QThread, signal/slot wiring between them, and a QtConcurrent worker pool for the few operations whose duration would otherwise stall an event loop. The build system layout — which library produces which directory — is on Build System and Project Layout; this page focuses on the runtime layout. For per-method contracts, follow the :doc: cross-links to the API reference.

Source tree

The application source lives under src/. Every sub-directory below is one level deep from there.

src/main.cpp

Application entry point. Sets the QApplication identity, loads font and save-path configuration, opens the ApplicationConfigDialog and RuntimeHardwareConfigDialog on first run, registers meta-types and catalog parsers, then constructs MainWindow and enters the event loop.

src/acquisition/

The experiment-execution layer. acquisitionmanager.{cpp,h} declares AcquisitionManager, which drives an in-progress experiment (waveform pipeline, aux/validation timer, backups, finalization). The batch/ sub-directory holds BatchManager and its concrete subclasses BatchSingle and BatchSequence, which coordinate across-experiment state.

src/data/

Data model, persistence, analysis, and application-wide singletons that are not hardware-specific.

src/gui/

Qt Widgets layer. mainwindow.{cpp,h} is the application’s central window and the wiring hub for all the orchestration singletons (see MainWindow as the wiring hub below).

  • gui/dialog/ — modal dialogs (Add Profile, Application Config, Hardware Configuration, FTMW Configuration, Batch Sequence, Communication, About, etc.).

  • gui/expsetup/ — the experiment-setup wizard pages and the dialog that hosts them.

  • gui/widget/ — embeddable widgets (FTMW view, chirp config, hardware status boxes, digitizer config, pulse config, …).

  • gui/plot/ — Qwt-based scientific plots: ZoomPanPlot base, MainFtPlot, FidPlot, ChirpConfigPlot, PulsePlot, TrackingPlot, plot-curve helpers, and the custom tracker/zoomer.

  • gui/lif/gui/ — LIF-specific widgets and plots.

  • gui/overlay/ — overlay configuration widgets and the unified overlay dialog.

  • gui/style/ThemeColors theme management.

  • gui/util/ — small GUI utilities (numeric formatting).

src/hardware/

Hardware abstraction and drivers.

  • hardware/core/HardwareObject (the abstract device base), HardwareManager, HardwareRegistry, the registration helpers in hardwareregistration.{cpp,h} and the aggregator headers hw_base.h / hw_h.h / hw_impl.h, RuntimeHardwareConfig, HardwareProfileManager, the interface-class sub-directories (clock/, ftmwdigitizer/, lifdigitizer/, liflaser/), and communication/.

  • hardware/optional/ — interfaces and drivers for hardware classes that are optional in any given experiment: chirpsource/ (AWG), flowcontroller/, gpibcontroller/, ioboard/, pressurecontroller/, pulsegenerator/, tempcontroller/.

  • hardware/python/ — Python trampoline classes (one per hardware type), the host process script python_hw_host.py, and per-type template scripts.

  • hardware/library/VendorLibrary and concrete subclasses (LabjackLibrary, SpectrumLibrary).

src/modules/

Optional compile-conditional modules. modules/cuda/ holds GpuAverager — CUDA-accelerated FID accumulation gated by the BC_ENABLE_CUDA build option.

src/resources/

Qt resource collection: icons, the resources.qrc manifest, and the udev rules file 52-serial.rules.

Orchestration singletons

A running Blackchirp instance is held together by a small cast of long-lived orchestrators. Most are application-wide singletons; the acquisition trio (HardwareManager, AcquisitionManager, BatchManager) is owned by MainWindow. Each entry below is a one-paragraph orientation; follow the cross-link for the per-method contract.

HardwareRegistry (compile-time catalog)

HardwareRegistry is the static catalog of every hardware driver linked into the binary. Each driver registers itself before main() runs (factory function, supported communication protocols, per-driver settings, inheritance chain) using the REGISTER_HARDWARE_* macros from hardwareregistration.h. The registry is the authoritative answer to “what drivers exist for hardware type X?” and is the only place that constructs HardwareObject instances by key.

HardwareProfileManager (profile metadata)

HardwareProfileManager owns the persistent profile records. A profile is an immutable (hardwareType, label, driver) triple with its own persisted settings and (for Python drivers) script path and class name; the <hardwareType>.<label> pair is the profile’s identity, and the driver is fixed at creation time. Profiles are CRUD’d from RuntimeHardwareConfigDialog (the Configure Hardware dialog) and stored via SettingsStorage. The user workflow is documented in Hardware and Library Configuration.

RuntimeHardwareConfig (active selection)

RuntimeHardwareConfig records which profiles are active in the running session, keyed by profile identity (<HardwareType>.<label>). The driver key for each active profile is held as a denormalized copy of the profile’s immutable value and validated against HardwareRegistry. Read access is open from any thread (read/write-locked); write access is restricted to friend classes — primarily HardwareManager and RuntimeHardwareConfigDialog. The active set is what HardwareManager::initialize() consults to decide what to instantiate.

LoadoutManager (named member-profile sets + FTMW presets)

LoadoutManager persists named loadouts (a complete hardware selection — which AWG profile, which digitizer profile, which clock profiles) and the FTMW presets that ride on top of each loadout (an RfConfig chain plus ChirpConfig and digitizer config, named within the loadout). The user picks a loadout from the Loadout menu and an FTMW preset from the FTMW Preset menu; HardwareManager::applyHardwareMap() consumes the loadout’s hardware map and pushes it through RuntimeHardwareConfig.

HardwareManager (live hardware owner)

HardwareManager owns every live HardwareObject for the active loadout. It runs on its own thread, moves threaded hardware objects to per-device threads, mediates all cross-thread hardware calls, and fans out connection, auxiliary-data, and experiment-lifecycle signals. MainWindow constructs it before the event loop starts and triggers HardwareManager::initialize() from the thread’s started() signal. The static HardwareManager::constInstance() accessor exposes read-only lookup of the hardware map for callers (such as HardwareObject instances resolving GPIB controllers) that cannot hold a direct reference.

ClockManager (RF clock subsystem)

ClockManager is owned by HardwareManager (pu_clockManager) and lives on the HardwareManager thread. It maps each RfConfig::ClockType role (UpLO, DownLO, DRClock, AwgRef, …) to a physical Clock device and output index, and gates the FTMW digitizer during clock transitions so partial frequency changes do not leak into acquired data. All cross-thread interaction with ClockManager goes through HardwareManager’s queued slots.

AcquisitionManager (experiment driver)

AcquisitionManager runs an in-progress experiment on its own thread: it holds the std::shared_ptr<Experiment>, drives the FTMW waveform pipeline through a WaveformBuffer and a QFutureWatcher-tracked worker, services the auxiliary-data and validation timers, dispatches periodic backups, and finalizes the experiment when complete. Its public slots are invoked from the GUI thread via QMetaObject::invokeMethod.

BatchManager (across-experiment coordinator)

BatchManager is an abstract base; the active subclass — BatchSingle for a single experiment, BatchSequence for a timed multi-experiment run — lives on the GUI thread inside MainWindow. It coordinates what comes after each AcquisitionManager experimentComplete(): report writing, post-processing, and advancing to the next experiment (or ending the batch).

LogHandler, ApplicationConfigManager

LogHandler is the application-wide diagnostic sink that the bcLog / bcWarn / bcError macros forward to; it owns the per-experiment log file and the in-app log view. ApplicationConfigManager exposes the user-level configuration that survives across runs — data save path, debug logging toggle, vendor library paths, font, LIF-enable flag — and emits change signals that other singletons subscribe to.

MainWindow as the wiring hub

MainWindow is responsible for two things beyond hosting the UI: constructing the orchestration singletons it owns, and wiring their signals together. The constructor allocates HardwareManager and AcquisitionManager, creates a dedicated QThread for each, calls moveToThread on the singleton, attaches the thread’s started() signal (HardwareManager only) to its initialize() slot, and wires deleteLater on finished(). The threads are not started until MainWindow::initializeHardware() emits startInit.

The same constructor wires the inter-manager signal flow that the acquisition lifecycle relies on: AcquisitionManager::beginAcquisition()HardwareManager::beginAcquisition(), AcquisitionManager::endAcquisition()HardwareManager::endAcquisition(), HardwareManager::experimentInitialized()MainWindow::experimentInitialized (which then queues AcquisitionManager::beginExperiment), HardwareManager::auxData()AcquisitionManager::processAuxData(), HardwareManager::validationData()AcquisitionManager::processValidationData(), HardwareManager::allClocksReady()AcquisitionManager::clockSettingsComplete(), and AcquisitionManager::experimentComplete()HardwareManager::experimentComplete() and the BatchManager subclass. MainWindow also dispatches user actions (toolbar buttons, menu items, dialog acceptance) to whichever singleton owns them, almost always via QMetaObject::invokeMethod so the call hops onto the destination thread.

The BatchManager subclass is constructed lazily in MainWindow::startBatch when the user begins an experiment and deleted the next time startBatch runs.

Subsequent sub-pages trace the specific signal chains in detail: Hardware Runtime covers the hardware-side wiring and the connection-test flow, Experiment Lifecycle follows an experiment from “Start Experiment” through to finalSave, and FTMW Acquisition and Visualization walks the FTMW data path.

Threading model

Blackchirp has four primary execution contexts and one shared worker pool. The contexts and the rules for crossing between them are set once here and assumed elsewhere.

GUI thread

The QApplication main thread. Hosts MainWindow, every dialog and widget, every plot, the active BatchManager subclass, and the application-wide singletons LoadoutManager, HardwareProfileManager, RuntimeHardwareConfig, LogHandler, and ApplicationConfigManager. (Those singletons are thread-safe for read access from any thread; writes are constrained.)

HardwareManager thread

A dedicated QThread named HardwareManagerThread, constructed in the MainWindow constructor before the event loop starts. HardwareManager is moved onto it; the thread’s started() signal triggers HardwareManager::initialize(), which loads the active profiles from RuntimeHardwareConfig, instantiates the HardwareObject instances, and brings them online. All HardwareManager slots — including the acquisition-lifecycle slots and the per-hardware-type request slots — execute on this thread. ClockManager is owned by HardwareManager and runs on the same thread.

Per-device threads

For each HardwareObject whose d_threaded flag is true, HardwareManager creates a dedicated QThread named <key>Thread (e.g., FtmwDigitizer.mainThread), calls moveToThread on the object, and connects the thread’s started() signal to HardwareObject::bcInitInstrument(). The flag is set in the driver’s constructor and may be overridden per-profile via RuntimeHardwareConfig. Two hard rules apply to threaded hardware:

  1. The driver constructor must pass nullptr as the QObject parent. moveToThread requires that the object have no parent (or share its parent’s thread), and a parent assigned at construction time would prevent the move.

  2. The constructor must not create child QObject instances (timers, communication-protocol objects, child workers). Children would be parented to the not-yet-moved object on the caller’s thread; once the object moves, the children are left behind. Construct children inside initialize() instead, which runs on the device thread after the move.

Non-threaded hardware lives on the HardwareManager thread; for those objects HardwareManager calls HardwareObject::bcInitInstrument() directly via QMetaObject::invokeMethod.

AcquisitionManager thread

A dedicated QThread named AcquisitionManagerThread, constructed in the MainWindow constructor. AcquisitionManager is moved onto it but the thread is not started until initializeHardware(). The GUI thread calls AcquisitionManager::beginExperiment() via QMetaObject::invokeMethod; from that point the FTMW drain timer, the aux-data timer, the experiment shared pointer, and every state mutation execute on this thread.

QtConcurrent worker pool

Two operations on AcquisitionManager run on the global Qt thread pool rather than the AM thread itself:

  • Waveform parse-and-accumulate. When the FTMW configuration uses a WaveformBuffer, an internal 20 ms drain timer reads pending entries out of the buffer and dispatches them to QtConcurrent::run. A QFutureWatcher<FtmwProcessingResult> carries the result back to the AM thread, where onProcessingComplete advances the segment, updates the progress signal, and restarts the drain timer. The drain timer is paused while the worker is in flight, so at most one waveform batch is in progress at a time. The full data flow is on FTMW Acquisition and Visualization.

  • Periodic backups. When Experiment::canBackup() returns true, Experiment::backup() is dispatched to QtConcurrent::run and observed via a QFutureWatcher<void>; the watcher’s finished signal re-emits as backupComplete on the AM thread.

Cross-thread call patterns

Blackchirp uses a small set of Qt patterns for crossing thread boundaries. The same patterns appear throughout the codebase; new code should follow them rather than invent variants.

Direct signal → slot connection

Qt::AutoConnection (the default) is correct in almost every case. Qt picks queued delivery when the signal and slot live on different threads and direct delivery when they live on the same one. Most of the wiring in the MainWindow constructor is bare connect calls relying on this behavior.

QMetaObject::invokeMethod

Used when a non-QObject caller, or a caller that does not hold a matching signal, needs to invoke a slot on another thread. Qt::QueuedConnection for fire-and-forget invocations (the GUI thread asking HardwareManager to start an experiment, an aux-data request, etc.). Qt::BlockingQueuedConnection only when the caller must wait for the result and is guaranteed to be on a different thread from the target — invoking BlockingQueuedConnection on the same thread deadlocks. The GUI uses it sparingly: MainWindow blocks briefly to read the current clock map before opening dialogs, for example.

QFutureWatcher<T>

The canonical pattern for “do expensive work on the worker pool, hop the result back to my thread.” AcquisitionManager uses it for both waveform processing and backups; new code that wants to dispatch a worker should use the same pattern rather than rolling a thread of its own.

QSemaphore (WaveformBuffer)

The FTMW digitizer thread is a producer and the AM thread is a consumer; the SPSC WaveformBuffer between them uses a QSemaphore for low-latency notification — the producer release-s after each shot, and the consumer can either drain on a timer (the default in AcquisitionManager::drainFtmwBuffer()) or block on waitForData. The semaphore avoids the per-shot signal-queue overhead of routing every waveform through Qt’s event loop. The full producer/consumer/pool story is on FTMW Acquisition and Visualization.

Static singletons

LoadoutManager, HardwareProfileManager, RuntimeHardwareConfig, HardwareRegistry, LogHandler, and ApplicationConfigManager all expose instance() and (where read-only access is meaningful) constInstance() accessors. Each protects its own state — SettingsStorage has an internal lock; RuntimeHardwareConfig uses a QReadWriteLock; LoadoutManager uses a QMutex — so calls from any thread are safe. The signals these singletons emit are delivered on the thread of the caller that triggered the change, so connect with Qt::AutoConnection and trust Qt to queue across thread boundaries.

Diagram

The diagram below splits the four execution contexts into columns — GUI, HardwareManager, AcquisitionManager, per-device — and shows ownership (solid arrows) and the dominant inter-manager signal flows (dashed arrows). The QtConcurrent worker pool is shown attached to the AcquisitionManager thread, since that is the only thread that dispatches to it.

        flowchart LR
    subgraph GUI[GUI thread]
        MW[MainWindow]
        BM[BatchManager<br/>BatchSingle / BatchSequence]
        Dialogs[Dialogs · plots · widgets]
        Singletons[Process-wide singletons:<br/>LoadoutManager · HardwareProfileManager<br/>RuntimeHardwareConfig · HardwareRegistry<br/>LogHandler · ApplicationConfigManager]
    end

    subgraph HMT[HardwareManager thread]
        HM[HardwareManager]
        CM[ClockManager]
        HMap[map&lt;key, HardwareObject*&gt;]
    end

    subgraph AMT[AcquisitionManager thread]
        AM[AcquisitionManager]
        Exp[shared_ptr&lt;Experiment&gt;]
        Drain[drainTimer · auxTimer]
        Pool[QtConcurrent pool:<br/>FTMW worker · backup worker<br/>tracked by QFutureWatcher]
    end

    subgraph DT[Per-device threads]
        HO1[HardwareObject A]
        HO2[HardwareObject B]
        HOn[...]
    end

    MW -- owns / moveToThread --> HM
    MW -- owns / moveToThread --> AM
    MW -- owns --> BM
    MW -- owns --> Dialogs
    HM -- owns --> CM
    HM -- owns --> HMap
    HMap -- moveToThread<br/>if d_threaded --> HO1
    HMap -- moveToThread<br/>if d_threaded --> HO2
    HMap -. moveToThread<br/>if d_threaded .-> HOn
    AM -- owns --> Exp
    AM -- owns --> Drain
    AM -- dispatches to --> Pool

    MW -. invokeMethod:<br/>initializeExperiment · setClocks · sleep .-> HM
    HM -. experimentInitialized .-> MW
    MW -. invokeMethod:<br/>beginExperiment · pause · abort .-> AM
    AM -. beginAcquisition · endAcquisition<br/>auxDataSignal · newClockSettings .-> HM
    HM -. auxData · validationData<br/>allClocksReady · lifSettingsComplete .-> AM
    AM -. experimentComplete .-> BM
    AM -. experimentComplete .-> HM
    HM -. queued slots:<br/>set / read / initialize / sleep .-> HO1
    HO1 -. updates · aux data<br/>connection results · failure .-> HM
    HO1 -. WaveformBuffer<br/>QSemaphore + SPSC ring .-> AM
    

Read the diagram top-down for ownership and left-to-right for the acquisition lifecycle. The GUI launches everything; the HardwareManager and AcquisitionManager threads exchange a small set of signals that drive an experiment from setup to finalization; the per-device threads carry per-instrument I/O; and the worker pool absorbs the two operations whose duration would otherwise stall the AM event loop.