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.
- optixde/__init__.py +10 -0
- optixde/bc/__init__.py +25 -0
- optixde/bc/rasterize.py +100 -0
- optixde/bc/robin.py +149 -0
- optixde/bc/segmented.py +228 -0
- optixde/fft_backend/__init__.py +98 -0
- optixde/fft_backend/base.py +336 -0
- optixde/fft_backend/cupy_backend.py +285 -0
- optixde/fft_backend/numpy_backend.py +195 -0
- optixde/fft_backend/propagator_cache.py +227 -0
- optixde/fft_backend/torch_backend.py +286 -0
- optixde/geometry/__init__.py +11 -0
- optixde/geometry/boolean.py +241 -0
- optixde/geometry/primitives.py +995 -0
- optixde/operators/__init__.py +15 -0
- optixde/operators/spectral.py +129 -0
- optixde/operators/transforms.py +355 -0
- optixde/post/__init__.py +35 -0
- optixde/post/plotting.py +802 -0
- optixde/solvers/__init__.py +214 -0
- optixde/solvers/_diagnostics.py +187 -0
- optixde/solvers/allen_cahn.py +24 -0
- optixde/solvers/base/__init__.py +126 -0
- optixde/solvers/base/allen_cahn.py +466 -0
- optixde/solvers/base/burgers.py +427 -0
- optixde/solvers/base/cylinder_flow.py +339 -0
- optixde/solvers/base/diffusion.py +813 -0
- optixde/solvers/base/etd.py +359 -0
- optixde/solvers/base/helmholtz.py +420 -0
- optixde/solvers/base/navier_stokes.py +429 -0
- optixde/solvers/base/ode.py +154 -0
- optixde/solvers/base/poisson.py +716 -0
- optixde/solvers/base/poisson_inhom.py +289 -0
- optixde/solvers/base/schrodinger.py +443 -0
- optixde/solvers/base/wave.py +388 -0
- optixde/solvers/burgers.py +26 -0
- optixde/solvers/cylinder_flow.py +22 -0
- optixde/solvers/diffusion.py +22 -0
- optixde/solvers/helmholtz.py +18 -0
- optixde/solvers/navier_stokes.py +20 -0
- optixde/solvers/poisson.py +20 -0
- optixde/solvers/schrodinger.py +22 -0
- optixde/solvers/segmented/__init__.py +21 -0
- optixde/solvers/segmented/poisson.py +40 -0
- optixde/solvers/segmented/polygonal.py +384 -0
- optixde/solvers/segmented/transient.py +180 -0
- optixde/solvers/solid/__init__.py +19 -0
- optixde/solvers/solid/elasticity_dynamic.py +70 -0
- optixde/solvers/solid/elasticity_static.py +205 -0
- optixde/solvers/solid/postprocess.py +153 -0
- optixde/solvers/splitting.py +83 -0
- optixde/solvers/wave.py +18 -0
- optixde/version.py +4 -0
- optixde-0.2.2.dist-info/METADATA +451 -0
- optixde-0.2.2.dist-info/RECORD +58 -0
- optixde-0.2.2.dist-info/WHEEL +5 -0
- optixde-0.2.2.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|
optixde/bc/rasterize.py
ADDED
|
@@ -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
|
optixde/bc/segmented.py
ADDED
|
@@ -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
|