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,484 @@
1
+ """STEP file importer (patched).
2
+
3
+ Correctness fixes:
4
+ - Robust entity parsing across line wraps (entities end with ';')
5
+ - Correct float parsing for knots/control points (supports E/e and D/d exponents)
6
+ - Correct knot expansion with multiplicities
7
+ - Order-preserving control point lookup via id->xyz dictionary
8
+
9
+ Performance improvements:
10
+ - Single pass read: builds entity dict and point dict once
11
+ - Avoids pandas and global regex scans
12
+ - Caches BSplineSpace objects by a stable hash key
13
+
14
+ Intended primarily for OpenVSP-exported STEP files containing B_SPLINE_SURFACE_WITH_KNOTS.
15
+
16
+ Public API:
17
+ - import_file(...)
18
+ - _check_if_load_stored_import(...)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ import pickle
26
+ import hashlib
27
+ from pathlib import Path
28
+ from typing import Dict, List, Optional, Tuple
29
+
30
+ import numpy as np
31
+ import lsdo_function_spaces as lfs
32
+ import csdl_alpha as csdl
33
+
34
+
35
+ # ---------------------------- Numeric parsing -----------------------------
36
+
37
+ _FLOAT_RE = re.compile(r"[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[EeDd][+-]?\d+)?")
38
+ _INT_RE = re.compile(r"[+-]?\d+")
39
+
40
+
41
+ def _to_float(tok: str) -> float:
42
+ return float(tok.replace('D', 'E').replace('d', 'e'))
43
+
44
+
45
+ # ---------------------------- STEP parsing -------------------------------
46
+
47
+ def _read_step_entities(file_name: str) -> Dict[int, str]:
48
+ """Return a dict {id: rhs_text} where rhs_text excludes the trailing ';'.
49
+
50
+ Handles entities that span multiple lines by accumulating until ';'.
51
+ """
52
+ entities: Dict[int, str] = {}
53
+ cur_id: Optional[int] = None
54
+ buf: List[str] = []
55
+
56
+ with open(file_name, 'r') as f:
57
+ for raw_line in f:
58
+ line = raw_line.strip()
59
+ if not line:
60
+ continue
61
+
62
+ if cur_id is None:
63
+ if not line.startswith('#'):
64
+ continue
65
+ eq = line.find('=')
66
+ if eq < 0:
67
+ continue
68
+ try:
69
+ cur_id = int(line[1:eq].strip())
70
+ except ValueError:
71
+ cur_id = None
72
+ continue
73
+ rest = line[eq+1:].strip()
74
+ buf = [rest]
75
+ else:
76
+ buf.append(line)
77
+
78
+ if buf and buf[-1].endswith(';'):
79
+ joined = ' '.join(buf).strip()
80
+ joined = joined[:-1].strip() # drop trailing ';'
81
+ entities[cur_id] = joined
82
+ cur_id = None
83
+ buf = []
84
+
85
+ return entities
86
+
87
+
88
+ def _split_top_level_commas(s: str) -> List[str]:
89
+ """Split by commas not inside parentheses or quoted strings."""
90
+ out: List[str] = []
91
+ depth = 0
92
+ start = 0
93
+ in_quote = False
94
+ i = 0
95
+ while i < len(s):
96
+ ch = s[i]
97
+ if ch == "'":
98
+ if in_quote:
99
+ if i + 1 < len(s) and s[i + 1] == "'":
100
+ i += 2
101
+ continue
102
+ in_quote = False
103
+ else:
104
+ in_quote = True
105
+ elif not in_quote:
106
+ if ch == '(':
107
+ depth += 1
108
+ elif ch == ')':
109
+ depth -= 1
110
+ elif ch == ',' and depth == 0:
111
+ out.append(s[start:i].strip())
112
+ start = i + 1
113
+ i += 1
114
+ out.append(s[start:].strip())
115
+ return out
116
+
117
+
118
+ def _strip_outer_parens(s: str) -> str:
119
+ s = s.strip()
120
+ if s.startswith('(') and s.endswith(')'):
121
+ return s[1:-1].strip()
122
+ return s
123
+
124
+
125
+ def _parse_int_list(step_list: str) -> List[int]:
126
+ body = _strip_outer_parens(step_list)
127
+ toks = _INT_RE.findall(body)
128
+ return [int(t) for t in toks]
129
+
130
+
131
+ def _parse_float_list(step_list: str) -> List[float]:
132
+ body = _strip_outer_parens(step_list)
133
+ toks = _FLOAT_RE.findall(body)
134
+ return [_to_float(t) for t in toks]
135
+
136
+
137
+ def _parse_cartesian_point(rhs: str) -> Optional[np.ndarray]:
138
+ """Parse CARTESIAN_POINT('',(x,y,z)) -> np.array([x,y,z])"""
139
+ if not rhs.startswith('CARTESIAN_POINT'):
140
+ return None
141
+ # grab all floats; take the last 3
142
+ vals = [_to_float(t) for t in _FLOAT_RE.findall(rhs)]
143
+ if len(vals) < 3:
144
+ return None
145
+ return np.array(vals[-3:], dtype=float)
146
+
147
+
148
+ def _parse_control_point_grid(field: str) -> np.ndarray:
149
+ """Parse control point grid '((#1,#2),(#3,#4))' -> int array shape (nu,nv)."""
150
+ body = _strip_outer_parens(field)
151
+ rows = _split_top_level_commas(body)
152
+ grid: List[List[int]] = []
153
+ for r in rows:
154
+ r_body = _strip_outer_parens(r)
155
+ ids = [int(x[1:]) for x in re.findall(r"#\d+", r_body)]
156
+ if ids:
157
+ grid.append(ids)
158
+ if not grid:
159
+ raise ValueError('Failed to parse control point grid.')
160
+ # Ensure rectangular
161
+ n0 = len(grid[0])
162
+ for rr in grid:
163
+ if len(rr) != n0:
164
+ raise ValueError('Control point grid is not rectangular.')
165
+ return np.array(grid, dtype=int)
166
+
167
+
168
+ def _detect_knot_fields(fields: List[str]) -> Tuple[List[int], List[int], List[float], List[float]]:
169
+ """Identify (u_mults, v_mults, u_knots, v_knots) from argument fields.
170
+
171
+ OpenVSP typically uses indices 8..11, but exporters vary. We look for
172
+ two int-lists followed by two float-lists among the parenthesized fields.
173
+ """
174
+ # First try the OpenVSP expected slots
175
+ try:
176
+ u_mults = _parse_int_list(fields[8])
177
+ v_mults = _parse_int_list(fields[9])
178
+ u_knots = _parse_float_list(fields[10])
179
+ v_knots = _parse_float_list(fields[11])
180
+ if len(u_mults) == len(u_knots) and len(v_mults) == len(v_knots):
181
+ return u_mults, v_mults, u_knots, v_knots
182
+ except Exception:
183
+ pass
184
+
185
+ # Otherwise scan for pattern: int list, int list, float list, float list
186
+ parenth_idxs = [i for i, f in enumerate(fields) if f.strip().startswith('(')]
187
+ parsed_int: Dict[int, List[int]] = {}
188
+ parsed_float: Dict[int, List[float]] = {}
189
+
190
+ for i in parenth_idxs:
191
+ txt = fields[i]
192
+ # Heuristic: if contains '.' or 'E'/'D' treat as float list
193
+ if any(c in txt for c in ['.', 'E', 'e', 'D', 'd']):
194
+ try:
195
+ parsed_float[i] = _parse_float_list(txt)
196
+ except Exception:
197
+ continue
198
+ else:
199
+ try:
200
+ parsed_int[i] = _parse_int_list(txt)
201
+ except Exception:
202
+ continue
203
+
204
+ # Find int,int,float,float sequence
205
+ idxs = sorted(parenth_idxs)
206
+ for a in idxs:
207
+ if a not in parsed_int:
208
+ continue
209
+ for b in idxs:
210
+ if b <= a or b not in parsed_int:
211
+ continue
212
+ for c in idxs:
213
+ if c <= b or c not in parsed_float:
214
+ continue
215
+ for d in idxs:
216
+ if d <= c or d not in parsed_float:
217
+ continue
218
+ u_mults = parsed_int[a]
219
+ v_mults = parsed_int[b]
220
+ u_knots = parsed_float[c]
221
+ v_knots = parsed_float[d]
222
+ if len(u_mults) == len(u_knots) and len(v_mults) == len(v_knots):
223
+ return u_mults, v_mults, u_knots, v_knots
224
+
225
+ raise ValueError('Could not locate knot and multiplicity fields in B_SPLINE_SURFACE_WITH_KNOTS.')
226
+
227
+
228
+ def _parse_bspline_surface_with_knots(rhs: str) -> Optional[Tuple[str, int, int, np.ndarray, List[int], List[int], List[float], List[float]]]:
229
+ """Parse B_SPLINE_SURFACE_WITH_KNOTS entity RHS.
230
+
231
+ Returns:
232
+ (name, deg_u, deg_v, cp_id_grid, u_mults, v_mults, u_knots, v_knots)
233
+ """
234
+ if not rhs.startswith('B_SPLINE_SURFACE_WITH_KNOTS'):
235
+ return None
236
+
237
+ args = rhs[len('B_SPLINE_SURFACE_WITH_KNOTS'):].strip()
238
+ args = _strip_outer_parens(args)
239
+ fields = _split_top_level_commas(args)
240
+ if len(fields) < 8:
241
+ return None
242
+
243
+ name = fields[0].strip().strip("'")
244
+ deg_u = int(fields[1].strip())
245
+ deg_v = int(fields[2].strip())
246
+
247
+ cp_grid = _parse_control_point_grid(fields[3])
248
+
249
+ u_mults, v_mults, u_knots, v_knots = _detect_knot_fields(fields)
250
+
251
+ return name, deg_u, deg_v, cp_grid, u_mults, v_mults, u_knots, v_knots
252
+
253
+
254
+ # ---------------------------- Knot helpers -------------------------------
255
+
256
+ def _expand_knots(base_knots: List[float], mults: List[int]) -> np.ndarray:
257
+ base = np.asarray(base_knots, dtype=float)
258
+ m = np.asarray(mults, dtype=int)
259
+ if base.shape[0] != m.shape[0]:
260
+ raise ValueError('Knot values and multiplicities must have same length.')
261
+ return np.repeat(base, m)
262
+
263
+
264
+ def _normalize_knots_affine(k: np.ndarray, eps: float = 1e-14) -> np.ndarray:
265
+ """Affine-map knots to [0,1] using endpoints. If degenerate, returns copy."""
266
+ k = np.asarray(k, dtype=float)
267
+ denom = k[-1] - k[0]
268
+ if abs(denom) < eps:
269
+ return k.copy()
270
+ return (k - k[0]) / denom
271
+
272
+
273
+ def _space_key(deg_u: int, deg_v: int, u_mults: List[int], v_mults: List[int], u_knots: List[float], v_knots: List[float]) -> str:
274
+ """Create a stable key for caching BSplineSpace objects."""
275
+ payload = (
276
+ str(deg_u) + '|' + str(deg_v) + '|'
277
+ + ','.join(map(str, u_mults)) + '|' + ','.join(map(str, v_mults)) + '|'
278
+ + ','.join(f'{x:.17g}' for x in u_knots) + '|' + ','.join(f'{x:.17g}' for x in v_knots)
279
+ ).encode('utf-8')
280
+ return hashlib.md5(payload).hexdigest()
281
+
282
+
283
+ # -----------------------------------------------------------------------------
284
+ # Stored import helper
285
+ # -----------------------------------------------------------------------------
286
+
287
+ def _check_if_load_stored_import(
288
+ file_name: str,
289
+ name: str = 'geometry',
290
+ parallelize: bool = True,
291
+ ) -> Optional[lfs.FunctionSet]:
292
+ """Load a previously stored import if it exists.
293
+
294
+ Note: parallelize is retained for backward-compatibility with the old API.
295
+ """
296
+ fn = os.path.basename(file_name)
297
+ fn_wo_ext = fn[:fn.rindex('.')]
298
+ file_path = f"stored_files/imports/{fn_wo_ext}_stored_import.pickle"
299
+ path = Path(file_path)
300
+
301
+ if not path.is_file():
302
+ return None
303
+
304
+ with open(file_path, 'rb') as handle:
305
+ function_set = pickle.load(handle)
306
+
307
+ # Re-wrap coefficients as csdl Variables (pickle stores numpy arrays)
308
+ for function in function_set.functions.values():
309
+ function.coefficients = csdl.Variable(value=np.asarray(function.coefficients))
310
+
311
+ # Invalidate cache if it was built with an incompatible class.
312
+ for function in function_set.functions.values():
313
+ if not isinstance(function.space, lfs.BSplineSpace):
314
+ return None
315
+
316
+ # Optionally rename set
317
+ if hasattr(function_set, 'name'):
318
+ function_set.name = name
319
+
320
+ return function_set
321
+
322
+
323
+ # -----------------------------------------------------------------------------
324
+ # Public API
325
+ # -----------------------------------------------------------------------------
326
+
327
+ def import_file(
328
+ file_name: str,
329
+ parallelize: bool = True,
330
+ normalize_knots: bool = True,
331
+ name: str = 'imported_geometry',
332
+ ) -> lfs.FunctionSet:
333
+ """Import OpenVSP STEP file containing B_SPLINE_SURFACE_WITH_KNOTS.
334
+
335
+ Parameters
336
+ ----------
337
+ file_name : str
338
+ STEP file path.
339
+ parallelize : bool
340
+ Retained for compatibility; parsing is typically faster single-threaded.
341
+ normalize_knots : bool
342
+ If True, affine-normalize each knot vector to [0,1].
343
+ name : str
344
+ Name for the returned FunctionSet.
345
+
346
+ Returns
347
+ -------
348
+ lfs.FunctionSet
349
+ A set of lfs.Function objects, one per B-spline surface.
350
+ """
351
+
352
+ # Quick existence check
353
+ with open(file_name, 'r') as f:
354
+ content = f.read(200000) # partial read for quick check
355
+ if 'B_SPLINE_SURFACE_WITH_KNOTS' not in content:
356
+ raise ValueError('No B_SPLINE_SURFACE_WITH_KNOTS found in file (or file not compatible).')
357
+
358
+ # Stored import
359
+ loaded = _check_if_load_stored_import(file_name, name=name, parallelize=parallelize)
360
+ if loaded is not None:
361
+ return loaded
362
+
363
+ print('Importing OpenVSP file:', file_name)
364
+
365
+ entities = _read_step_entities(file_name)
366
+
367
+ # Build point map: STEP id -> xyz
368
+ point_map: Dict[int, np.ndarray] = {}
369
+ for eid, rhs in entities.items():
370
+ if rhs.startswith('CARTESIAN_POINT'):
371
+ xyz = _parse_cartesian_point(rhs)
372
+ if xyz is not None:
373
+ point_map[eid] = xyz
374
+
375
+ # Parse surfaces
376
+ surfaces: List[Tuple[str, int, int, np.ndarray, List[int], List[int], List[float], List[float]]] = []
377
+ for eid, rhs in entities.items():
378
+ if rhs.startswith('B_SPLINE_SURFACE_WITH_KNOTS'):
379
+ parsed = _parse_bspline_surface_with_knots(rhs)
380
+ if parsed is not None:
381
+ surfaces.append(parsed)
382
+
383
+ if not surfaces:
384
+ raise ValueError('No parsable B_SPLINE_SURFACE_WITH_KNOTS entities found.')
385
+
386
+ # Cache spaces
387
+ space_cache: Dict[str, lfs.BSplineSpace] = {}
388
+ functions: List[lfs.Function] = []
389
+
390
+ for (surf_name, deg_u, deg_v, cp_ids, u_mults, v_mults, u_knots_base, v_knots_base) in surfaces:
391
+ # Expand and optionally normalize knots
392
+ ku = _expand_knots(u_knots_base, u_mults)
393
+ kv = _expand_knots(v_knots_base, v_mults)
394
+ if normalize_knots:
395
+ ku = _normalize_knots_affine(ku)
396
+ kv = _normalize_knots_affine(kv)
397
+
398
+ order_u = deg_u + 1
399
+ order_v = deg_v + 1
400
+ # Control point grid gives coefficient shape directly (nu, nv)
401
+ coeff_shape = (cp_ids.shape[0], cp_ids.shape[1])
402
+
403
+ # Basic consistency check: for clamped open knot vectors,
404
+ # len(knots) = n + p + 1, where p=degree, n=#ctrlpts-1
405
+ # Here: n_ctrl = coeff_shape[0] etc.
406
+ expected_ku = coeff_shape[0] + deg_u + 1
407
+ expected_kv = coeff_shape[1] + deg_v + 1
408
+ if ku.size != expected_ku or kv.size != expected_kv:
409
+ # Don't hard-fail: some exporters may include different specs.
410
+ # But warn to surface possible mismatches.
411
+ print(
412
+ f"[WARN] Knot length mismatch for '{surf_name}': "
413
+ f"len(ku)={ku.size} expected={expected_ku}; "
414
+ f"len(kv)={kv.size} expected={expected_kv}"
415
+ )
416
+
417
+ # Create/get space
418
+ skey = _space_key(deg_u, deg_v, u_mults, v_mults, u_knots_base, v_knots_base)
419
+ if skey in space_cache:
420
+ space = space_cache[skey]
421
+ else:
422
+ space = lfs.BSplineSpace(
423
+ num_parametric_dimensions=2,
424
+ degree=(deg_u, deg_v),
425
+ coefficients_shape=coeff_shape,
426
+ knots=tuple([ku, kv]),
427
+ )
428
+ space_cache[skey] = space
429
+
430
+ # Assemble control points in exact order
431
+ nu, nv = coeff_shape
432
+ ctrl = np.zeros((nu, nv, 3), dtype=float)
433
+ missing = 0
434
+ for i in range(nu):
435
+ for j in range(nv):
436
+ pid = int(cp_ids[i, j])
437
+ p = point_map.get(pid)
438
+ if p is None:
439
+ missing += 1
440
+ continue
441
+ ctrl[i, j, :] = p
442
+ if missing:
443
+ raise ValueError(f"Missing {missing} CARTESIAN_POINT references while building '{surf_name}'.")
444
+
445
+ coeffs = csdl.Variable(value=ctrl)
446
+ fn = lfs.Function(space=space, coefficients=coeffs, name=f"{surf_name}")
447
+ functions.append(fn)
448
+
449
+ fset = lfs.FunctionSet(functions, name=name)
450
+
451
+ # Store import to disk (convert csdl vars to numpy arrays)
452
+ fn_base = os.path.basename(file_name)
453
+ fn_wo_ext = fn_base[:fn_base.rindex('.')]
454
+ store_path = f"stored_files/imports/{fn_wo_ext}_stored_import.pickle"
455
+ Path('stored_files/imports').mkdir(parents=True, exist_ok=True)
456
+
457
+ with open(store_path, 'wb+') as handle:
458
+ fset_copy = fset.copy()
459
+ for key, function in fset.functions.items():
460
+ function_copy = function.copy()
461
+ # csdl.Variable -> ndarray
462
+ val = function.coefficients.value if hasattr(function.coefficients, 'value') else function.coefficients
463
+ function_copy.coefficients = np.asarray(val).copy()
464
+ fset_copy.functions[key] = function_copy
465
+ pickle.dump(fset_copy, handle, protocol=pickle.HIGHEST_PROTOCOL)
466
+
467
+ print('Complete import')
468
+ return fset
469
+
470
+
471
+ def import_file_patched(*args, **kwargs):
472
+ """Deprecated alias for import_file.
473
+
474
+ .. deprecated:: 1.0.0
475
+ Use :func:`import_file` instead.
476
+ """
477
+ import warnings
478
+ warnings.warn(
479
+ "import_file_patched is deprecated; use import_file instead.",
480
+ DeprecationWarning,
481
+ stacklevel=2,
482
+ )
483
+ return import_file(*args, **kwargs)
484
+
@@ -0,0 +1,11 @@
1
+ import numpy as np
2
+
3
+ def get_projection_squared_distances(points_1, points_2, direction):
4
+ difference = points_2 - points_1
5
+ if direction is None:
6
+ squared_distances = np.sum((difference)**2, axis=-1)
7
+ else:
8
+ direction = direction/np.linalg.norm(direction)
9
+ distances_along_axis = np.dot(difference, direction)
10
+ squared_distances = np.sum((difference)**2, axis=-1) - distances_along_axis**2
11
+ return squared_distances