CrySpace is a powerful control systems library for the Crystal programming language, inspired by the Python Control Systems Library (python-control). It provides tools for the analysis and design of feedback control systems, leveraging num.cr v1.31.0 for high-performance linear algebra.
- ๐ Featured: Active Suspension Storyboard
- โจ Features
- ๐ฆ Dependencies
- โ๏ธ Installation
- ๐ Usage
- 1. Basic Example: First-Order System
- 2. Mechanical Example: Mass-Spring-Damper
- 3. Comprehensive Example: RLC Circuit with PID
- 4. Feedback Connection
- 5. Step Response and State Analysis
- 6. General ODE Solving (RK4)
- 7. Transfer Function Arithmetic
- 8. Bidirectional Conversions
- 9. Advanced Control Design & Analysis
- ๐ Performance & Benchmarking
- ๐งช Testing
- ๐ค Contributing
โ ๏ธ Known Limitations
A complete control engineering journey โ from first-principles physics modelling to closed-loop simulation โ told as a story, chapter by chapter.
โถ Open Interactive HTML Storyboard
Live Bode plots ยท MathJax equations ยท Syntax-highlighted Crystal code ยท Dark-mode UI
| Resource | Link |
|---|---|
| ๐ Narrative README | examples/STORY.md |
| ๐ Interactive HTML (rendered) | suspension_storyboard.html via htmlpreview |
| ๐ Crystal source (CPU version) | examples/36_mega_usecase_storyboard.cr |
| โก Crystal source (Arrow SIMD version) | examples/40_arrow_storyboard.cr |
The storyboard covers 13 chapters and 30+ CrySpace functions: modelling โ stability โ frequency analysis โ LQR โ observers โ discretization โ model reduction โ PID โ nonlinear analysis โ system identification โ ODE solvers โ closed-loop simulation โ plotting.
-
State-Space Systems: Create and manipulate LTI (Linear Time-Invariant) systems in state-space form (
$\dot{x} = Ax + Bu, y = Cx + Du$ ). - Transfer Functions: Represent systems as ratios of polynomials.
-
High-Performance Linear Algebra:
- Leverages num.cr v1.30.0 with optimized LAPACK/BLAS bindings.
- Robust DARE (Discrete-time Algebraic Riccati Equation) solver using Schur-decomposition (pencil method).
- Fast Lyapunov and Sylvester equation solvers ($O(n^3)$).
-
Vectorized Math & Serialization via Apache Arrow:
- Run high-performance simulation trajectory math directly on the ARROW backend using vectorized C++ compute kernels. Passing an Arrow-backed time/input vector automatically offloads the simulation iterations (
simulate) to the Arrow backend using zero-copy matrix promotion. - In-place arithmetic operations (
add!,subtract!,multiply!,divide!) and unary negation (-) on the Arrow backend leverage SIMD-vectorized C++ compute functions with zero GC memory allocations. - Log and save simulation run datasets ultra-fast to disk in memory-mapped columnar binary Feather files via
Arrow::FeatherWriter.
- Run high-performance simulation trajectory math directly on the ARROW backend using vectorized C++ compute kernels. Passing an Arrow-backed time/input vector automatically offloads the simulation iterations (
-
System Interconnections:
- Parallel connection (
+) - Series connection (
*) - Feedback connection (
feedback)
- Parallel connection (
- Stability Analysis: Calculate system poles.
- Discretization: Convert continuous-time systems to discrete-time using optimized Zero-Order Hold (ZOH) via augmented matrix exponential.
-
Time Response:
- Step response simulation.
- General ODE solvers (Euler and Runge-Kutta 4).
-
Advanced Control Algorithms:
- Linear Quadratic Regulator (
lqranddlqr) design. - Lyapunov solvers (
lyapanddlyap) for continuous and discrete-time stability analysis. - Bode stability margins (
stability_margins) including Gain Margin and Phase Margin. - State-space realizations: convert systems to Observability Canonical Form (
to_observability_form) and Modal Form (to_modal_form). - Root Locus: calculate closed-loop pole trajectories under varying gain (
root_locus). - Frequency response data: Bode (
bode_data), Nyquist (nyquist_data), and Nichols (nichols_data) data generators. - Discrete-to-Continuous time conversion (
to_continuous/ D2C) using matrix logarithm. - Actuator-limited PID controller with anti-windup clamping (
PIDController). - Tustin (Bilinear) and Generalized Bilinear Discretization (
sample(dt, method: :tustin)). - Balanced Truncation Model Reduction (
balred). - Optimal Linear Quadratic Gaussian (
lqg) controller synthesis. - Similarity Transformation (
similarity_transform). - Kalman Controllability and Observability Decomposition (
controllable_decompositionandobservable_decomposition). - Minimal Realization solver (
minreal) to eliminate uncontrollable and unobservable states. - Augmented Integrator system design (
augment_integrator) for tracking control. - Optimal Kalman Estimator Gain solvers (
lqe/dlqe). - System Bandwidth (
bandwidth) calculation. - Impulse response (
impulse_response) and Initial State Free response (initial_response) simulation. - Public Controllability (
ctrb) and Observability (obsv) matrix helpers. - Canonical Transformations: Control Canonical Form (
to_control_canonical_form) and Observable Canonical Form (to_observable_canonical_form). - TransferFunction feedback loop solver with configurable signs (
feedback). - TransferFunction pole-zero cancellation (
minreal) minimal realization. - Pade approximation (
TransferFunction.pade) for time delays. - State-Space to TransferFunction conversion (
ss2tf). - Peak Gain (
peak_gain/ H-infinity norm) solver. - Loop Margins (
loop_margins/ Nyquist distance bounds). - Robust Optimal Control synthesis:
$H_2$ (h2syn) and$H_\infty$ (hinfsyn) state-feedback. - Sylvester Equation solver (
sylvester) for observer and block diagonalization designs. - Robust Pole Placement (
place) for multi-input systems. -
$H_2$ Norm (h2norm) calculation of continuous LTI systems. - Matched Pole-Zero discretization (
to_discrete(dt, method: :matched)). - Lead-Lag compensator design (
leadlag). - Loop shaping weight generator (
makeweight) for robust$H_\infty$ filters. - Least Squares System Identification (
least_squares_estimation) for TF parameters. - Eigenvalue Realization Algorithm (
era) for realization from impulse response data. - Describing Functions (
describing_function_saturation,describing_function_deadzone,describing_function_backlash) for nonlinear stability analysis. - Stability Tests: Routh-Hurwitz (
routh_hurwitz) and Jury (jury_test). - MIMO Analysis: Relative Gain Array (
rga) and Exact H-infinity Norm (hinfnorm_exact). - Pre-Warped Discretization (
sample(method: :prewarped)) and Model Residualization (residual_reduction). - Realization & Fitting: Ho-Kalman (
ho_kalman), Kung (kung), and Levy's Frequency Fitting (levy_fit). - Identification Signals: PRBS (
prbs) and Chirp (chirp) swept generators. - Notch Filter (
notch) design. - PID Tuning: Ziegler-Nichols closed-loop (
pid_tune_zn) and Cohen-Coon (pid_tune_cc). - Non-linear Estimators: Extended Kalman Filter (
ExtendedKalmanFilter) and Unscented Kalman Filter (UnscentedKalmanFilter). - Trajectory Optimization: Iterative LQR (
ilqr) solver. - Adaptive Control: Model Reference Adaptive Control (
mrac_simulate). - Runge-Kutta-Fehlberg 4(5) Adaptive ODE Solver (
rk45). - Jordan Canonical Form Transformation (
to_jordan_form). - Luenberger Observer (
LuenbergerObserver) class supporting continuous RK4 integration and discrete-time state updates. - Interactive SVG/HTML plotting dashboards: Step Response (
step_plot) and Bode Diagram (bode_plot) using Chart.js CDN.
- Linear Quadratic Regulator (
-
State Estimation:
- Discrete-Time Kalman Filter (
KalmanFilter) implementation.
- Discrete-Time Kalman Filter (
- SISO & MIMO: Support for Single-Input Single-Output and Multi-Input Multi-Output systems.
CrySpace relies on the following dependencies:
- num.cr (
~> 1.31.0): High-performance scientific computing and linear algebra library for Crystal.
- LAPACK (
liblapack-dev/lapack>= 3.12.0): Linear Algebra Package for high-performance numerical routines. - BLAS/CBLAS (
libcblas-dev/cblas/openblas>= 3.12.0/>= 0.3.26): Basic Linear Algebra Subprograms interface. - Apache Arrow GLib (
libarrow-glib-dev/apache-arrow-glib): Required for vectorized SIMD features and Arrow backend support (in bothcryspaceandnum.cr).
- Crystal (
>= 1.20.2): The compiler and language runtime.
-
Add the dependency to your
shard.yml:dependencies: cryspace: github: eltony81/cryspace
-
Install system dependencies (LAPACK and BLAS/CBLAS):
# On Ubuntu/Debian sudo apt-get install liblapack-dev libcblas-dev libarrow-glib-dev # On Arch Linux / Manjaro sudo pacman -S lapack openblas arrow pamac build apache-arrow-glib
cryspace supports high-performance SIMD execution for tensor, matrix, and simulation calculations via Apache Arrow.
- Offloaded Simulations: Dynamic state space simulations (
simulate) offload continuous-time trajectory mathematical iterations to C++ memory-mapped space, reducing garbage collector overhead to near-zero. - Vectorized In-Place Math: In-place arithmetic operations (
add!,subtract!,multiply!,divide!) on Arrow-backed Tensors offload to Arrow's C++ compute engine using SIMD instructions (AVX2, AVX-512, or ARM Neon). - Fast Unary Negation: Contiguous tensor negation (
-tensor) runs directly via C++ compute kernels with zero memory-allocation overhead. - Ultra-Fast Serialization: Save and load simulation datasets directly from disk in memory-mapped columnar binary Feather files via
Arrow::FeatherWriter.
-
Install System Dependencies: You must have the Apache Arrow and Arrow-GLib development packages installed on your system:
# On Ubuntu/Debian sudo apt-get install libarrow-dev libarrow-glib-dev # On Arch Linux / Manjaro sudo pacman -S arrow pamac build apache-arrow-glib
-
Compile with the
-Darrowcompiler flag: Pass the-Darrowflag during compilation:crystal build -Darrow --release src/your_app.cr
cryspace leverages the underlying num.cr dynamic backend dispatch. Standard CPU tensors (Tensor(Float64, CPU(Float64))) used in arithmetic equations will automatically route their execution paths to the most performant backend based on data size thresholds and active flags:
-
CPU Fallback (Small datasets): For sizes
$< 1,000$ elements, calculations are run directly on the standard CPU loop to avoid device latency. -
Arrow SIMD Acceleration (Medium datasets): For sizes between
$1,000$ and$1,000,000$ elements, operations are offloaded to Apache Arrow's vectorized C++ SIMD compute engine (when compiled with-Darrow). -
OpenCL GPU Acceleration (Large datasets): For sizes
$\ge 1,000,000$ elements, operations are copied to the OpenCL GPU device, computed in parallel across GPU cores, and copied back to CPU storage (when compiled with-Dopencl).
Depending on your hardware and workload sizes, choose your compile flags:
- Standard CPU Mode (Default / No Flags):
- Recommended for: Standard control systems (1-50 states), real-time applications, and low-latency loops.
- Features: Uses highly optimized OpenBLAS/LAPACK and internal zero-allocation simulation loops.
- Command:
crystal build --release src/your_app.cr
- CPU SIMD Acceleration (Apache Arrow):
- Recommended for: Medium to large systems and batch trajectory processing.
- Command:
crystal build -Darrow --release src/your_app.cr
- GPU Acceleration (OpenCL):
- Recommended for: Massive systems (>1000 states) or heavy parallel tensor math.
- Command:
crystal build -Dopencl --release src/your_app.cr
- Hybrid Auto-Dispatch Mode (CPU + SIMD + GPU):
- Command:
crystal build -Darrow -Dopencl --release src/your_app.cr
- Command:
Simply convert your time vector (t) and input vector (u) to Arrow-backed tensors using the .arrow converter. If the time vector passed to .simulate is an Arrow-backed tensor, cryspace will automatically run the simulation on the Arrow SIMD backend.
require "cryspace"
# 1. Define plant matrices
a = [[0.0, 10.0], [-10.0, -1.0]].to_tensor
b = [[0.0], [2.0]].to_tensor
c = [[1.0, 0.0]].to_tensor
d = [[0.0]].to_tensor
sys = CrySpace::StateSpace.new(a, b, c, d)
# 2. Setup vectors
t = Float64Tensor.linear_space(0.0, 10.0, 1001)
u = Float64Tensor.ones([1, 1001])
# 3. Promote to Arrow backend for SIMD execution (compiled with -Darrow)
t_arrow = t.arrow
u_arrow = u.arrow
# 4. Simulate - runs automatically on Arrow C++ compute engine
times, states, outputs = sys.simulate(t_arrow, u: u_arrow)To demonstrate the real-world impact of the Apache Arrow SIMD backend, a benchmark simulation is included in examples/arrow_vs_cpu_benchmark.cr. This script simulates a continuous closed-loop RLC PID control system with 4 states (
| Backend | Execution Time | Heap Allocations | Speedup | GC Overhead |
|---|---|---|---|---|
| CPU (Default) | ~1.42 s | ~1,800 MB | 1.0x (Baseline) | High (frequent pauses) |
Apache Arrow SIMD (-Darrow) |
~220 ms | < 1.8 MB | ~6.45x | Near-Zero |
-
SIMD Vectorization: The 4-state feedback loop update calculations (
$\dot{x} = Ax + Bu$ ) and RK4 intermediate steps are processed via Arrow's highly optimized, vectorized C++ compute kernels using modern CPU instruction sets (AVX2, AVX-512, or NEON), bypassing slower element-wise loop execution. - Zero GC Pressure: Because the simulator writes directly to pre-allocated columnar Apache Arrow tensors, the garbage collector overhead drops from allocating over 1.8 GB of temporary matrix slices down to less than 1.8 MB for the entire run. This eliminates latency spikes and memory fragmentation.
A simple starting point using manual iteration for a first-order system (
require "cryspace"
# G(s) = 1 / (s + 1)
a = [[-1.0]].to_tensor
b = [[1.0]].to_tensor
c = [[1.0]].to_tensor
d = [[0.0]].to_tensor
sys = CrySpace::StateSpace.new(a, b, c, d)
# Non-vectorized step response (manual loop)
t_arr, x_arr, y_arr = sys.step_response(n_steps: 10)
t_arr.each_with_index do |time, i|
state = x_arr[i][0, 0].value
output = y_arr[i][0, 0].value
puts "t: #{time.round(1)}s, State: #{state.round(4)}, Output: #{output.round(4)}"
endA classic 2-state matrix system representing a physical oscillator.
The Physical Setup:
Newton's Second Law for a mass
Choosing the state variables as position
Which gives the system matrices:
Implementation (Vectorized):
m, k, c = 1.0, 10.0, 0.5
a = [[0.0, 1.0], [-k/m, -c/m]].to_tensor
b = [[0.0], [1/m]].to_tensor
c = [[1.0, 0.0]].to_tensor
d = [[0.0]].to_tensor
sys_msd = CrySpace::StateSpace.new(a, b, c, d)
# Analyze structural properties
puts "Is Stable? #{sys_msd.is_stable?}"
puts "Is Controllable? #{sys_msd.is_controllable?}"
puts "Is Observable? #{sys_msd.is_observable?}"
# Vectorized simulation from 0 to 10s
t = Float64Tensor.linear_space(0.0, 10.0, 101)
times, states, outputs = sys_msd.simulate(t)
# Access trajectories
pos = states[..., 0]
vel = states[..., 1]
y_out = outputs[..., 0]
puts "Max position: #{pos.max.value}"This step-by-step guide shows how to model a physical system, design a PID controller, and simulate the closed-loop performance.
We model a second-order series RLC circuit with Capacitor Voltage (
The continuous-time system dynamics are governed by the following differential equations:
This can be written in state-space form (
require "cryspace"
# Circuit parameters
R, L, C = 1.0, 0.5, 0.1
a = [[0.0, 1/C], [-1/L, -R/L]].to_tensor
b = [[0.0], [1/L]].to_tensor
c = [[1.0, 0.0]].to_tensor # We measure Capacitor Voltage
d = [[0.0]].to_tensor
rlc_plant = CrySpace::StateSpace.new(a, b, c, d)We use a filtered derivative term to ensure the controller is "proper" and can be converted to State-Space.
kp, ki, kd = 10.0, 5.0, 1.0
tf = 0.01 # Filter time constant
# Construct PID Transfer Function
num_pid = [(kp * tf + kd), (kp + ki * tf), ki].to_tensor
den_pid = [tf, 1.0, 0.0].to_tensor
pid_tf = CrySpace::TransferFunction.new(num_pid, den_pid)
# Convert to State-Space for interconnection
pid_controller = pid_tf.to_statespaceWe connect the PID controller in negative feedback with the RLC plant.
# Negative feedback loop
sys_cl = rlc_plant.feedback(pid_controller)Verify if the designed control loop is stable and if all states are observable.
puts "Is Closed-Loop Stable? #{sys_cl.is_stable?}"
puts "Is Closed-Loop Observable? #{sys_cl.is_observable?}"
puts "Closed-Loop Poles: #{sys_cl.poles}"Simulate the full system transient and retrieve all state trajectories.
# Define time range
t = Float64Tensor.linear_space(0.0, 120.0, 1201)
# Vectorized simulation
times, states, outputs = sys_cl.simulate(t)
# Retrieve trajectories
# states[..., 0..1] -> RLC states
# states[..., 2..3] -> PID internal states
voltage = outputs[..., 0]
current = states[..., 1]
puts "Steady-state voltage: #{voltage[-1].value.round(4)} V"For advanced applications (such as when adding a first-order sensor lag
-
System Discretization: Discretize the continuous-time RLC plant and sensor models to
$dt = 0.01\text{s}$ using.sample(dt):plant_d = rlc_plant.sample(dt) sensor_d = sensor_dynamics.sample(dt)
-
Scalar Simulation: Extract scalar coefficients (e.g.
a11 = plant_d.a[0,0].value) to run the state updates using rawFloat64arithmetic. This bypasses the overhead of allocating high-levelTensorheap objects inside millions of loop iterations, speeding up execution by over$1000\times$ . -
Settling Time Optimization: Evaluate the simulated trajectory from the end backwards to find the exact step where it enters and stays within the target tolerance band:
ts_step = 0 (n_steps - 1).downto(0) do |step| if y_p_history[step] < 0.95 || y_p_history[step] > 1.05 ts_step = step + 1 break end end ts = ts_step * dt
-
Multi-Threaded Grid Search: Parallelize the candidate search by spawning concurrent fibers (
spawn) and collecting results via a thread-safeChannel, compiled with the-Dpreview_mtflag:crystal build -Dpreview_mt scratch/optimizer.cr -o scratch/optimizer CRYSTAL_WORKERS=8 ./scratch/optimizer
This optimization strategy finds the absolute best gains for both the standard RLC loop (
# Closed loop with unity gain feedback
k_gain = [[1.0]].to_tensor
sys_cl = sys.feedback(k_gain)
# Simulate closed-loop response
t_cl = Float64Tensor.linear_space(0.0, 10.0, 101)
_, states_cl, outputs_cl = sys_cl.simulate(t_cl)
# Retrieve values
cl_pos = states_cl[..., 0]
cl_out = outputs_cl[..., 0]
puts "Closed loop final position: #{cl_pos[-1].value}"You can simulate the system's response to a step input and obtain the trajectory of all internal states (
# Simulation for 5 seconds (50 steps of 0.1s)
t, x, y = sys.step_response(n_steps: 50)
puts "Time (s) | Position (Output) | Velocity (State x2)"
puts "-" * 55
t.each_with_index do |time, i|
x2 = x[i][1, 0].value
output = y[i][0, 0].value
puts "#{time.round(2).to_s.ljust(8)} | #{output.round(4).to_s.ljust(17)} | #{x2.round(4)}"
endIf you prefer to work with full matrices/tensors (similar to NumPy/MATLAB) without manual loops, you can pass a time vector:
# Create a time vector from 0 to 10s with 0.1s steps
t = Float64Tensor.linear_space(0.0, 10.0, 101)
# Simulate returns 2D Tensors:
# states: [time_steps x n_states]
# outputs: [time_steps x n_outputs]
times, states, outputs = sys.simulate(t)
# Now you have all position values in a single column
positions = states[..., 0]
velocities = states[..., 1]
system_outputs = outputs[..., 0]
puts "Final position: #{positions[-1].value}"You can solve arbitrary ODEs of the form:
and calculate derived outputs.
Passing a time vector to the solver returns results as Tensors, allowing for easy slicing and matrix operations:
f = ->(x : Float64Tensor, t : Float64) {
# Example: damped oscillator (x1: pos, x2: vel)
res = Float64Tensor.zeros([2, 1])
res[0, 0] = x[1, 0]
res[1, 0] = -10.0 * x[0, 0] - 0.5 * x[1, 0]
res
}
t_vec = Float64Tensor.linear_space(0.0, 10.0, 101)
x0 = [[1.0], [0.0]].to_tensor
# Vectorized solver returns {times, states} as Tensors
times, states = CrySpace::Solver.rk4(f, x0, t_vec)
# Extract trajectories
pos_trajectory = states[..., 0]
vel_trajectory = states[..., 1]
# Calculate an arbitrary output y = 2*pos + 0.1*vel using tensor math
outputs = pos_trajectory * 2.0 + vel_trajectory * 0.1
puts "Final Output: #{outputs[-1].value}"# Using t_span and dt returns Arrays of Tensors for the equation dx/dt = f(x, t)
times, states = CrySpace::Solver.rk4(f, x0, {0.0, 10.0}, 0.1)
times.each_with_index do |t, i|
pos = states[i][0, 0].value
vel = states[i][1, 0].value
y = 2 * pos + 0.1 * vel
puts "t: #{t.round(2)}, y: #{y.round(4)}"
endYou can combine Transfer Functions using standard operators.
# G1(s) = 1 / (s + 1)
tf1 = CrySpace::TransferFunction.new([1.0].to_tensor, [1.0, 1.0].to_tensor)
# G2(s) = 1 / (s + 2)
tf2 = CrySpace::TransferFunction.new([1.0].to_tensor, [1.0, 2.0].to_tensor)
# Parallel: G1 + G2
tf_sum = tf1 + tf2
# Series: G1 * G2
tf_mul = tf1 * tf2
# Feedback: G1 / (1 + G1*G2)
tf_cl = tf1.feedback(tf2)
# To simulate, convert to State-Space
sys_tf = tf_cl.to_statespace
t_tf = Float64Tensor.linear_space(0.0, 5.0, 51)
_, states_tf, outputs_tf = sys_tf.simulate(t_tf)
puts "TF output at 5s: #{outputs_tf[-1, 0].value}"Easily switch between State-Space and Transfer Function representations.
# State-Space to Transfer Function
tf = sys.to_transferfunction
# Transfer Function to State-Space (Controllable Canonical Form)
ss = tf.to_statespace
# The converted system can be simulated normally
t_ss = Float64Tensor.linear_space(0.0, 1.0, 11)
_, x_ss, y_ss = ss.simulate(t_ss)Compute a state-feedback gain matrix
# Double integrator plant
a = [[0.0, 1.0], [0.0, 0.0]].to_tensor
b = [[0.0], [1.0]].to_tensor
sys = CrySpace::StateSpace.new(a, b, [[1.0, 0.0]].to_tensor, [[0.0]].to_tensor)
# Place closed-loop poles at -2.0 and -3.0
k = sys.acker([-2.0, -3.0])
puts "Feedback Gain K: #{k}" # => [[6.0, 5.0]]Evaluate the system transfer matrix
# First-order system: G(s) = 1 / (s + 1)
sys = CrySpace::StateSpace.new([[-1.0]].to_tensor, [[1.0]].to_tensor, [[1.0]].to_tensor, [[0.0]].to_tensor)
# Evaluate at 1 rad/s and 10 rad/s
omega = [1.0, 10.0].to_tensor
response = sys.freqresp(omega)
puts "Response at 1 rad/s: #{response[0, 0, 0]}" # => (0.5 - 0.5j)
puts "Response at 10 rad/s: #{response[0, 0, 1]}" # => (0.0099 - 0.099j)Solve the Continuous Algebraic Riccati Equation.
# Continuous Algebraic Riccati Equation: A^T*P + P*A - P*B*R^-1*B^T*P + Q = 0
q = [[3.0]].to_tensor
r = [[1.0]].to_tensor
p = sys.care(q, r)
puts "Solution P: #{p}" # => [[4.64575]]Compute the optimal feedback gain matrix
# Define weighting matrices
q = [[3.0]].to_tensor
r = [[1.0]].to_tensor
# Design optimal controller
k, p, closed_loop_poles = sys.lqr(q, r)
puts "Optimal gain matrix K: #{k}"Solve continuous-time (
# Solve continuous Lyapunov equation
q_cont = [[1.0]].to_tensor
p_cont = sys.lyap(q_cont)
# Solve discrete Lyapunov equation
q_disc = [[1.0]].to_tensor
p_disc = sys.dlyap(q_disc)Compute classical Gain Margin (absolute and in dB), Phase Margin (in degrees), and crossover frequencies.
gm, gm_db, pm, w_gc, w_pc = sys.stability_margins
puts "Phase Margin: #{pm.round(2)} deg at #{w_gc} rad/s"Implement a Discrete-Time Kalman Filter to track state estimates in the presence of process and measurement noise.
# Discretize system first
sys_discrete = sys.sample(0.5)
q_noise = [[0.05]].to_tensor # process covariance
r_noise = [[1.5]].to_tensor # measurement covariance
kf = CrySpace::KalmanFilter.new(sys_discrete, q_noise, r_noise)
# In your recursive estimator loop:
kf.predict(u: control_input)
kf.update(y: noisy_measurement)
puts "Estimated State: #{kf.x}"Solve Discrete-time Algebraic Riccati Equations and design discrete-time LQR controllers.
# Solve DARE
p = sys.dare(q, r)
# Design DLQR (u = -Kx)
k, p, closed_loop_poles = sys.dlqr(q, r)Design full-state observers by placing the estimator poles using duality.
# Place observer poles at -4.0 and -5.0
l = sys.acker_obs([-4.0, -5.0])Compute controllability or observability gramians and Hankel singular values for model reduction.
# Controllability Gramian
wc = sys.gram(:c)
# Observability Gramian
wo = sys.gram(:o)
# Hankel Singular Values
hsv = sys.hsvdSimulate time-domain response of LTI systems with time-varying input vectors.
t = Float64Tensor.linear_space(0.0, 5.0, 51)
u = Float64Tensor.ones([1, t.size])
t_out, x_out, y_out = sys.lsim(u, t)Compute optimal PID controller parameters (
kp, ki, kd = CrySpace::Tuning.ziegler_nichols_fopdt(k_process, tau_process, theta_process)Convert a SISO system to Observability Canonical Form or any system to Modal (diagonalized) Form.
# Convert to Observability Canonical Form
sys_obs = sys.to_observability_form
# Convert to Modal Canonical Form (decoupled diagonal state-space representation)
sys_modal = sys.to_modal_formCalculate trajectories of closed-loop poles as the feedback gain
gains = [0.0, 0.5, 1.0, 2.0, 5.0, 10.0].to_tensor
poles_matrix = sys.root_locus(gains)A standard PID controller with actuator saturation limits and clamping anti-windup to prevent integral windup.
u_min, u_max = -10.0, 10.0
pid = CrySpace::PIDController.new(kp: 4.0, ki: 12.0, kd: 0.5, tf: 0.01, u_min: u_min, u_max: u_max)
# In your simulation/control loop:
u = pid.update(error, dt)Generate frequency response data for Bode plots (dB magnitude and degrees phase) and Nyquist plots (real and imaginary parts).
omega = [0.1, 1.0, 5.0, 10.0, 100.0].to_tensor
# Bode data: {frequencies, magnitudes_db, phases_deg}
w_out, db, deg = sys.bode_data(omega)
# Nyquist data: {real_parts, imag_parts}
real_parts, imag_parts = sys.nyquist_data(omega)Convert a discrete-time system back to continuous-time using the robust state-space matrix logarithm method.
# sys_discrete has a defined sampling time dt
sys_continuous = sys_discrete.to_continuousDiscretize a system using Tustin's bilinear approximation or GBT with a custom weighting parameter alpha.
# Discretize continuous system using Bilinear (Tustin) method
sys_discrete = sys.sample(dt: 0.05, method: :tustin)
# Discretize using Generalized Bilinear Transform with custom alpha (e.g. Backward Euler alpha=1.0)
sys_discrete_gbt = sys.sample(dt: 0.05, method: :gbt, alpha: 1.0)Reduce the model order of a stable state-space system by balancing controllability/observability Gramians and truncating states with small energy.
# Reduce system order to 2 states
sys_reduced = sys.balred(orders: 2)Design optimal output-feedback controllers by combining LQR feedback gain K and Kalman Filter estimator gain L.
# Synthesize LQG controller (returns a StateSpace controller)
lqg_controller = sys.lqg(k_gain, l_gain)Generate frequency response data for Nichols plots (gain magnitude in dB and phase in degrees).
omega = [0.1, 1.0, 10.0].to_tensor
w_out, db, deg = sys.nichols_data(omega)A quick alias to get gain and phase stability margins.
gm, gm_db, pm, w_gc, w_pc = sys.stability_marginsPerforms coordinate transformation
t_matrix = [[2.0, 0.0], [0.0, 2.0]].to_tensor
sys_transformed = sys.similarity_transform(t_matrix)X. Kalman Controllability & Observability Decomposition (controllable_decomposition & observable_decomposition)
Decomposes a state-space system into its controllable/uncontrollable or observable/unobservable parts.
# Controllability decomposition: returns {transformed_system, T_matrix, controllable_rank}
sys_c, t_c, rank_c = sys.controllable_decomposition
# Observability decomposition: returns {transformed_system, T_matrix, observable_rank}
sys_o, t_o, rank_o = sys.observable_decompositionComputes the minimal realization of the state-space system, eliminating uncontrollable and unobservable states to yield a system with the minimum number of states.
sys_minimal = sys.minrealAugments the state-space system with an integrator to enable zero steady-state error tracking control (often used with LQR design).
sys_augmented = sys.augment_integratorSolves the Continuous-Time Algebraic Riccati Equation (CARE) or Discrete-Time Algebraic Riccati Equation (DARE) using duality to compute the optimal Kalman filter estimator gain
# Continuous-time LQE estimator gain design
l_gain = sys.lqe(q_noise, r_noise)
# Discrete-time DLQE estimator gain design
l_gain_d = sys_discrete.dlqe(q_noise, r_noise)Calculates the bandwidth (in rad/s) for a SISO system, defined as the frequency where magnitude drops to
w_bw = sys.bandwidthSimulate system response to a unit impulse input or the unforced free response from a non-zero initial state.
# Impulse response simulation
t, x, y = sys.impulse_response(n_steps: 100)
# Free response starting from initial state x0
x0 = [[1.0], [0.0]].to_tensor
t, x, y = sys.initial_response(x0, n_steps: 100)Compute raw controllability and observability matrices to analyze system properties manually.
co_mat = sys.ctrb
ob_mat = sys.obsvEE. Canonical Realization Transformations (to_control_canonical_form & to_observable_canonical_form)
Transform single-input or single-output systems to Control Canonical Form (CCF) or Observable Canonical Form (OCF).
# CCF transformation: returns {ccf_system, T_matrix}
sys_ccf, t_ccf = sys.to_control_canonical_form
# OCF transformation: returns {ocf_system, T_matrix}
sys_ocf, t_ocf = sys.to_observable_canonical_formPerform algebraic control loop interconnection with custom feedback signs, and execute numerical pole-zero cancellation on transfer functions.
# Positive feedback loop: G_cl = G1 / (1 - G1 * G2)
g_cl = g1.feedback(g2, sign: 1)
# Minimal realization: cancels poles and zeros that are within a specified tolerance
g_minimal = g.minreal(tol: 1e-3)Approximates pure time delay (transport lag) as a rational transfer function of a specified order.
# Approximates e^(-0.5 * s) as a 2nd-order TransferFunction
tf_delay = CrySpace::TransferFunction.pade(delay: 0.5, order: 2)Converts a State-Space representation (
tf_sys = sys.ss2tfComputes the peak magnitude of the frequency response (i.e. the
peak_val = sys.peak_gainComputes gain margins bounds and phase margin bounds using the Open-Loop Nyquist distance to the critical point
# Returns { {gm_low, gm_high}, {pm_low, pm_high} }
gm, pm = sys.loop_marginsDesigns optimal state-feedback control gains
# Optimal H2 synthesis (equivalent to LQR)
k_h2, p_h2 = sys.h2syn(c_performance, d_performance)
# Robust suboptimal H-infinity synthesis for attenuation level gamma
k_hinf, p_hinf = sys.hinfsyn(c_performance, d_performance, gamma: 1.5)Analyzes the step response of a SISO system to extract key transient response metrics (rise time, settling time, overshoot, peak value, peak time, steady-state value):
# Run step response metrics analysis
info = sys.stepinfo(n_steps: 300, settling_threshold: 0.02)
puts "Rise Time: #{info.rise_time}s"
puts "Settling Time: #{info.settling_time}s"
puts "Overshoot: #{info.overshoot}%"
puts "Peak: #{info.peak} at #{info.peak_time}s"Easily design classical Lead or Lag compensators in TransferFunction form:
# Lead compensator: G_lead(s) = K * (s + zero) / (s + pole) (zero < pole)
lead = CrySpace::TransferFunction.lead_compensator(gain: 2.0, zero: 1.0, pole: 10.0)
# Lag compensator: G_lag(s) = K * (s + zero) / (s + pole) (zero > pole)
lag = CrySpace::TransferFunction.lag_compensator(gain: 3.0, zero: 5.0, pole: 0.5)Simulates the true state space system coupled with a state estimator observer (Kalman Filter or LQE) under state feedback
# Simulate observer dynamics with covariance matrices
t_out, x_out, x_est_out, y_out, u_out = sys.simulate_observer(
t,
k_gain,
l_gain,
x0: x0,
x0_est: x0_est,
process_noise_cov: q_cov,
measure_noise_cov: r_cov
)Computes the singular values of the frequency response matrix
# Compute singular values of MIMO frequency response
w_points, sv_matrix = sys_mimo.sigma_data(omega)Module-level functions for constructing system connections:
# Connect systems
sys_s = CrySpace.series(sys1, sys2)
sys_p = CrySpace.parallel(sys1, sys2)
sys_f = CrySpace.feedback(sys1, sys2)
sys_a = CrySpace.append(sys1, sys2) # block-diagonal parallel concatCalculates the feedforward scaling factor
# Prefilter scaling gain N
n_scale = sys.nbar(k_gain)Simulates the system's output response to a ramp input (
# Run ramp simulation
t, x, y = sys.ramp_response(n_steps: 100)Designs an analog lowpass Butterworth filter of a given order and cutoff frequency
# 2nd-order Butterworth lowpass filter with Wn = 10 rad/s
butter_filter = CrySpace::TransferFunction.butter(2, 10.0)Creates static gain and identity state-space systems with 0 states:
# Creates a 2x2 identity gain system
sys_eye = CrySpace::StateSpace.eye(2)
# Creates a static gain system from a matrix
sys_gain = CrySpace::StateSpace.static_gain(gain_matrix)Calculates the Sensitivity function
# Compute S and T for StateSpace or TransferFunction systems
s = g.sensitivity(k)
t = g.complementary_sensitivity(k)Transforms a normalized lowpass filter into a highpass or bandpass filter:
# Lowpass to Highpass with cutoff 3.0 rad/s
hp = lp.lowpass_to_highpass(3.0)
# Lowpass to Bandpass with center 2.0 rad/s and bandwidth 0.5 rad/s
bp = lp.lowpass_to_bandpass(center_frequency: 2.0, bandwidth: 0.5)Discretizes a TransferFunction or recovers a continuous TransferFunction directly:
# Convert continuous TransferFunction to discrete
tf_d = tf.to_discrete(dt: 0.1, method: :zoh)
# Convert discrete TransferFunction to continuous
tf_c = tf_d.to_continuousComputes the transmission zeros of a StateSpace system:
# Calculate transmission zeros of the system
zeros = sys.transmission_zerosComputes the balanced realization of a StateSpace system, returning the balanced system along with the similarity transformation matrices
# Balanced realization
sys_bal, t_matrix, t_inv = sys.balrealDesigns continuous or discrete LQR feedback gains
# continuous LQR with cross-coupling N
k, p, poles = sys.lqr(q, r, n_cross)
# discrete DLQR with cross-coupling N
kd, pd, poles_d = sys_d.dlqr(q, r, n_cross)Solves the continuous-time matrix Sylvester equation
# Solve A*X + X*B = C
x = CrySpace::StateSpace.sylvester(a, b, c)Places closed-loop poles at desired locations for SISO or multi-input systems:
# Designs feedback gain matrix K to place poles
k = sys.place([-5.0, -6.0])Computes the
# Get H2 norm
norm_h2 = sys.h2normConverts a continuous system to a discrete system using Matched Pole-Zero mapping:
# Discretize using matched pole-zero mapping
tf_d = tf.to_discrete(dt: 0.1, method: :matched)Designs a classic phase lead/lag compensator transfer function:
# Design a lead compensator with zero=2.0, pole=10.0, gain=1.5
comp = CrySpace::TransferFunction.leadlag(2.0, 10.0, 1.5)Creates a frequency-dependent loop-shaping performance weight
# Low-freq gain = 0.1, crossover frequency = 10.0, high-freq gain = 2.0
w = CrySpace::TransferFunction.makeweight(0.1, 10.0, 2.0)Estimates a TransferFunction parameters from input-output time series:
# Identify TF of order 1 from excitation data
sys_ident = CrySpace::Ident.least_squares_estimation(u_data, y_data, 1, dt)Realizes a StateSpace system from discrete-time impulse response data:
# Realize StateSpace of order 1 from impulse response data
sys_real = CrySpace::Ident.era(impulse_resp, 1, 1, 1, dt)Computes describing functions for nonlinear saturation and deadzone blocks:
# Saturation describing function for amplitude 2.0, limit 1.0
sat_gain = CrySpace::Nonlinear.describing_function_saturation(2.0, 1.0)
# Deadzone describing function for amplitude 1.0, half-width 0.5
dead_gain = CrySpace::Nonlinear.describing_function_deadzone(1.0, 0.5)Computes the Routh array and determines continuous-time stability:
# Check continuous stability
res = tf.routh_hurwitz
stable = res[:stable]
routh_table = res[:table]Computes the Jury table and determines discrete-time stability:
# Check discrete stability
res = tf_discrete.jury_test
stable = res[:stable]
jury_table = res[:table]Computes the RGA matrix at frequency
# RGA at DC
rga_dc = sys_mimo.rga(0.0)Discretizes a system while preserving matching response at pre-warped frequency
# Bilinear pre-warping discretization at omega_c = 5.0
sys_d = sys.sample(dt: 0.1, method: :prewarped, omega_c: 5.0)Constructs a minimal realization from discrete impulse response data:
# Ho-Kalman state-space realization
sys = CrySpace::Ident.ho_kalman(impulse_resp, n_states, m_inputs, n_outputs, dt)Direct state-space realization from impulse response data using Hankel SVD:
# Kung's state-space realization
sys = CrySpace::Ident.kung(impulse_resp, n_states, m_inputs, n_outputs, dt)Fits transfer function parameters to measured complex frequency response data:
# Fit TF with 0 zeros and 1 pole to frequency data
tf_fit = CrySpace::Ident.levy_fit(omega_points, complex_resp, 0, 1)Generates a Pseudo-Random Binary Sequence (LFSR) of maximum-length:
# Generate PRBS signal of order 4
prbs_signal = CrySpace::Ident.prbs(4)Generates a swept-frequency cosine signal:
# Swept cosine from f0=0 to f1=10 over time vector t
chirp_signal = CrySpace::Ident.chirp(t, 0.0, 1.0, 10.0)Designs a continuous-time notch filter rejecting a specific center frequency:
# Notch filter at w0=10.0 rad/s and zeta=0.1
notch_filter = CrySpace::TransferFunction.notch(10.0, 0.1)Tunes PID parameters based on ultimate gain
# ZN Closed-loop gains
gains = CrySpace::Tuning.pid_tune_zn(ku: 2.5, tu: 3.0, type: :pid)Tunes PID parameters based on FOPDT model parameters:
# Cohen-Coon gains
gains = CrySpace::Tuning.pid_tune_cc(gp: 2.0, tau: 10.0, theta: 1.5, type: :pid)Computes the exact peak gain of a continuous system using the Hamiltonian matrix method:
# Exact H-infinity norm
h_inf = sys.hinfnorm_exactWWW. Model Residualization / Singular Perturbation Reduction (residual_reduction)
Reduces model order while preserving the DC gain exactly:
# Reduce order to 1 state (residualization)
sys_reduced = sys.residual_reduction(1)Recursive estimator for nonlinear systems using Jacobians:
# Initialize and step EKF
ekf = CrySpace::ExtendedKalmanFilter.new(f, h, jf, jh, q, r, x0, p0)
ekf.predict(u)
ekf.update(y)Nonlinear estimator propagating sigma points through nonlinear dynamics:
# Initialize and step UKF
ukf = CrySpace::UnscentedKalmanFilter.new(f, h, q, r, x0, p0)
ukf.predict(u)
ukf.update(y)Performs trajectory optimization for nonlinear systems:
# Run iLQR
xs, us = CrySpace::AdaptiveNonlinear.ilqr(f, jf, ju, q, r, qf, x0, u_init)Simulates Lyapunov-based adaptive control loops tracking a reference model:
# Run MRAC simulation
xs, xms, us = CrySpace::AdaptiveNonlinear.mrac_simulate(a_plant, b_plant, a_model, b_model, gamma_x, gamma_r, r_sig, t_vec)Computes complex gain describing function for mechanical gear play/backlash:
# Complex describing function gain for amplitude 2.0, backlash width 0.5
df_gain = CrySpace::AdaptiveNonlinear.describing_function_backlash(2.0, 0.5)Solve differential equations adaptively based on error tolerance:
# dx/dt = -2x
f = ->(x : Float64Tensor, t : Float64) { x * -2.0 }
x0 = [1.0].to_tensor
times, states = CrySpace::Solver.rk45(f, x0, {0.0, 2.0}, tol: 1e-6)Computes the modal state transformation and diagonalizes (or block-diagonalizes for complex eigenvalues) the system matrices:
# Get Jordan Canonical Form and transformation matrix T
sys_jordan, t_matrix = sys.to_jordan_formTracks state estimation for LTI continuous and discrete systems:
# Initialize Luenberger Observer
observer = CrySpace::LuenbergerObserver.new(sys, l_gain, x0_hat: [[0.0]].to_tensor)
# Continuous update (propagating observer via RK4)
observer.update_continuous(y, u, dt)
# Discrete update (propagating observer via algebraic update)
observer.update_discrete(y, u)Generate fully self-contained HTML/Chart.js plotting dashboards for systems or transfer functions:
# Generate Step Response HTML dashboard
sys.step_plot("step_response.html")
# Generate Bode Diagram (magnitude & phase) HTML dashboard
sys.bode_plot("bode_response.html")CrySpace is designed for high-performance control systems engineering. Below is a benchmark comparing the Optimized CPU Backend (default) and the Apache Arrow SIMD Backend on a standard 4-state system with 1,000,000 simulation steps.
| Backend | Execution Time | GC Allocated Memory | Optimization Level |
|---|---|---|---|
| CPU (Optimized) | 4.9 s | 6.2 GB | Default / Zero-Allocation |
| Arrow SIMD | 89.4 s | 12.8 GB | High Throughput / FFI Overhead |
- CPU is King for Control Systems: For standard systems (1-50 states), the CPU backend is significantly faster due to the overhead of Foreign Function Interface (FFI) calls to the Arrow C++ kernels.
- Zero-Allocation Simulation: The CPU backend uses pre-allocated memory pools for
simulateiterations, minimizing Garbage Collector (GC) pressure. - When to use Arrow?: Arrow SIMD is recommended for massive systems (>100 states) or when performing batch processing of millions of trajectories simultaneously, where the SIMD throughput outweighs the call overhead.
Run the specs to ensure everything is working correctly:
crystal spec- Fork it (https://github.com/eltony81/cryspace/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
- eltony81 - creator and maintainer
- The automatic conversion from
TransferFunctiontoStateSpacefor complex PID controllers can be numerically unstable. The recommended approach is to define the controller directly in itsStateSpacerepresentation.

