Auto3DPFM#
\(_{Ralph}\) \(_{Bulanadi}\) \(_{(mode}\) \(_{author),}\) \(_{Yongtao}\) \(_{Liu,}\) \(_{liuy3@ornl.gov,}\) \(_{youngtaoliu@gmail.com}\)
\(_{July}\) \(_{2026}\)
Note
aecroscopywave version 0.1.20.
Overview#
aecroscopywave.modes.Auto3DPFM automates 3D polarization-vector mapping — combining optical tip/sample positioning, autofocus, and piezoresponse force microscopy (PFM) measurement into one pipeline. It is grounded in:
Broadly, the module groups into:
Autofocus — scoring image sharpness (variance-of-Laplacian, Tenengrad) to automatically focus the optical camera on the tip or sample.
Optical registration and coordinate transforms — locating the cantilever/laser spot in the optical image and converting between the microscope’s optical (“lab”), physical (“diamond”), and AFM (“Asylum”) coordinate systems, so a location picked in one can be reached in another.
Automated approach and tip positioning — driving the stage toward a target location using that coordinate transform, with reasonableness checks on each step.
Calibration routines — measuring the tip’s InvOLS, tip height, and blind-spot position needed to interpret PFM signal amplitude and phase correctly.
Data export — assembling the resulting scan positions and measurements into an HDF5/NSID file for downstream analysis.
Note
Unlike the interfaces covered in Chapters 2–3, Auto3DPFM is tailored to CNMS’s specific rig — coordinate systems, file paths, and macro calls are specific to that setup rather than general-purpose. If you want to build a similar mode for your own instrument, read this module alongside the paper above.
The rest of this notebook walks through what using it actually looks like: an annotated transcript of a real acquisition run, followed by analysis of the data it produces.
Example Run#
What follows is a lightly annotated transcript of one real acquisition run on CNMS’s combined optical/AFM rig, adapted from the mode author’s own run script.
Note
This is not a runnable tutorial in the sense of Chapters 2–3. It requires the physical instrument, a custom Igor Pro procedure file (igor_code.ipf) loaded alongside it, and includes one manual step where the operator switches the alignment laser off by hand partway through. It’s included so you can see what a complete Auto3DPFM run looks like end to end — read it alongside the paper above rather than expecting to execute it as-is on different hardware.
Setup#
Auto3DPFM is a flat collection of functions rather than a class, so it’s imported with import * and called directly. It’s used together with PyCypher (Chapter 2) for the AFM side of the rig.
from aecroscopywave.modes.Auto3DPFM import *
from aecroscopywave.interfaces import Cypher
import os
import numpy as np
Connect to Igor Pro, load the custom procedure file this mode depends on, and read the current AFM state.
pyigor = Cypher.PyCypher()
if "orig_directory" not in globals():
orig_directory = os.getcwd()
pyigor.Execute('NewPath/O mypath "' + Cypher.windows2igor(orig_directory) + '"')
pyigor.Execute('OpenProc /P=mypath /V=0 "igor_code.ipf"')
data = pyigor.DataFolder(r"root:packages:MFP3D:Main:Variables").Wave("MasterVariablesWave")
dim = data.GetDimensions()[1]
gmv = {}
for i in range(dim):
gmv[data.DimensionLabel(0, i, 0)] = data.GetNumericWavePointValue(i)
Note
The mode author’s original script calls a bare windows2igor(...), which isn’t defined or imported anywhere in Auto3DPFM.py — it would raise NameError if run as published. It’s the same helper PyCypher itself uses internally, so Cypher.windows2igor(...) (as written above) is the fix, since Cypher is already imported.
Configure the Run#
Where to save data for this run, and the base names used for images and the run log.
file_loc = r'C:\Users\Asylum User\Desktop\STAFF Software\Auto-3DPFM_v1-2\Test'
base_filename = 'Image'
log_filename = 'Log_3DPFM'
1. Coordinate Transform#
Auto3DPFM tracks the tip/laser spot through an optical camera, but drives the stage through the AFM’s own (“Asylum”) coordinates. calculate_asylum_coordinate_to_lab_coordinate_transform() moves to a few reference points, locates the laser spot optically at each one, and fits the affine transform T between the two coordinate systems — every later step that needs to reach a specific optical location uses this T.
T = calculate_asylum_coordinate_to_lab_coordinate_transform(
pyigor,
file_loc=file_loc,
base_filename=base_filename,
log_filename=log_filename,
auto_calc_centre=True,
verbose=True,
)
2. Autofocus#
auto_focus_calc() sweeps the optical camera’s focus motor and scores image sharpness (Tenengrad) at each step, separately for the region around the tip and the region around the sample, to find the two best focus heights. With auto_calc_centre=True it also asks the operator to switch the alignment laser off partway through, so the tip-focus region can be located without laser glare — this step blocks on input() until you do that and press Enter. auto_focus_save() then drives back to the two recovered focus positions and saves a reference image at each.
tip_focus, sample_focus = auto_focus_calc(
pyigor,
file_loc=file_loc,
base_filename=base_filename,
log_filename=log_filename,
save_plot=True,
save_plot_filename='FocusPlot',
auto_calc_centre=True,
step_size=8,
laser_off_working=False, # True for North, False for South
)
auto_focus_save(pyigor, tip_focus, sample_focus)
3. Calibration: Blind Spot and Tip Height#
Two calibration measurements the mode needs to interpret PFM amplitude and phase correctly:
find_blind_spot_x()sweeps the tip across a range of lateral positions looking for the cantilever’s blind spot — the point where the drive signal drops out because the laser is no longer hitting a usable part of the cantilever.measure_tip_height()uses the coordinate transformTto measure the tip’s physical height offset.
x0 = find_blind_spot_x(
pyigor, T,
file_loc=file_loc,
base_filename=base_filename,
log_filename=log_filename,
measurement_positions=np.linspace(0, 0.7, 8),
nominal_drive_frequency=300000,
)
h = measure_tip_height(
pyigor, T,
file_loc=file_loc,
base_filename=base_filename,
log_filename=log_filename,
)
4. Prepare for Scanning#
reduce_frequency() folds the current drive frequency down into a lower, more predictable band before scanning begins.
reduce_frequency(pyigor, log_filename, mod=100000)
5. Run the Location Scans#
run_tip_location_scans_at_points() is where the actual PFM measurements happen: it drives the tip, via T, to a sequence of target locations and scans at each one. sequence=['A', 'X', 'Y'] here means: the center (“Asylum” reference) position, then one step along the lab X axis, then one step along the lab Y axis — enough points to resolve the polarization vector, per the method in the paper.
filelist = run_tip_location_scans_at_points(
pyigor, T,
sequence=['A', 'X', 'Y'],
file_loc=file_loc,
base_filename=base_filename,
log_filename=log_filename,
dist_proportion_x=1.0,
dist_proportion_y=0.6,
)
6. Export to HDF5/NSID#
Finally, save_3DPFM_as_hdf5() collects everything this run logged into a single HDF5/NSID file for downstream analysis.
os.chdir(file_loc)
save_3DPFM_as_hdf5(base_filename, base_filename, positions='Theoretical')
Data Analysis#
save_3DPFM_as_hdf5() above wrote an HDF5/NSID file containing the reconstructed polarization vector field. This section loads it back and reproduces the kind of figures used in the paper: per-axis amplitude and phase maps, and a summary panel combining topography, the vertical PFM signal, and the in-plane polarization angle. It’s adapted from the mode author’s own analysis notebook.
Note
This section additionally needs matplotlib-scalebar (pip install matplotlib-scalebar), which — like nanosurf in Chapter 2 — is not a declared dependency of aecroscopywave.
Load the Result#
Read the ux, uy, uz polarization components and a reference topography channel back out of the file save_3DPFM_as_hdf5() wrote, and compute the total polarization magnitude.
import h5py
import matplotlib.pyplot as plt
from matplotlib.colors import hsv_to_rgb
from matplotlib_scalebar.scalebar import ScaleBar
os.chdir(file_loc)
with h5py.File(base_filename + '.hf5', 'r') as f:
ux = f['3DPFM/ux'][:]
uy = f['3DPFM/uy'][:]
uz = f['3DPFM/uz'][:]
# Channels are recorded under whichever of these labels the run used, in priority order.
priority = ['BE', 'B', 'C', 'A', 'E']
channel_group = next((x for x in priority if x in f.keys()), None)
topo = f[channel_group + '/Channel_000/Channel_000/Channel_000'][:]
scale = (f[channel_group + '/Channel_000/Channel_000/original_metadata'].attrs['FastScanSize']
/ f[channel_group + '/Channel_000/Channel_000/original_metadata'].attrs['PointsLines'])
U_mag = np.sqrt(np.abs(ux) ** 2 + np.abs(uy) ** 2 + np.abs(uz) ** 2)
U_mag_max = min(U_mag.max(), 1)
Amplitude and Phase Maps#
The total polarization magnitude U alongside each Cartesian component’s amplitude, then each component’s phase.
plt.figure(figsize=(15, 3))
param = [U_mag, np.abs(ux), np.abs(uy), np.abs(uz)]
title = ['$U$', '$U_x$', '$U_y$', '$U_z$']
for i in range(4):
plt.subplot(1, 4, i + 1)
plt.title(title[i])
plt.imshow(param[i], vmin=0, vmax=U_mag_max / 8)
plt.xticks([])
plt.yticks([])
plt.colorbar()
plt.savefig(base_filename + '_amplitude.png', dpi=180)
plt.figure(figsize=(15, 3))
param = [(np.degrees(np.angle(ux)) + 90) % 360,
(np.degrees(np.angle(uy)) + 90) % 360,
(np.degrees(np.angle(uz)) + 90) % 360]
title = [r'$\phi_x$', r'$\phi_y$', r'$\phi_z$']
for i in range(3):
plt.subplot(1, 3, i + 1)
plt.title(title[i])
plt.imshow(param[i], cmap='inferno', vmin=0, vmax=360)
plt.colorbar(ticks=[0, 180, 360])
plt.xticks([])
plt.yticks([])
plt.savefig(base_filename + '_phase.png', dpi=180)
Summary Panel#
A five-panel figure combining topography (with a physical scale bar via scale), the vertical PFM amplitude and phase, the total in-plane+out-of-plane amplitude, and the in-plane polarization angle. The in-plane angle panel encodes direction as hue and magnitude as brightness (HSV), the same convention the paper uses for its polarization maps.
fig = plt.figure(figsize=(20, 10))
plt.subplot(2, 3, 1)
plt.title('Height')
plt.imshow(topo, cmap='copper', vmin=np.min(topo), vmax=np.max(topo))
plt.xticks([])
plt.yticks([])
plt.colorbar()
scalebar = ScaleBar(scale, 'm', location='lower right', font_properties={'size': 18})
plt.gca().add_artist(scalebar)
plt.subplot(2, 3, 2)
plt.title('V. Amplitude')
plt.imshow(np.abs(uz), vmax=2e-11)
plt.xticks([])
plt.yticks([])
plt.colorbar()
plt.subplot(2, 3, 3)
plt.title('V. Phase')
plt.imshow((np.angle(uz) + np.pi / 2) % (2 * np.pi), cmap='inferno')
plt.xticks([])
plt.yticks([])
plt.colorbar()
plt.subplot(2, 3, 4)
plt.title('Total Amplitude')
plt.imshow(U_mag, vmax=U_mag_max)
plt.xticks([])
plt.yticks([])
plt.colorbar()
plt.subplot(2, 3, 5)
plt.title('In-Plane Polarization Angle')
angle = np.arctan2(np.real(uy), np.real(ux))
magnitude = np.sqrt(np.abs(uy) ** 2 + np.abs(ux) ** 2) * 50
H = (np.degrees(angle) % 360) / 360.0
S = np.ones_like(H)
V = (2 * magnitude) / U_mag_max
RGB = hsv_to_rgb(np.stack((H, S, V), axis=-1))
plt.imshow(RGB)
plt.xticks([])
plt.yticks([])
plt.savefig(base_filename + '_summary.png', dpi=180)
The library’s own notebooks/3DPFM_Analysis.ipynb goes further still — cropped in-plane orientation arrow (quiver) plots, alternate colormaps, and more. Use this section as a starting point, not the full toolkit.