Single FID Example

Note: This notebook currently runs against synthetic data captured with Blackchirp 2.0 against its built-in virtual hardware. A refresh using real instrument data is planned and will land here without any change to the surrounding narrative or API surface.

This notebook walks through the basic CP-FTMW data-processing surface of the Blackchirp Python module against a small Blackchirp 2.0 fixture shipped with the source tree (python/example-data/v2-ftmw/). It is a single-FID Forever acquisition with three intermediate backups, captured with label-based hardware identifiers, the four-channel default marker set, and string-form enum values in processing.csv and fidparams.csv.

The package is on PyPI (pip install blackchirp); the recommended import style is from blackchirp import *.

[1]:
from blackchirp import *
from matplotlib import pyplot as plt

Loading and Inspecting an Experiment

Pass BCExperiment the path to the experiment folder (the one that contains version.csv). The constructor reads every per-experiment CSV present and exposes each as a pandas DataFrame attribute named after the file.

[2]:
ls example-data/v2-ftmw/
auxdata.csv  clocks.csv  hardware.csv  log.csv      objectives.csv
chirps.csv   fid/        header.csv    markers.csv  version.csv

Once loaded, the contents of any of those CSVs may be inspected directly. Here we look at the header.

[3]:
exp = BCExperiment('./example-data/v2-ftmw/')

exp.header
[3]:
ObjKey ArrayKey ArrayIndex ValueKey Value Units
0 ChirpConfig <NA> ChirpInterval 20 μs
1 ChirpConfig <NA> SampleInterval 6.25e-05 μs
2 ChirpConfig <NA> SampleRate 16000 MHz
3 Experiment <NA> BCBuildVersion 7a31048a624bfe849eba0a820c7c4e1dc28ea270
4 Experiment <NA> BCMajorVersion 2
... ... ... ... ... ... ...
151 TemperatureController.default Channel 1 Name Temperature Ch2
152 TemperatureController.default Channel 2 Enabled false
153 TemperatureController.default Channel 2 Name Temperature Ch3
154 TemperatureController.default Channel 3 Enabled false
155 TemperatureController.default Channel 3 Name Temperature Ch4

156 rows × 6 columns

Each header entry is associated with an ObjKey and a ValueKey, and in some cases also an ArrayKey and ArrayIndex. Together those keys uniquely identify a row. Every row also carries a Value and Units. In Blackchirp 2.0, hardware-related ObjKeys use the label-based form HardwareClass.Label (for example PulseGenerator.Default, FtmwDigitizer.virtual, FlowController.Default).

For such a large DataFrame, Jupyter typically compresses the output. To get a brief overview of what information is available, use BCExperiment.header_unique_keys(), which returns the set of unique ObjKeys.

[4]:
help(exp.header_unique_keys)
Help on method header_unique_keys in module blackchirp.blackchirpexperiment:

header_unique_keys() -> 'set[str]' method of blackchirp.blackchirpexperiment.BCExperiment instance
    Fetch all unique ObjKeys in experiment header

    Returns:
        Set of unique header keys

[5]:
exp.header_unique_keys()
[5]:
{'ChirpConfig',
 'Experiment',
 'FlowController.Default',
 'FtmwConfig',
 'FtmwDigitizer.virtual',
 'PulseGenerator.Default',
 'RfConfig',
 'TemperatureController.default'}

To view only data associated with one of these keys, use BCExperiment.header_rows():

[6]:
help(exp.header_rows)
Help on method header_rows in module blackchirp.blackchirpexperiment:

header_rows(objKey: 'str' = None, valKey: 'str' = None, arrKey: 'str' = None) -> 'pd.DataFrame' method of blackchirp.blackchirpexperiment.BCExperiment instance
    Fetch rows from the header file matching conditions

    Filters rows in the header according to ObjKey, ValueKey, and ArrayKey.
    Any combination of these (or none) may be specified to filter.

    Args:
        objKey: Object key in header
        valKey: Value key in header
        arrKey: Array key in header

    Returns:
        DataFrame with matching rows. May be empty (use ``.empty`` /
        ``len()`` to test) — an empty result is not an error here.

For instance, to obtain only the settings for the pulse generator:

