synthetic surface and spm specific features
This commit is contained in:
@@ -96,14 +96,14 @@ All features from the original gap analysis are implemented:
|
||||
|
||||
### SPM Mode-Specific
|
||||
|
||||
| # | Feature | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| 71 | PFM Analysis | pfm.c | Piezoresponse Force Microscopy: compute in-plane and 3D polarization vectors from VPFM/LPFM amplitude and phase at multiple rotations. |
|
||||
| 72 | Lateral Force Simulation | latsim.c | Simulate topography artifacts in lateral force (friction) channels given friction coefficient, adhesion, and normal load parameters. |
|
||||
| 73 | SEM Simulation | semsim.c | Simulate Scanning Electron Microscopy signal from topography data using integration or Monte Carlo methods. |
|
||||
| 74 | Scanning Microwave Microscopy | smm.c, smm_apply.c | Fit complex reflection coefficients from SMM impedance measurements to extract capacitance and material properties. |
|
||||
| 75 | MFM Current Simulation | mfm_current.c | Simulate current distribution from magnetization for MFM. Extends existing MFMAnalysis node. |
|
||||
| 76 | MFM Domain Generation | mfm_parallel.c | Generate parallel magnetic domain patterns for MFM simulation and testing. |
|
||||
| # | Feature | Gwyddion Source | tono Node | Status |
|
||||
|---|---------|---------------|-----------|--------|
|
||||
| 71 | PFM Analysis | pfm.c | PFMAnalysis | **DONE** |
|
||||
| 72 | Lateral Force Simulation | latsim.c | LateralForceSim | **DONE** |
|
||||
| 73 | SEM Simulation | semsim.c | SEMSimulation | **DONE** |
|
||||
| 74 | Scanning Microwave Microscopy | smm.c, smm_apply.c | SMMAnalysis | **DONE** |
|
||||
| 75 | MFM Current Simulation | mfm_current.c | MFMCurrentSimulation | **DONE** |
|
||||
| 76 | MFM Domain Generation | mfm_parallel.c | MFMDomainGeneration | **DONE** |
|
||||
|
||||
### Lower Priority — Specialized or niche
|
||||
|
||||
@@ -127,32 +127,32 @@ All features from the original gap analysis are implemented:
|
||||
|
||||
### Synthesis — Additional surface generation patterns
|
||||
|
||||
tono's SyntheticSurface node covers fbm, white_noise, lattice, steps, particles, and flat. Gwyddion has 24 separate synthesis modules. These could be added as patterns to the existing SyntheticSurface node:
|
||||
All 22 synthesis patterns added to the existing SyntheticSurface node (28 patterns total):
|
||||
|
||||
| # | Pattern | Gwyddion Source | Description |
|
||||
|---|---------|---------------|-------------|
|
||||
| 92 | Columnar | col_synth.c | Columnar/stripe growth patterns. |
|
||||
| 93 | Objects | obj_synth.c | Random spheres, pyramids, boxes, cylinders on flat surface. |
|
||||
| 94 | Fibres | fibre_synth.c | Randomly oriented fibre/line features. |
|
||||
| 95 | Waves | wave_synth.c | Directional wave/ripple patterns. |
|
||||
| 96 | Dunes | dune_synth.c | Dune-like rippled surfaces. |
|
||||
| 97 | Domains | domain_synth.c | Phase-separated domain/island patterns. |
|
||||
| 98 | Ballistic Deposition | bdep_synth.c | Ballistic deposition growth simulation. |
|
||||
| 99 | Particle Deposition | deposit_synth.c | Particle deposition simulation. |
|
||||
| 100 | Rod Deposition | roddeposit_synth.c | Wire/rod deposition on surfaces. |
|
||||
| 101 | Diffusion Aggregation | diff_synth.c | Diffusion-limited aggregation patterns. |
|
||||
| 102 | Discs | disc_synth.c | Randomly distributed disc features. |
|
||||
| 103 | Plateaus | plateau_synth.c | Flat-topped feature patterns. |
|
||||
| 104 | Pileups | pileup_synth.c | Rounded rectangle pileup structures. |
|
||||
| 105 | Annealing | anneal_synth.c | Simulated annealing surface relaxation. |
|
||||
| 106 | Lattice (Voronoi) | lat_synth.c | Regular lattice with Voronoi-based variations. |
|
||||
| 107 | Phase Separation | phase_synth.c | Spinodal decomposition domain patterns. |
|
||||
| 108 | PDE Patterns | cpde_synth.c | Coupled partial differential equation patterns. |
|
||||
| 109 | Spectral (FFT) | fft_synth.c | Surfaces with customizable power spectrum. |
|
||||
| 110 | Residues | residue_synth.c | Irregular particle/residue deposits. |
|
||||
| 111 | Noise Distributions | lno_synth.c, noise_synth.c | Gaussian, Poisson, exponential, and other noise types. |
|
||||
| 112 | Periodic Patterns | pat_synth.c | Various periodic/modulated tiling patterns. |
|
||||
| 113 | WFR Patterns | wfr_synth.c | Wave-front-related surface patterns. |
|
||||
| # | Pattern | Gwyddion Source | tono Pattern | Status |
|
||||
|---|---------|---------------|-------------|--------|
|
||||
| 92 | Columnar | col_synth.c | columnar | **DONE** |
|
||||
| 93 | Objects | obj_synth.c | objects | **DONE** |
|
||||
| 94 | Fibres | fibre_synth.c | fibres | **DONE** |
|
||||
| 95 | Waves | wave_synth.c | waves | **DONE** |
|
||||
| 96 | Dunes | dune_synth.c | dunes | **DONE** |
|
||||
| 97 | Domains | domain_synth.c | domains | **DONE** |
|
||||
| 98 | Ballistic Deposition | bdep_synth.c | ballistic | **DONE** |
|
||||
| 99 | Particle Deposition | deposit_synth.c | deposition | **DONE** |
|
||||
| 100 | Rod Deposition | roddeposit_synth.c | rods | **DONE** |
|
||||
| 101 | Diffusion Aggregation | diff_synth.c | dla | **DONE** |
|
||||
| 102 | Discs | disc_synth.c | discs | **DONE** |
|
||||
| 103 | Plateaus | plateau_synth.c | plateaus | **DONE** |
|
||||
| 104 | Pileups | pileup_synth.c | pileups | **DONE** |
|
||||
| 105 | Annealing | anneal_synth.c | annealing | **DONE** |
|
||||
| 106 | Lattice (Voronoi) | lat_synth.c | voronoi | **DONE** |
|
||||
| 107 | Phase Separation | phase_synth.c | spinodal | **DONE** |
|
||||
| 108 | PDE Patterns | cpde_synth.c | pde | **DONE** |
|
||||
| 109 | Spectral (FFT) | fft_synth.c | spectral | **DONE** |
|
||||
| 110 | Residues | residue_synth.c | residues | **DONE** |
|
||||
| 111 | Noise Distributions | lno_synth.c, noise_synth.c | noise | **DONE** |
|
||||
| 112 | Periodic Patterns | pat_synth.c | periodic | **DONE** |
|
||||
| 113 | WFR Patterns | wfr_synth.c | wfr | **DONE** |
|
||||
|
||||
### File Format Support
|
||||
|
||||
@@ -180,9 +180,9 @@ Gwyddion supports 155+ file format modules. tono currently handles a smaller set
|
||||
| Originally tracked (1–40) | 40 | 39 done, 1 excluded (force curves) |
|
||||
| High Value (41–51) | 11 | **All 11 done** |
|
||||
| Medium Value (52–70) | 19 | **All 19 done** |
|
||||
| SPM Mode-Specific (71–76) | 6 | Pending |
|
||||
| SPM Mode-Specific (71–76) | 6 | **All 6 done** |
|
||||
| Lower Priority (77–91) | 15 | Pending |
|
||||
| Synthesis Patterns (92–113) | 22 | Pending (extend SyntheticSurface) |
|
||||
| Synthesis Patterns (92–113) | 22 | **All 22 done** |
|
||||
| File Formats | 10+ | Pending |
|
||||
|
||||
**69 of 70 tracked features implemented.** 43 remaining gaps identified.
|
||||
**97 of 98 tracked features implemented.** 15 remaining gaps identified (lower priority + file formats).
|
||||
|
||||
@@ -123,7 +123,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"TemplateMatch",
|
||||
"FacetAnalysis",
|
||||
"MFMAnalysis",
|
||||
"MFMCurrentSimulation",
|
||||
"MFMDomainGeneration",
|
||||
"ZeroCrossing",
|
||||
"PFMAnalysis",
|
||||
"LateralForceSim",
|
||||
"SEMSimulation",
|
||||
"SMMAnalysis",
|
||||
],
|
||||
"Mask": [
|
||||
"DrawMask",
|
||||
|
||||
86
backend/nodes/lateral_force_sim.py
Normal file
86
backend/nodes/lateral_force_sim.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Lateral Force Simulation — topography artifacts in friction channels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Lateral Force Simulation")
|
||||
class LateralForceSim:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"direction": (["forward", "reverse", "both"],),
|
||||
"friction_coefficient": ("FLOAT", {
|
||||
"default": 0.3, "min": 0.0, "max": 10.0, "step": 0.01,
|
||||
}),
|
||||
"adhesion": ("FLOAT", {
|
||||
"default": 1e-9, "min": 0.0, "max": 1e-6, "step": 1e-12,
|
||||
}),
|
||||
"load": ("FLOAT", {
|
||||
"default": 10e-9, "min": 1e-12, "max": 1e-6, "step": 1e-12,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'forward'),
|
||||
('DATA_FIELD', 'reverse'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Simulates topography-induced artifacts in lateral force (friction) "
|
||||
"microscopy channels. Computes the lateral force signal from surface "
|
||||
"slope, friction coefficient, adhesion, and normal load for forward "
|
||||
"and/or reverse scan directions. Based on a contact-mechanics model "
|
||||
"where the measured lateral force depends on the local tilt angle of "
|
||||
"the sample surface."
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
direction: str,
|
||||
friction_coefficient: float,
|
||||
adhesion: float,
|
||||
load: float,
|
||||
) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
# Surface slope angle from x-gradient of topography
|
||||
dz_dx = np.gradient(data, axis=1)
|
||||
theta = np.arctan2(dz_dx, field.dx)
|
||||
|
||||
sin_theta = np.sin(theta)
|
||||
cos_theta = np.cos(theta)
|
||||
|
||||
va = load * sin_theta
|
||||
vc = cos_theta
|
||||
vb = friction_coefficient * (load * vc + adhesion)
|
||||
vd = friction_coefficient * sin_theta
|
||||
|
||||
# Forward scan (tip moves in +x)
|
||||
denom_fwd = vc - vd
|
||||
safe_fwd = np.abs(denom_fwd) >= 1e-30
|
||||
lateral_forward = np.where(safe_fwd, (va + vb) / np.where(safe_fwd, denom_fwd, 1.0), 0.0)
|
||||
|
||||
# Reverse scan (tip moves in -x)
|
||||
denom_rev = vc + vd
|
||||
safe_rev = np.abs(denom_rev) >= 1e-30
|
||||
lateral_reverse = np.where(safe_rev, -(va - vb) / np.where(safe_rev, denom_rev, 1.0), 0.0)
|
||||
|
||||
if direction == "forward":
|
||||
lateral_reverse = lateral_forward.copy()
|
||||
elif direction == "reverse":
|
||||
lateral_forward = lateral_reverse.copy()
|
||||
|
||||
out_fwd = field.replace(data=lateral_forward, si_unit_z="N")
|
||||
out_rev = field.replace(data=lateral_reverse, si_unit_z="N")
|
||||
|
||||
return (out_fwd, out_rev)
|
||||
104
backend/nodes/mfm_current.py
Normal file
104
backend/nodes/mfm_current.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""MFM Current Simulation — magnetic field from a current-carrying line."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
# Vacuum permeability
|
||||
MU_0 = 4.0e-7 * np.pi
|
||||
|
||||
|
||||
@register_node(display_name="MFM Current Simulation")
|
||||
class MFMCurrentSimulation:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"height": ("FLOAT", {
|
||||
"default": 100e-9, "min": 1e-9, "max": 10e-6, "step": 1e-9,
|
||||
}),
|
||||
"current": ("FLOAT", {
|
||||
"default": 1e-3, "min": 1e-9, "max": 1.0, "step": 1e-6,
|
||||
}),
|
||||
"width": ("FLOAT", {
|
||||
"default": 100e-9, "min": 1e-9, "max": 10e-6, "step": 1e-9,
|
||||
}),
|
||||
"tip_magnetization": ("FLOAT", {
|
||||
"default": 1e5, "min": 1.0, "max": 1e8, "step": 1.0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'hx'),
|
||||
('DATA_FIELD', 'hz'),
|
||||
('DATA_FIELD', 'force'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Simulates the magnetic field produced by an infinite current-carrying "
|
||||
"strip (running along the y-axis) and the resulting force on an MFM "
|
||||
"tip. Uses the Biot-Savart law for a flat strip of finite width to "
|
||||
"compute the Hx and Hz field components at a given observation height, "
|
||||
"then derives the z-force on a point-dipole tip from the analytical "
|
||||
"gradient dHz/dz."
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
height: float,
|
||||
current: float,
|
||||
width: float,
|
||||
tip_magnetization: float,
|
||||
) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
xreal = field.xreal
|
||||
|
||||
# Spatial grid centred on the field: current line sits at x = 0.
|
||||
# Matches Gwyddion convention: x = j * xreal / xres - xreal / 2
|
||||
x = np.linspace(0, xreal, xres, endpoint=False) - xreal / 2.0
|
||||
|
||||
# Pre-computed constants (following Gwyddion mfm.c notation)
|
||||
m = current / (2.0 * np.pi * width) # I / (2 pi w)
|
||||
w2 = 0.5 * width # half-width
|
||||
hh = height * height # h^2
|
||||
|
||||
xpw2 = x + w2 # x + w/2
|
||||
xmw2 = x - w2 # x - w/2
|
||||
xpw2h2 = xpw2**2 + hh # (x + w/2)^2 + h^2
|
||||
xmw2h2 = xmw2**2 + hh # (x - w/2)^2 + h^2
|
||||
|
||||
# --- Hx (1-D) ---
|
||||
# Gwyddion: m * atan(h * w / (h^2 + x^2 - w2^2))
|
||||
# Equivalent to (I / (2 pi w)) * [atan((x+w/2)/h) - atan((x-w/2)/h)]
|
||||
hx_1d = m * np.arctan2(height * width, hh + x**2 - w2**2)
|
||||
|
||||
# --- Hz (1-D) ---
|
||||
# Gwyddion: 0.5 * m * ln((x-w/2)^2 + h^2) / ((x+w/2)^2 + h^2))
|
||||
hz_1d = 0.5 * m * np.log(xmw2h2 / xpw2h2)
|
||||
|
||||
# --- dHz/dz (1-D), analytical derivative ---
|
||||
# Gwyddion: m * x * h * w / ((xmw2h2) * (xpw2h2))
|
||||
t = 1.0 / (xmw2h2 * xpw2h2)
|
||||
dhz_dz_1d = m * x * height * width * t
|
||||
|
||||
# Tile 1-D rows into 2-D arrays (field is constant along y).
|
||||
hx_2d = np.tile(hx_1d, (yres, 1))
|
||||
hz_2d = np.tile(hz_1d, (yres, 1))
|
||||
|
||||
# Force on a point-dipole tip: Fz = mu_0 * m_tip * dHz/dz
|
||||
fz_1d = MU_0 * tip_magnetization * dhz_dz_1d
|
||||
fz_2d = np.tile(fz_1d, (yres, 1))
|
||||
|
||||
return (
|
||||
field.replace(data=hx_2d, si_unit_z="A/m"),
|
||||
field.replace(data=hz_2d, si_unit_z="A/m"),
|
||||
field.replace(data=fz_2d, si_unit_z="N"),
|
||||
)
|
||||
115
backend/nodes/mfm_domains.py
Normal file
115
backend/nodes/mfm_domains.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""MFM Domain Generation — stray field from parallel magnetic stripe domains."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="MFM Domain Generation")
|
||||
class MFMDomainGeneration:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"height": ("FLOAT", {
|
||||
"default": 50e-9, "min": 1e-9, "max": 10e-6, "step": 1e-9,
|
||||
}),
|
||||
"thickness": ("FLOAT", {
|
||||
"default": 20e-9, "min": 1e-9, "max": 1e-6, "step": 1e-9,
|
||||
}),
|
||||
"magnetization": ("FLOAT", {
|
||||
"default": 1e6, "min": 1.0, "max": 1e8, "step": 1.0,
|
||||
}),
|
||||
"stripe_width_a": ("FLOAT", {
|
||||
"default": 200e-9, "min": 1e-9, "max": 100e-6, "step": 1e-9,
|
||||
}),
|
||||
"stripe_width_b": ("FLOAT", {
|
||||
"default": 200e-9, "min": 1e-9, "max": 100e-6, "step": 1e-9,
|
||||
}),
|
||||
"gap": ("FLOAT", {
|
||||
"default": 0.0, "min": 0.0, "max": 10e-6, "step": 1e-9,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'hz'),
|
||||
('DATA_FIELD', 'dhz_dz'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Generates the stray field from parallel magnetic stripe domains with "
|
||||
"alternating up/down magnetization along x, uniform along y. Computes "
|
||||
"both the normal component Hz and its vertical gradient dHz/dz using "
|
||||
"FFT-based transfer functions, suitable for MFM simulation and testing. "
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
height: float,
|
||||
thickness: float,
|
||||
magnetization: float,
|
||||
stripe_width_a: float,
|
||||
stripe_width_b: float,
|
||||
gap: float,
|
||||
) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# --- 1. Build 1D magnetization profile along x ---
|
||||
x = np.linspace(0, field.xreal, xres, endpoint=False)
|
||||
period = stripe_width_a + stripe_width_b + 2 * gap
|
||||
|
||||
x_mod = x % period
|
||||
m_1d = np.zeros(xres, dtype=np.float64)
|
||||
|
||||
# Domain A: +M
|
||||
mask_a = x_mod < stripe_width_a
|
||||
m_1d[mask_a] = magnetization
|
||||
|
||||
# Domain B: -M
|
||||
b_start = stripe_width_a + gap
|
||||
b_end = stripe_width_a + gap + stripe_width_b
|
||||
mask_b = (x_mod >= b_start) & (x_mod < b_end)
|
||||
m_1d[mask_b] = -magnetization
|
||||
|
||||
# Gaps remain zero
|
||||
|
||||
# --- 2. FFT of the magnetization profile ---
|
||||
M_k = np.fft.rfft(m_1d)
|
||||
kx = np.fft.rfftfreq(xres, d=field.dx) * 2 * np.pi
|
||||
k_abs = np.abs(kx)
|
||||
|
||||
# Avoid division by zero at DC
|
||||
k_safe = np.where(k_abs == 0, 1.0, k_abs)
|
||||
|
||||
# --- 3. Transfer function for Hz ---
|
||||
# Hz(k, z) = -(1/2) * exp(-|k|*z) * (1 - exp(-|k|*t)) * M(k)
|
||||
transfer_hz = -0.5 * np.exp(-k_abs * height) * (1 - np.exp(-k_safe * thickness))
|
||||
transfer_hz[0] = 0.0 # no DC component
|
||||
|
||||
Hz_1d = np.fft.irfft(M_k * transfer_hz, n=xres)
|
||||
|
||||
# --- 4. Transfer function for dHz/dz ---
|
||||
# d/dz[exp(-k*z)] = -k * exp(-k*z), so the derivative adds a factor of -(-k) = k
|
||||
# but with the negative sign in Hz: dHz/dz picks up an extra factor of k_abs
|
||||
transfer_dhz = 0.5 * k_abs * np.exp(-k_abs * height) * (1 - np.exp(-k_safe * thickness))
|
||||
transfer_dhz[0] = 0.0
|
||||
|
||||
dHz_dz_1d = np.fft.irfft(M_k * transfer_dhz, n=xres)
|
||||
|
||||
# --- 5. Tile to 2D (uniform along y) ---
|
||||
Hz = np.tile(Hz_1d, (yres, 1))
|
||||
dHz_dz = np.tile(dHz_dz_1d, (yres, 1))
|
||||
|
||||
# --- 6. Build output DataFields ---
|
||||
hz_field = field.replace(data=Hz, si_unit_z="A/m")
|
||||
dhz_dz_field = field.replace(data=dHz_dz, si_unit_z="A/m²")
|
||||
|
||||
return (hz_field, dhz_dz_field)
|
||||
78
backend/nodes/pfm_analysis.py
Normal file
78
backend/nodes/pfm_analysis.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""PFM analysis — piezoresponse force microscopy polarization vectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="PFM Analysis")
|
||||
class PFMAnalysis:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"vpfm_amplitude": ("DATA_FIELD",),
|
||||
"lpfm_amplitude": ("DATA_FIELD",),
|
||||
"vpfm_phase": ("DATA_FIELD",),
|
||||
"lpfm_phase": ("DATA_FIELD",),
|
||||
"mode": (["2d", "3d"],),
|
||||
"lateral_sensitivity": ("FLOAT", {
|
||||
"default": 1.0, "min": 0.01, "max": 100.0, "step": 0.01,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'magnitude'),
|
||||
('DATA_FIELD', 'azimuth'),
|
||||
('DATA_FIELD', 'inclination'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Piezoresponse force microscopy analysis. Computes in-plane (2D) or "
|
||||
"full 3D polarization vectors from vertical and lateral PFM amplitude "
|
||||
"and phase channels. The lateral sensitivity parameter scales the "
|
||||
"lateral signal relative to the vertical. Outputs are the polarization "
|
||||
"magnitude, the in-plane azimuth angle, and the out-of-plane "
|
||||
"inclination angle (zero in 2D mode)."
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
vpfm_amplitude: DataField,
|
||||
lpfm_amplitude: DataField,
|
||||
vpfm_phase: DataField,
|
||||
lpfm_phase: DataField,
|
||||
mode: str,
|
||||
lateral_sensitivity: float,
|
||||
) -> tuple:
|
||||
va = np.asarray(vpfm_amplitude.data, dtype=np.float64)
|
||||
la = np.asarray(lpfm_amplitude.data, dtype=np.float64)
|
||||
vp = np.asarray(vpfm_phase.data, dtype=np.float64)
|
||||
lp = np.asarray(lpfm_phase.data, dtype=np.float64)
|
||||
|
||||
# In-plane components from lateral PFM
|
||||
x = la * lateral_sensitivity * np.cos(lp)
|
||||
y = la * lateral_sensitivity * np.sin(lp)
|
||||
# Out-of-plane component from vertical PFM
|
||||
z = va * np.cos(vp)
|
||||
|
||||
xy_mag = np.sqrt(x**2 + y**2)
|
||||
azimuth = np.arctan2(y, x)
|
||||
|
||||
if mode == "2d":
|
||||
magnitude = xy_mag
|
||||
inclination = np.zeros_like(magnitude)
|
||||
else:
|
||||
magnitude = np.sqrt(x**2 + y**2 + z**2)
|
||||
inclination = np.arctan2(z, xy_mag)
|
||||
|
||||
return (
|
||||
vpfm_amplitude.replace(data=magnitude, si_unit_z=""),
|
||||
vpfm_amplitude.replace(data=azimuth, si_unit_z="rad"),
|
||||
vpfm_amplitude.replace(data=inclination, si_unit_z="rad"),
|
||||
)
|
||||
89
backend/nodes/sem_simulation.py
Normal file
89
backend/nodes/sem_simulation.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""SEM simulation — scanning electron microscopy image simulation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import gaussian_filter, shift
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="SEM Simulation")
|
||||
class SEMSimulation:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"method": (["integration", "monte_carlo"],),
|
||||
"sigma": ("FLOAT", {
|
||||
"default": 3.0, "min": 0.1, "max": 50.0, "step": 0.1,
|
||||
}),
|
||||
"n_samples": ("INT", {
|
||||
"default": 100, "min": 10, "max": 10000,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'result'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Simulates scanning electron microscopy imaging from topography data. "
|
||||
"The integration method computes the surface slope (gradient magnitude) "
|
||||
"and applies Gaussian smoothing to approximate the secondary electron "
|
||||
"yield, modelling the beam interaction volume. The Monte Carlo method "
|
||||
"stochastically samples neighbour height differences weighted by a "
|
||||
"Gaussian kernel to estimate the local surface visibility, producing "
|
||||
"edge-enhanced contrast similar to real SEM images."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, method: str, sigma: float,
|
||||
n_samples: int) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
if method == "integration":
|
||||
result = self._integration(data, field.dx, field.dy, sigma)
|
||||
elif method == "monte_carlo":
|
||||
result = self._monte_carlo(data, sigma, n_samples)
|
||||
else:
|
||||
raise ValueError(f"Unknown SEM simulation method: {method!r}")
|
||||
|
||||
return (field.replace(data=result, si_unit_z=""),)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _integration(data: np.ndarray, dx: float, dy: float,
|
||||
sigma: float) -> np.ndarray:
|
||||
"""Gradient-magnitude method with Gaussian interaction smoothing."""
|
||||
dz_dy, dz_dx = np.gradient(data, dy, dx)
|
||||
# SEM signal ~ local slope (edge enhancement)
|
||||
slope = np.sqrt(dz_dx**2 + dz_dy**2)
|
||||
# Gaussian blur to simulate beam interaction volume
|
||||
result = gaussian_filter(slope, sigma=sigma)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _monte_carlo(data: np.ndarray, sigma: float,
|
||||
n_samples: int) -> np.ndarray:
|
||||
"""Stochastic height-difference sampling with Gaussian weighting."""
|
||||
rng = np.random.default_rng(42)
|
||||
result = np.zeros_like(data)
|
||||
|
||||
for _ in range(n_samples):
|
||||
dx_off = rng.normal(0, sigma)
|
||||
dy_off = rng.normal(0, sigma)
|
||||
dist = math.sqrt(dx_off**2 + dy_off**2)
|
||||
if dist > 0:
|
||||
shifted = shift(data, [dy_off, dx_off], mode='reflect')
|
||||
weight = math.exp(-(dx_off**2 + dy_off**2) / (2 * sigma**2))
|
||||
result += weight * (data - shifted) / dist
|
||||
|
||||
result /= n_samples
|
||||
return result
|
||||
136
backend/nodes/smm_analysis.py
Normal file
136
backend/nodes/smm_analysis.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""SMM analysis — scanning microwave microscopy 3-point calibration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="SMM Analysis")
|
||||
class SMMAnalysis:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"s11_amplitude": ("DATA_FIELD",),
|
||||
"s11_phase": ("DATA_FIELD",),
|
||||
"frequency": ("FLOAT", {
|
||||
"default": 1e9, "min": 1e6, "max": 100e9, "step": 1e6,
|
||||
}),
|
||||
"ref_impedance": ("FLOAT", {
|
||||
"default": 50.0, "min": 1.0, "max": 1000.0,
|
||||
}),
|
||||
"cal_c1": ("FLOAT", {
|
||||
"default": 1e-15, "min": 1e-18, "max": 1e-9, "step": 1e-18,
|
||||
}),
|
||||
"cal_c2": ("FLOAT", {
|
||||
"default": 10e-15, "min": 1e-18, "max": 1e-9, "step": 1e-18,
|
||||
}),
|
||||
"cal_c3": ("FLOAT", {
|
||||
"default": 100e-15, "min": 1e-18, "max": 1e-9, "step": 1e-18,
|
||||
}),
|
||||
"cal_s11_1": ("FLOAT", {
|
||||
"default": 0.9, "min": -1.0, "max": 1.0, "step": 0.001,
|
||||
}),
|
||||
"cal_s11_2": ("FLOAT", {
|
||||
"default": 0.5, "min": -1.0, "max": 1.0, "step": 0.001,
|
||||
}),
|
||||
"cal_s11_3": ("FLOAT", {
|
||||
"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.001,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'capacitance'),
|
||||
('DATA_FIELD', 'impedance_real'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Scanning microwave microscopy analysis using 3-point calibration. "
|
||||
"Corrects measured S11 reflection data using three known calibration "
|
||||
"capacitances to solve for the VNA error coefficients (e00, e01, e11), "
|
||||
"then extracts tip-sample capacitance and real impedance maps."
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
s11_amplitude: DataField,
|
||||
s11_phase: DataField,
|
||||
frequency: float,
|
||||
ref_impedance: float,
|
||||
cal_c1: float,
|
||||
cal_c2: float,
|
||||
cal_c3: float,
|
||||
cal_s11_1: float,
|
||||
cal_s11_2: float,
|
||||
cal_s11_3: float,
|
||||
) -> tuple:
|
||||
omega = 2.0 * np.pi * frequency
|
||||
|
||||
# --- Step 1: Compute ideal S11 for each calibration capacitance ---
|
||||
cal_caps = [cal_c1, cal_c2, cal_c3]
|
||||
cal_s11_meas = [cal_s11_1, cal_s11_2, cal_s11_3]
|
||||
s11_ideal = []
|
||||
for c in cal_caps:
|
||||
z_load = 1.0 / (1j * omega * c)
|
||||
s11_ideal.append(
|
||||
(z_load - ref_impedance) / (z_load + ref_impedance)
|
||||
)
|
||||
|
||||
# --- Step 2: Solve for error coefficients (e00, e01, e11) ---
|
||||
# Model: S11m_i = e00 + e01 * S11a_i / (1 - e11 * S11a_i)
|
||||
# Rearranged: S11m_i * (1 - e11 * S11a_i) = e00 * (1 - e11 * S11a_i) + e01 * S11a_i
|
||||
# S11m_i = e00 + S11a_i * (e01 + e11 * S11m_i - e11 * e00)
|
||||
#
|
||||
# Linear form with unknowns [e00, e01, e11]:
|
||||
# S11m_i = e00 + e01 * S11a_i / (1 - e11 * S11a_i)
|
||||
# Multiply through by (1 - e11 * S11a_i):
|
||||
# S11m_i - S11m_i * e11 * S11a_i = e00 - e00 * e11 * S11a_i + e01 * S11a_i
|
||||
# Rearrange to a linear system in (e00, e01, e11):
|
||||
# S11m_i = e00 + e01 * S11a_i + e11 * S11a_i * S11m_i
|
||||
# This follows because the product e00*e11 can be absorbed by defining
|
||||
# the system as: S11m = e00 + e01*Ga + e11*Ga*S11m
|
||||
# where Ga = S11_ideal.
|
||||
#
|
||||
# Matrix row: [1, S11a_i, S11a_i * S11m_i] * [e00, e01, e11]^T = S11m_i
|
||||
|
||||
A = np.zeros((3, 3), dtype=complex)
|
||||
b = np.zeros(3, dtype=complex)
|
||||
for i in range(3):
|
||||
ga = s11_ideal[i]
|
||||
sm = cal_s11_meas[i]
|
||||
A[i, 0] = 1.0
|
||||
A[i, 1] = ga
|
||||
A[i, 2] = ga * sm
|
||||
b[i] = sm
|
||||
|
||||
coeffs = np.linalg.solve(A, b)
|
||||
e00 = coeffs[0]
|
||||
e01 = coeffs[1]
|
||||
e11 = coeffs[2]
|
||||
|
||||
# --- Step 3: Apply calibration to measured S11 data ---
|
||||
amp = np.asarray(s11_amplitude.data, dtype=np.float64)
|
||||
phase = np.asarray(s11_phase.data, dtype=np.float64)
|
||||
s11m_complex = amp * np.exp(1j * phase)
|
||||
|
||||
# Invert the error model: Ga = (S11m - e00) / (e01 + e11*(S11m - e00))
|
||||
s11_corrected = (s11m_complex - e00) / (e01 + e11 * (s11m_complex - e00))
|
||||
|
||||
# --- Step 4: Convert corrected S11 to impedance ---
|
||||
z_tip = ref_impedance * (1.0 + s11_corrected) / (1.0 - s11_corrected)
|
||||
|
||||
# --- Step 5: Extract capacitance from admittance ---
|
||||
y_tip = 1.0 / z_tip
|
||||
capacitance = np.imag(y_tip) / omega
|
||||
|
||||
impedance_real = np.real(z_tip)
|
||||
|
||||
cap_field = s11_amplitude.replace(data=capacitance, si_unit_z="F")
|
||||
imp_field = s11_amplitude.replace(data=impedance_real, si_unit_z="Ohm")
|
||||
|
||||
return (cap_field, imp_field)
|
||||
@@ -8,6 +8,10 @@ from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Original generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fbm_surface(shape, rng, H=0.7):
|
||||
"""Fractional Brownian motion surface via spectral synthesis."""
|
||||
yres, xres = shape
|
||||
@@ -15,15 +19,13 @@ def _fbm_surface(shape, rng, H=0.7):
|
||||
ky = np.fft.fftfreq(yres)
|
||||
KX, KY = np.meshgrid(kx, ky)
|
||||
K = np.sqrt(KX**2 + KY**2)
|
||||
K[0, 0] = 1.0 # avoid division by zero
|
||||
K[0, 0] = 1.0
|
||||
power = K ** (-(H + 1.0))
|
||||
power[0, 0] = 0.0
|
||||
|
||||
phases = rng.uniform(0, 2 * np.pi, shape)
|
||||
amplitudes = rng.standard_normal(shape)
|
||||
fft_data = amplitudes * np.sqrt(power) * np.exp(1j * phases)
|
||||
surface = np.real(np.fft.ifft2(fft_data))
|
||||
return surface
|
||||
return np.real(np.fft.ifft2(fft_data))
|
||||
|
||||
|
||||
def _lattice_surface(shape, xreal, yreal, spacing, angle_deg):
|
||||
@@ -32,11 +34,9 @@ def _lattice_surface(shape, xreal, yreal, spacing, angle_deg):
|
||||
x = np.linspace(0, xreal, xres, endpoint=False)
|
||||
y = np.linspace(0, yreal, yres, endpoint=False)
|
||||
X, Y = np.meshgrid(x, y)
|
||||
|
||||
theta = np.radians(angle_deg)
|
||||
k = 2 * np.pi / spacing
|
||||
surface = np.cos(k * X) + np.cos(k * (X * np.cos(theta) + Y * np.sin(theta)))
|
||||
return surface
|
||||
return np.cos(k * X) + np.cos(k * (X * np.cos(theta) + Y * np.sin(theta)))
|
||||
|
||||
|
||||
def _steps_surface(shape, n_steps):
|
||||
@@ -61,14 +61,408 @@ def _particles_surface(shape, rng, n_particles, radius_px):
|
||||
return surface
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New generators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _columnar_surface(shape, rng, n, radius):
|
||||
"""Columnar growth — Gaussian pillars at random positions."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
sigma2 = max(1.0, float(radius) ** 2)
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
h = rng.uniform(0.3, 1.0)
|
||||
r2 = (yy - cy) ** 2 + (xx - cx) ** 2
|
||||
surface += h * np.exp(-r2 / (2.0 * sigma2))
|
||||
return surface
|
||||
|
||||
|
||||
def _objects_surface(shape, rng, n, size, obj_shape):
|
||||
"""Random geometric objects (sphere, pyramid, box, cylinder, cone)."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
s = max(float(size), 1.0)
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
h = rng.uniform(0.5, 1.0)
|
||||
dy = (yy - cy).astype(np.float64)
|
||||
dx = (xx - cx).astype(np.float64)
|
||||
r = np.sqrt(dy ** 2 + dx ** 2)
|
||||
if obj_shape == "pyramid":
|
||||
bump = np.maximum(1.0 - np.maximum(np.abs(dy), np.abs(dx)) / s, 0.0)
|
||||
elif obj_shape == "box":
|
||||
bump = ((np.abs(dy) <= s) & (np.abs(dx) <= s)).astype(np.float64)
|
||||
elif obj_shape == "cylinder":
|
||||
bump = (r <= s).astype(np.float64)
|
||||
elif obj_shape == "cone":
|
||||
bump = np.maximum(1.0 - r / s, 0.0)
|
||||
else: # sphere
|
||||
bump = np.sqrt(np.maximum(s ** 2 - dy ** 2 - dx ** 2, 0.0)) / s
|
||||
surface = np.maximum(surface, h * bump)
|
||||
return surface
|
||||
|
||||
|
||||
def _fibres_surface(shape, rng, n, length, width):
|
||||
"""Randomly oriented fibre/line features."""
|
||||
yres, xres = shape
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.mgrid[:yres, :xres]
|
||||
for _ in range(n):
|
||||
cy, cx = rng.uniform(0, yres), rng.uniform(0, xres)
|
||||
angle = rng.uniform(0, np.pi)
|
||||
h = rng.uniform(0.5, 1.0)
|
||||
cos_a, sin_a = np.cos(angle), np.sin(angle)
|
||||
along = (xx - cx) * cos_a + (yy - cy) * sin_a
|
||||
across = -(xx - cx) * sin_a + (yy - cy) * cos_a
|
||||
mask = (np.abs(along) <= length / 2) & (np.abs(across) <= width)
|
||||
surface = np.maximum(surface, h * mask.astype(np.float64))
|
||||
return surface
|
||||
|
||||
|
||||
def _waves_surface(shape, rng, n_sources, frequency):
|
||||
"""Superposition of decaying circular waves from random sources."""
|
||||
yres, xres = shape
|
||||
xn = np.arange(xres, dtype=np.float64) / max(xres - 1, 1)
|
||||
yn = np.arange(yres, dtype=np.float64) / max(yres - 1, 1)
|
||||
X, Y = np.meshgrid(xn, yn)
|
||||
surface = np.zeros(shape)
|
||||
for _ in range(n_sources):
|
||||
sx, sy = rng.random(), rng.random()
|
||||
amp = rng.uniform(0.5, 1.0)
|
||||
r = np.sqrt((X - sx) ** 2 + (Y - sy) ** 2)
|
||||
surface += amp * np.exp(-3.0 * r) * np.cos(2 * np.pi * frequency * r)
|
||||
return surface
|
||||
|
||||
|
||||
def _dunes_surface(shape, rng, frequency, direction_deg):
|
||||
"""Asymmetric dune-like rippled surface."""
|
||||
yres, xres = shape
|
||||
theta = np.radians(direction_deg)
|
||||
xn = np.arange(xres, dtype=np.float64) / max(xres - 1, 1)
|
||||
yn = np.arange(yres, dtype=np.float64) / max(yres - 1, 1)
|
||||
X, Y = np.meshgrid(xn, yn)
|
||||
phase = frequency * (X * np.cos(theta) + Y * np.sin(theta))
|
||||
frac = phase - np.floor(phase)
|
||||
profile = np.where(frac < 0.7, frac / 0.7, (1.0 - frac) / 0.3)
|
||||
return profile + rng.standard_normal(shape) * 0.03
|
||||
|
||||
|
||||
def _domains_surface(shape, rng, n_iterations):
|
||||
"""Phase-separated domains via 2D Ising model (checkerboard Metropolis)."""
|
||||
yres, xres = shape
|
||||
spins = rng.choice([-1.0, 1.0], size=shape)
|
||||
beta = 0.55
|
||||
y, x = np.ogrid[:yres, :xres]
|
||||
for _ in range(n_iterations):
|
||||
for parity in range(2):
|
||||
mask = ((y + x) % 2 == parity)
|
||||
neighbors = (np.roll(spins, 1, axis=0) + np.roll(spins, -1, axis=0) +
|
||||
np.roll(spins, 1, axis=1) + np.roll(spins, -1, axis=1))
|
||||
dE = 2.0 * spins * neighbors
|
||||
flip = (dE <= 0) | (rng.random(shape) < np.exp(np.minimum(-beta * dE, 0.0)))
|
||||
spins = np.where(mask & flip, -spins, spins)
|
||||
return spins
|
||||
|
||||
|
||||
def _ballistic_surface(shape, rng, n_iterations):
|
||||
"""Ballistic deposition with neighbor adhesion (vectorised)."""
|
||||
heights = np.zeros(shape)
|
||||
for _ in range(n_iterations):
|
||||
drops = rng.random(shape) > 0.7
|
||||
padded = np.pad(heights, 1, mode='wrap')
|
||||
neighbor_max = np.maximum.reduce([
|
||||
padded[:-2, 1:-1], padded[2:, 1:-1],
|
||||
padded[1:-1, :-2], padded[1:-1, 2:],
|
||||
])
|
||||
heights = np.where(drops, np.maximum(heights, neighbor_max) + 1, heights)
|
||||
return heights
|
||||
|
||||
|
||||
def _deposition_surface(shape, rng, n, radius):
|
||||
"""Particle stacking — spheres deposited with gravity."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
r2 = ((yy - cy) ** 2 + (xx - cx) ** 2).astype(np.float64)
|
||||
h_sphere = np.sqrt(np.maximum(float(radius) ** 2 - r2, 0.0))
|
||||
footprint = h_sphere > 0
|
||||
base = float(surface[footprint].max()) if footprint.any() else 0.0
|
||||
surface = np.maximum(surface, base + h_sphere)
|
||||
return surface
|
||||
|
||||
|
||||
def _rods_surface(shape, rng, n, length, width):
|
||||
"""Rod/wire features with rounded (semicircular) cross-section."""
|
||||
yres, xres = shape
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.mgrid[:yres, :xres]
|
||||
w = max(float(width), 1.0)
|
||||
for _ in range(n):
|
||||
cy, cx = rng.uniform(0, yres), rng.uniform(0, xres)
|
||||
angle = rng.uniform(0, np.pi)
|
||||
h = rng.uniform(0.5, 1.0)
|
||||
cos_a, sin_a = np.cos(angle), np.sin(angle)
|
||||
along = (xx - cx) * cos_a + (yy - cy) * sin_a
|
||||
across = (-(xx - cx) * sin_a + (yy - cy) * cos_a).astype(np.float64)
|
||||
in_rod = (np.abs(along) <= length / 2).astype(np.float64)
|
||||
profile = np.sqrt(np.maximum(w ** 2 - across ** 2, 0.0)) / w
|
||||
surface = np.maximum(surface, h * profile * in_rod)
|
||||
return surface
|
||||
|
||||
|
||||
def _dla_surface(shape, rng, n_iterations):
|
||||
"""Diffusion-limited aggregation via iterative boundary growth."""
|
||||
from scipy.ndimage import binary_dilation
|
||||
grid = np.zeros(shape)
|
||||
grid[shape[0] // 2, shape[1] // 2] = 1.0
|
||||
struct = np.ones((3, 3), dtype=bool)
|
||||
for _ in range(n_iterations):
|
||||
dilated = binary_dilation(grid > 0, structure=struct)
|
||||
boundary = dilated & (grid == 0)
|
||||
candidates = np.argwhere(boundary)
|
||||
if len(candidates) == 0:
|
||||
break
|
||||
n_add = max(1, len(candidates) // 8)
|
||||
chosen = rng.choice(len(candidates), size=min(n_add, len(candidates)),
|
||||
replace=False)
|
||||
for idx in chosen:
|
||||
grid[candidates[idx][0], candidates[idx][1]] = rng.uniform(0.5, 1.0)
|
||||
return grid
|
||||
|
||||
|
||||
def _discs_surface(shape, rng, n, radius):
|
||||
"""Flat-topped circular disc features."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
h = rng.uniform(0.5, 1.0)
|
||||
r = np.sqrt(((yy - cy) ** 2 + (xx - cx) ** 2).astype(np.float64))
|
||||
surface = np.maximum(surface, h * (r <= radius).astype(np.float64))
|
||||
return surface
|
||||
|
||||
|
||||
def _plateaus_surface(shape, rng, n, radius):
|
||||
"""Flat-topped features with smooth (tanh) edges."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
h = rng.uniform(0.5, 1.0)
|
||||
r = np.sqrt(((yy - cy) ** 2 + (xx - cx) ** 2).astype(np.float64))
|
||||
edge_w = max(float(radius) * 0.2, 1.0)
|
||||
bump = h * 0.5 * (1.0 - np.tanh(3.0 * (r - radius) / edge_w))
|
||||
surface = np.maximum(surface, np.maximum(bump, 0.0))
|
||||
return surface
|
||||
|
||||
|
||||
def _pileups_surface(shape, rng, n, size):
|
||||
"""Rounded rectangle pileup structures."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
s = max(float(size), 1.0)
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
h = rng.uniform(0.5, 1.0)
|
||||
aspect = rng.uniform(0.5, 2.0)
|
||||
angle = rng.uniform(0, np.pi)
|
||||
cos_a, sin_a = np.cos(angle), np.sin(angle)
|
||||
dx = ((xx - cx) * cos_a + (yy - cy) * sin_a).astype(np.float64)
|
||||
dy = (-(xx - cx) * sin_a + (yy - cy) * cos_a).astype(np.float64)
|
||||
w, ht = s * aspect, s / aspect
|
||||
r = ((np.abs(dx) / max(w, 1.0)) ** 4 + (np.abs(dy) / max(ht, 1.0)) ** 4) ** 0.25
|
||||
surface = np.maximum(surface, h * np.maximum(1.0 - r, 0.0))
|
||||
return surface
|
||||
|
||||
|
||||
def _annealing_surface(shape, rng, n_iterations):
|
||||
"""Surface relaxation via simulated annealing (terrain smoothing)."""
|
||||
surface = rng.standard_normal(shape)
|
||||
for i in range(n_iterations):
|
||||
t = max(0.01, 1.0 - i / n_iterations)
|
||||
avg = (np.roll(surface, 1, 0) + np.roll(surface, -1, 0) +
|
||||
np.roll(surface, 1, 1) + np.roll(surface, -1, 1)) / 4.0
|
||||
surface += 0.2 * (avg - surface)
|
||||
surface += rng.standard_normal(shape) * t * 0.02
|
||||
return surface
|
||||
|
||||
|
||||
def _voronoi_surface(shape, rng, n_sites):
|
||||
"""Voronoi tessellation with random heights per cell."""
|
||||
yres, xres = shape
|
||||
sites_y = rng.uniform(0, yres, size=n_sites)
|
||||
sites_x = rng.uniform(0, xres, size=n_sites)
|
||||
heights = rng.uniform(0, 1, size=n_sites)
|
||||
yy, xx = np.mgrid[:yres, :xres]
|
||||
surface = np.zeros(shape)
|
||||
min_dist = np.full(shape, np.inf)
|
||||
for i in range(n_sites):
|
||||
dist = (yy - sites_y[i]) ** 2 + (xx - sites_x[i]) ** 2
|
||||
closer = dist < min_dist
|
||||
surface = np.where(closer, heights[i], surface)
|
||||
min_dist = np.where(closer, dist, min_dist)
|
||||
return surface
|
||||
|
||||
|
||||
def _spinodal_surface(shape, rng, n_iterations):
|
||||
"""Spinodal decomposition via Cahn-Hilliard equation (FFT-based)."""
|
||||
yres, xres = shape
|
||||
c = 0.5 + 0.05 * rng.standard_normal(shape)
|
||||
kx = np.fft.fftfreq(xres) * 2 * np.pi
|
||||
ky = np.fft.fftfreq(yres) * 2 * np.pi
|
||||
KX, KY = np.meshgrid(kx, ky)
|
||||
K2 = KX ** 2 + KY ** 2
|
||||
dt, eps2 = 0.5, 0.01
|
||||
denom = 1.0 + dt * eps2 * K2 ** 2
|
||||
for _ in range(n_iterations):
|
||||
mu_hat = np.fft.fft2(c ** 3 - c)
|
||||
c_hat = np.fft.fft2(c)
|
||||
c_hat = (c_hat - dt * K2 * mu_hat) / denom
|
||||
c = np.real(np.fft.ifft2(c_hat))
|
||||
np.clip(c, -2.0, 2.0, out=c)
|
||||
return c
|
||||
|
||||
|
||||
def _pde_surface(shape, rng, n_iterations):
|
||||
"""Gray-Scott reaction-diffusion Turing patterns."""
|
||||
Du, Dv, F, k = 0.16, 0.08, 0.035, 0.065
|
||||
u = np.ones(shape)
|
||||
v = np.zeros(shape)
|
||||
r = min(shape[0], shape[1]) // 8
|
||||
cy, cx = shape[0] // 2, shape[1] // 2
|
||||
y0, y1 = max(0, cy - r), min(shape[0], cy + r)
|
||||
x0, x1 = max(0, cx - r), min(shape[1], cx + r)
|
||||
seed_shape = (y1 - y0, x1 - x0)
|
||||
u[y0:y1, x0:x1] = 0.5 + 0.1 * rng.standard_normal(seed_shape)
|
||||
v[y0:y1, x0:x1] = 0.25 + 0.1 * rng.standard_normal(seed_shape)
|
||||
for _ in range(n_iterations):
|
||||
lu = (np.roll(u, 1, 0) + np.roll(u, -1, 0) +
|
||||
np.roll(u, 1, 1) + np.roll(u, -1, 1) - 4 * u)
|
||||
lv = (np.roll(v, 1, 0) + np.roll(v, -1, 0) +
|
||||
np.roll(v, 1, 1) + np.roll(v, -1, 1) - 4 * v)
|
||||
uvv = u * v * v
|
||||
u += Du * lu - uvv + F * (1.0 - u)
|
||||
v += Dv * lv + uvv - (F + k) * v
|
||||
return v
|
||||
|
||||
|
||||
def _spectral_surface(shape, rng, exponent):
|
||||
"""FFT with power-law spectrum: P(k) proportional to k^(-exponent)."""
|
||||
yres, xres = shape
|
||||
kx = np.fft.fftfreq(xres)
|
||||
ky = np.fft.fftfreq(yres)
|
||||
KX, KY = np.meshgrid(kx, ky)
|
||||
K = np.sqrt(KX ** 2 + KY ** 2)
|
||||
K[0, 0] = 1.0
|
||||
power = K ** (-exponent)
|
||||
power[0, 0] = 0.0
|
||||
phases = rng.uniform(0, 2 * np.pi, shape)
|
||||
magnitudes = rng.standard_normal(shape)
|
||||
fft_data = magnitudes * np.sqrt(power) * np.exp(1j * phases)
|
||||
return np.real(np.fft.ifft2(fft_data))
|
||||
|
||||
|
||||
def _residues_surface(shape, rng, n, size):
|
||||
"""Irregular elliptical deposits with random orientation."""
|
||||
surface = np.zeros(shape)
|
||||
yy, xx = np.ogrid[:shape[0], :shape[1]]
|
||||
for _ in range(n):
|
||||
cy, cx = rng.integers(0, shape[0]), rng.integers(0, shape[1])
|
||||
h = rng.uniform(0.3, 1.0)
|
||||
aspect = rng.uniform(0.3, 3.0)
|
||||
angle = rng.uniform(0, np.pi)
|
||||
cos_a, sin_a = np.cos(angle), np.sin(angle)
|
||||
dx = ((xx - cx) * cos_a + (yy - cy) * sin_a).astype(np.float64)
|
||||
dy = (-(xx - cx) * sin_a + (yy - cy) * cos_a).astype(np.float64)
|
||||
sx = max(size * aspect, 1.0)
|
||||
sy = max(size / aspect, 1.0)
|
||||
bump = h * np.exp(-2.0 * ((dx / sx) ** 2 + (dy / sy) ** 2))
|
||||
surface = np.maximum(surface, bump)
|
||||
return surface
|
||||
|
||||
|
||||
def _noise_surface(shape, rng, noise_type):
|
||||
"""Various noise distributions."""
|
||||
if noise_type == "poisson":
|
||||
return rng.poisson(lam=5.0, size=shape).astype(np.float64)
|
||||
elif noise_type == "exponential":
|
||||
return rng.exponential(scale=1.0, size=shape)
|
||||
elif noise_type == "uniform":
|
||||
return rng.uniform(0, 1, size=shape)
|
||||
elif noise_type == "salt_pepper":
|
||||
base = np.zeros(shape)
|
||||
base[rng.random(shape) > 0.95] = 1.0
|
||||
base[rng.random(shape) > 0.95] = -1.0
|
||||
return base
|
||||
return rng.standard_normal(shape) # gaussian default
|
||||
|
||||
|
||||
def _periodic_surface(shape, frequency, periodic_type):
|
||||
"""Repeating tiling patterns."""
|
||||
yres, xres = shape
|
||||
xn = np.arange(xres, dtype=np.float64) / max(xres - 1, 1)
|
||||
yn = np.arange(yres, dtype=np.float64) / max(yres - 1, 1)
|
||||
X, Y = np.meshgrid(xn, yn)
|
||||
f = max(frequency, 0.1)
|
||||
if periodic_type == "hex":
|
||||
s = 1.0 / f
|
||||
r = np.sqrt(3) / 2
|
||||
cy = Y / (s * r)
|
||||
row = np.floor(cy)
|
||||
shift = (row % 2) * 0.5
|
||||
col = np.floor(X / s + shift)
|
||||
hx = (col - shift) * s
|
||||
hy = row * s * r
|
||||
return (np.sqrt((X - hx) ** 2 + (Y - hy) ** 2) < s * 0.35).astype(np.float64)
|
||||
elif periodic_type == "stripe":
|
||||
return (np.sin(2 * np.pi * f * X) > 0).astype(np.float64)
|
||||
elif periodic_type == "diamond":
|
||||
u = np.floor(f * (X + Y))
|
||||
v = np.floor(f * (X - Y))
|
||||
return ((u + v) % 2).astype(np.float64)
|
||||
elif periodic_type == "staircase":
|
||||
return np.floor(X * f * 2) / max(f, 0.1)
|
||||
elif periodic_type == "rings":
|
||||
r = np.sqrt((X - 0.5) ** 2 + (Y - 0.5) ** 2)
|
||||
return (np.sin(2 * np.pi * f * r * 4) > 0).astype(np.float64)
|
||||
# checker (default)
|
||||
return ((np.floor(X * f * 2) + np.floor(Y * f * 2)) % 2).astype(np.float64)
|
||||
|
||||
|
||||
def _wfr_surface(shape, rng, n_sources, frequency):
|
||||
"""Concentric wavefronts (ripples) from random sources — no decay."""
|
||||
yres, xres = shape
|
||||
xn = np.arange(xres, dtype=np.float64) / max(xres - 1, 1)
|
||||
yn = np.arange(yres, dtype=np.float64) / max(yres - 1, 1)
|
||||
X, Y = np.meshgrid(xn, yn)
|
||||
surface = np.zeros(shape)
|
||||
for _ in range(n_sources):
|
||||
sx, sy = rng.random(), rng.random()
|
||||
r = np.sqrt((X - sx) ** 2 + (Y - sy) ** 2)
|
||||
surface += np.cos(2 * np.pi * frequency * r)
|
||||
return surface / max(n_sources, 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@register_node(display_name="Synthetic Surface")
|
||||
class SyntheticSurface:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"pattern": (["fbm", "white_noise", "lattice", "steps", "particles", "flat"],
|
||||
{"default": "fbm"}),
|
||||
"pattern": ([
|
||||
"fbm", "white_noise", "lattice", "steps", "particles", "flat",
|
||||
"columnar", "objects", "fibres", "waves", "dunes",
|
||||
"domains", "ballistic", "deposition", "rods", "dla",
|
||||
"discs", "plateaus", "pileups", "annealing", "voronoi",
|
||||
"spinodal", "pde", "spectral", "residues",
|
||||
"noise", "periodic", "wfr",
|
||||
], {"default": "fbm"}),
|
||||
"xres": ("INT", {"default": 256, "min": 16, "max": 2048}),
|
||||
"yres": ("INT", {"default": 256, "min": 16, "max": 2048}),
|
||||
"xreal": ("FLOAT", {"default": 1e-6, "min": 1e-9, "max": 1.0, "step": 1e-9}),
|
||||
@@ -85,6 +479,17 @@ class SyntheticSurface:
|
||||
"n_steps": ("INT", {"default": 5, "min": 1, "max": 100}),
|
||||
"n_particles": ("INT", {"default": 20, "min": 1, "max": 500}),
|
||||
"particle_radius_px": ("INT", {"default": 10, "min": 2, "max": 100}),
|
||||
"n_iterations": ("INT", {"default": 200, "min": 10, "max": 5000}),
|
||||
"direction_deg": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0}),
|
||||
"feature_length_px": ("INT", {"default": 40, "min": 2, "max": 500}),
|
||||
"object_shape": (["sphere", "pyramid", "box", "cylinder", "cone"],
|
||||
{"default": "sphere"}),
|
||||
"noise_type": (["gaussian", "poisson", "exponential", "uniform", "salt_pepper"],
|
||||
{"default": "gaussian"}),
|
||||
"periodic_type": (["checker", "hex", "stripe", "diamond", "staircase", "rings"],
|
||||
{"default": "checker"}),
|
||||
"spectral_exponent": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 5.0, "step": 0.1}),
|
||||
"frequency": ("FLOAT", {"default": 5.0, "min": 0.5, "max": 50.0, "step": 0.5}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +500,9 @@ class SyntheticSurface:
|
||||
|
||||
DESCRIPTION = (
|
||||
"Generate synthetic test surfaces for development, calibration, and "
|
||||
"algorithm testing. Patterns: fbm (fractional Brownian motion), "
|
||||
"white_noise, lattice (periodic grid), steps (terraced), "
|
||||
"particles (spherical bumps on flat), flat (zero surface). "
|
||||
"algorithm testing. 28 patterns covering noise, geometry, growth "
|
||||
"simulations, phase separation, reaction-diffusion, and tiling. "
|
||||
"Equivalent to Gwyddion's *_synth.c modules."
|
||||
)
|
||||
|
||||
def process(
|
||||
@@ -115,6 +520,14 @@ class SyntheticSurface:
|
||||
n_steps: int = 5,
|
||||
n_particles: int = 20,
|
||||
particle_radius_px: int = 10,
|
||||
n_iterations: int = 200,
|
||||
direction_deg: float = 0.0,
|
||||
feature_length_px: int = 40,
|
||||
object_shape: str = "sphere",
|
||||
noise_type: str = "gaussian",
|
||||
periodic_type: str = "checker",
|
||||
spectral_exponent: float = 2.0,
|
||||
frequency: float = 5.0,
|
||||
) -> tuple:
|
||||
shape = (yres, xres)
|
||||
rng = np.random.default_rng(seed)
|
||||
@@ -131,6 +544,50 @@ class SyntheticSurface:
|
||||
data = _particles_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "flat":
|
||||
data = np.zeros(shape)
|
||||
elif pattern == "columnar":
|
||||
data = _columnar_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "objects":
|
||||
data = _objects_surface(shape, rng, n_particles, particle_radius_px, object_shape)
|
||||
elif pattern == "fibres":
|
||||
data = _fibres_surface(shape, rng, n_particles, feature_length_px, particle_radius_px)
|
||||
elif pattern == "waves":
|
||||
data = _waves_surface(shape, rng, n_particles, frequency)
|
||||
elif pattern == "dunes":
|
||||
data = _dunes_surface(shape, rng, frequency, direction_deg)
|
||||
elif pattern == "domains":
|
||||
data = _domains_surface(shape, rng, n_iterations)
|
||||
elif pattern == "ballistic":
|
||||
data = _ballistic_surface(shape, rng, n_iterations)
|
||||
elif pattern == "deposition":
|
||||
data = _deposition_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "rods":
|
||||
data = _rods_surface(shape, rng, n_particles, feature_length_px, particle_radius_px)
|
||||
elif pattern == "dla":
|
||||
data = _dla_surface(shape, rng, n_iterations)
|
||||
elif pattern == "discs":
|
||||
data = _discs_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "plateaus":
|
||||
data = _plateaus_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "pileups":
|
||||
data = _pileups_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "annealing":
|
||||
data = _annealing_surface(shape, rng, n_iterations)
|
||||
elif pattern == "voronoi":
|
||||
data = _voronoi_surface(shape, rng, n_particles)
|
||||
elif pattern == "spinodal":
|
||||
data = _spinodal_surface(shape, rng, n_iterations)
|
||||
elif pattern == "pde":
|
||||
data = _pde_surface(shape, rng, n_iterations)
|
||||
elif pattern == "spectral":
|
||||
data = _spectral_surface(shape, rng, spectral_exponent)
|
||||
elif pattern == "residues":
|
||||
data = _residues_surface(shape, rng, n_particles, particle_radius_px)
|
||||
elif pattern == "noise":
|
||||
data = _noise_surface(shape, rng, noise_type)
|
||||
elif pattern == "periodic":
|
||||
data = _periodic_surface(shape, frequency, periodic_type)
|
||||
elif pattern == "wfr":
|
||||
data = _wfr_surface(shape, rng, n_particles, frequency)
|
||||
else:
|
||||
raise ValueError(f"Unknown pattern: {pattern!r}")
|
||||
|
||||
|
||||
33
docs/nodes/Lateral Force Simulation.md
Normal file
33
docs/nodes/Lateral Force Simulation.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Lateral Force Simulation
|
||||
|
||||
Simulate lateral (friction) force signals from topography data, modeling how the local surface slope affects the cantilever torsion signal in contact-mode AFM. Equivalent to Gwyddion's `latsim.c` module.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| field | DATA_FIELD | Yes | Input topography surface field |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| forward | DATA_FIELD | Lateral force signal for the forward (+x) scan direction, in Newtons |
|
||||
| reverse | DATA_FIELD | Lateral force signal for the reverse (-x) scan direction, in Newtons |
|
||||
|
||||
## Controls
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| direction | dropdown | forward | Scan direction to compute: forward, reverse, or both. When set to forward or reverse, both outputs carry the same single-direction result |
|
||||
| friction_coefficient | FLOAT | 0.3 | Coulomb friction coefficient between tip and sample (0.0–10.0) |
|
||||
| adhesion | FLOAT | 1e-9 | Tip-sample adhesion force in Newtons (0.0–1e-6) |
|
||||
| load | FLOAT | 10e-9 | Applied normal load on the cantilever in Newtons (1e-12–1e-6) |
|
||||
|
||||
## Notes
|
||||
|
||||
- The lateral force is computed from a contact-mechanics model where the measured torsion signal depends on the local surface tilt angle. The x-gradient of the topography gives the slope, and the resulting lateral force combines the gravitational component along the slope with the friction force (proportional to the normal component of load plus adhesion): F_lateral = (F_load sin(theta) + mu (F_load cos(theta) + F_adhesion)) / (cos(theta) - mu sin(theta)).
|
||||
- Forward and reverse scans produce different lateral force signals because friction opposes the scan direction. The forward scan (+x) adds the friction contribution to the slope component, while the reverse scan (-x) subtracts it, producing the characteristic "friction loop" seen in LFM experiments.
|
||||
- Typical friction coefficients for common AFM sample materials: mica ~0.1–0.3, silicon ~0.2–0.5, polymers ~0.3–0.8, metals ~0.3–0.6. Use lower values for atomically smooth or lubricated surfaces.
|
||||
- Output values represent the lateral force on the cantilever tip in Newtons. To convert to photodetector voltage, divide by the lateral sensitivity of the optical lever system.
|
||||
- This node is the equivalent of Gwyddion's `latsim.c` lateral force simulation and uses the same contact-mechanics formulation for topography-induced friction artifacts.
|
||||
35
docs/nodes/MFM Current Simulation.md
Normal file
35
docs/nodes/MFM Current Simulation.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# MFM Current Simulation
|
||||
|
||||
Computes the magnetic stray field from an infinite current-carrying strip and the resulting force on an MFM tip. Useful for simulating the MFM response to current-carrying traces and interconnects.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| field | DATA_FIELD | Yes | Topography used for grid dimensions (x/y size and resolution) |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| hx | DATA_FIELD | In-plane (x) magnetic field component (A/m) |
|
||||
| hz | DATA_FIELD | Out-of-plane (z) magnetic field component (A/m) |
|
||||
| force | DATA_FIELD | Vertical force on the MFM tip (N) |
|
||||
|
||||
## Controls
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| height | FLOAT | 100e-9 | Tip-sample separation in metres |
|
||||
| current | FLOAT | 1e-3 | Strip current in amperes |
|
||||
| width | FLOAT | 100e-9 | Width of the current-carrying strip in metres |
|
||||
| tip_magnetization | FLOAT | 1e5 | Effective tip magnetic moment per unit volume in A/m |
|
||||
|
||||
## Notes
|
||||
|
||||
- The current strip is infinite along y and centred at x = 0, so the field varies only in the x direction.
|
||||
- Uses the Biot-Savart law for an infinite conducting strip of finite width to compute Hx and Hz.
|
||||
- Hx is the in-plane field component; Hz is the out-of-plane component.
|
||||
- Force is calculated with the point-dipole approximation: Fz = mu_0 * m_tip * dHz/dz.
|
||||
- Useful for simulating MFM response to current-carrying traces/interconnects.
|
||||
- Equivalent to Gwyddion's mfm_current.c.
|
||||
36
docs/nodes/MFM Domain Generation.md
Normal file
36
docs/nodes/MFM Domain Generation.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# MFM Domain Generation
|
||||
|
||||
Generate the stray field from a periodic pattern of parallel magnetic stripe domains with alternating up/down magnetization. Equivalent to Gwyddion's mfm_parallel.c.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| field | DATA_FIELD | Yes | Topography used for grid dimensions |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| hz | DATA_FIELD | z-component of the stray field (A/m) |
|
||||
| dhz_dz | DATA_FIELD | Vertical gradient of the stray field (A/m²) |
|
||||
|
||||
## Controls
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| height | FLOAT | 50e-9 | Tip-sample separation in metres |
|
||||
| thickness | FLOAT | 20e-9 | Magnetic film thickness in metres |
|
||||
| magnetization | FLOAT | 1e6 | Saturation magnetization in A/m |
|
||||
| stripe_width_a | FLOAT | 200e-9 | Width of the A (+M) domain in metres |
|
||||
| stripe_width_b | FLOAT | 200e-9 | Width of the B (-M) domain in metres |
|
||||
| gap | FLOAT | 0.0 | Domain wall gap between stripes in metres |
|
||||
|
||||
## Notes
|
||||
|
||||
- Domains alternate between A (+M, magnetization up) and B (-M, magnetization down) with an optional gap of zero magnetization between them.
|
||||
- The stray field is computed via FFT of the magnetization profile using an exponential decay transfer function.
|
||||
- The field is uniform along y; stripes run parallel to the y-axis.
|
||||
- dHz/dz is proportional to the MFM phase signal (force gradient), making it the quantity most directly comparable to raw MFM images.
|
||||
- Useful for simulating MFM calibration samples and testing MFM analysis workflows.
|
||||
- Typical stripe widths range from 100 nm to several microns.
|
||||
35
docs/nodes/PFM Analysis.md
Normal file
35
docs/nodes/PFM Analysis.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# PFM Analysis
|
||||
|
||||
Compute polarization vectors from Piezoresponse Force Microscopy (PFM) data by combining vertical (VPFM) and lateral (LPFM) amplitude and phase channels into polarization magnitude, azimuth, and inclination maps. Equivalent to Gwyddion's `pfm.c` module.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| vpfm_amplitude | DATA_FIELD | Yes | Vertical PFM amplitude channel |
|
||||
| lpfm_amplitude | DATA_FIELD | Yes | Lateral PFM amplitude channel |
|
||||
| vpfm_phase | DATA_FIELD | Yes | Vertical PFM phase channel (radians) |
|
||||
| lpfm_phase | DATA_FIELD | Yes | Lateral PFM phase channel (radians) |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| magnitude | DATA_FIELD | Polarization vector magnitude |
|
||||
| azimuth | DATA_FIELD | In-plane polarization angle |
|
||||
| inclination | DATA_FIELD | Out-of-plane polarization angle (3D mode only) |
|
||||
|
||||
## Controls
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| mode | dropdown | 2d | Decomposition mode: 2d (in-plane only) or 3d (full polarization vector) |
|
||||
| lateral_sensitivity | FLOAT | 1.0 | Scale factor applied to the lateral signal to compensate for differing VPFM/LPFM detector sensitivities |
|
||||
|
||||
## Notes
|
||||
|
||||
- PFM measures the local piezoelectric response of ferroelectric materials by detecting surface deformation under an applied AC bias via the AFM cantilever.
|
||||
- In **2d** mode, only in-plane polarization is resolved from the LPFM channels; magnitude and azimuth are computed but inclination is not produced. In **3d** mode, both vertical and lateral channels are combined to reconstruct the full polarization vector, including inclination.
|
||||
- The **lateral_sensitivity** parameter compensates for the fact that vertical and lateral deflection detectors typically have different sensitivities. Set this to the ratio of LPFM to VPFM detector sensitivity for accurate vector reconstruction.
|
||||
- Phase inputs must be in radians. If your data is in degrees, convert before connecting (multiply by pi/180).
|
||||
- Amplitude channels should be unsigned (absolute) piezoresponse magnitudes; the phase channels encode the sign (direction) of the polarization.
|
||||
31
docs/nodes/SEM Simulation.md
Normal file
31
docs/nodes/SEM Simulation.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# SEM Simulation
|
||||
|
||||
Simulate Scanning Electron Microscopy (SEM) secondary electron yield from topography data. Surface slopes and edges appear bright, while flat areas appear dark. Equivalent to Gwyddion's `semsim.c` module.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| field | DATA_FIELD | Yes | Input topography field |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| result | DATA_FIELD | Simulated SEM image (dimensionless intensity) |
|
||||
|
||||
## Controls
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| method | dropdown | integration | Simulation method: integration (fast, deterministic) or monte_carlo (stochastic) |
|
||||
| sigma | FLOAT | 3.0 | Beam interaction distance in pixels, controlling the spatial resolution of the simulated interaction volume |
|
||||
| n_samples | INT | 100 | Number of Monte Carlo samples per pixel (only used by the monte_carlo method) |
|
||||
|
||||
## Notes
|
||||
|
||||
- SEM imaging contrast arises because secondary electron yield depends on the local surface orientation relative to the incident beam. Tilted surfaces and sharp edges emit more secondary electrons and therefore appear brighter.
|
||||
- The integration method evaluates yield analytically from local slopes, making it faster and fully deterministic. The Monte Carlo method samples random electron trajectories, introducing realistic statistical variation into the result.
|
||||
- Sigma controls the effective size of the beam interaction volume. Larger values blur fine detail and simulate a broader excitation region; smaller values preserve sharp features.
|
||||
- n_samples only affects the monte_carlo method. Higher values produce smoother, more converged images at the cost of longer computation time.
|
||||
- This node is equivalent to Gwyddion's `semsim.c` data processing module.
|
||||
38
docs/nodes/SMM Analysis.md
Normal file
38
docs/nodes/SMM Analysis.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# SMM Analysis
|
||||
|
||||
Scanning Microwave Microscopy analysis: perform 3-point calibration and de-embedding to convert raw S11 reflection coefficient measurements into quantitative tip-sample capacitance and impedance maps. Equivalent to Gwyddion's smm.c and smm_apply.c modules.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|------|------|----------|-------------|
|
||||
| s11_amplitude | DATA_FIELD | Yes | Measured S11 reflection coefficient amplitude |
|
||||
| s11_phase | DATA_FIELD | Yes | Measured S11 reflection coefficient phase (radians) |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Type | Description |
|
||||
|------|------|-------------|
|
||||
| capacitance | DATA_FIELD | Calibrated tip-sample capacitance map (unit: F) |
|
||||
| impedance_real | DATA_FIELD | Real part of the de-embedded tip-sample impedance (unit: Ohm) |
|
||||
|
||||
## Controls
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| frequency | FLOAT | 1e9 | Microwave excitation frequency in Hz |
|
||||
| ref_impedance | FLOAT | 50.0 | Reference impedance of the transmission line in Ohm |
|
||||
| cal_c1 | FLOAT | — | First calibration capacitance standard (F) |
|
||||
| cal_c2 | FLOAT | — | Second calibration capacitance standard (F) |
|
||||
| cal_c3 | FLOAT | — | Third calibration capacitance standard (F) |
|
||||
| cal_s11_1 | FLOAT | — | Measured S11 (complex magnitude) at the first calibration standard |
|
||||
| cal_s11_2 | FLOAT | — | Measured S11 (complex magnitude) at the second calibration standard |
|
||||
| cal_s11_3 | FLOAT | — | Measured S11 (complex magnitude) at the third calibration standard |
|
||||
|
||||
## Notes
|
||||
|
||||
- SMM measures local microwave impedance and capacitance by recording the S11 reflection coefficient of a scanning probe coupled to a vector network analyser (VNA) operating at GHz frequencies.
|
||||
- The 3-point calibration procedure uses three known capacitance standards to solve for the VNA error terms and correct systematic measurement errors, mapping raw S11 values to the true tip-sample impedance.
|
||||
- The error model decomposes VNA systematics into three terms: e00 (directivity), e01 (tracking), and e11 (source match). These are determined from the three calibration measurements and then used to de-embed every pixel.
|
||||
- Calibration capacitances should span the expected measurement range; widely spaced standards yield a better-conditioned error model and more accurate results.
|
||||
- The phase input must be in radians. If your data is in degrees, convert before connecting to this node.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Synthetic Surface
|
||||
|
||||
Generate synthetic test surfaces for development, calibration, and algorithm testing. Equivalent to Gwyddion's *_synth.c modules.
|
||||
Generate synthetic test surfaces for development, calibration, and algorithm testing. 28 patterns covering noise, geometry, growth simulations, phase separation, reaction-diffusion, and tiling. Equivalent to Gwyddion's *_synth.c modules.
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -10,28 +10,202 @@ Generate synthetic test surfaces for development, calibration, and algorithm tes
|
||||
|
||||
## Controls
|
||||
|
||||
### Required
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| pattern | dropdown | fbm | Pattern type: fbm, white_noise, lattice, steps, particles, flat |
|
||||
| xres | INT | 256 | Horizontal resolution in pixels (16–2048) |
|
||||
| yres | INT | 256 | Vertical resolution in pixels (16–2048) |
|
||||
| xreal | FLOAT | 1e-6 | Physical width in metres |
|
||||
| yreal | FLOAT | 1e-6 | Physical height in metres |
|
||||
| amplitude | FLOAT | 1e-9 | Peak-to-peak amplitude in metres |
|
||||
| seed | INT | 42 | Random seed for reproducibility (0–999999) |
|
||||
| hurst_exponent | FLOAT | 0.7 | FBM roughness exponent: 0 = rough, 1 = smooth (fbm only) |
|
||||
| lattice_spacing | FLOAT | 100e-9 | Lattice period in metres (lattice only) |
|
||||
| lattice_angle | FLOAT | 90.0 | Angle between lattice vectors in degrees (lattice only) |
|
||||
| n_steps | INT | 5 | Number of step terraces (steps only) |
|
||||
| n_particles | INT | 20 | Number of particles (particles only) |
|
||||
| particle_radius_px | INT | 10 | Particle radius in pixels (particles only) |
|
||||
| pattern | dropdown | fbm | Synthesis pattern (28 options, see Notes below) |
|
||||
| xres | INT | 256 | Horizontal resolution in pixels (16--2048) |
|
||||
| yres | INT | 256 | Vertical resolution in pixels (16--2048) |
|
||||
| xreal | FLOAT | 1e-6 | Physical width in metres (1e-9--1.0) |
|
||||
| yreal | FLOAT | 1e-6 | Physical height in metres (1e-9--1.0) |
|
||||
| amplitude | FLOAT | 1e-9 | Peak-to-peak amplitude in metres (0--1e-3) |
|
||||
| seed | INT | 42 | Random seed for reproducibility (0--999999) |
|
||||
|
||||
### Optional (pattern-specific)
|
||||
|
||||
| Name | Type | Default | Range | Used by |
|
||||
|------|------|---------|-------|---------|
|
||||
| hurst_exponent | FLOAT | 0.7 | 0.0--1.0 | fbm |
|
||||
| lattice_spacing | FLOAT | 100e-9 | 1e-9--1e-3 | lattice |
|
||||
| lattice_angle | FLOAT | 90.0 | 0--180 | lattice |
|
||||
| n_steps | INT | 5 | 1--100 | steps |
|
||||
| n_particles | INT | 20 | 1--500 | particles, objects, discs, plateaus, pileups, residues, fibres, rods, columnar, deposition, waves, wfr, voronoi |
|
||||
| particle_radius_px | INT | 10 | 2--100 | particles, objects, discs, plateaus, pileups, residues, columnar, deposition, fibres (width), rods (width) |
|
||||
| n_iterations | INT | 200 | 10--5000 | domains, ballistic, annealing, spinodal, pde, dla |
|
||||
| direction_deg | FLOAT | 0.0 | 0--360 | dunes |
|
||||
| feature_length_px | INT | 40 | 2--500 | fibres, rods |
|
||||
| object_shape | dropdown | sphere | sphere, pyramid, box, cylinder, cone | objects |
|
||||
| noise_type | dropdown | gaussian | gaussian, poisson, exponential, uniform, salt_pepper | noise |
|
||||
| periodic_type | dropdown | checker | checker, hex, stripe, diamond, staircase, rings | periodic |
|
||||
| spectral_exponent | FLOAT | 2.0 | 0.5--5.0 | spectral |
|
||||
| frequency | FLOAT | 5.0 | 0.5--50.0 | waves, dunes, periodic, wfr |
|
||||
|
||||
## Notes
|
||||
|
||||
- **fbm**: Fractional Brownian motion via spectral synthesis. Hurst exponent controls roughness.
|
||||
- **white_noise**: Gaussian random noise.
|
||||
- **lattice**: Two-axis sinusoidal grid with configurable spacing and angle.
|
||||
- **steps**: Terraced step structure with equal step heights.
|
||||
- **particles**: Random spherical particles on a flat background.
|
||||
- **flat**: Zero surface (useful as a baseline).
|
||||
- All patterns are normalised to the specified amplitude range.
|
||||
All 28 patterns are normalised to the specified amplitude range after generation and use the seed for reproducibility. Changing the seed produces a different random realisation of the same pattern type. Optional parameters that do not apply to the selected pattern are silently ignored.
|
||||
|
||||
---
|
||||
|
||||
### Random & Spectral
|
||||
|
||||
**fbm** -- Fractional Brownian motion via spectral synthesis. Generates a 2D FFT field whose power spectrum follows a power law controlled by the Hurst exponent. An exponent of 0 produces very rough, jagged surfaces; an exponent of 1 produces smooth, gently undulating terrain. This models naturally rough surfaces such as thin films, etched or polished substrates, and geological terrain. Use it whenever you need a statistically self-affine rough surface for testing roughness analysis or levelling algorithms.
|
||||
|
||||
- Controls: `hurst_exponent` (0.0--1.0, default 0.7)
|
||||
|
||||
**white_noise** -- Uncorrelated Gaussian random noise. Every pixel is drawn independently from a standard normal distribution. This models detector noise, thermal noise floors, or provides a baseline noise field for testing denoising algorithms and signal-to-noise ratio calculations. No additional parameters beyond the common required controls.
|
||||
|
||||
**noise** -- Various noise distributions beyond Gaussian. Selecting a distribution changes the statistical character of the noise field:
|
||||
|
||||
- *gaussian* (default): standard normal distribution, symmetric tails.
|
||||
- *poisson*: integer-valued noise with lambda = 5; models photon counting statistics in optical or X-ray detection.
|
||||
- *exponential*: one-sided distribution; models rare-event intervals or surface feature waiting times.
|
||||
- *uniform*: flat distribution over [0, 1]; every value equally likely.
|
||||
- *salt_pepper*: impulse noise where ~5% of pixels are set to +1 or -1, rest zero; models dead pixels or spike artefacts in detector arrays.
|
||||
|
||||
Use this pattern to test how processing algorithms handle non-Gaussian noise statistics.
|
||||
|
||||
- Controls: `noise_type`
|
||||
|
||||
**spectral** -- FFT-based surface with a power-law spectrum P(k) proportional to k^(-alpha). This is a generalisation of FBM where the spectral exponent directly sets how power is distributed across spatial frequencies. A low exponent (near 0.5) concentrates energy at high spatial frequencies, producing rough, fine-grained textures. A high exponent (near 5.0) concentrates energy at low frequencies, producing smooth surfaces with long-range correlations. Use it to create test surfaces with precisely controlled spatial frequency content for filter testing or PSDF validation.
|
||||
|
||||
- Controls: `spectral_exponent` (0.5--5.0, default 2.0)
|
||||
|
||||
---
|
||||
|
||||
### Geometric Features
|
||||
|
||||
**particles** -- Random spherical particles on a flat background. Each particle is a hemispherical cap whose height is determined by the radius and position. Later particles overlay earlier ones (maximum height rule). This models nanoparticle deposits on substrates as seen in AFM imaging of colloidal or catalytic nanoparticle samples. Use it to test grain detection, particle counting, and size distribution algorithms.
|
||||
|
||||
- Controls: `n_particles` (count), `particle_radius_px` (radius in pixels)
|
||||
|
||||
**objects** -- Various 3D geometric shapes placed at random positions. Five shapes are available:
|
||||
|
||||
- *sphere*: hemispherical bumps (same geometry as particles).
|
||||
- *pyramid*: four-sided pyramid with linear slopes meeting at a point.
|
||||
- *box*: flat-topped rectangular blocks with vertical walls.
|
||||
- *cylinder*: flat-topped circular pillars.
|
||||
- *cone*: circular cone with linear profile tapering to a point.
|
||||
|
||||
Heights are randomised per object and the maximum-height rule applies. This models calibration gratings, nanofabricated structure arrays, and MEMS features. Use it to test shape recognition, tip characterisation, and volume measurement algorithms.
|
||||
|
||||
- Controls: `n_particles` (count), `particle_radius_px` (size), `object_shape`
|
||||
|
||||
**discs** -- Flat-topped circular disc features at random positions with random heights. The disc boundary is a sharp step edge. This models thin film islands, lithographic dot arrays, and etched circular features. Use it to test edge detection, step height measurement, and area coverage analysis.
|
||||
|
||||
- Controls: `n_particles` (count), `particle_radius_px` (radius)
|
||||
|
||||
**plateaus** -- Flat-topped circular features with smooth (tanh-profile) edge transitions instead of sharp steps. The edge transition width is 20% of the radius. This models mesa structures, etched plateaus, and features where the imaging tip or physical process rounds the edges. Useful for testing algorithms that need to distinguish between sharp and gradual step edges.
|
||||
|
||||
- Controls: `n_particles` (count), `particle_radius_px` (radius)
|
||||
|
||||
**pileups** -- Rounded rectangle structures with random aspect ratio (0.5--2.0) and random orientation. The profile uses a superelliptic (quartic) distance function, producing smoothly rounded rectangular footprints. This models rectangular nanostructures, MEMS features, and lithographic pads with rounded corners. Use it for testing grain shape analysis on non-circular features.
|
||||
|
||||
- Controls: `n_particles` (count), `particle_radius_px` (size)
|
||||
|
||||
**residues** -- Irregular elliptical deposits with random orientation and random aspect ratio (0.3--3.0). Each deposit has a Gaussian height profile, producing smooth, blob-like features. This models contamination residues, biological particles (cells, proteins), and other irregular deposits commonly encountered in AFM imaging of soft matter. Useful for testing feature detection on non-uniform, non-circular objects.
|
||||
|
||||
- Controls: `n_particles` (count), `particle_radius_px` (size)
|
||||
|
||||
---
|
||||
|
||||
### Linear Features
|
||||
|
||||
**fibres** -- Randomly oriented rectangular line features (constant cross-section, flat top). Each fibre has random position, orientation, and height. This models polymer fibres, nanowires, surface scratches, and elongated crystallites. Use it to test line detection, orientation analysis, and fibre network characterisation. The `particle_radius_px` parameter controls fibre width (half-width in pixels) while `feature_length_px` controls length.
|
||||
|
||||
- Controls: `n_particles` (count), `feature_length_px` (length), `particle_radius_px` (width)
|
||||
|
||||
**rods** -- Like fibres but with a semicircular cross-section profile instead of a flat top. This produces a more physically realistic rounded wire shape. Models nanowires, carbon nanotubes, and other cylindrical structures imaged by AFM where the cross-section appears as a half-circle due to tip convolution. Use it for testing tip deconvolution and cross-section fitting algorithms.
|
||||
|
||||
- Controls: `n_particles` (count), `feature_length_px` (length), `particle_radius_px` (width)
|
||||
|
||||
---
|
||||
|
||||
### Periodic & Tiling
|
||||
|
||||
**lattice** -- Two-axis sinusoidal grid. Two cosine waves are superimposed along directions separated by the specified angle, both with the same spatial period. At 90 degrees this produces a square grid; at 60 degrees a hexagonal-like pattern. This models crystal surfaces, calibration gratings, and periodic nanostructure arrays. Use it for testing Fourier analysis, lattice measurement, and periodicity detection.
|
||||
|
||||
- Controls: `lattice_spacing` (period in metres), `lattice_angle` (angle between lattice vectors in degrees, 0--180)
|
||||
|
||||
**steps** -- Terraced step structure with equal step heights across the x-axis. The surface is a staircase function with the specified number of terraces. This models vicinal crystal surfaces and atomic step trains as seen in STM/AFM imaging of semiconductor or metal surfaces. Use it for testing step detection, terrace fitting, and step height measurement algorithms.
|
||||
|
||||
- Controls: `n_steps` (number of terraces, 1--100)
|
||||
|
||||
**periodic** -- Repeating tiling patterns. Six tiling types are available:
|
||||
|
||||
- *checker* (default): alternating black/white squares (checkerboard).
|
||||
- *hex*: hexagonal array of circular dots.
|
||||
- *stripe*: vertical stripes (binary, based on sine threshold).
|
||||
- *diamond*: diamond/rhombus tiling pattern.
|
||||
- *staircase*: stepped ramp repeating across x.
|
||||
- *rings*: concentric ring pattern centred on the field.
|
||||
|
||||
This models lithographic patterns, photonic crystal layouts, and periodic test structures. Use it for testing spatial frequency analysis, periodicity detection, and pattern recognition. The `frequency` parameter controls the spatial frequency (number of pattern repeats across the field).
|
||||
|
||||
- Controls: `frequency` (spatial frequency, 0.5--50.0), `periodic_type`
|
||||
|
||||
**flat** -- Zero surface (all pixel values are zero before amplitude scaling; the output is identically zero regardless of amplitude). This serves as a baseline for testing, a null input for algorithm validation, or a starting point to be combined with other operations via Field Arithmetic. No additional parameters.
|
||||
|
||||
---
|
||||
|
||||
### Wave Patterns
|
||||
|
||||
**waves** -- Superposition of decaying circular waves from random source points. Each source emits a cosine wave that decays exponentially with distance (decay constant of 3.0 in normalised coordinates). Source amplitudes are randomised. This models surface acoustic wave interference, ripple patterns from localised impacts, and capillary wave superposition. Use it for testing wavelet analysis and source localisation algorithms.
|
||||
|
||||
- Controls: `n_particles` (number of wave sources), `frequency` (spatial frequency)
|
||||
|
||||
**dunes** -- Asymmetric sawtooth-like ripples along a specified direction. The profile ramps gradually over 70% of the period and drops sharply over the remaining 30%, mimicking wind-blown dune asymmetry. A small amount of Gaussian noise (3% amplitude) is added for realism. This models aeolian dune patterns, directional ion beam sputtering ripples, and other asymmetric periodic surface textures. Use it for testing directional analysis and asymmetry quantification.
|
||||
|
||||
- Controls: `frequency` (ripple frequency), `direction_deg` (propagation direction in degrees, 0--360)
|
||||
|
||||
**wfr** -- Concentric wavefronts from random sources without amplitude decay -- pure interference. Unlike `waves`, there is no exponential fall-off, so all sources contribute equally everywhere, producing complex moire-like interference patterns. The result is normalised by the number of sources. This models wave interference experiments, moire patterns, and standing wave fields. Use it for testing interference fringe analysis and phase extraction.
|
||||
|
||||
- Controls: `n_particles` (number of sources), `frequency` (spatial frequency)
|
||||
|
||||
---
|
||||
|
||||
### Growth & Deposition Simulations
|
||||
|
||||
**columnar** -- Gaussian pillar growth at random positions. Each column is a 2D Gaussian bump whose width is set by the radius parameter and whose height is randomised. Columns are summed (additive stacking), so overlapping columns produce taller features. This models columnar thin film growth as seen in PVD (physical vapour deposition) and CVD (chemical vapour deposition) processes. Use it for testing grain analysis on columnar microstructures.
|
||||
|
||||
- Controls: `n_particles` (number of columns), `particle_radius_px` (column width)
|
||||
|
||||
**deposition** -- Spherical particles deposited sequentially with gravity-like stacking. Each particle is a hemisphere; when it lands on existing material, it sits on top of the highest point within its footprint. This produces layered, stacked structures where later particles rest on earlier ones. Models sequential particle deposition, powder coatings, and colloidal stacking. Use it for testing height profile analysis of layered structures.
|
||||
|
||||
- Controls: `n_particles` (number of deposited particles), `particle_radius_px` (particle radius)
|
||||
|
||||
**ballistic** -- Ballistic deposition with neighbour adhesion. In each iteration, ~30% of randomly chosen sites receive a particle. The deposited height is one plus the maximum of the site's own height and its four nearest neighbours, producing lateral correlation and rough, porous growth fronts. This is a vectorised simulation of the classic ballistic deposition model. Models thin film growth with surface tension effects and produces surfaces with characteristic scaling exponents. Use it for testing roughness scaling analysis and growth exponent estimation.
|
||||
|
||||
- Controls: `n_iterations` (10--5000)
|
||||
|
||||
**dla** -- Diffusion-limited aggregation via iterative boundary growth from a central seed. At each iteration the cluster boundary is dilated, and a fraction (~12.5%) of boundary pixels are randomly added to the aggregate with random heights. This produces fractal, dendritic structures characteristic of DLA. Models electrodeposition, frost formation, mineral dendrites, and crystallisation from solution. Use it for testing fractal dimension analysis and morphological characterisation.
|
||||
|
||||
- Controls: `n_iterations` (10--5000)
|
||||
|
||||
---
|
||||
|
||||
### Phase Separation & PDE
|
||||
|
||||
**domains** -- Phase-separated domain patterns via a 2D Ising model using the checkerboard Metropolis algorithm. The surface is initialised with random +/-1 spins and evolved at inverse temperature beta = 0.55 (above the critical temperature for ordering). More iterations produce larger, more coarsened domains. This models magnetic domain patterns, block copolymer self-assembly, and binary alloy phase separation. The output is a binary +/-1 field that is normalised to the amplitude range. Use it for testing domain size analysis, boundary detection, and correlation length measurement.
|
||||
|
||||
- Controls: `n_iterations` (10--5000)
|
||||
|
||||
**spinodal** -- Spinodal decomposition via the Cahn-Hilliard equation solved with an FFT-based semi-implicit scheme. The initial condition is a near-uniform concentration (0.5) with small random perturbations. The system phase-separates into interconnected, bicontinuous domain structures whose characteristic length scale grows with iteration count. This models spinodal decomposition in alloys, polymer blends, and lipid membranes. Use it for testing structure factor analysis, domain coarsening kinetics, and bicontinuous morphology characterisation.
|
||||
|
||||
- Controls: `n_iterations` (10--5000)
|
||||
|
||||
**pde** -- Gray-Scott reaction-diffusion system producing Turing patterns. Two chemical species (u, v) react and diffuse with parameters F = 0.035, k = 0.065, which lies in the spots/stripes regime. The reaction is seeded in a central square region and grows outward. With few iterations (< 200) the pattern is sparse; with many iterations (500--2000) the pattern fills most of the field with well-developed spots and labyrinthine stripes. This models biological pattern formation (animal coat patterns, morphogenesis), chemical self-organisation (Belousov-Zhabotinsky reaction), and provides complex test patterns for segmentation algorithms.
|
||||
|
||||
- Controls: `n_iterations` (10--5000; recommend 500--2000 for fully developed patterns)
|
||||
|
||||
**annealing** -- Simulated annealing surface relaxation. Starts with a random Gaussian noise surface and progressively smooths it by averaging with nearest neighbours while adding decreasing amounts of thermal noise. The temperature decreases linearly from 1.0 to 0.01 over the iteration count. This models thermal relaxation, surface diffusion at elevated temperature, and produces surfaces with controlled correlation lengths. More iterations yield smoother surfaces. Use it for testing smoothness quantification and correlation analysis.
|
||||
|
||||
- Controls: `n_iterations` (10--5000)
|
||||
|
||||
---
|
||||
|
||||
### Tessellation
|
||||
|
||||
**voronoi** -- Voronoi tessellation with random heights per cell. Random seed points are placed in the field and each pixel is assigned to its nearest seed point. Every cell receives a uniform random height, producing a mosaic of flat-topped polygonal grains separated by sharp boundaries. This models grain boundaries in polycrystalline thin films, biological cell boundaries, and territorial partitioning. Use it for testing grain boundary detection, grain size distribution analysis, and watershed segmentation algorithms.
|
||||
|
||||
- Controls: `n_particles` (number of Voronoi sites)
|
||||
|
||||
64
tests/node_tests/lateral_force_sim.py
Normal file
64
tests/node_tests/lateral_force_sim.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shapes():
|
||||
"""Both forward and reverse outputs must have the same shape as the input."""
|
||||
from backend.nodes.lateral_force_sim import LateralForceSim
|
||||
|
||||
node = LateralForceSim()
|
||||
field = make_field(shape=(48, 64))
|
||||
|
||||
for direction in ("forward", "reverse", "both"):
|
||||
fwd, rev = node.process(field, direction, 0.3, 1e-9, 10e-9)
|
||||
assert fwd.data.shape == field.data.shape, f"forward shape mismatch for direction={direction}"
|
||||
assert rev.data.shape == field.data.shape, f"reverse shape mismatch for direction={direction}"
|
||||
|
||||
|
||||
def test_flat_surface_uniform():
|
||||
"""A flat (constant) topography has zero slope everywhere, so the lateral
|
||||
force should be spatially uniform (pure friction, no topographic component)."""
|
||||
from backend.nodes.lateral_force_sim import LateralForceSim
|
||||
|
||||
node = LateralForceSim()
|
||||
data = np.full((32, 32), 5e-9, dtype=np.float64)
|
||||
field = make_field(data=data)
|
||||
|
||||
fwd, rev = node.process(field, "both", 0.3, 1e-9, 10e-9)
|
||||
|
||||
# All values in each output should be identical (uniform)
|
||||
assert np.ptp(fwd.data) < 1e-20, "Forward lateral force is not uniform on flat surface"
|
||||
assert np.ptp(rev.data) < 1e-20, "Reverse lateral force is not uniform on flat surface"
|
||||
|
||||
|
||||
def test_forward_reverse_different():
|
||||
"""For a non-flat surface with 'both' direction, forward and reverse
|
||||
lateral force signals should differ (topographic artifact is direction-dependent)."""
|
||||
from backend.nodes.lateral_force_sim import LateralForceSim
|
||||
|
||||
node = LateralForceSim()
|
||||
# Create a steep ramp in x so there is a significant slope
|
||||
ramp = np.tile(np.linspace(0.0, 500e-9, 64), (64, 1))
|
||||
field = make_field(data=ramp)
|
||||
|
||||
fwd, rev = node.process(field, "both", 0.3, 1e-9, 10e-9)
|
||||
|
||||
# Forward and reverse differ due to slope-dependent asymmetry;
|
||||
# use strict tolerance to detect the difference at nanoNewton scale.
|
||||
assert not np.allclose(fwd.data, rev.data, atol=0, rtol=1e-6), (
|
||||
"Forward and reverse should differ on a sloped surface"
|
||||
)
|
||||
|
||||
|
||||
def test_finite_values():
|
||||
"""All output values must be finite (no NaN or inf) for typical inputs."""
|
||||
from backend.nodes.lateral_force_sim import LateralForceSim
|
||||
|
||||
node = LateralForceSim()
|
||||
field = make_field() # random topography
|
||||
|
||||
for direction in ("forward", "reverse", "both"):
|
||||
fwd, rev = node.process(field, direction, 0.3, 1e-9, 10e-9)
|
||||
assert np.isfinite(fwd.data).all(), f"Non-finite values in forward output (direction={direction})"
|
||||
assert np.isfinite(rev.data).all(), f"Non-finite values in reverse output (direction={direction})"
|
||||
70
tests/node_tests/mfm_current.py
Normal file
70
tests/node_tests/mfm_current.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shapes():
|
||||
from backend.nodes.mfm_current import MFMCurrentSimulation
|
||||
|
||||
node = MFMCurrentSimulation()
|
||||
field = make_field(shape=(32, 32))
|
||||
|
||||
hx, hz, force = node.process(field, height=100e-9, current=1e-3,
|
||||
width=100e-9, tip_magnetization=1e5)
|
||||
|
||||
assert hx.data.shape == (32, 32)
|
||||
assert hz.data.shape == (32, 32)
|
||||
assert force.data.shape == (32, 32)
|
||||
|
||||
|
||||
def test_finite_values():
|
||||
from backend.nodes.mfm_current import MFMCurrentSimulation
|
||||
|
||||
node = MFMCurrentSimulation()
|
||||
field = make_field(shape=(64, 64))
|
||||
|
||||
hx, hz, force = node.process(field, height=100e-9, current=1e-3,
|
||||
width=100e-9, tip_magnetization=1e5)
|
||||
|
||||
assert np.isfinite(hx.data).all()
|
||||
assert np.isfinite(hz.data).all()
|
||||
assert np.isfinite(force.data).all()
|
||||
|
||||
|
||||
def test_hz_antisymmetric():
|
||||
from backend.nodes.mfm_current import MFMCurrentSimulation
|
||||
|
||||
node = MFMCurrentSimulation()
|
||||
# Use even grid so centre falls between pixels symmetrically
|
||||
field = make_field(shape=(16, 64), xreal=1e-6, yreal=1e-6)
|
||||
|
||||
_, hz, _ = node.process(field, height=100e-9, current=1e-3,
|
||||
width=100e-9, tip_magnetization=1e5)
|
||||
|
||||
hz_row = hz.data[0, :]
|
||||
n = len(hz_row)
|
||||
# The x-grid uses linspace(0, xreal, n, endpoint=False) - xreal/2,
|
||||
# so x[i] + x[n-i] == 0 for i in 1..n-1.
|
||||
# Hz should be antisymmetric about x=0: Hz(x) ≈ -Hz(-x)
|
||||
for i in range(1, n // 2):
|
||||
left = hz_row[i]
|
||||
right = hz_row[n - i]
|
||||
assert np.sign(left) == -np.sign(right), (
|
||||
f"Hz not antisymmetric at positions {i} and {n - i}: "
|
||||
f"{left} vs {right}"
|
||||
)
|
||||
np.testing.assert_allclose(left, -right, rtol=1e-10)
|
||||
|
||||
|
||||
def test_units():
|
||||
from backend.nodes.mfm_current import MFMCurrentSimulation
|
||||
|
||||
node = MFMCurrentSimulation()
|
||||
field = make_field(shape=(32, 32))
|
||||
|
||||
hx, hz, force = node.process(field, height=100e-9, current=1e-3,
|
||||
width=100e-9, tip_magnetization=1e5)
|
||||
|
||||
assert hx.si_unit_z == "A/m"
|
||||
assert hz.si_unit_z == "A/m"
|
||||
assert force.si_unit_z == "N"
|
||||
80
tests/node_tests/mfm_domains.py
Normal file
80
tests/node_tests/mfm_domains.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shapes():
|
||||
from backend.nodes.mfm_domains import MFMDomainGeneration
|
||||
|
||||
node = MFMDomainGeneration()
|
||||
field = make_field(shape=(48, 64))
|
||||
|
||||
hz, dhz_dz = node.process(
|
||||
field,
|
||||
height=50e-9,
|
||||
thickness=20e-9,
|
||||
magnetization=1e6,
|
||||
stripe_width_a=200e-9,
|
||||
stripe_width_b=200e-9,
|
||||
gap=0.0,
|
||||
)
|
||||
assert hz.data.shape == (48, 64)
|
||||
assert dhz_dz.data.shape == (48, 64)
|
||||
|
||||
|
||||
def test_finite_values():
|
||||
from backend.nodes.mfm_domains import MFMDomainGeneration
|
||||
|
||||
node = MFMDomainGeneration()
|
||||
field = make_field(shape=(32, 32))
|
||||
|
||||
hz, dhz_dz = node.process(
|
||||
field,
|
||||
height=50e-9,
|
||||
thickness=20e-9,
|
||||
magnetization=1e6,
|
||||
stripe_width_a=200e-9,
|
||||
stripe_width_b=200e-9,
|
||||
gap=0.0,
|
||||
)
|
||||
assert np.isfinite(hz.data).all()
|
||||
assert np.isfinite(dhz_dz.data).all()
|
||||
|
||||
|
||||
def test_uniform_along_y():
|
||||
"""Stripes run along y, so every row of the output should be identical."""
|
||||
from backend.nodes.mfm_domains import MFMDomainGeneration
|
||||
|
||||
node = MFMDomainGeneration()
|
||||
field = make_field(shape=(64, 64))
|
||||
|
||||
hz, dhz_dz = node.process(
|
||||
field,
|
||||
height=50e-9,
|
||||
thickness=20e-9,
|
||||
magnetization=1e6,
|
||||
stripe_width_a=200e-9,
|
||||
stripe_width_b=200e-9,
|
||||
gap=0.0,
|
||||
)
|
||||
assert np.allclose(hz.data[0], hz.data[hz.data.shape[0] // 2])
|
||||
assert np.allclose(dhz_dz.data[0], dhz_dz.data[dhz_dz.data.shape[0] // 2])
|
||||
|
||||
|
||||
def test_units():
|
||||
from backend.nodes.mfm_domains import MFMDomainGeneration
|
||||
|
||||
node = MFMDomainGeneration()
|
||||
field = make_field(shape=(32, 32))
|
||||
|
||||
hz, dhz_dz = node.process(
|
||||
field,
|
||||
height=50e-9,
|
||||
thickness=20e-9,
|
||||
magnetization=1e6,
|
||||
stripe_width_a=200e-9,
|
||||
stripe_width_b=200e-9,
|
||||
gap=0.0,
|
||||
)
|
||||
assert hz.si_unit_z == "A/m"
|
||||
assert dhz_dz.si_unit_z == "A/m²"
|
||||
69
tests/node_tests/pfm_analysis.py
Normal file
69
tests/node_tests/pfm_analysis.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shapes():
|
||||
from backend.nodes.pfm_analysis import PFMAnalysis
|
||||
|
||||
node = PFMAnalysis()
|
||||
vpfm_amp = make_field(data=np.abs(make_field(shape=(48, 64)).data), shape=(48, 64))
|
||||
lpfm_amp = make_field(data=np.abs(make_field(shape=(48, 64)).data), shape=(48, 64))
|
||||
vpfm_phase = make_field(shape=(48, 64))
|
||||
lpfm_phase = make_field(shape=(48, 64))
|
||||
|
||||
magnitude, azimuth, inclination = node.process(
|
||||
vpfm_amp, lpfm_amp, vpfm_phase, lpfm_phase, "3d", 1.0,
|
||||
)
|
||||
|
||||
assert magnitude.data.shape == (48, 64)
|
||||
assert azimuth.data.shape == (48, 64)
|
||||
assert inclination.data.shape == (48, 64)
|
||||
|
||||
|
||||
def test_2d_mode_zero_inclination():
|
||||
from backend.nodes.pfm_analysis import PFMAnalysis
|
||||
|
||||
node = PFMAnalysis()
|
||||
vpfm_amp = make_field(data=np.abs(make_field().data))
|
||||
lpfm_amp = make_field(data=np.abs(make_field().data))
|
||||
vpfm_phase = make_field()
|
||||
lpfm_phase = make_field()
|
||||
|
||||
magnitude, azimuth, inclination = node.process(
|
||||
vpfm_amp, lpfm_amp, vpfm_phase, lpfm_phase, "2d", 1.0,
|
||||
)
|
||||
|
||||
assert np.allclose(inclination.data, 0.0)
|
||||
|
||||
|
||||
def test_3d_mode_nonzero_inclination():
|
||||
from backend.nodes.pfm_analysis import PFMAnalysis
|
||||
|
||||
node = PFMAnalysis()
|
||||
vpfm_amp = make_field(data=np.abs(make_field().data))
|
||||
lpfm_amp = make_field(data=np.abs(make_field().data))
|
||||
vpfm_phase = make_field()
|
||||
lpfm_phase = make_field()
|
||||
|
||||
magnitude, azimuth, inclination = node.process(
|
||||
vpfm_amp, lpfm_amp, vpfm_phase, lpfm_phase, "3d", 1.0,
|
||||
)
|
||||
|
||||
assert not np.allclose(inclination.data, 0.0)
|
||||
|
||||
|
||||
def test_magnitude_nonnegative():
|
||||
from backend.nodes.pfm_analysis import PFMAnalysis
|
||||
|
||||
node = PFMAnalysis()
|
||||
vpfm_amp = make_field(data=np.abs(make_field().data))
|
||||
lpfm_amp = make_field(data=np.abs(make_field().data))
|
||||
vpfm_phase = make_field()
|
||||
lpfm_phase = make_field()
|
||||
|
||||
for mode in ("2d", "3d"):
|
||||
magnitude, azimuth, inclination = node.process(
|
||||
vpfm_amp, lpfm_amp, vpfm_phase, lpfm_phase, mode, 1.0,
|
||||
)
|
||||
assert np.all(magnitude.data >= 0.0)
|
||||
46
tests/node_tests/sem_simulation.py
Normal file
46
tests/node_tests/sem_simulation.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_output_shape():
|
||||
from backend.nodes.sem_simulation import SEMSimulation
|
||||
|
||||
node = SEMSimulation()
|
||||
field = make_field(shape=(48, 64))
|
||||
|
||||
for method in ("integration", "monte_carlo"):
|
||||
result, = node.process(field, method, sigma=3.0, n_samples=20)
|
||||
assert result.data.shape == (48, 64)
|
||||
|
||||
|
||||
def test_flat_surface_zero():
|
||||
"""Flat topography (all zeros) should produce all-zero or near-zero SEM signal."""
|
||||
from backend.nodes.sem_simulation import SEMSimulation
|
||||
|
||||
node = SEMSimulation()
|
||||
field = make_field(data=np.zeros((32, 32)))
|
||||
|
||||
for method in ("integration", "monte_carlo"):
|
||||
result, = node.process(field, method, sigma=3.0, n_samples=20)
|
||||
assert np.allclose(result.data, 0.0, atol=1e-10)
|
||||
|
||||
|
||||
def test_integration_method():
|
||||
from backend.nodes.sem_simulation import SEMSimulation
|
||||
|
||||
node = SEMSimulation()
|
||||
field = make_field()
|
||||
|
||||
result, = node.process(field, "integration", sigma=3.0, n_samples=20)
|
||||
assert np.all(np.isfinite(result.data))
|
||||
|
||||
|
||||
def test_monte_carlo_method():
|
||||
from backend.nodes.sem_simulation import SEMSimulation
|
||||
|
||||
node = SEMSimulation()
|
||||
field = make_field()
|
||||
|
||||
result, = node.process(field, "monte_carlo", sigma=3.0, n_samples=20)
|
||||
assert np.all(np.isfinite(result.data))
|
||||
70
tests/node_tests/smm_analysis.py
Normal file
70
tests/node_tests/smm_analysis.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def _make_inputs():
|
||||
"""Return amplitude and phase fields suitable for SMM analysis."""
|
||||
raw = np.abs(make_field().data)
|
||||
amplitude_data = raw / raw.max() * 0.8 + 0.1
|
||||
amplitude = make_field(data=amplitude_data)
|
||||
phase = make_field()
|
||||
return amplitude, phase
|
||||
|
||||
|
||||
def test_output_shapes():
|
||||
from backend.nodes.smm_analysis import SMMAnalysis
|
||||
|
||||
node = SMMAnalysis()
|
||||
amplitude, phase = _make_inputs()
|
||||
cap, imp = node.process(
|
||||
amplitude, phase,
|
||||
frequency=1e9, ref_impedance=50.0,
|
||||
cal_c1=1e-15, cal_c2=10e-15, cal_c3=100e-15,
|
||||
cal_s11_1=0.9, cal_s11_2=0.5, cal_s11_3=0.1,
|
||||
)
|
||||
assert cap.data.shape == amplitude.data.shape
|
||||
assert imp.data.shape == amplitude.data.shape
|
||||
|
||||
|
||||
def test_finite_outputs():
|
||||
from backend.nodes.smm_analysis import SMMAnalysis
|
||||
|
||||
node = SMMAnalysis()
|
||||
amplitude, phase = _make_inputs()
|
||||
cap, imp = node.process(
|
||||
amplitude, phase,
|
||||
frequency=1e9, ref_impedance=50.0,
|
||||
cal_c1=1e-15, cal_c2=10e-15, cal_c3=100e-15,
|
||||
cal_s11_1=0.9, cal_s11_2=0.5, cal_s11_3=0.1,
|
||||
)
|
||||
assert np.isfinite(cap.data).all()
|
||||
assert np.isfinite(imp.data).all()
|
||||
|
||||
|
||||
def test_capacitance_units():
|
||||
from backend.nodes.smm_analysis import SMMAnalysis
|
||||
|
||||
node = SMMAnalysis()
|
||||
amplitude, phase = _make_inputs()
|
||||
cap, _imp = node.process(
|
||||
amplitude, phase,
|
||||
frequency=1e9, ref_impedance=50.0,
|
||||
cal_c1=1e-15, cal_c2=10e-15, cal_c3=100e-15,
|
||||
cal_s11_1=0.9, cal_s11_2=0.5, cal_s11_3=0.1,
|
||||
)
|
||||
assert cap.si_unit_z == "F"
|
||||
|
||||
|
||||
def test_impedance_units():
|
||||
from backend.nodes.smm_analysis import SMMAnalysis
|
||||
|
||||
node = SMMAnalysis()
|
||||
amplitude, phase = _make_inputs()
|
||||
_cap, imp = node.process(
|
||||
amplitude, phase,
|
||||
frequency=1e9, ref_impedance=50.0,
|
||||
cal_c1=1e-15, cal_c2=10e-15, cal_c3=100e-15,
|
||||
cal_s11_1=0.9, cal_s11_2=0.5, cal_s11_3=0.1,
|
||||
)
|
||||
assert imp.si_unit_z == "Ohm"
|
||||
@@ -1,57 +1,232 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from tests.node_tests._shared import make_field
|
||||
|
||||
|
||||
def test_all_patterns_produce_correct_shape():
|
||||
# All 28 patterns supported by SyntheticSurface
|
||||
ALL_PATTERNS = [
|
||||
"fbm", "white_noise", "lattice", "steps", "particles", "flat",
|
||||
"columnar", "objects", "fibres", "waves", "dunes",
|
||||
"domains", "ballistic", "deposition", "rods", "dla",
|
||||
"discs", "plateaus", "pileups", "annealing", "voronoi",
|
||||
"spinodal", "pde", "spectral", "residues",
|
||||
"noise", "periodic", "wfr",
|
||||
]
|
||||
|
||||
# Iterative patterns that need n_iterations kept low for speed
|
||||
ITERATIVE_PATTERNS = {"domains", "ballistic", "annealing", "spinodal", "pde", "dla"}
|
||||
|
||||
|
||||
def _make_node():
|
||||
from backend.nodes.synthetic_surface import SyntheticSurface
|
||||
|
||||
node = SyntheticSurface()
|
||||
for pattern in ("fbm", "white_noise", "lattice", "steps", "particles", "flat"):
|
||||
result, = node.process(
|
||||
pattern=pattern, xres=64, yres=48, xreal=1e-6, yreal=1e-6,
|
||||
amplitude=1e-9, seed=42,
|
||||
)
|
||||
assert result.data.shape == (48, 64), f"Failed for {pattern}"
|
||||
return SyntheticSurface()
|
||||
|
||||
|
||||
def test_flat_is_zero():
|
||||
from backend.nodes.synthetic_surface import SyntheticSurface
|
||||
|
||||
node = SyntheticSurface()
|
||||
result, = node.process(
|
||||
pattern="flat", xres=32, yres=32, xreal=1e-6, yreal=1e-6,
|
||||
amplitude=1e-9, seed=0,
|
||||
def _common_kwargs(pattern, xres=64, yres=64):
|
||||
"""Build kwargs dict with low iteration count for iterative patterns."""
|
||||
kwargs = dict(
|
||||
pattern=pattern,
|
||||
xres=xres,
|
||||
yres=yres,
|
||||
xreal=1e-6,
|
||||
yreal=1e-6,
|
||||
amplitude=1e-9,
|
||||
seed=42,
|
||||
)
|
||||
assert np.allclose(result.data, 0.0)
|
||||
if pattern in ITERATIVE_PATTERNS:
|
||||
kwargs["n_iterations"] = 20
|
||||
return kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Every pattern produces the correct output shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("pattern", ALL_PATTERNS)
|
||||
def test_all_patterns_produce_correct_shape(pattern):
|
||||
node = _make_node()
|
||||
kwargs = _common_kwargs(pattern, xres=64, yres=64)
|
||||
(field,) = node.process(**kwargs)
|
||||
assert field.data.shape == (64, 64), (
|
||||
f"Pattern {pattern!r} produced shape {field.data.shape}, expected (64, 64)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Seed reproducibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_seed_reproducibility():
|
||||
from backend.nodes.synthetic_surface import SyntheticSurface
|
||||
node = _make_node()
|
||||
kwargs = dict(
|
||||
pattern="fbm", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=123,
|
||||
)
|
||||
(r1,) = node.process(**kwargs)
|
||||
(r2,) = node.process(**kwargs)
|
||||
assert np.array_equal(r1.data, r2.data), "Same seed must produce identical output"
|
||||
|
||||
node = SyntheticSurface()
|
||||
kwargs = dict(pattern="fbm", xres=32, yres=32, xreal=1e-6, yreal=1e-6,
|
||||
amplitude=1e-9, seed=123)
|
||||
r1, = node.process(**kwargs)
|
||||
r2, = node.process(**kwargs)
|
||||
assert np.array_equal(r1.data, r2.data)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Amplitude scaling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_amplitude_scaling():
|
||||
from backend.nodes.synthetic_surface import SyntheticSurface
|
||||
|
||||
node = SyntheticSurface()
|
||||
result, = node.process(
|
||||
pattern="white_noise", xres=64, yres=64, xreal=1e-6, yreal=1e-6,
|
||||
amplitude=5e-9, seed=42,
|
||||
node = _make_node()
|
||||
amp = 5e-9
|
||||
(field,) = node.process(
|
||||
pattern="fbm", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=amp, seed=42,
|
||||
)
|
||||
assert result.data.max() <= 5e-9 + 1e-15
|
||||
assert result.data.min() >= -1e-15
|
||||
# After normalisation the range should be [0, amplitude]
|
||||
assert field.data.min() >= -1e-15, "Min value should be >= 0 (within tolerance)"
|
||||
assert field.data.max() <= amp + 1e-15, (
|
||||
f"Max value {field.data.max()} exceeds amplitude {amp}"
|
||||
)
|
||||
# The range should actually span close to the full amplitude
|
||||
assert field.data.max() - field.data.min() == pytest.approx(amp, rel=1e-6)
|
||||
|
||||
|
||||
def test_unknown_pattern():
|
||||
from backend.nodes.synthetic_surface import SyntheticSurface
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Flat pattern produces all zeros
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
node = SyntheticSurface()
|
||||
with pytest.raises(ValueError):
|
||||
node.process(pattern="unknown", xres=32, yres=32, xreal=1e-6, yreal=1e-6,
|
||||
amplitude=1e-9, seed=0)
|
||||
def test_flat_is_zero():
|
||||
node = _make_node()
|
||||
(field,) = node.process(
|
||||
pattern="flat", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=0,
|
||||
)
|
||||
assert np.allclose(field.data, 0.0), "Flat pattern must produce all zeros"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Object shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("obj_shape", ["sphere", "pyramid", "box", "cylinder", "cone"])
|
||||
def test_objects_shapes(obj_shape):
|
||||
node = _make_node()
|
||||
(field,) = node.process(
|
||||
pattern="objects", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1.0, seed=42,
|
||||
n_particles=5, particle_radius_px=5, object_shape=obj_shape,
|
||||
)
|
||||
assert field.data.shape == (64, 64), (
|
||||
f"Object shape {obj_shape!r} produced wrong output shape"
|
||||
)
|
||||
assert np.any(field.data != 0.0), (
|
||||
f"Object shape {obj_shape!r} should not be all zeros"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Noise types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("noise_type", [
|
||||
"gaussian", "poisson", "exponential", "uniform", "salt_pepper",
|
||||
])
|
||||
def test_noise_types(noise_type):
|
||||
node = _make_node()
|
||||
(field,) = node.process(
|
||||
pattern="noise", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=42,
|
||||
noise_type=noise_type,
|
||||
)
|
||||
assert field.data.shape == (64, 64), (
|
||||
f"Noise type {noise_type!r} produced wrong shape"
|
||||
)
|
||||
assert np.all(np.isfinite(field.data)), (
|
||||
f"Noise type {noise_type!r} produced non-finite values"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Periodic types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("periodic_type", [
|
||||
"checker", "hex", "stripe", "diamond", "staircase", "rings",
|
||||
])
|
||||
def test_periodic_types(periodic_type):
|
||||
node = _make_node()
|
||||
(field,) = node.process(
|
||||
pattern="periodic", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=42,
|
||||
periodic_type=periodic_type,
|
||||
)
|
||||
assert field.data.shape == (64, 64), (
|
||||
f"Periodic type {periodic_type!r} produced wrong shape"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Spinodal converges to bimodal distribution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_spinodal_converges():
|
||||
node = _make_node()
|
||||
(field,) = node.process(
|
||||
pattern="spinodal", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=42,
|
||||
n_iterations=50,
|
||||
)
|
||||
data = field.data
|
||||
mid = (data.min() + data.max()) / 2.0
|
||||
span = data.max() - data.min()
|
||||
# In a bimodal distribution most values cluster near min or max,
|
||||
# so few values should be in the middle third.
|
||||
middle_band = np.abs(data - mid) < span / 6.0
|
||||
fraction_in_middle = middle_band.sum() / data.size
|
||||
assert fraction_in_middle < 0.5, (
|
||||
f"Expected bimodal distribution but {fraction_in_middle:.1%} of values "
|
||||
f"are in the middle band"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Voronoi produces discrete height levels
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_voronoi_discrete_heights():
|
||||
node = _make_node()
|
||||
n_sites = 5
|
||||
(field,) = node.process(
|
||||
pattern="voronoi", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=42,
|
||||
n_particles=n_sites,
|
||||
)
|
||||
# Round to avoid floating-point noise and count unique levels
|
||||
unique_levels = np.unique(np.round(field.data, decimals=12))
|
||||
assert len(unique_levels) <= n_sites, (
|
||||
f"Voronoi with {n_sites} sites produced {len(unique_levels)} distinct "
|
||||
f"height levels, expected at most {n_sites}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Steps count matches n_steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_steps_count():
|
||||
node = _make_node()
|
||||
n_steps = 3
|
||||
(field,) = node.process(
|
||||
pattern="steps", xres=64, yres=64,
|
||||
xreal=1e-6, yreal=1e-6, amplitude=1e-9, seed=42,
|
||||
n_steps=n_steps,
|
||||
)
|
||||
# The steps generator creates floor(linspace(0, n_steps, xres)) values.
|
||||
# After normalisation, there should be approximately n_steps distinct
|
||||
# non-zero step levels (plus possibly zero).
|
||||
unique_levels = np.unique(np.round(field.data, decimals=12))
|
||||
# Allow for the zero level plus n_steps levels
|
||||
assert len(unique_levels) <= n_steps + 1, (
|
||||
f"Steps with n_steps={n_steps} produced {len(unique_levels)} distinct "
|
||||
f"levels, expected at most {n_steps + 1}"
|
||||
)
|
||||
# Should have at least 2 distinct levels (not a flat surface)
|
||||
assert len(unique_levels) >= 2, (
|
||||
f"Steps pattern should produce multiple distinct levels, got {len(unique_levels)}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user