How SymEval works

SymEval sits on top of two libraries that each do one job well.

SymPy holds the formula as symbols, so you can rearrange or simplify it before any numbers exist.

Pint holds the numbers as quantities, so a value carries its unit through every operation.

What SymEval adds is the symbolic evaluation: it evaluates the SymPy formula with Pint quantities and renders every step as LaTeX, in the spirit of handcalcs and CalcPad.

SymEval’s API is deliberately kept minimal and aligns with SymPy’s evalf.

The three parts

Every symbolic evaluation shows the same three parts, the way a calculation is written out to be checked:

  1. Symbolic form. The formula shown with symbols, untouched.
  2. Substituted form. Each symbol replaced by the Pint quantity you supplied, so the units are visible while the structure of the symbolic form stays intact.
  3. Result. The evaluated quantity, converted to the output unit you asked for.
Show code
from pint import Quantity
from symeval import sym_evalf
from sympy import Equality, Symbol

usains_speed = sym_evalf(
    Equality(Symbol("v"), Symbol("d") / Symbol("t")),
    subs={Symbol("d"): Quantity(100, "m"), Symbol("t"): Quantity(9.58, "s")},
    output_unit="km/h",
)
usains_speed

\begin{aligned} v &= \frac{d}{t} \\ &= \frac{100\ \mathrm{m}}{9.58\ \mathrm{s}} \\ v &= 10.44\ \frac{\mathrm{m}}{\mathrm{s}} = 37.58\ \frac{\mathrm{km}}{\mathrm{h}} \end{aligned}

Showing every step, rather than only the answer, is what lets a reviewer follow the reasoning: the formula is stated, the inputs are visible with their units, and the result is traceable back to both.

Why symbols and quantities are kept separate

Keeping the formula symbolic until the last moment is what lets you do algebra first. Starting from the ideal gas law as an equation, SymPy can solve for any variable before a single number is filled in:

P V = n R T \quad\longrightarrow\quad P = \frac{R T n}{V}

SymEval leans on this directly. Give sym_evalf a sympy.Eq and it solves for the single unknown (the free symbol not present in your substitutions), then evaluates. You never write the rearranged form by hand, so it cannot drift from the equation you started with.

Keeping the numbers as Pint quantities is what makes the units correct rather than merely annotated. Because each input is a real quantity, the substituted form shows genuine units, unit conversions happen inside the evaluation, and a mismatch (adding a length to a pressure, say) is an error rather than a wrong number that looks plausible.

Aligned with SymPy’s evalf

SymPy’s own tool for numerical evaluation is evalf: substitute numbers for symbols and evaluate the expression to a floating-point number, to a chosen number of significant digits. SymEval’s two entry points add granular functionality on top of it, and their names say what each one adds:

  • quantity_evalf is evalf with units: the values in subs are Pint quantities instead of bare numbers, and the result comes back as a pint.Quantity instead of a bare float.
  • sym_evalf is the same unit-aware evaluation plus the symbolic rendering: it returns a SymbolicEvaluation that displays the symbolic form, the substituted form, and the result.

The alignment carries through the API. Just as evalf is a method on every SymPy expression, importing SymEval attaches both entry points to sympy.Expr, so expr.sym_evalf(...) reads exactly like the expr.evalf(...) it extends.

Both are also plain functions (from symeval import sym_evalf), and the subs argument has the same shape as evalf’s subs, with quantities as values. Every other evalf kwarg (n, maxn, chop, strict, …) is forwarded verbatim.

One distinction worth knowing: n is numeric precision (significant digits used in the evaluation, 15 by default), while sym_evalf’s n_display is presentation (significant figures shown in the rendering, 4 by default) and never changes the computed quantity. Because both count significant figures, the sanity check is direct: when n < n_display, SymEval warns that the extra displayed digits are meaningless. Head to the API reference for full parameter tables.

Rendering a SymbolicEvaluation

A SymbolicEvaluation is both a rendering and a value; it exposes each separately, so you always get the one you meant.

_repr_latex_ — the automatic display. Returning the object as a cell’s value triggers it, and marimo or Jupyter render the result. It returns the evaluation wrapped as inline $\displaystyle …$: inline math renders left-justified, and \displaystyle keeps it full size.

usains_speed    # left-justified

\displaystyle \begin{aligned} v &= \frac{d}{t} \\ &= \frac{100\ \mathrm{m}}{9.58\ \mathrm{s}} \\ v &= 10.44\ \frac{\mathrm{m}}{\mathrm{s}} = 37.58\ \frac{\mathrm{km}}{\mathrm{h}} \end{aligned}

.latex — the rendering as a string. The same body, but bare: no $ delimiters and no \displaystyle, so it carries no justification of its own. You choose when you wrap it — $\displaystyle …$ reproduces the left-justified display above, $$…$$ centers it.

mo.md(rf"$${usains_speed.latex}$$")    # centered

\begin{aligned} v &= \frac{d}{t} \\ &= \frac{100\ \mathrm{m}}{9.58\ \mathrm{s}} \\ v &= 10.44\ \frac{\mathrm{m}}{\mathrm{s}} = 37.58\ \frac{\mathrm{km}}{\mathrm{h}} \end{aligned}

.quantity and str() — the value. The plain pint.Quantity in your output unit, for arithmetic, chaining, or a DataFrame column. It carries full precision: n_display rounds the display, not the value.

usains_speed.quantity   # <Quantity(37.5782881, 'kilometer / hour')>
str(usains_speed)       # '37.578288100208766 kilometer / hour'

The split is deliberate: _repr_latex_ and .latex are the rendering (an automatic display, or a string you compose), while .quantity (and str) is the value. So f"{usains_speed}" gives you the quantity, not the math — reach for .latex when you mean the rendering.

Chaining one evaluation into the next

A single symbolic evaluation is useful; chaining several is where engineering checks live. sym_evalf returns a SymbolicEvaluation whose .symbol and .quantity are a plain SymPy symbol and a plain Pint quantity. So the result of one step drops straight into the subs of the next, and each intermediate value renders its own steps. A multi-step check becomes a readable sequence of symbolic evaluations rather than one opaque number. The Getting started tutorial works through a chained calculation, feeding the axial resistance of a steel member into a demand-over-capacity ratio.

Reactivity and calculation report generation

Because a symbolic evaluation is just a plain Python object with a standard display hook, the marimo and Quarto integrations cost nothing extra.

Reactivity comes from marimo: tweak an input and every dependent symbolic evaluation updates, in a notebook or on a website.

Automation comes from the tooling around the notebook: Quarto renders it to an HTML, PDF, or Word calculation report, and a marimo notebook runs as a plain Python script, so reports can be regenerated whenever inputs change.

No wheels reinvented

The same restraint shows in what SymEval does not ship.

No unit registry. SymEval defines no registry of its own. It reads the registry from the Pint quantities you pass in, so every result lands in the registry your inputs came from, and there is never a second, conflicting notion of what a millimeter is. When there is nothing to read a registry from, SymEval falls back to Pint’s application registry.

No rendering runtime. SymEval renders through _repr_latex_, the standard hook marimo and Jupyter already look for. There is no marimo dependency and no cell magic: the same SymbolicEvaluation object displays anywhere the LaTeX representation is understood.

Back to top