在C#中从Excel快速计算公式

您多久收到一次客户听到他们将数据发送到Excel或要求您以Excel兼容格式导入或上传的信息?我确信在大多数领域,Excel是最流行,功能最强大且同时又简单便捷的工具之一。但是,最棘手的问题总是将此类数据与各种自动化系统集成在一起。我们的团队被要求考虑使用自定义Excel文件中的设置执行数据计算的可能性。

在C#中从Excel快速计算公式

如果您需要选择一种用于处理Excel文件的高效库,或者正在寻找一种使用方便的工具来管理和可视化公式的复杂财务(不仅是)数据计算解决方案,欢迎使用。

在研究公司新项目的需求时,我们发现了一个非常有趣的观点:“开发的解决方案应该能够在更改配置时计算产品(即阳台)的成本,并在界面上立即显示新成本。必须具有下载具有所有计算功能和组件价格清单的Excel文件的能力。”客户需要开发一个门户网站,以根据尺寸,形状,玻璃和所用材料的类型,紧固件的类型以及许多其他相关参数(用于计算确切的生产成本和可靠性指标)来设计阳台的配置。

我们对输入要求进行了形式化处理,以便更轻松地了解在何种情况下需要解决此问题:

  • 更改阳台参数时,可以在界面上快速计算价格;
  • 快速计算设计数据,包括许多不同的阳台配置和单独的要约,由单独的计算文件提供;
  • 从长远来看-使用各种消耗资源的操作(优化参数的任务等)进行功能开发
  • 所有这些都在通过API输出的远程服务器上,因为 所有公式都是客户的知识产权,第三方不应看到;
  • 峰值输入数据流:用户可以根据自己的需求频繁,快速地更改参数以选择产品配置。

听起来很不寻常,而且很诱人,让我们开始吧!

交钥匙解决方案和数据准备


解决复杂问题的任何研究都始于在StackOverflow,GitHub和许多寻求现成解决方案的论坛上冲浪。

选择了几种现成的解决方案,这些解决方案支持从Excel文件读取数据,并能够基于库中指定的公式执行计算。这些库中有完全免费的解决方案和商业开发。 


下一步是编写负载测试并测量每个库的运行时间。为此,请准备测试数据。我们创建一个新的Excel文件,为输入参数定义10个单元格,并定义一个公式记住这一刻)以获得使用所有输入参数的结果。在公式中,我们尝试使用各种计算复杂性的所有可能的数学函数,并以棘手的方式将它们组合。

因为这也与金钱(产品的成本)有关,重要的是要看结果的准确性。在这种情况下,我们将使用Excel Interop作为参考获得的结果值因为以这种方式获得的数据是通过Excel核心计算的,等于客户在开发公式和手动计算成本时看到的值。 

作为参考运行时,我们将使用纯C#手动编写的本机代码来显示用Excel编写的相同公式。

翻译的初始测试公式:

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]);
}

我们形成N次迭代的随机输入数据流(在这种情况下,我们使用10,000个向量)。

我们开始对整个流上输入参数的每个向量进行计算,获取结果并测量库初始化和常规计算的时间。

为了比较结果的准确性,我们使用两个指标-标准偏差和具有一定精度步长ε的匹配值的百分比。如果您随机生成一个输入值列表,小数点后有一个浮点数,则必须确定输入参数的准确性-这将使您获得正确的结果。否则,随机数可能具有数量级的较大差异-这会极大地影响结果的准确性并影响结果误差的估计。

因为最初,我们假设需要以材料成本的恒定值以及来自不同知识领域的一些常数进行操作,我们可以接受所有输入参数的值都将精确到小数点后三位。理想情况下,您需要为每个参数指定一个有效的值范围,并仅使用它们来生成随机值,但是由于测试公式是在没有任何数学和物理依据的情况下随机编译的,因此不可能在合理的时间内为所有10个参数计算出如此范围的值。因此,在计算中有时可能会获得计算误差。在计算准确性指标时,我们已经排除了此类输入数据向量,但是我们会将此类错误视为单独的特征。

测试架构 


对于每一个单独的库,它自己的类已经创建实现接口ITestExecutor ,其包括3种方法- SetUpExecute TearDown

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

方法SetUpTearDown在测试库的过程中仅使用一次,并且在对整个输入数据集进行时间计算时不考虑这些方法。

