Cálculo rápido de fórmulas de Excel en C #

¿Con qué frecuencia escucha a los clientes que enviarán datos a Excel o le pedirán que los importe o cargue en un formato compatible con Excel? Estoy seguro de que en la mayoría de las áreas, Excel es una de las herramientas más populares, potentes y al mismo tiempo simples y convenientes. Pero el punto más problemático es siempre la integración de dichos datos con varios sistemas automatizados. Se le pidió a nuestro equipo que considerara la posibilidad de realizar cálculos de datos utilizando la configuración de un archivo Excel personalizado.

Cálculo rápido de fórmulas de Excel en C #

Si necesita elegir una biblioteca productiva para trabajar con archivos Excel o si está buscando una solución para calcular datos financieros complejos (y no solo) con una herramienta conveniente para administrar y visualizar fórmulas listas para usar, bienvenido a cat.

Al estudiar los requisitos de un nuevo proyecto en la empresa, encontramos un punto muy interesante: “La solución desarrollada debería poder calcular el costo del producto (es decir, el balcón) al cambiar la configuración y mostrar instantáneamente el nuevo costo en la interfaz. Es necesario proporcionar la capacidad de descargar un archivo de Excel con todas las funciones de cálculo y listas de precios de componentes ". El cliente necesitaba desarrollar un portal para diseñar la configuración de balcones en función de los tamaños, formas, tipos de acristalamiento y materiales utilizados, tipos de fijaciones, así como muchos otros parámetros relacionados que se utilizan para calcular el costo exacto de producción y los indicadores de fiabilidad.

Formalizamos los requisitos de entrada para que sea más fácil de entender en qué contexto fue necesario resolver el problema:

  • Cálculo rápido de precios en la interfaz al cambiar los parámetros del balcón;
  • Cálculo rápido de datos de diseño, incluidas muchas configuraciones diferentes de balcones y ofertas individuales, suministradas por un archivo de cálculo separado;
  • A largo plazo, el desarrollo de la funcionalidad utilizando varias operaciones que consumen recursos (tareas de optimización de parámetros, etc.)
  • Todo esto está en un servidor remoto con salida a través de API, porque Todas las fórmulas son propiedad intelectual del cliente y no deben ser visibles para terceros;
  • Flujo de datos de entrada con pico de carga: el usuario puede cambiar los parámetros con frecuencia y rapidez para seleccionar la configuración del producto de acuerdo con sus requisitos.

Suena muy inusual y súper tentador, ¡comencemos!

Soluciones llave en mano y preparación de datos.


Cualquier investigación para resolver problemas complejos comienza con la navegación en StackOverflow, GitHub y muchos foros en busca de soluciones listas para usar.

Se seleccionaron varias soluciones preparadas que admitían la lectura de datos de archivos de Excel, además de poder realizar cálculos basados ​​en fórmulas especificadas dentro de la biblioteca. Entre estas bibliotecas hay soluciones completamente gratuitas y desarrollos comerciales. 


El siguiente paso es escribir pruebas de carga y medir el tiempo de ejecución de cada biblioteca. Para hacer esto, prepare los datos de prueba. Creamos un nuevo archivo de Excel, definimos 10 celdas para los parámetros de entrada y una fórmula ( recuerde este momento ) para obtener un resultado que use todos los parámetros de entrada. En la fórmula, tratamos de utilizar todas las funciones matemáticas posibles de diversa complejidad computacional y combinarlas de una manera complicada.

Porque También se trata de dinero (el costo del producto), es importante tener en cuenta la precisión de los resultados. En este caso, tomaremos los valores resultantes que se obtuvieron utilizando Interoperabilidad de Excel como referenciaporque Los datos obtenidos de esta manera se calculan a través del núcleo de Excel y son iguales a los valores que los clientes ven al desarrollar fórmulas y calcular manualmente el costo. 

Como tiempo de ejecución de referencia, utilizaremos código nativo escrito manualmente en C # puro para mostrar la misma fórmula escrita en Excel.

Fórmula de prueba inicial traducida:

public double Execute(double[] p)
{
    return Math.Pow(p[0] * p[8] / p[4] * Math.Sin(p[5]) * Math.Cos(p[2]) +
                          Math.Abs(p[1] - (p[2] + p[3] + p[4] + p[5] + p[6] + p[7] + p[8]))
                          * Math.Sqrt(p[0] * p[0] + p[1] * p[1]) / 2.0 * Math.PI, p[9]);
}

