optixde 0.2.2__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 (58) hide show
  1. optixde/__init__.py +10 -0
  2. optixde/bc/__init__.py +25 -0
  3. optixde/bc/rasterize.py +100 -0
  4. optixde/bc/robin.py +149 -0
  5. optixde/bc/segmented.py +228 -0
  6. optixde/fft_backend/__init__.py +98 -0
  7. optixde/fft_backend/base.py +336 -0
  8. optixde/fft_backend/cupy_backend.py +285 -0
  9. optixde/fft_backend/numpy_backend.py +195 -0
  10. optixde/fft_backend/propagator_cache.py +227 -0
  11. optixde/fft_backend/torch_backend.py +286 -0
  12. optixde/geometry/__init__.py +11 -0
  13. optixde/geometry/boolean.py +241 -0
  14. optixde/geometry/primitives.py +995 -0
  15. optixde/operators/__init__.py +15 -0
  16. optixde/operators/spectral.py +129 -0
  17. optixde/operators/transforms.py +355 -0
  18. optixde/post/__init__.py +35 -0
  19. optixde/post/plotting.py +802 -0
  20. optixde/solvers/__init__.py +214 -0
  21. optixde/solvers/_diagnostics.py +187 -0
  22. optixde/solvers/allen_cahn.py +24 -0
  23. optixde/solvers/base/__init__.py +126 -0
  24. optixde/solvers/base/allen_cahn.py +466 -0
  25. optixde/solvers/base/burgers.py +427 -0
  26. optixde/solvers/base/cylinder_flow.py +339 -0
  27. optixde/solvers/base/diffusion.py +813 -0
  28. optixde/solvers/base/etd.py +359 -0
  29. optixde/solvers/base/helmholtz.py +420 -0
  30. optixde/solvers/base/navier_stokes.py +429 -0
  31. optixde/solvers/base/ode.py +154 -0
  32. optixde/solvers/base/poisson.py +716 -0
  33. optixde/solvers/base/poisson_inhom.py +289 -0
  34. optixde/solvers/base/schrodinger.py +443 -0
  35. optixde/solvers/base/wave.py +388 -0
  36. optixde/solvers/burgers.py +26 -0
  37. optixde/solvers/cylinder_flow.py +22 -0
  38. optixde/solvers/diffusion.py +22 -0
  39. optixde/solvers/helmholtz.py +18 -0
  40. optixde/solvers/navier_stokes.py +20 -0
  41. optixde/solvers/poisson.py +20 -0
  42. optixde/solvers/schrodinger.py +22 -0
  43. optixde/solvers/segmented/__init__.py +21 -0
  44. optixde/solvers/segmented/poisson.py +40 -0
  45. optixde/solvers/segmented/polygonal.py +384 -0
  46. optixde/solvers/segmented/transient.py +180 -0
  47. optixde/solvers/solid/__init__.py +19 -0
  48. optixde/solvers/solid/elasticity_dynamic.py +70 -0
  49. optixde/solvers/solid/elasticity_static.py +205 -0
  50. optixde/solvers/solid/postprocess.py +153 -0
  51. optixde/solvers/splitting.py +83 -0
  52. optixde/solvers/wave.py +18 -0
  53. optixde/version.py +4 -0
  54. optixde-0.2.2.dist-info/METADATA +451 -0
  55. optixde-0.2.2.dist-info/RECORD +58 -0
  56. optixde-0.2.2.dist-info/WHEEL +5 -0
  57. optixde-0.2.2.dist-info/licenses/LICENSE +21 -0
  58. optixde-0.2.2.dist-info/top_level.txt +1 -0
