synthetic surface and spm specific features
This commit is contained in:
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user