Formamos un flujo de datos de entrada aleatorios para N iteraciones (en este caso, usamos 10,000 vectores).

Comenzamos los cálculos para cada vector de parámetros de entrada en todo el flujo, obtenemos los resultados y medimos el tiempo de inicialización de la biblioteca y el cálculo general.

Para comparar la precisión de los resultados, utilizamos dos indicadores: la desviación estándar y el porcentaje de valores coincidentes con un cierto paso de precisión epsilon. Si genera aleatoriamente una lista de valores de entrada con un punto flotante después del punto decimal, debe determinar la precisión de los parámetros de entrada; esto le permitirá obtener los resultados correctos. De lo contrario, los números aleatorios pueden tener una gran diferencia de órdenes de magnitud; esto puede afectar en gran medida la precisión de los resultados y la estimación del error del resultado.

Porque Inicialmente, suponemos que es necesario operar con valores constantes del costo de los materiales, así como algunas constantes de diferentes campos de conocimiento, podemos aceptar que todos los parámetros de entrada tendrán valores precisos de 3 decimales. Idealmente, debe especificar un rango válido de valores para cada uno de los parámetros y usarlos solo para generar valores aleatorios, pero dado que la fórmula para la prueba se compiló al azar sin ninguna justificación matemática y física, entonces no es posible calcular dicho rango de valores para los 10 parámetros en un período de tiempo razonable. Por lo tanto, en los cálculos a veces es posible obtener un error de cálculo. Excluimos dichos vectores de datos de entrada ya en el momento del cálculo para los indicadores de precisión,pero contaremos tales errores como una característica separada.

Arquitectura de prueba 


Para cada biblioteca independiente, su propia clase ha sido creado que implementa una interfaz ITestExecutor que incluye 3 métodos - SetUp, Execute y TearDown.

public interface ITestExecutor
{
    //      
    void SetUp();
    //   ,           
    double Execute(double[] p);
    //    ,      
    void TearDown();
}

Métodos SetUpy TearDownutilizan sólo una vez en el proceso de probar la biblioteca y no se consideran cuando se mide cálculos de tiempo en todo el conjunto de datos de entrada.

Como resultado, el algoritmo de prueba se redujo a lo siguiente:

  • Preparación de datos (formamos un flujo de vectores de entrada de una precisión dada);
  • Asignación de memoria para los datos resultantes (una matriz de resultados para cada biblioteca, una matriz con el número de errores); 
  • Inicialización de bibliotecas;
  • Obtener resultados de cálculo para cada una de las bibliotecas con guardar el resultado en una matriz preparada previamente y registrar el tiempo de ejecución;
  • Finalización del trabajo con el código de bibliotecas;
  • Análisis de los datos:

A continuación se presenta una representación gráfica de este algoritmo.

Diagrama de flujo de pruebas de rendimiento y precisión de bibliotecas de soporte de Excel


Resultados de la primera iteración


ÍndiceNativoEPPlus 4
y EPPlus 5
NPOIAgujaInteroperabilidad de Excel
Tiempo de inicialización (ms)0 02572666321653
Mie tiempo para 1 pase (ms)0,00020,40860,68476.978238.8423
Promedio desviación0,0003940,0003950,0002370,000631n / A
Exactitud99,99%99,92%99,97%99,84%n / A
Errores0,0%1.94%1.94%1,52%1.94%

¿Por qué se combinan los resultados de EPPlus 5 y EPPlus 4?
EPPlus . , , . , . EPPlus 5 , . , EPPlus, .

La primera iteración de las pruebas mostró un buen resultado, en el que los líderes entre las bibliotecas se hicieron visibles de inmediato. Como se puede ver en los datos anteriores, el líder absoluto en esta situación es la biblioteca EPPlus.

Los resultados no son impresionantes en comparación con el código nativo, pero puedes vivir.

Esto podría haberse detenido, pero después de hablar con los clientes surgieron las primeras dudas: los resultados fueron demasiado buenos.


Características de trabajar con la biblioteca Spire
Spire , InvalidCastException. , Excel- , , , . try...catch. , .

Rastrillo de la primera iteración de pruebas


