Implement pointobservation - #61
Open
finsberg wants to merge 12 commits into
Open
Conversation
Implement pointobservation using fenicsx-ii
finsberg
marked this pull request as ready for review
August 19, 2026 08:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Inverse problems are usually posed against data that lives at points, not on the computational mesh: sensor readings, well measurements, or the voxel centres of an image. Today dolfinx-adjoint can only build functionals from assemble_scalar, i.e. integrals over the mesh, so matching point data means first interpolating the measurements onto a finite element field. That is unfortunate for two reasons:
This PR adds the pointwise observation operator and a differentiable least-squares misfit built on top of it, so that the parameter-to-observable map$m \mapsto B,u(m)$ of PDE-constrained optimization and Bayesian inversion can be written down directly and taped.
The math
The observation operator
A finite element function is$u(x) = \sum_j u_j \phi_j(x)$ , with $\phi_j$ the basis of $V$ and $u_j$ the degrees of freedom. Evaluating it at a point is therefore linear in the degrees of freedom: it is just the basis functions sampled at that point. Collecting $n_d$ observation points $x_1,\dots,x_{n_d}$ into a matrix gives the discrete observation operator
where$N = \dim V$ . Row $i$ of $B$ evaluates $u$ at $x_i$ . The matrix is extremely sparse: $\phi_j(x_i)$ is nonzero only for the handful of basis functions supported on the cell containing $x_i$ .
For a vector-valued space with block size$b$ the same construction applies per component; row $ib + c$ observes component $c$ at point $x_i$ .
The misfit
Given measured data$d \in \mathbb{R}^{n_d}$ we minimize
with$\sigma^2$ the variance of the additive Gaussian observation noise (use $\sigma^2 = 1$ for a purely deterministic problem) and $W$ an optional diagonal matrix of per-observation weights. A $0/1$ weight masks individual sensors out; a general $W$ lets you use $\sigma^{-2} W^2 = \Gamma_{\text{noise}}^{-1}$ for a diagonal noise covariance, which is what makes $J$ the negative log-likelihood of the data in the Bayesian setting.
Derivatives
Three things fall out of this:
Because the Hessian never depends on$u$ , the block stores nothing but $d$ , $\sigma^2$ , and $W$ .
Implementation
PointObservation (src/dolfinx_adjoint/observation.py)Builds$B$ once and exposes its action:
The matrix itself is assembled by$V$ onto that space is $B$ . $B^{\top}$ for free.re is a small _PointCloudTrace reduction operator that hands
fenicsx_ii. The key observation is that we do not have to write a point-evaluation kernel at all: a DG-0 space on a point mesh (one cell per observation point,dolfinx.mesh.create_point_mesh) has exactly one degree of freedom per point, so the interpolation matrix fromfenicsx_ii.create_interpolation_matrixassembles it as a distributed PETSc.Mat, including all cross-process communication, and its transpose givesfenicsx_iithe coordinates: itsPointwiseTraceis written for 1D line meshes and gets the physical coordinates by compiling a SpatialCoordinate expression, which FFCx cannot do on a point cell — on a point mesh each cell is a geometry node, so the coordinates are read straight off the geometry.This adds
fenicsx-iias a dependency.The misfit.
returns a$B^{\top}$ is applied, so the communication round-trip runs once per Hessian action rather than twice.
pyadjoint.AdjFloatand records aPointObservationBlockon the tape, so it composes with LinearProblem/NonlinearProblem and adds to assemble_scalar terms (e.g. Tikhonov regularization) exactly like any other functional. The block implementsrecompute,evaluate_adj,evaluate_tlmandevaluate_hessianfrom the closed forms above. data and weights are copied on construction, so a caller reusing one buffer across a time-stepping loop cannot retroactively change a block the tape is still holding. In the Hessian, the second-order seed and the curvature term are summed in row space beforeParallel semantics
This is where most of the care went.
B.found/B.ownerand excluded from the operator rather than becoming zero rows. A zero row silently contributesapply/apply_transposework in the distributed row layout; gather/restrict/evaluate convert to and from a replicated global array, with a fill value (default nan) for points outside the mesh.Testing
37 tests in tests/test_observation.py, run in serial and in parallel:
Demo
demos/point_observations.py(added to the toc) solves the mother problem against noisy sensor data on a jitteredAI assistance
I used Claude Code (Claude Sonnet 5 and Claude Opus 5) to help implement, test, and iterate on this feature, and to draft this PR description. I reviewed, tested, and take full responsibility for the final contribution.