demathpy 0.1.0__tar.gz → 0.1.1__tar.gz
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-0.1.0 → demathpy-0.1.1}/PKG-INFO +48 -1
- {demathpy-0.1.0 → demathpy-0.1.1}/README.md +47 -0
- {demathpy-0.1.0 → demathpy-0.1.1}/pyproject.toml +1 -1
- {demathpy-0.1.0 → demathpy-0.1.1}/src/demathpy/__init__.py +3 -3
- demathpy-0.1.1/src/demathpy/ode.py +377 -0
- {demathpy-0.1.0 → demathpy-0.1.1}/src/demathpy/symbols.py +1 -1
- demathpy-0.1.0/src/demathpy/ode.py +0 -131
- {demathpy-0.1.0 → demathpy-0.1.1}/LICENSE +0 -0
- {demathpy-0.1.0 → demathpy-0.1.1}/src/demathpy/pde.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: demathpy
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: PDE/ODE math backend
|
|
5
5
|
Author: Misekai
|
|
6
6
|
Author-email: Misekai <mcore-us@misekai.net>
|
|
@@ -155,6 +155,53 @@ To integrate `Demathpy` into visualization software or interactive notebooks, yo
|
|
|
155
155
|
# plt.show()
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
### The ODE Class
|
|
159
|
+
|
|
160
|
+
Demathpy also efficiently solves Ordinary Differential Equations (ODEs) where the state depends only on time $t$. The API is identical to the PDE class.
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from demathpy import ODE
|
|
164
|
+
|
|
165
|
+
# 1. EXPONENTIAL DECAY
|
|
166
|
+
# dy/dt = -y
|
|
167
|
+
o = ODE("dy/dt = -y", u_shape=["y"])
|
|
168
|
+
o.initial = ["y = 1.0"]
|
|
169
|
+
o.init_state(shape=(1,)) # scalar system
|
|
170
|
+
o.set_initial_state()
|
|
171
|
+
|
|
172
|
+
for _ in range(100):
|
|
173
|
+
o.step(dt=0.01)
|
|
174
|
+
print(o.u) # Should be close to exp(-1)
|
|
175
|
+
|
|
176
|
+
# 2. VECTOR SYSTEMS (Predator-Prey)
|
|
177
|
+
# du/dt = u - u*v
|
|
178
|
+
# dv/dt = u*v - v
|
|
179
|
+
pp = ODE("du/dt = [u[0] - u[0]*u[1], u[0]*u[1] - u[1]]", u_shape=["u", "v"])
|
|
180
|
+
pp.initial = ["u = 1.1", "v = 1.0"]
|
|
181
|
+
pp.init_state(shape=(1,)) # Single ecosystem
|
|
182
|
+
pp.set_initial_state()
|
|
183
|
+
|
|
184
|
+
pp.step(dt=0.1)
|
|
185
|
+
|
|
186
|
+
# 3. BATCHED EXECUTION
|
|
187
|
+
# Simulating 1000 identical particles with different initial conditions
|
|
188
|
+
particles = ODE("dx/dt = -x + noise") # noise not impl by default but external vars work
|
|
189
|
+
# Or just decay
|
|
190
|
+
batch = ODE("dy/dt = -y")
|
|
191
|
+
batch.init_state(shape=(1000,)) # 1000 systems
|
|
192
|
+
# Set random initial states directly (or use equation if supported)
|
|
193
|
+
import numpy as np
|
|
194
|
+
batch.u[:] = np.random.rand(1000)
|
|
195
|
+
|
|
196
|
+
batch.step(0.1)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Key ODE Features:**
|
|
200
|
+
- **Equation Parsing:** Supports `dy/dt`, `d^2y/dt^2`, vector syntax `[a, b]`.
|
|
201
|
+
- **Initialization:** Use `init_state(shape=...)` where shape defines the batch size (independent systems).
|
|
202
|
+
- **Probing:** `get_grid(u_state=..., dt=0)` works exactly like PDE for generating phase portraits (return vector field at state).
|
|
203
|
+
- **Functions:** Includes `sin, cos, exp, step, heaviside, sign, abs` ...
|
|
204
|
+
|
|
158
205
|
### License
|
|
159
206
|
|
|
160
207
|
MIT
|
|
@@ -141,6 +141,53 @@ To integrate `Demathpy` into visualization software or interactive notebooks, yo
|
|
|
141
141
|
# plt.show()
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
+
### The ODE Class
|
|
145
|
+
|
|
146
|
+
Demathpy also efficiently solves Ordinary Differential Equations (ODEs) where the state depends only on time $t$. The API is identical to the PDE class.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from demathpy import ODE
|
|
150
|
+
|
|
151
|
+
# 1. EXPONENTIAL DECAY
|
|
152
|
+
# dy/dt = -y
|
|
153
|
+
o = ODE("dy/dt = -y", u_shape=["y"])
|
|
154
|
+
o.initial = ["y = 1.0"]
|
|
155
|
+
o.init_state(shape=(1,)) # scalar system
|
|
156
|
+
o.set_initial_state()
|
|
157
|
+
|
|
158
|
+
for _ in range(100):
|
|
159
|
+
o.step(dt=0.01)
|
|
160
|
+
print(o.u) # Should be close to exp(-1)
|
|
161
|
+
|
|
162
|
+
# 2. VECTOR SYSTEMS (Predator-Prey)
|
|
163
|
+
# du/dt = u - u*v
|
|
164
|
+
# dv/dt = u*v - v
|
|
165
|
+
pp = ODE("du/dt = [u[0] - u[0]*u[1], u[0]*u[1] - u[1]]", u_shape=["u", "v"])
|
|
166
|
+
pp.initial = ["u = 1.1", "v = 1.0"]
|
|
167
|
+
pp.init_state(shape=(1,)) # Single ecosystem
|
|
168
|
+
pp.set_initial_state()
|
|
169
|
+
|
|
170
|
+
pp.step(dt=0.1)
|
|
171
|
+
|
|
172
|
+
# 3. BATCHED EXECUTION
|
|
173
|
+
# Simulating 1000 identical particles with different initial conditions
|
|
174
|
+
particles = ODE("dx/dt = -x + noise") # noise not impl by default but external vars work
|
|
175
|
+
# Or just decay
|
|
176
|
+
batch = ODE("dy/dt = -y")
|
|
177
|
+
batch.init_state(shape=(1000,)) # 1000 systems
|
|
178
|
+
# Set random initial states directly (or use equation if supported)
|
|
179
|
+
import numpy as np
|
|
180
|
+
batch.u[:] = np.random.rand(1000)
|
|
181
|
+
|
|
182
|
+
batch.step(0.1)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**Key ODE Features:**
|
|
186
|
+
- **Equation Parsing:** Supports `dy/dt`, `d^2y/dt^2`, vector syntax `[a, b]`.
|
|
187
|
+
- **Initialization:** Use `init_state(shape=...)` where shape defines the batch size (independent systems).
|
|
188
|
+
- **Probing:** `get_grid(u_state=..., dt=0)` works exactly like PDE for generating phase portraits (return vector field at state).
|
|
189
|
+
- **Functions:** Includes `sin, cos, exp, step, heaviside, sign, abs` ...
|
|
190
|
+
|
|
144
191
|
### License
|
|
145
192
|
|
|
146
193
|
MIT
|
|
@@ -8,16 +8,16 @@ from .pde import (
|
|
|
8
8
|
parse_pde,
|
|
9
9
|
step_pdes,
|
|
10
10
|
)
|
|
11
|
-
from .ode import
|
|
11
|
+
from .ode import ODE, parse_ode
|
|
12
12
|
|
|
13
13
|
__all__ = [
|
|
14
14
|
"PDE",
|
|
15
|
+
"ODE",
|
|
15
16
|
"normalize_symbols",
|
|
16
17
|
"normalize_lhs",
|
|
17
18
|
"normalize_pde",
|
|
18
19
|
"init_grid",
|
|
19
20
|
"parse_pde",
|
|
20
21
|
"step_pdes",
|
|
21
|
-
"
|
|
22
|
-
"parse_odes_to_function",
|
|
22
|
+
"parse_ode",
|
|
23
23
|
]
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ordinary Differential Equation (ODE) utilities.
|
|
3
|
+
|
|
4
|
+
Provides a class-based ODE solver similar to the PDE module,
|
|
5
|
+
but for systems dependent only on time (t).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Dict, List, Tuple, Any, Optional
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import json
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .symbols import normalize_symbols, normalize_lhs
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _preprocess_expr(expr: str) -> str:
|
|
20
|
+
expr = (expr or "").strip()
|
|
21
|
+
expr = re.sub(r"\(\s*approx\s*\)", "", expr, flags=re.IGNORECASE)
|
|
22
|
+
expr = re.sub(r"\bapprox\b", "", expr, flags=re.IGNORECASE)
|
|
23
|
+
if "=" in expr:
|
|
24
|
+
expr = expr.split("=")[-1]
|
|
25
|
+
expr = normalize_symbols(expr)
|
|
26
|
+
return expr.strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def normalize_ode(ode: str) -> str:
|
|
30
|
+
return (ode or "").strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_ode(ode: str) -> Tuple[str, int, str, str]:
|
|
34
|
+
"""
|
|
35
|
+
Returns (var, order, lhs_coeff_expr, rhs_expr).
|
|
36
|
+
Parses "dy/dt = -y" or "d^2y/dt^2 = -y".
|
|
37
|
+
"""
|
|
38
|
+
ode = normalize_ode(ode)
|
|
39
|
+
if "=" not in ode:
|
|
40
|
+
# Assume implicit "dy/dt =" ?? No, safer to default to u, 1st order
|
|
41
|
+
return "u", 1, "1", ode
|
|
42
|
+
|
|
43
|
+
lhs, rhs = ode.split("=", 1)
|
|
44
|
+
lhs = normalize_lhs(lhs.strip())
|
|
45
|
+
rhs = rhs.strip()
|
|
46
|
+
|
|
47
|
+
def _extract_coeff(lhs_expr: str, deriv_expr: str) -> str:
|
|
48
|
+
coeff = lhs_expr.replace(deriv_expr, "").strip()
|
|
49
|
+
if not coeff:
|
|
50
|
+
return "1"
|
|
51
|
+
coeff = coeff.strip("*")
|
|
52
|
+
return _preprocess_expr(coeff) or "1"
|
|
53
|
+
|
|
54
|
+
# Updated regex for ODE derivatives: dy/dt, d^2x/dt^2
|
|
55
|
+
pattern = r"(?:∂|d)\s*(?:\^?(\d+))?\s*([a-zA-Z_]\w*)\s*/\s*(?:∂|d)t\s*(?:\^?(\d+))?"
|
|
56
|
+
m = re.search(pattern, lhs)
|
|
57
|
+
|
|
58
|
+
if m:
|
|
59
|
+
ord1 = m.group(1)
|
|
60
|
+
var = m.group(2)
|
|
61
|
+
ord2 = m.group(3)
|
|
62
|
+
|
|
63
|
+
order = 1
|
|
64
|
+
if ord1:
|
|
65
|
+
order = int(ord1)
|
|
66
|
+
elif ord2:
|
|
67
|
+
order = int(ord2)
|
|
68
|
+
|
|
69
|
+
coeff = _extract_coeff(lhs, m.group(0))
|
|
70
|
+
return var, order, coeff, _preprocess_expr(rhs)
|
|
71
|
+
|
|
72
|
+
# Fallback if no dy/dt found on lhs?
|
|
73
|
+
# Maybe user wrote "y' = ..." (not supported by regex yet)
|
|
74
|
+
return "u", 1, "1", _preprocess_expr(rhs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ODE:
|
|
78
|
+
equation: str
|
|
79
|
+
desc: str
|
|
80
|
+
|
|
81
|
+
u: np.ndarray
|
|
82
|
+
u_shape: List[str] # ["x"] or ["x", "y"] for vector systems
|
|
83
|
+
|
|
84
|
+
initial: List[str] # ["x=1", "y=0"]
|
|
85
|
+
|
|
86
|
+
external_variables: Dict[str, float]
|
|
87
|
+
time: float
|
|
88
|
+
|
|
89
|
+
_u_t: np.ndarray | None = None # For 2nd order or momentum
|
|
90
|
+
|
|
91
|
+
def __init__(self, equation: str = "", desc: str = "", u_shape: List[str] = None):
|
|
92
|
+
self.equation = equation
|
|
93
|
+
self.desc = desc
|
|
94
|
+
self.u = np.array([])
|
|
95
|
+
|
|
96
|
+
if u_shape:
|
|
97
|
+
self.u_shape = u_shape
|
|
98
|
+
elif equation:
|
|
99
|
+
# Infer from equation
|
|
100
|
+
var, _, _, _ = parse_ode(equation)
|
|
101
|
+
if var and var != "u":
|
|
102
|
+
self.u_shape = [var]
|
|
103
|
+
else:
|
|
104
|
+
self.u_shape = ["u"]
|
|
105
|
+
else:
|
|
106
|
+
self.u_shape = ["u"]
|
|
107
|
+
|
|
108
|
+
self.initial = []
|
|
109
|
+
self.external_variables = {}
|
|
110
|
+
self.time = 0.0
|
|
111
|
+
self._u_t = None
|
|
112
|
+
|
|
113
|
+
def init_state(self, shape: tuple = (1,)):
|
|
114
|
+
"""
|
|
115
|
+
Initialize the state array y (self.u).
|
|
116
|
+
For a scalar ODE ("du/dt = ..."), shape corresponds to batch size (N independent systems).
|
|
117
|
+
For a vector ODE (u_shape=["x", "v"]), self.u will be (2, N) if shape=(N,).
|
|
118
|
+
"""
|
|
119
|
+
self.time = 0.0
|
|
120
|
+
|
|
121
|
+
if not self.u_shape:
|
|
122
|
+
self.u_shape = ["u"]
|
|
123
|
+
|
|
124
|
+
num_components = len(self.u_shape)
|
|
125
|
+
|
|
126
|
+
# If user passed a single int, wrap it
|
|
127
|
+
if isinstance(shape, int):
|
|
128
|
+
shape = (shape,)
|
|
129
|
+
|
|
130
|
+
if num_components > 1:
|
|
131
|
+
self.u = np.zeros((num_components, *shape), dtype=float)
|
|
132
|
+
else:
|
|
133
|
+
self.u = np.zeros(shape, dtype=float)
|
|
134
|
+
|
|
135
|
+
self._u_t = np.zeros_like(self.u)
|
|
136
|
+
|
|
137
|
+
def set_initial_state(self):
|
|
138
|
+
"""
|
|
139
|
+
Parses `self.initial` and applies to `self.u`.
|
|
140
|
+
"""
|
|
141
|
+
env = self._build_eval_env()
|
|
142
|
+
env["t"] = 0.0
|
|
143
|
+
|
|
144
|
+
if not self.initial:
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
for ic_eqn in self.initial:
|
|
148
|
+
if "=" not in ic_eqn: continue
|
|
149
|
+
lhs, rhs = ic_eqn.split("=", 1)
|
|
150
|
+
lhs = lhs.strip()
|
|
151
|
+
rhs_expr = rhs.strip()
|
|
152
|
+
|
|
153
|
+
# Determine target component
|
|
154
|
+
target_idx = None
|
|
155
|
+
target_name = "u"
|
|
156
|
+
|
|
157
|
+
m = re.match(r"^([a-zA-Z_]\w*)", lhs)
|
|
158
|
+
if m:
|
|
159
|
+
target_name = m.group(1)
|
|
160
|
+
|
|
161
|
+
if target_name == "u" and len(self.u_shape) == 1:
|
|
162
|
+
target_idx = None
|
|
163
|
+
elif target_name in self.u_shape:
|
|
164
|
+
target_idx = self.u_shape.index(target_name)
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
rhs_expr = _preprocess_expr(rhs_expr)
|
|
168
|
+
val = eval(rhs_expr, {}, env)
|
|
169
|
+
|
|
170
|
+
if target_idx is None:
|
|
171
|
+
if np.shape(val) == np.shape(self.u):
|
|
172
|
+
self.u[:] = val
|
|
173
|
+
else:
|
|
174
|
+
self.u[:] = val # Broadcast
|
|
175
|
+
elif len(self.u_shape) == 1 and target_idx == 0:
|
|
176
|
+
self.u[:] = val
|
|
177
|
+
else:
|
|
178
|
+
if target_idx < len(self.u):
|
|
179
|
+
self.u[target_idx][:] = val
|
|
180
|
+
except Exception as e:
|
|
181
|
+
print(f"Failed to set IC '{ic_eqn}': {e}")
|
|
182
|
+
|
|
183
|
+
def _build_eval_env(self) -> Dict[str, object]:
|
|
184
|
+
|
|
185
|
+
def pos(u): return np.maximum(u, 0.0)
|
|
186
|
+
def sign(u): return np.sign(u)
|
|
187
|
+
def step_fn(u): return np.heaviside(u, 1.0)
|
|
188
|
+
|
|
189
|
+
env = {
|
|
190
|
+
"np": np,
|
|
191
|
+
"sin": np.sin, "cos": np.cos, "tan": np.tan,
|
|
192
|
+
"sinh": np.sinh, "cosh": np.cosh, "tanh": np.tanh,
|
|
193
|
+
"arcsin": np.arcsin, "arccos": np.arccos, "arctan": np.arctan,
|
|
194
|
+
"log": np.log, "log10": np.log10, "log2": np.log2,
|
|
195
|
+
"exp": np.exp, "sqrt": np.sqrt, "abs": np.abs,
|
|
196
|
+
"pi": np.pi, "inf": np.inf,
|
|
197
|
+
"pos": pos, "sign": sign, "step": step_fn
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
env.update(self.external_variables)
|
|
201
|
+
|
|
202
|
+
if "t" not in env:
|
|
203
|
+
env["t"] = self.time
|
|
204
|
+
|
|
205
|
+
return env
|
|
206
|
+
|
|
207
|
+
def evaluate_rhs(self, rhs_expr: str, env: Dict[str, Any]) -> np.ndarray:
|
|
208
|
+
rhs_expr = _preprocess_expr(rhs_expr)
|
|
209
|
+
return eval(rhs_expr, {}, env)
|
|
210
|
+
|
|
211
|
+
def evaluate_scalar(self, expr: str, env: Dict[str, Any]) -> float:
|
|
212
|
+
if expr in ("", "1"): return 1.0
|
|
213
|
+
val = self.evaluate_rhs(expr, env)
|
|
214
|
+
if np.isscalar(val): return float(val)
|
|
215
|
+
return float(np.mean(val))
|
|
216
|
+
|
|
217
|
+
def step(self, dt: float):
|
|
218
|
+
var_name, order, coeff_expr, rhs_expr = parse_ode(self.equation)
|
|
219
|
+
|
|
220
|
+
self.time += dt
|
|
221
|
+
env = self._build_eval_env()
|
|
222
|
+
env["t"] = self.time
|
|
223
|
+
|
|
224
|
+
# Inject state
|
|
225
|
+
if len(self.u_shape) == 1:
|
|
226
|
+
name = self.u_shape[0]
|
|
227
|
+
env[name] = self.u
|
|
228
|
+
v_t = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
229
|
+
env[f"{name}_t"] = v_t
|
|
230
|
+
else:
|
|
231
|
+
for i, name in enumerate(self.u_shape):
|
|
232
|
+
env[name] = self.u[i]
|
|
233
|
+
v_t = self._u_t[i] if self._u_t is not None else np.zeros_like(self.u[i])
|
|
234
|
+
env[f"{name}_t"] = v_t
|
|
235
|
+
# Also inject 'u' as general access if needed, or maybe not to avoid confusion?
|
|
236
|
+
# PDE does env["u"] = self.u.
|
|
237
|
+
env["u"] = self.u
|
|
238
|
+
env["u_t"] = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
rhs = self.evaluate_rhs(rhs_expr, env)
|
|
242
|
+
if isinstance(rhs, list):
|
|
243
|
+
rhs = np.array(rhs)
|
|
244
|
+
|
|
245
|
+
coeff = self.evaluate_scalar(coeff_expr, env)
|
|
246
|
+
forcing = rhs / (coeff if coeff else 1.0)
|
|
247
|
+
|
|
248
|
+
# Ensure shape compatibility if forcing is lower dim (e.g. constant vector [1,2] vs shape (2, N))
|
|
249
|
+
if isinstance(forcing, np.ndarray) and forcing.ndim < self.u.ndim:
|
|
250
|
+
diff = self.u.ndim - forcing.ndim
|
|
251
|
+
# Assume alignment on first dimension (components)
|
|
252
|
+
# Expand trailing dims
|
|
253
|
+
new_shape = forcing.shape + (1,) * diff
|
|
254
|
+
forcing = forcing.reshape(new_shape)
|
|
255
|
+
|
|
256
|
+
target_indices = []
|
|
257
|
+
if var_name == "u" and len(self.u_shape) == 1:
|
|
258
|
+
target_indices = [None]
|
|
259
|
+
elif var_name in self.u_shape:
|
|
260
|
+
idx = self.u_shape.index(var_name)
|
|
261
|
+
target_indices = [idx]
|
|
262
|
+
elif var_name == "u" and len(self.u_shape) > 1:
|
|
263
|
+
target_indices = range(len(self.u_shape))
|
|
264
|
+
|
|
265
|
+
# 2nd Order Euler / Semi-Implicit
|
|
266
|
+
if order == 2:
|
|
267
|
+
if self._u_t is None: self._u_t = np.zeros_like(self.u)
|
|
268
|
+
|
|
269
|
+
if var_name == "u" and len(self.u_shape) > 1:
|
|
270
|
+
self._u_t += dt * forcing
|
|
271
|
+
self.u += dt * self._u_t
|
|
272
|
+
elif len(target_indices) == 1:
|
|
273
|
+
idx = target_indices[0]
|
|
274
|
+
# Handle flat u for single component
|
|
275
|
+
is_flat = (len(self.u_shape) == 1)
|
|
276
|
+
|
|
277
|
+
target_u = self.u if (idx is None or is_flat) else self.u[idx]
|
|
278
|
+
target_ut = self._u_t if (idx is None or is_flat) else self._u_t[idx]
|
|
279
|
+
|
|
280
|
+
target_ut[:] += dt * forcing
|
|
281
|
+
target_u[:] += dt * target_ut
|
|
282
|
+
|
|
283
|
+
else:
|
|
284
|
+
# 1st Order Euler
|
|
285
|
+
if var_name == "u" and len(self.u_shape) > 1:
|
|
286
|
+
self.u += dt * forcing
|
|
287
|
+
elif len(target_indices) == 1:
|
|
288
|
+
idx = target_indices[0]
|
|
289
|
+
if idx is None or (len(self.u_shape) == 1):
|
|
290
|
+
self.u[:] += dt * forcing
|
|
291
|
+
else:
|
|
292
|
+
self.u[idx] += dt * forcing
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
print(f"Error stepping ODE: {e}")
|
|
296
|
+
raise e
|
|
297
|
+
|
|
298
|
+
def get_grid(self, u_state: np.ndarray = None, dt: float = 0.0) -> np.ndarray:
|
|
299
|
+
"""
|
|
300
|
+
Calculates the change (du) or rate of change (forcing/dydt) for the current state
|
|
301
|
+
without modifying the internal state.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
u_state: Optional state to substitute self.u
|
|
305
|
+
dt: if > 0, returns delta. If 0, returns rate.
|
|
306
|
+
"""
|
|
307
|
+
original_u = self.u
|
|
308
|
+
original_u_t = self._u_t
|
|
309
|
+
|
|
310
|
+
if u_state is not None:
|
|
311
|
+
self.u = u_state
|
|
312
|
+
if self._u_t is not None and self._u_t.shape != u_state.shape:
|
|
313
|
+
self._u_t = np.zeros_like(u_state)
|
|
314
|
+
elif self._u_t is None:
|
|
315
|
+
self._u_t = np.zeros_like(u_state)
|
|
316
|
+
|
|
317
|
+
var_name, order, coeff_expr, rhs_expr = parse_ode(self.equation)
|
|
318
|
+
|
|
319
|
+
env = self._build_eval_env()
|
|
320
|
+
env["t"] = self.time
|
|
321
|
+
|
|
322
|
+
if len(self.u_shape) == 1:
|
|
323
|
+
name = self.u_shape[0]
|
|
324
|
+
env[name] = self.u
|
|
325
|
+
v_t = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
326
|
+
env[f"{name}_t"] = v_t
|
|
327
|
+
else:
|
|
328
|
+
for i, name in enumerate(self.u_shape):
|
|
329
|
+
env[name] = self.u[i]
|
|
330
|
+
v_t = self._u_t[i] if self._u_t is not None else np.zeros_like(self.u[i])
|
|
331
|
+
env[f"{name}_t"] = v_t
|
|
332
|
+
env["u"] = self.u
|
|
333
|
+
env["u_t"] = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
rhs = self.evaluate_rhs(rhs_expr, env)
|
|
337
|
+
if isinstance(rhs, list):
|
|
338
|
+
rhs = np.array(rhs)
|
|
339
|
+
|
|
340
|
+
coeff = self.evaluate_scalar(coeff_expr, env)
|
|
341
|
+
forcing = rhs / (coeff if coeff else 1.0)
|
|
342
|
+
|
|
343
|
+
if order == 2:
|
|
344
|
+
# 2nd Order
|
|
345
|
+
if dt > 0:
|
|
346
|
+
v = self._u_t if self._u_t is not None else np.zeros_like(self.u)
|
|
347
|
+
du = dt * v + 0.5 * (dt**2) * forcing
|
|
348
|
+
else:
|
|
349
|
+
du = forcing
|
|
350
|
+
else:
|
|
351
|
+
# 1st Order
|
|
352
|
+
if dt > 0:
|
|
353
|
+
du = forcing * dt
|
|
354
|
+
else:
|
|
355
|
+
du = forcing
|
|
356
|
+
|
|
357
|
+
except Exception as e:
|
|
358
|
+
self.u = original_u
|
|
359
|
+
self._u_t = original_u_t
|
|
360
|
+
raise e
|
|
361
|
+
|
|
362
|
+
self.u = original_u
|
|
363
|
+
self._u_t = original_u_t
|
|
364
|
+
return du
|
|
365
|
+
|
|
366
|
+
def to_json(self) -> str:
|
|
367
|
+
return json.dumps({
|
|
368
|
+
"equation": self.equation,
|
|
369
|
+
"desc": self.desc,
|
|
370
|
+
"u_shape": self.u_shape,
|
|
371
|
+
"initial": self.initial,
|
|
372
|
+
"variables": self.external_variables,
|
|
373
|
+
"time": self.time
|
|
374
|
+
}, indent=2)
|
|
375
|
+
|
|
376
|
+
def __str__(self):
|
|
377
|
+
return self.to_json()
|
|
@@ -393,7 +393,7 @@ def normalize_symbols(expr: str) -> str:
|
|
|
393
393
|
"arcsin", "arccos", "arctan",
|
|
394
394
|
"exp", "sqrt",
|
|
395
395
|
"log", "log10", "log2",
|
|
396
|
-
"abs", "sech", "sign",
|
|
396
|
+
"abs", "sech", "sign", "step", "heaviside",
|
|
397
397
|
"lap", "dx", "dz", "dxx", "dzz", "grad", "div", "advect", "gradmag", "gradl1", "pos",
|
|
398
398
|
}:
|
|
399
399
|
return f"{name}("
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import re
|
|
3
|
-
import sympy
|
|
4
|
-
import numpy as np
|
|
5
|
-
from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application, convert_xor
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def _convert_ternary(expr: str) -> str:
|
|
9
|
-
"""
|
|
10
|
-
Convert a single C-style ternary (cond ? a : b) into SymPy Piecewise.
|
|
11
|
-
Supports one level (no nesting).
|
|
12
|
-
"""
|
|
13
|
-
if "?" not in expr or ":" not in expr:
|
|
14
|
-
return expr
|
|
15
|
-
|
|
16
|
-
# naive split for single ternary
|
|
17
|
-
# pattern: <cond> ? <a> : <b>
|
|
18
|
-
parts = expr.split("?")
|
|
19
|
-
if len(parts) != 2:
|
|
20
|
-
return expr
|
|
21
|
-
cond = parts[0].strip()
|
|
22
|
-
rest = parts[1]
|
|
23
|
-
if ":" not in rest:
|
|
24
|
-
return expr
|
|
25
|
-
a, b = rest.split(":", 1)
|
|
26
|
-
a = a.strip()
|
|
27
|
-
b = b.strip()
|
|
28
|
-
return f"Piecewise(({a}, {cond}), ({b}, True))"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def robust_parse(expr_str):
|
|
32
|
-
"""
|
|
33
|
-
Parses a string into a SymPy expression with relaxed syntax rules:
|
|
34
|
-
- Implicit multiplication (5x -> 5*x)
|
|
35
|
-
- Caret for power (x^2 -> x**2)
|
|
36
|
-
- Aliases 'y' to 'z' for 2D convenience
|
|
37
|
-
"""
|
|
38
|
-
if not isinstance(expr_str, str):
|
|
39
|
-
return sympy.sympify(expr_str)
|
|
40
|
-
|
|
41
|
-
transformations = (standard_transformations + (implicit_multiplication_application, convert_xor))
|
|
42
|
-
|
|
43
|
-
# Define symbols and alias y -> z
|
|
44
|
-
x, z, vx, vz, t, pid = sympy.symbols('x z vx vz t id')
|
|
45
|
-
local_dict = {
|
|
46
|
-
'x': x, 'z': z, 'y': z, 'vx': vx, 'vz': vz, 't': t, 'id': pid,
|
|
47
|
-
'pi': sympy.pi, 'e': sympy.E
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
# Ensure common functions are recognized (Abs not abs)
|
|
51
|
-
local_dict.update({
|
|
52
|
-
'sin': sympy.sin,
|
|
53
|
-
'cos': sympy.cos,
|
|
54
|
-
'tan': sympy.tan,
|
|
55
|
-
'exp': sympy.exp,
|
|
56
|
-
'sqrt': sympy.sqrt,
|
|
57
|
-
'log': sympy.log,
|
|
58
|
-
'abs': sympy.Abs,
|
|
59
|
-
'Abs': sympy.Abs,
|
|
60
|
-
'Piecewise': sympy.Piecewise,
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
try:
|
|
64
|
-
pre = _convert_ternary(expr_str)
|
|
65
|
-
return parse_expr(pre, transformations=transformations, local_dict=local_dict)
|
|
66
|
-
except Exception:
|
|
67
|
-
# Fallback
|
|
68
|
-
return sympy.sympify(expr_str, locals=local_dict)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def parse_odes_to_function(ode_json_str):
|
|
72
|
-
"""
|
|
73
|
-
Parses a JSON string of ODEs and returns a dynamic update function.
|
|
74
|
-
"""
|
|
75
|
-
try:
|
|
76
|
-
if isinstance(ode_json_str, str):
|
|
77
|
-
odes = json.loads(ode_json_str)
|
|
78
|
-
else:
|
|
79
|
-
odes = ode_json_str
|
|
80
|
-
except json.JSONDecodeError as e:
|
|
81
|
-
print(f"Failed to decode JSON from LLM: {e}")
|
|
82
|
-
return None
|
|
83
|
-
|
|
84
|
-
# Define standard symbols
|
|
85
|
-
x, z, vx, vz, t = sympy.symbols('x z vx vz t')
|
|
86
|
-
|
|
87
|
-
deriv_map = {}
|
|
88
|
-
keys = ['dx', 'dz', 'dvx', 'dvz']
|
|
89
|
-
|
|
90
|
-
for key in keys:
|
|
91
|
-
expr_str = odes.get(key, "0")
|
|
92
|
-
try:
|
|
93
|
-
# Parse the expression safely using robust parser
|
|
94
|
-
expr = robust_parse(str(expr_str))
|
|
95
|
-
|
|
96
|
-
# Create a localized function
|
|
97
|
-
# Arguments match the order we will call them
|
|
98
|
-
func = sympy.lambdify((x, z, vx, vz, t), expr, modules=['numpy', 'math'])
|
|
99
|
-
deriv_map[key] = func
|
|
100
|
-
except Exception as e:
|
|
101
|
-
print(f"Error parsing expression for {key}: {e}")
|
|
102
|
-
return None
|
|
103
|
-
|
|
104
|
-
def dynamics(particle, dt):
|
|
105
|
-
# Current state
|
|
106
|
-
cx, cz, cvx, cvz = particle.x, particle.z, particle.vx, particle.vz
|
|
107
|
-
# We assume particle might track time, or we just pass 0 if autonomous
|
|
108
|
-
ct = getattr(particle, 'time', 0.0)
|
|
109
|
-
|
|
110
|
-
try:
|
|
111
|
-
# Calculate derivatives
|
|
112
|
-
val_dx = deriv_map['dx'](cx, cz, cvx, cvz, ct)
|
|
113
|
-
val_dz = deriv_map['dz'](cx, cz, cvx, cvz, ct)
|
|
114
|
-
val_dvx = deriv_map['dvx'](cx, cz, cvx, cvz, ct)
|
|
115
|
-
val_dvz = deriv_map['dvz'](cx, cz, cvx, cvz, ct)
|
|
116
|
-
|
|
117
|
-
# Simple Euler Integration
|
|
118
|
-
particle.x += float(val_dx) * dt
|
|
119
|
-
particle.z += float(val_dz) * dt
|
|
120
|
-
particle.vx += float(val_dvx) * dt
|
|
121
|
-
particle.vz += float(val_dvz) * dt
|
|
122
|
-
|
|
123
|
-
# Update time if tracked
|
|
124
|
-
if hasattr(particle, 'time'):
|
|
125
|
-
particle.time += dt
|
|
126
|
-
|
|
127
|
-
except Exception as e:
|
|
128
|
-
# Prevent crashing the renderer on math errors (e.g. div by zero)
|
|
129
|
-
print(f"Runtime error in dynamics: {e}")
|
|
130
|
-
|
|
131
|
-
return dynamics
|
|
File without changes
|
|
File without changes
|