[7]:
exp.header_rows('PulseGenerator.Default')
[7]:
ObjKey ArrayKey ArrayIndex ValueKey Value Units
58 PulseGenerator.Default <NA> PulseGenEnabled true
59 PulseGenerator.Default <NA> PulseGenMode Continuous
60 PulseGenerator.Default <NA> RepRate 5 Hz
61 PulseGenerator.Default Channel 0 ActiveLevel ActiveHigh
62 PulseGenerator.Default Channel 0 Delay 0 μs
... ... ... ... ... ... ...
136 PulseGenerator.Default Channel 7 Mode Normal
137 PulseGenerator.Default Channel 7 Name Ch8
138 PulseGenerator.Default Channel 7 Role None
139 PulseGenerator.Default Channel 7 SyncChannel 0
140 PulseGenerator.Default Channel 7 Width 1 μs

83 rows × 6 columns

To see only the pulse widths:

[8]:
exp.header_rows('PulseGenerator.Default', 'Width')
[8]:
ObjKey ArrayKey ArrayIndex ValueKey Value Units
70 PulseGenerator.Default Channel 0 Width 900 μs
80 PulseGenerator.Default Channel 1 Width 50 μs
90 PulseGenerator.Default Channel 2 Width 1 μs
100 PulseGenerator.Default Channel 3 Width 1 μs
110 PulseGenerator.Default Channel 4 Width 1 μs
120 PulseGenerator.Default Channel 5 Width 1 μs
130 PulseGenerator.Default Channel 6 Width 1 μs
140 PulseGenerator.Default Channel 7 Width 1 μs

And finally, BCExperiment.header_value() retrieves one particular value. A corresponding BCExperiment.header_unit() returns its unit. The value comes back as a string, so it may need an explicit cast to int or float if it is used in a calculation.

[9]:
help(exp.header_value)
Help on method header_value in module blackchirp.blackchirpexperiment:

header_value(objKey: 'str', valKey: 'str', idx: 'int' = 0, arrKey: 'str' = None) -> 'str' method of blackchirp.blackchirpexperiment.BCExperiment instance
    Fetch one value from header

    The ``objKey`` and ``valKey`` (and ``arrKey``, if specified) are used
    to filter the header. The ``idx`` value then selects which matching
    row to return.

    Args:
        objKey: Object key in header
        valKey: Value key in header
        idx: Row number to return (optional)
        arrKey: Array key in header (optional)

    Returns:
        Matching value as a string.

    Raises:
        KeyError: If no row matches the supplied filter, or if ``idx``
            is past the end of the matching rows.

[10]:
exp.header_value('PulseGenerator.Default', 'Width', 0), exp.header_unit('PulseGenerator.Default', 'Width', 0)
[10]:
('900', 'μs')

Other CSVs are accessed the same way. The chirp definition lives in chirps.csv:

[11]:
exp.chirps
[11]:
Chirp Segment StartMHz EndMHz DurationUs Alpha Empty
0 0 0 4895 1520 2 -1687.5 False

The marker channels generated by the AWG in synchrony with the chirp live in markers.csv (a Blackchirp 2.0 addition):

[12]:
exp.markers
[12]:
Channel Name Role TimingMode StartUs EndUs Enabled
0 0 Protection Protection ChirpRelative -0.5 0.20 True
1 1 Gate Gate ChirpRelative -0.3 -0.17 True
2 2 Marker 2 Custom ChirpRelative -0.5 0.50 False
3 3 Marker 3 Custom ChirpRelative -0.5 0.50 False

FID data and Fourier Transforms

The CP-FTMW data are wrapped in a BCFTMW object accessible as BCExperiment.ftmw. For a single-FID acquisition the cumulative final FID is loaded with BCFTMW.get_fid(), returning a BCFid. The BCFid.ft() method computes the Fourier transform of that FID:

[13]:
x, y = exp.ftmw.get_fid().ft()

fig, ax = plt.subplots(figsize=(10, 3))
ax.plot(x, y)
ax.set_xlabel('Frequency (MHz)')
ax.set_xlim(26500, 40000)
[13]:
(26500.0, 40000.0)

