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 ExperimentMainWindow::startExperiment() opens ExperimentSetupDialog (the full configuration wizard), then constructs BatchSingle (BatchType::SingleExperiment).

  • Quick ExperimentMainWindow::quickStart() opens QuickExptDialog to repeat or reconfigure a previous experiment, then constructs BatchSingle.

  • Start SequenceMainWindow::startSequence() opens BatchSequenceDialog (count + interval), optionally re-runs the wizard or the quick-experiment dialog, then constructs BatchSequence (BatchType::Sequence). The sequence re-uses one experiment template, cloning a fresh Experiment from 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:

  1. Configure clocks. ClockManager::prepareForExperiment() reads the clock map from exp.ftmwConfig()->d_rfConfig.getClocks(), calls ClockManager::configureClocks() to resolve each RfConfig::ClockType role to a Clock and apply its multiplication factor, then writes the achieved frequencies back into the experiment with RfConfig::setCurrentClocks(). Non-FTMW experiments skip this step. Failure short-circuits the rest of the routine.

  2. Prepare every hardware object. The manager iterates d_hardwareMap and dispatches HardwareObject::hwPrepareForExperiment() to each device. For threaded drivers the call goes across a Qt::BlockingQueuedConnection so the success value is delivered synchronously. The base wrapper does two things every driver gets for free: if the device is currently disconnected it tries HardwareObject::testConnection() once, and if that test fails on a critical device (d_critical == true, the default) it sets exp.d_errorString and returns false. A non-critical disconnect is treated as success and the per-experiment HardwareObject::prepareForExperiment() override is bypassed for that device. The first failure breaks the loop and sets exp.d_hardwareSuccess = false.

  3. Sanity-check LIF. When exp.lifEnabled() is true the manager confirms that an active LifLaser exists in RuntimeHardwareConfig. A missing laser emits HardwareManager::lifSettingsComplete() with success = false and 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_hardwareSuccess is false, the slot calls BatchManager::experimentComplete() directly so the batch is told the experiment is over before it ever started, restores the UI to Idle, 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 in SettingsStorage, creates the on-disk directory via BlackchirpCSV::createExptDir(), walks the d_objectives set so each ExperimentObjective initializes 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 marked d_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 calls MainWindow::configureUi() with Acquiring so 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 AcquisitionManager owns 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:

  1. Stores the experiment, transitions to Acquiring, and emits statusMessage("Acquiring") for the status bar.

  2. When exp->d_timeDataInterval > 0, registers the per-timestamp FTMW aux keys (Ftmw/Shots always; Ftmw/ChirpRMS when chirp scoring is on; Ftmw/ChirpPhaseScore and Ftmw/ChirpShift when phase correction is on), calls AcquisitionManager::auxDataTick() once to seed the first point, and starts a Qt timer using startTimer for the periodic aux refresh.

  3. For FTMW experiments, emits AcquisitionManager::newClockSettings() carrying ftmwConfig->d_rfConfig.getClocks(). The signal is consumed on the GUI thread by MainWindow::clockPrompt() (see the Clock settings round-trip note below); the GUI ultimately calls HardwareManager::setClocks(), which gates the FtmwDigitizer while the clock frequencies change, walks ClockManager to apply each value, ungates the scope, and emits HardwareManager::allClocksReady(). That signal is wired statically to AcquisitionManager::clockSettingsComplete(), which stores the achieved frequencies in ftmwConfig->d_rfConfig.setCurrentClocks and calls FtmwConfig::hwReady(). Calling hwReady clears d_processingPaused so waveform processing can begin.

  4. Emits AcquisitionManager::beginAcquisition(). The signal is wired to HardwareManager::beginAcquisition(), which re-emits the broadcast onto every HardwareObject’s beginAcquisition slot — devices start triggering, the digitizer’s WaveformBuffer becomes the data conduit.

  5. For FTMW experiments with a live waveform buffer, allocates the processing QFutureWatcher, starts the 20 ms drain timer, and connects its timeout to AcquisitionManager::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.

  6. 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:

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:

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:

  • initSuccess true: call BatchManager::processExperiment() on the just-completed experiment. For BatchSingle this sets d_complete = true; for BatchSequence it increments d_experimentCount. Other concrete subclasses can do post-acquisition analysis here.

  • isAborted false and isComplete false and initSuccess true: call BatchManager::beginNextExperiment(). The default implementation emits beginExperiment immediately. The startBatch-installed lambda picks it up and calls HardwareManager::initializeExperiment() for the next experiment in the batch — back to step 1 of the lifecycle. BatchSequence overrides BatchSequence::beginNextExperiment() to run a one-shot QTimer for the configured inter-experiment interval, then emit beginExperiment from the timer’s lambda.

  • Otherwise (init failure, abort, or natural batch completion): call BatchManager::abort() if the experiment was aborted or init failed, then BatchManager::writeReport() unconditionally, then emit BatchManager::batchComplete() carrying isAborted.

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::abortAcquisitionAcquisitionManager::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.