From d96c48dac72d000620bef3c7823c618aaff10d5f Mon Sep 17 00:00:00 2001 From: Gilles Daviet Date: Tue, 2 Jul 2024 21:12:08 -0700 Subject: [PATCH] warp.fem: Variable nodes per element, global lookups, 3d streamlines example --- CHANGELOG.md | 6 + README.md | 24 +- docs/img/examples/fem_burgers.png | Bin 0 -> 13635 bytes docs/img/examples/fem_deformed_geometry.png | Bin 0 -> 19632 bytes docs/img/examples/fem_mixed_elasticity.png | Bin 12581 -> 63815 bytes docs/img/examples/fem_navier_stokes.png | Bin 14318 -> 74114 bytes docs/img/examples/fem_streamlines.png | Bin 0 -> 61311 bytes docs/modules/fem.rst | 12 +- warp/examples/fem/bsr_utils.py | 378 --------- warp/examples/fem/example_apic_fluid.py | 30 +- warp/examples/fem/example_burgers.py | 16 +- .../fem/example_convection_diffusion.py | 20 +- .../fem/example_convection_diffusion_dg.py | 38 +- .../examples/fem/example_deformed_geometry.py | 27 +- warp/examples/fem/example_diffusion.py | 20 +- warp/examples/fem/example_diffusion_3d.py | 23 +- warp/examples/fem/example_diffusion_mgpu.py | 19 +- warp/examples/fem/example_mixed_elasticity.py | 190 +++-- warp/examples/fem/example_navier_stokes.py | 20 +- warp/examples/fem/example_stokes.py | 25 +- warp/examples/fem/example_stokes_transfer.py | 14 +- warp/examples/fem/example_streamlines.py | 338 ++++++++ warp/examples/fem/mesh_utils.py | 133 --- warp/examples/fem/plot_utils.py | 292 ------- warp/examples/fem/utils.py | 777 ++++++++++++++++++ warp/fem/__init__.py | 4 +- warp/fem/cache.py | 44 + warp/fem/dirichlet.py | 55 +- warp/fem/domain.py | 103 ++- warp/fem/field/nodal_field.py | 51 +- warp/fem/geometry/hexmesh.py | 36 +- warp/fem/geometry/nanogrid.py | 132 ++- warp/fem/geometry/quadmesh_2d.py | 17 +- warp/fem/geometry/tetmesh.py | 164 ++-- warp/fem/geometry/trimesh_2d.py | 145 ++-- warp/fem/integrate.py | 161 ++-- warp/fem/operator.py | 20 +- warp/fem/quadrature/pic_quadrature.py | 35 +- warp/fem/quadrature/quadrature.py | 151 +++- warp/fem/space/basis_space.py | 106 ++- warp/fem/space/collocated_function_space.py | 2 +- warp/fem/space/grid_2d_function_space.py | 6 +- warp/fem/space/grid_3d_function_space.py | 8 +- warp/fem/space/hexmesh_function_space.py | 5 +- warp/fem/space/nanogrid_function_space.py | 26 +- warp/fem/space/partition.py | 92 +-- warp/fem/space/restriction.py | 35 +- warp/fem/space/shape/cube_shape_function.py | 6 +- warp/fem/space/shape/tet_shape_function.py | 5 - warp/fem/space/topology.py | 158 +++- warp/fem/types.py | 2 +- warp/fem/utils.py | 382 +++++++-- warp/tests/test_examples.py | 6 + warp/tests/test_fem.py | 110 ++- 54 files changed, 2837 insertions(+), 1632 deletions(-) create mode 100644 docs/img/examples/fem_burgers.png create mode 100644 docs/img/examples/fem_deformed_geometry.png create mode 100644 docs/img/examples/fem_streamlines.png delete mode 100644 warp/examples/fem/bsr_utils.py create mode 100644 warp/examples/fem/example_streamlines.py delete mode 100644 warp/examples/fem/mesh_utils.py delete mode 100644 warp/examples/fem/plot_utils.py create mode 100644 warp/examples/fem/utils.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 698a555d5..ba23ada2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - The `mask` argument to `wp.sim.eval_fk` now accepts both integer and bool arrays - Support for NumPy >= 2.0 - Fix hashing of replay functions and snippets +- `warp.fem` new features and fixes: + - Support for variable number of nodes per element + - Global `wp.fem.lookup()` operator now supports `wp.fem.Tetmesh` and `wp.fem.Trimesh2D` geometries + - Simplified defining custom subdomains (`wp.fem.Subdomain`), free-slip boundary conditions + - New `streamlines` example, updated `mixed_elasticity` to use a nonlinear model + - Fixed edge cases with Nanovdb function spaces ## [1.2.1] - 2024-06-14 diff --git a/README.md b/README.md index df32ddda6..6f189baf7 100644 --- a/README.md +++ b/README.md @@ -149,28 +149,28 @@ Built-in unit tests can be run from the command-line as follows: - - - + + + - - - + + + - + - - + + - + - - + +
apic fluidconvection diffusion diffusion 3ddiffusionmixed elasticityapic fluidstreamlines
mixed elasticityconvection diffusion navier stokesstokes transferstokesburgersdeformed geometry
diff --git a/docs/img/examples/fem_burgers.png b/docs/img/examples/fem_burgers.png new file mode 100644 index 0000000000000000000000000000000000000000..d93aa6c3762b1ad717d3188fd883f6fa56a33868 GIT binary patch literal 13635 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 W+^FB_!sr7~fS2-Wa#b>BA^#5+$7;&} literal 0 HcmV?d00001 diff --git a/docs/img/examples/fem_deformed_geometry.png b/docs/img/examples/fem_deformed_geometry.png new file mode 100644 index 0000000000000000000000000000000000000000..96631f1d360ec7fbc39c4c38e140257bbe5fdb78 GIT binary patch literal 19632 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 literal 12581 zcmV+=G1|_FP)Px#S5Qn;MgRZ*{{H^`{r&m*`S9@Y>FMe9_4VW9(M&d$!hzP_26nOU2HXJ=AUQUL)0Mn*=DkB>JuH*#`vdwY9? zgoG(6DH$0ViBN{D001BWNklYNw_@ zGzpM&8n$TaqA1K1zlvbuR|i~mz#e$A@vBT&$FCmPCai$V_*DaIUu4_@n}nZVGs_+E zy~H;s;_Z%}F5n)x++glnVz~Z@;aK8xjF!U@*Q5qkGuFTzF}&u8bJR1|U^rrU9k7`Z z{%}O6)8P;e=CmIcIL_IQnA5(MuT#6k&C&Z-0mI1>=cw-u`|xzY(dmeJ4{Kb9_E%#C z_V{!7MMuqQu>zR)a75ezR|}GK*odQ}YQy3VW5y+BqlQ!PZ$b=re!pC!V|~F%_h5X* z_>9$r3ZNYNT2vwVab0`ALn`+n1j-ifzzX(qd@-X>^9s4o<6sgL zA(8r6h!tz#qEjh$)i7`vE$P|ipp`?U_}oYN{xZj|-=EVny}ybRHNj8GUFIizfM+PCZ zYndrM9GUXXNWvkjtlSKy4!B15+@0Sq zBeA-;BB&{Yi76KQG)UdeX(H-PXEobgBR1jaL~a{n)iD}M$YBR9vt|X}9eKfIe8FkMnpoS6CuD#jBa8{h=c@<)ZpR?7L-cnIuZSy26}QUt zTaxrhzht(WePhs>eklqIbjf0m%ceT2i9u?lW8FVFeHBwH=^j>i$C_A}?li(?c9Zjw ze{C!DJBUOvgGdxGR7@@WB*}(2ZYU{lnCHI>8dtM4_UXPJZA-OI%D zlX+VIgy;J;H;-Zz$}XRf`$Sk<=_5<-NVSZ|nrgDqQ~F4|ihcjf@5h?Sf3&Og5&vZW zdXw>T_4%h;v(aK1-}6V{uJ0g(lUYG}WEOa`P$D^Bwpfkd7G)`jRS+y9R(g!zftr+j72)S<{V!9z~RKElXhKTuMH@2Jp9&2Smn zN9-T6oxP6Z%cr&Wzx9zr`bhY+7Wa?E`ldu3#@k3%!E1FrO)cO2d<$E%nn}Z zQA0Iq%It`-N6ipdf8q{OFeZbV(C6%uo;R^ZHj86~M;gUFSg{%NOc`UbwT?LtLqEuL z=KKtv_u%mohmfef)Qc@-dvRf1JZj293#seGQ31?qe{gi8Sxvwt{OOA_AIw}4pW@%J zMyS0eG%v&WPy?}MD#YV+d>%DYZt%a4uSDS1*RZZnq%V(+mb`v3XTgl+H=a(JkSKa) zXGa#8MP$4mq((?xNup3){FrLY`Ay|~zFCtGSv{9#|=Z4d_hIc%Y$^(B}t9;<@tAFoymcx8xC?R`;`4; zJTD3g>Oag97V@u&Nfuew)ZF}i|cG-~rWxcN^ z(l1|Q4)bTq$+yqs=!%_J1{^-zjlAL^K8X?)D%W|2P9 ze*UNNJ#RRl?;

