Cálculo rápido de fórmulas do Excel em C #

Com que frequência você ouve os clientes que eles enviarão dados para o Excel ou solicitarão que você importe ou faça o upload em um formato compatível com o Excel? Estou certo de que, na maioria das áreas, o Excel é uma das ferramentas mais populares, poderosas e, ao mesmo tempo, simples e convenientes. Mas o ponto mais problemático é sempre a integração desses dados com vários sistemas automatizados. Nossa equipe foi solicitada a considerar a possibilidade de realizar cálculos de dados usando as configurações de um arquivo personalizado do Excel.

Cálculo rápido de fórmulas do Excel em C #

Se você precisar escolher uma biblioteca produtiva para trabalhar com arquivos do Excel ou se estiver procurando uma solução para calcular dados financeiros complexos (e não apenas) com uma ferramenta conveniente para gerenciar e visualizar fórmulas prontas para uso, seja bem-vindo ao gato.

Estudando os requisitos de um novo projeto na empresa, encontramos um ponto muito interessante: “A solução desenvolvida deve ser capaz de calcular o custo do produto (a varanda) ao alterar a configuração e exibir instantaneamente o novo custo na interface. É necessário fornecer a capacidade de baixar um arquivo do Excel com todas as funções de cálculo e listas de preços de componentes. ” O cliente precisava desenvolver um portal para projetar a configuração das varandas, dependendo dos tamanhos, formas, tipos de vidros e materiais utilizados, tipos de fixações, além de muitos outros parâmetros relacionados que são usados ​​para calcular o custo exato dos indicadores de produção e confiabilidade.

Formalizamos os requisitos de entrada para que seja mais fácil entender em que contexto foi necessário resolver o problema:

  • Cálculo rápido de preços na interface ao alterar os parâmetros da varanda;
  • Cálculo rápido de dados de projeto, incluindo muitas configurações diferentes de varandas e ofertas individuais, fornecidas por um arquivo de cálculo separado;
  • A longo prazo - o desenvolvimento da funcionalidade usando várias operações que consomem recursos (tarefas de otimização de parâmetros etc.)
  • Tudo isso em um servidor remoto com saída via API, porque Todas as fórmulas são de propriedade intelectual do cliente e não devem ser visíveis para terceiros;
  • Fluxo de dados de entrada com pico de carga: o usuário pode alterar os parâmetros com freqüência e rapidez para selecionar a configuração do produto de acordo com seus requisitos.

Parece muito incomum e super tentador, vamos começar!

Soluções chave na mão e preparação de dados


Qualquer pesquisa para resolver problemas complexos começa com a navegação no StackOverflow, GitHub e em muitos fóruns que procuram soluções prontas.

Várias soluções prontas foram selecionadas que suportam a leitura de dados de arquivos do Excel, além de poderem executar cálculos com base nas fórmulas especificadas na biblioteca. Entre essas bibliotecas, existem soluções totalmente gratuitas e desenvolvimentos comerciais. 


O próximo passo é escrever testes de carga e medir o tempo de execução de cada biblioteca. Para fazer isso, prepare os dados do teste. Criamos um novo arquivo do Excel, definimos 10 células para os parâmetros de entrada e uma fórmula ( lembre-se deste momento ) para obter um resultado que use todos os parâmetros de entrada. Na fórmula, tentamos usar todas as funções matemáticas possíveis de várias complexidades computacionais e as combinamos de uma maneira complicada.

Porque também se trata de dinheiro (o custo do produto), é importante observar a precisão dos resultados. Nesse caso, tomaremos os valores resultantes que foram obtidos usando o Excel Interop como referênciaPorque Os dados obtidos dessa maneira são calculados através do núcleo do Excel e são iguais aos valores que os clientes veem ao desenvolver fórmulas e calcular manualmente o custo. 

Como tempo de execução de referência, usaremos o código nativo escrito manualmente em C # puro para exibir a mesma fórmula escrita no Excel.

Fórmula de teste inicial traduzida:

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 um fluxo de dados de entrada aleatórios para N iterações (neste caso, usamos 10.000 vetores).

Iniciamos os cálculos para cada vetor de parâmetros de entrada em todo o fluxo, obtemos os resultados e medimos o tempo de inicialização da biblioteca e o cálculo geral.