Arriba, le pedí que llamara su atención sobre un punto que jugó un papel importante en la obtención de resultados. Sospechamos que las fórmulas utilizadas en el archivo Excel real del cliente estarían lejos de ser primitivas, con muchas dependencias en su interior, pero no sospechamos que esto podría afectar en gran medida a los indicadores. Sin embargo, inicialmente, al compilar los datos de prueba, no preveía esto, y los datos del cliente (al menos la información de que se usan al menos 120 parámetros de entrada en el archivo final) insinuaron que necesitamos pensar y agregar fórmulas con dependencias entre celdas .

Comenzamos a preparar nuevos datos para la próxima iteración de pruebas. Detengámonos en 10 parámetros de entrada y agreguemos 4 nuevas fórmulas adicionales con dependencia solo de parámetros y 1 fórmula de agregación, que se basa en estas cuatro celdas con fórmulas y también se basará en los valores de los datos de entrada.

La nueva fórmula que se utilizará para las pruebas posteriores:

public double Execute(double[] p)
{
    var price1 = Math.Pow(p[0] * p[8] / p[4] * Math.Sin(p[5]) * Math.Cos(p[2]) +
                          Math.Abs(p[1] - (p[2] + p[3] + p[4] + p[5] + p[6] + p[7] + p[8]))
                          * Math.Sqrt(p[0] * p[0] + p[1] * p[1]) / 2.0 * Math.PI, p[9]);

    var price2 = p[4] * p[5] * p[2] / Math.Max(1, Math.Abs(p[7]));

    var price3 = Math.Abs(p[7] - p[3]) * p[2];

    var price4 = Math.Sqrt(Math.Abs(p[1] * p[2] + p[3] * p[4] + p[5] * p[6]) + 1.0);

    var price5 = p[0] * Math.Cos(p[1]) + p[2] * Math.Sin(p[1]);

    var sum = p[0] + p[1] + p[2] + p[3] + p[4] + p[5] + p[6] + p[7] + p[8] + p[9];

    var price6 = sum / Math.Max(1, Math.Abs((p[0] + p[1] + p[2] + p[3]) / 4.0))
                 + sum / Math.Max(1, Math.Abs((p[4] + p[5] + p[6] + p[7] + p[8] + p[9]) / 6.0));

    var pricingAverage = (price1 + price2 + price3 + price4 + price5 + price6) / 6.0;

    return pricingAverage / Math.Max(1, Math.Abs(price1 + price2 + price3 + price4 + price5 + price6));
}

Como puede ver, la fórmula resultante resultó ser mucho más complicada, lo que naturalmente afectó los resultados, no para mejor. A continuación se presenta una tabla con los resultados:

ÍndiceNativoEPPlus 4 EPPlus 5NPOIAgujaInteroperabilidad de Excel
Tiempo de inicialización (ms)0 02413687221640
Mie tiempo para 1 pase (ms)0,00040.9174 (+ 124%)1.8996 (+ 177%)7.7647 (+ 11%)50,7194 (+ 30%)
Promedio desviación0,0358840.0000000.0000000.000000n / A
Exactitud98,79%100.00%100.00%100.00%n / A
Errores0,0%0.3%0.3%0.28%0.3%


Nota: porque La interoperabilidad de Excel es demasiado grande, tuvo que excluirse del gráfico.

Como se puede ver en los resultados, la situación se ha vuelto completamente inadecuada para su uso en la producción. Un poco triste, abastecerse de café y sumergirse en el estudio, directamente en la generación del código. 


Codigo de GENERACION


Si de repente nunca enfrentó una tarea similar, realizaremos una breve excursión. 

La generación de código es una forma de resolver un problema generando dinámicamente código fuente basado en datos de entrada con posterior compilación y ejecución. Hay una generación de código estático que ocurre durante la construcción del proyecto (como ejemplo, puedo citar T4MVC, que crea un nuevo código basado en plantillas y metadatos que se pueden usar al escribir el código de la aplicación principal), y un código dinámico que se ejecuta durante el tiempo de ejecución. 

Nuestra tarea es formar una nueva función basada en los datos de origen (fórmulas de Excel), que recibe el resultado en función del vector de valores de entrada.

Para hacer esto, debes:

  • Lea la fórmula del archivo;
  • Recoge todas las dependencias;
  • C#;
  • ;
  • ;
  • .

