src/laser_sampling.py

changeset 12
a0d61ac3719e
parent 3
c3a4f4bb87f7
--- a/src/laser_sampling.py	Mon Jun 29 00:20:42 2026 +0300
+++ b/src/laser_sampling.py	Sun Oct 04 13:02:26 2026 -0500
@@ -1,12 +1,15 @@
+import basix
 import numpy as np
-from dolfinx import fem, geometry
-from dolfinx_access import cell_FunctionSpace_f64
+from dolfinx import fem
 from mpi4py import MPI
 from petsc4py import PETSc
 from pointsource_pde.dolfinx_extras import get_mass_matrix
+from scipy.sparse import coo_matrix
 from scipy.sparse.linalg import svds
 from slepc4py import SLEPc
 
+from dolfinx_access import cell_FunctionSpace_f64
+
 
 class LaserSampling:
     """
@@ -20,7 +23,6 @@
         nx,
         ny,
         beams,
-        num_segments=100,
         domain_size=[-2, 2, -2, 2],
     ):
         self.V = V
@@ -28,7 +30,10 @@
         self.nx, self.ny = nx, ny
         self.M = len(beams)
         self.domain_size = domain_size
-        self.num_segments = num_segments
+
+        # Basix element matching V, used to tabulate phi_h exactly at points
+        # self._element = self._create_basix_element()
+        self._element = self.V.element.basix_element
 
         # Build R matrix (single function does everything)
         self.R = self.matrix(beams)
@@ -60,71 +65,133 @@
         self._opnorm = s[0]  # / sigma
         # print(f"Laser sampling opnorm = {self._opnorm:.6f}")
 
-    def matrix(self, beams):
-        """Combined: generate beams + segment + cell collision + dof weighting"""
-        dofmap = self.V.dofmap
-        tdim = self.domain.topology.dim
+    # ------------------------------------------------------------------
+    # Exact per-cell line integration
+    #
+    # A beam is split into the ordered sub-segments where it actually
+    # crosses each element (via Cyrus-Beck clipping against the cell's
+    # polygon), and each sub-segment is integrated exactly using the
+    # 3-point Simpson rule (exact for phi_h of degree <= 2, since phi_h
+    # pulled back along a straight line stays degree <= 2 in the
+    # parameter t). This replaces the previous approach of sampling many
+    # midpoints and splitting weight equally across a cell's dofs.
+    # ------------------------------------------------------------------
 
-        # Geometry trees
-
-        bb_tree = geometry.bb_tree(self.domain, tdim)
-
-        n_dofs = self.V.dofmap.index_map.size_global
-        R = np.zeros((self.M, n_dofs))
+    def _cell_vertices_2d(self, cell_idx):
+        """Physical (x, y) vertices of a cell, ordered CCW around its centroid."""
+        x_geom = self.domain.geometry.x
+        verts = self.domain.geometry.dofmap[cell_idx]
+        pts = x_geom[verts, :2]
+        centroid = pts.mean(axis=0)
+        angles = np.arctan2(pts[:, 1] - centroid[1], pts[:, 0] - centroid[0])
+        return pts[np.argsort(angles)]
 
-        xmin, xmax, ymin, ymax = self.domain_size
+    def _clip_segment_to_cell(self, pts, x, y, eps=1e-12):
+        """
+        Cyrus-Beck clip of the segment x->y against the convex polygon `cell_idx`.
+        Returns (t0, t1) in [0, 1] such that x + t*(y - x), t in [t0, t1], is the
+        part of the segment inside the cell -- or None if there's no overlap.
+        Works for any convex cell (triangle or quad); assumes straight-sided
+        (affine, P1) geometry.
+        """
+        n_verts = len(pts)
+        d = y - x
+        t_lo, t_hi = 0.0, 1.0
+
+        for i in range(n_verts):
+            v0 = pts[i]
+            v1 = pts[(i + 1) % n_verts]
+            edge = v1 - v0
+            normal = np.array([edge[1], -edge[0]])  # outward normal (CCW polygon)
 
-        for beam_idx, (start_pt, end_pt) in enumerate(beams):
-            # Segment into midpoints
-            vec = end_pt - start_pt
-            total_len = np.linalg.norm(vec)
-            seg_len = total_len / self.num_segments
+            denom = normal @ d
+            numer = normal @ (v0 - x)
+
+            if abs(denom) < eps:
+                if numer < -eps:  # segment parallel to edge, and entirely outside it
+                    return None
+                continue  # parallel and inside: this edge imposes no constraint
 
-            midpoints = []
-            for i in range(self.num_segments):
-                t = (i + 0.5) / self.num_segments
-                midpoint = start_pt + t * vec
-                midpoints.append(midpoint)
-            # midpoints = np.array(midpoints, dtype=np.float64)  # Force float64
+            t_edge = numer / denom
+            if denom > 0:
+                t_hi = min(t_hi, t_edge)
+            else:
+                t_lo = max(t_lo, t_edge)
+
+            if t_lo > t_hi + eps:
+                return None
+
+        if t_lo > t_hi:
+            return None
+        return t_lo, t_hi
 
-            # Ensure shape (N, 3), C-contiguous, read-only for DOLFINx
-            # assert midpoints.shape[1] == 2, (
-            #     "Expected 2D points, got shape[1]=2 but need padding to 3D"
-            # )
-            # midpoints = np.pad(
-            #     midpoints, ((0, 0), (0, 1)), mode="constant"
-            # )  # (N, 3) with z=0
-            # midpoints = np.ascontiguousarray(midpoints)  # C-order
-            # midpoints.setflags(write=False)  # Read-only requirement
-
-            # Find colliding cells
-            # cell_candidates = geometry.compute_collisions_points(bb_tree, midpoints)
-            # colliding_cells = geometry.compute_colliding_cells(
-            #    self.domain, cell_candidates, midpoints
-            # )
+    def matrix(self, beams):
+        """Combined: exact beam/cell intersection + exact per-cell line integration."""
+        n_dofs = self.V.dofmap.index_map.size_global
+        # R = np.zeros((self.M, n_dofs))
+        rows, cols, data = [], [], []
 
-            # Cell→DOFs→weight accumulation
-            # cell_lists = list(colliding_cells.array)
-            # for seg_i, cell_idx_raw in enumerate(cell_lists):
-            #    cell_idx = int(cell_idx_raw)  # Convert np.int32 → Python int
-            for point in midpoints:
-                pad_point = np.array([point[0], point[1], 0.0])
-                cell_idx = cell_FunctionSpace_f64(self.V._cpp_object, pad_point)
-                if cell_idx >= 0:  # Valid cell index
-                    # Get global DOFs
-                    cell_dofs_local = dofmap.cell_dofs(cell_idx)
-                    # Convert local dofs to numpy int32 array (DOLFINx requirement)
-                    # local_dofs_array = np.asarray(cell_dofs_local, dtype=np.int32)
-                    # local_dofs_array.setflags(write=False)  # Read-only requirement
+        for cell_idx in range(len(self.domain.geometry.dofmap)):
+            pts = self._cell_vertices_2d(cell_idx)
+            for beam_idx, (start_pt, end_pt) in enumerate(beams):
+                clip = self._clip_segment_to_cell(pts, start_pt, end_pt)
+                if clip is not None:
+                    t0, t1 = clip
+                    if t1 - t0 > 1e-10:
+                        beam_v = end_pt - start_pt
+                        x0 = start_pt + t0 * beam_v
+                        x1 = start_pt + t1 * beam_v
+                        midpt = start_pt + (0.5 * (t1 + t0)) * beam_v
+                        """
+                        Exact int_{x0}^{x1} phi_h ds for phi_h in V restricted to `cell_idx`, as a
+                        (global_dofs, row_values) pair such that row_values @ phi_h.x.array[global_dofs]
+                        equals the integral. Uses the 3-point Simpson rule (endpoints + midpoint),
+                        which is exact since phi_h has degree <= 2 along the line.
+                        """
+                        x_geom = self.domain.geometry.x
+                        verts = self.domain.geometry.dofmap[cell_idx]
+                        v0, v1, v2 = (
+                            x_geom[verts[0], :2],
+                            x_geom[verts[1], :2],
+                            x_geom[verts[2], :2],
+                        )
+                        J = np.column_stack([v1 - v0, v2 - v0])
+                        """Map a physical point p to reference coordinates on an affine triangle."""
+                        pts_ref = np.array(
+                            [
+                                np.linalg.solve(J, x0 - v0),
+                                np.linalg.solve(J, midpt - v0),
+                                np.linalg.solve(J, x1 - v0),
+                            ]
+                        )
+                        tab = self._element.tabulate(0, pts_ref)[
+                            0
+                        ]  # (npoints, ndof_local, value_size)
+                        tab = tab[
+                            ..., 0
+                        ]  # scalar element -> drop trailing value_size=1 axis
 
-                    # Batch convert all local indices to global in one call
-                    cell_dofs_global = dofmap.index_map.local_to_global(cell_dofs_local)
-                    cell_dofs = np.unique(cell_dofs_global)
+                        length = (t1 - t0) * np.linalg.norm(beam_v)
+                        weights = np.array([1.0, 4.0, 1.0]) * length / 6.0
+                        local_dofs = self.V.dofmap.cell_dofs(cell_idx)
+                        # global_dofs = self.V.dofmap.index_map.local_to_global(local_dofs)
+                        # R[beam_idx, local_dofs] += weights @ tab
+                        rows.append(np.full(len(local_dofs), beam_idx, dtype=np.int64))
+                        cols.append(np.asarray(local_dofs, dtype=np.int64))
+                        data.append(weights @ tab)
+
+        # return R
 
-                    weight = seg_len / len(cell_dofs)
-                    R[beam_idx, cell_dofs] += weight  # ADD for overlaps!
+        if rows:
+            rows = np.concatenate(rows)
+            cols = np.concatenate(cols)
+            data = np.concatenate(data)
+        else:
+            rows = np.array([], dtype=np.int64)
+            cols = np.array([], dtype=np.int64)
+            data = np.array([], dtype=np.float64)
 
-        return R
+        return coo_matrix((data, (rows, cols)), shape=(self.M, n_dofs)).tocsr()
 
     def apply(self, x):
         """
@@ -143,15 +210,12 @@
         # ⟨x.array,R₀^* v⟩_{ℝ^n} = ⟨Ax.array,[R^*v].array⟩_{ℝ^m}
         # Taking [R^*v].array=A^{-1}R₀^* v, the conjugate works correctly.
         tmp = fem.Function(self.V)
-        np.matmul(self.R.T, j, out=tmp.x.array)
+        res = fem.Function(self.V)
+        tmp.x.array[:] = self.R.T @ j
         tmp.x.scatter_forward()
-        return tmp
-        # TODO: is convection_diffusion actualy based on norms in ℝ^n?
-        # res = fem.Function(self.V)
-        # res.x.array[:] = 0.0
-        # self.solver.solve(tmp.x.petsc_vec, res.x.petsc_vec)
-        # res.x.scatter_forward()
-        # return res
+        self.solver.solve(tmp.x.petsc_vec, res.x.petsc_vec)
+        res.x.scatter_forward()
+        return res
 
     def opnorm(self, *args):
         # raise NotImplementedError("Need mesh weighting?")

mercurial