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.cppApplication entry point. Sets the
QApplicationidentity, loads font and save-path configuration, opens theApplicationConfigDialogandRuntimeHardwareConfigDialogon first run, registers meta-types and catalog parsers, then constructsMainWindowand enters the event loop.src/acquisition/The experiment-execution layer.
acquisitionmanager.{cpp,h}declaresAcquisitionManager, which drives an in-progress experiment (waveform pipeline, aux/validation timer, backups, finalization). Thebatch/sub-directory holdsBatchManagerand its concrete subclassesBatchSingleandBatchSequence, which coordinate across-experiment state.src/data/Data model, persistence, analysis, and application-wide singletons that are not hardware-specific.
data/experiment/— experiment configuration:Experiment, theFtmwConfigfamily,RfConfig,ChirpConfig, theDigitizerConfigfamily,ExperimentObjective, andExperimentValidator.data/lif/— LIF-specific data:LifConfig,LifDigitizerConfig,LifStorage,LifTrace.data/loadout/—HardwareLoadout,LoadoutManager,FtmwPreset, and the loadout/preset snapshot helpers.data/storage/— persistence:BlackchirpCSV, theHeaderStoragetree,DataStorageBaseand its concrete subclasses (FidStorageBaseand friends,LifStorage,OverlayStorage,AuxDataStorage),SettingsStorage,WaveformBuffer, andApplicationConfigManager.data/analysis/—FtWorker(the FT processing worker),Analysis,PeakFinder,WaveformParser,Ft.data/processing/— overlay processing/operations.data/processing/parsers/— overlay/import file parsers (FileParser,FileParserRegistry,GenericXyParser,SpcatParser,XiamParser,CatalogParser).data/model/— Qt item models for chirp, clock, marker, peak, and overlay tables.data/settings/— the static key declarationshardwarekeys.handguikeys.h.data/presentation/—CurveAppearance.data/loghandler.{cpp,h}—LogHandlerglobal diagnostic logging.data/bcglobals.{cpp,h}— application-wide constants and persistent-key declarations.
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:ZoomPanPlotbase,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/—ThemeColorstheme 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 inhardwareregistration.{cpp,h}and the aggregator headershw_base.h/hw_h.h/hw_impl.h,RuntimeHardwareConfig,HardwareProfileManager, the interface-class sub-directories (clock/,ftmwdigitizer/,lifdigitizer/,liflaser/), andcommunication/.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 scriptpython_hw_host.py, and per-type template scripts.hardware/library/—VendorLibraryand concrete subclasses (LabjackLibrary,SpectrumLibrary).
src/modules/Optional compile-conditional modules.
modules/cuda/holdsGpuAverager— CUDA-accelerated FID accumulation gated by theBC_ENABLE_CUDAbuild option.src/resources/Qt resource collection: icons, the
resources.qrcmanifest, and the udev rules file52-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 theREGISTER_HARDWARE_*macros fromhardwareregistration.h. The registry is the authoritative answer to “what drivers exist for hardware type X?” and is the only place that constructsHardwareObjectinstances 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 fromRuntimeHardwareConfigDialog(the Configure Hardware dialog) and stored viaSettingsStorage. 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 againstHardwareRegistry. Read access is open from any thread (read/write-locked); write access is restricted to friend classes — primarilyHardwareManagerandRuntimeHardwareConfigDialog. The active set is whatHardwareManager::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
RfConfigchain plusChirpConfigand 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 throughRuntimeHardwareConfig.HardwareManager(live hardware owner)HardwareManager owns every live
HardwareObjectfor 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.MainWindowconstructs it before the event loop starts and triggersHardwareManager::initialize()from the thread’sstarted()signal. The staticHardwareManager::constInstance()accessor exposes read-only lookup of the hardware map for callers (such asHardwareObjectinstances 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 eachRfConfig::ClockTyperole (UpLO,DownLO,DRClock,AwgRef, …) to a physicalClockdevice 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 withClockManagergoes throughHardwareManager’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 aWaveformBufferand aQFutureWatcher-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 viaQMetaObject::invokeMethod.BatchManager(across-experiment coordinator)BatchManager is an abstract base; the active subclass —
BatchSinglefor a single experiment,BatchSequencefor a timed multi-experiment run — lives on the GUI thread insideMainWindow. It coordinates what comes after eachAcquisitionManagerexperimentComplete(): report writing, post-processing, and advancing to the next experiment (or ending the batch).LogHandler,ApplicationConfigManagerLogHandler is the application-wide diagnostic sink that the
bcLog/bcWarn/bcErrormacros 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
QApplicationmain thread. HostsMainWindow, every dialog and widget, every plot, the activeBatchManagersubclass, and the application-wide singletonsLoadoutManager,HardwareProfileManager,RuntimeHardwareConfig,LogHandler, andApplicationConfigManager. (Those singletons are thread-safe for read access from any thread; writes are constrained.)- HardwareManager thread
A dedicated
QThreadnamedHardwareManagerThread, constructed in theMainWindowconstructor before the event loop starts.HardwareManageris moved onto it; the thread’sstarted()signal triggersHardwareManager::initialize(), which loads the active profiles fromRuntimeHardwareConfig, instantiates theHardwareObjectinstances, and brings them online. AllHardwareManagerslots — including the acquisition-lifecycle slots and the per-hardware-type request slots — execute on this thread.ClockManageris owned byHardwareManagerand runs on the same thread.- Per-device threads
For each
HardwareObjectwhosed_threadedflag istrue,HardwareManagercreates a dedicatedQThreadnamed<key>Thread(e.g.,FtmwDigitizer.mainThread), callsmoveToThreadon the object, and connects the thread’sstarted()signal toHardwareObject::bcInitInstrument(). The flag is set in the driver’s constructor and may be overridden per-profile viaRuntimeHardwareConfig. Two hard rules apply to threaded hardware:The driver constructor must pass
nullptras theQObjectparent.moveToThreadrequires that the object have no parent (or share its parent’s thread), and a parent assigned at construction time would prevent the move.The constructor must not create child
QObjectinstances (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 insideinitialize()instead, which runs on the device thread after the move.
Non-threaded hardware lives on the HardwareManager thread; for those objects
HardwareManagercallsHardwareObject::bcInitInstrument()directly viaQMetaObject::invokeMethod.- AcquisitionManager thread
A dedicated
QThreadnamedAcquisitionManagerThread, constructed in theMainWindowconstructor.AcquisitionManageris moved onto it but the thread is not started untilinitializeHardware(). The GUI thread callsAcquisitionManager::beginExperiment()viaQMetaObject::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
AcquisitionManagerrun 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 toQtConcurrent::run. AQFutureWatcher<FtmwProcessingResult>carries the result back to the AM thread, whereonProcessingCompleteadvances 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()returnstrue,Experiment::backup()is dispatched toQtConcurrent::runand observed via aQFutureWatcher<void>; the watcher’sfinishedsignal re-emits asbackupCompleteon 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 theMainWindowconstructor is bareconnectcalls relying on this behavior.QMetaObject::invokeMethodUsed when a non-
QObjectcaller, or a caller that does not hold a matching signal, needs to invoke a slot on another thread.Qt::QueuedConnectionfor fire-and-forget invocations (the GUI thread askingHardwareManagerto start an experiment, an aux-data request, etc.).Qt::BlockingQueuedConnectiononly when the caller must wait for the result and is guaranteed to be on a different thread from the target — invokingBlockingQueuedConnectionon the same thread deadlocks. The GUI uses it sparingly:MainWindowblocks 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.”
AcquisitionManageruses 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
WaveformBufferbetween them uses aQSemaphorefor low-latency notification — the producerrelease-s after each shot, and the consumer can either drain on a timer (the default inAcquisitionManager::drainFtmwBuffer()) or block onwaitForData. 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, andApplicationConfigManagerall exposeinstance()and (where read-only access is meaningful)constInstance()accessors. Each protects its own state —SettingsStoragehas an internal lock;RuntimeHardwareConfiguses aQReadWriteLock;LoadoutManageruses aQMutex— 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 withQt::AutoConnectionand 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<key, HardwareObject*>]
end
subgraph AMT[AcquisitionManager thread]
AM[AcquisitionManager]
Exp[shared_ptr<Experiment>]
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.