结果,测试算法归结为以下内容:

  • 数据准备(我们形成给定精度的输入向量流);
  • 为结果数据分配内存(每个库一个结果数组,一个包含错误数量的数组); 
  • 库的初始化;
  • 通过将结果保存在预先准备好的数组中并记录执行时间来获取每个库的计算结果;
  • 用库代码完成工作;
  • 数据分析:

该算法的图形表示如下。

Excel支持库的性能和准确性测试的流程图


第一次迭代的结果


指数本机EPPlus 4
EPPlus 5
非营利组织尖塔Excel互操作
初始化时间(毫秒)02572666321653
星期三 1次通过的时间(毫秒)0,00020.40860.68476.978238.8423
平均 偏差0,0003940,0003950,0002370,000631不适用
准确性99.99%99.92%99.97%99.84%不适用
失误0,0%1.94%1.94%1.52%1.94%

为什么将EPPlus 5和EPPlus 4的结果结合起来?
EPPlus . , , . , . EPPlus 5 , . , EPPlus, .

测试的第一次迭代显示出良好的结果,其中库中的领导立即可见。从以上数据可以看出,这种情况下的绝对领先者是EPPlus库。

与本地代码相比,结果并不令人印象深刻,但是您可以生活。

本来可以停止的,但是在与客户交谈之后,最初的疑虑逐渐蔓延:结果太好了。


使用Spire库的功能
Spire , InvalidCastException. , Excel- , , , . try...catch. , .

第一次测试重复


上面,我请您提请注意在取得结果中起重要作用的一点。我们怀疑客户的真实Excel文件中使用的公式与原始数据相差很远,内部存在许多依赖性,但是我们并不怀疑这会严重影响指标。但是,最初,在编译测试数据时,我没有预见到这一点,并且来自客户的数据(至少在最终文件中使用了至少120个输入参数的信息)暗示我们需要考虑并添加具有单元格之间依存关系的公式。

我们开始为下一次测试迭代准备新数据。让我们详细介绍10个输入参数并添加仅依赖于参数和1个聚合公式的另外4个新公式,该公式基于这四个具有公式的单元格,并且还将依赖于输入数据的值。

用于后续测试的新公式:

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));
}

如您所见,结果公式变得更加复杂,这自然会影响结果-并非更好。结果表如下:

指数本机EPPlus 4 EPPlus 5非营利组织尖塔Excel互操作
初始化时间(毫秒)02413687221640
星期三 1次通过的时间(毫秒)0,00040.9174 (+ 124%)1.8996 (+ 177%)7.7647 (+ 11%)50.7194 (+ 30%)
平均 偏差0,0358840.0000000.0000000.000000不适用
准确性98.79%100.00%100.00%100.00%不适用
失误0,0%0.3%0.3%0.28%0.3%


注意:因为 Excel Interop太大,必须从图表中排除。

从结果可以看出,这种情况已完全不适合在生产中使用。有点难过,储备咖啡,然后深入研究-直接进入代码生成。 


代码生成


如果您突然从未遇到过类似的任务,那么我们将进行一次简短的游览。 

代码生成是一种通过基于输入数据动态生成源代码并随后进行编译和执行来解决问题的方法。在项目的构建过程中会发生静态代码生成(例如,我可以引用T4MVC的示例,T4MVC基于编写主应用程序代码时可以使用的模板和元数据创建新代码),以及在运行时运行的动态代码。 

我们的任务是根据源数据(来自Excel的公式)形成一个新函数,该函数根据输入值的向量接收结果。

为此,您必须:

  • 从文件中读取公式;
  • 收集所有依赖项;
  • C#;
  • ;
  • ;
  • .

所提供的所有库均适合阅读公式,但是事实证明,EPPlus库是此类功能最方便的接口。在对该库的源代码进行了一些翻阅之后,我发现了用于形成标记列表并将其进一步转换为表达式树的公共类。宾果,我想!现成的表达式树是理想的选择,只需遍历它并形成我们的C#代码即可。 

但是,当我开始研究生成的表达式树的节点时,一大麻烦在等着我。某些节点(尤其是对Excel函数的调用)封装了有关所使用的函数及其输入参数的信息,并且未提供对此数据的任何开放式访问。因此,必须推迟使用完成的表达式树。

