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:
objectA linear plant x_dot = A x + B u, y = C x + D u.
- 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).
- 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:
objectOutcome 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).
- class state_space_control.base.ControllerDesign[source]¶
Bases:
objectBase class for controller designs. Parameters go in __init__;
designmaps 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).
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:
objectA time-stamped annotation on a trajectory.
- 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:
objectThe robot’s configuration at one instant — what renderers consume.
- 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:
objectTime-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.
- events: List[TrajectoryEvent]¶
- validate() RobotTrajectory[source]¶
Fail loudly at the boundary, not deep inside a renderer.
- classmethod from_npz(path: str) RobotTrajectory[source]¶
- class state_space_control.trajectory.FrameSampler(traj: RobotTrajectory, method: str = 'linear')[source]¶
Bases:
objectAnswers “what does the robot look like at simulation time t?”.
Owns interpolation entirely; playback engines, plot cursors and offline exporters all call
frame_atso a 30 fps video render and a jittery 60 Hz timer follow the identical code path.tis 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:
objectBase class. Parameters go in __init__;
samplemaps a time grid to the input samples d(t) with shape (len(t),).- injection = 'input'¶
- state_space_control.excitations.make_excitation(name: str, **params) Excitation[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:
ExcitationConstant 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'}]¶
- registry_name = 'step'¶
- class state_space_control.excitations.Impulse(area=1.0)[source]¶
Bases:
ExcitationIdeal 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).
sampletherefore returns zeros — the simulator applies the jump.- PARAMS: List[Dict] = [{'default': 1.0, 'doc': 'impulse area (N*m*s for torque inputs)', 'name': 'area'}]¶
- registry_name = 'impulse'¶
- class state_space_control.excitations.Ramp(slope=1.0, t_start=0.0, saturation=None)[source]¶
Bases:
ExcitationLinearly 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'}]¶
- registry_name = 'ramp'¶
- class state_space_control.excitations.Sine(amplitude=1.0, freq_hz=0.5, phase=0.0)[source]¶
Bases:
ExcitationSinusoidal 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'}]¶
- registry_name = 'sine'¶
- class state_space_control.excitations.Custom(t_samples=(), u_samples=())[source]¶
Bases:
ExcitationUser-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'}]¶
- registry_name = 'custom'¶
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:
objectDeviation-space result of one closed-loop simulation.
- 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);
x0is 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.
modelis 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.
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:
ControllerDesignState-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:
ControllerDesignLQR 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:
ControllerDesignWeights 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:
ControllerDesignGeneral 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.
gammarequests 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:
ControllerDesignParallel 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