"""First-order-plus-dead-time (FOPDT) process models. Implementation status: **API stub (Milestone 1).** Field layout and validation rules are final per ``docs/design/02_data_models.md`` §3; 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__ = ["FOPDT", "IntegratingFOPDT"] @dataclass(frozen=True) class FOPDT(ProcessModel): """First-order plus dead time model ``G(s) = K·e^(−L·s) / (T·s + 1)``. The standard low-order description of self-regulating industrial processes and the canonical input to the rule-based tuners (Ziegler–Nichols step, Cohen–Coon, AMIGO, IMC/SIMC). Parameters ---------- gain : float Process gain ``K`` in (output units)/(input units). Must be finite and nonzero; negative values describe reverse-acting processes and are supported (tuners propagate the sign to ``kp`` and emit a :class:`pidtune.ModelApplicabilityWarning`). time_constant : float Time constant ``T`` in seconds, strictly positive and finite. For integrating processes use :meth:`integrating` instead of passing an infinite value (infinite values are rejected). dead_time : float, optional Transport delay ``L`` in seconds, ``>= 0`` and finite. Default ``0.0``. Raises ------ pidtune.ModelValidationError If any field violates its constraint; ``err.field`` names the offending parameter. Examples -------- >>> from pidtune import FOPDT >>> m = FOPDT(gain=2.0, time_constant=10.0, dead_time=1.5) # doctest: +SKIP >>> m.normalized_dead_time # doctest: +SKIP 0.1304... References ---------- Seborg, Edgar, Mellichamp & Doyle, *Process Dynamics and Control*, 4th ed., Wiley 2016, ch. 5–7. """ gain: float time_constant: float dead_time: float = 0.0 # type: ignore[assignment] # field shadows ABC property by design def __post_init__(self) -> None: """Validate fields; raises :class:`pidtune.ModelValidationError`.""" raise NotImplementedError("Implemented in Milestone 2.") # -- ProcessModel interface ------------------------------------------------ def to_transfer_function(self) -> "TransferFunction": """Return ``TransferFunction(num=(K,), den=(T, 1), dead_time=L)`` (exact).""" raise NotImplementedError("Implemented in Milestone 2.") @property def static_gain(self) -> float: """The process gain ``K`` (``G(0) = K`` for FOPDT).""" raise NotImplementedError("Implemented in Milestone 2.") @property def is_integrating(self) -> bool: """``False`` for plain FOPDT (see :class:`IntegratingFOPDT`).""" raise NotImplementedError("Implemented in Milestone 2.") @property def normalized_dead_time(self) -> float: """``τ = L / (L + T)``, in ``[0, 1)``. Never ``None`` for FOPDT.""" raise NotImplementedError("Implemented in Milestone 2.") # -- Constructors ------------------------------------------------------------ @classmethod def integrating(cls, gain: float, dead_time: float = 0.0) -> "IntegratingFOPDT": """Create an integrating process ``G(s) = K·e^(−L·s) / s``. Parameters ---------- gain : float Integrator gain ``K`` in (output units)/((input units)·s); finite and nonzero. dead_time : float, optional Transport delay in seconds, ``>= 0``. Default ``0.0``. Returns ------- IntegratingFOPDT A distinct subclass with ``is_integrating = True``; see design rationale in ``docs/design/02_data_models.md`` §3. """ raise NotImplementedError("Implemented in Milestone 2.") @dataclass(frozen=True) class IntegratingFOPDT(FOPDT): """Integrating process with dead time: ``G(s) = K·e^(−L·s) / s``. A deliberate subclass of :class:`FOPDT` rather than an infinite ``time_constant``: formulas such as ``L/(L+T)`` are undefined for integrators, and a distinct type makes tuners fail loudly (with :class:`pidtune.UnsupportedModelError`) or branch to their dedicated integrating-process rules (SIMC, AMIGO) explicitly. Construct via :meth:`FOPDT.integrating`. Direct construction is allowed but ``time_constant`` must be passed as ``float("nan")`` sentinel is *not* used — the field is fixed to ``0.0`` and ignored; accessing :attr:`time_constant` raises :class:`AttributeError` with a message explaining that integrating processes have no time constant. Notes ----- * ``static_gain`` is ``math.inf`` with the sign of ``gain``. * ``normalized_dead_time`` is ``None``. * ``to_transfer_function()`` returns ``num=(K,), den=(1, 0)`` plus dead time. """ def to_transfer_function(self) -> "TransferFunction": """Return ``TransferFunction(num=(K,), den=(1, 0), dead_time=L)`` (exact).""" raise NotImplementedError("Implemented in Milestone 2.") @property def static_gain(self) -> float: """``math.inf`` carrying the sign of :attr:`gain`.""" raise NotImplementedError("Implemented in Milestone 2.") @property def is_integrating(self) -> bool: """Always ``True``.""" raise NotImplementedError("Implemented in Milestone 2.") @property def normalized_dead_time(self) -> None: # type: ignore[override] """``None`` — undefined for integrating processes.""" raise NotImplementedError("Implemented in Milestone 2.")