optixde/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Yang Yang <yangyhhu@foxmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+
5
+ from .version import __version__
6
+ from . import geometry, bc, solvers, post, operators, fft_backend
7
+
8
+ __all__ = [
9
+ '__version__', 'geometry', 'bc', 'solvers', 'post', 'operators', 'fft_backend'
10
+ ]
optixde/bc/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Yang Yang <yangyhhu@foxmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+
5
+ """Boundary-condition helpers.
6
+
7
+ This subpackage centralizes utilities for enforcing boundary conditions in a
8
+ backend-aware way (NumPy / CuPy / PyTorch), and also exposes a segmented
9
+ polygon-boundary API for mixed boundary conditions on the same geometric edge.
10
+ """
11
+
12
+ from .robin import normalize_robin_spec, apply_robin_fd_projection
13
+ from .segmented import EdgeSlice, DirichletBC, NeumannBC, RobinBC, split_bcs_by_type
14
+ from .rasterize import rasterize_segmented_bcs
15
+
16
+ __all__ = [
17
+ "normalize_robin_spec",
18
+ "apply_robin_fd_projection",
19
+ "EdgeSlice",
20
+ "DirichletBC",
21
+ "NeumannBC",
22
+ "RobinBC",
23
+ "split_bcs_by_type",
24
+ "rasterize_segmented_bcs",
25
+ ]
@@ -0,0 +1,100 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Yang Yang <yangyhhu@foxmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+
9
+ from .segmented import DirichletBC, NeumannBC, RobinBC
10
+
11
+
12
+ def rasterize_segmented_bcs(geometry, X, bcs, band_width):
13
+ """Rasterize segmented edge BCs onto query points.
14
+
15
+ Parameters
16
+ ----------
17
+ geometry : Polygon-like
18
+ Must implement `project_to_edges(X)` and `edge_outward_normal(edge_id)`.
19
+ X : ndarray, shape (N, 2)
20
+ Query points.
21
+ bcs : list
22
+ Boundary condition objects from optixde.bc.segmented.
23
+ band_width : float
24
+ Boundary activation half-width.
25
+
26
+ Returns
27
+ -------
28
+ dict of ndarrays
29
+ type_id: 0 none, 1 dirichlet, 2 neumann, 3 robin
30
+ distance, nx, ny, value, alpha, beta, weight
31
+ """
32
+ X = np.asarray(X, dtype=float)
33
+ N = X.shape[0]
34
+ out = {
35
+ 'type_id': np.zeros(N, dtype=int),
36
+ 'distance': np.full(N, np.inf, dtype=float),
37
+ 'nx': np.zeros(N, dtype=float),
38
+ 'ny': np.zeros(N, dtype=float),
39
+ 'value': np.zeros(N, dtype=float),
40
+ 'alpha': np.zeros(N, dtype=float),
41
+ 'beta': np.zeros(N, dtype=float),
42
+ 'weight': np.zeros(N, dtype=float),
43
+ 'edge_id': np.full(N, -1, dtype=int),
44
+ 's': np.zeros(N, dtype=float),
45
+ }
46
+ if len(bcs) == 0:
47
+ return out
48
+
49
+ proj = geometry.project_to_edges(X)
50
+ edges = proj['edge_id']
51
+ svals = proj['s']
52
+ dists = proj['distance']
53
+
54
+ for bc in sorted(bcs, key=lambda item: getattr(item, 'priority', 0), reverse=True):
55
+ seg = bc.segment
56
+ hit = (
57
+ (edges == int(seg.edge_id))
58
+ & (svals >= float(seg.s0) - 1e-12)
59
+ & (svals <= float(seg.s1) + 1e-12)
60
+ & (dists <= float(band_width))
61
+ )
62
+ if not np.any(hit):
63
+ continue
64
+
65
+ replace = hit & (
66
+ (dists < out['distance'] - 1e-15)
67
+ | ((np.abs(dists - out['distance']) <= 1e-15) & (getattr(bc, 'priority', 0) >= 0))
68
+ )
69
+ if not np.any(replace):
70
+ continue
71
+
72
+ normal = geometry.edge_outward_normal(seg.edge_id)
73
+ x = X[replace, 0]
74
+ y = X[replace, 1]
75
+ out['distance'][replace] = dists[replace]
76
+ out['nx'][replace] = normal[0]
77
+ out['ny'][replace] = normal[1]
78
+ out['edge_id'][replace] = int(seg.edge_id)
79
+ out['s'][replace] = svals[replace]
80
+ out['weight'][replace] = geometry.smooth_dirac(dists[replace], band_width)
81
+
82
+ if isinstance(bc, DirichletBC):
83
+ out['type_id'][replace] = 1
84
+ out['value'][replace] = bc.value_callable()(x, y)
85
+ out['alpha'][replace] = 1.0
86
+ out['beta'][replace] = 0.0
87
+ elif isinstance(bc, NeumannBC):
88
+ out['type_id'][replace] = 2
89
+ out['value'][replace] = bc.flux_callable()(x, y)
90
+ out['alpha'][replace] = 0.0
91
+ out['beta'][replace] = 1.0
92
+ elif isinstance(bc, RobinBC):
93
+ out['type_id'][replace] = 3
94
+ out['value'][replace] = bc.value_callable()(x, y)
95
+ out['alpha'][replace] = bc.alpha_callable()(x, y)
96
+ out['beta'][replace] = bc.beta_callable()(x, y)
97
+ else:
98
+ raise TypeError(f'Unsupported segmented BC type: {type(bc)!r}')
99
+
100
+ return out
optixde/bc/robin.py ADDED
@@ -0,0 +1,149 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Yang Yang <yangyhhu@foxmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Robin (mixed) boundary condition helpers.
5
+
6
+ Convention on each side:
7
+
8
+ alpha * u + beta * (∂u/∂n) = g
9
+
10
+ For rectangular grids that include boundary nodes, we enforce Robin conditions
11
+ via a one-sided finite-difference projection.
12
+
13
+ This implementation is backend-aware: NumPy / CuPy / PyTorch.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, Tuple, Union
19
+
20
+
21
+ def _is_cupy_array(x: Any) -> bool:
22
+ try:
23
+ import cupy as cp
24
+ return isinstance(x, cp.ndarray)
25
+ except Exception:
26
+ return False
27
+
28
+
29
+ def _is_torch_tensor(x: Any) -> bool:
30
+ try:
31
+ import torch
32
+ return isinstance(x, torch.Tensor)
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ def array_namespace(x: Any):
38
+ if _is_torch_tensor(x):
39
+ import torch
40
+ return torch
41
+ if _is_cupy_array(x):
42
+ import cupy as cp
43
+ return cp
44
+ import numpy as np
45
+ return np
46
+
47
+
48
+ RobinSide = Tuple[float, float, Any] # (alpha, beta, g)
49
+ RobinSpec = Dict[str, RobinSide]
50
+ RobinInput = Union[None, RobinSide, Dict[str, RobinSide]]
51
+
52
+
53
+ def _as_side_value(xp, g: Any, n: int, ref):
54
+ if g is None:
55
+ if xp.__name__ == "torch":
56
+ return xp.zeros((n,), dtype=ref.dtype, device=ref.device)
57
+ return xp.zeros((n,), dtype=float)
58
+
59
+ if xp.__name__ == "torch":
60
+ import torch
61
+
62
+ if isinstance(g, torch.Tensor):
63
+ if g.ndim == 0:
64
+ return g.expand(n)
65
+ if g.shape[0] != n:
66
+ raise ValueError(f"Robin g has length {g.shape[0]}, expected {n}.")
67
+ return g
68
+ if isinstance(g, (int, float)):
69
+ return torch.full((n,), float(g), dtype=ref.dtype, device=ref.device)
70
+ gg = torch.as_tensor(g, dtype=ref.dtype, device=ref.device)
71
+ if gg.ndim == 0:
72
+ return gg.expand(n)
73
+ if gg.shape[0] != n:
74
+ raise ValueError(f"Robin g has length {gg.shape[0]}, expected {n}.")
75
+ return gg
76
+
77
+ if xp.isscalar(g):
78
+ return xp.full((n,), float(g), dtype=float)
79
+ arr = xp.asarray(g)
80
+ if arr.ndim == 0:
81
+ return xp.full((n,), float(arr), dtype=float)
82
+ if arr.shape[0] != n:
83
+ raise ValueError(f"Robin g has length {arr.shape[0]}, expected {n}.")
84
+ return arr.astype(float)
85
+
86
+
87
+ def normalize_robin_spec(robin: RobinInput) -> RobinSpec:
88
+ default: RobinSide = (1.0, 0.0, 0.0)
89
+ if robin is None:
90
+ return {"left": default, "right": default, "bottom": default, "top": default}
91
+ if isinstance(robin, tuple) and len(robin) == 3:
92
+ a, b, g = robin
93
+ return {"left": (a, b, g), "right": (a, b, g), "bottom": (a, b, g), "top": (a, b, g)}
94
+ if isinstance(robin, dict):
95
+ base = robin.get("default", default)
96
+ out: RobinSpec = {}
97
+ for side in ("left", "right", "bottom", "top"):
98
+ out[side] = robin.get(side, base)
99
+ if not (isinstance(out[side], tuple) and len(out[side]) == 3):
100
+ raise ValueError(
101
+ "Each Robin side must be a tuple (alpha, beta, g). "
102
+ f"Got {side}={out[side]!r}."
103
+ )
104
+ return out
105
+ raise ValueError(
106
+ "Robin must be None, a tuple (alpha,beta,g), or a dict with sides. "
107
+ f"Got {type(robin)}."
108
+ )
109
+
110
+
111
+ def apply_robin_fd_projection(u, Lx: float, Ly: float, robin: RobinInput) -> Any:
112
+ xp = array_namespace(u)
113
+ Ny, Nx = int(u.shape[0]), int(u.shape[1])
114
+ if Nx < 2 or Ny < 2:
115
+ raise ValueError("Robin projection requires at least a 2×2 grid including boundary nodes.")
116
+
117
+ hx = float(Lx) / float(Nx - 1)
118
+ hy = float(Ly) / float(Ny - 1)
119
+ spec = normalize_robin_spec(robin)
120
+
121
+ a, b, g = spec["left"]
122
+ g1 = _as_side_value(xp, g, Ny, u)
123
+ denom = float(a) + float(b) / hx
124
+ if abs(denom) < 1e-30:
125
+ raise ValueError("Invalid Robin coefficients on left: alpha + beta/h ~ 0.")
126
+ u[:, 0] = (g1 + (float(b) / hx) * u[:, 1]) / denom
127
+
128
+ a, b, g = spec["right"]
129
+ g1 = _as_side_value(xp, g, Ny, u)
130
+ denom = float(a) + float(b) / hx
131
+ if abs(denom) < 1e-30:
132
+ raise ValueError("Invalid Robin coefficients on right: alpha + beta/h ~ 0.")
133
+ u[:, -1] = (g1 + (float(b) / hx) * u[:, -2]) / denom
134
+
135
+ a, b, g = spec["bottom"]
136
+ g1 = _as_side_value(xp, g, Nx, u)
137
+ denom = float(a) + float(b) / hy
138
+ if abs(denom) < 1e-30:
139
+ raise ValueError("Invalid Robin coefficients on bottom: alpha + beta/h ~ 0.")
140
+ u[0, :] = (g1 + (float(b) / hy) * u[1, :]) / denom
141
+
142
+ a, b, g = spec["top"]
143
+ g1 = _as_side_value(xp, g, Nx, u)
144
+ denom = float(a) + float(b) / hy
145
+ if abs(denom) < 1e-30:
146
+ raise ValueError("Invalid Robin coefficients on top: alpha + beta/h ~ 0.")
147
+ u[-1, :] = (g1 + (float(b) / hy) * u[-2, :]) / denom
148
+
149
+ return u
@@ -0,0 +1,228 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Yang Yang <yangyhhu@foxmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Callable, Iterable, Optional, Sequence, Tuple, Union
8
+
9
+ import numpy as np
10
+
11
+ Number = Union[float, int]
12
+ ScalarOrCallable = Union[Number, Callable[[float, float], Union[Number, np.ndarray]]]
13
+
14
+
15
+
16
+ def _as_callable_2d(value: ScalarOrCallable) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
17
+ """Return a vectorized callable f(x, y).
18
+
19
+ This preserves the previous user-facing convenience that boundary values may be
20
+ given either as constants or as callables.
21
+ """
22
+ if callable(value):
23
+ def _wrapped(x, y):
24
+ out = value(x, y)
25
+ if np.isscalar(out):
26
+ return np.full_like(np.asarray(x, dtype=float), float(out), dtype=float)
27
+ return np.asarray(out, dtype=float)
28
+ return _wrapped
29
+
30
+ def _const(x, y):
31
+ return np.full_like(np.asarray(x, dtype=float), float(value), dtype=float)
32
+
33
+ return _const
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class EdgeSegment:
38
+ """A sub-segment on a polygon edge.
39
+
40
+ Parameters
41
+ ----------
42
+ edge_id : int
43
+ Polygon edge index.
44
+ s0, s1 : float
45
+ Local edge coordinates in [0, 1].
46
+ name : str | None
47
+ Optional user label retained for backward compatibility.
48
+ """
49
+
50
+ edge_id: int
51
+ s0: float = 0.0
52
+ s1: float = 1.0
53
+ name: Optional[str] = None
54
+
55
+ def __post_init__(self):
56
+ s0 = float(self.s0)
57
+ s1 = float(self.s1)
58
+ if s0 > s1:
59
+ s0, s1 = s1, s0
60
+ object.__setattr__(self, "s0", max(0.0, min(1.0, s0)))
61
+ object.__setattr__(self, "s1", max(0.0, min(1.0, s1)))
62
+ object.__setattr__(self, "edge_id", int(self.edge_id))
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class EdgeSlice:
67
+ """Backward-compatible geometric edge slice description.
68
+
69
+ Old user code may still construct boundary slices using explicit segment end
70
+ points on a named edge. We keep this object unchanged so existing scripts keep
71
+ working, then convert it to an ``EdgeSegment`` when geometry is available.
72
+ """
73
+
74
+ name: str
75
+ start: Tuple[float, float]
76
+ end: Tuple[float, float]
77
+ kind: str # "dirichlet", "neumann", or "robin"
78
+ value: ScalarOrCallable = 0.0
79
+
80
+
81
+ @dataclass
82
+ class BoundaryCondition:
83
+ """Base boundary-condition object for segmented polygon edges."""
84
+
85
+ segment: Union[EdgeSegment, EdgeSlice]
86
+ priority: int = 0
87
+
88
+
89
+ @dataclass
90
+ class DirichletBC(BoundaryCondition):
91
+ value: ScalarOrCallable = 0.0
92
+
93
+ def value_callable(self) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
94
+ return _as_callable_2d(self.value)
95
+
96
+
97
+ @dataclass
98
+ class NeumannBC(BoundaryCondition):
99
+ flux: ScalarOrCallable = 0.0
100
+
101
+ # Backward-compatible alias
102
+ @property
103
+ def value(self):
104
+ return self.flux
105
+
106
+ def flux_callable(self) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
107
+ return _as_callable_2d(self.flux)
108
+
109
+
110
+ @dataclass
111
+ class RobinBC(BoundaryCondition):
112
+ alpha: ScalarOrCallable = 1.0
113
+ beta: ScalarOrCallable = 1.0
114
+ value: ScalarOrCallable = 0.0
115
+
116
+ def alpha_callable(self) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
117
+ return _as_callable_2d(self.alpha)
118
+
119
+ def beta_callable(self) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
120
+ return _as_callable_2d(self.beta)
121
+
122
+ def value_callable(self) -> Callable[[np.ndarray, np.ndarray], np.ndarray]:
123
+ return _as_callable_2d(self.value)
124
+
125
+
126
+
127
+ def _point_on_segment_parameter(p0: np.ndarray, p1: np.ndarray, q: np.ndarray) -> float:
128
+ v = p1 - p0
129
+ vv = float(np.dot(v, v))
130
+ if vv <= 0.0:
131
+ return 0.0
132
+ return float(np.clip(np.dot(q - p0, v) / vv, 0.0, 1.0))
133
+
134
+
135
+
136
+ def _resolve_edge_slice(geometry, edge_slice: EdgeSlice) -> EdgeSegment:
137
+ """Map a legacy EdgeSlice onto geometry edge_id and local [s0, s1]."""
138
+ if geometry is None:
139
+ raise ValueError("Geometry is required to resolve a legacy EdgeSlice into edge_id/s-range.")
140
+ if not hasattr(geometry, "project_to_edges"):
141
+ raise TypeError("Geometry must implement project_to_edges to use legacy EdgeSlice objects.")
142
+
143
+ pts = np.asarray([edge_slice.start, edge_slice.end], dtype=float)
144
+ proj = geometry.project_to_edges(pts)
145
+ edge_ids = np.asarray(proj["edge_id"], dtype=int)
146
+ svals = np.asarray(proj["s"], dtype=float)
147
+ if edge_ids.shape[0] != 2:
148
+ raise ValueError("Failed to resolve EdgeSlice endpoints on geometry edges.")
149
+ if edge_ids[0] != edge_ids[1]:
150
+ raise ValueError(
151
+ f"EdgeSlice '{edge_slice.name}' spans multiple polygon edges ({edge_ids[0]} and {edge_ids[1]}). "
152
+ "A single segmented BC must lie on one edge."
153
+ )
154
+ return EdgeSegment(edge_id=int(edge_ids[0]), s0=float(min(svals)), s1=float(max(svals)), name=edge_slice.name)
155
+
156
+
157
+
158
+ def normalize_segment(segment: Union[EdgeSegment, EdgeSlice], geometry=None) -> EdgeSegment:
159
+ if isinstance(segment, EdgeSegment):
160
+ return segment
161
+ if isinstance(segment, EdgeSlice):
162
+ return _resolve_edge_slice(geometry, segment)
163
+ raise TypeError(f"Unsupported segment type: {type(segment)!r}")
164
+
165
+
166
+
167
+ def normalize_bc(bc, geometry=None):
168
+ """Return a BC whose ``segment`` is always an ``EdgeSegment``.
169
+
170
+ This keeps old scripts working while matching the rasterizer/solver contract.
171
+ """
172
+ if isinstance(bc, DirichletBC):
173
+ return DirichletBC(segment=normalize_segment(bc.segment, geometry), priority=bc.priority, value=bc.value)
174
+ if isinstance(bc, NeumannBC):
175
+ return NeumannBC(segment=normalize_segment(bc.segment, geometry), priority=bc.priority, flux=bc.flux)
176
+ if isinstance(bc, RobinBC):
177
+ return RobinBC(
178
+ segment=normalize_segment(bc.segment, geometry),
179
+ priority=bc.priority,
180
+ alpha=bc.alpha,
181
+ beta=bc.beta,
182
+ value=bc.value,
183
+ )
184
+ raise TypeError(f"Unsupported boundary condition type: {type(bc)!r}")
185
+
186
+
187
+
188
+ def normalize_bcs(bcs: Optional[Sequence], geometry=None):
189
+ if bcs is None:
190
+ return []
191
+ return [normalize_bc(bc, geometry) for bc in bcs]
192
+
193
+
194
+
195
+ def split_bcs_by_type(bcs: Iterable):
196
+ """Split BCs by type while preserving order.
197
+
198
+ Returned tuple: (dirichlet, neumann, robin)
199
+ """
200
+ dirichlet = []
201
+ neumann = []
202
+ robin = []
203
+ for bc in bcs:
204
+ if isinstance(bc, DirichletBC):
205
+ dirichlet.append(bc)
206
+ elif isinstance(bc, NeumannBC):
207
+ neumann.append(bc)
208
+ elif isinstance(bc, RobinBC):
209
+ robin.append(bc)
210
+ else:
211
+ raise TypeError(f"Unsupported boundary condition type: {type(bc)!r}")
212
+ return dirichlet, neumann, robin
213
+
214
+
215
+ __all__ = [
216
+ "Number",
217
+ "ScalarOrCallable",
218
+ "EdgeSegment",
219
+ "EdgeSlice",
220
+ "BoundaryCondition",
221
+ "DirichletBC",
222
+ "NeumannBC",
223
+ "RobinBC",
224
+ "normalize_segment",
225
+ "normalize_bc",
226
+ "normalize_bcs",
227
+ "split_bcs_by_type",
228
+ ]
@@ -0,0 +1,98 @@
1
+ # SPDX-FileCopyrightText: 2025-2026 Yang Yang <yangyhhu@foxmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """FFT backend abstraction for OptiXDE.
5
+
6
+ Backends:
7
+ - NumPyBackend (CPU)
8
+ - TorchBackend (CPU/GPU)
9
+ - CuPyBackend (GPU via cuFFT) [optional dependency: cupy]
10
+ """
11
+
12
+ from .base import FFTBackend, FreqGrids as FreqGrids
13
+ from .numpy_backend import NumpyBackend
14
+ from .propagator_cache import PropagatorCache as PropagatorCache
15
+
16
+ __all__ = [
17
+ "FFTBackend",
18
+ "FreqGrids",
19
+ "NumpyBackend",
20
+ "TorchBackend",
21
+ "CuPyBackend",
22
+ "PropagatorCache",
23
+ "get_backend",
24
+ "clear_backend_cache",
25
+ ]
26
+
27
+
28
+ _BACKEND_INSTANCES = {}
29
+
30
+
31
+ def _backend_key(name, kwargs):
32
+ """Build a stable key for the small set of backend constructor options."""
33
+ normalized = []
34
+ for key, value in sorted(kwargs.items()):
35
+ try:
36
+ hash(value)
37
+ normalized_value = value
38
+ except TypeError:
39
+ normalized_value = repr(value)
40
+ normalized.append((key, normalized_value))
41
+ return name, tuple(normalized)
42
+
43
+
44
+ def clear_backend_cache():
45
+ """Clear shared backend instances and all arrays cached by them."""
46
+ for backend in _BACKEND_INSTANCES.values():
47
+ backend.clear_caches()
48
+ _BACKEND_INSTANCES.clear()
49
+
50
+
51
+ def __getattr__(name):
52
+ if name == "TorchBackend":
53
+ try:
54
+ from .torch_backend import TorchBackend
55
+ except ModuleNotFoundError as exc:
56
+ if exc.name == "torch":
57
+ raise ImportError(
58
+ "TorchBackend requires torch. Install PyTorch to use this backend."
59
+ ) from exc
60
+ raise
61
+ return TorchBackend
62
+
63
+ if name == "CuPyBackend":
64
+ try:
65
+ from .cupy_backend import CuPyBackend
66
+ except ModuleNotFoundError as exc:
67
+ if exc.name == "cupy":
68
+ raise ImportError(
69
+ "CuPyBackend requires cupy. Install CuPy to use this backend."
70
+ ) from exc
71
+ raise
72
+ return CuPyBackend
73
+
74
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
75
+
76
+
77
+ def get_backend(name: str = "numpy", **kwargs) -> FFTBackend:
78
+ name = (name or "numpy").lower()
79
+ if name in ("np", "numpy"):
80
+ canonical_name = "numpy"
81
+ factory = NumpyBackend
82
+ if name in ("torch", "pytorch"):
83
+ from .torch_backend import TorchBackend
84
+ canonical_name = "torch"
85
+ factory = TorchBackend
86
+ elif name in ("cupy", "cp", "cufft"):
87
+ from .cupy_backend import CuPyBackend
88
+ canonical_name = "cupy"
89
+ factory = CuPyBackend
90
+ elif name not in ("np", "numpy"):
91
+ raise ValueError(f"Unknown backend: {name!r}. Use 'numpy', 'torch', or 'cupy'.")
92
+
93
+ key = _backend_key(canonical_name, kwargs)
94
+ backend = _BACKEND_INSTANCES.get(key)
95
+ if backend is None:
96
+ backend = factory(**kwargs)
97
+ _BACKEND_INSTANCES[key] = backend
98
+ return backend