diff --git a/.github/workflows/autotune_tests.yml b/.github/workflows/autotune_tests.yml
new file mode 100644
index 0000000..2b5f157
--- /dev/null
+++ b/.github/workflows/autotune_tests.yml
@@ -0,0 +1,35 @@
+name: autotune_tests
+on:
+ pull_request:
+ push:
+ branches: [master]
+
+jobs:
+ autotune_tests:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: autotune
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.10'
+
+ # Qt's "offscreen" platform plugin still needs these shared libraries.
+ - name: Install Qt offscreen system libraries
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libegl1 libgl1 libxkbcommon0 libdbus-1-3
+
+ - name: Install dependencies
+ run: |
+ pip install poetry
+ poetry install
+
+ - name: Run tests
+ env:
+ QT_QPA_PLATFORM: offscreen
+ run: poetry run pytest -v
diff --git a/autotune/CLAUDE.md b/autotune/CLAUDE.md
new file mode 100644
index 0000000..4c30cba
--- /dev/null
+++ b/autotune/CLAUDE.md
@@ -0,0 +1,94 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Project Overview
+
+This is the `autotune` module within the Flight Control Prototyping Scripts — a PyQt5 GUI application for PX4 fixed-wing/multirotor rate controller tuning via system identification from flight logs.
+
+## Setup and Running
+
+**Install with Poetry (recommended):**
+```bash
+cd /home/mathieu/src/Flight_Control_Prototyping_Scripts/autotune
+poetry install
+poetry run python3 autotune.py
+```
+
+**Or with venv:**
+```bash
+python3 -m venv virtualenv-test
+source virtualenv-test/bin/activate
+pip3 install numpy scipy pyulog control pyqt5 pyyaml
+python3 autotune.py
+```
+
+**Run simulation-based test (no GUI data needed):**
+```bash
+poetry run python3 simulated_autotune.py
+```
+
+**Run the unit/functional tests:**
+```bash
+# pytest is in the dev group; the GUI tests need Qt's offscreen platform
+QT_QPA_PLATFORM=offscreen poetry run pytest -v
+```
+`test_presets.py` covers preset load/save (seed, round-trip, order, corrupt-file
+fallback); `test_preset_dialogs.py` drives `PresetEditDialog` headlessly to verify
+the add/edit/rename/create/delete behavior. These run in CI via
+`.github/workflows/autotune_tests.yml`.
+
+## Code Quality
+
+The root repo uses pre-commit hooks with **black** (formatting) and **isort** (imports, Black profile). Run manually:
+```bash
+cd /home/mathieu/src/Flight_Control_Prototyping_Scripts
+poetry run black autotune/
+poetry run isort autotune/
+```
+
+## Architecture
+
+### Data flow
+```
+ULog Flight Log → data_extractor.py → data_selection_window.py →
+system_identification.py (ARX/RLS) → pid_design.py (GMVC) →
+autotune.py (Bode/step response validation) → PX4 gains
+```
+
+### Module responsibilities
+
+- **`autotune.py`** — Main PyQt5 GUI window (~900 lines). Orchestrates the full workflow: log loading, axis/vehicle-type selection, model parameter configuration, gain computation, and result visualization via matplotlib figures embedded in Qt.
+
+- **`data_extractor.py`** — Parses PX4 ULog binary logs using `pyulog`. Extracts and interpolates signals (rates, setpoints, actuator outputs, airspeed). Returns numpy arrays aligned to a common time base.
+
+- **`data_selection_window.py`** — Interactive matplotlib window for selecting the maneuver time window and inspecting signal quality/coherence before running identification. Loads input/output presets via `presets.py` and lets the user add/edit/delete them through `preset_dialogs.py`.
+
+- **`presets.py`** — Loads/saves input/output presets from the user-editable `presets.yaml` (next to the code). Seeds the file with `DEFAULT_PRESETS` on first run; falls back to defaults if the file is missing or unparseable.
+
+- **`preset_dialogs.py`** — `PresetEditDialog`: a Qt dialog to add, edit, or delete a preset, with an old→new signal diff that switches to "create" mode when the preset is renamed.
+
+- **`system_identification.py`** — Preprocesses signals (bias removal, filtering) and runs weighted RLS to fit an ARX model. Returns numerator/denominator polynomial coefficients.
+
+- **`arx_rls.py`** — Core recursive least-squares implementation. Assumes ARX model: `A(q⁻¹)y(k) = q⁻ᵈ B(q⁻¹)u(k) + A(q⁻¹)e(k)`. Uses matrix-inversion-free update for efficiency.
+
+- **`pid_design.py`** — Computes PX4-compatible P/I/D gains from identified model polynomials using **General Minimum Variance Control (GMVC)**. Inputs: ARX coefficients, sample time, rise time, damping ratio.
+
+- **`pid_analyse_window.py`** — Secondary PyQt5 window with pole-zero plots, Bode diagrams, stability margins, and disturbance response for the tuned controller.
+
+- **`closed_loop_sim.py`** — Simulates closed-loop behavior with the identified model and designed gains to validate stability before applying to the aircraft.
+
+- **`simulated_autotune.py`** — End-to-end test on a synthetic 2nd-order system (no flight log needed).
+
+### Key dependencies
+
+| Package | Purpose |
+|---------|---------|
+| `control` | Transfer functions, Bode plots, pole-zero, simulation |
+| `pyulog` | PX4 ULog binary flight log parsing |
+| `pyqt5` | GUI framework |
+| `numpy` / `scipy` | Numerical computing, signal processing |
+| `pyyaml` | Read/write the user-editable `presets.yaml` |
+
+### Sample logs for testing
+Located in `logs/`: `quadrotor_sitl_jmavsim.ulg`, `quadrotor_x500.ulg`, `vtol_standard.ulg`.
diff --git a/autotune/data_selection_window.py b/autotune/data_selection_window.py
index 060cb74..0a4f994 100644
--- a/autotune/data_selection_window.py
+++ b/autotune/data_selection_window.py
@@ -4,6 +4,9 @@
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.widgets import SpanSelector
from pid_analyse_window import PIDAnalyseWindow
+from preset_dialogs import PresetEditDialog
+from presets import load_presets, save_presets
+from PyQt5.QtGui import QBrush, QColor
from PyQt5.QtWidgets import (
QComboBox,
QDialog,
@@ -13,7 +16,6 @@
QLabel,
QMessageBox,
QPushButton,
- QRadioButton,
QVBoxLayout,
)
from scipy import signal
@@ -24,76 +26,10 @@ class DataSelectionWindow(QDialog):
def __init__(self, filename):
QDialog.__init__(self)
- self.preset_candidates = {
- "Rollrate": {
- "input": "vehicle_torque_setpoint/xyz[0].0",
- "output": "vehicle_angular_velocity/xyz[0].0",
- "input_legacy": "actuator_controls_0/control[0].0",
- },
- "Pitchrate": {
- "input": "vehicle_torque_setpoint/xyz[1].0",
- "output": "vehicle_angular_velocity/xyz[1].0",
- "input_legacy": "actuator_controls_0/control[1].0",
- },
- "Yawrate": {
- "input": "vehicle_torque_setpoint/xyz[2].0",
- "output": "vehicle_angular_velocity/xyz[2].0",
- "input_legacy": "actuator_controls_0/control[2].0",
- },
- "Rollrate(FW)": {
- "input": "vehicle_torque_setpoint/xyz[0].1",
- "output": "vehicle_angular_velocity/xyz[0].0",
- "input_legacy": "actuator_controls_1/control[0].0",
- },
- "Pitchrate(FW)": {
- "input": "vehicle_torque_setpoint/xyz[1].1",
- "output": "vehicle_angular_velocity/xyz[1].0",
- "input_legacy": "actuator_controls_1/control[1].0",
- },
- "Yawrate(FW)": {
- "input": "vehicle_torque_setpoint/xyz[2].1",
- "output": "vehicle_angular_velocity/xyz[2].0",
- "input_legacy": "actuator_controls_1/control[2].0",
- },
- "Rollrate(closed-loop)": {
- "input": "vehicle_rates_setpoint/roll.0",
- "output": "vehicle_angular_velocity/xyz[0].0",
- },
- "Pitchrate(closed-loop)": {
- "input": "vehicle_rates_setpoint/pitch.0",
- "output": "vehicle_angular_velocity/xyz[1].0",
- },
- "Yawrate(closed-loop)": {
- "input": "vehicle_rates_setpoint/yaw.0",
- "output": "vehicle_angular_velocity/xyz[2].0",
- },
- "GimbalRollRate": {
- "input": "motor_state/roll.effort_cmd.0",
- "output": "motor_angular_rates/roll.angular_rate.0",
- },
- "GimbalPitchRate": {
- "input": "motor_state/pitch.effort_cmd.0",
- "output": "motor_angular_rates/pitch.angular_rate.0",
- },
- "GimbalYawRate": {
- "input": "motor_state/yaw.effort_cmd.0",
- "output": "motor_angular_rates/yaw.angular_rate.0",
- },
- "GimbalRollAtt": {
- "input": "motor_control/motor_commands.roll.angular_rate_setpoint.0",
- "output": "attitude_info/roll.0",
- },
- "GimbalPitchAtt": {
- "input": "motor_control/motor_commands.pitch.angular_rate_setpoint.0",
- "output": "attitude_info/pitch.0",
- },
- "GimbalYawAtt": {
- "input": "motor_control/motor_commands.yaw.angular_rate_setpoint.0",
- "output": "attitude_info/yaw.0",
- },
- }
+ self.preset_candidates = load_presets()
self.presets = {}
+ self.topic_names = []
self.t = []
self.u = []
@@ -121,7 +57,15 @@ def __init__(self, filename):
self.combo_preset.setEditable(False)
self.combo_preset.currentIndexChanged.connect(self.selectPreset)
- in_out_group.addRow(QLabel("Preset:"), self.combo_preset)
+ preset_row = QHBoxLayout()
+ preset_row.addWidget(self.combo_preset)
+ self.btn_add_preset = QPushButton("Add")
+ self.btn_add_preset.clicked.connect(self.addPreset)
+ preset_row.addWidget(self.btn_add_preset)
+ self.btn_edit_preset = QPushButton("Edit")
+ self.btn_edit_preset.clicked.connect(self.editPreset)
+ preset_row.addWidget(self.btn_edit_preset)
+ in_out_group.addRow(QLabel("Preset:"), preset_row)
self.combo_u = SearchableComboBox()
self.combo_u.currentIndexChanged.connect(self.selectUData)
@@ -187,30 +131,43 @@ def openFile(self):
f"{topic.topic_name}/{topic.variable_name}.{topic.instance}"
for topic in self.topics
]
+ self.topic_names = list_names
self.combo_u.clear()
self.combo_u.addItems(list_names)
self.combo_y.clear()
self.combo_y.addItems(list_names)
- # Trigger preset selection. If no preset matches the available
- # topics, leave the input/output combos for manual configuration.
+ # Show all presets, then select the first one that matches the
+ # available topics. Presets that don't match are still listed (and
+ # editable) but greyed out and won't auto-fill the input/output.
self.fillPresets()
- if self.presets:
- self.combo_preset.setCurrentIndex(0)
- self.selectPreset(0)
+ for i in range(self.combo_preset.count()):
+ if self.combo_preset.itemText(i) in self.presets:
+ self.combo_preset.setCurrentIndex(i)
+ self.selectPreset(i)
+ break
def fillPresets(self):
+ self.combo_preset.blockSignals(True)
self.combo_preset.clear()
self.presets = {}
- for candidate in self.preset_candidates:
+ # List every preset so the user can see and edit all of them. Track
+ # which ones match the loaded log's topics in self.presets.
+ for index, candidate in enumerate(self.preset_candidates):
+ self.combo_preset.addItem(candidate)
(index_u, index_y) = self.findInputOutputIndex(
self.preset_candidates[candidate]
)
if index_u > -1 and index_y > -1:
self.presets[candidate] = self.preset_candidates[candidate]
+ else:
+ # Grey out presets that don't match the current log.
+ item = self.combo_preset.model().item(index)
+ if item is not None:
+ item.setForeground(QBrush(QColor("gray")))
- self.combo_preset.addItems(list(self.presets.keys()))
+ self.combo_preset.blockSignals(False)
def printRangeError(self):
msg = QMessageBox()
@@ -220,11 +177,10 @@ def printRangeError(self):
msg.exec_()
def selectPreset(self, index):
- preset_keys = list(self.presets.keys())
- if index < 0 or index >= len(preset_keys):
+ name = self.combo_preset.itemText(index)
+ if name not in self.preset_candidates:
return
- preset_key = preset_keys[index]
- preset = self.presets[preset_key]
+ preset = self.preset_candidates[name]
(index_u, index_y) = self.findInputOutputIndex(preset)
if index_u > -1:
@@ -246,6 +202,62 @@ def findInputOutputIndex(self, preset):
return (index_u, index_y)
+ def addPreset(self):
+ self._openPresetDialog(create=True)
+
+ def editPreset(self):
+ self._openPresetDialog(create=False)
+
+ def _openPresetDialog(self, create):
+ if not self.topic_names:
+ QMessageBox.information(
+ self, "No log loaded", "Open a log file before editing presets."
+ )
+ return
+
+ # The currently selected preset (from the topic-matched subset shown in
+ # the combo); may be empty if no preset matched the log.
+ selected_name = self.combo_preset.currentText()
+ if selected_name not in self.preset_candidates:
+ selected_name = None
+
+ if not create and selected_name is None:
+ QMessageBox.information(
+ self, "No preset selected", "Select a preset to edit first."
+ )
+ return
+
+ dialog = PresetEditDialog(
+ self,
+ create,
+ selected_name,
+ self.preset_candidates,
+ self.topic_names,
+ self.combo_u.currentText(),
+ self.combo_y.currentText(),
+ )
+ dialog.exec_()
+
+ if dialog.result_action == "save":
+ if dialog.remove_name and dialog.remove_name != dialog.name:
+ self.preset_candidates.pop(dialog.remove_name, None)
+ self.preset_candidates[dialog.name] = dialog.preset
+ target = dialog.name
+ elif dialog.result_action == "delete":
+ self.preset_candidates.pop(dialog.remove_name, None)
+ target = None
+ else:
+ return
+
+ save_presets(self.preset_candidates)
+ self.fillPresets()
+
+ # Reselect the affected preset if it is still present and matches.
+ if target is not None:
+ index = self.combo_preset.findText(target)
+ if index > -1:
+ self.combo_preset.setCurrentIndex(index)
+
def selectUData(self, index):
self.index_u = index
(self.t, self.u) = self.data_extractor.getPreview(self.topics[index])
diff --git a/autotune/poetry.lock b/autotune/poetry.lock
index 2b7d3ac..4119353 100644
--- a/autotune/poetry.lock
+++ b/autotune/poetry.lock
@@ -1,5 +1,18 @@
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
[[package]]
name = "contourpy"
version = "1.3.2"
@@ -115,6 +128,25 @@ files = [
docs = ["ipython", "matplotlib", "numpydoc", "sphinx"]
tests = ["pytest", "pytest-cov", "pytest-xdist"]
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
+ {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
+
+[package.extras]
+test = ["pytest (>=6)"]
+
[[package]]
name = "fonttools"
version = "4.59.0"
@@ -180,6 +212,18 @@ type1 = ["xattr ; sys_platform == \"darwin\""]
unicode = ["unicodedata2 (>=15.1.0) ; python_version <= \"3.12\""]
woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
+ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
+]
+
[[package]]
name = "kiwisolver"
version = "1.4.8"
@@ -380,7 +424,7 @@ version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
@@ -511,6 +555,37 @@ tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "ole
typing = ["typing-extensions ; python_version < \"3.10\""]
xmp = ["defusedxml"]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
+ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["coverage", "pytest", "pytest-benchmark"]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
+ {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
[[package]]
name = "pyparsing"
version = "3.2.3"
@@ -590,6 +665,30 @@ files = [
{file = "pyqt5_sip-12.17.0.tar.gz", hash = "sha256:682dadcdbd2239af9fdc0c0628e2776b820e128bec88b49b8d692fe682f90b4f"},
]
+[[package]]
+name = "pytest"
+version = "8.4.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
+ {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
+iniconfig = ">=1"
+packaging = ">=20"
+pluggy = ">=1.5,<2"
+pygments = ">=2.7.2"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -623,6 +722,89 @@ numpy = {version = ">=1.25", markers = "python_version >= \"3.9\""}
[package.extras]
test = ["ddt", "pytest"]
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+description = "YAML parser and emitter for Python"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
+ {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
+ {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3"},
+ {file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6"},
+ {file = "PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369"},
+ {file = "PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295"},
+ {file = "PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b"},
+ {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"},
+ {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"},
+ {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"},
+ {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"},
+ {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"},
+ {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"},
+ {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"},
+ {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"},
+ {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"},
+ {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"},
+ {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"},
+ {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"},
+ {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"},
+ {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"},
+ {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"},
+ {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"},
+ {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"},
+ {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"},
+ {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"},
+ {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"},
+ {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"},
+ {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"},
+ {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"},
+ {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"},
+ {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"},
+ {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"},
+ {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"},
+ {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"},
+ {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"},
+ {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"},
+ {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"},
+ {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"},
+ {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"},
+ {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"},
+ {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"},
+ {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"},
+ {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"},
+ {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"},
+ {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"},
+ {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"},
+ {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"},
+ {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"},
+ {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"},
+ {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"},
+ {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"},
+ {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"},
+ {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"},
+ {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"},
+ {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"},
+ {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"},
+ {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"},
+ {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"},
+ {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"},
+ {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"},
+ {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"},
+ {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"},
+ {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"},
+ {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
+]
+
[[package]]
name = "scipy"
version = "1.15.3"
@@ -699,7 +881,78 @@ files = [
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
]
+[[package]]
+name = "tomli"
+version = "2.4.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
+ {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
+ {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
+ {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
+ {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
+ {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
+ {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
+ {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
+ {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
+ {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
+ {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
+ {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
+ {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
+ {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
+ {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
+ {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
+ {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
+ {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
+ {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
+ {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
+ {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
+ {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
+ {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
+ {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
+ {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
+ {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
+ {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
+ {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
+ {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
+ {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
+ {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
+ {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
+ {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
+ {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
+ {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
+ {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
+ {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
+ {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
+ {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
+ {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
+ {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
+ {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
+ {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
+ {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
+ {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
+ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
+ {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
[metadata]
lock-version = "2.1"
python-versions = ">=3.10, <4.0"
-content-hash = "db831e3cde414745183aa5789dfb2f3b39b805d7c03e475a460a8c5989748608"
+content-hash = "1eaed51593e28bdbde1b2932eda85f852fa8118cde71b6832e71379a53ddc4ad"
diff --git a/autotune/preset_dialogs.py b/autotune/preset_dialogs.py
new file mode 100644
index 0000000..8e78d11
--- /dev/null
+++ b/autotune/preset_dialogs.py
@@ -0,0 +1,237 @@
+"""Dialog for adding/editing/renaming/deleting input-output presets."""
+
+from PyQt5.QtWidgets import (
+ QDialog,
+ QDialogButtonBox,
+ QGridLayout,
+ QHBoxLayout,
+ QLabel,
+ QMessageBox,
+ QPushButton,
+ QVBoxLayout,
+)
+from searchable_combo_box import SearchableComboBox
+
+
+class PresetEditDialog(QDialog):
+ """Add or edit a single preset.
+
+ The mode is fixed by the caller via ``create``:
+ - create=True: always makes a brand new preset (the name starts blank).
+ - create=False: edits ``original_name`` - keeping its name updates its
+ signals, typing a brand new name renames it, and it can be deleted.
+
+ The name field autocompletes existing names for convenience, but a name
+ that collides with a *different* existing preset is rejected on confirm (it
+ never silently overwrites or switches target).
+
+ The "new" input/output default to the current main-window selection.
+
+ After ``exec_()``, read the outcome:
+ - result_action == "save": upsert ``preset`` under ``name``; if
+ ``remove_name`` is set and differs from ``name``, delete it first.
+ - result_action == "delete": delete preset ``remove_name``.
+ - result_action == "cancel": no change.
+ """
+
+ def __init__(
+ self,
+ parent,
+ create,
+ original_name,
+ existing_presets,
+ topic_list,
+ default_input,
+ default_output,
+ ):
+ super().__init__(parent)
+ self.setWindowTitle("Add preset" if create else "Edit preset")
+
+ self.existing = existing_presets
+
+ self.result_action = "cancel"
+ self.name = None
+ self.preset = None
+ self.remove_name = None
+
+ # Fixed at construction. _creating True => create mode (no base preset).
+ # Otherwise we edit _base_name. The base never changes while the dialog
+ # is open, so autocomplete can't silently switch which preset is edited.
+ self._creating = bool(create)
+ self._base_name = None if self._creating else original_name
+
+ layout = QVBoxLayout()
+
+ # --- Name (searchable combo of existing presets) ---
+ name_row = QHBoxLayout()
+ name_row.addWidget(QLabel("Name:"))
+ self.combo_name = SearchableComboBox()
+ self.combo_name.addItems(list(existing_presets.keys()))
+ self.combo_name.setEditText(self._base_name or "")
+ self.combo_name.editTextChanged.connect(self._update_mode)
+ name_row.addWidget(self.combo_name)
+ layout.addLayout(name_row)
+
+ # --- old -> new grid ---
+ grid = QGridLayout()
+ grid.addWidget(QLabel("Signal"), 0, 0)
+ self.label_old_header = QLabel("Old")
+ grid.addWidget(self.label_old_header, 0, 1)
+ grid.addWidget(QLabel(""), 0, 2)
+ grid.addWidget(QLabel("New"), 0, 3)
+
+ grid.addWidget(QLabel("Input:"), 1, 0)
+ self.label_old_input = QLabel("")
+ grid.addWidget(self.label_old_input, 1, 1)
+ self.arrow_input = QLabel("→")
+ grid.addWidget(self.arrow_input, 1, 2)
+ self.combo_new_input = SearchableComboBox()
+ self.combo_new_input.addItems(topic_list)
+ self._select(self.combo_new_input, default_input)
+ grid.addWidget(self.combo_new_input, 1, 3)
+
+ grid.addWidget(QLabel("Output:"), 2, 0)
+ self.label_old_output = QLabel("")
+ grid.addWidget(self.label_old_output, 2, 1)
+ self.arrow_output = QLabel("→")
+ grid.addWidget(self.arrow_output, 2, 2)
+ self.combo_new_output = SearchableComboBox()
+ self.combo_new_output.addItems(topic_list)
+ self._select(self.combo_new_output, default_output)
+ grid.addWidget(self.combo_new_output, 2, 3)
+
+ layout.addLayout(grid)
+
+ # --- buttons ---
+ self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
+ self.btn_confirm = self.buttons.button(QDialogButtonBox.Ok)
+ self.buttons.accepted.connect(self._on_confirm)
+ self.buttons.rejected.connect(self.reject)
+
+ self.btn_delete = QPushButton("Delete preset")
+ self.btn_delete.setStyleSheet(
+ "color: white; background-color: #c0392b; font-weight: bold;"
+ )
+ self.btn_delete.clicked.connect(self._on_delete)
+
+ btn_row = QHBoxLayout()
+ btn_row.addWidget(self.btn_delete)
+ btn_row.addStretch()
+ btn_row.addWidget(self.buttons)
+ layout.addLayout(btn_row)
+
+ self.setLayout(layout)
+
+ if self._creating:
+ self.combo_name.setFocus()
+ self._update_mode()
+
+ def _select(self, combo, text):
+ if not text:
+ return
+ index = combo.findText(text)
+ if index >= 0:
+ combo.setCurrentIndex(index)
+ else:
+ combo.setEditText(text)
+
+ def _target_name(self):
+ return self.combo_name.currentText().strip()
+
+ def _mode(self):
+ """Return 'create', 'update' or 'rename' for the current name.
+
+ Derived from the explicit base preset, never from typed text matching.
+ """
+ if self._creating or self._base_name is None:
+ return "create"
+ if self._target_name() == self._base_name:
+ return "update"
+ return "rename"
+
+ def _source_name(self):
+ """Preset whose stored values feed the 'old' column and legacy keys."""
+ return self._base_name if self._base_name in self.existing else None
+
+ def _delete_target(self):
+ return self._base_name if self._base_name in self.existing else None
+
+ def _update_mode(self):
+ mode = self._mode()
+ source = self._source_name()
+ src = self.existing.get(source, {}) if source else {}
+
+ self.label_old_input.setText(src.get("input", ""))
+ self.label_old_output.setText(src.get("output", ""))
+
+ show_old = mode in ("update", "rename")
+ for w in (
+ self.label_old_header,
+ self.label_old_input,
+ self.label_old_output,
+ self.arrow_input,
+ self.arrow_output,
+ ):
+ w.setVisible(show_old)
+
+ # Editing always reads "Update preset", whether or not the name changed.
+ self.btn_confirm.setText(
+ "Create new preset" if mode == "create" else "Update preset"
+ )
+
+ self.btn_delete.setVisible(self._delete_target() is not None)
+
+ def _on_confirm(self):
+ name = self._target_name()
+ if not name:
+ QMessageBox.warning(
+ self, "Invalid name", "The preset name cannot be empty."
+ )
+ return
+
+ new_input = self.combo_new_input.currentText().strip()
+ new_output = self.combo_new_output.currentText().strip()
+ if not new_input or not new_output:
+ QMessageBox.warning(
+ self, "Invalid signals", "Input and output must both be set."
+ )
+ return
+
+ mode = self._mode()
+ if mode in ("create", "rename") and name in self.existing:
+ QMessageBox.warning(
+ self,
+ "Name already exists",
+ f"A preset named '{name}' already exists.",
+ )
+ return
+
+ # Preserve legacy fallbacks from the source preset.
+ source = self._source_name()
+ src = self.existing.get(source, {}) if source else {}
+ preset = {"input": new_input, "output": new_output}
+ for key in ("input_legacy", "output_legacy"):
+ if key in src:
+ preset[key] = src[key]
+
+ self.name = name
+ self.preset = preset
+ self.remove_name = source if (mode == "rename" and source != name) else None
+ self.result_action = "save"
+ self.accept()
+
+ def _on_delete(self):
+ target = self._delete_target()
+ if target is None:
+ return
+ reply = QMessageBox.question(
+ self,
+ "Delete preset",
+ f"Delete preset '{target}'?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No,
+ )
+ if reply == QMessageBox.Yes:
+ self.remove_name = target
+ self.result_action = "delete"
+ self.accept()
diff --git a/autotune/presets.py b/autotune/presets.py
new file mode 100644
index 0000000..a212727
--- /dev/null
+++ b/autotune/presets.py
@@ -0,0 +1,123 @@
+"""Persistence for autotune input/output presets.
+
+Presets map a human-readable name to the input/output ULog signals used for
+system identification. They live in an external, user-editable ``presets.yaml``
+file next to this module so they can be edited by hand or from the GUI without
+touching the source code. If the file is missing it is seeded with the built-in
+defaults below; if it is unreadable/corrupt we fall back to the defaults rather
+than crashing.
+"""
+
+import os
+
+import yaml
+
+# Built-in presets used to seed presets.yaml on first run (and as a fallback if
+# the file cannot be read). Each preset maps a name to a dict with "input" and
+# "output" topics, plus optional "input_legacy"/"output_legacy" fallbacks.
+DEFAULT_PRESETS = {
+ "Rollrate": {
+ "input": "vehicle_torque_setpoint/xyz[0].0",
+ "output": "vehicle_angular_velocity/xyz[0].0",
+ "input_legacy": "actuator_controls_0/control[0].0",
+ },
+ "Pitchrate": {
+ "input": "vehicle_torque_setpoint/xyz[1].0",
+ "output": "vehicle_angular_velocity/xyz[1].0",
+ "input_legacy": "actuator_controls_0/control[1].0",
+ },
+ "Yawrate": {
+ "input": "vehicle_torque_setpoint/xyz[2].0",
+ "output": "vehicle_angular_velocity/xyz[2].0",
+ "input_legacy": "actuator_controls_0/control[2].0",
+ },
+ "Rollrate(FW)": {
+ "input": "vehicle_torque_setpoint/xyz[0].1",
+ "output": "vehicle_angular_velocity/xyz[0].0",
+ "input_legacy": "actuator_controls_1/control[0].0",
+ },
+ "Pitchrate(FW)": {
+ "input": "vehicle_torque_setpoint/xyz[1].1",
+ "output": "vehicle_angular_velocity/xyz[1].0",
+ "input_legacy": "actuator_controls_1/control[1].0",
+ },
+ "Yawrate(FW)": {
+ "input": "vehicle_torque_setpoint/xyz[2].1",
+ "output": "vehicle_angular_velocity/xyz[2].0",
+ "input_legacy": "actuator_controls_1/control[2].0",
+ },
+ "Rollrate(closed-loop)": {
+ "input": "vehicle_rates_setpoint/roll.0",
+ "output": "vehicle_angular_velocity/xyz[0].0",
+ },
+ "Pitchrate(closed-loop)": {
+ "input": "vehicle_rates_setpoint/pitch.0",
+ "output": "vehicle_angular_velocity/xyz[1].0",
+ },
+ "Yawrate(closed-loop)": {
+ "input": "vehicle_rates_setpoint/yaw.0",
+ "output": "vehicle_angular_velocity/xyz[2].0",
+ },
+ "GimbalRollRate": {
+ "input": "motor_state/roll.effort_cmd.0",
+ "output": "motor_angular_rates/roll.angular_rate.0",
+ },
+ "GimbalPitchRate": {
+ "input": "motor_state/pitch.effort_cmd.0",
+ "output": "motor_angular_rates/pitch.angular_rate.0",
+ },
+ "GimbalYawRate": {
+ "input": "motor_state/yaw.effort_cmd.0",
+ "output": "motor_angular_rates/yaw.angular_rate.0",
+ },
+ "GimbalRollAtt": {
+ "input": "motor_control/motor_commands.roll.angular_rate_setpoint.0",
+ "output": "attitude_info/roll.0",
+ },
+ "GimbalPitchAtt": {
+ "input": "motor_control/motor_commands.pitch.angular_rate_setpoint.0",
+ "output": "attitude_info/pitch.0",
+ },
+ "GimbalYawAtt": {
+ "input": "motor_control/motor_commands.yaw.angular_rate_setpoint.0",
+ "output": "attitude_info/yaw.0",
+ },
+}
+
+
+def presets_file_path():
+ """Return the path to presets.yaml next to this module."""
+ return os.path.join(os.path.dirname(os.path.abspath(__file__)), "presets.yaml")
+
+
+def load_presets():
+ """Load presets from presets.yaml.
+
+ Seeds the file with DEFAULT_PRESETS if it does not exist. Falls back to a
+ copy of DEFAULT_PRESETS if the file is missing or cannot be parsed.
+ """
+ path = presets_file_path()
+
+ if not os.path.exists(path):
+ save_presets(DEFAULT_PRESETS)
+ return {k: dict(v) for k, v in DEFAULT_PRESETS.items()}
+
+ try:
+ with open(path, "r") as f:
+ presets = yaml.safe_load(f)
+ if not isinstance(presets, dict):
+ raise ValueError("presets.yaml does not contain a mapping")
+ return presets
+ except (OSError, yaml.YAMLError, ValueError) as e:
+ print(f"Warning: could not read {path} ({e}); using built-in defaults.")
+ return {k: dict(v) for k, v in DEFAULT_PRESETS.items()}
+
+
+def save_presets(presets):
+ """Write presets to presets.yaml, preserving insertion order."""
+ path = presets_file_path()
+ try:
+ with open(path, "w") as f:
+ yaml.safe_dump(presets, f, sort_keys=False, default_flow_style=False)
+ except OSError as e:
+ print(f"Warning: could not write {path} ({e}).")
diff --git a/autotune/pyproject.toml b/autotune/pyproject.toml
index 9175d22..2aa7ac7 100644
--- a/autotune/pyproject.toml
+++ b/autotune/pyproject.toml
@@ -12,10 +12,14 @@ dependencies = [
"control (>=0.10.2,<0.11.0)",
"scipy (>=1.15.1,<2.0.0)",
"pyqt5 (>=5.15.11,<6.0.0)",
- "pyulog (>=1.2.2,<2.0.0)"
+ "pyulog (>=1.2.2,<2.0.0)",
+ "pyyaml (>=6.0,<7.0)"
]
+[tool.poetry.group.dev.dependencies]
+pytest = "^8.0.0"
+
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
diff --git a/autotune/test_preset_dialogs.py b/autotune/test_preset_dialogs.py
new file mode 100644
index 0000000..483d581
--- /dev/null
+++ b/autotune/test_preset_dialogs.py
@@ -0,0 +1,168 @@
+"""Functional tests for the preset add/edit/rename/delete dialog.
+
+These drive PresetEditDialog headlessly (no real window is shown). Run with:
+ QT_QPA_PLATFORM=offscreen poetry run pytest test_preset_dialogs.py
+The offscreen platform is also set automatically below as a fallback.
+"""
+
+import os
+
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+
+import preset_dialogs # noqa: E402
+import pytest # noqa: E402
+from preset_dialogs import PresetEditDialog # noqa: E402
+from PyQt5.QtWidgets import QApplication, QMessageBox # noqa: E402
+
+EXISTING = {
+ "Rollrate": {"input": "a/in.0", "output": "a/out.0", "input_legacy": "a/leg.0"},
+ "Pitchrate": {"input": "b/in.0", "output": "b/out.0"},
+}
+TOPICS = ["a/in.0", "a/out.0", "b/in.0", "b/out.0", "c/in.0", "c/out.0", "a/leg.0"]
+SEL_IN, SEL_OUT = "c/in.0", "c/out.0" # "currently selected" main-window signals
+
+
+@pytest.fixture(scope="session")
+def qapp():
+ app = QApplication.instance() or QApplication([])
+ yield app
+
+
+@pytest.fixture
+def make(qapp):
+ def _make(original_name, create=False):
+ # Fresh copy of EXISTING per dialog so tests stay isolated.
+ existing = {k: dict(v) for k, v in EXISTING.items()}
+ return PresetEditDialog(
+ None, create, original_name, existing, TOPICS, SEL_IN, SEL_OUT
+ )
+
+ return _make
+
+
+@pytest.fixture
+def no_modals(monkeypatch):
+ """Stub the modal warning/question boxes so headless tests never block."""
+ monkeypatch.setattr(preset_dialogs.QMessageBox, "warning", lambda *a, **k: None)
+ monkeypatch.setattr(
+ preset_dialogs.QMessageBox, "question", lambda *a, **k: QMessageBox.Yes
+ )
+
+
+def test_edit_mode_defaults_to_current_selection(make):
+ d = make("Rollrate")
+ assert d._mode() == "update"
+ assert d.btn_confirm.text() == "Update preset"
+ # old column shows the stored preset, new combos default to the selection
+ assert d.label_old_input.text() == "a/in.0"
+ assert d.combo_new_input.currentText() == SEL_IN
+ assert d.combo_new_output.currentText() == SEL_OUT
+ assert d._delete_target() == "Rollrate"
+
+
+def test_typing_unknown_name_renames(make):
+ d = make("Rollrate")
+ d.combo_name.setEditText("Rollrate_v2")
+ assert d._mode() == "rename"
+ assert d.btn_confirm.text() == "Update preset"
+
+ d._on_confirm()
+
+ assert d.result_action == "save"
+ assert d.name == "Rollrate_v2"
+ assert d.remove_name == "Rollrate" # old key removed by caller
+ # legacy fallback is carried over from the renamed preset
+ assert d.preset == {
+ "input": SEL_IN,
+ "output": SEL_OUT,
+ "input_legacy": "a/leg.0",
+ }
+
+
+def test_edit_button_opens_in_update_mode(make):
+ d = make("Rollrate") # Edit button -> create=False
+ assert d._mode() == "update"
+ assert d.windowTitle() == "Edit preset"
+
+
+def test_add_button_creates(make):
+ d = make(None, create=True) # Add button -> create=True
+ assert d._mode() == "create"
+ assert d.windowTitle() == "Add preset"
+ assert d.btn_confirm.text() == "Create new preset"
+ assert d.combo_name.currentText() == ""
+ assert d._delete_target() is None # nothing to delete while creating
+
+ d.combo_name.setEditText("BrandNew")
+ d._on_confirm()
+
+ assert d.result_action == "save"
+ assert d.name == "BrandNew"
+ assert d.remove_name is None # nothing removed
+ assert d.preset == {"input": SEL_IN, "output": SEL_OUT}
+
+
+def test_renaming_to_existing_name_is_rejected(make, no_modals):
+ # Editing "Rollrate" and changing its name to another existing preset must
+ # be refused with feedback - never a silent overwrite/switch.
+ d = make("Rollrate")
+ d.combo_name.setEditText("Pitchrate")
+ assert d._mode() == "rename"
+ assert d._base_name == "Rollrate" # target never switches to Pitchrate
+
+ d._on_confirm()
+
+ assert d.result_action == "cancel" # blocked
+
+
+def test_selecting_existing_in_edit_mode_does_not_switch_target(make, no_modals):
+ # Picking an existing name from the dropdown only fills the text; it does
+ # not silently switch which preset is being edited.
+ d = make("Rollrate")
+ d.combo_name.setEditText("Pitchrate") # what a dropdown pick does to the text
+ assert d._base_name == "Rollrate"
+ assert d.label_old_input.text() == "a/in.0" # still showing Rollrate
+ d._on_confirm()
+ assert d.result_action == "cancel" # collision, not a silent Pitchrate update
+
+
+def test_add_then_typing_existing_name_stays_create(make, no_modals):
+ # In Add mode, typing a name that transiently (or fully) matches an existing
+ # preset must NOT turn into an edit/rename.
+ d = make(None, create=True)
+ d.combo_name.setEditText("Rollrate") # collides while typing
+ assert d._mode() == "create"
+ d.combo_name.setEditText("Rollrate2") # extended to a unique name
+ assert d._mode() == "create"
+ assert d.btn_confirm.text() == "Create new preset"
+
+ d._on_confirm()
+
+ assert d.result_action == "save"
+ assert d.name == "Rollrate2"
+ assert d.remove_name is None # existing "Rollrate" left untouched
+ assert d.preset == {"input": SEL_IN, "output": SEL_OUT}
+
+
+def test_add_then_typing_existing_name_then_confirm_is_rejected(make, no_modals):
+ # Leaving an Add-mode name equal to an existing preset is rejected as a
+ # collision (rather than silently overwriting).
+ d = make(None, create=True)
+ d.combo_name.setEditText("Pitchrate")
+ assert d._mode() == "create"
+ d._on_confirm()
+ assert d.result_action == "cancel"
+
+
+def test_empty_name_is_rejected(make, no_modals):
+ d = make(None, create=True)
+ d.combo_name.setEditText("")
+ d._on_confirm()
+ assert d.result_action == "cancel"
+
+
+def test_delete_targets_selected_preset(make, no_modals):
+ d = make("Rollrate")
+ d._on_delete()
+ assert d.result_action == "delete"
+ assert d.remove_name == "Rollrate"
diff --git a/autotune/test_presets.py b/autotune/test_presets.py
new file mode 100644
index 0000000..2b3a873
--- /dev/null
+++ b/autotune/test_presets.py
@@ -0,0 +1,70 @@
+"""Unit tests for preset persistence (presets.py).
+
+Run with: poetry run pytest test_presets.py
+"""
+
+import presets
+
+
+def _redirect(monkeypatch, tmp_path):
+ """Point presets.py at a throwaway file so the real presets.yaml is safe."""
+ path = tmp_path / "presets.yaml"
+ monkeypatch.setattr(presets, "presets_file_path", lambda: str(path))
+ return path
+
+
+def test_load_seeds_defaults_when_missing(tmp_path, monkeypatch):
+ path = _redirect(monkeypatch, tmp_path)
+ assert not path.exists()
+
+ loaded = presets.load_presets()
+
+ assert path.exists() # file is seeded on first run
+ assert loaded == presets.DEFAULT_PRESETS
+
+
+def test_round_trip_preserves_insertion_order(tmp_path, monkeypatch):
+ _redirect(monkeypatch, tmp_path)
+ data = {
+ "Zulu": {"input": "z/in.0", "output": "z/out.0"},
+ "Alpha": {"input": "a/in.0", "output": "a/out.0"},
+ }
+
+ presets.save_presets(data)
+ loaded = presets.load_presets()
+
+ assert loaded == data
+ assert list(loaded.keys()) == ["Zulu", "Alpha"] # not alphabetised
+
+
+def test_legacy_keys_survive_round_trip(tmp_path, monkeypatch):
+ _redirect(monkeypatch, tmp_path)
+ data = {
+ "Rollrate": {
+ "input": "vehicle_torque_setpoint/xyz[0].0",
+ "output": "vehicle_angular_velocity/xyz[0].0",
+ "input_legacy": "actuator_controls_0/control[0].0",
+ }
+ }
+
+ presets.save_presets(data)
+
+ assert presets.load_presets() == data
+
+
+def test_corrupt_file_falls_back_to_defaults(tmp_path, monkeypatch):
+ path = _redirect(monkeypatch, tmp_path)
+ path.write_text(":\n - [ this is not valid yaml\n")
+
+ loaded = presets.load_presets()
+
+ assert loaded == presets.DEFAULT_PRESETS
+
+
+def test_non_mapping_file_falls_back_to_defaults(tmp_path, monkeypatch):
+ path = _redirect(monkeypatch, tmp_path)
+ path.write_text("- just\n- a\n- list\n")
+
+ loaded = presets.load_presets()
+
+ assert loaded == presets.DEFAULT_PRESETS