File size: 11,083 Bytes
9936912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
 
 
 
 
 
9936912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
9936912
 
 
 
 
 
 
 
48ee375
 
 
 
 
 
9936912
 
 
 
 
 
 
48ee375
 
 
 
 
 
 
9936912
 
 
 
 
 
48ee375
9936912
 
 
48ee375
 
 
 
 
 
 
 
 
 
9936912
48ee375
 
 
 
 
9936912
 
48ee375
9936912
 
9e637cd
9936912
48ee375
9936912
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""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",
    }