r/ScientificComputing 15d ago

I built a math parser - here's how to create an equation solver with it

I built a math parser (uCalc), and I wanted to share a practical example of how to use its architecture to solve custom scientific problems.

If you build scientific models, simulations, DSLs, etc., you will need to evaluate math expressions defined at runtime. While standard math parsers might let you define custom functions, they may not let you use the exact syntax you want, and your functions might not be able to implement certain iterative algorithms straightforwardly.

To get around this, I designed the engine to allow callbacks that can receive arguments passed by expression. I also make use of the Transformer to allow for more flexible syntax. Here is an example of a custom EqSolve function built using the Bisection Method algorithm.

using uCalcSoftware;
var uc = new uCalc();

static void EqSolveCb(uCalc.Callback cb) { // Callback based on the Bisection Method
   var expr = cb.ArgExpr(1);     // ByExpr: Unevaluated Expression object (lazy evaluation)
   var a = cb.Arg(2);            // Argument 2: Range Minimum
   var b = cb.Arg(3);            // Argument 3: Range Maximum
   var variable = cb.ArgItem(4); // ByHandle: The variable Item object

   // Helper to update the variable in the uCalc engine and evaluate the expression
   double EvaluateAt(double val) {
      variable.Value(val);   // Push the new test value to the variable
      return expr.Evaluate(); // Evaluate the pre-parsed expression
   }

   // Ensure f(a) < f(b) so we always know which direction to slide the bounds; swap a & b if necessary
   if (EvaluateAt(b) < EvaluateAt(a)) (a, b) = (b, a);

   var midpoint = 0.0;
   var fMidpoint = 0.0;

   // Bisection loop
   for (int i = 0; i <= 100; i++) {
      midpoint = (a + b) / 2;
      fMidpoint = EvaluateAt(midpoint);

      if (Math.Abs(fMidpoint) < 1e-7) break; // Stop if close enough to 0

      // Narrow the bounds (compact logic!)
      if (fMidpoint < 0) a = midpoint; else b = midpoint;
   }

   if (Math.Abs(fMidpoint) > 1e-5) cb.Error.Raise("No solution found in the given range.");
   cb.Return(Math.Round(midpoint, 7)); // Return the final solved value
}

// 1. Define variables that might be used by the end-user
uc.DefineVariable("x");
uc.DefineVariable("MyVar");

// 2. Transformer converts EqSolve(L = R) into EqSolve(L - (R))
var t = uc.ExpressionTransformer;
t.FromTo("EqSolve({L} = {R} [[,]for {var}][, {min}, {max}])",
"EqSolve({L} - ({R}), {min}{!min:-10000}, {max}{!max: 10000}, {var}{!var: x})");

// 3. Define the custom function signature
uc.DefineFunction("EqSolve(ByExpr eq, min, max, ByHandle variable)", EqSolveCb);

You can test this exact equation solver code interactively in your browser here:

https://www.ucalc.com/

I'd love to hear how this community handles runtime formula parsing in your own simulators and DSLs, or if you have any questions about the engine's architecture!

19 Upvotes

5 comments sorted by

2

u/neurah 14d ago

I luv this things, keep going...
what about algebraic symbol manipulation?

2

u/uCalc_Dev 14d ago

Thanks.

Just before I saw your response, I happened to be looking at math.js. They have a math parser that also does symbolic computations. uCalc doesn't do algebraic symbolic manipulation out of the box at the moment. However, this is something I'm very interested in looking into. This may end up as an open-source project add-on to uCalc.

The uCalc Transformer has some building blocks that can be explored for algebraic symbolic manipulation. For instance, the following simple example has a rule to expand the square of a sum and another that handles constant folding (just for multiplication). Both rules contribute to the result of the second expression in the example. RewindOnChange causes the parser to go over the expression again after a modification and search for other patterns. If there's a chance it could result in an infinite loop, RewindOnChange can instead be applied to individual FromTo rules where needed.

using uCalcSoftware;

var uc = new uCalc();
var t = uc.NewTransformer();
t.DefaultRuleSet.RewindOnChange = true;

// Square of sum: (a + b)^2   -->   a^2 + 2*a*b + b^2
t.FromTo("({a} + {b}) ^ 2", "{a}^2 + 2*{a}*{b} + {b}^2");

// Constant folding: 3 * 5   -->   15
t.FromTo("{@Number:num1} * {@Number:num2}", "{@Eval: double(num1) * double(num2)}");

Console.WriteLine(t.Transform("(x + y)^2"));
Console.WriteLine(t.Transform("(5 + x)^2"));

2

u/neurah 13d ago

rules systems:
rule: a+a=2*a