Todas las bibliotecas presentadas son adecuadas para leer fórmulas, sin embargo, la biblioteca EPPlus resultó ser la interfaz más conveniente para dicha funcionalidad . Habiendo hurgado un poco en el código fuente de esta biblioteca, descubrí las clases públicas para formar una lista de tokens y su posterior transformación en un árbol de expresión. Bingo, pensé! Un árbol de expresión listo para usar de la caja es ideal, simplemente revíselo y forme nuestro código C #. 

Pero me esperaba una gran captura cuando comencé a estudiar los nodos del árbol de expresión resultante. Algunos nodos, en particular la llamada a las funciones de Excel, encapsularon información sobre la función utilizada y sus parámetros de entrada y no proporcionaron ningún acceso abierto a estos datos. Por lo tanto, el trabajo con el árbol de expresión terminado tuvo que posponerse.

Vamos un nivel más bajo e intentamos trabajar con la lista de tokens. Aquí todo es bastante simple: tenemos tokens que tienen tipo y valor. Porque se nos asigna una función y necesitamos formar una función, luego podemos convertir los tokens del árbol al equivalente en C #. Lo principal en este enfoque es organizar funciones compatibles. La mayoría de las funciones matemáticas ya eran compatibles, como calcular el coseno, el seno, obtener la raíz y elevar a una potencia. Pero las funciones de agregación, como el valor máximo, el mínimo y la cantidad, debían completarse. La principal diferencia es que en Excel, estas funciones funcionan con un rango de valores. Para simplificar el prototipo, crearemos funciones que toman una lista de parámetros como entrada, expandiendo previamente el rango de valores en una lista lineal.De esta forma obtenemos la conversión correcta y compatible de la sintaxis de Excel a la sintaxis de C #. 

A continuación se muestra el código principal para convertir la lista de tokens de la fórmula de Excel en un código C # válido.

private string TransformToSharpCode(string formula, ParsingContext parsingContext)
{
    // Initialize basic compile components, e.g. lexer
    var lexer = new Lexer(parsingContext.Configuration.FunctionRepository, parsingContext.NameValueProvider);

    // List of dependency variables that can be filled during formula transformation
    var variables = new Dictionary<string, string>();
    using (var scope = parsingContext.Scopes.NewScope(RangeAddress.Empty))
    {
        // Take resulting code line
        var compiledResultCode = TransformToSharpCode(formula, parsingContext, scope, lexer, variables);

        var output = new StringBuilder();

        // Define dependency variables in reverse order.
        foreach (var variableDefinition in variables.Reverse())
        {
            output.AppendLine($"var {variableDefinition.Key} = {variableDefinition.Value};");
        }

        // Take the result
        output.AppendLine($"return {compiledResultCode};");

        return output.ToString();
    }
}

private string TransformToSharpCode(ICollection<Token> tokens, ParsingContext parsingContext, ParsingScope scope, ILexer lexer, Dictionary<string, string> variables)
{
    var output = new StringBuilder();

    foreach (Token token in tokens)
    {
        switch (token.TokenType)
        {
            case TokenType.Function:
                output.Append(BuildFunctionName(token.Value));
                break;
            case TokenType.OpeningParenthesis:
                output.Append("(");
                break;
            case TokenType.ClosingParenthesis:
                output.Append(")");
                break;
            case TokenType.Comma:
                output.Append(", ");
                break;
            case TokenType.ExcelAddress:
                var address = token.Value;
                output.Append(TransformAddressToSharpCode(address, parsingContext, scope, lexer, variables));
                break;
            case TokenType.Decimal:
            case TokenType.Integer:
            case TokenType.Boolean:
                output.Append(token.Value);
                break;
            case TokenType.Operator:
                output.Append(token.Value);
                break;
        }
    }

    return output.ToString();
}

El siguiente matiz en la conversión fue el uso de constantes de Excel: son funciones, por lo que en C # también deben incluirse en una función. 

Queda por resolver solo una pregunta: la conversión de referencias de celda a un parámetro. En el caso en que el token contenga información sobre la celda, primero determinamos qué se almacena en esta celda. Si esta es una fórmula, amplíela recursivamente. Si la constante se reemplaza con un enlace análogo C #, de la forma p[row, column], donde ppuede ser una matriz bidimensional o una clase de acceso indexado para la asignación correcta de datos. Con un rango de celdas, hacemos lo mismo, solo expandimos previamente el rango en celdas individuales y las procesamos por separado. Por lo tanto, cubrimos la funcionalidad principal al traducir una función de Excel. 


