Experiment Lifecycle
This page tells the cross-manager story of one experiment. The user
clicks Start Experiment (or Quick Experiment, or Start Sequence);
MainWindow constructs a BatchManager, hands it
to the hardware layer, and waits for the round-trip back from
AcquisitionManager to know whether to start another
experiment or close out the batch. Most of that round-trip is spent
inside the two managers, but neither manager drives it alone — the
handoff between them is what this page covers.
The internal state machines of AcquisitionManager and
BatchManager are documented on their own API pages
(State machine and
State machine). The waveform-processing pipeline
that runs inside the AM’s acquisition loop is the topic of
FTMW Acquisition and Visualization; the LIF-side acquisition
mechanics are on LIF Acquisition and Visualization. This page
treats those pipelines as black boxes and concentrates on the signals
that cross between MainWindow,
HardwareManager, AcquisitionManager,
BatchManager, Experiment, and the live
HardwareObject set.
The thread layout is the one introduced in
Architecture: MainWindow and
BatchManager live on the GUI thread,
HardwareManager runs on its own
HardwareManagerThread, AcquisitionManager runs on
AcquisitionManagerThread, and threaded
HardwareObject instances each run on
"<hwKey>Thread". Every cross-thread call described below uses a
queued connection or QMetaObject::invokeMethod(); this page
calls out the few that are blocking-queued because they need a return
value.
Starting a batch
Three menu actions land in three MainWindow slots that
each construct a concrete BatchManager subclass and pass
it to MainWindow::startBatch():
Start Experiment →
MainWindow::startExperiment()opensExperimentSetupDialog(the full configuration wizard), then constructsBatchSingle(BatchType::SingleExperiment).Quick Experiment →
MainWindow::quickStart()opensQuickExptDialogto repeat or reconfigure a previous experiment, then constructsBatchSingle.Start Sequence →
MainWindow::startSequence()opensBatchSequenceDialog(count + interval), optionally re-runs the wizard or the quick-experiment dialog, then constructsBatchSequence(BatchType::Sequence). The sequence re-uses one experiment template, cloning a freshExperimentfrom it for each iteration.
MainWindow::startBatch() is the wiring hub for everything
that happens during the batch:
connect(bm,&BatchManager::beginExperiment,[this,bm](){
QMetaObject::invokeMethod(p_hwm,[this,bm](){
p_hwm->initializeExperiment(bm->currentExperiment());
});
});
connect(p_am,&AcquisitionManager::experimentComplete,
bm,&BatchManager::experimentComplete);
connect(bm,&BatchManager::batchComplete,this,&MainWindow::batchComplete);
connect(p_hwm,&HardwareManager::abortAcquisition,
p_am,&AcquisitionManager::abort,Qt::UniqueConnection);
The signals that span the per-batch lifecycle are installed here and
torn down in MainWindow::batchComplete() after the batch
ends. The static connections that survive across batches —
experimentInitialized, allClocksReady, newClockSettings,
beginAcquisition, endAcquisition, auxData,
validationData, and the LIF chain — were installed once during
MainWindow::MainWindow() and remain in place.
The first experiment in a batch is launched directly from the bottom
of MainWindow::startBatch():
QMetaObject::invokeMethod(p_hwm,[this](){
p_hwm->initializeExperiment(p_batchManager->currentExperiment());
});
Subsequent experiments in a BatchSequence are launched by
the connected BatchManager::beginExperiment signal — the
connect shown above hands the next experiment to the hardware layer
without MainWindow having to know that the batch advanced.
Hardware setup
HardwareManager::initializeExperiment() runs on the
HardwareManagerThread and is the gateway every experiment passes
through before any data are acquired. Its three steps run in order:
Configure clocks.
ClockManager::prepareForExperiment()reads the clock map fromexp.ftmwConfig()->d_rfConfig.getClocks(), callsClockManager::configureClocks()to resolve eachRfConfig::ClockTyperole to aClockand apply its multiplication factor, then writes the achieved frequencies back into the experiment withRfConfig::setCurrentClocks(). Non-FTMW experiments skip this step. Failure short-circuits the rest of the routine.Prepare every hardware object. The manager iterates
d_hardwareMapand dispatchesHardwareObject::hwPrepareForExperiment()to each device. For threaded drivers the call goes across aQt::BlockingQueuedConnectionso the success value is delivered synchronously. The base wrapper does two things every driver gets for free: if the device is currently disconnected it triesHardwareObject::testConnection()once, and if that test fails on a critical device (d_critical == true, the default) it setsexp.d_errorStringand returnsfalse. A non-critical disconnect is treated as success and the per-experimentHardwareObject::prepareForExperiment()override is bypassed for that device. The first failure breaks the loop and setsexp.d_hardwareSuccess = false.Sanity-check LIF. When
exp.lifEnabled()is true the manager confirms that an activeLifLaserexists inRuntimeHardwareConfig. A missing laser emitsHardwareManager::lifSettingsComplete()withsuccess = falseand clears the experiment’s hardware-success flag.
The routine ends with
HardwareManager::experimentInitialized() carrying the
populated Experiment. Even on failure the signal is
emitted — the hardware-success flag tells the GUI-side handler what
happened.
For the per-class API of HardwareObject::hwPrepareForExperiment(),
HardwareObject::beginAcquisition(), and
HardwareObject::endAcquisition(), see
HardwareObject. The clock-routing model is on
ClockManager.
Registering and handing off the experiment
HardwareManager::experimentInitialized() is delivered to
MainWindow::experimentInitialized() on the GUI thread (the
connection is queued because the signal is emitted on the HM thread).
The slot does the GUI-thread side of the handoff:
If
exp.d_hardwareSuccessis false, the slot callsBatchManager::experimentComplete()directly so the batch is told the experiment is over before it ever started, restores the UI toIdle, and returns.Otherwise it calls
Experiment::initialize()synchronously on the GUI thread. That call does the bookkeeping a brand-new experiment needs: it increments the persisted experiment counter inSettingsStorage, creates the on-disk directory viaBlackchirpCSV::createExptDir(), walks thed_objectivesset so eachExperimentObjectiveinitializes itself, and writes the initial CSVs (version.csv,header.csv,objectives.csv,hardware.csv,chirps.csv,clocks.csv). Peak Up without LIF is the exception — the experiment is markedd_isDummy, given experiment number-1, and no directory or files are written.The slot prepares per-experiment GUI state (progress bars, the FTMW view widget, the aux-data widget, the LIF widget when LIF is enabled), opens the log entry for the experiment via
LogHandler::beginExperimentLog()(or a one-line highlight for dummy experiments), and callsMainWindow::configureUi()withAcquiringso the toolbar buttons reflect the running state.Finally it crosses back into the AM thread:
QMetaObject::invokeMethod(p_am,[this,exp](){ p_am->beginExperiment(exp); });
From this point
AcquisitionManagerowns the experiment shared pointer for the duration of the acquisition loop.
Acquisition steady state
AcquisitionManager::beginExperiment() is the AM-thread entry
point for the loop. It runs in this order:
Stores the experiment, transitions to
Acquiring, and emitsstatusMessage("Acquiring")for the status bar.When
exp->d_timeDataInterval > 0, registers the per-timestamp FTMW aux keys (Ftmw/Shotsalways;Ftmw/ChirpRMSwhen chirp scoring is on;Ftmw/ChirpPhaseScoreandFtmw/ChirpShiftwhen phase correction is on), callsAcquisitionManager::auxDataTick()once to seed the first point, and starts a Qt timer usingstartTimerfor the periodic aux refresh.For FTMW experiments, emits
AcquisitionManager::newClockSettings()carryingftmwConfig->d_rfConfig.getClocks(). The signal is consumed on the GUI thread byMainWindow::clockPrompt()(see the Clock settings round-trip note below); the GUI ultimately callsHardwareManager::setClocks(), which gates theFtmwDigitizerwhile the clock frequencies change, walksClockManagerto apply each value, ungates the scope, and emitsHardwareManager::allClocksReady(). That signal is wired statically toAcquisitionManager::clockSettingsComplete(), which stores the achieved frequencies inftmwConfig->d_rfConfig.setCurrentClocksand callsFtmwConfig::hwReady(). CallinghwReadyclearsd_processingPausedso waveform processing can begin.Emits
AcquisitionManager::beginAcquisition(). The signal is wired toHardwareManager::beginAcquisition(), which re-emits the broadcast onto everyHardwareObject’sbeginAcquisitionslot — devices start triggering, the digitizer’sWaveformBufferbecomes the data conduit.For FTMW experiments with a live waveform buffer, allocates the processing
QFutureWatcher, starts the 20 ms drain timer, and connects its timeout toAcquisitionManager::drainFtmwBuffer(). The waveform pipeline that runs inside that timer is documented separately in FTMW Acquisition and Visualization and on State machine; this page treats the inner loop as a black box.For LIF experiments, emits
AcquisitionManager::nextLifPoint()to start the LIF parallel path described in the next section.
The aux-data tick keeps running on its own timer for the rest of the
acquisition. Each fire of AcquisitionManager::auxDataTick()
collects the FTMW shot count (and chirp metrics where enabled),
writes it through AcquisitionManager::processAuxData(), and
emits AcquisitionManager::auxDataSignal(). The signal is
wired to HardwareManager::getAuxData(), which fans the
bcReadAuxData call out to every hardware object, prefixes each
returned key with the source’s hwKey, and re-emits the aggregate
as HardwareManager::auxData() and
HardwareManager::validationData(). The two arrive back on
the AM as AcquisitionManager::processAuxData() (which
records to AuxDataStorage and re-emits to the plot
widgets) and AcquisitionManager::processValidationData()
(which checks each value against the experiment’s
ExperimentValidator set; an out-of-range reading calls
AcquisitionManager::abort()). The aux and validation paths
share the per-device readAuxData source — a device declares its
validation keys via HardwareObject::validationKeys() and
the same readings flow into both pipelines.
After every FTMW processing batch returns,
AcquisitionManager::checkComplete() checks
Experiment::canBackup(). When true, a backup is dispatched
through QtConcurrent::run and a QFutureWatcher
emits AcquisitionManager::backupComplete() on the AM
thread when the worker finishes; FtmwViewWidget
listens to that signal to refresh its list of available backups.
checkComplete also calls Experiment::isComplete(); a
true return invokes AcquisitionManager::finishAcquisition()
to end the loop normally.
Note
Clock settings round-trip. The chain
newClockSettings → clockPrompt → setClocks → allClocksReady →
clockSettingsComplete is the way Blackchirp resynchronizes when
the experiment crosses an FTMW segment boundary that needs new LO
frequencies. MainWindow::clockPrompt() sits between AM
and HM because clock outputs marked manual-tune in
SettingsStorage need a user prompt before the next
segment is allowed to begin; non-manual clocks pass through
silently. The round-trip is also taken once at the start of every
FTMW experiment — that is where hwReady first clears
d_processingPaused so the drain timer’s worker may run.
LIF parallel path
When exp.lifEnabled() is true,
AcquisitionManager::beginExperiment() ends with one extra
emission: AcquisitionManager::nextLifPoint(), carrying the
first scan point’s delay and laser position. The LIF parallel path
that follows — laser tuning, digitizer arming, shot accumulation,
point advancement — is the topic of
LIF Acquisition and Visualization. The point of contact for
this page is the two queued connections that make the parallel path
visible to the rest of the system:
HardwareManager::lifSettingsComplete()→AcquisitionManager::lifHardwareReady(). The HM emits this slot withsuccesswhen the LIF laser and digitizer have been re-armed at the new point.lifHardwareReadycallsLifConfig::hwReady()to clear LIF’s ownd_processingPausedflag — symmetric to FTMW’sFtmwConfig::hwReady()afterallClocksReady. Asuccess = falseis treated as a fatal hardware error and triggers the abort path described below.HardwareManager::lifDigitizerShotAcquired()→AcquisitionManager::processLifDigitizerShot(). Each digitized waveform fromLifDigitizeris added to the active scan point throughLifConfig::addWaveform(); when the point completes,advancereturns true and the AM emits a freshAcquisitionManager::nextLifPoint()for the next one. After every shot,checkCompleteruns the same completion check FTMW uses.
Completion and abort paths
Every completion path ends in the same place:
AcquisitionManager::finishAcquisition() → emit
AcquisitionManager::experimentComplete() → queued connection
to BatchManager::experimentComplete() (the slot, GUI thread).
The four ways to get there:
Normal completion.
Experiment::isComplete()returns true (everyExperimentObjectivereports complete);checkCompletecallsAcquisitionManager::finishAcquisition().User abort. The user clicks the Abort button.
MainWindow::startBatch()wired the button toBatchManager::abort()for the current batch — forBatchSinglethat just setsd_complete = true; forBatchSequenceit stops the inter-experiment timer. The actual acquisition abort is the parallelHardwareManager::abortAcquisition→AcquisitionManager::abortedge installed instartBatchand triggered the same way.AcquisitionManager::abort()callsExperiment::abort()(which setsd_isAbortedand cascadesExperimentObjective::abort()to every objective) and thenAcquisitionManager::finishAcquisition().Hardware-failure abort. A previously-connected device emits
HardwareObject::hardwareFailure().HardwareManager::hardwareFailure()updates the connection-state map (see Hardware Runtime) and, if the failed device is critical, emitsHardwareManager::abortAcquisition(). ThestartBatch-installed connection routes that toAcquisitionManager::abort(). From there the path is identical to the user-abort case.Validation-failure abort. A reading delivered to
AcquisitionManager::processValidationData()falls outside its configured range. The slot callsAcquisitionManager::abort()directly. Same downstream path.
AcquisitionManager::finishAcquisition() does the same five
things in every case: stop the drain timer; flip the
d_abortProcessing atomic and wait for the in-flight worker to
exit; emit endAcquisition (broadcast through HM to every
hardware object’s endAcquisition slot); transition to
Idle; and, for non-dummy experiments, call
Experiment::finalSave() to commit the closing artifacts to
disk. Then experimentComplete is emitted and the AM releases its
reference to the experiment.
Note
AcquisitionManager::experimentComplete() is a signal;
BatchManager::experimentComplete() is a slot. They
share a name because the slot exists to consume the signal — the
queued connection between them crosses the AM thread into the GUI
thread and is what advances every batch from one experiment to the
next.
Batch-level loop
BatchManager::experimentComplete() is the central
post-experiment hook. It logs the result through
LogHandler::logMessage() (using the experiment’s
d_endLogMessage), evaluates initSuccess = exp->d_hardwareSuccess
&& exp->d_initSuccess, and decides what to do next:
initSuccesstrue: callBatchManager::processExperiment()on the just-completed experiment. ForBatchSinglethis setsd_complete = true; forBatchSequenceit incrementsd_experimentCount. Other concrete subclasses can do post-acquisition analysis here.isAbortedfalse andisCompletefalse andinitSuccesstrue: callBatchManager::beginNextExperiment(). The default implementation emitsbeginExperimentimmediately. ThestartBatch-installed lambda picks it up and callsHardwareManager::initializeExperiment()for the next experiment in the batch — back to step 1 of the lifecycle.BatchSequenceoverridesBatchSequence::beginNextExperiment()to run a one-shotQTimerfor the configured inter-experiment interval, then emitbeginExperimentfrom the timer’s lambda.Otherwise (init failure, abort, or natural batch completion): call
BatchManager::abort()if the experiment was aborted or init failed, thenBatchManager::writeReport()unconditionally, then emitBatchManager::batchComplete()carryingisAborted.
MainWindow::batchComplete() consumes that signal: it
disconnects the per-batch wiring set up in startBatch (the
auxData connection to the aux-data view widget and the
HardwareManager::abortAcquisition → AcquisitionManager::abort
edge), refreshes the status bar and progress bar, and calls
MainWindow::configureUi() with Idle so the toolbar
returns to its non-acquiring state.
The lifecycle at a glance
The full single-experiment round-trip, from the moment
MainWindow::startBatch() runs to the moment
BatchManager::experimentComplete() returns:
sequenceDiagram
autonumber
participant MW as MainWindow (GUI)
participant BM as BatchManager (GUI)
participant HM as HardwareManager (HM thread)
participant CM as ClockManager
participant HOs as HardwareObject set
participant EX as Experiment
participant AM as AcquisitionManager (AM thread)
MW->>BM: new BatchSingle / BatchSequence
MW->>MW: startBatch wires per-batch signals
MW->>HM: invokeMethod(initializeExperiment(exp))
HM->>CM: prepareForExperiment(exp)
HM->>HOs: hwPrepareForExperiment(exp) per object
HOs-->>HM: success / failure
HM-->>MW: experimentInitialized(exp) [queued]
MW->>EX: initialize() (creates dir, writes initial CSVs)
MW->>AM: invokeMethod(beginExperiment(exp))
AM->>AM: state Acquiring, start aux + drain timers
AM-->>MW: newClockSettings(clocks)
MW->>HM: invokeMethod(setClocks(clocks))
HM-->>AM: allClocksReady -> clockSettingsComplete
AM-->>HM: beginAcquisition (broadcast to HOs)
loop steady state
AM->>AM: drainFtmwBuffer / processLifDigitizerShot
AM-->>HM: auxDataSignal -> getAuxData
HM-->>AM: auxData / validationData
AM->>AM: checkComplete
end
AM->>AM: finishAcquisition (endAcquisition, finalSave)
AM-->>BM: experimentComplete [queued]
alt batch continues
BM->>BM: processExperiment, beginNextExperiment
BM-->>MW: beginExperiment (next iteration)
else batch ends
BM->>BM: abort (if needed), writeReport
BM-->>MW: batchComplete(aborted)
end
Adding a new experiment mode
Two extension points exist for new experiment modes: a new
FtmwConfig subclass (or a new FtmwConfig::FtmwType
that an existing subclass can switch on) for a new FTMW acquisition
strategy, or a new BatchManager subclass for a new
batch-level iteration policy. Both are walked through end-to-end on
Adding an Experiment Mode. The lifecycle this
page describes is what the new mode plugs into; the recipe page
covers what the subclass has to override and how to register it with
MainWindow.