Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/source/tendencies.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ 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:

* **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
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
===============

Expand Down Expand Up @@ -190,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 <piecewise-linear-tendency>`, the values are never interpolated, so they may be :ref:`categorical <available-tendencies>` (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
Expand Down
46 changes: 46 additions & 0 deletions tests/tendencies/test_constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,49 @@ 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_categorical
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_categorical
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_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_categorical
109 changes: 109 additions & 0 deletions tests/tendencies/test_steps.py
Original file line number Diff line number Diff line change
@@ -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"]
66 changes: 66 additions & 0 deletions tests/test_waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,3 +320,69 @@ 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_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.
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_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_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
5 changes: 3 additions & 2 deletions waveform_editor/tendencies/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -77,6 +77,7 @@ class BaseTendency(param.Parameterized):
)
annotations = param.ClassSelector(class_=Annotations, default=Annotations())
allow_zero_duration = False
is_categorical = False

def __init__(self, **kwargs):
super().__init__()
Expand Down
14 changes: 12 additions & 2 deletions waveform_editor/tendencies/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
)
Expand All @@ -19,6 +19,13 @@ def __init__(self, **kwargs):
self.value = 0.0
super().__init__(**kwargs)

@property
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))
Comment on lines +22 to +27

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- {type: constant, value: ohmic, duration: 2}
- {type: smooth, duration: 2}
- {type: constant, value: nbi, duration: 2}

When I try to create the waveform above, it will raise a ValueError in the Scipy interpolation. Can we instead catch at an earlier point that categorical tendencies may not be mixed with non-categorical tendencies?

File "/home/sebbe/projects/iter_python/Waveform-Editor/venv/lib/python3.13/site-packages/scipy/interpolate/_cubic.py", line 792, in init
x, dx, y, axis, _ = prepare_input(x, y, axis, xp=np_compat)
~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/sebbe/projects/iter_python/Waveform-Editor/venv/lib/python3.13/site-packages/scipy/interpolate/_cubic.py", line 45, in prepare_input
y = xp.astype(y, dtype, copy=False)
File "/home/sebbe/projects/iter_python/Waveform-Editor/venv/lib/python3.13/site-packages/scipy/_lib/array_api_compat/numpy/aliases.py", line 106, in astype
return x.astype(dtype=dtype, copy=copy)
~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: could not convert string to float: np.str
('ohmic')


def get_value(
self, time: np.ndarray | None = None
) -> tuple[np.ndarray, np.ndarray]:
Expand All @@ -33,7 +40,10 @@ def get_value(
"""
if time is None:
time = np.array([self.start, self.end])
values = self.value * np.ones(len(time))
# 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:
Expand Down
Loading
Loading