Skip to content

latex

LaTeX backend for lowering symbolic expressions to math strings.

This package implements the LaTeX lowering backend that converts symbolic expression AST nodes into LaTeX math strings. Like the JAX and CVXPy backends, lowering uses a visitor pattern where each expression type has a corresponding visitor function registered via @visitor.

The visitor functions are split across submodules that mirror the openscvx.symbolic.expr package structure. Importing this package triggers registration of all visitors, which now cover every shipped node type — arithmetic, linear algebra, elementwise math, arrays, constraints, logic, spatial, Lie-group, STL, and Vmap. A future node added without a visitor raises NotImplementedError — the designed fallback until its visitor lands. (The legacy stljax operators are intentionally left unregistered; the GMSR ox.stl surface is the rendered one.)

Example::

from openscvx.symbolic.lowerers.latex import LatexLowerer

lowerer = LatexLowerer()
s = lowerer.lower(ox.Norm(x) - 5.0)  # '\left\| x \right\| - 5'

LatexLowerer

LaTeX backend for lowering symbolic expressions to math strings.

This class implements the visitor pattern for converting symbolic expression AST nodes to LaTeX. Each expression type has a corresponding visitor function decorated with @visitor that returns a str.

The lowering is recursive: each visitor lowers its children first, then composes them into a LaTeX fragment, calling :func:wrap on children that may need parentheses for the surrounding precedence.

Unlike :class:~openscvx.symbolic.lowerers.jax.JaxLowerer, the LaTeX lowerer is stateless — rendering is pure string assembly with no trace, node cache, or memoization.

Example

Lower an expression to a LaTeX string::

lowerer = LatexLowerer()
s = lowerer.lower(ox.Norm(x) - 5.0)
# '\left\| x \right\| - 5'
Source code in openscvx/symbolic/lowerers/latex/_lowerer.py
class LatexLowerer:
    """LaTeX backend for lowering symbolic expressions to math strings.

    This class implements the visitor pattern for converting symbolic
    expression AST nodes to LaTeX. Each expression type has a corresponding
    visitor function decorated with ``@visitor`` that returns a ``str``.

    The lowering is recursive: each visitor lowers its children first, then
    composes them into a LaTeX fragment, calling :func:`wrap` on children that
    may need parentheses for the surrounding precedence.

    Unlike :class:`~openscvx.symbolic.lowerers.jax.JaxLowerer`, the LaTeX
    lowerer is stateless — rendering is pure string assembly with no trace,
    node cache, or memoization.

    Example:
        Lower an expression to a LaTeX string::

            lowerer = LatexLowerer()
            s = lowerer.lower(ox.Norm(x) - 5.0)
            # '\\left\\| x \\right\\| - 5'
    """

    def lower(self, expr: Expr) -> str:
        """Lower a symbolic expression to a LaTeX string.

        Main entry point for lowering. Delegates to :func:`dispatch`, which
        looks up the appropriate visitor based on the expression type.

        Args:
            expr: Symbolic expression to lower (any Expr subclass)

        Returns:
            LaTeX math string (no ``$`` delimiters — callers add their own)

        Raises:
            NotImplementedError: If no visitor exists for the expression type
        """
        return dispatch(self, expr)
lower(expr: Expr) -> str

Lower a symbolic expression to a LaTeX string.

Main entry point for lowering. Delegates to :func:dispatch, which looks up the appropriate visitor based on the expression type.

Parameters:

Name Type Description Default
expr Expr

Symbolic expression to lower (any Expr subclass)

required

Returns:

Type Description
str

LaTeX math string (no $ delimiters — callers add their own)

Raises:

Type Description
NotImplementedError

If no visitor exists for the expression type

Source code in openscvx/symbolic/lowerers/latex/_lowerer.py
def lower(self, expr: Expr) -> str:
    """Lower a symbolic expression to a LaTeX string.

    Main entry point for lowering. Delegates to :func:`dispatch`, which
    looks up the appropriate visitor based on the expression type.

    Args:
        expr: Symbolic expression to lower (any Expr subclass)

    Returns:
        LaTeX math string (no ``$`` delimiters — callers add their own)

    Raises:
        NotImplementedError: If no visitor exists for the expression type
    """
    return dispatch(self, expr)

format_constant(value: np.ndarray) -> str

Render a numeric constant as LaTeX.

Scalars render via %g. Vectors render as a column bmatrix and matrices as a 2-D bmatrix, up to 6 entries per axis. Larger arrays render as a placeholder \mathrm{const} \in \mathbb{R}^{...} rather than an unreadable wall of numbers.

Parameters:

Name Type Description Default
value ndarray

