Symbolic Linearization with SymPy
The SymPy backend preserves the full algebraic structure of a Modelica model. With it you can derive linearizations by hand — analytically — instead of finite-differencing a black-box simulator. The Jacobians come out as exact rational expressions in the model's physical parameters.
The notebook imports the rumoca-generated SymPy module, builds the symbolic right-hand side,
differentiates to get A and
B at an operating point, and inspects
controllability from the symbolic matrices.
Symbolic linearization of the PVTOL with SymPy
Take the Rumoca-emitted SymPy model of the PVTOL, build its symbolic
right-hand side, and linearize it at hover. The result is LaTeX-ready
A, B matrices small enough to read by eye, plus a symbolic
controllability rank.
Export step — Rumoca compiles the Modelica source into the SymPy model this notebook imports (run once, already done for you):
rumoca compile model/pvtol.mo -m PVTOL --target sympy -o _generated/sympy
_generated/sympy/PVTOL_sympy.py is machine-generated and never hand-edited.
import sys
from pathlib import Path
import numpy as np
import sympy as sp
ROOT = Path.cwd()
sys.path.insert(0, str(ROOT / "_generated" / "sympy"))
import PVTOL_sympy as sym_plant
STATE_NAMES = ["x", "z", "theta", "x_dot", "z_dot", "theta_dot"]
INPUT_NAMES = ["T", "M"] Build the symbolic right-hand side from Model.explicit_solution
mdl = sym_plant.Model()
mdl.solve_explicit()
sol = mdl.explicit_solution
x_dot, z_dot, theta_dot = sp.symbols("x_dot z_dot theta_dot")
m_sym, J_sym = sp.symbols("m J")
der_x_dot = sol[m_sym * sp.Symbol("x_dot_dot")] / m_sym
der_z_dot = sol[m_sym * sp.Symbol("z_dot_dot")] / m_sym
der_theta_dot = sol[J_sym * sp.Symbol("theta_dot_dot")] / J_sym
f_sym = sp.Matrix([x_dot, z_dot, theta_dot, der_x_dot, der_z_dot, der_theta_dot])
for n, expr in zip(STATE_NAMES, f_sym):
print(f" d{n}/dt = {expr}") dx/dt = x_dot dz/dt = z_dot dtheta/dt = theta_dot dx_dot/dt = -T*sin(theta)/m dz_dot/dt = (T*cos(theta) - g*m)/m dtheta_dot/dt = M/J
Symbolic Jacobians A and B
x_syms = sp.Matrix([sp.Symbol(n) for n in STATE_NAMES])
u_syms = sp.Matrix([sp.Symbol(n) for n in INPUT_NAMES])
A_sym = sp.simplify(f_sym.jacobian(x_syms))
B_sym = sp.simplify(f_sym.jacobian(u_syms))
from IPython.display import display, Math
display(Math("A(x, u, p) = " + sp.latex(A_sym)))
display(Math("B(x, u, p) = " + sp.latex(B_sym))) Substitute hover trim
x = 0, θ = 0, T = m g, M = 0.
hover_subs = {
sp.Symbol("x"): 0, sp.Symbol("z"): 0, sp.Symbol("theta"): 0,
sp.Symbol("x_dot"): 0, sp.Symbol("z_dot"): 0, sp.Symbol("theta_dot"): 0,
sp.Symbol("T"): m_sym * sp.Symbol("g"),
sp.Symbol("M"): 0,
}
A_hover = sp.simplify(A_sym.subs(hover_subs))
B_hover = sp.simplify(B_sym.subs(hover_subs))
display(Math("A_{\\rm hover} = " + sp.latex(A_hover)))
display(Math("B_{\\rm hover} = " + sp.latex(B_hover))) Symbolic controllability
Matrix.rank() computes the rank of the controllability matrix
$,[B,\ AB,\ A^2 B,\ \dots,\ A^{5} B],$ symbolically and
parameter-independently — no numerical pipeline can make this
statement.
n = A_hover.shape[0]
ctrl_cols = [B_hover]
Ak = sp.eye(n)
for _ in range(1, n):
Ak = Ak * A_hover
ctrl_cols.append(Ak * B_hover)
C_sym = sp.Matrix.hstack(*ctrl_cols)
print(f"Controllability rank = {C_sym.rank()} / {n}") Controllability rank = 6 / 6