mathmethods-mcp 0.1.0__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.
@@ -0,0 +1,567 @@
1
+ import math
2
+ import re
3
+ from typing import Any, Callable, Dict, List, Tuple
4
+
5
+ import numpy as np
6
+ import sympy as sp
7
+ from scipy.integrate import solve_ivp
8
+ from scipy.optimize import brentq
9
+ from sympy.core.relational import Relational
10
+ from sympy.parsing.sympy_parser import (
11
+ parse_expr,
12
+ standard_transformations,
13
+ implicit_multiplication_application,
14
+ )
15
+
16
+ from .utils import numerical_derivative, safe_parse_expr
17
+
18
+
19
+ class Dynamic1DService:
20
+ @staticmethod
21
+ def _sanitize_expr(expr: str) -> str:
22
+ cleaned = expr.replace('sen', 'sin')
23
+ cleaned = re.sub(r'(?<![A-Za-z0-9_])[eE]\^', 'E**', cleaned).replace('^', '**')
24
+ return cleaned
25
+
26
+ @staticmethod
27
+ def _parse_expression(expr_str: str, params: Dict[str, float], allowed_symbols: List[str]) -> sp.Expr:
28
+ expr_str = Dynamic1DService._sanitize_expr(expr_str)
29
+ x = sp.Symbol('x', real=True)
30
+
31
+ local_dict: Dict[str, Any] = {
32
+ 'x': x,
33
+ 'e': sp.E,
34
+ 'pi': sp.pi,
35
+ }
36
+
37
+ for key in params.keys():
38
+ local_dict[key] = sp.Symbol(key, real=True)
39
+
40
+ for sym in allowed_symbols:
41
+ if sym not in local_dict:
42
+ local_dict[sym] = sp.Symbol(sym, real=True)
43
+
44
+ gate_allowed = list(dict.fromkeys(list(allowed_symbols) + list(params.keys())))
45
+ expr = safe_parse_expr(
46
+ expr_str,
47
+ local_dict=local_dict,
48
+ allowed_symbols=gate_allowed,
49
+ transformations=(standard_transformations + (implicit_multiplication_application,)),
50
+ )
51
+
52
+ free_symbols = {str(sym) for sym in expr.free_symbols}
53
+ allowed = set(allowed_symbols)
54
+ if not free_symbols.issubset(allowed):
55
+ missing = ', '.join(sorted(free_symbols - allowed))
56
+ raise ValueError(f"Faltan parametros: {missing}")
57
+
58
+ return expr
59
+
60
+ @staticmethod
61
+ def _expr_to_str(expr: sp.Expr) -> str:
62
+ return str(expr).replace('**', '^')
63
+
64
+ @staticmethod
65
+ def _format_inequality_result(result: Any) -> str | None:
66
+ if result is None:
67
+ return None
68
+ if result is True:
69
+ return 'siempre'
70
+ if result is False:
71
+ return 'nunca'
72
+ try:
73
+ return Dynamic1DService._expr_to_str(result)
74
+ except Exception:
75
+ return str(result)
76
+
77
+ @staticmethod
78
+ def _extract_real_conditions(expr: sp.Expr) -> List[Relational]:
79
+ conditions: List[sp.Relational] = []
80
+ for pow_expr in expr.atoms(sp.Pow):
81
+ exp = pow_expr.exp
82
+ if exp.is_Rational and exp.q % 2 == 0:
83
+ conditions.append(sp.Ge(sp.simplify(pow_expr.base), 0))
84
+ for log_expr in expr.atoms(sp.log):
85
+ if log_expr.args:
86
+ arg = sp.simplify(log_expr.args[0])
87
+ if arg.has(sp.Abs):
88
+ conditions.append(sp.Ne(arg, 0))
89
+ else:
90
+ conditions.append(sp.Gt(arg, 0))
91
+ return conditions
92
+
93
+ @staticmethod
94
+ def _exact_bifurcation_analysis(expr: sp.Expr, bif_param: str) -> Dict[str, Any]:
95
+ x = sp.Symbol('x', real=True)
96
+ p = sp.Symbol(bif_param, real=True)
97
+
98
+ expr_simplified = sp.simplify(expr)
99
+ deriv_expr = sp.diff(expr_simplified, x)
100
+
101
+ def extract_finite_roots(roots_set: sp.Set) -> List[sp.Expr] | None:
102
+ if isinstance(roots_set, sp.FiniteSet):
103
+ return list(roots_set)
104
+ if isinstance(roots_set, sp.Intersection):
105
+ for arg in roots_set.args:
106
+ extracted = extract_finite_roots(arg)
107
+ if extracted is not None:
108
+ return extracted
109
+ return None
110
+ if isinstance(roots_set, sp.Union):
111
+ combined: List[sp.Expr] = []
112
+ for arg in roots_set.args:
113
+ extracted = extract_finite_roots(arg)
114
+ if extracted is None:
115
+ return None
116
+ combined.extend(extracted)
117
+ return combined
118
+ return None
119
+
120
+ try:
121
+ roots_set = sp.solveset(sp.Eq(expr_simplified, 0), x, domain=sp.S.Reals)
122
+ roots = extract_finite_roots(roots_set)
123
+ if roots is None:
124
+ raise ValueError('Soluciones no finitas o no reales')
125
+ except Exception:
126
+ roots = []
127
+
128
+ if any(root.has(sp.LambertW) for root in roots):
129
+ raise ValueError('Raices con LambertW no soportadas')
130
+
131
+ if any(root.has(sp.I) for root in roots):
132
+ raise ValueError('Raices complejas (I) no soportadas para renderizado')
133
+
134
+ unique_roots: List[sp.Expr] = []
135
+ for root in roots:
136
+ if root not in unique_roots:
137
+ unique_roots.append(root)
138
+
139
+ root_entries: List[Dict[str, str]] = []
140
+ stability_entries: List[Dict[str, str]] = []
141
+ existence_conditions: List[sp.Expr] = []
142
+ has_always_real = False
143
+
144
+ for root in unique_roots:
145
+ root_str = Dynamic1DService._expr_to_str(root)
146
+ deriv_at_root = sp.simplify(deriv_expr.subs(x, root))
147
+
148
+ conditions = Dynamic1DService._extract_real_conditions(root)
149
+ if not conditions:
150
+ root_exists = 'siempre'
151
+ if root.is_real is True:
152
+ has_always_real = True
153
+ else:
154
+ if not root.has(sp.I):
155
+ has_always_real = True
156
+ else:
157
+ if len(conditions) == 1:
158
+ existence_conditions.append(conditions[0])
159
+ root_exists = Dynamic1DService._expr_to_str(conditions[0])
160
+ else:
161
+ merged = sp.And(*conditions)
162
+ existence_conditions.append(merged)
163
+ root_exists = Dynamic1DService._expr_to_str(merged)
164
+
165
+ root_entries.append({
166
+ 'root': root_str,
167
+ 'existence': root_exists,
168
+ })
169
+
170
+ stable_cond = None
171
+ unstable_cond = None
172
+ try:
173
+ stable_cond = sp.solve_univariate_inequality(deriv_at_root < 0, p, relational=True)
174
+ except Exception:
175
+ stable_cond = None
176
+ try:
177
+ unstable_cond = sp.solve_univariate_inequality(deriv_at_root > 0, p, relational=True)
178
+ except Exception:
179
+ unstable_cond = None
180
+
181
+ stability_entries.append({
182
+ 'root': root_str,
183
+ 'derivative': Dynamic1DService._expr_to_str(deriv_at_root),
184
+ 'stable_when': Dynamic1DService._format_inequality_result(stable_cond),
185
+ 'unstable_when': Dynamic1DService._format_inequality_result(unstable_cond),
186
+ })
187
+
188
+ existence_expr = None
189
+ if has_always_real:
190
+ existence_expr = 'siempre'
191
+ elif existence_conditions:
192
+ existence_expr = Dynamic1DService._expr_to_str(sp.simplify(sp.Or(*existence_conditions)))
193
+
194
+ return {
195
+ 'existence': existence_expr,
196
+ 'roots': root_entries,
197
+ 'stability': stability_entries,
198
+ 'derivative': Dynamic1DService._expr_to_str(deriv_expr),
199
+ }
200
+
201
+ @staticmethod
202
+ def _build_expr(model: str, func_str: str, params: Dict[str, float], control_enabled: bool) -> Tuple[str, Dict[str, float]]:
203
+ if model == 'malthus':
204
+ base = 'r*x'
205
+ elif model == 'verhulst':
206
+ base = 'mu*x*(1 - x/K)'
207
+ elif model == 'newton':
208
+ base = '-k*(x - Ta)'
209
+ else:
210
+ base = func_str
211
+
212
+ if control_enabled and model in {'verhulst', 'custom'}:
213
+ base = f'({base}) - h'
214
+
215
+ return base, params
216
+
217
+ @staticmethod
218
+ def _compile_function(expr_str: str, params: Dict[str, float], allowed_symbols: List[str] = None) -> Tuple[Callable, str]:
219
+ allowed = allowed_symbols or ['x']
220
+ if params:
221
+ allowed = list(dict.fromkeys(allowed + list(params.keys())))
222
+ expr = Dynamic1DService._parse_expression(expr_str, params, allowed)
223
+
224
+ if params:
225
+ subs_map = {sp.Symbol(key, real=True): value for key, value in params.items()}
226
+ expr = expr.subs(subs_map)
227
+
228
+ free_symbols = {str(sym) for sym in expr.free_symbols}
229
+ if free_symbols and free_symbols != {'x'}:
230
+ missing = ', '.join(sorted(free_symbols - {'x'}))
231
+ raise ValueError(f"Faltan parametros: {missing}")
232
+
233
+ x = sp.Symbol('x')
234
+ func = sp.lambdify(x, expr, 'numpy')
235
+ return func, str(expr)
236
+
237
+ @staticmethod
238
+ def _safe_eval(f: Callable, x: np.ndarray) -> np.ndarray:
239
+ with np.errstate(all='ignore'):
240
+ y = f(x)
241
+ y = np.asarray(y, dtype=float)
242
+ if y.ndim == 0:
243
+ # Función constante (p. ej. f(x) ≡ 0): el lambdify devuelve escalar.
244
+ y = np.full(np.shape(x), float(y), dtype=float)
245
+ y = y.copy()
246
+ y[~np.isfinite(y)] = np.nan
247
+ return y
248
+
249
+ @staticmethod
250
+ def _unique_sorted(values: List[float], tol: float = 1e-5) -> List[float]:
251
+ if not values:
252
+ return []
253
+ values_sorted = sorted(values)
254
+ unique_vals = [values_sorted[0]]
255
+ for val in values_sorted[1:]:
256
+ if abs(val - unique_vals[-1]) > tol:
257
+ unique_vals.append(val)
258
+ return unique_vals
259
+
260
+ @staticmethod
261
+ def find_equilibria(f: Callable, x_min: float, x_max: float, n: int = 400, tol: float = 1e-6) -> List[float]:
262
+ xs = np.linspace(x_min, x_max, n)
263
+ ys = Dynamic1DService._safe_eval(f, xs)
264
+
265
+ roots: List[float] = []
266
+ for i in range(len(xs) - 1):
267
+ y0, y1 = ys[i], ys[i + 1]
268
+ if np.isnan(y0) or np.isnan(y1):
269
+ continue
270
+
271
+ if abs(y0) < tol:
272
+ roots.append(float(xs[i]))
273
+ if y0 * y1 < 0:
274
+ try:
275
+ root = brentq(lambda z: float(f(z)), xs[i], xs[i + 1])
276
+ roots.append(float(root))
277
+ except Exception:
278
+ continue
279
+ if abs(y1) < tol:
280
+ roots.append(float(xs[i + 1]))
281
+
282
+ return Dynamic1DService._unique_sorted(roots, tol=1e-4)
283
+
284
+ @staticmethod
285
+ def classify_equilibria(f: Callable, roots: List[float]) -> List[Dict[str, Any]]:
286
+ results = []
287
+ df_tol = 1e-4
288
+ f_tol = 1e-6
289
+ for root in roots:
290
+ try:
291
+ df = float(numerical_derivative(f, root, order=1, h=1e-5))
292
+ except Exception:
293
+ df = float('nan')
294
+
295
+ stability = 'indeterminado'
296
+ reason = 'sin datos suficientes'
297
+ if math.isfinite(df) and abs(df) > df_tol:
298
+ if df < 0:
299
+ stability = 'estable'
300
+ reason = f"f'(x*) = {df:.6g} < 0"
301
+ else:
302
+ stability = 'inestable'
303
+ reason = f"f'(x*) = {df:.6g} > 0"
304
+ else:
305
+ delta = max(1e-4, abs(root) * 1e-3)
306
+ try:
307
+ left = float(f(root - delta))
308
+ right = float(f(root + delta))
309
+ if abs(left) < f_tol:
310
+ left = 0.0
311
+ if abs(right) < f_tol:
312
+ right = 0.0
313
+ if left > 0 and right < 0:
314
+ stability = 'estable'
315
+ reason = f"f(x*-d)>0 y f(x*+d)<0, d={delta:g}"
316
+ elif left < 0 and right > 0:
317
+ stability = 'inestable'
318
+ reason = f"f(x*-d)<0 y f(x*+d)>0, d={delta:g}"
319
+ elif left == 0.0 and right == 0.0:
320
+ stability = 'indeterminado'
321
+ reason = f"f(x*±d)≈0, d={delta:g}"
322
+ elif math.isfinite(left) and math.isfinite(right):
323
+ stability = 'semiestable'
324
+ reason = f"f(x*-d) y f(x*+d) no cambian de signo, d={delta:g}"
325
+ except Exception:
326
+ stability = 'indeterminado'
327
+
328
+ results.append({
329
+ 'x': root,
330
+ 'fprime': df if math.isfinite(df) else None,
331
+ 'stability': stability,
332
+ 'stability_reason': reason,
333
+ })
334
+
335
+ return results
336
+
337
+ @staticmethod
338
+ def phase_data(f: Callable, x_min: float, x_max: float, n: int = 400) -> Dict[str, Any]:
339
+ xs = np.linspace(x_min, x_max, n)
340
+ ys = Dynamic1DService._safe_eval(f, xs)
341
+
342
+ flow_points: List[Dict[str, float]] = []
343
+ flow_xs = np.linspace(x_min, x_max, 14)
344
+ flow_vals = Dynamic1DService._safe_eval(f, flow_xs)
345
+ for x_val, f_val in zip(flow_xs, flow_vals):
346
+ if np.isnan(f_val):
347
+ continue
348
+ direction = 1 if f_val > 0 else -1 if f_val < 0 else 0
349
+ flow_points.append({'x': float(x_val), 'dir': direction})
350
+
351
+ return {
352
+ 'x': xs.tolist(),
353
+ 'fx': ys.tolist(),
354
+ 'flow': flow_points,
355
+ }
356
+
357
+ @staticmethod
358
+ def time_solutions(f: Callable, initials: List[float], t_max: float, n: int = 200) -> Dict[str, Any]:
359
+ t_eval = np.linspace(0, t_max, n)
360
+ series = []
361
+ for x0 in initials:
362
+ sol = solve_ivp(
363
+ lambda t, x: f(x),
364
+ (0, t_max),
365
+ [x0],
366
+ t_eval=t_eval,
367
+ method='RK45',
368
+ )
369
+ series.append({
370
+ 'x0': float(x0),
371
+ 'x': sol.y[0].tolist() if sol.success else [],
372
+ })
373
+
374
+ return {
375
+ 't': t_eval.tolist(),
376
+ 'series': series,
377
+ }
378
+
379
+ @staticmethod
380
+ def solve(payload: Dict[str, Any]) -> Dict[str, Any]:
381
+ model = payload.get('model', 'custom')
382
+ func_str = payload.get('func_str', 'x')
383
+ params = payload.get('params', {}) or {}
384
+ control_enabled = bool(payload.get('control_enabled', False))
385
+
386
+ expr_str, params = Dynamic1DService._build_expr(model, func_str, params, control_enabled)
387
+ f, expr_compiled = Dynamic1DService._compile_function(expr_str, params)
388
+
389
+ x_min = float(payload.get('x_min', -1))
390
+ x_max = float(payload.get('x_max', 3))
391
+ t_max = float(payload.get('t_max', 10))
392
+ n_phase = int(payload.get('n_phase', 400))
393
+ n_time = int(payload.get('n_time', 200))
394
+ initials = payload.get('initial_conditions', [0.5])
395
+
396
+ roots = Dynamic1DService.find_equilibria(f, x_min, x_max, n=n_phase)
397
+ equilibria = Dynamic1DService.classify_equilibria(f, roots)
398
+ phase = Dynamic1DService.phase_data(f, x_min, x_max, n=n_phase)
399
+ time = Dynamic1DService.time_solutions(f, initials, t_max, n=n_time)
400
+
401
+ return {
402
+ 'model': model,
403
+ 'equation': expr_compiled,
404
+ 'params': params,
405
+ 'equilibria': equilibria,
406
+ 'phase': phase,
407
+ 'time': time,
408
+ }
409
+
410
+ @staticmethod
411
+ def validate(payload: Dict[str, Any]) -> Dict[str, Any]:
412
+ model = payload.get('model', 'custom')
413
+ func_str = payload.get('func_str', 'x')
414
+ params = payload.get('params', {}) or {}
415
+ control_enabled = bool(payload.get('control_enabled', False))
416
+
417
+ expr_str, params = Dynamic1DService._build_expr(model, func_str, params, control_enabled)
418
+ f, expr_compiled = Dynamic1DService._compile_function(expr_str, params)
419
+ _ = f(0.0)
420
+
421
+ return {
422
+ 'ok': True,
423
+ 'equation': expr_compiled,
424
+ 'model': model,
425
+ }
426
+
427
+ @staticmethod
428
+ def bifurcation(payload: Dict[str, Any]) -> Dict[str, Any]:
429
+ model = payload.get('model', 'custom')
430
+ func_str = payload.get('func_str', 'x')
431
+ params = payload.get('params', {}) or {}
432
+ control_enabled = bool(payload.get('control_enabled', False))
433
+
434
+ model_hint = payload.get('bif_model', model)
435
+
436
+ raw_bif_param = payload.get('bif_param', 'r')
437
+ bif_param = (raw_bif_param or 'r').strip() or 'r'
438
+ x_min = float(payload.get('x_min', -1))
439
+ x_max = float(payload.get('x_max', 3))
440
+ n_phase = int(payload.get('n_phase', 400))
441
+
442
+ bif_min = float(payload.get('bif_min', -1))
443
+ bif_max = float(payload.get('bif_max', 1))
444
+ bif_steps = int(payload.get('bif_steps', 60))
445
+
446
+ phase_params = payload.get('phase_params', []) or []
447
+ if not phase_params:
448
+ # Si no se proporcionan, calcular puntos críticos automáticos
449
+ center = (bif_min + bif_max) / 2
450
+ delta = (bif_max - bif_min) / 4
451
+ phase_params = [center - delta, center, center + delta]
452
+
453
+ expr_str, _ = Dynamic1DService._build_expr(model, func_str, params, control_enabled)
454
+ bif_allowed = list(dict.fromkeys(['x', bif_param] + list(params.keys())))
455
+ expr_template = Dynamic1DService._parse_expression(expr_str, params, bif_allowed)
456
+ if params:
457
+ expr_template = expr_template.subs(params)
458
+
459
+ exact_analysis = None
460
+ try:
461
+ exact_analysis = Dynamic1DService._exact_bifurcation_analysis(expr_template, bif_param)
462
+ exact_analysis['model_hint'] = model_hint
463
+ except Exception:
464
+ exact_analysis = None
465
+
466
+ param_values = np.linspace(bif_min, bif_max, bif_steps)
467
+ equilibria_rows: List[Dict[str, Any]] = []
468
+
469
+ # Intentar Inversión de Dominio (Domain Inversion)
470
+ inverse_success = False
471
+ try:
472
+ x_sym = sp.Symbol('x', real=True)
473
+ p_sym = sp.Symbol(bif_param, real=True)
474
+ r_exprs = sp.solve(expr_template, p_sym)
475
+
476
+ if len(r_exprs) == 1:
477
+ r_expr = r_exprs[0]
478
+ r_func = sp.lambdify(x_sym, r_expr, 'numpy')
479
+
480
+ df_dx_expr = sp.diff(expr_template, x_sym)
481
+ df_dx_func = sp.lambdify((x_sym, p_sym), df_dx_expr, 'numpy')
482
+
483
+ # Grilla densa en el espacio de estados
484
+ x_grid = np.linspace(x_min, x_max, n_phase * 5)
485
+
486
+ for x_val in x_grid:
487
+ x_val_float = float(x_val)
488
+ try:
489
+ r_val = float(r_func(x_val_float))
490
+ except Exception:
491
+ continue
492
+
493
+ if not math.isfinite(r_val):
494
+ continue
495
+
496
+ if bif_min <= r_val <= bif_max:
497
+ try:
498
+ df = float(df_dx_func(x_val_float, r_val))
499
+ except Exception:
500
+ df = float('nan')
501
+
502
+ stability = 'indeterminado'
503
+ reason = 'sin datos suficientes'
504
+ if math.isfinite(df) and abs(df) > 1e-4:
505
+ if df < 0:
506
+ stability = 'estable'
507
+ reason = f"f'(x*) = {df:.6g} < 0"
508
+ else:
509
+ stability = 'inestable'
510
+ reason = f"f'(x*) = {df:.6g} > 0"
511
+
512
+ equilibria_rows.append({
513
+ 'param': r_val,
514
+ 'x': x_val_float,
515
+ 'fprime': df if math.isfinite(df) else None,
516
+ 'stability': stability,
517
+ 'stability_reason': reason,
518
+ })
519
+
520
+ if equilibria_rows:
521
+ inverse_success = True
522
+ # Ordenar por el parametro para facilitar el graficado
523
+ equilibria_rows.sort(key=lambda item: item['param'])
524
+ except Exception:
525
+ pass
526
+
527
+ if not inverse_success:
528
+ for p_val in param_values:
529
+ local_params = dict(params)
530
+ local_params[bif_param] = float(p_val)
531
+ f, _ = Dynamic1DService._compile_function(expr_str, local_params)
532
+ roots = Dynamic1DService.find_equilibria(f, x_min, x_max, n=n_phase)
533
+ equilibria = Dynamic1DService.classify_equilibria(f, roots)
534
+ for eq in equilibria:
535
+ equilibria_rows.append({
536
+ 'param': float(p_val),
537
+ 'x': eq['x'],
538
+ 'fprime': eq['fprime'],
539
+ 'stability': eq['stability'],
540
+ 'stability_reason': eq.get('stability_reason'),
541
+ })
542
+
543
+ phase_slices: List[Dict[str, Any]] = []
544
+ for p_val in phase_params:
545
+ local_params = dict(params)
546
+ local_params[bif_param] = float(p_val)
547
+ f, _ = Dynamic1DService._compile_function(expr_str, local_params)
548
+ roots = Dynamic1DService.find_equilibria(f, x_min, x_max, n=n_phase)
549
+ equilibria = Dynamic1DService.classify_equilibria(f, roots)
550
+ phase = Dynamic1DService.phase_data(f, x_min, x_max, n=n_phase)
551
+ phase_slices.append({
552
+ 'param': float(p_val),
553
+ 'equilibria': equilibria,
554
+ 'phase': phase,
555
+ })
556
+
557
+ return {
558
+ 'model': model,
559
+ 'equation': str(expr_template),
560
+ 'bif_param': bif_param,
561
+ 'bifurcation': {
562
+ 'param_values': param_values.tolist(),
563
+ 'equilibria': equilibria_rows,
564
+ },
565
+ 'phase_slices': phase_slices,
566
+ 'exact_analysis': exact_analysis,
567
+ }