"""Performance metrics for simulated (or recorded) loop responses. Two layers of API, both operating on plain arrays so the module is useful for recorded plant data as well as for :class:`~pidtune.simulation.loop.SimulationResult` objects: * **Scalar functions** — error integrals (:func:`iae`, :func:`ise`, :func:`itae`, :func:`itse`), step-shape descriptors (:func:`overshoot`, :func:`rise_time`, :func:`settling_time`, :func:`peak_time`, :func:`steady_state_error`) and the actuator- effort measure :func:`total_variation`. These are the cost-function building blocks of :mod:`pidtune.tuners.optimization`. * **Report layer** — :class:`StepMetrics` plus :func:`compute_step_metrics`, which evaluates the full battery on a setpoint-step response in one call. This is what documentation examples and tuner comparison tables consume. Conventions (normative; see ``docs/design/04_api_specification.md``, section "Metrics"): * All time-domain integrals use trapezoidal quadrature on the given (possibly non-uniform) grid. * Step-shape metrics are defined against the *final settled value*, estimated as the mean of the last 5 % of samples, unless an explicit ``y_final`` is supplied. Functions raise ``pidtune.exceptions.MetricError`` if the response has visibly not settled (final-segment peak-to-peak exceeding the settling tolerance), rather than returning a silently meaningless number. * Percentages are returned as fractions (``0.083``), never as ``8.3``. All functions are **API stubs** in this milestone; the :class:`StepMetrics` record is final. Frequency-domain robustness measures (gain/phase margin, maximum sensitivity ``Ms``) are deliberately *not* here — they are properties of a (model, gains) pair, not of a trajectory — and are scoped to the analysis milestone in ``docs/design/06_roadmap.md``. """ from __future__ import annotations import dataclasses from typing import Optional, TYPE_CHECKING import numpy as np from numpy.typing import NDArray if TYPE_CHECKING: # annotation-only import from .simulation.loop import SimulationResult __all__ = [ "iae", "ise", "itae", "itse", "total_variation", "overshoot", "rise_time", "settling_time", "peak_time", "steady_state_error", "StepMetrics", "compute_step_metrics", ] def iae(t: NDArray[np.float64], e: NDArray[np.float64]) -> float: """Integral of absolute error, :math:`\\int |e(t)|\\,dt`. The default cost for balanced setpoint/disturbance performance: penalises error magnitude uniformly in time, neither tolerating long tails (as ISE does) nor over-weighting them (as ITAE does). Parameters ---------- t : numpy.ndarray 1-D strictly increasing sample times. e : numpy.ndarray 1-D error samples, same length as ``t``. Returns ------- float The integral, in (error unit) × (time unit). Raises ------ ValueError If shapes disagree, ``t`` is not strictly increasing, or either array contains non-finite values. """ raise NotImplementedError( "metrics.iae is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def ise(t: NDArray[np.float64], e: NDArray[np.float64]) -> float: """Integral of squared error, :math:`\\int e(t)^2\\,dt`. Heavily penalises large transient errors and tolerates small persistent ones; minimising ISE typically yields aggressive, oscillatory tunings. Provided primarily as an optimisation objective and for comparison with classical literature. Parameters ---------- t, e : numpy.ndarray As in :func:`iae`. Returns ------- float Raises ------ ValueError As in :func:`iae`. """ raise NotImplementedError( "metrics.ise is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def itae(t: NDArray[np.float64], e: NDArray[np.float64]) -> float: """Integral of time-weighted absolute error, :math:`\\int t\\,|e(t)|\\,dt`. Punishes errors that persist; minimising ITAE generally produces well-damped responses with little overshoot, which is why several classical tuning correlations (and this library's optimisation tuner default) target it. Time weights are measured from ``t[0]`` (i.e. the integrand uses ``t - t[0]``), so results are invariant to the recording's time origin. Parameters ---------- t, e : numpy.ndarray As in :func:`iae`. Returns ------- float Raises ------ ValueError As in :func:`iae`. """ raise NotImplementedError( "metrics.itae is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def itse(t: NDArray[np.float64], e: NDArray[np.float64]) -> float: """Integral of time-weighted squared error, :math:`\\int t\\,e(t)^2\\,dt`. A compromise between ISE's transient emphasis and ITAE's tail emphasis. Time weighting follows the same origin convention as :func:`itae`. Parameters ---------- t, e : numpy.ndarray As in :func:`iae`. Returns ------- float Raises ------ ValueError As in :func:`iae`. """ raise NotImplementedError( "metrics.itse is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def total_variation(u: NDArray[np.float64]) -> float: """Total variation of the control signal, :math:`\\sum_k |u_{k+1}-u_k|`. The standard smoothness/actuator-wear measure (Skogestad's "TV"). Optimisation-based tuners add a weighted TV term to their cost to discourage tunings that achieve good error integrals through violent actuation. Parameters ---------- u : numpy.ndarray 1-D control samples. Returns ------- float Raises ------ ValueError If ``u`` is not 1-D or contains non-finite values. """ raise NotImplementedError( "metrics.total_variation is an API stub; implementation is scheduled " "for the metrics milestone (docs/design/06_roadmap.md)." ) def overshoot( t: NDArray[np.float64], y: NDArray[np.float64], y_initial: float = 0.0, y_final: Optional[float] = None, ) -> float: """Fractional overshoot of a step response. Defined as ``(y_peak - y_final) / (y_final - y_initial)`` for the first peak beyond the final value, or ``0.0`` if the response never exceeds it. Sign conventions handle downward steps (``y_final < y_initial``) symmetrically. Parameters ---------- t : numpy.ndarray 1-D strictly increasing sample times. y : numpy.ndarray 1-D response samples, same length as ``t``. y_initial : float, optional Pre-step level. Default ``0.0`` (deviation variables). y_final : float, optional Settled value; estimated from the last 5 % of samples when ``None``. Returns ------- float Overshoot as a fraction (``0.083`` means 8.3 %). Raises ------ pidtune.exceptions.MetricError If ``y_final`` must be estimated but the response has not settled, or if ``y_final == y_initial`` (zero step makes the ratio undefined). """ raise NotImplementedError( "metrics.overshoot is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def rise_time( t: NDArray[np.float64], y: NDArray[np.float64], y_initial: float = 0.0, y_final: Optional[float] = None, fractions: tuple[float, float] = (0.1, 0.9), ) -> float: """10–90 % rise time of a step response (fractions configurable). Crossing times are located by linear interpolation between the bracketing samples, so the result is not quantised to the grid. Parameters ---------- t, y : numpy.ndarray As in :func:`overshoot`. y_initial : float, optional Pre-step level. Default ``0.0``. y_final : float, optional Settled value; estimated when ``None``. fractions : tuple of float, optional ``(low, high)`` crossing levels as fractions of the step. Must satisfy ``0 <= low < high <= 1``. Default ``(0.1, 0.9)``. Returns ------- float ``t_high_crossing - t_low_crossing``. Raises ------ ValueError If ``fractions`` is out of order or out of range. pidtune.exceptions.MetricError If either level is never crossed within the recording, or the settled value cannot be estimated. """ raise NotImplementedError( "metrics.rise_time is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def settling_time( t: NDArray[np.float64], y: NDArray[np.float64], y_initial: float = 0.0, y_final: Optional[float] = None, tolerance: float = 0.02, ) -> float: """Time after which the response stays within ``±tolerance`` of final. The tolerance band is ``tolerance * |y_final - y_initial|`` around ``y_final``; the settling time is the last instant the response exits the band (measured from ``t[0]``), located by linear interpolation. Parameters ---------- t, y : numpy.ndarray As in :func:`overshoot`. y_initial : float, optional Pre-step level. Default ``0.0``. y_final : float, optional Settled value; estimated when ``None``. tolerance : float, optional Band half-width as a fraction of the step. Default ``0.02`` (the conventional 2 % criterion). Returns ------- float Raises ------ ValueError If ``tolerance`` is not in ``(0, 1)``. pidtune.exceptions.MetricError If the response is outside the band at the end of the recording (it has not settled), or the settled value cannot be estimated. """ raise NotImplementedError( "metrics.settling_time is an API stub; implementation is scheduled for " "the metrics milestone (docs/design/06_roadmap.md)." ) def peak_time( t: NDArray[np.float64], y: NDArray[np.float64], y_initial: float = 0.0, y_final: Optional[float] = None, ) -> float: """Time of the first peak beyond the final value (from ``t[0]``). Returns the time of the maximum excursion past ``y_final`` in the step direction. If the response never exceeds ``y_final`` (no overshoot), returns the time of the closest approach to it — documented behaviour chosen so that comparison tables never hold NaNs. Parameters ---------- t, y : numpy.ndarray As in :func:`overshoot`. y_initial : float, optional Pre-step level. Default ``0.0``. y_final : float, optional Settled value; estimated when ``None``. Returns ------- float Raises ------ pidtune.exceptions.MetricError If the settled value cannot be estimated. """ raise NotImplementedError( "metrics.peak_time is an API stub; implementation is scheduled for the " "metrics milestone (docs/design/06_roadmap.md)." ) def steady_state_error( y: NDArray[np.float64], setpoint_value: float, tail_fraction: float = 0.05, ) -> float: """Signed residual offset between setpoint and settled output. ``setpoint_value - mean(y over the last tail_fraction of samples)``. Near zero for any correctly implemented integrating controller; meaningfully non-zero for P/PD control or saturated loops. Parameters ---------- y : numpy.ndarray 1-D response samples. setpoint_value : float The (constant) setpoint level being tracked. tail_fraction : float, optional Fraction of trailing samples averaged. Must be in ``(0, 0.5]``. Default ``0.05``. Returns ------- float Raises ------ ValueError If ``tail_fraction`` is out of range or ``y`` is not 1-D. """ raise NotImplementedError( "metrics.steady_state_error is an API stub; implementation is scheduled " "for the metrics milestone (docs/design/06_roadmap.md)." ) @dataclasses.dataclass(frozen=True) class StepMetrics: """Complete metric battery for one setpoint-step response. Produced by :func:`compute_step_metrics`; immutable so tuner comparison tables can cache and share instances freely. Attributes ---------- iae, ise, itae, itse : float Error integrals over the full recording (see the functions of the same names). overshoot : float Fractional overshoot. rise_time : float 10–90 % rise time. settling_time : float or None 2 % settling time, or ``None`` if the response had not settled within the recording (the one metric where "not available" is an expected outcome rather than an error, since the report layer must summarise imperfect runs). peak_time : float Time of first peak (see :func:`peak_time` for the no-overshoot convention). steady_state_error : float Signed residual offset. total_variation : float TV of the control signal. """ iae: float ise: float itae: float itse: float overshoot: float rise_time: float settling_time: Optional[float] peak_time: float steady_state_error: float total_variation: float def compute_step_metrics( result: "SimulationResult", settling_tolerance: float = 0.02, ) -> StepMetrics: """Evaluate the full metric battery on a closed-loop step result. Convenience report layer over the scalar functions in this module: extracts ``t``, ``error``, ``output`` and ``control`` from a :class:`~pidtune.simulation.loop.SimulationResult`, infers the step's initial and final setpoint levels from the recorded setpoint trajectory, and computes every :class:`StepMetrics` field. Where :func:`settling_time` would raise because the response has not settled, the field is recorded as ``None`` instead — a report over an imperfect run is still a report. Parameters ---------- result : pidtune.simulation.loop.SimulationResult A *closed-loop* result (``result.setpoint`` must not be ``None``) whose setpoint trajectory is a single step. settling_tolerance : float, optional Tolerance for the settling-time criterion. Default ``0.02``. Returns ------- StepMetrics Raises ------ ValueError If ``result`` is an open-loop result, or its setpoint trajectory is not a single step (more than one distinct transition). Examples -------- >>> report = compute_step_metrics(result) # doctest: +SKIP >>> f"{report.overshoot:.1%}, Ts={report.settling_time:.1f}" # doctest: +SKIP '8.3%, Ts=42.1' """ raise NotImplementedError( "compute_step_metrics is an API stub; implementation is scheduled for " "the metrics milestone (docs/design/06_roadmap.md)." )