lsdo-function-spaces 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. lsdo_function_spaces/__init__.py +64 -0
  2. lsdo_function_spaces/core/__init__.py +0 -0
  3. lsdo_function_spaces/core/function.py +1322 -0
  4. lsdo_function_spaces/core/function_set.py +1081 -0
  5. lsdo_function_spaces/core/function_set_space.py +379 -0
  6. lsdo_function_spaces/core/function_space.py +482 -0
  7. lsdo_function_spaces/core/operations/__init__.py +0 -0
  8. lsdo_function_spaces/core/operations/basic_ops.py +85 -0
  9. lsdo_function_spaces/core/operations/operations.py +5 -0
  10. lsdo_function_spaces/core/optimization.py +183 -0
  11. lsdo_function_spaces/core/spaces/__init__.py +0 -0
  12. lsdo_function_spaces/core/spaces/b_spline_space.py +418 -0
  13. lsdo_function_spaces/core/spaces/conditional_space.py +65 -0
  14. lsdo_function_spaces/core/spaces/constant_space.py +57 -0
  15. lsdo_function_spaces/core/spaces/idw_space.py +271 -0
  16. lsdo_function_spaces/core/spaces/non_cython_bsplines/__init__.py +0 -0
  17. lsdo_function_spaces/core/spaces/non_cython_bsplines/b_spline_csdl_custom_ops.py +420 -0
  18. lsdo_function_spaces/core/spaces/non_cython_bsplines/b_spline_patch_projection.py +1022 -0
  19. lsdo_function_spaces/core/spaces/non_cython_bsplines/b_spline_patch_projection_non_differentiable.py +186 -0
  20. lsdo_function_spaces/core/spaces/non_cython_bsplines/b_spline_patch_projection_optimized.py +594 -0
  21. lsdo_function_spaces/core/spaces/non_cython_bsplines/b_spline_space_new.py +6 -0
  22. lsdo_function_spaces/core/spaces/non_cython_bsplines/compute_basis_matrix_jax.py +172 -0
  23. lsdo_function_spaces/core/spaces/non_cython_bsplines/compute_basis_matrix_jax_factory.py +382 -0
  24. lsdo_function_spaces/core/spaces/non_cython_bsplines/compute_basis_matrix_jax_stencil.py +451 -0
  25. lsdo_function_spaces/core/spaces/non_cython_bsplines/compute_basis_matrix_numpy.py +249 -0
  26. lsdo_function_spaces/core/spaces/non_cython_bsplines/compute_basis_matrix_numpy_factory.py +391 -0
  27. lsdo_function_spaces/core/spaces/operation_space.py +64 -0
  28. lsdo_function_spaces/core/spaces/polynomial_space.py +79 -0
  29. lsdo_function_spaces/core/spaces/rbf_space.py +136 -0
  30. lsdo_function_spaces/core/spaces/tri_space.py +256 -0
  31. lsdo_function_spaces/utils/__init__.py +0 -0
  32. lsdo_function_spaces/utils/file_io.py +484 -0
  33. lsdo_function_spaces/utils/internal_utilities.py +11 -0
  34. lsdo_function_spaces/utils/plotting_functions.py +357 -0
  35. lsdo_function_spaces/utils/utility_functions.py +148 -0
  36. lsdo_function_spaces-1.0.0.dist-info/METADATA +189 -0
  37. lsdo_function_spaces-1.0.0.dist-info/RECORD +40 -0
  38. lsdo_function_spaces-1.0.0.dist-info/WHEEL +5 -0
  39. lsdo_function_spaces-1.0.0.dist-info/licenses/LICENSE.txt +165 -0
  40. lsdo_function_spaces-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1022 @@
