"""PID gain representations and exact conversions between controller forms. Three equivalent parameterizations are in common use: Parallel (canonical in ``pidtune``) ``u(t) = kp*e(t) + ki * \\int e dt + kd * de/dt`` Standard / ISA / "ideal" ``u(t) = kp * ( e(t) + (1/ti) * \\int e dt + td * de/dt )`` so that ``ki = kp/ti`` and ``kd = kp*td``. Series / "interacting" / "classical" ``C(s) = kc * (1 + 1/(ti*s)) * (1 + td*s)`` Equivalent standard form: ``Kp = kc*(ti+td)/ti``, ``Ti = ti+td``, ``Td = ti*td/(ti+td)``. The reverse mapping exists only when ``Ti >= 4*Td`` (the quadratic factorization must have real roots). All tuning rules in :mod:`pidtune.tuners` emit :class:`PIDGains`; the constructors :meth:`PIDGains.from_standard` / :meth:`PIDGains.from_series` let rules stated in other forms convert losslessly. These conversions are pure arithmetic and are implemented (not stubbed) in this design milestone because the design documents and downstream stubs rely on their exact semantics. """ from __future__ import annotations import math from dataclasses import dataclass, replace from typing import NamedTuple __all__ = ["PIDGains", "StandardGains", "SeriesGains"] class StandardGains(NamedTuple): """Standard (ISA) form parameters. Attributes ---------- kp: Proportional gain (overall controller gain). ti: Integral time. ``math.inf`` denotes "no integral action". td: Derivative time. ``0.0`` denotes "no derivative action". """ kp: float ti: float td: float class SeriesGains(NamedTuple): """Series (interacting) form parameters. Attributes ---------- kc: Series controller gain. ti: Series integral time. ``math.inf`` denotes "no integral action". td: Series derivative time. ``0.0`` denotes "no derivative action". """ kc: float ti: float td: float @dataclass(frozen=True) class PIDGains: """Immutable PID gains in parallel form. Parameters ---------- kp: Proportional gain. Must be finite. May be negative only for reverse-acting use cases where the caller manages sign explicitly; tuning rules always produce ``kp > 0`` and express direction via :class:`~pidtune.controllers.pid.PIDController`'s ``direction``. ki: Integral gain (``kp/ti`` in standard form). ``0.0`` disables integral action. kd: Derivative gain (``kp*td`` in standard form). ``0.0`` disables derivative action. Raises ------ ValueError If any gain is NaN or infinite. Examples -------- >>> g = PIDGains.from_standard(kp=2.0, ti=8.0, td=1.0) >>> g PIDGains(kp=2.0, ki=0.25, kd=2.0) >>> g.to_standard() StandardGains(kp=2.0, ti=8.0, td=1.0) >>> g.controller_type 'PID' """ kp: float ki: float = 0.0 kd: float = 0.0 def __post_init__(self) -> None: for name in ("kp", "ki", "kd"): value = getattr(self, name) try: value = float(value) except (TypeError, ValueError) as exc: # noqa: PERF203 raise TypeError(f"{name} must be a real number, got {value!r}") from exc if math.isnan(value) or math.isinf(value): raise ValueError(f"{name} must be finite, got {value!r}") object.__setattr__(self, name, value) # ------------------------------------------------------------------ # Alternative constructors # ------------------------------------------------------------------ @classmethod def from_standard( cls, kp: float, ti: float | None = None, td: float = 0.0 ) -> "PIDGains": """Build parallel gains from standard (ISA) form. Parameters ---------- kp: Overall controller gain. ti: Integral time. ``None`` or ``math.inf`` disables integral action; otherwise must be ``> 0``. td: Derivative time, ``>= 0``. Returns ------- PIDGains ``PIDGains(kp, kp/ti, kp*td)``. Raises ------ ValueError If ``ti <= 0`` (and not ``None``/``inf``) or ``td < 0``. """ kp = float(kp) td = float(td) if td < 0.0: raise ValueError(f"td must be >= 0, got {td!r}") if ti is None or (isinstance(ti, float) and math.isinf(ti)): ki = 0.0 else: ti = float(ti) if ti <= 0.0: raise ValueError(f"ti must be > 0 (or None/inf for no integral), got {ti!r}") ki = kp / ti return cls(kp=kp, ki=ki, kd=kp * td) @classmethod def from_series( cls, kc: float, ti: float | None = None, td: float = 0.0 ) -> "PIDGains": """Build parallel gains from series (interacting) form. ``C(s) = kc * (1 + 1/(ti*s)) * (1 + td*s)`` Parameters ---------- kc: Series controller gain. ti: Series integral time. ``None``/``inf`` disables integral action, in which case the controller is ``kc*(1 + td*s)`` (PD). td: Series derivative time, ``>= 0``. Returns ------- PIDGains The exactly equivalent parallel-form gains. Raises ------ ValueError If ``ti <= 0`` (and not ``None``/``inf``) or ``td < 0``. """ kc = float(kc) td = float(td) if td < 0.0: raise ValueError(f"td must be >= 0, got {td!r}") if ti is None or (isinstance(ti, float) and math.isinf(ti)): # kc*(1 + td*s): pure PD return cls(kp=kc, ki=0.0, kd=kc * td) ti = float(ti) if ti <= 0.0: raise ValueError(f"ti must be > 0 (or None/inf for no integral), got {ti!r}") std_kp = kc * (ti + td) / ti std_ti = ti + td std_td = (ti * td) / (ti + td) if td > 0.0 else 0.0 return cls.from_standard(std_kp, std_ti, std_td) # ------------------------------------------------------------------ # Conversions # ------------------------------------------------------------------ def to_standard(self) -> StandardGains: """Convert to standard (ISA) form. Returns ------- StandardGains ``(kp, ti, td)`` with ``ti = kp/ki`` (``inf`` if ``ki == 0``) and ``td = kd/kp``. Raises ------ ValueError If ``kp == 0`` while ``ki != 0`` or ``kd != 0`` — such a controller has no standard-form representation. """ if self.kp == 0.0: if self.ki == 0.0 and self.kd == 0.0: return StandardGains(0.0, math.inf, 0.0) raise ValueError( "Gains with kp == 0 but nonzero ki/kd have no standard-form " "representation; use the parallel form directly." ) ti = math.inf if self.ki == 0.0 else self.kp / self.ki td = self.kd / self.kp return StandardGains(self.kp, ti, td) def to_series(self) -> SeriesGains: """Convert to series (interacting) form. The standard form ``Kp*(1 + 1/(Ti*s) + Td*s)`` factors into a series form only when ``Ti >= 4*Td``; then:: ti' = (Ti/2) * (1 + sqrt(1 - 4*Td/Ti)) td' = (Ti/2) * (1 - sqrt(1 - 4*Td/Ti)) kc = Kp * ti' / Ti Returns ------- SeriesGains The exactly equivalent series-form gains. Raises ------ ValueError If ``Ti < 4*Td`` (complex factorization — no real series form), or if no standard form exists (see :meth:`to_standard`). """ kp, ti, td = self.to_standard() if td == 0.0: return SeriesGains(kp, ti, 0.0) if math.isinf(ti): # Kp*(1 + Td*s) == kc*(1 + td'*s) with no integral term. return SeriesGains(kp, math.inf, td) ratio = 4.0 * td / ti if ratio > 1.0: raise ValueError( f"No real series form exists: Ti ({ti!r}) < 4*Td ({4.0 * td!r}). " "Interacting controllers cannot realize complex PID zeros." ) root = math.sqrt(1.0 - ratio) ti_s = 0.5 * ti * (1.0 + root) td_s = 0.5 * ti * (1.0 - root) kc = kp * ti_s / ti return SeriesGains(kc, ti_s, td_s) # ------------------------------------------------------------------ # Utilities # ------------------------------------------------------------------ def with_(self, **changes: float) -> "PIDGains": """Return a copy with the given fields replaced. Examples -------- >>> PIDGains(1.0, 0.5).with_(kp=2.0) PIDGains(kp=2.0, ki=0.5, kd=0.0) """ return replace(self, **changes) @property def controller_type(self) -> str: """Classify the gain set: ``"P"``, ``"PI"``, ``"PD"`` or ``"PID"``.""" has_i = self.ki != 0.0 has_d = self.kd != 0.0 if has_i and has_d: return "PID" if has_i: return "PI" if has_d: return "PD" return "P"