Repository: BAMWelDX/weldx

Issue:
`SpatialSeries` class
As a first step towards series-based trace and shape segments, we need to derive a `SpatialSeries` from the `GenericSeries`. This is basically a series of 2d or 3d coordinates.

Discussion:
github-actions[bot]: ## Unit Test Results
       1 files  ±0         1 suites  ±0   2m 0s [:stopwatch:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "duration of all tests") +8s
2 101 tests ±0  2 101 [:heavy_check_mark:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "passed tests") ±0  0 [:zzz:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "skipped / disabled tests") ±0  0 [:x:](https://github.com/EnricoMi/publish-unit-test-result-action/blob/v1.20/README.md#the-symbols "failed tests") ±0 

Results for commit 825d6fec. ± Comparison against base commit 60cd0256.

CagtayFabry: This looks really cool 👍 🚀 ! (and super fun to play with 😎 )

I know it's not final but I'll add some quick comments as notes in the code
codecov[bot]: # [Codecov](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) Report
> Merging [#699](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) (2b4ce61) into [master](https://codecov.io/gh/BAMWelDX/weldx/commit/9f2bc65e67b124c8251a806d5efb19e111ba396d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) (9f2bc65) will **increase** coverage by `0.00%`.
> The diff coverage is `95.91%`.

[![Impacted file tree graph](https://codecov.io/gh/BAMWelDX/weldx/pull/699/graphs/tree.svg?width=650&height=150&src=pr&token=wdof1qQTsn&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX)](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX)

```diff
@@           Coverage Diff           @@
##           master     #699   +/-   ##
=======================================
  Coverage   96.05%   96.06%           
=======================================
  Files          92       92           
  Lines        6393     6457   +64     
=======================================
+ Hits         6141     6203   +62     
- Misses        252      254    +2     
```


| [Impacted Files](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) | Coverage Δ | |
|---|---|---|
| [weldx/geometry.py](https://codecov.io/gh/BAMWelDX/weldx/pull/699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX#diff-d2VsZHgvZ2VvbWV0cnkucHk=) | `98.37% <95.50%> (-0.14%)` | :arrow_down: |
| [weldx/core.py](https://codecov.io/gh/BAMWelDX/weldx/pull/699/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX#diff-d2VsZHgvY29yZS5weQ==) | `93.18% <100.00%> (+0.11%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX). Last update [9f2bc65...2b4ce61](https://codecov.io/gh/BAMWelDX/weldx/pull/699?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX).

vhirtham: great,... add a type hint,... get 9 unrelated warnings in the doc-build 👎 
CagtayFabry: > great,... add a type hint,... get 9 unrelated warnings in the doc-build 😕 👎

if unrelated just ignore and merge
We can just exclude `matplotlib` from the intersphinx mapping in `conf.py` since it is not really essential to `weldx` and the doc structure seems to cause a lot of issues
vhirtham: Since we added a variable for the name of the dimension used by the `SpatialSeries`, I also used it in the `DynamicTraceSegment`. In case we do want to rename it for some reason, there shouldn't be any hardcoded dimension names in the segment class. I also renamed all the internal variables and parameters to be more general. @CagtayFabry Have another look at the changes and feel free to merge if everything is okay.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b104a11..1e3d0a3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,7 +29,7 @@ repos:
           - mdformat-config
   # ----- Python formatting -----
   - repo: https://github.com/sondrelg/pep585-upgrade
-    rev: v1
+    rev: v1.0.1
     hooks:
     - id: upgrade-type-hints
       args: [ '--futures=true' ]
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 065e3d5..a07ce18 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -9,6 +9,8 @@
 added
 =====
 
+-  `SpatialSeries` and `DynamicTraceSegment` [:pull:`699`]
+
 -  first draft of the ``multi_pass_weld`` schema for WelDX files [:pull:`667`]
 
 -  add `GenericSeries` as base class supporting arrays and equations [:pull:`618`]
diff --git a/tutorials/01_03_geometry.ipynb b/tutorials/01_03_geometry.ipynb
index 6e20b53..69bbf8f 100644
--- a/tutorials/01_03_geometry.ipynb
+++ b/tutorials/01_03_geometry.ipynb
@@ -391,7 +391,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.12"
+   "version": "3.9.9"
   }
  },
  "nbformat": 4,
diff --git a/tutorials/welding_example_01_basics.ipynb b/tutorials/welding_example_01_basics.ipynb
index a6946ef..adc89de 100644
--- a/tutorials/welding_example_01_basics.ipynb
+++ b/tutorials/welding_example_01_basics.ipynb
@@ -357,7 +357,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "coords = [tcp_start_point.magnitude, tcp_end_point.magnitude]\n",
+    "coords = np.stack([tcp_start_point, tcp_end_point])\n",
     "\n",
     "tcp_wire = LocalCoordinateSystem(\n",
     "    coordinates=coords, orientation=rot, time=[t_start, t_end]\n",
@@ -398,7 +398,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "tcp_contact = LocalCoordinateSystem(coordinates=[0, 0, -10])"
+    "tcp_contact = LocalCoordinateSystem(coordinates=Q_([0, 0, -10], \"mm\"))"
    ]
   },
   {
@@ -469,9 +469,9 @@
    "outputs": [],
    "source": [
     "# add the workpiece coordinate system\n",
-    "csm.add_cs(\"T1\", \"workpiece\", LocalCoordinateSystem(coordinates=[200, 3, 5]))\n",
-    "csm.add_cs(\"T2\", \"T1\", LocalCoordinateSystem(coordinates=[0, 1, 0]))\n",
-    "csm.add_cs(\"T3\", \"T2\", LocalCoordinateSystem(coordinates=[0, 1, 0]))"
+    "csm.add_cs(\"T1\", \"workpiece\", LocalCoordinateSystem(coordinates=Q_([200, 3, 5], \"mm\")))\n",
+    "csm.add_cs(\"T2\", \"T1\", LocalCoordinateSystem(coordinates=Q_([0, 1, 0], \"mm\")))\n",
+    "csm.add_cs(\"T3\", \"T2\", LocalCoordinateSystem(coordinates=Q_([0, 1, 0], \"mm\")))"
    ]
   },
   {
@@ -581,7 +581,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.9.9"
+   "version": "3.8.12"
   }
  },
  "nbformat": 4,
diff --git a/weldx/__init__.py b/weldx/__init__.py
index a1839b8..1038810 100644
--- a/weldx/__init__.py
+++ b/weldx/__init__.py
@@ -50,6 +50,7 @@ These classes and functions are used to define welding processes.
     Time
     TimeSeries
     GenericSeries
+    SpatialSeries
     MathematicalExpression
     CoordinateSystemManager
     LocalCoordinateSystem
@@ -72,6 +73,7 @@ These classes are used to define workpiece geometries.
     Shape
     Trace
     SpatialData
+    DynamicTraceSegment
 
 **Full API Reference**
 
@@ -130,17 +132,21 @@ except ModuleNotFoundError:  # pragma: no cover
 from weldx.constants import Q_, U_
 
 # main modules
-import weldx.time
+import weldx.time  # skipcq: PY-W2000
+
+# skipcq: PY-W2000
 import weldx.util  # import this second to avoid circular dependencies
-import weldx.core
-import weldx.transformations
+import weldx.core  # skipcq: PY-W2000
+import weldx.transformations  # skipcq: PY-W2000
 import weldx.config
-import weldx.geometry
-import weldx.welding
+import weldx.geometry  # skipcq: PY-W2000
+import weldx.welding  # skipcq: PY-W2000
 
 # class imports to weldx namespace
 from weldx.config import Config
-from weldx.core import GenericSeries, MathematicalExpression, TimeSeries
+
+# skipcq: PY-W2000
+from weldx.core import GenericSeries, MathematicalExpression, TimeSeries, SpatialSeries
 from weldx.geometry import (
     ArcSegment,
     Geometry,
@@ -150,8 +156,9 @@ from weldx.geometry import (
     Shape,
     Trace,
     SpatialData,
+    DynamicTraceSegment,
 )
-from weldx.transformations import (
+from weldx.transformations import (  # skipcq: PY-W2000
     CoordinateSystemManager,
     LocalCoordinateSystem,
     WXRotation,
@@ -161,10 +168,10 @@ from weldx.welding.groove.iso_9692_1 import get_groove
 from weldx.time import Time
 
 # tags (this will partially import weldx.asdf but not the extension)
-from weldx import tags
+from weldx import tags  # skipcq: PY-W2000
 
 # asdf extensions
-import weldx.asdf
+import weldx.asdf  # skipcq: PY-W2000
 from weldx.asdf.file import WeldxFile
 
 __all__ = (
@@ -190,6 +197,7 @@ __all__ = (
     "util",
     "welding",
     "TimeSeries",
+    "DynamicTraceSegment",
     "LinearHorizontalTraceSegment",
     "Config",
     "Time",
diff --git a/weldx/core.py b/weldx/core.py
index cf25932..1d6f268 100644
--- a/weldx/core.py
+++ b/weldx/core.py
@@ -24,7 +24,7 @@ if TYPE_CHECKING:  # pragma: no cover
 
     from weldx.types import UnitLike
 
-__all__ = ["GenericSeries", "MathematicalExpression", "TimeSeries"]
+__all__ = ["GenericSeries", "MathematicalExpression", "TimeSeries", "SpatialSeries"]
 
 _me_parameter_types = Union[pint.Quantity, str, Tuple[pint.Quantity, str], xr.DataArray]
 
@@ -276,7 +276,6 @@ class MathematicalExpression:
             k: v if isinstance(v, xr.DataArray) else xr.DataArray(v)
             for k, v in self._parameters.items()
         }
-
         return self.function(**variables, **parameters)
 
 
@@ -1026,7 +1025,6 @@ class GenericSeries:
         else:
             # todo check data structure
             pass
-
         # check the constraints of derived types
         self._check_constraints_discrete(data)
         self._obj = data
@@ -1254,9 +1252,14 @@ class GenericSeries:
     def _evaluate_expr(self, coords: list[SeriesParameter]) -> GenericSeries:
         """Evaluate the expression at the passed coordinates."""
         if len(coords) == self._obj.num_variables:
-            eval_args = {v.symbol: v.data_array for v in coords}
-            data = self._obj.evaluate(**eval_args)
-            return self.__class__(data)
+            eval_args = {
+                v.symbol: v.data_array.assign_coords(
+                    {v.dim: v.data_array.pint.dequantify()}
+                )
+                for v in coords
+            }
+            da = self._obj.evaluate(**eval_args)
+            return self.__class__(da)
 
         # turn passed coords into parameters of the expression
         new_series = deepcopy(self)
@@ -1430,7 +1433,6 @@ class GenericSeries:
                 ref[k]["dimensionality"] = _units[k]
             if k in _vals:
                 ref[k]["values"] = _vals[k]
-
         ut.xr_check_coords(data_array, ref)
 
     @classmethod
@@ -1546,3 +1548,82 @@ class GenericSeries:
 
         """
         return NotImplemented
+
+
+# --------------------------------------------------------------------------------------
+# SpatialSeries
+# --------------------------------------------------------------------------------------
+
+
+class SpatialSeries(GenericSeries):
+    """Describes a line in 3d space depending on the positional coordinate ``s``."""
+
+    _position_dim_name = "s"
+
+    _required_variables: list[str] = [_position_dim_name]
+    """Required variable names"""
+
+    _required_dimensions: list[str] = [_position_dim_name, "c"]
+    """Required dimensions"""
+    _required_dimension_units: dict[str, pint.Unit] = {_position_dim_name: ""}
+    """Required units of a dimension"""
+    _required_dimension_coordinates: dict[str, list] = {"c": ["x", "y", "z"]}
+    """Required coordinates of a dimension."""
+
+    def __init__(
+        self,
+        obj: Union[pint.Quantity, xr.DataArray, str, MathematicalExpression],
+        dims: Union[list[str], dict[str, str]] = None,
+        coords: dict[str, pint.Quantity] = None,
+        units: dict[str, Union[str, pint.Unit]] = None,
+        interpolation: str = None,
+        parameters: dict[str, Union[str, pint.Quantity, xr.DataArray]] = None,
+    ):
+        if isinstance(obj, Q_):
+            obj = self._process_quantity(obj, dims, coords)
+            dims = None
+            coords = None
+        if parameters is not None:
+            parameters = self._process_parameters(parameters)
+        super().__init__(obj, dims, coords, units, interpolation, parameters)
+
+    @classmethod
+    def _process_quantity(
+        cls,
+        obj: Union[pint.Quantity, xr.DataArray, str, MathematicalExpression],
+        dims: Union[list[str], dict[str, str]],
+        coords: dict[str, pint.Quantity],
+    ) -> xr.DataArray:
+        """Turn a quantity into a a correctly formatted data array."""
+        if isinstance(coords, dict):
+            s = coords[cls._position_dim_name]
+        else:
+            s = coords
+            coords = {cls._position_dim_name: s}
+
+        if not isinstance(s, xr.DataArray):
+            if not isinstance(s, Q_):
+                s = Q_(s, "")
+            s = xr.DataArray(s, dims=[cls._position_dim_name]).pint.dequantify()
+            coords[cls._position_dim_name] = s
+
+        if "c" not in coords:
+            coords["c"] = ["x", "y", "z"]
+
+        if dims is None:
+            dims = [cls._position_dim_name, "c"]
+
+        return xr.DataArray(obj, dims=dims, coords=coords)
+
+    @staticmethod
+    def _process_parameters(params):
+        """Turn quantity parameters into the correctly formatted data arrays."""
+        for k, v in params.items():
+            if isinstance(v, Q_) and v.size == 3:
+                params[k] = xr.DataArray(v, dims=["c"], coords=dict(c=["x", "y", "z"]))
+        return params
+
+    @property
+    def position_dim_name(self):
+        """Return the name of the dimension that determines the position on the line."""
+        return self._position_dim_name
diff --git a/weldx/geometry.py b/weldx/geometry.py
index eb9e681..4126893 100644
--- a/weldx/geometry.py
+++ b/weldx/geometry.py
@@ -10,12 +10,14 @@ from typing import TYPE_CHECKING, Union
 import meshio
 import numpy as np
 import pint
+import sympy
 from xarray import DataArray
 
 import weldx.transformations as tf
 import weldx.util as ut
 from weldx.constants import _DEFAULT_ANG_UNIT, _DEFAULT_LEN_UNIT, Q_
 from weldx.constants import WELDX_UNIT_REGISTRY as UREG
+from weldx.core import MathematicalExpression, SpatialSeries
 from weldx.types import QuantityLike
 
 # only import heavy-weight packages on type checking
@@ -1379,70 +1381,248 @@ class Profile:
 # Trace segment classes -------------------------------------------------------
 
 
-class LinearHorizontalTraceSegment:
-    """Trace segment with a linear path and constant z-component."""
+class DynamicTraceSegment:
+    """Trace segment that can be defined by a ``SpatialSeries``."""
 
-    @UREG.wraps(None, (None, _DEFAULT_LEN_UNIT), strict=True)
-    def __init__(self, length: pint.Quantity):
-        """Construct linear horizontal trace segment.
+    def __init__(
+        self,
+        series: Union[
+            SpatialSeries, pint.Quantity, DataArray, str, MathematicalExpression
+        ],
+        max_coord: float = 1,
+        limit_orientation_to_xy: bool = False,
+        **kwargs,
+    ):
+        """Initialize a `DynamicTraceSegment`.
+
+        Parameters
+        ----------
+        series:
+            A `~weldx.core.SpatialSeries` that describes the trajectory of the trace
+            segment. Alternatively, one can pass every other object that is valid as
+            first argument to of the ``__init__`` method of the
+            `~weldx.core.SpatialSeries`.
+        max_coord:
+            [only expression based `~weldx.core.SpatialSeries`] The maximum coordinate
+            value of the passed series dimension that specifies the position on the 3d
+            line. The value defines the segments length by evaluating the expression on
+            the interval [0, ``max_coord``]
+        limit_orientation_to_xy:
+            If `True`, the orientation vectors of the coordinate systems along the trace
+            are confined to the xy-plane.
+        kwargs:
+            A set of keyword arguments that will be forwarded to the ``__init__`` method
+            of the `~weldx.core.SpatialSeries` in case the ``series`` parameter isn't
+            already a `~weldx.core.SpatialSeries`.
+        """
+        if not isinstance(series, SpatialSeries):
+            series = SpatialSeries(series, **kwargs)
+
+        self._series = series
+        self._max_coord = max_coord
+        self._limit_orientation = limit_orientation_to_xy
+
+        if series.is_expression:
+            self._derivative = self._get_derivative_expression()
+            self._length_expr = self._get_length_expr()
+        else:
+            self._derivative = None
+            self._length_expr = None
+
+        self._length = self.get_section_length(self._max_coord)
+
+    def _get_component_derivative_squared(self, i: int) -> sympy.Expr:
+        """Get the derivative of an expression for the i-th vector component."""
+
+        def _get_component(v, i):
+            if isinstance(v, Q_):
+                v = v.to_base_units().m
+            if v.size == 3:
+                return v[i]
+            return float(v)
+
+        me = self._series.data
+        subs = [(k, _get_component(v.data, i)) for k, v in me.parameters.items()]
+        return me.expression.subs(subs).diff(self._series.position_dim_name) ** 2
+
+    def _get_derivative_expression(self) -> MathematicalExpression:
+        """Get the derivative of an expression as `MathematicalExpression`."""
+        expr = MathematicalExpression(
+            self._series.data.expression.diff(self._series.position_dim_name)
+        )
+
+        # parameters might not be present anymore in the derived expression
+        params = {
+            k: v
+            for k, v in self._series.data.parameters.items()
+            if k in expr.get_variable_names()
+        }
+        expr.set_parameters(params)
+
+        return expr
+
+    def _get_tangent_vec_discrete(self, position: float) -> np.ndarray:
+        """Get the segments tangent vector at the given position (discrete case)."""
+        pos_data = self._series.coordinates[self._series.position_dim_name].data
+        idx_low = np.abs(pos_data - position).argmin()
+        if pos_data[idx_low] > position or idx_low + 1 == len(pos_data):
+            idx_low -= 1
+        vals = self._series.evaluate(s=[pos_data[idx_low], pos_data[idx_low + 1]]).data
+        return (vals[1] - vals[0]).m
+
+    def _get_length_expr(self) -> MathematicalExpression:
+        """Get the primitive of a the trace function if it is expression based."""
+        der_sq = [self._get_component_derivative_squared(i) for i in range(3)]
+        expr = sympy.sqrt(der_sq[0] + der_sq[1] + der_sq[2])
+        mc, u = sympy.symbols("max_coord, unit")
+        primitive = sympy.integrate(expr, (self._series.position_dim_name, 0, mc)) * u
+        params = dict(unit=Q_(1, Q_("1mm").to_base_units().u).to(_DEFAULT_LEN_UNIT))
+
+        return MathematicalExpression(primitive, params)
+
+    def get_section_length(self, position: float) -> pint.Quantity:
+        """Get the length from the start of the segment to the passed relative position.
 
         Parameters
         ----------
-        length :
-            Length of the segment
+        position:
+            The value of the relative position coordinate.
 
         Returns
         -------
-        LinearHorizontalTraceSegment
+        pint.Quantity:
+            The length at the specified value.
 
         """
-        if length <= 0:
-            raise ValueError("'length' must have a positive value.")
-        self._length = float(length)
+        if self._series.is_expression:
+            return self._length_expr.evaluate(max_coord=position).data
+        return self._len_section_disc(position=position)
 
-    def __repr__(self):
-        """Output representation of a LinearHorizontalTraceSegment."""
-        return f"LinearHorizontalTraceSegment('length': {self.length!r})"
+    def _len_section_disc(self, position: float) -> pint.Quantity:
+        """Get the length until a specific position on the trace (discrete version)."""
+        if position >= self._max_coord:
+            diff = self._series.data[1:] - self._series.data[:-1]
+        else:
+            pdn = self._series.position_dim_name
+            coords = self._series.coordinates[pdn].data
+            idx_coord_upper = np.abs(coords - position).argmin()
+            if coords[idx_coord_upper] < position:
+                idx_coord_upper = idx_coord_upper + 1
+
+            coords_eval = np.append(coords[:idx_coord_upper], position)
+            vecs = self._series.evaluate(**{pdn: coords_eval}).data
+
+            diff = vecs[1:] - vecs[:-1]
+
+        length = np.sum(np.linalg.norm(diff.m, axis=1))
+        return Q_(length, diff.u)
+
+    def _get_lcs_from_coords_and_tangent(
+        self, coords: pint.Quantity, tangent: np.ndarray
+    ) -> tf.LocalCoordinateSystem:
+        """Create a ``LocalCoordinateSystem`` from coordinates and tangent vector."""
+        pdn = self._series.position_dim_name
+
+        if coords.coords[pdn].size == 1:
+            coords = coords.isel(s=0)
+
+        x = tangent
+        z = [0, 0, 1] if x.size == 3 else [[0, 0, 1] for _ in range(x.shape[0])]
+        y = np.cross(z, x)
+
+        if self._limit_orientation:
+            x = np.cross(y, z)
+        else:
+            z = np.cross(x, y)
+
+        if x.size == 3:
+            orient = np.array([x, y, z]).transpose()
+        else:
+            orient = DataArray(
+                np.array([x, y, z]),
+                dims=["v", pdn, "c"],
+                coords={"c": ["x", "y", "z"], "v": [0, 1, 2], pdn: coords.coords[pdn]},
+            )
+
+        return tf.LocalCoordinateSystem(orient, coords)
+
+    def _lcs_expr(self, position: float) -> tf.LocalCoordinateSystem:
+        """Get a ``LocalCoordinateSystem`` at the passed rel. position (expression)."""
+        pdn = self._series.position_dim_name
+        eval_pos = {pdn: position * self._max_coord}
+
+        coords = self._series.evaluate(**eval_pos).data_array
+        x = self._derivative.evaluate(**eval_pos).transpose(..., "c")
+
+        return self._get_lcs_from_coords_and_tangent(coords, x.data.m)
+
+    def _lcs_disc(self, position: float) -> tf.LocalCoordinateSystem:
+        """Get a ``LocalCoordinateSystem`` at the passed rel. position (discrete)."""
+        pdn = self._series.position_dim_name
+
+        coords = self._series.evaluate(**{pdn: position}).data_array
+        if coords.coords[pdn].size == 1:
+            x = self._get_tangent_vec_discrete(position)
+        else:
+            x = np.array([self._get_tangent_vec_discrete(p) for p in position])
+        return self._get_lcs_from_coords_and_tangent(coords, x)
 
     @property
-    @UREG.wraps(_DEFAULT_LEN_UNIT, (None,), strict=True)
-    def length(self):
-        """Get the length of the segment.
+    def length(self) -> pint.Quantity:
+        """Get the length of the segment."""
+        return self._length
+
+    def local_coordinate_system(self, position: float) -> tf.LocalCoordinateSystem:
+        """Calculate a local coordinate system at a position of the trace segment.
+
+        Parameters
+        ----------
+        position:
+            The relative position on the segment (interval [0, 1]). 0 is the start of
+            the segment and 1 its end
 
         Returns
         -------
-        pint.Quantity
-            Length of the segment
+        weldx.transformations.LocalCoordinateSystem:
+            The coordinate system and the specified position.
 
         """
-        return self._length
+        if not isinstance(position, (float, int, Q_)):
+            position = np.array(position)
 
-    def local_coordinate_system(
-        self, relative_position: float
-    ) -> tf.LocalCoordinateSystem:
-        """Calculate a local coordinate system along the trace segment.
+        if self._series.is_expression:
+            return self._lcs_expr(position)
+        return self._lcs_disc(position)
+
+
+class LinearHorizontalTraceSegment(DynamicTraceSegment):
+    """Trace segment with a linear path and constant z-component."""
+
+    @UREG.wraps(None, (None, _DEFAULT_LEN_UNIT), strict=True)
+    def __init__(self, length: pint.Quantity):
+        """Construct linear trace segment of length ``length`` in ``x``-direction.
+
+        The trace will run between the points ``[0, 0, 0]`` and ``[length, 0, 0]``
 
         Parameters
         ----------
-        relative_position :
-            Relative position on the trace [0 .. 1]
+        length :
+            Length of the segment
 
         Returns
         -------
-        weldx.transformations.LocalCoordinateSystem
-            Local coordinate system
+        LinearHorizontalTraceSegment
 
         """
-        relative_position = np.clip(relative_position, 0, 1)
-
-        coordinates = np.array([1, 0, 0]) * relative_position * self._length
-        if isinstance(coordinates, pint.Quantity):
-            coordinates = coordinates.m
+        if length <= 0:
+            raise ValueError("'length' must be a positive value.")
 
-        return tf.LocalCoordinateSystem(coordinates=coordinates)
+        super().__init__(
+            Q_([[0, 0, 0], [length, 0, 0]], _DEFAULT_LEN_UNIT), coords=[0, 1]
+        )
 
 
-class RadialHorizontalTraceSegment:
+class RadialHorizontalTraceSegment(DynamicTraceSegment):
     """Trace segment describing an arc with constant z-component."""
 
     @UREG.wraps(None, (None, _DEFAULT_LEN_UNIT, _DEFAULT_ANG_UNIT, None), strict=True)
@@ -1469,13 +1649,23 @@ class RadialHorizontalTraceSegment:
             raise ValueError("'radius' must have a positive value.")
         if angle <= 0:
             raise ValueError("'angle' must have a positive value.")
+
         self._radius = float(radius)
         self._angle = float(angle)
-        self._length = self._arc_length(self._radius, self._angle)
+
         if clockwise:
-            self._sign_winding = -1
-        else:
             self._sign_winding = 1
+        else:
+            self._sign_winding = -1
+
+        expr = "(x*sin(s)+w*y*(cos(s)-1))*r "
+        params = dict(
+            x=Q_([1, 0, 0], "mm"),
+            y=Q_([0, 1, 0], "mm"),
+            r=self._radius,
+            w=self._sign_winding,
+        )
+        super().__init__(expr, max_coord=self._angle, parameters=params)
 
     def __repr__(self):
         """Output representation of a RadialHorizontalTraceSegment."""
@@ -1486,106 +1676,29 @@ class RadialHorizontalTraceSegment:
             f"'sign_winding': {self._sign_winding!r})"
         )
 
-    @staticmethod
-    def _arc_length(radius, angle) -> float:
-        """Calculate the arc length.
-
-        Parameters
-        ----------
-        radius :
-            Radius
-        angle :
-            Angle (rad)
-
-        Returns
-        -------
-        float
-            Arc length
-
-        """
-        return angle * radius
-
     @property
     @UREG.wraps(_DEFAULT_ANG_UNIT, (None,), strict=True)
     def angle(self) -> pint.Quantity:
-        """Get the angle of the segment.
-
-        Returns
-        -------
-        pint.Quantity
-            Angle of the segment (rad)
-
-        """
+        """Get the angle of the segment."""
         return self._angle
 
-    @property
-    @UREG.wraps(_DEFAULT_LEN_UNIT, (None,), strict=True)
-    def length(self) -> pint.Quantity:
-        """Get the length of the segment.
-
-        Returns
-        -------
-        pint.Quantity
-            Length of the segment
-
-        """
-        return self._length
-
     @property
     @UREG.wraps(_DEFAULT_LEN_UNIT, (None,), strict=True)
     def radius(self) -> pint.Quantity:
-        """Get the radius of the segment.
-
-        Returns
-        -------
-        pint.Quantity
-            Radius of the segment
-
-        """
+        """Get the radius of the segment."""
         return self._radius
 
     @property
     def is_clockwise(self) -> bool:
-        """Get True, if the segments winding is clockwise, False otherwise.
-
-        Returns
-        -------
-        bool
-            True or False
-
-        """
-        return self._sign_winding < 0
-
-    def local_coordinate_system(
-        self, relative_position: float
-    ) -> tf.LocalCoordinateSystem:
-        """Calculate a local coordinate system along the trace segment.
-
-        Parameters
-        ----------
-        relative_position :
-            Relative position on the trace [0 .. 1]
-
-        Returns
-        -------
-        weldx.transformations.LocalCoordinateSystem
-            Local coordinate system
-
-        """
-        relative_position = np.clip(relative_position, 0, 1)
-
-        orientation = tf.WXRotation.from_euler(
-            "z", self._angle * relative_position * self._sign_winding
-        ).as_matrix()
-        translation = np.array([0, -1, 0]) * self._radius * self._sign_winding
-
-        coordinates = np.matmul(orientation, translation) - translation
-        return tf.LocalCoordinateSystem(orientation, coordinates)
+        """Get True, if the segments winding is clockwise, False otherwise."""
+        return self._sign_winding > 0
 
 
 # Trace class -----------------------------------------------------------------
 
-trace_segment_types = Union[LinearHorizontalTraceSegment, RadialHorizontalTraceSegment]
+trace_segment_types = Union[
+    LinearHorizontalTraceSegment, RadialHorizontalTraceSegment, DynamicTraceSegment
+]
 
 
 class Trace:
@@ -1611,7 +1724,8 @@ class Trace:
 
         """
         if coordinate_system is None:
-            coordinate_system = tf.LocalCoordinateSystem()
+            default_coords = Q_([0, 0, 0], _DEFAULT_LEN_UNIT)
+            coordinate_system = tf.LocalCoordinateSystem(coordinates=default_coords)
 
         if not isinstance(coordinate_system, tf.LocalCoordinateSystem):
             raise TypeError(
@@ -1660,7 +1774,6 @@ class Trace:
             # Fill length lookups
             segment_length = segment.length
             total_length += segment_length
-
             self._segment_length_lookup += [segment_length]
             self._total_length_lookup += [total_length.copy()]
 
@@ -1707,7 +1820,7 @@ class Trace:
             Length of the trace.
 
         """
-        return self._total_length_lookup[-1].m
+        return self._total_length_lookup[-1]
 
     @property
     def segments(self) -> list[trace_segment_types]:
@@ -1775,7 +1888,6 @@ class Trace:
         pint.Quantity
             Raster data
 
-
         """
         if not raster_width > 0:
             raise ValueError("'raster_width' must be > 0")
@@ -1848,6 +1960,13 @@ class Trace:
         else:
             axes.plot(data[0].m, data[1].m, data[2].m, fmt)
 
+    def _k3d_line(self, raster_width: pint.Quantity = "1mm"):
+        """Get (or show) a k3d line from of the trace."""
+        import k3d
+
+        r = self.rasterize(raster_width).to(_DEFAULT_LEN_UNIT).magnitude
+        return k3d.line(r.astype("float32").T)
+
 
 # Linear profile interpolation class ------------------------------------------
 


Test patch:
diff --git a/weldx/tests/_helpers.py b/weldx/tests/_helpers.py
index 22ba66a..ae32855 100644
--- a/weldx/tests/_helpers.py
+++ b/weldx/tests/_helpers.py
@@ -4,6 +4,7 @@ from typing import Any
 
 import numpy as np
 
+from weldx.constants import Q_
 from weldx.transformations import LocalCoordinateSystem, WXRotation
 
 
@@ -46,7 +47,9 @@ def rotated_coordinate_system(
 
     rotated_orientation = np.matmul(r_tot, orientation)
 
-    return LocalCoordinateSystem(rotated_orientation, np.array(coordinates))
+    if not isinstance(coordinates, Q_):
+        coordinates = np.array(coordinates)
+    return LocalCoordinateSystem(rotated_orientation, coordinates)
 
 
 def are_all_columns_unique(matrix, decimals=3):
diff --git a/weldx/tests/test_geometry.py b/weldx/tests/test_geometry.py
index 9dfd007..8ed7a8a 100644
--- a/weldx/tests/test_geometry.py
+++ b/weldx/tests/test_geometry.py
@@ -2096,7 +2096,8 @@ def check_trace_segment_length(segment, tolerance=1e-9):
 
     """
     lcs = segment.local_coordinate_system(1)
-    length_numeric_prev = np.linalg.norm(lcs.coordinates)
+
+    length_numeric_prev = np.linalg.norm(lcs.coordinates.data.m)
 
     # calculate numerical length by linearization
     num_segments = 2.0
@@ -2111,7 +2112,9 @@ def check_trace_segment_length(segment, tolerance=1e-9):
         cs_0 = segment.local_coordinate_system(0)
         for rel_pos in np.arange(increment, 1.0 + increment / 2, increment):
             cs_1 = segment.local_coordinate_system(rel_pos)
-            length_numeric += np.linalg.norm(cs_1.coordinates - cs_0.coordinates)
+            length_numeric += np.linalg.norm(
+                cs_1.coordinates.data.m - cs_0.coordinates.data.m
+            )
             cs_0 = copy.deepcopy(cs_1)
 
         relative_change = length_numeric / length_numeric_prev
@@ -2150,7 +2153,9 @@ def check_trace_segment_orientation(segment):
     for rel_pos in np.arange(0.1, 1.01, 0.1):
         lcs = segment.local_coordinate_system(rel_pos)
         lcs_d = segment.local_coordinate_system(rel_pos - delta)
-        trace_direction_approx = tf.normalize(lcs.coordinates - lcs_d.coordinates)
+        trace_direction_approx = tf.normalize(
+            lcs.coordinates.data.m - lcs_d.coordinates.data.m
+        )
 
         # Check if the x-axis is aligned with the approximate trace direction
         assert vector_is_close(lcs.orientation[:, 0], trace_direction_approx, 1e-6)
@@ -2173,7 +2178,10 @@ def default_trace_segment_tests(segment, tolerance_length=1e-9):
     assert isinstance(lcs, tf.LocalCoordinateSystem)
 
     # check that coordinates for weight 0 are at [0, 0, 0]
-    assert vector_is_close(lcs.coordinates, [0, 0, 0])
+    coords = lcs.coordinates.data
+    if isinstance(coords, Q_):
+        coords = coords.m
+    assert vector_is_close(coords, [0, 0, 0])
 
     # length and orientation tests
     check_trace_segment_length(segment, tolerance_length)
@@ -2235,8 +2243,8 @@ def test_radial_horizontal_trace_segment():
         lcs_cw = segment_cw.local_coordinate_system(weight)
         lcs_ccw = segment_ccw.local_coordinate_system(weight)
 
-        assert vector_is_close(lcs_cw.coordinates, [x_exp.m, -y_exp.m, 0])
-        assert vector_is_close(lcs_ccw.coordinates, [x_exp.m, y_exp.m, 0])
+        assert vector_is_close(lcs_cw.coordinates.data.m, [x_exp.m, -y_exp.m, 0])
+        assert vector_is_close(lcs_ccw.coordinates.data.m, [x_exp.m, y_exp.m, 0])
 
     # invalid inputs
     with pytest.raises(ValueError):
@@ -2281,7 +2289,7 @@ def test_trace_construction():
     """Test the trace's construction."""
     linear_segment = geo.LinearHorizontalTraceSegment("1mm")
     radial_segment = geo.RadialHorizontalTraceSegment("1mm", Q_(np.pi, "rad"))
-    cs_coordinates = np.array([2, 3, -2])
+    cs_coordinates = Q_([2, 3, -2], "mm")
     cs_initial = helpers.rotated_coordinate_system(coordinates=cs_coordinates)
 
     # test single segment construction --------------------
@@ -2370,7 +2378,7 @@ def test_trace_local_coordinate_system():
 
     # check with arbitrary coordinate system --------------
     orientation = WXRotation.from_euler("x", np.pi / 2).as_matrix()
-    coordinates = np.array([-3, 2.5, 5])
+    coordinates = Q_([-3, 2.5, 5], "mm")
     cs_base = tf.LocalCoordinateSystem(orientation, coordinates)
 
     trace = geo.Trace([radial_segment, linear_segment], cs_base)
@@ -2392,7 +2400,7 @@ def test_trace_local_coordinate_system():
         weight = i / 10
         position_on_segment = linear_segment.length * weight
         position = radial_segment.length + position_on_segment
-        lcs_coordinates = [position_on_segment.m, 0, 0]
+        lcs_coordinates = Q_([position_on_segment.m, 0, 0], "mm")
 
         cs_exp = tf.LocalCoordinateSystem(coordinates=lcs_coordinates) + cs_start_seg2
         cs_trace = trace.local_coordinate_system(position)
@@ -2433,11 +2441,10 @@ def test_trace_rasterization():
     # check with arbitrary coordinate system --------------
     orientation = WXRotation.from_euler("y", np.pi / 2).as_matrix()
     coordinates = Q_([-3, 2.5, 5], "mm")
-    cs_base = tf.LocalCoordinateSystem(orientation, coordinates.m)
+    cs_base = tf.LocalCoordinateSystem(orientation, coordinates)
 
     trace = geo.Trace([linear_segment, radial_segment], cs_base)
     data = trace.rasterize("0.1mm")
-    print(data)
     raster_width_eff = trace.length / (data.shape[1] - 1)
 
     for i in range(data.shape[1]):