Add dmfield_evaluate from PETSc - #436
Conversation
DMFieldEvaluate computes the FE interpolant at each query point. For fields that are in the FE space (e.g., a quadratic velocity field on P2 elements), the gradient is exact to machine precision at any interior point. https://petsc.org/main/manualpages/DM/DMFieldEvaluate/
There was a problem hiding this comment.
Pull request overview
Adds a new uw.function.dmfield_evaluate() API backed by PETSc DMFieldEvaluate to compute FE-interpolated values and derivatives (gradient/Hessian) at arbitrary coordinates, aiming to avoid projection artifacts for derivative-derived quantities (e.g. strain rate, viscosity).
Changes:
- Introduces a Python API (
dmfield_evaluate) with a per-(mesh,var) cache and a cache-clear helper. - Adds a new Cython extension (
_dmfield_wrapper) to create/evaluate/destroy PETSc DMField objects. - Adds tests for FE-exact value/gradient/Hessian evaluation and for per-cell volumes computed via PETSc FVM geometry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_0580_dmfield_evaluate.py | New tests covering DMField-based evaluation and mesh cell-volume totals. |
| src/underworld3/function/dmfield_evaluate.py | New public API + cache management for DMField evaluation. |
| src/underworld3/function/_dmfield_wrapper.pyx | New Cython/ctypes wrapper around PETSc DMFieldCreateDS / DMFieldEvaluate / DMFieldDestroy. |
| src/underworld3/function/init.py | Exposes dmfield_evaluate* from underworld3.function. |
| src/underworld3/discretisation/discretisation_mesh.py | Computes/stores _cell_volumes during nuke_coords_and_rebuild(). |
| setup.py | Registers the new _dmfield_wrapper extension for compilation. |
Comments suppressed due to low confidence (3)
src/underworld3/function/_dmfield_wrapper.pyx:145
- This call site also hard-codes
libpetsc.dyliband assumesPETSC_DIRis set (os.environ["PETSC_DIR"]), which will raiseKeyErrorin environments where petsc4py is available but PETSC_DIR/ARCH is not exported. Use the same cross-platform PETSc library resolution as increate()(and ideally cache the loaded CDLL).
# Call via ctypes
lib_path = os.path.join(
os.environ["PETSC_DIR"],
os.environ.get("PETSC_ARCH", ""),
"lib", "libpetsc.dylib"
)
lib = ctypes.CDLL(lib_path)
src/underworld3/function/_dmfield_wrapper.pyx:180
- Same portability issue in
destroy():libpetsc.dylibis macOS-specific andos.environ["PETSC_DIR"]can raiseKeyError. Reuse the cross-platform PETSc library resolution logic (or a shared helper) here too.
lib_path = os.path.join(
os.environ["PETSC_DIR"],
os.environ.get("PETSC_ARCH", ""),
"lib", "libpetsc.dylib"
)
lib = ctypes.CDLL(lib_path)
tests/test_0580_dmfield_evaluate.py:82
- Same MPI-safety issue here:
v.sum()is rank-local. Use anallreducebefore comparing to the global area (2.0).
mesh = uw.meshing.UnstructuredSimplexBox(
minCoords=(0, 0), maxCoords=(2, 1), cellSize=0.3
)
v = mesh._cell_volumes
assert (v > 0).all()
assert np.allclose(v.sum(), 2.0, rtol=1e-3)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # Keep weakrefs so the cache is cleaned up when mesh/var die | ||
| _cache_owners[key] = (weakref.ref(mesh), weakref.ref(var)) |
| **Data freshness** — The variable's internal PETSc local vector | ||
| (``_lvec``) is read at the time of the *first* call. If the variable's | ||
| data changes later (e.g. after a Stokes solve), call | ||
| ``mesh.update_lvec()`` before ``dmfield_evaluate()`` to sync the local | ||
| vector. | ||
|
|
||
| **Parallel** — This function is not yet collective across MPI ranks. | ||
| Each rank evaluates its own set of coordinates. For parallel-safe | ||
| evaluation across the whole domain, use ``uw.function.global_evaluate`` | ||
| with an expression that has been pre-projected. | ||
|
|
| # Locate libpetsc | ||
| petsc_dir = os.environ.get("PETSC_DIR", "") | ||
| petsc_arch = os.environ.get("PETSC_ARCH", "") | ||
| lib_dir = os.path.join(petsc_dir, petsc_arch, "lib") | ||
| if not os.path.isdir(lib_dir): | ||
| lib_dir = os.path.join(petsc_dir, "lib") | ||
| lib_path = os.path.join(lib_dir, "libpetsc.dylib") | ||
| if not os.path.exists(lib_path): | ||
| raise RuntimeError(f"Cannot find libpetsc at {lib_path}") | ||
|
|
||
| lib = ctypes.CDLL(lib_path) |
| gradient : bool | ||
| If True, return first derivatives D. | ||
| hessian : bool | ||
| If False, return second derivatives H. | ||
|
|
| ierr = lib.DMFieldEvaluate( | ||
| c_void_p(self._field_handle), | ||
| pts_py.handle, | ||
| 0, # PETSC_REAL | ||
| B.ctypes.data_as(c_void_p) if B is not None and B.size else ctypes.c_void_p(), | ||
| D.ctypes.data_as(c_void_p) if D is not None and D.size else ctypes.c_void_p(), | ||
| H.ctypes.data_as(c_void_p) if H is not None and H.size else ctypes.c_void_p(), | ||
| ) |
|
|
||
| @pytest.fixture(scope="module") | ||
| def tri_mesh(): | ||
| """Unstructured simplex box — non-affine elements.""" |
| mesh = uw.meshing.StructuredQuadBox(elementRes=(8, 8)) | ||
| v = mesh._cell_volumes | ||
| assert (v > 0).all() | ||
| assert np.allclose(v.sum(), 1.0, rtol=1e-3) | ||
|
|
| # Cell volumes via PETSc's FVM geometry (one call per cell). | ||
| cStart, cEnd = self.dm.getHeightStratum(0) | ||
| self._cell_volumes = numpy.abs(numpy.array( | ||
| [self.dm.computeCellGeometryFVM(c)[0] | ||
| for c in range(cStart, cEnd)])) |
Adversarial review — live probes on the PR head (5f2f907)Reviewed both for inherent validity and for fit with this repo's evaluation stack ( Verdict: 10 findings — 3 CRITICAL, 3 MAJOR, 4 MINOR. The primitive is valid in concept — petsc4py has no DMField binding (confirmed), and FE-exact one-sided gradients plus Hessians are a real capability UW3 lacks — but this implementation needs a substantial rework before it can merge, and both CI jobs on the PR are currently red. Findings (ranked)CRITICAL-1 — Silent wrong values for points raw
|
| h | dmfield L2 / max | Clement path L2 / max |
|---|---|---|
| 0.1 | 9.9e-3 / 4.3e-2 | 8.9e-3 / 2.4e-2 |
| 0.05 | 2.2e-3 / 7.8e-3 | 1.9e-3 / 8.4e-3 |
| 0.025 | 5.8e-4 / 2.3e-3 | 4.7e-4 / 1.3e-3 |
Recovery averaging is superconvergent for smooth fields; "FE-exact" is exact only w.r.t. the interpolant. DMField's genuine wins: machine precision for fields in the FE space (confirmed at 1e-16 — and a solved P2 velocity is in the FE space, so the strain-rate pitch is sound there), one-sided semantics at discontinuities, and Hessians (which UW3 currently has no direct source for).
MINOR-7 — The datatype argument 0 is PETSC_DATATYPE_UNKNOWN, not PETSC_REAL — wrong-but-lucky (petsc only branches on == PETSC_SCALAR, so 0 falls into the real branch by accident). Should be the named constant.
MINOR-8 — The Cython file buys nothing: it is ctypes at runtime, re-CDLL'd on every call, with a c_int-for-PetscInt hazard on 64-bit-index builds. The repo's established pattern for exactly this is a Cython extern — see DMPlexInsertBoundaryValues in src/underworld3/cython/petsc_extras.pxi (PR #412). The three signatures needed are trivial (petscdmfield.h:39,45,56); an opaque DMField typedef plus petsc4py's PetscDM/PetscVec eliminates the dylib lookup, the datatype literal, and the int-width assumption.
MINOR-9 — mesh._cell_volumes is an unrelated drive-by: an eager O(cells) Python loop on every mesh build and every deform(), with no consumer in the codebase (only the PR's own tests). Measured: ~1 s per million cells, paid per free-surface/MMPDE step forever. Should be a lazy cached property, in its own PR.
MINOR-10 — Docstring defects: hessian parameter description inverted; Notes tell the user to call mesh.update_lvec() first while the function already does it unconditionally; the module example teaches the transposed D indexing (CRITICAL-2); one dead branch (nc > 0 is always true).
Against the UW3 use case (candidate replacement for the Clement derivative sub-path)
- Nodal fill: at gradient discontinuities dmfield returns the one-sided element gradient of whichever cell wins point location — arbitrary-sided, not a controlled choice (probe: T=|x−0.5| kink nodes, dmfield all −1, Clement averages to 0; real lid-driven P2 velocity, ∂vx/∂x at shared vertices: median difference 3.8e-2, max 20.0 at the lid corner). A semantic change, not a drop-in.
- Boundary points: disqualifying as-is on quad/hex meshes (CRITICAL-1) — this primitive must adopt the feat(evaluate): measured point-location capability + NaN/RBF fallback ladder (#392) #395 location ladder or hard-fail before it can sit under
uw.function.evaluate. - Where it genuinely earns its place: exact strain rates of solved FE velocity fields, Hessians for adaptivity metrics, one-sided evaluation where averaging is wrong.
Attacks that failed (confirmations)
Data freshness after var.data writes: fresh values (the unconditional update_lvec works). mesh.deform() with a cached DMField: exact values and gradients on the deformed mesh (DM/lvec mutated in place — the cache stays coherent). Values at true mesh nodes: exact to 4.4e-16 on both mesh classes; at arbitrary interior points dmfield's values (4.3e-4) beat evaluate's (2.7e-3) on the P2 sin field. The 13 shipped tests pass locally on macOS in ~3 s including the create/destroy loop. Zero-point parallel input errors cleanly rather than hanging. A batch location error does not poison the cache.
UNPROBED: 3D, np>2, periodic meshes, annulus/spherical, 64-bit-index and complex builds, hessian=True, gradient=False.
Recommended path
Worth landing eventually — UW3 has no Hessian source and no FE-exact gradient path, and this fills both — but as a rework: DMFieldCreateDS/Evaluate/Destroy externs in petsc_extras.pxi (the #412 pattern), COMM_SELF point Vecs, NaN-filled outputs with a hard check, the corrected (n, nc, dim) layout with fixed examples, a real cache lifecycle (finalizers, no strong refs), the location fallback (or an explicit simplex-only guard), and the _cell_volumes loop split out as a lazy property in its own PR.
Adversarial review per project policy — Underworld development team with AI support from Claude Code
lmoresi
left a comment
There was a problem hiding this comment.
Requesting changes formally, per the adversarial review posted below (probe evidence and file/line specifics there). The capability is wanted — UW3 has no Hessian source and no FE-exact gradient path, and petsc4py has no DMField binding, so a wrapper is the right idea — but this implementation isn't at the bar we hold our own work to, and both CI jobs are red.
Blocking items before this can merge:
- No silent garbage (CRITICAL-1): outputs must be NaN-initialized with a hard failure (or the PR #395 location ladder) for unlocatable points. 7.6% of P2 nodes on a plain quad box currently come back as uninitialized memory with no error.
- Fix the D/H array layout and every example that teaches it (CRITICAL-2): PETSc is point-component-direction; the documented
(n, dim, nc)indexing is transposed for vector fields. Add an asymmetric-field test (e.g. v=(y,0)) that actually pins the layout — the current symmetric tests cannot. - Parallel must work or refuse loudly (CRITICAL-3): points Vec on
COMM_SELF, plus an explicit statement of the rank-local contract. - Replace the ctypes/dylib mechanism with Cython externs in
petsc_extras.pxi— the established pattern from PR #412 (DMPlexInsertBoundaryValues). That removes the macOS-only library path, the per-callCDLL, thePETSC_DATATYPE_UNKNOWNliteral, and the PetscInt-width hazard in one move, and it's what makes CI green on Linux. - Real cache lifecycle (MAJOR-5): no strong mesh/var refs, registered finalizers — the current cache immortalizes every mesh it touches, which is disqualifying for adaptive/remeshing workflows.
- Split
mesh._cell_volumesinto its own PR as a lazy cached property — it's an eager per-deform()O(cells) Python loop with no consumer in this PR. - Honest framing: the smooth-field head-to-head shows the Clement path is superconvergent and slightly better there; this primitive's real value is exact strain rates of solved FE fields, one-sided evaluation at discontinuities, and Hessians. Docs should say that, not 'avoids boundary artifacts' generally.
Happy to pair on the extern wrapper — the three signatures are small and the #412 pattern maps directly.
Underworld development team with AI support from Claude Code
Replace ctypes/DMField wrapper with Cython externs (petsc_extras.pxi pattern from PR #412). Remove per-(mesh,var) cache in favour of create-evaluate-destroy per call (~0.5 us overhead vs ~50-200 us evaluate). Correct D/H array layout from (n, dim, nc) to (n, nc, dim) matching PETSc point-component-direction storage. Initialise outputs with NaN so unlocated points are detectable. Document the all-ranks collective contract for DMFieldEvaluate. Remove _cell_volumes. Addresses all 10 findings from the adversarial review: - CRITICAL-1 (silent garbage): NaN init + test_unlocated_points_nan - CRITICAL-2 (transposed D/H): correct (n, nc, dim) + asymmetric tests - CRITICAL-3 (broken parallel): COMM_SELF Vec, all-ranks-must-call, MPI test - MAJOR-4 (CI red / macOS-only): Cython externs replace ctypes/.dylib - MAJOR-5 (cache leak): no cache — fresh DMField per call - MAJOR-6 (accuracy docs): honest DMField vs Clement guidance - MINOR-7 (datatype 0): PETSC_REAL named constant - MINOR-8 (ctypes buys nothing): Cython externs earn their keep - MINOR-9 (cell_volumes): removed, deferred to separate PR - MINOR-10 (docstring defects): all fixed Tests: 17 serial + 1 MPI (18 total) — passing on StructuredQuadBox, UnstructuredSimplexBox, and mpiexec -n 2.
Response to adversarial review — all 10 findings fixedThis rework addresses every finding from the adversarial review. The core changes are summarised below, followed by a finding-by-finding response. Design decisions
Finding-by-finding
Test results18 tests, 0 failures (17 serial + 1 MPI) Coverage includes: quadratic velocity fields (P2), scalar fields (P1), unstructured simplex and structured quad meshes, asymmetric layout guards for both gradient and Hessian, unlocated-point NaN semantics, empty-coordinates edge case, non-square component count (nc=3, dim=2), NULL-pointer PETSc paths (D=NULL, H=NULL), create/destroy stress test (10 cycles), and MPI collective contract (rank 0 with points + rank 1 with zero points). Key API (corrected layout)B, D, H = dmfield_evaluate(velocity, mesh.X.coords)
# B.shape == (n_points, nc)
# D.shape == (n_points, nc, dim) — D[k, j, i] = ∂(component j)/∂xᵢ
# H.shape == (n_points, nc, dim, dim) |
…Wincompatible-pointer-types PETSc's DMFieldCreateDS/Destroy expect DMField* (struct _p_DMField*), not void*. macOS clang warns but Linux gcc errors with -Wincompatible-pointer-types. Declare the opaque type via Cython's ctypedef struct with name-mapping to "DMField".
…type PETSc's DMField is typedef struct _p_DMField *DMField — a pointer typedef. Cython's "DMField" name mapping generated 'struct DMField', which does not exist in PETSc. This compiled with a warning on macOS clang but errored on Linux gcc (-Wincompatible-pointer-types). Mapping to 'struct _p_DMField' generates code matching PETSc's actual struct tag, so Cython's _p_DMField* becomes struct _p_DMField * which is exactly DMField.
…rrors on macOS macOS clang treats -Wincompatible-pointer-types as a warning (not an error) by default, while Linux gcc errors. Add these flags so pointer type mismatches (like void* vs DMField*) fail the build on macOS too, allowing CI-compatible testing before pushing.
DMFieldEvaluate computes the FE interpolant at each query point. For fields that are in the FE space (e.g., a quadratic velocity field on P2 elements), the gradient is exact to machine precision at any interior point.
https://petsc.org/main/manualpages/DM/DMFieldEvaluate/