The FID itself can also be inspected. BCFid.y() returns the FID voltage array; BCFid.xy() returns the time array as well, in seconds by default. The units= keyword rescales the time axis. The chirp in this acquisition takes place from roughly 0.75 to 3.75 μs.

[14]:
fid = exp.ftmw.get_fid()

fidx, fidy = fid.xy(units='us')
fig, ax = plt.subplots(figsize=(10, 3))
ax.plot(fidx, fidy, label=f'{int(fid.shots)} shots')
ax.set_xlabel('Time (μs)')
ax.set_ylabel('FID (V)')
ax.legend(frameon=False)
[14]:
<matplotlib.legend.Legend at 0x7ff495c378c0>

fidy is a 2D numpy array. The second axis is the frame number; this acquisition has a single frame, but a multi-record acquisition would have more. A single frame can be selected by slicing (e.g. fidy[:, 3]).

[15]:
fidy.shape
[15]:
(750000, 1)

The contents of fidparams.csv are available as the fidparams attribute of BCFTMW. The sideband column is recorded by Blackchirp 2.0 as the enum name (UpperSideband / LowerSideband); older fixtures wrote the integer value 0 / 1. Either form works transparently — BCFid.is_lower_sideband() accepts both.

[16]:
exp.ftmw.fidparams
[16]:
spacing probefreq vmult shots sideband size
index
0 2.000000e-11 40960 0.000391 3757 LowerSideband 750000
1 2.000000e-11 40960 0.000391 1197 LowerSideband 750000
2 2.000000e-11 40960 0.000391 2398 LowerSideband 750000
3 2.000000e-11 40960 0.000391 3598 LowerSideband 750000

This experiment contains four FIDs: FID 0 is the final cumulative set, and FIDs 1, 2, 3 are intermediate backups taken during the acquisition. BCFTMW.get_fid() takes an optional FID number, so we can compare an early backup with the cumulative FID:

[17]:
x_backup, y_backup = exp.ftmw.get_fid(1).ft()
fig, ax = plt.subplots(figsize=(10, 3))
ax.plot(x_backup, y_backup, label='FID 1 (backup)')
ax.plot(x, y, label='FID 0 (cumulative)')
ax.set_xlabel('Frequency (MHz)')
ax.set_xlim(26500, 40000)
ax.legend()
[17]:
<matplotlib.legend.Legend at 0x7ff495cb9090>

BCFid.ft() also accepts overrides for any of the FID processing settings used by the Blackchirp GUI. The defaults come from processing.csv, which is exposed as the proc dictionary of BCFTMW. Note that Blackchirp 2.0 records FidWindowFunction and FtUnits as enum names (BlackmanHarris, FtuV); both name and integer forms are accepted. Note also one behavioural difference from the Blackchirp GUI: in the Python module, points within AutoscaleIgnoreMHz of the probe frequency are zeroed in the FT to make plot autoscaling well-behaved. The GUI keeps those points but excludes them from its vertical-range computation.

[18]:
exp.ftmw.proc
[18]:
{'AutoscaleIgnoreMHz': '250',
 'FidEndUs': '15',
 'FidExpfUs': '2.5',
 'FidRemoveDC': 'false',
 'FidStartUs': '3',
 'FidWindowFunction': 'BlackmanHarris',
 'FidZeroPadFactor': '1',
 'FtUnits': 'FtuV'}

BCFid.ft() allows any of these settings to be overridden:

[19]:
help(fid.ft)
Help on method ft in module blackchirp.bcfid:

