"""Synthetic surface generation — create test surfaces for development and calibration.""" from __future__ import annotations import numpy as np 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 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 ** (-(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) return np.real(np.fft.ifft2(fft_data)) def _lattice_surface(shape, xreal, yreal, spacing, angle_deg): """Periodic lattice (sinusoidal grid).""" yres, xres = shape 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 return np.cos(k * X) + np.cos(k * (X * np.cos(theta) + Y * np.sin(theta))) def _steps_surface(shape, n_steps): """Terraced step structure.""" yres, xres = shape ramp = np.linspace(0, n_steps, xres, endpoint=False) steps = np.floor(ramp) return np.tile(steps, (yres, 1)).astype(np.float64) def _particles_surface(shape, rng, n_particles, radius_px): """Random spherical particles on a flat background.""" yres, xres = shape surface = np.zeros(shape) yy, xx = np.ogrid[:yres, :xres] for _ in range(n_particles): cy = rng.integers(0, yres) cx = rng.integers(0, xres) r2 = (yy - cy)**2 + (xx - cx)**2 height = np.sqrt(np.maximum(radius_px**2 - r2, 0.0)) surface = np.maximum(surface, height) 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", "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}), "yreal": ("FLOAT", {"default": 1e-6, "min": 1e-9, "max": 1.0, "step": 1e-9}), "amplitude": ("FLOAT", {"default": 1e-9, "min": 0.0, "max": 1e-3, "step": 1e-10}), "seed": ("INT", {"default": 42, "min": 0, "max": 999999}), }, "optional": { "hurst_exponent": ("FLOAT", { "default": 0.7, "min": 0.0, "max": 1.0, "step": 0.05, "show_when_widget_value": {"pattern": ["fbm"]}, }), "lattice_spacing": ("FLOAT", { "default": 100e-9, "min": 1e-9, "max": 1e-3, "step": 1e-9, "show_when_widget_value": {"pattern": ["lattice"]}, }), "lattice_angle": ("FLOAT", { "default": 90.0, "min": 0.0, "max": 180.0, "step": 1.0, "show_when_widget_value": {"pattern": ["lattice"]}, }), "n_steps": ("INT", { "default": 5, "min": 1, "max": 100, "show_when_widget_value": {"pattern": ["steps"]}, }), "n_particles": ("INT", { "default": 20, "min": 1, "max": 500, "show_when_widget_value": {"pattern": [ "particles", "columnar", "objects", "fibres", "waves", "deposition", "rods", "discs", "plateaus", "pileups", "voronoi", "residues", "wfr", ]}, }), "particle_radius_px": ("INT", { "default": 10, "min": 2, "max": 100, "show_when_widget_value": {"pattern": [ "particles", "columnar", "objects", "fibres", "deposition", "rods", "discs", "plateaus", "pileups", "residues", ]}, }), "n_iterations": ("INT", { "default": 200, "min": 10, "max": 5000, "show_when_widget_value": {"pattern": [ "domains", "ballistic", "dla", "annealing", "spinodal", "pde", ]}, }), "direction_deg": ("FLOAT", { "default": 0.0, "min": 0.0, "max": 360.0, "step": 1.0, "show_when_widget_value": {"pattern": ["dunes"]}, }), "feature_length_px": ("INT", { "default": 40, "min": 2, "max": 500, "show_when_widget_value": {"pattern": ["fibres", "rods"]}, }), "object_shape": (["sphere", "pyramid", "box", "cylinder", "cone"], { "default": "sphere", "show_when_widget_value": {"pattern": ["objects"]}, }), "noise_type": (["gaussian", "poisson", "exponential", "uniform", "salt_pepper"], { "default": "gaussian", "show_when_widget_value": {"pattern": ["noise"]}, }), "periodic_type": (["checker", "hex", "stripe", "diamond", "staircase", "rings"], { "default": "checker", "show_when_widget_value": {"pattern": ["periodic"]}, }), "spectral_exponent": ("FLOAT", { "default": 2.0, "min": 0.5, "max": 5.0, "step": 0.1, "show_when_widget_value": {"pattern": ["spectral"]}, }), "frequency": ("FLOAT", { "default": 5.0, "min": 0.5, "max": 50.0, "step": 0.5, "show_when_widget_value": {"pattern": [ "waves", "dunes", "periodic", "wfr", ]}, }), } } OUTPUTS = ( ('DATA_FIELD', 'surface'), ) FUNCTION = "process" DESCRIPTION = ( "Generate synthetic test surfaces for development, calibration, and " "algorithm testing. 28 patterns covering noise, geometry, growth " "simulations, phase separation, reaction-diffusion, and tiling. " ) KEYWORDS = ("generate", "fbm", "fractal", "noise", "simulation", "test", "dla", "voronoi", "turing", "spinodal", "pattern") def process( self, pattern: str, xres: int, yres: int, xreal: float, yreal: float, amplitude: float, seed: int, hurst_exponent: float = 0.7, lattice_spacing: float = 100e-9, lattice_angle: float = 90.0, 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) if pattern == "fbm": data = _fbm_surface(shape, rng, H=hurst_exponent) elif pattern == "white_noise": data = rng.standard_normal(shape) elif pattern == "lattice": data = _lattice_surface(shape, xreal, yreal, lattice_spacing, lattice_angle) elif pattern == "steps": data = _steps_surface(shape, n_steps) elif pattern == "particles": 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}") # Normalise and scale by amplitude drange = data.max() - data.min() if drange > 0: data = (data - data.min()) / drange * amplitude else: data = np.zeros(shape) field = DataField( data=data, xreal=xreal, yreal=yreal, si_unit_xy="m", si_unit_z="m", ) return (field,)