state_space_control

Controller synthesis, the canonical trajectory format, excitations, and the closed-loop response simulator. See the guide and the format spec.

base — Plant, ControllerResult, registry

Core types: Plant, ControllerResult, and the controller registry.

A controller design is a class decorated with @register('name') that implements design(plant) -> ControllerResult. Adding a new controller type to the toolbox means adding one module under controllers/ — nothing else has to change:

from state_space_control.base import ControllerDesign, register

@register('my_controller')
class MyController(ControllerDesign):
    def __init__(self, gain=1.0):
        self.gain = gain

    def design(self, plant):
        ...
        return ControllerResult(name='my_controller', plant=plant, K=K)
class state_space_control.base.Plant(A: ~numpy.ndarray, B: ~numpy.ndarray, C: ~numpy.ndarray, D: ~numpy.ndarray, input_names: ~typing.List[str] = <factory>, output_names: ~typing.List[str] = <factory>, u_eq: ~numpy.ndarray | None = None)[source]

Bases: object

A linear plant x_dot = A x + B u, y = C x + D u.

A: ndarray
B: ndarray
C: ndarray
D: ndarray
input_names: List[str]
output_names: List[str]
u_eq: ndarray | None = None
property n_states: int
property n_inputs: int
property n_outputs: int
classmethod from_model(model) Plant[source]

Adapt anything with A/B/C/D attributes (e.g. a urdf_state_space.StateSpaceModel or a python-control StateSpace).

classmethod from_npz(path: str) Plant[source]

Load a plant saved by urdf_state_space (StateSpaceModel.save_npz).

poles() ndarray[source]
class state_space_control.base.ControllerResult(name: str, plant: ~state_space_control.base.Plant, K: ~numpy.ndarray | None = None, controller: ~state_space_control.base.Plant | None = None, info: ~typing.Dict = <factory>)[source]

Bases: object

Outcome of a controller synthesis.

Exactly one of the two is set by a design:

  • K: static state-feedback gain, control law u = u_eq - K x (needs full state measurement/estimation).

  • controller: dynamic output-feedback controller as an LTI system from the plant measurement y to the control u, sign included — the closed loop is formed by literally connecting u = controller(y).

name: str
plant: Plant
K: ndarray | None = None
controller: Plant | None = None
info: Dict
closed_loop() Plant[source]

Assemble the closed-loop system (outputs = plant outputs).

closed_loop_poles() ndarray[source]
is_stable(tol: float = 0.0) bool[source]
save_npz(path: str) None[source]
summary() str[source]
class state_space_control.base.ControllerDesign[source]

Bases: object

Base class for controller designs. Parameters go in __init__; design maps a Plant to a ControllerResult.

design(plant: Plant) ControllerResult[source]
state_space_control.base.register(name: str)[source]

Class decorator adding a ControllerDesign to the registry.

state_space_control.base.make_controller(name: str, **params) ControllerDesign[source]

Instantiate a registered design by name (see available_controllers).

state_space_control.base.available_controllers() List[str][source]
state_space_control.base.as_matrix(spec, n: int, name: str = 'matrix') ndarray[source]

Turn a YAML-friendly spec into an (n, n) matrix.

scalar -> scalar * I, flat list -> diag(list), nested list -> full.

trajectory — the canonical format

RobotTrajectory: the framework’s canonical motion-interchange format.

Every module that touches motion data speaks this format — producers (the linear state-space simulator today; nonlinear simulators, MuJoCo, rosbag importers, real-robot logs later) write it, consumers (web viewer, RViz playback, benchmark comparison, report/video export) read it, and neither side knows about the other. A consumer requires only a valid trajectory; nothing here imports controllers, excitations, or ROS.

This module must stay importable with numpy alone.

npz schema robot_trajectory/1

Arrays: t (N,), q (N, nj), qd (N, nj), optional u (N, m), optional base_pose (N, 7) as [x y z, qx qy qz qw] in the world frame, optional base_twist (N, 6); joint_names, actuated_joint_names as string arrays; schema and a JSON-encoded header string holding meta and events. Loads with allow_pickle=False.

base_pose is None means fixed base: the URDF root stays wherever the consumer’s world anchor puts it — do not invent an identity transform.

Reserved event types (consumers ignore unknown types — additive evolution): limit_violation (subject=joint, data={‘t_start’,’t_end’}), linear_validity (subject=joint), instability, settling_time, overshoot_peak, saturation, user.

Control inputs u are stored in deviation form (u − u_eq) with u_eq recorded in meta['operating_point'] — one convention, so producers never disagree silently.

class state_space_control.trajectory.TrajectoryEvent(t: float, type: str, subject: str = '', message: str = '', data: ~typing.Dict = <factory>)[source]

