From f7bcc14d92995082d857a6587f513d423526cf3b Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Thu, 30 Jul 2026 13:26:14 +0200 Subject: [PATCH 1/3] create steps tendency --- docs/source/tendencies.rst | 41 +++++ tests/tendencies/test_steps.py | 206 ++++++++++++++++++++++++ tests/test_waveform.py | 48 ++++++ waveform_editor/derived_waveform.py | 19 ++- waveform_editor/tendencies/piecewise.py | 32 +--- waveform_editor/tendencies/steps.py | 139 ++++++++++++++++ waveform_editor/tendencies/util.py | 66 ++++++++ waveform_editor/waveform.py | 11 +- 8 files changed, 525 insertions(+), 37 deletions(-) create mode 100644 tests/tendencies/test_steps.py create mode 100644 waveform_editor/tendencies/steps.py diff --git a/docs/source/tendencies.rst b/docs/source/tendencies.rst index 0dd3bd45..9eb6a9f7 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -227,6 +227,47 @@ 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``. +* ``duration``, ``end``: See :ref:`Common Time Parameters `. Either may be used to specify when the last step ends; if provided, it must be later than the last point in ``time``. If both are omitted, the tendency simply stops at the last point in ``time``. + +.. 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} + +If both ``duration`` and ``end`` are omitted, the tendency simply stops at the last point in ``time`` (so the last value only holds for an instant): + +.. code-block:: yaml + + - {type: steps, time: [0, 2, 4], value: [1, 3, 5]} + +Just like the :ref:`Constant Tendency `, the ``value`` list may contain numbers or strings: + +.. code-block:: yaml + + - {type: steps, time: [0, 10, 20], value: [ohmic, nbi, ec], end: 30} + +.. warning:: + Integers and floats may be freely combined within the ``value`` list, but other value types may not be combined with each other. + +.. 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..0882d88c --- /dev/null +++ b/tests/tendencies/test_steps.py @@ -0,0 +1,206 @@ +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 is allowed and results in a + float-typed 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, instead of erroring or defaulting to a 1 second duration.""" + 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_neither_duration_nor_end_given_single_step(): + """Test that a single-step tendency without `duration`/`end` results in a + zero-duration tendency at that single time point.""" + tendency = StepsTendency(user_time=[5], user_value=[3]) + assert tendency.start == 5 + assert tendency.end == 5 + assert not tendency.annotations + + +def test_end_not_after_last_time(): + """Test that the tendency must end after its last time point, if `duration` or + `end` is explicitly provided.""" + 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, since it is always derived from the + first point in `time`.""" + 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, including the vertical drops at each step.""" + 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_generate_single_step(): + """Check the generated values for a tendency with a single step.""" + tendency = StepsTendency(user_time=[5], user_value=[3], user_end=10) + time, values = tendency.get_value() + assert np.all(time == [5, 10]) + assert list(values) == [3, 3] + + +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; the edge values + should be used.""" + 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..d82c338f --- /dev/null +++ b/waveform_editor/tendencies/steps.py @@ -0,0 +1,139 @@ +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." + ) + allow_zero_duration = True + + 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) + self._remove_user_start_param(kwargs) + + # If neither `duration` nor `end` is given, the tendency simply stops at the + # last time point instead of defaulting to a 1 second duration. + 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.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. If no time array is + provided, points describing the step shape (including the vertical drops at + each transition) are returned. + + 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 (and self.end) so that connecting the + # returned points with straight lines draws the vertical steps. + 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_type = merge_value_types(type(element) for element in value) + if value_type is None: + type_names = sorted({type(element).__name__ for element in value}) + error_msg = ( + "All values of a steps tendency must have the same type, or be a " + f"mix of int and float. Found: {type_names}\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 + + def _remove_user_start_param(self, kwargs): + """Remove user_start if it is passed as a kwarg, and add an error message as + an annotation. The start of a steps tendency is always derived from the + `time` parameter instead. + + Args: + kwargs: the keyword arguments. + """ + 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" + ) 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 ( From 6f6eafad64b69d15c5f733dbd1de40e0f3192b6f Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Fri, 31 Jul 2026 10:33:54 +0200 Subject: [PATCH 2/3] improve docs --- docs/source/images/steps_1.png | Bin 0 -> 15723 bytes docs/source/images/steps_2.png | Bin 0 -> 15903 bytes docs/source/tendencies.rst | 30 ++++++++++++++++++------------ tests/tendencies/test_steps.py | 33 ++++++--------------------------- 4 files changed, 24 insertions(+), 39 deletions(-) create mode 100644 docs/source/images/steps_1.png create mode 100644 docs/source/images/steps_2.png diff --git a/docs/source/images/steps_1.png b/docs/source/images/steps_1.png new file mode 100644 index 0000000000000000000000000000000000000000..ca3e9f6678f95203166d8217edfbf0f7eeaf5f4e GIT binary patch literal 15723 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 literal 0 HcmV?d00001 diff --git a/docs/source/tendencies.rst b/docs/source/tendencies.rst index 9eb6a9f7..37eca9fd 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -232,13 +232,23 @@ Parameters 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 `. +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``. -* ``duration``, ``end``: See :ref:`Common Time Parameters `. Either may be used to specify when the last step ends; if provided, it must be later than the last point in ``time``. If both are omitted, the tendency simply stops at the last point in ``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 @@ -250,23 +260,19 @@ The same tendency can equivalently be written using ``duration`` instead of ``en - {type: steps, time: [0, 2, 4], value: [1, 3, 5], duration: 6} -If both ``duration`` and ``end`` are omitted, the tendency simply stops at the last point in ``time`` (so the last value only holds for an instant): - -.. code-block:: yaml - - - {type: steps, time: [0, 2, 4], value: [1, 3, 5]} - 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:: - Integers and floats may be freely combined within the ``value`` list, but other value types may not be combined with each other. - -.. warning:: - This tendency does **not** accept the common ``start`` parameter. The start of the tendency is always derived from the first point in ``time``. + 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 index 0882d88c..a8a1b09d 100644 --- a/tests/tendencies/test_steps.py +++ b/tests/tendencies/test_steps.py @@ -41,8 +41,7 @@ def test_string_values(): def test_int_float_mixing(): - """Test that mixing int and float values is allowed and results in a - float-typed tendency.""" + """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 @@ -86,7 +85,7 @@ def test_duration_and_end_inconsistent(): def test_neither_duration_nor_end_given(): """Test that omitting both `duration` and `end` makes the tendency stop at its - last time point, instead of erroring or defaulting to a 1 second duration.""" + last time point.""" tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5]) assert tendency.end == 4 assert tendency.duration == 4 @@ -96,18 +95,8 @@ def test_neither_duration_nor_end_given(): assert list(values) == [1, 1, 3, 3, 5] -def test_neither_duration_nor_end_given_single_step(): - """Test that a single-step tendency without `duration`/`end` results in a - zero-duration tendency at that single time point.""" - tendency = StepsTendency(user_time=[5], user_value=[3]) - assert tendency.start == 5 - assert tendency.end == 5 - assert not tendency.annotations - - def test_end_not_after_last_time(): - """Test that the tendency must end after its last time point, if `duration` or - `end` is explicitly provided.""" + """Test invalid end and duration values.""" tendency = StepsTendency(user_time=[0, 10], user_value=[1, 2], user_end=10) assert tendency.annotations @@ -140,8 +129,7 @@ def test_non_monotonic_time(): def test_start_not_allowed(): - """Test that `start` may not be provided, since it is always derived from the - first point in `time`.""" + """Test that `start` may not be provided""" tendency = StepsTendency( user_time=[0, 10], user_value=[1, 2], user_end=20, user_start=5 ) @@ -159,7 +147,7 @@ def test_start_and_end_values(): def test_generate(): - """Check the generated values, including the vertical drops at each step.""" + """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]) @@ -167,14 +155,6 @@ def test_generate(): assert not tendency.annotations -def test_generate_single_step(): - """Check the generated values for a tendency with a single step.""" - tendency = StepsTendency(user_time=[5], user_value=[3], user_end=10) - time, values = tendency.get_value() - assert np.all(time == [5, 10]) - assert list(values) == [3, 3] - - 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) @@ -183,8 +163,7 @@ def test_get_value_at_times(): def test_get_value_outside_bounds(): - """Check the generated values outside of the time array; the edge values - should be used.""" + """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] From bba2e43ae07d999e67799fde5e9e5bf08aea71d9 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Fri, 31 Jul 2026 15:07:51 +0200 Subject: [PATCH 3/3] cleanup --- waveform_editor/tendencies/steps.py | 42 +++++++++++------------------ 1 file changed, 15 insertions(+), 27 deletions(-) diff --git a/waveform_editor/tendencies/steps.py b/waveform_editor/tendencies/steps.py index d82c338f..689a8590 100644 --- a/waveform_editor/tendencies/steps.py +++ b/waveform_editor/tendencies/steps.py @@ -15,15 +15,20 @@ class StepsTendency(BaseTendency): value = param.Array( default=np.array([0.0], dtype=object), doc="The values of each step." ) - allow_zero_duration = True 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) - self._remove_user_start_param(kwargs) + + 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 instead of defaulting to a 1 second duration. + # last time point end_given = "user_duration" in kwargs or "user_end" in kwargs if not end_given: kwargs["user_end"] = time[-1] @@ -46,14 +51,13 @@ def __init__(self, user_time=None, user_value=None, **kwargs): 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. If no time array is - provided, points describing the step shape (including the vertical drops at - each transition) are returned. + """Get the tendency values at the provided time array. Args: time: The time array on which to generate points. @@ -62,8 +66,7 @@ def get_value( Tuple containing the time and its tendency values. """ if time is None: - # Duplicate each time point (and self.end) so that connecting the - # returned points with straight lines draws the vertical steps. + # 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 @@ -110,30 +113,15 @@ def _validate_time_value(self, time, value): self.pre_check_annotations.add(self.line_number, error_msg) return self.time, self.value, self.value_type - value_type = merge_value_types(type(element) for element in value) + value_types = {type(element) for element in value} + value_type = merge_value_types(value_types) if value_type is None: - type_names = sorted({type(element).__name__ for element in value}) error_msg = ( - "All values of a steps tendency must have the same type, or be a " - f"mix of int and float. Found: {type_names}\n" + "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 - - def _remove_user_start_param(self, kwargs): - """Remove user_start if it is passed as a kwarg, and add an error message as - an annotation. The start of a steps tendency is always derived from the - `time` parameter instead. - - Args: - kwargs: the keyword arguments. - """ - 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" - )