Adding an Experiment Mode
Two extension points control how Blackchirp runs an acquisition: the
FtmwType enum (chosen on the experiment-setup wizard) decides how a
single FTMW experiment terminates and whether it traverses multiple
segments, and the BatchManager hierarchy decides how
many experiments make up a single user “Acquire” action and what
schedules them. This page is one recipe per extension point. The two
sections are independent — adding a new FtmwType does not touch
BatchManager, and a new BatchManager
subclass works with every existing FtmwType — but they share the
same shape (a small enum, a concrete subclass, a wizard or dialog
wiring, an API-page extension), so they are presented together. The
cross-manager experiment-lifecycle flow they both plug into is on
Experiment Lifecycle.
Note
Larger additions that step outside these two extension points —
a new ExperimentObjective peer alongside FtmwConfig
and LifConfig (for example, a new spectroscopy modality
that does not fit either), or any other change that touches the
Experiment aggregate, the AcquisitionManager
loop, or the persistence layer — go beyond the scope of this page.
Those are coordinated multi-subsystem changes; please open a
discussion on the Blackchirp Discord or a tracking issue on the
GitHub issues board so the design can be sketched before
implementation begins.
Section A — A new FtmwType
When to add a new type
The existing values of FtmwConfig::FtmwType are
Target_Shots, Target_Duration, Forever, Peak_Up,
LO_Scan, and DR_Scan. Each is the combination of a
completion criterion and a segment-traversal pattern. The
existing six divide along those two axes:
|
Completion criterion |
Segments |
Concrete subclass |
|---|---|---|---|
|
Accumulated shot count reaches |
Single |
|
|
Wall clock reaches the recorded target time. |
Single |
|
|
Never; |
Single |
|
|
Never; rolling average runs until the user stops. |
Single (transient) |
|
|
All LO sweep steps and |
Multi |
|
|
All DR scan steps complete one full sweep. |
Multi |
The threshold for adding a new type rather than parameterizing an
existing one: if the new mode’s completion logic can be expressed by
tuning d_objective on an existing subclass, parameterize. If it
requires a new _init() / createStorage() / isComplete() /
perMilComplete() implementation, or a new segment-traversal
pattern, add a new type. Cross-link policies (a “scan a parameter
through values” mode that re-runs an entire experiment per parameter
value) belong on the BatchManager side instead — see
Section B.
The touches: enum, subclass, factory, wizard, API page
A new FtmwType is a five-touch change. The first three live in
data/experiment/; the last two live in gui/expsetup/ and
doc/source/classes/.
Add the enumerator. Append the new value to
FtmwConfig::FtmwTypeinsrc/data/experiment/ftmwconfig.h. The enum carriesQ_ENUM(FtmwType); the wizard’sQComboBoxand Qt’s metaobject system pick the new value up automatically. The serialized header field that stores the mode (BC::Store::FTMW::ftType) maps to the enumerator’s integer value, so do not reorder existing enumerators when appending — reordering breaks experiment header round-trips.Add a concrete subclass. Declare
FtmwConfigMyModeinsrc/data/experiment/ftmwconfigtypes.hnext to the existing six and implement it in the matching.cppfile. The subclass inheritsFtmwConfigand overrides:FtmwConfig::_init()— initialize mode-specific state at acquisition start. For multi-segment modes, this is where you populated_rfConfigwith the segment list using theRfConfighelpers (see RfConfig); for wall-clock modes, this is where you record the start time and compute the target time.FtmwConfig::_prepareToSave()andFtmwConfig::_loadComplete()— header round-trip serialization for any mode-specific scalars. Use theHeaderStorage::store/HeaderStorage::retrievehelpers against keys declared in a per-modeBC::Store::FtmwMyModenamespace, in the same style asBC::Store::FtmwLOandBC::Store::FtmwDRinftmwconfigtypes.h.FtmwConfig::createStorage()— return a shared pointer to the rightFidStorageBasesubclass for the mode. Pick from the three existing storage classes; see Storage choice below.FtmwConfig::isComplete()— completion predicate.FtmwConfig::perMilComplete()— progress in per-mille (0–1000); the value drives the GUI progress bar.FtmwConfig::completedShots()— total shot count for progress reporting and the per-experimentFtmw/Shotsaux reading.
Optional overrides:
FtmwConfig::indefinite()— returntrueto suppress the standard completion check;Foreveris the only existing user.FtmwConfig::bitShift()— return a non-zero shift to widen the rolling-average accumulator;Peak_Upreturns 8 so each ADC sample is multiplied by 256 before accumulation.FtmwConfig::advance()— multi-segment modes override to step the segment cursor and returntruewhen a segment boundary was crossed. The drain-loop / flush-marker mechanics that driveadvance()are documented in FTMW Acquisition and Visualization.
Provide both constructors used by the existing concretes — one that takes the FTMW digitizer’s hardware key (
const QString& digitizerHwKey), and one that takes aconst FtmwConfig &for the deserialization path that constructs a typed subclass from a base-class value object.Update the factory.
Experiment::enableFtmw()insrc/data/experiment/experiment.cppis the single dispatch fromFtmwTypeto a concrete subclass. Add acasefor the new enumerator that constructs the new subclass with the discovered FTMW digitizer key, setsps_ftmwConfig->d_type(the trailing assignment after the switch already handles this), and inserts it intod_objectives. There is no second factory site;FtmwConfig’s deserialization path constructs aFtmwConfigSinglefrom a header round-trip and then narrows it, so a header that records the newFtmwTypewill round-trip correctly as long as the factory case exists.Wire the type into the experiment-setup wizard.
ExperimentTypePage(src/gui/expsetup/experimenttypepage.{cpp,h}) is the entry point. The constructor populates theTypeQComboBoxfrom the metaobjectFtmwTypeenum, so a new enumerator is listed automatically. Two pieces still need editing:The
QStackedWidgetpage selector (ExperimentTypePage::configureUI()) routes eachFtmwTypeto the widget that exposes its mode-specific parameters — the shots spinner, the duration spinner, an empty placeholder forForever, or a richer widget forLO_Scan/DR_Scan. Add acasefor the new mode and either route it to one of the existing widgets or construct a newMyModeConfigWidget.LOScanConfigWidgetandDRScanConfigWidget(insrc/gui/expsetup/) are the models for a richer per-mode page; both derive fromExperimentConfigPageso they participate in the wizard’s setting-storage round-trip.ExperimentTypePage::apply()constructs theFtmwConfigby callingExperiment::enableFtmw()and populatingd_objective. Add acasefor the new mode that reads its mode-specific spinner and setsd_objectiveappropriately, and call your new config widget’sapply()hook if it has parameters of its own.
The wizard’s page-ordering logic (
ExperimentSetupDialog::pageVisitedand friends insrc/gui/expsetup/experimentsetupdialog.{cpp,h}) walks a fixed set of pages in sequence; a mode that needs an additional standalone wizard page after the type page should insert that page into the ordering there as well.Extend the API page.
doc/source/classes/ftmwconfig.rstalready lists.. doxygenclass::directives for each of the six concrete subclasses on the same page. Append a new.. doxygenclass:: FtmwConfigMyModeblock in the same style; if the new subclass introduces a per-mode keys namespace (analogous toBC::Store::FtmwLO), let the Doxygen comments on the namespace surface through the page rather than duplicating them in prose. The Doxygen-comment style contract that the API ref enforces is API reference style.
Multi-segment vs. single-segment design
The single-vs-multi axis decides which storage class to construct
and whether advance() does any work beyond the autosave hook
that the base class already provides.
Single-segment modes (
Target_Shots,Target_Duration,Forever,Peak_Up) accumulate into one segment for the entire acquisition. The storage class isFidSingleStorage(orFidPeakUpStoragefor the no-disk peak-up mode).advance()keeps the base-class behavior — periodic autosave driven by the experiment’sd_backupIntervalMinutes— and does not returntruefor a segment boundary.Multi-segment modes (
LO_Scan,DR_Scan) populated_rfConfigin_init()with a list of segments and useFidMultiStorage, which stores each segment’s FID data under a separatefid/<i>.csvfile. The drain loop inAcquisitionManagerperiodically callsFtmwConfig::advance(); whenadvance()returnstruethe AM emitsAcquisitionManager::newClockSettings()carrying the next segment’s clock list, and thesetAcquisitionGated+ flush-marker round-trip described in FTMW Acquisition and Visualization quiesces the digitizer while the new clocks are programmed.LO_ScanandDR_Scanare the canonical examples to model from; they extendFtmwConfig::createStorage()to callFidMultiStorage::setNumSegmentsafter construction.
A multi-segment mode also typically writes a backup at every segment
boundary; the user-guide notes for LO_Scan and DR_Scan
mention that the Backup Interval setting therefore has no effect
for those modes.
Completion: shot-based, wall-clock, indefinite
Three patterns cover every existing mode:
Shot-based.
FtmwConfig::isComplete()comparescompletedShots()againstd_objective;FtmwConfig::perMilComplete()returns1000 * completedShots() / d_objective. The user-supplied target shot count is collected byExperimentTypePagefrom theShotsspinner and assigned tod_objectiveinExperimentTypePage::apply().Target_Shotsis the single-segment example;LO_ScanandDR_Scanuse the samed_objectivefield as a per-segment target and derive total progress from theRfConfig’s segment counts.Wall-clock. Record the start time in
_init()and compute the target time fromd_objective(the units are mode-specific —Target_Durationuses minutes).FtmwConfig::isComplete()comparesQDateTime::currentDateTime()against the target;FtmwConfig::perMilComplete()interpolates the elapsed fraction.Target_Durationis the canonical example._prepareToSave()and_loadComplete()round-tripd_objectiveso the recorded duration is preserved on disk.Indefinite.
FtmwConfig::indefinite()returnstrueandFtmwConfig::isComplete()always returnsfalse. TheAcquisitionManagercompletion check skips the experiment, and the user must stop acquisition with the abort button.Foreveris the existing example;Peak_Upis a variant that is also indefinite but tracks shots towardd_objectivefor progress display.
Storage choice
Pick the FidStorageBase subclass that matches the
mode’s segment shape and persistence requirements:
Mode shape |
Storage class |
Existing modes |
|---|---|---|
Single segment, on-disk |
|
|
Single segment, transient (no disk I/O) |
|
|
Multi-segment, on-disk |
|
|
A new mode picks whichever fits. If none fit — for example, a mode
that needs a non-FID raw-data accumulator, or per-segment files with
a layout the existing classes do not produce — consider whether a
new FidStorageBase subclass is justified. That is rare,
and lives in src/data/storage/ rather than this recipe’s scope;
the DataStorageBase lifecycle a new storage class
plugs into is documented on Persistence,
and the FTMW-specific pipeline that writes through it is on
FTMW Acquisition and Visualization.
Section B — A new BatchManager subclass
When to add a new subclass
Two concrete subclasses ship today:
Class |
|
Policy |
|---|---|---|
|
|
Run one experiment, then end the batch. |
|
|
Repeat one experiment template on a fixed interval until the configured count is reached or the user aborts. |
The threshold for a new subclass: any policy that cannot be
expressed by varying the interval or count on
BatchSequence. Examples that would justify a new
subclass: an “until N successful experiments” policy that filters
out aborted runs, a “scan a parameter through values” policy that
mutates each cloned experiment before launching it, an
externally-triggered “run on cue” policy that waits on a TCP
notification rather than a timer.
The touches: enum, subclass, dialog, MainWindow entry, API page
A new BatchManager subclass is also a five-touch
change.
Add the enumerator. Append to
BatchManager::BatchTypeinsrc/acquisition/batch/batchmanager.h. The enumerator is passed to the base constructor and stored ind_typefor downstream code that branches on the active batch type.Subclass
BatchManagerinsrc/acquisition/batch/<myname>.{cpp,h}. The base class declares five pure virtuals; implement all of them. Each is documented in BatchManager; the per-method contracts in summary:BatchManager::currentExperiment()— return the active experiment shared pointer. Must never be null while the batch is running. Called by the main window when emittingbeginExperiment()and byBatchManager::experimentComplete()to inspect the just-finished experiment.BatchManager::isComplete()— returntruewhen no further experiments remain. Evaluated byBatchManager::experimentComplete()afterBatchManager::processExperiment()returns.BatchManager::abort()— mark the batch as complete soisComplete()returnstrueand release any pending timers or queued work. The user’s abort button connects directly to this slot.BatchManager::processExperiment()— post-acquisition bookkeeping for the experiment that just finished. May be a no-op (BatchSingleflips itsd_completeflag here;BatchSequenceincrements its count).BatchManager::writeReport()— write any per-batch summary report. May also be a no-op; both shipping subclasses leave it empty (see Persistence below).
Optional override:
BatchManager::beginNextExperiment()— defaults to emittingbeginExperiment()immediately. Override when the next experiment should not start right away — for exampleBatchSequencearms a single-shotQTimerand emitsbeginExperiment()from the timer’stimeoutlambda after cloning a freshExperimentfrom the template.
Wire a configuration dialog. Model on
BatchSequenceDialoginsrc/gui/dialog/. The dialog’s responsibility is to collect the parameters the new policy needs (count, interval, value list, trigger source — whatever applies) from the user and to remember the last-used values viaSettingsStorage; the keysBC::Key::SeqDialog::keyand thenumExpts/intervalkeys inbatchsequencedialog.hare the convention to follow. Existing dialogs distinguish a Quick path (re-use a previous experiment) from a Configure path (run the full setup wizard); replicate that pattern only if the policy reasonably supports both.Add a MainWindow entry point. Add a new menu action in the
Acquiremenu (the menu construction lives inMainWindow::MainWindow()) and connect it to a newMainWindowslot that opens the dialog, builds theExperiment(viaMainWindow::createExperiment()and the experiment wizard, or via the quick-experiment dialog), constructs the newBatchManagersubclass, and callsMainWindow::startBatch().MainWindow::startSequence()is the closest model — its branch structure is the same shape any new entry point will need.Extend the API page. Add a
.. doxygenclass:: MyBatchdirective todoc/source/classes/batchmanager.rstnext to the existingBatchManagerblock. If the policy introduces a non-obvious lifecycle wrinkle, append a paragraph to the Subclassing guide section on that page; otherwise the Doxygen comments are sufficient.
Building the wiring
The five overrides interact with the base class’s
BatchManager::experimentComplete() slot, which is the
hub of the per-batch loop. The slot’s decision tree (logged result
→ optional processExperiment → isComplete → either
beginNextExperiment or writeReport + batchComplete) is
documented at length in State machine. Three
points are worth restating from the subclass author’s perspective:
The subclass owns the next experiment. Whether it stores a template and clones from it (
BatchSequence), holds the only experiment shared pointer (BatchSingle), or constructs each experiment on demand from a parameter list, only the subclass decides how the nextExperimentcomes into existence. The base class never constructs anExperiment.BatchManager::processExperiment()is the place to look at the just-completed experiment’s data (numeric outputs, validation flags, derived values) and update aggregate state. No batch type does any data analysis here today; both shipping implementations only mutate the loop counter or the completion flag. A subclass that does want to inspect data should keep the work brief because the slot runs on the GUI thread (the comment inBatchManager::experimentComplete()flags this as a future cleanup).beginNextExperimentis the natural place to insert any inter-experiment delay or wait.BatchSequenceuses aQTimerfor a fixed interval; an externally-triggered batch would arm a TCP listener and emitbeginExperiment()from the listener’s slot; an interactive batch would pop a modal dialog and emitbeginExperiment()on its accepted signal. Whatever waiting state the subclass enters,abort()must cancel it cleanly.
Coordination with the AcquisitionManager
AcquisitionManager does not know which batch type is
running. It emits AcquisitionManager::experimentComplete()
unconditionally at the end of every experiment, and the connection
MainWindow::startBatch() installs from that signal to the
BatchManager slot of the same name is what advances
the batch. The signal fires on the AM thread; the slot runs on the
GUI thread; the connection is queued so the cross-thread
Experiment access is delivered serially. Subclass
authors do not manage that connection — it is set up and torn down
by MainWindow::startBatch() and
MainWindow::batchComplete() for the duration of the
batch.
The cross-manager flow that fires that signal — hardware setup,
acquisition steady state, end-of-experiment teardown — is the
topic of Experiment Lifecycle. The
BatchManager slot’s internal decision tree is on
State machine. This page does not duplicate
either; from the subclass author’s perspective the AM is a black
box that calls experimentComplete() and the batch’s job is to
either advance the loop or end it.
Persistence
Two persistence questions arise for a new batch type. Both have established conventions on the configuration side and an open recommendation on the report side.
Dialog configuration. The batch’s configuration — the
parameters the user chose in the dialog (count, interval, value
list, trigger details) — should persist across application
invocations so the dialog re-opens with the user’s last choices.
The convention is the one BatchSequenceDialog uses: the dialog
inherits SettingsStorage, declares a
BC::Key::<MyBatch> namespace with one QLatin1StringView per
field, and reads/writes through get and set (or the
lower-level SettingsStorage::setDefault() /
SettingsStorage::save() for default-on-first-run
behavior). The persistence model that backs QSettings is
documented on Persistence.
Report generation. BatchManager::writeReport() is a
pure virtual on the base class, but neither shipping subclass
generates a report — both BatchSingle::writeReport and
BatchSequence::writeReport are no-ops, and there is no
on-disk convention for where a batch report would live. A new
batch type that does want to generate a report needs both an
implementation and a destination, and the destination is currently
unspecified.
The recommended layout, when this becomes necessary, is a
batch/ top-level folder at the application’s Data Storage
Location, peer to the per-experiment numeric directories (and
peer to the existing rollingdata/, log/, and
textexports/ auxiliary streams documented on
Persistence). The Data Storage Location is
created at first launch through BcSavePathWidget (driven by
BcSavePathDialog at first run and reachable from the
application configuration thereafter); a new batch/ peer
would need wiring into both the first-launch creation flow and
the change-of-DSL flow that ApplicationConfigManager
coordinates. Until that landed, treat report generation as
genuinely unspecified — implement writeReport as a no-op (or
log via BatchManager::logMessage() /
BatchManager::statusMessage() for a transient summary)
rather than picking an ad-hoc on-disk location that future code
will have to migrate.