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 CHANGED
@@ -2,32 +2,22 @@
2
2
 
3
3
  from .symbols import normalize_symbols, normalize_lhs
4
4
  from .pde import (
5
+ PDE,
5
6
  normalize_pde,
6
7
  init_grid,
7
- sample_gradient,
8
8
  parse_pde,
9
- evaluate_rhs,
10
- evaluate_scalar,
11
9
  step_pdes,
12
- evaluate_rhs_compiled,
13
- evaluate_scalar_compiled,
14
- step_compiled_pdes,
15
10
  )
16
- from .ode import robust_parse, parse_odes_to_function
11
+ from .ode import ODE, parse_ode
17
12
 
18
13
  __all__ = [
14
+ "PDE",
15
+ "ODE",
19
16
  "normalize_symbols",
20
17
  "normalize_lhs",
21
18
  "normalize_pde",
22
19
  "init_grid",
23
- "sample_gradient",
24
20
  "parse_pde",
25
- "evaluate_rhs",
26
- "evaluate_scalar",
27
21
  "step_pdes",
28
- "evaluate_rhs_compiled",
29
- "evaluate_scalar_compiled",
30
- "step_compiled_pdes",
31
- "robust_parse",
32
- "parse_odes_to_function",
22
+ "parse_ode",
33
23
  ]
demathpy/ode.py CHANGED
@@ -1,131 +1,377 @@
1
- import json
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
+
2
12
  import re
3
- import sympy
13
+ import json
4
14
  import numpy as np
5
- from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application, convert_xor
6
15
 
16
+ from .symbols import normalize_symbols, normalize_lhs
7
17
 
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):
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]:
32
34
  """
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
35
+ Returns (var, order, lhs_coeff_expr, rhs_expr).
36
+ Parses "dy/dt = -y" or "d^2y/dt^2 = -y".
37
37
  """
38
- if not isinstance(expr_str, str):
39
- return sympy.sympify(expr_str)
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)
40
62
 
41
- transformations = (standard_transformations + (implicit_multiplication_application, convert_xor))
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
42
83
 
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)
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"]
78
105
  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
106
+ self.u_shape = ["u"]
83
107
 
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']
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)
89
210
 
90
- for key in keys:
91
- expr_str = odes.get(key, "0")
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
+
92
240
  try:
93
- # Parse the expression safely using robust parser
94
- expr = robust_parse(str(expr_str))
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))
95
264
 
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
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
+
100
294
  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)
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)
109
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
+
110
335
  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
336
+ rhs = self.evaluate_rhs(rhs_expr, env)
337
+ if isinstance(rhs, list):
338
+ rhs = np.array(rhs)
126
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
+
127
357
  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}")
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)
130
375
 
131
- return dynamics
376
+ def __str__(self):
377
+ return self.to_json()