La serie de Fourier proporciona una representación adecuada de una señal periódica siempre que ésta tenga potencia finita. En particular, para una señal periódica x~(t) de periodo fundamental T0:
En este caso, la suma de un número creciente de armónicos permite aproximar progresivamente la señal original. Si se considera la aproximación parcial de orden N,
Este resultado pone de manifiesto que la serie de Fourier reproduce correctamente el comportamiento global de la señal y su contenido espectral, aunque la convergencia no tiene por qué ser puntual en todos los instantes de tiempo.
En señales que presentan discontinuidades pueden aparecer oscilaciones locales en las proximidades de dichas discontinuidades, hecho conocido como fenómeno de Gibbs, que no desaparece al aumentar el número de armónicos, pero queda confinado a un entorno reducido alrededor de los puntos de discontinuidad (ver Efecto de Gibbs).
Bajo condiciones adicionales de regularidad, conocidas como condiciones de Dirichlet, puede garantizarse además la convergencia puntual de la serie de Fourier. En particular, dichas condiciones establecen que:
En este caso, la serie de Fourier converge al valor de la señal en los puntos de continuidad y al valor medio de los límites laterales en los puntos de discontinuidad.
import numpy as np
from bokeh.plotting import figure, show, output_notebook
from bokeh.models import ColumnDataSource, CustomJS, Slider, BoxAnnotation, Div, Arrow, NormalHead, Label, LabelSet, Span
from bokeh.layouts import column, row
# from myst_nb import glue # Descomenta si usas glue
output_notebook()
# --- PEGA AQUÍ LA FUNCIÓN HELPER style_math_axes QUE DEFINIMOS ARRIBA ---
# (O impórtala si la guardas en un archivo .py)
# ==============================================================================
# 1. DATOS PRE-CALCULADOS
# ==============================================================================
t = np.linspace(-1.1, 2.5, 5000)
square_wave_ideal = np.sign(np.sin(np.pi * t))
def fourier_square_sum(t, n_harmonics):
approximation = np.zeros_like(t)
for k in range(1, n_harmonics + 1, 2):
approximation += np.sin(k * np.pi * t) / k
approximation *= (4 / np.pi)
return approximation
N_values = [1, 3, 5, 7, 9, 11, 13, 15, 19, 25, 35, 51, 75, 101, 151, 201, 251]
data_dict = {'t': t, 'ideal': square_wave_ideal}
peak_data = {}
zoom_limits = {}
for n in N_values:
y_vals = fourier_square_sum(t, n)
data_dict[f'y_{n}'] = y_vals
search_indices = np.where((t > 0) & (t < 0.5))[0]
local_y = y_vals[search_indices]
if len(local_y) > 0:
max_idx_local = np.argmax(local_y)
max_val = local_y[max_idx_local]
max_t = t[search_indices[max_idx_local]]
overshoot_pct = (max_val - 1.0) * 100
label_text = f"Max: {max_val:.3f} (+{overshoot_pct:.1f}%)"
else:
max_t = 0.5; max_val = 4/np.pi; label_text = f"Max: {max_val:.3f}"
peak_data[f'p_{n}'] = {'t': [max_t], 'y': [max_val], 'text': [label_text]}
if n < 9:
zoom_limits[f'z_{n}'] = {'xs': -0.2, 'xe': max_t + 0.2, 'ys': -0.2, 'ye': 1.5}
else:
zoom_limits[f'z_{n}'] = {'xs': -0.05, 'xe': 0.2, 'ys': 0.8, 'ye': 1.25}
source = ColumnDataSource(data=dict(t=t, ideal=square_wave_ideal, y=data_dict['y_1']))
source_peak = ColumnDataSource(data=peak_data['p_1'])
# ==============================================================================
# 2. CONFIGURACIÓN VISUAL (REFACTORIZADO)
# ==============================================================================
COLOR_APROX = 'blue'
# RANGOS DE DATOS PUROS (Sin márgenes)
# Tu señal va de -1.0 a 2.4 en X
# Y va de aprox -1.3 a 1.3 en Y (el overshoot)
X_DATA_RANGE = (-1.0, 2.4)
Y_DATA_RANGE = (-1.2, 1.3) # Ponemos el rango "real" de la señal
# --- GRÁFICA PRINCIPAL ---
p_main = figure(height=400, width=450, tools="pan,wheel_zoom,reset",
title="Aproximación parcial de la serie de Fourier")
# >>> LLAMADA A LA FUNCIÓN HELPER <<<
# Esto sustituye a las 20 líneas de código de ejes manuales
style_math_axes(p_main,
x_range=X_DATA_RANGE,
y_range=Y_DATA_RANGE,
prolong_axes=[0.1, 0.1],
margins=[0, 0, 0, 0.05],
xlabel="t",
ylabel=r"$$\tilde{x}_N(t)$$")
# Ticks manuales (Esto es específico de esta gráfica, así que lo dejamos aquí)
for i in [1, 2]:
p_main.add_layout(Label(x=i, y=-0.2, text=str(i), text_align="center", text_font_size="10pt"))
for j in [-1, 1]:
p_main.add_layout(Label(x=-0.15, y=j, text=str(j), text_align="right", text_baseline="middle", text_font_size="10pt"))
p_main.add_layout(Label(x=-0.1, y=-0.2, text="0", text_font_size="10pt"))
# Fórmula Dinámica
N_init = N_values[0]
latex_text_init = r"$$\tilde{x}_{" + str(N_init) + r"}(t) = \sum_{k=-" + str(N_init) + r"}^{" + str(N_init) + r"} c_k e^{jk\pi t}$$"
formula_label = Label(x=0.6, y=1.3, text=latex_text_init, text_font_size="9pt", text_color="#333")
p_main.add_layout(formula_label)
# Curvas
p_main.line('t', 'ideal', source=source, color="black", alpha=0.3, line_width=1.5, line_dash="dashed", legend_label="Ideal")
p_main.line('t', 'y', source=source, color=COLOR_APROX, line_width=2, legend_label="Aprox")
p_main.varea(x='t', y1='ideal', y2='y', source=source, fill_color="red", fill_alpha=0.2, legend_label="Error")
# Caja de Zoom en principal
init_zoom = zoom_limits['z_1']
zoom_box = BoxAnnotation(left=init_zoom['xs'], right=init_zoom['xe'], bottom=init_zoom['ys'], top=init_zoom['ye'],
fill_color="gray", fill_alpha=0.1, line_color="gray", line_dash="dotted")
p_main.add_layout(zoom_box)
p_main.legend.location = "top_right"; p_main.legend.click_policy = "hide"; p_main.legend.border_line_color = None
# --- GRÁFICA ZOOM ---
# Esta mantiene su estilo "caja" (con bordes), pero quitamos ejes si quieres,
# o la dejamos estándar como estaba en tu ejemplo original. Aquí la dejo estándar.
p_zoom = figure(title="Zoom Dinámico", height=400, width=200,
x_range=(init_zoom['xs'], init_zoom['xe']),
y_range=(init_zoom['ys'], init_zoom['ye']),
tools="", toolbar_location=None,
background_fill_color="white", border_fill_color="white")
p_zoom.line('t', 'ideal', source=source, color="black", alpha=0.3, line_width=1.5, line_dash="dashed")
p_zoom.varea(x='t', y1='ideal', y2='y', source=source, fill_color="red", fill_alpha=0.2)
p_zoom.line('t', 'y', source=source, color=COLOR_APROX, line_width=3)
p_zoom.scatter('t', 'y', source=source_peak, size=8, color="red", marker="circle")
labels = LabelSet(x='t', y='y', text='text', source=source_peak,
text_font_size="8pt", text_color="red", x_offset=-60, y_offset=5, text_align="left")
p_zoom.add_layout(labels)
p_zoom.xaxis.axis_label = "t"; p_zoom.grid.grid_line_color = "#eeeeee"
# ==============================================================================
# 3. INTERACTIVIDAD (IGUAL)
# ==============================================================================
div_info = Div(text=f"<h3 style='margin-bottom:0px; color:#333'>Armónicos: N = {N_values[0]}</h3>", width=300)
slider = Slider(start=0, end=len(N_values)-1, value=0, step=1, title="", sizing_mode="stretch_width", show_value=False)
callback = CustomJS(args=dict(source=source, source_peak=source_peak, slider=slider, div=div_info,
all_data=data_dict, all_peaks=peak_data, all_lims=zoom_limits,
N_vals=N_values, p_zoom=p_zoom, zoom_box=zoom_box,
formula_label=formula_label), code="""
const idx = slider.value;
const N = N_vals[idx];
const data = source.data;
const y_new = all_data['y_' + N];
for (let i = 0; i < data['y'].length; i++) { data['y'][i] = y_new[i]; }
const peak_curr = all_peaks['p_' + N];
source_peak.data = { 't': peak_curr['t'], 'y': peak_curr['y'], 'text': peak_curr['text'] };
const lims = all_lims['z_' + N];
p_zoom.x_range.start = lims.xs; p_zoom.x_range.end = lims.xe;
p_zoom.y_range.start = lims.ys; p_zoom.y_range.end = lims.ye;
zoom_box.left = lims.xs; zoom_box.right = lims.xe; zoom_box.bottom = lims.ys; zoom_box.top = lims.ye;
div.text = "<h3 style='margin-bottom:0px; color:#333'>Armónicos: N = " + N + "</h3>";
formula_label.text = "$$\\\\tilde{x}_{" + N + "}(t) = \\\\sum_{k=-" + N + "}^{" + N + "} c_k e^{jk\\\\pi t}$$";
source.change.emit(); source_peak.change.emit();
""")
slider.js_on_change('value', callback)
texto_caption = """
<div style="font-family: sans-serif; margin-top: 10px; font-size: 14px; opacity: 0.8;">
<b>Efecto de Gibbs.</b> El sobreimpulso persiste cerca de las discontinuidades.
</div>
"""
caption = Div(text=texto_caption, sizing_mode="stretch_width")
layout_final = column(div_info, slider, row(p_main, p_zoom, sizing_mode="scale_width"), caption, sizing_mode="scale_width")
show(layout_final)