Para comparar a precisão dos resultados, usamos dois indicadores - o desvio padrão e a porcentagem de valores correspondentes com uma certa etapa de precisão epsilon. Se você gerar aleatoriamente uma lista de valores de entrada com um ponto flutuante após o ponto decimal, deverá determinar a precisão dos parâmetros de entrada - isso permitirá obter os resultados corretos. Caso contrário, os números aleatórios podem ter uma grande diferença de ordens de magnitude - isso pode afetar muito a precisão dos resultados e afetar a estimativa do erro do resultado.

Porque Inicialmente, assumimos que é necessário operar com valores constantes do custo dos materiais, bem como algumas constantes de diferentes campos do conhecimento, podemos aceitar que todos os parâmetros de entrada terão valores precisos com três casas decimais. Idealmente, você precisa especificar um intervalo válido de valores para cada um dos parâmetros e usá-los apenas para gerar valores aleatórios, mas como a fórmula para o teste foi compilada aleatoriamente sem nenhuma justificativa matemática e física; portanto, não é possível calcular esse intervalo de valores para todos os 10 parâmetros em um período de tempo razoável. Portanto, em cálculos, às vezes é possível obter um erro de cálculo. Excluímos esses vetores de dados de entrada já no momento do cálculo para indicadores de precisão,mas contaremos esses erros como uma característica separada.

Arquitetura de Teste 


Para cada biblioteca separada, foi criada sua própria classe que implementa uma interface ITestExecutor que inclui 3 métodos - SetUp, Execute e TearDown.

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

Métodos SetUpe TearDownusados ​​apenas uma vez no processo de teste da biblioteca e não são considerados ao medir cálculos de tempo em todo o conjunto de dados de entrada.

Como resultado, o algoritmo de teste se resumiu ao seguinte:

  • Preparação de dados (formamos um fluxo de vetores de entrada de uma determinada precisão);
  • Alocação de memória para os dados resultantes (uma matriz de resultados para cada biblioteca, uma matriz com o número de erros); 
  • Inicialização de bibliotecas;
  • Obtenção de resultados de cálculo para cada uma das bibliotecas, salvando o resultado em uma matriz pré-preparada e registrando o tempo de execução;
  • Conclusão do trabalho com o código das bibliotecas;
  • Análise dos dados:

Uma representação gráfica deste algoritmo é apresentada abaixo.

Fluxograma de testes de desempenho e precisão das bibliotecas de suporte do Excel


Resultados da primeira iteração


ÍndiceNativoEPPlus 4
e EPPlus 5
NPOISpireInteroperabilidade do Excel
Tempo de inicialização (ms)0 02572666321653
Casar tempo para 1 passagem (ms)0,00020,40860,68476,978238,8423
Média desvio0,0003940,0003950,0002370,000631n / D
Precisão99,99%99,92%99,97%99,84%n / D
Erros0,0%1,94%1,94%1,52%1,94%

Por que os resultados do EPPlus 5 e EPPlus 4 são combinados?
EPPlus . , , . , . EPPlus 5 , . , EPPlus, .

A primeira iteração dos testes mostrou um bom resultado, no qual os líderes entre as bibliotecas ficaram imediatamente visíveis. Como pode ser visto pelos dados acima, o líder absoluto nesta situação é a biblioteca EPPlus.

Os resultados não são impressionantes em comparação com o código nativo, mas você pode viver.

Isso poderia ter sido interrompido, mas depois de conversar com os clientes, surgiram as primeiras dúvidas: os resultados foram bons demais.


Recursos para trabalhar com a biblioteca Spire
Spire , InvalidCastException. , Excel- , , , . try...catch. , .

Rake da primeira iteração de testes


Acima, pedi que você chamasse sua atenção para um ponto que teve um papel importante na obtenção de resultados. Suspeitamos que as fórmulas usadas no arquivo real do Excel do cliente estivessem longe de ser primitivas, com muitas dependências internas, mas não suspeitávamos que isso pudesse afetar muito os indicadores. No entanto, inicialmente, ao compilar os dados de teste, eu não previ isso, e os dados do cliente (pelo menos as informações de que pelo menos 120 parâmetros de entrada são usados ​​no arquivo final) sugeriram que precisamos pensar e adicionar fórmulas com dependências entre células .

