"""Rudder angle sensor configuration. Supports single and dual (redundant) sensor setups. The primary sensor is mandatory for closed-loop operation. The redundant sensor is optional but strongly recommended for Phase-1 marine deployments. Sensor families supported in Phase 1: - ``AS5048A_SPI`` — contactless magnetic absolute encoder, 14-bit, SPI. Recommended for new installations (brief session 2026-05-18). - ``POTENTIOMETER`` — resistive potentiometer 0-10 V or 4-20 mA. Supported for legacy actuators. Both sensors use a polycarbonate linkage arm to the rudder stock, giving a proportional rotation to the rudder angle. """ from __future__ import annotations from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field class RudderSensorType(StrEnum): AS5048A_SPI = "as5048a_spi" """AMS AS5048A contactless magnetic encoder — 14-bit, SPI interface. No mechanical contact; requires diametrically magnetised 6 mm magnet on the rotating shaft. Recommended for all new Phase-1 installations.""" POTENTIOMETER = "potentiometer" """Resistive potentiometer with 0-10 V or 4-20 mA signal conditioning. Suitable for retrofit on existing actuators that already carry a pot.""" class RudderSensorConfig(BaseModel): """Configuration for one physical rudder angle sensor. The ``full_scale_deg`` / ``zero_offset_deg`` pair maps the electrical range of the sensor to real rudder degrees. Calibration during commissioning (Sprint 7) will update these values in NVS. """ model_config = ConfigDict(extra="forbid", validate_assignment=True) type: RudderSensorType """Physical sensor technology.""" label: str = Field( default="", max_length=60, description="Free-form label, e.g. 'Primary – rudder stock' or 'Redundant – actuator arm'.", ) # SPI-specific (ignored for POTENTIOMETER) spi_cs_gpio: int = Field( default=10, ge=0, le=39, description="ESP32 GPIO pin used as SPI chip-select for AS5048A.", ) # Calibration full_scale_deg: float = Field( default=35.0, gt=0.0, le=45.0, description="Rudder angle (degrees) corresponding to full electrical output.", ) zero_offset_deg: float = Field( default=0.0, ge=-10.0, le=10.0, description="Offset applied after scaling to correct mechanical amidships misalignment.", ) # Cross-validation (used by dual-sensor arbitrator) divergence_alarm_deg: float = Field( default=3.0, gt=0.0, le=15.0, description="Alarm threshold: raise SENSOR_DIVERGE if |A - B| exceeds this value.", ) divergence_failover_deg: float = Field( default=6.0, gt=0.0, le=20.0, description="Failover threshold: switch to the other sensor if |A - B| exceeds this.", ) class DualRudderSensorConfig(BaseModel): """Primary + optional redundant rudder sensor pair. When ``redundant`` is ``None`` the system operates in single-sensor mode (feedback_required still enforced). When ``redundant`` is present the firmware enables cross-validation and automatic failover. Divergence thresholds are taken from the *primary* sensor's config so there is one canonical place to tune them. """ model_config = ConfigDict(extra="forbid", validate_assignment=True) primary: RudderSensorConfig redundant: RudderSensorConfig | None = None @property def has_redundancy(self) -> bool: return self.redundant is not None