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.
- mathmethods/__init__.py +7 -0
- mathmethods/__main__.py +6 -0
- mathmethods/compiler.py +202 -0
- mathmethods/core/__init__.py +36 -0
- mathmethods/core/differentiation.py +122 -0
- mathmethods/core/dynamic_1d.py +567 -0
- mathmethods/core/dynamic_2d_conservative.py +283 -0
- mathmethods/core/dynamic_2d_lanchester.py +264 -0
- mathmethods/core/dynamic_2d_linear.py +269 -0
- mathmethods/core/dynamic_2d_non_homogeneous.py +710 -0
- mathmethods/core/dynamic_2d_nonlinear.py +298 -0
- mathmethods/core/integration.py +396 -0
- mathmethods/core/interpolation.py +126 -0
- mathmethods/core/monte_carlo.py +312 -0
- mathmethods/core/ode.py +154 -0
- mathmethods/core/root_finding.py +339 -0
- mathmethods/core/utils.py +226 -0
- mathmethods/server.py +1414 -0
- mathmethods_mcp-0.1.0.dist-info/METADATA +436 -0
- mathmethods_mcp-0.1.0.dist-info/RECORD +24 -0
- mathmethods_mcp-0.1.0.dist-info/WHEEL +5 -0
- mathmethods_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- mathmethods_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- mathmethods_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
}
|