synthetic surface and spm specific features

This commit is contained in:
2026-04-03 23:53:22 -07:00
parent 7747c1c7bc
commit 5d8c79454e
23 changed files with 2134 additions and 107 deletions

View File

@@ -123,7 +123,13 @@ MENU_LAYOUT: dict[str, list[str]] = {
"TemplateMatch",
"FacetAnalysis",
"MFMAnalysis",
"MFMCurrentSimulation",
"MFMDomainGeneration",
"ZeroCrossing",
"PFMAnalysis",
"LateralForceSim",
"SEMSimulation",
"SMMAnalysis",
],
"Mask": [
"DrawMask",

View 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)

View 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"),
)

View 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)

View 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"),
)

View 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

View 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)

View File

@@ -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}")