"""Abstract process-model interface and shared value objects. Implementation status: **API stub (Milestone 1).** All abstract members are fully specified; concrete helper methods raise :class:`NotImplementedError` and will be implemented in Milestone 2 (see ``docs/design/06_roadmap.md``). """ from __future__ import annotations import abc from dataclasses import dataclass from typing import TYPE_CHECKING, Any import numpy as np import numpy.typing as npt if TYPE_CHECKING: # pragma: no cover - import cycle guard for typing only from pidtune.models.transfer_function import TransferFunction __all__ = ["ProcessModel", "FrequencyResponse"] @dataclass(frozen=True) class FrequencyResponse: """Frequency response of a model over a grid of angular frequencies. Returned by :meth:`ProcessModel.frequency_response`. Immutable value object; arrays are stored as read-only ``float64``/``complex128`` ndarrays of equal length. Attributes ---------- w : numpy.ndarray Angular frequencies in rad/s, strictly increasing, all ``> 0``. response : numpy.ndarray Complex response ``G(j·w)`` including the dead-time phase term ``exp(-j·w·L)``. Examples -------- >>> fr = model.frequency_response(np.logspace(-2, 2, 500)) # doctest: +SKIP >>> magnitude_db = 20 * np.log10(np.abs(fr.response)) # doctest: +SKIP >>> phase_deg = np.degrees(np.unwrap(np.angle(fr.response))) # doctest: +SKIP """ w: npt.NDArray[np.float64] response: npt.NDArray[np.complex128] @property def magnitude(self) -> npt.NDArray[np.float64]: """Magnitude ``|G(j·w)|`` (linear, not dB).""" raise NotImplementedError("Implemented in Milestone 2.") @property def phase(self) -> npt.NDArray[np.float64]: """Unwrapped phase of ``G(j·w)`` in radians.""" raise NotImplementedError("Implemented in Milestone 2.") def ultimate_point(self) -> tuple[float, float]: """Locate the ultimate point (phase crossover) of the response. Finds the lowest frequency ``w_u`` at which the unwrapped phase crosses ``-180°`` (linear interpolation between grid points) and returns the corresponding ultimate gain ``Ku = 1 / |G(j·w_u)|`` and ultimate period ``Tu = 2·pi / w_u``. Returns ------- (ku, tu) : tuple of float Ultimate gain (dimensionless when the loop is consistent) and ultimate period in seconds. Raises ------ pidtune.ConvergenceError If the phase never reaches ``-180°`` within the frequency grid. The message recommends widening the grid. Notes ----- Used by :class:`pidtune.tuners.ZieglerNicholsFrequencyTuner` to compute ``Ku``/``Tu`` analytically from a model instead of from an ultimate-cycle experiment. """ raise NotImplementedError("Implemented in Milestone 2.") class ProcessModel(abc.ABC): """Abstract base class for all SISO process descriptions. A ``ProcessModel`` is an immutable, validated value object describing a continuous-time, single-input single-output process. The interface is the contract relied upon by tuners (Layer 3), the simulator (Layer 2) and identification. Subclassing ----------- Third parties may implement custom models by subclassing and providing :meth:`to_transfer_function` plus the :attr:`dead_time` property; the base class derives :meth:`frequency_response` and :meth:`step_response` from the lowered transfer function by default, so a minimal subclass implements exactly two members. See ``docs/design/05_errors_and_extensibility.md`` §2. Notes ----- * Time unit is seconds everywhere. * Instances are immutable: all concrete models are frozen dataclasses. * Validation occurs at construction and raises :class:`pidtune.ModelValidationError`; an instance that exists is valid. """ @abc.abstractmethod def to_transfer_function(self) -> "TransferFunction": """Lower this model to its exact transfer-function representation. The lowering is exact (never an approximation): the dead time is carried in :attr:`TransferFunction.dead_time`, not approximated. Returns ------- TransferFunction Rational transfer function plus dead time, equal in dynamics to this model. """ @property @abc.abstractmethod def dead_time(self) -> float: """Transport delay ``L`` in seconds, ``>= 0``.""" @property def static_gain(self) -> float: """Steady-state gain ``G(0)``. Returns ------- float ``G(0)`` for self-regulating processes; ``math.inf`` (with the sign of the integrator gain) when :attr:`is_integrating` is true. """ raise NotImplementedError("Implemented in Milestone 2.") @property def is_integrating(self) -> bool: """Whether the process contains a pure integrator (pole at ``s = 0``).""" raise NotImplementedError("Implemented in Milestone 2.") @property def normalized_dead_time(self) -> float | None: """Normalized dead time ``τ = L / (L + T)``. ``T`` is the dominant time constant. Defined for self-regulating models with a meaningful dominant lag; ``None`` otherwise (e.g. integrating processes, pure-gain transfer functions). Tuners use this to check rule applicability (``docs/design/03_tuning_methods.md`` §1). """ raise NotImplementedError("Implemented in Milestone 2.") def frequency_response(self, w: npt.ArrayLike) -> FrequencyResponse: """Evaluate the complex frequency response ``G(j·w)``. Parameters ---------- w : array_like of float Angular frequencies in rad/s. Must be finite, strictly positive and strictly increasing. Returns ------- FrequencyResponse Frequencies and complex response, dead-time phase included. Raises ------ ValueError If ``w`` contains non-finite, non-positive or non-increasing values. Notes ----- Default implementation lowers via :meth:`to_transfer_function` and evaluates the rational part with :func:`scipy.signal.freqresp`, multiplying in ``exp(-1j * w * dead_time)`` analytically. Subclasses may override with a closed form for speed. """ raise NotImplementedError("Implemented in Milestone 2.") def step_response( self, t: npt.ArrayLike, *, amplitude: float = 1.0 ) -> npt.NDArray[np.float64]: """Open-loop response to a step input of given amplitude at ``t = 0``. Parameters ---------- t : array_like of float Time grid in seconds, finite, non-negative, strictly increasing. Need not be uniform. amplitude : float, optional Step amplitude (default ``1.0``). Returns ------- numpy.ndarray Output samples ``y(t)``, same length as ``t``. For ``t`` within the dead time the response is exactly ``0.0``. Raises ------ ValueError If ``t`` violates the constraints above or ``amplitude`` is not finite. Notes ----- Default implementation uses :func:`scipy.signal.step` on the lowered rational part, then shifts the result by the dead time with linear interpolation onto the requested grid. """ raise NotImplementedError("Implemented in Milestone 2.") def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-compatible dictionary. The dictionary includes a ``"type"`` discriminator (the class name) and a ``"version"`` integer (currently ``1``). Round-trips through :meth:`from_dict`. """ raise NotImplementedError("Implemented in Milestone 2.") @classmethod def from_dict(cls, data: dict[str, Any]) -> "ProcessModel": """Reconstruct a model serialized with :meth:`to_dict`. Parameters ---------- data : dict Mapping produced by :meth:`to_dict`. When called on ``ProcessModel`` itself, dispatches on ``data["type"]`` to the registered concrete class; when called on a concrete class, ``data["type"]`` must match. Raises ------ pidtune.ModelValidationError If the payload is malformed, the version is unsupported, or the type is unknown/mismatched. """ raise NotImplementedError("Implemented in Milestone 2.")