This tutorial walks you through limes step by step.
Installation
limes is header-only. Clone the repository and point your compiler at the include directory:
git clone https://github.com/queelius/limes.git
cd limes
Using CMake
# In your CMakeLists.txt
add_subdirectory(limes)
target_link_libraries(your_target PRIVATE limes::limes)
Or if installed system-wide:
find_package(limes REQUIRED)
target_link_libraries(your_target PRIVATE limes::limes)
Header Only
Just add the include path:
g++ -std=c++20 -I/path/to/limes/include your_program.cpp
Step 1: Your First Expression
Include the library and use the expression namespace:
#include <iostream>
int main() {
auto x = arg<0>;
auto f = x * x;
std::array<double, 1> args{3.0};
double result = f.eval(args);
std::cout << "f(3) = " << result << "\n";
}
Main entry point for the limes library.
Expression layer for composable calculus.
What's Happening?
arg<0> creates a variable representing the first argument
x * x builds a Binary<Mul, Var, Var> expression at compile time
f.eval(args) evaluates the expression tree with the given arguments
The expression is not a function pointer—it's a type that carries structure.
Step 2: More Complex Expressions
Combine primitives to build complex expressions:
auto x = arg<0>;
auto f = sin(x);
auto g = cos(x * x);
auto h = exp(-x * x);
auto j = log(1 + x);
auto k = sin(x) * exp(-x);
auto m = sqrt(1 - x*x);
auto n = exp(sin(x)) + log(cos(x));
Available primitives: sin, cos, tan, exp, log, sqrt, abs, pow.
Step 3: Symbolic Differentiation
Differentiate expressions symbolically:
auto x = arg<0>;
auto f = sin(x * x);
auto df = derivative(f).wrt<0>();
std::array<double, 1> args{1.0};
double slope = df.eval(args);
std::cout << df.to_string() << "\n";
Chain Rule at Compile Time
The derivative is computed symbolically using the chain rule. For sin(x²):
- Outer function:
sin(u) → derivative is cos(u)
- Inner function:
u = x² → derivative is 2x
- Chain rule:
d/dx[sin(x²)] = cos(x²) · 2x
All of this happens at compile time through template metaprogramming.
Step 4: Basic Integration
Integrate expressions numerically:
auto x = arg<0>;
auto f = x * x;
auto I = integral(f).over<0>(0.0, 1.0);
auto result = I.eval();
std::cout << "Value: " << result.value() << "\n";
std::cout << "Error: " << result.error() << "\n";
The Fluent Builder
The pattern integral(f).over<Dim>(lo, hi) is a fluent builder:
integral(f) creates an IntegralBuilder wrapping the integrand
.over<0>(0.0, 1.0) specifies: integrate dimension 0 from 0 to 1
- The result is an
Integral expression node
Step 5: Integration Methods
Choose different numerical methods:
auto x = arg<0>;
auto I = integral(sin(x)).over<0>(0.0, 3.14159);
auto r1 = I.eval();
auto r2 = I.eval(gauss<7>());
auto r3 = I.eval(gauss<15>());
auto r4 = I.eval(monte_carlo_method(100000));
auto r5 = I.eval(adaptive_method(1e-12));
auto r6 = I.eval(simpson_method<100>());
Method Objects
Methods are objects, not just type tags. You can configure them:
auto mc = monte_carlo_method(100000).with_seed(42);
auto result = I.eval(mc);
.with_tolerance(1e-14)
.with_max_subdivisions(5000);
Adaptive integration with recursive interval subdivision until convergence.
Step 6: Multivariate Expressions
Work with multiple variables:
auto x = arg<0>;
auto y = arg<1>;
auto f = x*x + y*y;
std::array<double, 2> args{3.0, 4.0};
double result = f.eval(args);
auto df_dx = derivative(f).wrt<0>();
auto df_dy = derivative(f).wrt<1>();
auto [fx, fy] = derivative(f).gradient();
Step 7: Double Integrals
Chain .over() calls for iterated integrals:
auto x = arg<0>;
auto y = arg<1>;
auto I = integral(x * y)
.over<0>(0.0, 1.0)
.over<1>(0.0, 1.0);
auto result = I.eval();
std::cout << result.value() << "\n";
Dependent Bounds
The inner bound can depend on outer variables:
auto I = integral(x * y)
.over<1>(0.0, x)
.over<0>(0.0, 1.0);
auto result = I.eval();
Step 8: Box Integration
For N-dimensional rectangular regions, use Monte Carlo:
auto x = arg<0>;
auto y = arg<1>;
auto z = arg<2>;
auto I = integral(x * y * z)
.over_box({{0.0, 1.0}, {0.0, 1.0}, {0.0, 1.0}});
std::cout << result.value() << "\n";
constexpr auto monte_carlo_method(std::size_t n)
Factory for Monte Carlo integration.
Constrained Regions
Use .where() for non-rectangular regions:
.over_box({{-1.0, 1.0}, {-1.0, 1.0}})
.where([](double x, double y) {
});
auto result = I.eval(100000);
Step 9: Separable Integrals
When integrals are over independent variables, multiply them:
auto x = arg<0>;
auto y = arg<1>;
auto I = integral(sin(x)).over<0>(0.0, 3.14159);
auto J = integral(exp(y)).over<1>(0.0, 1.0);
auto IJ = I * J;
auto result = IJ.eval();
The library verifies independence at compile time—if the integrals share variables, you get a clear error message.
Step 10: Named Variables
For better debugging, use named variables:
auto x = var(0, "x");
auto y = var(1, "y");
auto f = sin(x) * cos(y);
std::cout << f.to_string() << "\n";
auto a = arg<0>;
auto b = arg<1>;
auto g = sin(a) * cos(b);
std::cout << g.to_string() << "\n";
Next Steps
Now that you understand the basics:
- See Examples for complete worked examples
- Explore limes::methods for all integration methods
- Read Motivation for the design philosophy
- Check the API reference for detailed documentation
Quick Reference
auto x = arg<0>;
auto x = var(0, "x");
sin(x), cos(x), exp(x), log(x), sqrt(x), abs(x)
x + y, x - y, x * y, x / y
pow(x, 2), pow(x, y)
derivative(f).wrt<0>()
derivative(f).wrt<0, 1>()
derivative(f).gradient()
integral(f).over<0>(a, b)
integral(f).over_box(bounds)
I * J
I.eval()
I.eval(gauss<7>())
result.value()
result.error()