bE* 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 z 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 diff --git a/docs/img/examples/fem_streamlines.png b/docs/img/examples/fem_streamlines.png new file mode 100644 index 0000000000000000000000000000000000000000..0da9d9acaaedd5ef0f5a1b8d9bffb62539a29686 GIT binary patch literal 61311 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 literal 0 HcmV?d00001 diff --git a/docs/modules/fem.rst b/docs/modules/fem.rst index c0911db96..7b9419e1f 100644 --- a/docs/modules/fem.rst +++ b/docs/modules/fem.rst @@ -19,9 +19,11 @@ The main mechanism is the :py:func:`.integrand` decorator, for instance: :: @integrand def linear_form( s: Sample, + domain: Domain, v: Field, ): - return v(s) + x = domain(s) + return v(s) * wp.max(0.0, 1.0 - wp.length(x)) @integrand @@ -31,7 +33,7 @@ The main mechanism is the :py:func:`.integrand` decorator, for instance: :: grad(v, s), ) -Integrands are normal Warp kernels, meaning any usual Warp function can be used. +Integrands are normal Warp kernels, meaning that they may contain arbitrary Warp functions. However, they accept a few special parameters: - :class:`.Sample` contains information about the current integration sample point, such as the element index and coordinates in element. @@ -123,7 +125,8 @@ Introductory examples - ``example_burgers.py``: 2D inviscid Burgers using Discontinuous Galerkin with upwind transport and slope limiter - ``example_stokes.py``: 2D incompressible Stokes flow using mixed :math:`P_k/P_{k-1}` or :math:`Q_k/P_{(k-1)d}` elements - ``example_navier_stokes.py``: 2D Navier-Stokes flow using mixed :math:`P_k/P_{k-1}` elements - - ``example_mixed_elasticity.py``: 2D linear elasticity using mixed continuous/discontinuous :math:`S_k/P_{(k-1)d}` elements + - ``example_mixed_elasticity.py``: 2D nonlinear elasticity using mixed continuous/discontinuous :math:`S_k/P_{(k-1)d}` elements + - ``example_streamlines.py``: Using the :func:`lookup` operator to trace through a velocity field Advanced usages @@ -269,6 +272,9 @@ Geometry .. autoclass:: FrontierSides :show-inheritance: +.. autoclass:: Subdomain + :show-inheritance: + .. autoclass:: Polynomial :members: diff --git a/warp/examples/fem/bsr_utils.py b/warp/examples/fem/bsr_utils.py deleted file mode 100644 index 0e586410d..000000000 --- a/warp/examples/fem/bsr_utils.py +++ /dev/null @@ -1,378 +0,0 @@ -from typing import Any, Optional, Tuple, Union - -import warp as wp -import warp.types -from warp.optim.linear import LinearOperator, aslinearoperator, preconditioner -from warp.sparse import BsrMatrix, bsr_get_diag, bsr_mv, bsr_transposed, bsr_zeros - - -def bsr_to_scipy(matrix: BsrMatrix) -> "scipy.sparse.bsr_array": # noqa: F821 - try: - from scipy.sparse import bsr_array, csr_array - except ImportError: - # WAR for older scipy - from scipy.sparse import bsr_matrix as bsr_array - from scipy.sparse import csr_matrix as csr_array - - if matrix.block_shape == (1, 1): - return csr_array( - ( - matrix.values.numpy().flatten()[: matrix.nnz], - matrix.columns.numpy()[: matrix.nnz], - matrix.offsets.numpy(), - ), - shape=matrix.shape, - ) - - return bsr_array( - ( - matrix.values.numpy().reshape((matrix.values.shape[0], *matrix.block_shape))[: matrix.nnz], - matrix.columns.numpy()[: matrix.nnz], - matrix.offsets.numpy(), - ), - shape=matrix.shape, - ) - - -def scipy_to_bsr( - sp: Union["scipy.sparse.bsr_array", "scipy.sparse.csr_array"], # noqa: F821 - device=None, - dtype=None, -) -> BsrMatrix: - try: - from scipy.sparse import csr_array - except ImportError: - # WAR for older scipy - from scipy.sparse import csr_matrix as csr_array - - if dtype is None: - dtype = warp.types.np_dtype_to_warp_type[sp.dtype] - - sp.sort_indices() - - if isinstance(sp, csr_array): - matrix = bsr_zeros(sp.shape[0], sp.shape[1], dtype, device=device) - else: - block_shape = sp.blocksize - block_type = wp.types.matrix(shape=block_shape, dtype=dtype) - matrix = bsr_zeros( - sp.shape[0] // block_shape[0], - sp.shape[1] // block_shape[1], - block_type, - device=device, - ) - - matrix.nnz = sp.nnz - matrix.values = wp.array(sp.data.flatten(), dtype=matrix.values.dtype, device=device) - matrix.columns = wp.array(sp.indices, dtype=matrix.columns.dtype, device=device) - matrix.offsets = wp.array(sp.indptr, dtype=matrix.offsets.dtype, device=device) - - return matrix - - -def get_linear_solver_func(method_name: str): - from warp.optim.linear import bicgstab, cg, cr, gmres - - if method_name == "bicgstab": - return bicgstab - if method_name == "gmres": - return gmres - if method_name == "cr": - return cr - return cg - - -def bsr_cg( - A: BsrMatrix, - x: wp.array, - b: wp.array, - max_iters: int = 0, - tol: float = 0.0001, - check_every=10, - use_diag_precond=True, - mv_routine=None, - quiet=False, - method: str = "cg", -) -> Tuple[float, int]: - """Solves the linear system A x = b using an iterative solver, optionally with diagonal preconditioning - - Args: - A: system left-hand side - x: result vector and initial guess - b: system right-hand-side - max_iters: maximum number of iterations to perform before aborting. If set to zero, equal to the system size. - tol: relative tolerance under which to stop the solve - check_every: number of iterations every which to evaluate the current residual norm to compare against tolerance - use_diag_precond: Whether to use diagonal preconditioning - mv_routine: Matrix-vector multiplication routine to use for multiplications with ``A`` - quiet: if True, do not print iteration residuals - method: Iterative solver method to use, defaults to Conjugate Gradient - - Returns: - Tuple (residual norm, iteration count) - - """ - - if mv_routine is None: - M = preconditioner(A, "diag") if use_diag_precond else None - else: - A = LinearOperator(A.shape, A.dtype, A.device, matvec=mv_routine) - M = None - - func = get_linear_solver_func(method_name=method) - - def print_callback(i, err, tol): - print(f"{func.__name__}: at iteration {i} error = \t {err} \t tol: {tol}") - - callback = None if quiet else print_callback - - end_iter, err, atol = func( - A=A, - b=b, - x=x, - maxiter=max_iters, - tol=tol, - check_every=check_every, - M=M, - callback=callback, - ) - - if not quiet: - res_str = "OK" if err <= atol else "TRUNCATED" - print(f"{func.__name__}: terminated after {end_iter} iterations with error = \t {err} ({res_str})") - - return err, end_iter - - -class SaddleSystem(LinearOperator): - """Builds a linear operator corresponding to the saddle-point linear system [A B^T; B 0] - - If use_diag_precond` is ``True``, builds the corresponding diagonal preconditioner `[diag(A); diag(B diag(A)^-1 B^T)]` - """ - - def __init__( - self, - A: BsrMatrix, - B: BsrMatrix, - Bt: Optional[BsrMatrix] = None, - use_diag_precond: bool = True, - ): - if Bt is None: - Bt = bsr_transposed(B) - - self._A = A - self._B = B - self._Bt = Bt - - self._u_dtype = wp.vec(length=A.block_shape[0], dtype=A.scalar_type) - self._p_dtype = wp.vec(length=B.block_shape[0], dtype=B.scalar_type) - self._p_byte_offset = A.nrow * wp.types.type_size_in_bytes(self._u_dtype) - - saddle_shape = (A.shape[0] + B.shape[0], A.shape[0] + B.shape[0]) - - super().__init__(saddle_shape, dtype=A.scalar_type, device=A.device, matvec=self._saddle_mv) - - if use_diag_precond: - self._preconditioner = self._diag_preconditioner() - else: - self._preconditioner = None - - def _diag_preconditioner(self): - A = self._A - B = self._B - - M_u = preconditioner(A, "diag") - - A_diag = bsr_get_diag(A) - - schur_block_shape = (B.block_shape[0], B.block_shape[0]) - schur_dtype = wp.mat(shape=schur_block_shape, dtype=B.scalar_type) - schur_inv_diag = wp.empty(dtype=schur_dtype, shape=B.nrow, device=self.device) - wp.launch( - _compute_schur_inverse_diagonal, - dim=B.nrow, - device=A.device, - inputs=[B.offsets, B.columns, B.values, A_diag, schur_inv_diag], - ) - - if schur_block_shape == (1, 1): - # Downcast 1x1 mats to scalars - schur_inv_diag = schur_inv_diag.view(dtype=B.scalar_type) - - M_p = aslinearoperator(schur_inv_diag) - - def precond_mv(x, y, z, alpha, beta): - x_u = self.u_slice(x) - x_p = self.p_slice(x) - y_u = self.u_slice(y) - y_p = self.p_slice(y) - z_u = self.u_slice(z) - z_p = self.p_slice(z) - - M_u.matvec(x_u, y_u, z_u, alpha=alpha, beta=beta) - M_p.matvec(x_p, y_p, z_p, alpha=alpha, beta=beta) - - return LinearOperator( - shape=self.shape, - dtype=self.dtype, - device=self.device, - matvec=precond_mv, - ) - - @property - def preconditioner(self): - return self._preconditioner - - def u_slice(self, a: wp.array): - return wp.array( - ptr=a.ptr, - dtype=self._u_dtype, - shape=self._A.nrow, - strides=None, - device=a.device, - pinned=a.pinned, - copy=False, - ) - - def p_slice(self, a: wp.array): - return wp.array( - ptr=a.ptr + self._p_byte_offset, - dtype=self._p_dtype, - shape=self._B.nrow, - strides=None, - device=a.device, - pinned=a.pinned, - copy=False, - ) - - def _saddle_mv(self, x, y, z, alpha, beta): - x_u = self.u_slice(x) - x_p = self.p_slice(x) - z_u = self.u_slice(z) - z_p = self.p_slice(z) - - if y.ptr != z.ptr and beta != 0.0: - wp.copy(src=y, dest=z) - - bsr_mv(self._A, x_u, z_u, alpha=alpha, beta=beta) - bsr_mv(self._Bt, x_p, z_u, alpha=alpha, beta=1.0) - bsr_mv(self._B, x_u, z_p, alpha=alpha, beta=beta) - - -def bsr_solve_saddle( - saddle_system: SaddleSystem, - x_u: wp.array, - x_p: wp.array, - b_u: wp.array, - b_p: wp.array, - max_iters: int = 0, - tol: float = 0.0001, - check_every=10, - quiet=False, - method: str = "cg", -) -> Tuple[float, int]: - """Solves the saddle-point linear system [A B^T; B 0] (x_u; x_p) = (b_u; b_p) using an iterative solver, optionally with diagonal preconditioning - - Args: - saddle_system: Saddle point system - x_u: primal part of the result vector and initial guess - x_p: Lagrange multiplier part of the result vector and initial guess - b_u: primal left-hand-side - b_p: constraint left-hand-side - max_iters: maximum number of iterations to perform before aborting. If set to zero, equal to the system size. - tol: relative tolerance under which to stop the solve - check_every: number of iterations every which to evaluate the current residual norm to compare against tolerance - quiet: if True, do not print iteration residuals - method: Iterative solver method to use, defaults to BiCGSTAB - - Returns: - Tuple (residual norm, iteration count) - - """ - x = wp.empty(dtype=saddle_system.scalar_type, shape=saddle_system.shape[0], device=saddle_system.device) - b = wp.empty_like(x) - - wp.copy(src=x_u, dest=saddle_system.u_slice(x)) - wp.copy(src=x_p, dest=saddle_system.p_slice(x)) - wp.copy(src=b_u, dest=saddle_system.u_slice(b)) - wp.copy(src=b_p, dest=saddle_system.p_slice(b)) - - func = get_linear_solver_func(method_name=method) - - def print_callback(i, err, tol): - print(f"{func.__name__}: at iteration {i} error = \t {err} \t tol: {tol}") - - callback = None if quiet else print_callback - - end_iter, err, atol = func( - A=saddle_system, - b=b, - x=x, - maxiter=max_iters, - tol=tol, - check_every=check_every, - M=saddle_system.preconditioner, - callback=callback, - ) - - if not quiet: - res_str = "OK" if err <= atol else "TRUNCATED" - print(f"{func.__name__}: terminated after {end_iter} iterations with absolute error = \t {err} ({res_str})") - - wp.copy(dest=x_u, src=saddle_system.u_slice(x)) - wp.copy(dest=x_p, src=saddle_system.p_slice(x)) - - return err, end_iter - - -@wp.kernel -def _compute_schur_inverse_diagonal( - B_offsets: wp.array(dtype=int), - B_indices: wp.array(dtype=int), - B_values: wp.array(dtype=Any), - A_diag: wp.array(dtype=Any), - P_diag: wp.array(dtype=Any), -): - row = wp.tid() - - zero = P_diag.dtype(P_diag.dtype.dtype(0.0)) - - schur = zero - - beg = B_offsets[row] - end = B_offsets[row + 1] - - for b in range(beg, end): - B = B_values[b] - col = B_indices[b] - Ai = wp.inverse(A_diag[col]) - S = B * Ai * wp.transpose(B) - schur += S - - schur_diag = wp.get_diag(schur) - id_diag = type(schur_diag)(schur_diag.dtype(1.0)) - - inv_diag = wp.select(schur == zero, wp.cw_div(id_diag, schur_diag), id_diag) - P_diag[row] = wp.diag(inv_diag) - - -def invert_diagonal_bsr_mass_matrix(A: BsrMatrix): - """Inverts each block of a block-diagonal mass matrix""" - - scale = A.scalar_type(A.block_shape[0]) - values = A.values - if not wp.types.type_is_matrix(values.dtype): - values = values.view(dtype=wp.mat(shape=(1, 1), dtype=A.scalar_type)) - - wp.launch( - kernel=_block_diagonal_mass_invert, - dim=A.nrow, - inputs=[values, scale], - device=values.device, - ) - - -@wp.kernel -def _block_diagonal_mass_invert(values: wp.array(dtype=Any), scale: Any): - i = wp.tid() - values[i] = scale * values[i] / wp.ddot(values[i], values[i]) diff --git a/warp/examples/fem/example_apic_fluid.py b/warp/examples/fem/example_apic_fluid.py index f153a6104..c4c0880ae 100644 --- a/warp/examples/fem/example_apic_fluid.py +++ b/warp/examples/fem/example_apic_fluid.py @@ -15,17 +15,13 @@ import numpy as np import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem import warp.sim.render from warp.fem import Domain, Field, Sample, at_node, div, grad, integrand from warp.sim import Model, State from warp.sparse import BsrMatrix, bsr_mm, bsr_mv, bsr_transposed -try: - from .bsr_utils import bsr_cg -except ImportError: - from bsr_utils import bsr_cg - @wp.func def collision_sdf(x: wp.vec3): @@ -130,7 +126,7 @@ def scale_transposed_divergence_mat( tr_divergence_mat_values: wp.array(dtype=wp.mat(shape=(3, 1), dtype=float)), inv_fraction_int: wp.array(dtype=float), ): - # In-place scaling of gradient operator rows wiht inverse mass + # In-place scaling of gradient operator rows with inverse mass u_i = wp.tid() block_beg = tr_divergence_mat_offsets[u_i] @@ -149,13 +145,24 @@ def compute_particle_ijk(positions: wp.array(dtype=wp.vec3), voxel_size: float, ijks[p] = wp.vec3i(int(wp.floor(pos[0])), int(wp.floor(pos[1])), int(wp.floor(pos[2]))) -def solve_incompressibility(divergence_mat: BsrMatrix, inv_volume, pressure, velocity, quiet: bool = False): +def solve_incompressibility( + divergence_mat: BsrMatrix, dirichlet_projector: BsrMatrix, inv_volume, pressure, velocity, quiet: bool = False +): """Solve for divergence-free velocity delta: delta_velocity = inv_volume * transpose(divergence_mat) * pressure divergence_mat * (velocity + delta_velocity) = 0 + dirichlet_projector * delta_velocity = 0 """ + # Constraint-free divergence -- computed *before* projection of divergence_mat + rhs = wp.empty_like(pressure) + bsr_mv(A=divergence_mat, x=velocity, y=rhs, alpha=-1.0) + + # Project matrix to enforce boundary conditions + # divergence_matrix -= divergence_matrix * vel_projector + bsr_mm(alpha=-1.0, x=divergence_mat, y=dirichlet_projector, z=divergence_mat, beta=1.0) + # Build transposed gradient matrix, scale with inverse fraction transposed_divergence_mat = bsr_transposed(divergence_mat) wp.launch( @@ -171,9 +178,7 @@ def solve_incompressibility(divergence_mat: BsrMatrix, inv_volume, pressure, vel # For simplicity, assemble Schur complement and solve with CG schur = bsr_mm(divergence_mat, transposed_divergence_mat) - rhs = wp.zeros_like(pressure) - bsr_mv(A=divergence_mat, x=velocity, y=rhs, alpha=-1.0, beta=0.0) - bsr_cg(schur, b=rhs, x=pressure, quiet=quiet, tol=1.0e-6) + fem_example_utils.bsr_cg(schur, b=rhs, x=pressure, quiet=quiet, tol=1.0e-6) # Apply pressure to velocity bsr_mv(A=transposed_divergence_mat, x=pressure, y=velocity, alpha=1.0, beta=1.0) @@ -354,13 +359,10 @@ def step(self): output_dtype=float, ) - # Project matrix to enforce boundary conditions - # divergence_matrix -= divergence_matrix * vel_projector - bsr_mm(alpha=-1.0, x=divergence_matrix, y=vel_projector, z=divergence_matrix, beta=1.0) - # Solve unilateral incompressibility solve_incompressibility( divergence_matrix, + vel_projector, inv_volume, pressure_field.dof_values, velocity_field.dof_values, diff --git a/warp/examples/fem/example_burgers.py b/warp/examples/fem/example_burgers.py index 3f9199ad3..8316f653e 100644 --- a/warp/examples/fem/example_burgers.py +++ b/warp/examples/fem/example_burgers.py @@ -16,18 +16,10 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem import warp.sparse as sp -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import invert_diagonal_bsr_mass_matrix - from .plot_utils import Plot -except ImportError: - from bsr_utils import invert_diagonal_bsr_mass_matrix - from plot_utils import Plot - @fem.integrand def vel_mass_form( @@ -97,7 +89,7 @@ def slope_limiter(domain: fem.Domain, s: fem.Sample, u: fem.Field, dx: wp.vec2): # Assumes regular grid topology center_coords = fem.Coords(0.5, 0.5, 0.0) - cell_center = fem.types.make_free_sample(s.element_index, center_coords) + cell_center = fem.make_free_sample(s.element_index, center_coords) center_pos = domain(cell_center) u_center = u(cell_center) @@ -150,7 +142,7 @@ def __init__(self, quiet=False, resolution=50, degree=1): vel_mass_form, fields={"u": trial, "v": self._test}, output_dtype=wp.float32, nodal=True ) self._inv_mass_matrix = sp.bsr_copy(matrix_inertia) - invert_diagonal_bsr_mass_matrix(self._inv_mass_matrix) + fem_example_utils.invert_diagonal_bsr_matrix(self._inv_mass_matrix) # Initial condition self.velocity_field = vector_space.make_field() @@ -160,7 +152,7 @@ def __init__(self, quiet=False, resolution=50, degree=1): self.velocity_norm_field = scalar_space.make_field() fem.interpolate(velocity_norm, dest=self.velocity_norm_field, fields={"u": self.velocity_field}) - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() self.renderer.add_surface("u_norm", self.velocity_norm_field) def _velocity_delta(self, trial_velocity): diff --git a/warp/examples/fem/example_convection_diffusion.py b/warp/examples/fem/example_convection_diffusion.py index 11e2cda3e..14cf21315 100644 --- a/warp/examples/fem/example_convection_diffusion.py +++ b/warp/examples/fem/example_convection_diffusion.py @@ -15,19 +15,9 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .mesh_utils import gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from mesh_utils import gen_trimesh - from plot_utils import Plot - @fem.integrand def initial_condition(domain: fem.Domain, s: fem.Sample): @@ -94,8 +84,8 @@ def __init__(self, quiet=False, degree=2, resolution=50, tri_mesh=False, viscosi self.current_frame = 0 if tri_mesh: - positions, tri_vidx = gen_trimesh(res=wp.vec2i(res)) - geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(res)) + geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions, build_bvh=True) else: geo = fem.Grid2D(res=wp.vec2i(res)) @@ -116,7 +106,7 @@ def __init__(self, quiet=False, degree=2, resolution=50, tri_mesh=False, viscosi output_dtype=float, ) - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() self.renderer.add_surface("phi", self._phi_field) def step(self): @@ -131,7 +121,7 @@ def step(self): ) # Solve linear system - bsr_cg(self._matrix, x=self._phi_field.dof_values, b=rhs, quiet=self._quiet, tol=1.0e-12) + fem_example_utils.bsr_cg(self._matrix, x=self._phi_field.dof_values, b=rhs, quiet=self._quiet, tol=1.0e-12) def render(self): self.renderer.begin_frame(time=self.current_frame * self.sim_dt) diff --git a/warp/examples/fem/example_convection_diffusion_dg.py b/warp/examples/fem/example_convection_diffusion_dg.py index c8f78a026..6b3986bed 100644 --- a/warp/examples/fem/example_convection_diffusion_dg.py +++ b/warp/examples/fem/example_convection_diffusion_dg.py @@ -15,32 +15,16 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem +from warp.examples.fem.example_convection_diffusion import ( + diffusion_form, + inertia_form, + initial_condition, + velocity, +) from warp.sparse import bsr_axpy -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .example_convection_diffusion import ( - diffusion_form, - inertia_form, - initial_condition, - velocity, - ) - from .mesh_utils import gen_quadmesh, gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from example_convection_diffusion import ( - diffusion_form, - inertia_form, - initial_condition, - velocity, - ) - from mesh_utils import gen_quadmesh, gen_trimesh - from plot_utils import Plot - # Standard transport term, on cells' interior @fem.integrand @@ -87,10 +71,10 @@ def __init__(self, quiet=False, degree=2, resolution=50, mesh="grid", viscosity= self.current_frame = 0 if mesh == "tri": - positions, tri_vidx = gen_trimesh(res=wp.vec2i(resolution)) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(resolution)) geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) elif mesh == "quad": - positions, quad_vidx = gen_quadmesh(res=wp.vec2i(resolution)) + positions, quad_vidx = fem_example_utils.gen_quadmesh(res=wp.vec2i(resolution)) geo = fem.Quadmesh2D(quad_vertex_indices=quad_vidx, positions=positions) else: geo = fem.Grid2D(res=wp.vec2i(resolution)) @@ -153,7 +137,7 @@ def __init__(self, quiet=False, degree=2, resolution=50, mesh="grid", viscosity= self._phi_field = scalar_space.make_field() fem.interpolate(initial_condition, dest=self._phi_field) - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() self.renderer.add_surface("phi", self._phi_field) def step(self): @@ -166,7 +150,7 @@ def step(self): ) phi = wp.zeros_like(rhs) - bsr_cg(self._matrix, b=rhs, x=phi, method="bicgstab", quiet=self._quiet) + fem_example_utils.bsr_cg(self._matrix, b=rhs, x=phi, method="bicgstab", quiet=self._quiet) wp.utils.array_cast(in_array=phi, out_array=self._phi_field.dof_values) diff --git a/warp/examples/fem/example_deformed_geometry.py b/warp/examples/fem/example_deformed_geometry.py index 8d41a10d4..4cfd80662 100644 --- a/warp/examples/fem/example_deformed_geometry.py +++ b/warp/examples/fem/example_deformed_geometry.py @@ -8,7 +8,7 @@ ########################################################################### # Example Deformed Geometry # -# This example solves a 2d diffusion problem: +# This example solves a 2d diffusion problem on a deformed (curved) mesh: # # nu Div u = 1 # @@ -17,20 +17,9 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem - -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .example_diffusion import diffusion_form, linear_form - from .mesh_utils import gen_quadmesh, gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from example_diffusion import diffusion_form, linear_form - from mesh_utils import gen_quadmesh, gen_trimesh - from plot_utils import Plot +from warp.examples.fem.example_diffusion import diffusion_form, linear_form @fem.integrand @@ -80,10 +69,10 @@ def __init__( # Grid or triangle mesh geometry if mesh == "tri": - positions, tri_vidx = gen_trimesh(res=wp.vec2i(resolution)) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(resolution)) base_geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) elif mesh == "quad": - positions, quad_vidx = gen_quadmesh(res=wp.vec2i(resolution)) + positions, quad_vidx = fem_example_utils.gen_quadmesh(res=wp.vec2i(resolution)) base_geo = fem.Quadmesh2D(quad_vertex_indices=quad_vidx, positions=positions) else: base_geo = fem.Grid2D(res=wp.vec2i(resolution)) @@ -102,7 +91,7 @@ def __init__( # Scalar field over our function space self._scalar_field = self._scalar_space.make_field() - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): geo = self._geo @@ -128,7 +117,7 @@ def step(self): # Solve linear system using Conjugate Gradient x = wp.zeros_like(rhs) - bsr_cg(matrix, b=rhs, x=x, quiet=self._quiet, tol=1.0e-6) + fem_example_utils.bsr_cg(matrix, b=rhs, x=x, quiet=self._quiet, tol=1.0e-6) # Assign system result to our discrete field self._scalar_field.dof_values = x @@ -148,7 +137,7 @@ def render(self): parser.add_argument("--degree", type=int, default=2, help="Polynomial degree of shape functions.") parser.add_argument("--serendipity", action="store_true", default=False, help="Use Serendipity basis functions.") parser.add_argument("--viscosity", type=float, default=2.0, help="Fluid viscosity parameter.") - parser.add_argument("--mesh", choices=("grid", "tri", "quad"), default="grid", help="Mesh type") + parser.add_argument("--mesh", choices=("grid", "tri", "quad"), default="tri", help="Mesh type") parser.add_argument( "--headless", action="store_true", diff --git a/warp/examples/fem/example_diffusion.py b/warp/examples/fem/example_diffusion.py index 550429765..adcedc296 100644 --- a/warp/examples/fem/example_diffusion.py +++ b/warp/examples/fem/example_diffusion.py @@ -17,21 +17,11 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem from warp.fem.utils import array_axpy from warp.sparse import bsr_axpy -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .mesh_utils import gen_quadmesh, gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from mesh_utils import gen_quadmesh, gen_trimesh - from plot_utils import Plot - @fem.integrand def linear_form( @@ -92,10 +82,10 @@ def __init__( # Grid or triangle mesh geometry if mesh == "tri": - positions, tri_vidx = gen_trimesh(res=wp.vec2i(resolution)) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(resolution)) self._geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) elif mesh == "quad": - positions, quad_vidx = gen_quadmesh(res=wp.vec2i(resolution)) + positions, quad_vidx = fem_example_utils.gen_quadmesh(res=wp.vec2i(resolution)) self._geo = fem.Quadmesh2D(quad_vertex_indices=quad_vidx, positions=positions) else: self._geo = fem.Grid2D(res=wp.vec2i(resolution)) @@ -107,7 +97,7 @@ def __init__( # Scalar field over our function space self._scalar_field = self._scalar_space.make_field() - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): geo = self._geo @@ -145,7 +135,7 @@ def step(self): # Solve linear system using Conjugate Gradient x = wp.zeros_like(rhs) - bsr_cg(matrix, b=rhs, x=x, quiet=self._quiet) + fem_example_utils.bsr_cg(matrix, b=rhs, x=x, quiet=self._quiet) # Assign system result to our discrete field self._scalar_field.dof_values = x diff --git a/warp/examples/fem/example_diffusion_3d.py b/warp/examples/fem/example_diffusion_3d.py index 3e1dbea3b..110f65521 100644 --- a/warp/examples/fem/example_diffusion_3d.py +++ b/warp/examples/fem/example_diffusion_3d.py @@ -17,22 +17,11 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem +from warp.examples.fem.example_diffusion import diffusion_form, linear_form from warp.sparse import bsr_axpy -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .example_diffusion import diffusion_form, linear_form - from .mesh_utils import gen_hexmesh, gen_tetmesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from example_diffusion import diffusion_form, linear_form - from mesh_utils import gen_hexmesh, gen_tetmesh - from plot_utils import Plot - @fem.integrand def vert_boundary_projector_form( @@ -65,14 +54,14 @@ def __init__( res = wp.vec3i(resolution, resolution // 2, resolution * 2) if mesh == "tet": - pos, tet_vtx_indices = gen_tetmesh( + pos, tet_vtx_indices = fem_example_utils.gen_tetmesh( res=res, bounds_lo=wp.vec3(0.0, 0.0, 0.0), bounds_hi=wp.vec3(1.0, 0.5, 2.0), ) self._geo = fem.Tetmesh(tet_vtx_indices, pos) elif mesh == "hex": - pos, hex_vtx_indices = gen_hexmesh( + pos, hex_vtx_indices = fem_example_utils.gen_hexmesh( res=res, bounds_lo=wp.vec3(0.0, 0.0, 0.0), bounds_hi=wp.vec3(1.0, 0.5, 2.0), @@ -95,7 +84,7 @@ def __init__( # Scalar field over our function space self._scalar_field: fem.DiscreteField = self._scalar_space.make_field() - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): geo = self._geo @@ -129,7 +118,7 @@ def step(self): with wp.ScopedTimer("CG solve"): x = wp.zeros_like(rhs) - bsr_cg(matrix, b=rhs, x=x, quiet=self._quiet) + fem_example_utils.bsr_cg(matrix, b=rhs, x=x, quiet=self._quiet) self._scalar_field.dof_values = x def render(self): diff --git a/warp/examples/fem/example_diffusion_mgpu.py b/warp/examples/fem/example_diffusion_mgpu.py index d35906506..2e4b35fe7 100644 --- a/warp/examples/fem/example_diffusion_mgpu.py +++ b/warp/examples/fem/example_diffusion_mgpu.py @@ -15,21 +15,12 @@ from typing import Tuple import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem +from warp.examples.fem.example_diffusion import diffusion_form, linear_form from warp.sparse import bsr_axpy, bsr_mv from warp.utils import array_cast -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .example_diffusion import diffusion_form, linear_form - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from example_diffusion import diffusion_form, linear_form - from plot_utils import Plot - @fem.integrand def mass_form( @@ -115,7 +106,7 @@ def __init__(self, quiet=False, device=None): self._scalar_space = fem.make_polynomial_space(self._geo, degree=3) self._scalar_field = self._scalar_space.make_field() - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): devices = wp.get_cuda_devices() @@ -161,7 +152,9 @@ def step(self): A.rank_data = (matrices, rhs_vecs, res_vecs, indices) with wp.ScopedDevice(main_device): - bsr_cg(A, x=global_res, b=glob_rhs, use_diag_precond=False, quiet=self._quiet, mv_routine=A.mv_routine) + fem_example_utils.bsr_cg( + A, x=global_res, b=glob_rhs, use_diag_precond=False, quiet=self._quiet, mv_routine=A.mv_routine + ) array_cast(in_array=global_res, out_array=self._scalar_field.dof_values) diff --git a/warp/examples/fem/example_mixed_elasticity.py b/warp/examples/fem/example_mixed_elasticity.py index 1c37bc691..65ec1af72 100644 --- a/warp/examples/fem/example_mixed_elasticity.py +++ b/warp/examples/fem/example_mixed_elasticity.py @@ -8,77 +8,102 @@ ########################################################################### # Example Mixed Elasticity # -# This example illustrates using Mixed FEM to solve a -# 2D linear elasticity problem: +# This example illustrates using Mixed FEM to solve a nonlinear static elasticity equilibrium problem: # -# Div[ E: D(u) ] = 0 +# Div[ d/dF Psi(F(u)) ] = 0 # -# with Dirichlet boundary conditions on horizontal sides, -# and E the elasticity rank-4 tensor +# with Dirichlet boundary conditions on vertical sides, +# and Psi an elastic potential function of the deformation gradient (here Neo-Hookean) +# +# which we write as a sequence of Newton iterations: +# int {sigma : grad v} = 0 for all displacement test functions v +# int {sigma : tau} = int{dPsi/dF : tau} + int{grad du : d2 Psi/dF2 : tau} for all stress test functions tau ########################################################################### import numpy as np import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem -from warp.sparse import bsr_mm, bsr_transposed - -try: - from .bsr_utils import bsr_cg, invert_diagonal_bsr_mass_matrix - from .mesh_utils import gen_quadmesh, gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg, invert_diagonal_bsr_mass_matrix - from mesh_utils import gen_quadmesh, gen_trimesh - from plot_utils import Plot - - -@wp.func -def compute_stress(tau: wp.mat22, E: wp.mat33): - """Strain to stress computation""" - tau_sym = wp.vec3(tau[0, 0], tau[1, 1], tau[0, 1] + tau[1, 0]) - sig_sym = E * tau_sym - return wp.mat22(sig_sym[0], 0.5 * sig_sym[2], 0.5 * sig_sym[2], sig_sym[1]) +from warp.sparse import bsr_mm, bsr_mv, bsr_transposed @fem.integrand -def symmetric_grad_form( +def displacement_gradient_form( s: fem.Sample, u: fem.Field, tau: fem.Field, ): - """D(u) : tau""" - return wp.ddot(tau(s), fem.D(u, s)) + """grad(u) : tau""" + return wp.ddot(tau(s), fem.grad(u, s)) @fem.integrand -def stress_form(s: fem.Sample, u: fem.Field, tau: fem.Field, E: wp.mat33): - """(E : D(u)) : tau""" - return wp.ddot(tau(s), compute_stress(fem.D(u, s), E)) +def nh_stress_form(s: fem.Sample, tau: fem.Field, u_cur: fem.Field, lame: wp.vec2): + """d Psi/dF : tau""" + + F = wp.identity(n=2, dtype=float) + fem.grad(u_cur, s) + + J = wp.determinant(F) + mu_nh = 2.0 * lame[1] + lambda_nh = lame[0] + lame[1] + gamma = 1.0 + mu_nh / lambda_nh + + dJ_dS = wp.mat22(F[1, 1], -F[1, 0], -F[0, 1], F[0, 0]) + nh_stress = mu_nh * F + lambda_nh * (J - gamma) * dJ_dS + + return wp.ddot(tau(s), nh_stress) @fem.integrand -def horizontal_boundary_projector_form( +def nh_stress_delta_form(s: fem.Sample, tau: fem.Field, u: fem.Field, u_cur: fem.Field, lame: wp.vec2): + """grad(u) : d2 Psi/dF2 : tau""" + + tau_s = tau(s) + sigma_s = fem.grad(u, s) + + F = wp.identity(n=2, dtype=float) + fem.grad(u_cur, s) + + dJ_dF = wp.mat22(F[1, 1], -F[1, 0], -F[0, 1], F[0, 0]) + + mu_nh = 2.0 * lame[1] + lambda_nh = lame[0] + lame[1] + + dpsi_dpsi = mu_nh * wp.ddot(tau_s, sigma_s) + lambda_nh * wp.ddot(dJ_dF * tau_s, dJ_dF * sigma_s) + + # positive part of d2J_dS2 + gamma = 1.0 + mu_nh / lambda_nh + J = wp.determinant(F) + if J >= gamma: + d2J_dF_sig = wp.mat22(sigma_s[1, 1], 0.0, 0.0, sigma_s[0, 0]) + else: + d2J_dF_sig = wp.mat22(0.0, -sigma_s[1, 0], -sigma_s[0, 1], 0.0) + + return dpsi_dpsi + lambda_nh * (J - gamma) * wp.ddot(d2J_dF_sig, tau_s) + + +@fem.integrand +def vertical_boundary_projector_form( s: fem.Sample, domain: fem.Domain, u: fem.Field, v: fem.Field, ): - # non zero on horizontal boundary of domain only + # non zero on vertical boundary of domain only nor = fem.normal(domain, s) - return wp.dot(u(s), v(s)) * wp.abs(nor[1]) + return wp.dot(u(s), v(s)) * wp.abs(nor[0]) @fem.integrand -def horizontal_displacement_form( +def vertical_displacement_form( s: fem.Sample, domain: fem.Domain, v: fem.Field, displacement: float, ): - # opposed to normal on horizontal boundary of domain only + # opposed to normal on vertical boundary of domain only nor = fem.normal(domain, s) - return -wp.abs(nor[1]) * displacement * wp.dot(nor, v(s)) + return -wp.abs(nor[0]) * displacement * wp.dot(nor, v(s)) @fem.integrand @@ -98,7 +123,6 @@ def __init__( resolution=25, mesh="grid", displacement=0.1, - young_modulus=1.0, poisson_ratio=0.5, nonconforming_stresses=False, ): @@ -106,49 +130,45 @@ def __init__( self._displacement = displacement - # Grid or triangle mesh geometry + # Grid or mesh geometry if mesh == "tri": - positions, tri_vidx = gen_trimesh(res=wp.vec2i(resolution)) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(resolution)) self._geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) elif mesh == "quad": - positions, quad_vidx = gen_quadmesh(res=wp.vec2i(resolution)) + positions, quad_vidx = fem_example_utils.gen_quadmesh(res=wp.vec2i(resolution)) self._geo = fem.Quadmesh2D(quad_vertex_indices=quad_vidx, positions=positions) else: self._geo = fem.Grid2D(res=wp.vec2i(resolution)) - # Strain-stress matrix - young = young_modulus - poisson = poisson_ratio - self._elasticity_mat = wp.mat33( - young - / (1.0 - poisson * poisson) - * np.array( - [ - [1.0, poisson, 0.0], - [poisson, 1.0, 0.0], - [0.0, 0.0, (2.0 * (1.0 + poisson)) * (1.0 - poisson * poisson)], - ] - ) - ) + # Lame coefficients from Young modulus and Poisson ratio + self._lame = wp.vec2(1.0 / (1.0 + poisson_ratio) * np.array([poisson_ratio / (1.0 - poisson_ratio), 0.5])) - # Function spaces -- S_k for displacement, Q_{k-1}d for stress + # Function spaces -- S_k for displacement, Q_k or P_{k-1}d for stress self._u_space = fem.make_polynomial_space( self._geo, degree=degree, dtype=wp.vec2, element_basis=fem.ElementBasis.SERENDIPITY ) - # Store stress degrees of freedom as symmetric tensors (3 dof) rather than full 2x2 matrices - tau_basis = fem.ElementBasis.NONCONFORMING_POLYNOMIAL if nonconforming_stresses else fem.ElementBasis.LAGRANGE + if isinstance(self._geo.reference_cell(), fem.geometry.element.Triangle): + # triangle elements + tau_basis = fem.ElementBasis.NONCONFORMING_POLYNOMIAL + tau_degree = degree - 1 + else: + # square elements + tau_basis = fem.ElementBasis.LAGRANGE + tau_degree = degree + self._tau_space = fem.make_polynomial_space( self._geo, - degree=degree - 1, + degree=tau_degree, discontinuous=True, element_basis=tau_basis, - dof_mapper=fem.SymmetricTensorMapper(wp.mat22), + family=fem.Polynomial.GAUSS_LEGENDRE, + dtype=wp.mat22, ) self._u_field = self._u_space.make_field() - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): boundary = fem.BoundarySides(self._geo) @@ -158,14 +178,14 @@ def step(self): u_bd_test = fem.make_test(space=self._u_space, domain=boundary) u_bd_trial = fem.make_trial(space=self._u_space, domain=boundary) u_bd_rhs = fem.integrate( - horizontal_displacement_form, + vertical_displacement_form, fields={"v": u_bd_test}, values={"displacement": self._displacement}, nodal=True, output_dtype=wp.vec2d, ) u_bd_matrix = fem.integrate( - horizontal_boundary_projector_form, fields={"u": u_bd_trial, "v": u_bd_test}, nodal=True + vertical_boundary_projector_form, fields={"u": u_bd_trial, "v": u_bd_test}, nodal=True ) # Stress/velocity coupling @@ -173,27 +193,43 @@ def step(self): tau_test = fem.make_test(space=self._tau_space, domain=domain) tau_trial = fem.make_trial(space=self._tau_space, domain=domain) - sym_grad_matrix = fem.integrate(symmetric_grad_form, fields={"u": u_trial, "tau": tau_test}) - stress_matrix = fem.integrate( - stress_form, fields={"u": u_trial, "tau": tau_test}, values={"E": self._elasticity_mat} + gradient_matrix = bsr_transposed( + fem.integrate(displacement_gradient_form, fields={"u": u_trial, "tau": tau_test}) ) # Compute inverse of the (block-diagonal) tau mass matrix tau_inv_mass_matrix = fem.integrate(tensor_mass_form, fields={"sig": tau_trial, "tau": tau_test}, nodal=True) - invert_diagonal_bsr_mass_matrix(tau_inv_mass_matrix) + fem_example_utils.invert_diagonal_bsr_matrix(tau_inv_mass_matrix) + + # Newton iterations (without line-search for simplicity) + for newton_iteration in range(5): + stress_matrix = fem.integrate( + nh_stress_delta_form, + fields={"u_cur": self._u_field, "u": u_trial, "tau": tau_test}, + values={"lame": self._lame}, + ) + + stress_rhs = fem.integrate( + nh_stress_form, + fields={"u_cur": self._u_field, "tau": tau_test}, + values={"lame": self._lame}, + output_dtype=wp.vec(length=stress_matrix.block_shape[0], dtype=wp.float64), + ) - # Assemble system matrix - u_matrix = bsr_mm(bsr_transposed(sym_grad_matrix), bsr_mm(tau_inv_mass_matrix, stress_matrix)) + # Assemble system matrix + u_matrix = bsr_mm(gradient_matrix, bsr_mm(tau_inv_mass_matrix, stress_matrix)) - # Enforce boundary conditions - u_rhs = wp.zeros_like(u_bd_rhs) - fem.project_linear_system(u_matrix, u_rhs, u_bd_matrix, u_bd_rhs) + # Enforce boundary conditions (apply displacement only at first iteration) + u_rhs = bsr_mv(gradient_matrix, bsr_mv(tau_inv_mass_matrix, stress_rhs, alpha=-1.0)) + fem.project_linear_system(u_matrix, u_rhs, u_bd_matrix, u_bd_rhs if newton_iteration == 0 else None) - x = wp.zeros_like(u_rhs) - bsr_cg(u_matrix, b=u_rhs, x=x, tol=1.0e-16, quiet=self._quiet) + x = wp.zeros_like(u_rhs) + fem_example_utils.bsr_cg(u_matrix, b=u_rhs, x=x, quiet=self._quiet) - # Extract result - self._u_field.dof_values = x + # Extract result -- cast to float32 and accumulate to displacement field + delta_u = wp.empty_like(self._u_field.dof_values) + wp.utils.array_cast(in_array=x, out_array=delta_u) + fem.utils.array_axpy(x=delta_u, y=self._u_field.dof_values) def render(self): self.renderer.add_surface_vector("solution", self._u_field) @@ -208,8 +244,7 @@ def render(self): parser.add_argument("--device", type=str, default=None, help="Override the default Warp device.") parser.add_argument("--resolution", type=int, default=25, help="Grid resolution.") parser.add_argument("--degree", type=int, default=2, help="Polynomial degree of shape functions.") - parser.add_argument("--displacement", type=float, default=0.1) - parser.add_argument("--young_modulus", type=float, default=1.0) + parser.add_argument("--displacement", type=float, default=-0.5) parser.add_argument("--poisson_ratio", type=float, default=0.5) parser.add_argument("--mesh", choices=("grid", "tri", "quad"), default="grid", help="Mesh type") parser.add_argument( @@ -231,7 +266,6 @@ def render(self): resolution=args.resolution, mesh=args.mesh, displacement=args.displacement, - young_modulus=args.young_modulus, poisson_ratio=args.poisson_ratio, nonconforming_stresses=args.nonconforming_stresses, ) @@ -239,4 +273,4 @@ def render(self): example.render() if not args.headless: - example.renderer.plot() + example.renderer.plot(displacement="solution") diff --git a/warp/examples/fem/example_navier_stokes.py b/warp/examples/fem/example_navier_stokes.py index a305fa687..b4759a54b 100644 --- a/warp/examples/fem/example_navier_stokes.py +++ b/warp/examples/fem/example_navier_stokes.py @@ -18,19 +18,11 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem from warp.fem.utils import array_axpy from warp.sparse import bsr_copy, bsr_mm, bsr_mv -try: - from .bsr_utils import SaddleSystem, bsr_solve_saddle - from .mesh_utils import gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import SaddleSystem, bsr_solve_saddle - from mesh_utils import gen_trimesh - from plot_utils import Plot - @fem.integrand def u_boundary_value(s: fem.Sample, domain: fem.Domain, v: fem.Field, top_vel: float): @@ -100,8 +92,8 @@ def __init__(self, quiet=False, degree=2, resolution=25, Re=1000.0, top_velocity viscosity = top_velocity / Re if tri_mesh: - positions, tri_vidx = gen_trimesh(res=wp.vec2i(res)) - geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(res)) + geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions, build_bvh=True) else: geo = fem.Grid2D(res=wp.vec2i(res)) @@ -153,7 +145,7 @@ def __init__(self, quiet=False, degree=2, resolution=25, Re=1000.0, top_velocity bsr_mm(x=bsr_copy(div_matrix), y=u_bd_projector, z=div_matrix, alpha=-1.0, beta=1.0) # Assemble saddle system - self._saddle_system = SaddleSystem(u_matrix, div_matrix) + self._saddle_system = fem_example_utils.SaddleSystem(u_matrix, div_matrix) # Save data for computing time steps rhs self._u_bd_projector = u_bd_projector @@ -165,7 +157,7 @@ def __init__(self, quiet=False, degree=2, resolution=25, Re=1000.0, top_velocity self._u_field = u_space.make_field() self._p_field = p_space.make_field() - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() self.renderer.add_surface_vector("velocity", self._u_field) def step(self): @@ -190,7 +182,7 @@ def step(self): wp.utils.array_cast(out_array=x_u, in_array=self._u_field.dof_values) wp.utils.array_cast(out_array=x_p, in_array=self._p_field.dof_values) - bsr_solve_saddle( + fem_example_utils.bsr_solve_saddle( saddle_system=self._saddle_system, tol=1.0e-6, x_u=x_u, diff --git a/warp/examples/fem/example_stokes.py b/warp/examples/fem/example_stokes.py index ef86959b8..23495fc86 100644 --- a/warp/examples/fem/example_stokes.py +++ b/warp/examples/fem/example_stokes.py @@ -17,19 +17,11 @@ ########################################################################### import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem import warp.sparse as sparse from warp.fem.utils import array_axpy -try: - from .bsr_utils import SaddleSystem, bsr_solve_saddle - from .mesh_utils import gen_quadmesh, gen_trimesh - from .plot_utils import Plot -except ImportError: - from bsr_utils import SaddleSystem, bsr_solve_saddle - from mesh_utils import gen_quadmesh, gen_trimesh - from plot_utils import Plot - @fem.integrand def constant_form(val: wp.vec2): @@ -90,10 +82,10 @@ def __init__( # Grid or triangle mesh geometry if mesh == "tri": - positions, tri_vidx = gen_trimesh(res=wp.vec2i(resolution)) + positions, tri_vidx = fem_example_utils.gen_trimesh(res=wp.vec2i(resolution)) geo = fem.Trimesh2D(tri_vertex_indices=tri_vidx, positions=positions) elif mesh == "quad": - positions, quad_vidx = gen_quadmesh(res=wp.vec2i(resolution)) + positions, quad_vidx = fem_example_utils.gen_quadmesh(res=wp.vec2i(resolution)) geo = fem.Quadmesh2D(quad_vertex_indices=quad_vidx, positions=positions) else: geo = fem.Grid2D(res=wp.vec2i(resolution)) @@ -117,7 +109,7 @@ def __init__( top_velocity = wp.vec2(top_velocity, 0.0) fem.interpolate(constant_form, dest=f_boundary, values={"val": top_velocity}) - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): u_space = self._u_field.space @@ -158,8 +150,13 @@ def step(self): x_u = wp.zeros_like(u_rhs) x_p = wp.zeros_like(p_rhs) - bsr_solve_saddle( - SaddleSystem(A=u_matrix, B=div_matrix), x_u=x_u, x_p=x_p, b_u=u_rhs, b_p=p_rhs, quiet=self._quiet + fem_example_utils.bsr_solve_saddle( + fem_example_utils.SaddleSystem(A=u_matrix, B=div_matrix), + x_u=x_u, + x_p=x_p, + b_u=u_rhs, + b_p=p_rhs, + quiet=self._quiet, ) wp.utils.array_cast(in_array=x_u, out_array=self._u_field.dof_values) diff --git a/warp/examples/fem/example_stokes_transfer.py b/warp/examples/fem/example_stokes_transfer.py index 7d4eb592c..34aadc3da 100644 --- a/warp/examples/fem/example_stokes_transfer.py +++ b/warp/examples/fem/example_stokes_transfer.py @@ -19,20 +19,12 @@ import numpy as np import warp as wp +import warp.examples.fem.utils as fem_example_utils import warp.fem as fem from warp.fem.utils import array_axpy from warp.sparse import bsr_axpy, bsr_mm, bsr_mv, bsr_transposed from warp.utils import array_cast -# Import example utilities -# Make sure that works both when imported as module and run as standalone file -try: - from .bsr_utils import bsr_cg - from .plot_utils import Plot -except ImportError: - from bsr_utils import bsr_cg - from plot_utils import Plot - @fem.integrand def vel_from_particles_form(s: fem.Sample, particle_vel: wp.array(dtype=wp.vec2), v: fem.Field): @@ -141,7 +133,7 @@ def __init__(self, quiet=False, resolution=50): self._pic_quadrature = fem.PicQuadrature(domain, particles, particle_areas) self._particle_velocities = particle_velocities - self.renderer = Plot() + self.renderer = fem_example_utils.Plot() def step(self): u_space = self._u_field.space @@ -192,7 +184,7 @@ def step(self): # Solve for displacement u_res = wp.zeros_like(u_rhs) - bsr_cg(u_matrix, x=u_res, b=u_rhs, quiet=self._quiet) + fem_example_utils.bsr_cg(u_matrix, x=u_res, b=u_rhs, quiet=self._quiet) # Compute pressure from displacement div_u = bsr_mv(A=div_matrix, x=u_res) diff --git a/warp/examples/fem/example_streamlines.py b/warp/examples/fem/example_streamlines.py new file mode 100644 index 000000000..be6e9fc28 --- /dev/null +++ b/warp/examples/fem/example_streamlines.py @@ -0,0 +1,338 @@ +import numpy as np + +import warp as wp +import warp.examples.fem.utils as fem_example_utils +import warp.fem as fem +import warp.sparse as sp +from warp.examples.fem.example_apic_fluid import divergence_form, solve_incompressibility + + +@fem.integrand +def classify_boundary_sides( + s: fem.Sample, + domain: fem.Domain, + outflow: wp.array(dtype=int), + freeslip: wp.array(dtype=int), + inflow: wp.array(dtype=int), +): + x = fem.position(domain, s) + n = fem.normal(domain, s) + + if n[0] < -0.5: + # left side + inflow[s.qp_index] = 1 + elif n[0] > 0.5: + if x[1] > 0.33 or x[2] < 0.33: + # right side, top + freeslip[s.qp_index] = 1 + else: + # right side, bottom + outflow[s.qp_index] = 1 + else: + freeslip[s.qp_index] = 1 + + +@fem.integrand +def inflow_velocity( + s: fem.Sample, + domain: fem.Domain, +): + n = fem.normal(domain, s) + return -n + + +@fem.integrand +def noslip_projector_form( + s: fem.Sample, + u: fem.Field, + v: fem.Field, +): + return wp.dot(u(s), v(s)) + + +@fem.integrand +def freeslip_projector_form( + s: fem.Sample, + domain: fem.Domain, + u: fem.Field, + v: fem.Field, +): + n = fem.normal(domain, s) + return wp.dot(u(s), n) * wp.dot(n, v(s)) + + +@fem.integrand +def mass_form( + s: fem.Sample, + u: fem.Field, + v: fem.Field, +): + return u(s) * v(s) + + +@fem.integrand +def velocity_norm( + s: fem.Sample, + u: fem.Field, +): + return u(s)[0] # wp.length(u(s)) + + +@fem.integrand +def spawn_streamlines(s: fem.Sample, domain: fem.Domain, jitter: float): + rng = wp.rand_init(s.qp_index) + random_offset = wp.vec3(wp.randf(rng), wp.randf(rng), wp.randf(rng)) - wp.vec3(0.5) + + # remove jistter along normal + n = fem.normal(domain, s) + random_offset -= wp.dot(random_offset, n) * n + + return domain(s) + jitter * random_offset + + +@fem.integrand +def gen_streamlines( + s: fem.Sample, + domain: fem.Domain, + u: fem.Field, + point_count: int, + dx: float, + pos: wp.array2d(dtype=wp.vec3), + speed: wp.array2d(dtype=float), +): + idx = s.qp_index + + p = domain(s) + for k in range(point_count): + v = u(s) + pos[idx, k] = p + speed[idx, k] = wp.length(v) + + flow_dir = wp.normalize(v) + adv_p = p + flow_dir * dx + adv_s = fem.lookup(domain, adv_p, s) + + if adv_s.element_index != fem.NULL_ELEMENT_INDEX: + # if the lookup result position is different from adv_p, + # it means we have been projected back onto the domain; + # align back with flow and terminate streamline + new_p = domain(adv_s) + if wp.length_sq(new_p - adv_p) > 0.000001: + p = p + wp.dot(new_p - p, flow_dir) * flow_dir + s = fem.lookup(domain, p, s) + dx = 0.0 + else: + s = adv_s + p = new_p + + +class Example: + def __init__(self, quiet=False, degree=2, resolution=16, mesh="grid", headless: bool = False): + self._quiet = quiet + self._degree = degree + + self._streamline_dx = 0.5 / resolution + self._streamline_point_count = 4 * resolution + + res = wp.vec3i(resolution) + + if mesh == "tet": + pos, tet_vtx_indices = fem_example_utils.gen_tetmesh( + res=res, + ) + self._geo = fem.Tetmesh(tet_vtx_indices, pos, build_bvh=True) + elif mesh == "nano": + volume = wp.Volume.allocate(min=[0, 0, 0], max=[1.0, 1.0, 1.0], voxel_size=1.0 / resolution, bg_value=None) + self._geo = fem.Nanogrid(volume) + else: + self._geo = fem.Grid3D( + res=res, + ) + + # Mark sides with boundary conditions that shpuld apply + boundary = fem.BoundarySides(self._geo) + inflow_mask = wp.zeros(shape=boundary.element_count(), dtype=int) + freeslip_mask = wp.zeros(shape=boundary.element_count(), dtype=int) + outflow_mask = wp.zeros(shape=boundary.element_count(), dtype=int) + + fem.interpolate( + classify_boundary_sides, + quadrature=fem.RegularQuadrature(boundary, order=0), + values={"outflow": outflow_mask, "freeslip": freeslip_mask, "inflow": inflow_mask}, + ) + + self._inflow = fem.Subdomain(boundary, element_mask=inflow_mask) + self._freeslip = fem.Subdomain(boundary, element_mask=freeslip_mask) + self._outflow = fem.Subdomain(boundary, element_mask=outflow_mask) + + self.plot = fem_example_utils.Plot() + + self.renderer = None + if not headless: + try: + self.renderer = wp.render.OpenGLRenderer( + camera_pos=(2.0, 0.5, 3.0), + camera_front=(-0.66, 0.0, -1.0), + draw_axis=False, + ) + except Exception: + pass + + def step(self): + self._generate_incompressible_flow() + + # first generate spawn points for the streamlines + # we do this by regularly sampling the inflow boundary with a small amount of jitter + streamline_spawn = fem.RegularQuadrature( + domain=self._inflow, order=self._degree, family=fem.Polynomial.GAUSS_LEGENDRE + ) + n_streamlines = streamline_spawn.total_point_count() + spawn_points = wp.array(dtype=wp.vec3, shape=n_streamlines) + + jitter_amount = self._streamline_dx / self._degree + fem.interpolate( + spawn_streamlines, dest=spawn_points, quadrature=streamline_spawn, values={"jitter": jitter_amount} + ) + + # now forward-trace the velocity field to generate the streamlines + # here we use a fixed number of points per streamline, otherwise we would need to + # do a first pass to count points, then array_scan the offsets, then a second pass + # to populate the per-point data + spawn_qp = fem.PicQuadrature(fem.Cells(self._geo), positions=spawn_points) + + point_count = self._streamline_point_count + points = wp.array(dtype=wp.vec3, shape=(n_streamlines, point_count)) + speed = wp.array(dtype=float, shape=(n_streamlines, point_count)) + + fem.interpolate( + gen_streamlines, + quadrature=spawn_qp, + fields={"u": self.velocity_field}, + values={ + "point_count": self._streamline_point_count, + "dx": self._streamline_dx, + "pos": points, + "speed": speed, + }, + ) + + self._points = points + self._speed = speed + + def render(self): + # self.renderer.add_volume("solution", self.pressure_field) + self.plot.add_volume("pressure", self.pressure_field) + self.plot.add_volume("vel_norm", self.velocity_norm_field) + + if self.renderer is not None: + streamline_count = self._points.shape[0] + point_count = self._streamline_point_count + + vertices = self._points.flatten().numpy() + + line_offsets = np.arange(streamline_count) * point_count + indices_beg = np.arange(point_count - 1)[np.newaxis, :] + line_offsets[:, np.newaxis] + indices_end = indices_beg + 1 + indices = np.vstack((indices_beg.flatten(), indices_end.flatten())).T.flatten() + + colors = self._speed.numpy()[:, :-1].flatten() + colors = [wp.render.bourke_color_map(0.0, 3.0, c) for c in colors] + + self.renderer.begin_frame(0) + self.renderer.render_line_list("streamlines", vertices, indices) + self.renderer.render_line_list("streamlines", vertices, indices, colors) + + self.renderer.end_frame() + + def _generate_incompressible_flow(self): + # Funtion spaces for velocity, scalars and pressure (Pk / Pk / Pk-1) + u_space = fem.make_polynomial_space(geo=self._geo, degree=self._degree, dtype=wp.vec3) + s_space = fem.make_polynomial_space(geo=self._geo, degree=self._degree, dtype=float) + p_space = fem.make_polynomial_space(geo=self._geo, degree=self._degree - 1, dtype=float) + + self.pressure_field = p_space.make_field() + self.velocity_field = u_space.make_field() + self.velocity_norm_field = s_space.make_field() + + # Boundary condition projector and matrices + inflow_test = fem.make_test(u_space, domain=self._inflow) + inflow_trial = fem.make_trial(u_space, domain=self._inflow) + dirichlet_projector = fem.integrate( + noslip_projector_form, fields={"u": inflow_test, "v": inflow_trial}, nodal=True, output_dtype=float + ) + + freeslip_test = fem.make_test(u_space, domain=self._freeslip) + freeslip_trial = fem.make_trial(u_space, domain=self._freeslip) + sp.bsr_axpy( + y=dirichlet_projector, + x=fem.integrate( + freeslip_projector_form, + fields={"u": freeslip_test, "v": freeslip_trial}, + nodal=True, + output_dtype=float, + ), + ) + fem.normalize_dirichlet_projector(dirichlet_projector) + + # Initialize velocity field with BC + fem.interpolate(inflow_velocity, dest=fem.make_restriction(self.velocity_field, domain=self._inflow)) + + # (Diagonal) mass matrix + rho_test = fem.make_test(s_space) + rho_trial = fem.make_trial(s_space) + inv_mass_matrix = fem.integrate( + mass_form, fields={"u": rho_trial, "v": rho_test}, nodal=True, output_dtype=float + ) + fem_example_utils.invert_diagonal_bsr_matrix(inv_mass_matrix) + + # Assemble divergence operator matrix + p_test = fem.make_test(p_space) + u_trial = fem.make_trial(u_space) + divergence_matrix = fem.integrate( + divergence_form, + fields={"u": u_trial, "psi": p_test}, + output_dtype=float, + ) + + # Solve unilateral incompressibility + solve_incompressibility( + divergence_matrix, + dirichlet_projector, + inv_mass_matrix.values, + self.pressure_field.dof_values, + self.velocity_field.dof_values, + quiet=self._quiet, + ) + + fem.interpolate(velocity_norm, dest=self.velocity_norm_field, fields={"u": self.velocity_field}) + + +if __name__ == "__main__": + import argparse + + wp.set_module_options({"enable_backward": False}) + + parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument("--device", type=str, default=None, help="Override the default Warp device.") + parser.add_argument("--resolution", type=int, default=8, help="Grid resolution.") + parser.add_argument("--degree", type=int, default=2, help="Polynomial degree of shape functions.") + parser.add_argument("--mesh", choices=("grid", "tet", "hex", "nano"), default="grid", help="Mesh type.") + parser.add_argument( + "--headless", + action="store_true", + help="Run in headless mode, suppressing the opening of any graphical windows.", + ) + parser.add_argument("--quiet", action="store_true", help="Suppresses the printing out of iteration residuals.") + + args = parser.parse_known_args()[0] + + with wp.ScopedDevice(args.device): + example = Example( + quiet=args.quiet, degree=args.degree, resolution=args.resolution, mesh=args.mesh, headless=args.headless + ) + + example.step() + example.render() + + if not args.headless: + example.plot.plot() diff --git a/warp/examples/fem/mesh_utils.py b/warp/examples/fem/mesh_utils.py deleted file mode 100644 index 0f3adda42..000000000 --- a/warp/examples/fem/mesh_utils.py +++ /dev/null @@ -1,133 +0,0 @@ -from typing import Optional - -import numpy as np - -import warp as wp -from warp.fem.utils import grid_to_hexes, grid_to_quads, grid_to_tets, grid_to_tris - - -def gen_trimesh(res, bounds_lo: Optional[wp.vec2] = None, bounds_hi: Optional[wp.vec2] = None): - """Constructs a triangular mesh by diving each cell of a dense 2D grid into two triangles - - Args: - res: Resolution of the grid along each dimension - bounds_lo: Position of the lower bound of the axis-aligned grid - bounds_up: Position of the upper bound of the axis-aligned grid - - Returns: - Tuple of ndarrays: (Vertex positions, Triangle vertex indices) - """ - - if bounds_lo is None: - bounds_lo = wp.vec2(0.0) - - if bounds_hi is None: - bounds_hi = wp.vec2(1.0) - - Nx = res[0] - Ny = res[1] - - x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) - y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) - - positions = np.transpose(np.meshgrid(x, y, indexing="ij"), axes=(1, 2, 0)).reshape(-1, 2) - - vidx = grid_to_tris(Nx, Ny) - - return wp.array(positions, dtype=wp.vec2), wp.array(vidx, dtype=int) - - -def gen_tetmesh(res, bounds_lo: Optional[wp.vec3] = None, bounds_hi: Optional[wp.vec3] = None): - """Constructs a tetrahedral mesh by diving each cell of a dense 3D grid into five tetrahedrons - - Args: - res: Resolution of the grid along each dimension - bounds_lo: Position of the lower bound of the axis-aligned grid - bounds_up: Position of the upper bound of the axis-aligned grid - - Returns: - Tuple of ndarrays: (Vertex positions, Tetrahedron vertex indices) - """ - - if bounds_lo is None: - bounds_lo = wp.vec3(0.0) - - if bounds_hi is None: - bounds_hi = wp.vec3(1.0) - - Nx = res[0] - Ny = res[1] - Nz = res[2] - - x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) - y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) - z = np.linspace(bounds_lo[2], bounds_hi[2], Nz + 1) - - positions = np.transpose(np.meshgrid(x, y, z, indexing="ij"), axes=(1, 2, 3, 0)).reshape(-1, 3) - - vidx = grid_to_tets(Nx, Ny, Nz) - - return wp.array(positions, dtype=wp.vec3), wp.array(vidx, dtype=int) - - -def gen_quadmesh(res, bounds_lo: Optional[wp.vec2] = None, bounds_hi: Optional[wp.vec2] = None): - """Constructs a quadrilateral mesh from a dense 2D grid - - Args: - res: Resolution of the grid along each dimension - bounds_lo: Position of the lower bound of the axis-aligned grid - bounds_up: Position of the upper bound of the axis-aligned grid - - Returns: - Tuple of ndarrays: (Vertex positions, Triangle vertex indices) - """ - if bounds_lo is None: - bounds_lo = wp.vec2(0.0) - - if bounds_hi is None: - bounds_hi = wp.vec2(1.0) - - Nx = res[0] - Ny = res[1] - - x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) - y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) - - positions = np.transpose(np.meshgrid(x, y, indexing="ij"), axes=(1, 2, 0)).reshape(-1, 2) - - vidx = grid_to_quads(Nx, Ny) - - return wp.array(positions, dtype=wp.vec2), wp.array(vidx, dtype=int) - - -def gen_hexmesh(res, bounds_lo: Optional[wp.vec3] = None, bounds_hi: Optional[wp.vec3] = None): - """Constructs a quadrilateral mesh from a dense 2D grid - - Args: - res: Resolution of the grid along each dimension - bounds_lo: Position of the lower bound of the axis-aligned grid - bounds_up: Position of the upper bound of the axis-aligned grid - - Returns: - Tuple of ndarrays: (Vertex positions, Triangle vertex indices) - """ - - if bounds_lo is None: - bounds_lo = wp.vec3(0.0) - - if bounds_hi is None: - bounds_hi = wp.vec3(1.0) - - Nx = res[0] - Ny = res[1] - Nz = res[2] - - x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) - y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) - z = np.linspace(bounds_lo[1], bounds_hi[1], Nz + 1) - - positions = np.transpose(np.meshgrid(x, y, z, indexing="ij"), axes=(1, 2, 3, 0)).reshape(-1, 3) - - vidx = grid_to_hexes(Nx, Ny, Nz) - - return wp.array(positions, dtype=wp.vec3), wp.array(vidx, dtype=int) diff --git a/warp/examples/fem/plot_utils.py b/warp/examples/fem/plot_utils.py deleted file mode 100644 index 8f0e1b924..000000000 --- a/warp/examples/fem/plot_utils.py +++ /dev/null @@ -1,292 +0,0 @@ -from typing import Set - -import numpy as np - -from warp.fem import DiscreteField - - -def plot_grid_surface(field, axes=None): - import matplotlib.pyplot as plt - from matplotlib import cm - - if axes is None: - fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) - - node_positions = field.space.node_grid() - - # Make data. - X = node_positions[0] - Y = node_positions[1] - Z = field.dof_values.numpy().reshape(X.shape) - - # Plot the surface. - return axes.plot_surface(X, Y, Z, cmap=cm.coolwarm, linewidth=0, antialiased=False) - - -def plot_tri_surface(field, axes=None): - import matplotlib.pyplot as plt - from matplotlib import cm - from matplotlib.tri.triangulation import Triangulation - - if axes is None: - fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) - - node_positions = field.space.node_positions().numpy() - - triangulation = Triangulation( - x=node_positions[:, 0], y=node_positions[:, 1], triangles=field.space.node_triangulation() - ) - - Z = field.dof_values.numpy() - - # Plot the surface. - return axes.plot_trisurf(triangulation, Z, cmap=cm.coolwarm, linewidth=0, antialiased=False) - - -def plot_scatter_surface(field, axes=None): - import matplotlib.pyplot as plt - from matplotlib import cm - - if axes is None: - fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) - - X, Y = field.space.node_positions().numpy().T - - # Make data. - Z = field.dof_values.numpy().reshape(X.shape) - - # Plot the surface. - return axes.scatter(X, Y, Z, c=Z, cmap=cm.coolwarm) - - -def plot_surface(field, axes=None): - if hasattr(field.space, "node_grid"): - return plot_grid_surface(field, axes) - elif hasattr(field.space, "node_triangulation"): - return plot_tri_surface(field, axes) - else: - return plot_scatter_surface(field, axes) - - -def plot_grid_color(field, axes=None): - import matplotlib.pyplot as plt - from matplotlib import cm - - if axes is None: - fig, axes = plt.subplots() - - node_positions = field.space.node_grid() - - # Make data. - X = node_positions[0] - Y = node_positions[1] - Z = field.dof_values.numpy().reshape(X.shape) - - # Plot the surface. - return axes.pcolormesh(X, Y, Z, cmap=cm.coolwarm) - - -def plot_velocities(field, axes=None): - import matplotlib.pyplot as plt - - if axes is None: - fig, axes = plt.subplots() - - node_positions = field.space.node_positions().numpy() - - # Make data. - X = node_positions[:, 0] - Y = node_positions[:, 1] - - vel = field.dof_values.numpy() - u = np.ascontiguousarray(vel[:, 0]) - v = np.ascontiguousarray(vel[:, 1]) - - u = u.reshape(X.shape) - v = v.reshape(X.shape) - - return axes.quiver(X, Y, u, v) - - -def plot_grid_streamlines(field, axes=None): - import matplotlib.pyplot as plt - - if axes is None: - fig, axes = plt.subplots() - - node_positions = field.space.node_grid() - - # Make data. - X = node_positions[0][:, 0] - Y = node_positions[1][0, :] - - vel = field.dof_values.numpy() - u = np.ascontiguousarray(vel[:, 0]) - v = np.ascontiguousarray(vel[:, 1]) - - u = np.transpose(u.reshape(node_positions[0].shape)) - v = np.transpose(v.reshape(node_positions[0].shape)) - - splot = axes.streamplot(X, Y, u, v, density=2) - splot.axes = axes - return splot - - -def plot_3d_scatter(field, axes=None): - import matplotlib.pyplot as plt - from matplotlib import cm - - if axes is None: - fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) - - X, Y, Z = field.space.node_positions().numpy().T - - # Make data. - f = field.dof_values.numpy().reshape(X.shape) - - # Plot the surface. - return axes.scatter(X, Y, Z, c=f, cmap=cm.coolwarm) - - -def plot_3d_velocities(field, axes=None): - import matplotlib.pyplot as plt - - if axes is None: - fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) - - X, Y, Z = field.space.node_positions().numpy().T - - vel = field.dof_values.numpy() - u = np.ascontiguousarray(vel[:, 0]) - v = np.ascontiguousarray(vel[:, 1]) - w = np.ascontiguousarray(vel[:, 2]) - - u = u.reshape(X.shape) - v = v.reshape(X.shape) - w = w.reshape(X.shape) - - return axes.quiver(X, Y, Z, u, v, w, length=1.0 / X.shape[0], normalize=False) - - -class Plot: - def __init__(self, stage=None, default_point_radius=0.01): - self.default_point_radius = default_point_radius - - self._surfaces = {} - self._surface_vectors = {} - self._volumes = {} - - self._usd_renderer = None - if stage is not None: - try: - from warp.render import UsdRenderer - - self._usd_renderer = UsdRenderer(stage) - except Exception as err: - print(f"Could not initialize UsdRenderer for stage '{stage}': {err}.") - - def begin_frame(self, time): - if self._usd_renderer is not None: - self._usd_renderer.begin_frame(time=time) - - def end_frame(self): - if self._usd_renderer is not None: - self._usd_renderer.end_frame() - - def add_surface(self, name: str, field: DiscreteField): - if self._usd_renderer is not None: - points_2d = field.space.node_positions().numpy() - values = field.dof_values.numpy() - points_3d = np.hstack((points_2d, values.reshape(-1, 1))) - - if hasattr(field.space, "node_triangulation"): - indices = field.space.node_triangulation() - self._usd_renderer.render_mesh(name, points=points_3d, indices=indices) - else: - self._usd_renderer.render_points(name, points=points_3d, radius=self.default_point_radius) - - if name not in self._surfaces: - field_clone = field.space.make_field(space_partition=field.space_partition) - self._surfaces[name] = (field_clone, []) - - self._surfaces[name][1].append(field.dof_values.numpy()) - - def add_surface_vector(self, name: str, field: DiscreteField): - if self._usd_renderer is not None: - points_2d = field.space.node_positions().numpy() - values = field.dof_values.numpy() - points_3d = np.hstack((points_2d + values, np.zeros_like(points_2d[:, 0]).reshape(-1, 1))) - - if hasattr(field.space, "node_triangulation"): - indices = field.space.node_triangulation() - self._usd_renderer.render_mesh(name, points=points_3d, indices=indices) - else: - self._usd_renderer.render_points(name, points=points_3d, radius=self.default_point_radius) - - if name not in self._surface_vectors: - field_clone = field.space.make_field(space_partition=field.space_partition) - self._surface_vectors[name] = (field_clone, []) - - self._surface_vectors[name][1].append(field.dof_values.numpy()) - - def add_volume(self, name: str, field: DiscreteField): - if self._usd_renderer is not None: - points_3d = field.space.node_positions().numpy() - values = field.dof_values.numpy() - - self._usd_renderer.render_points(name, points_3d, radius=values) - - if name not in self._volumes: - field_clone = field.space.make_field(space_partition=field.space_partition) - self._volumes[name] = (field_clone, []) - - self._volumes[name][1].append(field.dof_values.numpy()) - - def plot(self, streamlines: Set[str] = None): - if streamlines is None: - streamlines = [] - return self._plot_matplotlib(streamlines) - - def _plot_matplotlib(self, streamlines: Set[str] = None): - import matplotlib.animation as animation - import matplotlib.pyplot as plt - - if streamlines is None: - streamlines = [] - - def make_animation(ax, field, values, plot_func, num_frames: int): - def animate(i): - ax.clear() - field.dof_values = values[i] - return plot_func(field, axes=ax) - - return animation.FuncAnimation( - ax.figure, - animate, - interval=30, - blit=False, - frames=len(values), - ) - - for _name, (field, values) in self._surfaces.items(): - field.dof_values = values[0] - ax = plot_surface(field).axes - - if len(values) > 1: - _anim = make_animation(ax, field, values, plot_func=plot_surface, num_frames=len(values)) - - for name, (field, values) in self._surface_vectors.items(): - field.dof_values = values[0] - if name in streamlines and hasattr(field.space, "node_grid"): - ax = plot_grid_streamlines(field).axes - else: - ax = plot_velocities(field).axes - - if len(values) > 1: - _anim = make_animation(ax, field, values, plot_func=plot_velocities, num_frames=len(values)) - - for _name, (field, values) in self._volumes.items(): - field.dof_values = values[0] - ax = plot_3d_scatter(field).axes - - plt.show() diff --git a/warp/examples/fem/utils.py b/warp/examples/fem/utils.py new file mode 100644 index 000000000..6331c795f --- /dev/null +++ b/warp/examples/fem/utils.py @@ -0,0 +1,777 @@ +from typing import Any, Optional, Set, Tuple + +import numpy as np + +import warp as wp +import warp.fem as fem +from warp.optim.linear import LinearOperator, aslinearoperator, preconditioner +from warp.sparse import BsrMatrix, bsr_get_diag, bsr_mv, bsr_transposed + +__all__ = [ + "gen_hexmesh", + "gen_quadmesh", + "gen_tetmesh", + "gen_trimesh", + "bsr_cg", + "bsr_solve_saddle", + "SaddleSystem", + "invert_diagonal_bsr_matrix", + "Plot", +] + + +# +# Mesh utilities +# + + +def gen_trimesh(res, bounds_lo: Optional[wp.vec2] = None, bounds_hi: Optional[wp.vec2] = None): + """Constructs a triangular mesh by diving each cell of a dense 2D grid into two triangles + + Args: + res: Resolution of the grid along each dimension + bounds_lo: Position of the lower bound of the axis-aligned grid + bounds_up: Position of the upper bound of the axis-aligned grid + + Returns: + Tuple of ndarrays: (Vertex positions, Triangle vertex indices) + """ + + if bounds_lo is None: + bounds_lo = wp.vec2(0.0) + + if bounds_hi is None: + bounds_hi = wp.vec2(1.0) + + Nx = res[0] + Ny = res[1] + + x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) + y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) + + positions = np.transpose(np.meshgrid(x, y, indexing="ij"), axes=(1, 2, 0)).reshape(-1, 2) + + vidx = fem.utils.grid_to_tris(Nx, Ny) + + return wp.array(positions, dtype=wp.vec2), wp.array(vidx, dtype=int) + + +def gen_tetmesh(res, bounds_lo: Optional[wp.vec3] = None, bounds_hi: Optional[wp.vec3] = None): + """Constructs a tetrahedral mesh by diving each cell of a dense 3D grid into five tetrahedrons + + Args: + res: Resolution of the grid along each dimension + bounds_lo: Position of the lower bound of the axis-aligned grid + bounds_up: Position of the upper bound of the axis-aligned grid + + Returns: + Tuple of ndarrays: (Vertex positions, Tetrahedron vertex indices) + """ + + if bounds_lo is None: + bounds_lo = wp.vec3(0.0) + + if bounds_hi is None: + bounds_hi = wp.vec3(1.0) + + Nx = res[0] + Ny = res[1] + Nz = res[2] + + x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) + y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) + z = np.linspace(bounds_lo[2], bounds_hi[2], Nz + 1) + + positions = np.transpose(np.meshgrid(x, y, z, indexing="ij"), axes=(1, 2, 3, 0)).reshape(-1, 3) + + vidx = fem.utils.grid_to_tets(Nx, Ny, Nz) + + return wp.array(positions, dtype=wp.vec3), wp.array(vidx, dtype=int) + + +def gen_quadmesh(res, bounds_lo: Optional[wp.vec2] = None, bounds_hi: Optional[wp.vec2] = None): + """Constructs a quadrilateral mesh from a dense 2D grid + + Args: + res: Resolution of the grid along each dimension + bounds_lo: Position of the lower bound of the axis-aligned grid + bounds_up: Position of the upper bound of the axis-aligned grid + + Returns: + Tuple of ndarrays: (Vertex positions, Triangle vertex indices) + """ + if bounds_lo is None: + bounds_lo = wp.vec2(0.0) + + if bounds_hi is None: + bounds_hi = wp.vec2(1.0) + + Nx = res[0] + Ny = res[1] + + x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) + y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) + + positions = np.transpose(np.meshgrid(x, y, indexing="ij"), axes=(1, 2, 0)).reshape(-1, 2) + + vidx = fem.utils.grid_to_quads(Nx, Ny) + + return wp.array(positions, dtype=wp.vec2), wp.array(vidx, dtype=int) + + +def gen_hexmesh(res, bounds_lo: Optional[wp.vec3] = None, bounds_hi: Optional[wp.vec3] = None): + """Constructs a quadrilateral mesh from a dense 2D grid + + Args: + res: Resolution of the grid along each dimension + bounds_lo: Position of the lower bound of the axis-aligned grid + bounds_up: Position of the upper bound of the axis-aligned grid + + Returns: + Tuple of ndarrays: (Vertex positions, Triangle vertex indices) + """ + + if bounds_lo is None: + bounds_lo = wp.vec3(0.0) + + if bounds_hi is None: + bounds_hi = wp.vec3(1.0) + + Nx = res[0] + Ny = res[1] + Nz = res[2] + + x = np.linspace(bounds_lo[0], bounds_hi[0], Nx + 1) + y = np.linspace(bounds_lo[1], bounds_hi[1], Ny + 1) + z = np.linspace(bounds_lo[1], bounds_hi[1], Nz + 1) + + positions = np.transpose(np.meshgrid(x, y, z, indexing="ij"), axes=(1, 2, 3, 0)).reshape(-1, 3) + + vidx = fem.utils.grid_to_hexes(Nx, Ny, Nz) + + return wp.array(positions, dtype=wp.vec3), wp.array(vidx, dtype=int) + + +# +# Bsr matrix utilities +# + + +def _get_linear_solver_func(method_name: str): + from warp.optim.linear import bicgstab, cg, cr, gmres + + if method_name == "bicgstab": + return bicgstab + if method_name == "gmres": + return gmres + if method_name == "cr": + return cr + return cg + + +def bsr_cg( + A: BsrMatrix, + x: wp.array, + b: wp.array, + max_iters: int = 0, + tol: float = 0.0001, + check_every=10, + use_diag_precond=True, + mv_routine=None, + quiet=False, + method: str = "cg", +) -> Tuple[float, int]: + """Solves the linear system A x = b using an iterative solver, optionally with diagonal preconditioning + + Args: + A: system left-hand side + x: result vector and initial guess + b: system right-hand-side + max_iters: maximum number of iterations to perform before aborting. If set to zero, equal to the system size. + tol: relative tolerance under which to stop the solve + check_every: number of iterations every which to evaluate the current residual norm to compare against tolerance + use_diag_precond: Whether to use diagonal preconditioning + mv_routine: Matrix-vector multiplication routine to use for multiplications with ``A`` + quiet: if True, do not print iteration residuals + method: Iterative solver method to use, defaults to Conjugate Gradient + + Returns: + Tuple (residual norm, iteration count) + + """ + + if mv_routine is None: + M = preconditioner(A, "diag") if use_diag_precond else None + else: + A = LinearOperator(A.shape, A.dtype, A.device, matvec=mv_routine) + M = None + + func = _get_linear_solver_func(method_name=method) + + def print_callback(i, err, tol): + print(f"{func.__name__}: at iteration {i} error = \t {err} \t tol: {tol}") + + callback = None if quiet else print_callback + + end_iter, err, atol = func( + A=A, + b=b, + x=x, + maxiter=max_iters, + tol=tol, + check_every=check_every, + M=M, + callback=callback, + ) + + if not quiet: + res_str = "OK" if err <= atol else "TRUNCATED" + print(f"{func.__name__}: terminated after {end_iter} iterations with error = \t {err} ({res_str})") + + return err, end_iter + + +class SaddleSystem(LinearOperator): + """Builds a linear operator corresponding to the saddle-point linear system [A B^T; B 0] + + If use_diag_precond` is ``True``, builds the corresponding diagonal preconditioner `[diag(A); diag(B diag(A)^-1 B^T)]` + """ + + def __init__( + self, + A: BsrMatrix, + B: BsrMatrix, + Bt: Optional[BsrMatrix] = None, + use_diag_precond: bool = True, + ): + if Bt is None: + Bt = bsr_transposed(B) + + self._A = A + self._B = B + self._Bt = Bt + + self._u_dtype = wp.vec(length=A.block_shape[0], dtype=A.scalar_type) + self._p_dtype = wp.vec(length=B.block_shape[0], dtype=B.scalar_type) + self._p_byte_offset = A.nrow * wp.types.type_size_in_bytes(self._u_dtype) + + saddle_shape = (A.shape[0] + B.shape[0], A.shape[0] + B.shape[0]) + + super().__init__(saddle_shape, dtype=A.scalar_type, device=A.device, matvec=self._saddle_mv) + + if use_diag_precond: + self._preconditioner = self._diag_preconditioner() + else: + self._preconditioner = None + + def _diag_preconditioner(self): + A = self._A + B = self._B + + M_u = preconditioner(A, "diag") + + A_diag = bsr_get_diag(A) + + schur_block_shape = (B.block_shape[0], B.block_shape[0]) + schur_dtype = wp.mat(shape=schur_block_shape, dtype=B.scalar_type) + schur_inv_diag = wp.empty(dtype=schur_dtype, shape=B.nrow, device=self.device) + wp.launch( + _compute_schur_inverse_diagonal, + dim=B.nrow, + device=A.device, + inputs=[B.offsets, B.columns, B.values, A_diag, schur_inv_diag], + ) + + if schur_block_shape == (1, 1): + # Downcast 1x1 mats to scalars + schur_inv_diag = schur_inv_diag.view(dtype=B.scalar_type) + + M_p = aslinearoperator(schur_inv_diag) + + def precond_mv(x, y, z, alpha, beta): + x_u = self.u_slice(x) + x_p = self.p_slice(x) + y_u = self.u_slice(y) + y_p = self.p_slice(y) + z_u = self.u_slice(z) + z_p = self.p_slice(z) + + M_u.matvec(x_u, y_u, z_u, alpha=alpha, beta=beta) + M_p.matvec(x_p, y_p, z_p, alpha=alpha, beta=beta) + + return LinearOperator( + shape=self.shape, + dtype=self.dtype, + device=self.device, + matvec=precond_mv, + ) + + @property + def preconditioner(self): + return self._preconditioner + + def u_slice(self, a: wp.array): + return wp.array( + ptr=a.ptr, + dtype=self._u_dtype, + shape=self._A.nrow, + strides=None, + device=a.device, + pinned=a.pinned, + copy=False, + ) + + def p_slice(self, a: wp.array): + return wp.array( + ptr=a.ptr + self._p_byte_offset, + dtype=self._p_dtype, + shape=self._B.nrow, + strides=None, + device=a.device, + pinned=a.pinned, + copy=False, + ) + + def _saddle_mv(self, x, y, z, alpha, beta): + x_u = self.u_slice(x) + x_p = self.p_slice(x) + z_u = self.u_slice(z) + z_p = self.p_slice(z) + + if y.ptr != z.ptr and beta != 0.0: + wp.copy(src=y, dest=z) + + bsr_mv(self._A, x_u, z_u, alpha=alpha, beta=beta) + bsr_mv(self._Bt, x_p, z_u, alpha=alpha, beta=1.0) + bsr_mv(self._B, x_u, z_p, alpha=alpha, beta=beta) + + +def bsr_solve_saddle( + saddle_system: SaddleSystem, + x_u: wp.array, + x_p: wp.array, + b_u: wp.array, + b_p: wp.array, + max_iters: int = 0, + tol: float = 0.0001, + check_every=10, + quiet=False, + method: str = "cg", +) -> Tuple[float, int]: + """Solves the saddle-point linear system [A B^T; B 0] (x_u; x_p) = (b_u; b_p) using an iterative solver, optionally with diagonal preconditioning + + Args: + saddle_system: Saddle point system + x_u: primal part of the result vector and initial guess + x_p: Lagrange multiplier part of the result vector and initial guess + b_u: primal left-hand-side + b_p: constraint left-hand-side + max_iters: maximum number of iterations to perform before aborting. If set to zero, equal to the system size. + tol: relative tolerance under which to stop the solve + check_every: number of iterations every which to evaluate the current residual norm to compare against tolerance + quiet: if True, do not print iteration residuals + method: Iterative solver method to use, defaults to BiCGSTAB + + Returns: + Tuple (residual norm, iteration count) + + """ + x = wp.empty(dtype=saddle_system.scalar_type, shape=saddle_system.shape[0], device=saddle_system.device) + b = wp.empty_like(x) + + wp.copy(src=x_u, dest=saddle_system.u_slice(x)) + wp.copy(src=x_p, dest=saddle_system.p_slice(x)) + wp.copy(src=b_u, dest=saddle_system.u_slice(b)) + wp.copy(src=b_p, dest=saddle_system.p_slice(b)) + + func = _get_linear_solver_func(method_name=method) + + def print_callback(i, err, tol): + print(f"{func.__name__}: at iteration {i} error = \t {err} \t tol: {tol}") + + callback = None if quiet else print_callback + + end_iter, err, atol = func( + A=saddle_system, + b=b, + x=x, + maxiter=max_iters, + tol=tol, + check_every=check_every, + M=saddle_system.preconditioner, + callback=callback, + ) + + if not quiet: + res_str = "OK" if err <= atol else "TRUNCATED" + print(f"{func.__name__}: terminated after {end_iter} iterations with absolute error = \t {err} ({res_str})") + + wp.copy(dest=x_u, src=saddle_system.u_slice(x)) + wp.copy(dest=x_p, src=saddle_system.p_slice(x)) + + return err, end_iter + + +@wp.kernel +def _compute_schur_inverse_diagonal( + B_offsets: wp.array(dtype=int), + B_indices: wp.array(dtype=int), + B_values: wp.array(dtype=Any), + A_diag: wp.array(dtype=Any), + P_diag: wp.array(dtype=Any), +): + row = wp.tid() + + zero = P_diag.dtype(P_diag.dtype.dtype(0.0)) + + schur = zero + + beg = B_offsets[row] + end = B_offsets[row + 1] + + for b in range(beg, end): + B = B_values[b] + col = B_indices[b] + Ai = wp.inverse(A_diag[col]) + S = B * Ai * wp.transpose(B) + schur += S + + P_diag[row] = fem.utils.inverse_qr(schur) + + +def invert_diagonal_bsr_matrix(A: BsrMatrix): + """Inverts each block of a block-diagonal mass matrix""" + + values = A.values + if not wp.types.type_is_matrix(values.dtype): + values = values.view(dtype=wp.mat(shape=(1, 1), dtype=A.scalar_type)) + + wp.launch( + kernel=_block_diagonal_invert, + dim=A.nrow, + inputs=[values], + device=values.device, + ) + + +@wp.kernel +def _block_diagonal_invert(values: wp.array(dtype=Any)): + i = wp.tid() + values[i] = fem.utils.inverse_qr(values[i]) + + +# +# Plot utilities +# + + +def _plot_grid_surface(field, axes=None): + import matplotlib.pyplot as plt + from matplotlib import cm + + if axes is None: + fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) + + node_positions = field.space.node_grid() + + # Make data. + X = node_positions[0] + Y = node_positions[1] + Z = field.dof_values.numpy().reshape(X.shape) + + # Plot the surface. + return axes.plot_surface(X, Y, Z, cmap=cm.coolwarm, linewidth=0, antialiased=False) + + +def _plot_tri_surface(field, axes=None): + import matplotlib.pyplot as plt + from matplotlib import cm + from matplotlib.tri.triangulation import Triangulation + + if axes is None: + fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) + + node_positions = field.space.node_positions().numpy() + + triangulation = Triangulation( + x=node_positions[:, 0], y=node_positions[:, 1], triangles=field.space.node_triangulation() + ) + + Z = field.dof_values.numpy() + + # Plot the surface. + return axes.plot_trisurf(triangulation, Z, cmap=cm.coolwarm, linewidth=0, antialiased=False) + + +def _plot_tri_mesh(field, axes=None, **kwargs): + import matplotlib.pyplot as plt + from matplotlib.tri.triangulation import Triangulation + + if axes is None: + fig, axes = plt.subplots() + + vtx_positions = field.space.node_positions().numpy() + displacement = field.dof_values.numpy() + + X = vtx_positions[:, 0] + displacement[:, 0] + Y = vtx_positions[:, 1] + displacement[:, 1] + + triangulation = Triangulation(x=X, y=Y, triangles=field.space.node_triangulation()) + + # Plot the surface. + return axes.triplot(triangulation, **kwargs)[0] + + +def _plot_scatter_surface(field, axes=None): + import matplotlib.pyplot as plt + from matplotlib import cm + + if axes is None: + fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) + + X, Y = field.space.node_positions().numpy().T + + # Make data. + Z = field.dof_values.numpy().reshape(X.shape) + + # Plot the surface. + return axes.scatter(X, Y, Z, c=Z, cmap=cm.coolwarm) + + +def _plot_surface(field, axes=None): + if hasattr(field.space, "node_grid"): + return _plot_grid_surface(field, axes) + elif hasattr(field.space, "node_triangulation"): + return _plot_tri_surface(field, axes) + else: + return _plot_scatter_surface(field, axes) + + +def _plot_grid_color(field, axes=None): + import matplotlib.pyplot as plt + from matplotlib import cm + + if axes is None: + fig, axes = plt.subplots() + + node_positions = field.space.node_grid() + + # Make data. + X = node_positions[0] + Y = node_positions[1] + Z = field.dof_values.numpy().reshape(X.shape) + + # Plot the surface. + return axes.pcolormesh(X, Y, Z, cmap=cm.coolwarm) + + +def _plot_velocities(field, axes=None): + import matplotlib.pyplot as plt + + if axes is None: + fig, axes = plt.subplots() + + node_positions = field.space.node_positions().numpy() + + # Make data. + X = node_positions[:, 0] + Y = node_positions[:, 1] + + vel = field.dof_values.numpy() + u = np.ascontiguousarray(vel[:, 0]) + v = np.ascontiguousarray(vel[:, 1]) + + u = u.reshape(X.shape) + v = v.reshape(X.shape) + + return axes.quiver(X, Y, u, v) + + +def plot_grid_streamlines(field, axes=None): + import matplotlib.pyplot as plt + + if axes is None: + fig, axes = plt.subplots() + + node_positions = field.space.node_grid() + + # Make data. + X = node_positions[0][:, 0] + Y = node_positions[1][0, :] + + vel = field.dof_values.numpy() + u = np.ascontiguousarray(vel[:, 0]) + v = np.ascontiguousarray(vel[:, 1]) + + u = np.transpose(u.reshape(node_positions[0].shape)) + v = np.transpose(v.reshape(node_positions[0].shape)) + + splot = axes.streamplot(X, Y, u, v, density=2) + splot.axes = axes + return splot + + +def plot_3d_scatter(field, axes=None): + import matplotlib.pyplot as plt + from matplotlib import cm + + if axes is None: + fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) + + X, Y, Z = field.space.node_positions().numpy().T + + # Make data. + f = field.dof_values.numpy().reshape(X.shape) + + # Plot the surface. + return axes.scatter(X, Y, Z, c=f, cmap=cm.coolwarm) + + +def plot_3d_velocities(field, axes=None): + import matplotlib.pyplot as plt + + if axes is None: + fig, axes = plt.subplots(subplot_kw={"projection": "3d"}) + + X, Y, Z = field.space.node_positions().numpy().T + + vel = field.dof_values.numpy() + u = np.ascontiguousarray(vel[:, 0]) + v = np.ascontiguousarray(vel[:, 1]) + w = np.ascontiguousarray(vel[:, 2]) + + u = u.reshape(X.shape) + v = v.reshape(X.shape) + w = w.reshape(X.shape) + + return axes.quiver(X, Y, Z, u, v, w, length=1.0 / X.shape[0], normalize=False) + + +class Plot: + def __init__(self, stage=None, default_point_radius=0.01): + self.default_point_radius = default_point_radius + + self._surfaces = {} + self._surface_vectors = {} + self._volumes = {} + + self._usd_renderer = None + if stage is not None: + try: + from warp.render import UsdRenderer + + self._usd_renderer = UsdRenderer(stage) + except Exception as err: + print(f"Could not initialize UsdRenderer for stage '{stage}': {err}.") + + def begin_frame(self, time): + if self._usd_renderer is not None: + self._usd_renderer.begin_frame(time=time) + + def end_frame(self): + if self._usd_renderer is not None: + self._usd_renderer.end_frame() + + def add_surface(self, name: str, field: fem.DiscreteField): + if self._usd_renderer is not None: + points_2d = field.space.node_positions().numpy() + values = field.dof_values.numpy() + points_3d = np.hstack((points_2d, values.reshape(-1, 1))) + + if hasattr(field.space, "node_triangulation"): + indices = field.space.node_triangulation() + self._usd_renderer.render_mesh(name, points=points_3d, indices=indices) + else: + self._usd_renderer.render_points(name, points=points_3d, radius=self.default_point_radius) + + if name not in self._surfaces: + field_clone = field.space.make_field(space_partition=field.space_partition) + self._surfaces[name] = (field_clone, []) + + self._surfaces[name][1].append(field.dof_values.numpy()) + + def add_surface_vector(self, name: str, field: fem.DiscreteField): + if self._usd_renderer is not None: + points_2d = field.space.node_positions().numpy() + values = field.dof_values.numpy() + points_3d = np.hstack((points_2d + values, np.zeros_like(points_2d[:, 0]).reshape(-1, 1))) + + if hasattr(field.space, "node_triangulation"): + indices = field.space.node_triangulation() + self._usd_renderer.render_mesh(name, points=points_3d, indices=indices) + else: + self._usd_renderer.render_points(name, points=points_3d, radius=self.default_point_radius) + + if name not in self._surface_vectors: + field_clone = field.space.make_field(space_partition=field.space_partition) + self._surface_vectors[name] = (field_clone, []) + + self._surface_vectors[name][1].append(field.dof_values.numpy()) + + def add_volume(self, name: str, field: fem.DiscreteField): + if self._usd_renderer is not None: + points_3d = field.space.node_positions().numpy() + values = field.dof_values.numpy() + + self._usd_renderer.render_points(name, points_3d, radius=values) + + if name not in self._volumes: + field_clone = field.space.make_field(space_partition=field.space_partition) + self._volumes[name] = (field_clone, []) + + self._volumes[name][1].append(field.dof_values.numpy()) + + def plot(self, streamlines: Set[str] = None, displacement: str = None): + if streamlines is None: + streamlines = set() + return self._plot_matplotlib(streamlines, displacement) + + def _plot_matplotlib(self, streamlines: Set[str], displacement: str): + import matplotlib.animation as animation + import matplotlib.pyplot as plt + + if streamlines is None: + streamlines = [] + + def make_animation(ax, field, values, plot_func, num_frames: int): + def animate(i): + ax.clear() + field.dof_values = values[i] + return plot_func(field, axes=ax) + + return animation.FuncAnimation( + ax.figure, + animate, + interval=30, + blit=False, + frames=len(values), + ) + + for _name, (field, values) in self._surfaces.items(): + field.dof_values = values[0] + ax = _plot_surface(field).axes + + if len(values) > 1: + _anim = make_animation(ax, field, values, plot_func=_plot_surface, num_frames=len(values)) + + for name, (field, values) in self._surface_vectors.items(): + field.dof_values = values[0] + if name == displacement: + ax = _plot_tri_mesh(field).axes + + if len(values) > 1: + _anim = make_animation(ax, field, values, plot_func=_plot_tri_mesh, num_frames=len(values)) + elif name in streamlines and hasattr(field.space, "node_grid"): + ax = plot_grid_streamlines(field).axes + ax.set_axis_off() + else: + ax = _plot_velocities(field).axes + + if len(values) > 1: + _anim = make_animation(ax, field, values, plot_func=_plot_velocities, num_frames=len(values)) + + for _name, (field, values) in self._volumes.items(): + field.dof_values = values[0] + ax = plot_3d_scatter(field).axes + + plt.show() diff --git a/warp/fem/__init__.py b/warp/fem/__init__.py index 18b904a4d..38bad00f9 100644 --- a/warp/fem/__init__.py +++ b/warp/fem/__init__.py @@ -1,6 +1,6 @@ from .cache import TemporaryStore, borrow_temporary, borrow_temporary_like, set_default_temporary_store from .dirichlet import normalize_dirichlet_projector, project_linear_system -from .domain import BoundarySides, Cells, FrontierSides, GeometryDomain, Sides +from .domain import BoundarySides, Cells, FrontierSides, GeometryDomain, Sides, Subdomain from .field import DiscreteField, FieldLike, make_restriction, make_test, make_trial from .geometry import ( ExplicitGeometryPartition, @@ -58,4 +58,4 @@ make_space_partition, make_space_restriction, ) -from .types import Coords, Domain, ElementIndex, Field, Sample +from .types import NULL_ELEMENT_INDEX, Coords, Domain, ElementIndex, Field, Sample, make_free_sample diff --git a/warp/fem/cache.py b/warp/fem/cache.py index 142ee529b..2df3ea518 100644 --- a/warp/fem/cache.py +++ b/warp/fem/cache.py @@ -401,3 +401,47 @@ def borrow_temporary_like( device=array.device, requires_grad=array.requires_grad, ) + + +_device_events = {} + + +def capture_event(device=None): + """ + Records a CUDA event on the current stream and returns it, + reusing previously created events if possible. + + If the current device is not a CUDA device, returns ``None``. + + The event can be returned to the shared per-device pool for future reuse by + calling :func:`synchronize_event` + """ + + device = wp.get_device(device) + if not device.is_cuda: + return None + + try: + device_events = _device_events[device.ordinal] + except KeyError: + device_events = [] + _device_events[device.ordinal] = device_events + + with wp.ScopedDevice(device): + if not device_events: + return wp.record_event() + + return wp.record_event(device_events.pop()) + + +def synchronize_event(event: Union[wp.Event, None]): + """ + Synchronize an event created with :func:`capture_event` and returns it to the + per-device event pool. + + If `event` is ``None``, do nothing. + """ + + if event is not None: + wp.synchronize_event(event) + _device_events[event.device.ordinal].append(event) diff --git a/warp/fem/dirichlet.py b/warp/fem/dirichlet.py index a3071975b..c1a7f5e4b 100644 --- a/warp/fem/dirichlet.py +++ b/warp/fem/dirichlet.py @@ -1,11 +1,10 @@ from typing import Any, Optional import warp as wp +from warp.fem.utils import array_axpy, symmetric_eigenvalues_qr from warp.sparse import BsrMatrix, bsr_assign, bsr_axpy, bsr_copy, bsr_mm, bsr_mv from warp.types import type_is_matrix, type_length -from .utils import array_axpy - def normalize_dirichlet_projector(projector_matrix: BsrMatrix, fixed_value: Optional[wp.array] = None): """ @@ -115,11 +114,34 @@ def project_linear_system( project_system_matrix(system_matrix, projector_matrix) +@wp.func +def _normalize_projector_and_value(A: Any, b: Any): + # Do a modal decomposition of the left and right hand side, + # Make lhs an orthogonal projection and apply corresponding scaling to righ-hand-side + + eps = wp.trace(A) * A.dtype(1.0e-6) + + diag, ev = symmetric_eigenvalues_qr(A, eps * eps) + + zero = A.dtype(0) + A_unitary = type(A)(zero) + b_normalized = type(b)(zero) + + for k in range(b.length): + if diag[k] > eps: # ignore small eigenmodes + v = ev[k] + A_unitary += wp.outer(v, v) + b_normalized += wp.dot(v, b) / diag[k] * v + + return A_unitary, b_normalized + + @wp.kernel -def _normalize_dirichlet_projector_kernel( +def _normalize_dirichlet_projector_and_values_kernel( offsets: wp.array(dtype=int), columns: wp.array(dtype=int), block_values: wp.array(dtype=Any), + fixed_values: wp.array(dtype=Any), ): row = wp.tid() @@ -134,23 +156,17 @@ def _normalize_dirichlet_projector_kernel( if diag < end and columns[diag] == row: P = block_values[diag] - P_sq = P * P - trace_P = wp.trace(P) - trace_P_sq = wp.trace(P_sq) + P_norm, v_norm = _normalize_projector_and_value(P, fixed_values[row]) - if wp.nonzero(trace_P_sq): - scale = trace_P / trace_P_sq - block_values[diag] = scale * P - else: - block_values[diag] = P - P + block_values[diag] = P_norm + fixed_values[row] = v_norm @wp.kernel -def _normalize_dirichlet_projector_and_values_kernel( +def _normalize_dirichlet_projector_kernel( offsets: wp.array(dtype=int), columns: wp.array(dtype=int), block_values: wp.array(dtype=Any), - fixed_values: wp.array(dtype=Any), ): row = wp.tid() @@ -165,14 +181,5 @@ def _normalize_dirichlet_projector_and_values_kernel( if diag < end and columns[diag] == row: P = block_values[diag] - P_sq = P * P - trace_P = wp.trace(P) - trace_P_sq = wp.trace(P_sq) - - if wp.nonzero(trace_P_sq): - scale = trace_P / trace_P_sq - block_values[diag] = scale * P - fixed_values[row] = scale * fixed_values[row] - else: - block_values[diag] = P - P - fixed_values[row] = fixed_values[row] - fixed_values[row] + P_norm, v_norm = _normalize_projector_and_value(P, type(P[0])()) + block_values[diag] = P_norm diff --git a/warp/fem/domain.py b/warp/fem/domain.py index da7830a1f..d842e9b04 100644 --- a/warp/fem/domain.py +++ b/warp/fem/domain.py @@ -1,9 +1,11 @@ from enum import Enum -from typing import Union +from typing import Optional, Union import warp as wp import warp.codegen import warp.context +import warp.fem.cache as cache +import warp.fem.utils as utils from warp.fem.geometry import ( Element, Geometry, @@ -157,7 +159,7 @@ def element_measure_ratio(self) -> wp.Function: return self.geometry.cell_measure_ratio @property - def eval_normal(self) -> wp.Function: + def element_normal(self) -> wp.Function: return self.geometry.cell_normal @property @@ -172,6 +174,8 @@ def __init__(self, geometry: GeometryOrPartition): self.geometry = geometry super().__init__(geometry) + self.element_lookup = None + @property def element_kind(self) -> GeometryDomain.ElementKind: return GeometryDomain.ElementKind.SIDE @@ -224,7 +228,7 @@ def element_measure_ratio(self) -> wp.Function: return self.geometry.side_measure_ratio @property - def eval_normal(self) -> wp.Function: + def element_normal(self) -> wp.Function: return self.geometry.side_normal @@ -260,3 +264,96 @@ def geometry_element_count(self) -> int: @property def element_index(self) -> wp.Function: return self.geometry_partition.frontier_side_index + + +class Subdomain(GeometryDomain): + """Subdomain -- restriction of domain to a subset of its elements""" + + def __init__( + self, + domain: GeometryDomain, + element_mask: Optional[wp.array] = None, + element_indices: Optional[wp.array] = None, + temporary_store: Optional[cache.TemporaryStore] = None, + ): + """ + Create a subdomain from a subset of elements. + + Exactly one of `element_mask` and `element_indices` should be provided. + + Args: + domain: the containing domain + element_mask: Array of length ``domain.element_count()`` indicating which elements should be included. Array values must be either ``1`` (selected) or ``0`` (not selected). + element_indices: Explicit array of element indices to include + """ + + super().__init__(domain.geometry_partition) + + if element_indices is None: + if element_mask is None: + raise ValueError("Either 'element_mask' or 'element_indices' should be provided") + element_indices, _ = utils.masked_indices(mask=element_mask, temporary_store=temporary_store) + element_indices = element_indices.detach() + elif element_mask is not None: + raise ValueError("Only one of 'element_mask' and 'element_indices' should be provided") + + self._domain = domain + self._element_indices = element_indices + self.ElementIndexArg = self._make_element_index_arg() + self.element_index = self._make_element_index() + + # forward + self.ElementArg = self._domain.ElementArg + self.geometry_element_count = self._domain.geometry_element_count + self.reference_element = self._domain.reference_element + self.element_arg_value = self._domain.element_arg_value + self.element_measure = self._domain.element_measure + self.element_measure_ratio = self._domain.element_measure_ratio + self.element_position = self._domain.element_position + self.element_deformation_gradient = self._domain.element_deformation_gradient + self.element_lookup = self._domain.element_lookup + self.element_normal = self._domain.element_normal + + @property + def name(self) -> str: + return f"{self._domain.name}_Subdomain" + + def __eq__(self, other) -> bool: + return ( + self.__class__ == other.__class__ + and self.geometry_partition == other.geometry_partition + and self._element_indices == other._element_indices + ) + + @property + def element_kind(self) -> GeometryDomain.ElementKind: + return self._domain.element_kind + + @property + def dimension(self) -> int: + return self._domain.dimension + + def element_count(self) -> int: + return self._element_indices.shape[0] + + def _make_element_index_arg(self): + @cache.dynamic_struct(suffix=self.name) + class ElementIndexArg: + domain_arg: self._domain.ElementIndexArg + element_indices: wp.array(dtype=int) + + return ElementIndexArg + + @cache.cached_arg_value + def element_index_arg_value(self, device: warp.context.Devicelike): + arg = self.ElementIndexArg() + arg.domain_arg = self._domain.element_index_arg_value(device) + arg.element_indices = self._element_indices.to(device) + return arg + + def _make_element_index(self) -> wp.Function: + @cache.dynamic_func(suffix=self.name) + def element_index(arg: self.ElementIndexArg, index: int): + return self._domain.element_index(arg.domain_arg, arg.element_indices[index]) + + return element_index diff --git a/warp/fem/field/nodal_field.py b/warp/fem/field/nodal_field.py index 7251c1caa..a8cfd86ee 100644 --- a/warp/fem/field/nodal_field.py +++ b/warp/fem/field/nodal_field.py @@ -28,6 +28,7 @@ def __init__(self, space: CollocatedFunctionSpace, space_partition: SpacePartiti self.eval_div_outer = self._make_eval_div_outer() self.set_node_value = self._make_set_node_value() + self.node_partition_index = self._make_node_partition_index() def _make_eval_arg(self): @cache.dynamic_struct(suffix=self.name) @@ -62,14 +63,15 @@ def read_node_value(args: self.ElementEvalArg, geo_element_index: ElementIndex, return read_node_value def _make_eval_inner(self): - NODES_PER_ELEMENT = self.space.topology.NODES_PER_ELEMENT - @cache.dynamic_func(suffix=self.name) def eval_inner(args: self.ElementEvalArg, s: Sample): res = self.space.element_inner_weight( args.elt_arg, args.eval_arg.space_arg, s.element_index, s.element_coords, 0 ) * self._read_node_value(args, s.element_index, 0) - for k in range(1, NODES_PER_ELEMENT): + node_count = self.space.topology.element_node_count( + args.elt_arg, args.eval_arg.topology_arg, s.element_index + ) + for k in range(1, node_count): res += self.space.element_inner_weight( args.elt_arg, args.eval_arg.space_arg, s.element_index, s.element_coords, k ) * self._read_node_value(args, s.element_index, k) @@ -78,8 +80,6 @@ def eval_inner(args: self.ElementEvalArg, s: Sample): return eval_inner def _make_eval_grad_inner(self, world_space: bool): - NODES_PER_ELEMENT = self.space.topology.NODES_PER_ELEMENT - if not self.gradient_valid(): return None @@ -91,7 +91,10 @@ def eval_grad_inner_ref_space(args: self.ElementEvalArg, s: Sample): args.elt_arg, args.eval_arg.space_arg, s.element_index, s.element_coords, 0 ), ) - for k in range(1, NODES_PER_ELEMENT): + node_count = self.space.topology.element_node_count( + args.elt_arg, args.eval_arg.topology_arg, s.element_index + ) + for k in range(1, node_count): res += utils.generalized_outer( self._read_node_value(args, s.element_index, k), self.space.element_inner_weight_gradient( @@ -109,8 +112,6 @@ def eval_grad_inner_world_space(args: self.ElementEvalArg, s: Sample): return eval_grad_inner_world_space if world_space else eval_grad_inner_ref_space def _make_eval_div_inner(self): - NODES_PER_ELEMENT = self.space.topology.NODES_PER_ELEMENT - if not self.divergence_valid(): return None @@ -128,7 +129,10 @@ def eval_div_inner(args: self.ElementEvalArg, s: Sample): ), ) - for k in range(1, NODES_PER_ELEMENT): + node_count = self.space.topology.element_node_count( + args.elt_arg, args.eval_arg.topology_arg, s.element_index + ) + for k in range(1, node_count): res += utils.generalized_inner( self._read_node_value(args, s.element_index, k), utils.apply_right( @@ -143,8 +147,6 @@ def eval_div_inner(args: self.ElementEvalArg, s: Sample): return eval_div_inner def _make_eval_outer(self): - NODES_PER_ELEMENT = self.space.topology.NODES_PER_ELEMENT - @cache.dynamic_func(suffix=self.name) def eval_outer(args: self.ElementEvalArg, s: Sample): res = self.space.element_outer_weight( @@ -154,7 +156,10 @@ def eval_outer(args: self.ElementEvalArg, s: Sample): s.element_coords, 0, ) * self._read_node_value(args, s.element_index, 0) - for k in range(1, NODES_PER_ELEMENT): + node_count = self.space.topology.element_node_count( + args.elt_arg, args.eval_arg.topology_arg, s.element_index + ) + for k in range(1, node_count): res += self.space.element_outer_weight( args.elt_arg, args.eval_arg.space_arg, @@ -167,8 +172,6 @@ def eval_outer(args: self.ElementEvalArg, s: Sample): return eval_outer def _make_eval_grad_outer(self, world_space: bool): - NODES_PER_ELEMENT = self.space.topology.NODES_PER_ELEMENT - if not self.gradient_valid(): return None @@ -180,7 +183,10 @@ def eval_grad_outer_ref_space(args: self.ElementEvalArg, s: Sample): args.elt_arg, args.eval_arg.space_arg, s.element_index, s.element_coords, 0 ), ) - for k in range(1, NODES_PER_ELEMENT): + node_count = self.space.topology.element_node_count( + args.elt_arg, args.eval_arg.topology_arg, s.element_index + ) + for k in range(1, node_count): res += utils.generalized_outer( self._read_node_value(args, s.element_index, k), self.space.element_outer_weight_gradient( @@ -198,8 +204,6 @@ def eval_grad_outer_world_space(args: self.ElementEvalArg, s: Sample): return eval_grad_outer_world_space if world_space else eval_grad_outer_ref_space def _make_eval_div_outer(self): - NODES_PER_ELEMENT = self.space.topology.NODES_PER_ELEMENT - if not self.divergence_valid(): return None @@ -216,7 +220,11 @@ def eval_div_outer(args: self.ElementEvalArg, s: Sample): grad_transform, ), ) - for k in range(1, NODES_PER_ELEMENT): + + node_count = self.space.topology.element_node_count( + args.elt_arg, args.eval_arg.topology_arg, s.element_index + ) + for k in range(1, node_count): res += utils.generalized_inner( self._read_node_value(args, s.element_index, k), utils.apply_right( @@ -237,6 +245,13 @@ def set_node_value(args: self.EvalArg, partition_node_index: int, value: self.sp return set_node_value + def _make_node_partition_index(self): + @cache.dynamic_func(suffix=self.name) + def node_partition_index(args: self.EvalArg, node_index: int): + return self.space_partition.partition_node_index(args.eval_arg.partition_arg, node_index) + + return node_partition_index + class NodalField(NodalFieldBase): """A field holding values for all degrees of freedom at each node of the underlying function space partition diff --git a/warp/fem/geometry/hexmesh.py b/warp/fem/geometry/hexmesh.py index 05b6b19e8..146ca8194 100644 --- a/warp/fem/geometry/hexmesh.py +++ b/warp/fem/geometry/hexmesh.py @@ -65,7 +65,7 @@ class HexmeshSideArg: ) ) -# orthogal transform for face coordinates given first vertex + winding +# orthogonal transform for face coordinates given first vertex + winding # (two rows per entry) FACE_ORIENTATION = [ @@ -439,12 +439,12 @@ def side_to_cell_arg(side_arg: SideArg): return side_arg.cell_arg def _build_topology(self, temporary_store: TemporaryStore): - from warp.fem.utils import compress_node_indices, masked_indices + from warp.fem.utils import compress_node_indices, host_read_at_index, masked_indices from warp.utils import array_scan device = self.hex_vertex_indices.device - vertex_hex_offsets, vertex_hex_indices, _, __ = compress_node_indices( + vertex_hex_offsets, vertex_hex_indices = compress_node_indices( self.vertex_count(), self.hex_vertex_indices, temporary_store=temporary_store ) self._vertex_hex_offsets = vertex_hex_offsets.detach() @@ -492,16 +492,11 @@ def _build_topology(self, temporary_store: TemporaryStore): array_scan(in_array=vertex_start_face_count.array, out_array=vertex_unique_face_offsets.array, inclusive=False) # Get back edge count to host - if device.is_cuda: - face_count = borrow_temporary(temporary_store, shape=(1,), dtype=int, device="cpu", pinned=True) - # Last vertex will not own any edge, so its count will be zero; just fetching last prefix count is ok - wp.copy( - dest=face_count.array, src=vertex_unique_face_offsets.array, src_offset=self.vertex_count() - 1, count=1 + face_count = int( + host_read_at_index( + vertex_unique_face_offsets.array, self.vertex_count() - 1, temporary_store=temporary_store ) - wp.synchronize_stream(wp.get_stream(device)) - face_count = int(face_count.array.numpy()[0]) - else: - face_count = int(vertex_unique_face_offsets.array.numpy()[self.vertex_count() - 1]) + ) self._face_vertex_indices = wp.empty(shape=(face_count,), dtype=wp.vec4i, device=device) self._face_hex_indices = wp.empty(shape=(face_count,), dtype=wp.vec2i, device=device) @@ -557,6 +552,7 @@ def _build_topology(self, temporary_store: TemporaryStore): self._boundary_face_indices = boundary_face_indices.detach() def _compute_hex_edges(self, temporary_store: Optional[TemporaryStore] = None): + from warp.fem.utils import host_read_at_index from warp.utils import array_scan device = self.hex_vertex_indices.device @@ -599,19 +595,11 @@ def _compute_hex_edges(self, temporary_store: Optional[TemporaryStore] = None): array_scan(in_array=vertex_start_edge_count.array, out_array=vertex_unique_edge_offsets.array, inclusive=False) # Get back edge count to host - if device.is_cuda: - edge_count = borrow_temporary(temporary_store, shape=(1,), dtype=int, device="cpu", pinned=True) - # Last vertex will not own any edge, so its count will be zero; just fetching last prefix count is ok - wp.copy( - dest=edge_count.array, - src=vertex_unique_edge_offsets.array, - src_offset=self.vertex_count() - 1, - count=1, + self._edge_count = int( + host_read_at_index( + vertex_unique_edge_offsets.array, self.vertex_count() - 1, temporary_store=temporary_store ) - wp.synchronize_stream(wp.get_stream(device)) - self._edge_count = int(edge_count.array.numpy()[0]) - else: - self._edge_count = int(vertex_unique_edge_offsets.array.numpy()[self.vertex_count() - 1]) + ) self._hex_edge_indices = wp.empty( dtype=int, device=self.hex_vertex_indices.device, shape=(self.cell_count(), 12) diff --git a/warp/fem/geometry/nanogrid.py b/warp/fem/geometry/nanogrid.py index dafa897a3..9fb94bfb0 100644 --- a/warp/fem/geometry/nanogrid.py +++ b/warp/fem/geometry/nanogrid.py @@ -60,9 +60,21 @@ class NanogridSideArg: class Nanogrid(Geometry): + """Sparse grid geometry""" + dimension = 3 def __init__(self, grid: wp.Volume, temporary_store: Optional[cache.TemporaryStore] = None): + """ + Constructs a sparse grid geometry from an in-memory NanoVDB volume. + + Args: + grid: The NanoVDB volume. Any type is accepted, but for indexing efficiency an index grid is recommended. + If `grid` is an 'on' index grid, cells will be created for active voxels only, otherwise cells will + be created for all leaf voxels. + temporary_store: shared pool from which to allocate temporary arrays + """ + self._cell_grid = grid self._cell_grid_info = grid.get_grid_info() @@ -77,31 +89,29 @@ def __init__(self, grid: wp.Volume, temporary_store: Optional[cache.TemporarySto self._node_ijk = wp.array(shape=(node_count,), dtype=wp.vec3i, device=device) self._node_grid.get_voxels(out=self._node_ijk) - self._face_grid = _build_face_grid(self._cell_ijk, grid, temporary_store) - face_count = self._face_grid.get_voxel_count() - self._face_ijk = wp.array(shape=(face_count,), dtype=wp.vec3i, device=device) - self._face_grid.get_voxels(out=self._face_ijk) - - self._face_flags = wp.array(shape=(face_count,), dtype=wp.uint8, device=device) - boundary_face_mask = cache.borrow_temporary(temporary_store, shape=(face_count,), dtype=wp.int32, device=device) - - wp.launch( - _build_face_flags, - dim=face_count, - device=device, - inputs=[grid.id, self._face_ijk, self._face_flags, boundary_face_mask.array], - ) - boundary_face_indices, _ = utils.masked_indices(boundary_face_mask.array) - self._boundary_face_indices = boundary_face_indices.detach() + self._face_grid = None + self._face_ijk = None self._edge_grid = None self._edge_ijk = None - def _build_edge_grid(self, temporary_store: Optional[cache.TemporaryStore] = None): - self._edge_grid = _build_edge_grid(self._cell_ijk, self._cell_grid, temporary_store) - edge_count = self._edge_grid.get_voxel_count() - self._edge_ijk = wp.array(shape=(edge_count,), dtype=wp.vec3i, device=self._edge_grid.device) - self._edge_grid.get_voxels(out=self._edge_ijk) + @property + def cell_grid(self) -> wp.Volume: + return self._cell_grid + + @property + def vertex_grid(self) -> wp.Volume: + return self._node_grid + + @property + def face_grid(self) -> wp.Volume: + self._ensure_face_grid() + return self._face_grid + + @property + def edge_grid(self) -> wp.Volume: + self._ensure_edge_grid() + return self._edge_grid def cell_count(self): return self._cell_ijk.shape[0] @@ -110,17 +120,17 @@ def vertex_count(self): return self._node_ijk.shape[0] def side_count(self): + self._ensure_face_grid() return self._face_ijk.shape[0] - def edge_count(self): - if self._edge_ijk is None: - self._build_edge_grid() - - return self._edge_ijk.shape[0] - def boundary_side_count(self): + self._ensure_face_grid() return self._boundary_face_indices.shape[0] + def edge_count(self): + self._ensure_edge_grid() + return self._edge_ijk.shape[0] + def reference_cell(self) -> Cube: return Cube() @@ -166,9 +176,38 @@ def cell_lookup(args: CellArg, pos: wp.vec3): make_free_sample(NULL_ELEMENT_INDEX, Coords(OUTSIDE)), ) + @wp.func + def _project_on_voxel(uvw: wp.vec3, ijk: wp.vec3i): + coords = uvw - wp.vec3(ijk) + proj_coords = wp.min(wp.max(coords, wp.vec3(0.0)), wp.vec3(1.0)) + return wp.length_sq(coords - proj_coords), proj_coords + @wp.func def cell_lookup(args: CellArg, pos: wp.vec3, guess: Sample): - return Nanogrid.cell_lookup(args, pos) + s_global = Nanogrid.cell_lookup(args, pos) + + if s_global.element_index != NULL_ELEMENT_INDEX: + return s_global + + closest_voxel = int(NULL_ELEMENT_INDEX) + closest_coords = Coords(OUTSIDE) + closest_dist = float(1.0e8) + + # project to closest in stencil + uvw = wp.volume_world_to_index(args.cell_grid, pos) + cell_ijk = args.cell_ijk[guess.element_index] + for ni in range(-1, 2): + for nj in range(-1, 2): + for nk in range(-1, 2): + nijk = cell_ijk + wp.vec3i(ni, nj, nk) + cell_idx = wp.volume_lookup_index(args.cell_grid, nijk[0], nijk[1], nijk[2]) + dist, coords = Nanogrid._project_on_voxel(uvw, nijk) + if cell_idx != -1 and dist <= closest_dist: + closest_dist = dist + closest_voxel = cell_idx + closest_coords = coords + + return make_free_sample(closest_voxel, closest_coords) @wp.func def cell_measure(args: CellArg, s: Sample): @@ -182,6 +221,8 @@ def cell_normal(args: CellArg, s: Sample): @cache.cached_arg_value def side_arg_value(self, device) -> SideArg: + self._ensure_face_grid() + args = self.SideArg() args.cell_arg = self.cell_arg_value(device) args.face_ijk = self._face_ijk.to(device) @@ -199,6 +240,8 @@ class SideIndexArg: @cache.cached_arg_value def side_index_arg_value(self, device) -> SideIndexArg: + self._ensure_face_grid() + args = self.SideIndexArg() args.boundary_face_indices = self._boundary_face_indices.to(device) return args @@ -342,6 +385,39 @@ def side_from_cell_coords( def side_to_cell_arg(side_arg: SideArg): return side_arg.cell_arg + def _build_face_grid(self, temporary_store: Optional[cache.TemporaryStore] = None): + device = self._cell_grid.device + self._face_grid = _build_face_grid(self._cell_ijk, self._cell_grid, temporary_store) + face_count = self._face_grid.get_voxel_count() + self._face_ijk = wp.array(shape=(face_count,), dtype=wp.vec3i, device=device) + self._face_grid.get_voxels(out=self._face_ijk) + + self._face_flags = wp.array(shape=(face_count,), dtype=wp.uint8, device=device) + boundary_face_mask = cache.borrow_temporary(temporary_store, shape=(face_count,), dtype=wp.int32, device=device) + + wp.launch( + _build_face_flags, + dim=face_count, + device=device, + inputs=[self._cell_grid.id, self._face_ijk, self._face_flags, boundary_face_mask.array], + ) + boundary_face_indices, _ = utils.masked_indices(boundary_face_mask.array) + self._boundary_face_indices = boundary_face_indices.detach() + + def _build_edge_grid(self, temporary_store: Optional[cache.TemporaryStore] = None): + self._edge_grid = _build_edge_grid(self._cell_ijk, self._cell_grid, temporary_store) + edge_count = self._edge_grid.get_voxel_count() + self._edge_ijk = wp.array(shape=(edge_count,), dtype=wp.vec3i, device=self._edge_grid.device) + self._edge_grid.get_voxels(out=self._edge_ijk) + + def _ensure_face_grid(self): + if self._face_ijk is None: + self._build_face_grid() + + def _ensure_edge_grid(self): + if self._edge_ijk is None: + self._build_edge_grid() + @wp.kernel def _cell_node_indices( diff --git a/warp/fem/geometry/quadmesh_2d.py b/warp/fem/geometry/quadmesh_2d.py index 6919f125e..52fc2b9a6 100644 --- a/warp/fem/geometry/quadmesh_2d.py +++ b/warp/fem/geometry/quadmesh_2d.py @@ -299,12 +299,12 @@ def side_to_cell_arg(side_arg: SideArg): return side_arg.cell_arg def _build_topology(self, temporary_store: TemporaryStore): - from warp.fem.utils import compress_node_indices, masked_indices + from warp.fem.utils import compress_node_indices, host_read_at_index, masked_indices from warp.utils import array_scan device = self.quad_vertex_indices.device - vertex_quad_offsets, vertex_quad_indices, _, __ = compress_node_indices( + vertex_quad_offsets, vertex_quad_indices = compress_node_indices( self.vertex_count(), self.quad_vertex_indices, temporary_store=temporary_store ) self._vertex_quad_offsets = vertex_quad_offsets.detach() @@ -350,16 +350,11 @@ def _build_topology(self, temporary_store: TemporaryStore): array_scan(in_array=vertex_start_edge_count.array, out_array=vertex_unique_edge_offsets.array, inclusive=False) # Get back edge count to host - if device.is_cuda: - edge_count = borrow_temporary(temporary_store, shape=(1,), dtype=int, device="cpu", pinned=True) - # Last vertex will not own any edge, so its count will be zero; just fetching last prefix count is ok - wp.copy( - dest=edge_count.array, src=vertex_unique_edge_offsets.array, src_offset=self.vertex_count() - 1, count=1 + edge_count = int( + host_read_at_index( + vertex_unique_edge_offsets.array, self.vertex_count() - 1, temporary_store=temporary_store ) - wp.synchronize_stream(wp.get_stream(device)) - edge_count = int(edge_count.array.numpy()[0]) - else: - edge_count = int(vertex_unique_edge_offsets.array.numpy()[self.vertex_count() - 1]) + ) self._edge_vertex_indices = wp.empty(shape=(edge_count,), dtype=wp.vec2i, device=device) self._edge_quad_indices = wp.empty(shape=(edge_count,), dtype=wp.vec2i, device=device) diff --git a/warp/fem/geometry/tetmesh.py b/warp/fem/geometry/tetmesh.py index 6d4e6c899..9d6a2a401 100644 --- a/warp/fem/geometry/tetmesh.py +++ b/warp/fem/geometry/tetmesh.py @@ -30,8 +30,8 @@ class TetmeshCellArg: vertex_tet_offsets: wp.array(dtype=int) vertex_tet_indices: wp.array(dtype=int) - # for transforming reference gradient - deformation_gradients: wp.array(dtype=wp.mat33f) + # for global cell lookup + tet_bvh: wp.uint64 @wp.struct @@ -42,6 +42,7 @@ class TetmeshSideArg: _mat32 = wp.mat(shape=(3, 2), dtype=float) +_NULL_BVH = wp.constant(wp.uint64(-1)) class Tetmesh(Geometry): @@ -50,7 +51,11 @@ class Tetmesh(Geometry): dimension = 3 def __init__( - self, tet_vertex_indices: wp.array, positions: wp.array, temporary_store: Optional[TemporaryStore] = None + self, + tet_vertex_indices: wp.array, + positions: wp.array, + build_bvh: bool = False, + temporary_store: Optional[TemporaryStore] = None, ): """ Constructs a tetrahedral mesh. @@ -59,6 +64,7 @@ def __init__( tet_vertex_indices: warp array of shape (num_tets, 4) containing vertex indices for each tet positions: warp array of shape (num_vertices, 3) containing 3d position for each vertex temporary_store: shared pool from which to allocate temporary arrays + build_bvh: Whether to also build the tet BVH, which is necessary for the global `fem.lookup` operator to function without initial guess """ self.tet_vertex_indices = tet_vertex_indices @@ -72,8 +78,37 @@ def __init__( self._edge_count = 0 self._build_topology(temporary_store) - self._deformation_gradients: wp.array = None - self._compute_deformation_gradients() + self._tet_bvh: wp.Bvh = None + if build_bvh: + self._build_bvh() + + def update_bvh(self, force_rebuild: bool = False): + """ + Refits the BVH, or rebuilds it from scratch if `force_rebuild` is ``True``. + """ + + if self._tet_bvh is None or force_rebuild: + return self.build_bvh() + + wp.launch( + Tetmesh._compute_tet_bounds, + self.tet_vertex_indices, + self.positions, + self._tet_bvh.lowers, + self._tet_bvh.uppers, + ) + self._tet_bvh.refit() + + def _build_bvh(self, temporary_store: Optional[TemporaryStore] = None): + lowers = wp.array(shape=self.cell_count(), dtype=wp.vec3, device=self.positions.device) + uppers = wp.array(shape=self.cell_count(), dtype=wp.vec3, device=self.positions.device) + wp.launch( + Tetmesh._compute_tet_bounds, + device=self.positions.device, + dim=self.cell_count(), + inputs=[self.tet_vertex_indices, self.positions, lowers, uppers], + ) + self._tet_bvh = wp.Bvh(lowers, uppers) def cell_count(self): return self.tet_vertex_indices.shape[0] @@ -129,7 +164,8 @@ def cell_arg_value(self, device) -> CellArg: args.positions = self.positions.to(device) args.vertex_tet_offsets = self._vertex_tet_offsets.to(device) args.vertex_tet_indices = self._vertex_tet_indices.to(device) - args.deformation_gradients = self._deformation_gradients.to(device) + + args.tet_bvh = _NULL_BVH if self._tet_bvh is None else self._tet_bvh.id return args @@ -146,11 +182,15 @@ def cell_position(args: CellArg, s: Sample): @wp.func def cell_deformation_gradient(args: CellArg, s: Sample): - return args.deformation_gradients[s.element_index] + p0 = args.positions[args.tet_vertex_indices[s.element_index, 0]] + p1 = args.positions[args.tet_vertex_indices[s.element_index, 1]] + p2 = args.positions[args.tet_vertex_indices[s.element_index, 2]] + p3 = args.positions[args.tet_vertex_indices[s.element_index, 3]] + return wp.mat33(p1 - p0, p2 - p0, p3 - p0) @wp.func def cell_inverse_deformation_gradient(args: CellArg, s: Sample): - return wp.inverse(args.deformation_gradients[s.element_index]) + return wp.inverse(Tetmesh.cell_deformation_gradient(args, s)) @wp.func def _project_on_tet(args: CellArg, pos: wp.vec3, tet_index: int): @@ -165,29 +205,54 @@ def _project_on_tet(args: CellArg, pos: wp.vec3, tet_index: int): return dist, coords @wp.func - def cell_lookup(args: CellArg, pos: wp.vec3, guess: Sample): + def _bvh_lookup(args: CellArg, pos: wp.vec3): closest_tet = int(NULL_ELEMENT_INDEX) closest_coords = Coords(OUTSIDE) closest_dist = float(1.0e8) - for v in range(4): - vtx = args.tet_vertex_indices[guess.element_index, v] - tet_beg = args.vertex_tet_offsets[vtx] - tet_end = args.vertex_tet_offsets[vtx + 1] - - for t in range(tet_beg, tet_end): - tet = args.vertex_tet_indices[t] + if args.tet_bvh != _NULL_BVH: + query = wp.bvh_query_aabb(args.tet_bvh, pos, pos) + tet = int(0) + while wp.bvh_query_next(query, tet): dist, coords = Tetmesh._project_on_tet(args, pos, tet) if dist <= closest_dist: closest_dist = dist closest_tet = tet closest_coords = coords + return closest_dist, closest_tet, closest_coords + + @wp.func + def cell_lookup(args: CellArg, pos: wp.vec3): + closest_dist, closest_tet, closest_coords = Tetmesh._bvh_lookup(args, pos) + + return make_free_sample(closest_tet, closest_coords) + + @wp.func + def cell_lookup(args: CellArg, pos: wp.vec3, guess: Sample): + closest_dist, closest_tet, closest_coords = Tetmesh._bvh_lookup(args, pos) + return make_free_sample(closest_tet, closest_coords) + + if closest_tet == NULL_ELEMENT_INDEX: + # nothing found yet, bvh may not be available or outside mesh + for v in range(4): + vtx = args.tet_vertex_indices[guess.element_index, v] + tet_beg = args.vertex_tet_offsets[vtx] + tet_end = args.vertex_tet_offsets[vtx + 1] + + for t in range(tet_beg, tet_end): + tet = args.vertex_tet_indices[t] + dist, coords = Tetmesh._project_on_tet(args, pos, tet) + if dist <= closest_dist: + closest_dist = dist + closest_tet = tet + closest_coords = coords + return make_free_sample(closest_tet, closest_coords) @wp.func def cell_measure(args: CellArg, s: Sample): - return wp.abs(wp.determinant(args.deformation_gradients[s.element_index])) / 6.0 + return wp.abs(wp.determinant(Tetmesh.cell_deformation_gradient(args, s))) / 6.0 @wp.func def cell_measure_ratio(args: CellArg, s: Sample): @@ -247,12 +312,14 @@ def side_deformation_gradient(args: SideArg, s: Sample): @wp.func def side_inner_inverse_deformation_gradient(args: SideArg, s: Sample): cell_index = Tetmesh.side_inner_cell_index(args, s.element_index) - return wp.inverse(args.cell_arg.deformation_gradients[cell_index]) + s_cell = make_free_sample(cell_index, Coords()) + return Tetmesh.cell_inverse_deformation_gradient(args.cell_arg, s_cell) @wp.func def side_outer_inverse_deformation_gradient(args: SideArg, s: Sample): cell_index = Tetmesh.side_outer_cell_index(args, s.element_index) - return wp.inverse(args.cell_arg.deformation_gradients[cell_index]) + s_cell = make_free_sample(cell_index, Coords()) + return Tetmesh.cell_inverse_deformation_gradient(args.cell_arg, s_cell) @wp.func def side_measure(args: SideArg, s: Sample): @@ -355,12 +422,12 @@ def side_to_cell_arg(side_arg: SideArg): return side_arg.cell_arg def _build_topology(self, temporary_store: TemporaryStore): - from warp.fem.utils import compress_node_indices, masked_indices + from warp.fem.utils import compress_node_indices, host_read_at_index, masked_indices from warp.utils import array_scan device = self.tet_vertex_indices.device - vertex_tet_offsets, vertex_tet_indices, _, __ = compress_node_indices( + vertex_tet_offsets, vertex_tet_indices = compress_node_indices( self.vertex_count(), self.tet_vertex_indices, temporary_store=temporary_store ) self._vertex_tet_offsets = vertex_tet_offsets.detach() @@ -406,16 +473,11 @@ def _build_topology(self, temporary_store: TemporaryStore): array_scan(in_array=vertex_start_face_count.array, out_array=vertex_unique_face_offsets.array, inclusive=False) # Get back edge count to host - if device.is_cuda: - face_count = borrow_temporary(temporary_store, shape=(1,), dtype=int, device="cpu", pinned=True) - # Last vertex will not own any edge, so its count will be zero; just fetching last prefix count is ok - wp.copy( - dest=face_count.array, src=vertex_unique_face_offsets.array, src_offset=self.vertex_count() - 1, count=1 + face_count = int( + host_read_at_index( + vertex_unique_face_offsets.array, self.vertex_count() - 1, temporary_store=temporary_store ) - wp.synchronize_stream(wp.get_stream(device)) - face_count = int(face_count.array.numpy()[0]) - else: - face_count = int(vertex_unique_face_offsets.array.numpy()[self.vertex_count() - 1]) + ) self._face_vertex_indices = wp.empty(shape=(face_count,), dtype=wp.vec3i, device=device) self._face_tet_indices = wp.empty(shape=(face_count,), dtype=wp.vec2i, device=device) @@ -457,6 +519,7 @@ def _build_topology(self, temporary_store: TemporaryStore): self._boundary_face_indices = boundary_face_indices.detach() def _compute_tet_edges(self, temporary_store: Optional[TemporaryStore] = None): + from warp.fem.utils import host_read_at_index from warp.utils import array_scan device = self.tet_vertex_indices.device @@ -499,19 +562,11 @@ def _compute_tet_edges(self, temporary_store: Optional[TemporaryStore] = None): array_scan(in_array=vertex_start_edge_count.array, out_array=vertex_unique_edge_offsets.array, inclusive=False) # Get back edge count to host - if device.is_cuda: - edge_count = borrow_temporary(temporary_store, shape=(1,), dtype=int, device="cpu", pinned=True) - # Last vertex will not own any edge, so its count will be zero; just fetching last prefix count is ok - wp.copy( - dest=edge_count.array, - src=vertex_unique_edge_offsets.array, - src_offset=self.vertex_count() - 1, - count=1, + self._edge_count = int( + host_read_at_index( + vertex_unique_edge_offsets.array, self.vertex_count() - 1, temporary_store=temporary_store ) - wp.synchronize_stream(wp.get_stream(device)) - self._edge_count = int(edge_count.array.numpy()[0]) - else: - self._edge_count = int(vertex_unique_edge_offsets.array.numpy()[self.vertex_count() - 1]) + ) self._tet_edge_indices = wp.empty( dtype=int, device=self.tet_vertex_indices.device, shape=(self.cell_count(), 6) @@ -539,16 +594,6 @@ def _compute_tet_edges(self, temporary_store: Optional[TemporaryStore] = None): vertex_unique_edge_count.release() vertex_edge_ends.release() - def _compute_deformation_gradients(self): - self._deformation_gradients = wp.empty(dtype=wp.mat33f, device=self.positions.device, shape=(self.cell_count())) - - wp.launch( - kernel=Tetmesh._compute_deformation_gradients_kernel, - dim=self._deformation_gradients.shape, - device=self._deformation_gradients.device, - inputs=[self.tet_vertex_indices, self.positions, self._deformation_gradients], - ) - @wp.kernel def _count_starting_faces_kernel( tet_vertex_indices: wp.array2d(dtype=int), vertex_start_face_count: wp.array(dtype=int) @@ -821,20 +866,17 @@ def _compress_edges_kernel( tet_edge_indices[t][k + 3] = edge_id @wp.kernel - def _compute_deformation_gradients_kernel( + def _compute_tet_bounds( tet_vertex_indices: wp.array2d(dtype=int), - positions: wp.array(dtype=wp.vec3f), - transforms: wp.array(dtype=wp.mat33f), + positions: wp.array(dtype=wp.vec3), + lowers: wp.array(dtype=wp.vec3), + uppers: wp.array(dtype=wp.vec3), ): t = wp.tid() - p0 = positions[tet_vertex_indices[t, 0]] p1 = positions[tet_vertex_indices[t, 1]] p2 = positions[tet_vertex_indices[t, 2]] p3 = positions[tet_vertex_indices[t, 3]] - e1 = p1 - p0 - e2 = p2 - p0 - e3 = p3 - p0 - - transforms[t] = wp.mat33(e1, e2, e3) + lowers[t] = wp.min(wp.min(p0, p1), wp.min(p2, p3)) + uppers[t] = wp.max(wp.max(p0, p1), wp.max(p2, p3)) diff --git a/warp/fem/geometry/trimesh_2d.py b/warp/fem/geometry/trimesh_2d.py index 3125ffcfc..92d40dd60 100644 --- a/warp/fem/geometry/trimesh_2d.py +++ b/warp/fem/geometry/trimesh_2d.py @@ -30,7 +30,8 @@ class Trimesh2DCellArg: vertex_tri_offsets: wp.array(dtype=int) vertex_tri_indices: wp.array(dtype=int) - deformation_gradients: wp.array(dtype=wp.mat22f) + # for global cell lookup + tri_bvh: wp.uint64 @wp.struct @@ -40,13 +41,20 @@ class Trimesh2DSideArg: edge_tri_indices: wp.array(dtype=wp.vec2i) +_NULL_BVH = wp.constant(wp.uint64(-1)) + + class Trimesh2D(Geometry): """Two-dimensional triangular mesh geometry""" dimension = 2 def __init__( - self, tri_vertex_indices: wp.array, positions: wp.array, temporary_store: Optional[TemporaryStore] = None + self, + tri_vertex_indices: wp.array, + positions: wp.array, + build_bvh: bool = False, + temporary_store: Optional[TemporaryStore] = None, ): """ Constructs a two-dimensional triangular mesh. @@ -55,6 +63,7 @@ def __init__( tri_vertex_indices: warp array of shape (num_tris, 3) containing vertex indices for each tri positions: warp array of shape (num_vertices, 2) containing 2d position for each vertex temporary_store: shared pool from which to allocate temporary arrays + build_bvh: Whether to also build the triangle BVH, which is necessary for the global `fem.lookup` operator to function without initial guess """ self.tri_vertex_indices = tri_vertex_indices @@ -66,8 +75,37 @@ def __init__( self._vertex_tri_indices: wp.array = None self._build_topology(temporary_store) - self._deformation_gradients: wp.array = None - self._compute_deformation_gradients() + self._tri_bvh: wp.Bvh = None + if build_bvh: + self._build_bvh() + + def update_bvh(self, force_rebuild: bool = False): + """ + Refits the BVH, or rebuilds it from scratch if `force_rebuild` is ``True``. + """ + + if self._tri_bvh is None or force_rebuild: + return self.build_bvh() + + wp.launch( + Trimesh2D._compute_tri_bounds, + self.tri_vertex_indices, + self.positions, + self._tri_bvh.lowers, + self._tri_bvh.uppers, + ) + self._tri_bvh.refit() + + def _build_bvh(self, temporary_store: Optional[TemporaryStore] = None): + lowers = wp.array(shape=self.cell_count(), dtype=wp.vec3, device=self.positions.device) + uppers = wp.array(shape=self.cell_count(), dtype=wp.vec3, device=self.positions.device) + wp.launch( + Trimesh2D._compute_tri_bounds, + device=self.positions.device, + dim=self.cell_count(), + inputs=[self.tri_vertex_indices, self.positions, lowers, uppers], + ) + self._tri_bvh = wp.Bvh(lowers, uppers) def cell_count(self): return self.tri_vertex_indices.shape[0] @@ -112,7 +150,7 @@ def cell_arg_value(self, device) -> CellArg: args.positions = self.positions.to(device) args.vertex_tri_offsets = self._vertex_tri_offsets.to(device) args.vertex_tri_indices = self._vertex_tri_indices.to(device) - args.deformation_gradients = self._deformation_gradients.to(device) + args.tri_bvh = _NULL_BVH if self._tri_bvh is None else self._tri_bvh.id return args @@ -127,11 +165,14 @@ def cell_position(args: CellArg, s: Sample): @wp.func def cell_deformation_gradient(args: CellArg, s: Sample): - return args.deformation_gradients[s.element_index] + p0 = args.positions[args.tri_vertex_indices[s.element_index, 0]] + p1 = args.positions[args.tri_vertex_indices[s.element_index, 1]] + p2 = args.positions[args.tri_vertex_indices[s.element_index, 2]] + return wp.mat22(p1 - p0, p2 - p0) @wp.func def cell_inverse_deformation_gradient(args: CellArg, s: Sample): - return wp.inverse(args.deformation_gradients[s.element_index]) + return wp.inverse(Trimesh2D.cell_deformation_gradient(args, s)) @wp.func def _project_on_tri(args: CellArg, pos: wp.vec2, tri_index: int): @@ -145,29 +186,54 @@ def _project_on_tri(args: CellArg, pos: wp.vec2, tri_index: int): return dist, coords @wp.func - def cell_lookup(args: CellArg, pos: wp.vec2, guess: Sample): + def _bvh_lookup(args: CellArg, pos: wp.vec2): closest_tri = int(NULL_ELEMENT_INDEX) closest_coords = Coords(OUTSIDE) closest_dist = float(1.0e8) - for v in range(3): - vtx = args.tri_vertex_indices[guess.element_index, v] - tri_beg = args.vertex_tri_offsets[vtx] - tri_end = args.vertex_tri_offsets[vtx + 1] - - for t in range(tri_beg, tri_end): - tri = args.vertex_tri_indices[t] + if args.tri_bvh != _NULL_BVH: + pos3 = wp.vec3(pos[0], pos[1], 0.0) + query = wp.bvh_query_aabb(args.tri_bvh, pos3, pos3) + tri = int(0) + while wp.bvh_query_next(query, tri): dist, coords = Trimesh2D._project_on_tri(args, pos, tri) if dist <= closest_dist: closest_dist = dist closest_tri = tri closest_coords = coords + return closest_dist, closest_tri, closest_coords + + @wp.func + def cell_lookup(args: CellArg, pos: wp.vec2): + closest_dist, closest_tri, closest_coords = Trimesh2D._bvh_lookup(args, pos) + + return make_free_sample(closest_tri, closest_coords) + + @wp.func + def cell_lookup(args: CellArg, pos: wp.vec2, guess: Sample): + closest_dist, closest_tri, closest_coords = Trimesh2D._bvh_lookup(args, pos) + + if closest_tri == NULL_ELEMENT_INDEX: + # nothing found yet, bvh may not be available or outside mesh + for v in range(3): + vtx = args.tri_vertex_indices[guess.element_index, v] + tri_beg = args.vertex_tri_offsets[vtx] + tri_end = args.vertex_tri_offsets[vtx + 1] + + for t in range(tri_beg, tri_end): + tri = args.vertex_tri_indices[t] + dist, coords = Trimesh2D._project_on_tri(args, pos, tri) + if dist <= closest_dist: + closest_dist = dist + closest_tri = tri + closest_coords = coords + return make_free_sample(closest_tri, closest_coords) @wp.func def cell_measure(args: CellArg, s: Sample): - return 0.5 * wp.abs(wp.determinant(args.deformation_gradients[s.element_index])) + return 0.5 * wp.abs(wp.determinant(Trimesh2D.cell_deformation_gradient(args, s))) @wp.func def cell_normal(args: CellArg, s: Sample): @@ -214,12 +280,14 @@ def side_deformation_gradient(args: SideArg, s: Sample): @wp.func def side_inner_inverse_deformation_gradient(args: SideArg, s: Sample): cell_index = Trimesh2D.side_inner_cell_index(args, s.element_index) - return wp.inverse(args.cell_arg.deformation_gradients[cell_index]) + s_cell = make_free_sample(cell_index, Coords()) + return Trimesh2D.cell_inverse_deformation_gradient(args.cell_arg, s_cell) @wp.func def side_outer_inverse_deformation_gradient(args: SideArg, s: Sample): cell_index = Trimesh2D.side_outer_cell_index(args, s.element_index) - return wp.inverse(args.cell_arg.deformation_gradients[cell_index]) + s_cell = make_free_sample(cell_index, Coords()) + return Trimesh2D.cell_inverse_deformation_gradient(args.cell_arg, s_cell) @wp.func def side_measure(args: SideArg, s: Sample): @@ -321,12 +389,12 @@ def side_to_cell_arg(side_arg: SideArg): return side_arg.cell_arg def _build_topology(self, temporary_store: TemporaryStore): - from warp.fem.utils import compress_node_indices, masked_indices + from warp.fem.utils import compress_node_indices, host_read_at_index, masked_indices from warp.utils import array_scan device = self.tri_vertex_indices.device - vertex_tri_offsets, vertex_tri_indices, _, __ = compress_node_indices( + vertex_tri_offsets, vertex_tri_indices = compress_node_indices( self.vertex_count(), self.tri_vertex_indices, temporary_store=temporary_store ) self._vertex_tri_offsets = vertex_tri_offsets.detach() @@ -370,16 +438,11 @@ def _build_topology(self, temporary_store: TemporaryStore): array_scan(in_array=vertex_start_edge_count.array, out_array=vertex_unique_edge_offsets.array, inclusive=False) # Get back edge count to host - if device.is_cuda: - edge_count = borrow_temporary(temporary_store, shape=(1,), dtype=int, device="cpu", pinned=True) - # Last vertex will not own any edge, so its count will be zero; just fetching last prefix count is ok - wp.copy( - dest=edge_count.array, src=vertex_unique_edge_offsets.array, src_offset=self.vertex_count() - 1, count=1 + edge_count = int( + host_read_at_index( + vertex_unique_edge_offsets.array, self.vertex_count() - 1, temporary_store=temporary_store ) - wp.synchronize_stream(wp.get_stream(device)) - edge_count = int(edge_count.array.numpy()[0]) - else: - edge_count = int(vertex_unique_edge_offsets.array.numpy()[self.vertex_count() - 1]) + ) self._edge_vertex_indices = wp.empty(shape=(edge_count,), dtype=wp.vec2i, device=device) self._edge_tri_indices = wp.empty(shape=(edge_count,), dtype=wp.vec2i, device=device) @@ -422,16 +485,6 @@ def _build_topology(self, temporary_store: TemporaryStore): boundary_mask.release() - def _compute_deformation_gradients(self): - self._deformation_gradients = wp.empty(dtype=wp.mat22f, device=self.positions.device, shape=(self.cell_count())) - - wp.launch( - kernel=Trimesh2D._compute_deformation_gradients_kernel, - dim=self._deformation_gradients.shape, - device=self._deformation_gradients.device, - inputs=[self.tri_vertex_indices, self.positions, self._deformation_gradients], - ) - @wp.kernel def _count_starting_edges_kernel( tri_vertex_indices: wp.array2d(dtype=int), vertex_start_edge_count: wp.array(dtype=int) @@ -560,18 +613,16 @@ def _flip_edge_normals( edge_vertex_indices[e] = wp.vec2i(edge_vidx[1], edge_vidx[0]) @wp.kernel - def _compute_deformation_gradients_kernel( + def _compute_tri_bounds( tri_vertex_indices: wp.array2d(dtype=int), - positions: wp.array(dtype=wp.vec2f), - transforms: wp.array(dtype=wp.mat22f), + positions: wp.array(dtype=wp.vec2), + lowers: wp.array(dtype=wp.vec3), + uppers: wp.array(dtype=wp.vec3), ): t = wp.tid() - p0 = positions[tri_vertex_indices[t, 0]] p1 = positions[tri_vertex_indices[t, 1]] p2 = positions[tri_vertex_indices[t, 2]] - e1 = p1 - p0 - e2 = p2 - p0 - - transforms[t] = wp.mat22(e1, e2) + lowers[t] = wp.vec3(wp.min(wp.min(p0[0], p1[0]), p2[0]), wp.min(wp.min(p0[1], p1[1]), p2[1]), 0.0) + uppers[t] = wp.vec3(wp.max(wp.max(p0[0], p1[0]), p2[0]), wp.max(wp.max(p0[1], p1[1]), p2[1]), 0.0) diff --git a/warp/fem/integrate.py b/warp/fem/integrate.py index 0b47f5020..730fff8a2 100644 --- a/warp/fem/integrate.py +++ b/warp/fem/integrate.py @@ -16,7 +16,7 @@ ) from warp.fem.operator import Integrand, Operator from warp.fem.quadrature import Quadrature, RegularQuadrature -from warp.fem.types import NULL_DOF_INDEX, OUTSIDE, DofIndex, Domain, Field, Sample, make_free_sample +from warp.fem.types import NULL_DOF_INDEX, NULL_NODE_INDEX, OUTSIDE, DofIndex, Domain, Field, Sample, make_free_sample from warp.sparse import BsrMatrix, bsr_set_from_triplets, bsr_zeros from warp.types import type_length from warp.utils import array_cast @@ -215,7 +215,7 @@ def _check_field_compat( field_args: Dict[str, FieldLike], domain: GeometryDomain = None, ): - # Check field compatilibity + # Check field compatibility for name, field in fields.items(): if name not in field_args: raise ValueError( @@ -474,17 +474,18 @@ def integrate_kernel_fn( values: ValueStruct, result: wp.array(dtype=accumulate_dtype), ): - element_index = domain.element_index(domain_index_arg, wp.tid()) + domain_element_index = wp.tid() + element_index = domain.element_index(domain_index_arg, domain_element_index) elem_sum = accumulate_dtype(0.0) test_dof_index = NULL_DOF_INDEX trial_dof_index = NULL_DOF_INDEX - qp_point_count = quadrature.point_count(domain_arg, qp_arg, element_index) + qp_point_count = quadrature.point_count(domain_arg, qp_arg, domain_element_index, element_index) for k in range(qp_point_count): - qp_index = quadrature.point_index(domain_arg, qp_arg, element_index, k) - coords = quadrature.point_coords(domain_arg, qp_arg, element_index, k) - qp_weight = quadrature.point_weight(domain_arg, qp_arg, element_index, k) + qp_index = quadrature.point_index(domain_arg, qp_arg, domain_element_index, element_index, k) + coords = quadrature.point_coords(domain_arg, qp_arg, domain_element_index, element_index, k) + qp_weight = quadrature.point_weight(domain_arg, qp_arg, domain_element_index, element_index, k) sample = Sample(element_index, coords, qp_index, qp_weight, test_dof_index, trial_dof_index) vol = domain.element_measure(domain_arg, sample) @@ -519,23 +520,31 @@ def integrate_kernel_fn( ): local_node_index, test_dof = wp.tid() node_index = test.space_restriction.node_partition_index(test_arg, local_node_index) - element_count = test.space_restriction.node_element_count(test_arg, local_node_index) + element_beg, element_end = test.space_restriction.node_element_range(test_arg, node_index) trial_dof_index = NULL_DOF_INDEX val_sum = accumulate_dtype(0.0) - for n in range(element_count): - node_element_index = test.space_restriction.node_element_index(test_arg, local_node_index, n) + for n in range(element_beg, element_end): + node_element_index = test.space_restriction.node_element_index(test_arg, n) element_index = domain.element_index(domain_index_arg, node_element_index.domain_element_index) test_dof_index = DofIndex(node_element_index.node_index_in_element, test_dof) - qp_point_count = quadrature.point_count(domain_arg, qp_arg, element_index) + qp_point_count = quadrature.point_count( + domain_arg, qp_arg, node_element_index.domain_element_index, element_index + ) for k in range(qp_point_count): - qp_index = quadrature.point_index(domain_arg, qp_arg, element_index, k) - qp_coords = quadrature.point_coords(domain_arg, qp_arg, element_index, k) - qp_weight = quadrature.point_weight(domain_arg, qp_arg, element_index, k) + qp_index = quadrature.point_index( + domain_arg, qp_arg, node_element_index.domain_element_index, element_index, k + ) + qp_coords = quadrature.point_coords( + domain_arg, qp_arg, node_element_index.domain_element_index, element_index, k + ) + qp_weight = quadrature.point_weight( + domain_arg, qp_arg, node_element_index.domain_element_index, element_index, k + ) vol = domain.element_measure(domain_arg, make_free_sample(element_index, qp_coords)) @@ -562,23 +571,29 @@ def integrate_kernel_fn( domain_arg: domain.ElementArg, domain_index_arg: domain.ElementIndexArg, test_restriction_arg: test.space_restriction.NodeArg, + test_topo_arg: test.space.topology.TopologyArg, fields: FieldStruct, values: ValueStruct, result: wp.array2d(dtype=output_dtype), ): local_node_index, dof = wp.tid() - node_index = test.space_restriction.node_partition_index(test_restriction_arg, local_node_index) - element_count = test.space_restriction.node_element_count(test_restriction_arg, local_node_index) + partition_node_index = test.space_restriction.node_partition_index(test_restriction_arg, local_node_index) + element_beg, element_end = test.space_restriction.node_element_range(test_restriction_arg, partition_node_index) trial_dof_index = NULL_DOF_INDEX val_sum = accumulate_dtype(0.0) - for n in range(element_count): - node_element_index = test.space_restriction.node_element_index(test_restriction_arg, local_node_index, n) + for n in range(element_beg, element_end): + node_element_index = test.space_restriction.node_element_index(test_restriction_arg, n) element_index = domain.element_index(domain_index_arg, node_element_index.domain_element_index) + if n == element_beg: + node_index = test.space.topology.element_node_index( + domain_arg, test_topo_arg, element_index, node_element_index.node_index_in_element + ) + coords = test.space.node_coords_in_element( domain_arg, _get_test_arg(), @@ -609,7 +624,7 @@ def integrate_kernel_fn( val_sum += accumulate_dtype(node_weight * vol * val) - result[node_index, dof] = output_dtype(val_sum) + result[partition_node_index, dof] = output_dtype(val_sum) return integrate_kernel_fn @@ -625,7 +640,7 @@ def get_integrate_bilinear_kernel( output_dtype, accumulate_dtype, ): - NODES_PER_ELEMENT = trial.space.topology.NODES_PER_ELEMENT + MAX_NODES_PER_ELEMENT = trial.space.topology.MAX_NODES_PER_ELEMENT def integrate_kernel_fn( qp_arg: quadrature.Arg, @@ -636,22 +651,29 @@ def integrate_kernel_fn( trial_topology_arg: trial.space_partition.space_topology.TopologyArg, fields: FieldStruct, values: ValueStruct, - row_offsets: wp.array(dtype=int), triplet_rows: wp.array(dtype=int), triplet_cols: wp.array(dtype=int), triplet_values: wp.array3d(dtype=output_dtype), ): test_local_node_index, trial_node, test_dof, trial_dof = wp.tid() - element_count = test.space_restriction.node_element_count(test_arg, test_local_node_index) test_node_index = test.space_restriction.node_partition_index(test_arg, test_local_node_index) + element_beg, element_end = test.space_restriction.node_element_range(test_arg, test_node_index) trial_dof_index = DofIndex(trial_node, trial_dof) - for element in range(element_count): - test_element_index = test.space_restriction.node_element_index(test_arg, test_local_node_index, element) + for element in range(element_beg, element_end): + test_element_index = test.space_restriction.node_element_index(test_arg, element) element_index = domain.element_index(domain_index_arg, test_element_index.domain_element_index) - qp_point_count = quadrature.point_count(domain_arg, qp_arg, element_index) + + element_trial_node_count = trial.space.topology.element_node_count( + domain_arg, trial_topology_arg, element_index + ) + qp_point_count = wp.select( + trial_node < element_trial_node_count, + 0, + quadrature.point_count(domain_arg, qp_arg, test_element_index.domain_element_index, element_index), + ) test_dof_index = DofIndex( test_element_index.node_index_in_element, @@ -661,10 +683,16 @@ def integrate_kernel_fn( val_sum = accumulate_dtype(0.0) for k in range(qp_point_count): - qp_index = quadrature.point_index(domain_arg, qp_arg, element_index, k) - coords = quadrature.point_coords(domain_arg, qp_arg, element_index, k) + qp_index = quadrature.point_index( + domain_arg, qp_arg, test_element_index.domain_element_index, element_index, k + ) + coords = quadrature.point_coords( + domain_arg, qp_arg, test_element_index.domain_element_index, element_index, k + ) - qp_weight = quadrature.point_weight(domain_arg, qp_arg, element_index, k) + qp_weight = quadrature.point_weight( + domain_arg, qp_arg, test_element_index.domain_element_index, element_index, k + ) vol = domain.element_measure(domain_arg, make_free_sample(element_index, coords)) sample = Sample( @@ -678,15 +706,20 @@ def integrate_kernel_fn( val = integrand_func(sample, fields, values) val_sum += accumulate_dtype(qp_weight * vol * val) - block_offset = (row_offsets[test_node_index] + element) * NODES_PER_ELEMENT + trial_node + block_offset = element * MAX_NODES_PER_ELEMENT + trial_node triplet_values[block_offset, test_dof, trial_dof] = output_dtype(val_sum) # Set row and column indices if test_dof == 0 and trial_dof == 0: - trial_node_index = trial.space_partition.partition_node_index( - trial_partition_arg, - trial.space.topology.element_node_index(domain_arg, trial_topology_arg, element_index, trial_node), - ) + if trial_node < element_trial_node_count: + trial_node_index = trial.space_partition.partition_node_index( + trial_partition_arg, + trial.space.topology.element_node_index( + domain_arg, trial_topology_arg, element_index, trial_node + ), + ) + else: + trial_node_index = NULL_NODE_INDEX # will get ignored when converting to bsr triplet_rows[block_offset] = test_node_index triplet_cols[block_offset] = trial_node_index @@ -706,6 +739,7 @@ def integrate_kernel_fn( domain_arg: domain.ElementArg, domain_index_arg: domain.ElementIndexArg, test_restriction_arg: test.space_restriction.NodeArg, + test_topo_arg: test.space.topology.TopologyArg, fields: FieldStruct, values: ValueStruct, triplet_rows: wp.array(dtype=int), @@ -714,15 +748,20 @@ def integrate_kernel_fn( ): local_node_index, test_dof, trial_dof = wp.tid() - element_count = test.space_restriction.node_element_count(test_restriction_arg, local_node_index) - node_index = test.space_restriction.node_partition_index(test_restriction_arg, local_node_index) + partition_node_index = test.space_restriction.node_partition_index(test_restriction_arg, local_node_index) + element_beg, element_end = test.space_restriction.node_element_range(test_restriction_arg, partition_node_index) val_sum = accumulate_dtype(0.0) - for n in range(element_count): - node_element_index = test.space_restriction.node_element_index(test_restriction_arg, local_node_index, n) + for n in range(element_beg, element_end): + node_element_index = test.space_restriction.node_element_index(test_restriction_arg, n) element_index = domain.element_index(domain_index_arg, node_element_index.domain_element_index) + if n == element_beg: + node_index = test.space.topology.element_node_index( + domain_arg, test_topo_arg, element_index, node_element_index.node_index_in_element + ) + coords = test.space.node_coords_in_element( domain_arg, _get_test_arg(), @@ -755,8 +794,8 @@ def integrate_kernel_fn( val_sum += accumulate_dtype(node_weight * vol * val) triplet_values[local_node_index, test_dof, trial_dof] = output_dtype(val_sum) - triplet_rows[local_node_index] = node_index - triplet_cols[local_node_index] = node_index + triplet_rows[local_node_index] = partition_node_index + triplet_cols[local_node_index] = partition_node_index return integrate_kernel_fn @@ -1030,6 +1069,7 @@ def as_2d_array(array): domain_elt_arg, domain_elt_index_arg, test_arg, + test.space.topology.topo_arg_value(device), field_arg_values, value_struct_values, output_view, @@ -1069,7 +1109,7 @@ def as_2d_array(array): if nodal: nnz = test.space_restriction.node_count() else: - nnz = test.space_restriction.total_node_element_count() * trial.space.topology.NODES_PER_ELEMENT + nnz = test.space_restriction.total_node_element_count() * trial.space.topology.MAX_NODES_PER_ELEMENT triplet_rows_temp = cache.borrow_temporary(temporary_store, shape=(nnz,), dtype=int, device=device) triplet_cols_temp = cache.borrow_temporary(temporary_store, shape=(nnz,), dtype=int, device=device) @@ -1097,6 +1137,7 @@ def as_2d_array(array): domain_elt_arg, domain_elt_index_arg, test_arg, + test.space.topology.topo_arg_value(device), field_arg_values, value_struct_values, triplet_rows, @@ -1107,15 +1148,13 @@ def as_2d_array(array): ) else: - offsets = test.space_restriction.partition_element_offsets() - trial_partition_arg = trial.space_partition.partition_arg_value(device) trial_topology_arg = trial.space_partition.space_topology.topo_arg_value(device) wp.launch( kernel=kernel, dim=( test.space_restriction.node_count(), - trial.space.topology.NODES_PER_ELEMENT, + trial.space.topology.MAX_NODES_PER_ELEMENT, test.space.VALUE_DOF_COUNT, trial.space.VALUE_DOF_COUNT, ), @@ -1128,7 +1167,6 @@ def as_2d_array(array): trial_topology_arg, field_arg_values, value_struct_values, - offsets, triplet_rows, triplet_cols, triplet_values, @@ -1299,8 +1337,8 @@ def interpolate_to_field_fn( fields: FieldStruct, values: ValueStruct, ): - node_index = dest.space_restriction.node_partition_index(dest_node_arg, local_node_index) - element_count = dest.space_restriction.node_element_count(dest_node_arg, local_node_index) + partition_node_index = dest.space_restriction.node_partition_index(dest_node_arg, local_node_index) + element_beg, element_end = dest.space_restriction.node_element_range(dest_node_arg, partition_node_index) test_dof_index = NULL_DOF_INDEX trial_dof_index = NULL_DOF_INDEX @@ -1312,10 +1350,15 @@ def interpolate_to_field_fn( val_sum = value_type(0.0) vol_sum = float(0.0) - for n in range(element_count): - node_element_index = dest.space_restriction.node_element_index(dest_node_arg, local_node_index, n) + for n in range(element_beg, element_end): + node_element_index = dest.space_restriction.node_element_index(dest_node_arg, n) element_index = domain.element_index(domain_index_arg, node_element_index.domain_element_index) + if n == element_beg: + node_index = dest.space.topology.element_node_index( + domain_arg, dest_eval_arg.topology_arg, element_index, node_element_index.node_index_in_element + ) + coords = dest.space.node_coords_in_element( domain_arg, dest_eval_arg.space_arg, @@ -1387,16 +1430,17 @@ def interpolate_to_array_kernel_fn( values: ValueStruct, result: wp.array(dtype=value_type), ): - element_index = domain.element_index(domain_index_arg, wp.tid()) + domain_element_index = wp.tid() + element_index = domain.element_index(domain_index_arg, domain_element_index) test_dof_index = NULL_DOF_INDEX trial_dof_index = NULL_DOF_INDEX - qp_point_count = quadrature.point_count(domain_arg, qp_arg, element_index) + qp_point_count = quadrature.point_count(domain_arg, qp_arg, domain_element_index, element_index) for k in range(qp_point_count): - qp_index = quadrature.point_index(domain_arg, qp_arg, element_index, k) - coords = quadrature.point_coords(domain_arg, qp_arg, element_index, k) - qp_weight = quadrature.point_weight(domain_arg, qp_arg, element_index, k) + qp_index = quadrature.point_index(domain_arg, qp_arg, domain_element_index, element_index, k) + coords = quadrature.point_coords(domain_arg, qp_arg, domain_element_index, element_index, k) + qp_weight = quadrature.point_weight(domain_arg, qp_arg, domain_element_index, element_index, k) sample = Sample(element_index, coords, qp_index, qp_weight, test_dof_index, trial_dof_index) @@ -1419,16 +1463,17 @@ def interpolate_nonvalued_kernel_fn( fields: FieldStruct, values: ValueStruct, ): - element_index = domain.element_index(domain_index_arg, wp.tid()) + domain_element_index = wp.tid() + element_index = domain.element_index(domain_index_arg, domain_element_index) test_dof_index = NULL_DOF_INDEX trial_dof_index = NULL_DOF_INDEX - qp_point_count = quadrature.point_count(domain_arg, qp_arg, element_index) + qp_point_count = quadrature.point_count(domain_arg, qp_arg, domain_element_index, element_index) for k in range(qp_point_count): - qp_index = quadrature.point_index(domain_arg, qp_arg, element_index, k) - coords = quadrature.point_coords(domain_arg, qp_arg, element_index, k) - qp_weight = quadrature.point_weight(domain_arg, qp_arg, element_index, k) + qp_index = quadrature.point_index(domain_arg, qp_arg, domain_element_index, element_index, k) + coords = quadrature.point_coords(domain_arg, qp_arg, domain_element_index, element_index, k) + qp_weight = quadrature.point_weight(domain_arg, qp_arg, domain_element_index, element_index, k) sample = Sample(element_index, coords, qp_index, qp_weight, test_dof_index, trial_dof_index) integrand_func(sample, fields, values) diff --git a/warp/fem/operator.py b/warp/fem/operator.py index 7e68d6ac8..d6cfc646b 100644 --- a/warp/fem/operator.py +++ b/warp/fem/operator.py @@ -3,7 +3,7 @@ import warp as wp from warp.fem import utils -from warp.fem.types import Domain, Field, Sample +from warp.fem.types import Domain, Field, NodeIndex, Sample class Integrand: @@ -55,7 +55,7 @@ def position(domain: Domain, s: Sample): pass -@operator(resolver=lambda dmn: dmn.eval_normal) +@operator(resolver=lambda dmn: dmn.element_normal) def normal(domain: Domain, s: Sample): """Evaluates the element normal at the sample point `s`. Null for interior points.""" pass @@ -71,13 +71,12 @@ def deformation_gradient(domain: Domain, s: Sample): def lookup(domain: Domain, x: Any) -> Sample: """Looks-up the sample point corresponding to a world position `x`, projecting to the closest point on the domain. - Arg: + Args: x: world position of the point to look-up in the geometry guess: (optional) :class:`Sample` initial guess, may help perform the query - Notes: - Currently this operator is only fully supported for :class:`Grid2D` and :class:`Grid3D` geometries. - For :class:`TriangleMesh2D` and :class:`Tetmesh` geometries, the operator requires providing `guess`. + Note: + Currently this operator is unsupported for :class:`Hexmesh`, :class:`Quadmesh2D` and deformed geometries. """ pass @@ -142,7 +141,14 @@ def degree(f: Field): @operator(resolver=lambda f: f.at_node) def at_node(f: Field, s: Sample): - """For a Test or Trial field, returns a copy of the Sample `s` moved to the coordinates of the node being evaluated""" + """For a Test or Trial field `f`, returns a copy of the Sample `s` moved to the coordinates of the node being evaluated""" + pass + + +@operator(resolver=lambda f: f.node_partition_index) +def node_partition_index(f: Field, node_index: NodeIndex): + """For a NodalField `f`, returns the index of a given node in the fields's space partition, + or ``NULL_NODE_INDEX`` if it does not exists""" pass diff --git a/warp/fem/quadrature/pic_quadrature.py b/warp/fem/quadrature/pic_quadrature.py index ea47f9b14..dfd4ca720 100644 --- a/warp/fem/quadrature/pic_quadrature.py +++ b/warp/fem/quadrature/pic_quadrature.py @@ -42,6 +42,7 @@ def __init__( super().__init__(domain) self._bin_particles(positions, measures, temporary_store) + self._max_particles_per_cell: int = None @property def name(self): @@ -82,22 +83,40 @@ def active_cell_count(self): """Number of cells containing at least one particle""" return self._cell_count + def max_points_per_element(self): + if self._max_particles_per_cell is None: + max_ppc = wp.zeros(shape=(1,), dtype=int, device=self._cell_particle_offsets.array.device) + wp.launch( + PicQuadrature._max_particles_per_cell_kernel, + self._cell_particle_offsets.array.shape[0] - 1, + device=max_ppc.device, + inputs=[self._cell_particle_offsets.array, max_ppc], + ) + self._max_particles_per_cell = int(max_ppc.numpy()[0]) + return self._max_particles_per_cell + @wp.func - def point_count(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex): + def point_count(elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex): return qp_arg.cell_particle_offsets[element_index + 1] - qp_arg.cell_particle_offsets[element_index] @wp.func - def point_coords(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex, index: int): + def point_coords( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, index: int + ): particle_index = qp_arg.cell_particle_indices[qp_arg.cell_particle_offsets[element_index] + index] return qp_arg.particle_coords[particle_index] @wp.func - def point_weight(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex, index: int): + def point_weight( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, index: int + ): particle_index = qp_arg.cell_particle_indices[qp_arg.cell_particle_offsets[element_index] + index] return qp_arg.particle_fraction[particle_index] @wp.func - def point_index(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex, index: int): + def point_index( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, index: int + ): particle_index = qp_arg.cell_particle_indices[qp_arg.cell_particle_offsets[element_index] + index] return particle_index @@ -183,7 +202,7 @@ def bin_particles( self._particle_coords_temp = None self._cell_particle_offsets, self._cell_particle_indices, self._cell_count, _ = compress_node_indices( - self.domain.geometry_element_count(), cell_index + self.domain.geometry_element_count(), cell_index, return_unique_nodes=True, temporary_store=temporary_store ) self._compute_fraction(cell_index, measures, temporary_store) @@ -241,3 +260,9 @@ def compute_fraction( ], device=device, ) + + @wp.kernel + def _max_particles_per_cell_kernel(offsets: wp.array(dtype=int), max_count: wp.array(dtype=int)): + cell = wp.tid() + particle_count = offsets[cell + 1] - offsets[cell] + wp.atomic_max(max_count, 0, particle_count) diff --git a/warp/fem/quadrature/quadrature.py b/warp/fem/quadrature/quadrature.py index b9647a622..15009209a 100644 --- a/warp/fem/quadrature/quadrature.py +++ b/warp/fem/quadrature/quadrature.py @@ -36,8 +36,8 @@ def total_point_count(self): """Total number of quadrature points over the domain""" raise NotImplementedError() - def points_per_element(self): - """Number of points per element if constant, or ``None`` if varying""" + def max_points_per_element(self): + """Maximum number of points per element if known, or ``None`` otherwise""" return None @staticmethod @@ -61,7 +61,11 @@ def point_weight( @staticmethod def point_index( - elt_arg: "domain.GeometryDomain.ElementArg", qp_arg: Arg, element_index: ElementIndex, qp_index: int + elt_arg: "domain.GeometryDomain.ElementArg", + qp_arg: Arg, + domain_element_index: ElementIndex, + geo_element_index: ElementIndex, + element_qp_index: int, ): """Global index of the element's qp_index'th quadrature point""" raise NotImplementedError() @@ -106,7 +110,7 @@ def name(self): def total_point_count(self): return len(self.points) * self.domain.geometry_element_count() - def points_per_element(self): + def max_points_per_element(self): return self._N @property @@ -121,7 +125,12 @@ def _make_point_count(self): N = self._N @cache.dynamic_func(suffix=self.name) - def point_count(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex): + def point_count( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + ): return N return point_count @@ -130,7 +139,13 @@ def _make_point_coords(self): POINTS = self._POINTS @cache.dynamic_func(suffix=self.name) - def point_coords(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex, qp_index: int): + def point_coords( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + qp_index: int, + ): return Coords(POINTS[qp_index, 0], POINTS[qp_index, 1], POINTS[qp_index, 2]) return point_coords @@ -139,7 +154,13 @@ def _make_point_weight(self): WEIGHTS = self._WEIGHTS @cache.dynamic_func(suffix=self.name) - def point_weight(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex, qp_index: int): + def point_weight( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + qp_index: int, + ): return WEIGHTS[qp_index] return point_weight @@ -148,8 +169,14 @@ def _make_point_index(self): N = self._N @cache.dynamic_func(suffix=self.name) - def point_index(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex, qp_index: int): - return N * element_index + qp_index + def point_index( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + qp_index: int, + ): + return N * domain_element_index + qp_index return point_index @@ -157,8 +184,8 @@ def point_index(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index class NodalQuadrature(Quadrature): """Quadrature using space node points as quadrature points - Note that in contrast to the `nodal=True` flag for :func:`integrate`, this quadrature odes not make any assumption - about orthogonality of shape functions, and is thus safe to use for arbitrary integrands. + Note that in contrast to the `nodal=True` flag for :func:`integrate`, using this quadrature does not imply + any assumption about orthogonality of shape functions, and is thus safe to use for arbitrary integrands. """ def __init__(self, domain: domain.GeometryDomain, space: FunctionSpace): @@ -180,8 +207,8 @@ def name(self): def total_point_count(self): return self._space.node_count() - def points_per_element(self): - return self._space.topology.NODES_PER_ELEMENT + def max_points_per_element(self): + return self._space.topology.MAX_NODES_PER_ELEMENT def _make_arg(self): @cache.dynamic_struct(suffix=self.name) @@ -199,44 +226,67 @@ def arg_value(self, device): return arg def _make_point_count(self): - N = self._space.topology.NODES_PER_ELEMENT - @cache.dynamic_func(suffix=self.name) - def point_count(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex): - return N + def point_count( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + ): + return self._space.topology.element_node_count(elt_arg, qp_arg.topo_arg, element_index) return point_count def _make_point_coords(self): @cache.dynamic_func(suffix=self.name) - def point_coords(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex, qp_index: int): + def point_coords( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + qp_index: int, + ): return self._space.node_coords_in_element(elt_arg, qp_arg.space_arg, element_index, qp_index) return point_coords def _make_point_weight(self): @cache.dynamic_func(suffix=self.name) - def point_weight(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex, qp_index: int): + def point_weight( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + qp_index: int, + ): return self._space.node_quadrature_weight(elt_arg, qp_arg.space_arg, element_index, qp_index) return point_weight def _make_point_index(self): @cache.dynamic_func(suffix=self.name) - def point_index(elt_arg: self.domain.ElementArg, qp_arg: self.Arg, element_index: ElementIndex, qp_index: int): + def point_index( + elt_arg: self.domain.ElementArg, + qp_arg: self.Arg, + domain_element_index: ElementIndex, + element_index: ElementIndex, + qp_index: int, + ): return self._space.topology.element_node_index(elt_arg, qp_arg.topo_arg, element_index, qp_index) return point_index class ExplicitQuadrature(Quadrature): - """Quadrature using explicit per-cell points and weights. The number of quadrature points per cell is assumed - to be constant and deduced from the shape of the points and weights arrays. + """Quadrature using explicit per-cell points and weights. + + The number of quadrature points per cell is assumed to be constant and deduced from the shape of the points and weights arrays. + Quadrature points may be provided for either the whole geometry or just the domain's elements. Args: domain: Domain of definition of the quadrature formula - points: 2d array of shape ``(domain.geometry_element-count(), points_per_cell)`` containing the coordinates of each quadrature point. - weights: 2d array of shape ``(domain.geometry_element-count(), points_per_cell)`` containing the weight for each quadrature point. + points: 2d array of shape ``(domain.element_count(), points_per_cell)`` or ``(domain.geometry_element_count(), points_per_cell)`` containing the coordinates of each quadrature point. + weights: 2d array of shape ``(domain.element_count(), points_per_cell)`` or ``(domain.geometry_element_count(), points_per_cell)`` containing the weight for each quadrature point. See also: :class:`PicQuadrature` """ @@ -255,41 +305,78 @@ def __init__( if points.shape != weights.shape: raise ValueError("Points and weights arrays must have the same shape") + if points.shape[0] == domain.geometry_element_count(): + self.point_index = ExplicitQuadrature._point_index_geo + self.point_coords = ExplicitQuadrature._point_coords_geo + self.point_weight = ExplicitQuadrature._point_weight_geo + elif points.shape[0] == domain.element_count(): + self.point_index = ExplicitQuadrature._point_index_domain + self.point_coords = ExplicitQuadrature._point_coords_domain + self.point_weight = ExplicitQuadrature._point_weight_domain + else: + raise NotImplementedError( + "The number of rows of points and weights must match the element count of either the domain or the geometry" + ) + self._points_per_cell = points.shape[1] + self._whole_geo = points.shape[0] == domain.geometry_element_count() self._points = points self._weights = weights @property def name(self): - return f"{self.__class__.__name__}" + return f"{self.__class__.__name__}_{self._whole_geo}" def total_point_count(self): return self._weights.size - def points_per_element(self): + def max_points_per_element(self): return self._points_per_cell @cache.cached_arg_value def arg_value(self, device): arg = self.Arg() - arg.points_per_cell = self._points_per_cell arg.points = self._points.to(device) arg.weights = self._weights.to(device) return arg @wp.func - def point_count(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex): - return qp_arg.points_per_cell + def point_count(elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex): + return qp_arg.points.shape[1] @wp.func - def point_coords(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex, qp_index: int): + def _point_coords_domain( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, qp_index: int + ): + return qp_arg.points[domain_element_index, qp_index] + + @wp.func + def _point_weight_domain( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, qp_index: int + ): + return qp_arg.weights[domain_element_index, qp_index] + + @wp.func + def _point_index_domain( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, qp_index: int + ): + return qp_arg.points_per_cell * domain_element_index + qp_index + + @wp.func + def _point_coords_geo( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, qp_index: int + ): return qp_arg.points[element_index, qp_index] @wp.func - def point_weight(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex, qp_index: int): + def _point_weight_geo( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, qp_index: int + ): return qp_arg.weights[element_index, qp_index] @wp.func - def point_index(elt_arg: Any, qp_arg: Arg, element_index: ElementIndex, qp_index: int): + def _point_index_geo( + elt_arg: Any, qp_arg: Arg, domain_element_index: ElementIndex, element_index: ElementIndex, qp_index: int + ): return qp_arg.points_per_cell * element_index + qp_index diff --git a/warp/fem/space/basis_space.py b/warp/fem/space/basis_space.py index dd7cdfd47..7275b5c68 100644 --- a/warp/fem/space/basis_space.py +++ b/warp/fem/space/basis_space.py @@ -4,10 +4,10 @@ from warp.fem import cache from warp.fem.geometry import Geometry from warp.fem.quadrature import Quadrature -from warp.fem.types import Coords, ElementIndex, make_free_sample +from warp.fem.types import NULL_ELEMENT_INDEX, Coords, ElementIndex, make_free_sample from .shape import ShapeFunction -from .topology import DiscontinuousSpaceTopology, SpaceTopology +from .topology import RegularDiscontinuousSpaceTopology, SpaceTopology class BasisSpace: @@ -28,8 +28,6 @@ class BasisArg: def __init__(self, topology: SpaceTopology): self._topology = topology - self.NODES_PER_ELEMENT = self._topology.NODES_PER_ELEMENT - @property def topology(self) -> SpaceTopology: """Underlying topology of the basis space""" @@ -49,8 +47,6 @@ def basis_arg_value(self, device) -> "BasisArg": def node_positions(self, out: Optional[wp.array] = None) -> wp.array: """Returns a temporary array containing the world position for each node""" - NODES_PER_ELEMENT = self.NODES_PER_ELEMENT - pos_type = cache.cached_vec_type(length=self.geometry.dimension, dtype=float) node_coords_in_element = self.make_node_coords_in_element() @@ -64,7 +60,8 @@ def fill_node_positions( ): element_index = wp.tid() - for n in range(NODES_PER_ELEMENT): + element_node_count = self.topology.element_node_count(geo_cell_arg, topo_arg, element_index) + for n in range(element_node_count): node_index = self.topology.element_node_index(geo_cell_arg, topo_arg, element_index, n) coords = node_coords_in_element(geo_cell_arg, basis_arg, element_index, n) @@ -139,6 +136,8 @@ def __init__(self, topology: SpaceTopology, shape: ShapeFunction): self.node_tets = self._node_tets if hasattr(shape, "element_node_hexes"): self.node_hexes = self._node_hexes + if hasattr(topology, "node_grid"): + self.node_grid = topology.node_grid @property def shape(self) -> ShapeFunction: @@ -302,7 +301,7 @@ def trace_element_inner_weight( node_index_in_elt: int, ): cell_index, index_in_cell = self.topology.inner_cell_index(geo_side_arg, element_index, node_index_in_elt) - if index_in_cell < 0: + if cell_index == NULL_ELEMENT_INDEX: return 0.0 cell_coords = self.geometry.side_inner_cell_coords(geo_side_arg, element_index, coords) @@ -330,7 +329,7 @@ def trace_element_outer_weight( node_index_in_elt: int, ): cell_index, index_in_cell = self.topology.outer_cell_index(geo_side_arg, element_index, node_index_in_elt) - if index_in_cell < 0: + if cell_index == NULL_ELEMENT_INDEX: return 0.0 cell_coords = self.geometry.side_outer_cell_coords(geo_side_arg, element_index, coords) @@ -359,7 +358,7 @@ def trace_element_inner_weight_gradient( node_index_in_elt: int, ): cell_index, index_in_cell = self.topology.inner_cell_index(geo_side_arg, element_index, node_index_in_elt) - if index_in_cell < 0: + if cell_index == NULL_ELEMENT_INDEX: return grad_vec_type(0.0) cell_coords = self.geometry.side_inner_cell_coords(geo_side_arg, element_index, coords) @@ -381,7 +380,7 @@ def trace_element_outer_weight_gradient( node_index_in_elt: int, ): cell_index, index_in_cell = self.topology.outer_cell_index(geo_side_arg, element_index, node_index_in_elt) - if index_in_cell < 0: + if cell_index == NULL_ELEMENT_INDEX: return grad_vec_type(0.0) cell_coords = self.geometry.side_outer_cell_coords(geo_side_arg, element_index, coords) @@ -419,7 +418,7 @@ def trace(self): def make_discontinuous_basis_space(geometry: Geometry, shape: ShapeFunction): - topology = DiscontinuousSpaceTopology(geometry, shape.NODES_PER_ELEMENT) + topology = RegularDiscontinuousSpaceTopology(geometry, shape.NODES_PER_ELEMENT) if shape.NODES_PER_ELEMENT == 1: # piecewise-constant space @@ -428,6 +427,70 @@ def make_discontinuous_basis_space(geometry: Geometry, shape: ShapeFunction): return ShapeBasisSpace(topology=topology, shape=shape) +class UnstructuredPointTopology(SpaceTopology): + """Topology for unstructured points defined from quadrature formula. See :class:`PointBasisSpace`""" + + def __init__(self, quadrature: Quadrature): + if quadrature.max_points_per_element() is None: + raise ValueError("Quadrature must define a maximum number of points per element") + + if quadrature.domain.element_count() != quadrature.domain.geometry_element_count(): + raise ValueError("Point topology may only be defined on quadrature domains than span the whole geometry") + + self._quadrature = quadrature + self.TopologyArg = quadrature.Arg + + super().__init__(quadrature.domain.geometry, max_nodes_per_element=quadrature.max_points_per_element()) + + self.element_node_index = self._make_element_node_index() + self.element_node_count = self._make_element_node_count() + self.side_neighbor_node_counts = self._make_side_neighbor_node_counts() + + def node_count(self): + return self._quadrature.total_point_count() + + @property + def name(self): + return f"PointTopology_{self._quadrature}" + + def topo_arg_value(self, device) -> SpaceTopology.TopologyArg: + """Value of the topology argument structure to be passed to device functions""" + return self._quadrature.arg_value(device) + + def _make_element_node_index(self): + @cache.dynamic_func(suffix=self.name) + def element_node_index( + elt_arg: self.geometry.CellArg, + topo_arg: self.TopologyArg, + element_index: ElementIndex, + node_index_in_elt: int, + ): + return self._quadrature.point_index(elt_arg, topo_arg, element_index, element_index, node_index_in_elt) + + return element_node_index + + def _make_element_node_count(self): + @cache.dynamic_func(suffix=self.name) + def element_node_count( + elt_arg: self.geometry.CellArg, + topo_arg: self.TopologyArg, + element_index: ElementIndex, + ): + return self._quadrature.point_count(elt_arg, topo_arg, element_index, element_index) + + return element_node_count + + def _make_side_neighbor_node_counts(self): + @cache.dynamic_func(suffix=self.name) + def side_neighbor_node_counts( + side_arg: self.geometry.SideArg, + element_index: ElementIndex, + ): + return 0, 0 + + return side_neighbor_node_counts + + class PointBasisSpace(BasisSpace): """An unstructured :class:`BasisSpace` that is non-zero at a finite set of points only. @@ -437,12 +500,7 @@ class PointBasisSpace(BasisSpace): def __init__(self, quadrature: Quadrature): self._quadrature = quadrature - if quadrature.points_per_element() is None: - raise NotImplementedError("Varying number of points per element is not supported yet") - - topology = DiscontinuousSpaceTopology( - geometry=quadrature.domain.geometry, nodes_per_element=quadrature.points_per_element() - ) + topology = UnstructuredPointTopology(quadrature) super().__init__(topology) self.BasisArg = quadrature.Arg @@ -464,7 +522,7 @@ def node_coords_in_element( element_index: ElementIndex, node_index_in_elt: int, ): - return self._quadrature.point_coords(elt_arg, basis_arg, element_index, node_index_in_elt) + return self._quadrature.point_coords(elt_arg, basis_arg, element_index, element_index, node_index_in_elt) return node_coords_in_element @@ -476,11 +534,13 @@ def node_quadrature_weight( element_index: ElementIndex, node_index_in_elt: int, ): - return self._quadrature.point_weight(elt_arg, basis_arg, element_index, node_index_in_elt) + return self._quadrature.point_weight(elt_arg, basis_arg, element_index, element_index, node_index_in_elt) return node_quadrature_weight def make_element_inner_weight(self): + _DIRAC_INTEGRATION_RADIUS = wp.constant(1.0e-6) + @cache.dynamic_func(suffix=self.name) def element_inner_weight( elt_arg: self._quadrature.domain.ElementArg, @@ -489,8 +549,10 @@ def element_inner_weight( coords: Coords, node_index_in_elt: int, ): - qp_coord = self._quadrature.point_coords(elt_arg, basis_arg, element_index, node_index_in_elt) - return wp.select(wp.length_sq(coords - qp_coord) < 0.001, 0.0, 1.0) + qp_coord = self._quadrature.point_coords( + elt_arg, basis_arg, element_index, element_index, node_index_in_elt + ) + return wp.select(wp.length_sq(coords - qp_coord) < _DIRAC_INTEGRATION_RADIUS, 0.0, 1.0) return element_inner_weight diff --git a/warp/fem/space/collocated_function_space.py b/warp/fem/space/collocated_function_space.py index cecb0ba87..250a5d675 100644 --- a/warp/fem/space/collocated_function_space.py +++ b/warp/fem/space/collocated_function_space.py @@ -36,7 +36,7 @@ def __init__(self, basis: BasisSpace, dtype: type = float, dof_mapper: DofMapper self.element_outer_weight_gradient = self._basis.make_element_outer_weight_gradient() # For backward compatibility - if hasattr(basis.topology, "node_grid"): + if hasattr(basis, "node_grid"): self.node_grid = basis.node_grid if hasattr(basis, "node_triangulation"): self.node_triangulation = basis.node_triangulation diff --git a/warp/fem/space/grid_2d_function_space.py b/warp/fem/space/grid_2d_function_space.py index d053b7f2b..a3c969812 100644 --- a/warp/fem/space/grid_2d_function_space.py +++ b/warp/fem/space/grid_2d_function_space.py @@ -72,7 +72,7 @@ def element_node_index( return element_node_index - def _node_grid(self): + def node_grid(self): res = self.geometry.res cell_coords = np.array(self._shape.LOBATTO_COORDS)[:-1] @@ -81,13 +81,13 @@ def _node_grid(self): cell_coords, reps=res[0] ) grid_coords_x = np.append(grid_coords_x, res[0]) - X = grid_coords_x * self._grid.cell_size[0] + self._grid.origin[0] + X = grid_coords_x * self.geometry.cell_size[0] + self.geometry.origin[0] grid_coords_y = np.repeat(np.arange(0, res[1], dtype=float), len(cell_coords)) + np.tile( cell_coords, reps=res[1] ) grid_coords_y = np.append(grid_coords_y, res[1]) - Y = grid_coords_y * self._grid.cell_size[1] + self._grid.origin[1] + Y = grid_coords_y * self.geometry.cell_size[1] + self.geometry.origin[1] return np.meshgrid(X, Y, indexing="ij") diff --git a/warp/fem/space/grid_3d_function_space.py b/warp/fem/space/grid_3d_function_space.py index 9f6f53f2b..53d665fc9 100644 --- a/warp/fem/space/grid_3d_function_space.py +++ b/warp/fem/space/grid_3d_function_space.py @@ -79,7 +79,7 @@ def element_node_index( return element_node_index - def _node_grid(self): + def node_grid(self): res = self.geometry.res cell_coords = np.array(self._shape.LOBATTO_COORDS)[:-1] @@ -88,19 +88,19 @@ def _node_grid(self): cell_coords, reps=res[0] ) grid_coords_x = np.append(grid_coords_x, res[0]) - X = grid_coords_x * self._grid.cell_size[0] + self._grid.origin[0] + X = grid_coords_x * self.geometry.cell_size[0] + self.geometry.origin[0] grid_coords_y = np.repeat(np.arange(0, res[1], dtype=float), len(cell_coords)) + np.tile( cell_coords, reps=res[1] ) grid_coords_y = np.append(grid_coords_y, res[1]) - Y = grid_coords_y * self._grid.cell_size[1] + self._grid.origin[1] + Y = grid_coords_y * self.geometry.cell_size[1] + self.geometry.origin[1] grid_coords_z = np.repeat(np.arange(0, res[2], dtype=float), len(cell_coords)) + np.tile( cell_coords, reps=res[2] ) grid_coords_z = np.append(grid_coords_z, res[2]) - Z = grid_coords_z * self._grid.cell_size[2] + self._grid.origin[2] + Z = grid_coords_z * self.geometry.cell_size[2] + self.geometry.origin[2] return np.meshgrid(X, Y, Z, indexing="ij") diff --git a/warp/fem/space/hexmesh_function_space.py b/warp/fem/space/hexmesh_function_space.py index afd6b9da1..636244b4a 100644 --- a/warp/fem/space/hexmesh_function_space.py +++ b/warp/fem/space/hexmesh_function_space.py @@ -142,8 +142,9 @@ def _rotate_face_index(type_index: int, ori: int, size: int): fv = ori // 2 - rot_i = wp.dot(_FACE_ORIENTATION_I[2 * ori], coords) + _FACE_TRANSLATION_I[fv, 0] - rot_j = wp.dot(_FACE_ORIENTATION_I[2 * ori + 1], coords) + _FACE_TRANSLATION_I[fv, 1] + # face indices from shape function always have positive orientation, drop `ori % 2` + rot_i = wp.dot(_FACE_ORIENTATION_I[4 * fv], coords) + _FACE_TRANSLATION_I[fv, 0] + rot_j = wp.dot(_FACE_ORIENTATION_I[4 * fv + 1], coords) + _FACE_TRANSLATION_I[fv, 1] return rot_i * size + rot_j diff --git a/warp/fem/space/nanogrid_function_space.py b/warp/fem/space/nanogrid_function_space.py index 091ae88b4..d0ed44836 100644 --- a/warp/fem/space/nanogrid_function_space.py +++ b/warp/fem/space/nanogrid_function_space.py @@ -41,25 +41,23 @@ def __init__( self._grid = grid self._shape = shape - if need_edge_indices: - self._edge_count = self._grid.edge_count() - else: - self._edge_count = 0 + self._vertex_grid = grid.vertex_grid.id - self._vertex_grid = grid._node_grid - self._face_grid = grid._face_grid - self._edge_grid = grid._edge_grid + self._edge_grid = grid.edge_grid.id if need_edge_indices else -1 + self._face_grid = grid.face_grid.id if need_face_indices else -1 + self._edge_count = grid.edge_count() if need_edge_indices else 0 + self._face_count = grid.side_count() if need_face_indices else 0 @cache.cached_arg_value def topo_arg_value(self, device): arg = NanogridTopologyArg() - arg.vertex_grid = self._vertex_grid.id - arg.face_grid = self._face_grid.id - arg.edge_grid = -1 if self._edge_grid is None else self._edge_grid.id + arg.vertex_grid = self._vertex_grid + arg.face_grid = self._face_grid + arg.edge_grid = self._edge_grid arg.vertex_count = self._grid.vertex_count() - arg.face_count = self._grid.side_count() + arg.face_count = self._face_count arg.edge_count = self._edge_count return arg @@ -98,8 +96,8 @@ def node_count(self) -> int: return ( self._grid.vertex_count() - + self._grid.edge_count() * INTERIOR_NODES_PER_EDGE - + self._grid.side_count() * INTERIOR_NODES_PER_FACE + + self._edge_count * INTERIOR_NODES_PER_EDGE + + self._face_count * INTERIOR_NODES_PER_FACE + self._grid.cell_count() * INTERIOR_NODES_PER_CELL ) @@ -160,7 +158,7 @@ def __init__(self, grid: Nanogrid, shape: CubeSerendipityShapeFunctions): self.element_node_index = self._make_element_node_index() def node_count(self) -> int: - return self.geometry.vertex_count() + (self._shape.ORDER - 1) * self.geometry.edge_count() + return self.geometry.vertex_count() + (self._shape.ORDER - 1) * self._edge_count def _make_element_node_index(self): ORDER = self._shape.ORDER diff --git a/warp/fem/space/partition.py b/warp/fem/space/partition.py index 55718290c..207f160ce 100644 --- a/warp/fem/space/partition.py +++ b/warp/fem/space/partition.py @@ -1,12 +1,7 @@ from typing import Any, Optional import warp as wp -from warp.fem.cache import ( - TemporaryStore, - borrow_temporary, - borrow_temporary_like, - cached_arg_value, -) +import warp.fem.cache as cache from warp.fem.geometry import GeometryPartition, WholeGeometryPartition from warp.fem.types import NULL_NODE_INDEX from warp.fem.utils import _iota_kernel, compress_node_indices @@ -42,7 +37,7 @@ def partition_arg_value(self, device): @staticmethod def partition_node_index(args: "PartitionArg", space_node_index: int): - """Returns the index in the partition of a function space node, or -1 if it does not exist""" + """Returns the index in the partition of a function space node, or ``NULL_NODE_INDEX`` if it does not exist""" def __str__(self) -> str: return self.name @@ -76,7 +71,7 @@ def interior_node_count(self) -> int: def space_node_indices(self): """Return the global function space indices for nodes in this partition""" if self._node_indices is None: - self._node_indices = borrow_temporary(temporary_store=None, shape=(self.node_count(),), dtype=int) + self._node_indices = cache.borrow_temporary(temporary_store=None, shape=(self.node_count(),), dtype=int) wp.launch(kernel=_iota_kernel, dim=self.node_count(), inputs=[self._node_indices.array, 1]) return self._node_indices.array @@ -121,7 +116,7 @@ def __init__( geo_partition: GeometryPartition, with_halo: bool = True, device=None, - temporary_store: TemporaryStore = None, + temporary_store: cache.TemporaryStore = None, ): super().__init__(space_topology=space_topology, geo_partition=geo_partition) @@ -143,7 +138,7 @@ def space_node_indices(self): """Return the global function space indices for nodes in this partition""" return self._node_indices.array - @cached_arg_value + @cache.cached_arg_value def partition_arg_value(self, device): arg = NodePartition.PartitionArg() arg.space_to_partition = self._space_to_partition.array.to(device) @@ -153,12 +148,10 @@ def partition_arg_value(self, device): def partition_node_index(args: PartitionArg, space_node_index: int): return args.space_to_partition[space_node_index] - def _compute_node_indices_from_sides(self, device, with_halo: bool, temporary_store: TemporaryStore): + def _compute_node_indices_from_sides(self, device, with_halo: bool, temporary_store: cache.TemporaryStore): from warp.fem import cache trace_topology = self.space_topology.trace() - NODES_PER_CELL = self.space_topology.NODES_PER_ELEMENT - NODES_PER_SIDE = trace_topology.NODES_PER_ELEMENT @cache.dynamic_kernel(suffix=f"{self.geo_partition.name}_{self.space_topology.name}") def node_category_from_cells_kernel( @@ -171,7 +164,8 @@ def node_category_from_cells_kernel( cell_index = self.geo_partition.cell_index(geo_partition_arg, partition_cell_index) - for n in range(NODES_PER_CELL): + cell_node_count = self.space_topology.element_node_count(geo_arg, space_arg, cell_index) + for n in range(cell_node_count): space_nidx = self.space_topology.element_node_index(geo_arg, space_arg, cell_index, n) node_mask[space_nidx] = NodeCategory.OWNED_INTERIOR @@ -186,7 +180,8 @@ def node_category_from_owned_sides_kernel( side_index = self.geo_partition.side_index(geo_partition_arg, partition_side_index) - for n in range(NODES_PER_SIDE): + side_node_count = trace_topology.element_node_count(geo_arg, space_arg, side_index) + for n in range(side_node_count): space_nidx = trace_topology.element_node_index(geo_arg, space_arg, side_index, n) if node_mask[space_nidx] == NodeCategory.EXTERIOR: @@ -203,14 +198,15 @@ def node_category_from_frontier_sides_kernel( side_index = self.geo_partition.frontier_side_index(geo_partition_arg, frontier_side_index) - for n in range(NODES_PER_SIDE): + side_node_count = trace_topology.element_node_count(geo_arg, space_arg, side_index) + for n in range(side_node_count): space_nidx = trace_topology.element_node_index(geo_arg, space_arg, side_index, n) if node_mask[space_nidx] == NodeCategory.EXTERIOR: node_mask[space_nidx] = NodeCategory.HALO_OTHER_SIDE elif node_mask[space_nidx] == NodeCategory.OWNED_INTERIOR: node_mask[space_nidx] = NodeCategory.OWNED_FRONTIER - node_category = borrow_temporary( + node_category = cache.borrow_temporary( temporary_store, shape=(self.space_topology.node_count(),), dtype=int, @@ -259,50 +255,52 @@ def node_category_from_frontier_sides_kernel( node_category.release() - def _finalize_node_indices(self, node_category: wp.array(dtype=int), temporary_store: TemporaryStore): - category_offsets, node_indices, _, __ = compress_node_indices(NodeCategory.COUNT, node_category) + def _finalize_node_indices(self, node_category: wp.array(dtype=int), temporary_store: cache.TemporaryStore): + category_offsets, node_indices = compress_node_indices( + NodeCategory.COUNT, node_category, temporary_store=temporary_store + ) # Copy offsets to cpu device = node_category.device - self._category_offsets = borrow_temporary( - temporary_store, - shape=category_offsets.array.shape, - dtype=category_offsets.array.dtype, - pinned=device.is_cuda, - device="cpu", - ) - wp.copy(src=category_offsets.array, dest=self._category_offsets.array) - - if device.is_cuda: - # TODO switch to synchronize_event once available - wp.synchronize_stream(wp.get_stream(device)) - - category_offsets.release() + with wp.ScopedDevice(device): + self._category_offsets = cache.borrow_temporary( + temporary_store, + shape=category_offsets.array.shape, + dtype=category_offsets.array.dtype, + pinned=device.is_cuda, + device="cpu", + ) + wp.copy(src=category_offsets.array, dest=self._category_offsets.array) + copy_event = cache.capture_event() - # Compute global to local indices - self._space_to_partition = borrow_temporary_like(node_indices, temporary_store) - wp.launch( - kernel=NodePartition._scatter_partition_indices, - dim=self.space_topology.node_count(), - device=device, - inputs=[self.node_count(), node_indices.array, self._space_to_partition.array], - ) + # Compute global to local indices + self._space_to_partition = cache.borrow_temporary_like(node_indices, temporary_store) + wp.launch( + kernel=NodePartition._scatter_partition_indices, + dim=self.space_topology.node_count(), + device=device, + inputs=[category_offsets.array, node_indices.array, self._space_to_partition.array], + ) - # Copy to shrinked-to-fit array - self._node_indices = borrow_temporary(temporary_store, shape=(self.node_count()), dtype=int, device=device) - wp.copy(dest=self._node_indices.array, src=node_indices.array, count=self.node_count()) + # Copy to shrinked-to-fit array + cache.synchronize_event(copy_event) # Transfer to host must be finished to access node_count() + self._node_indices = cache.borrow_temporary( + temporary_store, shape=(self.node_count()), dtype=int, device=device + ) + wp.copy(dest=self._node_indices.array, src=node_indices.array, count=self.node_count()) - node_indices.release() + node_indices.release() @wp.kernel def _scatter_partition_indices( - local_node_count: int, + category_offsets: wp.array(dtype=int), node_indices: wp.array(dtype=int), space_to_partition_indices: wp.array(dtype=int), ): local_idx = wp.tid() space_idx = node_indices[local_idx] + local_node_count = category_offsets[NodeCategory.EXTERIOR] # all but exterior nodes if local_idx < local_node_count: space_to_partition_indices[space_idx] = local_idx else: @@ -315,7 +313,7 @@ def make_space_partition( space_topology: Optional[SpaceTopology] = None, with_halo: bool = True, device=None, - temporary_store: TemporaryStore = None, + temporary_store: cache.TemporaryStore = None, ) -> SpacePartition: """Computes the subset of nodes from a function space topology that touch a geometry partition diff --git a/warp/fem/space/restriction.py b/warp/fem/space/restriction.py index 561f786eb..37bf2250a 100644 --- a/warp/fem/space/restriction.py +++ b/warp/fem/space/restriction.py @@ -1,7 +1,7 @@ import warp as wp from warp.fem.cache import TemporaryStore, borrow_temporary, borrow_temporary_like, cached_arg_value from warp.fem.domain import GeometryDomain -from warp.fem.types import NodeElementIndex +from warp.fem.types import NULL_NODE_INDEX, NodeElementIndex from warp.fem.utils import compress_node_indices from .partition import SpacePartition @@ -36,7 +36,7 @@ def __init__( def _compute_node_element_indices(self, device, temporary_store: TemporaryStore): from warp.fem import cache - NODES_PER_ELEMENT = self.space_topology.NODES_PER_ELEMENT + MAX_NODES_PER_ELEMENT = self.space_topology.MAX_NODES_PER_ELEMENT @cache.dynamic_kernel( suffix=f"{self.domain.name}_{self.space_topology.name}_{self.space_partition.name}", @@ -51,14 +51,17 @@ def fill_element_node_indices( ): domain_element_index = wp.tid() element_index = self.domain.element_index(domain_index_arg, domain_element_index) - for n in range(NODES_PER_ELEMENT): + element_node_count = self.space_topology.element_node_count(element_arg, topo_arg, element_index) + for n in range(element_node_count): space_nidx = self.space_topology.element_node_index(element_arg, topo_arg, element_index, n) partition_nidx = self.space_partition.partition_node_index(partition_arg, space_nidx) element_node_indices[domain_element_index, n] = partition_nidx + for n in range(element_node_count, MAX_NODES_PER_ELEMENT): + element_node_indices[domain_element_index, n] = NULL_NODE_INDEX element_node_indices = borrow_temporary( temporary_store, - shape=(self.domain.element_count(), NODES_PER_ELEMENT), + shape=(self.domain.element_count(), MAX_NODES_PER_ELEMENT), dtype=int, device=device, ) @@ -83,7 +86,10 @@ def fill_element_node_indices( self._node_count, self._dof_partition_indices, ) = compress_node_indices( - self.space_partition.node_count(), flattened_node_indices, temporary_store=temporary_store + self.space_partition.node_count(), + flattened_node_indices, + return_unique_nodes=True, + temporary_store=temporary_store, ) # Extract element index and index in element @@ -93,7 +99,7 @@ def fill_element_node_indices( kernel=SpaceRestriction._split_vertex_element_index, dim=flattened_node_indices.shape, inputs=[ - NODES_PER_ELEMENT, + MAX_NODES_PER_ELEMENT, node_array_indices.array, self._dof_element_indices.array, self._dof_indices_in_element.array, @@ -132,20 +138,17 @@ def node_arg(self, device): return arg @wp.func - def node_partition_index(args: NodeArg, node_index: int): - return args.dof_partition_indices[node_index] + def node_partition_index(args: NodeArg, restriction_node_index: int): + return args.dof_partition_indices[restriction_node_index] @wp.func - def node_element_count(args: NodeArg, node_index: int): - partition_node_index = SpaceRestriction.node_partition_index(args, node_index) - return args.dof_element_offsets[partition_node_index + 1] - args.dof_element_offsets[partition_node_index] + def node_element_range(args: NodeArg, partition_node_index: int): + return args.dof_element_offsets[partition_node_index], args.dof_element_offsets[partition_node_index + 1] @wp.func - def node_element_index(args: NodeArg, node_index: int, element_index: int): - partition_node_index = SpaceRestriction.node_partition_index(args, node_index) - offset = args.dof_element_offsets[partition_node_index] + element_index - domain_element_index = args.dof_element_indices[offset] - index_in_element = args.dof_indices_in_element[offset] + def node_element_index(args: NodeArg, node_element_offset: int): + domain_element_index = args.dof_element_indices[node_element_offset] + index_in_element = args.dof_indices_in_element[node_element_offset] return NodeElementIndex(domain_element_index, index_in_element) @wp.kernel diff --git a/warp/fem/space/shape/cube_shape_function.py b/warp/fem/space/shape/cube_shape_function.py index a2ce5983a..d064ca031 100644 --- a/warp/fem/space/shape/cube_shape_function.py +++ b/warp/fem/space/shape/cube_shape_function.py @@ -98,7 +98,7 @@ def node_type_and_type_index( # x face type_instance = mi - type_index = wp.select(mi == 1, (j - 1) * (ORDER - 1) + k - 1, (k - 1) * (ORDER - 1) + j - 1) + type_index = (j - 1) * (ORDER - 1) + k - 1 return CubeTripolynomialShapeFunctions.FACE, type_instance, type_index if zj + mj == 1: @@ -110,13 +110,13 @@ def node_type_and_type_index( # y face type_instance = 2 + mj - type_index = wp.select(mj == 1, (i - 1) * (ORDER - 1) + k - 1, (k - 1) * (ORDER - 1) + i - 1) + type_index = (k - 1) * (ORDER - 1) + i - 1 return CubeTripolynomialShapeFunctions.FACE, type_instance, type_index if zk + mk == 1: # z face type_instance = 4 + mk - type_index = wp.select(mk == 1, (j - 1) * (ORDER - 1) + i - 1, (i - 1) * (ORDER - 1) + j - 1) + type_index = (i - 1) * (ORDER - 1) + j - 1 return CubeTripolynomialShapeFunctions.FACE, type_instance, type_index type_index = ((i - 1) * (ORDER - 1) + (j - 1)) * (ORDER - 1) + k - 1 diff --git a/warp/fem/space/shape/tet_shape_function.py b/warp/fem/space/shape/tet_shape_function.py index c3093aafe..e2c753e37 100644 --- a/warp/fem/space/shape/tet_shape_function.py +++ b/warp/fem/space/shape/tet_shape_function.py @@ -92,7 +92,6 @@ def _tet_node_index(tx: int, ty: int, tz: int, degree: int): class TetrahedronPolynomialShapeFunctions: - INVALID = wp.constant(-1) VERTEX = wp.constant(0) EDGE = wp.constant(1) FACE = wp.constant(2) @@ -138,14 +137,10 @@ def node_tet_coordinates( def _get_node_type_and_type_index(self): ORDER = self.ORDER - NODES_PER_ELEMENT = self.NODES_PER_ELEMENT def node_type_and_index( node_index_in_elt: int, ): - if node_index_in_elt < 0 or node_index_in_elt >= NODES_PER_ELEMENT: - return TetrahedronPolynomialShapeFunctions.INVALID, TetrahedronPolynomialShapeFunctions.INVALID - if node_index_in_elt < 4: return TetrahedronPolynomialShapeFunctions.VERTEX, node_index_in_elt diff --git a/warp/fem/space/topology.py b/warp/fem/space/topology.py index d14b3af35..24f5cda54 100644 --- a/warp/fem/space/topology.py +++ b/warp/fem/space/topology.py @@ -1,9 +1,9 @@ -from typing import Optional, Type +from typing import Optional, Tuple, Type import warp as wp from warp.fem import cache from warp.fem.geometry import DeformedGeometry, Geometry -from warp.fem.types import ElementIndex +from warp.fem.types import NULL_ELEMENT_INDEX, NULL_NODE_INDEX, ElementIndex class SpaceTopology: @@ -18,8 +18,8 @@ class SpaceTopology: dimension: int """Embedding dimension of the function space""" - NODES_PER_ELEMENT: int - """Number of interpolation nodes per element of the geometry. + MAX_NODES_PER_ELEMENT: int + """maximum number of interpolation nodes per element of the geometry. .. note:: This will change to be defined per-element in future versions """ @@ -30,12 +30,14 @@ class TopologyArg: pass - def __init__(self, geometry: Geometry, nodes_per_element: int): + def __init__(self, geometry: Geometry, max_nodes_per_element: int): self._geometry = geometry self.dimension = geometry.dimension - self.NODES_PER_ELEMENT = wp.constant(nodes_per_element) + self.MAX_NODES_PER_ELEMENT = wp.constant(max_nodes_per_element) self.ElementArg = geometry.CellArg + self._make_constant_element_node_count() + @property def geometry(self) -> Geometry: """Underlying geometry""" @@ -51,25 +53,42 @@ def topo_arg_value(self, device) -> "TopologyArg": @property def name(self): - return f"{self.__class__.__name__}_{self.NODES_PER_ELEMENT}" + return f"{self.__class__.__name__}_{self.MAX_NODES_PER_ELEMENT}" def __str__(self): return self.name + @staticmethod + def element_node_count( + geo_arg: "ElementArg", # noqa: F821 + topo_arg: "TopologyArg", + element_index: ElementIndex, + ) -> int: + """Returns the actual number of nodes in a given element""" + raise NotImplementedError + @staticmethod def element_node_index( geo_arg: "ElementArg", # noqa: F821 topo_arg: "TopologyArg", element_index: ElementIndex, node_index_in_elt: int, - ): + ) -> int: """Global node index for a given node in a given element""" raise NotImplementedError + @staticmethod + def side_neighbor_node_counts( + side_arg: "ElementArg", # noqa: F821 + side_index: ElementIndex, + ) -> Tuple[int, int]: + """Returns the number of nodes for both the inner and outer cells of a given sides""" + raise NotImplementedError + def element_node_indices(self, out: Optional[wp.array] = None) -> wp.array: """Returns a temporary array containing the global index for each node of each element""" - NODES_PER_ELEMENT = self.NODES_PER_ELEMENT + MAX_NODES_PER_ELEMENT = self.MAX_NODES_PER_ELEMENT @cache.dynamic_kernel(suffix=self.name) def fill_element_node_indices( @@ -78,12 +97,13 @@ def fill_element_node_indices( element_node_indices: wp.array2d(dtype=int), ): element_index = wp.tid() - for n in range(NODES_PER_ELEMENT): + element_node_count = self.element_node_count(geo_cell_arg, topo_arg, element_index) + for n in range(element_node_count): element_node_indices[element_index, n] = self.element_node_index( geo_cell_arg, topo_arg, element_index, n ) - shape = (self.geometry.cell_count(), NODES_PER_ELEMENT) + shape = (self.geometry.cell_count(), MAX_NODES_PER_ELEMENT) if out is None: element_node_indices = wp.empty( shape=shape, @@ -135,14 +155,36 @@ def is_derived_from(self, other: "SpaceTopology") -> bool: return self.full_space_topology() == other return False + def _make_constant_element_node_count(self): + NODES_PER_ELEMENT = wp.constant(self.MAX_NODES_PER_ELEMENT) + + @cache.dynamic_func(suffix=self.name) + def constant_element_node_count( + geo_arg: self.geometry.CellArg, + topo_arg: self.TopologyArg, + element_index: ElementIndex, + ): + return NODES_PER_ELEMENT + + @cache.dynamic_func(suffix=self.name) + def constant_side_neighbor_node_counts( + side_arg: self.geometry.SideArg, + element_index: ElementIndex, + ): + return NODES_PER_ELEMENT, NODES_PER_ELEMENT + + self.element_node_count = constant_element_node_count + self.side_neighbor_node_counts = constant_side_neighbor_node_counts + class TraceSpaceTopology(SpaceTopology): """Auto-generated trace topology defining the node indices associated to the geometry sides""" def __init__(self, topo: SpaceTopology): - super().__init__(topo.geometry, 2 * topo.NODES_PER_ELEMENT) - self._topo = topo + + super().__init__(topo.geometry, 2 * topo.MAX_NODES_PER_ELEMENT) + self.dimension = topo.dimension - 1 self.ElementArg = topo.geometry.SideArg @@ -154,6 +196,8 @@ def __init__(self, topo: SpaceTopology): self.neighbor_cell_index = self._make_neighbor_cell_index() self.element_node_index = self._make_element_node_index() + self.element_node_count = self._make_element_node_count() + self.side_neighbor_node_counts = None def node_count(self) -> int: return self._topo.node_count() @@ -163,39 +207,51 @@ def name(self): return f"{self._topo.name}_Trace" def _make_inner_cell_index(self): - NODES_PER_ELEMENT = self._topo.NODES_PER_ELEMENT - @cache.dynamic_func(suffix=self.name) - def inner_cell_index(args: self.geometry.SideArg, element_index: ElementIndex, node_index_in_elt: int): - index_in_inner_cell = wp.select(node_index_in_elt < NODES_PER_ELEMENT, -1, node_index_in_elt) - return self.geometry.side_inner_cell_index(args, element_index), index_in_inner_cell + def inner_cell_index(side_arg: self.geometry.SideArg, element_index: ElementIndex, node_index_in_elt: int): + inner_count, outer_count = self._topo.side_neighbor_node_counts(side_arg, element_index) + if node_index_in_elt >= inner_count: + return NULL_ELEMENT_INDEX, NULL_NODE_INDEX + return self.geometry.side_inner_cell_index(side_arg, element_index), node_index_in_elt return inner_cell_index def _make_outer_cell_index(self): - NODES_PER_ELEMENT = self._topo.NODES_PER_ELEMENT - @cache.dynamic_func(suffix=self.name) - def outer_cell_index(args: self.geometry.SideArg, element_index: ElementIndex, node_index_in_elt: int): - return self.geometry.side_outer_cell_index(args, element_index), node_index_in_elt - NODES_PER_ELEMENT + def outer_cell_index(side_arg: self.geometry.SideArg, element_index: ElementIndex, node_index_in_elt: int): + inner_count, outer_count = self._topo.side_neighbor_node_counts(side_arg, element_index) + if node_index_in_elt < inner_count: + return NULL_ELEMENT_INDEX, NULL_NODE_INDEX + return self.geometry.side_outer_cell_index(side_arg, element_index), node_index_in_elt - inner_count return outer_cell_index def _make_neighbor_cell_index(self): - NODES_PER_ELEMENT = self._topo.NODES_PER_ELEMENT - @cache.dynamic_func(suffix=self.name) - def neighbor_cell_index(args: self.geometry.SideArg, element_index: ElementIndex, node_index_in_elt: int): - if node_index_in_elt < NODES_PER_ELEMENT: - return self.geometry.side_inner_cell_index(args, element_index), node_index_in_elt - else: - return ( - self.geometry.side_outer_cell_index(args, element_index), - node_index_in_elt - NODES_PER_ELEMENT, - ) + def neighbor_cell_index(side_arg: self.geometry.SideArg, element_index: ElementIndex, node_index_in_elt: int): + inner_count, outer_count = self._topo.side_neighbor_node_counts(side_arg, element_index) + if node_index_in_elt < inner_count: + return self.geometry.side_inner_cell_index(side_arg, element_index), node_index_in_elt + + return ( + self.geometry.side_outer_cell_index(side_arg, element_index), + node_index_in_elt - inner_count, + ) return neighbor_cell_index + def _make_element_node_count(self): + @cache.dynamic_func(suffix=self.name) + def trace_element_node_count( + geo_side_arg: self.geometry.SideArg, + topo_arg: self._topo.TopologyArg, + element_index: ElementIndex, + ): + inner_count, outer_count = self._topo.side_neighbor_node_counts(geo_side_arg, element_index) + return inner_count + outer_count + + return trace_element_node_count + def _make_element_node_index(self): @cache.dynamic_func(suffix=self.name) def trace_element_node_index( @@ -219,7 +275,7 @@ def __eq__(self, other: "TraceSpaceTopology") -> bool: return self._topo == other._topo -class DiscontinuousSpaceTopologyMixin: +class RegularDiscontinuousSpaceTopologyMixin: """Helper for defining discontinuous topologies (per-element nodes)""" def __init__(self, *args, **kwargs): @@ -227,14 +283,14 @@ def __init__(self, *args, **kwargs): self.element_node_index = self._make_element_node_index() def node_count(self): - return self.geometry.cell_count() * self.NODES_PER_ELEMENT + return self.geometry.cell_count() * self.MAX_NODES_PER_ELEMENT @property def name(self): - return f"{self.geometry.name}_D{self.NODES_PER_ELEMENT}" + return f"{self.geometry.name}_D{self.MAX_NODES_PER_ELEMENT}" def _make_element_node_index(self): - NODES_PER_ELEMENT = self.NODES_PER_ELEMENT + NODES_PER_ELEMENT = self.MAX_NODES_PER_ELEMENT @cache.dynamic_func(suffix=self.name) def element_node_index( @@ -248,7 +304,7 @@ def element_node_index( return element_node_index -class DiscontinuousSpaceTopology(DiscontinuousSpaceTopologyMixin, SpaceTopology): +class RegularDiscontinuousSpaceTopology(RegularDiscontinuousSpaceTopologyMixin, SpaceTopology): """Topology for generic discontinuous spaces""" pass @@ -256,20 +312,20 @@ class DiscontinuousSpaceTopology(DiscontinuousSpaceTopologyMixin, SpaceTopology) class DeformedGeometrySpaceTopology(SpaceTopology): def __init__(self, geometry: DeformedGeometry, base_topology: SpaceTopology): - super().__init__(geometry, base_topology.NODES_PER_ELEMENT) - self.base = base_topology + super().__init__(geometry, base_topology.MAX_NODES_PER_ELEMENT) + self.node_count = self.base.node_count self.topo_arg_value = self.base.topo_arg_value self.TopologyArg = self.base.TopologyArg - self.element_node_index = self._make_element_node_index() + self._make_passthrough_functions() @property def name(self): return f"{self.base.name}_{self.geometry.field.name}" - def _make_element_node_index(self): + def _make_passthrough_functions(self): @cache.dynamic_func(suffix=self.name) def element_node_index( elt_arg: self.geometry.CellArg, @@ -279,7 +335,25 @@ def element_node_index( ): return self.base.element_node_index(elt_arg.elt_arg, topo_arg, element_index, node_index_in_elt) - return element_node_index + @cache.dynamic_func(suffix=self.name) + def element_node_count( + elt_arg: self.geometry.CellArg, + topo_arg: self.TopologyArg, + element_count: ElementIndex, + ): + return self.base.element_node_count(elt_arg.elt_arg, topo_arg, element_count) + + @cache.dynamic_func(suffix=self.name) + def side_neighbor_node_counts( + side_arg: self.geometry.SideArg, + element_index: ElementIndex, + ): + inner_count, outer_count = self.base.side_neighbor_node_counts(side_arg.base_arg, element_index) + return inner_count, outer_count + + self.element_node_index = element_node_index + self.element_node_count = element_node_count + self.side_neighbor_node_counts = side_neighbor_node_counts def forward_base_topology(topology_class: Type[SpaceTopology], geometry: Geometry, *args, **kwargs) -> SpaceTopology: diff --git a/warp/fem/types.py b/warp/fem/types.py index c0376abeb..0a2b39ef8 100644 --- a/warp/fem/types.py +++ b/warp/fem/types.py @@ -14,7 +14,7 @@ NULL_ELEMENT_INDEX = wp.constant(-1) NULL_QP_INDEX = wp.constant(-1) -NULL_NODE_INDEX = wp.constant(-1) +NULL_NODE_INDEX = wp.constant((1 << 31) - 1) # this should be larger than normal nodes when sorting DofIndex = wp.vec2i """Opaque descriptor for indexing degrees of freedom within elements""" diff --git a/warp/fem/utils.py b/warp/fem/utils.py index b24d0e720..42ccfa71d 100644 --- a/warp/fem/utils.py +++ b/warp/fem/utils.py @@ -1,14 +1,10 @@ -from typing import Any, Tuple +from typing import Any, Tuple, Union import numpy as np import warp as wp -from warp.fem.cache import ( - Temporary, - TemporaryStore, - borrow_temporary, - borrow_temporary_like, -) +import warp.fem.cache as cache +from warp.fem.types import NULL_NODE_INDEX from warp.utils import array_scan, radix_sort_pairs, runlength_encode @@ -115,121 +111,331 @@ def skew_part(x: wp.mat33): return wp.vec3(a, b, c) +@wp.func +def householder_qr_decomposition(A: Any): + """ + QR decomposition of a square matrix using Householder reflections + + Returns Q and R such that Q R = A, Q orthonormal (such that QQ^T = Id), R upper triangular + """ + + x = type(A[0])() + Q = wp.identity(n=type(x).length, dtype=A.dtype) + + zero = x.dtype(0.0) + two = x.dtype(2.0) + + for i in range(type(x).length): + for k in range(type(x).length): + x[k] = wp.select(k < i, A[k, i], zero) + + alpha = wp.length(x) * wp.sign(x[i]) + x[i] += alpha + two_over_x_sq = wp.select(alpha == zero, two / wp.length_sq(x), zero) + + A -= wp.outer(two_over_x_sq * x, x * A) + Q -= wp.outer(Q * x, two_over_x_sq * x) + + return Q, A + + +@wp.func +def householder_make_hessenberg(A: Any): + """Transforms a square matrix to Hessenberg form (single lower diagonal) using Householder reflections + + Returns: + Q and H such that Q H Q^T = A, Q orthonormal, H under Hessenberg form + If A is symmetric, H will be tridiagonal + """ + + x = type(A[0])() + Q = wp.identity(n=type(x).length, dtype=A.dtype) + + zero = x.dtype(0.0) + two = x.dtype(2.0) + + for i in range(1, type(x).length): + for k in range(type(x).length): + x[k] = wp.select(k < i, A[k, i - 1], zero) + + alpha = wp.length(x) * wp.sign(x[i]) + x[i] += alpha + two_over_x_sq = wp.select(alpha == zero, two / wp.length_sq(x), zero) + + # apply on both sides + A -= wp.outer(two_over_x_sq * x, x * A) + A -= wp.outer(A * x, two_over_x_sq * x) + Q -= wp.outer(Q * x, two_over_x_sq * x) + + return Q, A + + +@wp.func +def solve_triangular(R: Any, b: Any): + """Solves for R x = b where R is an upper triangular matrix + + Returns x + """ + zero = b.dtype(0) + x = type(b)(b.dtype(0)) + for i in range(b.length, 0, -1): + j = i - 1 + r = b[j] - wp.dot(R[j], x) + x[j] = wp.select(R[j, j] == zero, r / R[j, j], zero) + + return x + + +@wp.func +def inverse_qr(A: Any): + # Computes a square matrix inverse using QR factorization + + Q, R = householder_qr_decomposition(A) + + A_inv = type(A)() + for i in range(type(A[0]).length): + A_inv[i] = solve_triangular(R, Q[i]) # ith column of Q^T + + return wp.transpose(A_inv) + + +@wp.func +def symmetric_eigenvalues_qr(A: Any, tol: Any): + """ + Computes the eigenvalues and eigen vectors of a square symmetric matrix A using the QR algorithm + + Args: + A: square symmetric matrix + tol: Tolerance for the diagonalization residual (squared L2 norm of off-diagonal terms) + + Returns a tuple (D: vector of eigenvalues, P: matrix with one eigenvector per row) such that A = P^T D P + """ + + two = A.dtype(2.0) + zero = A.dtype(0.0) + + # temp storage for matrix rows + ri = type(A[0])() + rn = type(ri)() + + # tridiagonal storage for R + R_L = type(ri)() + R_L = type(ri)(zero) + R_U = type(ri)(zero) + + # so that we can use the type length in expression + # this will prevent unrolling by warp, but should be ok for native code + m = int(0) + for _ in range(type(ri).length): + m += 1 + + # Put A under Hessenberg form (tridiagonal) + Q, H = householder_make_hessenberg(A) + Q = wp.transpose(Q) # algorithm below works and transposed Q as rows are easier to index + + for _ in range(16 * m): # failsafe, usually converges faster than that + # Initialize R with current H + R_D = wp.get_diag(H) + for i in range(1, type(ri).length): + R_L[i - 1] = H[i, i - 1] + R_U[i - 1] = H[i - 1, i] + + # compute QR decomposition, directly transform H and eigenvectors + for n in range(1, m): + i = n - 1 + + # compute reflection + xi = R_D[i] + xn = R_L[i] + + xii = xi * xi + xnn = xn * xn + alpha = wp.sqrt(xii + xnn) * wp.sign(xi) + + xi += alpha + xii = xi * xi + xin = xi * xn + + two_over_x_sq = wp.select(alpha == zero, two / (xii + xnn), zero) + xii *= two_over_x_sq + xin *= two_over_x_sq + xnn *= two_over_x_sq + + # Left-multiply R and Q, multiply H on both sides + # Note that R should get non-zero coefficients on the second upper diagonal, + # but those won't get read afterwards, so we can ignore them + + R_D[n] -= R_U[i] * xin + R_D[n] * xnn + R_U[n] -= R_U[n] * xnn + + ri = Q[i] + rn = Q[n] + Q[i] -= ri * xii + rn * xin + Q[n] -= ri * xin + rn * xnn + + # H is multiplied on both sides, but stays tridiagonal except for moving buldge + # Note: we could reduce the stencil to for 4 columns qui we do below, + # but unlikely to be worth it for our small matrix sizes + ri = H[i] + rn = H[n] + H[i] -= ri * xii + rn * xin + H[n] -= ri * xin + rn * xnn + + # multiply on right, manually. We just need to consider 4 rows + if i > 0: + ci = H[i - 1, i] + cn = H[i - 1, n] + H[i - 1, i] -= ci * xii + cn * xin + H[i - 1, n] -= ci * xin + cn * xnn + + for k in range(2): + ci = H[i + k, i] + cn = H[i + k, n] + H[i + k, i] -= ci * xii + cn * xin + H[i + k, n] -= ci * xin + cn * xnn + + if n + 1 < m: + ci = H[n + 1, i] + cn = H[n + 1, n] + H[n + 1, i] -= ci * xii + cn * xin + H[n + 1, n] -= ci * xin + cn * xnn + + # Terminate if the upper diagonal of R is near zero + if wp.length_sq(R_U) < tol: + break + + return wp.get_diag(H), Q + + def compress_node_indices( - node_count: int, node_indices: wp.array(dtype=int), temporary_store: TemporaryStore = None -) -> Tuple[Temporary, Temporary, int, Temporary]: + node_count: int, + node_indices: wp.array(dtype=int), + return_unique_nodes=False, + temporary_store: cache.TemporaryStore = None, +) -> Union[Tuple[cache.Temporary, cache.Temporary], Tuple[cache.Temporary, cache.Temporary, int, cache.Temporary]]: """ Compress an unsorted list of node indices into: - a node_offsets array, giving for each node the start offset of corresponding indices in sorted_array_indices - a sorted_array_indices array, listing the indices in the input array corresponding to each node + + Plus if `return_unique_nodes` is ``True``, - the number of unique node indices - a unique_node_indices array containing the sorted list of unique node indices (i.e. the list of indices i for which node_offsets[i] < node_offsets[i+1]) + + Node indices equal to NULL_NODE_INDEX will be ignored """ index_count = node_indices.size + device = node_indices.device - sorted_node_indices_temp = borrow_temporary( - temporary_store, shape=2 * index_count, dtype=int, device=node_indices.device - ) - sorted_array_indices_temp = borrow_temporary_like(sorted_node_indices_temp, temporary_store) + with wp.ScopedDevice(device): + sorted_node_indices_temp = cache.borrow_temporary(temporary_store, shape=2 * index_count, dtype=int) + sorted_array_indices_temp = cache.borrow_temporary_like(sorted_node_indices_temp, temporary_store) - sorted_node_indices = sorted_node_indices_temp.array - sorted_array_indices = sorted_array_indices_temp.array + sorted_node_indices = sorted_node_indices_temp.array + sorted_array_indices = sorted_array_indices_temp.array - wp.copy(dest=sorted_node_indices, src=node_indices, count=index_count) + wp.copy(dest=sorted_node_indices, src=node_indices, count=index_count) - indices_per_element = 1 if node_indices.ndim == 1 else node_indices.shape[-1] - wp.launch( - kernel=_iota_kernel, - dim=index_count, - inputs=[sorted_array_indices, indices_per_element], - device=sorted_array_indices.device, - ) + indices_per_element = 1 if node_indices.ndim == 1 else node_indices.shape[-1] + wp.launch( + kernel=_iota_kernel, + dim=index_count, + inputs=[sorted_array_indices, indices_per_element], + ) - # Sort indices - radix_sort_pairs(sorted_node_indices, sorted_array_indices, count=index_count) + # Sort indices + radix_sort_pairs(sorted_node_indices, sorted_array_indices, count=index_count) - # Build prefix sum of number of elements per node - unique_node_indices_temp = borrow_temporary( - temporary_store, shape=index_count, dtype=int, device=node_indices.device - ) - node_element_counts_temp = borrow_temporary( - temporary_store, shape=index_count, dtype=int, device=node_indices.device - ) + # Build prefix sum of number of elements per node + unique_node_indices_temp = cache.borrow_temporary(temporary_store, shape=index_count, dtype=int) + node_element_counts_temp = cache.borrow_temporary(temporary_store, shape=index_count, dtype=int) - unique_node_indices = unique_node_indices_temp.array - node_element_counts = node_element_counts_temp.array + unique_node_indices = unique_node_indices_temp.array + node_element_counts = node_element_counts_temp.array - unique_node_count_dev = borrow_temporary(temporary_store, shape=(1,), dtype=int, device=sorted_node_indices.device) - runlength_encode( - sorted_node_indices, - unique_node_indices, - node_element_counts, - value_count=index_count, - run_count=unique_node_count_dev.array, - ) + unique_node_count_dev = cache.borrow_temporary(temporary_store, shape=(1,), dtype=int) - # Transfer unique node count to host - if node_indices.device.is_cuda: - unique_node_count_host = borrow_temporary(temporary_store, shape=(1,), dtype=int, pinned=True, device="cpu") - wp.copy(src=unique_node_count_dev.array, dest=unique_node_count_host.array, count=1) - wp.synchronize_stream(wp.get_stream(node_indices.device)) - unique_node_count_dev.release() + runlength_encode( + sorted_node_indices, + unique_node_indices, + node_element_counts, + value_count=index_count, + run_count=unique_node_count_dev.array, + ) + + # Scatter seen run counts to global array of element count per node + node_offsets_temp = cache.borrow_temporary(temporary_store, shape=(node_count + 1), dtype=int) + node_offsets = node_offsets_temp.array + + node_offsets.zero_() + wp.launch( + kernel=_scatter_node_counts, + dim=node_count + 1, # +1 to accomodate possible NULL node, + inputs=[node_element_counts, unique_node_indices, node_offsets, unique_node_count_dev.array], + ) + + if device.is_cuda and return_unique_nodes: + unique_node_count_host = cache.borrow_temporary( + temporary_store, shape=(1,), dtype=int, pinned=True, device="cpu" + ) + wp.copy(src=unique_node_count_dev.array, dest=unique_node_count_host.array, count=1) + copy_event = cache.capture_event(device) + + # Prefix sum of number of elements per node + array_scan(node_offsets, node_offsets, inclusive=True) + + sorted_node_indices_temp.release() + node_element_counts_temp.release() + + if not return_unique_nodes: + unique_node_count_dev.release() + return node_offsets_temp, sorted_array_indices_temp + + if device.is_cuda: + cache.synchronize_event(copy_event) + unique_node_count_dev.release() + else: + unique_node_count_host = unique_node_count_dev unique_node_count = int(unique_node_count_host.array.numpy()[0]) unique_node_count_host.release() - else: - unique_node_count = int(unique_node_count_dev.array.numpy()[0]) - unique_node_count_dev.release() + return node_offsets_temp, sorted_array_indices_temp, unique_node_count, unique_node_indices_temp - # Scatter seen run counts to global array of element count per node - node_offsets_temp = borrow_temporary( - temporary_store, shape=(node_count + 1), device=node_element_counts.device, dtype=int - ) - node_offsets = node_offsets_temp.array - node_offsets.zero_() - wp.launch( - kernel=_scatter_node_counts, - dim=unique_node_count, - inputs=[node_element_counts, unique_node_indices, node_offsets], - device=node_offsets.device, - ) +def host_read_at_index(array: wp.array, index: int = -1, temporary_store: cache.TemporaryStore = None) -> int: + """Returns the value of the array element at the given index on host""" - # Prefix sum of number of elements per node - array_scan(node_offsets, node_offsets, inclusive=True) + if index < 0: + index += array.shape[0] - sorted_node_indices_temp.release() - node_element_counts_temp.release() + if array.device.is_cuda: + temp = cache.borrow_temporary(temporary_store, shape=1, dtype=int, pinned=True, device="cpu") + wp.copy(dest=temp.array, src=array, src_offset=index, count=1) + wp.synchronize_stream(wp.get_stream(array.device)) + return temp.array.numpy()[0] - return node_offsets_temp, sorted_array_indices_temp, unique_node_count, unique_node_indices_temp + return array.numpy()[index] def masked_indices( - mask: wp.array, missing_index=-1, temporary_store: TemporaryStore = None -) -> Tuple[Temporary, Temporary]: + mask: wp.array, missing_index=-1, temporary_store: cache.TemporaryStore = None +) -> Tuple[cache.Temporary, cache.Temporary]: """ From an array of boolean masks (must be either 0 or 1), returns: - The list of indices for which the mask is 1 - A map associating to each element of the input mask array its local index if non-zero, or missing_index if zero. """ - offsets_temp = borrow_temporary_like(mask, temporary_store) + offsets_temp = cache.borrow_temporary_like(mask, temporary_store) offsets = offsets_temp.array wp.utils.array_scan(mask, offsets, inclusive=True) # Get back total counts on host - if offsets.device.is_cuda: - masked_count_temp = borrow_temporary(temporary_store, shape=1, dtype=int, pinned=True, device="cpu") - wp.copy(dest=masked_count_temp.array, src=offsets, src_offset=offsets.shape[0] - 1, count=1) - wp.synchronize_stream(wp.get_stream(offsets.device)) - masked_count = int(masked_count_temp.array.numpy()[0]) - masked_count_temp.release() - else: - masked_count = int(offsets.numpy()[-1]) + masked_count = int(host_read_at_index(offsets, temporary_store=temporary_store)) # Convert counts to indices - indices_temp = borrow_temporary(temporary_store, shape=masked_count, device=mask.device, dtype=int) + indices_temp = cache.borrow_temporary(temporary_store, shape=masked_count, device=mask.device, dtype=int) wp.launch( kernel=_masked_indices_kernel, @@ -262,10 +468,22 @@ def _iota_kernel(indices: wp.array(dtype=int), divisor: int): @wp.kernel def _scatter_node_counts( - unique_counts: wp.array(dtype=int), unique_node_indices: wp.array(dtype=int), node_counts: wp.array(dtype=int) + unique_counts: wp.array(dtype=int), + unique_node_indices: wp.array(dtype=int), + node_counts: wp.array(dtype=int), + unique_node_count: wp.array(dtype=int), ): i = wp.tid() - node_counts[1 + unique_node_indices[i]] = unique_counts[i] + + if i >= unique_node_count[0]: + return + + node_index = unique_node_indices[i] + if node_index == NULL_NODE_INDEX: + wp.atomic_sub(unique_node_count, 0, 1) + return + + node_counts[1 + node_index] = unique_counts[i] @wp.kernel diff --git a/warp/tests/test_examples.py b/warp/tests/test_examples.py index 153847ad6..819e37b5f 100644 --- a/warp/tests/test_examples.py +++ b/warp/tests/test_examples.py @@ -395,6 +395,12 @@ class TestFemDiffusionExamples(unittest.TestCase): devices=test_devices, test_options={"num_frames": 101, "resolution": 10, "tri_mesh": True, "headless": True}, ) +add_example_test( + TestFemExamples, + name="fem.example_streamlines", + devices=test_devices, + test_options={"headless": True}, +) if __name__ == "__main__": # force rebuild of all kernels diff --git a/warp/tests/test_fem.py b/warp/tests/test_fem.py index aa48a8626..e3648792b 100644 --- a/warp/tests/test_fem.py +++ b/warp/tests/test_fem.py @@ -18,7 +18,14 @@ from warp.fem.geometry.closest_point import project_on_tet_at_origin, project_on_tri_at_origin from warp.fem.space import shape from warp.fem.types import make_free_sample -from warp.fem.utils import grid_to_hexes, grid_to_quads, grid_to_tets, grid_to_tris +from warp.fem.utils import ( + grid_to_hexes, + grid_to_quads, + grid_to_tets, + grid_to_tris, + inverse_qr, + symmetric_eigenvalues_qr, +) from warp.tests.unittest_utils import * @@ -1201,6 +1208,34 @@ def test_point_basis(test, device): test.assertAlmostEqual(np.sum(zeros.numpy()), 0.0, places=5) + # test point basis with variable points per cell + points = wp.array([[0.25, 0.33], [0.33, 0.25], [0.8, 0.8]], dtype=wp.vec2) + pic = fem.PicQuadrature(domain, positions=points) + + test.assertEqual(pic.active_cell_count(), 2) + test.assertEqual(pic.total_point_count(), 3) + test.assertEqual(pic.max_points_per_element(), 2) + + point_basis = fem.PointBasisSpace(pic) + point_space = fem.make_collocated_function_space(point_basis) + point_test = fem.make_test(point_space, domain=domain) + test.assertEqual(point_test.space_restriction.node_count(), 3) + + ones = fem.integrate(linear_form, fields={"u": point_test}, quadrature=pic) + test.assertAlmostEqual(np.sum(ones.numpy()), pic.active_cell_count() / geo.cell_count(), places=5) + + zeros = fem.integrate(linear_form, quadrature=other_quadrature, fields={"u": point_test}) + test.assertAlmostEqual(np.sum(zeros.numpy()), 0.0, places=5) + + linear_vec = fem.make_polynomial_space(geo, dtype=wp.vec2) + linear_test = fem.make_test(linear_vec) + point_trial = fem.make_trial(point_space) + + mat = fem.integrate(vector_divergence_form, fields={"u": linear_test, "q": point_trial}, quadrature=pic) + test.assertEqual(mat.nrow, 9) + test.assertEqual(mat.ncol, 3) + test.assertEqual(mat.nnz, 12) + @fem.integrand def _bicubic(s: Sample, domain: Domain): @@ -1228,7 +1263,7 @@ def test_particle_quadratures(test, device): explicit_quadrature = fem.ExplicitQuadrature(domain, points, weights) - test.assertEqual(explicit_quadrature.points_per_element(), points_per_cell) + test.assertEqual(explicit_quadrature.max_points_per_element(), points_per_cell) test.assertEqual(explicit_quadrature.total_point_count(), points_per_cell * geo.cell_count()) val = fem.integrate(_bicubic, quadrature=explicit_quadrature) @@ -1247,7 +1282,7 @@ def test_particle_quadratures(test, device): pic_quadrature = fem.PicQuadrature(domain, positions=(element_indices, element_coords)) - test.assertIsNone(pic_quadrature.points_per_element()) + test.assertEqual(pic_quadrature.max_points_per_element(), 2) test.assertEqual(pic_quadrature.total_point_count(), 3) test.assertEqual(pic_quadrature.active_cell_count(), 2) @@ -1255,6 +1290,73 @@ def test_particle_quadratures(test, device): test.assertAlmostEqual(val, 1.25, places=5) +@wp.kernel +def test_qr_eigenvalues(): + tol = 1.0e-6 + + # zero + Zero = wp.mat33(0.0) + Id = wp.identity(n=3, dtype=float) + D3, P3 = symmetric_eigenvalues_qr(Zero, tol * tol) + wp.expect_eq(D3, wp.vec3(0.0)) + wp.expect_eq(P3, Id) + + # Identity + D3, P3 = symmetric_eigenvalues_qr(Id, tol * tol) + wp.expect_eq(D3, wp.vec3(1.0)) + wp.expect_eq(wp.transpose(P3) * P3, Id) + + # rank 1 + v = wp.vec4(0.0, 1.0, 1.0, 0.0) + Rank1 = wp.outer(v, v) + D4, P4 = symmetric_eigenvalues_qr(Rank1, tol * tol) + wp.expect_near(wp.max(D4), wp.length_sq(v), tol) + Err4 = wp.transpose(P4) * wp.diag(D4) * P4 - Rank1 + wp.expect_near(wp.ddot(Err4, Err4), 0.0, tol) + + # rank 2 + v2 = wp.vec4(0.0, 0.5, -0.5, 0.0) + Rank2 = Rank1 + wp.outer(v2, v2) + D4, P4 = symmetric_eigenvalues_qr(Rank2, tol * tol) + wp.expect_near(wp.max(D4), wp.length_sq(v), tol) + wp.expect_near(D4[0] + D4[1] + D4[2] + D4[3], wp.length_sq(v) + wp.length_sq(v2), tol) + Err4 = wp.transpose(P4) * wp.diag(D4) * P4 - Rank2 + wp.expect_near(wp.ddot(Err4, Err4), 0.0, tol) + + # rank 4 + v3 = wp.vec4(1.0, 2.0, 3.0, 4.0) + v4 = wp.vec4(2.0, 1.0, 0.0, -1.0) + Rank4 = Rank2 + wp.outer(v3, v3) + wp.outer(v4, v4) + D4, P4 = symmetric_eigenvalues_qr(Rank4, tol * tol) + Err4 = wp.transpose(P4) * wp.diag(D4) * P4 - Rank4 + wp.expect_near(wp.ddot(Err4, Err4), 0.0, tol) + + +@wp.kernel +def test_qr_inverse(): + rng = wp.rand_init(4356, wp.tid()) + M = wp.mat33( + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + wp.randf(rng, 0.0, 10.0), + ) + + if wp.determinant(M) != 0.0: + tol = 1.0e-8 + Mi = inverse_qr(M) + Id = wp.identity(n=3, dtype=float) + Err = M * Mi - Id + wp.expect_near(wp.ddot(Err, Err), 0.0, tol) + Err = Mi * M - Id + wp.expect_near(wp.ddot(Err, Err), 0.0, tol) + + devices = get_test_devices() cuda_devices = get_selected_cuda_test_devices() @@ -1281,6 +1383,8 @@ class TestFem(unittest.TestCase): add_function_test(TestFem, "test_dof_mapper", test_dof_mapper) add_function_test(TestFem, "test_point_basis", test_point_basis) add_function_test(TestFem, "test_particle_quadratures", test_particle_quadratures) +add_kernel_test(TestFem, test_qr_eigenvalues, dim=1, devices=devices) +add_kernel_test(TestFem, test_qr_inverse, dim=100, devices=devices) class TestFemShapeFunctions(unittest.TestCase):