sprint-4: Project Editor + .appack compiler + IRudderActuator HAL + dual sensor config
Cherry-pick of ab28cb7 onto main (was missing from main branch history). Python: - arautopilot/core/sensor_config.py: RudderSensorConfig + DualRudderSensorConfig (AS5048A SPI / potentiometer, dual with cross-validation thresholds) - arautopilot/core/vessel_config.py: add sensors: DualRudderSensorConfig field - arautopilot/studio/compiler/appack.py: .appack compiler (ZIP with manifest.json + project.yaml + firmware_config.h + install_notes.txt) - arautopilot/studio/editors/project_editor.py: full project config editor (vessel / actuator / sensors / PID, RBAC-gated fields, save/load .yaml/.json) - arautopilot/studio/main_window.py: wire ProjectEditorWidget into Project tab - tests: test_sensor_config.py (11 tests) + test_appack_compiler.py (10 tests) Firmware: - hal/rudder_actuator_iface.h: IRudderActuator abstract interface - hal/rudder_actuator_hydraulic.h: reversible hydraulic pump (LEDC PWM) - hal/rudder_actuator_electric.h: reversible DC motor + deadband compensation - hal/rudder_actuator_factory.h: build-time type selection via AR_ACTUATOR_TYPE Tests: 483 passed (was 462, +21 Sprint 4) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
"""Project configurator widget — Sprint 4.
|
||||
|
||||
A scrollable form that lets the integrator (Engineer / Super Admin) create
|
||||
or edit a ``ProjectConfig`` and compile it into an ``.appack`` deployment
|
||||
package.
|
||||
|
||||
Layout
|
||||
------
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ PROJECT INFO client / project name / notes │
|
||||
│ VESSEL name / type / dimensions │
|
||||
│ ACTUATOR type / angle limit / deadband / … │
|
||||
│ SENSORS primary + optional redundant │
|
||||
│ PID GAINS inner + outer base (SA-gated) │
|
||||
│ ─────────────────────────────────────────────────── │
|
||||
│ [New] [Open…] [Save] [Compile .appack…] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
|
||||
RBAC gates
|
||||
----------
|
||||
- ``EDIT_COMMISSIONING`` (Engineer+) : actuator + sensor + rudder limit.
|
||||
- ``EDIT_BASE_GAINS`` (Super Admin) : PID base gains fields.
|
||||
- ``EDIT_COMMISSIONING`` (Engineer+) : Compile .appack button.
|
||||
- Any role can load and read a project; only the above roles may edit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from arautopilot.core.actuator_config import ActuatorConfig, ActuatorType
|
||||
from arautopilot.core.pid_config import PidConfig, PidGains
|
||||
from arautopilot.core.project_config import ProjectConfig
|
||||
from arautopilot.core.rbac import Capability
|
||||
from arautopilot.core.sensor_config import (
|
||||
DualRudderSensorConfig,
|
||||
RudderSensorConfig,
|
||||
RudderSensorType,
|
||||
)
|
||||
from arautopilot.core.vessel_config import VesselConfig, VesselType
|
||||
from arautopilot.studio.session import Session
|
||||
|
||||
|
||||
class ProjectEditorWidget(QWidget):
|
||||
"""Full project config editor — fills the 'Project' tab in the Studio."""
|
||||
|
||||
def __init__(self, session: Session, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._session = session
|
||||
self._project: ProjectConfig | None = None
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Scrollable form area
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget()
|
||||
form_layout = QVBoxLayout(content)
|
||||
form_layout.setContentsMargins(12, 12, 12, 12)
|
||||
form_layout.setSpacing(12)
|
||||
|
||||
self._build_project_info_group(form_layout)
|
||||
self._build_vessel_group(form_layout)
|
||||
self._build_actuator_group(form_layout)
|
||||
self._build_sensor_group(form_layout)
|
||||
self._build_pid_group(form_layout)
|
||||
form_layout.addStretch(1)
|
||||
|
||||
scroll.setWidget(content)
|
||||
root.addWidget(scroll, stretch=1)
|
||||
root.addWidget(self._build_toolbar())
|
||||
|
||||
self._apply_rbac()
|
||||
self._load_defaults()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Group builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_project_info_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Project Info")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
self._client_name = QLineEdit()
|
||||
self._client_name.setPlaceholderText("e.g. Astilleros del Sur S.A.")
|
||||
fl.addRow("Client name *", self._client_name)
|
||||
|
||||
self._project_name = QLineEdit()
|
||||
self._project_name.setPlaceholderText("e.g. M/Y Pacifica – Autopilot install")
|
||||
fl.addRow("Project name *", self._project_name)
|
||||
|
||||
self._notes = QTextEdit()
|
||||
self._notes.setPlaceholderText("Optional notes…")
|
||||
self._notes.setFixedHeight(72)
|
||||
fl.addRow("Notes", self._notes)
|
||||
|
||||
parent.addWidget(box)
|
||||
|
||||
def _build_vessel_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Vessel")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
self._vessel_name = QLineEdit()
|
||||
self._vessel_name.setPlaceholderText("e.g. Pacifica")
|
||||
fl.addRow("Vessel name *", self._vessel_name)
|
||||
|
||||
self._vessel_type = QComboBox()
|
||||
for vt in VesselType:
|
||||
self._vessel_type.addItem(vt.value.replace("_", " ").title(), vt)
|
||||
fl.addRow("Type", self._vessel_type)
|
||||
|
||||
self._length_m = QDoubleSpinBox()
|
||||
self._length_m.setRange(5.0, 200.0)
|
||||
self._length_m.setSuffix(" m")
|
||||
self._length_m.setDecimals(1)
|
||||
self._length_m.setValue(30.0)
|
||||
fl.addRow("Length overall", self._length_m)
|
||||
|
||||
self._displacement_t = QDoubleSpinBox()
|
||||
self._displacement_t.setRange(0.0, 10_000.0)
|
||||
self._displacement_t.setSuffix(" t")
|
||||
self._displacement_t.setDecimals(1)
|
||||
fl.addRow("Displacement (0 = unknown)", self._displacement_t)
|
||||
|
||||
self._max_speed_kn = QDoubleSpinBox()
|
||||
self._max_speed_kn.setRange(1.0, 80.0)
|
||||
self._max_speed_kn.setSuffix(" kn")
|
||||
self._max_speed_kn.setDecimals(1)
|
||||
self._max_speed_kn.setValue(18.0)
|
||||
fl.addRow("Max speed", self._max_speed_kn)
|
||||
|
||||
parent.addWidget(box)
|
||||
|
||||
def _build_actuator_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Rudder Actuator")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
self._act_type = QComboBox()
|
||||
phase1 = [
|
||||
ActuatorType.HYDRAULIC_REVERSIBLE,
|
||||
ActuatorType.ELECTRIC_DC_REVERSIBLE,
|
||||
ActuatorType.SERVOMOTOR_FEEDBACK,
|
||||
ActuatorType.STERNDRIVE_ANALOG,
|
||||
]
|
||||
for at in phase1:
|
||||
self._act_type.addItem(at.value.replace("_", " ").title(), at)
|
||||
fl.addRow("Type", self._act_type)
|
||||
|
||||
self._act_name = QLineEdit()
|
||||
self._act_name.setPlaceholderText("e.g. Hynautic K-21 + Capilano cylinder")
|
||||
fl.addRow("Model / label", self._act_name)
|
||||
|
||||
self._max_rudder_deg = QDoubleSpinBox()
|
||||
self._max_rudder_deg.setRange(15.0, 45.0)
|
||||
self._max_rudder_deg.setSuffix(" °")
|
||||
self._max_rudder_deg.setDecimals(1)
|
||||
self._max_rudder_deg.setValue(35.0)
|
||||
self._max_rudder_deg.setToolTip(
|
||||
"Software rudder angle limit. Must not exceed the mechanical stop.\n"
|
||||
"Requires Engineer or Super Admin to change."
|
||||
)
|
||||
fl.addRow("Rudder angle limit ⚠", self._max_rudder_deg)
|
||||
|
||||
self._deadband_pct = QDoubleSpinBox()
|
||||
self._deadband_pct.setRange(0.0, 30.0)
|
||||
self._deadband_pct.setSuffix(" %")
|
||||
self._deadband_pct.setDecimals(1)
|
||||
self._deadband_pct.setValue(5.0)
|
||||
fl.addRow("Deadband", self._deadband_pct)
|
||||
|
||||
self._max_rate_dps = QDoubleSpinBox()
|
||||
self._max_rate_dps.setRange(0.5, 15.0)
|
||||
self._max_rate_dps.setSuffix(" °/s")
|
||||
self._max_rate_dps.setDecimals(1)
|
||||
self._max_rate_dps.setValue(4.5)
|
||||
fl.addRow("Max slew rate", self._max_rate_dps)
|
||||
|
||||
self._max_current_a = QDoubleSpinBox()
|
||||
self._max_current_a.setRange(1.0, 200.0)
|
||||
self._max_current_a.setSuffix(" A")
|
||||
self._max_current_a.setDecimals(1)
|
||||
self._max_current_a.setValue(15.0)
|
||||
fl.addRow("Overcurrent limit", self._max_current_a)
|
||||
|
||||
parent.addWidget(box)
|
||||
self._actuator_box = box
|
||||
|
||||
def _build_sensor_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("Rudder Angle Sensors")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
fl.addRow(QLabel("<b>Primary sensor</b>"))
|
||||
|
||||
self._sens_primary_type = QComboBox()
|
||||
for st in RudderSensorType:
|
||||
self._sens_primary_type.addItem(st.value.replace("_", " ").upper(), st)
|
||||
fl.addRow("Type", self._sens_primary_type)
|
||||
|
||||
self._sens_primary_cs = QSpinBox()
|
||||
self._sens_primary_cs.setRange(0, 39)
|
||||
self._sens_primary_cs.setValue(10)
|
||||
self._sens_primary_cs.setToolTip("ESP32 GPIO used as SPI CS for AS5048A")
|
||||
fl.addRow("SPI CS GPIO", self._sens_primary_cs)
|
||||
|
||||
self._sens_primary_fsd = QDoubleSpinBox()
|
||||
self._sens_primary_fsd.setRange(1.0, 45.0)
|
||||
self._sens_primary_fsd.setSuffix(" °")
|
||||
self._sens_primary_fsd.setDecimals(1)
|
||||
self._sens_primary_fsd.setValue(35.0)
|
||||
fl.addRow("Full-scale angle", self._sens_primary_fsd)
|
||||
|
||||
fl.addRow(QLabel(""))
|
||||
fl.addRow(QLabel("<b>Redundant sensor (optional)</b>"))
|
||||
|
||||
self._sens_redundant_enabled = QCheckBox("Enable redundant sensor")
|
||||
fl.addRow("", self._sens_redundant_enabled)
|
||||
|
||||
self._sens_redundant_type = QComboBox()
|
||||
for st in RudderSensorType:
|
||||
self._sens_redundant_type.addItem(st.value.replace("_", " ").upper(), st)
|
||||
fl.addRow("Type", self._sens_redundant_type)
|
||||
|
||||
self._sens_redundant_cs = QSpinBox()
|
||||
self._sens_redundant_cs.setRange(0, 39)
|
||||
self._sens_redundant_cs.setValue(11)
|
||||
fl.addRow("SPI CS GPIO", self._sens_redundant_cs)
|
||||
|
||||
self._sens_redundant_fsd = QDoubleSpinBox()
|
||||
self._sens_redundant_fsd.setRange(1.0, 45.0)
|
||||
self._sens_redundant_fsd.setSuffix(" °")
|
||||
self._sens_redundant_fsd.setDecimals(1)
|
||||
self._sens_redundant_fsd.setValue(35.0)
|
||||
fl.addRow("Full-scale angle", self._sens_redundant_fsd)
|
||||
|
||||
self._sens_diverge_alarm = QDoubleSpinBox()
|
||||
self._sens_diverge_alarm.setRange(0.5, 15.0)
|
||||
self._sens_diverge_alarm.setSuffix(" °")
|
||||
self._sens_diverge_alarm.setDecimals(1)
|
||||
self._sens_diverge_alarm.setValue(3.0)
|
||||
fl.addRow("Divergence alarm threshold", self._sens_diverge_alarm)
|
||||
|
||||
self._sens_diverge_failover = QDoubleSpinBox()
|
||||
self._sens_diverge_failover.setRange(1.0, 20.0)
|
||||
self._sens_diverge_failover.setSuffix(" °")
|
||||
self._sens_diverge_failover.setDecimals(1)
|
||||
self._sens_diverge_failover.setValue(6.0)
|
||||
fl.addRow("Divergence failover threshold", self._sens_diverge_failover)
|
||||
|
||||
self._sens_redundant_enabled.toggled.connect(self._on_redundant_toggled)
|
||||
self._on_redundant_toggled(False)
|
||||
|
||||
parent.addWidget(box)
|
||||
self._sensor_box = box
|
||||
|
||||
def _build_pid_group(self, parent: QVBoxLayout) -> None:
|
||||
box = QGroupBox("PID Base Gains [Super Admin only]")
|
||||
fl = QFormLayout(box)
|
||||
|
||||
fl.addRow(QLabel("<b>Inner loop (rudder position, 50 Hz)</b>"))
|
||||
self._inner_kp = self._gain_spin("Inner Kp", fl)
|
||||
self._inner_ki = self._gain_spin("Inner Ki", fl)
|
||||
self._inner_kd = self._gain_spin("Inner Kd", fl)
|
||||
|
||||
fl.addRow(QLabel(""))
|
||||
fl.addRow(QLabel("<b>Outer loop (heading, 10 Hz)</b>"))
|
||||
self._outer_kp = self._gain_spin("Outer Kp", fl)
|
||||
self._outer_ki = self._gain_spin("Outer Ki", fl)
|
||||
self._outer_kd = self._gain_spin("Outer Kd", fl)
|
||||
|
||||
fl.addRow(QLabel(""))
|
||||
self._rot_ff = self._gain_spin("ROT feed-forward", fl)
|
||||
|
||||
parent.addWidget(box)
|
||||
self._pid_box = box
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Toolbar
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_toolbar(self) -> QWidget:
|
||||
bar = QWidget()
|
||||
bar.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||||
h = QHBoxLayout(bar)
|
||||
h.setContentsMargins(8, 4, 8, 4)
|
||||
|
||||
btn_new = QPushButton("New")
|
||||
btn_new.clicked.connect(self._on_new)
|
||||
h.addWidget(btn_new)
|
||||
|
||||
btn_open = QPushButton("Open…")
|
||||
btn_open.clicked.connect(self._on_open)
|
||||
h.addWidget(btn_open)
|
||||
|
||||
btn_save = QPushButton("Save")
|
||||
btn_save.clicked.connect(self._on_save)
|
||||
h.addWidget(btn_save)
|
||||
|
||||
h.addStretch(1)
|
||||
|
||||
self._btn_compile = QPushButton("Compile .appack…")
|
||||
self._btn_compile.setToolTip("Generate deployment package (Engineer+)")
|
||||
self._btn_compile.clicked.connect(self._on_compile)
|
||||
h.addWidget(self._btn_compile)
|
||||
|
||||
return bar
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RBAC
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _apply_rbac(self) -> None:
|
||||
can_commission = self._session.has(Capability.EDIT_COMMISSIONING)
|
||||
can_gains = self._session.has(Capability.EDIT_BASE_GAINS)
|
||||
can_compile = self._session.has(Capability.EDIT_COMMISSIONING)
|
||||
|
||||
for w in [
|
||||
self._max_rudder_deg,
|
||||
self._deadband_pct,
|
||||
self._max_rate_dps,
|
||||
self._max_current_a,
|
||||
self._act_type,
|
||||
self._act_name,
|
||||
self._sens_primary_type,
|
||||
self._sens_primary_cs,
|
||||
self._sens_primary_fsd,
|
||||
self._sens_redundant_enabled,
|
||||
self._sens_redundant_type,
|
||||
self._sens_redundant_cs,
|
||||
self._sens_redundant_fsd,
|
||||
self._sens_diverge_alarm,
|
||||
self._sens_diverge_failover,
|
||||
]:
|
||||
w.setEnabled(can_commission)
|
||||
|
||||
for w in [
|
||||
self._inner_kp,
|
||||
self._inner_ki,
|
||||
self._inner_kd,
|
||||
self._outer_kp,
|
||||
self._outer_ki,
|
||||
self._outer_kd,
|
||||
self._rot_ff,
|
||||
]:
|
||||
w.setEnabled(can_gains)
|
||||
|
||||
self._btn_compile.setEnabled(can_compile)
|
||||
|
||||
if not can_gains:
|
||||
self._pid_box.setTitle("PID Base Gains [Super Admin only — read-only]")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Slots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _on_redundant_toggled(self, enabled: bool) -> None:
|
||||
for w in [
|
||||
self._sens_redundant_type,
|
||||
self._sens_redundant_cs,
|
||||
self._sens_redundant_fsd,
|
||||
self._sens_diverge_alarm,
|
||||
self._sens_diverge_failover,
|
||||
]:
|
||||
w.setEnabled(enabled and self._session.has(Capability.EDIT_COMMISSIONING))
|
||||
|
||||
def _on_new(self) -> None:
|
||||
self._project = None
|
||||
self._load_defaults()
|
||||
|
||||
def _on_open(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Open project",
|
||||
str(Path.home()),
|
||||
"AR-Autopilot Project (*.yaml *.yml *.json);;All files (*)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
self._project = ProjectConfig.load(path)
|
||||
self._populate_from_project(self._project)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Load error", str(exc))
|
||||
|
||||
def _on_save(self) -> None:
|
||||
try:
|
||||
project = self._build_project()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Validation error", str(exc))
|
||||
return
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save project",
|
||||
str(Path.home() / f"{project.project_name}.yaml"),
|
||||
"AR-Autopilot Project (*.yaml);;JSON (*.json)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
project.save_yaml(path) if path.endswith((".yaml", ".yml")) else project.save_json(path)
|
||||
self._project = project
|
||||
QMessageBox.information(self, "Saved", f"Project saved to:\n{path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Save error", str(exc))
|
||||
|
||||
def _on_compile(self) -> None:
|
||||
if not self._session.has(Capability.EDIT_COMMISSIONING):
|
||||
QMessageBox.warning(self, "Access denied", "Engineer or Super Admin required.")
|
||||
return
|
||||
|
||||
try:
|
||||
project = self._build_project()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Validation error", str(exc))
|
||||
return
|
||||
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save .appack",
|
||||
str(Path.home() / f"{project.project_name}.appack"),
|
||||
"AR-Autopilot Package (*.appack)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
from arautopilot.studio.compiler.appack import AppackCompiler
|
||||
compiler = AppackCompiler(project)
|
||||
out = compiler.compile(Path(path))
|
||||
self._session.audit.record(
|
||||
actor=self._session.user.username,
|
||||
action="compile_appack",
|
||||
detail=f"project={project.project_id} output={out}",
|
||||
)
|
||||
QMessageBox.information(
|
||||
self, "Compiled", f".appack written to:\n{out}"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
QMessageBox.critical(self, "Compile error", str(exc))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Build / populate helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_project(self) -> ProjectConfig:
|
||||
redundant: RudderSensorConfig | None = None
|
||||
if self._sens_redundant_enabled.isChecked():
|
||||
redundant = RudderSensorConfig(
|
||||
type=self._sens_redundant_type.currentData(),
|
||||
label="Redundant – actuator arm",
|
||||
spi_cs_gpio=self._sens_redundant_cs.value(),
|
||||
full_scale_deg=self._sens_redundant_fsd.value(),
|
||||
divergence_alarm_deg=self._sens_diverge_alarm.value(),
|
||||
divergence_failover_deg=self._sens_diverge_failover.value(),
|
||||
)
|
||||
|
||||
vessel = VesselConfig(
|
||||
name=self._vessel_name.text().strip() or "Unnamed vessel",
|
||||
type=self._vessel_type.currentData(),
|
||||
length_m=self._length_m.value(),
|
||||
displacement_t=self._displacement_t.value(),
|
||||
max_speed_kn=self._max_speed_kn.value(),
|
||||
actuator=ActuatorConfig(
|
||||
type=self._act_type.currentData(),
|
||||
name=self._act_name.text().strip(),
|
||||
max_rudder_angle_deg=self._max_rudder_deg.value(),
|
||||
deadband_pct=self._deadband_pct.value(),
|
||||
max_rate_dps=self._max_rate_dps.value(),
|
||||
max_current_a=self._max_current_a.value(),
|
||||
),
|
||||
pid=PidConfig(
|
||||
inner_loop_base=PidGains(
|
||||
kp=self._inner_kp.value(),
|
||||
ki=self._inner_ki.value(),
|
||||
kd=self._inner_kd.value(),
|
||||
),
|
||||
outer_loop_base=PidGains(
|
||||
kp=self._outer_kp.value(),
|
||||
ki=self._outer_ki.value(),
|
||||
kd=self._outer_kd.value(),
|
||||
),
|
||||
rot_feedforward_gain=self._rot_ff.value(),
|
||||
),
|
||||
sensors=DualRudderSensorConfig(
|
||||
primary=RudderSensorConfig(
|
||||
type=self._sens_primary_type.currentData(),
|
||||
label="Primary – rudder stock",
|
||||
spi_cs_gpio=self._sens_primary_cs.value(),
|
||||
full_scale_deg=self._sens_primary_fsd.value(),
|
||||
),
|
||||
redundant=redundant,
|
||||
),
|
||||
)
|
||||
|
||||
existing_id = self._project.project_id if self._project else None
|
||||
project = ProjectConfig(
|
||||
client_name=self._client_name.text().strip() or "Unknown client",
|
||||
project_name=self._project_name.text().strip() or "Unnamed project",
|
||||
notes=self._notes.toPlainText().strip(),
|
||||
vessel=vessel,
|
||||
)
|
||||
if existing_id is not None:
|
||||
object.__setattr__(project, "project_id", existing_id)
|
||||
return project
|
||||
|
||||
def _populate_from_project(self, p: ProjectConfig) -> None:
|
||||
self._client_name.setText(p.client_name)
|
||||
self._project_name.setText(p.project_name)
|
||||
self._notes.setPlainText(p.notes)
|
||||
|
||||
v = p.vessel
|
||||
self._vessel_name.setText(v.name)
|
||||
_set_combo(self._vessel_type, v.type)
|
||||
self._length_m.setValue(v.length_m)
|
||||
self._displacement_t.setValue(v.displacement_t)
|
||||
self._max_speed_kn.setValue(v.max_speed_kn)
|
||||
|
||||
a = v.actuator
|
||||
_set_combo(self._act_type, a.type)
|
||||
self._act_name.setText(a.name)
|
||||
self._max_rudder_deg.setValue(a.max_rudder_angle_deg)
|
||||
self._deadband_pct.setValue(a.deadband_pct)
|
||||
self._max_rate_dps.setValue(a.max_rate_dps)
|
||||
self._max_current_a.setValue(a.max_current_a)
|
||||
|
||||
sp = v.sensors.primary
|
||||
_set_combo(self._sens_primary_type, sp.type)
|
||||
self._sens_primary_cs.setValue(sp.spi_cs_gpio)
|
||||
self._sens_primary_fsd.setValue(sp.full_scale_deg)
|
||||
|
||||
sr = v.sensors.redundant
|
||||
self._sens_redundant_enabled.setChecked(sr is not None)
|
||||
if sr is not None:
|
||||
_set_combo(self._sens_redundant_type, sr.type)
|
||||
self._sens_redundant_cs.setValue(sr.spi_cs_gpio)
|
||||
self._sens_redundant_fsd.setValue(sr.full_scale_deg)
|
||||
self._sens_diverge_alarm.setValue(sr.divergence_alarm_deg)
|
||||
self._sens_diverge_failover.setValue(sr.divergence_failover_deg)
|
||||
|
||||
pid = v.pid
|
||||
self._inner_kp.setValue(pid.inner_loop_base.kp)
|
||||
self._inner_ki.setValue(pid.inner_loop_base.ki)
|
||||
self._inner_kd.setValue(pid.inner_loop_base.kd)
|
||||
self._outer_kp.setValue(pid.outer_loop_base.kp)
|
||||
self._outer_ki.setValue(pid.outer_loop_base.ki)
|
||||
self._outer_kd.setValue(pid.outer_loop_base.kd)
|
||||
self._rot_ff.setValue(pid.rot_feedforward_gain)
|
||||
|
||||
def _load_defaults(self) -> None:
|
||||
self._client_name.clear()
|
||||
self._project_name.clear()
|
||||
self._notes.clear()
|
||||
self._vessel_name.clear()
|
||||
self._vessel_type.setCurrentIndex(0)
|
||||
self._length_m.setValue(30.0)
|
||||
self._displacement_t.setValue(0.0)
|
||||
self._max_speed_kn.setValue(18.0)
|
||||
self._act_type.setCurrentIndex(0)
|
||||
self._act_name.clear()
|
||||
self._max_rudder_deg.setValue(35.0)
|
||||
self._deadband_pct.setValue(5.0)
|
||||
self._max_rate_dps.setValue(4.5)
|
||||
self._max_current_a.setValue(15.0)
|
||||
self._sens_primary_type.setCurrentIndex(0)
|
||||
self._sens_primary_cs.setValue(10)
|
||||
self._sens_primary_fsd.setValue(35.0)
|
||||
self._sens_redundant_enabled.setChecked(False)
|
||||
self._sens_redundant_cs.setValue(11)
|
||||
self._sens_redundant_fsd.setValue(35.0)
|
||||
self._sens_diverge_alarm.setValue(3.0)
|
||||
self._sens_diverge_failover.setValue(6.0)
|
||||
self._inner_kp.setValue(1.5)
|
||||
self._inner_ki.setValue(0.1)
|
||||
self._inner_kd.setValue(0.05)
|
||||
self._outer_kp.setValue(2.0)
|
||||
self._outer_ki.setValue(0.05)
|
||||
self._outer_kd.setValue(0.3)
|
||||
self._rot_ff.setValue(0.4)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _gain_spin(label: str, fl: QFormLayout) -> QDoubleSpinBox:
|
||||
spin = QDoubleSpinBox()
|
||||
spin.setRange(0.0, 100.0)
|
||||
spin.setDecimals(4)
|
||||
spin.setSingleStep(0.01)
|
||||
fl.addRow(label, spin)
|
||||
return spin
|
||||
|
||||
|
||||
def _set_combo(combo: QComboBox, value: object) -> None:
|
||||
for i in range(combo.count()):
|
||||
if combo.itemData(i) == value:
|
||||
combo.setCurrentIndex(i)
|
||||
return
|
||||
Reference in New Issue
Block a user