我们往下走了一层,尝试使用令牌列表。这里的一切都非常简单:我们拥有具有类型和值的令牌。因为我们得到一个函数,我们需要形成一个函数,那么我们可以将树标记转换为C#中的等效标记。这种方法的主要目的是组织兼容的功能。大多数数学函数已经兼容-例如计算余弦,正弦,求根,求幂。但是聚合函数(例如最大值,最小值,数量)需要完成。主要区别在于,在Excel中,这些函数使用一定范围的值。为了简化原型,我们将创建将参数列表作为输入的函数,然后将值的范围扩展为线性列表。这样,我们就可以从Excel语法正确地兼容转换为C#语法。 

以下是将令牌列表从Excel公式转换为有效C#代码的主要代码。

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();
}

转换中的下一个细微差别是使用Excel常量-它们是函数,因此在C#中,它们也必须包装在函数中。 

剩下的只是解决一个问题:将单元格引用转换为参数。在令牌包含有关该单元格的信息的情况下,我们首先确定该单元格中存储了什么。如果这是一个公式,则递归扩展它。如果用C#-analog链接替换常量,形式为p[row, column]则常量p可以是二维数组或用于正确数据映射的索引访问类。对于一定范围的单元格,我们执行相同的操作,只是将范围预先扩展为单个单元格并分别进行处理。因此,我们在翻译Excel函数时涵盖了主要功能。 


以下是将链接到Excel电子表格单元格的代码转换为有效的C#代码的代码:

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;
}

剩下的只是将产生的转换后的代码包装到静态函数中,将程序集与兼容性函数链接,然后编译动态程序集。将其加载到内存中,获得指向我们函数的链接-您可以使用它。 

我们编写用于测试的包装器类,并使用时间测量来运行测试。 

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))
    );
}

结果,我们有了此解决方案的原型,并将其标记为EPPlusCompiled Mark-I。运行测试后,我们得到了期待已久的结果。加速度几乎是300倍。已经不错,但是生成的代码仍然比本地代码慢16倍。会更好吗?

是的你可以!由于我们将用变量公式替换指向其他单元格的所有链接,因此尝试改善结果。我们的测试在公式中使用了依赖单元格,因此在翻译器的第一个版本中,我们收到了对相同数据的多次计算。因此,决定在计算中使用中间变量。使用生成的因变量扩展代码后,性能提高了2倍。这种改进称为EPPlus已编译,Mark-II比较表如下所示:

图书馆星期三 时间(毫秒)Coef。减速
本机0.000041个
EPPlus编译,Mark-II0.0038
EPPlus编译,Mark-I0.0061十六
EPPlus1,20893023

在这些条件下以及为任务分配的时间限制下,我们得到的结果使我们更接近本地代码的性能-与原始版本相比有8倍的滞后-3028倍的几个数量级的滞后。但是,如果您删除时间限制,那么有可能改善结果并尽可能接近本机代码吗?

我的答案是肯定的,但是不幸的是,我不再有时间实现这些技术。也许我将为此主题专门撰写一篇文章。目前,我只能谈论主要思想和选项,以简短摘要的形式编写它们,这些摘要已通过反向转换进行了验证。反向转换是指在生成代码的方向上手工编写的本机代码的降级。这种方法使您可以足够快地检查某些问题,而无需在代码中进行重大更改。它还允许您回答在某些情况下本机代码的性能将如何下降的问题,这意味着,如果在相反的方向上自动改进了生成的代码,则可以在相似系数的情况下获得性能上的改进。

摘要


  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
平均 偏差0,0358840,00,00,00,0不适用
准确性98.79%100.0%100.0%100.0%100.0%不适用
失误0,0%0,0%0.3%0.3%0.28%0.3%

在C#中从Excel快速计算公式

总结这些步骤,我们提供了一种将公式直接从自定义Excel文档转换为服务器上工作代码的机制。这使您可以使用将Excel与任何业务解决方案集成的非凡灵活性,而不会失去大量用户习惯使用的强大用户界面。在如此短的开发周期内,是否可以使用与Excel中相同的用于分析和可视化数据的工具集来开发这种便捷的界面?

与您必须实现的Excel文档最不寻常,最有趣的集成是什么?

All Articles