Automated Domain Writing#

\(_{Yongtao}\) \(_{Liu,}\) \(_{liuy3@ornl.gov,}\) \(_{youngtaoliu@gmail.com}\)

\(_{July}\) \(_{2026}\)

Note

aecroscopywave version 0.1.20.

Overview#

This notebook demonstrates the Domain Writing workflow introduced in this chapter, in three variations. All three share one connection to Igor Pro / PyScanner (pycy, pyvi, set up once in Setup below); each section then configures its own DAQ settings and waveform before pulsing.

  1. Pulse at Center with Backswitch — pulses of varying amplitude and duration are applied at a single, fixed tip location, with a backswitch pulse and a re-centering move between each one.

  2. Grid Pulsing — the same pulse is applied across a grid of tip locations, moved between with pixel-based coordinates.

  3. Grid Pulsing with Cypher Image — grid pulsing driven from a full Cypher image: pixel coordinates are converted to piezo voltages, and each pulsed location is imaged individually before returning to the full frame.

Setup#

Import#

import numpy as np
import matplotlib.pyplot as plt
import time

from aecroscopywave.wavebuilding import WaveGenerator
from aecroscopywave.interfaces import WaveVI, Cypher

Initialize an instance of PyCypher#

Connects to the running Igor Pro session via COM — Igor Pro and the Cypher AFM software must already be open. This connection (pycy) is reused by all three sections below.

pycy = Cypher.PyCypher()
# Test executing Igor Pro commands; you should see messages in Igor Pro's command window
pycy.igor.Execute("Print \"Igor Execute: Hello from Igor Pro!\"")
pycy.Execute("Print \"Execute: Hello from Igor Pro!\"")

Initialize an instance of PyWaveVI#

Connects to the LabVIEW-based PyScanner application that drives the NI-6124 DAQ card. Update _path to match the PyScanner executable’s location on your machine. Like pycy above, this connection (pyvi) is reused by all three sections — each section only reconfigures the DAQ settings it needs.

_path = r'C:\Users\PyScanner_FPGA_6124_01\PyScanner_FPGA_6124_01.exe'
pyvi = WaveVI.PyWaveVI(vi_path = _path)

1. Pulse at Center with Backswitch#

Configure DAQ#

This section’s DAQ output uses amplifier stage 0.

# Set DAQ settings for PyWaveVI. It takes the default settings in LabView if no argument is provided
pyvi.set_IO_settings(IO_setting_dict ={"trigger_type": 0, "AO_amplifier": 0, "IO_timeout": 5, "AI_ch01": 1, "AI_ch02": 1, "AI_ch03": 1})

Set a wave#

Before every write pulse in this workflow, a fixed +7 V “backswitch” pulse is applied first to reset the sample to a known polarization state — so each subsequent switching pulse starts from the same baseline rather than whatever state the previous pulse left behind.

t, backswitchwave = WaveGenerator.square_pulse(amplitude = 7, pulse_duration = [0.1, 0.1, 0])
plt.plot(t, backswitchwave)

Upload wave to VI#

Uploads the backswitch waveform to the LabVIEW VI buffer, ready to be sent to the DAQ card.

pyvi.set_AO_waveforms(waveform=backswitchwave, zero_tail=False)

Check uploaded wave#

Reads back the waveform currently sitting in the VI buffer, as a sanity check before executing it.

uploadedwave = pyvi.get_AO_waveforms()
plt.plot(uploadedwave)

Execute wave#

Clears, uploads, and triggers the waveform on the NI-6124 card in one call — this is what actually outputs the backswitch pulse to the sample.

pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)

Set zero wave#

Defines a 0 V wave and immediately executes it, returning the output to a neutral baseline between pulses.

t, setzero = WaveGenerator.square_pulse(amplitude = 0, pulse_duration = [0.1, 0.1, 0.1])
plt.plot(t, setzero)

pyvi.set_AO_waveforms(waveform=setzero, zero_tail=False)
pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)

Move tip#

Moves the tip to pixel (255, 255) — a corner of the current scan frame — as a safe starting position before pulsing begins.