Bases: object

A time-stamped annotation on a trajectory.

t: float
type: str
subject: str = ''
message: str = ''
data: Dict
to_dict() Dict[source]
classmethod from_dict(d: Dict) TrajectoryEvent[source]
class state_space_control.trajectory.RobotFrame(t: float, joint_positions: Dict[str, float], joint_velocities: Dict[str, float], base_pose: ndarray | None = None, base_twist: ndarray | None = None, u: ndarray | None = None)[source]

Bases: object

The robot’s configuration at one instant — what renderers consume.

t: float
joint_positions: Dict[str, float]
joint_velocities: Dict[str, float]
base_pose: ndarray | None = None
base_twist: ndarray | None = None
u: ndarray | None = None
class state_space_control.trajectory.RobotTrajectory(t: ~numpy.ndarray, q: ~numpy.ndarray, qd: ~numpy.ndarray, joint_names: ~typing.List[str], actuated_joint_names: ~typing.List[str] = <factory>, u: ~numpy.ndarray | None = None, base_pose: ~numpy.ndarray | None = None, base_twist: ~numpy.ndarray | None = None, events: ~typing.List[~state_space_control.trajectory.TrajectoryEvent] = <factory>, meta: ~typing.Dict = <factory>)[source]

Bases: object

Time-stamped motion of one URDF-based robot, any base type.

Joint positions are absolute (operating-point offsets already applied by the producer); consumers never see deviation coordinates.

t: ndarray
q: ndarray
qd: ndarray
joint_names: List[str]
actuated_joint_names: List[str]
u: ndarray | None = None
base_pose: ndarray | None = None
base_twist: ndarray | None = None
events: List[TrajectoryEvent]
meta: Dict
property duration: float
property n_joints: int
validate() RobotTrajectory[source]

Fail loudly at the boundary, not deep inside a renderer.

save_npz(path: str) None[source]
classmethod from_npz(path: str) RobotTrajectory[source]
class state_space_control.trajectory.FrameSampler(traj: RobotTrajectory, method: str = 'linear')[source]

Bases: object

Answers “what does the robot look like at simulation time t?”.

Owns interpolation entirely; playback engines, plot cursors and offline exporters all call frame_at so a 30 fps video render and a jittery 60 Hz timer follow the identical code path. t is clamped to the trajectory’s time span.

METHODS = ('linear', 'hold')
frame_at(t: float) RobotFrame[source]

excitations — test-input registry

Excitation plugin registry: the inputs that drive a response simulation.

An excitation is anything that produces the exogenous input d(t) for a closed-loop experiment. In this regulator framework v1 excitations enter as an input disturbance at the plant input, u = u_ctrl + d(t) — the same convention the benchmark’s step metrics use. Pure initial-condition experiments use the zero excitation with an x0.

Registering a new excitation mirrors the controller registry exactly:

from state_space_control.excitations import Excitation, register_excitation

@register_excitation('chirp')
class Chirp(Excitation):
    PARAMS = [{'name': 'f0', 'default': 0.1}, ...]
    def sample(self, t): ...

and it appears in the wizard/CLI with no other change. The injection class attribute is ‘input’ for everything in v1; ‘reference’ and ‘output’ are reserved so reference tracking and measurement disturbances can be added later without renaming anything.

class state_space_control.excitations.Excitation[source]

Bases: object

Base class. Parameters go in __init__; sample maps a time grid to the input samples d(t) with shape (len(t),).

injection = 'input'
PARAMS: List[Dict] = []
sample(t: ndarray) ndarray[source]
describe() str[source]
state_space_control.excitations.register_excitation(name: str)[source]
state_space_control.excitations.make_excitation(name: str, **params) Excitation[source]
state_space_control.excitations.available_excitations() List[str][source]
state_space_control.excitations.excitation_schemas() List[Dict][source]

UI form metadata, same shape as the controller schemas.

class state_space_control.excitations.Step(amplitude=1.0, t_start=0.0)[source]

Bases: Excitation

Constant disturbance switched on at t_start.

PARAMS: List[Dict] = [{'default': 1.0, 'doc': 'step height', 'name': 'amplitude'}, {'default': 0.0, 'doc': 'switch-on time [s]', 'name': 't_start'}]
sample(t)[source]
registry_name = 'step'
class state_space_control.excitations.Impulse(area=1.0)[source]

Bases: Excitation

Ideal impulse of the given area, realized exactly as the equivalent initial-state jump x0 += B[:, channel] * area (a sampled 1-step pulse would depend on the grid spacing; the LTI equivalence does not). sample therefore returns zeros — the simulator applies the jump.

