"""Discrete-time PID controller. This module defines the :class:`PIDController` API stub for milestone 1. The class carries the complete behavioural contract (discretization scheme, anti-windup semantics, bumpless-transfer rules) in its docstrings; the numerical body of :meth:`PIDController.step` is scheduled for milestone 2 ("Core controller & simulation engine", see ``docs/design/06_roadmap.md``) and currently raises :class:`NotImplementedError`. Construction-time validation **is** fully implemented so the configuration contract is testable now. Control law (continuous-time reference) --------------------------------------- With setpoint weighting ``b`` (proportional) and ``c`` (derivative):: u(t) = kp*(b*r - y) + ki * \\int (r - y) dt + kd * d/dt ( c*r - y ) with the derivative term low-pass filtered by ``Td/N`` (``N = derivative_filter_n``), output clamped to ``output_limits``, and integral windup handled per ``anti_windup``. Discretization contract (normative for milestone 2) --------------------------------------------------- * Integral: trapezoidal (Tustin) — ``I[k] = I[k-1] + ki*dt*(e[k] + e[k-1])/2``. * Derivative: filtered backward difference on ``ed = c*r - y``:: a = Td / (Td + N*dt) # Td = kd/kp, 0 if kp == 0 D[k] = a*D[k-1] + (kd*(1-a)/dt) * (ed[k] - ed[k-1]) reducing to a plain backward difference as ``N → inf`` and to no derivative action when ``kd == 0``. * Saturation: ``u = clip(P + I + D + ff, *output_limits)``. * Anti-windup: - ``"conditional"`` — skip the integral update when the unclamped output is saturated **and** the error drives it further into saturation; - ``"back_calculation"`` — ``I[k] += (dt/Tt)*(u_sat - u_unsat)`` with tracking time ``Tt`` (default ``sqrt(Ti*Td)``, falling back to ``Ti`` when ``Td == 0``); - ``"none"`` — no protection (for analysis/teaching only). * ``direction="reverse"`` negates the computed output delta relative to the operating point (equivalently, negates all three gains internally). """ from __future__ import annotations import math from typing import Optional, Tuple from pidtune.controllers.gains import PIDGains __all__ = ["PIDController"] _ANTI_WINDUP_MODES = ("conditional", "back_calculation", "none") _DIRECTIONS = ("direct", "reverse") class PIDController: """Discrete-time PID controller with industrial-grade features. Parameters ---------- gains: Parallel-form gains (:class:`~pidtune.controllers.gains.PIDGains`). sample_time: Fixed controller period. If given (``> 0``), :meth:`step` must be called once per period and ``dt`` may be omitted. If ``None``, the caller passes a strictly positive ``dt`` to every :meth:`step` call. output_limits: ``(low, high)`` actuator limits; either side may be ``None`` for unbounded. ``low < high`` is required when both are given. derivative_filter_n: Derivative filter ratio ``N`` (filter time constant ``Td/N``). Typical range 5–20; ``math.inf`` selects an unfiltered derivative. Must be ``> 0``. setpoint_weight_b: Proportional setpoint weight in ``[0, 1]``. ``b < 1`` softens setpoint-step kicks without affecting disturbance rejection (AMIGO recommends ``b = 0`` for delay-dominant processes). setpoint_weight_c: Derivative setpoint weight in ``[0, 1]``. The near-universal choice ``c = 0`` ("derivative on measurement") eliminates derivative kick. anti_windup: ``"conditional"`` (default), ``"back_calculation"`` or ``"none"``. See module docstring for exact semantics. tracking_time: Back-calculation tracking time ``Tt`` (``> 0``). Only meaningful with ``anti_windup="back_calculation"``; ``None`` selects the default ``sqrt(Ti*Td)`` (or ``Ti`` when ``Td == 0``). direction: ``"direct"`` (output increases when measurement is below setpoint) or ``"reverse"``. Raises ------ TypeError If ``gains`` is not a :class:`PIDGains`. ValueError For any out-of-range configuration value (message names the offending parameter). Examples -------- >>> from pidtune.controllers import PIDGains, PIDController >>> ctrl = PIDController( ... PIDGains.from_standard(kp=2.0, ti=8.0, td=1.0), ... sample_time=0.1, ... output_limits=(0.0, 100.0), ... setpoint_weight_b=0.5, ... ) """ def __init__( self, gains: PIDGains, sample_time: Optional[float] = None, output_limits: Tuple[Optional[float], Optional[float]] = (None, None), derivative_filter_n: float = 10.0, setpoint_weight_b: float = 1.0, setpoint_weight_c: float = 0.0, anti_windup: str = "conditional", tracking_time: Optional[float] = None, direction: str = "direct", ) -> None: if not isinstance(gains, PIDGains): raise TypeError(f"gains must be a PIDGains instance, got {type(gains).__name__}") if sample_time is not None: sample_time = float(sample_time) if not sample_time > 0.0: raise ValueError(f"sample_time must be > 0 or None, got {sample_time!r}") low, high = output_limits if low is not None: low = float(low) if high is not None: high = float(high) if low is not None and high is not None and not low < high: raise ValueError(f"output_limits low must be < high, got ({low!r}, {high!r})") derivative_filter_n = float(derivative_filter_n) if not derivative_filter_n > 0.0: raise ValueError(f"derivative_filter_n must be > 0 (inf allowed), got {derivative_filter_n!r}") setpoint_weight_b = float(setpoint_weight_b) if not 0.0 <= setpoint_weight_b <= 1.0: raise ValueError(f"setpoint_weight_b must be in [0, 1], got {setpoint_weight_b!r}") setpoint_weight_c = float(setpoint_weight_c) if not 0.0 <= setpoint_weight_c <= 1.0: raise ValueError(f"setpoint_weight_c must be in [0, 1], got {setpoint_weight_c!r}") if anti_windup not in _ANTI_WINDUP_MODES: raise ValueError( f"anti_windup must be one of {_ANTI_WINDUP_MODES}, got {anti_windup!r}" ) if tracking_time is not None: tracking_time = float(tracking_time) if not tracking_time > 0.0: raise ValueError(f"tracking_time must be > 0 or None, got {tracking_time!r}") if direction not in _DIRECTIONS: raise ValueError(f"direction must be one of {_DIRECTIONS}, got {direction!r}") self._gains = gains self._sample_time = sample_time self._output_limits = (low, high) self._derivative_filter_n = derivative_filter_n self._setpoint_weight_b = setpoint_weight_b self._setpoint_weight_c = setpoint_weight_c self._anti_windup = anti_windup self._tracking_time = tracking_time self._direction = direction # Mutable runtime state (defined here so the state contract is explicit). self._integral: float = 0.0 self._derivative_state: float = 0.0 self._prev_error: Optional[float] = None self._prev_deriv_input: Optional[float] = None self._last_output: Optional[float] = None self._saturated: bool = False # ------------------------------------------------------------------ # Read-only configuration / state # ------------------------------------------------------------------ @property def gains(self) -> PIDGains: """Current gains (immutable; change via :meth:`set_gains`).""" return self._gains @property def sample_time(self) -> Optional[float]: """Fixed controller period, or ``None`` for per-call ``dt``.""" return self._sample_time @property def output_limits(self) -> Tuple[Optional[float], Optional[float]]: """``(low, high)`` actuator limits.""" return self._output_limits @property def last_output(self) -> Optional[float]: """Most recent output, or ``None`` before the first :meth:`step`.""" return self._last_output @property def saturated(self) -> bool: """Whether the most recent output hit an output limit.""" return self._saturated # ------------------------------------------------------------------ # Operation # ------------------------------------------------------------------ def step( self, setpoint: float, measurement: float, dt: Optional[float] = None, ff: float = 0.0, ) -> float: """Advance the controller one sample and return the control output. Parameters ---------- setpoint: Reference value ``r[k]``. measurement: Process value ``y[k]``. dt: Elapsed time since the previous call. Required (``> 0``) when the controller was built with ``sample_time=None``; must be omitted or equal to ``sample_time`` otherwise. ff: Feedforward term added to the output *before* clamping (so the anti-windup logic accounts for it). Returns ------- float Clamped control output ``u[k]``. Raises ------ ValueError If ``dt`` is missing/non-positive in variable-``dt`` mode, or conflicts with a fixed ``sample_time``. NotImplementedError Numerical body is scheduled for milestone 2; the discretization contract is fixed in the module docstring. """ raise NotImplementedError( "PIDController.step is specified in this design milestone and " "implemented in milestone 2 (docs/design/06_roadmap.md)." ) def reset( self, measurement: Optional[float] = None, output: Optional[float] = None, ) -> None: """Reset internal state, optionally for bumpless (re)start. Parameters ---------- measurement: If given, derivative history is seeded with this value so the first :meth:`step` produces no derivative kick. output: If given (e.g. the current manual actuator value), the integral term is initialized so that the first computed output equals ``output`` at zero error — bumpless manual→auto transfer. Notes ----- State cleared: integral accumulator, derivative filter state, error history, ``last_output``, ``saturated``. Configuration is untouched. """ self._integral = 0.0 self._derivative_state = 0.0 self._prev_error = None self._prev_deriv_input = measurement self._last_output = None self._saturated = False if output is not None: # Seed the integral so P+I+D reproduces `output` at zero error. self._integral = float(output) self._last_output = float(output) def set_gains(self, gains: PIDGains, bumpless: bool = True) -> None: """Replace the gains, optionally without an output transient. Parameters ---------- gains: New parallel-form gains. bumpless: When ``True`` (default) the integral accumulator is rescaled so the output is continuous across the gain change (the standard ``I' = I + (kp_old - kp_new)*e`` adjustment is applied on the next :meth:`step`). When ``False`` the state is left untouched. Raises ------ TypeError If ``gains`` is not a :class:`PIDGains`. """ if not isinstance(gains, PIDGains): raise TypeError(f"gains must be a PIDGains instance, got {type(gains).__name__}") self._pending_bumpless = bool(bumpless) self._gains = gains # ------------------------------------------------------------------ # Introspection # ------------------------------------------------------------------ def __repr__(self) -> str: # pragma: no cover - cosmetic g = self._gains return ( f"PIDController(gains=PIDGains(kp={g.kp!r}, ki={g.ki!r}, kd={g.kd!r}), " f"sample_time={self._sample_time!r}, output_limits={self._output_limits!r}, " f"anti_windup={self._anti_windup!r}, direction={self._direction!r})" ) def _default_tracking_time(self) -> float: """Default back-calculation tracking time ``Tt``. ``sqrt(Ti*Td)`` when both integral and derivative action exist, ``Ti`` when only integral action exists, else ``inf`` (no tracking). """ std = self._gains.to_standard() if math.isinf(std.ti): return math.inf if std.td > 0.0: return math.sqrt(std.ti * std.td) return std.ti