Começamos a preparar novos dados para a próxima iteração de teste. Vamos nos concentrar em 10 parâmetros de entrada e adicionar 4 novas fórmulas adicionais com dependência apenas de parâmetros e 1 fórmula de agregação, que se baseia nessas quatro células com fórmulas e também dependerá dos valores dos dados de entrada.

A nova fórmula que será usada para testes subsequentes:

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 você pode ver, a fórmula resultante acabou sendo muito mais complicada, o que naturalmente afetou os resultados - não para melhor. Uma tabela com os resultados é apresentada abaixo:

ÍndiceNativoEPPlus 4 EPPlus 5NPOISpireInteroperabilidade do Excel
Tempo de inicialização (ms)0 02413687221640
Casar tempo para 1 passagem (ms)0,00040,9174 (+ 124%)1,8996 (+ 177%)7,7647 (+ 11%)50,7194 (+ 30%)
Média desvio0,0358840,0000000,0000000,000000n / D
Precisão98,79%100,00%100,00%100,00%n / D
Erros0,0%0,3%0,3%0,28%0,3%


Nota: Porque A interoperabilidade do Excel é muito grande e precisou ser excluída do gráfico.

Como pode ser visto nos resultados, a situação se tornou completamente inadequada para uso na produção. Um pouco triste, compre café e mergulhe mais fundo no estudo - direto na geração do código. 


Geração de código


Se de repente você nunca enfrentou uma tarefa semelhante, realizaremos uma breve excursão. 

A geração de código é uma maneira de resolver um problema, gerando dinamicamente o código fonte com base nos dados de entrada, com subsequente compilação e execução. Há geração de código estático que ocorre durante a construção do projeto (como exemplo, posso citar o T4MVC, que cria um novo código com base em modelos e metadados que podem ser usados ​​ao escrever o código principal do aplicativo) e código dinâmico que é executado durante o tempo de execução. 

Nossa tarefa é formar uma nova função com base nos dados de origem (fórmulas do Excel), que recebe o resultado com base no vetor de valores de entrada.

Para fazer isso, você deve:

  • Leia a fórmula do arquivo;
  • Colete todas as dependências;
  • C#;
  • ;
  • ;
  • .

Todas as bibliotecas apresentadas são adequadas para a leitura de fórmulas; no entanto, a biblioteca EPPlus acabou sendo a interface mais conveniente para essa funcionalidade . Tendo vasculhado um pouco o código fonte desta biblioteca, descobri as classes públicas para formar uma lista de tokens e sua transformação adicional em uma árvore de expressão. Bingo, pensei! Uma árvore de expressão pronta da caixa é ideal, basta passar por ela e formar nosso código C #. 

Mas um grande problema estava me esperando quando comecei a estudar os nós da árvore de expressão resultante. Alguns nós, em particular a chamada para funções do Excel, encapsularam informações sobre a função usada e seus parâmetros de entrada e não forneceram nenhum acesso aberto a esses dados. Portanto, o trabalho com a árvore de expressão final teve que ser adiado.

Subimos um nível abaixo e tentamos trabalhar com a lista de tokens. Tudo é bem simples aqui: temos tokens com tipo e valor. Porque recebemos uma função e precisamos formar uma função; então, podemos converter os tokens da árvore para o equivalente em C #. O principal nesta abordagem é organizar funções compatíveis. A maioria das funções matemáticas já era compatível - como calcular o cosseno, o seno, obter a raiz e aumentar a potência. Mas as funções de agregação - como valor máximo, mínimo e valor - precisavam ser finalizadas. A principal diferença é que, no Excel, essas funções funcionam com uma faixa de valores. Para simplificar o protótipo, criaremos funções que recebem uma lista de parâmetros como entrada, expandindo anteriormente o intervalo de valores em uma lista linear.Dessa forma, obtemos a conversão correta e compatível da sintaxe do Excel para a sintaxe C #. 

Abaixo está o código principal para converter uma lista de tokens de uma fórmula do Excel em um 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();
}

A próxima nuance na conversão foi o uso de constantes do Excel - elas são funções; portanto, em C # elas também precisam ser agrupadas em uma função. 

