"""Arbitrary rational transfer functions with first-class dead time. Implementation status: **API stub (Milestone 1).** Field layout, operator semantics and normalization rules are final per ``docs/design/02_data_models.md`` §5; bodies are implemented in Milestone 2 (``reduce`` in Milestone 4). """ from __future__ import annotations from dataclasses import dataclass from typing import Any import numpy as np import numpy.typing as npt from pidtune.models.base import ProcessModel __all__ = ["TransferFunction"] @dataclass(frozen=True, init=False) class TransferFunction(ProcessModel): """Rational transfer function plus transport delay. :: b_m·s^m + … + b_1·s + b_0 G(s) = ───────────────────────── · e^(−L·s) a_n·s^n + … + a_1·s + a_0 Parameters ---------- num : sequence of float Numerator coefficients in **descending** powers of ``s``. Must be finite and not all zero. Leading zeros are stripped. den : sequence of float Denominator coefficients in descending powers of ``s``. Finite, leading coefficient nonzero after stripping. Normalized on construction so the leading denominator coefficient is ``1.0``. dead_time : float, optional Transport delay ``L >= 0`` in seconds. Default ``0.0``. allow_improper : bool, optional If ``False`` (default), require ``deg(num) <= deg(den)`` (proper). Improper transfer functions are permitted only as intermediate controller objects (e.g. an unfiltered PID), never as plants; :class:`~pidtune.simulation.ModelPlant` rejects them. Raises ------ pidtune.ModelValidationError On any constraint violation. Notes ----- * Near-cancelling pole/zero pairs are removed during normalization with relative tolerance ``1e-9``; when this happens a :class:`pidtune.NumericalWarning` is emitted. * Coefficients are stored as tuples of Python floats — immutable and hashable; :meth:`num_array`/:meth:`den_array` give ndarrays. Examples -------- >>> from pidtune import TransferFunction >>> g = TransferFunction(num=[2.0], den=[10.0, 1.0], dead_time=1.5) # doctest: +SKIP >>> g2 = g * TransferFunction([1.0], [3.0, 1.0]) # doctest: +SKIP >>> g2.dead_time # doctest: +SKIP 1.5 """ num: tuple[float, ...] den: tuple[float, ...] dead_time: float # type: ignore[assignment] # field shadows ABC property by design def __init__( self, num: npt.ArrayLike, den: npt.ArrayLike, dead_time: float = 0.0, *, allow_improper: bool = False, ) -> None: raise NotImplementedError("Implemented in Milestone 2.") # -- Array accessors ------------------------------------------------------- def num_array(self) -> npt.NDArray[np.float64]: """Numerator coefficients as a fresh ``float64`` ndarray (descending powers).""" raise NotImplementedError("Implemented in Milestone 2.") def den_array(self) -> npt.NDArray[np.float64]: """Denominator coefficients as a fresh ``float64`` ndarray (descending powers).""" raise NotImplementedError("Implemented in Milestone 2.") # -- Analysis --------------------------------------------------------------- def poles(self) -> npt.NDArray[np.complex128]: """Poles of the rational part (roots of ``den``), via ``numpy.roots``.""" raise NotImplementedError("Implemented in Milestone 2.") def zeros(self) -> npt.NDArray[np.complex128]: """Zeros of the rational part (roots of ``num``).""" raise NotImplementedError("Implemented in Milestone 2.") def is_stable(self) -> bool: """``True`` iff all poles lie strictly in the open left half-plane. Poles within ``1e-12`` of the imaginary axis count as *not* stable. Note the dead time does not affect open-loop stability. """ raise NotImplementedError("Implemented in Milestone 2.") def order(self) -> int: """Denominator degree of the rational part.""" raise NotImplementedError("Implemented in Milestone 2.") # -- Algebra ------------------------------------------------------------------ def __mul__(self, other: "TransferFunction | float") -> "TransferFunction": """Series connection. Dead times **add**; scalars scale the numerator.""" raise NotImplementedError("Implemented in Milestone 2.") def __rmul__(self, other: float) -> "TransferFunction": """Scalar gain times this transfer function.""" raise NotImplementedError("Implemented in Milestone 2.") def __add__(self, other: "TransferFunction | float") -> "TransferFunction": """Parallel connection. Raises ------ pidtune.UnsupportedModelError When both operands carry *different* nonzero dead times — no exact rational result exists. The message recommends ``.pade(order)`` first. Equal dead times are factored out and preserved. """ raise NotImplementedError("Implemented in Milestone 2.") def __sub__(self, other: "TransferFunction | float") -> "TransferFunction": """Parallel connection with negation; same dead-time rules as ``+``.""" raise NotImplementedError("Implemented in Milestone 2.") def __neg__(self) -> "TransferFunction": """Negated numerator; dead time unchanged.""" raise NotImplementedError("Implemented in Milestone 2.") def feedback(self, other: "TransferFunction | float" = 1.0, *, sign: int = -1) -> ( "TransferFunction" ): """Closed-loop transfer function ``self / (1 - sign·self·other)``. Parameters ---------- other : TransferFunction or float, optional Feedback-path element ``H`` (default unity). sign : {-1, +1}, optional ``-1`` (default) is negative feedback. Raises ------ pidtune.UnsupportedModelError If the loop (``self·other``) carries a nonzero dead time — the closed loop of a dead-time system is not rational. Callers must ``pade`` explicitly first; tuners that need exact dead-time closed loops use frequency-domain or time-domain evaluation instead of this method. """ raise NotImplementedError("Implemented in Milestone 2.") # -- Dead-time handling --------------------------------------------------------- def pade(self, order: int = 1) -> "TransferFunction": """Replace the dead time by a Padé approximant of the given order. Parameters ---------- order : int, optional Approximant order ``>= 1`` (numerator and denominator order both equal to ``order``). Default ``1``. Returns ------- TransferFunction Equivalent rational TF with ``dead_time == 0.0``. If this TF already has zero dead time, returns ``self`` unchanged. Notes ----- This is the *only* place the library approximates dead time at the model level; it is never invoked implicitly. """ raise NotImplementedError("Implemented in Milestone 2.") # -- Reduction (bridge to rule-based tuners) ----------------------------------- def reduce( self, target: type, *, method: str = "frequency", t_final: float | None = None, ) -> ProcessModel: """Fit a lower-order model (FOPDT or SOPDT) to this transfer function. Parameters ---------- target : type ``pidtune.FOPDT`` or ``pidtune.SOPDT``. method : {"frequency", "step", "half-rule"}, optional ``"frequency"`` fits at logarithmically spaced points up to the critical frequency; ``"step"`` least-squares fits the step response; ``"half-rule"`` applies Skogestad's half rule (requires a real, stable pole structure). Default ``"frequency"``. t_final : float or None, optional Step-response horizon for ``method="step"``; default is five times the slowest time constant. Returns ------- ProcessModel Instance of ``target``. Raises ------ pidtune.UnsupportedModelError If ``target`` is not a supported model class, or ``method="half-rule"`` is requested for a TF with complex poles. pidtune.ConvergenceError If the underlying fit fails. Warns ----- pidtune.NumericalWarning When the reduction error exceeds 10 % relative RMS. Notes ----- Scheduled for **Milestone 4** (identification), since it shares the fitting machinery with :mod:`pidtune.identification`. """ raise NotImplementedError("Implemented in Milestone 4.") # -- Discretization -------------------------------------------------------------- def to_discrete( self, dt: float, *, method: str = "zoh", delay_handling: str = "thiran", ) -> Any: """Discretize for simulation. Parameters ---------- dt : float Sample time in seconds, ``> 0``. method : {"zoh", "tustin"}, optional Discretization of the rational part, passed to :func:`scipy.signal.cont2discrete`. Default ``"zoh"``. delay_handling : {"thiran", "round"}, optional How the fractional part of ``dead_time / dt`` is realized: ``"thiran"`` (default) appends a first-order Thiran allpass for the fractional sample; ``"round"`` rounds to the nearest whole sample and emits a :class:`pidtune.NumericalWarning` stating the introduced delay error. Returns ------- DiscreteTransferFunction Internal discrete realization consumed by :class:`pidtune.simulation.ModelPlant` (public type, internal audience; see ``docs/design/04_api_specification.md`` §5). Raises ------ pidtune.ModelValidationError If ``dt <= 0`` or this TF is improper. """ raise NotImplementedError("Implemented in Milestone 2.") # -- ProcessModel interface -------------------------------------------------------- def to_transfer_function(self) -> "TransferFunction": """Return ``self`` (identity lowering).""" raise NotImplementedError("Implemented in Milestone 2.") @property def static_gain(self) -> float: """``b_0 / a_0``; ``±inf`` when ``a_0 == 0`` (integrating).""" raise NotImplementedError("Implemented in Milestone 2.") @property def is_integrating(self) -> bool: """``True`` iff the denominator has a root at ``s = 0`` (``a_0 == 0``).""" raise NotImplementedError("Implemented in Milestone 2.") @property def normalized_dead_time(self) -> float | None: """``L / (L + T_dom)`` with ``T_dom = 1/|slowest stable pole|``; ``None`` for integrating or pure-gain transfer functions.""" raise NotImplementedError("Implemented in Milestone 2.")