1
+ import jax
2
+ import jax.numpy as jnp
3
+ import numpy as np
4
+ from functools import partial
5
+ from jax import custom_jvp, custom_vjp
6
+ from lsdo_function_spaces.core.spaces.non_cython_bsplines.compute_basis_matrix_jax_stencil import evaluate_b_spline_jax
7
+ from lsdo_function_spaces.core.spaces.non_cython_bsplines.compute_basis_matrix_numpy import compute_basis_matrix_numpy
8
+ import csdl_alpha as csdl
9
+ import time
10
+
11
+
12
+ jax.config.update("jax_enable_x64", True)
13
+
14
+
15
+ @partial(jax.jit, static_argnames=("degrees", "knot_vectors"))
16
+ def compute_projection_residual(
17
+ point_in_space,
18
+ para_coords,
19
+ degrees,
20
+ coefficients,
21
+ knot_vectors,
22
+ ):
23
+ """
24
+ Compute the residual of the projection of a point in space onto a B-spline surface.
25
+
26
+ Parameters:
27
+ -----------
28
+ point_in_space : jnp.ndarray, shape (d,)
29
+ The point in space to project.
30
+ para_coords : jnp.ndarray, shape (M, d)
31
+ Parameter coordinates for the B-spline surface.
32
+ degrees : tuple of int
33
+ Degrees of the B-spline in each dimension.
34
+ coefficients : jnp.ndarray, shape (N, num_phys_dims)
35
+ Coefficients of the B-spline basis functions.
36
+ knot_vectors : tuple of jnp.ndarray
37
+ Knot vectors for each dimension.
38
+
39
+ Returns:
40
+ --------
41
+ residual : jnp.ndarray, shape (M,)
42
+ The residuals of the projection.
43
+ """
44
+ n_dims = len(degrees)
45
+ n_phys_dims = coefficients.shape[-1]
46
+ point_in_space = point_in_space.reshape(-1, n_phys_dims)
47
+
48
+ # Convert static, hashable knot_vectors (tuples of floats) to JAX arrays
49
+ knot_vectors = tuple(jnp.array(U) for U in knot_vectors)
50
+
51
+ bsp_eval = evaluate_b_spline_jax(
52
+ us=para_coords,
53
+ degrees=degrees,
54
+ knot_vectors=knot_vectors,
55
+ coeffs=coefficients,
56
+ )
57
+
58
+ surface_jacobian = [
59
+ evaluate_b_spline_jax(
60
+ us=para_coords,
61
+ degrees=degrees,
62
+ knot_vectors=knot_vectors,
63
+ coeffs=coefficients,
64
+ der_orders=tuple(
65
+ int(i == j) for j in range(n_dims)
66
+ )
67
+ ) for i in range(n_dims)
68
+ ]
69
+ surface_jacobian_array = jnp.array(surface_jacobian).squeeze().T
70
+
71
+ diff = bsp_eval - point_in_space
72
+
73
+ residuals = [jnp.sum(diff * d, axis=1) for d in surface_jacobian] # shape: (num_pts,) for each
74
+ res = jnp.array(residuals).reshape(n_dims, )
75
+
76
+ return res, bsp_eval, surface_jacobian, surface_jacobian_array
77
+
78
+ @partial(jax.jit, static_argnames=("degrees", "knot_vectors"))
79
+ def compute_projection_jacobian(
80
+ point_in_space,
81
+ para_coords,
82
+ degrees,
83
+ coefficients,
84
+ knot_vectors,
85
+ bsp_eval,
86
+ surface_jacobian,
87
+ ):
88
+ """
89
+ Compute the Jacobian of the projection of a point in space onto a B-spline surface.
90
+
91
+ Parameters:
92
+ -----------
93
+ point_in_space : jnp.ndarray, shape (d,)
94
+ The point in space to project.
95
+ para_coords : jnp.ndarray, shape (M, d)
96
+ Parameter coordinates for the B-spline surface.
97
+ degrees : tuple of int
98
+ Degrees of the B-spline in each dimension.
99
+ coefficients : jnp.ndarray, shape (N, num_phys_dims)
100
+ Coefficients of the B-spline basis functions.
101
+ knot_vectors : tuple of jnp.ndarray
102
+ Knot vectors for each dimension.
103
+ bsp_eval : jnp.ndarray, shape (M, num_phys_dims)
104
+ B-spline evaluation at the parameter coordinates.
105
+ surface_jacobian : list of jnp.ndarray, shape (M, num_phys_dims)
106
+ Jacobian of the B-spline surface at the parameter coordinates.
107
+
108
+ Returns:
109
+ --------
110
+ jacobian : jnp.ndarray, shape (M, d)
111
+ The Jacobian of the projection.
112
+ """
113
+ n_dims = len(degrees)
114
+
115
+ # Convert static, hashable knot_vectors (tuples of floats) to JAX arrays
116
+ knot_vectors = tuple(jnp.array(U) for U in knot_vectors)
117
+
118
+ diff = (bsp_eval - point_in_space).flatten()
119
+
120
+ surface_hessian_tensor = [
121
+ [evaluate_b_spline_jax(
122
+ us=para_coords,
123
+ degrees=degrees,
124
+ knot_vectors=knot_vectors,
125
+ coeffs=coefficients,
126
+ der_orders=tuple((i == k) + (j == k) for k in range(n_dims))
127
+ ) for j in range(n_dims)
128
+ ] for i in range(n_dims)
129
+ ]
130
+
131
+ projection_jacobian = jnp.zeros((n_dims, n_dims), dtype=bsp_eval.dtype)
132
+
133
+ for i in range(n_dims):
134
+ for j in range(n_dims):
135
+ inner = jnp.sum(surface_jacobian[i] * surface_jacobian[j], axis=1) + jnp.sum(diff * surface_hessian_tensor[i][j], axis=1)
136
+ projection_jacobian = projection_jacobian.at[i, j].set(inner[0])
137
+
138
+ return projection_jacobian
139
+
140
+ @partial(jax.jit, static_argnames=("degrees", "knots"))
141
+ def compute_point_to_bspline_projection(
142
+ point,
143
+ degrees,
144
+ coefficients,
145
+ para_coords,
146
+ knots,
147
+ max_iter=100,
148
+ tol=1e-12,
149
+ ):
150
+ """Project a point onto an n-dimensional B-spline using Newton iteration."""
151
+ n_dims = len(degrees) # parametric dimensions
152
+
153
+ para_coords = para_coords.reshape(n_dims, )
154
+
155
+ # Convert static, hashable knots (tuples of floats) to JAX arrays
156
+ knots = tuple(jnp.array(k) for k in knots)
157
+
158
+ # knots = tuple([knots[i] for i in range(n_dims)])
159
+
160
+ def body(state):
161
+ i, para_coords, _, _, _, _, _ = state
162
+
163
+ res, bsp_eval, surface_jacobian, surface_jacobian_array = compute_projection_residual(
164
+ point, para_coords, degrees, coefficients, knots
165
+ )
166
+ J = compute_projection_jacobian(
167
+ point, para_coords, degrees, coefficients, knots, bsp_eval, surface_jacobian,
168
+ )
169
+
170
+ grad = res
171
+ # Applying active-set like approach
172
+ # Inactive directions are those where the gradient is zero or the parameter is at the boundary
173
+ inactive_mask = jnp.logical_or(
174
+ jnp.logical_and(para_coords <= 0.0, grad > 0.0),
175
+ jnp.logical_and(para_coords >= 1.0, grad < 0.0),
176
+ )
177
+ inactive_mask = jnp.logical_or(inactive_mask, grad == 0.0)
178
+ active_mask = ~inactive_mask
179
+
180
+ # Mask Jacobian and residual using active_mask
181
+ J_masked = J * (active_mask[:, None] & active_mask[None, :])
182
+ res_masked = res * active_mask
183
+
184
+ res_norm = jnp.linalg.norm(res_masked)
185
+
186
+ # NOTE: we are solving an augmented system (always 2x2)
187
+ # Ideally we would remove the inactive directions from the system
188
+ # However, for JAX, the dimensions must be fixed for jit compilation
189
+ step = -jnp.linalg.solve(J_masked + jnp.eye(n_dims) * (~active_mask), res_masked)
190
+ step = step * active_mask # zero out inactive directions
191
+
192
+ para_coords_new = jnp.clip(para_coords + step, 0.0, 1.0)
193
+ converged_new = res_norm < tol
194
+
195
+ return (i + 1, para_coords_new, res_masked, converged_new, J, active_mask, surface_jacobian_array)
196
+
197
+ def cond(state):
198
+ i, _, _, converged, _, _, _ = state
199
+ return (i < max_iter) & (~converged)
200
+
201
+ dim = len(degrees)
202
+ bool_init = jnp.zeros_like(para_coords, dtype=jnp.bool_)
203
+ dS_dxi_init = jnp.zeros((3, dim))
204
+ init_state = (0, para_coords, jnp.zeros_like(para_coords), False, jnp.zeros((dim, dim)), bool_init, dS_dxi_init)
205
+ final_i, final_coords, final_res, final_converged, J, mask, surface_jacobian = jax.lax.while_loop(cond, body, init_state)
206
+
207
+ return final_coords, final_res, final_converged, final_i, J, mask, surface_jacobian
208
+
209
+ @partial(jax.jit, static_argnames=("degrees", "knots"))
210
+ def res_fun_single(para_coords, point, coefficients, degrees=None, knots=None):
211
+ """Compute the residual for a single point."""
212
+ return jax.jit(compute_projection_residual, static_argnums=(2, ))(
213
+ point_in_space=point,
214
+ para_coords=para_coords,
215
+ degrees=degrees,
216
+ coefficients=coefficients,
217
+ knot_vectors=knots,
218
+ )[0]
219
+
220
+ @partial(jax.jit, static_argnames=("degrees", "knots"))
221
+ def newton_solve_single(point, coefficients, para_coords_0, degrees=None, knots=None):
222
+ """Newton's method to solve the projection for a single point."""
223
+ return compute_point_to_bspline_projection(
224
+ point=point,
225
+ degrees=degrees,
226
+ coefficients=coefficients,
227
+ para_coords=para_coords_0,
228
+ knots=knots,
229
+ )[0]
230
+
231
+ @jax.custom_jvp
232
+ @jax.custom_vjp
233
+ @partial(jax.jit, static_argnames=("degrees", "knots"))
234
+ def implicit_solve_single(point, coefficients, para_coords_0, degrees=None, knots=None):
235
+ return newton_solve_single(
236
+ point=point,
237
+ coefficients=coefficients,
238
+ para_coords_0=para_coords_0,
239
+ degrees=degrees,
240
+ knots=knots,
241
+ )
242
+
243
+ @partial(jax.jit, static_argnames=("degrees", "knots"))
244
+ def fwd(point, coefficients, para_coords_0, degrees=None, knots=None):
245
+ para_coord_star = implicit_solve_single(
246
+ point=point,
247
+ coefficients=coefficients,
248
+ para_coords_0=para_coords_0,
249
+ degrees=degrees,
250
+ knots=knots,
251
+ )
252
+ return para_coord_star, (para_coord_star, point, coefficients)
253
+
254
+ @partial(jax.jit, static_argnames=("degrees", "knots"))
255
+ def bwd(primals, x_bar, degrees=None, knots=None):
256
+ n_dims = len(degrees)
257
+
258
+ para_coord_star, point, coefficients = primals
259
+ # Compute the Jacobian of the projection
260
+ res, bsp_eval, surface_jacobian, _ = compute_projection_residual(
261
+ point_in_space=point,
262
+ para_coords=para_coord_star,
263
+ degrees=degrees,
264
+ coefficients=coefficients,
265
+ knot_vectors=knots,
266
+ )
267
+
268
+ grad = res
269
+ # Applying active-set like approach
270
+ # Inactive directions are those where the gradient is zero or the parameter is at the boundary
271
+ inactive_mask = jnp.logical_or(
272
+ jnp.logical_and(para_coord_star.flatten() <= 0.0, grad > 0.0),
273
+ jnp.logical_and(para_coord_star.flatten() >= 1.0, grad < 0.0),
274
+ )
275
+ # inactive_mask = jnp.logical_or(inactive_mask, grad == 0.0)
276
+ active_mask = ~inactive_mask
277
+ jax.lax.stop_gradient(active_mask) # ensure mask is not differentiated
278
+
279
+ J = compute_projection_jacobian(
280
+ point_in_space=point,
281
+ para_coords=para_coord_star,
282
+ degrees=degrees,
283
+ coefficients=coefficients,
284
+ knot_vectors=knots,
285
+ bsp_eval=bsp_eval,
286
+ surface_jacobian=surface_jacobian,
287
+ )
288
+
289
+ # Mask Jacobian and residual using active_mask
290
+ J_masked = J * (active_mask[:, None] & active_mask[None, :])
291
+ x_bar_masked = x_bar * active_mask
292
+ J_masked = J_masked + jnp.eye(n_dims) * (~active_mask) # add identity to avoid singularity
293
+
294
+ # lambda_ = jnp.linalg.solve(J.T, x_bar)
295
+ lambda_ = jnp.linalg.solve(J_masked.T, x_bar_masked)
296
+
297
+ def res_fun_wrapped(point, coefficients):
298
+ res = res_fun_single(
299
+ para_coords=para_coord_star,
300
+ point=point,
301
+ coefficients=coefficients,
302
+ degrees=degrees,
303
+ knots=knots,
304
+ )
305
+ return res.squeeze()
306
+
307
+ _, vjp_res = jax.vjp(res_fun_wrapped, point, coefficients)
308
+ dpoint, dcoeffs = vjp_res(-lambda_)
309
+
310
+ return (dpoint, dcoeffs, None)
311
+ implicit_solve_single.defvjp(fwd, bwd)
312
+
313
+
314
+ @implicit_solve_single.defjvp
315
+ def iv_jvp(primals, tangents):
316
+ """
317
+ primals: tuple of positional arguments passed to implicit_solve_single
318
+ tangents: tuple of corresponding tangents (same length as primals)
319
+ """
320
+ # --- Defensive unpacking of primals (support 3..5 entries) ---
321
+ if len(primals) < 3:
322
+ raise ValueError("iv_jvp expected at least (point, coefficients, para_coords_0) primals")
323
+
324
+ point = primals[0]
325
+ coefficients = primals[1]
326
+ para_coords_0 = primals[2]
327
+
328
+ degrees = None
329
+ knots = None
330
+ if len(primals) >= 4:
331
+ degrees = primals[3]
332
+ if len(primals) >= 5:
333
+ knots = primals[4]
334
+
335
+ # --- Defensive unpacking of tangents (fill missing with zeros) ---
336
+ # tangents may be shorter if some primals are static/non-differentiable
337
+ def safe_get(tup, idx, fill):
338
+ try:
339
+ return tup[idx]
340
+ except Exception:
341
+ return fill
342
+
343
+ point_dot = safe_get(tangents, 0, jnp.zeros_like(point))
344
+ coeffs_dot = safe_get(tangents, 1, jnp.zeros_like(coefficients))
345
+
346
+ # --- Compute the primal solution (call with the same kwargs your forward used) ---
347
+ # Use keyword args to be robust to ordering; implicit_solve_single must accept them.
348
+ if (degrees is None) and (knots is None):
349
+ para_coord_star = implicit_solve_single(point, coefficients, para_coords_0)
350
+ else:
351
+ para_coord_star = implicit_solve_single(
352
+ point=point,
353
+ coefficients=coefficients,
354
+ para_coords_0=para_coords_0,
355
+ degrees=degrees,
356
+ knots=knots,
357
+ )
358
+
359
+ # --- Build residual wrapper evaluated at the converged parametric location ---
360
+ # res_fun_single(para_coords, point, coefficients, degrees, knots) -> residual (n_dim,)
361
+ def res_wrapped(p_in, coeffs_in):
362
+ # call the same residual function you use elsewhere; make sure shapes consistent
363
+ return res_fun_single(
364
+ para_coords=para_coord_star,
365
+ point=p_in,
366
+ coefficients=coeffs_in,
367
+ degrees=degrees,
368
+ knots=knots,
369
+ ).squeeze()
370
+
371
+ # Directional derivative of residual w.r.t. (point, coefficients) in directions (point_dot, coeffs_dot)
372
+ _, res_dir = jax.jvp(res_wrapped, (point, coefficients), (point_dot, coeffs_dot))
373
+
374
+ # Evaluate Jacobian J = dr/dpara (size = n_param_dims x n_param_dims)
375
+ res_val, bsp_eval, surface_jacobian, _ = compute_projection_residual(
376
+ point_in_space=point,
377
+ para_coords=para_coord_star,
378
+ degrees=degrees,
379
+ coefficients=coefficients,
380
+ knot_vectors=knots if (knots is not None) else None,
381
+ )
382
+
383
+ # Build active mask as in forward
384
+ grad = res_val
385
+ inactive_mask = jnp.logical_or(
386
+ jnp.logical_and(para_coord_star.flatten() <= 0.0, grad > 0.0),
387
+ jnp.logical_and(para_coord_star.flatten() >= 1.0, grad < 0.0),
388
+ )
389
+ # inactive_mask = jnp.logical_or(inactive_mask, grad == 0.0)
390
+ active_mask = ~inactive_mask
391
+ jax.lax.stop_gradient(active_mask)
392
+
393
+ J = compute_projection_jacobian(
394
+ point_in_space=point,
395
+ para_coords=para_coord_star,
396
+ degrees=degrees,
397
+ coefficients=coefficients,
398
+ knot_vectors=knots if (knots is not None) else None,
399
+ bsp_eval=bsp_eval,
400
+ surface_jacobian=surface_jacobian,
401
+ )
402
+
403
+ n_dims = J.shape[0]
404
+ J_masked = J * (active_mask[:, None] & active_mask[None, :])
405
+ J_aug = J_masked + jnp.eye(n_dims, dtype=J.dtype) * (~active_mask)
406
+ res_dir_masked = res_dir * active_mask
407
+
408
+ # Solve for parametric tangent: J * para_dot = - res_dir
409
+ # para_dot = - jnp.linalg.solve(J, res_dir)
410
+ para_dot = - jnp.linalg.solve(J_aug, res_dir_masked)
411
+
412
+ # return primal and tangent (exact pair required by defjvp)
413
+ return para_coord_star, para_dot
414
+
415
+
416
+ def _nearest_idx_for_point(bsp_pts: jnp.ndarray, query_pt: jnp.ndarray) -> jnp.ndarray:
417
+ """
418
+ Find the index of the nearest neighbor in bsp_pts to a single query_pt,
419
+ using a fori_loop to keep memory footprint O(1) per point.
420
+ """
421
+ def body(i, carry):
422
+ best_i, best_d2 = carry
423
+ # compute squared distance to bsp_pts[i]
424
+ d2 = jnp.sum((bsp_pts[i] - query_pt) ** 2)
425
+ # update if smaller
426
+ better = d2 < best_d2
427
+ new_best_i = jax.lax.select(better, i, best_i)
428
+ new_best_d2 = jax.lax.select(better, d2, best_d2)
429
+ return (new_best_i, new_best_d2)
430
+
431
+ # initialize with idx=0, distance=∞
432
+ init = (jnp.array(0, dtype=jnp.int64), jnp.array(jnp.inf, dtype=bsp_pts.dtype))
433
+ best_idx, _ = jax.lax.fori_loop(0, bsp_pts.shape[0], body, init)
434
+ return best_idx
435
+
436
+ def brute_force_nn(bsp_pts: jnp.ndarray,
437
+ queries: jnp.ndarray) -> jnp.ndarray:
438
+ """
439
+ For each row in queries (shape [Q, D]), returns the index of the closest
440
+ point in bsp_pts (shape [P, D]). Result is an array of shape [Q].
441
+ """
442
+ # vmapped over the first axis of queries
443
+ return jax.vmap(lambda q: _nearest_idx_for_point(bsp_pts, q))(queries)
444
+
445
+ class ProjectionOperationVJPVJP(csdl.experimental.CustomExplicitOperationBeta):
446
+ def __init__(
447
+ self,
448
+ num_parametric_dimensions,
449
+ degrees,
450
+ knots,
451
+ num_grid_search_points=100,
452
+ ):
453
+ super().__init__()
454
+ self.num_parametric_dimensions = num_parametric_dimensions
455
+ self.degrees = degrees
456
+ self.knots = knots
457
+ self.num_grid_search_points = num_grid_search_points
458
+
459
+ def sensitivity_analysis_single(
460
+ point,
461
+ point_dot,
462
+ coefficients,
463
+ coefficients_dot,
464
+ x_bar,
465
+ para_coords_closest,
466
+ degrees,
467
+ knots,
468
+ ):
469
+ # Build vjp-wrapper for a single point. It returns (dpoint, dcoeffs) for the current x_bar.
470
+ def vjp_wrapper(point_in, coeffs_in, xbar_in):
471
+ # 1) compute primal para_coord_star for this point (use the same initial guess logic as the forward op)
472
+ # We reuse para_coords_closest[i] as the initial guess for Newton.
473
+ para0 = jnp.array(para_coords_closest)
474
+ para_star = implicit_solve_single(
475
+ point=point_in,
476
+ coefficients=coeffs_in,
477
+ para_coords_0=para0,
478
+ degrees=degrees,
479
+ knots=knots,
480
+ )
481
+
482
+ # 2) build primals tuple in the same shape ordering used by bwd:
483
+ primals = (para_star, point_in, coeffs_in)
484
+
485
+ # 3) call bwd to get the first-order VJP outputs (dpoint, dcoeffs)
486
+ # bwd returns (dpoint, dcoeffs, None)
487
+ dpt, dcoeffs, _ = bwd(primals=primals, x_bar=xbar_in, degrees=degrees, knots=knots)
488
+ # Return both outputs so jvp can linearize them both
489
+ return dpt, dcoeffs
490
+
491
+ # Now linearize vjp_wrapper via jax.jvp.
492
+ # primals for vjp_wrapper: (point, coefficients, x_bar)
493
+ primals_vw = (point, coefficients, x_bar)
494
+ # tangents: how the inputs change — we use d_d_points[i] and d_d_control_points; assume x_bar perturbation = 0
495
+ tangents_vw = (point_dot, coefficients_dot, jnp.zeros_like(x_bar))
496
+
497
+ # perform jvp; it returns (primal_outs, tangent_outs)
498
+ (primal_outs), (tangent_outs) = jax.jvp(vjp_wrapper, primals_vw, tangents_vw)
499
+
500
+ # tangent_outs is a tuple (dpoint_dot, dcoeffs_dot)
501
+ dpoint_dot, dcoeffs_dot = tangent_outs
502
+
503
+ para0 = jnp.array(para_coords_closest)
504
+ # primals and tangents for the implicit solve
505
+ primals_solve = (point, coefficients, para0, degrees, knots) # match ordering your defjvp expects
506
+ tangents_solve = (point_dot, coefficients_dot, jnp.zeros_like(para0))
507
+
508
+ # call iv_jvp directly: it returns (primal_para_star, para_dot)
509
+ para_star_primal, para_dot = iv_jvp(primals_solve, tangents_solve)
510
+
511
+ return dpoint_dot, dcoeffs_dot, para_dot
512
+
513
+ self.sensitivity_analysis_single = sensitivity_analysis_single
514
+
515
+ self._cache = {
516
+ "points" : None,
517
+ "control_points" : None,
518
+ "para_coords" : None,
519
+ }
520
+
521
+ # Perform grid search
522
+ self.n = len(self.degrees)
523
+
524
+ def generate_parametric_grid(N):
525
+ samples_1d = []
526
+ for U in self.knots:
527
+ # take only the unique knots in sorted order
528
+ knots = np.unique(U)
529
+ # for each interval [knots[j], knots[j+1]), sample N points
530
+ pts = []
531
+ for j in range(len(knots) - 1):
532
+ a, b = knots[j], knots[j+1]
533
+ pts.append(np.linspace(a, b, N, endpoint=False))
534
+ # finally include the very last knot
535
+ pts.append(np.array([knots[-1]]))
536
+ samples_1d.append(np.concatenate(pts))
537
+
538
+ # build the d‐dimensional tensor grid
539
+ mesh = np.meshgrid(*samples_1d, indexing="ij")
540
+ # flatten each coordinate array and stack into shape (M, d)
541
+ coord_arrays = [m.flatten() for m in mesh]
542
+ grid = np.stack(coord_arrays, axis=-1)
543
+ return grid
544
+
545
+ self.para_grid = generate_parametric_grid(num_grid_search_points)
546
+
547
+ def evaluate(self, inputs, d_outputs):
548
+ points = inputs["points"].reshape(-1, 3)
549
+ control_points = inputs["control_points"]
550
+ d_para_coords = inputs["d_para_coords"]
551
+
552
+ d_points = d_outputs["d_points"]
553
+ d_control_points = d_outputs["d_control_points"]
554
+
555
+ # Inputs from 1st VJP
556
+ self.declare_input("points", points)
557
+ self.declare_input("control_points", control_points)
558
+ self.declare_input("d_para_coords", d_para_coords)
559
+
560
+ # 2nd order "V"s
561
+ self.declare_input("d_d_points", d_points)
562
+ self.declare_input("d_d_control_points", d_control_points)
563
+
564
+ d_d_points = self.create_output("d_vjp_points", points.shape)
565
+ d_d_control_points = self.create_output("d_vjp_control_points", control_points.shape)
566
+ d_d_para_cords = self.create_output("d_d_para_cords", d_para_coords.shape)
567
+
568
+ d_inputs = {
569
+ "points": d_d_points,
570
+ "control_points": d_d_control_points,
571
+ "d_para_coords": d_d_para_cords,
572
+ }
573
+
574
+ return d_inputs
575
+
576
+ def compute(self, inputs, outputs):
577
+ points = jnp.array(inputs["points"]).reshape((-1, 3))
578
+ coefficients = jnp.array(inputs["control_points"])
579
+ d_para_coords = jnp.array(inputs["d_para_coords"])
580
+
581
+ d_d_points = jnp.array(inputs["d_d_points"])
582
+ d_d_control_points = jnp.array(inputs["d_d_control_points"])
583
+
584
+ save_name = f"points_shape_{points.shape}_coefficients_shape_{coefficients.shape}"
585
+ para_coords = self.para_grid
586
+
587
+ if f"{save_name}_basis_mat" in self._cache:
588
+ basis_mat = self._cache[f"{save_name}_basis_mat"]
589
+ # print("Using cached basis matrix.")
590
+ else:
591
+ # convert stored knots (hashable tuples) back to numpy arrays for
592
+ # the NumPy implementation
593
+ knot_vectors_np = tuple(np.array(U) for U in self.knots)
594
+ basis_mat = compute_basis_matrix_numpy(
595
+ us=para_coords,
596
+ degrees=self.degrees,
597
+ knot_vectors=knot_vectors_np,
598
+ )
599
+ self._cache[f"{save_name}_basis_mat"] = basis_mat
600
+ bsp_eval = basis_mat @ coefficients.reshape(-1, coefficients.shape[-1])
601
+
602
+
603
+ if f"{save_name}_nearest_indices" in self._cache:
604
+ nearest_indices_fun = self._cache[f"{save_name}_nearest_indices"]
605
+ # print("Using cached nearest indices REVERSE (2nd order VJP).")
606
+ else:
607
+ nearest_indices_fun = jax.jit(brute_force_nn)
608
+ self._cache[f"{save_name}_nearest_indices"] = nearest_indices_fun
609
+
610
+ nearest_indices = nearest_indices_fun(jnp.array(bsp_eval), points)
611
+ para_coords_closest = para_coords[nearest_indices]
612
+
613
+
614
+ if f"{save_name}_sensitivity_analysis" in self._cache:
615
+ sensitivity_analysis_jitted = self._cache[f"{save_name}_sensitivity_analysis"]
616
+ # print("Using cached sensitivity analysis single.")
617
+ else:
618
+ sensitivity_analysis_single = self.sensitivity_analysis_single
619
+ sensitivity_analysis_batched = jax.vmap(
620
+ sensitivity_analysis_single,
621
+ in_axes=(0, 0, None, None, 0, 0, None, None),
622
+ )
623
+ # sensitivity_analysis_jitted = jax.jit(sensitivity_analysis_batched, static_argnums=(6, ))
624
+ sensitivity_analysis_jitted = sensitivity_analysis_batched
625
+
626
+ self._cache[f"{save_name}_sensitivity_analysis"] = sensitivity_analysis_jitted
627
+
628
+ t1 = time.time()
629
+ dpoint_dot, dcoeffs_dot, para_dot = sensitivity_analysis_jitted(
630
+ points,
631
+ d_d_points,
632
+ coefficients,
633
+ d_d_control_points,
634
+ d_para_coords,
635
+ para_coords_closest,
636
+ self.degrees,
637
+ self.knots,
638
+ )
639
+ t2 = time.time()
640
+ print(f"Second-order sensitivity analysis took {t2 - t1:.4f} seconds.")
641
+
642
+ outputs["d_vjp_points"] = jax.device_get(dpoint_dot)
643
+ outputs["d_vjp_control_points"] = jax.device_get(jnp.sum(dcoeffs_dot, axis=0)) # accumulate control points
644
+ outputs["d_d_para_cords"] = jax.device_get(para_dot)
645
+
646
+ class ProjectionOperationVJP(csdl.experimental.CustomExplicitOperationBeta):
647
+ def __init__(
648
+ self,
649
+ num_parametric_dimensions,
650
+ degrees,
651
+ knots,
652
+ num_grid_search_points=100,
653
+ ):
654
+ super().__init__()
655
+ self.num_parametric_dimensions = num_parametric_dimensions
656
+ self.degrees = degrees
657
+ self.knots = knots
658
+ self.num_grid_search_points = num_grid_search_points
659
+
660
+ def sensitivity_analysis_single(
661
+ point,
662
+ coefficients,
663
+ para_coords_closest,
664
+ d_para_coords,
665
+ ):
666
+ para_coords_star = implicit_solve_single(
667
+ point, coefficients, jnp.array(para_coords_closest),
668
+ degrees=self.degrees, knots=self.knots
669
+ )
670
+
671
+ para_coords_star = para_coords_star.reshape(-1, len(self.degrees))
672
+
673
+ primals = (para_coords_star, point, coefficients)
674
+
675
+ dpoint, dcoeffs, _ = bwd(
676
+ primals=primals,
677
+ x_bar=d_para_coords,
678
+ degrees=self.degrees,
679
+ knots=self.knots,
680
+ )
681
+
682
+ return dpoint, dcoeffs
683
+ self.sensitivity_analysis_single = sensitivity_analysis_single
684
+
685
+ self._cache = {
686
+ "points" : None,
687
+ "control_points" : None,
688
+ "para_coords" : None,
689
+ }
690
+
691
+ # Perform grid search
692
+ self.n = len(self.degrees)
693
+
694
+ def generate_parametric_grid(N):
695
+ samples_1d = []
696
+ for U in self.knots:
697
+ # take only the unique knots in sorted order
698
+ knots = np.unique(U)
699
+ # for each interval [knots[j], knots[j+1]), sample N points
700
+ pts = []
701
+ for j in range(len(knots) - 1):
702
+ a, b = knots[j], knots[j+1]
703
+ pts.append(np.linspace(a, b, N, endpoint=False))
704
+ # finally include the very last knot
705
+ pts.append(np.array([knots[-1]]))
706
+ samples_1d.append(np.concatenate(pts))
707
+
708
+ # build the d‐dimensional tensor grid
709
+ mesh = np.meshgrid(*samples_1d, indexing="ij")
710
+ # flatten each coordinate array and stack into shape (M, d)
711
+ coord_arrays = [m.flatten() for m in mesh]
712
+ grid = np.stack(coord_arrays, axis=-1)
713
+ return grid
714
+
715
+ self.para_grid = generate_parametric_grid(num_grid_search_points)
716
+
717
+ def evaluate(self, inputs, d_outputs):
718
+ # print("evaluate first")
719
+ points = inputs["points"].reshape(-1, 3)
720
+ control_points = inputs["control_points"]
721
+ d_para_coords = d_outputs["para_coords"]
722
+
723
+ self.declare_input("points", points)
724
+ self.declare_input("control_points", control_points)
725
+ self.declare_input("d_para_coords", d_para_coords)
726
+
727
+ d_points = self.create_output("d_points", points.shape)
728
+ d_control_points = self.create_output("d_control_points", control_points.shape)
729
+
730
+ self.declare_vjp_function(
731
+ ProjectionOperationVJPVJP,
732
+ num_parametric_dimensions=self.num_parametric_dimensions,
733
+ knots=self.knots,
734
+ degrees=self.degrees,
735
+ num_grid_search_points=self.num_grid_search_points,
736
+ )
737
+
738
+ d_inputs = {
739
+ "points": d_points,
740
+ "control_points": d_control_points,
741
+ }
742
+
743
+ return d_inputs
744
+
745
+ def compute(self, inputs, outputs):
746
+ points = jnp.array(inputs["points"]).reshape((-1, 3))
747
+ coefficients = jnp.array(inputs["control_points"])
748
+ d_para_coords = jnp.array(inputs["d_para_coords"])
749
+
750
+ save_name = f"points_shape_{points.shape}_coefficients_shape_{coefficients.shape}"
751
+ para_coords = self.para_grid
752
+
753
+ if f"{save_name}_basis_mat" in self._cache:
754
+ basis_mat = self._cache[f"{save_name}_basis_mat"]
755
+ # print("Using cached basis matrix.")
756
+ else:
757
+ knot_vectors_np = tuple(np.array(U) for U in self.knots)
758
+ basis_mat = compute_basis_matrix_numpy(
759
+ us=para_coords,
760
+ degrees=self.degrees,
761
+ knot_vectors=knot_vectors_np,
762
+ )
763
+ self._cache[f"{save_name}_basis_mat"] = basis_mat
764
+ bsp_eval = basis_mat @ coefficients.reshape(-1, coefficients.shape[-1])
765
+
766
+ if f"{save_name}_nearest_indices" in self._cache:
767
+ nearest_indices_fun = self._cache[f"{save_name}_nearest_indices"]
768
+ # print("Using cached nearest indices REVERSE.")
769
+ else:
770
+ nearest_indices_fun = jax.jit(brute_force_nn)
771
+ self._cache[f"{save_name}_nearest_indices"] = nearest_indices_fun
772
+
773
+ nearest_indices = nearest_indices_fun(jnp.array(bsp_eval), points)
774
+ para_coords_closest = para_coords[nearest_indices]
775
+
776
+ if f"{save_name}_sensitivity_analysis" in self._cache:
777
+ sensitivity_analysis_jitted = self._cache[f"{save_name}_sensitivity_analysis"]
778
+ # print("Using cached sensitivity analysis single.")
779
+ else:
780
+ sensitivity_analysis_single = self.sensitivity_analysis_single
781
+ sensitivity_analysis_batched = jax.vmap(
782
+ sensitivity_analysis_single,
783
+ in_axes=(0, None, 0, 0),
784
+ )
785
+ # sensitivity_analysis_jitted = jax.jit(sensitivity_analysis_batched, static_argnums=(2, ))
786
+ sensitivity_analysis_jitted = sensitivity_analysis_batched
787
+
788
+ self._cache[f"{save_name}_sensitivity_analysis"] = sensitivity_analysis_jitted
789
+
790
+ dpoint, dcoeffs = sensitivity_analysis_jitted(
791
+ points,
792
+ coefficients,
793
+ para_coords_closest,
794
+ d_para_coords,
795
+ )
796
+
797
+
798
+ outputs["d_points"] = jax.device_get(dpoint)
799
+ outputs["d_control_points"] = jax.device_get(jnp.sum(dcoeffs, axis=0)) # accumulate control points
800
+
801
+ class ProjectionOperation(csdl.experimental.CustomExplicitOperationBeta):
802
+ def __init__(
803
+ self,
804
+ num_parametric_dimensions,
805
+ degrees,
806
+ knots,
807
+ num_grid_search_points=100,
808
+ ):
809
+ super().__init__()
810
+ self.num_parametric_dimensions = num_parametric_dimensions
811
+ self.degrees = degrees
812
+ self.knots = knots
813
+ self.num_grid_search_points = num_grid_search_points
814
+ self.res_jac = None
815
+ self.mask = None
816
+ self.final_coords = None
817
+
818
+ def point_to_b_spline_projection_single(point, para_coords, coefficients):
819
+ return compute_point_to_bspline_projection(
820
+ point=point,
821
+ degrees=degrees,
822
+ coefficients=coefficients,
823
+ para_coords=para_coords,
824
+ knots=knots,
825
+ )
826
+ self.point_to_b_spline_projection_batched = jax.vmap(point_to_b_spline_projection_single, in_axes=(0, 0, None), out_axes=0)
827
+
828
+ self._cache = {
829
+ "points" : None,
830
+ "control_points" : None,
831
+ "para_coords" : None,
832
+ }
833
+
834
+ # Perform grid search
835
+ self.n = len(self.degrees)
836
+
837
+ def generate_parametric_grid(N):
838
+ samples_1d = []
839
+ for U in self.knots:
840
+ # take only the unique knots in sorted order
841
+ knots = np.unique(U)
842
+ # for each interval [knots[j], knots[j+1]), sample N points
843
+ pts = []
844
+ for j in range(len(knots) - 1):
845
+ a, b = knots[j], knots[j+1]
846
+ pts.append(np.linspace(a, b, N, endpoint=False))
847
+ # finally include the very last knot
848
+ pts.append(np.array([knots[-1]]))
849
+ samples_1d.append(np.concatenate(pts))
850
+
851
+ # build the d‐dimensional tensor grid
852
+ mesh = np.meshgrid(*samples_1d, indexing="ij")
853
+ # flatten each coordinate array and stack into shape (M, d)
854
+ coord_arrays = [m.flatten() for m in mesh]
855
+ grid = np.stack(coord_arrays, axis=-1)
856
+ return grid
857
+
858
+ self.para_grid = generate_parametric_grid(num_grid_search_points)
859
+
860
+ def evaluate(self, points, control_points):
861
+ points = points.reshape(-1, 3)
862
+ num_points = points.shape[0]
863
+ self.declare_input("points", points)
864
+ self.declare_input("control_points", control_points)
865
+
866
+ para_coords = self.create_output("para_coords", (num_points, self.num_parametric_dimensions))
867
+
868
+ self.declare_vjp_function(
869
+ ProjectionOperationVJP,
870
+ num_parametric_dimensions=self.num_parametric_dimensions,
871
+ knots=self.knots,
872
+ degrees=self.degrees,
873
+ num_grid_search_points=self.num_grid_search_points,
874
+ )
875
+
876
+ return para_coords
877
+
878
+ def compute(self, inputs, outputs):
879
+ points = jnp.array(inputs["points"])
880
+ control_points = jnp.array(inputs["control_points"])
881
+
882
+ save_name = f"points_shape_{points.shape}_control_points_shape_{control_points.shape}"
883
+ para_coords = self.para_grid
884
+
885
+ if f"{save_name}_basis_mat" in self._cache:
886
+ basis_mat = self._cache[f"{save_name}_basis_mat"]
887
+ # print("Using cached basis matrix.")
888
+ else:
889
+ knot_vectors_np = tuple(np.array(U) for U in self.knots)
890
+ basis_mat = compute_basis_matrix_numpy(
891
+ us=para_coords,
892
+ degrees=self.degrees,
893
+ knot_vectors=knot_vectors_np,
894
+ )
895
+ self._cache[f"{save_name}_basis_mat"] = basis_mat
896
+ bsp_eval = basis_mat @ control_points.reshape(-1, control_points.shape[-1])
897
+
898
+ if f"{save_name}_nearest_indices" in self._cache:
899
+ nearest_indices_fun = self._cache[f"{save_name}_nearest_indices"]
900
+ print("Using cached nearest indices FORWARD.")
901
+ else:
902
+ nearest_indices_fun = jax.jit(brute_force_nn)
903
+ self._cache[f"{save_name}_nearest_indices"] = nearest_indices_fun
904
+
905
+ nearest_indices = nearest_indices_fun(jnp.array(bsp_eval), points)
906
+ para_coords_closest = para_coords[nearest_indices]
907
+
908
+
909
+ if f"{save_name}_projection_fun" in self._cache:
910
+ projection_fun = self._cache[f"{save_name}_projection_fun"]
911
+ # print("Using cached projection function.")
912
+ else:
913
+ projection_fun = jax.jit(
914
+ self.point_to_b_spline_projection_batched,
915
+ )
916
+ self._cache[f"{save_name}_projection_fun"] = projection_fun
917
+
918
+ final_coords, final_res, converged, final_i, J, mask, _ = projection_fun(
919
+ jnp.array(points),
920
+ jnp.array(para_coords_closest),
921
+ jnp.array(control_points),
922
+ )
923
+
924
+ self.mask = mask
925
+
926
+ if not converged.all():
927
+ print(f"Warning: {np.sum(~converged)} out of {len(converged)} projection points did not converge.")
928
+ print("Initial guess for these points was:", para_coords_closest[~converged])
929
+ print("Final parameter coordinates for these points were:", final_coords[~converged])
930
+ print("Final residuals for these points were:", final_res[~converged])
931
+ print("Final iteration counts for these points were:", final_i[~converged])
932
+ print("Jacobian for these points was:", J[~converged])
933
+
934
+ outputs["para_coords"] = jax.device_get(final_coords)
935
+
936
+ if __name__ == "__main__":
937
+ import numpy as np
938
+ np.random.seed(42) # For reproducibility
939
+
940
+
941
+ rec = csdl.Recorder(inline=True)
942
+ rec.start()
943
+
944
+ # Define the B-spline space parameters
945
+ num_cp_x = 10
946
+ num_cp_y = 8
947
+ nx = num_cp_x - 1 # Number of control points - 1
948
+ ny = num_cp_y - 1 # Number of control points - 1
949
+ px = 3 # Degree of the B-spline
950
+ py = 2 # Degree of the B-spline
951
+ p = (px, py)
952
+ coefficients_shape = (num_cp_x, num_cp_y)
953
+ derivative_orders = (1, 0) # # derivatives for the evaluation
954
+
955
+ knots_x = np.concatenate(
956
+ [np.zeros(px),
957
+ np.linspace(0, 1, num_cp_x - px + 1),
958
+ np.ones(px)]
959
+ )
960
+ knots_y = np.concatenate(
961
+ [np.zeros(py),
962
+ np.linspace(0, 1, num_cp_y - py + 1),
963
+ np.ones(py)]
964
+ )
965
+
966
+ # Make knot vectors hashable Python tuples so they can be used as static
967
+ # arguments to jax.jit. Functions in this module convert them back to
968
+ # JAX arrays when needed.
969
+ knots = (
970
+ tuple(knots_x.tolist()),
971
+ tuple(knots_y.tolist()),
972
+ )
973
+ knots_jnp = (jnp.array(knots_x), jnp.array(knots_y)) # JAX-compatible knot vectors
974
+
975
+
976
+ test_dv = csdl.Variable(
977
+ name="test_design_variable",
978
+ value=np.array([0.25, 0., 0.5]),
979
+ )
980
+ # test_dv.set_as_design_variable()
981
+
982
+ coeffs_x, coeffs_y = np.meshgrid(np.linspace(0, 5, num_cp_x), np.linspace(0, 2, num_cp_y), indexing='ij')
983
+ coeffs = np.array(np.stack((coeffs_x, coeffs_y, 0.2 * np.random.rand(num_cp_x, num_cp_y)), axis=-1))
984
+ coeffs_jnp = jnp.array(coeffs)
985
+ coeffs_csdl = csdl.Variable(name="coefficients", value=coeffs)
986
+ coeffs_csdl = coeffs_csdl
987
+ # test_dv = csdl.expand(test_dv, out_shape=coeffs_csdl.shape, action="k->ijk")
988
+ # coeffs_csdl = coeffs_csdl + test_dv
989
+ coeffs_csdl.set_as_design_variable()
990
+
991
+
992
+ random_points_in_space = np.random.rand(100, 3) # [1, :].reshape(-1, 3) # Random points in space
993
+ random_points_in_space[:, 0] *= 5
994
+ random_points_in_space[:, 1] *= 2
995
+ random_points_in_space[:, 2] = 5
996
+
997
+ random_points_in_space_csdl = csdl.Variable(name="points_to_project", value=random_points_in_space)
998
+ random_points_in_space_csdl.set_as_design_variable()
999
+
1000
+ projection_op = ProjectionOperation(
1001
+ num_parametric_dimensions=2,
1002
+ degrees=p,
1003
+ knots=knots,
1004
+ num_grid_search_points=100,
1005
+ )
1006
+
1007
+ uvs = projection_op.evaluate(
1008
+ points=random_points_in_space_csdl,
1009
+ control_points=coeffs_csdl,
1010
+ )
1011
+ uvs_sum = csdl.sum(uvs)
1012
+ d_uvs_sum_d_coeffs = csdl.derivative(uvs_sum, coeffs_csdl)
1013
+ d_uvs_sum_d_coeffs_sum = csdl.sum(d_uvs_sum_d_coeffs)
1014
+ d_uvs_sum_d_coeffs_sum.name = "projection_derivative (objective)"
1015
+ d_uvs_sum_d_coeffs_sum.set_as_objective()
1016
+
1017
+ sim = csdl.experimental.JaxSimulator(recorder=rec, gpu=False)
1018
+ # sim = csdl.experimental.PySimulator(recorder=rec)
1019
+ sim.check_optimization_derivatives(step_size=1e-8, raise_on_error=False)
1020
+
1021
+ print("Parametric coordinates:", uvs.value)
1022
+