Resta resolver apenas uma questão: a conversão de referências de células em um parâmetro. No caso em que o token contém informações sobre a célula, primeiro determinamos o que está armazenado nessa célula. Se essa é uma fórmula, expanda-a recursivamente. Se a constante for substituída por um link analógico em C #, no formulário p[row, column], em que ppoderá ser uma matriz bidimensional ou uma classe de acesso indexada para o mapeamento correto dos dados. Com um intervalo de células, fazemos o mesmo, basta expandir previamente o intervalo em células individuais e processá-las separadamente. Assim, abordamos a principal funcionalidade ao traduzir uma função do Excel. 


Abaixo está o código para converter um link em uma célula da planilha do Excel em um 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;
}

Resta apenas envolver o código convertido resultante em uma função estática, vincular o assembly a funções de compatibilidade e compilar o assembly dinâmico. Carregue-o na memória, obtenha um link para nossa função - e você pode usá-lo. 

Nós escrevemos uma classe de invólucro para testar e executar testes com medição de tempo. 

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, temos um protótipo dessa solução e o marcamos como EPPlusCompiled, Mark-I . Após a execução dos testes, obtemos o resultado esperado. A aceleração é quase 300 vezes. Já não é ruim, mas o código resultante ainda é 16 vezes mais lento que o nativo. Poderia ser melhor?

Sim você pode! Vamos tentar melhorar o resultado devido ao fato de substituirmos todos os links para células adicionais por fórmulas com variáveis. Nosso teste usa o uso múltiplo de células dependentes na fórmula; portanto, na primeira versão do tradutor, recebemos vários cálculos dos mesmos dados. Portanto, decidiu-se usar variáveis ​​intermediárias nos cálculos. Após expandir o código usando a geração de variáveis ​​dependentes, obtivemos um aumento de desempenho de mais duas vezes. Essa melhoria é chamadaEPPlusCompiled, Mark-II . A tabela de comparação é apresentada abaixo:

BibliotecaCasar tempo (ms)Coef. lentidão
Nativo0,000041 1
EPPlusCompiled, Mark-II0,0038
EPPlusCompiled, Mark-I0,0061dezesseis
EPPlus1.20893023

Sob essas condições e os prazos alocados para a tarefa, obtivemos um resultado que nos aproxima do desempenho do código nativo com um pequeno atraso - 8 vezes em comparação com a versão original - um atraso de várias ordens de magnitude, 3028 vezes. Mas é possível melhorar o resultado e chegar o mais próximo possível do código nativo, se você remover os limites de tempo e quanto será apropriado?

Minha resposta é sim, mas, infelizmente, não tive mais tempo para implementar essas técnicas. Talvez eu devote um artigo separado para esse tópico. No momento, só posso falar sobre as principais idéias e opções, escrevendo-as na forma de resumos curtos que foram verificados por transformação reversa. Por conversão reversa, quero dizer a degradação do código nativo escrito à mão na direção do código gerado. Essa abordagem permite que você verifique algumas teses com bastante rapidez e não exige alterações significativas no código. Também permite responder à pergunta de como o desempenho do código nativo se deteriorará sob certas condições, o que significa que, se o código gerado for aprimorado automaticamente na direção oposta, podemos obter uma melhoria de desempenho com um coeficiente semelhante.

Resumos


  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
Média desvio0,0358840,00,00,00,0n / D
Precisão98,79%100,0%100,0%100,0%100,0%n / D
Erros0,0%0,0%0,3%0,3%0,28%0,3%

Cálculo rápido de fórmulas do Excel em C #

Resumindo as etapas, temos um mecanismo para converter fórmulas diretamente de um documento personalizado do Excel em código de trabalho no servidor. Isso permite que você use a incrível flexibilidade de integrar o Excel a qualquer solução de negócios sem perder a poderosa interface do usuário com a qual um grande número de usuários está acostumado a trabalhar. Foi possível desenvolver uma interface tão conveniente com o mesmo conjunto de ferramentas para analisar e visualizar dados como no Excel por um período tão curto de desenvolvimento?

E quais foram as integrações mais incomuns e interessantes com os documentos do Excel que você teve que implementar?

All Articles