"""Rudder actuator configuration. Supports the actuator families enumerated in section 3 of the brief. Sterndrive, IPS and Zeus interfaces are reserved for Phase 3 and are intentionally **not** enabled in Phase 1 configurations. """ from __future__ import annotations from enum import Enum from pydantic import BaseModel, ConfigDict, Field, field_validator class ActuatorType(str, Enum): """Actuator family driving the rudder. Phase-1 supported families have a regular value. Reserved (Phase 3) families also live here so that configurations and the UI can model them, but the firmware refuses to operate them in Phase 1. """ HYDRAULIC_REVERSIBLE = "hydraulic_reversible" """Reversible hydraulic pump (Hynautic, Hypro, Octopus, Vetus, L&S). Phase 1.""" ELECTRIC_DC_REVERSIBLE = "electric_dc_reversible" """Reversible DC motor with mechanical end-stops (Lewmar, Simpson Lawrence). Phase 1.""" SERVOMOTOR_FEEDBACK = "servomotor_feedback" """Servomotor with built-in position feedback. Phase 1.""" STERNDRIVE_ANALOG = "sterndrive_analog" """Analog directional sterndrive. Phase 1.""" VOLVO_IPS = "volvo_ips" """Volvo IPS via EVC. Phase 3 — reserved.""" MERCURY_ZEUS = "mercury_zeus" """Mercury Zeus via SmartCraft. Phase 3 — reserved.""" _PHASE_1_ACTUATORS: frozenset[ActuatorType] = frozenset( { ActuatorType.HYDRAULIC_REVERSIBLE, ActuatorType.ELECTRIC_DC_REVERSIBLE, ActuatorType.SERVOMOTOR_FEEDBACK, ActuatorType.STERNDRIVE_ANALOG, } ) def is_phase_1(actuator_type: ActuatorType) -> bool: """Return ``True`` iff the firmware is allowed to drive this actuator in Phase 1.""" return actuator_type in _PHASE_1_ACTUATORS class ActuatorConfig(BaseModel): """Configuration of the rudder actuator and its calibration. All angles are in degrees. ``deadband_pct`` and ``min_useful_pwm_pct`` are percentages of full command. Asymmetry is the ratio of starboard speed to port speed: ``1.0`` means symmetric, ``1.10`` means the pump pushes 10 % faster to starboard than to port. """ model_config = ConfigDict(extra="forbid", validate_assignment=True) type: ActuatorType """Actuator family.""" name: str = Field( default="", max_length=120, description="Free-form label, e.g. 'Hynautic K-21 + Capilano cylinder'.", ) # -- Calibration / non-linearity compensation ------------------------------ deadband_pct: float = Field( default=5.0, ge=0.0, le=30.0, description="Initial percentage of command that produces no motion (static friction).", ) min_useful_pwm_pct: float = Field( default=8.0, ge=0.0, le=50.0, description="Minimum PWM where the actuator actually moves; commands below this snap up to it.", ) asymmetry_stbd_over_port: float = Field( default=1.0, ge=0.50, le=2.00, description="Ratio of starboard speed to port speed; 1.0 = symmetric.", ) # -- Mechanical / electrical limits --------------------------------------- max_rudder_angle_deg: float = Field( default=35.0, gt=0.0, le=45.0, description="Mechanical limit on either side from amidships; typically 35 deg.", ) max_rate_dps: float = Field( default=4.5, gt=0.0, le=15.0, description="Maximum slew rate in degrees per second (typical 3-6 dps).", ) max_current_a: float = Field( default=15.0, gt=0.0, le=200.0, description="Overcurrent trip threshold (A) for the actuator power line.", ) # -- Safety policy -------------------------------------------------------- feedback_required: bool = Field( default=True, description=( "Closed loop only. Open-loop operation requires explicit integrator " "override (brief section 3, Phase 1). Default True." ), ) @field_validator("min_useful_pwm_pct") @classmethod def _min_useful_must_cover_deadband(cls, v: float, info: object) -> float: # info.data is provided by pydantic-core for cross-field validation data = getattr(info, "data", {}) or {} deadband = data.get("deadband_pct") if deadband is not None and v < deadband: raise ValueError( f"min_useful_pwm_pct ({v}) must be >= deadband_pct ({deadband})" ) return v def is_phase_1_supported(self) -> bool: """Return ``True`` iff this actuator can be driven in Phase 1.""" return is_phase_1(self.type)