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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: demathpy
3
- Version: 0.1.0
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
@@ -1,7 +1,7 @@
1
1
 
2
2
  [project]
3
3
  name = "demathpy"
4
- version = "0.1.0"
4
+ version = "0.1.1"
5
5
  authors = [
6
6
  { name="Misekai", email="mcore-us@misekai.net" },
7
7
  ]
@@ -8,16 +8,16 @@ from .pde import (
8
8
  parse_pde,
9
9
  step_pdes,
10
10
  )
11
- from .ode import robust_parse, parse_odes_to_function
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
- "robust_parse",
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