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,298 @@
1
+ # backend/app/methods/dynamic_2d_nonlinear.py
2
+ import math
3
+ from typing import Any, Dict
4
+
5
+ import numpy as np
6
+ import sympy as sp
7
+ from sympy.parsing.sympy_parser import (
8
+ implicit_multiplication_application,
9
+ standard_transformations,
10
+ )
11
+ from .utils import safe_parse_expr
12
+
13
+
14
+ class Dynamic2DNonLinearService:
15
+ @staticmethod
16
+ def clasificar_punto(traza, determinante, discriminante, autovalores) -> tuple:
17
+ if abs(determinante) < 1e-10:
18
+ clasif = "Punto Degenerado (No Hiperbólico)"
19
+ comp = "El Jacobiano es singular (al menos un autovalor es cero). Falla la linealización. Esto suele indicar una variedad central donde ocurren bifurcaciones estáticas (Silla-Nodo, Transcrítica o Pitchfork)."
20
+ return clasif, comp
21
+
22
+ elif determinante < 0:
23
+ clasif = "Silla"
24
+ comp = "Trayectorias se acercan por una dirección (variedad estable) y se alejan por otra (variedad inestable). Punto hiperbólico."
25
+
26
+ elif discriminante > 1e-10:
27
+ if traza < 0:
28
+ clasif = "Nodo Estable"
29
+ comp = "Trayectorias tienden al equilibrio asintóticamente sin oscilar (Sumidero puro). Punto hiperbólico."
30
+ elif traza > 0:
31
+ clasif = "Nodo Inestable"
32
+ comp = "Trayectorias se alejan del equilibrio sin oscilar (Fuente pura). Punto hiperbólico."
33
+ else:
34
+ clasif = "Nodo con traza cero (Degenerado)"
35
+ comp = "Comportamiento nodal limítrofe."
36
+
37
+ elif abs(discriminante) <= 1e-10:
38
+ if traza < 0:
39
+ clasif = "Nodo Degenerado Estable"
40
+ comp = "Trayectorias convergen al punto, con un autovector dominante. Punto hiperbólico."
41
+ elif traza > 0:
42
+ clasif = "Nodo Degenerado Inestable"
43
+ comp = "Trayectorias divergen del punto, con un autovector dominante. Punto hiperbólico."
44
+ else:
45
+ clasif = "Caso No Hiperbólico"
46
+ comp = "Falla el teorema de Hartman-Grobman."
47
+
48
+ else:
49
+ parte_real = float(np.real(autovalores[0]))
50
+ if abs(parte_real) < 1e-10:
51
+ clasif = "Centro o Bifurcación de Hopf (No Hiperbólico)"
52
+ comp = "Los autovalores son puramente imaginarios. Hartman-Grobman falla. Físicamente, el punto puede ser un 'Centro' conservativo, o estar en el punto crítico de una Bifurcación de Hopf (nacimiento/muerte de un Ciclo Límite). Confirmar con Funciones de Lyapunov o cambio a Coordenadas Polares."
53
+ elif parte_real < 0:
54
+ clasif = "Foco Estable"
55
+ comp = "Trayectorias giran en espiral convergiendo hacia el equilibrio (Atractor). Punto hiperbólico."
56
+ else:
57
+ clasif = "Foco Inestable"
58
+ comp = "Trayectorias giran en espiral alejándose del equilibrio (Repulsor). Punto hiperbólico."
59
+
60
+ return clasif, comp
61
+
62
+ @staticmethod
63
+ def solve(payload: Dict[str, Any]) -> Dict[str, Any]:
64
+ eq_x_str = payload.get("eq_x", "y - x")
65
+ eq_y_str = payload.get("eq_y", "x**2 - 1")
66
+ params = payload.get("params", {}) or {}
67
+ params = {str(k): float(v) for k, v in params.items()}
68
+ mu_val = float(params.pop("mu", payload.get("mu", 0.0)))
69
+ x0 = float(payload.get("x0", 0.5))
70
+ y0 = float(payload.get("y0", 0.5))
71
+ t0 = float(payload.get("t0", 0.0))
72
+ t_fin = float(payload.get("t_fin", 10.0))
73
+ h = float(payload.get("h", 0.02))
74
+ x_min = float(payload.get("x_min", -4.0))
75
+ x_max = float(payload.get("x_max", 4.0))
76
+ y_min = float(payload.get("y_min", -4.0))
77
+ y_max = float(payload.get("y_max", 4.0))
78
+ cantidad_trayectorias = int(payload.get("cantidad_trayectorias", 25))
79
+
80
+ x, y, mu_sym = sp.symbols("x y mu", real=True)
81
+ param_symbols = {name: sp.Symbol(name, real=True) for name in params.keys()}
82
+ transformations = standard_transformations + (
83
+ implicit_multiplication_application,
84
+ )
85
+
86
+ try:
87
+ local_dict = {"x": x, "y": y, "mu": mu_sym, **param_symbols}
88
+ gate_allowed = list(dict.fromkeys(["x", "y", "mu"] + list(params.keys())))
89
+ f_raw = safe_parse_expr(
90
+ eq_x_str,
91
+ local_dict=local_dict,
92
+ allowed_symbols=gate_allowed,
93
+ transformations=transformations,
94
+ )
95
+ g_raw = safe_parse_expr(
96
+ eq_y_str,
97
+ local_dict=local_dict,
98
+ allowed_symbols=gate_allowed,
99
+ transformations=transformations,
100
+ )
101
+
102
+ subs_map = {mu_sym: mu_val, **{param_symbols[k]: v for k, v in params.items()}}
103
+ f = f_raw.subs(subs_map)
104
+ g = g_raw.subs(subs_map)
105
+
106
+ free_symbols = {str(sym) for sym in f.free_symbols.union(g.free_symbols)}
107
+ missing = sorted(sym for sym in free_symbols if sym not in {"x", "y"})
108
+ if missing:
109
+ raise ValueError(f"Faltan parametros: {', '.join(missing)}")
110
+ except Exception as e:
111
+ raise ValueError(f"Error de sintaxis en las ecuaciones: {str(e)}")
112
+
113
+ J = sp.Matrix([[sp.diff(f, x), sp.diff(f, y)], [sp.diff(g, x), sp.diff(g, y)]])
114
+
115
+ sols = sp.nonlinsolve([f, g], [x, y])
116
+ puntos_equilibrio = []
117
+
118
+ if isinstance(sols, sp.FiniteSet):
119
+ for sol in sols:
120
+ try:
121
+ x_val = complex(sol[0])
122
+ y_val = complex(sol[1])
123
+ if abs(x_val.imag) < 1e-9 and abs(y_val.imag) < 1e-9:
124
+ puntos_equilibrio.append((x_val.real, y_val.real))
125
+ except:
126
+ pass
127
+
128
+ analisis_puntos = []
129
+ for pto in puntos_equilibrio:
130
+ px, py = pto[0], pto[1]
131
+ J_eval = J.subs({x: px, y: py})
132
+ J_num = np.array(J_eval.tolist(), dtype=float)
133
+
134
+ traza = float(np.trace(J_num))
135
+ determinante = float(np.linalg.det(J_num))
136
+ discriminante = traza**2 - 4 * determinante
137
+ autovalores, autovectores = np.linalg.eig(J_num)
138
+
139
+ clasif, comp = Dynamic2DNonLinearService.clasificar_punto(
140
+ traza, determinante, discriminante, autovalores
141
+ )
142
+ autos_formateados = [
143
+ {"real": float(np.real(av)), "imag": float(np.imag(av))}
144
+ for av in autovalores
145
+ ]
146
+
147
+ j_local_formateado = [
148
+ [round(float(J_num[0][0]), 4), round(float(J_num[0][1]), 4)],
149
+ [round(float(J_num[1][0]), 4), round(float(J_num[1][1]), 4)],
150
+ ]
151
+
152
+ analisis_puntos.append(
153
+ {
154
+ "x": px,
155
+ "y": py,
156
+ "traza": traza,
157
+ "determinante": determinante,
158
+ "discriminante": discriminante,
159
+ "clasificacion": clasif,
160
+ "comportamiento": comp,
161
+ "autovalores": autos_formateados,
162
+ "jacobiano_local": j_local_formateado,
163
+ }
164
+ )
165
+
166
+ f_num = sp.lambdify((x, y), f, "numpy")
167
+ g_num = sp.lambdify((x, y), g, "numpy")
168
+
169
+ def rk4_step(X_curr, step_h):
170
+ try:
171
+ v_x, v_y = float(X_curr[0]), float(X_curr[1])
172
+ k1_x = f_num(v_x, v_y)
173
+ k1_y = g_num(v_x, v_y)
174
+
175
+ k2_x = f_num(v_x + step_h * k1_x / 2, v_y + step_h * k1_y / 2)
176
+ k2_y = g_num(v_x + step_h * k1_x / 2, v_y + step_h * k1_y / 2)
177
+
178
+ k3_x = f_num(v_x + step_h * k2_x / 2, v_y + step_h * k2_y / 2)
179
+ k3_y = g_num(v_x + step_h * k2_x / 2, v_y + step_h * k2_y / 2)
180
+
181
+ k4_x = f_num(v_x + step_h * k3_x, v_y + step_h * k3_y)
182
+ k4_y = g_num(v_x + step_h * k3_x, v_y + step_h * k3_y)
183
+
184
+ new_x = v_x + (step_h / 6) * (k1_x + 2 * k2_x + 2 * k3_x + k4_x)
185
+ new_y = v_y + (step_h / 6) * (k1_y + 2 * k2_y + 2 * k3_y + k4_y)
186
+
187
+ if (
188
+ math.isnan(new_x)
189
+ or math.isnan(new_y)
190
+ or abs(new_x) > 1e6
191
+ or abs(new_y) > 1e6
192
+ ):
193
+ return None
194
+ return np.array([new_x, new_y])
195
+ except:
196
+ return None
197
+
198
+ n_steps_t = max(1, int(round((t_fin - t0) / h)))
199
+ t_vals = np.linspace(t0, t_fin, n_steps_t + 1)
200
+ X_main = [np.array([x0, y0])]
201
+ for _ in range(len(t_vals) - 1):
202
+ curr = X_main[-1]
203
+ if curr is None:
204
+ break
205
+ next_p = rk4_step(curr, h)
206
+ X_main.append(next_p)
207
+ X_main_final = np.array([p for p in X_main if p is not None])
208
+
209
+ automatic_trajectories = []
210
+ n_side = int(math.ceil(math.sqrt(cantidad_trayectorias)))
211
+ if n_side > 1:
212
+ x0_auto = np.linspace(x_min, x_max, n_side)
213
+ y0_auto = np.linspace(y_min, y_max, n_side)
214
+ for xi in x0_auto:
215
+ for yi in y0_auto:
216
+ t_len = int(len(t_vals) / 2)
217
+ X_temp = [np.array([xi, yi])]
218
+ for _ in range(t_len - 1):
219
+ curr = X_temp[-1]
220
+ if curr is None:
221
+ break
222
+ next_p = rk4_step(curr, h * 2)
223
+ X_temp.append(next_p)
224
+
225
+ X_temp_final = np.array([p for p in X_temp if p is not None])
226
+ if len(X_temp_final) > 1:
227
+ automatic_trajectories.append(
228
+ {
229
+ "x": X_temp_final[:, 0].tolist(),
230
+ "y": X_temp_final[:, 1].tolist(),
231
+ }
232
+ )
233
+
234
+ # --- ARREGLO DE NULCLINAS ROTA ---
235
+ # Aumentamos la resolucion de la grilla a 150 para curvas ultra suaves
236
+ grid_res = 150
237
+ X_mesh, Y_mesh = np.meshgrid(
238
+ np.linspace(x_min, x_max, grid_res), np.linspace(y_min, y_max, grid_res)
239
+ )
240
+ try:
241
+ Z_x = f_num(X_mesh, Y_mesh)
242
+ Z_y = g_num(X_mesh, Y_mesh)
243
+ if np.isscalar(Z_x):
244
+ Z_x = np.full(X_mesh.shape, Z_x)
245
+ if np.isscalar(Z_y):
246
+ Z_y = np.full(Y_mesh.shape, Z_y)
247
+
248
+ # Recortamos los valores explosivos y limpiamos NaNs
249
+ Z_x = np.nan_to_num(Z_x, nan=0.0, posinf=1e5, neginf=-1e5)
250
+ Z_y = np.nan_to_num(Z_y, nan=0.0, posinf=1e5, neginf=-1e5)
251
+ Z_x = np.clip(Z_x, -1e6, 1e6)
252
+ Z_y = np.clip(Z_y, -1e6, 1e6)
253
+
254
+ except Exception:
255
+ Z_x = np.zeros_like(X_mesh)
256
+ Z_y = np.zeros_like(Y_mesh)
257
+
258
+ x_sym, y_sym_var = sp.symbols("x y", real=True)
259
+ try:
260
+ sol_x = sp.solve(sp.Eq(f, 0), y_sym_var)
261
+ nul_x_despejada = (
262
+ f"y = {str(sol_x[0]).replace('**', '^').replace('*', ' ')}"
263
+ if sol_x
264
+ else "No despejable como y(x)"
265
+ )
266
+ except:
267
+ nul_x_despejada = "No despejable como y(x)"
268
+
269
+ try:
270
+ sol_y = sp.solve(sp.Eq(g, 0), y_sym_var)
271
+ nul_y_despejada = (
272
+ f"y = {str(sol_y[0]).replace('**', '^').replace('*', ' ')}"
273
+ if sol_y
274
+ else "No despejable como y(x)"
275
+ )
276
+ except:
277
+ nul_y_despejada = "No despejable como y(x)"
278
+
279
+ return {
280
+ "ecuacion_latex_x": sp.latex(f_raw),
281
+ "ecuacion_latex_y": sp.latex(g_raw),
282
+ "jacobiano_latex": sp.latex(J),
283
+ "puntos_analizados": analisis_puntos,
284
+ "principal_trajectory": {
285
+ "t": t_vals[: len(X_main_final)].tolist(),
286
+ "x": X_main_final[:, 0].tolist(),
287
+ "y": X_main_final[:, 1].tolist(),
288
+ },
289
+ "automatic_trajectories": automatic_trajectories,
290
+ "contour_data": {
291
+ "x_axis": X_mesh[0].tolist(),
292
+ "y_axis": Y_mesh[:, 0].tolist(),
293
+ "z_x_nul": Z_x.tolist(),
294
+ "z_y_nul": Z_y.tolist(),
295
+ },
296
+ "nulclina_x_desp": nul_x_despejada,
297
+ "nulclina_y_desp": nul_y_despejada,
298
+ }