# Initialize tip movement
pycy.initialize_move_tip()
# Move tip
pycy.move_tip(coordinates = [255, 255], coordinates_type="pixel", transit_time = 0.5)

Pulsing#

Rather than pulsing at different spatial locations, this experiment sweeps two pulse parameters together: amplitude (pulse_v, -3 to -9 V) and duration (pulse_d, 0.05 to 1 s). np.meshgrid builds every amplitude/duration combination (num_x × num_y = 36 total), so the effect of each pulse condition can be compared side by side.

# Pulse
num_x = 6
num_y = 6
start_pulse_v = -3   # Define location array parameters
end_pulse_v = -9
start_pulse_d = 0.05
end_pulse_d = 1

# Generate location array
pulse_v = np.linspace(start_pulse_v, end_pulse_v, num_x, dtype = float)
pulse_d = np.linspace(start_pulse_d, end_pulse_d, num_y, dtype = float)
pulse = np.meshgrid(pulse_v, pulse_d)
pulse_v = pulse[0].reshape(-1)
pulse_d = pulse[1].reshape(-1)  # pulse_pos_x and pulse_pos_y are the coordinates of all locations
print (pulse_v)
print(pulse_d)

Records the current frame center (CenterX, CenterY) so the final full-frame image can be re-centered on it after all the pulsing is done.

metadata = pycy.get_MasterVariables()
metadata
CenterX, CenterY = metadata['XOffset'], metadata['YOffset']

Start grid pulsing#

For each of the 36 amplitude/duration pairs: upload and execute the backswitch pulse while imaging at zero drive amplitude, zero the output, apply that pair’s write pulse, then re-center the tip and image the result at full drive amplitude. Repeating this for every combination builds a set of switching events under different pulse conditions, side by side in one frame.

# Initialize tip movement
pycy.initialize_move_tip()
pycy.engage()

for i in range(len(pulse_v)):
    # Upload backswitchwave and execute
    pyvi.set_AO_waveforms(waveform=backswitchwave, zero_tail=False)
    pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)
    # Backswitch Scan
    pycy.set_Master_Panel(parameters={ 'DriveAmplitude': 0, "ScanSize": 5e-7}, do_scan="Frame Up")
    time.sleep(1)
    # Upload setzero and execute
    pyvi.set_AO_waveforms(waveform=setzero, zero_tail=False)
    pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)
    time.sleep(1)
    #####################---Move tip to the pulse location---#####################
    _, square_wave = WaveGenerator.square_pulse(amplitude = pulse_v[i], pulse_duration = [0.2, pulse_d[i], 0.2])
    pyvi.set_AO_waveforms(waveform=square_wave, zero_tail=False)
    time.sleep(1)
    
    pycy.initialize_move_tip()
    time.sleep(1)
    pycy.engage()
    time.sleep(0.5)
    # Loc center
    pycy.move_tip(coordinates = [32, 32], coordinates_type="pixel", transit_time = 0.5)
    time.sleep(1)
    pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)
    time.sleep(1)
 
    #####################---Set image parameter, particular center offset---#####################
    pycy.set_Master_Panel(parameters={ 'DriveAmplitude': 1, "ScanSize": 5e-7}, do_scan="Frame Up")

With pulsing complete, image the whole written region at higher resolution (512×512), centered back on the original frame.

params = { 'DriveAmplitude': 1, "ScanSize": 1e-6, "ScanPoints": 512, "ScanLines": 512, "ScanRate": 1, 
          'XOffset': CenterX, 'YOffset': CenterY}
pycy.set_Master_Panel(parameters=params, do_scan="Frame Up")

Save Data#

Save the pulse parameter sweep and the final AFM state for later analysis.

np.savez('pulse.npz', pulse_v = pulse_v, pulse_d = pulse_d)
import json
metadata = pycy.get_MasterVariables()

# Save
with open("metadata.json", "w") as f:
    json.dump(metadata, f)

2. Grid Pulsing#

Configure DAQ#

This run uses a different amplifier stage (AO_amplifier: 2) than Sections 1 and 3.

