"""Fixed-step loop engine and simulation result records. This module is the single place where controllers, plants and signals meet. It defines: * :class:`SimulationOptions` — run configuration (horizon, step, disturbance/noise hooks). * :class:`SimulationResult` — the immutable record every simulation returns; the common currency consumed by :mod:`pidtune.metrics`, plotting helpers, and optimisation-based tuners. * :func:`simulate_closed_loop` / :func:`simulate_open_loop` — the two engine entry points. * :func:`setpoint_step_response` — the one-line convenience wrapper used throughout the documentation. Loop sequencing (normative) --------------------------- Per sample ``k`` (specified in ``docs/design/04_api_specification.md``, section "Loop sequencing", and restated here because implementations and tests must match it exactly): 1. ``r[k] = setpoint(t[k])`` 2. ``u[k] = controller.update(r[k], y_meas[k-1])`` — the controller acts on the *previous* measurement, modelling the one-sample computational delay of a real digital loop. 3. ``u_plant[k] = u[k] + load_disturbance(t[k])`` 4. ``y[k] = plant.step(u_plant[k])`` 5. ``y_meas[k] = y[k] + measurement_noise(t[k])`` ``y_meas[-1]`` is initialised to the plant's initial output. All arrays in the result have identical length ``len(t)``. All callables here are **API stubs** in this milestone. """ from __future__ import annotations import dataclasses from typing import Optional, TYPE_CHECKING import numpy as np from numpy.typing import NDArray from .plant import Plant from .signals import SignalFn if TYPE_CHECKING: # annotation-only imports from ..controllers.pid import PIDController from ..models.base import ProcessModel __all__ = [ "SimulationOptions", "SimulationResult", "simulate_closed_loop", "simulate_open_loop", "setpoint_step_response", ] @dataclasses.dataclass(frozen=True) class SimulationOptions: """Configuration for a simulation run. Collected into a dataclass (rather than loose keyword arguments) so that optimisation-based tuners and batch comparison utilities can pass a single options object through their pipelines, and so that runs are trivially serialisable for reproducibility. Attributes ---------- t_final : float Simulation horizon. Must be strictly positive. A useful rule of thumb is 10–20× the dominant closed-loop time constant. dt : float Fixed sample period for both controller and plant. Must be strictly positive and at most ``t_final``. For faithful PID behaviour choose ``dt`` ≤ one tenth of the smallest relevant time constant. load_disturbance : SignalFn, optional Additive input disturbance injected *after* the controller output (step 3 of the loop sequence). Default ``None`` (zero). measurement_noise : SignalFn, optional Additive measurement corruption applied to the plant output before it is fed back (step 5). Default ``None`` (zero). record_states : bool, optional When ``True``, plants that expose internal state will have it recorded into :attr:`SimulationResult.extras` under the key ``"states"``. Default ``False``. Raises ------ ValueError From ``__post_init__`` if ``t_final`` or ``dt`` is invalid. """ t_final: float dt: float load_disturbance: Optional[SignalFn] = None measurement_noise: Optional[SignalFn] = None record_states: bool = False def __post_init__(self) -> None: if not (self.t_final > 0.0 and np.isfinite(self.t_final)): raise ValueError( f"SimulationOptions.t_final must be a finite positive number, got {self.t_final!r}." ) if not (self.dt > 0.0 and np.isfinite(self.dt)): raise ValueError( f"SimulationOptions.dt must be a finite positive number, got {self.dt!r}." ) if self.dt > self.t_final: raise ValueError( f"SimulationOptions.dt ({self.dt}) must not exceed t_final ({self.t_final})." ) @dataclasses.dataclass(frozen=True) class SimulationResult: """Immutable record of one simulation run. All arrays are 1-D ``float64`` of identical length. Instances are frozen; downstream consumers (metrics, plotting) must not mutate them. For open-loop runs, :attr:`setpoint` and :attr:`error` are ``None``. Attributes ---------- t : numpy.ndarray Sample times, starting at ``0.0``, uniformly spaced by ``dt``. setpoint : numpy.ndarray or None Reference trajectory ``r[k]``; ``None`` for open-loop runs. output : numpy.ndarray True plant output ``y[k]`` (before measurement noise). measured : numpy.ndarray Measurement fed back to the controller, ``y[k] + noise``. Equal to :attr:`output` when no noise hook is configured. control : numpy.ndarray Controller output ``u[k]`` (before load disturbance, after any controller-side saturation). error : numpy.ndarray or None ``setpoint - measured``; ``None`` for open-loop runs. dt : float The sample period used. extras : dict Optional engine extras (e.g. recorded plant states). Keys are engine-defined strings; absent keys mean "not recorded". """ t: NDArray[np.float64] setpoint: Optional[NDArray[np.float64]] output: NDArray[np.float64] measured: NDArray[np.float64] control: NDArray[np.float64] error: Optional[NDArray[np.float64]] dt: float extras: dict = dataclasses.field(default_factory=dict) def __len__(self) -> int: """Number of samples in the run.""" return int(self.t.shape[0]) def to_dict(self) -> dict: """Export as a plain dict of lists (JSON-serialisable). Returns ------- dict Keys mirror the attribute names; array values become ``list[float]``, ``None`` values are preserved, and ``extras`` is included verbatim only if every value is itself JSON-serialisable. Notes ----- Intended for archiving runs alongside tuning reports. The inverse constructor is deliberately omitted from v1 scope; see ``docs/design/06_roadmap.md``. """ raise NotImplementedError( "SimulationResult.to_dict is an API stub; implementation is scheduled " "for the simulation-engine milestone (docs/design/06_roadmap.md)." ) def simulate_closed_loop( plant: Plant, controller: "PIDController", setpoint: SignalFn, options: SimulationOptions, ) -> SimulationResult: """Run a closed-loop simulation of ``controller`` against ``plant``. Executes the normative loop sequence documented at module level: the controller acts on the previous measurement, the plant advances one fixed step per sample, and optional disturbance and noise hooks are applied at their specified injection points. The plant is ``reset()`` and ``initialize(options.dt)``-ed at the start of the run; the controller is likewise ``reset()`` so that a single controller/plant pair can be reused across runs (this is what optimisation-based tuners rely on). Parameters ---------- plant : Plant Any object satisfying the :class:`~pidtune.simulation.plant.Plant` contract. controller : pidtune.controllers.pid.PIDController The controller under test. Its internal sample time is overridden by ``options.dt`` for the duration of the run. setpoint : SignalFn Reference trajectory, evaluated once on the run's time grid. options : SimulationOptions Horizon, step and hooks. Returns ------- SimulationResult Raises ------ pidtune.exceptions.SimulationError If the plant rejects ``options.dt``, or if any signal produces non-finite values, or if the loop diverges beyond the engine's overflow guard (|y| > 1e12), in which case the error message reports the time of divergence. Examples -------- >>> from pidtune.models import FOPDTModel # doctest: +SKIP >>> from pidtune.controllers import PIDController # doctest: +SKIP >>> from pidtune.simulation import (LinearPlant, signals, # doctest: +SKIP ... SimulationOptions, simulate_closed_loop) >>> model = FOPDTModel(gain=2.0, time_constant=10.0, dead_time=3.0) # doctest: +SKIP >>> result = simulate_closed_loop( # doctest: +SKIP ... LinearPlant(model), ... PIDController(gains), ... signals.step(1.0), ... SimulationOptions(t_final=120.0, dt=0.1), ... ) """ raise NotImplementedError( "simulate_closed_loop is an API stub; implementation is scheduled for " "the simulation-engine milestone (docs/design/06_roadmap.md)." ) def simulate_open_loop( plant: Plant, input_signal: SignalFn, options: SimulationOptions, ) -> SimulationResult: """Drive ``plant`` open-loop with ``input_signal``. Used for synthetic identification experiments (generate a step or PRBS response, then feed it to :mod:`pidtune.identification`) and for documentation figures. The ``load_disturbance`` hook in ``options`` is honoured (added to the input); ``measurement_noise`` corrupts the recorded :attr:`SimulationResult.measured` channel. Parameters ---------- plant : Plant Plant to drive. input_signal : SignalFn The input trajectory ``u(t)``. options : SimulationOptions Horizon, step and hooks. Returns ------- SimulationResult With :attr:`SimulationResult.setpoint` and :attr:`SimulationResult.error` set to ``None`` and :attr:`SimulationResult.control` holding the applied input. Raises ------ pidtune.exceptions.SimulationError Under the same conditions as :func:`simulate_closed_loop`. """ raise NotImplementedError( "simulate_open_loop is an API stub; implementation is scheduled for " "the simulation-engine milestone (docs/design/06_roadmap.md)." ) def setpoint_step_response( model: "ProcessModel", controller: "PIDController", amplitude: float = 1.0, t_final: Optional[float] = None, dt: Optional[float] = None, ) -> SimulationResult: """One-line closed-loop step response for a linear process model. Convenience wrapper that builds a :class:`~pidtune.simulation.plant.LinearPlant` from ``model``, a unit (or ``amplitude``) setpoint step at ``t = 0``, and sensible defaults for the grid: * ``t_final`` defaults to ``15 * (time_constant + dead_time)`` as reported by the model's characteristic-time accessor; * ``dt`` defaults to ``min(time_constant, dead_time or inf) / 20``, clamped to at most ``t_final / 200``. Parameters ---------- model : pidtune.models.base.ProcessModel Process model to simulate. controller : pidtune.controllers.pid.PIDController Controller under test. amplitude : float, optional Setpoint step height. Default ``1.0``. t_final : float, optional Override the default horizon. dt : float, optional Override the default sample period. Returns ------- SimulationResult Examples -------- The canonical "tune, then look at it" workflow:: result = setpoint_step_response(model, PIDController(tuning.gains)) report = pidtune.metrics.compute_step_metrics(result) """ raise NotImplementedError( "setpoint_step_response is an API stub; implementation is scheduled for " "the simulation-engine milestone (docs/design/06_roadmap.md)." )