Spaces:
Build error
Build error
| /* global Office, Excel, console */ | |
| // ==================================================================== | |
| // CHRONOS2 FORECASTING ADD-IN | |
| // Office.js Task Pane Implementation | |
| // ==================================================================== | |
| // URL del API en HuggingFace Spaces | |
| const API_BASE_URL = 'https://ttzzs-chronos2-excel-forecasting-api.hf.space'; | |
| // Para desarrollo local, descomenta la siguiente línea: | |
| // const API_BASE_URL = 'https://localhost:8000'; | |
| // Inicializar cuando Office esté listo | |
| Office.onReady((info) => { | |
| if (info.host === Office.HostType.Excel) { | |
| console.log('Chronos2 Add-in loaded successfully'); | |
| checkServerStatus(); | |
| // Auto-check cada 30 segundos | |
| setInterval(checkServerStatus, 30000); | |
| } | |
| }); | |
| // ==================================================================== | |
| // UTILIDADES | |
| // ==================================================================== | |
| function log(message, type = 'info') { | |
| const resultsDiv = document.getElementById('results'); | |
| const timestamp = new Date().toLocaleTimeString(); | |
| const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : 'ℹ️'; | |
| const entry = document.createElement('div'); | |
| entry.className = `log-entry log-${type}`; | |
| entry.innerHTML = `<span class="timestamp">${timestamp}</span> ${icon} ${message}`; | |
| resultsDiv.insertBefore(entry, resultsDiv.firstChild); | |
| // Limitar a 20 entries | |
| while (resultsDiv.children.length > 20) { | |
| resultsDiv.removeChild(resultsDiv.lastChild); | |
| } | |
| } | |
| async function checkServerStatus() { | |
| try { | |
| const response = await fetch(`${API_BASE_URL}/health`, { | |
| method: 'GET', | |
| headers: { 'Content-Type': 'application/json' } | |
| }); | |
| const data = await response.json(); | |
| if (response.ok) { | |
| updateServerStatus(true, `Connected - ${data.model_id}`); | |
| } else { | |
| updateServerStatus(false, 'Server error'); | |
| } | |
| } catch (error) { | |
| updateServerStatus(false, 'Server offline'); | |
| } | |
| } | |
| function updateServerStatus(isOnline, message) { | |
| const statusEl = document.getElementById('serverStatus'); | |
| const textEl = document.getElementById('statusText'); | |
| statusEl.className = `status-indicator ${isOnline ? 'online' : 'offline'}`; | |
| textEl.textContent = message; | |
| } | |
| function showTab(tabName) { | |
| // Ocultar todos los tabs | |
| const tabs = document.querySelectorAll('.tab-content'); | |
| tabs.forEach(tab => tab.classList.remove('active')); | |
| const buttons = document.querySelectorAll('.tab'); | |
| buttons.forEach(btn => btn.classList.remove('active')); | |
| // Mostrar el tab seleccionado | |
| document.getElementById(`tab-${tabName}`).classList.add('active'); | |
| event.target.classList.add('active'); | |
| } | |
| // ==================================================================== | |
| // FUNCIONES DE EXCEL (Office.js) | |
| // ==================================================================== | |
| async function getSelectedRange() { | |
| return Excel.run(async (context) => { | |
| const range = context.workbook.getSelectedRange(); | |
| range.load('values, address'); | |
| await context.sync(); | |
| return { | |
| values: range.values, | |
| address: range.address | |
| }; | |
| }); | |
| } | |
| async function writeToRange(data, startCell) { | |
| return Excel.run(async (context) => { | |
| try { | |
| console.log(`[writeToRange] Writing ${data?.length || 0} rows to ${startCell}`); | |
| console.log('[writeToRange] Data:', JSON.stringify(data).substring(0, 200)); | |
| if (!data || data.length === 0) { | |
| throw new Error('No data to write'); | |
| } | |
| if (!data[0] || data[0].length === 0) { | |
| throw new Error('Invalid data structure: empty first row'); | |
| } | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| const numRows = data.length; | |
| const numCols = data[0].length; | |
| console.log(`[writeToRange] Creating range: ${numRows} rows x ${numCols} cols from ${startCell}`); | |
| const range = sheet.getRange(startCell).getResizedRange(numRows - 1, numCols - 1); | |
| range.values = data; | |
| range.format.autofitColumns(); | |
| await context.sync(); | |
| console.log('[writeToRange] ✅ Data written successfully'); | |
| } catch (error) { | |
| console.error('[writeToRange] ❌ Error:', error); | |
| console.error('[writeToRange] Stack:', error.stack); | |
| throw error; | |
| } | |
| }); | |
| } | |
| async function writeForecastResults(timestamps, median, q10, q90, startRow) { | |
| return Excel.run(async (context) => { | |
| try { | |
| console.log('[writeForecastResults] Starting...'); | |
| console.log(`[writeForecastResults] timestamps: ${timestamps?.length || 0} items`); | |
| console.log(`[writeForecastResults] median: ${median?.length || 0} items`); | |
| console.log(`[writeForecastResults] q10: ${q10?.length || 0} items`); | |
| console.log(`[writeForecastResults] q90: ${q90?.length || 0} items`); | |
| console.log(`[writeForecastResults] startRow: ${startRow}`); | |
| // VALIDACIÓN | |
| if (!timestamps || !median) { | |
| throw new Error('Invalid data: timestamps or median is undefined'); | |
| } | |
| if (timestamps.length === 0) { | |
| throw new Error('No forecast data received (empty timestamps)'); | |
| } | |
| if (timestamps.length !== median.length) { | |
| throw new Error(`Data mismatch: ${timestamps.length} timestamps vs ${median.length} median values`); | |
| } | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| // Preparar datos | |
| const data = []; | |
| data.push(['Timestamp', 'Median', 'Q10', 'Q90']); // Headers | |
| for (let i = 0; i < timestamps.length; i++) { | |
| data.push([ | |
| timestamps[i], | |
| median[i], | |
| q10 ? q10[i] : '', | |
| q90 ? q90[i] : '' | |
| ]); | |
| } | |
| console.log(`[writeForecastResults] Prepared ${data.length} rows (including header)`); | |
| // Escribir en columnas D-G a partir de la fila especificada | |
| const startCell = `D${startRow}`; | |
| console.log(`[writeForecastResults] Writing to ${startCell}`); | |
| await writeToRange(data, startCell); | |
| // Aplicar formato | |
| const headerRange = sheet.getRange(`D${startRow}:G${startRow}`); | |
| headerRange.format.font.bold = true; | |
| headerRange.format.fill.color = '#4472C4'; | |
| headerRange.format.font.color = 'white'; | |
| await context.sync(); | |
| console.log('[writeForecastResults] ✅ Forecast results written successfully'); | |
| } catch (error) { | |
| console.error('[writeForecastResults] ❌ Error:', error); | |
| console.error('[writeForecastResults] Stack:', error.stack); | |
| throw error; | |
| } | |
| }); | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 1: PRONÓSTICO UNIVARIANTE | |
| // ==================================================================== | |
| async function forecastUnivariate() { | |
| log('Starting univariate forecast...'); | |
| try { | |
| // Leer rango seleccionado | |
| const selection = await getSelectedRange(); | |
| const values = selection.values.flat().filter(v => v !== '' && !isNaN(v)); | |
| if (values.length < 3) { | |
| log('Error: Select at least 3 data points', 'error'); | |
| return; | |
| } | |
| log(`Selected ${values.length} data points from ${selection.address}`); | |
| // Obtener parámetros | |
| const predictionLength = parseInt(document.getElementById('predictionLength').value); | |
| const frequency = document.getElementById('frequency').value; | |
| // Construir request | |
| const requestBody = { | |
| prediction_length: predictionLength, | |
| series: { values: values }, | |
| start_timestamp: new Date().toISOString().split('T')[0], | |
| freq: frequency, | |
| quantile_levels: [0.1, 0.5, 0.9] | |
| }; | |
| log('Sending request to API...'); | |
| // Llamar a la API | |
| const response = await fetch(`${API_BASE_URL}/forecast_univariate`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| log(`Received forecast for ${data.timestamps.length} periods`, 'success'); | |
| // Escribir resultados | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| await writeForecastResults( | |
| data.timestamps, | |
| data.median, | |
| data.quantiles['0.1'], | |
| data.quantiles['0.9'], | |
| startRow | |
| ); | |
| }); | |
| log('✨ Forecast written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 2: DETECCIÓN DE ANOMALÍAS | |
| // ==================================================================== | |
| async function detectAnomalies() { | |
| log('Starting anomaly detection...'); | |
| try { | |
| const selection = await getSelectedRange(); | |
| const values = selection.values.flat().filter(v => v !== '' && !isNaN(v)); | |
| const contextLength = parseInt(document.getElementById('contextLength').value); | |
| const recentPoints = parseInt(document.getElementById('recentPoints').value); | |
| if (values.length < contextLength + recentPoints) { | |
| log(`Error: Need at least ${contextLength + recentPoints} points`, 'error'); | |
| return; | |
| } | |
| const context = values.slice(0, contextLength); | |
| const recent = values.slice(contextLength, contextLength + recentPoints); | |
| const requestBody = { | |
| context: { values: context }, | |
| recent_observed: recent, | |
| prediction_length: recentPoints, | |
| quantile_low: 0.05, | |
| quantile_high: 0.95 | |
| }; | |
| log('Analyzing data...'); | |
| const response = await fetch(`${API_BASE_URL}/detect_anomalies`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| const anomalyCount = data.anomalies.filter(a => a.is_anomaly).length; | |
| if (anomalyCount > 0) { | |
| log(`⚠️ Found ${anomalyCount} anomalies!`, 'error'); | |
| } else { | |
| log('No anomalies detected ✓', 'success'); | |
| } | |
| // Escribir resultados en Excel | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| // Preparar datos | |
| const tableData = [['Index', 'Value', 'Expected', 'Lower', 'Upper', 'Is Anomaly']]; | |
| data.anomalies.forEach(a => { | |
| tableData.push([ | |
| a.index, | |
| parseFloat(a.value.toFixed(2)), | |
| parseFloat(a.predicted_median.toFixed(2)), | |
| parseFloat(a.lower.toFixed(2)), | |
| parseFloat(a.upper.toFixed(2)), | |
| a.is_anomaly ? 'YES' : 'No' | |
| ]); | |
| }); | |
| const range = sheet.getRangeByIndexes(startRow, 0, tableData.length, 6); | |
| range.values = tableData; | |
| range.format.autofitColumns(); | |
| // Format header | |
| const headerRange = sheet.getRangeByIndexes(startRow, 0, 1, 6); | |
| headerRange.format.font.bold = true; | |
| headerRange.format.fill.color = '#4472C4'; | |
| headerRange.format.font.color = 'white'; | |
| // Highlight anomalies | |
| for (let i = 0; i < data.anomalies.length; i++) { | |
| if (data.anomalies[i].is_anomaly) { | |
| const anomalyRange = sheet.getRangeByIndexes(startRow + i + 1, 0, 1, 6); | |
| anomalyRange.format.fill.color = '#FFC7CE'; | |
| } | |
| } | |
| await context.sync(); | |
| }); | |
| log('✨ Anomaly results written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 3: BACKTEST | |
| // ==================================================================== | |
| async function runBacktest() { | |
| log('Running backtest...'); | |
| try { | |
| const selection = await getSelectedRange(); | |
| const values = selection.values.flat().filter(v => v !== '' && !isNaN(v)); | |
| const testLength = parseInt(document.getElementById('testLength').value); | |
| if (values.length <= testLength) { | |
| log('Error: Series must be longer than test length', 'error'); | |
| return; | |
| } | |
| const requestBody = { | |
| series: { values: values }, | |
| prediction_length: testLength, | |
| test_length: testLength | |
| }; | |
| log('Evaluating model...'); | |
| const response = await fetch(`${API_BASE_URL}/backtest_simple`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| const metrics = data.metrics; | |
| log(`📊 Backtest Results: MAE=${metrics.mae.toFixed(2)}, MAPE=${metrics.mape.toFixed(2)}%`, 'success'); | |
| // Escribir resultados en Excel | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| // Tabla de métricas | |
| const metricsData = [ | |
| ['Metric', 'Value'], | |
| ['MAE', parseFloat(metrics.mae.toFixed(2))], | |
| ['MAPE', metrics.mape.toFixed(2) + '%'], | |
| ['RMSE', parseFloat(metrics.rmse.toFixed(2))], | |
| ['WQL', parseFloat(metrics.wql.toFixed(3))] | |
| ]; | |
| const metricsRange = sheet.getRangeByIndexes(startRow, 0, metricsData.length, 2); | |
| metricsRange.values = metricsData; | |
| metricsRange.format.autofitColumns(); | |
| // Format header | |
| const headerRange = sheet.getRangeByIndexes(startRow, 0, 1, 2); | |
| headerRange.format.font.bold = true; | |
| headerRange.format.fill.color = '#70AD47'; | |
| headerRange.format.font.color = 'white'; | |
| // Forecast vs Actuals si están disponibles | |
| if (data.forecast_median && data.actuals) { | |
| const forecastData = [['Timestamp', 'Forecast', 'Actual', 'Error']]; | |
| for (let i = 0; i < data.forecast_median.length; i++) { | |
| const error = Math.abs(data.forecast_median[i] - data.actuals[i]); | |
| forecastData.push([ | |
| data.forecast_timestamps[i] || `t+${i+1}`, | |
| parseFloat(data.forecast_median[i].toFixed(2)), | |
| parseFloat(data.actuals[i].toFixed(2)), | |
| parseFloat(error.toFixed(2)) | |
| ]); | |
| } | |
| const forecastRange = sheet.getRangeByIndexes( | |
| startRow + metricsData.length + 2, | |
| 0, | |
| forecastData.length, | |
| 4 | |
| ); | |
| forecastRange.values = forecastData; | |
| forecastRange.format.autofitColumns(); | |
| const forecastHeaderRange = sheet.getRangeByIndexes( | |
| startRow + metricsData.length + 2, | |
| 0, | |
| 1, | |
| 4 | |
| ); | |
| forecastHeaderRange.format.font.bold = true; | |
| forecastHeaderRange.format.fill.color = '#4472C4'; | |
| forecastHeaderRange.format.font.color = 'white'; | |
| } | |
| await context.sync(); | |
| }); | |
| log('✨ Backtest results written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 4: MULTI-SERIES | |
| // ==================================================================== | |
| async function forecastMultiSeries() { | |
| log('Starting multi-series forecast...'); | |
| try { | |
| const selection = await getSelectedRange(); | |
| const data = selection.values; | |
| // Agrupar por series_id (columna A) | |
| const seriesMap = {}; | |
| for (let i = 1; i < data.length; i++) { // Skip header | |
| const seriesId = data[i][0]; | |
| const value = data[i][2]; // Columna C | |
| if (seriesId && value !== '' && !isNaN(value)) { | |
| if (!seriesMap[seriesId]) { | |
| seriesMap[seriesId] = []; | |
| } | |
| seriesMap[seriesId].push(parseFloat(value)); | |
| } | |
| } | |
| const seriesList = Object.entries(seriesMap).map(([id, values]) => ({ | |
| series_id: id, | |
| values: values | |
| })); | |
| if (seriesList.length === 0) { | |
| log('Error: No valid series found', 'error'); | |
| return; | |
| } | |
| log(`Found ${seriesList.length} series`); | |
| const predictionLength = parseInt(document.getElementById('multiPredLength').value); | |
| const requestBody = { | |
| prediction_length: predictionLength, | |
| series_list: seriesList, | |
| start_timestamp: new Date().toISOString().split('T')[0], | |
| freq: 'D', | |
| quantile_levels: [0.1, 0.5, 0.9] | |
| }; | |
| log('Forecasting all series...'); | |
| const response = await fetch(`${API_BASE_URL}/forecast_multi_id`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`API error: ${response.statusText}`); | |
| } | |
| const result = await response.json(); | |
| log(`✨ Generated forecasts for ${result.forecasts.length} series`, 'success'); | |
| // Escribir resultados en Excel | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| let currentRow = startRow; | |
| // Escribir cada serie | |
| result.forecasts.forEach(forecast => { | |
| // Header de la serie | |
| const seriesHeaderRange = sheet.getRangeByIndexes(currentRow, 0, 1, 1); | |
| seriesHeaderRange.values = [[`Series: ${forecast.series_id}`]]; | |
| seriesHeaderRange.format.font.bold = true; | |
| seriesHeaderRange.format.fill.color = '#4472C4'; | |
| seriesHeaderRange.format.font.color = 'white'; | |
| currentRow++; | |
| // Datos de la serie | |
| const tableData = [['Timestamp', 'Median', 'Q10', 'Q90']]; | |
| for (let i = 0; i < forecast.timestamps.length; i++) { | |
| tableData.push([ | |
| forecast.timestamps[i], | |
| parseFloat(forecast.median[i].toFixed(2)), | |
| parseFloat(forecast.quantiles['0.1'][i].toFixed(2)), | |
| parseFloat(forecast.quantiles['0.9'][i].toFixed(2)) | |
| ]); | |
| } | |
| const dataRange = sheet.getRangeByIndexes( | |
| currentRow, | |
| 0, | |
| tableData.length, | |
| 4 | |
| ); | |
| dataRange.values = tableData; | |
| dataRange.format.autofitColumns(); | |
| // Format header | |
| const headerRange = sheet.getRangeByIndexes(currentRow, 0, 1, 4); | |
| headerRange.format.font.bold = true; | |
| headerRange.format.fill.color = '#D9E1F2'; | |
| currentRow += tableData.length + 1; // +1 para separación | |
| }); | |
| await context.sync(); | |
| }); | |
| log('✨ Multi-series forecasts written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 5: COVARIABLES | |
| // ==================================================================== | |
| async function forecastWithCovariates() { | |
| log('Starting forecast with covariates...'); | |
| try { | |
| const selection = await getSelectedRange(); | |
| const data = selection.values; | |
| if (data.length < 3) { | |
| log('Error: Need at least 3 rows of data', 'error'); | |
| return; | |
| } | |
| // Obtener parámetros | |
| const predictionLength = parseInt(document.getElementById('covPredLength').value); | |
| const covariateNamesInput = document.getElementById('covariateNames').value; | |
| const covariateNames = covariateNamesInput.split(',').map(s => s.trim()); | |
| log(`Reading data with ${covariateNames.length} covariates: ${covariateNames.join(', ')}`); | |
| // Estructura esperada: | |
| // Col A: Date/Timestamp | |
| // Col B: Target value | |
| // Col C+: Covariates | |
| const context = []; | |
| const future = []; | |
| for (let i = 1; i < data.length; i++) { // Skip header | |
| const timestamp = data[i][0] ? data[i][0].toString() : null; | |
| const target = data[i][1]; | |
| // Leer covariables | |
| const covariates = {}; | |
| for (let j = 0; j < covariateNames.length && j < data[i].length - 2; j++) { | |
| const covValue = data[i][j + 2]; | |
| if (covValue !== '' && !isNaN(covValue)) { | |
| covariates[covariateNames[j]] = parseFloat(covValue); | |
| } | |
| } | |
| // Si tiene target, es contexto histórico | |
| if (target !== '' && !isNaN(target)) { | |
| context.push({ | |
| timestamp: timestamp, | |
| target: parseFloat(target), | |
| covariates: covariates | |
| }); | |
| } | |
| // Si no tiene target pero sí covariables, son valores futuros | |
| else if (Object.keys(covariates).length > 0) { | |
| future.push({ | |
| timestamp: timestamp, | |
| covariates: covariates | |
| }); | |
| } | |
| } | |
| if (context.length === 0) { | |
| log('Error: No historical data found', 'error'); | |
| return; | |
| } | |
| log(`Context: ${context.length} points, Future: ${future.length} points`); | |
| const requestBody = { | |
| context: context, | |
| future: future.length > 0 ? future : null, | |
| prediction_length: predictionLength, | |
| quantile_levels: [0.1, 0.5, 0.9] | |
| }; | |
| log('Calling API with covariates...'); | |
| const response = await fetch(`${API_BASE_URL}/forecast_with_covariates`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`API error: ${response.statusText} - ${errorText}`); | |
| } | |
| const result = await response.json(); | |
| log(`✨ Forecast generated with ${result.pred_df.length} predictions`, 'success'); | |
| // Escribir resultados en una nueva ubicación | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount, columnCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| const startCol = 0; | |
| // Crear tabla con los resultados | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| // Headers | |
| const headers = Object.keys(result.pred_df[0]); | |
| const tableData = [headers]; | |
| // Data rows | |
| result.pred_df.forEach(row => { | |
| const rowData = headers.map(h => row[h]); | |
| tableData.push(rowData); | |
| }); | |
| const outputRange = sheet.getRangeByIndexes( | |
| startRow, | |
| startCol, | |
| tableData.length, | |
| headers.length | |
| ); | |
| outputRange.values = tableData; | |
| outputRange.format.autofitColumns(); | |
| // Format header | |
| const headerRange = sheet.getRangeByIndexes(startRow, startCol, 1, headers.length); | |
| headerRange.format.font.bold = true; | |
| headerRange.format.fill.color = '#4472C4'; | |
| headerRange.format.font.color = 'white'; | |
| await context.sync(); | |
| }); | |
| log('✨ Results written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 6: ESCENARIOS | |
| // ==================================================================== | |
| async function generateScenarios() { | |
| log('Starting scenario generation...'); | |
| try { | |
| const selection = await getSelectedRange(); | |
| const data = selection.values; | |
| if (data.length < 3) { | |
| log('Error: Need at least 3 rows of data', 'error'); | |
| return; | |
| } | |
| const numScenarios = parseInt(document.getElementById('numScenarios').value); | |
| // Estructura esperada similar a covariates: | |
| // Col A: Date, Col B: Target, Col C+: Covariates | |
| // Para escenarios, generaremos variaciones de las covariables | |
| const context = []; | |
| const covariateNames = []; | |
| // Detectar nombres de covariables del header | |
| for (let j = 2; j < data[0].length; j++) { | |
| if (data[0][j]) { | |
| covariateNames.push(data[0][j].toString()); | |
| } | |
| } | |
| log(`Detected covariates: ${covariateNames.join(', ')}`); | |
| // Leer contexto histórico | |
| for (let i = 1; i < data.length; i++) { | |
| const timestamp = data[i][0] ? data[i][0].toString() : null; | |
| const target = data[i][1]; | |
| if (target !== '' && !isNaN(target)) { | |
| const covariates = {}; | |
| for (let j = 0; j < covariateNames.length && j < data[i].length - 2; j++) { | |
| const covValue = data[i][j + 2]; | |
| if (covValue !== '' && !isNaN(covValue)) { | |
| covariates[covariateNames[j]] = parseFloat(covValue); | |
| } | |
| } | |
| context.push({ | |
| timestamp: timestamp, | |
| target: parseFloat(target), | |
| covariates: covariates | |
| }); | |
| } | |
| } | |
| if (context.length === 0) { | |
| log('Error: No historical data found', 'error'); | |
| return; | |
| } | |
| // Generar escenarios automáticamente | |
| const predictionLength = 7; | |
| const scenarios = []; | |
| // Calcular valores promedio de covariables para generar variaciones | |
| const avgCovariates = {}; | |
| covariateNames.forEach(name => { | |
| const values = context | |
| .map(p => p.covariates[name]) | |
| .filter(v => v !== undefined); | |
| avgCovariates[name] = values.length > 0 | |
| ? values.reduce((a, b) => a + b, 0) / values.length | |
| : 0; | |
| }); | |
| // Escenario 1: Baseline (promedios) | |
| const baselineScenario = { | |
| name: 'Baseline', | |
| future_covariates: [] | |
| }; | |
| for (let i = 0; i < predictionLength; i++) { | |
| baselineScenario.future_covariates.push({ | |
| timestamp: `future_${i+1}`, | |
| covariates: {...avgCovariates} | |
| }); | |
| } | |
| scenarios.push(baselineScenario); | |
| // Escenario 2: Optimista (+20%) | |
| if (numScenarios >= 2) { | |
| const optimisticScenario = { | |
| name: 'Optimistic (+20%)', | |
| future_covariates: [] | |
| }; | |
| for (let i = 0; i < predictionLength; i++) { | |
| const covs = {}; | |
| covariateNames.forEach(name => { | |
| covs[name] = avgCovariates[name] * 1.2; | |
| }); | |
| optimisticScenario.future_covariates.push({ | |
| timestamp: `future_${i+1}`, | |
| covariates: covs | |
| }); | |
| } | |
| scenarios.push(optimisticScenario); | |
| } | |
| // Escenario 3: Pesimista (-20%) | |
| if (numScenarios >= 3) { | |
| const pessimisticScenario = { | |
| name: 'Pessimistic (-20%)', | |
| future_covariates: [] | |
| }; | |
| for (let i = 0; i < predictionLength; i++) { | |
| const covs = {}; | |
| covariateNames.forEach(name => { | |
| covs[name] = avgCovariates[name] * 0.8; | |
| }); | |
| pessimisticScenario.future_covariates.push({ | |
| timestamp: `future_${i+1}`, | |
| covariates: covs | |
| }); | |
| } | |
| scenarios.push(pessimisticScenario); | |
| } | |
| log(`Generated ${scenarios.length} scenarios`); | |
| const requestBody = { | |
| context: context, | |
| scenarios: scenarios, | |
| prediction_length: predictionLength, | |
| quantile_levels: [0.1, 0.5, 0.9] | |
| }; | |
| log('Calling scenarios API...'); | |
| const response = await fetch(`${API_BASE_URL}/forecast_scenarios`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`API error: ${response.statusText} - ${errorText}`); | |
| } | |
| const result = await response.json(); | |
| log(`✨ Generated ${result.scenarios.length} scenario forecasts`, 'success'); | |
| // Escribir resultados | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| let currentRow = startRow; | |
| // Escribir cada escenario | |
| result.scenarios.forEach(scenario => { | |
| // Header del escenario | |
| const scenarioHeaderRange = sheet.getRangeByIndexes(currentRow, 0, 1, 1); | |
| scenarioHeaderRange.values = [[`Scenario: ${scenario.name}`]]; | |
| scenarioHeaderRange.format.font.bold = true; | |
| scenarioHeaderRange.format.fill.color = '#70AD47'; | |
| scenarioHeaderRange.format.font.color = 'white'; | |
| currentRow++; | |
| // Datos del escenario | |
| if (scenario.pred_df && scenario.pred_df.length > 0) { | |
| const headers = Object.keys(scenario.pred_df[0]); | |
| const tableData = [headers]; | |
| scenario.pred_df.forEach(row => { | |
| tableData.push(headers.map(h => row[h])); | |
| }); | |
| const dataRange = sheet.getRangeByIndexes( | |
| currentRow, | |
| 0, | |
| tableData.length, | |
| headers.length | |
| ); | |
| dataRange.values = tableData; | |
| dataRange.format.autofitColumns(); | |
| currentRow += tableData.length + 1; // +1 para separación | |
| } | |
| }); | |
| await context.sync(); | |
| }); | |
| log('✨ Scenarios written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |
| // ==================================================================== | |
| // FUNCIÓN 7: MULTIVARIANTE | |
| // ==================================================================== | |
| async function forecastMultivariate() { | |
| log('Starting multivariate forecast...'); | |
| try { | |
| const selection = await getSelectedRange(); | |
| const data = selection.values; | |
| if (data.length < 3) { | |
| log('Error: Need at least 3 rows of data', 'error'); | |
| return; | |
| } | |
| // Obtener parámetros | |
| const predictionLength = parseInt(document.getElementById('multivarPredLength').value); | |
| const targetColumnsInput = document.getElementById('targetColumns').value; | |
| const targetColumns = targetColumnsInput.split(',').map(s => s.trim()); | |
| log(`Forecasting ${targetColumns.length} target variables: ${targetColumns.join(', ')}`); | |
| // Estructura esperada: | |
| // Col A: Date/Timestamp | |
| // Col B+: Target variables (múltiples columnas que queremos predecir) | |
| const context = []; | |
| // Validar que hay suficientes columnas | |
| if (data[0].length < targetColumns.length + 1) { | |
| log(`Error: Expected ${targetColumns.length + 1} columns but found ${data[0].length}`, 'error'); | |
| return; | |
| } | |
| // Leer datos | |
| for (let i = 1; i < data.length; i++) { // Skip header | |
| const timestamp = data[i][0] ? data[i][0].toString() : null; | |
| // Leer todos los targets | |
| const targets = {}; | |
| let hasValidData = false; | |
| for (let j = 0; j < targetColumns.length && j < data[i].length - 1; j++) { | |
| const value = data[i][j + 1]; | |
| if (value !== '' && !isNaN(value)) { | |
| targets[targetColumns[j]] = parseFloat(value); | |
| hasValidData = true; | |
| } | |
| } | |
| if (hasValidData) { | |
| context.push({ | |
| timestamp: timestamp, | |
| targets: targets, | |
| covariates: {} // Sin covariables por ahora | |
| }); | |
| } | |
| } | |
| if (context.length === 0) { | |
| log('Error: No valid data found', 'error'); | |
| return; | |
| } | |
| log(`Read ${context.length} data points`); | |
| const requestBody = { | |
| context: context, | |
| target_columns: targetColumns, | |
| prediction_length: predictionLength, | |
| quantile_levels: [0.1, 0.5, 0.9] | |
| }; | |
| log('Calling multivariate forecast API...'); | |
| const response = await fetch(`${API_BASE_URL}/forecast_multivariate`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(requestBody) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`API error: ${response.statusText} - ${errorText}`); | |
| } | |
| const result = await response.json(); | |
| log(`✨ Generated multivariate forecast with ${result.pred_df.length} predictions`, 'success'); | |
| // Escribir resultados | |
| await Excel.run(async (context) => { | |
| const selection = context.workbook.getSelectedRange(); | |
| selection.load('rowIndex, rowCount'); | |
| await context.sync(); | |
| const startRow = selection.rowIndex + selection.rowCount + 2; | |
| const sheet = context.workbook.worksheets.getActiveWorksheet(); | |
| // Crear tabla con resultados | |
| if (result.pred_df && result.pred_df.length > 0) { | |
| const headers = Object.keys(result.pred_df[0]); | |
| const tableData = [headers]; | |
| result.pred_df.forEach(row => { | |
| tableData.push(headers.map(h => row[h])); | |
| }); | |
| const outputRange = sheet.getRangeByIndexes( | |
| startRow, | |
| 0, | |
| tableData.length, | |
| headers.length | |
| ); | |
| outputRange.values = tableData; | |
| outputRange.format.autofitColumns(); | |
| // Format header | |
| const headerRange = sheet.getRangeByIndexes(startRow, 0, 1, headers.length); | |
| headerRange.format.font.bold = true; | |
| headerRange.format.fill.color = '#4472C4'; | |
| headerRange.format.font.color = 'white'; | |
| await context.sync(); | |
| } | |
| }); | |
| log('✨ Multivariate forecast written to spreadsheet', 'success'); | |
| } catch (error) { | |
| log(`Error: ${error.message}`, 'error'); | |
| console.error(error); | |
| } | |
| } | |