"""Ziegler-Nichols tuning rules (step/reaction-curve and ultimate-cycle forms). References ---------- * J. G. Ziegler and N. B. Nichols, "Optimum Settings for Automatic Controllers", *Trans. ASME*, 64, 759–768, 1942. * B. D. Tyreus and W. L. Luyben, "Tuning PI Controllers for Integrator/Dead Time Processes", *Ind. Eng. Chem. Res.*, 31, 2625–2628, 1992. Survey context: ``docs/design/03_tuning_methods.md`` §2. """ from __future__ import annotations from typing import Any, ClassVar from pidtune.models.base import ProcessModel from pidtune.models.fopdt import FOPDT from pidtune.tuners.base import ControllerType, Tuner, TuningResult, register_tuner __all__ = [ "ZieglerNicholsStepTuner", "ZieglerNicholsUltimateTuner", "TyreusLuybenTuner", ] @register_tuner class ZieglerNicholsStepTuner(Tuner): """Open-loop (reaction-curve) Ziegler-Nichols tuning from a FOPDT model. Given a FOPDT model ``K * exp(-θs) / (τs + 1)``, define ``a = K·θ/τ``. The classic rules are: ========== ========= ========== ========= Controller Kp Ti Td ========== ========= ========== ========= P 1 / a — — PI 0.9 / a 3·θ — PID 1.2 / a 2·θ θ / 2 ========== ========= ========== ========= Applicability ------------- Recommended for ``0.1 ≤ θ/τ ≤ 1``. Outside this range the rule still computes but a :class:`~pidtune.exceptions.TuningApplicabilityWarning` is emitted; the resulting loops are typically oscillatory (the rules target quarter-amplitude damping, which many applications find too aggressive — see AMIGO and SIMC for gentler defaults). Raises ``TuningError`` for ``θ == 0`` (the table divides by ``a``). ``TuningResult.metadata`` keys: ``{"a": float, "theta_over_tau": float}``. """ name: ClassVar[str] = "zn-step" supported_controller_types: ClassVar[frozenset] = frozenset( {ControllerType.P, ControllerType.PI, ControllerType.PID} ) def supports(self, model: ProcessModel) -> bool: """Accept only :class:`~pidtune.models.fopdt.FOPDT` models.""" return isinstance(model, FOPDT) def tune( self, model: ProcessModel, *, controller_type: ControllerType = ControllerType.PID, **options: Any, ) -> TuningResult: """Apply the reaction-curve table to a FOPDT model. Parameters ---------- model: A :class:`~pidtune.models.fopdt.FOPDT` instance with ``θ > 0``. controller_type: One of P, PI, PID. **options: No options are accepted; any keyword raises :class:`TypeError`. """ raise NotImplementedError("Implemented in Milestone 3.") @register_tuner class ZieglerNicholsUltimateTuner(Tuner): """Closed-loop (ultimate-cycle) Ziegler-Nichols tuning. Based on the ultimate gain ``Ku`` (proportional gain at which the loop sustains constant-amplitude oscillation) and the ultimate period ``Pu``: ========== ========== ========== ========= Controller Kp Ti Td ========== ========== ========== ========= P 0.50·Ku — — PI 0.45·Ku Pu / 1.2 — PID 0.60·Ku Pu / 2 Pu / 8 ========== ========== ========== ========= Two entry points are provided: * :meth:`tune` computes (Ku, Pu) analytically from the model's frequency response (phase crossover at -180°) and then applies the table. Raises ``TuningError`` if the model has no phase crossover (e.g. first-order with no delay — infinite gain margin). * :meth:`from_ultimate` applies the table directly to experimentally obtained (Ku, Pu), e.g. from a relay test (:mod:`pidtune.tuners.relay`), with no model required. ``TuningResult.metadata`` keys: ``{"ku": float, "pu": float}``. """ name: ClassVar[str] = "zn-ultimate" supported_controller_types: ClassVar[frozenset] = frozenset( {ControllerType.P, ControllerType.PI, ControllerType.PID} ) def tune( self, model: ProcessModel, *, controller_type: ControllerType = ControllerType.PID, **options: Any, ) -> TuningResult: """Locate the phase-crossover point of ``model`` and apply the table. The crossover frequency is found by bisection on the phase of ``model.frequency_response`` over a logarithmic frequency grid; ``Ku = 1/|G(jω_180)|`` and ``Pu = 2π/ω_180``. Raises ------ pidtune.exceptions.TuningError If no -180° crossing exists in the searched frequency range. """ raise NotImplementedError("Implemented in Milestone 3.") @classmethod def from_ultimate( cls, ku: float, pu: float, *, controller_type: ControllerType = ControllerType.PID, ) -> TuningResult: """Apply the ZN ultimate table to measured ``Ku`` and ``Pu``. Parameters ---------- ku: Ultimate gain, must be positive and finite. pu: Ultimate period in the model's time unit, must be positive. Returns ------- TuningResult With ``model=None`` and ``metadata={"ku": ku, "pu": pu}``. """ raise NotImplementedError("Implemented in Milestone 3.") @register_tuner class TyreusLuybenTuner(Tuner): """Tyreus-Luyben ultimate-cycle rules — a gentler ZN variant. Uses the same (Ku, Pu) inputs as :class:`ZieglerNicholsUltimateTuner` but with conservative constants suited to integrating and lag-dominant processes: ========== ========== ========== ========= Controller Kp Ti Td ========== ========== ========== ========= PI Ku / 3.2 2.2·Pu — PID Ku / 2.2 2.2·Pu Pu / 6.3 ========== ========== ========== ========= Shares :meth:`from_ultimate` semantics with the ZN ultimate tuner. ``TuningResult.metadata`` keys: ``{"ku": float, "pu": float}``. """ name: ClassVar[str] = "tyreus-luyben" supported_controller_types: ClassVar[frozenset] = frozenset( {ControllerType.PI, ControllerType.PID} ) def tune( self, model: ProcessModel, *, controller_type: ControllerType = ControllerType.PID, **options: Any, ) -> TuningResult: """Compute (Ku, Pu) from the model frequency response, apply the TL table. See :meth:`ZieglerNicholsUltimateTuner.tune` for crossover-location semantics and failure modes. """ raise NotImplementedError("Implemented in Milestone 3.") @classmethod def from_ultimate( cls, ku: float, pu: float, *, controller_type: ControllerType = ControllerType.PID, ) -> TuningResult: """Apply the Tyreus-Luyben table to measured ``Ku`` and ``Pu``.""" raise NotImplementedError("Implemented in Milestone 3.")