UNMSM · Facultad de Ciencias Matemáticas · Programación No Lineal y Dinámica

Guía de sustentación del código

Para cuando el profesor pregunte «¿qué parte del código hace esto?»: cómo está armado el proyecto, qué hace cada método paso a paso, y una lectura línea por línea de cada for y cada decisión importante.

3 métodos: criterio 2ª derivada · Newton-Raphson · razón dorada logica/ (matemática) + interfaz/ (Flet)
Panorama general

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í:

interfaz/*_view.py Texto ingresado Ej. "f(x) = x**2 - 4*x + 3"
→
logica/parser.py interpretar_funcion() Valida el texto y lo convierte en un objeto de sympy
→
logica/metodos/*.py resolver() Aplica el método (deriva, itera, etc.)
→
dataclass Resultado Un objeto con cada paso ya calculado
→
interfaz/*_view.py Tarjetas y pestañas Solo pinta lo que ya viene calculado

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.

Paso previo a los tres métodos

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

logica/parser.pylínea 37
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".

logica/parser.pylíneas 27–33
_GLOBAL_DICT_SEGURO = {
    "__builtins__": {},
    "Integer": sp.Integer,
    "Float": sp.Float,
    "Rational": sp.Rational,
    "Symbol": sp.Symbol,
}
Seguridad
¿Por qué "__builtins__": {}?

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.

Método 1 de 3 · logica/metodos/criterio_segunda_derivada.py

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).

  1. Derivar f(x) para obtener f'(x).
  2. Igualar a cero y resolver f'(x) = 0 → esas soluciones son los puntos críticos.
  3. Derivar otra vez para obtener f''(x) (mide la concavidad).
  4. 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

criterio_segunda_derivada.py
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 for que arma una lista nueva: recorre cada solución c y 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.

logica/busqueda_en_intervalo.py
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 para sin(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.
Máximo en un intervalo ≠ máximo local

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).

criterio_segunda_derivada.py
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})")
Bucle for

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.

criterio_segunda_derivada.py
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"
Condición (if)

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:

Cada fila es un valor que ya calculó resolver()
PasoResultado
f'(x)2*x - 4
f'(x) = 02*x - 4 = 0
x críticox = 2
f''(x)2
f''(2)2 > 0
✅ f'' es constante e igual a 2 > 0 en todo punto → x = 2 es un mínimo, y f(2) = -1.
Método 2 de 3 · logica/metodos/newton_raphson.py

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.

  1. Empezar en un punto x₀ elegido por el usuario.
  2. 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)
  3. 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

newton_raphson.py
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
Bucle for

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 = True y break corta el for ahí 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):

Tabla real que genera resolver() — una fila por vuelta del for
nxₙf(xₙ)f'(xₙ)xₙ₊₁error
11.000000-1.0000002.0000001.5000000.500000
21.5000000.2500003.0000001.4166670.083333
31.4166670.0069442.8333331.4142160.002451
41.4142160.0000062.8284311.4142140.000002
51.4142140.0000002.8284271.4142140.000000
✅ Convergió en 5 iteraciones a x ≈ 1.414214 (= √2, con seis decimales exactos).
Método 3 de 3 · logica/metodos/seccion_aurea.py

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).

  1. Partir de un intervalo [a, b] elegido por el usuario.
  2. Ubicar dos puntos internos: x₁ = (1 − R)·(b − a) + a y x₂ = R·(b − a) + a.
  3. 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).
  4. Repetir hasta que el intervalo sea más angosto que el margen de error.

El código, paso por paso

seccion_aurea.py
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".

seccion_aurea.py
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
Qué intervalo se toma en cada vuelta (verificado con las 4 combinaciones)
Se buscaf(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 a o b es 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):

Cada fila muestra el intervalo con el que EMPIEZA la vuelta; la fila 1 es el [a, b] ingresado
nabx₁x₂f(x₁)f(x₂)decisión
10.0000005.0000001.9098303.0901701.0081312.188471f(x1) < f(x2) → [a, x2] = [0.000000, 3.090170]
20.0000003.0901701.1803401.9098301.6718431.008131f(x1) > f(x2) → [x1, b] = [1.180340, 3.090170]
31.1803403.0901701.9098302.3606801.0081311.130090f(x1) < f(x2) → [a, x2] = [1.180340, 2.360680]
41.1803402.3606801.6311901.9098301.1360211.008131f(x1) > f(x2) → [x1, b] = [1.631190, 2.360680]
51.6311902.3606801.9098302.0820391.0081311.006730f(x1) > f(x2) → [x1, b] = [1.909830, 2.360680]
61.9098302.3606802.0820392.1884711.0067301.035521f(x1) < f(x2) → [a, x2] = [1.909830, 2.188471]
✅ El intervalo quedó en [1.91, 2.19] (ancho < 0.3) → óptimo aproximado en x ≈ 2.05, f(x) ≈ 1.00 (el mínimo real está en x = 2, f(2) = 1).
Compartido por los tres métodos · logica/graficador.py

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

graficador.py · evaluar_en_puntos()
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
Bucle for

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 como NaN (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

graficador.py · _construir_figura()
# 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.
Para repasar antes de sustentar

Preguntas que podría hacer el profesor

P.¿Por qué separaron logica/ de interfaz/?
Para poder probar y explicar la matemática sin depender de la interfaz gráfica — cada 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?
En el criterio de la 2ª derivada, 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?
El 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?
Newton-Raphson usa la pendiente de la tangente (f') para decidir hacia dónde moverse — por eso converge muy rápido, pero necesita que la función sea derivable. La razón dorada solo compara valores de f en dos puntos, así que funciona incluso con funciones no derivables, a cambio de converger más despacio.
P.¿Por qué la razón dorada usa R ≈ 0.618 y no la mitad del intervalo?
Si se partiera el intervalo por la mitad (como en bisección), habría que volver a evaluar la función en ambos puntos nuevos cada vez. Con la proporción áurea, uno de los dos puntos internos de la vuelta anterior coincide con uno de los nuevos — en una versión optimizada del algoritmo eso ahorra la mitad de las evaluaciones. En este proyecto se prioriza la claridad del código (se recalculan los dos puntos cada vez), pero la proporción φ es la que se usa siempre, sea cual sea la implementación.
P.¿Por qué el diccionario de sympy tiene "__builtins__": {}?
Ver la sección "Leer la función" — evita que alguien ejecute código Python arbitrario escribiendo algo malicioso en el campo de la función.
P.¿Cómo se buscan los puntos críticos de sin(x), que tiene infinitos?
Con un intervalo [a, b]: 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?
Porque en un intervalo cerrado el máximo también puede estar en un extremo. Para 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?
Vista previa instantánea (se estira la imagen actual), dibujo en segundo plano de solo la última ventana pedida, y caché de imágenes ya dibujadas. Ver la sección "Zoom de la gráfica".
P.¿Por qué la fila 1 de la razón dorada muestra el intervalo original?
Porque la fila describe la iteración: con qué [a, b] empezó, qué puntos x₁ y x₂ se compararon y qué intervalo se tomó. El código guarda a_inicio, b_inicio antes de reemplazar a o b.
P.¿Dónde se decide el color / forma de cada tarjeta en la interfaz?
En 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.