"""Abstract tuner interface, tuning results, and the tuner registry. Design references: ``docs/design/04_api_specification.md`` (tuner interface) and ``docs/design/05_errors_extensibility.md`` (registry, plugins, warning conventions). The contract for all tuners --------------------------- * A tuner is **stateless and reusable**: options passed to the constructor configure *how* it tunes; :meth:`Tuner.tune` may be called any number of times with different models. * ``tune()`` either returns a complete :class:`TuningResult` or raises a subclass of :class:`pidtune.exceptions.TuningError`. It never returns partial results or ``None``. * Tuners must not mutate the model they receive. * When a rule is applied outside its recommended validity range, the tuner emits a :class:`~pidtune.exceptions.TuningApplicabilityWarning` *and* records the same message in :attr:`TuningResult.warnings`. """ from __future__ import annotations import re from abc import ABC, abstractmethod from dataclasses import dataclass, field from enum import Enum from typing import Any, Callable, ClassVar, Dict, Mapping, Tuple, Type from pidtune.controllers.gains import PIDGains from pidtune.exceptions import UnsupportedModelError from pidtune.models.base import ProcessModel __all__ = [ "ControllerType", "TuningResult", "Tuner", "register_tuner", "get_tuner", "available_tuners", "load_entry_point_tuners", ] class ControllerType(str, Enum): """The controller structure a tuning rule should target. Not every tuner supports every structure; the supported set is declared in :attr:`Tuner.supported_controller_types` and violating it raises :class:`~pidtune.exceptions.TuningError`. """ P = "P" PI = "PI" PD = "PD" PID = "PID" @dataclass(frozen=True) class TuningResult: """The outcome of a tuning computation. Attributes ---------- gains: Recommended controller gains (ideal/ISA form unless the producing tuner documents otherwise; the form is recorded on ``gains`` itself). method: Registry name of the tuner that produced this result (e.g. ``"amigo"``). controller_type: The controller structure the gains are intended for. model: The process model the tuning was based on, if any. Relay autotuning from a live experiment may legitimately have ``model=None``. metadata: Method-specific extras, e.g. ``{"ku": 4.2, "pu": 12.5}`` for ultimate-cycle methods or ``{"lambda": 8.0}`` for IMC. Keys are documented per tuner. Values must be JSON-serializable. warnings: Human-readable warning messages emitted during tuning (see design doc 05 §3). Empty tuple when the rule applied cleanly. Notes ----- Instances are frozen; derive modified copies with :func:`dataclasses.replace`. """ gains: PIDGains method: str controller_type: ControllerType model: ProcessModel | None = None metadata: Mapping[str, Any] = field(default_factory=dict) warnings: Tuple[str, ...] = () def to_dict(self) -> Dict[str, Any]: """Serialize the result to a plain, JSON-compatible dictionary. The ``model`` field is serialized via ``model.to_dict()`` when present, ``None`` otherwise. Round-trips with :meth:`TuningResult.from_dict` (implemented in Milestone 3). """ raise NotImplementedError("Implemented in Milestone 3.") @classmethod def from_dict(cls, data: Mapping[str, Any]) -> "TuningResult": """Reconstruct a :class:`TuningResult` produced by :meth:`to_dict`. Raises ------ ValueError If required keys are missing or have invalid values. """ raise NotImplementedError("Implemented in Milestone 3.") class Tuner(ABC): """Abstract base class for all tuning methods. Subclass contract ----------------- * Set the class attribute :attr:`name` (registry key; lowercase, ``[a-z0-9][a-z0-9_-]*``). * Set :attr:`supported_controller_types` to the structures the method defines rules for. * Implement :meth:`tune`. Use :meth:`_require_supported_controller` and raise :class:`~pidtune.exceptions.UnsupportedModelError` for model classes the method cannot consume. Built-in subclasses are registered with :func:`register_tuner` so they resolve via :func:`get_tuner`. """ #: Registry name of the tuner. Must be overridden by subclasses. name: ClassVar[str] #: Controller structures this method can produce gains for. supported_controller_types: ClassVar[frozenset] = frozenset( {ControllerType.PI, ControllerType.PID} ) @abstractmethod def tune( self, model: ProcessModel, *, controller_type: ControllerType = ControllerType.PID, **options: Any, ) -> TuningResult: """Compute controller gains for ``model``. Parameters ---------- model: The process model to tune against. Each tuner documents which model classes it accepts (most rule-based tuners require :class:`~pidtune.models.fopdt.FOPDT`). controller_type: Desired controller structure. Must be a member of :attr:`supported_controller_types`. **options: Method-specific keyword options, documented per subclass. Unknown options raise :class:`TypeError`. Returns ------- TuningResult Raises ------ pidtune.exceptions.UnsupportedModelError If the model class or shape cannot be handled. pidtune.exceptions.TuningError If gains cannot be computed (e.g. zero dead time for a rule that divides by it). """ # ------------------------------------------------------------------ # Helpers available to subclasses # ------------------------------------------------------------------ def supports(self, model: ProcessModel) -> bool: """Return ``True`` if :meth:`tune` would accept ``model``. The default implementation is optimistic (returns ``True``); subclasses with restricted model support override it. This is a cheap structural check only — it does not guarantee the *rule* is within its recommended applicability range. """ return True def _require_supported_controller(self, controller_type: ControllerType) -> None: """Raise :class:`~pidtune.exceptions.TuningError` for unsupported structures. Subclasses call this at the top of :meth:`tune`. """ raise NotImplementedError("Implemented in Milestone 3.") def _require_model_type( self, model: ProcessModel, *types: Type[ProcessModel] ) -> None: """Raise :class:`UnsupportedModelError` unless ``model`` is one of ``types``.""" if not isinstance(model, types): accepted = ", ".join(t.__name__ for t in types) raise UnsupportedModelError( f"{type(self).__name__} accepts model types ({accepted}); " f"got {type(model).__name__}. Consider identifying a reduced " f"model first (see pidtune.identification)." ) # ---------------------------------------------------------------------- # Registry (design doc 05 §5.1–5.2) # ---------------------------------------------------------------------- _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _TUNER_REGISTRY: Dict[str, Type[Tuner]] = {} def register_tuner( cls: Type[Tuner] | None = None, *, name: str | None = None, overwrite: bool = False, ) -> Type[Tuner] | Callable[[Type[Tuner]], Type[Tuner]]: """Register a :class:`Tuner` subclass in the global name registry. Usable as a bare decorator (``@register_tuner``, name taken from ``cls.name``) or with arguments (``@register_tuner(name="my-tuner")``). Parameters ---------- cls: The tuner class (when used as a bare decorator). name: Override the registry key; defaults to ``cls.name``. overwrite: Allow replacing an existing registration. Defaults to ``False``; accidental shadowing raises :class:`ValueError`. Raises ------ ValueError If the name is malformed, missing, or already registered (and ``overwrite`` is false). TypeError If ``cls`` is not a :class:`Tuner` subclass. """ def _register(klass: Type[Tuner]) -> Type[Tuner]: if not (isinstance(klass, type) and issubclass(klass, Tuner)): raise TypeError( f"register_tuner expects a Tuner subclass; got {klass!r}." ) key = (name or getattr(klass, "name", "")).strip().lower() if not key or not _NAME_RE.match(key): raise ValueError( f"Invalid tuner name {key!r}; expected pattern " f"'[a-z0-9][a-z0-9_-]*'. Set the `name` class attribute or " f"pass name=... to register_tuner." ) if key in _TUNER_REGISTRY and not overwrite: raise ValueError( f"A tuner named {key!r} is already registered " f"({_TUNER_REGISTRY[key].__qualname__}); pass overwrite=True " f"to replace it." ) _TUNER_REGISTRY[key] = klass return klass if cls is not None: return _register(cls) return _register def get_tuner(name: str, /, **constructor_options: Any) -> Tuner: """Instantiate a registered tuner by name. Parameters ---------- name: Case-insensitive registry key (see :func:`available_tuners`). **constructor_options: Forwarded to the tuner class constructor. Raises ------ KeyError If no tuner with that name is registered. The error message lists the available names. """ key = name.strip().lower() try: klass = _TUNER_REGISTRY[key] except KeyError: known = ", ".join(sorted(_TUNER_REGISTRY)) or "" raise KeyError( f"No tuner registered under {name!r}. Available tuners: {known}. " f"Third-party tuners may need load_entry_point_tuners() first." ) from None return klass(**constructor_options) def available_tuners() -> Tuple[str, ...]: """Return the sorted names of all currently registered tuners.""" return tuple(sorted(_TUNER_REGISTRY)) def load_entry_point_tuners() -> Tuple[str, ...]: """Discover and register tuners exposed via entry points. Scans the ``pidtune.tuners`` entry-point group (see design doc 05 §5.2), imports each advertised class, and registers it under its entry-point name. Failures in individual plugins are reported as :class:`~pidtune.exceptions.PidtuneWarning` and do not abort discovery. Returns ------- tuple of str The names that were newly registered by this call. Notes ----- Discovery is *not* performed automatically at import time, keeping ``import pidtune`` fast and side-effect free. """ raise NotImplementedError("Implemented in Milestone 3.")