A continuación se muestra el código para convertir un enlace a una celda de hoja de cálculo de Excel en un código C # válido:

private string TransformAddressToSharpCode(string excelAddress, ParsingContext parsingContext, ParsingScope scope, ILexer lexer, Dictionary<string, string> variables)
{
    // Try to parse excel range of addresses
    // Currently, reference to another worksheet in address string is not supported

    var rangeParts = excelAddress.Split(':');
    if (rangeParts.Length == 1)
    {
        // Unpack single excel address
        return UnpackExcelAddress(excelAddress, parsingContext, scope, lexer, variables);
    }

    // Parse excel range address
    ParseAddressToIndexes(rangeParts[0], out int startRowIndex, out int startColumnIndex);
    ParseAddressToIndexes(rangeParts[1], out int endRowIndex, out int endColumnIndex);

    var rowDelta = endRowIndex - startRowIndex;
    var columnDelta = endColumnIndex - startColumnIndex;

    var allAccessors = new List<string>(Math.Abs(rowDelta * columnDelta));

    // TODO This part of code doesn't support reverse-ordered range address
    for (var rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++)
    {
        for (var columnIndex = startColumnIndex; columnIndex <= endColumnIndex; columnIndex++)
        {
            // Unpack single excel address
            allAccessors.Add(UnpackExcelAddress(rowIndex, columnIndex, parsingContext, scope, lexer, variables));
        }
    }

    return string.Join(", ", allAccessors);
}

private string UnpackExcelAddress(string excelAddress, ParsingContext parsingContext, ParsingScope scope, ILexer lexer, Dictionary<string, string> variables)
{
    ParseAddressToIndexes(excelAddress, out int rowIndex, out int columnIndex);
    return UnpackExcelAddress(rowIndex, columnIndex, parsingContext, scope, lexer, variables);
}

private string UnpackExcelAddress(int rowIndex, int columnIndex, ParsingContext parsingContext, ParsingScope scope, ILexer lexer, Dictionary<string, string> variables)
{
    var formula = parsingContext.ExcelDataProvider.GetRangeFormula(_worksheet.Name, rowIndex, columnIndex);
    if (string.IsNullOrWhiteSpace(formula))
    {
        // When excel address doesn't contain information about any excel formula, we should just use external input data parameter provider.
        return $"p[{rowIndex},{columnIndex}]";
    }

    // When formula is provided, try to identify that variable is not defined yet
    // TODO Worksheet name is not included in variable name, potentially that can cause conflicts
    // Extracting and reusing calculations via local variables improves performance for 0.0045ms
    var cellVariableId = $"C{rowIndex}R{columnIndex}";
    if (variables.ContainsKey(cellVariableId))
    {
        return cellVariableId;
    }

    // When variable does not exist, transform new formula and register that to variable scope
    variables.Add(cellVariableId, TransformToSharpCode(formula, parsingContext, scope, lexer, variables));

    return cellVariableId;
}

Solo queda envolver el código convertido resultante en una función estática, vincular el ensamblaje con funciones de compatibilidad y compilar el ensamblado dinámico. Cárguelo en la memoria, obtenga un enlace a nuestra función y podrá usarlo. 

Escribimos una clase de envoltura para probar y ejecutar pruebas con medición de tiempo. 

public void SetUp()
{
    // Initialize excel package by EPPlus library
    _package = new ExcelPackage(new FileInfo(_fileName));
    _workbook = _package.Workbook;
    _worksheet = _workbook.Worksheets[1];

    _inputRange = new ExcelRange[10];
    for (int rowIndex = 0; rowIndex < 10; rowIndex++)
    {
        _inputRange[rowIndex] = _worksheet.Cells[rowIndex + 1, 2];
    }

    // Access to result cell and extract formula string
    _resultRange = _worksheet.Cells[11, 2];

    var formula = _resultRange.Formula;

    // Initialize parsing context and setup data provider
    var parsingContext = ParsingContext.Create();
    parsingContext.ExcelDataProvider = new EpplusExcelDataProvider(_package);

    // Transform Excel formula to CSharp code
    var sourceCode = TransformToSharpCode(formula, parsingContext);

    // Compile CSharp code to IL dynamic assembly via helper wrappers
    _code = CodeGenerator.CreateCode<double>(
        sourceCode,
        new string[]
        {
            // List of used namespaces
            "System", // Required for Math functions
            "ExcelCalculations.PerformanceTests" // Required for Excel function wrappers stored at ExcelCompiledFunctions static class
        },
        new string[]
        {
            // Add reference to current compiled assembly, that is required to use Excel function wrappers located at ExcelCompiledFunctions static class
            "....\\bin\\Debug\\ExcelCalculations.PerformanceTests.exe"
        },
        // Notify that this source code should use parameter;
        // Use abstract p parameter - interface for values accessing.
        new CodeParameter("p", typeof(IExcelValueProvider))
    );
}

