# 04 — Public API Specification This document is the normative reference for `pidtune`'s public API. Every symbol listed here is covered by semantic-versioning guarantees once `1.0` is released; everything else (underscore-prefixed names, modules not listed) is internal. The API stub files under `src/pidtune/` mirror this document one-to-one and carry the full behavioural contracts in their docstrings. ## 0. Conventions - **Arrays:** all vector inputs/outputs are `numpy.ndarray` of `float64`; functions accept any array-like and convert with `np.asarray(..., dtype=float)`. - **Time:** seconds by convention but unit-agnostic — the only requirement is consistency across model, controller, and simulation. - **Immutability:** value objects (`PIDGains`, model classes, result objects) are frozen dataclasses; "modification" returns new instances (e.g. `gains.with_(kp=2.0)`). - **Errors:** all library exceptions derive from `pidtune.exceptions.PIDTuneError` (see doc 05). Standard `TypeError`/`ValueError` are used only for plainly invalid Python-level arguments (wrong type, negative time constant, etc.). - **Typing:** the package is fully type-annotated and ships `py.typed`. ## 1. Top-level façade — `pidtune` ```python import pidtune __version__: str # Re-exports (the supported import surface for casual use): from pidtune import ( FOPDTModel, SOPDTModel, TransferFunctionModel, # models PIDGains, PIDController, # controllers TuningResult, # tuners SimulationResult, simulate_closed_loop, # simulation fit_fopdt, fit_sopdt, # identification ) def tune(model, controller_type="pid", method="auto", **options) -> TuningResult ``` `pidtune.tune` is the convenience entry point. `method` is a registry key (`"zn-open"`, `"zn-closed"`, `"cohen-coon"`, `"amigo"`, `"imc"`, `"simc"`, `"relay"`, `"optimize"`, or `"auto"`). `"auto"` dispatches: FOPDT → AMIGO, SOPDT → IMC, transfer function → optimization. Unknown keys raise `ConfigurationError` listing valid keys. Third parties can register tuners in the same registry (doc 05 §3). ## 2. Process models — `pidtune.models` *(delivered; summary)* `ProcessModel` (ABC) → `FOPDTModel(k, tau, theta)`, `SOPDTModel(k, tau1, tau2, theta, zeta=None)`, `TransferFunctionModel(num, den, delay=0.0)`. Shared contract: `.step_response(t)`, `.frequency_response(omega)`, `.to_transfer_function()`, `.normalized_dead_time` (where defined), validation at construction. See doc 02 for the full data-model specification. ## 3. Controllers — `pidtune.controllers` ### 3.1 `PIDGains` (frozen dataclass) — `pidtune.controllers.gains` Canonical **parallel form**: `u = kp·e + ki·∫e dt + kd·de/dt`. ```python PIDGains(kp: float, ki: float = 0.0, kd: float = 0.0) PIDGains.from_standard(kp, ti=None, td=0.0) # Kp·(e + (1/Ti)∫e + Td·ė) PIDGains.from_series(kc, ti=None, td=0.0) # Kc·(1 + 1/(Ti s))·(1 + Td s) gains.to_standard() -> StandardGains # NamedTuple(kp, ti, td); ti=inf if ki==0 gains.to_series() -> SeriesGains # NamedTuple(kc, ti, td); ValueError if Ti < 4·Td gains.with_(**changes) -> PIDGains gains.controller_type -> str # "P" | "PI" | "PD" | "PID" ``` Conversions are exact and round-trip; the series form exists only when `Ti ≥ 4·Td` (real factorization), which `to_series` enforces. ### 3.2 `PIDController` — `pidtune.controllers.pid` Discrete-time positional PID with the features needed for *honest* closed-loop evaluation of tuning results: ```python PIDController( gains: PIDGains, sample_time: float | None = None, # fixed dt; None → dt passed per step output_limits: tuple[float | None, float | None] = (None, None), derivative_filter_n: float = 10.0, # filter Td/N; 0/inf → unfiltered setpoint_weight_b: float = 1.0, # proportional-on-(b·sp − pv) setpoint_weight_c: float = 0.0, # derivative-on-(c·sp − pv); 0 → on PV anti_windup: str = "conditional", # "conditional" | "back_calculation" | "none" tracking_time: float | None = None, # Tt for back-calculation; default √(Ti·Td) direction: str = "direct", # "direct" | "reverse" ) ctrl.step(setpoint, measurement, dt=None, ff=0.0) -> float ctrl.reset(measurement=None, output=None) # bumpless (re)start ctrl.set_gains(gains, bumpless=True) ctrl.gains / ctrl.last_output / ctrl.saturated # read-only state ``` Difference equations (Tustin integral, filtered backward-difference derivative) are specified in the class docstring and are the implementation contract for milestone 2. ## 4. Tuners — `pidtune.tuners` ### 4.1 Base interface — `pidtune.tuners.base` ```python class ControllerType(str, Enum): P, PI, PD, PID @dataclass(frozen=True) class TuningResult: gains: PIDGains method: str # registry key, e.g. "amigo" controller_type: ControllerType model: ProcessModel | None warnings: tuple[str, ...] metadata: Mapping[str, Any] # every intermediate quantity (auditable) def make_controller(self, **controller_kwargs) -> PIDController class Tuner(ABC): name: ClassVar[str] supported_models: ClassVar[tuple[type, ...]] supported_types: ClassVar[tuple[ControllerType, ...]] def tune(self, model, controller_type=ControllerType.PID) -> TuningResult ``` `Tuner.tune` template-validates (model type, controller type, parameter windows) and delegates to the subclass hook `_compute(model, controller_type)`. ### 4.2 Concrete tuners | Class (module) | Constructor surface | Input | |---|---|---| | `ZieglerNicholsOpenLoop` (`ziegler_nichols`) | `()` | `FOPDTModel` | | `ZieglerNicholsClosedLoop` (`ziegler_nichols`) | `(rule=ZNRule.CLASSIC)`; `tune(model)` **or** `tune_from_ultimate(ku, pu, controller_type)` | model with phase crossover, or `(Ku, Pu)` | | `CohenCoonTuner` (`cohen_coon`) | `()` | `FOPDTModel` | | `AmigoTuner` (`amigo`) | `()` | `FOPDTModel` | | `IMCTuner` (`imc`) | `(lambda_c=None, variant="imc")` (`"imc"`/`"simc"`) | `FOPDTModel`, `SOPDTModel` | | `RelayAutotuner` (`relay`) | `(amplitude, hysteresis=0.0, rule=ZNRule.TYREUS_LUYBEN, min_cycles=4, tolerance=0.05, max_duration=None)`; `tune(plant, dt, ...)` | `Plant` | | `OptimizationTuner` (`optimization`) | `(objective="itae", algorithm="nelder-mead", initial=None, bounds=None, max_overshoot=None, max_sensitivity=None, scenario=None, seed=None)` | any `ProcessModel` | `RelayAutotuner.tune` returns a `TuningResult` whose metadata embeds a `RelayExperimentResult` dataclass (`ku, pu, amplitude, relay_amplitude, hysteresis, t, u, y, n_cycles, converged`). ## 5. Simulation — `pidtune.simulation` ### 5.1 `Plant` — `pidtune.simulation.plant` ```python class Plant(ABC): def reset(self) -> None def step(self, u: float, dt: float) -> float # advance dt, return measurement class ModelPlant(Plant): def __init__(self, model: ProcessModel, y0=0.0, u0=0.0, noise_std=0.0, rng=None) ``` `ModelPlant` discretizes the model state space (ZOH; delay via an input ring buffer) and is the bridge between analytic models and time stepping. The `Plant` ABC is intentionally minimal so users can wrap **real hardware** and use the relay autotuner unchanged. ### 5.2 Closed loop — `pidtune.simulation.loop` ```python @dataclass(frozen=True) class SimulationResult: t: np.ndarray; setpoint: np.ndarray; y: np.ndarray; u: np.ndarray dt: float # derived-metric conveniences delegate to pidtune.metrics: def iae(self) / ise() / itae() / overshoot() / settling_time(band=0.02) / rise_time() def simulate_closed_loop(plant, controller, setpoint, duration, dt, disturbance=None, y0=0.0) -> SimulationResult def simulate_open_loop(plant, u, duration, dt) -> SimulationResult ``` `setpoint`/`disturbance` accept a scalar, an array of the right length, or a callable `f(t) -> float` (composable with `pidtune.simulation.signals`). ### 5.3 Signals — `pidtune.simulation.signals` `step(t0=0.0, amplitude=1.0, offset=0.0)`, `ramp(...)`, `pulse(...)`, `sine(...)`, `prbs(amplitude, bit_time, seed=None)` — each returns a callable `f(t) -> float` suitable for `setpoint=`/`disturbance=`. ## 6. Identification — `pidtune.identification.step_response` ```python @dataclass(frozen=True) class IdentificationResult: model: ProcessModel method: str fit_quality: float # NRMSE fit in [..1], 1 = perfect residuals: np.ndarray metadata: Mapping[str, Any] def fit_fopdt(t, y, u_step=1.0, y0=None, method="two-point") -> IdentificationResult # method: "two-point" (28.3 %/63.2 %), "tangent", "least-squares" def fit_sopdt(t, y, u_step=1.0, y0=None) -> IdentificationResult # least-squares ``` Raises `IdentificationError` when the data is unusable (non-monotone time grid, no discernible steady state, fit quality below threshold), with actionable messages. ## 7. Metrics — `pidtune.metrics` Pure functions over arrays (and used by `SimulationResult` conveniences): ```python iae(t, e) / ise(t, e) / itae(t, e) / itse(t, e) -> float overshoot(y, setpoint_final, y0=0.0) -> float # fraction of step size settling_time(t, y, setpoint_final, band=0.02, y0=0.0) -> float rise_time(t, y, setpoint_final, y0=0.0, lo=0.1, hi=0.9) -> float control_effort(t, u) -> float # total variation ∫|du| gain_margin(model, gains) / phase_margin(model, gains) / max_sensitivity(model, gains) ``` Frequency-domain metrics require a `ProcessModel` and are the basis for the optimization tuner's robustness constraints. ## 8. Stability of this specification Changes to any signature above require a design-doc amendment in the same PR (see doc 05 §4 and the contribution rules in doc 06).