"""Optimization-based PID tuning by closed-loop simulation. Searches gain space to minimize a time-domain performance criterion (IAE, ISE, ITAE, ITSE) evaluated by simulating the closed loop, optionally subject to frequency-domain robustness constraints (maximum sensitivity ``Ms``, gain/phase margins). Backends are provided by SciPy: ``scipy.optimize.minimize`` (default Nelder-Mead, derivative-free) and ``scipy.optimize.differential_evolution`` for global search. Targeted SciPy: ``scipy>=1.10``. Survey context: ``docs/design/03_tuning_methods.md`` §7. """ from __future__ import annotations from dataclasses import dataclass from typing import Any, ClassVar, Optional, Tuple from pidtune.models.base import ProcessModel from pidtune.tuners.base import ControllerType, Tuner, TuningResult, register_tuner __all__ = ["ObjectiveSpec", "ConstraintSpec", "OptimizationTuner"] @dataclass(frozen=True) class ObjectiveSpec: """What the optimizer minimizes. Attributes ---------- criterion: One of ``"IAE"``, ``"ISE"``, ``"ITAE"``, ``"ITSE"`` (see :mod:`pidtune.metrics` for definitions). Default ``"IAE"``. setpoint_weight: Weight of the setpoint-step scenario in the combined objective. disturbance_weight: Weight of the load-disturbance-step scenario. The default ``(0.5, 0.5)`` balances tracking and regulation; set one weight to zero to optimize for a single scenario. control_effort_weight: Optional penalty multiplier on total variation of the control signal, discouraging aggressive, actuator-wearing solutions. ``0.0`` disables it. horizon: Simulation horizon for objective evaluation, as a multiple of the model's dominant time constant (``None`` ⇒ auto, 20×). """ criterion: str = "IAE" setpoint_weight: float = 0.5 disturbance_weight: float = 0.5 control_effort_weight: float = 0.0 horizon: Optional[float] = None @dataclass(frozen=True) class ConstraintSpec: """Robustness constraints enforced during optimization. Constraints are evaluated on the loop transfer function ``L = C·G`` via the model's frequency response and enforced with a smooth penalty (Nelder-Mead) or as explicit constraints (differential evolution). Attributes ---------- max_sensitivity: Upper bound on ``Ms = max|1/(1+L(jω))|``. Typical robust values are 1.2–2.0; ``None`` disables. Default 2.0. min_gain_margin: Lower bound on the gain margin (absolute ratio, e.g. ``2.0`` for 6 dB). ``None`` disables. min_phase_margin: Lower bound on the phase margin in degrees. ``None`` disables. """ max_sensitivity: Optional[float] = 2.0 min_gain_margin: Optional[float] = None min_phase_margin: Optional[float] = None @register_tuner class OptimizationTuner(Tuner): """Tune PID gains by constrained numerical optimization. Works with **any** :class:`~pidtune.models.base.ProcessModel` — FOPDT, SOPDT, or arbitrary :class:`~pidtune.models.transfer_function.TransferFunction` — since it only requires the ability to simulate the closed loop and evaluate a frequency response. Workflow per :meth:`tune` call: 1. Obtain an initial guess: from ``initial_gains`` if given, otherwise from the ``warm_start`` rule-based tuner (default ``"amigo"`` for FOPDT, ``"imc"`` otherwise). 2. Optimize ``log``-scaled (Kp, Ti, Td) — log scaling keeps gains positive and conditions the search. 3. Verify constraints on the final point; if violated beyond tolerance, raise :class:`~pidtune.exceptions.ConvergenceError` carrying the best feasible iterate found (``.partial_result``). Determinism: with ``method="differential_evolution"`` pass ``seed`` for reproducible results. ``TuningResult.metadata`` keys: ``{"objective_value", "criterion", "iterations", "method", "ms_achieved", "warm_start"}``. """ name: ClassVar[str] = "optimize" supported_controller_types: ClassVar[frozenset] = frozenset( {ControllerType.PI, ControllerType.PID} ) def __init__( self, *, objective: ObjectiveSpec | None = None, constraints: ConstraintSpec | None = None, method: str = "nelder-mead", max_iterations: int = 500, seed: Optional[int] = None, ) -> None: """Configure the optimizer. Parameters ---------- objective: Objective definition; ``None`` uses :class:`ObjectiveSpec` defaults (balanced IAE). constraints: Robustness constraints; ``None`` uses :class:`ConstraintSpec` defaults (``Ms ≤ 2.0`` only). method: ``"nelder-mead"`` (local, derivative-free; default) or ``"differential_evolution"`` (global, slower). max_iterations: Iteration budget; exceeding it without meeting convergence tolerances raises :class:`~pidtune.exceptions.ConvergenceError`. seed: Random seed for stochastic backends. """ self.objective = objective if objective is not None else ObjectiveSpec() self.constraints = constraints if constraints is not None else ConstraintSpec() self.method = method self.max_iterations = int(max_iterations) self.seed = seed def tune( self, model: ProcessModel, *, controller_type: ControllerType = ControllerType.PID, initial_gains: Any = None, warm_start: str | None = None, bounds: Optional[Tuple[Tuple[float, float], ...]] = None, **options: Any, ) -> TuningResult: """Optimize controller gains for ``model``. Parameters ---------- model: Any process model supporting simulation and frequency response. controller_type: PI or PID (the search space dimension follows: 2 or 3). initial_gains: Optional :class:`~pidtune.controllers.gains.PIDGains` starting point; overrides ``warm_start``. warm_start: Registry name of a rule-based tuner used to seed the search. ``None`` selects an appropriate default for the model class. bounds: Optional ``((kp_lo, kp_hi), (ti_lo, ti_hi)[, (td_lo, td_hi)])`` box bounds; required by the differential-evolution backend, auto-derived from the warm start (±2 decades) if omitted. **options: No further options are accepted. Raises ------ pidtune.exceptions.ConvergenceError If the iteration budget is exhausted or no constraint-feasible point is found; carries the best iterate in ``exc.partial_result``. """ raise NotImplementedError("Implemented in Milestone 6.")