atakan
refactor: Collapse four inference backends into one MLX path
9e637cd
Raw
History Blame Contribute Delete
11.1 kB
"""Controller synthesis tools: LQR, Pole Placement, PID Tuning, and MPC QP."""
from __future__ import annotations
import math
from typing import Any
import cvxpy as cp
import numpy as np
from scipy import linalg, signal
from controlai_agent.registry import registry
from controlai_agent.verifier import verifier
@registry.register(
name="continuous_lqr",
description="Solve Continuous-time Linear Quadratic Regulator (CARE) problem: min integral (x^T Q x + u^T R u) dt.",
parameters_schema={
"type": "object",
"properties": {
"A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"Q": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"R": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
},
"required": ["A", "B", "Q", "R"],
},
)
def continuous_lqr(
A: list[list[float]], B: list[list[float]], Q: list[list[float]], R: list[list[float]]
) -> dict[str, Any]:
A_mat = np.array(A, dtype=float)
B_mat = np.array(B, dtype=float)
Q_mat = np.array(Q, dtype=float)
R_mat = np.array(R, dtype=float)
P = linalg.solve_continuous_are(A_mat, B_mat, Q_mat, R_mat)
K = np.linalg.solve(R_mat, B_mat.T @ P)
A_cl = A_mat - B_mat @ K
poles = np.linalg.eigvals(A_cl)
v_report = verifier.verify_care(A_mat, B_mat, Q_mat, R_mat, P, K)
return {
"P": P.tolist(),
"K": K.tolist(),
# Returned so the answer never has to derive it. The model was
# observed writing A - BK as [[-6, 1], [-5, -6]] for a double
# integrator whose true closed loop is [[0, 1], [-6, -5]] -- correct
# gain, wrong write-up. Handing it the computed matrix removes the
# arithmetic from the answer entirely.
"closed_loop_A": (A - B @ K).tolist(),
"closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
"is_stable": bool(np.all(np.real(poles) < 0)),
"verification": v_report,
}
@registry.register(
name="discrete_lqr",
description="Solve Discrete-time Linear Quadratic Regulator (DARE) problem: min sum (x_k^T Q x_k + u_k^T R u_k).",
parameters_schema={
"type": "object",
"properties": {
"A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"Q": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"R": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
},
"required": ["A", "B", "Q", "R"],
},
)
def discrete_lqr(
A: list[list[float]], B: list[list[float]], Q: list[list[float]], R: list[list[float]]
) -> dict[str, Any]:
A_mat = np.array(A, dtype=float)
B_mat = np.array(B, dtype=float)
Q_mat = np.array(Q, dtype=float)
R_mat = np.array(R, dtype=float)
P = linalg.solve_discrete_are(A_mat, B_mat, Q_mat, R_mat)
K = np.linalg.solve(R_mat + B_mat.T @ P @ B_mat, B_mat.T @ P @ A_mat)
A_cl = A_mat - B_mat @ K
poles = np.linalg.eigvals(A_cl)
v_report = verifier.verify_dare(A_mat, B_mat, Q_mat, R_mat, P, K)
return {
"P": P.tolist(),
"K": K.tolist(),
"closed_loop_A": (A - B @ K).tolist(),
"closed_loop_poles": [[float(p.real), float(p.imag)] for p in poles],
"is_stable": bool(np.all(np.abs(poles) < 1.0)),
"verification": v_report,
}
@registry.register(
name="place_state_feedback",
description=(
"Compute state feedback gain matrix K such that eig(A - B*K) matches target desired poles. "
"Each entry of desired_poles is either a real number (a real pole) or a [real, imag] pair "
"(one half of a complex-conjugate pair -- e.g. targeting a damping ratio/natural frequency "
"needs poles like [-2, 3] and [-2, -3], both halves listed explicitly)."
),
parameters_schema={
"type": "object",
"properties": {
"A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}},
"desired_poles": {
"type": "array",
"items": {
"oneOf": [
{"type": "number"},
{"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2},
]
},
"description": "Target closed-loop pole locations: a number for a real pole, or [real, imag] for a complex one.",
},
},
"required": ["A", "B", "desired_poles"],
},
)
def place_state_feedback(
A: list[list[float]], B: list[list[float]], desired_poles: list[float | list[float]]
) -> dict[str, Any]:
A_mat = np.array(A, dtype=float)
B_mat = np.array(B, dtype=float)
# Each pole is a plain number (real pole) or a [real, imag] pair.
desired_poles = [complex(p[0], p[1]) if isinstance(p, (list, tuple)) else complex(p) for p in desired_poles]
# dtype=complex, not float: targeting a specific damping ratio / natural
# frequency means passing a complex-conjugate pole pair, which is the
# normal case for a 2nd-order-or-higher design, not an edge case. Forcing
# float here silently discarded the imaginary part, turning a legitimate
# conjugate pair into the SAME real pole listed twice -- which then fails
# outright for a SISO system since place_poles cannot repeat a pole more
# than rank(B) times.
des = np.array(desired_poles, dtype=complex)
placed = signal.place_poles(A_mat, B_mat, des)
# K is mathematically real for real A, B with a properly conjugate-paired
# desired_poles; drop the negligible numerical imaginary residue so the
# result is JSON-serializable (a genuinely unpaired complex pole is
# rejected by place_poles itself before this line is reached).
K = placed.gain_matrix.real
closed_poles = np.linalg.eigvals(A_mat - B_mat @ K)
v_report = verifier.verify_pole_placement(A_mat, B_mat, K, list(des))
return {
"K": K.tolist(),
"closed_loop_A": (A - B @ K).tolist(),
"closed_loop_poles": [[float(p.real), float(p.imag)] for p in closed_poles],
"target_poles": [[float(p.real), float(p.imag)] for p in des],
"verification": v_report,
}
@registry.register(
name="pid_tune_fopdt",
description="Tune standard PID gains (Kp, Ki, Kd) for First-Order Plus Dead-Time (FOPDT) plant G(s) = K*e^(-L s)/(T s + 1) using Chien-Hrones-Reswick (CHR) method.",
parameters_schema={
"type": "object",
"properties": {
"K_plant": {"type": "number", "description": "Static process gain K"},
"T_tau": {"type": "number", "minimum": 1e-6, "description": "Time constant T in seconds"},
"L_delay": {"type": "number", "minimum": 1e-6, "description": "Apparent dead time / delay L in seconds"},
"tuning_objective": {
"type": "string",
"enum": ["setpoint_tracking_0_overshoot", "setpoint_tracking_20_overshoot", "disturbance_rejection"],
"default": "setpoint_tracking_0_overshoot",
},
},
"required": ["K_plant", "T_tau", "L_delay"],
},
)
def pid_tune_fopdt(
K_plant: float,
T_tau: float,
L_delay: float,
tuning_objective: str = "setpoint_tracking_0_overshoot",
) -> dict[str, Any]:
a = (K_plant * L_delay) / T_tau
if tuning_objective == "setpoint_tracking_0_overshoot":
kp = 0.6 / a
ti = T_tau
td = 0.5 * L_delay
elif tuning_objective == "setpoint_tracking_20_overshoot":
kp = 0.95 / a
ti = 1.35 * T_tau
td = 0.47 * L_delay
else: # disturbance rejection
kp = 1.2 / a
ti = 2.0 * L_delay
td = 0.42 * L_delay
ki = kp / ti
kd = kp * td
return {
"Kp": kp,
"Ki": ki,
"Kd": kd,
"Ti_integral_time": ti,
"Td_derivative_time": td,
"tuning_rule": "Chien-Hrones-Reswick (CHR)",
"objective": tuning_objective,
}
@registry.register(
name="mpc_solve_qp",
description="Solve finite-horizon Model Predictive Control (MPC) quadratic program with box state/input constraints via CVXPY and Clarabel.",
parameters_schema={
"type": "object",
"properties": {
"A": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Discrete state matrix A"},
"B": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Discrete input matrix B"},
"Q": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "State penalty matrix Q"},
"R": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "Input penalty matrix R"},
"x0": {"type": "array", "items": {"type": "number"}, "description": "Initial state vector"},
"horizon": {"type": "integer", "minimum": 1, "maximum": 50, "description": "Prediction horizon N"},
"u_limit": {"type": "number", "description": "Input constraint magnitude |u_k| <= u_limit"},
},
"required": ["A", "B", "Q", "R", "x0", "horizon"],
},
)
def mpc_solve_qp(
A: list[list[float]],
B: list[list[float]],
Q: list[list[float]],
R: list[list[float]],
x0: list[float],
horizon: int,
u_limit: float | None = None,
) -> dict[str, Any]:
A_mat = np.array(A, dtype=float)
B_mat = np.array(B, dtype=float)
Q_mat = np.array(Q, dtype=float)
R_mat = np.array(R, dtype=float)
x0_vec = np.array(x0, dtype=float)
nx = A_mat.shape[0]
nu = B_mat.shape[1] if B_mat.ndim > 1 else 1
x = cp.Variable((nx, horizon + 1))
u = cp.Variable((nu, horizon))
cost = 0
constraints = [x[:, 0] == x0_vec]
for k in range(horizon):
cost += cp.quad_form(x[:, k], Q_mat) + cp.quad_form(u[:, k], R_mat)
constraints.append(x[:, k + 1] == A_mat @ x[:, k] + B_mat @ u[:, k])
if u_limit is not None:
constraints.append(cp.norm_inf(u[:, k]) <= u_limit)
cost += cp.quad_form(x[:, horizon], Q_mat)
prob = cp.Problem(cp.Minimize(cost), constraints)
opt_cost = prob.solve(solver="CLARABEL")
if prob.status not in ("optimal", "optimal_inaccurate"):
return {"status": "error", "error": f"MPC solver failed: {prob.status}"}
u_opt = u.value.tolist()
x_opt = x.value.tolist()
return {
"status": "success",
"first_control_move": u.value[:, 0].tolist(),
"optimal_cost": float(opt_cost),
"predicted_state_trajectory": x_opt,
"optimal_input_trajectory": u_opt,
"solver": "CLARABEL",
}