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"]
2 changes: 2 additions & 0 deletions tests/test_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ def test_dump_comments():

yaml_str = dedent("""
globals:
version: 2
dd_version: 3.42.0
imports:
ec_launchers: imas:hdf5?path=test_md
Expand Down Expand Up @@ -284,6 +285,7 @@ def test_dump_globals():
dumped_yaml = config.dump()
expected_dump = dedent("""
globals:
version: 2
dd_version: 3.41.0
imports:
ec_launchers: imas:mdsplus?path=test
Expand Down
153 changes: 0 additions & 153 deletions tests/test_derived_waveform.py

This file was deleted.

Loading
Loading