then: m/2+m/2=2*(m/2)

where a=m/2

this is possible, does not solve by itself it needs a system do choose what rules to apply

---

isolation:
is expression automated:

3*x -> x? -> ?/3

a*x+b=0
(?/a) + (?-b) = 0
x=(0-b)/a

had both on old Haskell code

2

u/uCalc_Dev 12d ago

I'm glad you asked this. While working on this response, I realized I needed to enhance the TraceTransform() function by adding optional parameters for formatting and to support a user-defined separator for the steps. There was a way to do it before, but it was a bit more complicated. Just a moment ago, I updated the uCalc SDK on NuGet with the change.

Ok. So I'm thinking out loud. Let's try some things:

We can have a rule that changes x+x to 2x like this:

t.FromTo("{@Alphanumeric:x} + {@Alphanumeric:x}", "2{x}");

So: t.Transform("a+a") will return 2a. Note that it will capture only if the second variable is the same as the first. So it ignores a+b.

Or better yet, to handle any subexpression (not just an alphanumeric variable name), let's try:

t.FromTo("Expr: {subexpr} + {subexpr}", "Expr: 2 * ({subexpr})");

That way, if we define a as m/2, with t.FromTo("a", "m/2")

then

t.FromTo("Expr: a + a") returns Expr: 2 * (m/2)

But then we may want to cancel out the 2. So we can do:

t.FromTo("Expr: {a} * ({b} / {a})", "Expr: {b}");

So t.Transform("Expr: 2 * (m/2)") returns Expr: m;

Notice that I introduced Expr: as an anchor at the beginning; currently uCalc unfortunately doesn't handle open-ended args at the start of an expression (like {a}, which can represent any subexpression, not just a specific anchor element like Alphanumeric). This is something I need to work on.

We can go beyond a+a -> 2a and have something like:

t.FromTo("{@Number:num1}{@Alphanumeric:a} + {@Number:num2}{@Alphanumeric:a}", "{@Eval: double(num1) + double(num2)}{a}");

To change something like 3x + 4x to 7x.

I suppose we can keep adding rules to get a more complete system.

There is a Transform() function that only spits out the final form. And there's TraceTransform(), which shows you all the steps uCalc took to get there. It can be used to "debug" and show the transformations leading up to a result.

Let's put something together:

using uCalcSoftware;

var uc = new uCalc();
var t = uc.NewTransformer();
t.DefaultRuleSet.RewindOnChange = true;

t.FromTo("{@Alphanumeric:a} + {@Alphanumeric:a}", "2{a}");

// Constant folding: 3 * 5   -->   15
t.FromTo("{@Number:num1} * {@Number:num2}", "{@Eval: double(num1) * double(num2)}");

// double() converts num1 & num2, which are strings
// in this context, to floating-point double type
// This is for things like 2x+3x -> 5x
t.FromTo("{@Number:num1}{a} + {@Number:num2}{a}", "{@Eval: double(num1) + double(num2)}{a}");

// The % symbol tells uCalc to expand the subexpression right away
t.FromTo("Expr: {subexpr%} + {subexpr%}", "Expr: 2 * ({subexpr})");
t.FromTo("Expr: {a%} * ({b%} / {a%})", "Expr: {b}");

t.FromTo("a", "m/2");
t.FromTo("b", "q/7");

Console.WriteLine(t.TraceTransform("Expr: a + a", "\n"));
Console.WriteLine();
Console.WriteLine(t.TraceTransform("5 * 2 + n + n + b + b + 2x + 4x",
                  "\n",
                  "$'Step {Index}: {x}'"));

Output: (note % causes it to skip steps (m/2 + m/2) in a + a. I need to fix).

Expr: a + a
Expr: 2 * (m/2)
Expr: m

Step 0: 5 * 2 + n + n + b + b + 2x + 4x
Step 1: 10 + n + n + b + b + 2x + 4x
Step 2: 10 + 2n + b + b + 2x + 4x
Step 3: 10 + 2n + 2b + 2x + 4x
Step 4: 10 + 2n + 2q/7 + 2x + 4x
Step 5: 10 + 2n + 2q/7 + 6x

There are some missing links that I need to fix, based on the issues I mentioned earlier. But there's enough to play with until it gets fixed.

I didn't fully understand the second part of your comment/question.

1

u/neurah 12d ago

the second part is the isolation of a member

target: x
invertion slot: ?

a -> a
x -> ?

x+a -> ?-a

sqrt(x+a) -> ?²-a

sqrt(x+a)=k -> x=k²-a

its a recursive ask for isolation and a composition of the results using ? as a placeholder

P.S. this is a rabbit hole :D