Cómo está armado el proyecto
El código se separa en dos partes que no se mezclan: logica/ tiene solo matemática (nada de botones ni ventanas — se podría probar desde una consola de Python sin abrir la app), e interfaz/ tiene solo lo visual (Flet). Cuando resolvés una función, la información viaja así:
Por eso cada método vive en su propio archivo dentro de logica/metodos/, con una única función resolver(...) como punto de entrada. Si el profesor pregunta "¿dónde está el método X", la respuesta siempre es ese archivo.
1. Leer la función que escribe el usuario
Antes de aplicar cualquier método hay que convertir el texto "f(x) = x^2 - 4*x + 3" en algo con lo que sympy pueda derivar y resolver. Eso lo hace interpretar_funcion() en logica/parser.py, en dos partes.
a) Una expresión regular separa nombre, variable y fórmula
patron = r'^\s*([A-Za-z]\w*)\s*\(\s*([A-Za-z]\w*)\s*\)\s*=\s*(.+)$'
match = re.match(patron, entrada)
- ([A-Za-z]\w*)
- Grupo 1: el nombre de la función (la "f" de "f(x)"). Letra inicial, luego letras/números/guión bajo.
- \(...\)
- Los paréntesis literales alrededor de la variable — grupo 2, misma regla que el nombre.
- =\s*(.+)$
- Todo lo que sigue al "=" hasta el final del texto es el grupo 3: la expresión a derivar/resolver.
b) sympy interpreta la expresión, pero con una lista blanca
Esta es la parte que más suele llamar la atención al profesor: por qué hay un diccionario que parece "vacío a propósito".
_GLOBAL_DICT_SEGURO = {
"__builtins__": {},
"Integer": sp.Integer,
"Float": sp.Float,
"Rational": sp.Rational,
"Symbol": sp.Symbol,
}
sympy.parse_expr internamente evalúa texto como código Python. Sin restricciones, alguien podría escribir algo como f(x) = __import__('os').system('...') y ejecutar comandos arbitrarios en la máquina. Al reemplazar __builtins__ por un diccionario vacío, ninguna función nativa de Python (open, exec, import, etc.) queda disponible — solo lo que agregamos a mano, que son puros constructores numéricos de sympy.
Después, si sobra algún símbolo que no sea la variable declarada (por ejemplo escribiste "y" sin querer), interpretar_funcion lo detecta con funcion.free_symbols - {variable} y tira un error claro en vez de dejar pasar una función mal escrita.
2. Criterio de la primera y segunda derivada
Es el método "clásico" de optimización sin restricciones: sirve para hallar máximos y mínimos locales de f(x) de forma exacta (simbólica), sin iterar ni aproximar. Opcionalmente se puede dar un intervalo [a, b]: entonces solo se consideran los puntos críticos de ese tramo (útil con seno o coseno, que tienen muchísimos picos y valles).
- Derivar f(x) para obtener f'(x).
- Igualar a cero y resolver f'(x) = 0 → esas soluciones son los puntos críticos.
- Derivar otra vez para obtener f''(x) (mide la concavidad).
- Evaluar el signo de f''(c) en cada punto crítico c:
f''(c) > 0 → mínimo f''(c) < 0 → máximo f''(c) = 0 → indeterminado
El código, paso por paso
a, b = leer_intervalo(a, b) # (None, None) si no se dio intervalo
nombre, x, f = interpretar_funcion(funcion_txt)
# Paso 1: derivar la función.
f1 = sp.diff(f, x)
# Paso 2: igualar la primera derivada a cero y resolver -> puntos críticos.
if a is None:
criticos = sp.solve(sp.Eq(f1, 0), x)
criticos_reales = [c for c in criticos if getattr(c, "is_real", True)]
else:
criticos_reales = criticos_en_intervalo(f1, x, a, b)
# Paso 3: derivar otra vez, para poder evaluar la concavidad.
f2 = sp.diff(f1, x)
- sp.diff(f, x)
- Deriva simbólicamente — no es una aproximación numérica, es la derivada exacta que da sympy.
- sp.solve(sp.Eq(f1, 0), x)
- Resuelve la ecuación f'(x) = 0 y devuelve todas las soluciones (pueden ser reales, complejas, o ninguna).
- [c for c in criticos if ...]
- Lista por comprensión equivalente a un
forque arma una lista nueva: recorre cada solucióncy se queda solo con las que son reales (getattr(c, "is_real", True)— si sympy no puede decidirlo, la deja pasar por defecto).
Con un intervalo [a, b]: criticos_en_intervalo
Vive en su propio archivo, logica/busqueda_en_intervalo.py, para no recargar el método.
soluciones = sp.solveset(sp.Eq(f1, 0), x, sp.Interval(a, b))
if isinstance(soluciones, sp.FiniteSet):
... # todas las soluciones exactas dentro de [a, b]
return _criticos_numericos(f1, x, a, b) # plan B
- sp.solveset(..., Interval)
- A diferencia de
solve, devuelve todas las soluciones que caben en el intervalo (por ejemplo π/2, 3π/2 y 5π/2 parasin(x)en [0, 10]). - _criticos_numericos
- Bucle for Plan B si sympy no logra una solución exacta (ej.
x·sin(x)): se recorre el intervalo en 4000 puntos, se detecta dónde f' cambia de signo y se afina cada raíz por bisección (partir el tramo a la mitad 60 veces). No detecta raíces dobles. - TOLERANCIA_CERO
- Los puntos hallados numéricamente tienen decimales; ahí un f'' menor que 1e-7 en valor absoluto cuenta como 0 (indeterminado), por el error de redondeo.
El criterio de la 2ª derivada solo entrega máximos y mínimos locales. Para el máximo de una función en un intervalo cerrado también hay que comparar los extremos del intervalo: el "Ejemplo rápido" de la página Cóncavas y convexas lo hace con f(x) = 2x³ − 3x² − 12x + 5 en [−2, 4] (máximo local en x = −1, pero el máximo real está en el extremo x = 4, con f = 37).
puntos = []
puntos_para_grafica = []
etiquetas_para_grafica = []
for c in criticos_reales:
puntos.append(_construir_punto_critico(c, f, f2, x, x_tex))
coordenadas = _coordenadas_para_grafica(c, f, x)
if coordenadas is not None:
puntos_para_grafica.append(coordenadas)
etiquetas_para_grafica.append(f"({coordenadas[0]:.2f}, {coordenadas[1]:.2f})")
Este for recorre cada punto crítico encontrado (puede haber 0, 1 o varios) y, por cada uno, hace dos cosas: (1) llama a _construir_punto_critico para clasificarlo como máximo/mínimo/indeterminado, y (2) si el punto se puede convertir a número real (a veces sympy deja una solución en forma simbólica que no se puede graficar), lo guarda para dibujarlo en la gráfica con su etiqueta de coordenadas.
def _clasificar_punto_critico(valor_f2):
# A partir del signo de f''(c), decide condición / tipo / clasificación.
if valor_f2.is_positive:
return "> 0", "Mínimo", "convexa"
if valor_f2.is_negative:
return "< 0", "Máximo", "cóncava"
return "= 0", "Indeterminado", "Convexa y Cóncava"
Acá está el criterio matemático de la segunda derivada, traducido directo a código: dos preguntas en cadena. Si ninguna de las dos es cierta (f'' vale exactamente 0), cae al return final: "Indeterminado". .is_positive / .is_negative son propiedades que sympy calcula sobre la expresión simbólica, no comparaciones numéricas con errores de redondeo.
Ejemplo trabajado
Con f(x) = x**2 - 4*x + 3:
| Paso | Resultado |
|---|---|
| f'(x) | 2*x - 4 |
| f'(x) = 0 | 2*x - 4 = 0 |
| x crítico | x = 2 |
| f''(x) | 2 |
| f''(2) | 2 > 0 |
3. Newton-Raphson
A diferencia del método anterior, este no resuelve de forma exacta: busca una raíz de f(x) = 0 (un cero de la función) acercándose de a poco, partiendo de un punto inicial x₀ que elige el usuario.
- Empezar en un punto x₀ elegido por el usuario.
- En cada vuelta, calcular la recta tangente a la curva en ese punto y ver dónde cruza el eje x:
xn+1 = xn − f(xn) / f'(xn)
- Repetir usando ese nuevo punto, hasta que ya casi no cambie (el cambio es menor que el margen de error) o se agote el número máximo de vueltas.
El código, paso por paso
for numero_iteracion in range(1, max_iteraciones + 1):
valor_f = _valor_numerico(f_lamb, x_actual)
valor_f1 = _valor_numerico(f1_lamb, x_actual)
if abs(valor_f1) < 1e-12:
raise ValueError(
f"f'(x) se anula en x = {x_actual:.6f}: Newton-Raphson no puede "
"continuar. Prueba con otro punto inicial."
)
x_siguiente = x_actual - valor_f / valor_f1
error = abs(x_siguiente - x_actual)
iteraciones.append(IteracionNewton(...)) # fila para la tabla
x_actual = x_siguiente
if error < tolerancia:
convergio = True
break
Este es el corazón del método. range(1, max_iteraciones + 1) pone un techo (por defecto 100 vueltas) para que nunca quede colgado si la función no converge. Dentro de cada vuelta:
- valor_f, valor_f1
- Evalúan f y f' en el punto actual — numéricamente (con
sp.lambdify, no simbólicamente), porque acá ya no hace falta una fórmula exacta, solo números rápidos. - if abs(valor_f1) < 1e-12
- Condición de seguridad: si la tangente queda casi horizontal (f'(x) ≈ 0), dividir por ella dispararía un error de división por cero o un salto absurdo — se corta con un mensaje claro en vez de romper la app.
- x_siguiente = ...
- Es la fórmula de Newton-Raphson de arriba, escrita tal cual.
- if error < tolerancia: break
- La condición de parada: en cuanto el cambio entre dos puntos seguidos es menor que el margen de error que puso el usuario, se marca
convergio = Trueybreakcorta elforahí mismo — no hace falta llegar a las 100 vueltas.
Ejemplo trabajado
f(x) = x**2 - 2, x₀ = 1, margen = 0.000001 (busca la raíz cuadrada de 2):
| n | xₙ | f(xₙ) | f'(xₙ) | xₙ₊₁ | error |
|---|---|---|---|---|---|
| 1 | 1.000000 | -1.000000 | 2.000000 | 1.500000 | 0.500000 |
| 2 | 1.500000 | 0.250000 | 3.000000 | 1.416667 | 0.083333 |
| 3 | 1.416667 | 0.006944 | 2.833333 | 1.414216 | 0.002451 |
| 4 | 1.414216 | 0.000006 | 2.828431 | 1.414214 | 0.000002 |
| 5 | 1.414214 | 0.000000 | 2.828427 | 1.414214 | 0.000000 |
4. Método de la razón dorada (sección áurea)
Encuentra un mínimo o máximo sin usar derivadas: en vez de eso, va achicando un intervalo [a, b] que sabemos que contiene al óptimo, comparando el valor de la función en dos puntos internos elegidos con la razón áurea R = (√5 − 1)/2 ≈ 0.618 (= 1/φ, con φ = (1 + √5)/2 ≈ 1.618).
- Partir de un intervalo [a, b] elegido por el usuario.
- Ubicar dos puntos internos: x₁ = (1 − R)·(b − a) + a y x₂ = R·(b − a) + a.
- Comparar f(x₁) con f(x₂) y quedarse con [a, x₂] o [x₁, b] según la tabla de abajo (el otro tramo no puede contener el óptimo).
- Repetir hasta que el intervalo sea más angosto que el margen de error.
El código, paso por paso
def es_mejor(valor_1, valor_2):
"""True si el punto 1 es mejor candidato, según se busque min o max."""
return valor_1 < valor_2 if buscar == "min" else valor_1 > valor_2
Función chica pero clave: es lo que hace que un solo método sirva tanto para buscar mínimo como máximo, sin duplicar código. Es un if/else escrito como expresión (operador ternario): si se busca un mínimo, "mejor" significa "menor"; si se busca un máximo, significa "mayor".
for numero_iteracion in range(1, max_iteraciones + 1):
# Paso 1: ubicar los dos puntos interiores con la razón áurea R.
x1 = (1 - RAZON_AUREA) * (b - a) + a
x2 = RAZON_AUREA * (b - a) + a
valor_x1 = _valor_numerico(f_lamb, x1)
valor_x2 = _valor_numerico(f_lamb, x2)
a_inicio, b_inicio = a, b # la fila de la tabla muestra a y b ANTES de reemplazar
# Paso 2: se toma [a, x2] (se reemplaza b) o [x1, b] (se reemplaza a).
if es_mejor(valor_x1, valor_x2):
b = x2
else:
a = x1
iteraciones.append(IteracionAurea(...)) # fila para la tabla
# Paso 3: repetir hasta que el intervalo sea angosto (convergió).
if (b - a) < tolerancia:
convergio = True
break
| Se busca | f(x₁) < f(x₂) | f(x₁) > f(x₂) |
|---|---|---|
| Mínimo | [a, x₂] | [x₁, b] |
| Máximo | [x₁, b] | [a, x₂] |
- x1 = (1-R)·(b-a) + a
- El punto interior más cercano a a. (Antes estaba escrito como
b - R·(b-a): es el mismo valor, ahora en la forma estándar.) - x2 = R·(b-a) + a
- El punto interior más cercano a b, simétrico al anterior.
- a_inicio, b_inicio
- Guardan el intervalo con el que empieza la vuelta: por eso la fila 1 de la tabla muestra el a y el b que ingresó el usuario, y recién la fila 2 muestra el intervalo ya reemplazado.
- if es_mejor(...): b = x2
- Condición que decide qué extremo del intervalo mover. Achicar el intervalo reasignando
aobes literalmente "descartar" ese pedazo — no se vuelve a mirar en la siguiente vuelta. - if (b - a) < tolerancia
- Misma idea de parada que en Newton-Raphson, pero midiendo el ancho del intervalo en vez del cambio entre dos puntos.
Ejemplo trabajado
f(x) = (x - 2)**2 + 1, intervalo [0, 5], buscando mínimo con margen de error 0.3 (para que se vea completo en pocas filas):
| n | a | b | x₁ | x₂ | f(x₁) | f(x₂) | decisión |
|---|---|---|---|---|---|---|---|
| 1 | 0.000000 | 5.000000 | 1.909830 | 3.090170 | 1.008131 | 2.188471 | f(x1) < f(x2) → [a, x2] = [0.000000, 3.090170] |
| 2 | 0.000000 | 3.090170 | 1.180340 | 1.909830 | 1.671843 | 1.008131 | f(x1) > f(x2) → [x1, b] = [1.180340, 3.090170] |
| 3 | 1.180340 | 3.090170 | 1.909830 | 2.360680 | 1.008131 | 1.130090 | f(x1) < f(x2) → [a, x2] = [1.180340, 2.360680] |
| 4 | 1.180340 | 2.360680 | 1.631190 | 1.909830 | 1.136021 | 1.008131 | f(x1) > f(x2) → [x1, b] = [1.631190, 2.360680] |
| 5 | 1.631190 | 2.360680 | 1.909830 | 2.082039 | 1.008131 | 1.006730 | f(x1) > f(x2) → [x1, b] = [1.909830, 2.360680] |
| 6 | 1.909830 | 2.360680 | 2.082039 | 2.188471 | 1.006730 | 1.035521 | f(x1) < f(x2) → [a, x2] = [1.909830, 2.188471] |
5. Cómo se dibuja la gráfica
Los tres métodos terminan llamando a graficar_funcion(...) para armar la imagen que se ve en la pestaña "Gráfica". Dos partes de ese archivo suelen generar preguntas.
Evaluar la función sin que un solo punto raro rompa todo
def evaluar_en_puntos(f_lamb, xs):
ys = np.full(xs.shape, np.nan)
with np.errstate(all="ignore"):
for i, xv in enumerate(xs):
try:
yv = f_lamb(xv)
yv = complex(yv)
if abs(yv.imag) < 1e-9 and np.isfinite(yv.real):
ys[i] = yv.real
except Exception:
pass
return ys
La gráfica se arma evaluando la función en 800 puntos de x (xs) para saber a qué altura (y) dibujar la curva en cada uno. Este for recorre esos 800 puntos uno por uno, con dos cuidados:
- try / except
- Si en algún x la función no está definida (ej.
ln(x)con x ≤ 0),f_lamb(xv)tira una excepción — se atrapa y ese punto queda comoNaN(vacío) en vez de tumbar toda la gráfica. - complex(yv) / .imag
- Algunas funciones (raíces de negativos, etc.) devuelven un número complejo en vez de real. Se descarta ese punto salvo que la parte imaginaria sea prácticamente cero (error de redondeo), en cuyo caso se usa la parte real.
Cortar la curva en las asíntotas
# Corta el trazo en las asíntotas: sin esto matplotlib une los dos lados
# con una raya vertical falsa (típico en 1/x o tan(x)).
ys_trazo = ys.copy()
salto = (y_hi - y_lo) * 0.5
d = np.abs(np.diff(ys))
ys_trazo[1:][np.isfinite(d) & (d > salto)] = np.nan
Sin este truco, una función como 1/x se vería con una línea vertical falsa uniendo "-∞" con "+∞" en x = 0. np.diff(ys) calcula cuánto salta la curva de un punto al siguiente; si el salto es más grande que la mitad de todo el alto visible del gráfico, se asume que es una asíntota y ese tramo se "levanta el lápiz" (se pone en NaN, que matplotlib no dibuja).
Zoom de la gráfica (rueda, botones o intervalo)
Cada resultado trae una función redibujar(x_min, x_max) que devuelve la imagen para cualquier ventana de x. La interfaz (grafica_con_zoom en interfaz/componentes.py) la llama cada vez que cambia la ventana. Los ejes tienen una posición fija dentro de la figura (EJES_IZQUIERDA, EJES_ANCHO): así, la posición del cursor se traduce exactamente a un valor de x y el zoom "de la rueda" queda justo donde está el mouse. El eje y se recalcula cada vez con lo visible.
Dibujar con matplotlib tarda ~0.2 s, demasiado para hacerlo en cada "clic" de la rueda. Por eso se combinan tres trucos:
- Vista previa
- Apenas gira la rueda, la imagen que ya está en pantalla se estira horizontalmente para aparentar la nueva ventana, mientras se dibuja la buena.
- La última gana
- El dibujo corre en segundo plano (
asyncio.to_thread); si llegan más giros mientras tanto, las ventanas intermedias se saltan y solo se dibuja la más reciente. - Caché
- Se recuerdan las últimas 40 imágenes: volver a "Restablecer" o repetir un zoom es instantáneo.
Preguntas que podría hacer el profesor
P.¿Por qué separaron logica/ de interfaz/?
resolver() es una función normal de Python que se puede llamar desde una consola, sin abrir la app. También significa que agregar un método nuevo (por ejemplo, para el módulo de Dinámica que está "Próximamente") no obliga a tocar nada de la interfaz.P.¿Qué pasa si la función no tiene puntos críticos reales?
criticos_reales queda como lista vacía y el for simplemente no itera ninguna vez — puntos queda vacío y la interfaz muestra "No se encontraron valores críticos reales" en vez de romperse.P.¿Qué pasa si Newton-Raphson no converge?
for agota las max_iteraciones (100 por defecto) sin que el if error < tolerancia se cumpla nunca, así que convergio se queda en False. La interfaz lo muestra igual, pero con el mensaje "No convergió" en vez de "Convergió", usando el último valor obtenido.P.¿Por qué Newton-Raphson necesita f' y la razón dorada no?
P.¿Por qué la razón dorada usa R ≈ 0.618 y no la mitad del intervalo?
P.¿Por qué el diccionario de sympy tiene "__builtins__": {}?
P.¿Cómo se buscan los puntos críticos de sin(x), que tiene infinitos?
criticos_en_intervalo usa sp.solveset(..., Interval(a, b)), que devuelve todas las soluciones exactas dentro del tramo (π/2, 3π/2, 5π/2... en [0, 10]). Si sympy no puede (ej. x·sin(x)), recorre el intervalo buscando cambios de signo de f' y afina cada raíz por bisección.P.¿Por qué el máximo en [−2, 4] no es el máximo local?
2x³ − 3x² − 12x + 5 el máximo local es x = −1 (f = 12), pero f(4) = 37 es mayor: el máximo del intervalo está en x = 4. El criterio de la 2ª derivada solo ve puntos críticos, así que hay que comparar también f(a) y f(b).P.¿Cómo hace zoom la gráfica sin sentirse lenta?
P.¿Por qué la fila 1 de la razón dorada muestra el intervalo original?
a_inicio, b_inicio antes de reemplazar a o b.P.¿Dónde se decide el color / forma de cada tarjeta en la interfaz?
interfaz/tema.py están todos los colores centralizados (por ejemplo VERDE, ERROR, TARJETA_MORADA). Ningún archivo de vista tiene un color "hardcodeado" suelto — todos importan de ahí, así que cambiar la paleta entera es tocar un solo archivo.