Getting started¶
This guide introduces scaly's modelling workflow through a double-integrator control problem: symbolic expressions, functions, derivatives, optimization, and generated C. It assumes familiarity with Python, NumPy, and basic optimal control, but no experience with symbolic modelling libraries such as CasADi.
The examples use scaly with the IPOPT plugin and a C compiler, as described in Installation.
Symbolic variables and expressions¶
Consider the discrete-time model
A symbolic variable represents an input whose numerical value is not yet
specified. In scaly, it has a name and a fixed shape. For this model, z
represents a two-element state vector and u a one-element control vector:
import numpy as np
import scaly as sc
z = sc.sym("z", 2)
u = sc.sym("u", 1)
znext = z + 0.1 * sc.concat([z[1:], u])
All three objects are instances of sc.Expr. z and u are input expressions and
znext describes a calculation using them. Unlike an operation on NumPy arrays,
this addition does not calculate a numerical result. It creates an expression
node that records the addition and its operands.
Together, the inputs and operations form an expression graph. Its nodes
represent values, and its edges record which values an operation needs.
For znext, the graph records the velocity slice, its concatenation with u,
the multiplication by 0.1, and the addition to z. Scaly uses this graph to
calculate derivatives and generate code. Assigning a new value to the Python
name z later does not change the recorded graph.
Shapes, indexing, broadcasting, and arithmetic follow NumPy conventions.
Here, z[1:] has shape (1,), so concatenating it with u produces a vector
of shape (2,). * is elementwise multiplication and @ is matrix
multiplication. The expression reference lists
Expr methods, and array builders include operations
such as sc.concat, sc.stack, and sc.sumsqr.
From expressions to a Function¶
An sc.Expr describes a value. An sc.Function gives a calculation named
inputs and outputs so that it can be evaluated, composed with other functions,
differentiated, or exported as C. Function is the unit of composition in scaly.
The @sc.function decorator constructs one by running a Python body with
symbolic inputs:
@sc.function(sc.G(sc.L("z", 2), sc.L("u", 1)), sc.L("znext", ...))
def model(inputs: tuple[sc.Expr, sc.Expr]) -> sc.Expr:
z, u = inputs
return z + 0.1 * sc.concat([z[1:], u])
L stands for leaf and declares one named array, G stands for group and
combines leaves or other groups into a tree of inputs or outputs. Here, the
input group is a tuple containing z and u. The output is one leaf named
znext, whose shape is inferred from the returned expression because its
declaration uses ....
The decorator creates the symbols, runs the body once, and records the returned
expression. After decoration, model is an sc.Function object, rather than
the original Python function. It remains callable:
A call with numerical arrays evaluates the graph. The first numerical call generates and compiles C. Subsequent calls reuse the compiled code. The Python body does not run again. A call with symbolic expressions instead includes the function in a larger graph, as the trajectory example below demonstrates.
The input tree determines the call structure: sc.G introduces a tuple,
whereas a single sc.L takes or returns an array directly. Shape () denotes
a scalar, and shape (1,) denotes a one-element vector. These are distinct.
The functions guide covers nested groups and other declarations.
Optional type annotations¶
The annotations on model describe the symbolic Python body: a tuple of two
Expr inputs and one Expr output. They do not change tracing or numerical
evaluation. The example also works without them.
Scaly's typing is designed to give you more checking as you provide more type
information. With annotations, an IDE's type checker can check the body's
argument and return types against the decorator's declared structure. For
example, returning a tuple where the decorator declares one leaf is a type
error. The decorated Function also carries symbolic and numerical input and
output types, so calls with the wrong tuple structure can be flagged before
execution. Array dimensions are checked at runtime as Python annotations
cannot encode shapes.
Derivatives are Functions too¶
For the model above,
sc.jacobian constructs an sc.Function for this derivative:
model_jac = sc.jacobian(model, "znext", "z")
print(model_jac((np.array([1.0, 2.0]), np.array([0.5]))))
# [[1. 0.1]
# [0. 1. ]]
The strings select the output and input by their declared names. model_jac
keeps the input structure of model, even though this particular Jacobian is
constant. For a nonlinear model, the supplied values determine where the
Jacobian is evaluated.
Scaly applies differentiation rules to the expression graph, producing another graph. It does not approximate derivatives by perturbing numerical inputs. The resulting function can be composed and exported like any other function. The derivatives guide covers gradients, Hessians, and products with derivative matrices. The sparsity guide covers calculating only entries that may be nonzero.
Composing a trajectory model¶
A function can reuse model to compute a trajectory and its cost:
N = 20
@sc.function(
sc.G(sc.L("z0", 2), sc.L("us", N)),
sc.G(sc.L("zN", ...), sc.L("cost", ...)),
)
def rollout(inputs: tuple[sc.Expr, sc.Expr]) -> tuple[sc.Expr, sc.Expr]:
z, us = inputs
cost = sc.const(0.0)
for k in range(N):
u = us[k : k + 1]
cost = cost + sc.sumsqr(z) + 0.1 * sc.sumsqr(u)
z = model((z, u))
return z, cost + 10.0 * sc.sumsqr(z)
z0 = np.array([1.0, 0.0])
us0 = np.zeros(N)
zN, cost = rollout((z0, us0))
print(zN, float(cost))
# [1. 0.] 30.0
sc.const(0.0) creates a constant expression, and sc.sumsqr(z) expresses
\(\lVert z\rVert^2\). The symbolic call model((z, u)) records a call to the
existing Function inside rollout. The two output leaves give the numerical
result its tuple structure.
Python runs while the decorator builds the graph. Consequently, this for
loop creates N successive calls to model. It does not record a loop.
The horizon is fixed when rollout is defined, while z0 and us can change
on every evaluation. This distinction between Python execution and recorded
operations also matters for repeated independent calculations, covered in
the bonus section on vmap.
Optimization problems and solvers¶
The trajectory model gives a single-shooting formulation with controls as decision variables and the measured initial state \(\bar z\) as a parameter:
The recurrence is already built into rollout. sc.problem adds the objective,
terminal equality, and control bounds:
@sc.problem(vars=sc.L("us", N), params=sc.L("z0", 2))
def control_problem(us: sc.Expr, z0: sc.Expr) -> sc.ProblemSpec[sc.Expr]:
zN, cost = rollout((z0, us))
return sc.ProblemSpec(
minimize=cost,
eq=(zN,),
lb=sc.const(-2.0),
ub=sc.const(2.0),
)
solve = sc.solver(control_problem, "ipopt", options={"print_level": 0})
Inside control_problem, us and z0 are symbolic sc.Expr inputs. The cost,
constraint expressions, and bounds returned in sc.ProblemSpec are also
sc.Expr objects. The builder records their dependence on variables and
parameters without evaluating them numerically.
The decorator creates an sc.Problem, which describes the optimization problem
independently of the solver. vars declares what the solver may change, and
params declares what stays fixed during each solve. Expressions in eq are
constrained to zero. The scalar lb and ub bounds apply to every control.
sc.solver selects a backend and returns an sc.Solver. Scaly constructs
the objective, constraint, and derivative functions that backend needs.
The numerical call takes the problem parameters:
us_opt, lam_box, lam_eq, lam_ineq = solve(z0)
status = solve.stats().to_solver_status()
print(status.name)
assert status.ok
zN_opt, cost_opt = rollout((z0, us_opt))
print(np.round(zN_opt, 6))
# Approximately [0. 0.]
The result contains the controls and multipliers for variable bounds, equality
constraints, and inequality constraints. lam_ineq is empty here because the
problem has only variable bounds and equalities. Solver status is separate
from these arrays and indicates whether the solve succeeded.
Initial variables and multipliers default to zero. solve(z0, x0=us0) supplies
an initial control sequence, and warm= accepts a previous result for a warm
start. The solver guide describes backend-specific warm-start
behavior and the underlying solve.function, an sc.Function with explicit
inputs for the initial variables and multipliers.
Generated C for deployment¶
The same model can be used from Python during development and exported for integration into a C or C++ application. For example, a ROS node can evaluate the dynamics or run an optimization solver without calling Python. Standalone model code can also be compiled with the toolchain used by an embedded target.
from pathlib import Path
from scaly.codegen import write_module
write_module(model, Path("generated"))
This writes generated/model.c and generated/model.h. Passing solve instead
exports the solver and its model calculations. That code also needs the native
IPOPT libraries. Exporting files ahead of the application build is the
ahead-of-time (AOT) path, in contrast to just-in-time (JIT) compilation on the first Python call.
The code generation guide describes export options, compilation, and the generated C and C++ APIs, including argument buffers, working memory, and sparse output layouts.
Bonus: repeated stages with vmap¶
sc.vmap records repeated independent calls to a function as one mapped
operation. The compiler can then emit a loop instead of a separate call site
for every stage. A multiple-shooting formulation makes this useful for the
same control problem by including states among the decision variables:
Each dynamics residual, or defect, now takes candidate states as inputs. Its evaluation does not depend on the result of another defect evaluation:
@sc.function(
sc.G(sc.L("z", 2), sc.L("u", 1), sc.L("znext", 2)),
sc.L("defect", ...),
)
def defect(inputs: tuple[sc.Expr, sc.Expr, sc.Expr]) -> sc.Expr:
z, u, znext = inputs
return model((z, u)) - znext
@sc.problem(vars=sc.L("w", 3 * N + 2), params=sc.L("z0", 2))
def multiple_shooting(w: sc.Expr, z0: sc.Expr) -> sc.ProblemSpec[sc.Expr]:
states = w[: 2 * (N + 1)]
controls = w[2 * (N + 1) :]
defects = sc.vmap(defect, N, {
"z": states[:-2],
"u": controls,
"znext": states[2:],
})
return sc.ProblemSpec(
minimize=sc.sumsqr(states[:-2]) + 0.1 * sc.sumsqr(controls)
+ 10.0 * sc.sumsqr(states[-2:]),
eq=(states[:2] - z0, defects, states[-2:]),
ineq=(sc.bounded(controls, lo=-2.0, hi=2.0),),
)
w stacks N + 1 two-element states followed by N controls. The mapping keys
are the input names of defect. Scaly splits each supplied expression into
N chunks of the corresponding input size: two elements for each state and
one for each control. states[:-2] supplies stages 0 through N - 1, and
states[2:] supplies stages 1 through N. sc.bounded expresses the control
limits as inequalities within the larger decision vector.
The difference in generated structure is roughly as follows. These are sketches with simplified signatures, before any inlining or other compiler optimization:
// Single shooting: the Python loop creates N call sites.
model(z0, u0, z1);
model(z1, u1, z2);
/* ... */
model(z19, u19, z20);
// Multiple shooting: vmap keeps one loop body.
for (int k = 0; k < N; ++k) {
defect(states[k], controls[k], states[k + 1], defects[k]);
}
The mapped stage code stays one loop body as the horizon grows, including in
its derivatives. Numerical work and storage still grow with N, as can
sparsity tables in the generated header. vmap cannot replace the sequential
recurrence in rollout: it applies when the calls can be evaluated independently.
The functions guide covers shared inputs
and explicit slice mappings.