#!/usr/bin/env python3 import json import pandas as pd import numpy as np from datetime import datetime, timedelta from typing import List, Dict, Any, Optional import plotly.graph_objects as go import plotly.express as px from plotly.subplots import make_subplots from scipy import stats from scipy.signal import find_peaks from sklearn.preprocessing import StandardScaler from sklearn.cluster import DBSCAN import gradio as gr # Store global para mantener los datos analizados analysis_store = {} class TrendsAnalyzer: def __init__(self): self.scaler = StandardScaler() def detect_trends(self, data: pd.Series, window: int = 7) -> Dict[str, Any]: """Detecta tendencias en la serie temporal""" # Suavizado con media móvil smoothed = data.rolling(window=window, center=True).mean() # Calcular pendientes slopes = [] for i in range(len(smoothed) - window): x = np.arange(window) y = smoothed.iloc[i:i+window].values if not np.isnan(y).all(): slope, _, _, _, _ = stats.linregress(x, y) slopes.append(slope) else: slopes.append(0) # Clasificar tendencias trend_threshold = np.std(slopes) * 0.5 trends = [] for slope in slopes: if slope > trend_threshold: trends.append("creciente") elif slope < -trend_threshold: trends.append("decreciente") else: trends.append("estable") return { "slopes": slopes, "trends": trends, "smoothed_data": smoothed.tolist(), "trend_strength": np.std(slopes) } def detect_anomalies(self, data: pd.Series, method: str = "zscore") -> Dict[str, Any]: """Detecta anomalías en los datos""" if method == "zscore": z_scores = np.abs(stats.zscore(data.dropna())) anomalies = z_scores > 2.5 anomaly_indices = data.index[anomalies].tolist() elif method == "iqr": Q1 = data.quantile(0.25) Q3 = data.quantile(0.75) IQR = Q3 - Q1 lower_bound = Q1 - 1.5 * IQR upper_bound = Q3 + 1.5 * IQR anomalies = (data < lower_bound) | (data > upper_bound) anomaly_indices = data.index[anomalies].tolist() elif method == "isolation": # Usar DBSCAN como aproximación values = data.values.reshape(-1, 1) values_scaled = self.scaler.fit_transform(values) clustering = DBSCAN(eps=0.5, min_samples=5).fit(values_scaled) anomalies = clustering.labels_ == -1 anomaly_indices = data.index[anomalies].tolist() return { "anomaly_indices": anomaly_indices, "anomaly_values": data.iloc[anomalies].tolist() if len(anomaly_indices) > 0 else [], "total_anomalies": len(anomaly_indices) } def detect_seasonality(self, data: pd.Series, periods: List[int] = [7, 30, 365]) -> Dict[str, Any]: """Detecta patrones estacionales""" seasonality_results = {} for period in periods: if len(data) >= period * 2: # Autocorelación para detectar periodicidad autocorr = [] for lag in range(1, min(period + 1, len(data) // 2)): corr = data.autocorr(lag=lag) autocorr.append(corr if not np.isnan(corr) else 0) max_corr = max(autocorr) if autocorr else 0 seasonality_results[f"period_{period}"] = { "strength": max_corr, "detected": max_corr > 0.3 } return seasonality_results def find_peaks_valleys(self, data: pd.Series) -> Dict[str, Any]: """Encuentra picos y valles en la serie""" # Encontrar picos peaks, peak_properties = find_peaks(data.values, prominence=np.std(data) * 0.5) # Encontrar valles (picos invertidos) valleys, valley_properties = find_peaks(-data.values, prominence=np.std(data) * 0.5) return { "peaks": { "indices": data.index[peaks].tolist(), "values": data.iloc[peaks].tolist(), "count": len(peaks) }, "valleys": { "indices": data.index[valleys].tolist(), "values": data.iloc[valleys].tolist(), "count": len(valleys) } } def generate_forecast(self, data: pd.Series, periods: int = 30) -> Dict[str, Any]: """Genera pronóstico simple usando tendencia lineal""" # Ajustar modelo lineal simple x = np.arange(len(data)) y = data.values # Remover NaN mask = ~np.isnan(y) if mask.sum() < 2: return {"error": "Insuficientes datos para pronóstico"} slope, intercept, r_value, _, _ = stats.linregress(x[mask], y[mask]) # Generar pronóstico future_x = np.arange(len(data), len(data) + periods) forecast = slope * future_x + intercept # Calcular intervalos de confianza (simplificados) residuals = y[mask] - (slope * x[mask] + intercept) mse = np.mean(residuals ** 2) std_error = np.sqrt(mse) return { "forecast_values": forecast.tolist(), "confidence_upper": (forecast + 1.96 * std_error).tolist(), "confidence_lower": (forecast - 1.96 * std_error).tolist(), "r_squared": r_value ** 2, "trend_slope": slope } # Instancia global del analizador analyzer = TrendsAnalyzer() def create_visualizations(df: pd.DataFrame, column: str, analysis_results: Dict[str, Any]) -> go.Figure: """Crea las visualizaciones de la serie temporal""" fig = make_subplots( rows=3, cols=2, subplot_titles=( 'Serie Temporal Original', 'Tendencias Detectadas', 'Anomalías', 'Picos y Valles', 'Pronóstico', 'Distribución de Valores' ), specs=[[{"secondary_y": False}, {"secondary_y": False}], [{"secondary_y": False}, {"secondary_y": False}], [{"secondary_y": False}, {"secondary_y": False}]] ) # Serie original fig.add_trace( go.Scatter(x=df.index, y=df[column], name="Original", line=dict(color="blue")), row=1, col=1 ) # Tendencias if 'trends' in analysis_results: smoothed = analysis_results['trends']['smoothed_data'] fig.add_trace( go.Scatter(x=df.index, y=smoothed, name="Suavizada", line=dict(color="orange")), row=1, col=2 ) # Anomalías if 'anomalies' in analysis_results and analysis_results['anomalies']['anomaly_indices']: anomaly_indices = analysis_results['anomalies']['anomaly_indices'] anomaly_values = analysis_results['anomalies']['anomaly_values'] fig.add_trace( go.Scatter(x=df.index, y=df[column], name="Serie", line=dict(color="blue")), row=2, col=1 ) fig.add_trace( go.Scatter( x=anomaly_indices, y=anomaly_values, mode="markers", name="Anomalías", marker=dict(color="red", size=8) ), row=2, col=1 ) # Picos y valles if 'peaks_valleys' in analysis_results: fig.add_trace( go.Scatter(x=df.index, y=df[column], name="Serie", line=dict(color="blue")), row=2, col=2 ) peaks = analysis_results['peaks_valleys']['peaks'] if peaks['indices']: fig.add_trace( go.Scatter( x=peaks['indices'], y=peaks['values'], mode="markers", name="Picos", marker=dict(color="green", size=8, symbol="triangle-up") ), row=2, col=2 ) valleys = analysis_results['peaks_valleys']['valleys'] if valleys['indices']: fig.add_trace( go.Scatter( x=valleys['indices'], y=valleys['values'], mode="markers", name="Valles", marker=dict(color="red", size=8, symbol="triangle-down") ), row=2, col=2 ) # Pronóstico if 'forecast' in analysis_results and 'error' not in analysis_results['forecast']: forecast_data = analysis_results['forecast'] last_date = df.index[-1] # Generar fechas futuras if isinstance(last_date, pd.Timestamp): future_dates = pd.date_range( start=last_date + pd.Timedelta(days=1), periods=len(forecast_data['forecast_values']), freq='D' ) else: future_dates = range(len(df), len(df) + len(forecast_data['forecast_values'])) # Serie histórica fig.add_trace( go.Scatter(x=df.index, y=df[column], name="Histórico", line=dict(color="blue")), row=3, col=1 ) # Pronóstico fig.add_trace( go.Scatter( x=future_dates, y=forecast_data['forecast_values'], name="Pronóstico", line=dict(color="red", dash="dash") ), row=3, col=1 ) # Intervalos de confianza fig.add_trace( go.Scatter( x=future_dates, y=forecast_data['confidence_upper'], fill=None, mode='lines', line=dict(color='rgba(0,0,0,0)'), showlegend=False ), row=3, col=1 ) fig.add_trace( go.Scatter( x=future_dates, y=forecast_data['confidence_lower'], fill='tonexty', mode='lines', line=dict(color='rgba(0,0,0,0)'), name='Intervalo de Confianza', fillcolor='rgba(255,0,0,0.2)' ), row=3, col=1 ) # Distribución fig.add_trace( go.Histogram(x=df[column], name="Distribución", nbinsx=30), row=3, col=2 ) fig.update_layout(height=1200, showlegend=True, title_text="Análisis de Tendencias Temporales") return fig def analyze_time_series(file, column_name, trend_window, anomaly_method, forecast_periods): """ Analiza una serie temporal para detectar tendencias, anomalías, estacionalidad y picos/valles.""" try: # Leer archivo if file.name.endswith('.csv'): df = pd.read_csv(file.name) elif file.name.endswith(('.xlsx', '.xls')): df = pd.read_excel(file.name) else: return "Error: Formato de archivo no soportado", None, None # Validar columna if column_name not in df.columns: return f"Error: Columna '{column_name}' no encontrada", None, None # Intentar convertir índice a datetime si es posible if 'fecha' in df.columns or 'date' in df.columns: date_col = 'fecha' if 'fecha' in df.columns else 'date' df[date_col] = pd.to_datetime(df[date_col]) df.set_index(date_col, inplace=True) # Preparar serie series = df[column_name].astype(float) # Realizar análisis results = {} # Análisis de tendencias results['trends'] = analyzer.detect_trends(series, window=trend_window) # Detección de anomalías results['anomalies'] = analyzer.detect_anomalies(series, method=anomaly_method) # Detección de estacionalidad results['seasonality'] = analyzer.detect_seasonality(series) # Picos y valles results['peaks_valleys'] = analyzer.find_peaks_valleys(series) # Pronóstico results['forecast'] = analyzer.generate_forecast(series, periods=forecast_periods) # Guardar resultados analysis_id = f"analysis_{datetime.now().strftime('%Y%m%d_%H%M%S')}" analysis_store[analysis_id] = { 'data': df, 'column': column_name, 'results': results } # Crear visualización fig = create_visualizations(df, column_name, results) # Generar reporte report = generate_report(results, series) return report, fig, analysis_id except Exception as e: return f"Error en el análisis: {str(e)}", None, None def generate_report(results: Dict[str, Any], series: pd.Series) -> str: """Genera un reporte textual del análisis""" report = "# Reporte de Análisis de Tendencias Temporales\n\n" # Estadísticas básicas report += "## Estadísticas Básicas\n" report += f"- Número de observaciones: {len(series)}\n" report += f"- Media: {series.mean():.2f}\n" report += f"- Desviación estándar: {series.std():.2f}\n" report += f"- Mínimo: {series.min():.2f}\n" report += f"- Máximo: {series.max():.2f}\n\n" # Tendencias if 'trends' in results: trend_strength = results['trends']['trend_strength'] report += "## Análisis de Tendencias\n" report += f"- Fuerza de la tendencia: {trend_strength:.4f}\n" if trend_strength > 0.1: report += "- La serie presenta tendencias significativas\n\n" else: report += "- La serie presenta tendencias débiles o estables\n\n" # Anomalías if 'anomalies' in results: anomaly_count = results['anomalies']['total_anomalies'] report += "## Detección de Anomalías\n" report += f"- Anomalías detectadas: {anomaly_count}\n" report += f"- Porcentaje de anomalías: {(anomaly_count/len(series)*100):.1f}%\n\n" # Estacionalidad if 'seasonality' in results: report += "## Análisis de Estacionalidad\n" for period, data in results['seasonality'].items(): period_name = period.replace('period_', '') if data['detected']: report += f"- Patrón estacional de {period_name} períodos detectado (fuerza: {data['strength']:.3f})\n" report += "\n" # Picos y valles if 'peaks_valleys' in results: peaks_count = results['peaks_valleys']['peaks']['count'] valleys_count = results['peaks_valleys']['valleys']['count'] report += "## Picos y Valles\n" report += f"- Picos detectados: {peaks_count}\n" report += f"- Valles detectados: {valleys_count}\n\n" # Pronóstico if 'forecast' in results and 'error' not in results['forecast']: r_squared = results['forecast']['r_squared'] slope = results['forecast']['trend_slope'] report += "## Pronóstico\n" report += f"- R² del modelo: {r_squared:.3f}\n" report += f"- Pendiente de tendencia: {slope:.4f}\n" if slope > 0: report += "- Tendencia proyectada: Creciente\n" elif slope < 0: report += "- Tendencia proyectada: Decreciente\n" else: report += "- Tendencia proyectada: Estable\n" return report # Funciones MCP para Gradio 5 def mcp_analyze_trends(data: List[float], dates: Optional[List[str]] = None, window: int = 7, anomaly_method: str = "zscore") -> str: """ Analiza una serie temporal para detectar tendencias, anomalías, estacionalidad y picos/valles. Parámetros: - data (List[float]): Serie de valores numéricos a analizar. - dates (List[str], opcional): Fechas correspondientes a los datos. Si se omite, se usará índice por posición. - window (int): Ventana de suavizado para detección de tendencias. - anomaly_method (str): Método de detección de anomalías. Opciones: "zscore", "iqr", "isolation". Retorna: - str: JSON con resumen del análisis, resultados completos y un ID único.""" def make_json_serializable(obj): """Convierte objetos no serializables a JSON""" if isinstance(obj, np.integer): return int(obj) elif isinstance(obj, np.floating): return float(obj) elif isinstance(obj, np.ndarray): return obj.tolist() elif isinstance(obj, pd.Timestamp): return obj.isoformat() elif isinstance(obj, pd.Index): return obj.tolist() elif isinstance(obj, dict): return {key: make_json_serializable(value) for key, value in obj.items()} elif isinstance(obj, list): return [make_json_serializable(item) for item in obj] elif isinstance(obj, (bool, np.bool_)): return bool(obj) elif pd.isna(obj): return None else: return obj try: # Crear serie pandas if dates: index = pd.to_datetime(dates) series = pd.Series(data, index=index) else: series = pd.Series(data) # Realizar análisis results = {} results['trends'] = analyzer.detect_trends(series, window=window) results['anomalies'] = analyzer.detect_anomalies(series, method=anomaly_method) results['seasonality'] = analyzer.detect_seasonality(series) results['peaks_valleys'] = analyzer.find_peaks_valleys(series) # Convertir a JSON serializable results_serializable = make_json_serializable(results) # Guardar análisis analysis_id = f"mcp_analysis_{datetime.now().strftime('%Y%m%d_%H%M%S')}" analysis_store[analysis_id] = { 'data': series.to_frame('value'), 'column': 'value', 'results': results } return json.dumps({ "analysis_id": analysis_id, "summary": { "total_points": len(data), "anomalies_detected": int(results['anomalies']['total_anomalies']), "peaks_count": int(results['peaks_valleys']['peaks']['count']), "valleys_count": int(results['peaks_valleys']['valleys']['count']), "trend_strength": float(results['trends']['trend_strength']) }, "results": results_serializable }, indent=2) except Exception as e: return f"Error en análisis MCP: {str(e)}" def mcp_get_analysis_report(analysis_id: str) -> str: """Recupera el reporte textual completo de un análisis previo a partir de su ID. Parámetros: - analysis_id (str): Identificador único del análisis almacenado, obtenido de mcp_analyze_trends. Formato: "mcp_analysis_YYYYMMDD_HHMMSS" Retorna: - str: Reporte completo en formato Markdown con: * Estadísticas básicas (media, desviación, min/max) * Análisis de tendencias (fuerza y dirección) * Detección de anomalías (cantidad y porcentaje) * Análisis de estacionalidad (patrones detectados) * Picos y valles (conteo de extremos) * Información de pronóstico si está disponible En caso de error o ID no encontrado, devuelve mensaje de error.""" try: if analysis_id not in analysis_store: return "Error: Análisis no encontrado" stored_analysis = analysis_store[analysis_id] series = stored_analysis['data'][stored_analysis['column']] results = stored_analysis['results'] report = generate_report(results, series) return report except Exception as e: return f"Error obteniendo reporte: {str(e)}" def mcp_forecast_series(data: List[float], periods: int = 30) -> str: """ Genera un pronóstico lineal para una serie temporal basado en regresión lineal simple. Parámetros: - data (List[float]): Serie de datos históricos numéricos. Mínimo 2 valores requeridos. Ejemplo: [100, 105, 110, 108, 115, 120] - periods (int): Número de períodos futuros a predecir (5-100). Por defecto: 30 Retorna: - str: JSON con pronóstico que incluye: * forecast_values: Array de valores pronosticados * confidence_upper: Límite superior del intervalo de confianza (95%) * confidence_lower: Límite inferior del intervalo de confianza (95%) * r_squared: Coeficiente de determinación del modelo (0-1, donde 1 es perfecto) * trend_slope: Pendiente de la tendencia (positivo=creciente, negativo=decreciente, ~0=estable) Usa regresión lineal simple con intervalos de confianza del 95%. En caso de datos insuficientes, retorna error.""" try: series = pd.Series(data) forecast_results = analyzer.generate_forecast(series, periods=periods) return json.dumps(forecast_results, indent=2) except Exception as e: return f"Error en pronóstico: {str(e)}" # Interfaz Gradio con funciones MCP integradas def create_gradio_interface(): with gr.Blocks(title="Análisis de Tendencias Temporales MCP", theme=gr.themes.Soft()) as interface: gr.Markdown("# 📈 Análisis de Tendencias Temporales con MCP") gr.Markdown("Servidor de análisis con capacidades MCP integradas para series temporales.") with gr.Tab("🔍 Análisis de Archivos"): with gr.Row(): with gr.Column(scale=1): file_input = gr.File( label="Subir archivo (CSV/Excel)", file_types=[".csv", ".xlsx", ".xls"] ) column_input = gr.Textbox( label="Nombre de la columna a analizar", placeholder="ej: ventas, temperatura, precio" ) with gr.Row(): trend_window = gr.Slider( minimum=3, maximum=30, value=7, label="Ventana para detectar tendencias" ) forecast_periods = gr.Slider( minimum=5, maximum=100, value=30, label="Períodos a pronosticar" ) anomaly_method = gr.Dropdown( choices=["zscore", "iqr", "isolation"], value="zscore", label="Método de detección de anomalías" ) analyze_btn = gr.Button("🔍 Analizar Serie Temporal", variant="primary") with gr.Column(scale=2): report_output = gr.Markdown(label="Reporte de Análisis") analysis_id_output = gr.Textbox(label="ID de Análisis", visible=True) with gr.Row(): plot_output = gr.Plot(label="Visualizaciones") analyze_btn.click( analyze_time_series, inputs=[file_input, column_input, trend_window, anomaly_method, forecast_periods], outputs=[report_output, plot_output, analysis_id_output] ) with gr.Tab("🤖 Funciones MCP"): gr.Markdown("## Funciones MCP para Análisis Programático") with gr.Row(): with gr.Column(): gr.Markdown("### Analizar Serie Temporal") mcp_data_input = gr.Textbox( label="Datos (JSON array)", placeholder='[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]', lines=3 ) mcp_dates_input = gr.Textbox( label="Fechas (opcional, JSON array)", placeholder='["2024-01-01", "2024-01-02", ...]', lines=2 ) mcp_window = gr.Number(label="Ventana", value=7) mcp_method = gr.Dropdown( choices=["zscore", "iqr", "isolation"], value="zscore", label="Método de anomalías" ) mcp_analyze_btn = gr.Button("📊 Analizar con MCP") with gr.Column(): gr.Markdown("### Obtener Reporte") mcp_analysis_id = gr.Textbox( label="ID de Análisis", placeholder="analysis_20241201_123456" ) mcp_report_btn = gr.Button("📄 Obtener Reporte") gr.Markdown("### Generar Pronóstico") mcp_forecast_data = gr.Textbox( label="Datos para Pronóstico", placeholder='[10, 12, 14, 16, 18, 20]', lines=2 ) mcp_periods = gr.Number(label="Períodos", value=10) mcp_forecast_btn = gr.Button("🔮 Pronosticar") mcp_output = gr.Textbox( label="Resultado MCP", lines=15, show_copy_button=True ) def handle_mcp_analyze(data_str, dates_str, window, method): """Función auxiliar para interfaz Gradio. Convierte strings JSON a listas y llama mcp_analyze_trends. Parámetros: - data_str (str): JSON string con array de números - dates_str (str): JSON string con array de fechas (opcional) - window (int): Ventana de tendencias - method (str): Método de detección de anomalías Retorna resultado de mcp_analyze_trends o mensaje de error.""" try: data = json.loads(data_str) dates = json.loads(dates_str) if dates_str.strip() else None return mcp_analyze_trends(data, dates, int(window), method) except Exception as e: return f"Error: {str(e)}" def handle_mcp_forecast(data_str, periods): """Función auxiliar para interfaz Gradio. Convierte string JSON a lista y llama mcp_forecast_series. Parámetros: - data_str (str): JSON string con array de números históricos - periods (int): Períodos a pronosticar Retorna resultado de mcp_forecast_series o mensaje de error.""" try: data = json.loads(data_str) return mcp_forecast_series(data, int(periods)) except Exception as e: return f"Error: {str(e)}" mcp_analyze_btn.click( handle_mcp_analyze, inputs=[mcp_data_input, mcp_dates_input, mcp_window, mcp_method], outputs=mcp_output ) mcp_report_btn.click( mcp_get_analysis_report, inputs=mcp_analysis_id, outputs=mcp_output ) mcp_forecast_btn.click( handle_mcp_forecast, inputs=[mcp_forecast_data, mcp_periods], outputs=mcp_output ) with gr.Tab("📚 Documentación"): gr.Markdown(""" ## Available MCP Functions ### 1. `analyze_trends` Analyzes a time series to detect trends, anomalies, and patterns. **Parameters:** - `data`: Array of numbers (required) - `dates`: Array of dates (optional) - `window`: Trend smoothing window (default: 7) - `anomaly_method`: Detection method ("zscore", "iqr", "isolation") **Returns:** JSON with analysis ID and full results ### 2. `get_analysis_report` Retrieves the textual report of a previous analysis. **Parameters:** - `analysis_id`: Analysis ID (required) **Returns:** Report in Markdown format ### 3. `forecast_series` Generates forecasts for a time series. **Parameters:** - `data`: Array of historical numbers (required) - `periods`: Number of periods to forecast (default: 30) **Returns:** JSON with forecasted values and confidence intervals ## Analysis Features - 🔍 **Trend detection** with moving average smoothing - ⚠️ **Anomaly identification** using multiple methods - 📅 **Seasonality analysis** for recurring patterns - 📊 **Peak and valley detection** using scipy - 🔮 **Linear forecasting** with confidence intervals - 📈 **Interactive visualizations** with Plotly ## Use from External Applications This server can be used as a full MCP server, providing time series analysis through structured function calls. """) return interface def main(): """Función principal""" print("🚀 Iniciando Servidor de Análisis de Tendencias Temporales") print("📊 Con capacidades MCP integradas en Gradio 5") # Crear y lanzar interfaz con MCP habilitado interface = create_gradio_interface() # Lanzar con MCP=True para habilitar capacidades MCP en Gradio 5 interface.launch( share=True, mcp_server=True # Habilita capacidades MCP en Gradio 5 ) if __name__ == "__main__": main()