Source code for pysurfacefun.core

"""
High-order fast direct solver for elliptic surface PDEs on quadrilateral Chebyshev patches.

This module is a reduced Python version of the MATLAB Surfacefun package:
https://github.com/danfortunato/surfacefun

It provides geometry construction, strong-form surface differentiation, scalar
surface functions, and direct patch-based solves for

    lapcoef * Delta_Gamma u + ccoef * u = f.

For pure Laplace-Beltrami problems on closed surfaces, set ``rankdef=True`` and
use a mean-zero right-hand side.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Callable, Iterable

import numpy as np


Array = np.ndarray


# ---------------------------------------------------------------------------
# Chebyshev utilities.
# ---------------------------------------------------------------------------


[docs] def chebpts(n: int, kind: int = 2, interval: tuple[float, float] = (-1.0, 1.0)) -> Array: """Chebyshev points, ordered from left to right.""" if n <= 0: return np.empty(0) if n == 1: x = np.array([0.0]) elif kind == 1: x = np.polynomial.chebyshev.chebpts1(n) elif kind == 2: x = np.polynomial.chebyshev.chebpts2(n) else: raise ValueError("kind must be 1 or 2") a, b = interval return 0.5 * ((b - a) * x + (a + b))
[docs] def chebpts2( nx: int, ny: int | None = None, D: Array | tuple[float, float, float, float] | None = None, kind: int = 2, ) -> tuple[Array, Array]: """ Tensor-product Chebyshev grid. The domain mapping is included here because the sphere constructor calls ``chebpts2(n, n, [0, 1, 0, 1])``. """ if ny is None: ny = nx if D is None: D = np.array([-1.0, 1.0, -1.0, 1.0]) else: D = np.array(D, dtype=float).flatten() if D.size != 4: raise ValueError("Unrecognized domain.") if kind is None: kind = 2 if kind == 2: x = np.polynomial.chebyshev.chebpts2(nx) y = np.polynomial.chebyshev.chebpts2(ny) elif kind == 1: x = np.polynomial.chebyshev.chebpts1(nx) y = np.polynomial.chebyshev.chebpts1(ny) else: raise ValueError("kind must be 1 or 2") x = 0.5 * ((D[1] - D[0]) * x + (D[0] + D[1])) y = 0.5 * ((D[3] - D[2]) * y + (D[2] + D[3])) return np.meshgrid(x, y)
[docs] def diffmat(n: int) -> Array: """Dense Chebyshev nodal differentiation matrix.""" x = chebpts(n, 2) if n == 0: return np.empty((0, 0)) if n == 1: return np.zeros((1, 1)) w = (-1.0) ** np.arange(n) w[0] *= 0.5 w[-1] *= 0.5 D = np.zeros((n, n)) for i in range(n): for j in range(n): if i != j: D[i, j] = w[j] / (w[i] * (x[i] - x[j])) D[np.diag_indices(n)] = -np.sum(D, axis=1) return D
def barycentric_weights(x: Array) -> Array: """Generic barycentric weights for the small Chebyshev grids used here.""" x = np.asarray(x, dtype=float) w = np.ones_like(x) for j in range(x.size): diff = x[j] - np.delete(x, j) w[j] = 1.0 / np.prod(diff) return w def barymat(x_eval: Array, x_nodes: Array, weights: Array | None = None) -> Array: """Barycentric interpolation matrix.""" x_eval = np.asarray(x_eval, dtype=float).ravel() x_nodes = np.asarray(x_nodes, dtype=float).ravel() if weights is None: weights = barycentric_weights(x_nodes) B = np.zeros((x_eval.size, x_nodes.size)) for i, x in enumerate(x_eval): hit = np.where(np.isclose(x, x_nodes, rtol=0.0, atol=1e-14))[0] if hit.size: B[i, hit[0]] = 1.0 else: tmp = weights / (x - x_nodes) B[i, :] = tmp / np.sum(tmp) return B def quadwts(n: int, kind: int = 2) -> Array: """ Quadrature weights on Chebyshev nodes. The second-kind formula is an FFT-based Clenshaw--Curtis routine. """ if n <= 0: return np.empty(0) if n == 1: return np.array([2.0]) if kind == 2: c = 2 / np.concatenate(([1], 1 - np.arange(2, n, 2) ** 2), axis=0) c = np.concatenate((c, c[int(n / 2) - 1 : 0 : -1]), axis=0) w = np.real(np.fft.ifft(c)) w[0] = w[0] / 2 w = np.concatenate((w, [w[0]]), axis=0) return w return np.full(n, 2.0 / n) def rowscale(scale: Array, A: Array) -> Array: return np.asarray(scale).reshape(-1, 1) * A def get_work_array(work: Array | None, shape: tuple[int, ...], dtype) -> Array: dtype = np.dtype(dtype) if work is None or work.shape != shape or work.dtype != dtype: return np.zeros(shape, dtype=dtype) work.fill(0) return work def block_diag(blocks: Iterable[Array]) -> Array: blocks = [np.asarray(B) for B in blocks] if not blocks: return np.zeros((0, 0)) nr = sum(B.shape[0] for B in blocks) nc = sum(B.shape[1] for B in blocks) out = np.zeros((nr, nc)) r = 0 c = 0 for B in blocks: out[r : r + B.shape[0], c : c + B.shape[1]] = B r += B.shape[0] c += B.shape[1] return out # --------------------------------------------------------------------------- # surfacemesh.sphere and metric data. # ---------------------------------------------------------------------------
[docs] @dataclass class SurfaceMesh: x: list[Array] y: list[Array] z: list[Array] def __post_init__(self) -> None: n = self.x[0].shape[0] D = diffmat(n) self.xu: list[Array] = [] self.xv: list[Array] = [] self.yu: list[Array] = [] self.yv: list[Array] = [] self.zu: list[Array] = [] self.zv: list[Array] = [] self.ux: list[Array] = [] self.vx: list[Array] = [] self.uy: list[Array] = [] self.vy: list[Array] = [] self.uz: list[Array] = [] self.vz: list[Array] = [] self.E: list[Array] = [] self.F: list[Array] = [] self.G: list[Array] = [] self.J: list[Array] = [] self.singular: list[bool] = [] for x, y, z in zip(self.x, self.y, self.z): xu = x @ D.T xv = D @ x yu = y @ D.T yv = D @ y zu = z @ D.T zv = D @ z E = xu * xu + yu * yu + zu * zu G = xv * xv + yv * yv + zv * zv F = xu * xv + yu * yv + zu * zv J = E * G - F * F scl = np.max(np.abs(G * xu - F * xv)) singular = bool(np.any(np.abs(J) < 1e-10 * max(scl, 1.0))) if singular: ux = G * xu - F * xv uy = G * yu - F * yv uz = G * zu - F * zv vx = E * xv - F * xu vy = E * yv - F * yu vz = E * zv - F * zu else: ux = (G * xu - F * xv) / J uy = (G * yu - F * yv) / J uz = (G * zu - F * zv) / J vx = (E * xv - F * xu) / J vy = (E * yv - F * yu) / J vz = (E * zv - F * zu) / J self.xu.append(xu) self.xv.append(xv) self.yu.append(yu) self.yv.append(yv) self.zu.append(zu) self.zv.append(zv) self.ux.append(ux) self.vx.append(vx) self.uy.append(uy) self.vy.append(vy) self.uz.append(uz) self.vz.append(vz) self.E.append(E) self.F.append(F) self.G.append(G) self.J.append(J) self.singular.append(singular) @property def npatches(self) -> int: return len(self.x) @property def n(self) -> int: return self.x[0].shape[0] @property def order(self) -> int: return self.n - 1
[docs] @staticmethod def sphere(n: int, nref: int = 0, projection: str = "quasiuniform") -> "SurfaceMesh": """Build a six-patch Chebyshev discretization of the unit sphere.""" v = np.array([[-1.0, 1.0, -1.0, 1.0]]) for _ in range(nref): vnew = np.zeros((4 * v.shape[0], 4)) for k, vk in enumerate(v): mid = np.array([np.mean(vk[:2]), np.mean(vk[2:])]) vnew[4 * k + 0] = [vk[0], mid[0], vk[2], mid[1]] vnew[4 * k + 1] = [mid[0], vk[1], vk[2], mid[1]] vnew[4 * k + 2] = [vk[0], mid[0], mid[1], vk[3]] vnew[4 * k + 3] = [mid[0], vk[1], mid[1], vk[3]] v = vnew xx0, yy0 = chebpts2(n, n, [0.0, 1.0, 0.0, 1.0]) uu = [] vv = [] for vk in v: sclx = vk[1] - vk[0] scly = vk[3] - vk[2] uu.append(sclx * xx0 + vk[0]) vv.append(scly * yy0 + vk[2]) uu = np.stack(uu, axis=2) vv = np.stack(vv, axis=2) ones = np.ones_like(uu) xx = np.concatenate((-ones, ones, vv, vv, uu, uu), axis=2) yy = np.concatenate((uu, uu, -ones, ones, vv, vv), axis=2) zz = np.concatenate((vv, vv, uu, uu, -ones, ones), axis=2) if projection.lower() == "naive": xx, yy, zz = project_naive(xx, yy, zz) elif projection.lower() == "quasiuniform": xx, yy, zz = project_quasiuniform(xx, yy, zz) else: raise ValueError("projection must be 'naive' or 'quasiuniform'") x = [xx[:, :, k] for k in range(xx.shape[2])] y = [yy[:, :, k] for k in range(yy.shape[2])] z = [zz[:, :, k] for k in range(zz.shape[2])] return SurfaceMesh(x, y, z)
[docs] @staticmethod def torus(n: int, nu: int = 8, nv: int | None = None) -> "SurfaceMesh": """Build a Fourier-parametrized torus patch mesh.""" if nv is None: nv = nu x: list[Array] = [] y: list[Array] = [] z: list[Array] = [] ubreaks = np.linspace(0.0, 2.0 * np.pi, nu + 1) vbreaks = np.linspace(0.0, 2.0 * np.pi, nv + 1) for ku in range(nu): for kv in range(nv): uu, vv = chebpts2(n, n, [ubreaks[ku], ubreaks[ku + 1], vbreaks[kv], vbreaks[kv + 1]]) xx, yy, zz = eval_torus(uu, vv) x.append(xx) y.append(yy) z.append(zz) return SurfaceMesh(x, y, z)
[docs] @staticmethod def stellarator(n: int, nu: int = 8, nv: int | None = None) -> "SurfaceMesh": """Build a Fourier-parametrized stellarator patch mesh.""" if nv is None: nv = nu x: list[Array] = [] y: list[Array] = [] z: list[Array] = [] ubreaks = np.linspace(0.0, 2.0 * np.pi, nu + 1) vbreaks = np.linspace(0.0, 2.0 * np.pi, nv + 1) for ku in range(nu): for kv in range(nv): uu, vv = chebpts2(n, n, [ubreaks[ku], ubreaks[ku + 1], vbreaks[kv], vbreaks[kv + 1]]) xx, yy, zz = eval_stellarator(uu, vv) x.append(xx) y.append(yy) z.append(zz) if is_power_of_two(nv) and is_power_of_two(nu): ordering = morton(nv, nu).ravel(order="F") - 1 x, y, z = reorder_cells_by_destination(ordering, x, y, z) return SurfaceMesh(x, y, z)
[docs] @staticmethod def from_rhino(filename: str, n: int) -> "SurfaceMesh": """ Import a Rhino CSV patch mesh. The file must have three numeric columns ``x,y,z`` and ``n*n`` consecutive rows per patch. """ data = np.loadtxt(filename, delimiter=",") if data.ndim != 2 or data.shape[1] < 3: raise ValueError("Rhino CSV must contain at least three numeric columns") rows_per_patch = n * n if data.shape[0] % rows_per_patch != 0: raise ValueError( f"row count {data.shape[0]} is not divisible by n*n={rows_per_patch}" ) nelem = data.shape[0] // rows_per_patch X = data[:, 0].reshape((n, n * nelem), order="F") Y = data[:, 1].reshape((n, n * nelem), order="F") Z = data[:, 2].reshape((n, n * nelem), order="F") x = [X[:, k * n : (k + 1) * n].copy() for k in range(nelem)] y = [Y[:, k * n : (k + 1) * n].copy() for k in range(nelem)] z = [Z[:, k * n : (k + 1) * n].copy() for k in range(nelem)] return SurfaceMesh(x, y, z)
def project_naive(x: Array, y: Array, z: Array) -> tuple[Array, Array, Array]: nrm = np.sqrt(x * x + y * y + z * z) return x / nrm, y / nrm, z / nrm def project_quasiuniform(x: Array, y: Array, z: Array) -> tuple[Array, Array, Array]: xp = x * np.sqrt(1 - y * y / 2 - z * z / 2 + (y * y * z * z) / 3) yp = y * np.sqrt(1 - x * x / 2 - z * z / 2 + (x * x * z * z) / 3) zp = z * np.sqrt(1 - x * x / 2 - y * y / 2 + (x * x * y * y) / 3) return xp, yp, zp def eval_torus(u: Array, v: Array) -> tuple[Array, Array, Array]: """Evaluate the Fourier torus parametrization.""" d = np.array( [ [0.17, 0.11, 0.0, 0.0], [0.0, 1.0, 0.01, 0.0], [0.0, 4.5, 0.0, 0.0], [0.0, -0.25, -0.45, 0.0], ] ) x = np.zeros_like(u) y = np.zeros_like(u) z = np.zeros_like(u) for i in range(-1, 3): for j in range(-1, 3): coeff = d[i + 1, j + 1] phase = (1 - i) * u + j * v x = x + coeff * np.cos(v) * np.cos(phase) y = y + coeff * np.sin(v) * np.cos(phase) z = z + coeff * np.sin(phase) return x, y, z def eval_stellarator(u: Array, v: Array) -> tuple[Array, Array, Array]: """Evaluate the Fourier stellarator parametrization.""" Q = 3 R = 1.0 r0 = 0.0 z0 = 0.0 d = np.array( [ [0.15, 0.09, 0.00, 0.00, 0.00], [0.00, 1.00, 0.03, -0.01, 0.00], [0.08, 4.00, -0.01, -0.02, 0.00], [0.01, -0.28, -0.28, 0.03, 0.02], [0.00, 0.09, -0.03, 0.06, 0.00], [0.01, -0.02, 0.02, 0.00, -0.02], ] ) rz = np.zeros_like(u, dtype=complex) for j in range(-1, 5): for k in range(-1, 4): rz = rz + d[j + 1, k + 1] * np.exp(-1j * j * u + 1j * k * Q * v) rz = np.exp(1j * u) * rz r1 = np.real(rz) z1 = np.imag(rz) r = r0 + R * (r1 - r0) z = z0 + R * (z1 - z0) x = r * np.cos(v) y = r * np.sin(v) return x, y, z def is_power_of_two(n: int) -> bool: return n > 0 and (n & (n - 1)) == 0 def morton(m: int, n: int | None = None) -> Array: """Create the same Morton ordering matrix as ``tools/morton.m``.""" if n is None: n = m if m == 0 or n == 0: return np.empty((0, 0), dtype=int) if not (is_power_of_two(m) and is_power_of_two(n)): raise ValueError("m and n must be powers of two") if m == 1: return np.arange(1, n + 1).reshape(1, n) if n == 1: return np.arange(1, m + 1).reshape(m, 1) if m == 2 and n == 2: return np.array([[1, 2], [3, 4]]) B = morton(m // 2, n // 2) shift = (m // 2) * (n // 2) return np.block([[B, B + shift], [B + 2 * shift, B + 3 * shift]]) def reorder_cells_by_destination(ordering: Array, x: list[Array], y: list[Array], z: list[Array]) -> tuple[list[Array], list[Array], list[Array]]: newx: list[Array | None] = [None] * len(x) newy: list[Array | None] = [None] * len(y) newz: list[Array | None] = [None] * len(z) for dest, xx, yy, zz in zip(ordering, x, y, z): newx[int(dest)] = xx newy[int(dest)] = yy newz[int(dest)] = zz return list(newx), list(newy), list(newz) # --------------------------------------------------------------------------- # Scalar and vector surface functions. # ---------------------------------------------------------------------------
[docs] @dataclass class SurfaceFunction: domain: SurfaceMesh vals: list[Array]
[docs] @staticmethod def from_callable(domain: SurfaceMesh, func: Callable[[Array, Array, Array], Array]) -> "SurfaceFunction": vals = [np.asarray(func(x, y, z)) for x, y, z in zip(domain.x, domain.y, domain.z)] return SurfaceFunction(domain, vals)
[docs] @staticmethod def constant(domain: SurfaceMesh, value: float | complex) -> "SurfaceFunction": return SurfaceFunction(domain, [value * np.ones_like(x) for x in domain.x])
[docs] def vec(self) -> Array: return np.column_stack([v.ravel(order="F") for v in self.vals]).ravel(order="F")
[docs] def norm_inf(self) -> float: return float(max(np.max(np.abs(v)) for v in self.vals))
[docs] def mean2(self) -> float: total = 0.0 + 0.0j area = 0.0 wu = quadwts(self.domain.n, 2) W = np.outer(wu, wu) for v, J in zip(self.vals, self.domain.J): jac = np.sqrt(np.maximum(J, 0.0)) total += np.sum(W * jac * v) area += float(np.sum(W * jac)) out = total / area return float(np.real(out)) if np.isrealobj(out) else out
[docs] def remove_mean(self) -> "SurfaceFunction": return self - self.mean2()
[docs] def copy(self) -> "SurfaceFunction": return SurfaceFunction(self.domain, [v.copy() for v in self.vals])
def __add__(self, other: float | "SurfaceFunction") -> "SurfaceFunction": if isinstance(other, SurfaceFunction): return SurfaceFunction(self.domain, [a + b for a, b in zip(self.vals, other.vals)]) return SurfaceFunction(self.domain, [a + other for a in self.vals]) __radd__ = __add__ def __sub__(self, other: float | "SurfaceFunction") -> "SurfaceFunction": if isinstance(other, SurfaceFunction): return SurfaceFunction(self.domain, [a - b for a, b in zip(self.vals, other.vals)]) return SurfaceFunction(self.domain, [a - other for a in self.vals]) def __rsub__(self, other: float) -> "SurfaceFunction": return SurfaceFunction(self.domain, [other - a for a in self.vals]) def __mul__(self, other: float | "SurfaceFunction") -> "SurfaceFunction": if isinstance(other, SurfaceFunction): return SurfaceFunction(self.domain, [a * b for a, b in zip(self.vals, other.vals)]) return SurfaceFunction(self.domain, [a * other for a in self.vals]) __rmul__ = __mul__ def __truediv__(self, other: float | "SurfaceFunction") -> "SurfaceFunction": if isinstance(other, SurfaceFunction): return SurfaceFunction(self.domain, [a / b for a, b in zip(self.vals, other.vals)]) return SurfaceFunction(self.domain, [a / other for a in self.vals]) def __rtruediv__(self, other: float) -> "SurfaceFunction": return SurfaceFunction(self.domain, [other / a for a in self.vals]) def __pow__(self, power: float) -> "SurfaceFunction": return SurfaceFunction(self.domain, [a**power for a in self.vals]) def __abs__(self) -> "SurfaceFunction": return SurfaceFunction(self.domain, [np.abs(a) for a in self.vals]) def __neg__(self) -> "SurfaceFunction": return SurfaceFunction(self.domain, [-a for a in self.vals])
[docs] @dataclass class SurfaceVectorFunction: """Three-component vector field sampled on a surface mesh.""" components: tuple[SurfaceFunction, SurfaceFunction, SurfaceFunction] @property def domain(self) -> SurfaceMesh: return self.components[0].domain
[docs] def norm_inf(self) -> float: vals = zip(*(component.vals for component in self.components)) return float(max(np.max(np.sqrt(a * a + b * b + c * c)) for a, b, c in vals))
def __add__(self, other: float | Array | "SurfaceVectorFunction") -> "SurfaceVectorFunction": if isinstance(other, SurfaceVectorFunction): return SurfaceVectorFunction(tuple(a + b for a, b in zip(self.components, other.components))) arr = np.asarray(other) if arr.ndim == 0: return SurfaceVectorFunction(tuple(a + float(arr) for a in self.components)) if arr.size == 3: return SurfaceVectorFunction(tuple(a + arr[i] for i, a in enumerate(self.components))) raise ValueError("can only add scalars, length-3 vectors, or SurfaceVectorFunction") __radd__ = __add__ def __sub__(self, other: float | Array | "SurfaceVectorFunction") -> "SurfaceVectorFunction": return self + (-other) def __rsub__(self, other: float | Array) -> "SurfaceVectorFunction": return (-self) + other def __neg__(self) -> "SurfaceVectorFunction": return SurfaceVectorFunction(tuple(-a for a in self.components)) def __mul__(self, other: float | SurfaceFunction) -> "SurfaceVectorFunction": return SurfaceVectorFunction(tuple(a * other for a in self.components)) __rmul__ = __mul__ def __truediv__(self, other: float | SurfaceFunction) -> "SurfaceVectorFunction": return SurfaceVectorFunction(tuple(a / other for a in self.components))
[docs] def sphere(n: int, nref: int = 0, projection: str = "quasiuniform") -> SurfaceMesh: """Build a six-patch Chebyshev discretization of the unit sphere.""" return SurfaceMesh.sphere(n, nref, projection)
[docs] def torus(n: int, nu: int = 8, nv: int | None = None) -> SurfaceMesh: """Build a Fourier-parametrized torus patch mesh.""" return SurfaceMesh.torus(n, nu, nv)
[docs] def stellarator(n: int, nu: int = 8, nv: int | None = None) -> SurfaceMesh: """Build a Fourier-parametrized stellarator patch mesh.""" return SurfaceMesh.stellarator(n, nu, nv)
[docs] def from_rhino(filename: str, n: int) -> SurfaceMesh: """Load a Rhino-style CSV patch mesh.""" return SurfaceMesh.from_rhino(filename, n)
[docs] def surfacefun(func: Callable[[Array, Array, Array], Array] | float | list[Array], dom: SurfaceMesh) -> SurfaceFunction: """Construct a scalar surface function on a ``SurfaceMesh``.""" if callable(func): return SurfaceFunction.from_callable(dom, func) if np.isscalar(func): return SurfaceFunction.constant(dom, func) return SurfaceFunction(dom, [np.asarray(v) for v in func])
[docs] def surfacefunv( fx: Callable[[Array, Array, Array], Array] | SurfaceFunction | float | list[Array], fy: Callable[[Array, Array, Array], Array] | SurfaceFunction | float | list[Array] | None = None, fz: Callable[[Array, Array, Array], Array] | SurfaceFunction | float | list[Array] | None = None, dom: SurfaceMesh | None = None, ) -> SurfaceVectorFunction: """Construct a three-component vector surface function.""" if isinstance(fx, SurfaceMesh) and fy is None and fz is None: zero = surfacefun(0.0, fx) return SurfaceVectorFunction((zero, zero.copy(), zero.copy())) if isinstance(fx, SurfaceFunction) and isinstance(fy, SurfaceFunction) and isinstance(fz, SurfaceFunction): return SurfaceVectorFunction((fx, fy, fz)) if dom is None: raise ValueError("dom is required when components are not SurfaceFunction objects") assert fy is not None and fz is not None return SurfaceVectorFunction((surfacefun(fx, dom), surfacefun(fy, dom), surfacefun(fz, dom)))
[docs] def surfaceop(dom: SurfaceMesh, op: dict, rhs: SurfaceFunction | Callable | float = 0.0) -> "SurfaceOp": """Construct a scalar elliptic surface operator.""" return SurfaceOp(dom, op, rhs)
[docs] def compose(op: Callable, f: SurfaceFunction | float, g: SurfaceFunction | float | None = None) -> SurfaceFunction: """ Compose a scalar function with one or two surface functions. """ if g is None: if not isinstance(f, SurfaceFunction): raise TypeError("single-argument compose expects a SurfaceFunction") return SurfaceFunction(f.domain, [op(v) for v in f.vals]) if isinstance(f, SurfaceFunction) and isinstance(g, SurfaceFunction): return SurfaceFunction(f.domain, [op(a, b) for a, b in zip(f.vals, g.vals)]) if isinstance(f, SurfaceFunction): return SurfaceFunction(f.domain, [op(a, g) for a in f.vals]) if isinstance(g, SurfaceFunction): return SurfaceFunction(g.domain, [op(f, b) for b in g.vals]) raise TypeError("at least one argument must be a SurfaceFunction")
[docs] def exp(f: SurfaceFunction) -> SurfaceFunction: return compose(np.exp, f)
[docs] def log(f: SurfaceFunction) -> SurfaceFunction: return compose(np.log, f)
[docs] def log10(f: SurfaceFunction) -> SurfaceFunction: return compose(np.log10, f)
[docs] def sqrt(f: SurfaceFunction) -> SurfaceFunction: return compose(np.sqrt, f)
[docs] def sin(f: SurfaceFunction) -> SurfaceFunction: return compose(np.sin, f)
[docs] def cos(f: SurfaceFunction) -> SurfaceFunction: return compose(np.cos, f)
[docs] def real(f: SurfaceFunction) -> SurfaceFunction: return compose(np.real, f)
[docs] def imag(f: SurfaceFunction) -> SurfaceFunction: return compose(np.imag, f)
[docs] def conj(f: SurfaceFunction) -> SurfaceFunction: return compose(np.conj, f)
[docs] def maxEst(f: SurfaceFunction) -> float: """Estimate the maximum sampled value.""" return float(max(np.max(v) for v in f.vals))
[docs] def minEst(f: SurfaceFunction) -> float: """Estimate the minimum sampled value.""" return float(min(np.min(v) for v in f.vals))
def resample_mesh(dom: SurfaceMesh, n: int) -> SurfaceMesh: """Resample every patch of a surface mesh to an ``n`` by ``n`` grid.""" m = dom.n B = barymat(chebpts(n, 2), chebpts(m, 2)) x = [B @ patch @ B.T for patch in dom.x] y = [B @ patch @ B.T for patch in dom.y] z = [B @ patch @ B.T for patch in dom.z] return SurfaceMesh(x, y, z) def resample(f: SurfaceFunction, n: int) -> SurfaceFunction: """Resample a surface function to an ``n`` by ``n`` grid on each patch.""" m = f.domain.n B = barymat(chebpts(n, 2), chebpts(m, 2)) vals = [B @ v @ B.T for v in f.vals] return SurfaceFunction(resample_mesh(f.domain, n), vals)
[docs] def prolong(f: SurfaceFunction, n: int | None = None) -> SurfaceFunction: """ Prolong/restrict a surface function to a new polynomial grid. This implementation uses barycentric patch interpolation, which is stable for the smooth resolved functions used in the examples. """ if n is None: return f.copy() return resample(f, n)
[docs] def diff(f: SurfaceFunction, n: int | tuple[int, int, int] = 1, dim: int = 1) -> SurfaceFunction: """ Differentiate a surface function in Cartesian surface directions. ``dim=1`` gives the tangential x derivative, ``dim=2`` gives y, and ``dim=3`` gives z. Passing ``n=(nx, ny, nz)`` applies mixed repeated derivatives. """ if isinstance(n, tuple): nx, ny, nz = n elif dim == 1: nx, ny, nz = int(n), 0, 0 elif dim == 2: nx, ny, nz = 0, int(n), 0 elif dim == 3: nx, ny, nz = 0, 0, int(n) else: raise ValueError("dim must be 1, 2, or 3") D = diffmat(f.domain.n) vals = [v.copy() for v in f.vals] out = SurfaceFunction(f.domain, vals) for k in range(f.domain.npatches): v = out.vals[k] for _ in range(nx): v = mapped_vdiff(v, f.domain, k, 1, D) for _ in range(ny): v = mapped_vdiff(v, f.domain, k, 2, D) for _ in range(nz): v = mapped_vdiff(v, f.domain, k, 3, D) out.vals[k] = v return out
def mapped_vdiff(vals: Array, dom: SurfaceMesh, k: int, dim: int, D: Array) -> Array: dfdu = vals @ D.T dfdv = D @ vals if dim == 1: du, dv = dom.ux[k], dom.vx[k] elif dim == 2: du, dv = dom.uy[k], dom.vy[k] elif dim == 3: du, dv = dom.uz[k], dom.vz[k] else: raise ValueError("dim must be 1, 2, or 3") return du * dfdu + dv * dfdv
[docs] def diffx(f: SurfaceFunction, n: int = 1) -> SurfaceFunction: return diff(f, n, 1)
[docs] def diffy(f: SurfaceFunction, n: int = 1) -> SurfaceFunction: return diff(f, n, 2)
[docs] def diffz(f: SurfaceFunction, n: int = 1) -> SurfaceFunction: return diff(f, n, 3)
[docs] def gradient(f: SurfaceFunction) -> SurfaceVectorFunction: """Return the surface gradient as a three-component vector function.""" return SurfaceVectorFunction((diffx(f), diffy(f), diffz(f)))
[docs] def grad(f: SurfaceFunction) -> SurfaceVectorFunction: return gradient(f)
[docs] def laplacian(f: SurfaceFunction) -> SurfaceFunction: """Surface Laplacian.""" return diffx(f, 2) + diffy(f, 2) + diffz(f, 2)
[docs] def lap(f: SurfaceFunction) -> SurfaceFunction: return laplacian(f)
[docs] def normal(dom: SurfaceMesh) -> SurfaceVectorFunction: """Surface unit normal.""" nx: list[Array] = [] ny: list[Array] = [] nz: list[Array] = [] volume = 0.0 wu = quadwts(dom.n, 2) W = np.outer(wu, wu) for x, y, z, xu, xv, yu, yv, zu, zv, J in zip( dom.x, dom.y, dom.z, dom.xu, dom.xv, dom.yu, dom.yv, dom.zu, dom.zv, dom.J ): n0 = yu * zv - zu * yv n1 = zu * xv - xu * zv n2 = xu * yv - yu * xv scl = np.sqrt(n0 * n0 + n1 * n1 + n2 * n2) n0 = n0 / scl n1 = n1 / scl n2 = n2 / scl nx.append(n0) ny.append(n1) nz.append(n2) volume += float(np.sum(((x * n0 + y * n1 + z * n2) / 3.0) * W * np.sqrt(np.maximum(J, 0.0)))) if volume < 0: nx = [-v for v in nx] ny = [-v for v in ny] nz = [-v for v in nz] return surfacefunv(nx, ny, nz, dom)
[docs] def dot(f: SurfaceVectorFunction, g: SurfaceVectorFunction) -> SurfaceFunction: fc = f.components gc = g.components return fc[0] * gc[0] + fc[1] * gc[1] + fc[2] * gc[2]
[docs] def cross(f: SurfaceVectorFunction | Array | list[float], g: SurfaceVectorFunction | Array | list[float], *args) -> SurfaceVectorFunction: """Vector cross product for arrays or surface vector functions.""" if args: return cross(f, cross(g, *args)) f_is_vec = isinstance(f, SurfaceVectorFunction) g_is_vec = isinstance(g, SurfaceVectorFunction) if f_is_vec and g_is_vec: fc = f.components gc = g.components return surfacefunv( fc[1] * gc[2] - fc[2] * gc[1], fc[2] * gc[0] - fc[0] * gc[2], fc[0] * gc[1] - fc[1] * gc[0], ) if f_is_vec: arr = np.asarray(g, dtype=float).ravel() if arr.size != 3: raise ValueError("constant vector must have length 3") fc = f.components return surfacefunv( fc[1] * arr[2] - fc[2] * arr[1], fc[2] * arr[0] - fc[0] * arr[2], fc[0] * arr[1] - fc[1] * arr[0], ) if g_is_vec: arr = np.asarray(f, dtype=float).ravel() if arr.size != 3: raise ValueError("constant vector must have length 3") gc = g.components return surfacefunv( arr[1] * gc[2] - arr[2] * gc[1], arr[2] * gc[0] - arr[0] * gc[2], arr[0] * gc[1] - arr[1] * gc[0], ) raise TypeError("at least one argument must be a SurfaceVectorFunction")
[docs] def divergence(f: SurfaceVectorFunction) -> SurfaceFunction: fc = f.components return diffx(fc[0]) + diffy(fc[1]) + diffz(fc[2])
[docs] def div(f: SurfaceVectorFunction) -> SurfaceFunction: return divergence(f)
[docs] def vector_norm(f: SurfaceVectorFunction) -> SurfaceFunction: fc = f.components return sqrt(fc[0] * fc[0] + fc[1] * fc[1] + fc[2] * fc[2])
[docs] def normalize(f: SurfaceVectorFunction) -> SurfaceVectorFunction: mag = vector_norm(f) safe = SurfaceFunction( f.domain, [np.where(np.abs(v) > 10 * np.finfo(float).eps, v, 1.0) for v in mag.vals], ) return f / safe
[docs] def hodge(f: SurfaceVectorFunction) -> tuple[SurfaceFunction, SurfaceFunction, SurfaceVectorFunction, SurfaceVectorFunction, SurfaceVectorFunction]: """ Hodge decomposition of a vector surface function. Returns ``u, v, w, curlfree, divfree`` such that f = grad(u) + normal x grad(v) + w. """ dom = f.domain nvec = normal(dom) L_u = SurfaceOp(dom, {"lap": 1.0}, div(f)) L_u.rankdef = True u = L_u.solve().remove_mean() L_v = SurfaceOp(dom, {"lap": 1.0}, -div(cross(nvec, f))) L_v.rankdef = True v = L_v.solve().remove_mean() curlfree = grad(u) divfree = cross(nvec, grad(v)) w = f - curlfree - divfree return u, v, w, curlfree, divfree
[docs] def integral2(f: SurfaceFunction, reduce: bool = True) -> float | Array: """Surface integral of a scalar surface function.""" wu = quadwts(f.domain.n, 2) W = np.outer(wu, wu) dtype = np.result_type(*f.vals) if f.vals else float per_patch = np.zeros(f.domain.npatches, dtype=dtype) for k, (vals, J) in enumerate(zip(f.vals, f.domain.J)): per_patch[k] = np.sum(vals * W * np.sqrt(np.maximum(J, 0.0))) total = np.sum(per_patch) return float(np.real(total)) if reduce and np.isrealobj(total) else total if reduce else per_patch
[docs] def integral(f: SurfaceFunction, reduce: bool = True) -> float | Array: return integral2(f, reduce)
[docs] def sum2(f: SurfaceFunction, reduce: bool = True) -> float | Array: return integral2(f, reduce)
[docs] def mean2(f: SurfaceFunction) -> float: return integral2(f) / surfacearea(f.domain)
[docs] def surfacearea(dom: SurfaceMesh) -> float: return integral2(SurfaceFunction.constant(dom, 1.0))
[docs] def boundingbox(dom: SurfaceMesh) -> Array: """Bounding box ``[xmin, xmax, ymin, ymax, zmin, zmax]``.""" xmin = min(float(np.min(x)) for x in dom.x) xmax = max(float(np.max(x)) for x in dom.x) ymin = min(float(np.min(y)) for y in dom.y) ymax = max(float(np.max(y)) for y in dom.y) zmin = min(float(np.min(z)) for z in dom.z) zmax = max(float(np.max(z)) for z in dom.z) return np.array([xmin, xmax, ymin, ymax, zmin, zmax])
[docs] def randnfun3( length_scale: float, bbox: Array | tuple[float, float, float, float, float, float], seed: int | None = None, nmodes: int = 64, ) -> Callable[[Array, Array, Array], Array]: """ Smooth deterministic 3D random field. This returns a finite smooth random Fourier field on the supplied physical bounding box. It is meant for reproducible initial data in time-dependent examples. """ bbox = np.asarray(bbox, dtype=float).ravel() if bbox.size != 6: raise ValueError("bbox must be [xmin, xmax, ymin, ymax, zmin, zmax]") if length_scale <= 0: raise ValueError("length_scale must be positive") rng = np.random.default_rng(seed) center = np.array([bbox[0] + bbox[1], bbox[2] + bbox[3], bbox[4] + bbox[5]]) / 2.0 wavevectors = rng.normal(scale=1.0 / length_scale, size=(nmodes, 3)) phases = rng.uniform(0.0, 2.0 * np.pi, size=nmodes) acos = rng.normal(size=nmodes) / np.sqrt(nmodes) asin = rng.normal(size=nmodes) / np.sqrt(nmodes) def field(x: Array, y: Array, z: Array) -> Array: shape = np.shape(x) pts = np.column_stack( ( np.asarray(x).ravel() - center[0], np.asarray(y).ravel() - center[1], np.asarray(z).ravel() - center[2], ) ) theta = pts @ wavevectors.T + phases vals = np.cos(theta) @ acos + np.sin(theta) @ asin return vals.reshape(shape) return field
[docs] def smooth_random_function_3d( length_scale: float, bbox: Array | tuple[float, float, float, float, float, float], seed: int | None = None, nmodes: int = 64, ) -> Callable[[Array, Array, Array], Array]: """Descriptive alias for ``randnfun3``.""" return randnfun3(length_scale, bbox, seed=seed, nmodes=nmodes)
[docs] def norm(f: SurfaceFunction | SurfaceVectorFunction, p: int | float | str = 2, reduce: bool = True) -> float | Array | SurfaceFunction: """ Surface function norms. Supported: 1, 2, positive integer p, ``"inf"``, ``"max"``, ``"H1"``, and ``"lap"``. """ if isinstance(f, SurfaceVectorFunction): mag = vector_norm(f) if p == 2 and reduce: return mag return norm(mag, p, reduce) if p in (np.inf, "inf", "max"): per_patch = np.asarray([np.max(np.abs(v)) for v in f.vals]) return float(np.max(per_patch)) if reduce else per_patch if p == "H1": g = gradient(f) parts = [norm(f, 2, False)] parts += [norm(comp, 2, False) for comp in g.components] per_patch = np.sqrt(sum(part * part for part in parts)) return float(np.sqrt(np.sum(per_patch * per_patch))) if reduce else per_patch if p == "lap": n0 = norm(f, 2, False) n1 = norm(lap(f), 2, False) per_patch = np.sqrt(n0 * n0 + n1 * n1) return float(np.sqrt(np.sum(per_patch * per_patch))) if reduce else per_patch p_float = float(p) per_patch = np.zeros(f.domain.npatches) for k, vals in enumerate(f.vals): per_patch[k] = integral2(SurfaceFunction(f.domain, [np.abs(v) ** p_float if j == k else np.zeros_like(v) for j, v in enumerate(f.vals)])) per_patch = per_patch ** (1.0 / p_float) if reduce: return float(np.sum(per_patch ** p_float) ** (1.0 / p_float)) return per_patch
# --------------------------------------------------------------------------- # surfaceop subset: leaf operators, Schur complements, recursive solve. # ---------------------------------------------------------------------------
[docs] @dataclass class PDO: dxx: float = 0.0 dyy: float = 0.0 dzz: float = 0.0 dxy: float = 0.0 dyx: float = 0.0 dyz: float = 0.0 dzy: float = 0.0 dxz: float = 0.0 dzx: float = 0.0 dx: float = 0.0 dy: float = 0.0 dz: float = 0.0 b: float = 0.0
def parse_pdo(op: dict) -> PDO: out = PDO() if "lap" in op: out.dxx = op["lap"] out.dyy = op["lap"] out.dzz = op["lap"] if "grad" in op: out.dx = op["grad"] out.dy = op["grad"] out.dz = op["grad"] for name in out.__dataclass_fields__: if name in op: setattr(out, name, op[name]) if "c" in op: out.b = op["c"] return out @dataclass class Patch: domain: SurfaceMesh ids: list[int] S: Array D2N: Array D2N_scl: list[Array] u_part: Array du_part: Array edges: Array xyz: Array w: Array def solve(self, bc: Array | Callable | None = None) -> list[Array]: raise NotImplementedError @dataclass class Leaf(Patch): n: int = 0 Aii: Array | None = None Aii_inv: Array | None = None ii_flat: Array | None = None normal_d: Array | None = None _bc_work: Array | None = field(default=None, init=False, repr=False) def solve(self, bc: Array | Callable | None = None) -> list[Array]: if bc is None: self._bc_work = get_work_array(self._bc_work, (self.S.shape[1], self.u_part.shape[1]), self.u_part.dtype) bc_arr = self._bc_work elif callable(bc): vals = bc(self.xyz[:, 0], self.xyz[:, 1], self.xyz[:, 2]) bc_arr = np.asarray(vals).reshape(-1, 1) else: bc_arr = np.asarray(bc) if bc_arr.ndim == 0: bc_arr = np.full((self.S.shape[1], self.u_part.shape[1]), bc_arr.item()) elif bc_arr.ndim == 1: bc_arr = bc_arr.reshape(-1, 1) u = self.S @ bc_arr + self.u_part return [u[:, j].reshape((self.n, self.n), order="F") for j in range(u.shape[1])] @dataclass class Parent(Patch): child1: Patch | None = None child2: Patch | None = None idx1: tuple[Array, Array] | None = None idx2: tuple[Array, Array] | None = None flip1: Array | None = None flip2: Array | None = None scl1: Array | None = None scl2: Array | None = None A_inv: Array | None = None M: Array | None = None rankdef_merged: bool = False _bc_work: Array | None = field(default=None, init=False, repr=False) _bc1_work: Array | None = field(default=None, init=False, repr=False) _bc2_work: Array | None = field(default=None, init=False, repr=False) def solve(self, bc: Array | Callable | None = None) -> list[Array]: nrhs = self.u_part.shape[1] if bc is None: self._bc_work = get_work_array(self._bc_work, (self.xyz.shape[0], nrhs), self.u_part.dtype) bc_arr = self._bc_work elif callable(bc): vals = bc(self.xyz[:, 0], self.xyz[:, 1], self.xyz[:, 2]) bc_arr = np.asarray(vals).reshape(-1, 1) else: bc_arr = np.asarray(bc) if bc_arr.ndim == 0: bc_arr = np.full((self.xyz.shape[0], nrhs), bc_arr.item()) elif bc_arr.ndim == 1: bc_arr = bc_arr.reshape(-1, 1) u_interface = self.u_part.copy() if self.S.size and bc_arr.size: u_interface = u_interface + self.S @ bc_arr assert self.child1 is not None and self.child2 is not None assert self.idx1 is not None and self.idx2 is not None assert self.flip1 is not None and self.flip2 is not None ext1, glue1 = self.idx1 ext2, glue2 = self.idx2 bc_dtype = np.result_type(bc_arr, u_interface) self._bc1_work = get_work_array(self._bc1_work, (self.child1.S.shape[1], nrhs), bc_dtype) self._bc2_work = get_work_array(self._bc2_work, (self.child2.S.shape[1], nrhs), bc_dtype) bc1 = self._bc1_work bc2 = self._bc2_work if ext1.size: bc1[ext1, :] = bc_arr[: ext1.size, :] if glue1.size: bc1[glue1, :] = self.flip1.T @ u_interface offset = ext1.size if ext2.size: bc2[ext2, :] = bc_arr[offset : offset + ext2.size, :] if glue2.size: bc2[glue2, :] = self.flip2.T @ u_interface return self.child1.solve(bc1) + self.child2.solve(bc2)
[docs] class SurfaceOp: def __init__(self, domain: SurfaceMesh, op: dict, rhs: SurfaceFunction | Callable | float = 0.0): self.domain = domain self.op = parse_pdo(op) self.rhs = rhs self.rankdef = False self.merge_idx = default_merge_idx(domain.npatches) self._ii_flat = interior_mask_flat(domain.n) self._num_int = int(np.sum(self._ii_flat)) self._rhs_work: Array | None = None self.patches: list[Patch] = initialize_leaves(self.op, self.domain, self.rhs) self._built = False
[docs] def build(self) -> None: if self._built: return patches = self.patches for level, idx in enumerate(self.merge_idx): top = level == len(self.merge_idx) - 1 q: list[Patch] = [] for a_idx, b_idx in idx: a = patches[a_idx] b = None if b_idx is None else patches[b_idx] if b is None: q.append(a) else: q.append(merge_patches(a, b, rankdef=(self.rankdef and top))) patches = q self.patches = patches self._built = True
[docs] def solve(self) -> SurfaceFunction: self.build() vals = self.patches[0].solve(None) return SurfaceFunction(self.domain, vals)
[docs] def apply(self, rhs: SurfaceFunction | Callable | float) -> SurfaceFunction: """Update the right-hand side and immediately solve.""" self.update_rhs(rhs) return self.solve()
[docs] def update_rhs(self, rhs: SurfaceFunction | Callable | float) -> "SurfaceOp": """ Update only the right-hand side data of an already assembled operator. The geometry, local differential matrices, and Schur-complement solution operators are reused. Only the particular solution data are recomputed and pushed up the merge tree. """ if not self._built: self.rhs = rhs self.patches = initialize_leaves(self.op, self.domain, self.rhs) return self rhs_int = evaluate_rhs(rhs, self.domain, self._ii_flat, self._num_int, self._rhs_work) self._rhs_work = rhs_int for patch in self.patches: update_patch_rhs(patch, rhs_int) self.rhs = rhs return self
def default_merge_idx(npatches: int) -> list[list[tuple[int, int | None]]]: """Binary merge schedule for patch-local solution operators.""" out: list[list[tuple[int, int | None]]] = [] np_now = npatches ids: list[int | None] = list(range(npatches)) while np_now > 1: if len(ids) % 2: ids.append(None) level = [] next_ids: list[int | None] = [] for k in range(0, len(ids), 2): level.append((ids[k], ids[k + 1])) next_ids.append(k // 2) out.append(level) ids = next_ids np_now = len(ids) return out def initialize_leaves(op: PDO, dom: SurfaceMesh, rhs: SurfaceFunction | Callable | float) -> list[Patch]: n = dom.n num_patches = dom.npatches Xref, Yref = chebpts2(n) ii = (np.abs(Xref) < 1.0) & (np.abs(Yref) < 1.0) ee = ~ii ii_flat = ii.ravel(order="F") ee_flat = ee.ravel(order="F") num_bdy = int(np.sum(ee_flat)) num_int = int(np.sum(ii_flat)) nskel = n - 2 num_skel = 4 * nskel S2L = skel2leaf(n, nskel) L2S = leaf2skel(nskel, n) xskel = chebpts(nskel, 1) xleaf = chebpts(n, 2) B = barymat(xskel, xleaf) wskel_1d = quadwts(nskel, 1).reshape(-1, 1) wskel = np.vstack((wskel_1d, wskel_1d, wskel_1d, wskel_1d)).ravel() left_skel = np.arange(0, nskel) right_skel = np.arange(nskel, 2 * nskel) down_skel = np.arange(2 * nskel, 3 * nskel) up_skel = np.arange(3 * nskel, 4 * nskel) NL, NR, ND, NU = binormals(dom) NN_list = [] for k in range(num_patches): NN_list.append(np.vstack((B @ NL[:, :, k], B @ NR[:, :, k], B @ ND[:, :, k], B @ NU[:, :, k]))) ux = stack_patch_vectors(dom.ux) vx = stack_patch_vectors(dom.vx) uy = stack_patch_vectors(dom.uy) vy = stack_patch_vectors(dom.vy) uz = stack_patch_vectors(dom.uz) vz = stack_patch_vectors(dom.vz) D = diffmat(n) I = np.eye(n) II = np.eye(n * n) Du = np.kron(D, I) Dv = np.kron(I, D) rhs_int = evaluate_rhs(rhs, dom, ii_flat, num_int) leaves: list[Patch] = [] tmpS_template = np.zeros((n * n, num_bdy + rhs_int.shape[1]), dtype=rhs_int.dtype) tmpS_template[np.ix_(ee_flat, np.arange(num_bdy))] = np.eye(num_bdy) for k in range(num_patches): x = dom.x[k] y = dom.y[k] z = dom.z[k] edges = np.array( [ [x[0, 0], y[0, 0], z[0, 0], x[-1, 0], y[-1, 0], z[-1, 0], nskel], [x[0, -1], y[0, -1], z[0, -1], x[-1, -1], y[-1, -1], z[-1, -1], nskel], [x[0, 0], y[0, 0], z[0, 0], x[0, -1], y[0, -1], z[0, -1], nskel], [x[-1, 0], y[-1, 0], z[-1, 0], x[-1, -1], y[-1, -1], z[-1, -1], nskel], ], dtype=float, ) Dx = ux[:, [k]] * Du + vx[:, [k]] * Dv Dy = uy[:, [k]] * Du + vy[:, [k]] * Dv Dz = uz[:, [k]] * Du + vz[:, [k]] * Dv A = np.zeros((n * n, n * n)) if op.dxx: A += op.dxx * (Dx @ Dx) if op.dyy: A += op.dyy * (Dy @ Dy) if op.dzz: A += op.dzz * (Dz @ Dz) if op.dxy: A += op.dxy * (Dx @ Dy) if op.dyx: A += op.dyx * (Dy @ Dx) if op.dyz: A += op.dyz * (Dy @ Dz) if op.dzy: A += op.dzy * (Dz @ Dy) if op.dxz: A += op.dxz * (Dx @ Dz) if op.dzx: A += op.dzx * (Dz @ Dx) if op.dx: A += op.dx * Dx if op.dy: A += op.dy * Dy if op.dz: A += op.dz * Dz if op.b: A += op.b * II Aii = A[np.ix_(ii_flat, ii_flat)] Aie = A[np.ix_(ii_flat, ee_flat)] rhs_k = rhs_int[:, :, k] local_rhs = np.hstack((-Aie, rhs_k)) Aii_inv = inverse_dense(Aii) S_int = solve_with_inverse(Aii_inv, local_rhs) tmpS = tmpS_template.copy() tmpS[ii_flat, :] = S_int S = tmpS[:, :num_bdy] @ S2L u_part = tmpS[:, num_bdy:] dx = L2S @ Dx[ee_flat, :] dy = L2S @ Dy[ee_flat, :] dz = L2S @ Dz[ee_flat, :] NN = NN_list[k] normal_d = rowscale(NN[:, 0], dx) + rowscale(NN[:, 1], dy) + rowscale(NN[:, 2], dz) D2N = normal_d @ S du_part = normal_d @ u_part D2N_scl = [ np.ones(nskel), np.ones(nskel), np.ones(nskel), np.ones(nskel), ] J = dom.J[k].ravel(order="F") xyz = L2S @ np.column_stack( ( x.ravel(order="F")[ee_flat], y.ravel(order="F")[ee_flat], z.ravel(order="F")[ee_flat], ) ) JJ = L2S @ np.sqrt(np.maximum(J[ee_flat], 0.0)) w = wskel * JJ leaves.append( Leaf( domain=dom, ids=[k], S=S, D2N=D2N, D2N_scl=D2N_scl, u_part=u_part, du_part=du_part, edges=edges, xyz=xyz, w=w, n=n, Aii=Aii, Aii_inv=Aii_inv, ii_flat=ii_flat, normal_d=normal_d, ) ) return leaves def interior_mask_flat(n: int) -> Array: """Interior-node mask in column-major patch order.""" Xref, Yref = chebpts2(n) return ((np.abs(Xref) < 1.0) & (np.abs(Yref) < 1.0)).ravel(order="F") def update_patch_rhs(patch: Patch, rhs_int: Array) -> None: """Recursive right-hand-side update for the HPS merge tree.""" if isinstance(patch, Leaf): patch_id = patch.ids[0] rhs_k = rhs_int[:, :, patch_id] assert patch.Aii_inv is not None and patch.ii_flat is not None and patch.normal_d is not None ii_flat = patch.ii_flat u_part = np.zeros((patch.n * patch.n, rhs_k.shape[1]), dtype=rhs_k.dtype) u_part[ii_flat, :] = solve_with_inverse(patch.Aii_inv, rhs_k) patch.u_part = u_part patch.du_part = patch.normal_d @ patch.u_part return if not isinstance(patch, Parent): raise TypeError("unknown patch type") assert patch.child1 is not None and patch.child2 is not None assert patch.idx1 is not None and patch.idx2 is not None assert patch.flip1 is not None and patch.flip2 is not None assert patch.scl1 is not None and patch.scl2 is not None assert patch.A_inv is not None and patch.M is not None update_patch_rhs(patch.child1, rhs_int) update_patch_rhs(patch.child2, rhs_int) i1, s1 = patch.idx1 i2, s2 = patch.idx2 nrhs = patch.child1.u_part.shape[1] if s1.size: z_part = rowscale(patch.scl2, patch.flip1 @ patch.child1.du_part[s1, :]) + rowscale( patch.scl1, patch.flip2 @ patch.child2.du_part[s2, :], ) patch.u_part = solve_with_inverse(patch.A_inv, z_part) else: patch.u_part = np.zeros((0, nrhs), dtype=patch.child1.u_part.dtype) patch.du_part = ( np.vstack((patch.child1.du_part[i1, :], patch.child2.du_part[i2, :])) + patch.M @ patch.u_part ) def evaluate_rhs( rhs: SurfaceFunction | Callable | float, dom: SurfaceMesh, ii_flat: Array, num_int: int, out: Array | None = None, ) -> Array: if isinstance(rhs, SurfaceFunction): vals = [v.ravel(order="F")[ii_flat] for v in rhs.vals] dtype = np.result_type(*vals) if vals else float out = get_work_array(out, (num_int, 1, dom.npatches), dtype) for k, v in enumerate(vals): out[:, 0, k] = v return out if callable(rhs): vals = [] for k in range(dom.npatches): x = dom.x[k].ravel(order="F")[ii_flat] y = dom.y[k].ravel(order="F")[ii_flat] z = dom.z[k].ravel(order="F")[ii_flat] vals.append(np.asarray(rhs(x, y, z)).ravel()) dtype = np.result_type(*vals) if vals else float out = get_work_array(out, (num_int, 1, dom.npatches), dtype) for k, v in enumerate(vals): out[:, 0, k] = v return out out = get_work_array(out, (num_int, 1, dom.npatches), np.asarray(rhs).dtype) out.fill(rhs) return out def stack_patch_vectors(cells: list[Array]) -> Array: return np.column_stack([a.ravel(order="F") for a in cells]) def solve_dense(A: Array, B: Array) -> Array: if A.shape[0] == 0: return np.zeros_like(B) return np.linalg.solve(A, B) def inverse_dense(A: Array) -> Array: if A.shape[0] == 0: return np.zeros_like(A) return np.linalg.inv(A) def solve_with_inverse(A_inv: Array, B: Array) -> Array: if A_inv.shape[0] == 0: return np.zeros((0, B.shape[1]), dtype=B.dtype) return A_inv @ B def skel2leaf(nleaf: int, nskel: int) -> Array: xskel = chebpts(nskel, 1) xleaf = chebpts(nleaf, 2) B = barymat(xleaf, xskel) left_skel = np.arange(0, nskel) right_skel = np.arange(nskel, 2 * nskel) down_skel = np.arange(2 * nskel, 3 * nskel) up_skel = np.arange(3 * nskel, 4 * nskel) left_leaf = np.arange(0, nleaf) right_leaf = np.arange(3 * nleaf - 4, 4 * nleaf - 4) up_leaf = np.r_[np.arange(nleaf - 1, 3 * nleaf - 4, 2), 4 * nleaf - 5] down_leaf = np.r_[0, np.arange(nleaf, 3 * nleaf - 3, 2)] P = np.zeros((4 * nleaf - 4, 4 * nskel)) P[np.ix_(left_leaf, left_skel)] = B P[np.ix_(right_leaf, right_skel)] = B P[np.ix_(down_leaf, down_skel)] = B P[np.ix_(up_leaf, up_skel)] = B corners = np.array([0, nleaf - 1, 3 * nleaf - 4, 4 * nleaf - 5]) P[corners, :] *= 0.5 return P def leaf2skel(nskel: int, nleaf: int) -> Array: xskel = chebpts(nskel, 1) xleaf = chebpts(nleaf, 2) B = barymat(xskel, xleaf) left_skel = np.arange(0, nskel) right_skel = np.arange(nskel, 2 * nskel) down_skel = np.arange(2 * nskel, 3 * nskel) up_skel = np.arange(3 * nskel, 4 * nskel) left_leaf = np.arange(0, nleaf) right_leaf = np.arange(3 * nleaf - 4, 4 * nleaf - 4) up_leaf = np.r_[np.arange(nleaf - 1, 3 * nleaf - 4, 2), 4 * nleaf - 5] down_leaf = np.r_[0, np.arange(nleaf, 3 * nleaf - 3, 2)] P = np.zeros((4 * nskel, 4 * nleaf - 4)) P[np.ix_(left_skel, left_leaf)] = B P[np.ix_(right_skel, right_leaf)] = B P[np.ix_(down_skel, down_leaf)] = B P[np.ix_(up_skel, up_leaf)] = B return P def binormals(dom: SurfaceMesh) -> tuple[Array, Array, Array, Array]: n = dom.n P = dom.npatches NL = np.zeros((n, 3, P)) NR = np.zeros((n, 3, P)) ND = np.zeros((n, 3, P)) NU = np.zeros((n, 3, P)) for k in range(P): xu, xv = dom.xu[k], dom.xv[k] yu, yv = dom.yu[k], dom.yv[k] zu, zv = dom.zu[k], dom.zv[k] nl = -np.column_stack((xu[:, 0], yu[:, 0], zu[:, 0])) nr = np.column_stack((xu[:, -1], yu[:, -1], zu[:, -1])) nd = -np.column_stack((xv[0, :], yv[0, :], zv[0, :])) nu = np.column_stack((xv[-1, :], yv[-1, :], zv[-1, :])) tl = normalize_rows(np.column_stack((xv[:, 0], yv[:, 0], zv[:, 0]))) tr = normalize_rows(np.column_stack((xv[:, -1], yv[:, -1], zv[:, -1]))) td = normalize_rows(np.column_stack((xu[0, :], yu[0, :], zu[0, :]))) tu = normalize_rows(np.column_stack((xu[-1, :], yu[-1, :], zu[-1, :]))) NL[:, :, k] = normalize_rows(nl - tl * np.sum(nl * tl, axis=1, keepdims=True)) NR[:, :, k] = normalize_rows(nr - tr * np.sum(nr * tr, axis=1, keepdims=True)) ND[:, :, k] = normalize_rows(nd - td * np.sum(nd * td, axis=1, keepdims=True)) NU[:, :, k] = normalize_rows(nu - tu * np.sum(nu * tu, axis=1, keepdims=True)) return NL, NR, ND, NU def normalize_rows(v: Array) -> Array: nrm = np.linalg.norm(v, axis=1, keepdims=True) return v / nrm def merge_patches(a: Patch, b: Patch, rankdef: bool = False) -> Patch: i1, i2, s1, s2, flip1, flip2, scl1, scl2, sclAB, edgesAB = patch_intersect(a, b) D2Na = a.D2N D2Nb = b.D2N A = -( rowscale(scl2, flip1 @ D2Na[np.ix_(s1, s1)] @ flip1.T) + rowscale(scl1, flip2 @ D2Nb[np.ix_(s2, s2)] @ flip2.T) ) z = np.hstack( ( rowscale(scl2, flip1 @ D2Na[np.ix_(s1, i1)]), rowscale(scl1, flip2 @ D2Nb[np.ix_(s2, i2)]), ) ) z_part = rowscale(scl2, flip1 @ a.du_part[s1, :]) + rowscale(scl1, flip2 @ b.du_part[s2, :]) if rankdef and i1.size == 0 and i2.size == 0 and A.size: w = a.w[s1].reshape(-1, 1) A = A + w @ w.T A_inv = inverse_dense(A) S = solve_with_inverse(A_inv, z) u_part = solve_with_inverse(A_inv, z_part) M = np.vstack((D2Na[np.ix_(i1, s1)] @ flip1.T, D2Nb[np.ix_(i2, s2)] @ flip2.T)) D2N = M @ S if S.size else np.zeros((i1.size + i2.size, i1.size + i2.size)) b1 = np.arange(i1.size) b2 = np.arange(i2.size) + i1.size if i1.size: D2N[np.ix_(b1, b1)] += D2Na[np.ix_(i1, i1)] if i2.size: D2N[np.ix_(b2, b2)] += D2Nb[np.ix_(i2, i2)] du_part = np.vstack((a.du_part[i1, :], b.du_part[i2, :])) + M @ u_part xyz = np.vstack((a.xyz[i1, :], b.xyz[i2, :])) w = np.concatenate((a.w[i1], b.w[i2])) return Parent( domain=a.domain, ids=a.ids + b.ids, S=S, D2N=D2N, D2N_scl=sclAB, u_part=u_part, du_part=du_part, edges=edgesAB, xyz=xyz, w=w, child1=a, child2=b, idx1=(i1, s1), idx2=(i2, s2), flip1=flip1, flip2=flip2, scl1=scl1, scl2=scl2, A_inv=A_inv, M=M, rankdef_merged=rankdef, ) def patch_intersect(a: Patch, b: Patch) -> tuple[Array, Array, Array, Array, Array, Array, Array, Array, list[Array], Array]: edgesA = a.edges.copy() edgesB = b.edges.copy() sclA = float(np.max(np.abs(edgesA[:, :6]))) if edgesA.size else 0.0 sclB = float(np.max(np.abs(edgesB[:, :6]))) if edgesB.size else 0.0 scl = max(sclA, sclB, 1.0) tol = 1e-8 * scl iA1, iB1 = intersect_tol(edgesA[:, :6], edgesB[:, :6], tol) iA2, iB2 = intersect_tol(edgesA[:, [3, 4, 5, 0, 1, 2]], edgesB[:, :6], tol) iA = np.concatenate((iA1, iA2)).astype(int) iB = np.concatenate((iB1, iB2)).astype(int) pA = edgesA[:, 6].astype(int) pB = edgesB[:, 6].astype(int) ppA = np.r_[0, np.cumsum(pA)] ppB = np.r_[0, np.cumsum(pB)] s1 = np.concatenate([np.arange(ppA[e], ppA[e + 1]) for e in iA]) if iA.size else np.empty(0, dtype=int) s2 = np.concatenate([np.arange(ppB[e], ppB[e + 1]) for e in iB]) if iB.size else np.empty(0, dtype=int) flip1 = np.eye(s1.size) blocks = [] for e in iB1: blocks.append(np.eye(pB[e])) for e in iB2: blocks.append(np.fliplr(np.eye(pB[e]))) flip2 = block_diag(blocks) if blocks else np.eye(s1.size) allA = np.arange(int(np.sum(pA))) allB = np.arange(int(np.sum(pB))) i1 = np.setdiff1d(allA, s1, assume_unique=False) i2 = np.setdiff1d(allB, s2, assume_unique=False) scl1 = np.concatenate([a.D2N_scl[e] for e in iA]) if iA.size else np.empty(0) scl2 = np.concatenate([b.D2N_scl[e] for e in iB]) if iB.size else np.empty(0) sclA = list(a.D2N_scl) sclB = list(b.D2N_scl) for e in sorted(iA.tolist(), reverse=True): del sclA[e] for e in sorted(iB.tolist(), reverse=True): del sclB[e] sclAB = sclA + sclB edgesA_remaining = np.delete(edgesA, iA, axis=0) if iA.size else edgesA edgesB_remaining = np.delete(edgesB, iB, axis=0) if iB.size else edgesB edgesAB = np.vstack((edgesA_remaining, edgesB_remaining)) return i1, i2, s1, s2, flip1, flip2, scl1, scl2, sclAB, edgesAB def intersect_tol(A: Array, B: Array, tol: float) -> tuple[Array, Array]: AI = [] BI = [] for k in range(A.shape[0]): dist = np.sum(np.abs(B - A[k, :]), axis=1) hit = np.where(dist < tol)[0] if hit.size: AI.append(k) BI.append(int(hit[0])) return np.asarray(AI, dtype=int), np.asarray(BI, dtype=int) # --------------------------------------------------------------------------- # Sphere test functions, plotting, and VTU export. # --------------------------------------------------------------------------- def associated_legendre(l: int, m: int, x: Array) -> Array: """Associated Legendre P_l^m(x), unnormalized, for m >= 0.""" if m < 0 or m > l: raise ValueError("require 0 <= m <= l") x = np.asarray(x) pmm = np.ones_like(x) if m > 0: somx2 = np.sqrt(np.maximum(1.0 - x * x, 0.0)) fact = 1.0 for _ in range(1, m + 1): pmm *= -fact * somx2 fact += 2.0 if l == m: return pmm pmmp1 = x * (2 * m + 1) * pmm if l == m + 1: return pmmp1 pll = np.zeros_like(x) p_lm2 = pmm p_lm1 = pmmp1 for ll in range(m + 2, l + 1): pll = ((2 * ll - 1) * x * p_lm1 - (ll + m - 1) * p_lm2) / (ll - m) p_lm2, p_lm1 = p_lm1, pll return pll
[docs] def real_spherical_harmonic(l: int, m: int, x: Array, y: Array, z: Array) -> Array: """ Real, unnormalized spherical harmonic Y_l^m on the unit sphere. This avoids a scipy dependency. Normalization is irrelevant for convergence tests because the error is relative. """ phi = np.arctan2(y, x) P = associated_legendre(l, abs(m), np.clip(z, -1.0, 1.0)) if m >= 0: return P * np.cos(m * phi) return P * np.sin(abs(m) * phi)
[docs] def solve_laplace_beltrami_sphere( f: SurfaceFunction | Callable, n: int = 9, nref: int = 0, c: float = 0.0, rankdef: bool = True, ) -> SurfaceFunction: """ Solve Delta_Gamma u + c u = f on S^2. For c=0 this is rank deficient; use a mean-zero RHS and rankdef=True. """ dom = f.domain if isinstance(f, SurfaceFunction) else SurfaceMesh.sphere(n, nref) L = SurfaceOp(dom, {"lap": 1.0, "c": c}, f) L.rankdef = rankdef u = L.solve() return u.remove_mean() if rankdef and c == 0.0 else u
[docs] def write_vtu(filename: str, u: SurfaceFunction, point_name: str = "u") -> None: """ Write a VTU file with triangulated patch grids. Requires meshio. Install with: pip install meshio """ import meshio points = [] values = [] faces = [] offset = 0 n = u.domain.n for x, y, z, val in zip(u.domain.x, u.domain.y, u.domain.z, u.vals): pts = np.column_stack((x.ravel(order="F"), y.ravel(order="F"), z.ravel(order="F"))) points.append(pts) values.append(val.ravel(order="F")) for j in range(n - 1): for i in range(n - 1): a = offset + i + j * n b = offset + (i + 1) + j * n c = offset + i + (j + 1) * n d = offset + (i + 1) + (j + 1) * n faces.append([a, b, d]) faces.append([a, d, c]) offset += n * n meshio.write_points_cells( filename, np.vstack(points), [("triangle", np.asarray(faces, dtype=int))], point_data={point_name: np.concatenate(values)}, )
[docs] def plot_surface( u: SurfaceFunction, title: str = "", vmin: float | None = None, vmax: float | None = None, colorbar: bool = True, ) -> None: """Basic Matplotlib visualization of the patch values.""" import matplotlib.pyplot as plt from matplotlib.cm import ScalarMappable from matplotlib.colors import Normalize fig = plt.figure(figsize=(7, 6)) ax = fig.add_subplot(111, projection="3d") plot_vals = [np.real(v) if np.iscomplexobj(v) else v for v in u.vals] if vmin is None: vmin = min(float(np.min(v)) for v in plot_vals) if vmax is None: vmax = max(float(np.max(v)) for v in plot_vals) norm_obj = Normalize(vmin=vmin, vmax=vmax) for x, y, z, val in zip(u.domain.x, u.domain.y, u.domain.z, plot_vals): ax.plot_surface( x, y, z, facecolors=plt.cm.turbo(norm_obj(val)), linewidth=0.15, edgecolor=(0, 0, 0, 0.25), antialiased=True, shade=False, ) ax.set_title(title) ax.set_axis_off() ax.set_box_aspect((1, 1, 1)) if colorbar: mappable = ScalarMappable(norm=norm_obj, cmap=plt.cm.turbo) mappable.set_array([]) fig.colorbar(mappable, ax=ax, shrink=0.75, pad=0.02) plt.show()
[docs] def plot_vector_field(f: SurfaceVectorFunction, title: str = "", stride: int = 2, scale: float = 0.6) -> None: """Plot vector magnitude on the surface with sparse 3D quiver arrows.""" import matplotlib.pyplot as plt mag = vector_norm(f) fig = plt.figure(figsize=(7, 6)) ax = fig.add_subplot(111, projection="3d") vmin = min(float(np.min(v)) for v in mag.vals) vmax = max(float(np.max(v)) for v in mag.vals) fx, fy, fz = f.components for x, y, z, mval, vx, vy, vz in zip( f.domain.x, f.domain.y, f.domain.z, mag.vals, fx.vals, fy.vals, fz.vals ): ax.plot_surface( x, y, z, facecolors=plt.cm.turbo((mval - vmin) / (vmax - vmin + 1e-15)), linewidth=0.1, edgecolor=(0, 0, 0, 0.12), antialiased=True, shade=False, ) sl = (slice(None, None, stride), slice(None, None, stride)) ax.quiver( x[sl], y[sl], z[sl], vx[sl], vy[sl], vz[sl], length=scale, normalize=True, color="k", linewidth=0.45, ) ax.set_title(title) ax.set_axis_off() ax.set_box_aspect((1, 1, 1)) plt.show()
def demo_laplace_beltrami_sphere() -> None: """Solve a small Laplace-Beltrami problem on the unit sphere.""" l = 3 m = 2 n = 7 nref = 0 dom = SurfaceMesh.sphere(n, nref) exact = SurfaceFunction.from_callable(dom, lambda x, y, z: real_spherical_harmonic(l, m, x, y, z)) rhs = -l * (l + 1) * exact L = SurfaceOp(dom, {"lap": 1.0}, rhs) L.rankdef = True uh = L.solve().remove_mean() relerr = (uh - exact.remove_mean()).norm_inf() / exact.norm_inf() print(f"relative L_inf error = {relerr:.3e}") try: write_vtu("laplace_beltrami_sphere.vtu", uh) print("wrote laplace_beltrami_sphere.vtu") except ImportError: print("VTU skipped: install meshio to export ParaView files") try: plot_surface(uh, "Laplace-Beltrami on S^2") except ImportError: print("plot skipped: install matplotlib to plot") if __name__ == "__main__": demo_laplace_beltrami_sphere()