"""Relay-feedback autotuning (Åström-Hägglund). The relay experiment replaces the controller with an on/off relay, driving the loop into a controlled limit-cycle oscillation. Describing-function analysis converts the oscillation amplitude ``a`` and period ``Pu`` into an estimate of the ultimate gain:: Ku ≈ 4·d / (π·a) (ideal relay of amplitude d) Ku ≈ 4·d / (π·sqrt(a² − ε²)) (relay with hysteresis ε) after which any ultimate-cycle rule (ZN, Tyreus-Luyben) yields gains. References ---------- * K. J. Åström and T. Hägglund, "Automatic tuning of simple regulators with specifications on phase and amplitude margins", *Automatica*, 20, 645–651, 1984. * Survey context: ``docs/design/03_tuning_methods.md`` §6. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, ClassVar, Mapping, Tuple import numpy as np from pidtune.models.base import ProcessModel from pidtune.simulation.plant import Plant from pidtune.tuners.base import ControllerType, Tuner, TuningResult, register_tuner __all__ = ["RelayConfig", "RelayExperimentResult", "RelayAutotuner"] @dataclass(frozen=True) class RelayConfig: """Configuration of a relay-feedback experiment. Attributes ---------- amplitude: Relay output amplitude ``d`` (control signal switches between ``bias ± amplitude``). Must be positive. hysteresis: Relay hysteresis ``ε`` in output units; the relay switches only when the error magnitude exceeds ``ε``. Use a value comfortably above the measurement noise band. Must be ≥ 0. bias: Control-signal bias the relay switches around (operating point). setpoint: Process-output level the experiment oscillates about. dt: Sampling interval of the experiment. Must be positive. max_time: Hard time limit; exceeding it without convergence raises :class:`~pidtune.exceptions.ConvergenceError`. min_cycles / amplitude_tolerance: Convergence criterion: the experiment ends once ``min_cycles`` consecutive oscillation cycles agree in amplitude and period to within ``amplitude_tolerance`` (relative). asymmetric: If true, use an asymmetric relay (different up/down amplitudes, ratio 1.5:1) which excites the process enough to additionally estimate a full FOPDT model, not just (Ku, Pu). """ amplitude: float = 1.0 hysteresis: float = 0.0 bias: float = 0.0 setpoint: float = 0.0 dt: float = 0.01 max_time: float = 1000.0 min_cycles: int = 4 amplitude_tolerance: float = 0.05 asymmetric: bool = False @dataclass(frozen=True) class RelayExperimentResult: """Outcome of a relay-feedback experiment. Attributes ---------- ku: Estimated ultimate gain from describing-function analysis. pu: Estimated ultimate period (mean of converged cycles). oscillation_amplitude: Mean peak-to-mean output amplitude ``a`` of the converged cycles. cycles_used: Number of converged cycles included in the estimates. time / output / control: Full recorded trajectories of the experiment (equal-length 1-D arrays) for inspection and plotting. config: The :class:`RelayConfig` the experiment ran with. metadata: Extras such as the FOPDT estimate from an asymmetric relay (``{"fopdt": {...}}``) when available. """ ku: float pu: float oscillation_amplitude: float cycles_used: int time: np.ndarray output: np.ndarray control: np.ndarray config: RelayConfig metadata: Mapping[str, Any] = field(default_factory=dict) @register_tuner class RelayAutotuner(Tuner): """End-to-end relay autotuning against a plant or process model. Three usage patterns: 1. **Model in, gains out** — :meth:`tune` wraps the model in a simulated :class:`~pidtune.simulation.plant.Plant`, runs the relay experiment, and applies the chosen ultimate-cycle rule. 2. **Hardware in the loop** — :meth:`run_experiment` accepts *any* :class:`~pidtune.simulation.plant.Plant` implementation, including a user adapter wrapping real I/O, and returns a :class:`RelayExperimentResult`. 3. **Experiment in, gains out** — :meth:`tune_from_experiment` maps a previously obtained result to gains, so the (slow, physical) experiment need not be repeated to try different rules. ``TuningResult.metadata`` keys: ``{"ku", "pu", "oscillation_amplitude", "cycles_used", "rule"}``. """ name: ClassVar[str] = "relay" supported_controller_types: ClassVar[frozenset] = frozenset( {ControllerType.PI, ControllerType.PID} ) def __init__(self, *, config: RelayConfig | None = None, rule: str = "zn-ultimate") -> None: """Configure the autotuner. Parameters ---------- config: Relay experiment configuration; ``None`` uses ``RelayConfig()`` defaults. rule: Registry name of the ultimate-cycle rule used to map (Ku, Pu) to gains: ``"zn-ultimate"`` (default) or ``"tyreus-luyben"``. """ self.config = config if config is not None else RelayConfig() self.rule = rule def run_experiment(self, plant: Plant) -> RelayExperimentResult: """Run the relay-feedback experiment against ``plant``. The plant is reset, then driven by the hysteresis relay at the configured sampling interval until the oscillation converges (see :class:`RelayConfig`) or ``max_time`` elapses. Raises ------ pidtune.exceptions.ConvergenceError If a sustained, converged oscillation is not reached within ``config.max_time`` (e.g. overdamped process with too small a relay amplitude, or hysteresis larger than the achievable oscillation). """ raise NotImplementedError("Implemented in Milestone 5.") def tune( self, model: ProcessModel, *, controller_type: ControllerType = ControllerType.PID, **options: Any, ) -> TuningResult: """Simulate a relay experiment on ``model`` and return gains. Equivalent to wrapping ``model`` with :func:`pidtune.simulation.plant.plant_from_model`, calling :meth:`run_experiment`, then :meth:`tune_from_experiment`. """ raise NotImplementedError("Implemented in Milestone 5.") def tune_from_experiment( self, experiment: RelayExperimentResult, *, controller_type: ControllerType = ControllerType.PID, ) -> TuningResult: """Map a completed relay experiment to controller gains. Applies the configured ultimate-cycle ``rule`` to ``experiment.ku`` and ``experiment.pu``. The returned result has ``model=None`` (unless the experiment metadata carries a FOPDT estimate, which is then attached). """ raise NotImplementedError("Implemented in Milestone 5.")