Spaces:
Running on Zero
Running on Zero
| """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 | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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, | |
| } | |
| 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", | |
| } | |