Como resultado, tenemos un prototipo de esta solución y lo marcamos como EPPlusCompiled, Mark-I . Después de ejecutar las pruebas, obtenemos el resultado tan esperado. La aceleración es casi 300 veces. Ya no está mal, pero el código resultante sigue siendo 16 veces más lento que el nativo. ¿Podría ser mejor?

¡Sí tu puedes! Intentemos mejorar el resultado debido al hecho de que reemplazaremos todos los enlaces a celdas adicionales con fórmulas con variables. Nuestra prueba utiliza múltiples celdas dependientes en la fórmula, por lo que en la primera versión del traductor recibimos múltiples cálculos de los mismos datos. Por lo tanto, se decidió utilizar variables intermedias en los cálculos. Después de expandir el código utilizando la generación de variables dependientes, obtuvimos un aumento de rendimiento de 2 veces más. Esta mejora se llamaEPPlus Compilado, Mark-II . La tabla de comparación se presenta a continuación:

BibliotecaMie tiempo (ms)Coef. ralentizaciones
Nativo0.000041
EPPlus Compilado, Mark-II0.0038
EPPlus Compilado, Mark-I0.0061dieciséis
EPPlus1,20893023

En estas condiciones y los límites de tiempo asignados para la tarea, obtuvimos un resultado que nos acerca al rendimiento del código nativo con un ligero retraso, 8 veces, en comparación con la versión original, un retraso de varios órdenes de magnitud, 3028 veces. ¿Pero es posible mejorar el resultado y acercarse lo más posible al código nativo, si elimina los límites de tiempo y cuánto será apropiado?

Mi respuesta es sí, pero, desafortunadamente, ya no tuve tiempo para implementar estas técnicas. Quizás dedique un artículo separado a este tema. Por el momento, solo puedo hablar sobre las ideas y opciones principales, escribiéndolas en forma de resúmenes cortos que han sido verificados por transformación inversa. Por conversión inversa, me refiero a la degradación del código nativo escrito a mano en la dirección del código generado. Este enfoque le permite verificar algunas tesis lo suficientemente rápido y no requiere cambios significativos en el código. También le permite responder a la pregunta de cómo se deteriorará el rendimiento del código nativo bajo ciertas condiciones, lo que significa que si el código generado se mejora automáticamente en la dirección opuesta, podemos obtener una mejora del rendimiento con un coeficiente similar.

Resúmenes


  1. , ;
  2. inline ;
  3. - Sum, Max, Min ;
  4. Sum inline ;
  5. ( ) .


NativeEPPlus Compiled, Mark-IIEPPlus 4EPPlus 5NPOISpireExcel Interop
()02392413687221640
. 1 ()0,00040,0030,91741,89967.764750,7194
Promedio desviación0,0358840,00,00,00,0n / A
Exactitud98,79%100.0%100.0%100.0%100.0%n / A
Errores0,0%0,0%0.3%0.3%0.28%0.3%

Cálculo rápido de fórmulas de Excel en C #

Resumiendo los pasos, tenemos un mecanismo para convertir fórmulas directamente desde un documento Excel personalizado en código de trabajo en el servidor. Esto le permite utilizar la increíble flexibilidad de integrar Excel con cualquier solución empresarial sin perder la potente interfaz de usuario con la que está acostumbrado a trabajar un gran número de usuarios. ¿Fue posible desarrollar una interfaz tan conveniente con el mismo conjunto de herramientas para analizar y visualizar datos que en Excel durante un período de desarrollo tan corto?

¿Y cuáles fueron las integraciones más inusuales e interesantes con los documentos de Excel que tuvo que implementar?

All Articles