# Set DAQ settings for PyWaveVI. It takes the default settings in LabView if no argument is provided
pyvi.set_IO_settings(IO_setting_dict ={"trigger_type": 0, "AO_amplifier": 2, "IO_timeout": 5, "AI_ch01": 1, "AI_ch02": 1, "AI_ch03": 1})

Set a wave#

A preliminary -1 V pulse used to verify the upload/check/execute pipeline works end-to-end. It’s superseded by the actual experiment pulse defined in “Set wave” below, under Grid Pulsing.

t, square_wave = WaveGenerator.square_pulse(amplitude = -1, pulse_duration = [0.2, 5, 0.2])
plt.plot(t, square_wave)

Upload wave to VI#

Uploads the preliminary test pulse to the VI buffer.

pyvi.set_AO_waveforms(waveform=square_wave, zero_tail=False)

Check uploaded wave#

Reads it back as a sanity check.

uploadedwave = pyvi.get_AO_waveforms()
plt.plot(uploadedwave)

Execute wave#

Fires the preliminary test pulse to confirm the pipeline works end-to-end.

pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)

Move tip#

Moves the tip to pixel (255, 255) as a starting position before grid pulsing begins.

# Initialize tip movement
pycy.initialize_move_tip()
# Move tip
pycy.move_tip(coordinates = [255, 255], coordinates_type="pixel", transit_time = 0.5)
# Engage tip or withdraw
pycy.engage()
# pycy.withdraw()

Grid Pulsing#

Set grid locations#

Builds a 20×20 grid of pixel coordinates spanning the full frame (0–255 in both axes) — 400 tip locations to visit, one pulse per location.

# All locations span across [start_point_x, end_point_x] in x-direction and [start_point_y, end_point_y] in y-direction.
# There are num_x rows and num_y columns in the locations array

start_point_x = 0   # Define location array parameters
end_point_x = 255
start_point_y = 0
end_point_y = 255
num_x = 20
num_y = 20

# Generate location array
pos_x = np.linspace(start_point_x, end_point_x, num_x, dtype = int)
pos_y = np.linspace(start_point_y, end_point_y, num_y, dtype = int)
pulse_pos = np.meshgrid(pos_x, pos_y)
pulse_pos_x = pulse_pos[0].reshape(-1)
pulse_pos_y = pulse_pos[1].reshape(-1)  # pulse_pos_x and pulse_pos_y are the coordinates of all locations

Set wave#

This -3.5 V, 5 s pulse is the one actually applied at every grid location below.

t, square_wave = WaveGenerator.square_pulse(amplitude = -3.5, pulse_duration = [0.2, 5, 0.2])
plt.plot(t, square_wave)

Upload wave to VI#

pyvi.set_AO_waveforms(waveform=square_wave, zero_tail=False)

Upload wave to DAQ#

Uploads and clears in preparation, but doesn’t trigger yet (do_IO=False) — the pulse fires once per grid location inside the loop below instead.

pyvi.set_IO_control(clear = True, upload = True, do_IO = False, fetch_result = False)

Engage tip#

pycy.engage()

Start grid pulsing#

Moves the tip to each of the 400 grid locations in turn and re-triggers the already-uploaded pulse at each one (do_IO=True, upload=False) — since the waveform doesn’t change between locations, it only needs to be uploaded once, above.

for i in range(len(pulse_pos_x)):
    #####################---Move tip to the pulse location---##################### 
    pycy.move_tip(coordinates = [pulse_pos_x[i], pulse_pos_y[i]], coordinates_type="pixel", transit_time = 0.5)
    time.sleep(0.5)
    
    #####################---Apply pulse---##################### 
    pyvi.set_IO_control(clear = False, upload = False, do_IO = True, fetch_result = False)
    time.sleep(1)
    

3. Grid Pulsing with Cypher Image#

Configure DAQ#

Back to amplifier stage 0, as in Section 1.

# Set DAQ settings for PyWaveVI. It takes the default settings in LabView if no argument is provided
pyvi.set_IO_settings(IO_setting_dict ={"trigger_type": 0, "AO_amplifier": 0, "IO_timeout": 5, "AI_ch01": 1, "AI_ch02": 1, "AI_ch03": 1})

Set a wave#