PARAMS: List[Dict] = [{'default': 1.0, 'doc': 'impulse area (N*m*s for torque inputs)', 'name': 'area'}]
sample(t)[source]
registry_name = 'impulse'
class state_space_control.excitations.Ramp(slope=1.0, t_start=0.0, saturation=None)[source]

Bases: Excitation

Linearly growing disturbance, optionally saturating.

PARAMS: List[Dict] = [{'default': 1.0, 'doc': 'growth rate [1/s]', 'name': 'slope'}, {'default': 0.0, 'doc': 'onset time [s]', 'name': 't_start'}, {'default': None, 'doc': 'clip magnitude (empty = unbounded)', 'name': 'saturation'}]
sample(t)[source]
registry_name = 'ramp'
class state_space_control.excitations.Sine(amplitude=1.0, freq_hz=0.5, phase=0.0)[source]

Bases: Excitation

Sinusoidal disturbance amplitude*sin(2*pi*freq_hz*t + phase).

PARAMS: List[Dict] = [{'default': 1.0, 'name': 'amplitude'}, {'default': 0.5, 'doc': 'frequency [Hz]', 'name': 'freq_hz'}, {'default': 0.0, 'doc': 'phase [rad]', 'name': 'phase'}]
sample(t)[source]
registry_name = 'sine'
class state_space_control.excitations.Custom(t_samples=(), u_samples=())[source]

Bases: Excitation

User-supplied samples, linearly interpolated (zero outside the given span).

PARAMS: List[Dict] = [{'default': [], 'doc': 'time points [s]', 'name': 't_samples'}, {'default': [], 'doc': 'values at those times', 'name': 'u_samples'}]
sample(t)[source]
registry_name = 'custom'
class state_space_control.excitations.Zero[source]

Bases: Excitation

No input — for pure initial-condition experiments (set x0).

PARAMS: List[Dict] = []
sample(t)[source]
registry_name = 'zero'

simulation — closed-loop response

Closed-loop response simulation → RobotTrajectory production.

simulate_response works on any ControllerResult through closed_loop() — static gain or dynamic controller, present or future plugin — in deviation coordinates. to_robot_trajectory is the single place where deviation states become absolute joint positions (q = q_eq + δq) and where reproducibility metadata and event annotations are attached; everything downstream consumes only the canonical RobotTrajectory.

state_space_control.simulation.control_output_matrix(result: ControllerResult) ndarray[source]

Cu such that u(t) = Cu @ x_cl(t) for the closed loop of result.

Mirrors the state ordering of ControllerResult.closed_loop(): [x_plant] for static K, [x_plant; x_controller] for dynamic.

state_space_control.simulation.default_t_final(poles: ndarray) float[source]

~8 time constants of the slowest stable pole, fallback 10 s.

class state_space_control.simulation.SimOutput(t: ~numpy.ndarray, x_plant: ~numpy.ndarray, x_ctrl: ~numpy.ndarray, y: ~numpy.ndarray, u: ~numpy.ndarray, d: ~numpy.ndarray, channel: int, x0: ~numpy.ndarray, stable: bool, capped: bool, excitation_meta: ~typing.Dict = <factory>)[source]

Bases: object

Deviation-space result of one closed-loop simulation.

t: ndarray
x_plant: ndarray
x_ctrl: ndarray
y: ndarray
u: ndarray
d: ndarray
channel: int
x0: ndarray
stable: bool
capped: bool
excitation_meta: Dict
state_space_control.simulation.simulate_response(result: ControllerResult, excitation: Excitation, *, x0: ndarray | None = None, t: ndarray | None = None, t_final: float | None = None, n_points: int = 1200, channel: int = 0) SimOutput[source]

Simulate the closed loop under an excitation at one plant input.

The excitation enters as an input disturbance, u = u_ctrl + d(t); x0 is the initial deviation state of the plant (controller states start at zero). Unstable loops are simulated but time-capped so the divergence stays renderable.

state_space_control.simulation.annotate_events(traj: RobotTrajectory, *, limits: Dict[str, Tuple[float, float]] | None = None, validity_threshold: float = 0.2) List[TrajectoryEvent][source]

Honesty annotations, computable for a trajectory from any source.

  • limit_violation: q leaves the URDF limits (linear sims ignore them; flagged, never clamped).

  • linear_validity: |q q_eq| exceeds the threshold — beyond it the linearized response is fiction (needs meta[‘operating_point’]).

  • instability / settling_time / overshoot_peak: read off the actuated joints when the producer recorded stability info.

state_space_control.simulation.to_robot_trajectory(model, result: ControllerResult, sim: SimOutput, *, limits: Dict[str, Tuple[float, float]] | None = None, validity_threshold: float = 0.2, extra_meta: Dict | None = None) RobotTrajectory[source]

Deviation states → canonical trajectory. THE q = q_eq + δq step.

