diff --git a/docs/source/images/steps_1.png b/docs/source/images/steps_1.png new file mode 100644 index 00000000..ca3e9f66 Binary files /dev/null and b/docs/source/images/steps_1.png differ diff --git a/docs/source/images/steps_2.png b/docs/source/images/steps_2.png new file mode 100644 index 00000000..dddfc95b Binary files /dev/null and b/docs/source/images/steps_2.png differ diff --git a/docs/source/tendencies.rst b/docs/source/tendencies.rst index 0dd3bd45..37eca9fd 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -227,6 +227,53 @@ Parameters .. warning:: This tendency does **not** accept the common ``start``, ``duration``, or ``end`` parameters. These are derived directly from the required ``time`` list. +.. _steps-tendency: + +Steps Tendency +============== + +Defines a a sequence of steps, each held constant from its time point until the next one +(or until the end of the tendency for the last step). This is a compact alternative to +writing out a sequence of :ref:`Constant Tendencies `. + +Parameters +---------- +* ``time``: A list of the start times of each step. Must be strictly monotonically increasing and must have at least 1 point. +* ``value``: A list of the values held during each step. Must have the same length as ``time``. + Just like the :ref:`Constant Tendency `, the ``value`` list may contain numbers or strings: +* ``duration``, ``end``: See :ref:`Common Time Parameters `. + Either may be used to specify when the last step ends. + If both are omitted, the tendency simply stops at the last point in the ``time`` list. + +.. image:: images/steps_1.png + :alt: Example plot of a Steps Tendency + :width: 400px + :align: center + +.. code-block:: yaml + + - {type: steps, time: [0, 2, 4], value: [1, 3, 5], end: 6} + +The same tendency can equivalently be written using ``duration`` instead of ``end``: + +.. code-block:: yaml + + - {type: steps, time: [0, 2, 4], value: [1, 3, 5], duration: 6} + +Just like the :ref:`Constant Tendency `, the ``value`` list may contain numbers or strings: + +.. image:: images/steps_2.png + :alt: Example plot of a Steps Tendency containing string values + :width: 400px + :align: center + +.. code-block:: yaml + + - {type: steps, time: [0, 10, 20], value: [ohmic, nbi, ec], end: 30} + +.. warning:: + This tendency does not accept the common ``start`` parameter. The start of the tendency is always derived from the first point in ``time``. + Periodic Tendencies =================== diff --git a/tests/tendencies/test_steps.py b/tests/tendencies/test_steps.py new file mode 100644 index 00000000..a8a1b09d --- /dev/null +++ b/tests/tendencies/test_steps.py @@ -0,0 +1,185 @@ +import numpy as np +import pytest + +from waveform_editor.tendencies.steps import StepsTendency + + +def test_empty(): + """Test empty tendency.""" + tendency = StepsTendency() + assert tendency.annotations + + tendency = StepsTendency(user_time=[0, 2, 4]) + assert tendency.annotations + + tendency = StepsTendency(user_value=[1, 2, 3]) + assert tendency.annotations + + +def test_filled(): + """Test value of a filled tendency.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=6) + assert np.all(tendency.time == np.array([0, 2, 4])) + assert list(tendency.value) == [1, 3, 5] + assert tendency.value_type is int + assert tendency.start == 0 + assert tendency.end == 6 + assert not tendency.annotations + + +def test_string_values(): + """Test a tendency with string values.""" + tendency = StepsTendency( + user_time=[0, 10, 20], user_value=["ohmic", "nbi", "ec"], user_end=30 + ) + assert list(tendency.value) == ["ohmic", "nbi", "ec"] + assert tendency.value_type is str + assert not tendency.annotations + + _, values = tendency.get_value(np.array([0, 5, 10, 15, 20, 25, 30])) + assert list(values) == ["ohmic", "ohmic", "nbi", "nbi", "ec", "ec", "ec"] + + +def test_int_float_mixing(): + """Test that mixing int and float values in a single steps tendency""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 2.5, 3], user_end=6) + assert tendency.value_type is float + assert not tendency.annotations + + +def test_mixed_value_types_not_supported(): + """Test that mixing string and numerical values is not allowed.""" + tendency = StepsTendency(user_time=[0, 10], user_value=[1, "ec"], user_end=20) + assert tendency.annotations + + +def test_unsupported_value_type(): + """Test that a value which is not an int, float, or str is not supported.""" + tendency = StepsTendency(user_time=[0, 10], user_value=[1, [2, 3]], user_end=20) + assert tendency.annotations + + +def test_duration_instead_of_end(): + """Test that `duration` can be used instead of `end`.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_duration=6) + assert tendency.end == 6 + assert not tendency.annotations + + +def test_duration_and_end_consistent(): + """Test that providing both `duration` and `end` is fine if consistent.""" + tendency = StepsTendency( + user_time=[0, 2, 4], user_value=[1, 3, 5], user_duration=6, user_end=6 + ) + assert tendency.end == 6 + assert not tendency.annotations + + +def test_duration_and_end_inconsistent(): + """Test that providing inconsistent `duration` and `end` results in an error.""" + tendency = StepsTendency( + user_time=[0, 2, 4], user_value=[1, 3, 5], user_duration=6, user_end=10 + ) + assert tendency.annotations + + +def test_neither_duration_nor_end_given(): + """Test that omitting both `duration` and `end` makes the tendency stop at its + last time point.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5]) + assert tendency.end == 4 + assert tendency.duration == 4 + assert not tendency.annotations + + _, values = tendency.get_value(np.array([0, 1, 2, 3, 4])) + assert list(values) == [1, 1, 3, 3, 5] + + +def test_end_not_after_last_time(): + """Test invalid end and duration values.""" + tendency = StepsTendency(user_time=[0, 10], user_value=[1, 2], user_end=10) + assert tendency.annotations + + tendency = StepsTendency(user_time=[0, 10], user_value=[1, 2], user_end=5) + assert tendency.annotations + + tendency = StepsTendency(user_time=[0, 10], user_value=[1, 2], user_duration=5) + assert tendency.annotations + + +def test_mismatched_lengths(): + """Test that time and value arrays must have the same length.""" + tendency = StepsTendency(user_time=[0, 10, 20], user_value=[1, 2], user_end=30) + assert tendency.annotations + + +def test_empty_arrays(): + """Test that time and value arrays must contain at least one element.""" + tendency = StepsTendency(user_time=[], user_value=[], user_end=10) + assert tendency.annotations + + +def test_non_monotonic_time(): + """Test that the time array must be monotonically increasing.""" + tendency = StepsTendency(user_time=[0, 10, 5], user_value=[1, 2, 3], user_end=20) + assert tendency.annotations + + tendency = StepsTendency(user_time=[0, 10, 10], user_value=[1, 2, 3], user_end=20) + assert tendency.annotations + + +def test_start_not_allowed(): + """Test that `start` may not be provided""" + tendency = StepsTendency( + user_time=[0, 10], user_value=[1, 2], user_end=20, user_start=5 + ) + assert tendency.annotations + + +def test_start_and_end_values(): + """Test the start and end values and their derivatives.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=6) + assert tendency.start_value == 1 + assert tendency.end_value == 5 + assert tendency.start_derivative == 0 + assert tendency.end_derivative == 0 + assert not tendency.annotations + + +def test_generate(): + """Check the generated values.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=6) + time, values = tendency.get_value() + assert np.all(time == [0, 2, 2, 4, 4, 6]) + assert list(values) == [1, 1, 3, 3, 5, 5] + assert not tendency.annotations + + +def test_get_value_at_times(): + """Check the value assignment at arbitrary time points.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=6) + _, values = tendency.get_value(np.array([0, 1, 1.99, 2, 3, 4, 5, 6])) + assert list(values) == [1, 1, 1, 3, 3, 5, 5, 5] + + +def test_get_value_outside_bounds(): + """Check the generated values outside of the time array""" + tendency = StepsTendency(user_time=[1, 2, 3], user_value=[2, 4, 8], user_end=4) + _, values = tendency.get_value(np.array([-1, 0, 4.5, 5])) + assert list(values) == [2, 2, 8, 8] + + +def test_get_derivative(): + """Check that the derivative is always zero.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=6) + derivatives = tendency.get_derivative(np.array([0, 1, 2, 3, 4, 5, 6])) + assert np.all(derivatives == 0) + + +@pytest.mark.parametrize("value", [[1, 2, 3], [1.5, 2.5, 3.5], ["a", "b", "c"]]) +def test_value_types(value): + """Test that int, float, and str steps are all supported.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=value, user_end=6) + assert not tendency.annotations + assert tendency.value_type is type(value[0]) + assert list(tendency.value) == value diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 9942af6f..53826b38 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -5,6 +5,7 @@ from waveform_editor.tendencies.linear import LinearTendency from waveform_editor.tendencies.periodic.sine_wave import SineWaveTendency from waveform_editor.tendencies.smooth import SmoothTendency +from waveform_editor.tendencies.steps import StepsTendency from waveform_editor.waveform import Waveform DD_VERSION = "3.42.0" @@ -297,6 +298,53 @@ def test_multiple_tendencies_mixed(): assert waveform.annotations +def test_steps_tendency_chained(): + """Test a steps tendency chained with a constant tendency in a waveform.""" + waveform = Waveform( + waveform=[ + { + "user_type": "steps", + "user_time": [0, 2, 4], + "user_value": [1, 3, 5], + "user_end": 6, + "line_number": 1, + }, + { + "user_type": "constant", + "user_duration": 2, + "line_number": 2, + }, + ] + ) + assert not waveform.annotations + assert isinstance(waveform.tendencies[0], StepsTendency) + assert isinstance(waveform.tendencies[1], ConstantTendency) + # The constant tendency without an explicit value inherits the last step's value + assert waveform.tendencies[1].value == 5 + + times, values = waveform.get_value(np.linspace(0, 8, 9)) + assert np.allclose(values, [1, 1, 3, 3, 5, 5, 5, 5, 5]) + + +def test_steps_tendency_string_values(): + """Test a steps tendency with string values.""" + waveform = Waveform( + waveform=[ + { + "user_type": "steps", + "user_time": [0, 10, 20], + "user_value": ["ohmic", "nbi", "ec"], + "user_end": 30, + "line_number": 1, + }, + ] + ) + assert not waveform.annotations + assert waveform.value_type is str + _, values = waveform.get_value(np.array([0, 15, 25])) + assert list(values) == ["ohmic", "nbi", "ec"] + + def test_dtype_flt_dd_path(): """Test float field types.""" diff --git a/waveform_editor/derived_waveform.py b/waveform_editor/derived_waveform.py index 89961188..ee37fb73 100644 --- a/waveform_editor/derived_waveform.py +++ b/waveform_editor/derived_waveform.py @@ -4,6 +4,7 @@ from asteval import Interpreter from waveform_editor.base_waveform import BaseWaveform +from waveform_editor.tendencies.util import merge_value_types NUMPY_UFUNCS = {} for name in np.__all__: @@ -115,9 +116,10 @@ def rename_dependency(self, old_name, new_name): self.prepare_expression() def _validate_dependencies(self): - """Warn if a dependency doesn't exist, or if the dependencies that do - exist don't have a compatible type. Mixing int and float dependencies - is allowed and results in a float-typed derived waveform. + """Warn if a dependency doesn't exist, if a dependency is string-typed, or + if the dependencies that do exist don't have a compatible type. Mixing int + and float dependencies is allowed and results in a float-typed derived + waveform. """ if not self.dependencies: return @@ -138,17 +140,18 @@ def _validate_dependencies(self): self.annotations.add( 0, "Derived waveforms cannot depend on string-typed waveforms.\n" ) - elif dependency_types <= {int, float}: - self.value_type = float if float in dependency_types else int - elif len(dependency_types) == 1: - self.value_type = dependency_types.pop() - else: + return + + merged_type = merge_value_types(dependency_types) + if merged_type is None: type_names = sorted(t.__name__ for t in dependency_types) self.annotations.add( 0, "All dependencies of a derived waveform must have the same " f"type, or be a mix of int and float. Found: {type_names}\n", ) + else: + self.value_type = merged_type def _build_eval_context(self, time: np.ndarray) -> dict: """Build the evaluation context dictionary with dependencies resolved. diff --git a/waveform_editor/tendencies/piecewise.py b/waveform_editor/tendencies/piecewise.py index e557bc18..31142225 100644 --- a/waveform_editor/tendencies/piecewise.py +++ b/waveform_editor/tendencies/piecewise.py @@ -3,6 +3,7 @@ from waveform_editor.annotations import Annotations from waveform_editor.tendencies.base import BaseTendency +from waveform_editor.tendencies.util import validate_time_array class PiecewiseLinearTendency(BaseTendency): @@ -88,37 +89,20 @@ def _validate_time_value(self, time, value): encountered during the validation, the self.time and self.value defaults are returned instead. """ - if time is None or value is None: - error_msg = "Both the `time` and `value` arrays must be specified.\n" - self.pre_check_annotations.add(self.line_number, error_msg) - elif len(time) != len(value): - error_msg = ( - "The provided time and value arrays are not of the same length.\n" - ) - self.pre_check_annotations.add(self.line_number, error_msg) - elif len(time) < 1: - error_msg = ( - "The provided time and value arrays should have a length " - "of at least 1.\n" - ) - self.pre_check_annotations.add(self.line_number, error_msg) + time = validate_time_array( + self.pre_check_annotations, self.line_number, time, value + ) + if time is None: + return self.time, self.value try: - time = np.asarray_chkfinite(time, dtype=float) value = np.asarray_chkfinite(value, dtype=float) - is_monotonic = np.all(np.diff(time) > 0) - if not is_monotonic: - error_msg = "The provided time array is not monotonically increasing.\n" - self.pre_check_annotations.add(self.line_number, error_msg) except Exception as error: self.pre_check_annotations.add(self.line_number, str(error)) - - # If there are any errors, use the default values instead - if not self.pre_check_annotations: - return time, value - else: return self.time, self.value + return time, value + def _remove_user_time_params(self, kwargs): """Remove user_start, user_duration, and user_end if they are passed as kwargs, and add error messages as annotations. These variables will be set from the diff --git a/waveform_editor/tendencies/steps.py b/waveform_editor/tendencies/steps.py new file mode 100644 index 00000000..689a8590 --- /dev/null +++ b/waveform_editor/tendencies/steps.py @@ -0,0 +1,127 @@ +import numpy as np +import param + +from waveform_editor.annotations import Annotations +from waveform_editor.tendencies.base import BaseTendency +from waveform_editor.tendencies.util import merge_value_types, validate_time_array + + +class StepsTendency(BaseTendency): + """ + A tendency representing a step function. + """ + + time = param.Array(default=np.array([0.0]), doc="The start times of each step.") + value = param.Array( + default=np.array([0.0], dtype=object), doc="The values of each step." + ) + + def __init__(self, user_time=None, user_value=None, **kwargs): + self.pre_check_annotations = Annotations() + time, value, value_type = self._validate_time_value(user_time, user_value) + + if "user_start" in kwargs: + kwargs.pop("user_start") + line_number = kwargs.get("line_number", 0) + self.pre_check_annotations.add( + line_number, "'start' is not allowed in a steps tendency\n" + ) + + # If neither `duration` nor `end` is given, the tendency simply stops at the + # last time point + end_given = "user_duration" in kwargs or "user_end" in kwargs + if not end_given: + kwargs["user_end"] = time[-1] + + super().__init__( + user_start=time[0], + time=time, + value=value, + value_type=value_type, + **kwargs, + ) + self.annotations.add_annotations(self.pre_check_annotations) + + if end_given and self.end <= self.time[-1]: + error_msg = ( + "The tendency must end after its last time point. Provide a " + "`duration` or `end` that is larger than the last point in " + "`time`.\n" + ) + self.annotations.add(self.line_number, error_msg) + + self.start_value_set = True + self.allow_zero_duration = True + self.param.update(values_changed=True) + + def get_value( + self, time: np.ndarray | None = None + ) -> tuple[np.ndarray, np.ndarray]: + """Get the tendency values at the provided time array. + + Args: + time: The time array on which to generate points. + + Returns: + Tuple containing the time and its tendency values. + """ + if time is None: + # Duplicate each time point so that vertical steps are covered + time = np.repeat(np.append(self.time, self.end), 2)[1:-1] + value = np.repeat(self.value, 2) + return time, value + + indices = np.searchsorted(self.time, time, side="right") - 1 + indices = np.clip(indices, 0, len(self.value) - 1) + return time, self.value[indices] + + def get_derivative(self, time: np.ndarray) -> np.ndarray: + """Get the values of the derivatives at the provided time array. + + Args: + time: The time array on which to generate points. + + Returns: + numpy array containing the derivatives + """ + return np.zeros(len(time)) + + def _validate_time_value(self, time, value): + """Validates the provided time and value lists. + + Args: + time: List of the start times of each step. + value: List of the values held during each step. + + Returns: + Tuple containing the validated time array, value array, and value type. + If any errors are encountered during validation, the self.time, + self.value, and self.value_type defaults are returned instead. + """ + time = validate_time_array( + self.pre_check_annotations, self.line_number, time, value + ) + if time is None: + return self.time, self.value, self.value_type + + for element in value: + if not isinstance(element, (int, float, str)): + error_msg = ( + f"Unsupported value type: {type(element).__name__!r}. Values " + "must be numbers or strings.\n" + ) + self.pre_check_annotations.add(self.line_number, error_msg) + return self.time, self.value, self.value_type + + value_types = {type(element) for element in value} + value_type = merge_value_types(value_types) + if value_type is None: + error_msg = ( + "All values of a steps tendency must have the same type, or be a mix " + f"of int and float. Found: {sorted(t.__name__ for t in value_types)}\n" + ) + self.pre_check_annotations.add(self.line_number, error_msg) + return self.time, self.value, self.value_type + + value_array = np.array(list(value), dtype=object) + return time, value_array, value_type diff --git a/waveform_editor/tendencies/util.py b/waveform_editor/tendencies/util.py index 15041fc2..a404994f 100644 --- a/waveform_editor/tendencies/util.py +++ b/waveform_editor/tendencies/util.py @@ -1,6 +1,72 @@ import numpy as np +def merge_value_types(types): + """Determine a single value type from a collection of value types. Mixing + ``int`` and ``float`` is allowed and results in ``float``, any other mix of + distinct types is not allowed. + + Args: + types: An iterable of ``int``, ``float``, and/or ``str`` types. + + Returns: + The merged type, or None if the types cannot be merged. + """ + types = set(types) + if types <= {int, float}: + return float if float in types else int + if len(types) == 1: + return types.pop() + return None + + +def validate_time_array(annotations, line_number, time, value): + """Validate a ``time`` list paired with a ``value`` list: both must be given, + have the same non-zero length, and ``time`` must be finite and strictly + monotonically increasing. + + Args: + annotations: The Annotations instance to report problems to. + line_number: The line number to attach any error to. + time: List of time points. + value: List of values paired with the time points. + + Returns: + The validated time array or None if invalid. + """ + if time is None or value is None: + annotations.add( + line_number, "Both the `time` and `value` arrays must be specified.\n" + ) + return None + if len(time) != len(value): + annotations.add( + line_number, + "The provided time and value arrays are not of the same length.\n", + ) + return None + if len(time) < 1: + annotations.add( + line_number, + "The provided time and value arrays should have a length of at least 1.\n", + ) + return None + + try: + time = np.asarray_chkfinite(time, dtype=float) + if not np.all(np.diff(time) > 0): + annotations.add( + line_number, + "The provided time array is not monotonically increasing.\n", + ) + return None + except Exception as error: + annotations.add(line_number, str(error)) + return None + + return time + + class InconsistentInputsError(ValueError): """Error raised when the input is inconsistent with the constraint matrix""" diff --git a/waveform_editor/waveform.py b/waveform_editor/waveform.py index 7a7a69b7..188bcca1 100644 --- a/waveform_editor/waveform.py +++ b/waveform_editor/waveform.py @@ -15,6 +15,8 @@ from waveform_editor.tendencies.piecewise import PiecewiseLinearTendency from waveform_editor.tendencies.repeat import RepeatTendency from waveform_editor.tendencies.smooth import SmoothTendency +from waveform_editor.tendencies.steps import StepsTendency +from waveform_editor.tendencies.util import merge_value_types IDS_DATATYPES = { float: {IDSDataType.FLT}, @@ -43,6 +45,7 @@ "smooth": SmoothTendency, "piecewise": PiecewiseLinearTendency, "repeat": RepeatTendency, + "steps": StepsTendency, } @@ -208,11 +211,8 @@ def _validate_value_type(self): return value_types = set(tendency.value_type for tendency in self.tendencies) - if len(value_types) == 1: - self.value_type = value_types.pop() - elif value_types == {int, float}: - self.value_type = float - else: + merged_type = merge_value_types(value_types) + if merged_type is None: type_names = ", ".join(sorted(t.__name__ for t in value_types)) error_msg = ( f"Cannot mix string and numerical tendency value types within a single " @@ -220,6 +220,7 @@ def _validate_value_type(self): ) self.annotations.add(0, error_msg) return + self.value_type = merged_type # If a valid DD path is chosen, check if the value_type matches the DD type if (