The constant value (scalar, vector, matrix, or higher-rank array).

required

Returns:

Type Description
str

The LaTeX fragment for the constant.

Source code in openscvx/symbolic/lowerers/latex/_lowerer.py
def format_constant(value: np.ndarray) -> str:
    """Render a numeric constant as LaTeX.

    Scalars render via ``%g``. Vectors render as a column ``bmatrix`` and
    matrices as a 2-D ``bmatrix``, up to 6 entries per axis. Larger arrays
    render as a placeholder ``\\mathrm{const} \\in \\mathbb{R}^{...}`` rather
    than an unreadable wall of numbers.

    Args:
        value: The constant value (scalar, vector, matrix, or higher-rank
            array).

    Returns:
        The LaTeX fragment for the constant.
    """
    arr = np.asarray(value)

    if arr.ndim == 0:
        return _format_scalar(arr.item())

    if arr.ndim == 1:
        if arr.shape[0] > 6:
            return rf"\mathrm{{const}} \in \mathbb{{R}}^{{{arr.shape[0]}}}"
        body = r" \\ ".join(_format_scalar(v) for v in arr)
        return rf"\begin{{bmatrix}} {body} \end{{bmatrix}}"

    if arr.ndim == 2:
        m, n = arr.shape
        if m > 6 or n > 6:
            return rf"\mathrm{{const}} \in \mathbb{{R}}^{{{m} \times {n}}}"
        rows = [" & ".join(_format_scalar(v) for v in row) for row in arr]
        body = r" \\ ".join(rows)
        return rf"\begin{{bmatrix}} {body} \end{{bmatrix}}"

    shape = r" \times ".join(str(d) for d in arr.shape)
    return rf"\mathrm{{const}} \in \mathbb{{R}}^{{{shape}}}"

latex_symbol(name: str) -> str

Render a variable name as a LaTeX symbol.

Rules, in order:

  • A Greek-word name renders as its command: 'alpha' -> '\alpha'.
  • A single character renders bare: 'x' -> 'x'.
  • A base_sub name splits on the first underscore into a subscript: 'x_pos' -> 'x_{\mathrm{pos}}' (base and subscript are rendered recursively).
  • Any other multi-letter name renders upright: 'position' -> '\mathrm{position}', with underscores escaped.

Parameters:

Name Type Description Default
name str

The variable name to render.

required

Returns:

Type Description
str

The LaTeX fragment for the name.

Source code in openscvx/symbolic/lowerers/latex/_lowerer.py
def latex_symbol(name: str) -> str:
    """Render a variable name as a LaTeX symbol.

    Rules, in order:

    - A Greek-word name renders as its command: ``'alpha'`` -> ``'\\alpha'``.
    - A single character renders bare: ``'x'`` -> ``'x'``.
    - A ``base_sub`` name splits on the first underscore into a subscript:
      ``'x_pos'`` -> ``'x_{\\mathrm{pos}}'`` (base and subscript are rendered
      recursively).
    - Any other multi-letter name renders upright: ``'position'`` ->
      ``'\\mathrm{position}'``, with underscores escaped.

    Args:
        name: The variable name to render.

    Returns:
        The LaTeX fragment for the name.
    """
    if name in _GREEK:
        return rf"\{name}"
    if len(name) == 1:
        return name
    # base_sub -> subscript, when the split yields non-empty halves.
    if "_" in name and not name.startswith("_") and not name.endswith("_"):
        base, sub = name.split("_", 1)
        return rf"{latex_symbol(base)}_{{{latex_symbol(sub)}}}"
    escaped = name.replace("_", r"\_")
    return rf"\mathrm{{{escaped}}}"

problem_to_latex(symbolic: SymbolicProblem, dynamics_dict: dict, lam_cost: Union[float, np.ndarray], *, dynamics: str, constraints: str, weights: str = 'symbolic', env: Literal['align', 'aligned'] = 'align') -> str

Assemble a Mayer-form LaTeX formulation of a problem.

Parameters:

Name Type Description Default
symbolic SymbolicProblem

The preprocessed :class:SymbolicProblem (source of states, controls, categorized constraints, and horizon N).

required
dynamics_dict dict

The user-authored {state_name: expr} dynamics, before time/CTCS augmentation.

required
lam_cost Union[float, ndarray]

Objective weights — a scalar or an (n_states,) array indexed by each state's _slice (the resolved algorithm.lam_cost).

required
dynamics str

Detail level for the dynamics section — "inline" or "separate".

required
constraints str

Detail level for the constraint section — "inline", "symbolic", or "separate".

required
weights str

