"""Second-order-plus-dead-time (SOPDT) process models. Implementation status: **API stub (Milestone 1).** Field layout and construction paths are final per ``docs/design/02_data_models.md`` §4; method bodies are implemented in Milestone 2. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING from pidtune.models.base import ProcessModel if TYPE_CHECKING: # pragma: no cover from pidtune.models.transfer_function import TransferFunction __all__ = ["SOPDT"] @dataclass(frozen=True, init=False) class SOPDT(ProcessModel): """Second-order plus dead time model. Two equivalent parameterizations are supported, with one canonical internal storage ``(gain, natural_frequency, damping, dead_time, lead_time)`` so that equal dynamics always compare equal: * **Overdamped / critically damped** (the default constructor):: G(s) = K · e^(−L·s) / ((T1·s + 1)(T2·s + 1)) * **Underdamped** (via :meth:`underdamped`):: G(s) = K · e^(−L·s) / ((s/wn)² + 2·ζ·(s/wn) + 1) An optional numerator zero ``lead_time`` ``Tz`` extends both forms to ``K·(Tz·s + 1)·e^(−L·s)/(...)``; negative ``Tz`` models inverse-response processes (needed by the IMC tuning rules for such plants). Parameters ---------- gain : float Process gain ``K``; finite and nonzero. Negative allowed (reverse-acting). time_constant_1, time_constant_2 : float Time constants in seconds, both strictly positive and finite. Normalized on construction so ``T1 >= T2``; passing them swapped is accepted. dead_time : float, optional Transport delay ``L >= 0`` in seconds. Default ``0.0``. lead_time : float or None, optional Numerator zero time constant ``Tz`` in seconds (any finite nonzero value; negative means inverse response), or ``None`` for no zero. Default ``None``. Raises ------ pidtune.ModelValidationError On any constraint violation; ``err.field`` names the parameter. Examples -------- >>> from pidtune import SOPDT >>> m = SOPDT(gain=1.0, time_constant_1=8.0, time_constant_2=2.0, ... dead_time=1.0) # doctest: +SKIP >>> m.damping >= 1.0 # doctest: +SKIP True >>> u = SOPDT.underdamped(gain=1.0, natural_frequency=0.5, ... damping=0.4) # doctest: +SKIP References ---------- Åström & Hägglund, *Advanced PID Control*, ISA 2006, ch. 2. """ gain: float natural_frequency: float damping: float dead_time: float # type: ignore[assignment] # field shadows ABC property by design lead_time: float | None def __init__( self, gain: float, time_constant_1: float, time_constant_2: float, dead_time: float = 0.0, *, lead_time: float | None = None, ) -> None: """Construct an overdamped/critically-damped SOPDT from time constants. See class docstring for parameter semantics. Internally converts to ``(wn, ζ)`` with ``wn = 1/sqrt(T1·T2)`` and ``ζ = (T1 + T2) / (2·sqrt(T1·T2)) >= 1``. """ raise NotImplementedError("Implemented in Milestone 2.") @classmethod def underdamped( cls, gain: float, natural_frequency: float, damping: float, dead_time: float = 0.0, *, lead_time: float | None = None, ) -> "SOPDT": """Construct an SOPDT from natural frequency and damping ratio. Parameters ---------- gain : float Process gain ``K``; finite and nonzero. natural_frequency : float ``wn`` in rad/s, strictly positive and finite. damping : float Damping ratio ``ζ``, strictly positive and finite. Values ``>= 1`` are accepted and produce the identical canonical object as the time-constant constructor (one representation per dynamics). dead_time : float, optional Transport delay, ``>= 0``. Default ``0.0``. lead_time : float or None, optional Numerator zero ``Tz``; see class docstring. Default ``None``. Returns ------- SOPDT Raises ------ pidtune.ModelValidationError On any constraint violation. """ raise NotImplementedError("Implemented in Milestone 2.") # -- Derived parameters -------------------------------------------------- @property def time_constant_1(self) -> float: """Larger time constant ``T1`` (seconds). Defined only for ``damping >= 1``; raises :class:`AttributeError` with an explanatory message for underdamped instances. """ raise NotImplementedError("Implemented in Milestone 2.") @property def time_constant_2(self) -> float: """Smaller time constant ``T2`` (seconds); see :attr:`time_constant_1`.""" raise NotImplementedError("Implemented in Milestone 2.") @property def is_underdamped(self) -> bool: """``True`` iff ``damping < 1``.""" raise NotImplementedError("Implemented in Milestone 2.") # -- ProcessModel interface ------------------------------------------------ def to_transfer_function(self) -> "TransferFunction": """Exact lowering: ``num=(K·Tz, K)`` (or ``(K,)``), ``den=(1/wn², 2ζ/wn, 1)``.""" raise NotImplementedError("Implemented in Milestone 2.") @property def static_gain(self) -> float: """The process gain ``K``.""" raise NotImplementedError("Implemented in Milestone 2.") @property def is_integrating(self) -> bool: """Always ``False`` for SOPDT.""" raise NotImplementedError("Implemented in Milestone 2.") @property def normalized_dead_time(self) -> float: """``τ = L / (L + T1)`` using the dominant time constant. For underdamped instances the dominant lag is taken as ``1/(ζ·wn)`` (the envelope time constant), per ``docs/design/02_data_models.md`` §4. """ raise NotImplementedError("Implemented in Milestone 2.") def reduce_to_fopdt(self, method: str = "half-rule") -> "FOPDTLike": """Reduce to an FOPDT approximation for FOPDT-only tuning rules. Parameters ---------- method : {"half-rule", "step-fit"}, optional ``"half-rule"`` applies Skogestad's half rule (``T = T1 + T2/2``, ``L = L + T2/2``); ``"step-fit"`` fits an FOPDT to this model's analytic step response by least squares. Default ``"half-rule"``. Returns ------- FOPDT The reduced model. Warns ----- pidtune.NumericalWarning When the reduction error (relative step-response RMS) exceeds 10 %, so the user knows FOPDT rules may be off. References ---------- Skogestad, "Simple analytic rules for model reduction and PID tuning", *J. Process Control* 13 (2003) 291–309. """ raise NotImplementedError("Implemented in Milestone 2.") # Typing alias used in signatures above without importing fopdt at runtime # (avoids a models-internal import cycle in the stubs). if TYPE_CHECKING: # pragma: no cover from pidtune.models.fopdt import FOPDT as FOPDTLike