LIF Acquisition and Visualization
The LIF (laser-induced fluorescence) pipeline is fundamentally simpler
than FTMW. There is no ring buffer, no thread-pool drain loop, and no
parallel byte-unpacking — every shot is delivered as a Qt signal and
the integration math runs on the acquisition thread directly. The
complexity that this page documents lives elsewhere: in the
two-dimensional (delay, laser) scan grid, in the index arithmetic
that bridges that grid to flat per-cell storage, and in the
visualization that slices the same grid two different ways.
The LIF path is signal-based end-to-end because the data volumes and
trigger rates make signal overhead a non-issue. Each
LifDigitizer shot is a small QVector<qint8>; trigger rates
are bounded by the laser repetition rate (typically ≤ 100 Hz, i.e.
two to three orders of magnitude slower than the FTMW digitizer).
At those rates per-shot QMetaCallEvent allocation is invisible,
so the FTMW pipeline’s ring-buffer plus drain-timer plus thread-pool
machinery would only add latency without buying anything back.
The LIF scan model
A LIF acquisition sweeps a two-dimensional grid:
The delay axis is the time, in microseconds, between a triggering event (typically a discharge or other gas-pulse trigger) and the laser firing. Blackchirp programs the delay onto the LIF channel of every active
PulseGeneratorviaHardwareManager::setPGenLifDelay().The laser axis is the laser position commanded to the active
LifLaser. The units are determined by the laser driver — typically wavelength in nanometers — and surface in the wizard throughBC::Key::LifLaser::unitsand::decimals.
At each (delay, laser) grid point the LIF digitizer records a
fluorescence trace. The magnitude inside a configurable integration
gate is computed by LifTrace::integrate() and becomes a
single point of the LIF spectrum. The digitizer trace itself is
preserved on disk so that gate position and filter parameters can be
re-applied without re-acquiring.
Three settings on LifConfig govern how the grid is
traversed:
LifConfig::LifScanOrder—DelayFirstcycles through every delay point at one laser position before stepping the laser.LaserFirstcycles through every laser position at one delay before stepping the delay. The scan order affects only the order in which points are visited; it does not change the on-disk layout (see Storage below).d_delayRandom— whentrue, the delay axis is randomly permuted at the start of each sweep. The permutation is rebuilt byLifConfig::initialize()and reshuffled insideLifConfig::advance()whenever a delay sweep completes. Randomization helps decorrelate slow drifts (sample condition, background) from the delay coordinate. The laser axis is always stepped sequentially.LifConfig::LifCompleteMode—StopWhenCompleteends acquisition once the grid has been fully covered.ContinueAveragingreports completion (perMilComplete() == 1000) and continues sweeping for further averaging until the user aborts. The “indefinite” flag drives the AcquisitionManager completion check.
The user-facing operation of the scan is documented in LIF Experiment Setup and LIF Configuration; the wiring that connects the wizard widgets to the fields above is the topic of Configuration UI below.
LifConfig and LifDigitizerConfig
LifConfig is the experiment objective and the runtime
cursor for a LIF acquisition. It inherits
ExperimentObjective for the lifecycle interface
(initialize(), advance(),
hwReady(), isComplete(),
indefinite(), cleanupAndSave()) and
HeaderStorage for header serialization. The class owns:
A
LifDigitizerConfig(accessible viaLifConfig::digitizerConfig()) that wraps the digitizer-side parameters: which analog channels carry the LIF signal and the optional reference signal, and the digitizer’sLifDigitizerConfig::ChannelOrder. The digitizer-shared fields (record length, sample rate, trigger, vertical scaling) come from theDigitizerConfigbase.A
LifStorage(accessible viaLifConfig::storage()) that persists raw traces and processing-gate settings.A
LifTrace::LifProcSettings(d_procSettings) that holds the integration-gate bounds and waveform-filter parameters.
The current (delay, laser) cursor is tracked on
LifConfig directly. d_currentDelayIndex and
d_currentLaserIndex are the grid coordinates;
LifConfig::currentDelay() and
LifConfig::currentLaserPos() translate them to the physical
values by
currentDelay() = d_currentDelayIndex * d_delayStepUs + d_delayStartUs;
currentLaserPos() = d_currentLaserIndex * d_laserPosStep + d_laserPosStart;
These two values are what the AM emits in the nextLifPoint signal
described next. Negative d_delayStepUs or d_laserPosStep is
permitted (the user can scan high-to-low); the storage indices remain
0-based, but the visualization layer reverses indices on the fly so
the rendered axes are monotonic. See Visualization below.
The class-level contract — every method, every storage key — is on LifConfig and LifStorage. This page covers the cross-system flow.
Acquisition flow
The LIF acquisition flow is a strict signal-based ping-pong between
AcquisitionManager (on AcquisitionManagerThread),
HardwareManager (on HardwareManagerThread), and the
two LIF hardware objects (each on its own "<hwKey>Thread").
flowchart LR
AM["AcquisitionManager<br/>(AM thread)"]
HM["HardwareManager<br/>(HM thread)"]
LL["LifLaser<br/>(hw thread)"]
PG["PulseGenerator<br/>(hw thread)"]
LS["LifDigitizer<br/>(hw thread)"]
AM -- "nextLifPoint" --> HM
HM -- "BlockingQueued<br/>setPosition + setLifDelay" --> LL
HM -- " " --> PG
HM -- "lifSettingsComplete" --> AM
LS -- "waveformRead" --> HM
HM -- "lifDigitizerShotAcquired" --> AM
The signals on the diagram are the Qt connections installed by
MainWindow::MainWindow() whenever
ApplicationConfigManager::isLifEnabled() is true:
connect(p_hwm, &HardwareManager::lifSettingsComplete,
p_am, &AcquisitionManager::lifHardwareReady);
connect(p_hwm, &HardwareManager::lifDigitizerShotAcquired,
p_am, &AcquisitionManager::processLifDigitizerShot);
connect(p_am, &AcquisitionManager::nextLifPoint,
p_hwm, &HardwareManager::setLifParameters);
The handshake at each grid point runs in five steps:
AM emits
nextLifPoint(currentDelay, currentLaserPos).AcquisitionManager::beginExperiment()fires the first one if the experiment has LIF enabled (after FTMW setup, when both objectives are active);AcquisitionManager::processLifDigitizerShot()fires every subsequent one. The signal lands queued on the HM.HM gates the digitizer and reprograms the laser and pulse generator.
HardwareManager::setLifParameters()callsLifDigitizer::setAcquisitionGated()to suppress any in-flight waveform, then issues blocking-queuedLifLaser::setPosition()andPulseGenerator::setLifDelay()calls (one per active pulse generator). After both return, the digitizer’s pre-trigger buffer is flushed viaLifDigitizer::flushAcquisitionBuffer()and the gate is released. The blocking-queued idiom is what guarantees that no shot from the previous grid point can leak into the new one.HM emits
lifSettingsComplete(success).AcquisitionManager::lifHardwareReady()is the slot. Onsuccess == falsethe AM logs an error and aborts. On success it callsLifConfig::hwReady(), which clears thed_processingPausedflag inherited fromExperimentObjective.The next laser shot triggers the digitizer. The
LifDigitizersubclass reads its acquired waveform from the instrument and emitswaveformRead(QVector<qint8>), which the HM has wired (inHardwareManager::storeConnection()on theLifDigitizerbranch) to its ownHardwareManager::lifDigitizerShotAcquired(). The HM signal relays the sameQVector<qint8>toAcquisitionManager::processLifDigitizerShot().AM accumulates and advances.
processLifDigitizerShot()checks that the AM is in theAcquiringstate and thatd_processingPausedis clear, then:Calls
LifConfig::addWaveform(), which constructs aLifTracefrom the bytes (using the cachedLifDigitizerConfigfor sample-rate and y-multiplier scaling) and forwards it toLifStorage::addTrace()for accumulation in the current cell.Emits
lifPointUpdate()for the GUI.Calls
LifConfig::advance().advancereturnstruewhen the current cell has reached its shot target, handles the random-shuffle if a delay sweep just completed, advancesd_currentDelayIndex/d_currentLaserIndexaccording tod_order, and callsLifStorage::advance()to flush the just-completed cell to disk. Whenadvancereturnstrueand the experiment as a whole is not yet complete, the AM emits a freshnextLifPointfor the new cursor — the loop returns to step 1.Emits
lifShotAcquired(perMilComplete)to drive the main window’s LIF progress bar.
The gate inside processLifDigitizerShot() —
d_processingPaused — is what protects step 4’s
waveformRead from being mis-attributed to the previous grid
point: the AM ignores any shot that arrives before the matching
lifHardwareReady(success) has cleared the flag. The blocking
queue inside HardwareManager::setLifParameters() makes the
flag’s lifetime well-defined — the HM only emits
lifSettingsComplete after both the laser move and the pulse-delay
write have returned.
Completion is the same predicate the FTMW path uses:
AcquisitionManager::checkComplete() consults
Experiment::isComplete(), which is true when every
enabled objective reports complete.
LifConfig::isComplete() returns true once the first
full sweep has finished (d_complete = true set inside
advance()); under
LifConfig::ContinueAveraging the objective also reports
indefinite() == true once perMilComplete() reaches 1000, so
the experiment continues until the user aborts. Under
LifConfig::StopWhenComplete the experiment finishes the
moment the first sweep ends; subsequent waveforms are dropped on the
floor by the early-return guard in LifConfig::addWaveform().
Storage: the 2D grid and its flattening
LifStorage extends DataStorageBase and is
shared between the AM (writer) and the GUI (reader) via
std::shared_ptr. The grid dimensions are fixed at construction:
d_delayPoints rows × d_laserPoints columns. Cells are
identified by a (delayIndex, laserIndex) pair.
Internally, LifStorage keeps a std::map<int, LifTrace>
keyed by a single flat integer. The flattening convention is
laser-fastest, row-major:
int LifStorage::index(int dp, int lp) const
{
return dp * d_laserPoints + lp;
}
so iterating index from 0 upward visits all laser positions for
delayIndex = 0 before stepping delayIndex to 1. The same
integer is used as the on-disk filename stem (lif/<index>.csv),
which means the on-disk file ordering is independent of the
``LifScanOrder`` chosen for the live traversal. DelayFirst and
LaserFirst differ only in the order cells are visited and saved;
the file layout is identical.
The illustration below shows the cell visit order for a 3×4 grid
under each scan order; the bracketed numbers are the flat
index values written to disk:
Grid layout (delay rows × laser columns)
--------------------------------------
laser →
[0] [1] [2] [3]
delay [0] 0 1 2 3
delay [1] 4 5 6 7
delay [2] 8 9 10 11
DelayFirst visit order:
(0,0) (1,0) (2,0) → advance laser
(0,1) (1,1) (2,1) → advance laser
...
LaserFirst visit order:
(0,0) (0,1) (0,2) (0,3) → advance delay
(1,0) (1,1) (1,2) (1,3) → advance delay
...
Either order writes to the same flat index for a given
(delayIndex, laserIndex) cell.
Inside the storage, three structures coexist:
d_currentTrace— the trace currently being accumulated. The AM feeds shots into it viaLifStorage::addTrace().d_data— the completed-cell map. Cells move fromd_currentTraceintod_datawhenLifStorage::advance()(and its internalsave()) flushes the cell to disk.d_nextNew— a one-bit state flag. Afteradvance, the next call toaddTrace()re-seedsd_currentTracefromd_data(if the cell already has prior shots, e.g. on aContinueAveragingre-sweep) or installs the incoming trace as the seed.
The mutex on DataStorageBase (pu_mutex) coordinates
the AM writer with GUI readers exactly as it does for FTMW: every
read or write takes the lock; readers ask for LifStorage results
by value, so they get a stable snapshot. The cost is one
LifTrace copy per refresh — bounded, because
LifTrace’s payload is wrapped in
QSharedDataPointer<LifTraceData>, so the copy is shallow until a
mutator modifies the data.
Processing-gate persistence
The integration gate and waveform-filter parameters live in
d_procSettings on LifConfig and are persisted
separately from the trace files. When the experiment starts,
LifConfig::initialize() calls
LifStorage::writeProcessingSettings() to write the active
LifTrace::LifProcSettings into lif/processing.csv (under the
keys declared in BC::Key::LifStorage —
LifGateStartPoint, LifGateEndPoint, RefGateStartPoint,
RefGateEndPoint, LowPassAlpha, SavGolEnabled,
SavGolWindow, SavGolPoly). When an experiment is reopened,
LifConfig::loadLifData() reads the same file via
LifStorage::readProcessingSettings() and pushes the result
back onto d_procSettings, so the viewer reproduces the same
spectrum without re-asking the user. The user-facing semantics of
each key are documented in LIF Data Storage.
The recorded trace files on disk are never modified by the
processing-gate workflow. Save in the LIF tab’s processing panel
overwrites only processing.csv; Reprocess All re-integrates
the in-memory traces using the new gate but does not touch the
lif/<index>.csv files. The trace-file format itself is
documented on LIF Data Storage.
There is no LIF analog to FidPeakUpStorage. A
“LIF peak-up” mode is implicit in LifControlWidget’s
Hardware → LIF Configuration dialog: that dialog runs a live
LifTracePlot rolling-average against fresh
waveformRead shots without ever constructing a
LifStorage, because the user is verifying gating and
laser alignment, not acquiring a scan. Production data is acquired
only inside an experiment, which always uses
LifStorage.
Visualization
The LIF tab is implemented by LifDisplayWidget
(gui/lif/gui/lifdisplaywidget.{cpp,h}). It hosts four plot areas
plus the processing panel:
LifTracePlot — the most-recently-acquired raw trace as a function of sample time. The shaded zones show the LIF and reference integration gates. Drives the visual confirmation that the gate covers the fluorescence pulse correctly.
LifSlicePlot (delay slice) — integrated LIF signal versus delay, at the laser-position column selected on the spectrogram.
LifSlicePlot (laser slice) — integrated LIF signal versus laser position, at the delay row selected on the spectrogram.
LifSpectrogramPlot — the full 2D map. Implemented as a Qwt
QwtPlotSpectrogramdriven by aQwtMatrixRasterData. The matrix is laid out laser-fastest (setValueMatrix(specDat, d_laserPosPoints)), matching the storage’s flattening convention so the matrix index can be computed asli + di * lp.
The widget consumes the storage in two passes:
Per-shot integration. When the AM emits
lifPointUpdate,LifDisplayWidget::updatePoint()readsLifStorage::currentLifTrace(), integrates it with the activeLifProcSettings, and writes the integrated value intod_currentIntegratedData[li + di * lp]. It also tells the spectrogram which cell is “live” so the live-cursor markers track the acquisition.Periodic redraw. A
QObject::startTimer()(interval from the Refresh Interval spin box, default 500 ms, persisted underBC::Key::LifDW::refresh) ticksLifDisplayWidget::updatePlots(), which pushesd_currentIntegratedDataintoLifSpectrogramPlot::updateData()and refreshes the two slice plots and the trace plot. Decoupling per-shot integration from per-tick redraw keeps the AM thread responsive and bounds the GUI’s draw cost.
When the user drags a cursor on the spectrogram or invokes the
right-click “Move cursor here” menu, the spectrogram emits
laserSlice(int delayIndex) or delaySlice(int laserIndex);
the widget translates those into a fresh
LifStorage::getLifTrace() call to feed the trace plot and
into pre-computed slices through d_currentIntegratedData. The
slice helpers laserSlice and delaySlice walk the same flat
buffer along the appropriate stride — laser slice steps by 1 inside a
fixed delay row, delay slice steps by d_laserPoints between rows.
Reverse-step axes
The user is allowed to configure a negative d_delayStepUs or
d_laserPosStep (a high-to-low scan). Storage indices remain
0-based and ascending, so the display widget keeps two boolean
flags — d_delayReverse and d_laserReverse — and applies an
index → (size - 1 - index) flip whenever it converts between a
storage index and a display index. The spectrogram itself always
draws with monotonic axes.
Reprocessing and processing settings
The processing panel is LifProcessingWidget
(gui/lif/gui/lifprocessingwidget.{cpp,h}). It owns spin boxes
for the LIF and reference gate bounds, a \(\\alpha\) spin for the
single-pole IIR low-pass filter, an enable check plus window/order
spins for the Savitzky-Golay filter, and three buttons:
Reprocess All triggers
LifDisplayWidget::reprocess(), which re-integrates every(di, li)cell in storage with the currentLifProcSettings. The grid walk runs on aQtConcurrentworker tracked by aQFutureWatcher<QVector<double>>(p_reprocessWatcher), not on the UI thread: a 50×50 sweep would otherwise block the GUI for seconds. A modalQProgressDialog(200 ms minimum duration) reports progress and a Cancel button flipsQPromise::isCanceled()so the worker bails between traces. The watcher’sfinishedslot writes the result intod_currentIntegratedDataand redraws on the main thread; a cancelled pass leaves the spectrogram empty. A re-entrancy guard ignores a second trigger while a pass is still in flight. The per-cell parameter lookup is O(1) becauseLifStoragecaches the parsedlifparams.csvon first trace load.Reset triggers
LifDisplayWidget::resetProc(), which re-readsprocessing.csvviaLifStorage::readProcessingSettings()and pushes the on-disk values back into the spin boxes.Save triggers
LifDisplayWidget::saveProc(), which callsLifStorage::writeProcessingSettings()with the currentLifProcessingWidget::getSettings(). This is the only path that mutatesprocessing.csv; the trace files are never rewritten.
The LifTracePlot instance hosted on the same tab subscribes to
LifProcessingWidget::settingChanged() and re-draws its
gate zones whenever the user nudges any of the spin boxes, so the
trace view tracks the panel without going through Reprocess
All.
The user-facing behavior of the LIF tab — what each control does, how the cursors interact with the slices — is documented in Viewing LIF Data; this page covers only the data flow and the class wiring.
Configuration UI
The wizard exposes LIF in two places. They are separate pages because they serve different concerns: the first defines the experiment (scan grid plus traversal options); the second defines the hardware configuration used to acquire it (digitizer settings, shots per point, processing gate).
Experiment-type page —
ExperimentTypePage(gui/expsetup/experimenttypepage.{cpp,h}) is the wizard’s first page and contains the FTMW group plus, when the LIF module is enabled inApplicationConfigManager, an LIF group. The LIF group hosts the Delay panel (start / step / points / read-only end), the Laser panel (laser-driver-supplied range and units), and an Options panel (scan order, complete mode, auto-disable-flashlamp checkbox, randomize-delay checkbox).ExperimentTypePage::apply()writes every one of those knobs onto the experiment’sLifConfig:d_delayStartUs,d_delayStepUs,d_delayPoints,d_laserPosStart,d_laserPosStep,d_laserPosPoints,d_completeMode,d_order,d_disableFlashlamp,d_delayRandom.LIF configuration page —
ExperimentLifConfigPage(gui/lif/gui/experimentlifconfigpage.{cpp,h}) is the per-LIF wizard page that wraps aLifControlWidget. The control widget is shared with the live Hardware → LIF Configuration dialog (seeMainWindow::launchLifConfigDialog()) and hosts: the liveLifTracePlot, aDigitizerConfigWidgetkeyed against the activeLifDigitizer, the laser control (LifLaserWidget), a shots-per-point spin,LifProcessingWidget, and Start / Stop / Reset buttons.LifControlWidget::toConfig()writesd_shotsPerPoint,d_procSettings, and the digitizer configuration ontoLifConfig;LifControlWidget::setFromConfig()is the inverse for loading a saved experiment.
The Hardware → LIF Configuration dialog uses the same
LifControlWidget outside of an experiment to drive the
laser and digitizer manually. The Start Acquisition button emits
startSignal(LifConfig) which the HM converts to
HardwareManager::startLifConfigAcq(); incoming
lifDigitizerShotAcquired shots flow into the embedded
LifTracePlot for live alignment work, with no
LifStorage constructed.
The status box on the main hardware status panel
(LifLaserStatusBox) listens to
lifLaserPosUpdate(double) and lifLaserFlashlampUpdate(bool)
on the HM, which the HM in turn forwards from its connection to the
LifLaser’s laserPosUpdate and
laserFlashlampUpdate signals.
Pointers
LIF storage layout, on-disk format, base-36 encoding, and ``processing.csv`` semantics. See LIF Data Storage and LifStorage.
Per-class API contracts. LifConfig, LifStorage, DataStorageBase, AcquisitionManager. The LIF plot widgets do not currently have dedicated API pages; they are documented inline on this page only.
The cross-manager experiment lifecycle that surrounds the LIF
loop — wizard apply, hardware initialization, and the
experimentInitialized → beginAcquisition →
experimentComplete round-trip — is on
Experiment Lifecycle. The LIF-specific path
covered here begins after beginAcquisition arrives at the AM and
ends when AcquisitionManager::checkComplete() reports
completion.