How objective coefficients render — "symbolic" (the default) renders each as a \lambda subscripted by the state's inner symbol and element; "numeric" substitutes the lam_cost values (%g, omitted when equal to 1).

'symbolic'
env Literal['align', 'aligned']

Output envelope. "align" (the default) wraps the Mayer form in subequations + align for paper-grade per-row numbering — the form Problem.to_latex returns. "aligned" emits plain aligned blocks instead; this exists solely for the notebook hook, since MathJax/KaTeX do not implement subequations and align cannot nest inside $$, whereas aligned can.

'align'

Returns:

Type Description
str

One LaTeX display-math fragment: the formulation block followed, in

str

"separate" modes, by a bare definition block per separated section,

str

joined by newlines with no connective glue. No $$ delimiters.

Raises:

Type Description
ValueError

If dynamics, constraints, or weights is not a valid mode.

Source code in openscvx/symbolic/lowerers/latex/formulation.py
def problem_to_latex(
    symbolic: "SymbolicProblem",
    dynamics_dict: dict,
    lam_cost: Union[float, np.ndarray],
    *,
    dynamics: str,
    constraints: str,
    weights: str = "symbolic",
    env: Literal["align", "aligned"] = "align",
) -> str:
    """Assemble a Mayer-form LaTeX formulation of a problem.

    Args:
        symbolic: The preprocessed :class:`SymbolicProblem` (source of states,
            controls, categorized constraints, and horizon ``N``).
        dynamics_dict: The user-authored ``{state_name: expr}`` dynamics, before
            time/CTCS augmentation.
        lam_cost: Objective weights — a scalar or an ``(n_states,)`` array indexed
            by each state's ``_slice`` (the resolved ``algorithm.lam_cost``).
        dynamics: Detail level for the dynamics section — ``"inline"`` or
            ``"separate"``.
        constraints: Detail level for the constraint section — ``"inline"``,
            ``"symbolic"``, or ``"separate"``.
        weights: How objective coefficients render — ``"symbolic"`` (the default)
            renders each as a ``\\lambda`` subscripted by the state's inner symbol
            and element; ``"numeric"`` substitutes the ``lam_cost`` values (``%g``,
            omitted when equal to 1).
        env: Output envelope.  ``"align"`` (the default) wraps the Mayer form in
            ``subequations`` + ``align`` for paper-grade per-row numbering — the
            form ``Problem.to_latex`` returns.  ``"aligned"`` emits plain
            ``aligned`` blocks instead; this exists solely for the notebook hook,
            since MathJax/KaTeX do not implement ``subequations`` and ``align``
            cannot nest inside ``$$``, whereas ``aligned`` can.

    Returns:
        One LaTeX display-math fragment: the formulation block followed, in
        ``"separate"`` modes, by a bare definition block per separated section,
        joined by newlines with no connective glue.  No ``$$`` delimiters.

    Raises:
        ValueError: If ``dynamics``, ``constraints``, or ``weights`` is not a
            valid mode.
    """
    _validate_mode("dynamics", dynamics, _DYNAMICS_MODES)
    _validate_mode("constraints", constraints, _CONSTRAINT_MODES)
    _validate_mode("weights", weights, _WEIGHT_MODES)

    lowerer = LatexLowerer()
    N = symbolic.N

    objective = _objective(symbolic.states, lam_cost, weights, lowerer)

    dyn_pairs = _dynamics_pairs(dynamics_dict, lowerer)
    if dynamics == "inline":
        dyn_rows = [rf"{lhs} = {ex}" for lhs, ex in dyn_pairs]
    else:
        dyn_rows = [r"\dot{x} = f(x, u)"]

    con_refs, con_defs = _constraint_rows(symbolic, N, constraints, lowerer)

    box_rows = _box_bound_rows(list(symbolic.states) + list(symbolic.controls), lowerer)
    bc_rows = _boundary_rows(symbolic.states, lowerer)

    st_rows = dyn_rows + con_refs + box_rows + bc_rows

    blocks = [_formulation(objective, st_rows, env)]
    if dynamics == "separate":
        blocks.append(_definition_block((rf"{lhs} &= {ex}" for lhs, ex in dyn_pairs), env))
    if constraints == "separate":
        blocks.append(
            _definition_block(
                (rf"{label}(x, u) &= {residual}" for label, residual in con_defs), env
            )
        )
    if env == "aligned" and len(blocks) > 1:
        # Adjacent ``aligned`` boxes inside one ``$$`` typeset side by side;
        # ``gathered`` stacks them vertically for the notebook renderer.
        return "\\begin{gathered}\n" + "\n\\\\\n".join(blocks) + "\n\\end{gathered}"
    return "\n".join(blocks)