A +7 V backswitch pulse, executed once below to reset the whole sample to a known state before the grid starts — unlike Section 1, this section does not repeat the backswitch between individual pulses.

t, backswitchwave = WaveGenerator.square_pulse(amplitude = 7, pulse_duration = [0.1, 0.1, 0])
plt.plot(t, backswitchwave)

Upload wave to VI#

Uploads the backswitch waveform, ready to execute.

pyvi.set_AO_waveforms(waveform=backswitchwave, zero_tail=False)

Check uploaded wave#

Reads back the buffered waveform as a sanity check.

uploadedwave = pyvi.get_AO_waveforms()
plt.plot(uploadedwave)

Execute wave#

Fires the one-time backswitch pulse.

pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)

Set zero wave#

Defines and immediately executes a 0 V wave, returning the output to baseline after the backswitch pulse.

_, setzero = WaveGenerator.square_pulse(amplitude = 0, pulse_duration = [0.1, 0.1, 0.1])

pyvi.set_AO_waveforms(waveform=setzero, zero_tail=False)
pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)

Move tip#

Moves the tip to pixel (255, 255) as a starting position before the grid pulsing sequence begins.

# Initialize tip movement
pycy.initialize_move_tip()
# Move tip
pycy.move_tip(coordinates = [255, 255], coordinates_type="pixel", transit_time = 0.5)

Grid Pulsing#

Set grid locations#

An 11×11 grid (121 locations) this time — coarser than Section 2’s 20×20, since each location here will also be individually imaged, which takes considerably longer per point.

# All locations span across [start_point_x, end_point_x] in x-direction and [start_point_y, end_point_y] in y-direction.
# There are num_x rows and num_y columns in the locations array

start_point_x = 0   # Define location array parameters
end_point_x = 255
start_point_y = 0
end_point_y = 255
num_x = 11
num_y = 11

# Generate location array
pos_x = np.linspace(start_point_x, end_point_x, num_x, dtype = int)
pos_y = np.linspace(start_point_y, end_point_y, num_y, dtype = int)
pulse_pos = np.meshgrid(pos_x, pos_y)
pulse_pos_x = pulse_pos[0].reshape(-1)
pulse_pos_y = pulse_pos[1].reshape(-1)  # pulse_pos_x and pulse_pos_y are the coordinates of all locations

Set pulse parameters#

As in Section 1, amplitude and duration are swept together over the same range — but here indexed by the same 11×11 grid used for tip positions, so each grid location gets its own distinct pulse condition rather than every location sharing one fixed pulse.

# Pulse

start_pulse_v = -3   # Define location array parameters
end_pulse_v = -9
start_pulse_d = 0.05
end_pulse_d = 1.0

# Generate location array
pulse_v = np.linspace(start_pulse_v, end_pulse_v, num_x, dtype = float)
pulse_d = np.linspace(start_pulse_d, end_pulse_d, num_y, dtype = float)
pulse = np.meshgrid(pulse_v, pulse_d)
pulse_v = pulse[0].reshape(-1)
pulse_d = pulse[1].reshape(-1)  # pulse_pos_x and pulse_pos_y are the coordinates of all locations
print (pulse_v)
print(pulse_d)

Convert pixel coordinates to voltages#

Unlike Sections 1–2, tip moves in this section are specified directly in piezo voltage rather than pixel coordinates (see the loop below) — pixel_to_voltage() converts each grid location once, up front, so the loop doesn’t have to redo this conversion on every iteration.

pulse_pos_voltages = []  # Convert pixel coordinates to piezo voltages
for i in range(len(pulse_pos_x)):
    pulse_pos_voltages.append(pycy.pixel_to_voltage(pixel_coordinates=[pulse_pos_x[i], pulse_pos_y[i]]))

pulse_pos_voltages

Generate grid points#

Defines a helper that lays out physical (voltage-unit) imaging positions for the per-location scans below — independent of, though matched in shape to, the pulse grid above.

from typing import Tuple

