From 3ad11ed63ae860da55fced680d411457d088419f Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 09:13:32 +0100 Subject: [PATCH 01/18] feat(autotune): allow negative feedforward values --- autotune/autotune.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index a21f8b4..7af194c 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -328,7 +328,7 @@ def createPidLayout(self): "P": {"min": 0.001, "max": 4.0, "step": 0.001}, "I": {"min": 0.0, "max": 20.0, "step": 0.1}, "D": {"min": 0.0, "max": 0.2, "step": 0.001}, - "FF": {"min": 0.0, "max": 1.0, "step": 0.001}, + "FF": {"min": -1.0, "max": 1.0, "step": 0.001}, } def make_slider_callback(gain): @@ -365,6 +365,7 @@ def make_line_edit_callback(gain): self.gain_slider[gain].setMinimum(slider_props[gain]["min"]) self.gain_slider[gain].setMaximum(slider_props[gain]["max"]) self.gain_slider[gain].setInterval(slider_props[gain]["step"]) + self.gain_slider[gain].setValue(self.gains[gain]) self.gain_slider[gain].valueChanged.connect(make_slider_callback(gain)) layout_pid.addWidget(self.gain_slider[gain], row, 1) From 38dfcb155903d60bcfb98169721e9b4d26bd9f2f Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 10:38:15 +0100 Subject: [PATCH 02/18] feat(autotune): show rise time, overshoot and settling time --- autotune/autotune.py | 119 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 117 insertions(+), 2 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 7af194c..53f0db9 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -93,6 +93,15 @@ def __init__(self, parent=None): self.input_ref = None self.closed_loop_ref = None self.closed_loop_ax = None + self.measured_step_info = None + self.step_info_patches = [] + self.step_info_spinbox = {} + self.step_info_measured_lbl = {} + self.step_info = { + "rise_time": 0.12, + "overshoot": 10.0, + "settling_time": 0.4, + } self.bode_plot_ref = [] self.pz_plot_refs = [] self.file_name = None @@ -206,13 +215,18 @@ def __init__(self, parent=None): layout_plot.addWidget(self.canvas) layout_v.addLayout(layout_h) layout_v.setStretch(0, 1) - layout_v.addWidget(self.tuning_tabs) + bottom_row = QHBoxLayout() + bottom_row.addWidget(self.tuning_tabs, stretch=1) + bottom_row.addWidget(self.createStepInfoGroup()) + layout_v.addLayout(bottom_row) self.setLayout(layout_v) def reset(self): self.model_ref = None self.input_ref = None self.closed_loop_ref = None + self.measured_step_info = None + self.step_info_patches = [] self.bode_plot_ref = [] self.pz_plot_refs = [] self.is_system_identified = False @@ -392,6 +406,98 @@ def make_line_edit_callback(gain): return layout_pid + def createStepInfoGroup(self): + specs = { + "rise_time": ("Rise time", 0.12, 0.01, 2.0, 0.01, "s"), + "overshoot": ("Overshoot", 10.0, 0.0, 100.0, 0.5, "%"), + "settling_time": ("Settling time", 0.4, 0.01, 5.0, 0.01, "s"), + } + group = QGroupBox("Step info") + grid = QGridLayout() + grid.addWidget(QLabel("Max"), 0, 1) + grid.addWidget(QLabel("Measured"), 0, 2) + for row, (key, (label, default, lo, hi, step, unit)) in enumerate( + specs.items(), start=1 + ): + sb = QDoubleSpinBox() + sb.setRange(lo, hi) + sb.setSingleStep(step) + sb.setDecimals( + len(str(step).rstrip("0").split(".")[-1]) if "." in str(step) else 0 + ) + sb.setValue(default) + sb.valueChanged.connect(self.onStepInfoChanged) + self.step_info_spinbox[key] = sb + + measured_lbl = QLabel("—") + self.step_info_measured_lbl[key] = measured_lbl + + grid.addWidget(QLabel(label + " (" + unit + ")"), row, 0) + grid.addWidget(sb, row, 1) + grid.addWidget(measured_lbl, row, 2) + + group.setLayout(grid) + return group + + def onStepInfoChanged(self): + for key in self.step_info: + self.step_info[key] = self.step_info_spinbox[key].value() + self.updateStepInfoEnvelope() + + def checkStepInfoViolated(self): + info = self.measured_step_info + step_info = self.step_info + if info["RiseTime"] > step_info["rise_time"]: + return True + if info["Overshoot"] > step_info["overshoot"]: + return True + if info["SettlingTime"] > step_info["settling_time"]: + return True + return False + + def updateStepInfoEnvelope(self): + if self.closed_loop_ax is None: + return + + for patch in self.step_info_patches: + patch.remove() + self.step_info_patches = [] + + ax = self.closed_loop_ax + step_info = self.step_info + kw = dict(color="#ff4444", linestyle="--", linewidth=1) + + y_limit = 1.0 + step_info["overshoot"] / 100.0 + p = ax.axhline(y_limit, **kw) + self.step_info_patches.append(p) + + p = ax.plot( + [step_info["settling_time"], step_info["settling_time"]], [0, 1.0], **kw + )[0] + self.step_info_patches.append(p) + + measured_map = { + "rise_time": ("RiseTime", lambda v: f"{v:.3f}"), + "overshoot": ("Overshoot", lambda v: f"{v:.1f}"), + "settling_time": ("SettlingTime", lambda v: f"{v:.3f}"), + } + for key, (info_key, fmt) in measured_map.items(): + lbl = self.step_info_measured_lbl[key] + if self.measured_step_info is None: + lbl.setText("—") + lbl.setStyleSheet("") + else: + measured = self.measured_step_info[info_key] + lbl.setText(fmt(measured)) + if np.isnan(measured): + lbl.setStyleSheet("") + elif measured > self.step_info[key]: + lbl.setStyleSheet("color: red") + else: + lbl.setStyleSheet("color: green") + + self.canvas.draw() + def updateGainFromSlider(self, gain: str): if self.gain_slider[gain].hasFocus(): self.gains[gain] = self.gain_slider[gain].value() @@ -765,6 +871,15 @@ def updateClosedLoop(self): self.plotBode(open_loop, closed_loop) def plotClosedLoop(self, t, y): + # Compute metrics on pre-disturbance portion only + mask = t < 1.0 + try: + self.measured_step_info = ctrl.step_info( + y[mask], timepts=t[mask], final_output=1.0 + ) + except (IndexError, ValueError): + self.measured_step_info = None + if self.closed_loop_ref is None: ax = self.figure.add_subplot(3, 3, 7) ax.step(t, [1 if i > 0 else 0 for i in t], "k--") @@ -779,7 +894,7 @@ def plotClosedLoop(self, t, y): self.closed_loop_ref.set_ydata(y) self.closed_loop_ax.set_ylim(np.min(y), np.max([1.5, np.max(y)])) - self.canvas.draw() + self.updateStepInfoEnvelope() def plotBode(self, open_loop, closed_loop): From c7a60bd54517588b04df033425c181155e499498 Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 13:58:49 +0100 Subject: [PATCH 03/18] feat(autotune): show fit quality based on NRMSE --- autotune/autotune.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/autotune/autotune.py b/autotune/autotune.py index 53f0db9..f1ba9ad 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -183,6 +183,9 @@ def __init__(self, parent=None): id_params_group.addRow(self.btn_run_sys_id) + self.lbl_fit = QLabel("—") + id_params_group.addRow(QLabel("Fit"), self.lbl_fit) + left_menu.addLayout(id_params_group) layout_tf = self.createTfLayout() @@ -227,6 +230,8 @@ def reset(self): self.closed_loop_ref = None self.measured_step_info = None self.step_info_patches = [] + self.lbl_fit.setText("—") + self.lbl_fit.setStyleSheet("") self.bode_plot_ref = [] self.pz_plot_refs = [] self.is_system_identified = False @@ -621,6 +626,22 @@ def replayInputData(self): self.t_est, self.y_est = ctrl.forced_response(self.Gz, T=self.t, U=u_delayed) if len(self.t_est) > len(self.y_est): self.t_est = self.t_est[0 : len(self.y_est - 1)] + + y_detrended = detrend(self.y[: len(self.y_est)]) + y_est_detrended = detrend(self.y_est) + fit = 100 * ( + 1 + - np.linalg.norm(y_detrended - y_est_detrended) + / np.linalg.norm(y_detrended - np.mean(y_detrended)) + ) + self.lbl_fit.setText(f"{fit:.1f}%") + if fit >= 80: + self.lbl_fit.setStyleSheet("color: green") + elif fit >= 60: + self.lbl_fit.setStyleSheet("color: orange") + else: + self.lbl_fit.setStyleSheet("color: red") + self.plotInputOutput() def updateTfDisplay(self, a_coeffs, b_coeffs): From e3104c75f6d59825edeec9c6589738dfce8b52d7 Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 14:18:54 +0100 Subject: [PATCH 04/18] feat(autotune): select between RLS and OLS algorithms --- autotune/autotune.py | 11 ++++++++- autotune/system_identification.py | 41 +++++++++---------------------- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index f1ba9ad..b16c46f 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -153,6 +153,14 @@ def __init__(self, parent=None): self.line_edit_delays.setRange(0, 1000) self.line_edit_delays.valueChanged.connect(self.onDelaysChanged) id_params_group.addRow(QLabel("Delays"), self.line_edit_delays) + + self.id_method_combo = QComboBox() + self.id_method_combo.addItems(["RLS", "OLS"]) + self.id_method_combo.currentIndexChanged.connect( + lambda: self.btn_run_sys_id.setEnabled(True) + ) + id_params_group.addRow(QLabel("Method"), self.id_method_combo) + input_scale_group = QGroupBox("Input scaling") input_scale_group.setToolTip( "Scale the input to identify a model at trim airspeed (requires true airspeed data)" @@ -600,7 +608,8 @@ def runIdentification(self): d = self.sys_id_delays # number of delays id = SystemIdentification(n, m, d, self.dt) - est = id.fit(self.u.reshape(-1, 1), self.y.reshape(-1, 1)) + use_rls = self.id_method_combo.currentText() == "RLS" + est = id.fit(self.u.reshape(-1, 1), self.y.reshape(-1, 1), use_rls=use_rls) self.num = est.G_.num_list[0][0] self.den = est.G_.den_list[0][0][0 : n + 1] diff --git a/autotune/system_identification.py b/autotune/system_identification.py index 290be14..8fbe234 100644 --- a/autotune/system_identification.py +++ b/autotune/system_identification.py @@ -41,7 +41,7 @@ import control as ctrl import numpy as np from arx_rls import ArxRls -from scipy.optimize import lsq_linear, minimize +from scipy.optimize import lsq_linear class SysIdResult(object): @@ -62,7 +62,7 @@ def __init__(self, n=2, m=2, d=1, dt=0.0): tau = 60.0 # forgetting period self.lbda = 1.0 - self.dt / tau - def fit(self, u, y): + def fit(self, u, y, use_rls=True): n_steps = len(u) # High-pass filter parameters @@ -101,47 +101,30 @@ def fit(self, u, y): u_lp[k] = alpha_lp * u_lp[k - 1] + (1 - alpha_lp) * u_hp[k] y_lp[k] = alpha_lp * y_lp[k - 1] + (1 - alpha_lp) * y_hp[k] - use_rls = True if use_rls: - # Identification rls = ArxRls( self.n, self.m, self.d, lbda=(1 - self.dt / self.forgetting_tc) ) - for k in range(n_steps): - # Update model rls.update(u_lp[k], y_lp[k]) - theta_hat = rls._theta_hat - - # Save for plotting for i in range(self.n): a_coeffs[i, k] = theta_hat[i] for i in range(self.m + 1): b_coeffs[i, k] = theta_hat[i + self.n] - - else: # use LS - # Build matrix of regressors - A = np.zeros((n_steps, self.n + self.m + 1)) - for row in range(n_steps): + else: # OLS + skip = max(self.n, self.d + self.m) + rows = n_steps - skip + A = np.zeros((rows, self.n + self.m + 1)) + B = np.zeros(rows) + for row in range(skip, n_steps): for i in range(self.n): - A[row, i] = -y_lp[row - (i + 1)] + A[row - skip, i] = -y_lp[row - (i + 1)] for i in range(self.m + 1): - A[row, i + self.n] = u_lp[row - (self.d + i)] - - B = [y_lp[i] for i in range(n_steps)] # Measured values - - res = lsq_linear(A, B, lsmr_tol="auto", verbose=1) - theta_hat = res.x - - # Refine model using output-error optimization - J = lambda x: np.sum( - np.power(abs(np.array(B) - self.simulateModel(x, u_lp, self.dt)), 2.0) - ) # cost function - x0 = np.append(res.x, 0) # initial conditions - res = minimize(J, x0, method="nelder-mead", options={"disp": True}) + A[row - skip, i + self.n] = u_lp[row - (self.d + i)] + B[row - skip] = y_lp[row] + res = lsq_linear(A, B, lsmr_tol="auto") theta_hat = res.x - for i in range(self.n): a_coeffs[i, -1] = theta_hat[i] for i in range(self.m + 1): From a4860f0b7a4831a9b6d3fbb8f9f9bff5c0111284 Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 14:28:49 +0100 Subject: [PATCH 05/18] feat(autotune):add setters for pre-processing filters --- autotune/autotune.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index b16c46f..20028d3 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -161,6 +161,27 @@ def __init__(self, parent=None): ) id_params_group.addRow(QLabel("Method"), self.id_method_combo) + preproc_group = QGroupBox("Pre-processing") + preproc_form = QFormLayout() + self.f_hp_spinbox = QDoubleSpinBox() + self.f_hp_spinbox.setRange(0.0, 50.0) + self.f_hp_spinbox.setSingleStep(0.1) + self.f_hp_spinbox.setDecimals(1) + self.f_hp_spinbox.setValue(0.5) + self.f_hp_spinbox.valueChanged.connect( + lambda: self.btn_run_sys_id.setEnabled(True) + ) + preproc_form.addRow(QLabel("HP cutoff (Hz)"), self.f_hp_spinbox) + self.f_lp_spinbox = QDoubleSpinBox() + self.f_lp_spinbox.setRange(1.0, 200.0) + self.f_lp_spinbox.setSingleStep(1.0) + self.f_lp_spinbox.setDecimals(1) + self.f_lp_spinbox.setValue(30.0) + self.f_lp_spinbox.valueChanged.connect( + lambda: self.btn_run_sys_id.setEnabled(True) + ) + preproc_form.addRow(QLabel("LP cutoff (Hz)"), self.f_lp_spinbox) + input_scale_group = QGroupBox("Input scaling") input_scale_group.setToolTip( "Scale the input to identify a model at trim airspeed (requires true airspeed data)" @@ -183,7 +204,10 @@ def __init__(self, parent=None): self.line_edit_trim.setEnabled(False) input_scale_form.addRow(QLabel("Trim airspeed"), self.line_edit_trim) input_scale_group.setLayout(input_scale_form) - id_params_group.addRow(input_scale_group) + preproc_form.addRow(input_scale_group) + + preproc_group.setLayout(preproc_form) + id_params_group.addRow(preproc_group) self.btn_run_sys_id = QPushButton("Run identification") self.btn_run_sys_id.clicked.connect(self.onSysIdClicked) @@ -607,6 +631,8 @@ def runIdentification(self): m = self.sys_id_n_zeros # order of the numerator (b_0,...,b_m) d = self.sys_id_delays # number of delays id = SystemIdentification(n, m, d, self.dt) + id.f_hp = self.f_hp_spinbox.value() + id.f_lp = self.f_lp_spinbox.value() use_rls = self.id_method_combo.currentText() == "RLS" est = id.fit(self.u.reshape(-1, 1), self.y.reshape(-1, 1), use_rls=use_rls) From 0fd0cac33564fae9eecb65a689c757e5b80660fd Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 14:53:13 +0100 Subject: [PATCH 06/18] feat(autotune): add option to stabilize model using pole reflection --- autotune/autotune.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/autotune/autotune.py b/autotune/autotune.py index 20028d3..5644e9e 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -218,6 +218,18 @@ def __init__(self, parent=None): self.lbl_fit = QLabel("—") id_params_group.addRow(QLabel("Fit"), self.lbl_fit) + self.lbl_stability = QLabel("—") + self.btn_stabilize = QPushButton("Stabilize") + self.btn_stabilize.setVisible(False) + self.btn_stabilize.setToolTip( + "Reflects unstable poles inside the unit circle (p → 1/p*)" + ) + self.btn_stabilize.clicked.connect(self.stabilizeModel) + stability_row = QHBoxLayout() + stability_row.addWidget(self.lbl_stability) + stability_row.addWidget(self.btn_stabilize) + id_params_group.addRow(QLabel("Stability"), stability_row) + left_menu.addLayout(id_params_group) layout_tf = self.createTfLayout() @@ -264,6 +276,9 @@ def reset(self): self.step_info_patches = [] self.lbl_fit.setText("—") self.lbl_fit.setStyleSheet("") + self.lbl_stability.setText("—") + self.lbl_stability.setStyleSheet("") + self.btn_stabilize.setVisible(False) self.bode_plot_ref = [] self.pz_plot_refs = [] self.is_system_identified = False @@ -678,6 +693,28 @@ def replayInputData(self): self.lbl_fit.setStyleSheet("color: red") self.plotInputOutput() + self.checkStability() + + def checkStability(self): + unstable = np.any(np.abs(self.Gz.poles()) > 1) + if unstable: + self.lbl_stability.setText("⚠ Unstable") + self.lbl_stability.setStyleSheet("color: red") + self.btn_stabilize.setVisible(True) + else: + self.lbl_stability.setText("✓ Stable") + self.lbl_stability.setStyleSheet("color: green") + self.btn_stabilize.setVisible(False) + + def stabilizeModel(self): + poles = self.Gz.poles() + stable_poles = np.where(np.abs(poles) > 1, 1.0 / np.conj(poles), poles) + self.den = np.real(np.poly(stable_poles)) + self.Gz = ctrl.TransferFunction(self.num, self.den, self.dt) + self.updateTfDisplay(self.den[1:], self.num) + self.plotPolesZeros() + self.replayInputData() + self.computeController() def updateTfDisplay(self, a_coeffs, b_coeffs): From 9b880759730b0b0bb2346ab864a321b00c0611d8 Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 16:10:54 +0100 Subject: [PATCH 07/18] fix(autotune): extract filtering --- autotune/system_identification.py | 68 ++++++++++++++++--------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/autotune/system_identification.py b/autotune/system_identification.py index 8fbe234..f6c0378 100644 --- a/autotune/system_identification.py +++ b/autotune/system_identification.py @@ -44,6 +44,41 @@ from scipy.optimize import lsq_linear +def apply_filters(u, y, f_hp, f_lp, dt): + n_steps = len(u) + + if f_hp > 0.0: + tau_hp = 1 / (2 * np.pi * f_hp) + alpha_hp = tau_hp / (tau_hp + dt) + else: + alpha_hp = 0.0 + + tau_lp = 1 / (2 * np.pi * f_lp) + alpha_lp = tau_lp / (tau_lp + dt) + + u_hp = np.zeros(n_steps) + y_hp = np.zeros(n_steps) + u_hp[0] = u[0] + y_hp[0] = y[0] + u_lp = np.zeros(n_steps) + y_lp = np.zeros(n_steps) + u_lp[0] = u[0] + y_lp[0] = y[0] + + for k in range(1, n_steps): + if alpha_hp > 0.0: + u_hp[k] = alpha_hp * u_hp[k - 1] + alpha_hp * (u[k] - u[k - 1]) + y_hp[k] = alpha_hp * y_hp[k - 1] + alpha_hp * (y[k] - y[k - 1]) + else: + u_hp[k] = u[k] + y_hp[k] = y[k] + + u_lp[k] = alpha_lp * u_lp[k - 1] + (1 - alpha_lp) * u_hp[k] + y_lp[k] = alpha_lp * y_lp[k - 1] + (1 - alpha_lp) * y_hp[k] + + return u_lp, y_lp + + class SysIdResult(object): def __init__(self, num, den, dt): self.G_ = ctrl.TransferFunction(num, den, dt) @@ -65,41 +100,10 @@ def __init__(self, n=2, m=2, d=1, dt=0.0): def fit(self, u, y, use_rls=True): n_steps = len(u) - # High-pass filter parameters - if self.f_hp > 0.0: - tau_hp = 1 / (2 * np.pi * self.f_hp) - alpha_hp = tau_hp / (tau_hp + self.dt) - else: - alpha_hp = 0.0 - - u_hp = np.zeros(n_steps) - y_hp = np.zeros(n_steps) - u_hp[0] = u[0] - y_hp[0] = y[0] - - # Low-pass filter parameters - tau_lp = 1 / (2 * np.pi * self.f_lp) - alpha_lp = tau_lp / (tau_lp + self.dt) - u_lp = np.zeros(n_steps) - y_lp = np.zeros(n_steps) - u_lp[0] = u[0] - y_lp[0] = y[0] - a_coeffs = np.zeros((self.n, n_steps)) b_coeffs = np.zeros((self.m + 1, n_steps)) - # Apply high and low-pass filters - for k in range(n_steps): - if k > 0: - if alpha_hp > 0.0: - u_hp[k] = alpha_hp * u_hp[k - 1] + alpha_hp * (u[k] - u[k - 1]) - y_hp[k] = alpha_hp * y_hp[k - 1] + alpha_hp * (y[k] - y[k - 1]) - else: - u_hp[k] = u[k] - y_hp[k] = y[k] - - u_lp[k] = alpha_lp * u_lp[k - 1] + (1 - alpha_lp) * u_hp[k] - y_lp[k] = alpha_lp * y_lp[k - 1] + (1 - alpha_lp) * y_hp[k] + u_lp, y_lp = apply_filters(u, y, self.f_hp, self.f_lp, self.dt) if use_rls: rls = ArxRls( From 5ed1a022fd61ff614faf8757cae6a0f92d5b1505 Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 16:11:21 +0100 Subject: [PATCH 08/18] feat(autotune): add optimizer to find best sys-id parameters --- autotune/autotune.py | 123 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 5644e9e..916fac0 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -46,7 +46,7 @@ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar from pid_design import computePidGmvc -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( QApplication, QCheckBox, @@ -77,6 +77,93 @@ from system_identification import SystemIdentification +def compute_fit(u, y, t, dt, n_poles, n_zeros, delay, f_hp, f_lp, use_rls=True): + try: + sys_id = SystemIdentification(n_poles, n_zeros, delay, dt) + sys_id.f_hp = f_hp + sys_id.f_lp = f_lp + est = sys_id.fit(u.reshape(-1, 1), y.reshape(-1, 1), use_rls=use_rls) + Gz = ctrl.TransferFunction( + est.G_.num_list[0][0], est.G_.den_list[0][0][: n_poles + 1], dt + ) + u_detrended = detrend(u) + u_delayed = np.concatenate( + ([0] * delay, u_detrended[: len(u_detrended) - delay]) + ) + _, y_est = ctrl.forced_response(Gz, T=t, U=u_delayed) + y_detrended = detrend(y[: len(y_est)]) + y_est_detrended = detrend(y_est) + norm_ref = np.linalg.norm(y_detrended - np.mean(y_detrended)) + if norm_ref < 1e-10: + return -np.inf + return 100.0 * (1.0 - np.linalg.norm(y_detrended - y_est_detrended) / norm_ref) + except Exception: + return -np.inf + + +class ParamSearchWorker(QThread): + finished = pyqtSignal(dict, float) + progress = pyqtSignal(int, int) + + def __init__(self, u, y, t, dt, use_rls): + super().__init__() + self.u = u + self.y = y + self.t = t + self.dt = dt + self.use_rls = use_rls + + def run(self): + n_poles_range = [2, 3, 4, 5, 6] + n_zeros_range = [2, 3, 4, 5, 6] + delay_range = [0, 1, 2, 3] + f_hp_range = [0.0, 0.5, 1.0, 2.0] + f_lp_range = [10.0, 20.0, 30.0, 50.0] + + combos = [ + (n_poles, n_zeros, delay, f_hp, f_lp) + for n_poles in n_poles_range + for n_zeros in n_zeros_range + for delay in delay_range + for f_hp in f_hp_range + for f_lp in f_lp_range + if n_poles >= n_zeros + ] + total = len(combos) + best_fit = -np.inf + best_params = {} + + for i, (n_poles, n_zeros, delay, f_hp, f_lp) in enumerate(combos): + fit = compute_fit( + self.u, + self.y, + self.t, + self.dt, + n_poles, + n_zeros, + delay, + f_hp, + f_lp, + self.use_rls, + ) + new_order = n_poles + n_zeros + best_order = best_params.get("n_poles", 0) + best_params.get("n_zeros", 0) + # Prefer lower-order models: a higher-order model must improve fit + # by more than 1% to be accepted, avoiding overfitting. + if fit > best_fit and (new_order <= best_order or fit > best_fit + 1.0): + best_fit = fit + best_params = { + "n_poles": n_poles, + "n_zeros": n_zeros, + "delay": delay, + "f_hp": f_hp, + "f_lp": f_lp, + } + self.progress.emit(i + 1, total) + + self.finished.emit(best_params, best_fit) + + def isNumber(value): try: float(value) @@ -213,7 +300,14 @@ def __init__(self, parent=None): self.btn_run_sys_id.clicked.connect(self.onSysIdClicked) self.btn_run_sys_id.setEnabled(False) - id_params_group.addRow(self.btn_run_sys_id) + self.btn_find_params = QPushButton("Find parameters") + self.btn_find_params.clicked.connect(self.onFindParamsClicked) + self.btn_find_params.setEnabled(False) + + run_row = QHBoxLayout() + run_row.addWidget(self.btn_run_sys_id) + run_row.addWidget(self.btn_find_params) + id_params_group.addRow(run_row) self.lbl_fit = QLabel("—") id_params_group.addRow(QLabel("Fit"), self.lbl_fit) @@ -379,6 +473,30 @@ def onSysIdClicked(self): self.runIdentification() self.computeController() + def onFindParamsClicked(self): + self.btn_find_params.setEnabled(False) + self.btn_find_params.setText("Searching... (0%)") + use_rls = self.id_method_combo.currentText() == "RLS" + self._param_search_worker = ParamSearchWorker( + self.u.copy(), self.y.copy(), self.t.copy(), self.dt, use_rls + ) + self._param_search_worker.progress.connect(self.onParamSearchProgress) + self._param_search_worker.finished.connect(self.onParamSearchFinished) + self._param_search_worker.start() + + def onParamSearchProgress(self, current, total): + self.btn_find_params.setText(f"Searching... ({100 * current // total}%)") + + def onParamSearchFinished(self, best_params, best_fit): + self.line_edit_poles.setValue(best_params["n_poles"]) + self.line_edit_zeros.setValue(best_params["n_zeros"]) + self.line_edit_delays.setValue(best_params["delay"]) + self.f_hp_spinbox.setValue(best_params["f_hp"]) + self.f_lp_spinbox.setValue(best_params["f_lp"]) + self.btn_find_params.setText("Find parameters") + self.btn_find_params.setEnabled(True) + self.onSysIdClicked() + def printImproperTfError(self): msg = QMessageBox() msg.setIcon(QMessageBox.Critical) @@ -1121,6 +1239,7 @@ def loadLog(self): self.line_edit_trim.setValue(trim_airspeed) self.refreshInputOutputData() + self.btn_find_params.setEnabled(True) self.runIdentification() self.computeController() From d5b018f4541395834b45c7561e47904150f2d4d7 Mon Sep 17 00:00:00 2001 From: bresch Date: Tue, 24 Mar 2026 16:36:18 +0100 Subject: [PATCH 09/18] fix(autotune): rework optimization method selector --- autotune/autotune.py | 26 ++++++++++++++++---------- autotune/system_identification.py | 6 +++--- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 916fac0..06d4d0e 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -77,12 +77,12 @@ from system_identification import SystemIdentification -def compute_fit(u, y, t, dt, n_poles, n_zeros, delay, f_hp, f_lp, use_rls=True): +def compute_fit(u, y, t, dt, n_poles, n_zeros, delay, f_hp, f_lp, method="RLS"): try: sys_id = SystemIdentification(n_poles, n_zeros, delay, dt) sys_id.f_hp = f_hp sys_id.f_lp = f_lp - est = sys_id.fit(u.reshape(-1, 1), y.reshape(-1, 1), use_rls=use_rls) + est = sys_id.fit(u.reshape(-1, 1), y.reshape(-1, 1), method=method) Gz = ctrl.TransferFunction( est.G_.num_list[0][0], est.G_.den_list[0][0][: n_poles + 1], dt ) @@ -105,13 +105,13 @@ class ParamSearchWorker(QThread): finished = pyqtSignal(dict, float) progress = pyqtSignal(int, int) - def __init__(self, u, y, t, dt, use_rls): + def __init__(self, u, y, t, dt, method): super().__init__() self.u = u self.y = y self.t = t self.dt = dt - self.use_rls = use_rls + self.method = method def run(self): n_poles_range = [2, 3, 4, 5, 6] @@ -144,7 +144,7 @@ def run(self): delay, f_hp, f_lp, - self.use_rls, + self.method, ) new_order = n_poles + n_zeros best_order = best_params.get("n_poles", 0) + best_params.get("n_zeros", 0) @@ -242,7 +242,7 @@ def __init__(self, parent=None): id_params_group.addRow(QLabel("Delays"), self.line_edit_delays) self.id_method_combo = QComboBox() - self.id_method_combo.addItems(["RLS", "OLS"]) + self.id_method_combo.addItems(["OLS", "RLS"]) self.id_method_combo.currentIndexChanged.connect( lambda: self.btn_run_sys_id.setEnabled(True) ) @@ -476,9 +476,12 @@ def onSysIdClicked(self): def onFindParamsClicked(self): self.btn_find_params.setEnabled(False) self.btn_find_params.setText("Searching... (0%)") - use_rls = self.id_method_combo.currentText() == "RLS" self._param_search_worker = ParamSearchWorker( - self.u.copy(), self.y.copy(), self.t.copy(), self.dt, use_rls + self.u.copy(), + self.y.copy(), + self.t.copy(), + self.dt, + self.id_method_combo.currentText(), ) self._param_search_worker.progress.connect(self.onParamSearchProgress) self._param_search_worker.finished.connect(self.onParamSearchFinished) @@ -767,8 +770,11 @@ def runIdentification(self): id.f_hp = self.f_hp_spinbox.value() id.f_lp = self.f_lp_spinbox.value() - use_rls = self.id_method_combo.currentText() == "RLS" - est = id.fit(self.u.reshape(-1, 1), self.y.reshape(-1, 1), use_rls=use_rls) + est = id.fit( + self.u.reshape(-1, 1), + self.y.reshape(-1, 1), + method=self.id_method_combo.currentText(), + ) self.num = est.G_.num_list[0][0] self.den = est.G_.den_list[0][0][0 : n + 1] diff --git a/autotune/system_identification.py b/autotune/system_identification.py index f6c0378..c46267a 100644 --- a/autotune/system_identification.py +++ b/autotune/system_identification.py @@ -97,7 +97,7 @@ def __init__(self, n=2, m=2, d=1, dt=0.0): tau = 60.0 # forgetting period self.lbda = 1.0 - self.dt / tau - def fit(self, u, y, use_rls=True): + def fit(self, u, y, method="RLS"): n_steps = len(u) a_coeffs = np.zeros((self.n, n_steps)) @@ -105,7 +105,7 @@ def fit(self, u, y, use_rls=True): u_lp, y_lp = apply_filters(u, y, self.f_hp, self.f_lp, self.dt) - if use_rls: + if method == "RLS": rls = ArxRls( self.n, self.m, self.d, lbda=(1 - self.dt / self.forgetting_tc) ) @@ -116,7 +116,7 @@ def fit(self, u, y, use_rls=True): a_coeffs[i, k] = theta_hat[i] for i in range(self.m + 1): b_coeffs[i, k] = theta_hat[i + self.n] - else: # OLS + elif method == "OLS": skip = max(self.n, self.d + self.m) rows = n_steps - skip A = np.zeros((rows, self.n + self.m + 1)) From 813b3e7823c588437c79318c2f746bedb75a3b82 Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 13:37:11 +0100 Subject: [PATCH 10/18] fix: remove unused functions --- autotune/autotune.py | 11 ----------- autotune/system_identification.py | 19 ------------------- 2 files changed, 30 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 06d4d0e..4e8bae0 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -617,17 +617,6 @@ def onStepInfoChanged(self): self.step_info[key] = self.step_info_spinbox[key].value() self.updateStepInfoEnvelope() - def checkStepInfoViolated(self): - info = self.measured_step_info - step_info = self.step_info - if info["RiseTime"] > step_info["rise_time"]: - return True - if info["Overshoot"] > step_info["overshoot"]: - return True - if info["SettlingTime"] > step_info["settling_time"]: - return True - return False - def updateStepInfoEnvelope(self): if self.closed_loop_ax is None: return diff --git a/autotune/system_identification.py b/autotune/system_identification.py index c46267a..781a0bf 100644 --- a/autotune/system_identification.py +++ b/autotune/system_identification.py @@ -139,25 +139,6 @@ def fit(self, u, y, method="RLS"): estimate = SysIdResult(self.getNum(), self.getDen(), self.dt) return estimate - def simulateModel(self, x, u, dt): - a_coeffs = np.ones(self.n + 1) - b_coeffs = np.zeros(self.m + 1) - - for i in range(self.n): - a_coeffs[i + 1] = x[i] - for i in range(self.m + 1): - b_coeffs[i] = x[i + self.n] - - delays = ctrl.TransferFunction( - [1], np.append([1], np.zeros(self.d)), dt, inputs="r", outputs="rd" - ) - plant = ctrl.TransferFunction(b_coeffs, a_coeffs, dt, inputs="rd", outputs="y") - - system = ctrl.interconnect([delays, plant], inputs="r", outputs="y") - - _, y = ctrl.forced_response(system, U=u) - return y - def getNum(self): num = [ self.theta_hat.item(i) for i in range(self.n, self.n + self.m + 1) From d749f8636ce5a5f8d11fa1f6fd6b1be72ee8fa0a Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 13:45:54 +0100 Subject: [PATCH 11/18] fix: set disturbance time as constant --- autotune/autotune.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 4e8bae0..7e8d87f 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -207,6 +207,8 @@ def __init__(self, parent=None): self.sys_id_n_zeros = 2 self.sys_id_n_poles = 2 + self.kDisturbanceTime = 1.0 + # this is the Canvas Widget that displays the `figure` # it takes the `figure` instance as a parameter to __init__ self.canvas = FigureCanvas(self.figure) @@ -1045,7 +1047,7 @@ def updateClosedLoop(self): outputs="y", ) d = np.zeros_like(t_out) - d[t_out >= 1.0] = -0.05 # TODO: parameterize + d[t_out >= self.kDisturbanceTime] = -0.05 # TODO: parameterize _, y_d = ctrl.forced_response(disturbance_loop, t_out, d) y_out += y_d @@ -1078,7 +1080,7 @@ def updateClosedLoop(self): def plotClosedLoop(self, t, y): # Compute metrics on pre-disturbance portion only - mask = t < 1.0 + mask = t < self.kDisturbanceTime try: self.measured_step_info = ctrl.step_info( y[mask], timepts=t[mask], final_output=1.0 From 567d3f29f742d805c503cf207e093d1ca339880f Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 14:01:09 +0100 Subject: [PATCH 12/18] fix: extract NRMSE computation --- autotune/autotune.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 7e8d87f..91993c5 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -77,6 +77,14 @@ from system_identification import SystemIdentification +def computeNRMSE(y, y_est): + # Normalized Root Mean Square Error (NRMSE) expressed as a percentage. + norm_ref = np.linalg.norm(y - np.mean(y)) + if norm_ref < 1e-10: + return -np.inf + return 100.0 * (1.0 - np.linalg.norm(y - y_est) / norm_ref) + + def compute_fit(u, y, t, dt, n_poles, n_zeros, delay, f_hp, f_lp, method="RLS"): try: sys_id = SystemIdentification(n_poles, n_zeros, delay, dt) @@ -93,10 +101,7 @@ def compute_fit(u, y, t, dt, n_poles, n_zeros, delay, f_hp, f_lp, method="RLS"): _, y_est = ctrl.forced_response(Gz, T=t, U=u_delayed) y_detrended = detrend(y[: len(y_est)]) y_est_detrended = detrend(y_est) - norm_ref = np.linalg.norm(y_detrended - np.mean(y_detrended)) - if norm_ref < 1e-10: - return -np.inf - return 100.0 * (1.0 - np.linalg.norm(y_detrended - y_est_detrended) / norm_ref) + return computeNRMSE(y_detrended, y_est_detrended) except Exception: return -np.inf @@ -794,11 +799,7 @@ def replayInputData(self): y_detrended = detrend(self.y[: len(self.y_est)]) y_est_detrended = detrend(self.y_est) - fit = 100 * ( - 1 - - np.linalg.norm(y_detrended - y_est_detrended) - / np.linalg.norm(y_detrended - np.mean(y_detrended)) - ) + fit = computeNRMSE(y_detrended, y_est_detrended) self.lbl_fit.setText(f"{fit:.1f}%") if fit >= 80: self.lbl_fit.setStyleSheet("color: green") From 9d55c1cd8a6642cb082b19a66572f5386d25d0d0 Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 14:05:04 +0100 Subject: [PATCH 13/18] fix: clarify best fit condition --- autotune/autotune.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 91993c5..2afb191 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -155,7 +155,7 @@ def run(self): best_order = best_params.get("n_poles", 0) + best_params.get("n_zeros", 0) # Prefer lower-order models: a higher-order model must improve fit # by more than 1% to be accepted, avoiding overfitting. - if fit > best_fit and (new_order <= best_order or fit > best_fit + 1.0): + if (fit > best_fit + 1.0) or (fit > best_fit and new_order == best_order): best_fit = fit best_params = { "n_poles": n_poles, From 1f11d90a920e4ed6cc848c5a61073fb23a7023a5 Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 14:11:48 +0100 Subject: [PATCH 14/18] feat: do not loop through f_lp and f_hp as this takes a lot of time --- autotune/autotune.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 2afb191..9ca3f58 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -110,35 +110,33 @@ class ParamSearchWorker(QThread): finished = pyqtSignal(dict, float) progress = pyqtSignal(int, int) - def __init__(self, u, y, t, dt, method): + def __init__(self, u, y, t, dt, f_hp, f_lp, method): super().__init__() self.u = u self.y = y self.t = t self.dt = dt + self.f_hp = f_hp + self.f_lp = f_lp self.method = method def run(self): n_poles_range = [2, 3, 4, 5, 6] n_zeros_range = [2, 3, 4, 5, 6] delay_range = [0, 1, 2, 3] - f_hp_range = [0.0, 0.5, 1.0, 2.0] - f_lp_range = [10.0, 20.0, 30.0, 50.0] combos = [ - (n_poles, n_zeros, delay, f_hp, f_lp) + (n_poles, n_zeros, delay) for n_poles in n_poles_range for n_zeros in n_zeros_range for delay in delay_range - for f_hp in f_hp_range - for f_lp in f_lp_range if n_poles >= n_zeros ] total = len(combos) best_fit = -np.inf best_params = {} - for i, (n_poles, n_zeros, delay, f_hp, f_lp) in enumerate(combos): + for i, (n_poles, n_zeros, delay) in enumerate(combos): fit = compute_fit( self.u, self.y, @@ -147,8 +145,8 @@ def run(self): n_poles, n_zeros, delay, - f_hp, - f_lp, + self.f_hp, + self.f_lp, self.method, ) new_order = n_poles + n_zeros @@ -161,8 +159,6 @@ def run(self): "n_poles": n_poles, "n_zeros": n_zeros, "delay": delay, - "f_hp": f_hp, - "f_lp": f_lp, } self.progress.emit(i + 1, total) @@ -488,6 +484,8 @@ def onFindParamsClicked(self): self.y.copy(), self.t.copy(), self.dt, + self.f_hp_spinbox.value(), + self.f_lp_spinbox.value(), self.id_method_combo.currentText(), ) self._param_search_worker.progress.connect(self.onParamSearchProgress) @@ -501,8 +499,6 @@ def onParamSearchFinished(self, best_params, best_fit): self.line_edit_poles.setValue(best_params["n_poles"]) self.line_edit_zeros.setValue(best_params["n_zeros"]) self.line_edit_delays.setValue(best_params["delay"]) - self.f_hp_spinbox.setValue(best_params["f_hp"]) - self.f_lp_spinbox.setValue(best_params["f_lp"]) self.btn_find_params.setText("Find parameters") self.btn_find_params.setEnabled(True) self.onSysIdClicked() From c91e00d48665af63f36ad98d956c84ff53029b70 Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 14:26:32 +0100 Subject: [PATCH 15/18] feat: add cancel button and change search range --- autotune/autotune.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 9ca3f58..0396ef4 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -112,6 +112,7 @@ class ParamSearchWorker(QThread): def __init__(self, u, y, t, dt, f_hp, f_lp, method): super().__init__() + self._cancel = False self.u = u self.y = y self.t = t @@ -121,9 +122,9 @@ def __init__(self, u, y, t, dt, f_hp, f_lp, method): self.method = method def run(self): - n_poles_range = [2, 3, 4, 5, 6] - n_zeros_range = [2, 3, 4, 5, 6] - delay_range = [0, 1, 2, 3] + n_poles_range = range(2, 8) + n_zeros_range = range(2, 8) + delay_range = range(0, 6) combos = [ (n_poles, n_zeros, delay) @@ -161,6 +162,8 @@ def run(self): "delay": delay, } self.progress.emit(i + 1, total) + if self._cancel: + break self.finished.emit(best_params, best_fit) @@ -477,8 +480,13 @@ def onSysIdClicked(self): self.computeController() def onFindParamsClicked(self): - self.btn_find_params.setEnabled(False) - self.btn_find_params.setText("Searching... (0%)") + if ( + hasattr(self, "_param_search_worker") + and self._param_search_worker.isRunning() + ): + self._param_search_worker._cancel = True + return + self.btn_find_params.setText("Cancel (0%)") self._param_search_worker = ParamSearchWorker( self.u.copy(), self.y.copy(), @@ -493,7 +501,7 @@ def onFindParamsClicked(self): self._param_search_worker.start() def onParamSearchProgress(self, current, total): - self.btn_find_params.setText(f"Searching... ({100 * current // total}%)") + self.btn_find_params.setText(f"Cancel ({100 * current // total}%)") def onParamSearchFinished(self, best_params, best_fit): self.line_edit_poles.setValue(best_params["n_poles"]) From bf59dc4e5579886d3067d2a99c681b1cd184fe0c Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 14:34:24 +0100 Subject: [PATCH 16/18] chore(autotune): extract widgets blocks --- autotune/autotune.py | 146 +++++++++++++++++++++++-------------------- 1 file changed, 77 insertions(+), 69 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 0396ef4..c0f6dc7 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -231,29 +231,93 @@ def __init__(self, parent=None): left_menu.addWidget(self.btn_open_log) id_params_group = QFormLayout() + self.createModelOrderWidgets(id_params_group) + self.createMethodWidget(id_params_group) + self.createPreprocessingWidget(id_params_group) + self.createRunSysIdButton(id_params_group) + self.createFindParamsButton(id_params_group) + self.createFitWidget(id_params_group) + self.createStabilityWidget(id_params_group) + + left_menu.addLayout(id_params_group) + + layout_tf = self.createTfLayout() + left_menu.addLayout(layout_tf) + + offset_group = QFormLayout() + self.line_edit_offset = QDoubleSpinBox() + self.line_edit_offset.setValue(0.0) + self.line_edit_offset.setRange(-10.0, 10.0) + self.line_edit_offset.textChanged.connect(self.onOffsetChanged) + offset_group.addRow(QLabel("Offset"), self.line_edit_offset) + left_menu.addLayout(offset_group) + left_menu.addStretch(1) + + self.tuning_tabs = QTabWidget() + + self.tab_pid = QWidget() + self.tab_pid.setLayout(self.createPidLayout()) + self.tuning_tabs.addTab(self.tab_pid, "PID") + + self.tab_gmvc = QWidget() + self.tab_gmvc.setLayout(self.createGmvcLayout()) + self.tuning_tabs.addTab(self.tab_gmvc, "GMVC") + + layout_plot = QVBoxLayout() + layout_h.addLayout(left_menu) + layout_h.addLayout(layout_plot) + layout_h.setStretch(1, 1) + layout_plot.addWidget(self.toolbar) + layout_plot.addWidget(self.canvas) + layout_v.addLayout(layout_h) + layout_v.setStretch(0, 1) + bottom_row = QHBoxLayout() + bottom_row.addWidget(self.tuning_tabs, stretch=1) + bottom_row.addWidget(self.createStepInfoGroup()) + layout_v.addLayout(bottom_row) + self.setLayout(layout_v) + + def reset(self): + self.model_ref = None + self.input_ref = None + self.closed_loop_ref = None + self.measured_step_info = None + self.step_info_patches = [] + self.lbl_fit.setText("—") + self.lbl_fit.setStyleSheet("") + self.lbl_stability.setText("—") + self.lbl_stability.setStyleSheet("") + self.btn_stabilize.setVisible(False) + self.bode_plot_ref = [] + self.pz_plot_refs = [] + self.is_system_identified = False + + def createModelOrderWidgets(self, layout): self.line_edit_zeros = QSpinBox() self.line_edit_zeros.setValue(self.sys_id_n_zeros) self.line_edit_zeros.setRange(0, 6) self.line_edit_zeros.valueChanged.connect(self.onZerosChanged) - id_params_group.addRow(QLabel("Zeros"), self.line_edit_zeros) + layout.addRow(QLabel("Zeros"), self.line_edit_zeros) self.line_edit_poles = QSpinBox() self.line_edit_poles.setValue(self.sys_id_n_poles) self.line_edit_poles.setRange(0, 6) self.line_edit_poles.valueChanged.connect(self.onPolesChanged) - id_params_group.addRow(QLabel("Poles"), self.line_edit_poles) + layout.addRow(QLabel("Poles"), self.line_edit_poles) self.line_edit_delays = QSpinBox() self.line_edit_delays.setValue(self.sys_id_delays) self.line_edit_delays.setRange(0, 1000) self.line_edit_delays.valueChanged.connect(self.onDelaysChanged) - id_params_group.addRow(QLabel("Delays"), self.line_edit_delays) + layout.addRow(QLabel("Delays"), self.line_edit_delays) + def createMethodWidget(self, layout): self.id_method_combo = QComboBox() self.id_method_combo.addItems(["OLS", "RLS"]) self.id_method_combo.currentIndexChanged.connect( lambda: self.btn_run_sys_id.setEnabled(True) ) - id_params_group.addRow(QLabel("Method"), self.id_method_combo) + layout.addRow(QLabel("Method"), self.id_method_combo) + def createPreprocessingWidget(self, layout): preproc_group = QGroupBox("Pre-processing") preproc_form = QFormLayout() self.f_hp_spinbox = QDoubleSpinBox() @@ -274,12 +338,10 @@ def __init__(self, parent=None): lambda: self.btn_run_sys_id.setEnabled(True) ) preproc_form.addRow(QLabel("LP cutoff (Hz)"), self.f_lp_spinbox) - input_scale_group = QGroupBox("Input scaling") input_scale_group.setToolTip( "Scale the input to identify a model at trim airspeed (requires true airspeed data)" ) - input_scale_form = QFormLayout() self.input_scale_combo = QComboBox() self.input_scale_combo.setEditable(False) @@ -288,7 +350,6 @@ def __init__(self, parent=None): self.input_scale_combo.setEnabled(False) self.input_scale_combo.currentIndexChanged.connect(self.selectInputScale) input_scale_form.addRow(self.input_scale_combo) - self.line_edit_trim = QDoubleSpinBox() self.trim_airspeed = 20.0 self.line_edit_trim.setValue(self.trim_airspeed) @@ -298,26 +359,26 @@ def __init__(self, parent=None): input_scale_form.addRow(QLabel("Trim airspeed"), self.line_edit_trim) input_scale_group.setLayout(input_scale_form) preproc_form.addRow(input_scale_group) - preproc_group.setLayout(preproc_form) - id_params_group.addRow(preproc_group) + layout.addRow(preproc_group) + def createRunSysIdButton(self, layout): self.btn_run_sys_id = QPushButton("Run identification") self.btn_run_sys_id.clicked.connect(self.onSysIdClicked) self.btn_run_sys_id.setEnabled(False) + layout.addRow(self.btn_run_sys_id) + def createFindParamsButton(self, layout): self.btn_find_params = QPushButton("Find parameters") self.btn_find_params.clicked.connect(self.onFindParamsClicked) self.btn_find_params.setEnabled(False) + layout.addRow(self.btn_find_params) - run_row = QHBoxLayout() - run_row.addWidget(self.btn_run_sys_id) - run_row.addWidget(self.btn_find_params) - id_params_group.addRow(run_row) - + def createFitWidget(self, layout): self.lbl_fit = QLabel("—") - id_params_group.addRow(QLabel("Fit"), self.lbl_fit) + layout.addRow(QLabel("Fit"), self.lbl_fit) + def createStabilityWidget(self, layout): self.lbl_stability = QLabel("—") self.btn_stabilize = QPushButton("Stabilize") self.btn_stabilize.setVisible(False) @@ -328,60 +389,7 @@ def __init__(self, parent=None): stability_row = QHBoxLayout() stability_row.addWidget(self.lbl_stability) stability_row.addWidget(self.btn_stabilize) - id_params_group.addRow(QLabel("Stability"), stability_row) - - left_menu.addLayout(id_params_group) - - layout_tf = self.createTfLayout() - left_menu.addLayout(layout_tf) - - offset_group = QFormLayout() - self.line_edit_offset = QDoubleSpinBox() - self.line_edit_offset.setValue(0.0) - self.line_edit_offset.setRange(-10.0, 10.0) - self.line_edit_offset.textChanged.connect(self.onOffsetChanged) - offset_group.addRow(QLabel("Offset"), self.line_edit_offset) - left_menu.addLayout(offset_group) - left_menu.addStretch(1) - - self.tuning_tabs = QTabWidget() - - self.tab_pid = QWidget() - self.tab_pid.setLayout(self.createPidLayout()) - self.tuning_tabs.addTab(self.tab_pid, "PID") - - self.tab_gmvc = QWidget() - self.tab_gmvc.setLayout(self.createGmvcLayout()) - self.tuning_tabs.addTab(self.tab_gmvc, "GMVC") - - layout_plot = QVBoxLayout() - layout_h.addLayout(left_menu) - layout_h.addLayout(layout_plot) - layout_h.setStretch(1, 1) - layout_plot.addWidget(self.toolbar) - layout_plot.addWidget(self.canvas) - layout_v.addLayout(layout_h) - layout_v.setStretch(0, 1) - bottom_row = QHBoxLayout() - bottom_row.addWidget(self.tuning_tabs, stretch=1) - bottom_row.addWidget(self.createStepInfoGroup()) - layout_v.addLayout(bottom_row) - self.setLayout(layout_v) - - def reset(self): - self.model_ref = None - self.input_ref = None - self.closed_loop_ref = None - self.measured_step_info = None - self.step_info_patches = [] - self.lbl_fit.setText("—") - self.lbl_fit.setStyleSheet("") - self.lbl_stability.setText("—") - self.lbl_stability.setStyleSheet("") - self.btn_stabilize.setVisible(False) - self.bode_plot_ref = [] - self.pz_plot_refs = [] - self.is_system_identified = False + layout.addRow(QLabel("Stability"), stability_row) def createTfLayout(self): layout_tf = QVBoxLayout() From 2e402795a67fbfcd7daa5e84115c510993198f52 Mon Sep 17 00:00:00 2001 From: bresch Date: Thu, 26 Mar 2026 14:38:39 +0100 Subject: [PATCH 17/18] chore: re-order widgets --- autotune/autotune.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index c0f6dc7..17192bd 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -231,11 +231,11 @@ def __init__(self, parent=None): left_menu.addWidget(self.btn_open_log) id_params_group = QFormLayout() + self.createPreprocessingWidget(id_params_group) + self.createFindParamsButton(id_params_group) self.createModelOrderWidgets(id_params_group) self.createMethodWidget(id_params_group) - self.createPreprocessingWidget(id_params_group) self.createRunSysIdButton(id_params_group) - self.createFindParamsButton(id_params_group) self.createFitWidget(id_params_group) self.createStabilityWidget(id_params_group) From 1878de96d7395c0a3bdf0be887963f4273a9a2b2 Mon Sep 17 00:00:00 2001 From: bresch Date: Fri, 27 Mar 2026 17:29:29 +0100 Subject: [PATCH 18/18] fix: order of tf = number of poles --- autotune/autotune.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/autotune/autotune.py b/autotune/autotune.py index 17192bd..99f10a4 100644 --- a/autotune/autotune.py +++ b/autotune/autotune.py @@ -150,8 +150,8 @@ def run(self): self.f_lp, self.method, ) - new_order = n_poles + n_zeros - best_order = best_params.get("n_poles", 0) + best_params.get("n_zeros", 0) + new_order = n_poles + best_order = best_params.get("n_poles", 0) # Prefer lower-order models: a higher-order model must improve fit # by more than 1% to be accepted, avoiding overfitting. if (fit > best_fit + 1.0) or (fit > best_fit and new_order == best_order):