"""Excitation plugin registry: the inputs that drive a response simulation.
An excitation is anything that produces the exogenous input d(t) for a
closed-loop experiment. In this regulator framework v1 excitations enter as
an *input disturbance at the plant input*, u = u_ctrl + d(t) — the same
convention the benchmark's step metrics use. Pure initial-condition
experiments use the ``zero`` excitation with an x0.
Registering a new excitation mirrors the controller registry exactly::
from state_space_control.excitations import Excitation, register_excitation
@register_excitation('chirp')
class Chirp(Excitation):
PARAMS = [{'name': 'f0', 'default': 0.1}, ...]
def sample(self, t): ...
and it appears in the wizard/CLI with no other change. The ``injection``
class attribute is 'input' for everything in v1; 'reference' and 'output'
are reserved so reference tracking and measurement disturbances can be
added later without renaming anything.
"""
from typing import Dict, List, Type
import numpy as np
[docs]
class Excitation:
"""Base class. Parameters go in __init__; ``sample`` maps a time grid
to the input samples d(t) with shape (len(t),)."""
injection = 'input'
PARAMS: List[Dict] = []
[docs]
def sample(self, t: np.ndarray) -> np.ndarray:
raise NotImplementedError
[docs]
def describe(self) -> str:
return getattr(self, 'registry_name', type(self).__name__)
_REGISTRY: Dict[str, Type[Excitation]] = {}
[docs]
def register_excitation(name: str):
def deco(cls: Type[Excitation]):
_REGISTRY[name] = cls
cls.registry_name = name
return cls
return deco
[docs]
def make_excitation(name: str, **params) -> Excitation:
if name not in _REGISTRY:
raise ValueError(f'Unknown excitation {name!r}; '
f'available: {available_excitations()}')
return _REGISTRY[name](**params)
[docs]
def available_excitations() -> List[str]:
return sorted(_REGISTRY)
[docs]
def excitation_schemas() -> List[Dict]:
"""UI form metadata, same shape as the controller schemas."""
out = []
for name in available_excitations():
cls = _REGISTRY[name]
out.append({'name': name, 'doc': (cls.__doc__ or '').strip(),
'injection': cls.injection,
'params': list(cls.PARAMS)})
return out
def _num(x, name):
try:
return float(x)
except (TypeError, ValueError):
raise ValueError(f'{name} must be a number, got {x!r}')
[docs]
@register_excitation('step')
class Step(Excitation):
"""Constant disturbance switched on at t_start."""
PARAMS = [
{'name': 'amplitude', 'default': 1.0, 'doc': 'step height'},
{'name': 't_start', 'default': 0.0, 'doc': 'switch-on time [s]'},
]
def __init__(self, amplitude=1.0, t_start=0.0):
self.amplitude = _num(amplitude, 'amplitude')
self.t_start = _num(t_start, 't_start')
[docs]
def sample(self, t):
return np.where(np.asarray(t) >= self.t_start, self.amplitude, 0.0)
[docs]
@register_excitation('impulse')
class Impulse(Excitation):
"""Ideal impulse of the given area, realized exactly as the equivalent
initial-state jump x0 += B[:, channel] * area (a sampled 1-step pulse
would depend on the grid spacing; the LTI equivalence does not).
``sample`` therefore returns zeros — the simulator applies the jump."""
PARAMS = [{'name': 'area', 'default': 1.0,
'doc': 'impulse area (N*m*s for torque inputs)'}]
def __init__(self, area=1.0):
self.area = _num(area, 'area')
[docs]
def sample(self, t):
return np.zeros(len(np.asarray(t)))
[docs]
@register_excitation('ramp')
class Ramp(Excitation):
"""Linearly growing disturbance, optionally saturating."""
PARAMS = [
{'name': 'slope', 'default': 1.0, 'doc': 'growth rate [1/s]'},
{'name': 't_start', 'default': 0.0, 'doc': 'onset time [s]'},
{'name': 'saturation', 'default': None,
'doc': 'clip magnitude (empty = unbounded)'},
]
def __init__(self, slope=1.0, t_start=0.0, saturation=None):
self.slope = _num(slope, 'slope')
self.t_start = _num(t_start, 't_start')
self.saturation = None if saturation in (None, '') \
else abs(_num(saturation, 'saturation'))
[docs]
def sample(self, t):
d = self.slope * np.maximum(np.asarray(t, dtype=float) - self.t_start,
0.0)
if self.saturation is not None:
d = np.clip(d, -self.saturation, self.saturation)
return d
[docs]
@register_excitation('sine')
class Sine(Excitation):
"""Sinusoidal disturbance amplitude*sin(2*pi*freq_hz*t + phase)."""
PARAMS = [
{'name': 'amplitude', 'default': 1.0},
{'name': 'freq_hz', 'default': 0.5, 'doc': 'frequency [Hz]'},
{'name': 'phase', 'default': 0.0, 'doc': 'phase [rad]'},
]
def __init__(self, amplitude=1.0, freq_hz=0.5, phase=0.0):
self.amplitude = _num(amplitude, 'amplitude')
self.freq_hz = _num(freq_hz, 'freq_hz')
self.phase = _num(phase, 'phase')
[docs]
def sample(self, t):
return self.amplitude * np.sin(
2.0 * np.pi * self.freq_hz * np.asarray(t, dtype=float)
+ self.phase)
[docs]
@register_excitation('custom')
class Custom(Excitation):
"""User-supplied samples, linearly interpolated (zero outside the
given span)."""
PARAMS = [
{'name': 't_samples', 'default': [], 'doc': 'time points [s]'},
{'name': 'u_samples', 'default': [], 'doc': 'values at those times'},
]
def __init__(self, t_samples=(), u_samples=()):
ts = np.asarray(t_samples, dtype=float)
us = np.asarray(u_samples, dtype=float)
if ts.ndim != 1 or ts.shape != us.shape or len(ts) < 2:
raise ValueError('custom excitation needs matching 1-D t_samples/'
'u_samples with at least 2 points')
if np.any(np.diff(ts) <= 0):
raise ValueError('custom t_samples must be strictly increasing')
if not np.all(np.isfinite(us)):
raise ValueError('custom u_samples must be finite')
self.ts, self.us = ts, us
[docs]
def sample(self, t):
return np.interp(np.asarray(t, dtype=float), self.ts, self.us,
left=0.0, right=0.0)
[docs]
@register_excitation('zero')
class Zero(Excitation):
"""No input — for pure initial-condition experiments (set x0)."""
PARAMS = []
[docs]
def sample(self, t):
return np.zeros(len(np.asarray(t)))