From 53e2550dd434c7b3621660ea77c63e7a45af3d12 Mon Sep 17 00:00:00 2001 From: Daan van Vugt Date: Thu, 25 Jun 2026 11:09:35 +0200 Subject: [PATCH 1/5] dtype-aware tendency values --- tests/tendencies/test_constant.py | 31 ++++++++++++++++++++++++++ tests/test_waveform.py | 29 ++++++++++++++++++++++++ waveform_editor/tendencies/base.py | 5 +++-- waveform_editor/tendencies/constant.py | 9 ++++++-- waveform_editor/waveform.py | 23 +++++++++++++------ 5 files changed, 86 insertions(+), 11 deletions(-) diff --git a/tests/tendencies/test_constant.py b/tests/tendencies/test_constant.py index c21f87cf..63eda92a 100644 --- a/tests/tendencies/test_constant.py +++ b/tests/tendencies/test_constant.py @@ -86,3 +86,34 @@ def test_declarative_assignments(): assert t2.value == 6 assert not t1.annotations assert not t2.annotations + + +def test_integer_value_not_coerced(): + """Integer inputs are kept as integers, not coerced to float.""" + tendency = ConstantTendency(user_duration=1, user_value=5) + assert tendency.value == 5 + assert isinstance(tendency.value, int) + assert not tendency.is_string + assert not tendency.annotations + + +def test_string_value(): + """A constant tendency can hold a string (non-numeric) value.""" + tendency = ConstantTendency(user_duration=2, user_value="nbi") + assert tendency.value == "nbi" + assert tendency.is_string + assert tendency.start_value == "nbi" + assert tendency.end_value == "nbi" + + _, values = tendency.get_value(np.array([0.0, 1.0, 2.0])) + assert list(values) == ["nbi", "nbi", "nbi"] + assert not tendency.annotations + + +def test_string_value_chained(): + """A value-less string constant inherits the previous string value.""" + prev = ConstantTendency(user_value="ec", user_start=0, user_duration=1) + tendency = ConstantTendency(user_duration=1) + tendency.set_previous_tendency(prev) + assert tendency.value == "ec" + assert tendency.is_string diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 1d38ebad..52a92bd5 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -320,3 +320,32 @@ def test_overlap_derivatives(): expected = [2, 2, -1.5, -1.5, -1.5, -1.5, -1.5] values = waveform.get_derivative(np.linspace(0, 3, 7)) assert np.allclose(values, expected) + + +def test_string_waveform(): + """A waveform of string constants evaluates as a zero-order-hold step function.""" + waveform = Waveform( + waveform=[ + {"user_type": "constant", "user_value": "ohmic", "user_duration": 2}, + {"user_type": "constant", "user_value": "nbi", "user_duration": 2}, + ] + ) + assert waveform.is_string + + _, values = waveform.get_value(np.array([0.0, 1.0, 2.0, 3.0])) + # The switch happens at t=2; later tendencies take precedence at the boundary. + assert list(values) == ["ohmic", "ohmic", "nbi", "nbi"] + + # Values are held (not interpolated) when extrapolating beyond the domain. + _, extrap = waveform.get_value(np.array([-1.0, 5.0])) + assert list(extrap) == ["ohmic", "nbi"] + + +def test_numeric_waveform_evaluates_to_float(): + """A numeric waveform evaluates to a float array (the int value is not stepwise).""" + waveform = Waveform( + waveform=[{"user_type": "constant", "user_value": 8, "user_duration": 3}] + ) + assert not waveform.is_string + _, values = waveform.get_value(np.array([0.0, 1.0, 2.0, 3.0])) + assert values.dtype == float diff --git a/waveform_editor/tendencies/base.py b/waveform_editor/tendencies/base.py index 50b43198..7bb40535 100644 --- a/waveform_editor/tendencies/base.py +++ b/waveform_editor/tendencies/base.py @@ -61,8 +61,8 @@ class BaseTendency(param.Parameterized): values from the start value of this tendency. """, ) - start_value = param.Number(default=0.0, doc="Value at self.start") - end_value = param.Number(default=0.0, doc="Value at self.end") + start_value = param.Parameter(default=0.0, doc="Value at self.start") + end_value = param.Parameter(default=0.0, doc="Value at self.end") start_derivative = param.Number(default=0.0, doc="Derivative at self.start") end_derivative = param.Number(default=0.0, doc="Derivative at self.end") @@ -77,6 +77,7 @@ class BaseTendency(param.Parameterized): ) annotations = param.ClassSelector(class_=Annotations, default=Annotations()) allow_zero_duration = False + is_string = False def __init__(self, **kwargs): super().__init__() diff --git a/waveform_editor/tendencies/constant.py b/waveform_editor/tendencies/constant.py index 6b1cd4af..62b1e4a7 100644 --- a/waveform_editor/tendencies/constant.py +++ b/waveform_editor/tendencies/constant.py @@ -10,7 +10,7 @@ class ConstantTendency(BaseTendency): Constant tendency class for a constant signal. """ - user_value = param.Number( + user_value = param.Parameter( default=None, doc="The constant value of the tendency provided by the user.", ) @@ -19,6 +19,11 @@ def __init__(self, **kwargs): self.value = 0.0 super().__init__(**kwargs) + @property + def is_string(self): + """Whether this constant holds a string (non-numeric) value.""" + return isinstance(self.value, str) + def get_value( self, time: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray]: @@ -33,7 +38,7 @@ def get_value( """ if time is None: time = np.array([self.start, self.end]) - values = self.value * np.ones(len(time)) + values = np.full(len(time), self.value) return time, values def get_derivative(self, time: np.ndarray) -> np.ndarray: diff --git a/waveform_editor/waveform.py b/waveform_editor/waveform.py index fd1c7ed6..9ecb98f3 100644 --- a/waveform_editor/waveform.py +++ b/waveform_editor/waveform.py @@ -69,6 +69,11 @@ def _bind_imports(self): tendency.resolver = resolver tendency.default_path = self.name + @property + def is_string(self): + """Whether this waveform produces string (non-numeric) values.""" + return any(t.is_string for t in self.tendencies) + def get_value( self, time: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray]: @@ -125,7 +130,11 @@ def _evaluate_tendencies(self, time, eval_derivatives=False): Returns: numpy array containing the computed values. """ - values = np.zeros_like(time, dtype=float) + is_string = self.is_string and not eval_derivatives + if is_string: + values = np.empty(len(time), dtype=object) + else: + values = np.zeros_like(time, dtype=float) for i, tendency in enumerate(self.tendencies): mask = (time >= tendency.start) & (time <= tendency.end) @@ -135,17 +144,17 @@ def _evaluate_tendencies(self, time, eval_derivatives=False): else: _, values[mask] = tendency.get_value(time[mask]) - # Handle gaps between tendencies, we linearly interpolate between the - # gap values. + # Handle gaps between tendencies (hold for strings, interpolate otherwise). if i and tendency.prev_tendency.end < tendency.start: prev_tendency = tendency.prev_tendency mask = (time < tendency.start) & (time > prev_tendency.end) - slope = (tendency.start_value - prev_tendency.end_value) / ( - tendency.start - prev_tendency.end - ) if np.any(mask): if eval_derivatives: - values[mask] = slope + values[mask] = ( + tendency.start_value - prev_tendency.end_value + ) / (tendency.start - prev_tendency.end) + elif is_string: + values[mask] = prev_tendency.end_value else: values[mask] = np.interp( time[mask], From 0cf91b80dc61512bf12eab075e6375fa73cd906d Mon Sep 17 00:00:00 2001 From: Daan van Vugt Date: Thu, 25 Jun 2026 18:53:59 +0200 Subject: [PATCH 2/5] Generalize categorical tendency values, validate mixing, document - Rename is_string -> is_categorical so booleans are treated like strings (held as a zero-order-hold step rather than coerced to float and interpolated) - Reject mixing categorical (string/boolean) and numeric values within a single waveform, since the gaps between them cannot be interpolated - Document the constant tendency value types (float/int/categorical) in the docs --- docs/source/tendencies.rst | 21 +++++++++++++ tests/tendencies/test_constant.py | 21 +++++++++++-- tests/test_waveform.py | 41 ++++++++++++++++++++++++-- waveform_editor/tendencies/base.py | 2 +- waveform_editor/tendencies/constant.py | 8 +++-- waveform_editor/waveform.py | 31 +++++++++++++++---- 6 files changed, 109 insertions(+), 15 deletions(-) diff --git a/docs/source/tendencies.rst b/docs/source/tendencies.rst index 72d610aa..bb0a693a 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -53,6 +53,27 @@ If the ``value`` is not specified, it will be set to the last value of the previ - {type: linear, to: 3, duration: 10} - {type: constant, duration: 10} +Value types +----------- + +The ``value`` is type-aware and is not restricted to floating-point numbers: + +* **Floats** are interpolated across gaps between tendencies, as usual. +* **Integers** are preserved as integers (they are not coerced to float). +* **Categorical values** (strings or booleans) describe non-numeric signals, such + as a heating scheme (``"nbi"``, ``"ec"``) or an on/off flag. These are held as a + step (zero-order hold) rather than interpolated: across a gap, the previous value + is carried forward, and the same applies when extrapolating beyond the waveform. + +.. code-block:: yaml + + - {type: constant, value: ohmic, duration: 2} + - {type: constant, value: nbi, duration: 2} + +.. note:: + Categorical and numeric values cannot be mixed within a single waveform, because + the gaps between them cannot be interpolated. Doing so raises a validation error. + Linear Tendency =============== diff --git a/tests/tendencies/test_constant.py b/tests/tendencies/test_constant.py index 63eda92a..b557dfec 100644 --- a/tests/tendencies/test_constant.py +++ b/tests/tendencies/test_constant.py @@ -93,7 +93,7 @@ def test_integer_value_not_coerced(): tendency = ConstantTendency(user_duration=1, user_value=5) assert tendency.value == 5 assert isinstance(tendency.value, int) - assert not tendency.is_string + assert not tendency.is_categorical assert not tendency.annotations @@ -101,7 +101,7 @@ def test_string_value(): """A constant tendency can hold a string (non-numeric) value.""" tendency = ConstantTendency(user_duration=2, user_value="nbi") assert tendency.value == "nbi" - assert tendency.is_string + assert tendency.is_categorical assert tendency.start_value == "nbi" assert tendency.end_value == "nbi" @@ -110,10 +110,25 @@ def test_string_value(): assert not tendency.annotations +def test_boolean_value(): + """A constant tendency can hold a boolean value, kept as a (categorical) bool.""" + tendency = ConstantTendency(user_duration=2, user_value=True) + assert tendency.value is True + assert tendency.is_categorical + # start/end values round-trip through numpy, so compare by value (np.bool_) + assert bool(tendency.start_value) is True + assert bool(tendency.end_value) is True + + _, values = tendency.get_value(np.array([0.0, 1.0, 2.0])) + assert [bool(v) for v in values] == [True, True, True] + assert values.dtype == bool + assert not tendency.annotations + + def test_string_value_chained(): """A value-less string constant inherits the previous string value.""" prev = ConstantTendency(user_value="ec", user_start=0, user_duration=1) tendency = ConstantTendency(user_duration=1) tendency.set_previous_tendency(prev) assert tendency.value == "ec" - assert tendency.is_string + assert tendency.is_categorical diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 52a92bd5..0d6b3e19 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -330,7 +330,7 @@ def test_string_waveform(): {"user_type": "constant", "user_value": "nbi", "user_duration": 2}, ] ) - assert waveform.is_string + assert waveform.is_categorical _, values = waveform.get_value(np.array([0.0, 1.0, 2.0, 3.0])) # The switch happens at t=2; later tendencies take precedence at the boundary. @@ -341,11 +341,48 @@ def test_string_waveform(): assert list(extrap) == ["ohmic", "nbi"] +def test_boolean_waveform(): + """A waveform of boolean constants evaluates as a zero-order-hold step function.""" + waveform = Waveform( + waveform=[ + {"user_type": "constant", "user_value": False, "user_duration": 2}, + {"user_type": "constant", "user_value": True, "user_duration": 2}, + ] + ) + assert waveform.is_categorical + + _, values = waveform.get_value(np.array([0.0, 1.0, 2.0, 3.0])) + assert [bool(v) for v in values] == [False, False, True, True] + + def test_numeric_waveform_evaluates_to_float(): """A numeric waveform evaluates to a float array (the int value is not stepwise).""" waveform = Waveform( waveform=[{"user_type": "constant", "user_value": 8, "user_duration": 3}] ) - assert not waveform.is_string + assert not waveform.is_categorical _, values = waveform.get_value(np.array([0.0, 1.0, 2.0, 3.0])) assert values.dtype == float + + +def test_mixing_categorical_and_numeric_is_flagged(): + """Mixing categorical (string/bool) and numeric tendencies adds an annotation.""" + waveform = Waveform( + waveform=[ + {"user_type": "constant", "user_value": "nbi", "user_duration": 2}, + {"user_type": "constant", "user_value": 3, "user_duration": 2}, + ] + ) + assert waveform.annotations + assert any("mix" in a["text"].lower() for a in waveform.annotations) + + +def test_homogeneous_categorical_not_flagged(): + """A waveform of only categorical values is not flagged as mixed.""" + waveform = Waveform( + waveform=[ + {"user_type": "constant", "user_value": "nbi", "user_duration": 2}, + {"user_type": "constant", "user_value": "ec", "user_duration": 2}, + ] + ) + assert not waveform.annotations diff --git a/waveform_editor/tendencies/base.py b/waveform_editor/tendencies/base.py index 7bb40535..f92246d3 100644 --- a/waveform_editor/tendencies/base.py +++ b/waveform_editor/tendencies/base.py @@ -77,7 +77,7 @@ class BaseTendency(param.Parameterized): ) annotations = param.ClassSelector(class_=Annotations, default=Annotations()) allow_zero_duration = False - is_string = False + is_categorical = False def __init__(self, **kwargs): super().__init__() diff --git a/waveform_editor/tendencies/constant.py b/waveform_editor/tendencies/constant.py index 62b1e4a7..01c8d7fd 100644 --- a/waveform_editor/tendencies/constant.py +++ b/waveform_editor/tendencies/constant.py @@ -20,9 +20,11 @@ def __init__(self, **kwargs): super().__init__(**kwargs) @property - def is_string(self): - """Whether this constant holds a string (non-numeric) value.""" - return isinstance(self.value, str) + def is_categorical(self): + """Whether this constant holds a non-numeric (categorical) value, e.g. a + string or boolean, that is held as a step rather than interpolated.""" + value = self.value + return isinstance(value, bool) or not isinstance(value, (int, float)) def get_value( self, time: np.ndarray | None = None diff --git a/waveform_editor/waveform.py b/waveform_editor/waveform.py index 9ecb98f3..c78c52ab 100644 --- a/waveform_editor/waveform.py +++ b/waveform_editor/waveform.py @@ -70,9 +70,10 @@ def _bind_imports(self): tendency.default_path = self.name @property - def is_string(self): - """Whether this waveform produces string (non-numeric) values.""" - return any(t.is_string for t in self.tendencies) + def is_categorical(self): + """Whether this waveform produces non-numeric (categorical) values, e.g. + strings or booleans, which are held as steps rather than interpolated.""" + return any(t.is_categorical for t in self.tendencies) def get_value( self, time: np.ndarray | None = None @@ -130,8 +131,8 @@ def _evaluate_tendencies(self, time, eval_derivatives=False): Returns: numpy array containing the computed values. """ - is_string = self.is_string and not eval_derivatives - if is_string: + is_categorical = self.is_categorical and not eval_derivatives + if is_categorical: values = np.empty(len(time), dtype=object) else: values = np.zeros_like(time, dtype=float) @@ -153,7 +154,7 @@ def _evaluate_tendencies(self, time, eval_derivatives=False): values[mask] = ( tendency.start_value - prev_tendency.end_value ) / (tendency.start - prev_tendency.end) - elif is_string: + elif is_categorical: values[mask] = prev_tendency.end_value else: values[mask] = np.interp( @@ -210,11 +211,29 @@ def _process_waveform(self, waveform): self.tendencies[i - 1].set_next_tendency(self.tendencies[i]) self.tendencies[i].set_previous_tendency(self.tendencies[i - 1]) + self._validate_value_types() + self.update_annotations() for tendency in self.tendencies: tendency.param.watch(self.update_annotations, "annotations") + def _validate_value_types(self): + """Categorical (e.g. string or boolean) and numeric tendencies cannot be + mixed within a single waveform, as the gaps between them cannot be + interpolated. Flag the first tendency whose type breaks the pattern.""" + if not self.tendencies: + return + first_is_categorical = self.tendencies[0].is_categorical + for tendency in self.tendencies[1:]: + if tendency.is_categorical != first_is_categorical: + error_msg = ( + "Cannot mix categorical (e.g. string or boolean) and numeric " + "values within a single waveform.\n" + ) + self.annotations.add(tendency.line_number, error_msg) + break + def update_annotations(self, event=None): """Merges the annotations of the individual tendencies into the annotations of this waveform.""" From efc0a3c52e9462189a02614b6371c6e243903b2e Mon Sep 17 00:00:00 2001 From: Daan van Vugt Date: Thu, 25 Jun 2026 19:02:35 +0200 Subject: [PATCH 3/5] docs: clarify integers are interpolated as floats, not categorical --- docs/source/tendencies.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/tendencies.rst b/docs/source/tendencies.rst index bb0a693a..ee55ef7f 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -58,8 +58,9 @@ Value types The ``value`` is type-aware and is not restricted to floating-point numbers: -* **Floats** are interpolated across gaps between tendencies, as usual. -* **Integers** are preserved as integers (they are not coerced to float). +* **Numeric values** (floats and integers) produce a floating-point signal that is + interpolated across gaps between tendencies, as usual. Integers are treated as + floats during evaluation. * **Categorical values** (strings or booleans) describe non-numeric signals, such as a heating scheme (``"nbi"``, ``"ec"``) or an on/off flag. These are held as a step (zero-order hold) rather than interpolated: across a gap, the previous value From fc4860013410ec435cc2e9dbcad4f5c864677253 Mon Sep 17 00:00:00 2001 From: Daan van Vugt Date: Fri, 10 Jul 2026 11:57:12 +0200 Subject: [PATCH 4/5] Evaluate numeric constant values as float on all eval paths np.full preserved int dtype for integer constants, so get_value() (the time=None path) returned an int64 array while the explicit-time path upcast to float. Force float for numeric values; categorical values (str/bool) keep their native dtype. --- waveform_editor/tendencies/constant.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/waveform_editor/tendencies/constant.py b/waveform_editor/tendencies/constant.py index 01c8d7fd..720d8283 100644 --- a/waveform_editor/tendencies/constant.py +++ b/waveform_editor/tendencies/constant.py @@ -40,7 +40,10 @@ def get_value( """ if time is None: time = np.array([self.start, self.end]) - values = np.full(len(time), self.value) + # Numeric values evaluate as floats (ints included); categorical values keep + # their native dtype (str stays str, bool stays bool). + dtype = None if self.is_categorical else float + values = np.full(len(time), self.value, dtype=dtype) return time, values def get_derivative(self, time: np.ndarray) -> np.ndarray: From 2ff479418623c1002e624335b54c6437cc97d7e9 Mon Sep 17 00:00:00 2001 From: Daan van Vugt Date: Thu, 25 Jun 2026 11:40:34 +0200 Subject: [PATCH 5/5] Add steps (zero-order-hold) tendency A piecewise-constant tendency that holds each value from its breakpoint until the next, supporting non-numeric (string/boolean) values. Registered as 'steps'. - start is inferred from the first breakpoint; an explicit end/duration may be given so the final value is held for a real duration (defaults to the last time) - shared time/value validation and start/end resolution are factored out of PiecewiseLinearTendency via overridable _coerce_value / _resolve_time_bounds hooks, so the steps tendency only overrides what genuinely differs --- docs/source/tendencies.rst | 26 ++++++ tests/tendencies/test_steps.py | 109 ++++++++++++++++++++++++ waveform_editor/tendencies/piecewise.py | 37 +++++++- waveform_editor/tendencies/steps.py | 58 +++++++++++++ waveform_editor/waveform.py | 2 + 5 files changed, 228 insertions(+), 4 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 ee55ef7f..c95bb395 100644 --- a/docs/source/tendencies.rst +++ b/docs/source/tendencies.rst @@ -212,6 +212,32 @@ 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 piecewise-constant (zero-order-hold) signal. Each ``time`` is the start of a step that holds the corresponding ``value`` until the next breakpoint, and the final value is held until the tendency's ``end``. Unlike the :ref:`Piecewise Linear Tendency `, the values are never interpolated, so they may be :ref:`categorical ` (strings or booleans) as well as numeric. + +Parameters +---------- +* ``time``: A list of step start times. Must be strictly monotonically increasing and must have at least 1 point. +* ``value``: A list of corresponding values, one per breakpoint in ``time``. Must have the same length as ``time``. Values may be numbers, strings, or booleans. +* ``duration``, ``end``: Optionally extend the tendency past the last breakpoint, so the final value is held for a real duration. If omitted, the tendency ends at the last ``time`` (the final value then has zero duration within the tendency). The ``start`` is always inferred from the first ``time`` and may not be set explicitly. + +.. code-block:: yaml + + - {type: steps, time: [0, 2, 4], value: [1, 3, 5], end: 6} + +A common use is a non-numeric signal, such as the active heating scheme: + +.. code-block:: yaml + + - {type: steps, time: [0, 10, 20], value: [ohmic, nbi, ec], end: 30} + +.. warning:: + Categorical and numeric values cannot be mixed within a single waveform. + .. _import-tendency: Import diff --git a/tests/tendencies/test_steps.py b/tests/tendencies/test_steps.py new file mode 100644 index 00000000..256041fc --- /dev/null +++ b/tests/tendencies/test_steps.py @@ -0,0 +1,109 @@ +import numpy as np + +from waveform_editor.tendencies.steps import StepsTendency +from waveform_editor.waveform import Waveform + + +def test_filled(): + """Time/value arrays keep their native dtype and are not interpolated.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5]) + assert np.all(tendency.time == np.array([0, 2, 4])) + assert np.all(tendency.value == np.array([1, 3, 5])) + assert not tendency.is_categorical + assert not tendency.annotations + + +def test_zero_order_hold(): + """Each value is held from its breakpoint until the next; ends extrapolate.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5]) + t = np.array([-1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + _, values = tendency.get_value(t) + assert list(values) == [1, 1, 1, 3, 3, 5, 5] + + +def test_derivative_is_zero(): + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5]) + assert np.all(tendency.get_derivative(np.array([0.0, 1.0, 2.0, 3.0])) == 0) + + +def test_string_values(): + """A steps tendency can hold string values.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=["ohmic", "nbi", "ec"]) + assert tendency.is_categorical + _, values = tendency.get_value(np.array([0.0, 1.0, 2.0, 3.0, 4.0])) + assert list(values) == ["ohmic", "ohmic", "nbi", "nbi", "ec"] + assert not tendency.annotations + + +def test_boolean_values(): + """A steps tendency can hold boolean values, which are categorical (held).""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[False, True, False]) + assert tendency.is_categorical + _, values = tendency.get_value(np.array([0.0, 1.0, 2.0, 3.0, 4.0])) + assert [bool(v) for v in values] == [False, False, True, True, False] + assert not tendency.annotations + + +def test_non_monotonic_time(): + tendency = StepsTendency(user_time=[0, 2, 1], user_value=[1, 2, 3]) + assert tendency.annotations + + +def test_mismatched_lengths(): + tendency = StepsTendency(user_time=[0, 2], user_value=[1, 2, 3]) + assert tendency.annotations + + +def test_start_and_end_inference(): + """Start comes from the first breakpoint; end defaults to the last breakpoint.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5]) + assert tendency.start == 0 + assert tendency.end == 4 + + +def test_explicit_end_holds_final_value(): + """An explicit end holds the final value for its full duration.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=6) + assert tendency.start == 0 + assert tendency.end == 6 + assert not tendency.annotations + _, values = tendency.get_value(np.array([4.0, 5.0, 6.0])) + assert list(values) == [5, 5, 5] + + +def test_end_before_last_time_is_flagged(): + """An end that precedes the last breakpoint is an error.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_end=3) + assert tendency.annotations + + +def test_start_not_allowed(): + """Providing an explicit start is rejected; it is inferred from the time list.""" + tendency = StepsTendency(user_time=[0, 2, 4], user_value=[1, 3, 5], user_start=1) + assert tendency.annotations + assert tendency.start == 0 + + +def test_in_waveform(): + """A steps tendency drives a zero-order-hold waveform (numeric and string).""" + numeric = Waveform( + waveform=[ + {"user_type": "steps", "user_time": [0, 2, 4], "user_value": [1, 3, 5]} + ] + ) + assert not numeric.is_categorical + _, values = numeric.get_value(np.array([0.0, 1.0, 2.0, 3.0, 4.0])) + assert list(values) == [1, 1, 3, 3, 5] + + string = Waveform( + waveform=[ + { + "user_type": "steps", + "user_time": [0, 2, 4], + "user_value": ["a", "b", "c"], + } + ] + ) + assert string.is_categorical + _, values = string.get_value(np.array([1.0, 3.0, 5.0])) + assert list(values) == ["a", "b", "c"] diff --git a/waveform_editor/tendencies/piecewise.py b/waveform_editor/tendencies/piecewise.py index e557bc18..3222fa9a 100644 --- a/waveform_editor/tendencies/piecewise.py +++ b/waveform_editor/tendencies/piecewise.py @@ -21,12 +21,11 @@ class PiecewiseLinearTendency(BaseTendency): def __init__(self, user_time=None, user_value=None, **kwargs): self.pre_check_annotations = Annotations() time, value = self._validate_time_value(user_time, user_value) - self._remove_user_time_params(kwargs) + time_bounds = self._resolve_time_bounds(time, kwargs) super().__init__( - user_start=time[0], - user_end=time[-1], time=time, value=value, + **time_bounds, **kwargs, ) self.annotations.add_annotations(self.pre_check_annotations) @@ -34,6 +33,22 @@ def __init__(self, user_time=None, user_value=None, **kwargs): self.start_value_set = True self.param.update(values_changed=True) + def _resolve_time_bounds(self, time, kwargs): + """Determine the tendency's start/end from the time array. The piecewise-linear + tendency derives its full interval from the time list, so the common time + parameters (``start``, ``duration``, ``end``) are not accepted. + + Args: + time: The validated time array. + kwargs: The remaining keyword arguments. Disallowed time parameters are + removed in place. + + Returns: + A dict of the ``user_start``/``user_end`` keyword arguments to pass on. + """ + self._remove_user_time_params(kwargs) + return {"user_start": time[0], "user_end": time[-1]} + def get_value( self, time: np.ndarray | None = None ) -> tuple[np.ndarray, np.ndarray]: @@ -105,7 +120,7 @@ def _validate_time_value(self, time, value): try: time = np.asarray_chkfinite(time, dtype=float) - value = np.asarray_chkfinite(value, dtype=float) + value = self._coerce_value(value) is_monotonic = np.all(np.diff(time) > 0) if not is_monotonic: error_msg = "The provided time array is not monotonically increasing.\n" @@ -119,6 +134,20 @@ def _validate_time_value(self, time, value): else: return self.time, self.value + def _coerce_value(self, value): + """Coerce the value array to the dtype expected by this tendency. The + piecewise-linear tendency interpolates its values, so they must be finite + floats. Subclasses may override this (e.g. a step function keeps the native + dtype, allowing non-numeric values). + + Args: + value: The values defined on each time step. + + Returns: + The coerced value array. + """ + return np.asarray_chkfinite(value, dtype=float) + 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..81f5e999 --- /dev/null +++ b/waveform_editor/tendencies/steps.py @@ -0,0 +1,58 @@ +import numpy as np + +from waveform_editor.tendencies.piecewise import PiecewiseLinearTendency + + +class StepsTendency(PiecewiseLinearTendency): + """A piecewise-constant (zero-order-hold) tendency. + + Each ``time`` is the start of a step holding the corresponding ``value`` until the + next breakpoint; the final value is held until the tendency's ``end``. Unlike the + piecewise-linear tendency, the values are never interpolated, so they may be + non-numeric (e.g. strings or booleans). + + The start is inferred from the first breakpoint. An explicit ``end`` (or + ``duration``) may be given so the final value is held for a real duration; if + neither is given, the tendency ends at the last breakpoint. + """ + + @property + def is_categorical(self): + # Strings ("U"/"S"), objects ("O") and booleans ("b") are non-numeric and are + # held as a step rather than interpolated. + return self.value.dtype.kind in ("U", "S", "O", "b") + + def _coerce_value(self, value): + # A step function is never interpolated, so keep the native dtype (which may be + # non-numeric) instead of coercing to float like the piecewise-linear tendency. + return np.asarray(value) + + def _resolve_time_bounds(self, time, kwargs): + # The start is fixed at the first breakpoint, but (unlike piecewise-linear) an + # explicit end/duration is allowed so the final value can be held for a real + # duration. Without one, the tendency ends at the last breakpoint. + line_number = kwargs.get("line_number", 0) + if "user_start" in kwargs: + kwargs.pop("user_start") + self.pre_check_annotations.add( + line_number, "'start' is not allowed in a steps tendency\n" + ) + end = kwargs.get("user_end") + if end is None and kwargs.get("user_duration") is None: + kwargs["user_end"] = time[-1] + elif isinstance(end, (int, float)) and end < time[-1]: + self.pre_check_annotations.add( + line_number, + "The `end` of a steps tendency must not precede the last time.\n", + ) + return {"user_start": time[0]} + + def get_value(self, time: np.ndarray | None = None): + if time is None: + return self.time, self.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: + return np.zeros_like(time, dtype=float) diff --git a/waveform_editor/waveform.py b/waveform_editor/waveform.py index c78c52ab..7e7f92c9 100644 --- a/waveform_editor/waveform.py +++ b/waveform_editor/waveform.py @@ -15,6 +15,7 @@ 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 tendency_map = { "linear": LinearTendency, @@ -29,6 +30,7 @@ "constant": ConstantTendency, "smooth": SmoothTendency, "piecewise": PiecewiseLinearTendency, + "steps": StepsTendency, "repeat": RepeatTendency, "import": ImportTendency, # `reference` kept as an alias for the import tendency type.