model is duck-typed: anything with q_eq, u_eq, joint_names and actuated_joint_names (e.g. a urdf_state_space.StateSpaceModel).

analysis — response metrics

Analysis helpers for plants and closed loops (scipy-only).

state_space_control.analysis.damping_report(sys: Plant) str[source]

Per-pole natural frequency and damping ratio table.

state_space_control.analysis.step_response(sys: Plant, input_index: int = 0, t_final: float | None = None, n_points: int = 500) Tuple[ndarray, ndarray][source]

Step response of one input channel; returns (t, y[t, outputs]).

state_space_control.analysis.settling_time(t: ndarray, y: ndarray, band: float = 0.02) float[source]

Time after which y stays within +/-band of its final value.

controllers

Continuous-time LQR: u = u_eq - K x minimizing integral of x’Qx + u’Ru.

class state_space_control.controllers.lqr.LQR(Q=1.0, R=1.0)[source]

Bases: ControllerDesign

State-feedback LQR. Q and R may be scalars, diagonals, or matrices.

design(plant: Plant) ControllerResult[source]
registry_name = 'lqr'

LQG: LQR state feedback on a Kalman-filter state estimate.

Controller (from measurement y to control u):

xhat_dot = (A - B K - L C) xhat + L y u = -K xhat

class state_space_control.controllers.lqg.LQG(Q=1.0, R=1.0, W=1.0, V=1.0)[source]

Bases: ControllerDesign

LQR cost (Q, R) plus Kalman filter with process noise covariance W (entering through the input matrix B) and measurement noise covariance V.

design(plant: Plant) ControllerResult[source]
registry_name = 'lqg'

H-infinity synthesis (needs python-control + slycot).

hinf_mixsyn solves the standard S/KS/T mixed-sensitivity problem

min_K || [W1 S; W2 K S; W3 T] ||_inf

W1 shapes the sensitivity S (tracking / disturbance rejection), W2 the control effort K S, W3 the complementary sensitivity T (robustness, high-frequency rolloff). hinf runs plain hinfsyn on a user-supplied generalized plant.

class state_space_control.controllers.hinf.MixedSensitivityHInf(W1=None, W2=None, W3=None)[source]

Bases: ControllerDesign

Weights may be scalars or {num, den} transfer-function coefficients.

design(plant: Plant) ControllerResult[source]
registry_name = 'hinf_mixsyn'
class state_space_control.controllers.hinf.HInf(A, B, C, D, n_meas: int, n_ctrl: int, gamma=None)[source]

Bases: ControllerDesign

General H-infinity synthesis on an augmented plant.

The augmented plant (inputs [w; u], outputs [z; y]) is supplied as matrices; the design is independent of how you built it.

gamma requests a SUBOPTIMAL central controller for that fixed attenuation level instead of the gamma-optimal one. The optimal central controller often carries near-singular ultra-fast modes (1e9 rad/s and beyond) that no sampled implementation can realize; backing gamma off (e.g. 3-10x the optimal value) trades a little attenuation for a controller with implementable bandwidth.

design(plant: Plant) ControllerResult[source]
registry_name = 'hinf'

Per-joint PID as a registered LTI controller design.

Like every design in this toolbox, the PID is a regulator about the operating point: it drives the deviation outputs to zero,

u = u_eq + PID(-delta_y),

not a setpoint-tracking loop. The returned ControllerResult.controller is the LTI system from plant measurement y to control u (sign included), so closed_loop() composes it exactly like the LQG/H-infinity cases.

Each actuated input channel gets an independent parallel PID with a filtered derivative, acting on the position measurement of its own joint:

u_i = -( Kp_i y_i + Ki_i int y_i dt + Kd_i d/dt[y_i]_filtered )

with derivative filter Kd s / (tau s + 1) – two states per channel (integrator + filter). Channel i’s measurement is the plant output named ‘<actuated_joint_i>.q’ when names are available; otherwise output i is used and the plant must have at least as many outputs as inputs.

class state_space_control.controllers.pid.PID(Kp=1.0, Ki=0.0, Kd=0.0, tau=0.01)[source]

Bases: ControllerDesign

Parallel PID with filtered derivative; Kp/Ki/Kd scalar or per-joint.

design(plant: Plant) ControllerResult[source]
registry_name = 'pid'

cli — the ss_design entry point

Design a controller for a saved plant from a YAML spec:

ss_design plant.npz design.yaml [-o controller.npz]

where plant.npz comes from urdf_state_space (StateSpaceModel.save_npz or the urdf2ss export) and design.yaml looks like:

controller: lqr
params:
  Q: [100, 100, 1, 1]     # scalar, diagonal list, or full matrix
  R: 0.1
state_space_control.cli.main(argv=None) int[source]