# Design Document 05 — Error Handling & Extensibility Conventions Status: Accepted (Milestone 1) Depends on: [01_architecture.md](01_architecture.md), [04_api_specification.md](04_api_specification.md) --- ## 1. Goals 1. **Fail fast, fail loudly.** Invalid process models, gains, or tuner options must be rejected at construction time with a precise message — never deferred to a confusing numerical failure deep inside a simulation loop. 2. **Distinguish "wrong input" from "method limitation".** A user passing a negative time constant made a mistake; a user asking Cohen-Coon to tune a model with θ/τ = 8 hit a documented limitation of the method. The exception types make that distinction machine-readable. 3. **Warn, don't silently fix.** `pidtune` never silently clamps, rescales, or substitutes values. When a result is usable but questionable (e.g. a tuning rule applied outside its recommended validity range), the library returns the result *and* emits a `PidtuneWarning` subclass, and records the same text in `TuningResult.warnings` so the information survives serialization. 4. **Be extensible without forking.** Third parties must be able to add tuners, process models, simulation signals, and performance metrics without modifying `pidtune` itself. --- ## 2. Exception hierarchy All exceptions live in `pidtune.exceptions` (already delivered) and derive from a single root so callers can write `except PidtuneError:` as a catch-all for library-originated failures: ``` PidtuneError(Exception) ├── ModelError # process-model construction / evaluation problems │ └── ModelValidationError # parameter out of physical/valid range ├── TuningError # a tuner could not produce gains │ ├── UnsupportedModelError # tuner cannot handle this model class/shape │ └── ConvergenceError # iterative/optimization tuner failed to converge ├── SimulationError # closed-loop / plant simulation failures │ └── SolverError # ODE/discretization solver diverged or rejected └── IdentificationError # model fitting from data failed ``` ### 2.1 Raising conventions | Situation | Action | |---|---| | Constructor receives invalid parameter (τ ≤ 0, NaN gain, negative dead time) | raise `ModelValidationError` immediately | | Tuner receives a model type it cannot handle at all | raise `UnsupportedModelError` | | Tuner receives a *supported* model outside the rule's recommended range | proceed, emit `TuningApplicabilityWarning`, append message to `TuningResult.warnings` | | Optimization-based tuner exhausts iterations | raise `ConvergenceError` carrying the best-so-far gains in `.partial_result` | | Simulation produces NaN/Inf state | raise `SolverError` with the time of divergence | | User calls an API kept only for deprecation | emit `DeprecationWarning`, delegate to replacement | ### 2.2 Error message style Messages follow the pattern **what was wrong → what was received → what is valid**: ``` ModelValidationError: FOPDT time constant `tau` must be positive; got tau=-3.0. TuningError: Cohen-Coon requires a dead time theta > 0; got theta=0.0 (use IMC/lambda or AMIGO for delay-free models). ``` Every exception message that involves a parameter includes the parameter *name* and the offending *value*. Suggestions of alternatives ("use IMC/lambda…") are encouraged when the limitation is method-specific. ### 2.3 Exceptions vs. `ValueError`/`TypeError` Plain `TypeError` is used only for genuinely wrong Python types (e.g. passing a string where a model is expected). Anything that is *semantically* invalid for control purposes uses the `PidtuneError` hierarchy, even when `ValueError` would be defensible, so that application code embedding `pidtune` can route library failures distinctly. --- ## 3. Warning hierarchy ``` PidtuneWarning(UserWarning) ├── TuningApplicabilityWarning # rule applied outside its validated θ/τ (etc.) range ├── ModelFitWarning # identification succeeded with poor fit quality └── NumericalAccuracyWarning # discretization step, stiff dynamics, etc. ``` Rules: * Warnings are emitted via `warnings.warn(msg, category, stacklevel=2)` so they point at user code, not library internals. * Any warning emitted *during a tuning call* is also appended (as text) to `TuningResult.warnings`. The result object is therefore self-describing even when the Python warning filter suppressed the runtime warning. * The library never emits bare `UserWarning`. --- ## 4. Validation conventions * **Dataclasses validate in `__post_init__`.** All model and gains dataclasses are frozen and validate every field once; downstream code may assume a constructed object is valid. * **NaN/Inf are always rejected** at construction (checked with `math.isfinite`). * **Tuner options are validated before any computation** so partially-applied side effects cannot occur. * **No `assert` for input validation.** Asserts may be stripped with `python -O`; they are reserved for internal invariants only. --- ## 5. Extensibility ### 5.1 Tuner registry `pidtune.tuners.base` provides a string-keyed registry so applications, CLIs, and config files can refer to tuners by name: ```python from pidtune.tuners import register_tuner, get_tuner, available_tuners @register_tuner # name taken from cls.name class MyShopFloorTuner(Tuner): name = "shopfloor-v2" ... tuner = get_tuner("shopfloor-v2") ``` * Names are case-insensitive, normalized to lowercase, and must match `[a-z0-9][a-z0-9_-]*`. * Re-registering an existing name raises `ValueError` unless `overwrite=True` is passed — silent shadowing of built-ins is a foot-gun. * All built-in tuners self-register at import of `pidtune.tuners`. ### 5.2 Plugin entry points Third-party packages may expose tuners via the `pidtune.tuners` entry-point group: ```toml [project.entry-points."pidtune.tuners"] shopfloor-v2 = "mypkg.tuning:MyShopFloorTuner" ``` `pidtune.tuners.load_entry_point_tuners()` discovers and registers these lazily (it is *not* run at import time, to keep `import pidtune` fast and side-effect free). Discovery errors in one plugin are reported as warnings and do not block other plugins. ### 5.3 Subclassing contracts | To add a… | Subclass | Must implement | May rely on | |---|---|---|---| | Process model | `pidtune.models.base.ProcessModel` | `transfer_function()`, `step_response()`, parameter validation | `frequency_response()` default built on `transfer_function()` | | Tuning method | `pidtune.tuners.base.Tuner` | `name`, `tune()` | `_check_supported()`, `TuningResult` construction helpers | | Plant for simulation | `pidtune.simulation.plant.Plant` | `reset()`, `step(u, dt)` | the loop runner in `pidtune.simulation.loop` | | Test signal | `pidtune.simulation.signals.Signal` | `__call__(t)` | arithmetic composition helpers | | Performance metric | callable `(t, e) -> float` | n/a (plain callable) | `pidtune.metrics` registry decorator | Base classes never call private hooks of subclasses; the documented abstract methods are the entire contract. Anything prefixed `_` may change between minor versions. ### 5.4 Stability & deprecation policy * Public API = everything exported by `pidtune/__init__.py` and the documented module surfaces in [04_api_specification.md](04_api_specification.md). Semantic versioning applies to this surface from `1.0.0` onward. * Deprecations live for **two minor releases** minimum: release N warns (`DeprecationWarning` + changelog entry), release N+2 may remove. * New keyword-only parameters may be added in minor releases; positional signatures of public callables never change without deprecation. * Numerical *results* of tuning rules are part of the contract: a change that alters computed gains for the same inputs (other than a documented bug fix) is treated as breaking. --- ## 6. Logging `pidtune` uses a module-level `logging.getLogger("pidtune")` hierarchy with a `NullHandler` attached at the package root. The library never configures handlers, formats, or levels — that is the embedding application's job. DEBUG-level logs trace tuner option resolution and solver step decisions; nothing above DEBUG is emitted in normal successful operation (warnings go through `warnings`, failures through exceptions).