def generate_grid_points(
    center_offset: Tuple[float, float],
    img_size: float,
    grid_shape: Tuple[int, int] = (10, 10),
    margin: float = 0.0001
) -> np.ndarray:
    """
    Generate a uniform grid of points within the current image area.

    Parameters
    ----------
    center_offset : Tuple[float, float]
        (x, y) offset of the image center in voltage units.
    img_size : float
        Side length of the square image area in voltage units.
    grid_shape : Tuple[int, int], optional
        Number of grid points as (rows, cols). Default is (10, 10).
    margin : float, optional
        Fractional margin to inset from image edges (0.0 = edge-to-edge,
        0.1 = 10% inset on each side). Default is 0.0.

    Returns
    -------
    np.ndarray, shape (N, 2)
        Array of (x, y) voltage coordinates for each grid point,
        ordered row by row (left-to-right, bottom-to-top).

    Examples
    --------
    >>> pts = generate_grid_points(center_offset=(1.0, -0.5), img_size=2.0)
    >>> pts.shape
    (100, 2)
    """
    cx, cy = center_offset
    half = (img_size / 2) * (1.0 - margin)

    x_coords = np.linspace(cx - half, cx + half, grid_shape[0])
    y_coords = np.linspace(cy - half, cy + half, grid_shape[1])

    xx, yy = np.meshgrid(x_coords, y_coords)

    # Flatten to (N, 2) array of (x, y) pairs
    grid_points = np.column_stack([xx.ravel(), yy.ravel()])
    return grid_points

Records the current frame center and uses it to build the physical grid of per-location imaging positions (pts).

metadata = pycy.get_MasterVariables()
metadata
CenterX, CenterY = metadata['XOffset'], metadata['YOffset']
pts = generate_grid_points(center_offset=(metadata['XOffset'], metadata['YOffset']), grid_shape=(num_x, num_y), img_size=4e-6)
pts.shape

Start grid pulsing#

For each of the 121 grid locations: drop the drive amplitude to zero and move the tip there in voltage units, re-engage and apply that location’s pulse, then capture a small, fast image (64×64, 350 nm) centered exactly on that spot. Unlike Sections 1–2, this builds up one image per pulsed location rather than a single full-frame image at the end.

# Initialize tip movement
pycy.initialize_move_tip()
pycy.engage()

for i in range(len(pulse_pos_x)):
    #####################---Move tip to the pulse location---#####################
    pycy.PV(parameters = {'DriveAmplitude': 0})
    time.sleep(2)
    pycy.initialize_move_tip()
    time.sleep(1)
    pycy.move_tip(coordinates = [pulse_pos_voltages[i][0], pulse_pos_voltages[i][1]], coordinates_type="voltage", transit_time = 0.5)
    time.sleep(0.5)
    #####################---Apply pulse---##################### 
    pycy.engage()
    _, square_wave = WaveGenerator.square_pulse(amplitude = pulse_v[i], pulse_duration = [0.2, pulse_d[i], 0.2])
    pyvi.set_AO_waveforms(waveform=square_wave, zero_tail=False)
    time.sleep(1)
    pyvi.set_IO_control(clear = True, upload = True, do_IO = True, fetch_result = False)
    time.sleep(1)

    #####################---Set image parameter, particular center offset---#####################
    params = { 'DriveAmplitude': 1, "ScanSize": 3.5e-7, "ScanPoints": 64, "ScanLines": 64, "ScanRate": 1, 
                  'XOffset': pts[i,0], 'YOffset': pts[i,1]}
    pycy.set_Master_Panel(parameters=params, do_scan="Frame Up")

Finally, image the complete written region at the original scan size, re-centered on the frame’s starting position (do_scan="Frame Down" this time, rather than “Frame Up”).

params = { 'DriveAmplitude': 1, "ScanSize": 5e-6, "ScanPoints": 512, "ScanLines": 512, "ScanRate": 1, 
          'XOffset': CenterX, 'YOffset': CenterY}
pycy.set_Master_Panel(parameters=params, do_scan="Frame Down")

Save Data#

As in Section 1, save the pulse parameter sweep and the final AFM state for later analysis.

np.savez('pulse.npz', pulse_v = pulse_v, pulse_d = pulse_d)
import json
metadata = pycy.get_MasterVariables()

# Save
with open("metadata.json", "w") as f:
    json.dump(metadata, f)