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/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
- # Regex for derivative term
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
- def evolve_pde(grid: np.ndarray, dt: float, constants: dict) -> np.ndarray:
108
- """
109
- Generic PDE evolution step. Requires constants to define dynamics.
110
- If no constants are provided, returns the grid unchanged.
72
+ class PDE:
73
+ equation: str
74
+ desc: str
75
+ mode: str
111
76
 
112
- Expected constants:
113
- - alpha: diffusion coefficient (optional)
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
- if not vars_grid:
274
- return np.zeros((2, 2), dtype=float)
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
- try:
277
- return eval(rhs_expr, {}, env)
278
- except Exception as exc:
279
- raise RuntimeError(f"PDE RHS eval failed for '{rhs_expr}': {exc}")
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
- if not vars_grid:
305
- return np.zeros((2, 2), dtype=float)
93
+ def __init__(self, equation: str = "", desc: str = "", space_axis: List[str] = None):
94
+ self.equation = equation
95
+ self.desc = desc
306
96
 
307
- try:
308
- return eval(rhs_expr, {}, env)
309
- except Exception as exc:
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
- def step_pdes(
340
- field_pdes: List[str],
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
- vars_grid = dict(grids)
353
- if external_grids:
354
- vars_grid.update(external_grids)
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
- for pde in field_pdes:
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
- var, order, coeff_expr, rhs_expr = parse_pde(pde)
359
- if var not in grids:
360
- grids[var] = np.zeros_like(next(iter(grids.values())))
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
- rhs = evaluate_rhs(rhs_expr, vars_grid, constants, grid_dx)
363
- coeff = evaluate_scalar(coeff_expr, vars_grid, constants, grid_dx)
364
- rhs = rhs / float(coeff or 1.0)
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
- if order == 2:
367
- vname = f"{var}_t"
368
- if vname not in grids:
369
- grids[vname] = np.zeros_like(grids[var])
370
- grids[vname] = grids[vname] + dt * rhs
371
- grids[var] = grids[var] + dt * grids[vname]
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
- grids[var] = grids[var] + dt * rhs
374
- except Exception as exc:
375
- errors.append(f"PDE eval failed for '{pde}': {exc}")
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
- return errors
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
- def step_compiled_pdes(
381
- compiled_pdes: List[Dict[str, object]],
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
- """Advance already-compiled PDEs. Returns list of error strings."""
389
- errors: List[str] = []
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
- vars_grid.update(external_grids)
396
-
397
- for entry in compiled_pdes:
816
+ full_grids.update(external_grids)
817
+
818
+ for i, eq in enumerate(field_pdes):
398
819
  try:
399
- var = entry.get("var", "u")
400
- order = int(entry.get("order", 1))
401
- coeff_expr = str(entry.get("coeff_expr", "1"))
402
- rhs_expr = str(entry.get("rhs_expr", "0"))
403
-
404
- if var not in grids:
405
- grids[var] = np.zeros_like(next(iter(grids.values())))
406
-
407
- rhs = evaluate_rhs_compiled(rhs_expr, vars_grid, constants, grid_dx)
408
- coeff = evaluate_scalar_compiled(coeff_expr, vars_grid, constants, grid_dx)
409
- rhs = rhs / float(coeff or 1.0)
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
- vname = f"{var}_t"
413
- if vname not in grids:
414
- grids[vname] = np.zeros_like(grids[var])
415
- grids[vname] = grids[vname] + dt * rhs
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
- grids[var] = grids[var] + dt * rhs
844
+ grids[var] += dt * forcing
845
+
419
846
  except Exception as exc:
420
- errors.append(f"PDE eval failed for '{entry}': {exc}")
421
-
847
+ errors.append(f"Error {exc}")
848
+
422
849
  return errors