From 2a3ab449bc1b6456bf0ef87429c309c6ea957e32 Mon Sep 17 00:00:00 2001 From: vhirtham Date: Mon, 28 Feb 2022 17:31:49 +0100 Subject: [PATCH] `SpatialSeries` and `DynamicTraceSegment` (#699) --- .pre-commit-config.yaml | 2 +- CHANGELOG.rst | 2 + tutorials/01_03_geometry.ipynb | 2 +- tutorials/welding_example_01_basics.ipynb | 12 +- weldx/__init__.py | 26 +- weldx/core.py | 95 +++++- weldx/geometry.py | 371 ++++++++++++++-------- weldx/tests/_helpers.py | 5 +- weldx/tests/test_geometry.py | 29 +- 9 files changed, 382 insertions(+), 162 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b104a112b..1e3d0a3e2 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 065e3d506..a07ce1882 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 6e20b5375..69bbf8f48 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 a6946ef6d..adc89def8 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 a1839b856..103881086 100644 --- a/weldx/__init__.py +++ b/weldx/__init__.py @@ -50,6 +50,7 @@ Time TimeSeries GenericSeries + SpatialSeries MathematicalExpression CoordinateSystemManager LocalCoordinateSystem @@ -72,6 +73,7 @@ Shape Trace SpatialData + DynamicTraceSegment **Full API Reference** @@ -130,17 +132,21 @@ 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 @@ Shape, Trace, SpatialData, + DynamicTraceSegment, ) -from weldx.transformations import ( +from weldx.transformations import ( # skipcq: PY-W2000 CoordinateSystemManager, LocalCoordinateSystem, WXRotation, @@ -161,10 +168,10 @@ 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 @@ "util", "welding", "TimeSeries", + "DynamicTraceSegment", "LinearHorizontalTraceSegment", "Config", "Time", diff --git a/weldx/core.py b/weldx/core.py index cf259322b..1d6f26822 100644 --- a/weldx/core.py +++ b/weldx/core.py @@ -24,7 +24,7 @@ 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 @@ def evaluate(self, **kwargs) -> Any: 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 @@ def _init_discrete( else: # todo check data structure pass - # check the constraints of derived types self._check_constraints_discrete(data) self._obj = data @@ -1254,9 +1252,14 @@ def _evaluate_preprocessor(self, **kwargs) -> list[SeriesParameter]: 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 @@ def _check_constraints_discrete(cls, data_array: xr.DataArray): 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 @@ def interp_like( """ 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 eb9e68168..4126893fc 100644 --- a/weldx/geometry.py +++ b/weldx/geometry.py @@ -10,12 +10,14 @@ 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 @@ def shapes(self) -> list[Shape]: # 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 @@ def __init__( 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 @@ def __repr__(self): 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 @@ def __init__( """ 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 @@ def _create_lookups(self, coordinate_system_start: tf.LocalCoordinateSystem): # 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 @@ def length(self) -> pint.Quantity: 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 @@ def rasterize(self, raster_width: pint.Quantity) -> pint.Quantity: pint.Quantity Raster data - """ if not raster_width > 0: raise ValueError("'raster_width' must be > 0") @@ -1848,6 +1960,13 @@ def plot( 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 ------------------------------------------ diff --git a/weldx/tests/_helpers.py b/weldx/tests/_helpers.py index 22ba66acf..ae3285546 100644 --- a/weldx/tests/_helpers.py +++ b/weldx/tests/_helpers.py @@ -4,6 +4,7 @@ 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 9dfd007cf..8ed7a8a25 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]):