codex/past-exam-of-the-mphil-in-micro-and-nanotechnology-enterprise-course-of-the-university-of-cambridge/2023/paper-1-afm-feedback.py

past-exam-of-the-mphil-in-micro-and-nanotechnology-enterprise-course-of-the-university-of-cambridge/2023/paper-1-afm-feedback.py
"""Ideal tip geometry plus schematic tracking responses; not measured data.

Run from output media directory. Dependencies in root pyproject.toml.
"""
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
import numpy as np


def main():
    x = np.linspace(-1.2, 6.2, 2200)
    height, width = 1.3, 5.0
    setback = height * np.tan(np.deg2rad(15))
    slope = 1 / np.tan(np.deg2rad(15))
    accessible = np.minimum(height, np.maximum(0, np.maximum(height - slope*x,
                                                           height - slope*(width-x))))
    lag = np.empty_like(x)
    lag[0] = height
    step = x[1] - x[0]
    for i in range(1, len(x)):
        lag[i] = lag[i-1] + (step/.23) * (accessible[i] - lag[i-1])
    time = x - x[0]
    frequency, damping = 20, .12
    # Exact two-state propagation for linearly interpolated forcing. This is
    # the same damped oscillator as the transfer-function model, using NumPy.
    matrix = np.array([[0., 1.], [-frequency**2, -2*damping*frequency]])
    forcing = np.array([0., frequency**2])
    identity = np.eye(2)
    wd = frequency*np.sqrt(1-damping**2)
    decay = damping*frequency
    evolution = np.exp(-decay*step)*(identity*np.cos(wd*step)
                  +(matrix+decay*identity)*np.sin(wd*step)/wd)
    constant = np.linalg.solve(matrix, (evolution-identity) @ forcing)
    ramp = np.linalg.solve(matrix, constant-step*forcing)/step
    state = np.zeros(2)
    high = np.zeros_like(time)
    drive = accessible-height
    for i in range(len(time)-1):
        state = (evolution @ state + (constant-ramp)*drive[i]
                 +ramp*drive[i+1])
        high[i+1] = state[0]
    high += height
    fig, axes = plt.subplots(2, 2, figsize=(10, 6.5), layout="constrained", facecolor="white")
    for ax in axes.flat:
        ax.axhline(0, color="#555555", lw=.8)
        ax.set(xlim=(-1.2, 6.2), ylim=(-.22, 1.63), xlabel="Lateral position (µm)",
               ylabel="Height (µm)")
        ax.spines[["top", "right"]].set_visible(False)
        ax.grid(alpha=.12)
    ax = axes[0,0]
    ax.add_patch(Polygon([(-1.2,0),(-.45,0),(0,height),(-1.2,height)],
                         facecolor="#bbbbbb", edgecolor="#555555"))
    ax.add_patch(Polygon([(width, height),(width+.45,0),(6.2,0),(6.2,height)],
                         facecolor="#bbbbbb", edgecolor="#555555"))
    ax.plot(x, accessible, color="#135c92", lw=2, label="Accessible apex envelope")
    ax.set_title("Finite tapered tip cannot follow an undercut")
    ax.legend(fontsize=9, loc="lower center")
    for ax, trace, title, color in zip(axes.flat[1:], [accessible, lag, high],
            ["Suitable gain: tracks the accessible envelope", "Low gain: delayed response along scan",
             "Excessive gain: illustrative linear-loop ringing"],
            ["#135c92", "#b15c16", "#a63636"]):
        ax.plot(x, accessible, color="#999999", ls="--", lw=1.4)
        ax.plot(x, trace, color=color, lw=2)
        ax.set_title(title, fontsize=11)
        ax.annotate("Scan direction", xy=(3.1,1.43), xytext=(1.5,1.43),
                    arrowprops={"arrowstyle":"->"}, ha="center", fontsize=9)
    axes[1,1].text(.5,.03,"Linear control response only; contact saturation not modelled",
                    transform=axes[1,1].transAxes, ha="center", va="bottom", fontsize=8)
    assert np.isclose(setback, .34833395016)
    fig.suptitle("Tip geometry and feedback dynamics produce different AFM errors", fontsize=14)
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=125, facecolor="white")
    plt.close(fig)


if __name__ == "__main__":
    main()