ft(
    *,
    start_us: 'float' = None,
    end_us: 'float' = None,
    winf: 'str' = None,
    zpf: 'int' = None,
    rdc: 'bool' = None,
    expf_us: 'float' = None,
    autoscale_MHz: 'float' = None,
    units_power: 'int' = None,
    frame: 'int' = None,
    freq_units: 'str' = 'MHz'
) -> 'tuple[np.ndarray, np.ndarray]' method of blackchirp.bcfid.BCFid instance
    Compute the Fourier transform of the FID

    By default, this computes the FT for each frame in the FID using the settings
    stored in the proc dictionary. This behavior can be overridden by specifying
    any combination of the keyword arguments.

    Args:
        start_us: Starting time, in μs. Points at earlier times are set to 0.
        end_us: Ending time, in μs. Points at later times are set to 0.
        winf: Window function name. One of ``None``, ``Bartlett``,
            ``Blackman``, ``BlackmanHarris``, ``Hamming``, ``Hanning``,
            ``KaiserBessel``. May also be passed in any form accepted by
            `scipy.signal.get_window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.get_window.html>`_
            — tuples and arbitrary names are forwarded directly.
        zpf: Zero-padding factor (positive integer). If nonzero, the FID is
            padded with zeroes until its length reaches the next power of 2,
            Then, its length is further extended by 2\*\*zpf.
        rdc: If true, the average of the FID is subtracted before the FT is
            computed.
        expf_us: Time constant for an exponential decay filter, in μs.
        autoscale_MHz: Range of FT points to set to 0, relative to the Downconversion
            LO frequency. Useful for suppressing noise near DC.
        units_power: FT is scaled by 10\*\*units_power. For μV units, set
            units_power=6. May also be specified in ``processing.csv`` as
            an enum name (``FtV`` / ``FtmV`` / ``FtuV`` / ``FtnV``) or
            as the corresponding integer.
        frame: Apply FT to only the specified frame.
        freq_units: Units for the returned frequency array. One of
            ``"Hz"``, ``"kHz"``, ``"MHz"`` (default), ``"GHz"``,
            ``"THz"``. ``start_us``, ``end_us``, ``expf_us``, and
            ``autoscale_MHz`` are interpreted in their declared
            units regardless of this choice.

    Returns:
        Frequency array (in ``freq_units``), Intensity array.

    Raises:
        ValueError: If ``winf`` is a string that does not match a known
            window-function name, if the ``FidWindowFunction`` value
            stored in ``processing.csv`` is unrecognised, if the
            ``FtUnits`` value is unrecognised, or if ``freq_units``
            is not a recognised frequency-unit string.

    Examples:
        Assuming a BCFid object named ``fid``::

            #default FT calculation
            x,y = fid.ft()

            #override some processing settings
            x,y = fid.ft(start_us=3.0,rdc=False,units_power=3)

            #compute ft for only frame 3 (assuming the number of frames is >=4)
            x,y = fid.ft(frame=3)

            #average all frames, then apply a custom window function
            fid.average_frames()
            p = 1.5
            sigma = len(fid)//5
            x,y = fid.ft(winf=('general_gaussian',p,sigma))

For example, override the exponential filter time constant and compare with the default settings:

[20]:
expf = 5.0
x_filt, y_filt = exp.ftmw.get_fid().ft(expf_us=expf)

fig, axes = plt.subplots(2, 1, figsize=(10, 6))
for ax in axes:
    ax.plot(x, y, label='Default')
    ax.plot(x_filt, y_filt, label=f'expf_us = {expf:.2f}')
axes[0].set_xlim(26500, 40000)
axes[1].set_xlim(34600, 34610)
axes[0].legend()
axes[1].set_xlabel('Frequency (MHz)')
[20]:
Text(0.5, 0, 'Frequency (MHz)')

The Python module accepts a wider range of window functions than the GUI. The winf argument can be any value supported by `scipy.signal.get_window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.get_window.html>`__. For example, a generalized Gaussian window with explicit p and σ parameters is passed as a tuple:

[21]:
p = 1.5
sigma = len(fid) // 5
x_win, y_win = fid.ft(winf=('general_gaussian', p, sigma))

fig, axes = plt.subplots(2, 1, figsize=(10, 6))
for ax in axes:
    ax.plot(x, y, label='Default')
    ax.plot(x_win, y_win, label=f'Gaussian (p={p:.2f}, σ={int(sigma)})')
axes[0].set_xlim(26500, 40000)
axes[1].set_xlim(34600, 34610)
axes[0].legend()
axes[1].set_xlabel('Frequency (MHz)')
[21]:
Text(0.5, 0, 'Frequency (MHz)')

Like the FID, the FT y array is 2-D, with the second axis indexing the frame. By default, the FT is applied to all frames; pass frame=N to restrict it to one. The freq_units= keyword rescales the returned frequency axis to Hz, kHz, MHz, GHz, or THz.

[22]:
y.shape
[22]:
(1048577, 1)