src/laser_sampling.py

changeset 12
a0d61ac3719e
parent 3
c3a4f4bb87f7
equal deleted inserted replaced
11:c4f923017349 12:a0d61ac3719e
1 import basix
1 import numpy as np 2 import numpy as np
2 from dolfinx import fem, geometry 3 from dolfinx import fem
3 from dolfinx_access import cell_FunctionSpace_f64
4 from mpi4py import MPI 4 from mpi4py import MPI
5 from petsc4py import PETSc 5 from petsc4py import PETSc
6 from pointsource_pde.dolfinx_extras import get_mass_matrix 6 from pointsource_pde.dolfinx_extras import get_mass_matrix
7 from scipy.sparse import coo_matrix
7 from scipy.sparse.linalg import svds 8 from scipy.sparse.linalg import svds
8 from slepc4py import SLEPc 9 from slepc4py import SLEPc
10
11 from dolfinx_access import cell_FunctionSpace_f64
9 12
10 13
11 class LaserSampling: 14 class LaserSampling:
12 """ 15 """
13 A linear operator class for laser sampling of a concentration (fem.Function) 16 A linear operator class for laser sampling of a concentration (fem.Function)
18 V, 21 V,
19 domain, 22 domain,
20 nx, 23 nx,
21 ny, 24 ny,
22 beams, 25 beams,
23 num_segments=100,
24 domain_size=[-2, 2, -2, 2], 26 domain_size=[-2, 2, -2, 2],
25 ): 27 ):
26 self.V = V 28 self.V = V
27 self.domain = domain 29 self.domain = domain
28 self.nx, self.ny = nx, ny 30 self.nx, self.ny = nx, ny
29 self.M = len(beams) 31 self.M = len(beams)
30 self.domain_size = domain_size 32 self.domain_size = domain_size
31 self.num_segments = num_segments 33
34 # Basix element matching V, used to tabulate phi_h exactly at points
35 # self._element = self._create_basix_element()
36 self._element = self.V.element.basix_element
32 37
33 # Build R matrix (single function does everything) 38 # Build R matrix (single function does everything)
34 self.R = self.matrix(beams) 39 self.R = self.matrix(beams)
35 40
36 A = get_mass_matrix(V) 41 A = get_mass_matrix(V)
58 # SVD operator norm 63 # SVD operator norm
59 _u, s, _v = svds(self.R, k=1) 64 _u, s, _v = svds(self.R, k=1)
60 self._opnorm = s[0] # / sigma 65 self._opnorm = s[0] # / sigma
61 # print(f"Laser sampling opnorm = {self._opnorm:.6f}") 66 # print(f"Laser sampling opnorm = {self._opnorm:.6f}")
62 67
68 # ------------------------------------------------------------------
69 # Exact per-cell line integration
70 #
71 # A beam is split into the ordered sub-segments where it actually
72 # crosses each element (via Cyrus-Beck clipping against the cell's
73 # polygon), and each sub-segment is integrated exactly using the
74 # 3-point Simpson rule (exact for phi_h of degree <= 2, since phi_h
75 # pulled back along a straight line stays degree <= 2 in the
76 # parameter t). This replaces the previous approach of sampling many
77 # midpoints and splitting weight equally across a cell's dofs.
78 # ------------------------------------------------------------------
79
80 def _cell_vertices_2d(self, cell_idx):
81 """Physical (x, y) vertices of a cell, ordered CCW around its centroid."""
82 x_geom = self.domain.geometry.x
83 verts = self.domain.geometry.dofmap[cell_idx]
84 pts = x_geom[verts, :2]
85 centroid = pts.mean(axis=0)
86 angles = np.arctan2(pts[:, 1] - centroid[1], pts[:, 0] - centroid[0])
87 return pts[np.argsort(angles)]
88
89 def _clip_segment_to_cell(self, pts, x, y, eps=1e-12):
90 """
91 Cyrus-Beck clip of the segment x->y against the convex polygon `cell_idx`.
92 Returns (t0, t1) in [0, 1] such that x + t*(y - x), t in [t0, t1], is the
93 part of the segment inside the cell -- or None if there's no overlap.
94 Works for any convex cell (triangle or quad); assumes straight-sided
95 (affine, P1) geometry.
96 """
97 n_verts = len(pts)
98 d = y - x
99 t_lo, t_hi = 0.0, 1.0
100
101 for i in range(n_verts):
102 v0 = pts[i]
103 v1 = pts[(i + 1) % n_verts]
104 edge = v1 - v0
105 normal = np.array([edge[1], -edge[0]]) # outward normal (CCW polygon)
106
107 denom = normal @ d
108 numer = normal @ (v0 - x)
109
110 if abs(denom) < eps:
111 if numer < -eps: # segment parallel to edge, and entirely outside it
112 return None
113 continue # parallel and inside: this edge imposes no constraint
114
115 t_edge = numer / denom
116 if denom > 0:
117 t_hi = min(t_hi, t_edge)
118 else:
119 t_lo = max(t_lo, t_edge)
120
121 if t_lo > t_hi + eps:
122 return None
123
124 if t_lo > t_hi:
125 return None
126 return t_lo, t_hi
127
63 def matrix(self, beams): 128 def matrix(self, beams):
64 """Combined: generate beams + segment + cell collision + dof weighting""" 129 """Combined: exact beam/cell intersection + exact per-cell line integration."""
65 dofmap = self.V.dofmap
66 tdim = self.domain.topology.dim
67
68 # Geometry trees
69
70 bb_tree = geometry.bb_tree(self.domain, tdim)
71
72 n_dofs = self.V.dofmap.index_map.size_global 130 n_dofs = self.V.dofmap.index_map.size_global
73 R = np.zeros((self.M, n_dofs)) 131 # R = np.zeros((self.M, n_dofs))
74 132 rows, cols, data = [], [], []
75 xmin, xmax, ymin, ymax = self.domain_size 133
76 134 for cell_idx in range(len(self.domain.geometry.dofmap)):
77 for beam_idx, (start_pt, end_pt) in enumerate(beams): 135 pts = self._cell_vertices_2d(cell_idx)
78 # Segment into midpoints 136 for beam_idx, (start_pt, end_pt) in enumerate(beams):
79 vec = end_pt - start_pt 137 clip = self._clip_segment_to_cell(pts, start_pt, end_pt)
80 total_len = np.linalg.norm(vec) 138 if clip is not None:
81 seg_len = total_len / self.num_segments 139 t0, t1 = clip
82 140 if t1 - t0 > 1e-10:
83 midpoints = [] 141 beam_v = end_pt - start_pt
84 for i in range(self.num_segments): 142 x0 = start_pt + t0 * beam_v
85 t = (i + 0.5) / self.num_segments 143 x1 = start_pt + t1 * beam_v
86 midpoint = start_pt + t * vec 144 midpt = start_pt + (0.5 * (t1 + t0)) * beam_v
87 midpoints.append(midpoint) 145 """
88 # midpoints = np.array(midpoints, dtype=np.float64) # Force float64 146 Exact int_{x0}^{x1} phi_h ds for phi_h in V restricted to `cell_idx`, as a
89 147 (global_dofs, row_values) pair such that row_values @ phi_h.x.array[global_dofs]
90 # Ensure shape (N, 3), C-contiguous, read-only for DOLFINx 148 equals the integral. Uses the 3-point Simpson rule (endpoints + midpoint),
91 # assert midpoints.shape[1] == 2, ( 149 which is exact since phi_h has degree <= 2 along the line.
92 # "Expected 2D points, got shape[1]=2 but need padding to 3D" 150 """
93 # ) 151 x_geom = self.domain.geometry.x
94 # midpoints = np.pad( 152 verts = self.domain.geometry.dofmap[cell_idx]
95 # midpoints, ((0, 0), (0, 1)), mode="constant" 153 v0, v1, v2 = (
96 # ) # (N, 3) with z=0 154 x_geom[verts[0], :2],
97 # midpoints = np.ascontiguousarray(midpoints) # C-order 155 x_geom[verts[1], :2],
98 # midpoints.setflags(write=False) # Read-only requirement 156 x_geom[verts[2], :2],
99 157 )
100 # Find colliding cells 158 J = np.column_stack([v1 - v0, v2 - v0])
101 # cell_candidates = geometry.compute_collisions_points(bb_tree, midpoints) 159 """Map a physical point p to reference coordinates on an affine triangle."""
102 # colliding_cells = geometry.compute_colliding_cells( 160 pts_ref = np.array(
103 # self.domain, cell_candidates, midpoints 161 [
104 # ) 162 np.linalg.solve(J, x0 - v0),
105 163 np.linalg.solve(J, midpt - v0),
106 # Cell→DOFs→weight accumulation 164 np.linalg.solve(J, x1 - v0),
107 # cell_lists = list(colliding_cells.array) 165 ]
108 # for seg_i, cell_idx_raw in enumerate(cell_lists): 166 )
109 # cell_idx = int(cell_idx_raw) # Convert np.int32 → Python int 167 tab = self._element.tabulate(0, pts_ref)[
110 for point in midpoints: 168 0
111 pad_point = np.array([point[0], point[1], 0.0]) 169 ] # (npoints, ndof_local, value_size)
112 cell_idx = cell_FunctionSpace_f64(self.V._cpp_object, pad_point) 170 tab = tab[
113 if cell_idx >= 0: # Valid cell index 171 ..., 0
114 # Get global DOFs 172 ] # scalar element -> drop trailing value_size=1 axis
115 cell_dofs_local = dofmap.cell_dofs(cell_idx) 173
116 # Convert local dofs to numpy int32 array (DOLFINx requirement) 174 length = (t1 - t0) * np.linalg.norm(beam_v)
117 # local_dofs_array = np.asarray(cell_dofs_local, dtype=np.int32) 175 weights = np.array([1.0, 4.0, 1.0]) * length / 6.0
118 # local_dofs_array.setflags(write=False) # Read-only requirement 176 local_dofs = self.V.dofmap.cell_dofs(cell_idx)
119 177 # global_dofs = self.V.dofmap.index_map.local_to_global(local_dofs)
120 # Batch convert all local indices to global in one call 178 # R[beam_idx, local_dofs] += weights @ tab
121 cell_dofs_global = dofmap.index_map.local_to_global(cell_dofs_local) 179 rows.append(np.full(len(local_dofs), beam_idx, dtype=np.int64))
122 cell_dofs = np.unique(cell_dofs_global) 180 cols.append(np.asarray(local_dofs, dtype=np.int64))
123 181 data.append(weights @ tab)
124 weight = seg_len / len(cell_dofs) 182
125 R[beam_idx, cell_dofs] += weight # ADD for overlaps! 183 # return R
126 184
127 return R 185 if rows:
186 rows = np.concatenate(rows)
187 cols = np.concatenate(cols)
188 data = np.concatenate(data)
189 else:
190 rows = np.array([], dtype=np.int64)
191 cols = np.array([], dtype=np.int64)
192 data = np.array([], dtype=np.float64)
193
194 return coo_matrix((data, (rows, cols)), shape=(self.M, n_dofs)).tocsr()
128 195
129 def apply(self, x): 196 def apply(self, x):
130 """ 197 """
131 This does not work with MPI 198 This does not work with MPI
132 """ 199 """
141 # We have ⟨x,R^*v⟩_V = ⟨Ax.array,[R^*v].array⟩_{ℝ^m} 208 # We have ⟨x,R^*v⟩_V = ⟨Ax.array,[R^*v].array⟩_{ℝ^m}
142 # But Rx = R₀ x.array, so we need 209 # But Rx = R₀ x.array, so we need
143 # ⟨x.array,R₀^* v⟩_{ℝ^n} = ⟨Ax.array,[R^*v].array⟩_{ℝ^m} 210 # ⟨x.array,R₀^* v⟩_{ℝ^n} = ⟨Ax.array,[R^*v].array⟩_{ℝ^m}
144 # Taking [R^*v].array=A^{-1}R₀^* v, the conjugate works correctly. 211 # Taking [R^*v].array=A^{-1}R₀^* v, the conjugate works correctly.
145 tmp = fem.Function(self.V) 212 tmp = fem.Function(self.V)
146 np.matmul(self.R.T, j, out=tmp.x.array) 213 res = fem.Function(self.V)
214 tmp.x.array[:] = self.R.T @ j
147 tmp.x.scatter_forward() 215 tmp.x.scatter_forward()
148 return tmp 216 self.solver.solve(tmp.x.petsc_vec, res.x.petsc_vec)
149 # TODO: is convection_diffusion actualy based on norms in ℝ^n? 217 res.x.scatter_forward()
150 # res = fem.Function(self.V) 218 return res
151 # res.x.array[:] = 0.0
152 # self.solver.solve(tmp.x.petsc_vec, res.x.petsc_vec)
153 # res.x.scatter_forward()
154 # return res
155 219
156 def opnorm(self, *args): 220 def opnorm(self, *args):
157 # raise NotImplementedError("Need mesh weighting?") 221 # raise NotImplementedError("Need mesh weighting?")
158 return self._opnorm 222 return self._opnorm
159 223

mercurial