# pidtune — Process-Model Data Model *Design document 2 of 6 · Milestone 1* This document specifies the data model for process descriptions: the `ProcessModel` interface and the three concrete models — `FOPDT`, `SOPDT`, and `TransferFunction`. These types are Layer 1 of the architecture and the lingua franca between identification, tuning, and simulation. ## 1. Requirements 1. Rule-based tuners need *named physical parameters* (process gain, time constants, dead time) — not polynomial coefficients. 2. Generic machinery (simulation, frequency response, optimization tuning) needs a *uniform numerical representation* — a rational transfer function plus dead time. 3. Models must be **immutable value objects**: hashable-by-value semantics, safe to attach to `TuningResult`s, cheap to copy, reproducible reprs. 4. Validation happens at construction; an instance that exists is valid. 5. Dead time (transport delay) is a *first-class field*, never approximated at the model layer. Padé approximation, where needed, is an explicit, parameterized operation (`TransferFunction.pade(order=...)`), because the approximation order materially affects tuning math. ## 2. The `ProcessModel` interface `pidtune.models.base.ProcessModel` is an ABC with the following contract (full signatures in doc 04 §2): | Member | Contract | | --- | --- | | `to_transfer_function()` | Exact lowering to `TransferFunction` (rational part + `dead_time` field). Never lossy. | | `frequency_response(w)` | Complex response `G(jw)` including the dead-time term `exp(-j·w·L)`. Vectorized over `w`. | | `step_response(t)` | Open-loop unit-step output at times `t` (continuous-time, computed via `scipy.signal`). | | `dead_time` (property) | Transport delay `L ≥ 0` in seconds. | | `static_gain` (property) | `G(0)`; `±inf` for integrating processes, with `is_integrating` flag. | | `normalized_dead_time` (property) | `τ = L / (L + T)` where defined; `None` otherwise. Used for tuning-rule applicability checks (doc 03). | All models implement `__eq__`/`__hash__` by value and a `repr` that round-trips through `eval` given the `pidtune` namespace. ## 3. `FOPDT` — first-order plus dead time The workhorse of process control: ``` K · e^(−L·s) G(s) = ─────────────── T·s + 1 ``` ### Fields | Field | Symbol | Type | Constraint | | --- | --- | --- | --- | | `gain` | K | `float` | finite, `!= 0` | | `time_constant` | T | `float` | `> 0` | | `dead_time` | L | `float` | `>= 0` (default `0.0`) | Negative `gain` is **allowed** (reverse-acting processes); tuners handle the sign by tuning against `|K|` and propagating the sign to `kp`, emitting a `ModelApplicabilityWarning` so users notice. ### Derived quantities - `normalized_dead_time` = `L / (L + T)` ∈ [0, 1). Classical rules quote validity in terms of `L/T` or this τ; tuners check it (doc 03 §1). - `to_transfer_function()` → `TransferFunction(num=[K], den=[T, 1], dead_time=L)`. ### Integrating variant Integrating processes (`G(s) = K·e^(−Ls)/s`) are common (levels, positions) and several rules (SIMC, AMIGO) have dedicated formulas. Rather than a separate class, `FOPDT` supports `time_constant=math.inf` **explicitly rejected**; instead use the classmethod `FOPDT.integrating(gain, dead_time)` which returns an `IntegratingFOPDT` instance (subclass) with `is_integrating = True` and `static_gain = ±inf`. This keeps the type honest: code that requires a self-regulating process type-checks against plain `FOPDT` and the subclass overrides what differs. Rationale: a magic `inf` field silently breaks formulas like `L/(L+T)`; a distinct type fails loudly. ## 4. `SOPDT` — second-order plus dead time ``` K · e^(−L·s) G(s) = ───────────────────────────────── (T1·s + 1) · (T2·s + 1) (overdamped form) K · e^(−L·s) G(s) = ───────────────────────────────── (s/wn)² + 2·ζ·(s/wn) + 1 (underdamped form) ``` One class, two construction paths, one canonical internal storage: - `SOPDT(gain, time_constant_1, time_constant_2, dead_time=0.0)` — overdamped/critically-damped, `T1 >= T2 > 0` (normalized on construction; passing them swapped is accepted and reordered). - `SOPDT.underdamped(gain, natural_frequency, damping, dead_time=0.0)` — `wn > 0`, `0 < ζ`; for `ζ >= 1` this constructor *delegates* to the time-constant form so there is exactly one representation per dynamics. Internally the model stores `(gain, wn, zeta, dead_time)`; `time_constant_1/2` are properties defined for `ζ >= 1` and raise `AttributeError` with a clear message for underdamped instances. `damping` and `natural_frequency` are always defined. This avoids the classic bug of two equal dynamics comparing unequal. Optionally `SOPDT` carries a numerator zero (`lead_time`, default `None`) giving `K·(Tz·s+1)e^(−Ls)/(...)` — required for IMC rules on processes with inverse response (`Tz < 0`). ## 5. `TransferFunction` — arbitrary rational TF + dead time ``` b_m·s^m + … + b_1·s + b_0 G(s) = ─────────────────────────── · e^(−L·s) a_n·s^n + … + a_1·s + a_0 ``` ### Fields | Field | Type | Constraint | | --- | --- | --- | | `num` | `tuple[float, ...]` | descending powers of `s`; not all zero | | `den` | `tuple[float, ...]` | descending; leading coefficient `!= 0`; `len(den) >= len(num)` (proper) unless `allow_improper=True` | | `dead_time` | `float` | `>= 0` | Coefficients are normalized on construction (leading denominator coefficient scaled to 1, trailing zero-pairs in num/den cancelled with a relative tolerance, documented) so equality and hashing behave. ### Operations - Arithmetic: `+`, `-`, `*`, `feedback(other=1.0)` for block-diagram composition. Dead times **add** under `*`; under `+`/`feedback`, mixing *different* nonzero dead times raises `UnsupportedModelError` (an exact rational result does not exist) unless the user first calls `.pade(order)`. - `pade(order=1)` → `TransferFunction` with `dead_time == 0`, the delay replaced by an order-`order` Padé approximant. Always explicit. - `poles()`, `zeros()` → `ndarray[complex]` via `numpy.roots`. - `is_stable()` → all poles strictly in the open left half-plane. - `frequency_response(w)`, `step_response(t)` per the `ProcessModel` contract (backed by `scipy.signal.TransferFunction` / `freqresp` / `step`, with the delay applied analytically in frequency domain and by output-shifting with interpolation in time domain). - `reduce(model=FOPDT, method="frequency", ...)` (Milestone 4): fit a lower-order model — the bridge that lets rule-based tuners serve users who only have a high-order TF. Methods: half-rule (Skogestad), step-response fit, frequency-domain fit. ### Discretization `to_discrete(dt, method="zoh") -> DiscreteTransferFunction` (internal-ish but public): used by `ModelPlant`. The fractional part of `dead_time/dt` is handled by Thiran allpass interpolation (`order=1` default) or rounding (`delay_handling="round"`), chosen explicitly. This is documented because sub-sample delay handling visibly changes relay-autotune results at coarse `dt`. ## 6. Identification data model `pidtune.identification` produces models from data. Its result type `FitResult` (doc 04 §6) bundles: the fitted `ProcessModel`, residual norm, R², per-parameter standard errors (from the Jacobian at the optimum), the data slice actually used, and structured warnings (e.g. `IdentificationWarning("step amplitude near noise floor")`). The fitted model itself remains a plain `FOPDT`/`SOPDT` — fit metadata never contaminates the model type. ## 7. Serialization All models and `PIDGains`/`TuningResult` implement `to_dict()` / `from_dict()` with a `"type"` discriminator and a `"version"` field (currently `1`). JSON-compatible scalars only. This is the supported persistence path; pickling works but is not a compatibility promise. ## 8. Validation summary Construction-time validation raises `ModelValidationError` (subclass of `PidtuneError` and `ValueError`, doc 05) with the offending field named in `err.field`. NaN/inf anywhere → rejected. Non-finite arrays passed to `frequency_response`/`step_response` → `ValueError` from shared `_validation` helpers with consistent messages.