# pidtune — Architecture & Module Layout *Design document 1 of 6 · Milestone 1* ## 1. Goals and non-goals ### Goals - **One coherent workflow**: model → tune → simulate → evaluate. Each stage has a small, explicit interface; outputs of one stage are inputs of the next, without ad-hoc dictionaries. - **Correctness over convenience**: controller forms (ideal/ISA, parallel, series) are explicit; unit conventions are stated once and enforced; every tuning rule cites its source and its applicability range and warns when used outside it. - **Pure-Python + NumPy/SciPy only** as hard dependencies. No compiled extensions, no `python-control` dependency (we provide a thin transfer-function model of our own, backed by `scipy.signal`). - **Deterministic and testable**: every numerical routine accepts explicit seeds/tolerances; nothing reads global state. - **Extensible by users**: third parties can add tuning rules, process models, and plants without modifying library code (see [doc 05](05_errors_and_extensibility.md)). ### Non-goals - Real-time control execution on hardware (we expose a discrete PID suitable for embedding, but scheduling/IO are out of scope). - MIMO control design. The library is SISO; this is stated in the API and enforced at model-construction time. - General control-systems analysis beyond what tuning needs (no root locus, no state-space design). For that, users should reach for `python-control`. ## 2. Layered architecture ``` ┌──────────────────────────────────────────────────┐ Layer 4 │ pidtune (top-level façade: re-exports, recipes) │ └──────────────────────────────────────────────────┘ ┌───────────────┐ ┌──────────────────────────────┐ Layer 3 │ tuners │ │ identification │ │ (ZN, CC, │ │ (fit FOPDT/SOPDT from data) │ │ AMIGO, IMC, │ └──────────────────────────────┘ │ relay, opt.) │ └───────────────┘ ┌───────────────┐ ┌───────────────┐ ┌───────────┐ Layer 2 │ simulation │ │ controllers │ │ metrics │ │ (plants, loop)│ │ (PID forms) │ │ │ └───────────────┘ └───────────────┘ └───────────┘ ┌──────────────────────────────────────────────────┐ Layer 1 │ models (FOPDT, SOPDT, TransferFunction) │ │ exceptions, _typing, _validation (internal) │ └──────────────────────────────────────────────────┘ ``` **Dependency rule:** a module may import only from layers strictly below it (and from its own layer's `base` module). Concretely: - `models` depends only on NumPy/SciPy and `exceptions`. - `controllers` depends on `exceptions` (and nothing else in the library): controllers are model-agnostic. - `simulation` depends on `models` and `controllers`. - `metrics` depends on `simulation` result types only (it consumes `SimulationResult`-shaped arrays). - `tuners` depend on `models`, `controllers` (for `PIDGains`), and — only for relay autotuning and optimization-based tuning — `simulation`. - `identification` depends on `models`. - The top-level package re-exports the public surface and contains *no logic* beyond convenience recipes documented as such. This rule is enforced in CI with an import-linter contract (Milestone 2). ## 3. Module layout ``` src/pidtune/ ├── __init__.py # public façade; __all__; version ├── exceptions.py # full exception hierarchy + warning categories ├── metrics.py # IAE/ISE/ITAE, overshoot, settling, margins ├── models/ │ ├── __init__.py │ ├── base.py # ProcessModel ABC; FrequencyResponse dataclass │ ├── fopdt.py # FOPDT dataclass-model │ ├── sopdt.py # SOPDT dataclass-model │ └── transfer_function.py # TransferFunction (rational TF + dead time) ├── controllers/ │ ├── __init__.py │ ├── gains.py # PIDGains dataclass + form conversions │ └── pid.py # PIDController (discrete, AW, D-filter) ├── tuners/ │ ├── __init__.py │ ├── base.py # Tuner ABC, TuningResult, TuningSpec │ ├── ziegler_nichols.py # ZN step + ultimate-cycle rules │ ├── cohen_coon.py │ ├── amigo.py │ ├── imc.py # IMC / lambda tuning (SIMC variant included) │ ├── relay.py # relay-feedback autotuning (Åström–Hägglund) │ └── optimization.py # objective-driven tuning via scipy.optimize ├── simulation/ │ ├── __init__.py │ ├── plant.py # Plant ABC, ModelPlant, FunctionPlant │ ├── loop.py # ClosedLoopSimulator, SimulationResult │ └── signals.py # step/ramp/PRBS/noise reference & disturbance └── identification/ ├── __init__.py └── step_response.py # fit_fopdt / fit_sopdt from recorded data ``` Internal helpers live in modules prefixed with `_` (e.g. `_validation.py`) and are never part of the public API. Anything importable without a leading underscore from `pidtune` or a documented subpackage **is** public API and is covered by the deprecation policy (doc 05 §4). ## 4. Core abstractions (summary) Full signatures are in [doc 04](04_api_specification.md); this section gives the rationale. ### 4.1 `ProcessModel` (models) The interface every process description satisfies: - `to_transfer_function() -> TransferFunction` — the canonical lowering. Every model can be reduced to a rational transfer function plus a dead time. All generic algorithms (simulation, frequency response) are written once against `TransferFunction`. - `frequency_response(w) -> FrequencyResponse` — complex response at given angular frequencies; needed for ultimate-gain computation and margins. - `step_response(t) -> ndarray` — open-loop unit step; needed by optimization tuners and by users for sanity checks. `FOPDT` and `SOPDT` additionally expose their named physical parameters and serve as *the* interchange type for rule-based tuners: every classical rule declares which model class(es) it accepts (doc 03 §1). ### 4.2 `PIDGains` and `PIDController` (controllers) `PIDGains` is an immutable dataclass: `kp`, `ki`, `kd`, plus `form` (`"ideal" | "parallel" | "series"`) and optional derivative-filter coefficient `n`. Form conversions are explicit methods (`to_form(...)`), never implicit, because silent form confusion is the single most common field error in PID deployment. `PIDController` is a *discrete-time* implementation (Tustin/backward-Euler selectable) with anti-windup (back-calculation or conditional integration), derivative-on-measurement option, output saturation, and bumpless manual/auto transfer. It is deliberately stateful with an explicit `reset()`; the simulator owns stepping it. ### 4.3 `Tuner` and `TuningResult` (tuners) All tuners implement `tune(model_or_plant, **options) -> TuningResult`. - Rule-based tuners (ZN-step, Cohen–Coon, AMIGO, IMC) accept a `ProcessModel` (most require `FOPDT`; IMC also accepts `SOPDT` and `TransferFunction`). - Experiment-based tuners (relay) accept a `Plant` — they must interact. - Optimization-based tuners accept either (a model is wrapped in a `ModelPlant`). `TuningResult` carries: `gains: PIDGains`, the `method` identifier, the `model` used (if any), raw intermediate quantities (e.g. `ku`, `tu` for ultimate-cycle methods), a list of structured `warnings`, and a free-form `diagnostics` mapping. Results are immutable and reprs are reproducible. ### 4.4 `Plant` and `ClosedLoopSimulator` (simulation) `Plant` is the *interactive* abstraction: `reset()`, `step(u, dt) -> y`. Two implementations ship: `ModelPlant` (wraps any `ProcessModel`, discretizing the underlying TF with a dead-time FIFO) and `FunctionPlant` (wraps a user callable `f(state, u, t) -> dy/dt` integrated with RK4 — the escape hatch for nonlinear plants). `ClosedLoopSimulator` wires controller + plant + reference/disturbance signals and returns a `SimulationResult` (named arrays `t, r, y, u, e` plus metadata) consumed by `metrics`. ## 5. Conventions - **Units**: time is **seconds** throughout. Gains are in engineering units (controller output units per process-variable unit). `ki` has units 1/s times `kp`'s units; `kd` has units s times `kp`'s units. Documented on every dataclass. - **Continuous vs discrete**: models and tuning math are continuous-time; the controller and simulator are discrete-time with explicit `dt`. Discretization method is always a named parameter, never hidden. - **Arrays**: NumPy `float64` 1-D arrays; functions accept array-likes and return `ndarray`. No pandas dependency; `SimulationResult.to_dict()` makes DataFrame construction one line for users who want it. - **Naming**: `snake_case` functions, `CapWords` classes, parameters named after the literature symbol where unambiguous (`gain`, `time_constant`, `dead_time`; `kp`, `ki`, `kd`; `ku`, `tu`). - **Typing**: the whole public surface is fully type-annotated and checked with `mypy --strict`. `py.typed` ships with the first implementation release. - **Docstrings**: NumPy style, with `Parameters`, `Returns`, `Raises`, `Warns`, `References`, and `Examples` sections; examples are doctested in CI from Milestone 2 onward. ## 6. Dependency policy | Dependency | Why | Constraint | | --- | --- | --- | | `numpy` | arrays everywhere | `>=1.24,<3` | | `scipy` | `scipy.signal` (TF math, `cont2discrete`, step/freq response), `scipy.optimize` (optimization tuner, model fitting) | `>=1.10,<2` | | `matplotlib` (optional extra `plot`) | `SimulationResult.plot()` convenience | `>=3.7` | No other runtime dependencies will be accepted without a design-doc amendment. Plotting is import-guarded: importing `pidtune` never imports matplotlib. ## 7. Testing strategy (forward reference) Defined here so contributors implementing Milestone 2+ know the bar: - **Unit tests** per module; classical rules tested against worked examples from the cited literature (e.g. Åström & Hägglund 2006 tables). - **Property tests**: form conversions are lossless round-trips; discrete controller matches continuous PID response as `dt → 0` on smooth inputs. - **Closed-loop acceptance tests**: each tuner, applied to its reference model family, must produce a stable loop with bounded overshoot in simulation. These double as regression tests for the simulator. - Coverage target ≥ 90 % for numerical modules.