demathpy 0.0.2__py3-none-any.whl → 0.1.1__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.
- demathpy/__init__.py +5 -15
- demathpy/ode.py +358 -112
- demathpy/pde.py +756 -329
- demathpy/symbols.py +20 -2
- demathpy-0.1.1.dist-info/METADATA +207 -0
- demathpy-0.1.1.dist-info/RECORD +8 -0
- {demathpy-0.0.2.dist-info → demathpy-0.1.1.dist-info}/WHEEL +1 -1
- demathpy-0.0.2.dist-info/METADATA +0 -106
- demathpy-0.0.2.dist-info/RECORD +0 -8
- {demathpy-0.0.2.dist-info → demathpy-0.1.1.dist-info}/licenses/LICENSE +0 -0
demathpy/pde.py
CHANGED
|
@@ -7,46 +7,32 @@ field PDEs into particle motion without hardcoded dynamics.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from typing import Dict, List, Tuple
|
|
10
|
+
from typing import Dict, List, Tuple, Any, Optional
|
|
11
11
|
|
|
12
12
|
import re
|
|
13
|
+
import json
|
|
13
14
|
import numpy as np
|
|
14
15
|
|
|
15
16
|
from .symbols import normalize_symbols, normalize_lhs
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
def normalize_pde(pde: str) -> str:
|
|
19
|
-
return (pde or "").strip()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def init_grid(width: int, height: int, dx: float) -> Tuple[np.ndarray, float]:
|
|
23
|
-
nx = max(2, int(width / dx))
|
|
24
|
-
nz = max(2, int(height / dx))
|
|
25
|
-
grid = np.zeros((nz, nx), dtype=float)
|
|
26
|
-
return grid, dx
|
|
27
|
-
|
|
28
|
-
|
|
29
19
|
def _preprocess_expr(expr: str) -> str:
|
|
30
20
|
expr = (expr or "").strip()
|
|
31
|
-
# Drop common annotation tokens (LLM outputs sometimes include these).
|
|
32
21
|
expr = re.sub(r"\(\s*approx\s*\)", "", expr, flags=re.IGNORECASE)
|
|
33
22
|
expr = re.sub(r"\bapprox\b", "", expr, flags=re.IGNORECASE)
|
|
34
|
-
# If the RHS contains multiple '=' (e.g. an annotated expansion), prefer the
|
|
35
|
-
# rightmost expression, which is typically the most explicit.
|
|
36
23
|
if "=" in expr:
|
|
37
24
|
expr = expr.split("=")[-1]
|
|
38
25
|
expr = normalize_symbols(expr)
|
|
39
26
|
return expr.strip()
|
|
40
27
|
|
|
41
28
|
|
|
29
|
+
def normalize_pde(pde: str) -> str:
|
|
30
|
+
return (pde or "").strip()
|
|
31
|
+
|
|
32
|
+
|
|
42
33
|
def parse_pde(pde: str) -> Tuple[str, int, str, str]:
|
|
43
34
|
"""
|
|
44
35
|
Returns (var, order, lhs_coeff_expr, rhs_expr).
|
|
45
|
-
Supports forms:
|
|
46
|
-
∂u/∂t = RHS
|
|
47
|
-
∂²u/∂t² = RHS
|
|
48
|
-
∂^n u / ∂t^n = RHS
|
|
49
|
-
rho(∂²u/∂t²) = RHS
|
|
50
36
|
"""
|
|
51
37
|
pde = normalize_pde(pde)
|
|
52
38
|
if "=" not in pde:
|
|
@@ -60,34 +46,13 @@ def parse_pde(pde: str) -> Tuple[str, int, str, str]:
|
|
|
60
46
|
coeff = lhs_expr.replace(deriv_expr, "").strip()
|
|
61
47
|
if not coeff:
|
|
62
48
|
return "1"
|
|
63
|
-
# Remove leading or trailing multiplication symbols
|
|
64
49
|
coeff = coeff.strip("*")
|
|
65
50
|
return _preprocess_expr(coeff) or "1"
|
|
66
|
-
|
|
67
|
-
# N-th order time derivative: ∂^n v / ∂t^n
|
|
68
|
-
# Matches patterns like: ∂^2 u / ∂t^2, ∂3u/∂t3, ∂ u / ∂t
|
|
69
|
-
# We normalized unicode `²` to `^2` in normalize_lhs.
|
|
70
51
|
|
|
71
|
-
|
|
72
|
-
# breakdown:
|
|
73
|
-
# ∂ literal partial
|
|
74
|
-
# \s* whitespace
|
|
75
|
-
# (?:\^?(\d+))? optional order: ^2, 2, or nothing (order 1)
|
|
76
|
-
# \s* whitespace
|
|
77
|
-
# ([a-zA-Z_]\w*) variable name (group 2)
|
|
78
|
-
# \s* whitespace
|
|
79
|
-
# / division
|
|
80
|
-
# \s* whitespace
|
|
81
|
-
# ∂t ∂t
|
|
82
|
-
# (?:\^?(\d+))? optional order at bottom: ^2, 2, or nothing
|
|
83
|
-
|
|
84
|
-
pattern = r"∂\s*(?:\^?(\d+))?\s*([a-zA-Z_]\w*)\s*/\s*∂t\s*(?:\^?(\d+))?"
|
|
52
|
+
pattern = r"(?:∂|d)\s*(?:\^?(\d+))?\s*([a-zA-Z_]\w*)\s*/\s*(?:∂|d)t\s*(?:\^?(\d+))?"
|
|
85
53
|
m = re.search(pattern, lhs)
|
|
86
54
|
|
|
87
55
|
if m:
|
|
88
|
-
# group 1: order top, group 2: var, group 3: order bottom
|
|
89
|
-
# if inconsistent orders, we trust top or bottom if they match?
|
|
90
|
-
# Usually they match. If missing, assume 1.
|
|
91
56
|
ord1 = m.group(1)
|
|
92
57
|
var = m.group(2)
|
|
93
58
|
ord2 = m.group(3)
|
|
@@ -104,319 +69,781 @@ def parse_pde(pde: str) -> Tuple[str, int, str, str]:
|
|
|
104
69
|
return "u", 1, "1", _preprocess_expr(rhs)
|
|
105
70
|
|
|
106
71
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
72
|
+
class PDE:
|
|
73
|
+
equation: str
|
|
74
|
+
desc: str
|
|
75
|
+
mode: str
|
|
111
76
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return grid
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def sample_gradient(grid: np.ndarray, x: float, z: float, dx: float) -> Tuple[float, float]:
|
|
119
|
-
if grid is None:
|
|
120
|
-
return 0.0, 0.0
|
|
121
|
-
nz, nx = grid.shape
|
|
122
|
-
ix = int(np.clip(x / dx, 1, nx - 2))
|
|
123
|
-
iz = int(np.clip(z / dx, 1, nz - 2))
|
|
124
|
-
du_dx = (grid[iz, ix + 1] - grid[iz, ix - 1]) / (2 * dx)
|
|
125
|
-
du_dz = (grid[iz + 1, ix] - grid[iz - 1, ix]) / (2 * dx)
|
|
126
|
-
return du_dx, du_dz
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _build_eval_env(vars_grid: Dict[str, np.ndarray], constants: Dict[str, float], grid_dx: float) -> Dict[str, object]:
|
|
130
|
-
def lap(u):
|
|
131
|
-
return dxx(u) + dzz(u)
|
|
132
|
-
|
|
133
|
-
def dx(u):
|
|
134
|
-
return (np.roll(u, -1, axis=1) - np.roll(u, 1, axis=1)) / (2 * grid_dx)
|
|
135
|
-
|
|
136
|
-
def dz(u):
|
|
137
|
-
return (np.roll(u, -1, axis=0) - np.roll(u, 1, axis=0)) / (2 * grid_dx)
|
|
138
|
-
|
|
139
|
-
def dxx(u):
|
|
140
|
-
return (np.roll(u, -1, axis=1) - 2 * u + np.roll(u, 1, axis=1)) / (grid_dx ** 2)
|
|
141
|
-
|
|
142
|
-
def dzz(u):
|
|
143
|
-
return (np.roll(u, -1, axis=0) - 2 * u + np.roll(u, 1, axis=0)) / (grid_dx ** 2)
|
|
144
|
-
|
|
145
|
-
def gradmag(u):
|
|
146
|
-
return dx(u) ** 2 + dz(u) ** 2
|
|
147
|
-
|
|
148
|
-
def gradl1(u):
|
|
149
|
-
return np.abs(dx(u)) + np.abs(dz(u))
|
|
150
|
-
|
|
151
|
-
def grad(u):
|
|
152
|
-
# Return a 2x(HxW) array so scalar multiplication broadcasts naturally
|
|
153
|
-
# and div(...) can consume it.
|
|
154
|
-
return np.stack((dx(u), dz(u)), axis=0)
|
|
155
|
-
|
|
156
|
-
def div(v):
|
|
157
|
-
# Accept either a tuple/list (vx, vz) or a stacked array with shape (2, ...).
|
|
158
|
-
if isinstance(v, (tuple, list)) and len(v) == 2:
|
|
159
|
-
return dx(v[0]) + dz(v[1])
|
|
160
|
-
if isinstance(v, np.ndarray) and v.ndim >= 3 and v.shape[0] == 2:
|
|
161
|
-
return dx(v[0]) + dz(v[1])
|
|
162
|
-
return np.zeros_like(next(iter(vars_grid.values())))
|
|
163
|
-
|
|
164
|
-
def sech(u):
|
|
165
|
-
return 1.0 / np.cosh(u)
|
|
166
|
-
|
|
167
|
-
def sign(u):
|
|
168
|
-
return np.sign(u)
|
|
169
|
-
|
|
170
|
-
def pos(u):
|
|
171
|
-
return np.maximum(u, 0.0)
|
|
172
|
-
|
|
173
|
-
env = {
|
|
174
|
-
"np": np,
|
|
175
|
-
"sin": np.sin,
|
|
176
|
-
"cos": np.cos,
|
|
177
|
-
"tan": np.tan,
|
|
178
|
-
"sinh": np.sinh,
|
|
179
|
-
"cosh": np.cosh,
|
|
180
|
-
"tanh": np.tanh,
|
|
181
|
-
"arcsin": np.arcsin,
|
|
182
|
-
"arccos": np.arccos,
|
|
183
|
-
"arctan": np.arctan,
|
|
184
|
-
"log": np.log,
|
|
185
|
-
"log10": np.log10,
|
|
186
|
-
"log2": np.log2,
|
|
187
|
-
"exp": np.exp,
|
|
188
|
-
"sqrt": np.sqrt,
|
|
189
|
-
"abs": np.abs,
|
|
190
|
-
"pi": np.pi,
|
|
191
|
-
"inf": np.inf,
|
|
192
|
-
"lap": lap,
|
|
193
|
-
"dx": dx,
|
|
194
|
-
"dz": dz,
|
|
195
|
-
"dxx": dxx,
|
|
196
|
-
"dzz": dzz,
|
|
197
|
-
"gradmag": gradmag,
|
|
198
|
-
"gradl1": gradl1,
|
|
199
|
-
"grad": grad,
|
|
200
|
-
"div": div,
|
|
201
|
-
"pos": pos,
|
|
202
|
-
"sech": sech,
|
|
203
|
-
"sign": sign,
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
# Add constants
|
|
207
|
-
for k, v in (constants or {}).items():
|
|
208
|
-
env[k] = v
|
|
209
|
-
|
|
210
|
-
if "E" not in env:
|
|
211
|
-
env["E"] = np.e
|
|
212
|
-
if "e" not in env:
|
|
213
|
-
env["e"] = np.e
|
|
214
|
-
|
|
215
|
-
# Normalize common constant aliases used in test corpora / LLM outputs.
|
|
216
|
-
# - Users often provide 'lambda' and 'eps' in JSON to avoid Unicode / reserved keywords.
|
|
217
|
-
if "lambda" in env and "lam" not in env:
|
|
218
|
-
env["lam"] = env["lambda"]
|
|
219
|
-
if "eps" in env and "epsilon" not in env:
|
|
220
|
-
env["epsilon"] = env["eps"]
|
|
221
|
-
|
|
222
|
-
# Common aliasing for constants
|
|
223
|
-
if "w" in env and "omega" not in env:
|
|
224
|
-
env["omega"] = env["w"]
|
|
225
|
-
if "w2" in env and "omega2" not in env:
|
|
226
|
-
env["omega2"] = env["w2"]
|
|
227
|
-
if "ax" in env and "alphax" not in env:
|
|
228
|
-
env["alphax"] = env["ax"]
|
|
229
|
-
if "az" in env and "alphaz" not in env:
|
|
230
|
-
env["alphaz"] = env["az"]
|
|
231
|
-
if "th" in env and "theta" not in env:
|
|
232
|
-
env["theta"] = env["th"]
|
|
233
|
-
if "damp" in env and "zeta" not in env:
|
|
234
|
-
env["zeta"] = env["damp"]
|
|
235
|
-
|
|
236
|
-
# Provide time symbol if present
|
|
237
|
-
if "t" not in env:
|
|
238
|
-
env["t"] = float((constants or {}).get("t", 0.0))
|
|
239
|
-
|
|
240
|
-
# Add variable grids
|
|
241
|
-
for k, v in vars_grid.items():
|
|
242
|
-
env[k] = v
|
|
243
|
-
|
|
244
|
-
return env
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def evaluate_rhs(rhs_expr: str, vars_grid: Dict[str, np.ndarray], constants: Dict[str, float], grid_dx: float) -> np.ndarray:
|
|
248
|
-
"""
|
|
249
|
-
Evaluate RHS expression on grid using numpy operations.
|
|
250
|
-
"""
|
|
251
|
-
rhs_expr = _preprocess_expr(rhs_expr)
|
|
252
|
-
env = _build_eval_env(vars_grid, constants, grid_dx)
|
|
253
|
-
|
|
254
|
-
# Provide default zero grids for missing symbols referenced in RHS
|
|
255
|
-
if vars_grid:
|
|
256
|
-
zero_grid = np.zeros_like(next(iter(vars_grid.values())))
|
|
257
|
-
identifiers = set(re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\b", rhs_expr))
|
|
258
|
-
reserved = {
|
|
259
|
-
"np",
|
|
260
|
-
"sin", "cos", "tan", "sinh", "cosh", "tanh",
|
|
261
|
-
"arcsin", "arccos", "arctan",
|
|
262
|
-
"exp", "sqrt", "abs", "log", "log10", "log2",
|
|
263
|
-
"sech", "sign",
|
|
264
|
-
"pi", "inf", "E", "e",
|
|
265
|
-
"lap", "dx", "dz", "dxx", "dzz", "grad", "div", "gradmag", "gradl1", "pos", "t",
|
|
266
|
-
}
|
|
267
|
-
for name in identifiers:
|
|
268
|
-
if name in reserved:
|
|
269
|
-
continue
|
|
270
|
-
if name not in env:
|
|
271
|
-
env[name] = zero_grid
|
|
77
|
+
u: np.ndarray
|
|
78
|
+
u_desc: str
|
|
79
|
+
u_shape: List[str]
|
|
272
80
|
|
|
273
|
-
|
|
274
|
-
|
|
81
|
+
boundry: List[str] # List of equations like "x(0, t) = 0"
|
|
82
|
+
initial: List[str] # List of equations like "u(x,0) = sin(x)"
|
|
83
|
+
|
|
84
|
+
space_axis: List[str]
|
|
85
|
+
external_variables: Dict[str, float]
|
|
275
86
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def evaluate_rhs_compiled(rhs_expr: str, vars_grid: Dict[str, np.ndarray], constants: Dict[str, float], grid_dx: float) -> np.ndarray:
|
|
283
|
-
"""Evaluate already-compiled RHS expression (no preprocessing)."""
|
|
284
|
-
env = _build_eval_env(vars_grid, constants, grid_dx)
|
|
285
|
-
|
|
286
|
-
if vars_grid:
|
|
287
|
-
zero_grid = np.zeros_like(next(iter(vars_grid.values())))
|
|
288
|
-
identifiers = set(re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\b", rhs_expr))
|
|
289
|
-
reserved = {
|
|
290
|
-
"np",
|
|
291
|
-
"sin", "cos", "tan", "sinh", "cosh", "tanh",
|
|
292
|
-
"arcsin", "arccos", "arctan",
|
|
293
|
-
"exp", "sqrt", "abs", "log", "log10", "log2",
|
|
294
|
-
"sech", "sign",
|
|
295
|
-
"pi", "inf", "E", "e",
|
|
296
|
-
"lap", "dx", "dz", "dxx", "dzz", "grad", "div", "gradmag", "gradl1", "pos", "t",
|
|
297
|
-
}
|
|
298
|
-
for name in identifiers:
|
|
299
|
-
if name in reserved:
|
|
300
|
-
continue
|
|
301
|
-
if name not in env:
|
|
302
|
-
env[name] = zero_grid
|
|
87
|
+
grid_dx: float
|
|
88
|
+
time: float # Internal time tracker
|
|
89
|
+
|
|
90
|
+
_u_t: np.ndarray | None = None
|
|
91
|
+
_parsed_bcs: List[Dict[str, Any]] = []
|
|
303
92
|
|
|
304
|
-
|
|
305
|
-
|
|
93
|
+
def __init__(self, equation: str = "", desc: str = "", space_axis: List[str] = None):
|
|
94
|
+
self.equation = equation
|
|
95
|
+
self.desc = desc
|
|
306
96
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
raise RuntimeError(f"PDE RHS eval failed for '{rhs_expr}': {exc}")
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def evaluate_scalar(expr: str, vars_grid: Dict[str, np.ndarray], constants: Dict[str, float], grid_dx: float) -> float:
|
|
314
|
-
"""Evaluate a scalar coefficient expression with the same env as RHS."""
|
|
315
|
-
if expr in ("", "1"):
|
|
316
|
-
return 1.0
|
|
317
|
-
rhs = evaluate_rhs(expr, vars_grid, constants, grid_dx)
|
|
318
|
-
if np.isscalar(rhs):
|
|
319
|
-
return float(rhs)
|
|
320
|
-
try:
|
|
321
|
-
return float(np.mean(rhs))
|
|
322
|
-
except Exception as exc:
|
|
323
|
-
raise RuntimeError(f"PDE coeff eval failed for '{expr}': {exc}")
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def evaluate_scalar_compiled(expr: str, vars_grid: Dict[str, np.ndarray], constants: Dict[str, float], grid_dx: float) -> float:
|
|
327
|
-
"""Evaluate scalar coefficient expression without preprocessing."""
|
|
328
|
-
if expr in ("", "1"):
|
|
329
|
-
return 1.0
|
|
330
|
-
rhs = evaluate_rhs_compiled(expr, vars_grid, constants, grid_dx)
|
|
331
|
-
if np.isscalar(rhs):
|
|
332
|
-
return float(rhs)
|
|
333
|
-
try:
|
|
334
|
-
return float(np.mean(rhs))
|
|
335
|
-
except Exception as exc:
|
|
336
|
-
raise RuntimeError(f"PDE coeff eval failed for '{expr}': {exc}")
|
|
97
|
+
self.u = np.array([])
|
|
98
|
+
self.u_desc = ""
|
|
99
|
+
self.u_shape = ["u"]
|
|
337
100
|
|
|
101
|
+
self.boundry = []
|
|
102
|
+
self.initial = []
|
|
338
103
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
grids: Dict[str, np.ndarray],
|
|
342
|
-
constants: Dict[str, float],
|
|
343
|
-
grid_dx: float,
|
|
344
|
-
dt: float,
|
|
345
|
-
external_grids: Dict[str, np.ndarray] | None = None,
|
|
346
|
-
) -> List[str]:
|
|
347
|
-
"""Advance PDEs by one time step. Returns list of error strings."""
|
|
348
|
-
errors: List[str] = []
|
|
349
|
-
if not field_pdes or not grids:
|
|
350
|
-
return errors
|
|
104
|
+
self.space_axis = space_axis or ["z", "x"]
|
|
105
|
+
self.external_variables = {}
|
|
351
106
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
107
|
+
self.grid_dx = 1.0
|
|
108
|
+
self.time = 0.0
|
|
109
|
+
self._u_t = None
|
|
110
|
+
self._parsed_bcs = []
|
|
111
|
+
|
|
112
|
+
def init_grid(self, width: int = 0, height: int = 0, depth: int = 0, dx: float = 0.1, shape: tuple = None):
|
|
113
|
+
self.grid_dx = dx
|
|
114
|
+
self.time = 0.0
|
|
115
|
+
|
|
116
|
+
if shape:
|
|
117
|
+
grid_shape = shape
|
|
118
|
+
else:
|
|
119
|
+
dims = []
|
|
120
|
+
for axis in self.space_axis:
|
|
121
|
+
if axis == 'x':
|
|
122
|
+
dims.append(max(2, int(width / dx)))
|
|
123
|
+
elif axis == 'y':
|
|
124
|
+
dims.append(max(2, int(height / dx)))
|
|
125
|
+
elif axis == 'z':
|
|
126
|
+
if 'y' in self.space_axis:
|
|
127
|
+
dims.append(max(2, int(depth / dx)))
|
|
128
|
+
else:
|
|
129
|
+
dims.append(max(2, int(height / dx)))
|
|
130
|
+
else:
|
|
131
|
+
dims.append(max(2, int(width / dx)))
|
|
132
|
+
|
|
133
|
+
grid_shape = tuple(dims)
|
|
134
|
+
if not grid_shape:
|
|
135
|
+
grid_shape = (max(2, int(height/dx)), max(2, int(width/dx)))
|
|
355
136
|
|
|
356
|
-
|
|
137
|
+
if not self.u_shape:
|
|
138
|
+
self.u_shape = ["u"]
|
|
139
|
+
|
|
140
|
+
num_components = len(self.u_shape)
|
|
141
|
+
if num_components > 1:
|
|
142
|
+
self.u = np.zeros((num_components, *grid_shape), dtype=float)
|
|
143
|
+
else:
|
|
144
|
+
self.u = np.zeros(grid_shape, dtype=float)
|
|
145
|
+
|
|
146
|
+
self._u_t = np.zeros_like(self.u)
|
|
147
|
+
|
|
148
|
+
# Initialize default boundaries only if empty and no equations provided
|
|
149
|
+
if not self.boundry:
|
|
150
|
+
# Default to periodic strings if user hasn't provided equations
|
|
151
|
+
pass # We will rely on empty -> periodic or logic in parsing
|
|
152
|
+
|
|
153
|
+
def set_initial_state(self):
|
|
154
|
+
"""
|
|
155
|
+
Parses and applies initial conditions from self.initial.
|
|
156
|
+
Equations format: "u = sin(x)" or "ux = ..." or "u(x,0) = ..."
|
|
157
|
+
"""
|
|
158
|
+
env = self._build_eval_env()
|
|
159
|
+
env["t"] = 0.0
|
|
160
|
+
|
|
161
|
+
if not self.initial:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
for ic_eqn in self.initial:
|
|
165
|
+
# Simple parsing: LHS = RHS
|
|
166
|
+
if "=" not in ic_eqn: continue
|
|
167
|
+
lhs, rhs = ic_eqn.split("=", 1)
|
|
168
|
+
lhs = lhs.strip()
|
|
169
|
+
rhs_expr = rhs.strip()
|
|
170
|
+
|
|
171
|
+
# Determine target component
|
|
172
|
+
target_idx = None
|
|
173
|
+
target_name = "u"
|
|
174
|
+
|
|
175
|
+
# Check for "u", "ux", "u(x,0)", etc.
|
|
176
|
+
# Regex to match var name at start
|
|
177
|
+
m = re.match(r"^([a-zA-Z_]\w*)", lhs)
|
|
178
|
+
if m:
|
|
179
|
+
target_name = m.group(1)
|
|
180
|
+
|
|
181
|
+
# Map target_name to u index
|
|
182
|
+
if target_name == "u" and len(self.u_shape) == 1:
|
|
183
|
+
target_idx = None # Scalar u
|
|
184
|
+
elif target_name in self.u_shape:
|
|
185
|
+
target_idx = self.u_shape.index(target_name)
|
|
186
|
+
|
|
187
|
+
# Evaluate RHS
|
|
188
|
+
try:
|
|
189
|
+
# Preprocess rhs to safe python
|
|
190
|
+
rhs_expr = _preprocess_expr(rhs_expr)
|
|
191
|
+
val = eval(rhs_expr, {}, env)
|
|
192
|
+
|
|
193
|
+
# Apply
|
|
194
|
+
if target_idx is None:
|
|
195
|
+
#Scalar
|
|
196
|
+
if np.shape(val) == np.shape(self.u):
|
|
197
|
+
self.u[:] = val
|
|
198
|
+
else:
|
|
199
|
+
# Broadcast?
|
|
200
|
+
self.u[:] = val
|
|
201
|
+
else:
|
|
202
|
+
# Vector component
|
|
203
|
+
if target_idx < len(self.u):
|
|
204
|
+
self.u[target_idx][:] = val
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f"Failed to set IC '{ic_eqn}': {e}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_grid(self, u_state: np.ndarray = None, dt: float = 0.0) -> np.ndarray:
|
|
210
|
+
"""
|
|
211
|
+
Calculates the change (du) or rate of change (forcing) for the current PDE state
|
|
212
|
+
without modifying the internal state. Useful for visualization (vector fields).
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
u_state (np.ndarray, optional): A state to evaluate instead of self.u.
|
|
216
|
+
Must match self.u shape.
|
|
217
|
+
dt (float, optional): Time step.
|
|
218
|
+
If > 0, returns the delta (du = forcing * dt).
|
|
219
|
+
If 0 (default), returns the rate of change (forcing).
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
np.ndarray: The computed change or rate grid (same shape as u).
|
|
223
|
+
"""
|
|
224
|
+
# 1. Backup state
|
|
225
|
+
original_u = self.u
|
|
226
|
+
original_u_t = self._u_t
|
|
227
|
+
|
|
228
|
+
# 2. Swap State (Temporarily)
|
|
229
|
+
if u_state is not None:
|
|
230
|
+
if u_state.shape != self.u.shape:
|
|
231
|
+
# Try to run anyway but this might crash later if env injection fails
|
|
232
|
+
pass
|
|
233
|
+
self.u = u_state
|
|
234
|
+
# For 2nd order, we might ideally need a u_t_state.
|
|
235
|
+
# We'll assume existing u_t or zeros if shape mismatch.
|
|
236
|
+
if self._u_t is not None and self._u_t.shape != u_state.shape:
|
|
237
|
+
self._u_t = np.zeros_like(u_state)
|
|
238
|
+
elif self._u_t is None:
|
|
239
|
+
self._u_t = np.zeros_like(u_state)
|
|
240
|
+
|
|
241
|
+
# 3. Prepare Environment
|
|
242
|
+
var_name, order, coeff_expr, rhs_expr = parse_pde(self.equation)
|
|
243
|
+
|
|
244
|
+
env = self._build_eval_env()
|
|
245
|
+
env["t"] = self.time
|
|
246
|
+
|
|
247
|
+
# Inject u components (Logic duplicated from step)
|
|
248
|
+
if len(self.u_shape) == 1:
|
|
249
|
+
name = self.u_shape[0]
|
|
250
|
+
env[name] = self.u
|
|
251
|
+
v_t = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
252
|
+
env[f"{name}_t"] = v_t
|
|
253
|
+
else:
|
|
254
|
+
for i, name in enumerate(self.u_shape):
|
|
255
|
+
env[name] = self.u[i]
|
|
256
|
+
v_t = self._u_t[i] if self._u_t is not None else np.zeros_like(self.u[i])
|
|
257
|
+
env[f"{name}_t"] = v_t
|
|
258
|
+
env["u"] = self.u
|
|
259
|
+
env["u_t"] = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
260
|
+
|
|
261
|
+
# 4. Compute Forcing
|
|
357
262
|
try:
|
|
358
|
-
|
|
359
|
-
if
|
|
360
|
-
|
|
263
|
+
rhs = self.evaluate_rhs(rhs_expr, env)
|
|
264
|
+
if isinstance(rhs, list):
|
|
265
|
+
rhs = np.array(rhs)
|
|
266
|
+
|
|
267
|
+
coeff = self.evaluate_scalar(coeff_expr, env)
|
|
268
|
+
forcing = rhs / (coeff if coeff else 1.0)
|
|
269
|
+
|
|
270
|
+
# 5. Handle Order logic for return value
|
|
271
|
+
if order == 2:
|
|
272
|
+
# 2nd Order: u'' = forcing.
|
|
273
|
+
if dt > 0:
|
|
274
|
+
# Update logic approximation: u_new ~ u + dt * v + 0.5 * dt^2 * a
|
|
275
|
+
v = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
276
|
+
du = dt * v + 0.5 * (dt**2) * forcing
|
|
277
|
+
else:
|
|
278
|
+
# If dt=0, return forcing (acceleration).
|
|
279
|
+
# Note: du/dt (velocity) is u_t, but usually one probes the field of forces.
|
|
280
|
+
du = forcing
|
|
281
|
+
else:
|
|
282
|
+
# 1st Order
|
|
283
|
+
if dt > 0:
|
|
284
|
+
du = forcing * dt
|
|
285
|
+
else:
|
|
286
|
+
du = forcing # Rate of change
|
|
287
|
+
|
|
288
|
+
except Exception as e:
|
|
289
|
+
# Restore and re-raise
|
|
290
|
+
self.u = original_u
|
|
291
|
+
self._u_t = original_u_t
|
|
292
|
+
raise e
|
|
293
|
+
|
|
294
|
+
# 6. Restore
|
|
295
|
+
self.u = original_u
|
|
296
|
+
self._u_t = original_u_t
|
|
297
|
+
|
|
298
|
+
return du
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _parse_boundaries(self):
|
|
302
|
+
"""
|
|
303
|
+
Parses `self.boundry` list of equations into structured BCs.
|
|
304
|
+
Supports formats:
|
|
305
|
+
- "x=0 = 1.0" (Dirichlet on Axis X at 0)
|
|
306
|
+
- "u(x=0) = 1.0" (Dirichlet)
|
|
307
|
+
- "dx(u)(x=0) = 0" (Neumann)
|
|
308
|
+
"""
|
|
309
|
+
self._parsed_bcs = []
|
|
310
|
+
|
|
311
|
+
# Helper to map axis name to index
|
|
312
|
+
def get_axis_idx(name):
|
|
313
|
+
try:
|
|
314
|
+
return self.space_axis.index(name)
|
|
315
|
+
except ValueError:
|
|
316
|
+
return -1
|
|
317
|
+
|
|
318
|
+
for bc_eqn in self.boundry:
|
|
319
|
+
bc_eqn = bc_eqn.strip()
|
|
320
|
+
if not bc_eqn: continue
|
|
321
|
+
|
|
322
|
+
# 1. Handle "periodic" keyword
|
|
323
|
+
if bc_eqn.lower() == "periodic":
|
|
324
|
+
self._parsed_bcs.append({"type": "periodic", "axis": None})
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
# 2. Split LHS = RHS
|
|
328
|
+
# Use rsplit to split on the last equals sign, to allow "u(x=0) = 5"
|
|
329
|
+
parts = bc_eqn.rsplit("=", 1)
|
|
330
|
+
if len(parts) != 2:
|
|
331
|
+
# Fallback for "periodic x" or just "periodic" mixed in text
|
|
332
|
+
if "periodic" in bc_eqn.lower():
|
|
333
|
+
self._parsed_bcs.append({"type": "periodic", "axis": None})
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
lhs, rhs = parts
|
|
337
|
+
lhs = _preprocess_expr(lhs) # Normalize symbols in LHS
|
|
338
|
+
rhs = rhs.strip()
|
|
339
|
+
|
|
340
|
+
# 3. Detect Boundary Type
|
|
341
|
+
# Neumann if derivative operator present
|
|
342
|
+
bc_type = "dirichlet"
|
|
343
|
+
if any(op in lhs for op in ["dx", "dy", "dz", "grad", "∂", "d/d"]):
|
|
344
|
+
bc_type = "neumann"
|
|
345
|
+
|
|
346
|
+
# 4. Detect Axis and Side
|
|
347
|
+
# Look for explicit assignment "x=0" or "x=10" inside LHS
|
|
348
|
+
# OR implicit function arg "u(0, y)" style (harder, stick to explicit first)
|
|
349
|
+
|
|
350
|
+
# Regex for "axis = value" inside parentheses or standalone
|
|
351
|
+
# Matches: "x=0", "x = 10.0", "z=5"
|
|
352
|
+
# Note: _preprocess_expr might have removed spaces
|
|
353
|
+
axis_match = re.search(r"([a-z])\s*=\s*([0-9\.]+)", lhs)
|
|
354
|
+
|
|
355
|
+
target_axis = -1
|
|
356
|
+
boundary_side = 0
|
|
357
|
+
|
|
358
|
+
if axis_match:
|
|
359
|
+
ax_name = axis_match.group(1)
|
|
360
|
+
val_str = axis_match.group(2)
|
|
361
|
+
val = float(val_str)
|
|
362
|
+
target_axis = get_axis_idx(ax_name)
|
|
363
|
+
|
|
364
|
+
# Determine "Left/Lower" or "Right/Upper" based on 0 check or relative?
|
|
365
|
+
# For robustness, we should probably check against grid size if possible,
|
|
366
|
+
# but grid isn't always init when parsing.
|
|
367
|
+
# Convention: 0 is start (0), anything > 0 is end (1) ??
|
|
368
|
+
# Better: 0 is side 0. If val > 0 implies side 1??
|
|
369
|
+
# Let's assume user provides 0 for left, and Width/L for right.
|
|
370
|
+
if val <= 1e-9: boundary_side = 0
|
|
371
|
+
else: boundary_side = 1
|
|
372
|
+
|
|
373
|
+
else:
|
|
374
|
+
# Fallback: check if LHS starts with axis name? "x(0)"
|
|
375
|
+
# Or "u(0)" implies axis 0, coordinate 0?
|
|
376
|
+
# This is ambiguous. Let's warn or skip.
|
|
377
|
+
pass
|
|
378
|
+
|
|
379
|
+
# Evaluate RHS validity check? No, keep as string/expression.
|
|
380
|
+
|
|
381
|
+
self._parsed_bcs.append({
|
|
382
|
+
"type": bc_type,
|
|
383
|
+
"axis": target_axis,
|
|
384
|
+
"side": boundary_side,
|
|
385
|
+
"rhs": rhs,
|
|
386
|
+
"eqn": bc_eqn
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _apply_boundary_conditions(self):
|
|
391
|
+
"""
|
|
392
|
+
Enforce BCs on the grid state `self.u`.
|
|
393
|
+
"""
|
|
394
|
+
if not self._parsed_bcs and self.boundry:
|
|
395
|
+
self._parse_boundaries()
|
|
396
|
+
|
|
397
|
+
env = self._build_eval_env()
|
|
398
|
+
env["t"] = self.time
|
|
361
399
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
400
|
+
# If no equations, and user didn't specify periodic string, what default?
|
|
401
|
+
# If user explicitly put "periodic" in boundry list, handled by parser.
|
|
402
|
+
|
|
403
|
+
for bc in self._parsed_bcs:
|
|
404
|
+
if bc["type"] == "periodic":
|
|
405
|
+
# Periodic is handled by _get_neighbor implicitly usually.
|
|
406
|
+
# No data clamping needed unless we use ghost nodes explicitly.
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
if bc["axis"] == -1: continue
|
|
410
|
+
|
|
411
|
+
# Map space axis to array axis
|
|
412
|
+
# self.u ndim vs spatial dims
|
|
413
|
+
spatial_dims = len(self.space_axis)
|
|
414
|
+
ndim = self.u.ndim
|
|
415
|
+
arr_axis = (ndim - spatial_dims) + bc["axis"]
|
|
416
|
+
|
|
417
|
+
# Slice for boundary
|
|
418
|
+
# side 0 -> index 0. side 1 -> index -1.
|
|
419
|
+
idx = 0 if bc["side"] == 0 else -1
|
|
420
|
+
|
|
421
|
+
# Construct slice object to access all other dims
|
|
422
|
+
# e.g. u[..., 0] or u[..., -1]
|
|
423
|
+
sl = [slice(None)] * ndim
|
|
424
|
+
sl[arr_axis] = idx
|
|
425
|
+
tuple_sl = tuple(sl)
|
|
426
|
+
|
|
427
|
+
# Evaluate RHS
|
|
428
|
+
try:
|
|
429
|
+
rhs_val = self.evaluate_rhs(bc["rhs"], env)
|
|
430
|
+
|
|
431
|
+
if bc["type"] == "dirichlet":
|
|
432
|
+
# Hard set boundary value
|
|
433
|
+
# If u is vector, and equation didn't specify component?
|
|
434
|
+
# Assuming scalar u or eqn u(...) applies to all?
|
|
435
|
+
# For now assume matches u shape.
|
|
436
|
+
self.u[tuple_sl] = rhs_val
|
|
437
|
+
|
|
438
|
+
elif bc["type"] == "neumann":
|
|
439
|
+
# Neumann: du/dn = rhs
|
|
440
|
+
# n is outward normal.
|
|
441
|
+
# Left boundary (side 0): normal is -x. du/dn = -du/dx.
|
|
442
|
+
# Right boundary (side 1): normal is +x. du/dn = +du/dx.
|
|
443
|
+
|
|
444
|
+
# Approximating: u[0] = u[1] - rhs * dx (if normal is -x ?)
|
|
445
|
+
# Left (idx 0): du/dx ~ (u[1]-u[0])/dx.
|
|
446
|
+
# If du/dn = g, then -du/dx = g => du/dx = -g.
|
|
447
|
+
# (u[1]-u[0])/dx = -g => u[1]-u[0] = -g*dx => u[0] = u[1] + g*dx.
|
|
448
|
+
|
|
449
|
+
# Right (idx -1): du/dx ~ (u[-1]-u[-2])/dx.
|
|
450
|
+
# du/dn = +du/dx = g.
|
|
451
|
+
# u[-1] - u[-2] = g*dx => u[-1] = u[-2] + g*dx.
|
|
452
|
+
|
|
453
|
+
g = rhs_val
|
|
454
|
+
dx = self.grid_dx
|
|
455
|
+
|
|
456
|
+
if bc["side"] == 0: # Left
|
|
457
|
+
neighbor_sl = list(sl)
|
|
458
|
+
neighbor_sl[arr_axis] = 1 # Inner point u[1]
|
|
459
|
+
self.u[tuple_sl] = self.u[tuple(neighbor_sl)] + g * dx
|
|
460
|
+
else: # Right
|
|
461
|
+
neighbor_sl = list(sl)
|
|
462
|
+
neighbor_sl[arr_axis] = -2 # Inner point u[-2]
|
|
463
|
+
self.u[tuple_sl] = self.u[tuple(neighbor_sl)] + g * dx
|
|
464
|
+
|
|
465
|
+
except Exception as e:
|
|
466
|
+
# Log error?
|
|
467
|
+
pass
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _get_neighbor(self, u_in: np.ndarray, axis: int, step: int) -> np.ndarray:
|
|
471
|
+
# Check explicit equation-based BCs first?
|
|
472
|
+
# My _apply_boundary_conditions modifies the grid boundaries.
|
|
473
|
+
# If boundaries are modified (Dirichlet/Neumann) directly on the grid,
|
|
474
|
+
# then `np.roll` (periodic) is NOT appropriate for those axes.
|
|
475
|
+
# We need `shift` that respects the "Edge" condition.
|
|
476
|
+
|
|
477
|
+
# If we have parsed BCs for this axis, assume NOT periodic unless specified?
|
|
478
|
+
# If no BCs for axis, default to Periodic?
|
|
479
|
+
|
|
480
|
+
# Determine BC type for this axis
|
|
481
|
+
ndim = u_in.ndim
|
|
482
|
+
spatial_dims = len(self.space_axis)
|
|
483
|
+
if ndim < spatial_dims: return np.roll(u_in, -step, axis=axis)
|
|
484
|
+
space_idx = axis - (ndim - spatial_dims)
|
|
485
|
+
|
|
486
|
+
is_periodic = True
|
|
487
|
+
# Check parsed BCs for Dirichlet/Neumann on this axis
|
|
488
|
+
# (This is slow, optimizable)
|
|
489
|
+
if not self._parsed_bcs and self.boundry:
|
|
490
|
+
self._parse_boundaries()
|
|
491
|
+
|
|
492
|
+
for bc in self._parsed_bcs:
|
|
493
|
+
if bc.get("axis") == space_idx and bc["type"] in ["dirichlet", "neumann"]:
|
|
494
|
+
is_periodic = False
|
|
495
|
+
break
|
|
496
|
+
|
|
497
|
+
if is_periodic:
|
|
498
|
+
return np.roll(u_in, -step, axis=axis)
|
|
499
|
+
else:
|
|
500
|
+
# Shift with edge padding (Clamping)
|
|
501
|
+
# The actual values at boundary are set by _apply_boundary_conditions
|
|
502
|
+
# So calculating derivatives at the boundary using clamped neighbors
|
|
503
|
+
# (or just simple neighbors) is standard.
|
|
504
|
+
# E.g. derivative at boundary? Usually skipped or one-sided.
|
|
505
|
+
# Central difference dx uses (i+1) and (i-1).
|
|
506
|
+
# At i=0, uses i=1 and i=-1 (ghost).
|
|
507
|
+
# By using Pad with Edge, i=-1 becomes i=0.
|
|
508
|
+
# So dx(0) = (u[1] - u[0]) / 2dx. This is roughly one-sided.
|
|
509
|
+
|
|
510
|
+
if step > 0:
|
|
511
|
+
pad_width = [(0,0)] * ndim
|
|
512
|
+
pad_width[axis] = (0, step)
|
|
513
|
+
# mode='edge' repeats the updated boundary value
|
|
514
|
+
padded = np.pad(u_in, pad_width, mode='edge')
|
|
515
|
+
slices = [slice(None)] * ndim
|
|
516
|
+
slices[axis] = slice(step, None)
|
|
517
|
+
return padded[tuple(slices)]
|
|
518
|
+
else:
|
|
519
|
+
s = -step
|
|
520
|
+
pad_width = [(0,0)] * ndim
|
|
521
|
+
pad_width[axis] = (s, 0)
|
|
522
|
+
padded = np.pad(u_in, pad_width, mode='edge')
|
|
523
|
+
slices = [slice(None)] * ndim
|
|
524
|
+
slices[axis] = slice(0, -s)
|
|
525
|
+
return padded[tuple(slices)]
|
|
526
|
+
|
|
527
|
+
def _build_eval_env(self) -> Dict[str, object]:
|
|
528
|
+
grid_dx = self.grid_dx
|
|
529
|
+
|
|
530
|
+
def _resolve_axis(u: np.ndarray, name: str) -> int:
|
|
531
|
+
spatial_dims = len(self.space_axis)
|
|
532
|
+
try:
|
|
533
|
+
s_idx = self.space_axis.index(name)
|
|
534
|
+
return (u.ndim - spatial_dims) + s_idx
|
|
535
|
+
except ValueError:
|
|
536
|
+
return -1
|
|
537
|
+
|
|
538
|
+
def dx(u):
|
|
539
|
+
ax = _resolve_axis(u, "x")
|
|
540
|
+
if ax == -1: return np.zeros_like(u)
|
|
541
|
+
return (self._get_neighbor(u, ax, 1) - self._get_neighbor(u, ax, -1)) / (2 * grid_dx)
|
|
542
|
+
|
|
543
|
+
def dz(u):
|
|
544
|
+
ax = _resolve_axis(u, "z")
|
|
545
|
+
if ax == -1: return np.zeros_like(u)
|
|
546
|
+
return (self._get_neighbor(u, ax, 1) - self._get_neighbor(u, ax, -1)) / (2 * grid_dx)
|
|
547
|
+
|
|
548
|
+
def dy(u):
|
|
549
|
+
ax = _resolve_axis(u, "y")
|
|
550
|
+
if ax == -1: return np.zeros_like(u)
|
|
551
|
+
return (self._get_neighbor(u, ax, 1) - self._get_neighbor(u, ax, -1)) / (2 * grid_dx)
|
|
552
|
+
|
|
553
|
+
def dxx(u):
|
|
554
|
+
ax = _resolve_axis(u, "x")
|
|
555
|
+
if ax == -1: return np.zeros_like(u)
|
|
556
|
+
return (self._get_neighbor(u, ax, 1) - 2 * u + self._get_neighbor(u, ax, -1)) / (grid_dx ** 2)
|
|
557
|
+
|
|
558
|
+
def dzz(u):
|
|
559
|
+
ax = _resolve_axis(u, "z")
|
|
560
|
+
if ax == -1: return np.zeros_like(u)
|
|
561
|
+
return (self._get_neighbor(u, ax, 1) - 2 * u + self._get_neighbor(u, ax, -1)) / (grid_dx ** 2)
|
|
562
|
+
|
|
563
|
+
def lap(u):
|
|
564
|
+
val = np.zeros_like(u)
|
|
565
|
+
spatial_dims = len(self.space_axis)
|
|
566
|
+
base_axis = u.ndim - spatial_dims
|
|
567
|
+
|
|
568
|
+
for i, axis_name in enumerate(self.space_axis):
|
|
569
|
+
ax = base_axis + i
|
|
570
|
+
d2 = (self._get_neighbor(u, ax, 1) - 2 * u + self._get_neighbor(u, ax, -1)) / (grid_dx ** 2)
|
|
571
|
+
val += d2
|
|
572
|
+
return val
|
|
573
|
+
|
|
574
|
+
# ... (rest as before) ...
|
|
575
|
+
def gradmag(u):
|
|
576
|
+
val = np.zeros_like(u)
|
|
577
|
+
spatial_dims = len(self.space_axis)
|
|
578
|
+
base_axis = u.ndim - spatial_dims
|
|
579
|
+
for i in enumerate(self.space_axis):
|
|
580
|
+
ax = base_axis + i[0]
|
|
581
|
+
d1 = (self._get_neighbor(u, ax, 1) - self._get_neighbor(u, ax, -1)) / (2 * grid_dx)
|
|
582
|
+
val += d1**2
|
|
583
|
+
return val
|
|
584
|
+
|
|
585
|
+
def gradl1(u):
|
|
586
|
+
val = np.zeros_like(u)
|
|
587
|
+
spatial_dims = len(self.space_axis)
|
|
588
|
+
base_axis = u.ndim - spatial_dims
|
|
589
|
+
for i in enumerate(self.space_axis):
|
|
590
|
+
ax = base_axis + i[0]
|
|
591
|
+
d1 = (self._get_neighbor(u, ax, 1) - self._get_neighbor(u, ax, -1)) / (2 * grid_dx)
|
|
592
|
+
val += np.abs(d1)
|
|
593
|
+
return val
|
|
594
|
+
|
|
595
|
+
def grad(u):
|
|
596
|
+
comps = []
|
|
597
|
+
spatial_dims = len(self.space_axis)
|
|
598
|
+
base_axis = u.ndim - spatial_dims
|
|
599
|
+
for i in enumerate(self.space_axis):
|
|
600
|
+
ax = base_axis + i[0]
|
|
601
|
+
d1 = (self._get_neighbor(u, ax, 1) - self._get_neighbor(u, ax, -1)) / (2 * grid_dx)
|
|
602
|
+
comps.append(d1)
|
|
603
|
+
return np.stack(comps, axis=0)
|
|
604
|
+
|
|
605
|
+
def div(v):
|
|
606
|
+
val = 0.0
|
|
607
|
+
spatial_dims = len(self.space_axis)
|
|
608
|
+
def get_comp(idx):
|
|
609
|
+
if isinstance(v, (list, tuple)): return v[idx]
|
|
610
|
+
elif isinstance(v, np.ndarray): return v[idx]
|
|
611
|
+
return None
|
|
612
|
+
for i, axis_name in enumerate(self.space_axis):
|
|
613
|
+
comp = get_comp(i)
|
|
614
|
+
if comp is None: continue
|
|
615
|
+
ax = (comp.ndim - spatial_dims) + i
|
|
616
|
+
d1 = (self._get_neighbor(comp, ax, 1) - self._get_neighbor(comp, ax, -1)) / (2 * grid_dx)
|
|
617
|
+
val += d1
|
|
618
|
+
return val
|
|
619
|
+
|
|
620
|
+
def advect(u_vel, f):
|
|
621
|
+
val = np.zeros_like(f) if isinstance(f, np.ndarray) else 0.0
|
|
622
|
+
spatial_dims = len(self.space_axis)
|
|
623
|
+
|
|
624
|
+
f_is_vector = False
|
|
625
|
+
f_comps = []
|
|
626
|
+
if isinstance(f, (list, tuple)):
|
|
627
|
+
f_is_vector = True
|
|
628
|
+
f_comps = f
|
|
629
|
+
elif isinstance(f, np.ndarray) and f.ndim > spatial_dims and f.shape[0] == len(self.u_shape):
|
|
630
|
+
f_is_vector = True
|
|
631
|
+
f_comps = [f[k] for k in range(f.shape[0])]
|
|
632
|
+
else:
|
|
633
|
+
f_comps = [f]
|
|
365
634
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
635
|
+
res_comps = [np.zeros_like(fc) for fc in f_comps]
|
|
636
|
+
|
|
637
|
+
for i, axis_name in enumerate(self.space_axis):
|
|
638
|
+
vel_comp = u_vel[i] if (isinstance(u_vel, (list, tuple, np.ndarray)) and len(u_vel) > i) else 0
|
|
639
|
+
ax = (f_comps[0].ndim - spatial_dims) + i
|
|
640
|
+
|
|
641
|
+
for k, fc in enumerate(f_comps):
|
|
642
|
+
df_di = (self._get_neighbor(fc, ax, 1) - self._get_neighbor(fc, ax, -1)) / (2 * grid_dx)
|
|
643
|
+
res_comps[k] += vel_comp * df_di
|
|
644
|
+
|
|
645
|
+
if f_is_vector:
|
|
646
|
+
if isinstance(f, (list, tuple)): return tuple(res_comps)
|
|
647
|
+
else: return np.stack(res_comps, axis=0)
|
|
372
648
|
else:
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
649
|
+
return res_comps[0]
|
|
650
|
+
|
|
651
|
+
def pos(u): return np.maximum(u, 0.0)
|
|
652
|
+
def sech(u): return 1.0 / np.cosh(u)
|
|
653
|
+
def sign(u): return np.sign(u)
|
|
654
|
+
|
|
655
|
+
env = {
|
|
656
|
+
"np": np,
|
|
657
|
+
"sin": np.sin, "cos": np.cos, "tan": np.tan,
|
|
658
|
+
"sinh": np.sinh, "cosh": np.cosh, "tanh": np.tanh,
|
|
659
|
+
"arcsin": np.arcsin, "arccos": np.arccos, "arctan": np.arctan,
|
|
660
|
+
"log": np.log, "log10": np.log10, "log2": np.log2,
|
|
661
|
+
"exp": np.exp, "sqrt": np.sqrt, "abs": np.abs,
|
|
662
|
+
"pi": np.pi, "inf": np.inf,
|
|
663
|
+
"lap": lap,
|
|
664
|
+
"dx": dx, "dz": dz, "dy": dy,
|
|
665
|
+
"dxx": dxx, "dzz": dzz,
|
|
666
|
+
"gradmag": gradmag, "gradl1": gradl1,
|
|
667
|
+
"grad": grad, "div": div, "advect": advect,
|
|
668
|
+
"pos": pos, "sech": sech, "sign": sign,
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
env.update(self.external_variables)
|
|
672
|
+
|
|
673
|
+
if "t" not in env:
|
|
674
|
+
env["t"] = 0.0
|
|
675
|
+
|
|
676
|
+
if self.u.size > 0:
|
|
677
|
+
shape = self.u.shape
|
|
678
|
+
spatial_dims = len(self.space_axis)
|
|
679
|
+
grid_shape = shape[-spatial_dims:]
|
|
680
|
+
|
|
681
|
+
# Generalize coordinate generation
|
|
682
|
+
coords = []
|
|
683
|
+
for i, axis_len in enumerate(grid_shape):
|
|
684
|
+
coords.append(np.arange(axis_len) * grid_dx)
|
|
685
|
+
|
|
686
|
+
if len(coords) > 1:
|
|
687
|
+
# Use indexing='ij' for matrix indexing (y, x) style
|
|
688
|
+
# OR 'xy' for Cartesian?
|
|
689
|
+
# init_grid dims are appended in order of space_axis.
|
|
690
|
+
# if space_axis=['z', 'x'], dim0 is z, dim1 is x.
|
|
691
|
+
# We want Meshgrid to return Z, X arrays matching that shape.
|
|
692
|
+
# np.meshgrid(z_ax, x_ax, indexing='ij') returns (Z, X) where Z[i,j] = z_ax[i].
|
|
693
|
+
grids = np.meshgrid(*coords, indexing='ij')
|
|
694
|
+
else:
|
|
695
|
+
grids = [coords[0]]
|
|
696
|
+
|
|
697
|
+
for i, axis_name in enumerate(self.space_axis):
|
|
698
|
+
env[axis_name] = grids[i]
|
|
699
|
+
|
|
700
|
+
return env
|
|
376
701
|
|
|
377
|
-
|
|
702
|
+
def evaluate_rhs(self, rhs_expr: str, env: Dict[str, Any]) -> np.ndarray:
|
|
703
|
+
rhs_expr = _preprocess_expr(rhs_expr)
|
|
704
|
+
return eval(rhs_expr, {}, env)
|
|
705
|
+
|
|
706
|
+
def evaluate_scalar(self, expr: str, env: Dict[str, Any]) -> float:
|
|
707
|
+
if expr in ("", "1"): return 1.0
|
|
708
|
+
val = self.evaluate_rhs(expr, env)
|
|
709
|
+
if np.isscalar(val): return float(val)
|
|
710
|
+
return float(np.mean(val))
|
|
378
711
|
|
|
712
|
+
def step(self, dt: float):
|
|
713
|
+
var_name, order, coeff_expr, rhs_expr = parse_pde(self.equation)
|
|
714
|
+
|
|
715
|
+
self.time += dt # Update internally managed time
|
|
716
|
+
|
|
717
|
+
env = self._build_eval_env()
|
|
718
|
+
env["t"] = self.time # use current time
|
|
719
|
+
|
|
720
|
+
# Inject u components
|
|
721
|
+
if len(self.u_shape) == 1:
|
|
722
|
+
name = self.u_shape[0]
|
|
723
|
+
env[name] = self.u
|
|
724
|
+
# Inject time derivative if exists (or zero)
|
|
725
|
+
v_t = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
726
|
+
env[f"{name}_t"] = v_t
|
|
727
|
+
else:
|
|
728
|
+
for i, name in enumerate(self.u_shape):
|
|
729
|
+
env[name] = self.u[i]
|
|
730
|
+
v_t = self._u_t[i] if self._u_t is not None else np.zeros_like(self.u[i])
|
|
731
|
+
env[f"{name}_t"] = v_t
|
|
732
|
+
env["u"] = self.u
|
|
733
|
+
env["u_t"] = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
379
734
|
|
|
380
|
-
|
|
381
|
-
|
|
735
|
+
try:
|
|
736
|
+
rhs = self.evaluate_rhs(rhs_expr, env)
|
|
737
|
+
if isinstance(rhs, list):
|
|
738
|
+
rhs = np.array(rhs)
|
|
739
|
+
|
|
740
|
+
coeff = self.evaluate_scalar(coeff_expr, env)
|
|
741
|
+
|
|
742
|
+
target_indices = []
|
|
743
|
+
if var_name == "u" and len(self.u_shape) == 1:
|
|
744
|
+
target_indices = [None]
|
|
745
|
+
elif var_name in self.u_shape:
|
|
746
|
+
idx = self.u_shape.index(var_name)
|
|
747
|
+
target_indices = [idx]
|
|
748
|
+
elif var_name == "u" and len(self.u_shape) > 1:
|
|
749
|
+
target_indices = range(len(self.u_shape))
|
|
750
|
+
|
|
751
|
+
forcing = rhs / (coeff if coeff else 1.0)
|
|
752
|
+
|
|
753
|
+
# Apply Time Update
|
|
754
|
+
if order == 2:
|
|
755
|
+
if self._u_t is None: self._u_t = np.zeros_like(self.u)
|
|
756
|
+
|
|
757
|
+
if var_name == "u" and len(self.u_shape) > 1:
|
|
758
|
+
self._u_t += dt * forcing
|
|
759
|
+
self.u += dt * self._u_t
|
|
760
|
+
elif len(target_indices) == 1:
|
|
761
|
+
idx = target_indices[0]
|
|
762
|
+
target_u = self.u if idx is None else self.u[idx]
|
|
763
|
+
target_ut = self._u_t if idx is None else self._u_t[idx]
|
|
764
|
+
target_ut[:] += dt * forcing
|
|
765
|
+
target_u[:] += dt * target_ut
|
|
766
|
+
else:
|
|
767
|
+
if var_name == "u" and len(self.u_shape) > 1:
|
|
768
|
+
self.u += dt * forcing
|
|
769
|
+
elif len(target_indices) == 1:
|
|
770
|
+
idx = target_indices[0]
|
|
771
|
+
if idx is None:
|
|
772
|
+
self.u += dt * forcing
|
|
773
|
+
else:
|
|
774
|
+
self.u[idx] += dt * forcing
|
|
775
|
+
|
|
776
|
+
# Apply Boundary Conditions
|
|
777
|
+
self._apply_boundary_conditions()
|
|
778
|
+
|
|
779
|
+
except Exception as e:
|
|
780
|
+
print(f"Error stepping PDE: {e}")
|
|
781
|
+
raise e
|
|
782
|
+
|
|
783
|
+
def to_json(self) -> str:
|
|
784
|
+
return json.dumps({
|
|
785
|
+
"equation": self.equation,
|
|
786
|
+
"desc": self.desc,
|
|
787
|
+
"u_shape": self.u_shape,
|
|
788
|
+
"space_axis": self.space_axis,
|
|
789
|
+
"boundry": self.boundry,
|
|
790
|
+
"constants": self.external_variables,
|
|
791
|
+
"grid_dx": self.grid_dx,
|
|
792
|
+
"time": self.time
|
|
793
|
+
}, indent=2)
|
|
794
|
+
|
|
795
|
+
def __str__(self):
|
|
796
|
+
return self.to_json()
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# Legacy wrappers
|
|
800
|
+
def init_grid(width: int, height: int, dx: float) -> Tuple[np.ndarray, float]:
|
|
801
|
+
p = PDE(space_axis=["z", "x"])
|
|
802
|
+
p.init_grid(width, height, dx=dx)
|
|
803
|
+
return p.u, dx
|
|
804
|
+
|
|
805
|
+
def step_pdes(
|
|
806
|
+
field_pdes: List[str],
|
|
382
807
|
grids: Dict[str, np.ndarray],
|
|
383
808
|
constants: Dict[str, float],
|
|
384
809
|
grid_dx: float,
|
|
385
810
|
dt: float,
|
|
386
811
|
external_grids: Dict[str, np.ndarray] | None = None,
|
|
387
812
|
) -> List[str]:
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
if not compiled_pdes or not grids:
|
|
391
|
-
return errors
|
|
392
|
-
|
|
393
|
-
vars_grid = dict(grids)
|
|
813
|
+
errors = []
|
|
814
|
+
full_grids = dict(grids)
|
|
394
815
|
if external_grids:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
for
|
|
816
|
+
full_grids.update(external_grids)
|
|
817
|
+
|
|
818
|
+
for i, eq in enumerate(field_pdes):
|
|
398
819
|
try:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
rhs =
|
|
820
|
+
p = PDE(equation=eq, space_axis=["z", "x"])
|
|
821
|
+
p.grid_dx = grid_dx
|
|
822
|
+
p.external_variables = constants.copy()
|
|
823
|
+
p.external_variables.update({k: v for k,v in full_grids.items() if k not in constants})
|
|
824
|
+
|
|
825
|
+
var, order, _, rhs_expr = parse_pde(eq)
|
|
826
|
+
env = p._build_eval_env()
|
|
827
|
+
env.update(full_grids)
|
|
828
|
+
env["t"] = constants.get("t", 0.0)
|
|
829
|
+
|
|
830
|
+
rhs = p.evaluate_rhs(rhs_expr, env)
|
|
831
|
+
forcing = rhs
|
|
832
|
+
|
|
833
|
+
val = grids.get(var)
|
|
834
|
+
if val is None:
|
|
835
|
+
grids[var] = np.zeros_like(next(iter(grids.values())))
|
|
836
|
+
val = grids[var]
|
|
410
837
|
|
|
411
838
|
if order == 2:
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
grids[var] = grids[var] + dt * grids[vname]
|
|
839
|
+
v_t_name = f"{var}_t"
|
|
840
|
+
if v_t_name not in grids: grids[v_t_name] = np.zeros_like(val)
|
|
841
|
+
grids[v_t_name] += dt * forcing
|
|
842
|
+
grids[var] += dt * grids[v_t_name]
|
|
417
843
|
else:
|
|
418
|
-
|
|
844
|
+
grids[var] += dt * forcing
|
|
845
|
+
|
|
419
846
|
except Exception as exc:
|
|
420
|
-
errors.append(f"
|
|
421
|
-
|
|
847
|
+
errors.append(f"Error {exc}")
|
|
848
|
+
|
|
422
849
|
return errors
|