diff --git a/.github/workflows/pytest_asdf.yml b/.github/workflows/pytest_asdf.yml index 08d920717..79218e85f 100644 --- a/.github/workflows/pytest_asdf.yml +++ b/.github/workflows/pytest_asdf.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: os: [ubuntu-18.04] - py: ['3.8', '3.9'] + py: ['3.8'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index f5d35490b..480b47289 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - py: ['3.8', '3.9'] + py: ['3.8'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - py: ['3.8', '3.9'] + py: ['3.8'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 @@ -48,7 +48,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - py: ['3.8', '3.9'] + py: ['3.8'] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/.typo-ci.yml b/.typo-ci.yml index 096ae4523..0294fcebd 100644 --- a/.typo-ci.yml +++ b/.typo-ci.yml @@ -30,12 +30,15 @@ excluded_words: - networkx - shutil - functools + - boltons + - meshio # matplotlib ---------------------------------- - gca - mpl - ncols - plt - pyplot + - zorder # flake8 -------------------------------------- - noqa - addopts @@ -56,6 +59,7 @@ excluded_words: - ndarray - ndim - newaxis + - tolist - vstack # pandas / xarray ----------------------------- - bfill @@ -116,6 +120,9 @@ excluded_words: - numpydoc # markdown / latex ---------------------------- - cdot + # file extensions ----------------------------- + - vtk + - stl # other --------------------------------------- - addopts - csm @@ -144,6 +151,7 @@ excluded_words: - tslibs - quickstart - nsecond + - iterutils # German --------------------------------------- - Bundesanstalt - für diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5f5a241..97bafdab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Release Notes -## 0.3.0 (unreleased) +## 0.3.0 (12.03.2021) ### added @@ -8,8 +8,13 @@ function [[#219]](https://github.com/BAMWelDX/weldx/pull/219) - add `SpatialDate` class for storing 3D point data with optional triangulation [[#234]](https://github.com/BAMWelDX/weldx/pull/234) -- add `plot` function to visualize `LocalCoordinateSystem` and `CoordinateSystemManager` instances in 3d space +- add `plot` function to `SpatialData`[[#251]](https://github.com/BAMWelDX/weldx/pull/251) +- add `plot` function to visualize `LocalCoordinateSystem` and `CoordinateSystemManager` instances in 3d space [[#231]](https://github.com/BAMWelDX/weldx/pull/231) +- add `weldx.welding.groove.iso_9692_1.IsoBaseGroove.cross_sect_area` property to compute cross sectional area between + the workpieces [[#248]](https://github.com/BAMWelDX/weldx/pull/248). +- add `weldx.welding.util.compute_welding_speed` function [[#248]](https://github.com/BAMWelDX/weldx/pull/248). + ### ASDF - Add possibility to store meta data and content of an external file in an ASDF @@ -27,6 +32,14 @@ as `custom_schema` when reading/writing `ASDF`-files - the `single_pass_weld-1.0.0.schema` is an example schema for a simple, linear, single pass GMAW application - add `core/geometry/point_cloud-1.0.0.yaml` schema [[#234]](https://github.com/BAMWelDX/weldx/pull/234) +- add file schema describing a simple linear welding + application `datamodels/single_pass_weld-1.0.0.schema` [[#256]](https://github.com/BAMWelDX/weldx/pull/256) + +### documentation + +- Simplify tutorial code and enhance plots by using newly implemented plot functions + [[#231]](https://github.com/BAMWelDX/weldx/pull/231) [[#251]](https://github.com/BAMWelDX/weldx/pull/251) +- add AWS shielding gas descriptions to documentation [[#270]](https://github.com/BAMWelDX/weldx/pull/270) ### changes @@ -43,6 +56,13 @@ - add `stack` option to most `geometry` classes for rasterization [[#234]](https://github.com/BAMWelDX/weldx/pull/234) - The graph of a `CoordinateSystemManager` is now plotted with `plot_graph` instead of `plot`. [[#231]](https://github.com/BAMWelDX/weldx/pull/231) +- add custom `wx_shape` validation for `TimeSeries` and `Quantity` [[#256]](https://github.com/BAMWelDX/weldx/pull/256) +- refactor the `transformations` and `visualization` module into smaller + files [[#247]](https://github.com/BAMWelDX/weldx/pull/247) +- refactor `weldx.utility` into `weldx.util` [[#247]](https://github.com/BAMWelDX/weldx/pull/247) +- refactor `weldx.asdf.utils` into `weldx.asdf.util` [[#247]](https://github.com/BAMWelDX/weldx/pull/247) +- it is now allowed to merge a time-dependent `timedelta` subsystem into another `CSM` instance if the parent instance + has set an explicit reference time [[#268]](https://github.com/BAMWelDX/weldx/pull/268) ### fixes @@ -53,6 +73,11 @@ - fix deprecated signature in `WXRotation` [[#224]](https://github.com/BAMWelDX/weldx/pull/224) - fix a bug with singleton dimensions in xarray interpolation/matmul [[#243]](https://github.com/BAMWelDX/weldx/pull/243) +- update some documentation formatting and links [[#247]](https://github.com/BAMWelDX/weldx/pull/247) +- fix `wx_shape` validation for scalar `Quantity` and `TimeSeries` + objects [[#256]](https://github.com/BAMWelDX/weldx/pull/256) +- fix a case where `CSM.time_union()` would return with mixed `DateTimeIndex` and `TimeDeltaIndex` + types [[#268]](https://github.com/BAMWelDX/weldx/pull/268) ### dependencies @@ -60,7 +85,10 @@ - Add [k3d](https://github.com/K3D-tools/K3D-jupyter) as new dependency - restrict `scipy<1.6` pending [ASDF #916](https://github.com/asdf-format/asdf/issues/916) [[#224]](https://github.com/BAMWelDX/weldx/pull/224) -- set minimum Python version to 3.8 [[#229]](https://github.com/BAMWelDX/weldx/pull/229)[[#255]](https://github.com/BAMWelDX/weldx/pull/255) +- set minimum Python version to + 3.8 [[#229]](https://github.com/BAMWelDX/weldx/pull/229)[[#255]](https://github.com/BAMWelDX/weldx/pull/255) +- only import some packages upon first use [[#247]](https://github.com/BAMWelDX/weldx/pull/247) +- Add [meshio](https://pypi.org/project/meshio/) as new dependency [#265](https://github.com/BAMWelDX/weldx/pull/265) ## 0.2.2 (30.11.2020) diff --git a/README.md b/README.md index dd6e061a9..4f956a089 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,11 @@ division at Bundesanstalt für Materialforschung und -prüfung (BAM). ## Installation -The WelDX package can be installed using conda from the `conda-forge` channel. - +The WelDX package can be installed using conda or mamba package manager from the :code:`conda-forge` channel. +These managers originate from the freely available [Anaconda Python stack](https://docs.conda.io/en/latest/miniconda.html>). +If you do not have Anaconda or Miniconda installed yet, we ask you to install ``Miniconda-3``. +Documentation for the installation procedure can be found [here](https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html#regular-installation). +After this step you have access to the conda command and can proceed to installing the WeldX package. ```console conda install weldx -c conda-forge ``` diff --git a/codecov.yml b/codecov.yml index 6596b0892..7ff2384f6 100644 --- a/codecov.yml +++ b/codecov.yml @@ -25,4 +25,4 @@ coverage: ignore: - "tests" - "*__init__.py" - - "weldx/visualization.py" + - "weldx/visualization/*" diff --git a/doc/_templates/module-template.rst b/doc/_templates/module-template.rst index 8836ec68c..f61010bf5 100644 --- a/doc/_templates/module-template.rst +++ b/doc/_templates/module-template.rst @@ -14,28 +14,28 @@ {% endif %} {% endblock %} - {% block functions %} - {% if functions %} - .. rubric:: {{ _('Functions') }} + {% block classes %} + {% if classes %} + .. rubric:: {{ _('Classes') }} .. autosummary:: :toctree: + :template: class-template.rst :nosignatures: - {% for item in functions %} + {% for item in classes %} {{ item }} {%- endfor %} {% endif %} {% endblock %} - {% block classes %} - {% if classes %} - .. rubric:: {{ _('Classes') }} + {% block functions %} + {% if functions %} + .. rubric:: {{ _('Functions') }} .. autosummary:: :toctree: - :template: class-template.rst :nosignatures: - {% for item in classes %} + {% for item in functions %} {{ item }} {%- endfor %} {% endif %} diff --git a/doc/api.rst b/doc/api.rst index 833a4c13d..0d1a9158b 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -1,6 +1,5 @@ API -================================= - +=== **Python modules** @@ -15,7 +14,7 @@ API weldx.geometry weldx.measurement weldx.transformations - weldx.utility + weldx.util weldx.visualization weldx.welding @@ -29,6 +28,6 @@ API :recursive: weldx.asdf.extension - weldx.asdf.utils + weldx.asdf.util weldx.asdf.validators diff --git a/doc/conf.py b/doc/conf.py index 44e3dcf99..3121c47d4 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -22,11 +22,20 @@ import traitlets +# find weldx from parent path. +sys.path.insert(0, os.path.abspath("../")) + +import typing + +import ipywidgets +import pandas as _ +import xarray + +typing.TYPE_CHECKING = True import weldx +import weldx.visualization # load visualization from weldx.asdf.constants import WELDX_TAG_BASE -sys.path.insert(0, os.path.abspath("")) - # -- copy files to doc folder ------------------------------------------------- doc_dir = pathlib.Path(".") changelog_file = pathlib.Path("./../CHANGELOG.md") @@ -45,7 +54,7 @@ author = "BAM" # The full version, including alpha/beta/rc tags -release = weldx.__version__ +release = weldx.__version__ if weldx.__version__ else "undefined" # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" @@ -104,7 +113,7 @@ napoleon_type_aliases = None # sphinx-autodoc-typehints https://github.com/agronholm/sphinx-autodoc-typehints -set_type_checking_flag = False +set_type_checking_flag = True typehints_fully_qualified = False always_document_param_types = False typehints_document_rtype = True @@ -191,7 +200,9 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["**.ipynb_checkpoints"] +exclude_patterns = [ + "**.ipynb_checkpoints", +] # -- Options for HTML output ------------------------------------------------- diff --git a/doc/index.rst b/doc/index.rst index 59341eef1..11b65bb51 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -27,11 +27,21 @@ The second main component is the the ``WelDX`` file standard that is used to def Installation ############ -The WelDX package can be installed using conda from the :code:`bamwelding` channel (with some required packages available on the :code:`conda-forge` channel). +The WelDX package can be installed using conda or mamba package manager from the :code:`conda-forge` channel. +These managers originate from the freely available `Anaconda Python stack `_. +If you do not have Anaconda or Miniconda installed yet, we ask you to install ``Miniconda-3``. +Documentation for the installation procedure can be found `here `_. After this step you have access to the conda command and can proceed to installing the WeldX package. :: - conda install weldx -c conda-forge -c bamwelding + conda install weldx -c conda-forge + + +The package is also available on pypi. + +:: + + pip install weldx Funding diff --git a/doc/nitpick_ignore b/doc/nitpick_ignore index 513742720..9972f9dc1 100644 --- a/doc/nitpick_ignore +++ b/doc/nitpick_ignore @@ -27,6 +27,10 @@ py:class pint.quantity.Quantity py:class scipy.spatial.transform.rotation.Rotation py:class scipy.spatial.transform.Rotation +# derived from scipy +py:method weldx.transformations.WXRotation.from_mrp +py:method weldx.transformations.WXRotation.random + # sympy py:class sympy.core.expr.Expr diff --git a/doc/schemas/aws.rst b/doc/schemas/aws.rst new file mode 100644 index 000000000..71dee7de1 --- /dev/null +++ b/doc/schemas/aws.rst @@ -0,0 +1,10 @@ +AWS +=== + +The ``AWS`` directory contains example implementations for shielding gas descriptions following NISTIR 7107. + +.. asdf-autoschemas:: + + aws/process/shielding_gas_for_procedure-1.0.0 + aws/process/shielding_gas_type-1.0.0 + aws/process/gas_component-1.0.0 diff --git a/doc/shape-validation.md b/doc/shape-validation.md index 9b6648aae..1927a89ea 100644 --- a/doc/shape-validation.md +++ b/doc/shape-validation.md @@ -32,7 +32,7 @@ Each shape item follows these rules: * an ``Integer`` indicates a fix dimension for the same item -* a ``~``, `:` or `None` indicates a single dimension of arbitrary length. +* a ``~`` indicates a single dimension of arbitrary length. * a ``...`` indicates an arbitrary number of dimensions of arbitrary length, which can be optional. @@ -40,7 +40,7 @@ Each shape item follows these rules: * parenthesis ``(_)`` indicate that the dimension is optional. This can be combined with the other rules. -* the symbols ``~`` or `:` furthermore add the option to implement an interval. This string `4~` would be an open +* the symbols ``~`` furthermore add the option to implement an interval. This string `4~` would be an open interval that accepts all dimensions that are greater or equal to 4. ### Exceptions diff --git a/doc/standard.rst b/doc/standard.rst index 356297f70..e7e326fc3 100644 --- a/doc/standard.rst +++ b/doc/standard.rst @@ -17,6 +17,7 @@ The WelDX standard consists of the following schema definitions: schemas/process.rst schemas/time.rst schemas/groove.rst + schemas/aws.rst schemas/datamodels.rst ASDF Extension diff --git a/environment.yml b/environment.yml index c3b73361c..118a02850 100644 --- a/environment.yml +++ b/environment.yml @@ -17,6 +17,7 @@ dependencies: # - asdf>=2.7 - openpyxl - fs + - meshio # graph packages - networkx # Code quality diff --git a/scripts/welding_schema.ipynb b/scripts/welding_schema.ipynb deleted file mode 100644 index 8bef39a0c..000000000 --- a/scripts/welding_schema.ipynb +++ /dev/null @@ -1,485 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Welding schema" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%cd -q .." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# some python imports that will be used throughout the tutorial\n", - "import matplotlib.pyplot as plt\n", - "import networkx as nx\n", - "import numpy as np\n", - "import pandas as pd\n", - "import pint\n", - "import sympy\n", - "import xarray as xr\n", - "from mpl_toolkits.mplot3d import Axes3D\n", - "\n", - "import asdf" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# importing the weldx package with prevalent default abbreviations\n", - "import weldx\n", - "import weldx.geometry as geo\n", - "import weldx.measurement as msm\n", - "import weldx.transformations as tf\n", - "import weldx.utility as ut\n", - "import weldx.visualization as vis\n", - "from weldx import Q_\n", - "from weldx.transformations import LocalCoordinateSystem as lcs\n", - "from weldx.transformations import WXRotation\n", - "from weldx.welding.groove.iso_9692_1 import get_groove" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Timestamp" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# file timestamp\n", - "reference_timestamp = pd.Timestamp(\"2020-11-09 12:00:00\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Geometry" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# groove + trace = geometry\n", - "groove = get_groove(\n", - " groove_type=\"VGroove\",\n", - " workpiece_thickness=Q_(5, \"mm\"),\n", - " groove_angle=Q_(50, \"deg\"),\n", - " root_face=Q_(1, \"mm\"),\n", - " root_gap=Q_(1, \"mm\"),\n", - ")\n", - "\n", - "# define the weld seam length in mm\n", - "seam_length = Q_(300, \"mm\")\n", - "\n", - "# create a linear trace segment a the complete weld seam trace\n", - "trace_segment = geo.LinearHorizontalTraceSegment(seam_length)\n", - "trace = geo.Trace(trace_segment)\n", - "\n", - "geometry = dict(groove_shape=groove, seam_length=seam_length)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup the Coordinate System Manager (CSM)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# crete a new coordinate system manager with default base coordinate system\n", - "csm = weldx.transformations.CoordinateSystemManager(\"base\")\n", - "\n", - "# add the workpiece coordinate system\n", - "csm.add_cs(\n", - " coordinate_system_name=\"workpiece\",\n", - " reference_system_name=\"base\",\n", - " lcs=trace.coordinate_system,\n", - ")\n", - "\n", - "tcp_start_point = Q_([5.0, 0.0, 2.0], \"mm\")\n", - "tcp_end_point = Q_([-5.0, 0.0, 2.0], \"mm\") + np.append(seam_length, Q_([0, 0], \"mm\"))\n", - "\n", - "v_weld = Q_(10, \"mm/s\")\n", - "s_weld = (tcp_end_point - tcp_start_point)[0] # length of the weld\n", - "t_weld = s_weld / v_weld\n", - "\n", - "t_start = pd.Timedelta(\"0s\")\n", - "t_end = pd.Timedelta(str(t_weld.to_base_units()))\n", - "\n", - "rot = WXRotation.from_euler(seq=\"x\", angles=180, degrees=True)\n", - "\n", - "coords = [tcp_start_point.magnitude, tcp_end_point.magnitude]\n", - "\n", - "tcp_wire = lcs(coordinates=coords, orientation=rot, time=[t_start, t_end])\n", - "\n", - "# add the workpiece coordinate system\n", - "csm.add_cs(\n", - " coordinate_system_name=\"tcp_wire\",\n", - " reference_system_name=\"workpiece\",\n", - " lcs=tcp_wire,\n", - ")\n", - "\n", - "tcp_contact = lcs(coordinates=[0, 0, -10])\n", - "\n", - "# add the workpiece coordinate system\n", - "csm.add_cs(\n", - " coordinate_system_name=\"tcp_contact\",\n", - " reference_system_name=\"tcp_wire\",\n", - " lcs=tcp_contact,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Measurements" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# time\n", - "time = pd.timedelta_range(start=\"0s\", end=\"10s\", freq=\"1ms\")\n", - "\n", - "# current data\n", - "I_ts = ut.sine(f=Q_(10, \"1/s\"), amp=Q_(20, \"A\"), bias=Q_(300, \"A\"))\n", - "I = I_ts.interp_time(time)\n", - "I[\"time\"] = I[\"time\"]\n", - "\n", - "current_data = msm.Data(name=\"Welding current\", data=I)\n", - "\n", - "# voltage data\n", - "U_ts = ut.sine(f=Q_(10, \"1/s\"), amp=Q_(3, \"V\"), bias=Q_(40, \"V\"), phase=Q_(0.1, \"rad\"))\n", - "U = U_ts.interp_time(time)\n", - "U[\"time\"] = U[\"time\"]\n", - "\n", - "voltage_data = msm.Data(name=\"Welding voltage\", data=U)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from asdf.tags.core import Software\n", - "\n", - "HKS_sensor = msm.GenericEquipment(name=\"HKS P1000-S3\")\n", - "BH_ELM = msm.GenericEquipment(name=\"Beckhoff ELM3002-0000\")\n", - "twincat_scope = Software(name=\"Beckhoff TwinCAT ScopeView\", version=\"3.4.3143\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "src_current = msm.Source(\n", - " name=\"Current Sensor\",\n", - " output_signal=msm.Signal(signal_type=\"analog\", unit=\"V\", data=None),\n", - " error=msm.Error(Q_(0.1, \"percent\")),\n", - ")\n", - "\n", - "HKS_sensor.sources = []\n", - "HKS_sensor.sources.append(src_current)\n", - "\n", - "from weldx.core import MathematicalExpression\n", - "\n", - "[a, x, b] = sympy.symbols(\"a x b\")\n", - "current_AD_func = MathematicalExpression(a * x + b)\n", - "current_AD_func.set_parameter(\"a\", Q_(32768.0 / 10.0, \"1/V\"))\n", - "current_AD_func.set_parameter(\"b\", Q_(0.0, \"\"))\n", - "\n", - "current_AD_transform = msm.DataTransformation(\n", - " name=\"AD conversion current measurement\",\n", - " input_signal=src_current.output_signal,\n", - " output_signal=msm.Signal(\"digital\", \"\", data=None),\n", - " error=msm.Error(Q_(0.01, \"percent\")),\n", - " func=current_AD_func,\n", - ")\n", - "\n", - "BH_ELM.data_transformations = []\n", - "BH_ELM.data_transformations.append(current_AD_transform)\n", - "\n", - "# define current output calibration expression and transformation\n", - "current_calib_func = MathematicalExpression(a * x + b)\n", - "current_calib_func.set_parameter(\"a\", Q_(1000.0 / 32768.0, \"A\"))\n", - "current_calib_func.set_parameter(\"b\", Q_(0.0, \"A\"))\n", - "\n", - "current_calib_transform = msm.DataTransformation(\n", - " name=\"Calibration current measurement\",\n", - " input_signal=current_AD_transform.output_signal,\n", - " output_signal=msm.Signal(\"digital\", \"A\", data=current_data),\n", - " error=msm.Error(0.0),\n", - " func=current_calib_func,\n", - " meta=twincat_scope,\n", - ")\n", - "\n", - "\n", - "welding_current_chain = msm.MeasurementChain(\n", - " name=\"welding current measurement chain\",\n", - " data_source=src_current,\n", - " data_processors=[current_AD_transform, current_calib_transform],\n", - ")\n", - "\n", - "welding_current = msm.Measurement(\n", - " name=\"welding current measurement\",\n", - " data=[current_data],\n", - " measurement_chain=welding_current_chain,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "src_voltage = msm.Source(\n", - " name=\"Voltage Sensor\",\n", - " output_signal=msm.Signal(\"analog\", \"V\", data=None),\n", - " error=msm.Error(Q_(0.1, \"percent\")),\n", - ")\n", - "\n", - "HKS_sensor.sources.append(src_voltage)\n", - "\n", - "# define AD conversion expression and transformation step\n", - "[a, x, b] = sympy.symbols(\"a x b\")\n", - "voltage_ad_func = MathematicalExpression(a * x + b)\n", - "voltage_ad_func.set_parameter(\"a\", Q_(32768.0 / 10.0, \"1/V\"))\n", - "voltage_ad_func.set_parameter(\"b\", Q_(0.0, \"\"))\n", - "\n", - "voltage_AD_transform = msm.DataTransformation(\n", - " name=\"AD conversion voltage measurement\",\n", - " input_signal=src_voltage.output_signal,\n", - " output_signal=msm.Signal(\"digital\", \"\", data=None),\n", - " error=msm.Error(Q_(0.01, \"percent\")),\n", - " func=voltage_ad_func,\n", - ")\n", - "\n", - "HKS_sensor.data_transformations.append(voltage_AD_transform)\n", - "\n", - "# define voltage output calibration expression and transformation\n", - "voltage_calib_func = MathematicalExpression(a * x + b)\n", - "voltage_calib_func.set_parameter(\"a\", Q_(100.0 / 32768.0, \"V\"))\n", - "voltage_calib_func.set_parameter(\"b\", Q_(0.0, \"V\"))\n", - "\n", - "voltage_calib_transform = msm.DataTransformation(\n", - " name=\"Calibration voltage measurement\",\n", - " input_signal=voltage_AD_transform.output_signal,\n", - " output_signal=msm.Signal(\"digital\", \"V\", data=voltage_data),\n", - " error=msm.Error(0.0),\n", - " func=voltage_calib_func,\n", - " meta=twincat_scope,\n", - ")\n", - "\n", - "\n", - "welding_voltage_chain = msm.MeasurementChain(\n", - " name=\"welding voltage measurement chain\",\n", - " data_source=src_voltage,\n", - " data_processors=[voltage_AD_transform, voltage_calib_transform],\n", - ")\n", - "\n", - "welding_voltage = msm.Measurement(\n", - " name=\"welding voltage measurement\",\n", - " data=[voltage_data],\n", - " measurement_chain=welding_voltage_chain,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## GMAW Process" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from weldx.welding.processes import GmawProcess" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "params_pulse = dict(\n", - " wire_feedrate=Q_(10.0, \"m/min\"),\n", - " pulse_voltage=Q_(40.0, \"V\"),\n", - " pulse_duration=Q_(5.0, \"ms\"),\n", - " pulse_frequency=Q_(100.0, \"Hz\"),\n", - " base_current=Q_(60.0, \"A\"),\n", - ")\n", - "process_pulse = GmawProcess(\n", - " \"pulse\",\n", - " \"CLOOS\",\n", - " \"Quinto\",\n", - " params_pulse,\n", - " tag=\"CLOOS/pulse\",\n", - " meta={\"modulation\": \"UI\"},\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from weldx.asdf.tags.weldx.aws.process.gas_component import GasComponent\n", - "from weldx.asdf.tags.weldx.aws.process.shielding_gas_for_procedure import (\n", - " ShieldingGasForProcedure,\n", - ")\n", - "from weldx.asdf.tags.weldx.aws.process.shielding_gas_type import ShieldingGasType" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "gas_comp = [\n", - " GasComponent(\"argon\", Q_(82, \"percent\")),\n", - " GasComponent(\"carbon dioxide\", Q_(18, \"percent\")),\n", - "]\n", - "gas_type = ShieldingGasType(gas_component=gas_comp, common_name=\"SG\")\n", - "\n", - "gas_for_procedure = ShieldingGasForProcedure(\n", - " use_torch_shielding_gas=True,\n", - " torch_shielding_gas=gas_type,\n", - " torch_shielding_gas_flowrate=Q_(20, \"l / min\"),\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "process = dict(welding_process=process_pulse, shielding_gas=gas_for_procedure)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## ASDF file" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "tree = dict(\n", - " reference_timestamp=reference_timestamp,\n", - " equipment=[HKS_sensor, BH_ELM],\n", - " measurements=[welding_current, welding_voltage],\n", - " welding_current=current_calib_transform.output_signal,\n", - " welding_voltage=voltage_calib_transform.output_signal,\n", - " coordinate_systems=csm,\n", - " geometry=geometry,\n", - " process=process,\n", - " meta={\"welder\": \"A.W. Elder\"},\n", - ")\n", - "\n", - "\n", - "buffer = weldx.asdf.utils._write_buffer(\n", - " tree,\n", - " asdffile_kwargs=dict(\n", - " custom_schema=\"./weldx/asdf/schemas/weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml\"\n", - " ),\n", - ")\n", - "weldx.asdf.utils.notebook_fileprinter(buffer)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "filename = \"schema_example_01.asdf\"\n", - "\n", - "with asdf.AsdfFile(\n", - " tree,\n", - " ignore_version_mismatch=False,\n", - " custom_schema=\"./weldx/asdf/schemas/weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml\",\n", - ") as ff:\n", - " ff.write_to(filename)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "weldx", - "language": "python", - "name": "weldx" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.9" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/scripts/welding_schema.py b/scripts/welding_schema.py new file mode 100644 index 000000000..ff50ba4cd --- /dev/null +++ b/scripts/welding_schema.py @@ -0,0 +1,292 @@ +# Welding schema +if __name__ == "__main__": + + # Imports + from pathlib import Path + + import asdf + import numpy as np + import pandas as pd + import sympy + from asdf.tags.core import Software + + # importing the weldx package with prevalent default abbreviations + import weldx + import weldx.geometry as geo + import weldx.measurement as msm + import weldx.util as ut + from weldx import Q_, GmawProcess + from weldx import LocalCoordinateSystem as lcs + from weldx import TimeSeries, WXRotation, get_groove + from weldx.asdf.tags.weldx.aws.process.gas_component import GasComponent + from weldx.asdf.tags.weldx.aws.process.shielding_gas_for_procedure import ( + ShieldingGasForProcedure, + ) + from weldx.asdf.tags.weldx.aws.process.shielding_gas_type import ShieldingGasType + + # Timestamp + reference_timestamp = pd.Timestamp("2020-11-09 12:00:00") + + # Geometry + # groove + trace = geometry + groove = get_groove( + groove_type="VGroove", + workpiece_thickness=Q_(5, "mm"), + groove_angle=Q_(50, "deg"), + root_face=Q_(1, "mm"), + root_gap=Q_(1, "mm"), + ) + + # define the weld seam length in mm + seam_length = Q_(300, "mm") + + # create a linear trace segment a the complete weld seam trace + trace_segment = geo.LinearHorizontalTraceSegment(seam_length) + trace = geo.Trace(trace_segment) + + geometry = dict(groove_shape=groove, seam_length=seam_length) + + base_metal = dict(common_name="S355J2+N", standard="DIN EN 10225-2:2011") + + workpiece = dict(base_metal=base_metal, geometry=geometry) + + # Setup the Coordinate System Manager (CSM) + # crete a new coordinate system manager with default base coordinate system + csm = weldx.transformations.CoordinateSystemManager("base") + + # add the workpiece coordinate system + csm.add_cs( + coordinate_system_name="workpiece", + reference_system_name="base", + lcs=trace.coordinate_system, + ) + + tcp_start_point = Q_([5.0, 0.0, 2.0], "mm") + tcp_end_point = Q_([-5.0, 0.0, 2.0], "mm") + np.append( + seam_length, Q_([0, 0], "mm") + ) + + v_weld = Q_(10, "mm/s") + s_weld = (tcp_end_point - tcp_start_point)[0] # length of the weld + t_weld = s_weld / v_weld + + t_start = pd.Timedelta("0s") + t_end = pd.Timedelta(str(t_weld.to_base_units())) + + rot = WXRotation.from_euler(seq="x", angles=180, degrees=True) + + coords = [tcp_start_point.magnitude, tcp_end_point.magnitude] + + tcp_wire = lcs(coordinates=coords, orientation=rot, time=[t_start, t_end]) + + # add the workpiece coordinate system + csm.add_cs( + coordinate_system_name="tcp_wire", + reference_system_name="workpiece", + lcs=tcp_wire, + ) + + tcp_contact = lcs(coordinates=[0, 0, -10]) + + # add the workpiece coordinate system + csm.add_cs( + coordinate_system_name="tcp_contact", + reference_system_name="tcp_wire", + lcs=tcp_contact, + ) + + TCP_reference = csm.get_cs("tcp_contact", "workpiece") + + # Measurements + # time + time = pd.timedelta_range(start="0s", end="10s", freq="1ms") + + # current data + I_ts = ut.sine(f=Q_(10, "1/s"), amp=Q_(20, "A"), bias=Q_(300, "A")) + I = I_ts.interp_time(time) # noqa: E741 + I["time"] = I["time"] + + current_data = msm.Data(name="Welding current", data=I) + + # voltage data + U_ts = ut.sine( + f=Q_(10, "1/s"), amp=Q_(3, "V"), bias=Q_(40, "V"), phase=Q_(0.1, "rad") + ) + U = U_ts.interp_time(time) + U["time"] = U["time"] + + voltage_data = msm.Data(name="Welding voltage", data=U) + + HKS_sensor = msm.GenericEquipment(name="HKS P1000-S3") + BH_ELM = msm.GenericEquipment(name="Beckhoff ELM3002-0000") + twincat_scope = Software(name="Beckhoff TwinCAT ScopeView", version="3.4.3143") + + src_current = msm.Source( + name="Current Sensor", + output_signal=msm.Signal(signal_type="analog", unit="V", data=None), + error=msm.Error(Q_(0.1, "percent")), + ) + + HKS_sensor.sources = [] + HKS_sensor.sources.append(src_current) + + from weldx.core import MathematicalExpression + + [a, x, b] = sympy.symbols("a x b") + current_AD_func = MathematicalExpression(a * x + b) + current_AD_func.set_parameter("a", Q_(32768.0 / 10.0, "1/V")) + current_AD_func.set_parameter("b", Q_(0.0, "")) + + current_AD_transform = msm.DataTransformation( + name="AD conversion current measurement", + input_signal=src_current.output_signal, + output_signal=msm.Signal("digital", "", data=None), + error=msm.Error(Q_(0.01, "percent")), + func=current_AD_func, + ) + + BH_ELM.data_transformations = [] + BH_ELM.data_transformations.append(current_AD_transform) + + # define current output calibration expression and transformation + current_calib_func = MathematicalExpression(a * x + b) + current_calib_func.set_parameter("a", Q_(1000.0 / 32768.0, "A")) + current_calib_func.set_parameter("b", Q_(0.0, "A")) + + current_calib_transform = msm.DataTransformation( + name="Calibration current measurement", + input_signal=current_AD_transform.output_signal, + output_signal=msm.Signal("digital", "A", data=current_data), + error=msm.Error(0.0), + func=current_calib_func, + ) + current_calib_transform.wx_metadata = dict(software=twincat_scope) + + welding_current_chain = msm.MeasurementChain( + name="welding current measurement chain", + data_source=src_current, + data_processors=[current_AD_transform, current_calib_transform], + ) + + welding_current = msm.Measurement( + name="welding current measurement", + data=[current_data], + measurement_chain=welding_current_chain, + ) + + src_voltage = msm.Source( + name="Voltage Sensor", + output_signal=msm.Signal("analog", "V", data=None), + error=msm.Error(Q_(0.1, "percent")), + ) + + HKS_sensor.sources.append(src_voltage) + + # define AD conversion expression and transformation step + [a, x, b] = sympy.symbols("a x b") + voltage_ad_func = MathematicalExpression(a * x + b) + voltage_ad_func.set_parameter("a", Q_(32768.0 / 10.0, "1/V")) + voltage_ad_func.set_parameter("b", Q_(0.0, "")) + + voltage_AD_transform = msm.DataTransformation( + name="AD conversion voltage measurement", + input_signal=src_voltage.output_signal, + output_signal=msm.Signal("digital", "", data=None), + error=msm.Error(Q_(0.01, "percent")), + func=voltage_ad_func, + ) + + HKS_sensor.data_transformations.append(voltage_AD_transform) + + # define voltage output calibration expression and transformation + voltage_calib_func = MathematicalExpression(a * x + b) + voltage_calib_func.set_parameter("a", Q_(100.0 / 32768.0, "V")) + voltage_calib_func.set_parameter("b", Q_(0.0, "V")) + + voltage_calib_transform = msm.DataTransformation( + name="Calibration voltage measurement", + input_signal=voltage_AD_transform.output_signal, + output_signal=msm.Signal("digital", "V", data=voltage_data), + error=msm.Error(0.0), + func=voltage_calib_func, + ) + voltage_calib_transform.wx_metadata = dict(software=twincat_scope) + + welding_voltage_chain = msm.MeasurementChain( + name="welding voltage measurement chain", + data_source=src_voltage, + data_processors=[voltage_AD_transform, voltage_calib_transform], + ) + + welding_voltage = msm.Measurement( + name="welding voltage measurement", + data=[voltage_data], + measurement_chain=welding_voltage_chain, + ) + + # GMAW Process + params_pulse = dict( + wire_feedrate=Q_(10.0, "m/min"), + pulse_voltage=Q_(40.0, "V"), + pulse_duration=Q_(5.0, "ms"), + pulse_frequency=Q_(100.0, "Hz"), + base_current=Q_(60.0, "A"), + ) + process_pulse = GmawProcess( + "pulse", + "CLOOS", + "Quinto", + params_pulse, + tag="CLOOS/pulse", + meta={"modulation": "UI"}, + ) + + gas_comp = [ + GasComponent("argon", Q_(82, "percent")), + GasComponent("carbon dioxide", Q_(18, "percent")), + ] + gas_type = ShieldingGasType(gas_component=gas_comp, common_name="SG") + + gas_for_procedure = ShieldingGasForProcedure( + use_torch_shielding_gas=True, + torch_shielding_gas=gas_type, + torch_shielding_gas_flowrate=Q_(20, "l / min"), + ) + + process = dict( + welding_process=process_pulse, + shielding_gas=gas_for_procedure, + weld_speed=TimeSeries(v_weld), + welding_wire={"diameter": Q_(1.2, "mm")}, + ) + + # ASDF file + tree = dict( + reference_timestamp=reference_timestamp, + equipment=[HKS_sensor, BH_ELM], + measurements=[welding_current, welding_voltage], + welding_current=current_calib_transform.output_signal, + welding_voltage=voltage_calib_transform.output_signal, + coordinate_systems=csm, + TCP=TCP_reference, + workpiece=workpiece, + process=process, + wx_metadata={"welder": "A.W. Elder"}, + ) + + model_path = Path(weldx.__path__[0]) / Path( + "./asdf/schemas/weldx.bam.de/weldx/datamodels/" + "single_pass_weld-1.0.0.schema.yaml" + ) + model_path = model_path.as_posix() + + res = weldx.asdf.util._write_read_buffer( + tree, + asdffile_kwargs=dict(custom_schema=str(model_path)), + ) + + with asdf.AsdfFile( + tree, + custom_schema=str(model_path), + ) as ff: + ff.write_to("single_pass_weld_example.asdf") diff --git a/setup.cfg b/setup.cfg index 12248a674..979b3fcb3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,6 +51,7 @@ install_requires = fs ipywidgets k3d + meshio include_package_data = True @@ -86,11 +87,13 @@ ignore = D203,D213 [tool:pytest] addopts = --tb=short --color=yes -rs --cov=weldx --cov-report=term-missing:skip-covered #addopts = --tb=short --color=yes -rs -p no:cov -asdf_schema_root = weldx/asdf/schemas -#asdf_schema_tests_enabled = true # custom test markers, see https://docs.pytest.org/en/latest/example/markers.html#mark-examples markers = slow: marks tests as slow to run (skipped by default, enable with --runslow option) +asdf_schema_root = weldx/asdf/schemas +#asdf_schema_tests_enabled = true +asdf_schema_skip_tests = + weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml [isort] profile = black diff --git a/tests/asdf_tests/test_asdf_aws_schema.py b/tests/asdf_tests/test_asdf_aws_schema.py index 4f9eeefb2..f27d52ab9 100644 --- a/tests/asdf_tests/test_asdf_aws_schema.py +++ b/tests/asdf_tests/test_asdf_aws_schema.py @@ -19,7 +19,7 @@ ShieldingGasForProcedure, ) from weldx.asdf.tags.weldx.aws.process.shielding_gas_type import ShieldingGasType -from weldx.asdf.utils import _write_read_buffer +from weldx.asdf.util import _write_read_buffer from weldx.constants import WELDX_QUANTITY as Q_ # iso groove ----------------------------------------------------------------- diff --git a/tests/asdf_tests/test_asdf_core.py b/tests/asdf_tests/test_asdf_core.py index b7c280b62..ce4ec8905 100644 --- a/tests/asdf_tests/test_asdf_core.py +++ b/tests/asdf_tests/test_asdf_core.py @@ -14,7 +14,7 @@ import weldx.transformations as tf from tests._helpers import get_test_name from weldx.asdf.tags.weldx.core.file import ExternalFile -from weldx.asdf.utils import _write_buffer, _write_read_buffer +from weldx.asdf.util import _write_buffer, _write_read_buffer from weldx.constants import WELDX_QUANTITY as Q_ from weldx.core import MathematicalExpression as ME # nopep8 from weldx.core import TimeSeries @@ -489,7 +489,7 @@ def test_init_exceptions(kwargs, exception_type, test_name): [ ("doc/_static", "WelDX_notext.ico"), ("doc/_static", "WelDX_notext.svg"), - ("weldx", "transformations.py"), + ("weldx", "__init__.py"), ], ) def test_write_to(dir_read, file_name): diff --git a/tests/asdf_tests/test_asdf_gmaw_process.py b/tests/asdf_tests/test_asdf_gmaw_process.py index 5fd8c2490..f006fc44b 100644 --- a/tests/asdf_tests/test_asdf_gmaw_process.py +++ b/tests/asdf_tests/test_asdf_gmaw_process.py @@ -3,7 +3,7 @@ import pytest from weldx import Q_ -from weldx.asdf.utils import _write_read_buffer +from weldx.asdf.util import _write_read_buffer from weldx.core import TimeSeries from weldx.welding.processes import GmawProcess diff --git a/tests/asdf_tests/test_asdf_groove.py b/tests/asdf_tests/test_asdf_groove.py index 5c5317021..e883583c8 100644 --- a/tests/asdf_tests/test_asdf_groove.py +++ b/tests/asdf_tests/test_asdf_groove.py @@ -2,8 +2,9 @@ import matplotlib.pyplot as plt import pytest +from decorator import contextmanager -from weldx.asdf.utils import _write_read_buffer +from weldx.asdf.util import _write_read_buffer from weldx.constants import WELDX_QUANTITY as Q_ from weldx.geometry import Profile from weldx.welding.groove.iso_9692_1 import ( @@ -83,9 +84,6 @@ def test_asdf_groove_exceptions(): groove_angle=Q_(50, "deg"), ) - with pytest.raises(NotImplementedError): - IsoBaseGroove().to_profile() - with pytest.raises(ValueError): get_groove( groove_type="FFGroove", @@ -95,3 +93,36 @@ def test_asdf_groove_exceptions(): root_gap=Q_(1, "mm"), code_number="6.1.1", ).to_profile() + + +@pytest.mark.parametrize("groove", test_params.values(), ids=test_params.keys()) +def test_cross_section(groove): # noqa + @contextmanager + def temp_attr(obj, attr, new_value): + old_value = getattr(obj, attr) + setattr(obj, attr, new_value) + yield + setattr(obj, attr, old_value) + + groove_obj, groove_cls = groove + # make rasterization for U-based grooves rather rough. + with temp_attr(groove_obj, "_AREA_RASTER_WIDTH", 0.75): # skipcq: PYL-E1129 + try: + A = groove_obj.cross_sect_area + except NotImplementedError: + return + except Exception as ex: + raise ex + + # check docstring got inherited. + assert groove_cls.cross_sect_area.__doc__ is not None + + assert hasattr(A, "units") + assert A.units == Q_("mm²") + assert A > 0 + + +def test_igroove_area(): # noqa + groove, _ = test_params["i_groove"] + A = groove.cross_sect_area + assert A == groove.t * groove.b diff --git a/tests/asdf_tests/test_asdf_time.py b/tests/asdf_tests/test_asdf_time.py index 95658fed5..04d1874ad 100644 --- a/tests/asdf_tests/test_asdf_time.py +++ b/tests/asdf_tests/test_asdf_time.py @@ -4,7 +4,7 @@ import pytest from asdf import ValidationError -from weldx.asdf.utils import _write_buffer, _write_read_buffer +from weldx.asdf.util import _write_buffer, _write_read_buffer @pytest.mark.parametrize( diff --git a/tests/asdf_tests/test_asdf_types.py b/tests/asdf_tests/test_asdf_types.py index 5ec5a4c5a..72971751b 100644 --- a/tests/asdf_tests/test_asdf_types.py +++ b/tests/asdf_tests/test_asdf_types.py @@ -1,7 +1,7 @@ import pandas as pd from weldx.asdf.types import META_ATTR, USER_ATTR -from weldx.asdf.utils import _write_read_buffer +from weldx.asdf.util import _write_read_buffer from weldx.measurement import Error diff --git a/tests/asdf_tests/test_asdf_validators.py b/tests/asdf_tests/test_asdf_validators.py index 789ccceca..0024df220 100644 --- a/tests/asdf_tests/test_asdf_validators.py +++ b/tests/asdf_tests/test_asdf_validators.py @@ -4,12 +4,12 @@ import pytest from asdf import ValidationError -from weldx import Q_ +from weldx import Q_, TimeSeries from weldx.asdf.extension import WxSyntaxError from weldx.asdf.tags.weldx.debug.test_property_tag import PropertyTagTestClass from weldx.asdf.tags.weldx.debug.test_shape_validator import ShapeValidatorTestClass from weldx.asdf.tags.weldx.debug.test_unit_validator import UnitValidatorTestClass -from weldx.asdf.utils import _write_read_buffer +from weldx.asdf.util import _write_read_buffer from weldx.asdf.validators import _compare_tag_version, _custom_shape_validator @@ -200,6 +200,14 @@ def test_shape_validator(test_input): optional_prop=np.ones((3, 2, 9)), ), # wrong optional ShapeValidatorTestClass(time_prop=pd.date_range("2020", freq="D", periods=3)), + ShapeValidatorTestClass( + quantity=Q_([0, 3], "s"), # mismatch shape [1] + ), + ShapeValidatorTestClass( + timeseries=TimeSeries( + Q_([0, 3], "m"), Q_([0, 1], "s") + ) # mismatch shape [1] + ), ], ) def test_shape_validator_exceptions(test_input): diff --git a/tests/test_core.py b/tests/test_core.py index 2bbaea5a5..51f8837aa 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,7 +6,7 @@ import pint import pytest -import weldx.utility as ut +import weldx.util as ut from tests._helpers import get_test_name from weldx.constants import WELDX_QUANTITY as Q_ from weldx.constants import WELDX_UNIT_REGISTRY as UREG diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 8edeb1daa..587a1b769 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -2,16 +2,20 @@ import copy import math +from pathlib import Path +from tempfile import TemporaryDirectory from typing import List, Union import numpy as np +import pint import pytest from xarray import DataArray import tests._helpers as helpers import weldx.geometry as geo import weldx.transformations as tf -import weldx.utility as ut +import weldx.util as ut +from weldx import Q_ from weldx.geometry import SpatialData # helpers --------------------------------------------------------------------- @@ -2777,6 +2781,96 @@ def test_geometry_rasterization_profile_interpolation(): assert ut.vector_is_close(data[:, idx_0 + j], point_exp) +def get_test_profile() -> geo.Profile: + """Create a `weldx.geometry.Profile` for tests. + + Returns + ------- + weldx.geometry.Profile : + `weldx.geometry.Profile` for tests. + + """ + shape_0 = geo.Shape().add_line_segments(Q_([[1, 0], [1, 1], [3, 1]], "cm")) + shape_1 = geo.Shape().add_line_segments(Q_([[-1, 0], [-1, 1]], "cm")) + return geo.Profile([shape_0, shape_1]) + + +def get_test_geometry_constant_profile() -> geo.Geometry: + """Create a `weldx.geometry.Geometry` with constant profile for tests. + + Returns + ------- + weldx.geometry.Geometry : + `weldx.geometry.Geometry` with constant profile for tests. + + """ + profile = get_test_profile() + trace = geo.Trace([geo.LinearHorizontalTraceSegment(Q_(1, "cm"))]) + return geo.Geometry(profile=profile, trace=trace) + + +def get_test_geometry_variable_profile(): + """Create a `weldx.geometry.Geometry` with variable profile for tests. + + Returns + ------- + weldx.geometry.Geometry : + `weldx.geometry.Geometry` with constant profile for tests. + + """ + profile = get_test_profile() + variable_profile = geo.VariableProfile( + [profile, profile], [0, 1], [geo.linear_profile_interpolation_sbs] + ) + trace = geo.Trace([geo.LinearHorizontalTraceSegment(Q_(1, "cm"))]) + return geo.Geometry(profile=variable_profile, trace=trace) + + +class TestGeometry: + """Test the geometry class.""" + + @staticmethod + @pytest.mark.parametrize( + "geometry, p_rw, t_rw, exp_num_points, exp_num_triangles", + [ + (get_test_geometry_constant_profile(), Q_(1, "cm"), Q_(1, "cm"), 12, 8), + (get_test_geometry_variable_profile(), Q_(1, "cm"), Q_(1, "cm"), 12, 0), + ], + ) + def test_spatial_data( + geometry: geo.Geometry, + p_rw: pint.Quantity, + t_rw: pint.Quantity, + exp_num_points: int, + exp_num_triangles: int, + ): + """Test the `spatial_data` function. + + Parameters + ---------- + geometry : weldx.geometry.Geometry + Geometry that should be tested + p_rw : pint.Quantity + Profile raster width that is passed to the function + t_rw : pint.Quantity + Trace raster width that is passed to the function + exp_num_points : int + Expected number of points of the returned `weldx.geometry.SpatialData` + instance + exp_num_triangles : int + Expected number of triangles of the returned `weldx.geometry.SpatialData` + instance + + """ + spatial_data = geometry.spatial_data(p_rw, t_rw) + assert len(spatial_data.coordinates.data) == exp_num_points + + num_triangles = 0 + if spatial_data.triangles is not None: + num_triangles = len(spatial_data.triangles) + assert num_triangles == exp_num_triangles + + # -------------------------------------------------------------------------------------- # SpatialData # -------------------------------------------------------------------------------------- @@ -2835,3 +2929,34 @@ def test_class_creation_exceptions(arguments, exception_type, test_name): """ with pytest.raises(exception_type): SpatialData(*arguments) + + @staticmethod + @pytest.mark.parametrize( + "filename", + ["test.ply", "test.stl", "test.vtk", Path("test.stl")], + ) + def test_read_write_file(filename: Union[str, Path]): + """Test the `from_file` and `write_to_file` functions. + + The test simply creates a `SpatialData` instance, writes it to a file and reads + it back. The result is compared to the original object. + + Parameters + ---------- + filename : + Name of the file + + """ + points = [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]] + triangles = [[0, 1, 2], [2, 3, 0]] + + data = SpatialData(points, triangles) + with TemporaryDirectory(dir=Path(__file__).parent) as tmpdirname: + filepath = f"{tmpdirname}/{filename}" + if isinstance(filename, Path): + filepath = Path(filepath) + data.write_to_file(filepath) + data_read = SpatialData.from_file(filepath) + + assert np.allclose(data.coordinates, data_read.coordinates) + assert np.allclose(data.triangles, data_read.triangles) diff --git a/tests/test_measurement.py b/tests/test_measurement.py index 91b5a5008..5fb5aeb9b 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -4,7 +4,7 @@ import xarray as xr import weldx.measurement as msm -from weldx.asdf.utils import _write_read_buffer +from weldx.asdf.util import _write_read_buffer from weldx.constants import WELDX_QUANTITY as Q_ from weldx.core import MathematicalExpression diff --git a/tests/test_transformations.py b/tests/test_transformations.py index b72b116a7..7d65781e3 100644 --- a/tests/test_transformations.py +++ b/tests/test_transformations.py @@ -14,9 +14,9 @@ from pandas import date_range import weldx.transformations as tf -import weldx.utility as ut +import weldx.util as ut from tests._helpers import get_test_name -from weldx import Q_ +from weldx import Q_, SpatialData from weldx.transformations import LocalCoordinateSystem as LCS # noqa # helpers for tests ----------------------------------------------------------- @@ -2412,8 +2412,8 @@ def test_time_union( # create CSM and add coordinate systems csm = tf.CoordinateSystemManager("root", "base", csm_time_ref) - for i in range(len(lcs_times)): - csm.add_cs(f"lcs_{i}", "root", lcs[i]) + for i, lcs_ in enumerate(lcs): + csm.add_cs(f"lcs_{i}", "root", lcs_) # create expected data type exp_time = pd.TimedeltaIndex(exp_time, "D") @@ -2962,13 +2962,13 @@ def test_merge(list_of_csm_and_lcs_instances, nested): (None, "01", False, True, False), # parent static (None, None, True, False, False), - ("01", None, True, False, True), + ("01", None, True, False, False), ("01", "01", True, False, False), ("01", "03", True, False, True), (None, "01", True, False, True), # both dynamic (None, None, False, False, False), - ("01", None, False, False, True), + ("01", None, False, False, False), ("01", "01", False, False, False), ("01", "03", False, False, True), (None, "01", False, False, True), @@ -3478,7 +3478,7 @@ def setup_csm_test_assign_data() -> tf.CoordinateSystemManager: ( "lcs_3", "my_data", - tf.SpatialData([[1, -3, -1], [2, 4, -1], [-1, 2, 3], [3, -4, 2]]), + SpatialData([[1, -3, -1], [2, 4, -1], [-1, 2, 3], [3, -4, 2]]), "lcs_1", [[-5, -2, -4], [-5, -9, -5], [-9, -7, -2], [-8, -1, -6]], ), @@ -3525,7 +3525,7 @@ def test_data_functions(self, lcs_ref, data_name, data, lcs_out, exp): assert csm.has_data(lcs, data_name) == (lcs == lcs_ref) transformed_data = csm.get_data(data_name, lcs_out) - if isinstance(transformed_data, tf.SpatialData): + if isinstance(transformed_data, SpatialData): transformed_data = transformed_data.coordinates.data else: transformed_data = transformed_data.data @@ -3991,6 +3991,28 @@ def test_coordinate_system_manager_interp_time(): check_coordinate_systems_close(lcs, exp) check_coordinate_systems_close(lcs_inv, exp_inv) + # Related to pull request #275. This assures that interp time works + # correctly if some coordinate systems have no own reference time, but the CSM + # does. + lcs1 = tf.LocalCoordinateSystem( + coordinates=[[1, 0, 0], [2, 0, 0]], time=pd.TimedeltaIndex([1, 2]) + ) + + lcs2 = tf.LocalCoordinateSystem( + coordinates=[[1, 0, 0], [2, 0, 0]], + time=pd.TimedeltaIndex([1, 2]) + pd.Timestamp("2000-01-03"), + ) + + csm_1 = tf.CoordinateSystemManager("root", time_ref=pd.Timestamp("2000-01-01")) + csm_1.add_cs("lcs2", "root", lcs2) + + csm_2 = tf.CoordinateSystemManager("root") + csm_2.add_cs("lcs1", "root", lcs1) + + csm_1.merge(csm_2) + + csm_1.interp_time(csm_1.time_union()) + def test_coordinate_system_manager_transform_data(): """Test the coordinate system managers transform_data function.""" diff --git a/tests/test_utility.py b/tests/test_utility.py index 79b8d24ec..26601ac05 100644 --- a/tests/test_utility.py +++ b/tests/test_utility.py @@ -11,7 +11,7 @@ from pandas import date_range from pint.errors import DimensionalityError -import weldx.utility as ut +import weldx.util as ut from weldx.constants import WELDX_QUANTITY as Q_ diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 80963d504..d158da080 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -45,8 +45,8 @@ def test_plot_coordinate_system(): vs.draw_coordinate_system_matplotlib(lcs_constant, ax, label="label") -def test_set_axes_equal(): +def test_axes_equal(): """Test executing all possible code paths.""" fig = plt.figure() ax = fig.gca(projection="3d") - vs.set_axes_equal(ax) + vs.axes_equal(ax) diff --git a/tests/test_welding_util.py b/tests/test_welding_util.py new file mode 100644 index 000000000..e5af39dd4 --- /dev/null +++ b/tests/test_welding_util.py @@ -0,0 +1,27 @@ +"""Test welding util functions.""" +import pint +import pytest + +from weldx.constants import WELDX_QUANTITY as Q_ +from weldx.welding.groove.iso_9692_1 import IGroove +from weldx.welding.util import compute_welding_speed + + +def test_welding_speed(): # noqa + groove = IGroove(b=Q_(10, "mm"), t=Q_(5, "cm")) + wire_diameter = Q_(1, "mm") + wire_feed = Q_(1, "mm/s") + result = compute_welding_speed(groove, wire_feed, wire_diameter) + assert result.units == wire_feed.units + assert result > 0 + + +def test_illegal_input_dimension(): # noqa + groove = IGroove(b=Q_(10, "mm"), t=Q_(5, "cm")) + with pytest.raises(pint.errors.DimensionalityError): + # only a length for feed + compute_welding_speed(groove, wire_feed=Q_(1, "mm"), wire_diameter=Q_(1, "mm")) + + with pytest.raises(pint.errors.DimensionalityError): + # diameter wrong dimension + compute_welding_speed(groove, wire_feed=Q_(1, "mm/s"), wire_diameter=Q_(1, "s")) diff --git a/tutorials/GMAW_process.ipynb b/tutorials/GMAW_process.ipynb index eb2b374c1..185a4304f 100644 --- a/tutorials/GMAW_process.ipynb +++ b/tutorials/GMAW_process.ipynb @@ -148,8 +148,8 @@ "metadata": {}, "outputs": [], "source": [ - "buffer = weldx.asdf.utils._write_buffer(tree)\n", - "weldx.asdf.utils.notebook_fileprinter(buffer)" + "buffer = weldx.asdf.util._write_buffer(tree)\n", + "weldx.asdf.util.notebook_fileprinter(buffer)" ] }, { @@ -158,7 +158,7 @@ "metadata": {}, "outputs": [], "source": [ - "data = weldx.asdf.utils._read_buffer(buffer)" + "data = weldx.asdf.util._read_buffer(buffer)" ] } ], diff --git a/tutorials/custom_metadata.ipynb b/tutorials/custom_metadata.ipynb index 90f54669b..649fdf1a2 100644 --- a/tutorials/custom_metadata.ipynb +++ b/tutorials/custom_metadata.ipynb @@ -30,7 +30,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We start by by creating the sensor object as a `GenericEquipment`:" + "We start by creating the sensor object as a `GenericEquipment`:" ] }, { @@ -78,7 +78,7 @@ "metadata": {}, "outputs": [], "source": [ - "from weldx.asdf.utils import _write_buffer, notebook_fileprinter\n", + "from weldx.asdf.util import _write_buffer, notebook_fileprinter\n", "\n", "buffer = _write_buffer({\"sensor\": HKS_sensor})\n", "notebook_fileprinter(buffer)" @@ -104,7 +104,7 @@ "metadata": {}, "outputs": [], "source": [ - "from weldx.asdf.utils import _read_buffer\n", + "from weldx.asdf.util import _read_buffer\n", "\n", "data = _read_buffer(buffer)\n", "display(data[\"sensor\"].wx_metadata)\n", diff --git a/tutorials/experiment_design_01.ipynb b/tutorials/experiment_design_01.ipynb index ca5c998d7..a494c0693 100644 --- a/tutorials/experiment_design_01.ipynb +++ b/tutorials/experiment_design_01.ipynb @@ -81,7 +81,7 @@ "# importing the weldx package with prevalent default abbreviations\n", "import weldx\n", "import weldx.geometry as geo\n", - "import weldx.utility as ut\n", + "import weldx.util as util\n", "from weldx import Q_\n", "from weldx.transformations import LocalCoordinateSystem as LCS\n", "from weldx.welding.groove.iso_9692_1 import get_groove" @@ -246,8 +246,8 @@ "metadata": {}, "outputs": [], "source": [ - "sine_y = ut.sine(f=Q_(1, \"Hz\"), amp=Q_([[0, 1, 0]], \"mm\"))\n", - "coords = ut.lcs_coords_from_ts(sine_y, time)\n", + "sine_y = util.sine(f=Q_(1, \"Hz\"), amp=Q_([[0, 1, 0]], \"mm\"))\n", + "coords = util.lcs_coords_from_ts(sine_y, time)\n", "csm.add_cs(\n", " coordinate_system_name=\"tcp_sine_y\",\n", " reference_system_name=\"tcp_wire\",\n", @@ -268,8 +268,8 @@ "metadata": {}, "outputs": [], "source": [ - "sine_z = ut.sine(f=Q_(1, \"Hz\"), amp=Q_([[0, 0, 2]], \"mm\"), bias=Q_([0, 0, 0], \"mm\"))\n", - "coords = ut.lcs_coords_from_ts(sine_z, time)\n", + "sine_z = util.sine(f=Q_(1, \"Hz\"), amp=Q_([[0, 0, 2]], \"mm\"), bias=Q_([0, 0, 0], \"mm\"))\n", + "coords = util.lcs_coords_from_ts(sine_z, time)\n", "csm.add_cs(\n", " coordinate_system_name=\"tcp_sine_z\",\n", " reference_system_name=\"tcp_wire\",\n", @@ -509,4 +509,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/tutorials/geometry_02_geometry.ipynb b/tutorials/geometry_02_geometry.ipynb index 0ff252915..96d771a68 100644 --- a/tutorials/geometry_02_geometry.ipynb +++ b/tutorials/geometry_02_geometry.ipynb @@ -25,6 +25,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -43,6 +44,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -94,6 +96,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -119,6 +122,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -153,6 +157,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -176,6 +181,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -200,6 +206,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -249,6 +256,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -275,6 +283,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -285,8 +294,9 @@ "outputs": [], "source": [ "geometry = Geometry(profile=profile, trace=trace)\n", - "geometry.plot(profile_raster_width=Q_(1, \"cm\"),\n", - " trace_raster_width=Q_(10, \"cm\"))\n", + "geometry.plot(profile_raster_width=Q_(0.25, \"cm\"),\n", + " trace_raster_width=Q_(10, \"cm\"),\n", + " show_wireframe=False)\n", "trace.plot(raster_width=Q_(1, \"cm\"), axes=plt.gca(), fmt=\"r-\")" ] }, @@ -330,6 +340,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -368,6 +379,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -383,7 +395,7 @@ " )\n", "\n", "geometry_vp = Geometry(profile=v_profile,trace=trace)\n", - "geometry_vp.plot(profile_raster_width=Q_(1, \"cm\"),\n", + "geometry_vp.plot(profile_raster_width=Q_(0.25, \"cm\"),\n", " trace_raster_width=Q_(8, \"cm\"))" ] }, @@ -426,6 +438,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -457,6 +470,7 @@ "cell_type": "code", "execution_count": null, "metadata": { + "collapsed": false, "jupyter": { "outputs_hidden": false }, @@ -470,9 +484,16 @@ "trace_custom_interp = Trace(LinearHorizontalTraceSegment(Q_(20, \"cm\")))\n", "geometry_custom_interp = Geometry(v_profile_custom_interp, trace_custom_interp)\n", "\n", - "geometry_custom_interp.plot(profile_raster_width=Q_(1, \"cm\"),\n", + "geometry_custom_interp.plot(profile_raster_width=Q_(0.25, \"cm\"),\n", " trace_raster_width=Q_(2, \"cm\"))" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/tutorials/groove_types_01.ipynb b/tutorials/groove_types_01.ipynb index 23aee9496..6731a6ded 100644 --- a/tutorials/groove_types_01.ipynb +++ b/tutorials/groove_types_01.ipynb @@ -120,6 +120,23 @@ "print(v_profile)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### calculating groove cross sectional area\n", + "An approximation of the groove cross sectional area can be calculated via the `cross_sect_area` property" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(v_groove.cross_sect_area)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -196,7 +213,7 @@ "outputs": [], "source": [ "tree = dict(test_v_groove=v_groove)\n", - "buffer = weldx.asdf.utils._write_buffer(tree)" + "buffer = weldx.asdf.util._write_buffer(tree)" ] }, { @@ -212,7 +229,7 @@ "metadata": {}, "outputs": [], "source": [ - "weldx.asdf.utils.notebook_fileprinter(buffer)" + "weldx.asdf.util.notebook_fileprinter(buffer)" ] }, { @@ -228,7 +245,7 @@ "metadata": {}, "outputs": [], "source": [ - "data = weldx.asdf.utils._read_buffer(buffer)\n", + "data = weldx.asdf.util._read_buffer(buffer)\n", "data[\"test_v_groove\"]" ] }, @@ -287,4 +304,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/tutorials/measurement_example.ipynb b/tutorials/measurement_example.ipynb index 68d1210af..a4a0694a4 100644 --- a/tutorials/measurement_example.ipynb +++ b/tutorials/measurement_example.ipynb @@ -25,20 +25,15 @@ "metadata": {}, "outputs": [], "source": [ - "import asdf\n", "import numpy as np\n", "import pandas as pd\n", - "import pint\n", "import sympy\n", - "import xarray as xr\n", - "from matplotlib import pyplot as plt\n", "\n", "import weldx\n", "import weldx.measurement as msm\n", "import weldx.transformations as tf\n", - "import weldx.utility as ut\n", "from weldx import Q_\n", - "from weldx.asdf.extension import WeldxAsdfExtension, WeldxExtension" + "from weldx import util" ] }, { @@ -74,7 +69,7 @@ "metadata": {}, "outputs": [], "source": [ - "I_ts = ut.sine(f=Q_(10, \"1/s\"), amp=Q_(20, \"A\"), bias=Q_(300, \"A\"))\n", + "I_ts = util.sine(f=Q_(10, \"1/s\"), amp=Q_(20, \"A\"), bias=Q_(300, \"A\"))\n", "I = I_ts.interp_time(time)\n", "I[\"time\"] = I[\"time\"]+pd.Timestamp(\"2020-01-01\")\n", "\n", @@ -88,7 +83,7 @@ "metadata": {}, "outputs": [], "source": [ - "U_ts = ut.sine(f=Q_(10, \"1/s\"), amp=Q_(3, \"V\"), bias=Q_(40, \"V\"), phase=Q_(0.1, \"rad\"))\n", + "U_ts = util.sine(f=Q_(10, \"1/s\"), amp=Q_(3, \"V\"), bias=Q_(40, \"V\"), phase=Q_(0.1, \"rad\"))\n", "U = U_ts.interp_time(time)\n", "U[\"time\"] = U[\"time\"]+pd.Timestamp(\"2020-01-01\")\n", "\n", @@ -477,7 +472,7 @@ " # \"data_sources\": sources,\n", " # \"data_processors\": processors,\n", "}\n", - "buffer = weldx.asdf.utils._write_buffer(tree)" + "buffer = weldx.asdf.util._write_buffer(tree)" ] }, { @@ -486,7 +481,7 @@ "metadata": {}, "outputs": [], "source": [ - "weldx.asdf.utils.notebook_fileprinter(buffer)" + "weldx.asdf.util.notebook_fileprinter(buffer)" ] }, { @@ -495,7 +490,7 @@ "metadata": {}, "outputs": [], "source": [ - "data = weldx.asdf.utils._read_buffer(buffer)" + "data = weldx.asdf.util._read_buffer(buffer)" ] } ], @@ -520,4 +515,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/tutorials/timeseries_01.ipynb b/tutorials/timeseries_01.ipynb index a70745207..e1a5a1a65 100644 --- a/tutorials/timeseries_01.ipynb +++ b/tutorials/timeseries_01.ipynb @@ -35,7 +35,7 @@ "\n", "import weldx\n", "import weldx.transformations as tf\n", - "import weldx.utility as ut\n", + "import weldx.util \n", "\n", "from weldx.asdf.extension import WeldxAsdfExtension, WeldxExtension\n", "from weldx.asdf.tags.weldx.core.mathematical_expression import MathematicalExpression\n", diff --git a/tutorials/welding_example_01_basics.ipynb b/tutorials/welding_example_01_basics.ipynb index 89bd0a7e7..6de5b1c0b 100644 --- a/tutorials/welding_example_01_basics.ipynb +++ b/tutorials/welding_example_01_basics.ipynb @@ -65,7 +65,7 @@ "import weldx\n", "import weldx.geometry as geo\n", "import weldx.transformations as tf\n", - "import weldx.utility as ut\n", + "import weldx.util \n", "import weldx.visualization as vis\n", "from weldx import Q_\n", "from weldx.transformations import LocalCoordinateSystem as lcs\n", @@ -133,7 +133,7 @@ "To do this, two steps are missing:\n", "\n", "1. we have to decide on a weld seam length first (we will use 300 mm in this example)\n", - "2. create a trace object that defines the path of our element though space. (we use a simple linear trace in this example)" + "2. create a trace object that defines the path of our element through space. (we use a simple linear trace in this example)" ] }, { @@ -164,14 +164,14 @@ "outputs": [], "source": [ "# create 3d workpiece geometry from the groove profile and trace objects\n", - "geometry = geo.Geometry(groove.to_profile(width_default=Q_(4, \"mm\")), trace)" + "geometry = geo.Geometry(groove.to_profile(width_default=Q_(5, \"mm\")), trace)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "To visualize the geometry we rasterize each profile along specific steps of the trace." + "To visualize the geometry we simply call its `plot` function. Since it internally rasterizes the data, we need to provide the raster widths:" ] }, { @@ -181,18 +181,15 @@ "outputs": [], "source": [ "# rasterize geometry\n", - "profile_raster_width = 0.5 # resolution of each profile in mm\n", - "trace_raster_width = 15 # space between profiles in mm\n", - "geometry_data_sp = geometry.rasterize(\n", - " profile_raster_width=profile_raster_width, trace_raster_width=trace_raster_width\n", - ")" + "profile_raster_width = 2 # resolution of each profile in mm\n", + "trace_raster_width = 30 # space between profiles in mm" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "Simple scatter plot of the geometry" + "Here is the plot:" ] }, { @@ -207,25 +204,33 @@ " ax.legend()\n", " ax.set_xlabel(\"x / mm\")\n", " ax.set_ylabel(\"y / mm\")\n", - " ax.set_zlabel(\"z / mm\")" + " ax.set_zlabel(\"z / mm\")\n", + " ax.view_init(30, -10)\n", + " ax.set_ylim([-5.5, 5.5])\n", + " ax.set_zlim([0, 13])\n", + "\n", + "\n", + "color_dict = {\n", + " \"tcp_contact\": (255, 0, 0),\n", + " \"tcp_wire\": (0, 150, 0),\n", + " \"T1\": (255, 0, 150),\n", + " \"T2\": (255, 150, 150),\n", + " \"T3\": (255, 150, 0),\n", + " \"specimen\": (0, 0, 255),\n", + "}" ] }, { "cell_type": "code", "execution_count": null, - "metadata": { - "nbsphinx": "hidden" - }, + "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", + "ax = geometry.plot(\n", + " profile_raster_width,\n", + " trace_raster_width,\n", + " color=color_dict[\"specimen\"],\n", + " show_wireframe=True,\n", " label=\"groove\",\n", ")\n", "ax_setup(ax)" @@ -257,7 +262,7 @@ "source": [ "The trace we created earlier to extend the groove shape into 3d has its own associated coordinate system that starts in the origin of the groove (see point (0,0) in our first plot of the groove profile) and has the x-axis running along the direction of the weld seam by convention.\n", "\n", - "We simply add the trace coordinate system to our coordinate system manager defining at as the *workpiece* coordinate system." + "We simply add the trace coordinate system to our coordinate system manager defining it as the *workpiece* coordinate system." ] }, { @@ -274,6 +279,26 @@ ")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that we have added the workpiece coordinate system to the CSM, we can attach a rasterized representation of our geometry to it:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "csm.assign_data(\n", + " geometry.spatial_data(profile_raster_width, trace_raster_width),\n", + " \"specimen\",\n", + " \"workpiece\",\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -434,7 +459,7 @@ "metadata": {}, "source": [ "## plot the TCP trajectory\n", - "To examine the movement of our wire TCP and contact tip, lets create a simple plot. We only have a linear movement we don't have to add additional timestamps to the moving coordinate systems." + "To examine the movement of our wire TCP and contact tip, lets create a simple plot. We only have a linear movement so we don't have to add additional timestamps to the moving coordinate systems to increase the resolution of the traces." ] }, { @@ -443,25 +468,12 @@ "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", - " label=\"groove\",\n", + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_contact\", \"tcp_wire\"],\n", + " colors=color_dict,\n", + " show_vectors=False,\n", + " show_wireframe=True,\n", ")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire\", c=\"g\", marker=\"o\")\n", - "\n", - "cs = csm.get_cs(\"tcp_contact\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_contact\", c=\"r\", marker=\"o\")\n", - "\n", "ax_setup(ax)" ] }, @@ -491,11 +503,14 @@ "metadata": {}, "outputs": [], "source": [ - "for T in [\"T1\", \"T2\", \"T3\"]:\n", - " cs = csm.get_cs(T, \"workpiece\")\n", - " data = cs.coordinates.data\n", - " ax.scatter(data[0], data[1], data[2], label=T, c=\"orange\", marker=\"o\")\n", - "ax.legend()" + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_contact\", \"tcp_wire\", \"T1\", \"T2\", \"T3\"],\n", + " reference_system=\"workpiece\",\n", + " colors=color_dict,\n", + " show_vectors=False,\n", + " show_wireframe=True,\n", + ")\n", + "ax_setup(ax)" ] }, { @@ -507,6 +522,36 @@ "csm" ] }, + { + "cell_type": "markdown", + "metadata": { + "nbsphinx": "hidden" + }, + "source": [ + "## K3D Visualization" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "nbsphinx": "hidden" + }, + "outputs": [], + "source": [ + "csm.plot(\n", + " backend=\"k3d\",\n", + " coordinate_systems=[\"tcp_contact\", \"tcp_wire\", \"T1\", \"T2\", \"T3\"],\n", + " colors=color_dict,\n", + " limits=[[-5, 150]],\n", + " show_vectors=False,\n", + " show_traces=True,\n", + " show_data_labels=False,\n", + " show_labels=False,\n", + " show_origins=True,\n", + ")" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -529,7 +574,7 @@ "metadata": {}, "outputs": [], "source": [ - "buffer = weldx.asdf.utils._write_buffer(tree)" + "buffer = weldx.asdf.util._write_buffer(tree)" ] }, { @@ -538,7 +583,7 @@ "metadata": {}, "outputs": [], "source": [ - "weldx.asdf.utils.notebook_fileprinter(buffer)" + "weldx.asdf.util.notebook_fileprinter(buffer)" ] } ], @@ -558,7 +603,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.9" + "version": "3.9.2" } }, "nbformat": 4, diff --git a/tutorials/welding_example_02_weaving.ipynb b/tutorials/welding_example_02_weaving.ipynb index 21e422e62..ad9040d55 100644 --- a/tutorials/welding_example_02_weaving.ipynb +++ b/tutorials/welding_example_02_weaving.ipynb @@ -46,10 +46,9 @@ "outputs": [], "source": [ "# some python imports that will be used throughout the tutorial\n", - "import numpy as np\n", - "\n", "import matplotlib.pyplot as plt\n", "import networkx as nx\n", + "import numpy as np\n", "import pandas as pd\n", "import pint\n", "import xarray as xr\n", @@ -66,7 +65,7 @@ "import weldx\n", "import weldx.geometry as geo\n", "import weldx.transformations as tf\n", - "import weldx.utility as ut\n", + "from weldx import util\n", "import weldx.visualization as vis\n", "from weldx import Q_\n", "from weldx.core import MathematicalExpression, TimeSeries\n", @@ -127,11 +126,11 @@ "trace = geo.Trace(trace_segment)\n", "\n", "# create 3d workpiece geometry from the groove profile and trace objects\n", - "geometry = geo.Geometry(groove.to_profile(width_default=Q_(4, \"mm\")), trace)\n", + "geometry = geo.Geometry(groove.to_profile(width_default=Q_(5, \"mm\")), trace)\n", "\n", "# rasterize geometry\n", - "profile_raster_width = 0.5 # resolution of each profile in mm\n", - "trace_raster_width = 15 # space between profiles in mm\n", + "profile_raster_width = 2 # resolution of each profile in mm\n", + "trace_raster_width = 30 # space between profiles in mm\n", "geometry_data_sp = geometry.rasterize(\n", " profile_raster_width=profile_raster_width, trace_raster_width=trace_raster_width\n", ")" @@ -154,7 +153,18 @@ "csm = weldx.transformations.CoordinateSystemManager(\"base\")\n", "\n", "# add the workpiece coordinate system\n", - "csm.add_cs(coordinate_system_name=\"workpiece\", reference_system_name=\"base\", lcs=trace.coordinate_system)" + "csm.add_cs(\n", + " coordinate_system_name=\"workpiece\",\n", + " reference_system_name=\"base\",\n", + " lcs=trace.coordinate_system,\n", + ")\n", + "\n", + "# add the geometry data of the specimen\n", + "csm.assign_data(\n", + " geometry.spatial_data(profile_raster_width, trace_raster_width),\n", + " \"specimen\",\n", + " \"workpiece\",\n", + ")" ] }, { @@ -185,9 +195,7 @@ "\n", "coords = [tcp_start_point.magnitude, tcp_end_point.magnitude]\n", "\n", - "tcp_wire = lcs(\n", - " coordinates=coords, orientation=rot, time=[t_start, t_end]\n", - ")" + "tcp_wire = lcs(coordinates=coords, orientation=rot, time=[t_start, t_end])" ] }, { @@ -203,7 +211,9 @@ "metadata": {}, "outputs": [], "source": [ - "csm.add_cs(coordinate_system_name=\"tcp_wire\", reference_system_name=\"workpiece\", lcs=tcp_wire)\n", + "csm.add_cs(\n", + " coordinate_system_name=\"tcp_wire\", reference_system_name=\"workpiece\", lcs=tcp_wire\n", + ")\n", "csm" ] }, @@ -213,11 +223,24 @@ "metadata": {}, "outputs": [], "source": [ - "def ax_setup(ax):\n", + "def ax_setup(ax, rotate=170):\n", " ax.legend()\n", " ax.set_xlabel(\"x / mm\")\n", " ax.set_ylabel(\"y / mm\")\n", - " ax.set_zlabel(\"z / mm\")" + " ax.set_zlabel(\"z / mm\")\n", + " ax.view_init(30, -10)\n", + " ax.set_ylim([-5.5, 5.5])\n", + " ax.view_init(30, rotate)\n", + " ax.legend()\n", + "\n", + "\n", + "color_dict = {\n", + " \"tcp_sine\": (255, 0, 0),\n", + " \"tcp_wire_sine\": (255, 0, 0),\n", + " \"tcp_wire_sine2\": (255, 0, 0),\n", + " \"tcp_wire\": (0, 150, 0),\n", + " \"specimen\": (0, 0, 255),\n", + "}" ] }, { @@ -226,21 +249,13 @@ "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", - " label=\"groove\",\n", + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\"],\n", + " colors=color_dict,\n", + " limits=[(0, 140), (-5, 5), (0, 12)],\n", + " show_vectors=False,\n", + " show_wireframe=True,\n", ")\n", - "\n", - "cs = cs = csm.get_cs(\"tcp_wire\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire\", c=\"g\", marker=\"o\")\n", - "\n", "ax_setup(ax)" ] }, @@ -258,7 +273,7 @@ "metadata": {}, "outputs": [], "source": [ - "ts_sine = ut.sine(f = Q_(1.5 * 2 * np.pi, \"Hz\"), amp = Q_([[0, 0.75, 0]], \"mm\"))" + "ts_sine = util.sine(f=Q_(0.5 * 2 * np.pi, \"Hz\"), amp=Q_([[0, 0.75, 0]], \"mm\"))" ] }, { @@ -275,7 +290,7 @@ "outputs": [], "source": [ "t = pd.timedelta_range(start=t_start, end=t_end, freq=\"10ms\")\n", - "ts_sine_data = ut.lcs_coords_from_ts(ts_sine,t)" + "ts_sine_data = util.lcs_coords_from_ts(ts_sine, t)" ] }, { @@ -307,7 +322,9 @@ "metadata": {}, "outputs": [], "source": [ - "csm.add_cs(coordinate_system_name=\"tcp_sine\", reference_system_name=\"tcp_wire\", lcs=tcp_sine)\n", + "csm.add_cs(\n", + " coordinate_system_name=\"tcp_sine\", reference_system_name=\"tcp_wire\", lcs=tcp_sine\n", + ")\n", "csm" ] }, @@ -324,25 +341,38 @@ "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", - " label=\"groove\",\n", + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_sine\"],\n", + " colors=color_dict,\n", + " limits=[(0, 140), (-5, 5), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", + " show_wireframe=True,\n", + ")\n", + "ax_setup(ax)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here a little bit closer to see the actual sine wave:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_sine\"],\n", + " colors=color_dict,\n", + " limits=[(0, 5), (-2, 2), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", + " show_wireframe=False,\n", ")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire\", c=\"g\", marker=\"o\")\n", - "\n", - "cs = csm.get_cs(\"tcp_sine\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_contact\", c=\"r\")\n", - "\n", "ax_setup(ax)" ] }, @@ -392,25 +422,31 @@ "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", - " label=\"groove\",\n", + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_wire_sine\"],\n", + " colors=color_dict,\n", + " limits=[(0, 140), (-5, 5), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", + " show_wireframe=True,\n", + ")\n", + "ax_setup(ax)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_sine\"],\n", + " colors=color_dict,\n", + " limits=[(0, 5), (-2, 2), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", + " show_wireframe=False,\n", ")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire\", c=\"g\", marker=\"o\")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire_sine\", \"workpiece\")\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_contact\", c=\"r\")\n", - "\n", "ax_setup(ax)" ] }, @@ -426,7 +462,7 @@ "metadata": {}, "source": [ "## plot with time interpolation\n", - "Sometimes we might only be interested in a specific time range of the experiment or we want to change the time resolution. For this we can use the time interpolation methods of the coordinate systems (or the CSM).\n", + "Sometimes we might only be interested in a specific time range of the experiment or we want to change the time resolution. For this we can use the time interpolation methods of the CSM (or the coordinate systems).\n", "\n", "Let's say we want to weave only 8 seconds of our experiment (starting from 2020-04-20 10:03:00) but interpolate steps of 1 ms." ] @@ -437,7 +473,7 @@ "metadata": {}, "outputs": [], "source": [ - "t_interp = pd.timedelta_range(start=\"0s\", end=\"11s\", freq=\"1ms\")" + "t_interp = pd.timedelta_range(start=\"3s\", end=\"11s\", freq=\"1ms\")" ] }, { @@ -446,25 +482,14 @@ "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", - " label=\"groove\",\n", + "ax = csm.interp_time(t_interp).plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_wire_sine\"],\n", + " colors=color_dict,\n", + " limits=[(0, 140), (-5, 5), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", + " show_wireframe=True,\n", ")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire\", \"workpiece\").interp_time(t_interp)\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire\", c=\"g\")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire_sine\", \"workpiece\").interp_time(t_interp)\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire_sine\", c=\"r\")\n", - "\n", "ax_setup(ax)" ] }, @@ -482,10 +507,10 @@ "metadata": {}, "outputs": [], "source": [ - "ts_sine = ut.sine(f=Q_(1 / 8 * 2 * np.pi, \"Hz\"), amp=Q_([[0, 0, 1]], \"mm\"))\n", + "ts_sine = util.sine(f=Q_(1 / 8 * 2 * np.pi, \"Hz\"), amp=Q_([[0, 0, 1]], \"mm\"))\n", "\n", "t = pd.timedelta_range(start=\"0s\", end=\"8s\", freq=\"25ms\")\n", - "ts_sine_data = ut.lcs_coords_from_ts(ts_sine,t)\n", + "ts_sine_data = util.lcs_coords_from_ts(ts_sine, t)\n", "tcp_sine2 = lcs(coordinates=ts_sine_data)" ] }, @@ -511,7 +536,9 @@ "metadata": {}, "outputs": [], "source": [ - "t_interp = pd.timedelta_range(start=tcp_wire.time[0].values, end=tcp_wire.time[-1].values, freq=\"20ms\")" + "t_interp = pd.timedelta_range(\n", + " start=tcp_wire.time[0].values, end=tcp_wire.time[-1].values, freq=\"20ms\"\n", + ")" ] }, { @@ -541,23 +568,30 @@ "metadata": {}, "outputs": [], "source": [ - "fig = plt.figure()\n", - "ax = fig.add_subplot(111, projection=\"3d\", proj_type=\"ortho\")\n", - "ax.scatter(\n", - " geometry_data_sp[0, :],\n", - " geometry_data_sp[1, :],\n", - " geometry_data_sp[2, :],\n", - " marker=\".\",\n", - " c=\"b\",\n", - " label=\"groove\",\n", + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_wire_sine2\"],\n", + " colors=color_dict,\n", + " limits=[(0, 140), (-5, 5), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", ")\n", - "\n", - "cs = csm.get_cs(\"tcp_wire_sine2\", \"workpiece\")\n", - "# cs = tcp_wire_sine2\n", - "data = cs.coordinates.data\n", - "ax.plot(data[:, 0], data[:, 1], data[:, 2], label=\"tcp_wire_sine2\", c=\"r\")\n", - "\n", - "ax_setup(ax)" + "ax_setup(ax, rotate=110)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "ax = csm.plot(\n", + " coordinate_systems=[\"tcp_wire\", \"tcp_wire_sine2\"],\n", + " colors=color_dict,\n", + " limits=[(60, 100), (-2, 2), (0, 12)],\n", + " show_origins=False,\n", + " show_vectors=False,\n", + ")\n", + "ax_setup(ax, rotate=110)" ] }, { @@ -577,10 +611,17 @@ }, "outputs": [], "source": [ - "try:\n", - " csm.plot(backend=\"k3d\", limits=[[-5,150]], coordinate_systems=[\"base\", \"workpiece\", \"tcp_wire_sine2\"], show_vectors=False, show_traces=True, show_labels=False, show_origins=True)\n", - "except:\n", - " pass" + "csm.plot(\n", + " backend=\"k3d\",\n", + " coordinate_systems=[\"tcp_wire_sine2\"],\n", + " colors=color_dict,\n", + " limits=[[-5, 150]],\n", + " show_vectors=False,\n", + " show_traces=True,\n", + " show_data_labels=False,\n", + " show_labels=False,\n", + " show_origins=True,\n", + ")" ] } ], @@ -600,9 +641,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.7.9" + "version": "3.9.2" } }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/weldx/__init__.py b/weldx/__init__.py index a1c5fc50c..9eff1ddcb 100644 --- a/weldx/__init__.py +++ b/weldx/__init__.py @@ -14,7 +14,7 @@ ) # main modules -import weldx.utility # import this first to avoid circular dependencies +import weldx.util # import this first to avoid circular dependencies import weldx.config import weldx.core import weldx.geometry @@ -41,21 +41,33 @@ LocalCoordinateSystem, WXRotation, ) +from weldx.welding.processes import GmawProcess from weldx.welding.groove.iso_9692_1 import get_groove -with warnings.catch_warnings(): - warnings.simplefilter("ignore") - Q_([]) - -__all__ = [ +__all__ = ( + # major modules "core", "geometry", + "GmawProcess", "measurement", "transformations", - "utility", + "util", "asdf", - "Q_", "welding", -] + # geometries + "ArcSegment", + "Geometry", + "LineSegment", + "Profile", + "Shape", + "Trace", + "SpatialData", + # coordinates + "LocalCoordinateSystem", + "CoordinateSystemManager", + "get_groove", + # quantities + "Q_", +) config.Config.load_installed_standards() diff --git a/weldx/asdf/__init__.py b/weldx/asdf/__init__.py index b34dd4f7d..2341ecb00 100644 --- a/weldx/asdf/__init__.py +++ b/weldx/asdf/__init__.py @@ -1,8 +1,7 @@ -""" -isort:skip_file -""" +"""This submodule contains ASDF related weldx extensions and schemas.""" +# isort:skip_file from weldx.asdf import tags # implement tags before the asdf extensions -from weldx.asdf import constants, utils +from weldx.asdf import constants, util from weldx.asdf.extension import WeldxAsdfExtension, WeldxExtension # class imports to weldx.asdf namespace diff --git a/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/gas_component-1.0.0.yaml b/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/gas_component-1.0.0.yaml index f0d681b90..9db709e20 100644 --- a/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/gas_component-1.0.0.yaml +++ b/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/gas_component-1.0.0.yaml @@ -5,7 +5,7 @@ id: "http://weldx.bam.de/schemas/weldx/aws/process/gas_component-1.0.0" tag: "tag:weldx.bam.de:weldx/aws/process/gas_component-1.0.0" title: | - + Shielding gas component description: | A single gas element of a mixture and its percentage of the mixture by weight. type: object diff --git a/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_for_procedure-1.0.0.yaml b/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_for_procedure-1.0.0.yaml index e2d640902..76ffb611a 100644 --- a/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_for_procedure-1.0.0.yaml +++ b/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_for_procedure-1.0.0.yaml @@ -5,7 +5,7 @@ id: "http://weldx.bam.de/schemas/weldx/aws/process/shielding_gas_for_procedure-1 tag: "tag:weldx.bam.de:weldx/aws/process/shielding_gas_for_procedure-1.0.0" title: | - + GMAW process shielding gas description: | Description of applicable gas composition and flowrates, including torch gas shielding, backing gas, and trailing gas. type: object diff --git a/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_type-1.0.0.yaml b/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_type-1.0.0.yaml index 9fa7fbc4f..6078928c2 100644 --- a/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_type-1.0.0.yaml +++ b/weldx/asdf/schemas/weldx.bam.de/weldx/aws/process/shielding_gas_type-1.0.0.yaml @@ -5,7 +5,7 @@ id: "http://weldx.bam.de/schemas/weldx/aws/process/shielding_gas_type-1.0.0" tag: "tag:weldx.bam.de:weldx/aws/process/shielding_gas_type-1.0.0" title: | - + GMAW shielding gas description: | Description of a gas or gas mixture used for shielding in arc welding. type: object diff --git a/weldx/asdf/schemas/weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml b/weldx/asdf/schemas/weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml index 0af1a850b..107f8bd61 100644 --- a/weldx/asdf/schemas/weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml +++ b/weldx/asdf/schemas/weldx.bam.de/weldx/datamodels/single_pass_weld-1.0.0.schema.yaml @@ -4,10 +4,19 @@ $schema: "http://stsci.edu/schemas/yaml-schema/draft-01" id: "http://weldx.bam.de/schemas/weldx/datamodels/single_pass_weld-1.0.0.schema" title: | - Single pass GMAW weldment. + Single pass, single wire GMAW weldment. description: | Schema describing a simple single pass welding application along a linear weld seam with constant groove shape. + The workpiece is defined by two constant properties: + - the groove shape as defined by ISO 9692-1 + - the total seam length + It is assumed that the complete workpiece length is equal to the seam length. + Outside of the welding groove shape, no information is given regarding the outer shape of the workpiece. + More complex workpiece data can be attached as custom data to the associated coordinate system. + + The TCP movement coordinates along the workpiece x-axis should fall into the range between 0 and the seam length to be plausible. + type: object properties: process: @@ -17,52 +26,406 @@ properties: $ref: "http://weldx.bam.de/schemas/weldx/process/GMAW-1.0.0" shielding_gas: tag: "tag:weldx.bam.de:weldx/aws/process/shielding_gas_for_procedure-1.0.0" - equipment: - type: array - items: - tag: "tag:weldx.bam.de:weldx/equipment/generic_equipment-1.0.0" - measurements: - type: array - items: - tag: "tag:weldx.bam.de:weldx/measurement/measurement-1.0.0" + weld_speed: + description: | + The constant weld speed of the welding TCP movement. + tag: "tag:weldx.bam.de:weldx/core/time_series-1.0.0" + wx_unit: "m/s" + wx_shape: [1] + welding_wire: + type: object + properties: + diameter: + description: | + The diameter of the welding wire. + tag: "tag:stsci.edu:asdf/unit/quantity-1.1.0" + wx_unit: "m" + wx_shape: [1] + class: + description: | + The wire classification according to DIN EN ISO 14341, DIN EN 12072 or similar standards. + Addition standard details should be stored in the wx_user property. + type: string + required: [wire_diameter] + required: [welding_process, shielding_gas, weld_speed, welding_wire] welding_current: + description: | + The signal representing the welding current measurement. tag: "tag:weldx.bam.de:weldx/measurement/signal-1.0.0" wx_unit: "A" welding_voltage: + description: | + The signal representing the welding voltage measurement. tag: "tag:weldx.bam.de:weldx/measurement/signal-1.0.0" wx_unit: "V" + TCP: + description: | + Transformation describing the welding TCP movement in relation to the groove coordinates. + tag: "tag:weldx.bam.de:weldx/core/transformations/local_coordinate_system-1.0.0" + wx_shape: + time: [2~] coordinate_systems: tag: "tag:weldx.bam.de:weldx/core/transformations/coordinate_system_hierarchy-1.0.0" - geometry: + equipment: + type: array + items: + tag: "tag:weldx.bam.de:weldx/equipment/generic_equipment-1.0.0" + measurements: + description: | + List of all measurements associated with the experiment. + type: array + items: + tag: "tag:weldx.bam.de:weldx/measurement/measurement-1.0.0" + workpiece: + description: | + The workpiece to be welded defined by the base metal and the geometric description of the weld seam. type: object properties: - groove_shape: + base_metal: description: | - Constant groove shape of the weld seam. - oneOf: - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DHUGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DHVGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DUGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DVGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/FFGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/HUGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/HVGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/IGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/UGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/UVGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/VGroove-1.0.0" - - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/VVGroove-1.0.0" - seam_length: + The base metal composition of the workpiece. + type: object + properties: + common_name: + description: | + The common description of the base metal composition or classification as listed in the standard. + type: string + standard: + description: | + The standard listing and describing the base metal compositions. + type: string + required: [common_name, standard] + geometry: description: | - Length of the linear weld seam. - tag: "tag:stsci.edu:asdf/unit/quantity-1.1.0" - wx_unit: "m" - required: [groove_shape, seam_length] + Description of the workpiece geometry consisting of the groove shape and the total seam length. + type: object + properties: + groove_shape: + description: | + Constant groove shape of the weld seam. + oneOf: + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DHUGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DHVGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DUGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/DVGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/FFGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/HUGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/HVGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/IGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/UGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/UVGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/VGroove-1.0.0" + - tag: "tag:weldx.bam.de:weldx/groove/iso_9692_1_2013_12/VVGroove-1.0.0" + seam_length: + description: | + Length of the linear weld seam. + tag: "tag:stsci.edu:asdf/unit/quantity-1.1.0" + wx_unit: "m" + required: [groove_shape, seam_length] + required: [base_metal, geometry] reference_timestamp: + description: | + An optional timestamp indicating the start of the welding process. tag: "tag:weldx.bam.de:weldx/time/timestamp-1.0.0" - meta: + wx_metadata: description: | General metadata container. type: object -required: [equipment,geometry,measurements,coordinate_systems,welding_current,welding_voltage] + wx_user: + description: | + Metadata container for additional user documentation of the experiment. + type: object +required: [equipment,workpiece,measurements,welding_current,welding_voltage,TCP] +additionalProperties: false + + +examples: + - + - A simple welding application + - | + TCP: ! + time: ! + values: !core/ndarray-1.0.0 + data: [0, 29000000000] + datatype: int64 + shape: [2] + start: ! {value: P0DT0H0M0S} + end: ! {value: P0DT0H0M29S} + min: ! {value: P0DT0H0M0S} + max: ! {value: P0DT0H0M29S} + orientations: ! + name: orientations + dimensions: [c, v] + dtype: + name: coordinates + dimensions: [time, c] + dtype: + name: Coordinate system manager 0 + root_system_name: base + subsystems: [] + coordinate_systems: + - ! + name: workpiece + reference_system: base + transformation: ! {} + - ! + name: tcp_wire + reference_system: workpiece + transformation: ! + time: ! + values: !core/ndarray-1.0.0 + data: [0, 29000000000] + datatype: int64 + shape: [2] + start: ! {value: P0DT0H0M0S} + end: ! {value: P0DT0H0M29S} + min: ! {value: P0DT0H0M0S} + max: ! {value: P0DT0H0M29S} + orientations: ! + name: orientations + dimensions: [c, v] + dtype: + name: coordinates + dimensions: [time, c] + dtype: + name: tcp_contact + reference_system: tcp_wire + transformation: ! + coordinates: ! + name: coordinates + dimensions: [c] + dtype: + name: HKS P1000-S3 + sources: + - &id003 ! + name: Current Sensor + output_signal: &id002 ! + signal_type: analog + unit: V + error: ! + deviation: !unit/quantity-1.1.0 {unit: percent, value: 0.1} + - &id007 ! + name: Voltage Sensor + output_signal: &id001 ! + signal_type: analog + unit: V + error: ! + deviation: !unit/quantity-1.1.0 {unit: percent, value: 0.1} + data_transformations: + - &id008 ! + name: AD conversion voltage measurement + input_signal: *id001 + output_signal: &id009 ! + signal_type: digital + unit: '' + error: ! + deviation: !unit/quantity-1.1.0 {unit: percent, value: 0.01} + func: ! + expression: a*x + b + parameters: + a: !unit/quantity-1.1.0 {unit: 1 / volt, value: 3276.8} + b: !unit/quantity-1.1.0 {unit: dimensionless, value: 0.0} + - ! + name: Beckhoff ELM3002-0000 + sources: [] + data_transformations: + - &id004 ! + name: AD conversion current measurement + input_signal: *id002 + output_signal: &id005 ! + signal_type: digital + unit: '' + error: ! + deviation: !unit/quantity-1.1.0 {unit: percent, value: 0.01} + func: ! + expression: a*x + b + parameters: + a: !unit/quantity-1.1.0 {unit: 1 / volt, value: 3276.8} + b: !unit/quantity-1.1.0 {unit: dimensionless, value: 0.0} + measurements: + - ! + name: welding current measurement + data: + - &id006 ! + name: Welding current + data: ! + attributes: {} + coordinates: + - ! + name: time + dimensions: [time] + dtype: + name: data + dimensions: [time] + dtype: + name: welding current measurement chain + data_source: *id003 + data_processors: + - *id004 + - ! + name: Calibration current measurement + input_signal: *id005 + output_signal: &id012 ! + signal_type: digital + unit: A + data: *id006 + error: ! + deviation: 0.0 + func: ! + expression: a*x + b + parameters: + a: !unit/quantity-1.1.0 {unit: ampere, value: 0.030517578125} + b: !unit/quantity-1.1.0 {unit: ampere, value: 0.0} + wx_metadata: + software: &id011 !core/software-1.0.0 {name: Beckhoff TwinCAT ScopeView, version: 3.4.3143} + - ! + name: welding voltage measurement + data: + - &id010 ! + name: Welding voltage + data: ! + attributes: {} + coordinates: + - ! + name: time + dimensions: [time] + dtype: + name: data + dimensions: [time] + dtype: + name: welding voltage measurement chain + data_source: *id007 + data_processors: + - *id008 + - ! + name: Calibration voltage measurement + input_signal: *id009 + output_signal: &id013 ! + signal_type: digital + unit: V + data: *id010 + error: ! + deviation: 0.0 + func: ! + expression: a*x + b + parameters: + a: !unit/quantity-1.1.0 {unit: volt, value: 0.0030517578125} + b: !unit/quantity-1.1.0 {unit: volt, value: 0.0} + wx_metadata: + software: *id011 + process: + shielding_gas: ! + use_torch_shielding_gas: true + torch_shielding_gas: ! + gas_component: + - ! + gas_chemical_name: argon + gas_percentage: !unit/quantity-1.1.0 {unit: percent, value: 82} + - ! + gas_chemical_name: carbon dioxide + gas_percentage: !unit/quantity-1.1.0 {unit: percent, value: 18} + common_name: SG + torch_shielding_gas_flowrate: !unit/quantity-1.1.0 {unit: liter / minute, value: 20} + weld_speed: !unit/quantity-1.1.0 {unit: millimeter / second, value: 10} + welding_process: ! + base_process: pulse + manufacturer: CLOOS + meta: {modulation: UI} + parameters: + base_current: ! + unit: ampere + value: 60.0 + pulse_duration: ! + unit: millisecond + value: 5.0 + pulse_frequency: ! + unit: hertz + value: 100.0 + pulse_voltage: ! + unit: volt + value: 40.0 + wire_feedrate: ! + unit: meter / minute + value: 10.0 + power_source: Quinto + tag: CLOOS/pulse + welding_wire: + diameter: !unit/quantity-1.1.0 {unit: millimeter, value: 1.2} + reference_timestamp: ! {value: '2020-11-09T12:00:00'} + welding_current: *id012 + welding_voltage: *id013 + workpiece: + base_metal: {common_name: S355J2+N, standard: 'DIN EN 10225-2:2011'} + geometry: + groove_shape: ! + t: !unit/quantity-1.1.0 {unit: millimeter, value: 5} + alpha: !unit/quantity-1.1.0 {unit: degree, value: 50} + b: !unit/quantity-1.1.0 {unit: millimeter, value: 1} + c: !unit/quantity-1.1.0 {unit: millimeter, value: 1} + code_number: ['1.3', '1.5'] + seam_length: !unit/quantity-1.1.0 {unit: millimeter, value: 300} + wx_metadata: {welder: A.W. Elder} ... diff --git a/weldx/asdf/schemas/weldx.bam.de/weldx/debug/test_shape_validator-1.0.0.yaml b/weldx/asdf/schemas/weldx.bam.de/weldx/debug/test_shape_validator-1.0.0.yaml index 8690addbc..cf304373e 100644 --- a/weldx/asdf/schemas/weldx.bam.de/weldx/debug/test_shape_validator-1.0.0.yaml +++ b/weldx/asdf/schemas/weldx.bam.de/weldx/debug/test_shape_validator-1.0.0.yaml @@ -28,6 +28,14 @@ properties: type: number wx_shape: [1] + quantity: + tag: "tag:stsci.edu:asdf/unit/quantity-1.1.0" + wx_shape: [1] + + timeseries: + tag: "tag:weldx.bam.de:weldx/core/time_series-1.0.0" + wx_shape: [1] + nested_prop: type: object properties: @@ -49,7 +57,7 @@ properties: -required: [prop1, prop2, prop3, prop4, nested_prop, time_prop] +required: [prop1, prop2, prop3, prop4, quantity, timeseries, nested_prop, time_prop] propertyOrder: [prop1,prop2,prop3,prop4,nested_prop,optional_prop] flowStyle: block additionalProperties: true diff --git a/weldx/asdf/schemas/weldx.bam.de/weldx/measurement/data_transformation-1.0.0.yaml b/weldx/asdf/schemas/weldx.bam.de/weldx/measurement/data_transformation-1.0.0.yaml index fac17e300..50910a945 100644 --- a/weldx/asdf/schemas/weldx.bam.de/weldx/measurement/data_transformation-1.0.0.yaml +++ b/weldx/asdf/schemas/weldx.bam.de/weldx/measurement/data_transformation-1.0.0.yaml @@ -21,8 +21,6 @@ properties: $ref: "tag:weldx.bam.de:weldx/measurement/error-1.0.0" func: $ref: "tag:weldx.bam.de:weldx/core/mathematical_expression-1.0.0" - meta: - type: object required: [name, input_signal, output_signal] propertyOrder: [name, input_signal, output_signal, error, func] diff --git a/weldx/asdf/tags/weldx/core/transformations/rotation.py b/weldx/asdf/tags/weldx/core/transformations/rotation.py index a60766d8c..cbe403789 100644 --- a/weldx/asdf/tags/weldx/core/transformations/rotation.py +++ b/weldx/asdf/tags/weldx/core/transformations/rotation.py @@ -4,7 +4,7 @@ from weldx.asdf.types import WeldxType from weldx.asdf.validators import wx_unit_validator from weldx.constants import WELDX_QUANTITY as Q_ -from weldx.transformations import WXRotation +from weldx.transformations.rotation import WXRotation class WXRotationTypeASDF(WeldxType): diff --git a/weldx/asdf/tags/weldx/debug/test_shape_validator.py b/weldx/asdf/tags/weldx/debug/test_shape_validator.py index dfbcffee4..a9f3b1e5d 100644 --- a/weldx/asdf/tags/weldx/debug/test_shape_validator.py +++ b/weldx/asdf/tags/weldx/debug/test_shape_validator.py @@ -2,7 +2,9 @@ import numpy as np import pandas as pd +import pint +from weldx import Q_, TimeSeries from weldx.asdf.types import WeldxType from weldx.asdf.validators import wx_shape_validator @@ -18,6 +20,8 @@ class ShapeValidatorTestClass: prop3: np.ndarray = np.ones((2, 4, 6, 8, 10)) prop4: np.ndarray = np.ones((1, 3, 5, 7, 9)) prop5: float = 3.141 + quantity: pint.Quantity = Q_(10, "m") + timeseries: TimeSeries = TimeSeries(Q_(10, "m")) nested_prop: dict = field( default_factory=lambda: { "p1": np.ones((10, 8, 6, 4, 2)), diff --git a/weldx/asdf/utils.py b/weldx/asdf/util.py similarity index 61% rename from weldx/asdf/utils.py rename to weldx/asdf/util.py index 86107da03..f79cd9e51 100644 --- a/weldx/asdf/utils.py +++ b/weldx/asdf/util.py @@ -1,15 +1,29 @@ +"""Utilities for asdf files.""" from io import BytesIO from pathlib import Path +from typing import Tuple import asdf import yaml +from boltons.iterutils import get_path from weldx.asdf.extension import WeldxAsdfExtension, WeldxExtension +__all__ = [ + "read_buffer", + "write_buffer", + "write_read_buffer", + "get_yaml_header", + "asdf_json_repr", + "notebook_fileprinter", +] + # asdf read/write debug tools functions --------------------------------------- -def _write_buffer(tree: dict, asdffile_kwargs: dict = None, write_kwargs: dict = None): +def _write_buffer( + tree: dict, asdffile_kwargs: dict = None, write_kwargs: dict = None +) -> BytesIO: """Write ASDF file into buffer. Parameters @@ -17,15 +31,16 @@ def _write_buffer(tree: dict, asdffile_kwargs: dict = None, write_kwargs: dict = tree: Tree object to serialize. asdffile_kwargs - Additional keywords to pass to asdf.AsdfFile() + Additional keywords to pass to `asdf.AsdfFile` write_kwargs - Additional keywords to pass to asdf.AsdfFile.write_to() + Additional keywords to pass to `asdf.AsdfFile.write_to` Weldx-Extensions are always set. Returns ------- - BytesIO + io.BytesIO Bytes buffer of the ASDF file. + """ if asdffile_kwargs is None: asdffile_kwargs = {} @@ -46,11 +61,11 @@ def _read_buffer(buffer: BytesIO, open_kwargs: dict = None): Parameters ---------- - buffer + buffer : io.BytesIO Buffer containing ASDF file contents open_kwargs - Additional keywords to pass to asdf.AsdfFile.open() - Extensions are always set, copy_arrays=True is set by default. + Additional keywords to pass to `asdf.AsdfFile.open` + Extensions are always set, ``copy_arrays=True`` is set by default. Returns ------- @@ -75,52 +90,43 @@ def _write_read_buffer( tree: dict, asdffile_kwargs=None, write_kwargs=None, open_kwargs=None ): """Perform a buffered write/read roundtrip of a tree using default ASDF settings. + Parameters ---------- tree Tree object to serialize. asdffile_kwargs - Additional keywords to pass to asdf.AsdfFile() + Additional keywords to pass to `asdf.AsdfFile` write_kwargs - Additional keywords to pass to asdf.AsdfFile.write_to() + Additional keywords to pass to `asdf.AsdfFile.write_to` Extensions are always set. open_kwargs - Additional keywords to pass to asdf.AsdfFile.open() - Extensions are always set, copy_arrays=True is set by default. + Additional keywords to pass to `asdf.AsdfFile.open` + Extensions are always set, ``copy_arrays=True`` is set by default. + Returns ------- dict + """ buffer = _write_buffer(tree, asdffile_kwargs, write_kwargs) return _read_buffer(buffer, open_kwargs) -try: # pragma: no cover - import IPython - from IPython.display import JSON - from pygments import highlight - from pygments.formatters import HtmlFormatter - from pygments.lexers import get_lexer_by_name, get_lexer_for_filename -except ImportError: # pragma: no cover - pass -else: # pragma: no cover - - def _get_yaml_header(file) -> str: - """Read the YAML header part of an ASDF file. +def get_yaml_header(file) -> str: # pragma: no cover + """Read the YAML header part (excluding binary sections) of an ASDF file. - Parameters - ---------- - file - filename or BytesIO buffer of ASDF file + Parameters + ---------- + file + filename, ``pathlib.Path`` or ``BytesIO`` buffer of ASDF file - Returns - ------- - str + Returns + ------- + str + The YAML header the ASDF file """ - if isinstance(file, str): - file = BytesIO(bytes(file, "utf-8")) - if isinstance(file, BytesIO): file.seek(0) code = file.read() @@ -128,9 +134,25 @@ def _get_yaml_header(file) -> str: with open(file, "rb") as f: code = f.read() - parts = code.partition(b"\n...") - code = parts[0].decode("utf-8") + parts[1].decode("utf-8") - return code + parts = code.partition(b"\n...") + code = parts[0].decode("utf-8") + parts[1].decode("utf-8") + return code + + +# make read/write buffer functions public +write_buffer = _write_buffer +read_buffer = _read_buffer +write_read_buffer = _write_read_buffer + +try: # pragma: no cover + import IPython + from IPython.display import JSON + from pygments import highlight + from pygments.formatters import HtmlFormatter + from pygments.lexers import get_lexer_by_name, get_lexer_for_filename +except ImportError: # pragma: no cover + pass +else: # pragma: no cover def notebook_fileprinter(file, lexer="YAML"): """Prints the code from file/BytesIO to notebook cell with syntax highlighting. @@ -138,7 +160,7 @@ def notebook_fileprinter(file, lexer="YAML"): Parameters ---------- file - filename or BytesIO buffer of ASDF file + filename or ``BytesIO`` buffer of ASDF file lexer Syntax style to use @@ -150,7 +172,7 @@ def notebook_fileprinter(file, lexer="YAML"): else: lexer = get_lexer_for_filename(file) - code = _get_yaml_header(file) + code = get_yaml_header(file) formatter = HtmlFormatter() return IPython.display.HTML( @@ -160,7 +182,7 @@ def notebook_fileprinter(file, lexer="YAML"): ) ) - def asdf_json_repr(file, **kwargs): + def asdf_json_repr(file, path: Tuple = None, **kwargs): """Display YAML header using IPython JSON display repr. This function works in JupyterLab. @@ -169,6 +191,8 @@ def asdf_json_repr(file, **kwargs): ---------- file filename or BytesIO buffer of ASDF file + path + tuple representing the lookup path in the yaml/asdf tree kwargs kwargs passed down to JSON constructor @@ -177,7 +201,29 @@ def asdf_json_repr(file, **kwargs): IPython.display.JSON JSON object for rich output in JupyterLab + Examples + -------- + Visualize the full tree of an existing ASDF file:: + + weldx.asdf.utils.asdf_json_repr("single_pass_weld_example.asdf") + + Visualize a specific element in the tree structure by proving the path:: + + weldx.asdf.utils.asdf_json_repr( + "single_pass_weld_example.asdf", path=("process", "welding_process") + ) + + """ - code = _get_yaml_header(file) + if isinstance(file, str): + root = file + "/" + else: + root = "/" + + code = get_yaml_header(file) yaml_dict = yaml.load(code, Loader=yaml.BaseLoader) + if path: + root = root + "/".join(path) + yaml_dict = get_path(yaml_dict, path) + kwargs["root"] = root return JSON(yaml_dict, **kwargs) diff --git a/weldx/asdf/validators.py b/weldx/asdf/validators.py index 85fb1fe77..1b3417f33 100644 --- a/weldx/asdf/validators.py +++ b/weldx/asdf/validators.py @@ -364,7 +364,7 @@ def _compare_lists(_list, list_expected): def _get_instance_shape(instance_dict: Union[TaggedDict, Dict[str, Any]]) -> List[int]: """Get the shape of an ASDF instance from its tagged dict form.""" - if isinstance(instance_dict, (float, int)): # test against [1] for single values + if isinstance(instance_dict, (float, int)): # test against [1] for scalar values return [1] elif "shape" in instance_dict: return instance_dict["shape"] @@ -374,6 +374,14 @@ def _get_instance_shape(instance_dict: Union[TaggedDict, Dict[str, Any]]) -> Lis return TimedeltaIndexType.shape_from_tagged(instance_dict) elif "weldx/time/datetimeindex" in instance_dict._tag: return DatetimeIndexType.shape_from_tagged(instance_dict) + elif "weldx/core/time_series" in instance_dict._tag: + if isinstance(instance_dict["value"], dict): # ndarray + return _get_instance_shape(instance_dict["value"]) + return [1] # scalar + elif "asdf/unit/quantity" in instance_dict._tag: + if isinstance(instance_dict["value"], dict): # ndarray + return _get_instance_shape(instance_dict["value"]) + return [1] # scalar return None diff --git a/weldx/core.py b/weldx/core.py index be972a13d..dc7132f52 100644 --- a/weldx/core.py +++ b/weldx/core.py @@ -8,10 +8,12 @@ import sympy import xarray as xr -import weldx.utility as ut +import weldx.util as ut from weldx.constants import WELDX_QUANTITY as Q_ from weldx.constants import WELDX_UNIT_REGISTRY as UREG +__all__ = ["MathematicalExpression", "TimeSeries"] + class MathematicalExpression: """Mathematical expression using sympy syntax.""" @@ -119,7 +121,9 @@ def equals( if check_structural_equality: equality = self.expression == other.expression else: - equality = sympy.simplify(self.expression - other.expression) == 0 + from sympy import simplify + + equality = simplify(self.expression - other.expression) == 0 if check_parameters: equality = equality and self._parameters == other.parameters diff --git a/weldx/geometry.py b/weldx/geometry.py index 87663eef3..ecbbc822a 100644 --- a/weldx/geometry.py +++ b/weldx/geometry.py @@ -1,22 +1,28 @@ """Provides classes to define lines and surfaces.""" +from __future__ import annotations import copy import math from dataclasses import dataclass -from typing import Dict, List, Union +from pathlib import Path +from typing import TYPE_CHECKING, Dict, List, Tuple, Union -import matplotlib.pyplot as plt +import meshio import numpy as np +import pint from xarray import DataArray import weldx.transformations as tf -import weldx.utility as ut -import weldx.visualization as vs +import weldx.util as ut from weldx.constants import WELDX_UNIT_REGISTRY as UREG _DEFAULT_LEN_UNIT = UREG.millimeters _DEFAULT_ANG_UNIT = UREG.rad +# only import heavy-weight packages on type checking +if TYPE_CHECKING: + import matplotlib.axes + # LineSegment ----------------------------------------------------------------- @@ -1226,7 +1232,9 @@ def plot( """ raster_data = self.rasterize(raster_width, stack=False) if ax is None: # pragma: no cover - _, ax = plt.subplots() + from matplotlib.pyplot import subplots + + _, ax = subplots() ax.grid(grid) if not ax.name == "3d": ax.axis(axis) @@ -1297,7 +1305,7 @@ def length(self): """ return self._length - def local_coordinate_system(self, relative_position): + def local_coordinate_system(self, relative_position) -> tf.LocalCoordinateSystem: """Calculate a local coordinate system along the trace segment. Parameters @@ -1426,7 +1434,7 @@ def is_clockwise(self): """ return self._sign_winding < 0 - def local_coordinate_system(self, relative_position): + def local_coordinate_system(self, relative_position) -> tf.LocalCoordinateSystem: """Calculate a local coordinate system along the trace segment. Parameters @@ -1593,7 +1601,7 @@ def num_segments(self): """ return len(self._segments) - def local_coordinate_system(self, position): + def local_coordinate_system(self, position) -> tf.LocalCoordinateSystem: """Get the local coordinate system at a specific position on the trace. Parameters @@ -1664,7 +1672,7 @@ def rasterize(self, raster_width): @UREG.wraps(None, (None, _DEFAULT_LEN_UNIT, None, None, None), strict=False) def plot( - self, raster_width=1, axes=None, fmt=None, set_axes_equal=False + self, raster_width=1, axes=None, fmt=None, axes_equal=False ): # pragma: no cover """Plot the trace. @@ -1677,7 +1685,7 @@ def plot( new figure will be created fmt : str Format string that is passed to matplotlib.pyplot.plot. - set_axes_equal : bool + axes_equal : bool Set plot axes to equal scaling (Default = False). """ @@ -1685,14 +1693,18 @@ def plot( if fmt is None: fmt = "x-" if axes is None: - fig = plt.figure() + from matplotlib.pyplot import figure + + fig = figure() axes = fig.gca(projection="3d", proj_type="ortho") axes.plot(data[0], data[1], data[2], fmt) axes.set_xlabel("x") axes.set_ylabel("y") axes.set_zlabel("z") - if set_axes_equal: - vs.set_axes_equal(axes) + if axes_equal: + import weldx.visualization as vs + + vs.axes_equal(axes) else: axes.plot(data[0], data[1], data[2], fmt) @@ -1843,7 +1855,7 @@ def max_location(self): return self._locations[-1] @property - def num_interpolation_schemes(self): + def num_interpolation_schemes(self) -> int: """Get the number of interpolation schemes. Returns @@ -1855,7 +1867,7 @@ def num_interpolation_schemes(self): return len(self._interpolation_schemes) @property - def num_locations(self): + def num_locations(self) -> int: """Get the number of profile locations. Returns @@ -1867,7 +1879,7 @@ def num_locations(self): return len(self._locations) @property - def num_profiles(self): + def num_profiles(self) -> int: """Get the number of profiles. Returns @@ -2081,7 +2093,7 @@ def _rasterize_constant_profile( """ locations = self._rasterize_trace(trace_raster_width) - if stack: # old behavior for 3d pointcloud + if stack: # old behavior for 3d point cloud profile_data = self._profile_raster_data_3d( self._profile, profile_raster_width, stack=True ) @@ -2187,48 +2199,96 @@ def rasterize(self, profile_raster_width, trace_raster_width, stack: bool = True @UREG.wraps( None, - (None, _DEFAULT_LEN_UNIT, _DEFAULT_LEN_UNIT, None, None, None), + ( + None, + _DEFAULT_LEN_UNIT, + _DEFAULT_LEN_UNIT, + None, + None, + None, + None, + ), strict=False, ) def plot( self, - profile_raster_width, - trace_raster_width, - axes=None, - fmt=None, - set_axes_equal=False, - ): # pragma: no cover + profile_raster_width: pint.Quantity, + trace_raster_width: pint.Quantity, + axes: matplotlib.axes.Axes = None, + color: Union[int, Tuple[int, int, int], Tuple[float, float, float]] = None, + label: str = None, + show_wireframe: bool = True, + ) -> matplotlib.axes.Axes: # pragma: no cover """Plot the geometry. Parameters ---------- - profile_raster_width: float, int + profile_raster_width : pint.Quantity Target distance between the individual points of a profile - trace_raster_width: float, int + trace_raster_width : pint.Quantity Target distance between the individual profiles on the trace axes : matplotlib.axes.Axes The target `matplotlib.axes.Axes` object of the plot. If 'None' is passed, a new figure will be created - fmt : str - Format string that is passed to matplotlib.pyplot.plot. - set_axes_equal : bool - Set plot axes to equal scaling (Default = False). + color : Union[int, Tuple[int, int, int], Tuple[float, float, float]] + A 24 bit integer, a triplet of integers with a value range of 0-255 + or a triplet of floats with a value range of 0.0-1.0 that represent an RGB + color + label : str + Label of the plotted geometry + show_wireframe : bool + If `True`, the mesh is plotted as wireframe. Otherwise only the raster + points are visualized. Currently, the wireframe can't be visualized if a + `VariableProfile` is used. + + Returns + ------- + matplotlib.axes.Axes : + The utilized matplotlib axes, if matplotlib was used as rendering backend """ - data = self.rasterize(profile_raster_width, trace_raster_width) - if fmt is None: - fmt = "o" - if axes is None: - fig = plt.figure() - axes = fig.gca(projection="3d", proj_type="ortho") - axes.plot(data[0], data[1], data[2], fmt) - axes.set_xlabel("x") - axes.set_ylabel("y") - axes.set_zlabel("z") - if set_axes_equal: - vs.set_axes_equal(axes) - else: - axes.plot(data[0], data[1], data[2], fmt) + data = self.spatial_data(profile_raster_width, trace_raster_width) + return data.plot( + axes=axes, color=color, label=label, show_wireframe=show_wireframe + ) + + @UREG.wraps( + None, + (None, _DEFAULT_LEN_UNIT, _DEFAULT_LEN_UNIT), + strict=False, + ) + def spatial_data( + self, profile_raster_width: pint.Quantity, trace_raster_width: pint.Quantity + ): + """Rasterize the geometry and get it as `SpatialData` instance. + + If no `VariableProfile` is used, a triangulation is added automatically. + + Parameters + ---------- + profile_raster_width : pint.Quantity + Target distance between the individual points of a profile + trace_raster_width : pint.Quantity + Target distance between the individual profiles on the trace + + Returns + ------- + SpatialData : + The rasterized geometry data + + """ + # Todo: This branch is a "dirty" fix for the fact that there is no "stackable" + # rasterization for geometries with a VariableProfile. The stacked + # rasterization is needed for the triangulation performed in + # `from_geometry_raster`. + if isinstance(self._profile, VariableProfile): + rasterization = self.rasterize(profile_raster_width, trace_raster_width) + return SpatialData(np.swapaxes(rasterization, 0, 1)) + + rasterization = self.rasterize( + profile_raster_width, trace_raster_width, stack=False + ) + return SpatialData.from_geometry_raster(rasterization) # SpatialData -------------------------------------------------------------------------- @@ -2271,12 +2331,32 @@ def __post_init__(self): raise ValueError("SpatialData triangulation must be a 2d array") @staticmethod - def from_geometry_raster(geometry: Geometry) -> "SpatialData": + def from_file(file_name: Union[str, Path]) -> "SpatialData": + """Create an instance from a file. + + Parameters + ---------- + file_name : + Name of the source file. + + Returns + ------- + SpatialData: + New `SpatialData` instance + + """ + mesh = meshio.read(file_name) + triangles = mesh.cells_dict.get("triangle") + + return SpatialData(mesh.points, triangles) + + @staticmethod + def from_geometry_raster(geometry_raster: np.ndarray) -> "SpatialData": """Triangulate rasterized Geometry Profile. Parameters ---------- - geometry : weldx.geometry.Geometry + geometry_raster : numpy.ndarray A single unstacked geometry rasterization. Returns @@ -2286,4 +2366,74 @@ def from_geometry_raster(geometry: Geometry) -> "SpatialData": """ # todo: this needs a test - return SpatialData(*ut.triangulate_geometry(geometry)) + # todo: workaround ... fix the real problem + # if not isinstance(geometry_raster, np.ndarray): + # geometry_raster = np.array(geometry_raster) + if geometry_raster[0].ndim == 2: + return SpatialData(*ut.triangulate_geometry(geometry_raster)) + + part_data = [ut.triangulate_geometry(part) for part in geometry_raster] + + total_points = [] + total_triangles = [] + for points, triangulation in part_data: + total_triangles += (triangulation + len(total_points)).tolist() + total_points += points.tolist() + return SpatialData(total_points, total_triangles) + + def plot( + self, + axes: matplotlib.axes.Axes = None, + color: Union[int, Tuple[int, int, int], Tuple[float, float, float]] = None, + label: str = None, + show_wireframe: bool = True, + ) -> matplotlib.axes.Axes: + """Plot the spatial data. + + Parameters + ---------- + axes : matplotlib.axes.Axes + The target `matplotlib.axes.Axes` object of the plot. If 'None' is passed, a + new figure will be created + color : Union[int, Tuple[int, int, int], Tuple[float, float, float]] + A 24 bit integer, a triplet of integers with a value range of 0-255 + or a triplet of floats with a value range of 0.0-1.0 that represent an RGB + color + label : str + Label of the plotted geometry + show_wireframe : bool + If `True`, the mesh is plotted as wireframe. Otherwise only the raster + points are visualized. Currently, the wireframe can't be visualized if a + `VariableProfile` is used. + + Returns + ------- + matplotlib.axes.Axes : + The utilized matplotlib axes, if matplotlib was used as rendering backend + + """ + import weldx.visualization as vs + + return vs.plot_spatial_data_matplotlib( + data=self, + axes=axes, + color=color, + label=label, + show_wireframe=show_wireframe, + ) + + def write_to_file(self, file_name: Union[str, Path]): + """Write spatial data into a file. + + The extension prescribes the output format. + + Parameters + ---------- + file_name : + Name of the file + + """ + mesh = meshio.Mesh( + points=self.coordinates.data, cells={"triangle": self.triangles} + ) + mesh.write(file_name) diff --git a/weldx/transformations/__init__.py b/weldx/transformations/__init__.py new file mode 100644 index 000000000..369429502 --- /dev/null +++ b/weldx/transformations/__init__.py @@ -0,0 +1,44 @@ +""" Contains methods and classes for coordinate transformations. + +.. currentmodule:: weldx.transformations + +.. rubric:: Classes + +.. autosummary:: + :toctree: + :template: class-template.rst + :nosignatures: + + CoordinateSystemManager + LocalCoordinateSystem + WXRotation + +.. rubric:: Functions + +.. autosummary:: + :toctree: + :nosignatures: + + rotation_matrix_x + rotation_matrix_y + rotation_matrix_z + scale_matrix + normalize + orientation_point_plane_containing_origin + orientation_point_plane + is_orthogonal + is_orthogonal_matrix + point_left_of_line + reflection_sign + vector_points_to_left_of_vector + +""" +from .cs_manager import CoordinateSystemManager +from .local_cs import LocalCoordinateSystem +from .rotation import ( + WXRotation, + rotation_matrix_x, + rotation_matrix_y, + rotation_matrix_z, +) +from .util import * diff --git a/weldx/transformations.py b/weldx/transformations/cs_manager.py similarity index 58% rename from weldx/transformations.py rename to weldx/transformations/cs_manager.py index 11482336e..76cfb3532 100644 --- a/weldx/transformations.py +++ b/weldx/transformations/cs_manager.py @@ -1,1285 +1,33 @@ """Contains methods and classes for coordinate transformations.""" +from __future__ import annotations import itertools -import math from copy import deepcopy from dataclasses import dataclass -from typing import Dict, List, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Tuple, Union -import matplotlib.pyplot as plt import networkx as nx import numpy as np import pandas as pd import pint import xarray as xr -from scipy.spatial.transform import Rotation as Rot -import weldx.utility as ut +from weldx import util from weldx.constants import WELDX_UNIT_REGISTRY as UREG from weldx.geometry import SpatialData -from weldx.visualization import ( - CoordinateSystemManagerVisualizerK3D, - plot_coordinate_system_manager_matplotlib, - plot_local_coordinate_system_matplotlib, -) +from weldx.transformations.util import build_time_index -_DEFAULT_LEN_UNIT = UREG.millimeters -_DEFAULT_ANG_UNIT = UREG.rad -__all__ = ["LocalCoordinateSystem", "CoordinateSystemManager", "WXRotation"] - -# functions ----------------------------------------------------------------------- - - -def _build_time_index( - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, pint.Quantity] = None, - time_ref: pd.Timestamp = None, -) -> pd.TimedeltaIndex: - """Build time index used for xarray objects. - - Parameters - ---------- - time: - Datetime- or Timedelta-like time index. - time_ref: - Reference timestamp for Timedelta inputs. - - Returns - ------- - pandas.TimedeltaIndex - - """ - if time is None: - # time_ref = None - return time, time_ref - - time = ut.to_pandas_time_index(time) - - if isinstance(time, pd.DatetimeIndex): - if time_ref is None: - time_ref = time[0] - time = time - time_ref - - return time, time_ref - - -@UREG.wraps(None, (_DEFAULT_ANG_UNIT), strict=False) -def rotation_matrix_x(angle): - """Create a rotation matrix that rotates around the x-axis. - - Parameters - ---------- - angle : - Rotation angle - - Returns - ------- - numpy.ndarray - Rotation matrix - - """ - return Rot.from_euler("x", angle).as_matrix() - - -@UREG.wraps(None, (_DEFAULT_ANG_UNIT), strict=False) -def rotation_matrix_y(angle): - """Create a rotation matrix that rotates around the y-axis. - - Parameters - ---------- - angle : - Rotation angle - - Returns - ------- - numpy.ndarray - Rotation matrix - - """ - return Rot.from_euler("y", angle).as_matrix() - - -@UREG.wraps(None, (_DEFAULT_ANG_UNIT), strict=False) -def rotation_matrix_z(angle) -> np.ndarray: - """Create a rotation matrix that rotates around the z-axis. - - Parameters - ---------- - angle : - Rotation angle - - Returns - ------- - numpy.ndarray - Rotation matrix - - """ - return Rot.from_euler("z", angle).as_matrix() - - -def scale_matrix(scale_x, scale_y, scale_z) -> np.ndarray: - """Return a scaling matrix. - - Parameters - ---------- - scale_x : - Scaling factor in x direction - scale_y : - Scaling factor in y direction - scale_z : - Scaling factor in z direction - - Returns - ------- - numpy.ndarray - Scaling matrix - - """ - return np.diag([scale_x, scale_y, scale_z]).astype(float) - - -def normalize(a): - """Normalize (l2 norm) an ndarray along the last dimension. - - Parameters - ---------- - a : - data in ndarray - - Returns - ------- - numpy.ndarray - Normalized ndarray - - """ - norm = np.linalg.norm(a, axis=(-1), keepdims=True) - if not np.all(norm): - raise ValueError("Length 0 encountered during normalization.") - return a / norm - - -def orientation_point_plane_containing_origin(point, p_a, p_b): - """Determine a points orientation relative to a plane containing the origin. - - The side is defined by the winding order of the triangle 'origin - A - - B'. When looking at it from the left-hand side, the ordering is clockwise - and counter-clockwise when looking from the right-hand side. - - The function returns 1 if the point lies left of the plane, -1 if it is - on the right and 0 if it lies on the plane. - - Note, that this function is not appropriate to check if a point lies on - a plane since it has no tolerance to compensate for numerical errors. - - Additional note: The points A and B can also been considered as two - vectors spanning the plane. - - Parameters - ---------- - point : - Point - p_a : - Second point of the triangle 'origin - A - B'. - p_b : - Third point of the triangle 'origin - A - B'. - - Returns - ------- - int - 1, -1 or 0 (see description) - - """ - if ( - math.isclose(np.linalg.norm(p_a), 0) - or math.isclose(np.linalg.norm(p_b), 0) - or math.isclose(np.linalg.norm(p_b - p_a), 0) - ): - raise ValueError("One or more points describing the plane are identical.") - - return np.sign(np.linalg.det([p_a, p_b, point])) - - -def orientation_point_plane(point, p_a, p_b, p_c): - """Determine a points orientation relative to an arbitrary plane. - - The side is defined by the winding order of the triangle 'A - B - C'. - When looking at it from the left-hand side, the ordering is clockwise - and counter-clockwise when looking from the right-hand side. - - The function returns 1 if the point lies left of the plane, -1 if it is - on the right and 0 if it lies on the plane. - - Note, that this function is not appropriate to check if a point lies on - a plane since it has no tolerance to compensate for numerical errors. - - Parameters - ---------- - point : - Point - p_a : - First point of the triangle 'A - B - C'. - p_b : - Second point of the triangle 'A - B - C'. - p_c : - Third point of the triangle 'A - B - C'. - - Returns - ------- - int - 1, -1 or 0 (see description) - - """ - vec_a_b = p_b - p_a - vec_a_c = p_c - p_a - vec_a_point = point - p_a - return orientation_point_plane_containing_origin(vec_a_point, vec_a_b, vec_a_c) - - -def is_orthogonal(vec_u, vec_v, tolerance=1e-9): - """Check if vectors are orthogonal. - - Parameters - ---------- - vec_u : - First vector - vec_v : - Second vector - tolerance : - Numerical tolerance (Default value = 1e-9) - - Returns - ------- - bool - True or False - - """ - if math.isclose(np.dot(vec_u, vec_u), 0) or math.isclose(np.dot(vec_v, vec_v), 0): - raise ValueError("One or both vectors have zero length.") - - return math.isclose(np.dot(vec_u, vec_v), 0, abs_tol=tolerance) - - -def is_orthogonal_matrix(a: np.ndarray, atol=1e-9) -> bool: - """Check if ndarray is orthogonal matrix in the last two dimensions. - - Parameters - ---------- - a : - Matrix to check - atol : - atol to pass onto np.allclose (Default value = 1e-9) - - Returns - ------- - bool - True if last 2 dimensions of a are orthogonal - - """ - return np.allclose(np.matmul(a, a.swapaxes(-1, -2)), np.eye(a.shape[-1]), atol=atol) - - -def point_left_of_line(point, line_start, line_end): - """Determine if a point lies left of a line. - - Returns 1 if the point is left of the line and -1 if it is to the right. - If the point is located on the line, this function returns 0. - - Parameters - ---------- - point : - Point - line_start : - Starting point of the line - line_end : - End point of the line - - Returns - ------- - int - 1,-1 or 0 (see description) - - """ - vec_line_start_end = line_end - line_start - vec_line_start_point = point - line_start - return vector_points_to_left_of_vector(vec_line_start_point, vec_line_start_end) - - -def reflection_sign(matrix): - """Get a sign indicating if the transformation is a reflection. - - Returns -1 if the transformation contains a reflection and 1 if not. - - Parameters - ---------- - matrix : - Transformation matrix - - Returns - ------- - int - 1 or -1 (see description) - - """ - sign = int(np.sign(np.linalg.det(matrix))) - - if sign == 0: - raise ValueError("Invalid transformation") - - return sign - - -def vector_points_to_left_of_vector(vector, vector_reference): - """Determine if a vector points to the left of another vector. - - Returns 1 if the vector points to the left of the reference vector and - -1 if it points to the right. In case both vectors point into the same - or the opposite directions, this function returns 0. - - Parameters - ---------- - vector : - Vector - vector_reference : - Reference vector - - Returns - ------- - int - 1,-1 or 0 (see description) - - """ - return int(np.sign(np.linalg.det([vector_reference, vector]))) - - -# WXRotation --------------------------------------------------------------------------- - - -class WXRotation(Rot): - """Wrapper for creating meta-tagged Scipy.Rotation objects.""" - - @classmethod - def from_quat(cls, quat: np.ndarray) -> "WXRotation": - """Initialize from quaternions. - - scipy.spatial.transform.Rotation docs for details. - """ - rot = super().from_quat(quat) - setattr(rot, "wx_meta", {"constructor": "from_quat"}) - return rot - - @classmethod - def from_matrix(cls, matrix: np.ndarray) -> "WXRotation": - """Initialize from matrix. - - scipy.spatial.transform.Rotation docs for details. - """ - rot = super().from_matrix(matrix) - setattr(rot, "wx_meta", {"constructor": "from_matrix"}) - return rot - - @classmethod - def from_rotvec(cls, rotvec: np.ndarray) -> "WXRotation": - """Initialize from rotation vector. - - scipy.spatial.transform.Rotation docs for details. - """ - rot = super().from_rotvec(rotvec) - setattr(rot, "wx_meta", {"constructor": "from_rotvec"}) - return rot - - @classmethod - def from_euler(cls, seq: str, angles, degrees: bool = False) -> "WXRotation": - """Initialize from euler angles. - - scipy.spatial.transform.Rotation docs for details. - """ - rot = super().from_euler(seq=seq, angles=angles, degrees=degrees) - setattr( - rot, - "wx_meta", - {"constructor": "from_euler", "seq": seq, "degrees": degrees}, - ) - return rot - - -# LocalCoordinateSystem ---------------------------------------------------------------- - - -class LocalCoordinateSystem: - """Defines a local cartesian coordinate system in 3d. - - Notes - ----- - Learn how to use this class by reading the - :doc:`Tutorial <../tutorials/transformations_01_coordinate_systems>`. - - """ - - def __init__( - self, - orientation: Union[xr.DataArray, np.ndarray, List[List], Rot] = None, - coordinates: Union[xr.DataArray, np.ndarray, List] = None, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, pint.Quantity] = None, - time_ref: pd.Timestamp = None, - construction_checks: bool = True, - ): - """Construct a cartesian coordinate system. - - Parameters - ---------- - orientation : - Matrix of 3 orthogonal column vectors which represent - the coordinate systems orientation. Keep in mind, that the columns of the - corresponding orientation matrix is equal to the normalized orientation - vectors. So each orthogonal transformation matrix can also be - provided as orientation. - Passing a scipy.spatial.transform.Rotation object is also supported. - coordinates : - Coordinates of the origin - time : - Time data for time dependent coordinate systems - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - construction_checks : - If 'True', the validity of the data will be verified - - Returns - ------- - LocalCoordinateSystem - Cartesian coordinate system - - """ - if orientation is None: - orientation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) - if coordinates is None: - coordinates = np.array([0, 0, 0]) - - time, time_ref = _build_time_index(time, time_ref) - orientation = self._build_orientation(orientation, time) - coordinates = self._build_coordinates(coordinates, time) - - if construction_checks: - ut.xr_check_coords( - coordinates, - dict( - c={"values": ["x", "y", "z"]}, - time={"dtype": "timedelta64", "optional": True}, - ), - ) - - ut.xr_check_coords( - orientation, - dict( - c={"values": ["x", "y", "z"]}, - v={"values": [0, 1, 2]}, - time={"dtype": "timedelta64", "optional": True}, - ), - ) - - orientation = xr.apply_ufunc( - normalize, - orientation, - input_core_dims=[["c"]], - output_core_dims=[["c"]], - ) - - # vectorize test if orthogonal - if not ut.xr_is_orthogonal_matrix(orientation, dims=["c", "v"]): - raise ValueError("Orientation vectors must be orthogonal") - - # unify time axis - if ( - ("time" in orientation.coords) - and ("time" in coordinates.coords) - and (not np.all(orientation.time.data == coordinates.time.data)) - ): - time_union = ut.get_time_union([orientation, coordinates]) - orientation = ut.xr_interp_orientation_in_time(orientation, time_union) - coordinates = ut.xr_interp_coordinates_in_time(coordinates, time_union) - - coordinates.name = "coordinates" - orientation.name = "orientation" - - self._dataset = xr.merge([coordinates, orientation], join="exact") - if "time" in self._dataset and time_ref is not None: - self._dataset.weldx.time_ref = time_ref - - def __repr__(self): - """Give __repr_ output in xarray format.""" - return self._dataset.__repr__().replace( - " "LocalCoordinateSystem": - """Add 2 coordinate systems. - - Generates a new coordinate system by treating the left-hand side - coordinate system as being defined in the right hand-side coordinate - system. - The transformations from the base coordinate system to the new - coordinate system are equivalent to the combination of the - transformations from both added coordinate systems: - - R_n = R_r * R_l - T_n = R_r * T_l + T_r - - R_r and T_r are rotation matrix and translation vector of the - right-hand side coordinate system, R_l and T_l of the left-hand side - coordinate system and R_n and T_n of the resulting coordinate system. - - If the left-hand side system has a time component, the data of the right-hand - side system will be interpolated to the same times, before the previously shown - operations are performed per point in time. In case, that the left-hand side - system has no time component, but the right-hand side does, the resulting system - has the same time components as the right-hand side system. - - Parameters - ---------- - rhs_cs : - Right-hand side coordinate system - - Returns - ------- - LocalCoordinateSystem - Resulting coordinate system. - - """ - lhs_cs = self - if ( - lhs_cs.reference_time != rhs_cs.reference_time - and lhs_cs.has_reference_time - and rhs_cs.has_reference_time - ): - if lhs_cs.reference_time < rhs_cs.reference_time: - time_ref = lhs_cs.reference_time - rhs_cs = deepcopy(rhs_cs) - rhs_cs.reset_reference_time(time_ref) - else: - time_ref = rhs_cs.reference_time - lhs_cs = deepcopy(lhs_cs) - lhs_cs.reset_reference_time(time_ref) - elif not lhs_cs.has_reference_time: - time_ref = rhs_cs.reference_time - else: - time_ref = lhs_cs.reference_time - - rhs_cs = rhs_cs.interp_time(lhs_cs.time, time_ref) - - orientation = ut.xr_matmul( - rhs_cs.orientation, lhs_cs.orientation, dims_a=["c", "v"] - ) - coordinates = ( - ut.xr_matmul(rhs_cs.orientation, lhs_cs.coordinates, ["c", "v"], ["c"]) - + rhs_cs.coordinates - ) - return LocalCoordinateSystem(orientation, coordinates, time_ref=time_ref) - - def __sub__(self, rhs_cs: "LocalCoordinateSystem") -> "LocalCoordinateSystem": - """Subtract 2 coordinate systems. - - Generates a new coordinate system from two local coordinate systems - with the same reference coordinate system. The resulting system is - equivalent to the left-hand side system but with the right-hand side - as reference coordinate system. - This is achieved by the following transformations: - - R_n = R_r^(-1) * R_l - T_n = R_r^(-1) * (T_l - T_r) - - R_r and T_r are rotation matrix and translation vector of the - right-hand side coordinate system, R_l and T_l of the left-hand side - coordinate system and R_n and T_n of the resulting coordinate system. - - If the left-hand side system has a time component, the data of the right-hand - side system will be interpolated to the same times, before the previously shown - operations are performed per point in time. In case, that the left-hand side - system has no time component, but the right-hand side does, the resulting system - has the same time components as the right-hand side system. - - Parameters - ---------- - rhs_cs : - Right-hand side coordinate system - - Returns - ------- - LocalCoordinateSystem - Resulting coordinate system. - - """ - rhs_cs_inv = rhs_cs.invert() - return self + rhs_cs_inv - - def __eq__(self: "LocalCoordinateSystem", other: "LocalCoordinateSystem") -> bool: - """Check equality of LocalCoordinateSystems.""" - return ( - self.orientation.identical(other.orientation) - and self.coordinates.identical(other.coordinates) - and self.reference_time == other.reference_time - ) - - @staticmethod - def _build_orientation( - orientation: Union[xr.DataArray, np.ndarray, List[List], Rot], - time: pd.DatetimeIndex = None, - ): - """Create xarray orientation from different formats and time-inputs. - - Parameters - ---------- - orientation : - Orientation object or data. - time : - Valid time index formatted with `_build_time_index`. - - Returns - ------- - xarray.DataArray - - """ - if not isinstance(orientation, xr.DataArray): - time_orientation = None - if isinstance(orientation, Rot): - orientation = orientation.as_matrix() - elif not isinstance(orientation, np.ndarray): - orientation = np.array(orientation) - - if orientation.ndim == 3: - time_orientation = time - orientation = ut.xr_3d_matrix(orientation, time_orientation) - - # make sure we have correct "time" format - orientation = orientation.weldx.time_ref_restore() - - return orientation - - @staticmethod - def _build_coordinates(coordinates, time: pd.DatetimeIndex = None): - """Create xarray coordinates from different formats and time-inputs. - - Parameters - ---------- - coordinates: - Coordinates data. - time: - Valid time index formatted with `_build_time_index`. - - Returns - ------- - xarray.DataArray - - """ - if not isinstance(coordinates, xr.DataArray): - time_coordinates = None - if not isinstance(coordinates, (np.ndarray, pint.Quantity)): - coordinates = np.array(coordinates) - if coordinates.ndim == 2: - time_coordinates = time - coordinates = ut.xr_3d_vector(coordinates, time_coordinates) - - # make sure we have correct "time" format - coordinates = coordinates.weldx.time_ref_restore() - - return coordinates - - @classmethod - def from_euler( - cls, sequence, angles, degrees=False, coordinates=None, time=None, time_ref=None - ) -> "LocalCoordinateSystem": - """Construct a local coordinate system from an euler sequence. - - This function uses scipy.spatial.transform.Rotation.from_euler method to define - the coordinate systems orientation. Take a look at it's documentation, if some - information is missing here. The related parameter docs are a copy of the scipy - documentation. - - Parameters - ---------- - sequence : - Specifies sequence of axes for rotations. Up to 3 characters - belonging to the set {‘X’, ‘Y’, ‘Z’} for intrinsic rotations, - or {‘x’, ‘y’, ‘z’} for extrinsic rotations. - Extrinsic and intrinsic rotations cannot be mixed in one function call. - angles : - Euler angles specified in radians (degrees is False) or degrees - (degrees is True). For a single character seq, angles can be: - - a single value - - array_like with shape (N,), where each angle[i] corresponds to a single - rotation - - array_like with shape (N, 1), where each angle[i, 0] corresponds to a - single rotation - For 2- and 3-character wide seq, angles can be: - - array_like with shape (W,) where W is the width of seq, which corresponds - to a single rotation with W axes - - array_like with shape (N, W) where each angle[i] corresponds to a sequence - of Euler angles describing a single rotation - degrees : - If True, then the given angles are assumed to be in degrees. - Default is False. - coordinates : - Coordinates of the origin (Default value = None) - time : - Time data for time dependent coordinate systems (Default value = None) - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - - Returns - ------- - LocalCoordinateSystem - Local coordinate system - - """ - orientation = Rot.from_euler(sequence, angles, degrees) - return cls(orientation, coordinates=coordinates, time=time, time_ref=time_ref) - - @classmethod - def from_orientation( - cls, orientation, coordinates=None, time=None, time_ref=None - ) -> "LocalCoordinateSystem": - """Construct a local coordinate system from orientation matrix. - - Parameters - ---------- - orientation : - Orthogonal transformation matrix - coordinates : - Coordinates of the origin (Default value = None) - time : - Time data for time dependent coordinate systems (Default value = None) - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - - Returns - ------- - LocalCoordinateSystem - Local coordinate system - - """ - return cls(orientation, coordinates=coordinates, time=time, time_ref=time_ref) - - @classmethod - def from_xyz( - cls, vec_x, vec_y, vec_z, coordinates=None, time=None, time_ref=None - ) -> "LocalCoordinateSystem": - """Construct a local coordinate system from 3 vectors defining the orientation. - - Parameters - ---------- - vec_x : - Vector defining the x-axis - vec_y : - Vector defining the y-axis - vec_z : - Vector defining the z-axis - coordinates : - Coordinates of the origin (Default value = None) - time : - Time data for time dependent coordinate systems (Default value = None) - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - - Returns - ------- - LocalCoordinateSystem - Local coordinate system - - """ - vec_x = ut.to_float_array(vec_x) - vec_y = ut.to_float_array(vec_y) - vec_z = ut.to_float_array(vec_z) - - orientation = np.concatenate((vec_x, vec_y, vec_z), axis=vec_x.ndim - 1) - orientation = np.reshape(orientation, (*vec_x.shape, 3)) - orientation = orientation.swapaxes(orientation.ndim - 1, orientation.ndim - 2) - return cls(orientation, coordinates=coordinates, time=time, time_ref=time_ref) - - @classmethod - def from_xy_and_orientation( - cls, - vec_x, - vec_y, - positive_orientation=True, - coordinates=None, - time=None, - time_ref=None, - ) -> "LocalCoordinateSystem": - """Construct a coordinate system from 2 vectors and an orientation. - - Parameters - ---------- - vec_x : - Vector defining the x-axis - vec_y : - Vector defining the y-axis - positive_orientation : - Set to True if the orientation should - be positive and to False if not (Default value = True) - coordinates : - Coordinates of the origin (Default value = None) - time : - Time data for time dependent coordinate systems (Default value = None) - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - - Returns - ------- - LocalCoordinateSystem - Local coordinate system - - """ - vec_z = cls._calculate_orthogonal_axis(vec_x, vec_y) * cls._sign_orientation( - positive_orientation - ) - - return cls.from_xyz(vec_x, vec_y, vec_z, coordinates, time, time_ref=time_ref) - - @classmethod - def from_yz_and_orientation( - cls, - vec_y, - vec_z, - positive_orientation=True, - coordinates=None, - time=None, - time_ref=None, - ) -> "LocalCoordinateSystem": - """Construct a coordinate system from 2 vectors and an orientation. - - Parameters - ---------- - vec_y : - Vector defining the y-axis - vec_z : - Vector defining the z-axis - positive_orientation : - Set to True if the orientation should - be positive and to False if not (Default value = True) - coordinates : - Coordinates of the origin (Default value = None) - time : - Time data for time dependent coordinate systems (Default value = None) - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - - Returns - ------- - LocalCoordinateSystem - Local coordinate system - - """ - vec_x = cls._calculate_orthogonal_axis(vec_y, vec_z) * cls._sign_orientation( - positive_orientation - ) - - return cls.from_xyz(vec_x, vec_y, vec_z, coordinates, time, time_ref=time_ref) - - @classmethod - def from_xz_and_orientation( - cls, - vec_x, - vec_z, - positive_orientation=True, - coordinates=None, - time=None, - time_ref=None, - ) -> "LocalCoordinateSystem": - """Construct a coordinate system from 2 vectors and an orientation. - - Parameters - ---------- - vec_x : - Vector defining the x-axis - vec_z : - Vector defining the z-axis - positive_orientation : - Set to True if the orientation should - be positive and to False if not (Default value = True) - coordinates : - Coordinates of the origin (Default value = None) - time : - Time data for time dependent coordinate systems (Default value = None) - time_ref : - Reference Timestamp to use if time is Timedelta or pint.Quantity. - - Returns - ------- - LocalCoordinateSystem - Local coordinate system - - """ - vec_y = cls._calculate_orthogonal_axis(vec_z, vec_x) * cls._sign_orientation( - positive_orientation - ) - - return cls.from_xyz(vec_x, vec_y, vec_z, coordinates, time, time_ref=time_ref) - - @staticmethod - def _sign_orientation(positive_orientation): - """Get -1 or 1 depending on the coordinate systems orientation. - - Parameters - ---------- - positive_orientation : - Set to True if the orientation should - be positive and to False if not - - Returns - ------- - int - 1 if the coordinate system has positive orientation, - -1 otherwise - - """ - if positive_orientation: - return 1 - return -1 - - @staticmethod - def _calculate_orthogonal_axis(a_0, a_1): - """Calculate an axis which is orthogonal to two other axes. - - The calculated axis has a positive orientation towards the other 2 - axes. - - Parameters - ---------- - a_0 : - First axis - a_1 : - Second axis - - Returns - ------- - numpy.ndarray - Orthogonal axis - - """ - return np.cross(a_0, a_1) - - @property - def orientation(self) -> xr.DataArray: - """Get the coordinate systems orientation matrix. - - Returns - ------- - xarray.DataArray - Orientation matrix - - """ - return self.dataset.orientation - - @property - def coordinates(self) -> xr.DataArray: - """Get the coordinate systems coordinates. - - Returns - ------- - xarray.DataArray - Coordinates of the coordinate system - - """ - return self.dataset.coordinates - - @property - def is_time_dependent(self) -> bool: - """Return `True` if the coordinate system is time dependent. - - Returns - ------- - bool : - `True` if the coordinate system is time dependent, `False` otherwise. - - """ - return self.time is not None - - @property - def has_reference_time(self) -> bool: - """Return `True` if the coordinate system has a reference time. - - Returns - ------- - bool : - `True` if the coordinate system has a reference time, `False` otherwise. - - """ - return self.reference_time is not None - - @property - def reference_time(self) -> Union[pd.Timestamp, None]: - """Get the coordinate systems reference time. - - Returns - ------- - pandas.Timestamp: - The coordinate systems reference time - - """ - return self._dataset.weldx.time_ref - - @property - def datetimeindex(self) -> Union[pd.DatetimeIndex, None]: - """Get the time as 'pandas.DatetimeIndex'. - - If the coordinate system has no reference time, 'None' is returned. - - Returns - ------- - Union[pandas.DatetimeIndex, None]: - The coordinate systems time as 'pandas.DatetimeIndex' - - """ - if not self.has_reference_time: - return None - return self.time + self.reference_time - - @property - def time(self) -> Union[pd.TimedeltaIndex, None]: - """Get the time union of the local coordinate system (None if system is static). - - Returns - ------- - pandas.TimedeltaIndex - DateTimeIndex-like time union - - """ - if "time" in self._dataset.coords: - return self._dataset.time - return None - - @property - def time_quantity(self) -> pint.Quantity: - """Get the time as 'pint.Quantity'. - - Returns - ------- - pint.Quantity: - The coordinate systems time as 'pint.Quantity' - - """ - return ut.pandas_time_delta_to_quantity(self.time) - - @property - def dataset(self) -> xr.Dataset: - """Get the underlying xarray.Dataset with ordered dimensions. - - Returns - ------- - xarray.Dataset - xarray Dataset with coordinates and orientation as DataVariables. - - """ - return self._dataset.transpose(..., "c", "v") - - @property - def is_unity_translation(self) -> bool: - """Return true if the LCS has a zero translation/coordinates value.""" - if self.coordinates.shape[-1] == 3 and np.allclose( - self.coordinates, np.zeros(3) - ): - return True - return False - - @property - def is_unity_rotation(self) -> bool: - """Return true if the LCS represents a unity rotation/orientations value.""" - if self.orientation.shape[-2:] == (3, 3) and np.allclose( - self.orientation, np.eye(3) - ): - return True - return False - - def as_euler( - self, seq: str = "xyz", degrees: bool = False - ) -> np.ndarray: # pragma: no cover - """Return Euler angle representation of the coordinate system orientation. - - Parameters - ---------- - seq : - Euler rotation sequence as described in - https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial - .transform.Rotation.as_euler.html - degrees : - Returned angles are in degrees if True, else they are in radians. - Default is False. - - Returns - ------- - numpy.ndarray - Array of euler angles. - - """ - return self.as_rotation().as_euler(seq=seq, degrees=degrees) - - def as_rotation(self) -> Rot: # pragma: no cover - """Get a scipy.Rotation object from the coordinate system orientation. - - Returns - ------- - scipy.spatial.transform.Rotation - Scipy rotation object representing the orientation. - - """ - return Rot.from_matrix(self.orientation.values) - - def interp_time( - self, - time: Union[ - pd.DatetimeIndex, - pd.TimedeltaIndex, - List[pd.Timestamp], - "LocalCoordinateSystem", - None, - ], - time_ref: Union[pd.Timestamp, None] = None, - ) -> "LocalCoordinateSystem": - """Interpolates the data in time. - - Parameters - ---------- - time : - Series of times. - If passing "None" no interpolation will be performed. - time_ref: - The reference timestamp - - Returns - ------- - LocalCoordinateSystem - Coordinate system with interpolated data - - """ - if (not self.is_time_dependent) or (time is None): - return self - - # use LCS reference time if none provided - if isinstance(time, LocalCoordinateSystem) and time_ref is None: - time_ref = time.reference_time - time = ut.to_pandas_time_index(time) - - if self.has_reference_time != ( - time_ref is not None or isinstance(time, pd.DatetimeIndex) - ): - raise TypeError( - "Only 1 reference time provided for time dependent coordinate " - "system. Either the reference time of the coordinate system or the " - "one passed to the function is 'None'. Only cases where the " - "reference times are both 'None' or both contain a timestamp are " - "allowed. Also check that the reference time has the correct type." - ) - - if self.has_reference_time and (not isinstance(time, pd.DatetimeIndex)): - time = time + time_ref - - orientation = ut.xr_interp_orientation_in_time(self.orientation, time) - coordinates = ut.xr_interp_coordinates_in_time(self.coordinates, time) - - return LocalCoordinateSystem(orientation, coordinates, time_ref=time_ref) - - def invert(self) -> "LocalCoordinateSystem": - """Get a local coordinate system defining the parent in the child system. - - Inverse is defined as orientation_new=orientation.T, - coordinates_new=orientation.T*(-coordinates) - - Returns - ------- - LocalCoordinateSystem - Inverted coordinate system. - - """ - orientation = ut.xr_transpose_matrix_data(self.orientation, dim1="c", dim2="v") - coordinates = ut.xr_matmul( - self.orientation, - -self.coordinates, - dims_a=["c", "v"], - dims_b=["c"], - trans_a=True, - ) - return LocalCoordinateSystem( - orientation, coordinates, self.time, self.reference_time - ) - - def plot( - self, - axes: plt.Axes.axes = None, - color: str = None, - label: str = None, - time: Union[ - pd.DatetimeIndex, - pd.TimedeltaIndex, - List[pd.Timestamp], - "LocalCoordinateSystem", - ] = None, - time_ref: pd.Timestamp = None, - time_index: int = None, - show_origin: bool = True, - show_trace: bool = True, - show_vectors: bool = True, - ): # pragma: no cover - """Plot the coordinate system. - - Parameters - ---------- - axes : matplotlib.axes.Axes - The target matplotlib axes object that should be drawn to. If `None` is - provided, a new one will be created. - color : str - The color of the coordinate system. The string must be a valid matplotlib - color format. See: - https://matplotlib.org/3.1.0/api/colors_api.html#module-matplotlib.colors - label : str - The name of the coordinate system - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem - The time steps that should be plotted. Missing time steps in the data will - be interpolated. - time_ref : pandas.Timestamp - A reference timestamp that can be provided if the ``time`` parameter is a - `pandas.TimedeltaIndex` - time_index: int - If the coordinate system is time dependent, this parameter can be used to - to select a specific key frame by its index. - show_origin: bool - If `True`, a small dot with the assigned color will mark the coordinate - systems' origin. - show_trace : bool - If `True`, the trace of time dependent coordinate systems is plotted. - show_vectors : bool - If `True`, the coordinate cross of time dependent coordinate systems is - plotted. - - """ - plot_local_coordinate_system_matplotlib( - self, - axes=axes, - color=color, - label=label, - time=time, - time_ref=time_ref, - time_index=time_index, - show_origin=show_origin, - show_trace=show_trace, - show_vectors=show_vectors, - ) - - def reset_reference_time(self, time_ref_new: pd.Timestamp): - """Reset the reference time of the coordinate system. - - The time values of the coordinate system are adjusted to the new reference time. - If no reference time has been set before, the time values will remain - unmodified. This assumes that the current time delta values are already - referring to the new reference time. - - Parameters - ---------- - time_ref_new: pandas.Timestamp - The new reference time +from .local_cs import LocalCoordinateSystem - """ - self._dataset.weldx.time_ref = time_ref_new +# only import heavy-weight packages on type checking +if TYPE_CHECKING: + import matplotlib.axes + from scipy.spatial.transform import Rotation as Rot +_DEFAULT_LEN_UNIT = UREG.millimeters +_DEFAULT_ANG_UNIT = UREG.rad -# CoordinateSystemManager -------------------------------------------------------------- +__all__ = ["CoordinateSystemManager"] class CoordinateSystemManager: @@ -1311,12 +59,12 @@ def __init__( Parameters ---------- - root_coordinate_system_name : str + root_coordinate_system_name Name of the root coordinate system. - coordinate_system_manager_name : str - Name of the coordinate system manager. If 'None' is passed, a default name + coordinate_system_manager_name + Name of the coordinate system manager. If `None` is passed, a default name is chosen. - time_ref : pandas.Timestamp + time_ref A reference timestamp. If it is defined, all time dependent information returned by the CoordinateSystemManager will refer to it by default. @@ -1325,6 +73,8 @@ def __init__( CoordinateSystemManager """ + from networkx import DiGraph + if coordinate_system_manager_name is None: coordinate_system_manager_name = self._generate_default_name() self._name = coordinate_system_manager_name @@ -1337,7 +87,7 @@ def __init__( self._sub_system_data_dict = {} - self._graph = nx.DiGraph() + self._graph = DiGraph() self._add_coordinate_system_node(root_coordinate_system_name) @classmethod @@ -1355,12 +105,12 @@ def _from_subsystem_graph( Parameters ---------- - root_coordinate_system_name : str + root_coordinate_system_name Name of the root coordinate system. - coordinate_system_manager_name : str - Name of the coordinate system manager. If 'None' is passed, a default name + coordinate_system_manager_name + Name of the coordinate system manager. If `None` is passed, a default name is chosen. - time_ref : pandas.Timestamp + time_ref A reference timestamp. If it is defined, all time dependent information returned by the CoordinateSystemManager will refer to it by default. graph: @@ -1438,16 +188,18 @@ def __eq__(self: "CoordinateSystemManager", other: "CoordinateSystemManager"): return True @property - def lcs(self) -> List["LocalCoordinateSystem"]: - """Get a list of all attached `LocalCoordinateSystem` instances. + def lcs(self) -> List[LocalCoordinateSystem]: + """Get a list of all attached `~weldx.transformations.LocalCoordinateSystem` \ + instances. Only the defined systems and not the automatically generated inverse systems are included. Returns ------- - List[LocalCoordinateSystem] : - List of all attached `LocalCoordinateSystem` instances. + List[~weldx.transformations.LocalCoordinateSystem] : + List of all attached `~weldx.transformations.LocalCoordinateSystem` + instances. """ return [ @@ -1457,13 +209,15 @@ def lcs(self) -> List["LocalCoordinateSystem"]: ] @property - def lcs_time_dependent(self) -> List["LocalCoordinateSystem"]: - """Get a list of all attached time dependent `LocalCoordinateSystem` instances. + def lcs_time_dependent(self) -> List[LocalCoordinateSystem]: + """Get a list of all attached time dependent \ + `~weldx.transformations.LocalCoordinateSystem` instances. Returns ------- - List[LocalCoordinateSystem] : - List of all attached time dependent `LocalCoordinateSystem` instances + List[~weldx.transformations.LocalCoordinateSystem] : + List of all attached time dependent + `~weldx.transformations.LocalCoordinateSystem` instances """ return [lcs for lcs in self.lcs if lcs.is_time_dependent] @@ -1474,7 +228,7 @@ def uses_absolute_times(self) -> bool: Returns ------- - bool :[ + bool : `True` if the `CoordinateSystemManager` or one of its attached coordinate systems possess a reference time. `False` otherwise @@ -1560,7 +314,7 @@ def _compare_subsystems_equal(cls, data: Dict, other: Dict) -> bool: Returns ------- bool: - 'True' if both dictionaries are identical, 'False' otherwise. + `True` if both dictionaries are identical, `False` otherwise. """ if len(data) != len(other): @@ -1597,7 +351,8 @@ def _generate_default_name() -> str: Default name. """ - return f"Coordinate system manager {next(CoordinateSystemManager._id_gen)}" + id_ = next(CoordinateSystemManager._id_gen) # skipcq: PTC-W0063 + return f"Coordinate system manager {id_}" @property def _extended_sub_system_data(self) -> Dict: @@ -1795,7 +550,7 @@ def subsystem_names(self) -> List[str]: List with subsystem names. """ - return self._sub_system_data_dict.keys() + return list(self._sub_system_data_dict.keys()) def add_cs( self, @@ -1823,16 +578,17 @@ def add_cs( Parameters ---------- - coordinate_system_name : str + coordinate_system_name Name of the new coordinate system. - reference_system_name : str + reference_system_name Name of the parent system. This must have been already added. - lcs : LocalCoordinateSystem + lcs An instance of `~weldx.transformations.LocalCoordinateSystem` that describes how the new coordinate system is oriented in its parent system. - lsc_child_in_parent: bool - If set to `True`, the passed `LocalCoordinateSystem` instance describes + lsc_child_in_parent + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. @@ -1968,7 +724,8 @@ def create_cs( ): """Create a coordinate system and add it to the coordinate system manager. - This function uses the '__init__' method of the 'LocalCoordinateSystem' class. + This function uses the ``__init__`` method of the + `~weldx.transformations.LocalCoordinateSystem` class. Parameters ---------- @@ -1989,9 +746,10 @@ def create_cs( Time data for time dependent coordinate systems. time_ref : Reference time for time dependent coordinate systems - lsc_child_in_parent: - If set to 'True', the passed 'LocalCoordinateSystem' instance describes - the new system orientation towards is parent. If 'False', it describes + lsc_child_in_parent : + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes + the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. """ @@ -2013,8 +771,8 @@ def create_cs_from_euler( ): """Create a coordinate system and add it to the coordinate system manager. - This function uses the 'from_euler' method of the - 'LocalCoordinateSystem' class. + This function uses the `~weldx.transformations.LocalCoordinateSystem.from_euler` + method of the `~weldx.transformations.LocalCoordinateSystem` class. Parameters ---------- @@ -2024,8 +782,8 @@ def create_cs_from_euler( Name of the parent system. This must have been already added. sequence : Specifies sequence of axes for rotations. Up to 3 characters - belonging to the set {‘X’, ‘Y’, ‘Z’} for intrinsic rotations, - or {‘x’, ‘y’, ‘z’} for extrinsic rotations. + belonging to the set {``X``, ``Y``, ``Z``} for intrinsic rotations, + or {``x``, ``y``, ``z``} for extrinsic rotations. Extrinsic and intrinsic rotations cannot be mixed in one function call. angles : Euler angles specified in radians (degrees is False) or degrees @@ -2047,9 +805,10 @@ def create_cs_from_euler( Coordinates of the origin. time : Time data for time dependent coordinate systems. - lsc_child_in_parent: - If set to 'True', the passed 'LocalCoordinateSystem' instance describes - the new system orientation towards is parent. If 'False', it describes + lsc_child_in_parent : + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes + the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. @@ -2074,8 +833,8 @@ def create_cs_from_xyz( ): """Create a coordinate system and add it to the coordinate system manager. - This function uses the 'from_xyz' method of the - 'LocalCoordinateSystem' class. + This function uses the `~weldx.transformations.LocalCoordinateSystem.from_xyz` + method of the `~weldx.transformations.LocalCoordinateSystem` class. Parameters ---------- @@ -2093,9 +852,10 @@ def create_cs_from_xyz( Coordinates of the origin. time : Time data for time dependent coordinate systems. - lsc_child_in_parent: - If set to 'True', the passed 'LocalCoordinateSystem' instance describes - the new system orientation towards is parent. If 'False', it describes + lsc_child_in_parent : + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes + the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. """ @@ -2117,8 +877,9 @@ def create_cs_from_xy_and_orientation( ): """Create a coordinate system and add it to the coordinate system manager. - This function uses the 'from_xy_and_orientation' method of the - 'LocalCoordinateSystem' class. + This function uses the + `~weldx.transformations.LocalCoordinateSystem.from_xy_and_orientation` method + of the `~weldx.transformations.LocalCoordinateSystem` class. Parameters ---------- @@ -2137,9 +898,10 @@ def create_cs_from_xy_and_orientation( Coordinates of the origin. time : Time data for time dependent coordinate systems. - lsc_child_in_parent: - If set to 'True', the passed 'LocalCoordinateSystem' instance describes - the new system orientation towards is parent. If 'False', it describes + lsc_child_in_parent : + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes + the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. """ @@ -2163,8 +925,9 @@ def create_cs_from_xz_and_orientation( ): """Create a coordinate system and add it to the coordinate system manager. - This function uses the 'from_xz_and_orientation' method of the - 'LocalCoordinateSystem' class. + This function uses the + `~weldx.transformations.LocalCoordinateSystem.from_xz_and_orientation` method + of the `~weldx.transformations.LocalCoordinateSystem` class. Parameters ---------- @@ -2183,9 +946,10 @@ def create_cs_from_xz_and_orientation( Coordinates of the origin. time : Time data for time dependent coordinate systems. - lsc_child_in_parent: - If set to 'True', the passed 'LocalCoordinateSystem' instance describes - the new system orientation towards is parent. If 'False', it describes + lsc_child_in_parent : + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes + the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. """ @@ -2209,8 +973,9 @@ def create_cs_from_yz_and_orientation( ): """Create a coordinate system and add it to the coordinate system manager. - This function uses the 'from_yz_and_orientation' method of the - 'LocalCoordinateSystem' class. + This function uses the + `~weldx.transformations.LocalCoordinateSystem.from_yz_and_orientation` method + of the `~weldx.transformations.LocalCoordinateSystem` class. Parameters ---------- @@ -2229,9 +994,10 @@ def create_cs_from_yz_and_orientation( Coordinates of the origin. time : Time data for time dependent coordinate systems. - lsc_child_in_parent: - If set to 'True', the passed 'LocalCoordinateSystem' instance describes - the new system orientation towards is parent. If 'False', it describes + lsc_child_in_parent : + If set to `True`, the passed + `~weldx.transformations.LocalCoordinateSystem` instance describes + the new system orientation towards is parent. If `False`, it describes how the parent system is positioned in its new child system. """ @@ -2261,12 +1027,12 @@ def delete_cs(self, coordinate_system_name: str, delete_children: bool = False): Parameters ---------- - coordinate_system_name: + coordinate_system_name : Name of the coordinate system that should be deleted. - delete_children: - If 'False', an exception is raised if the coordinate system has one or more + delete_children : + If `False`, an exception is raised if the coordinate system has one or more children since deletion would cause them to be disconnected to the root. - If 'True', all children are deleted as well. + If `True`, all children are deleted as well. """ if not self.has_coordinate_system(coordinate_system_name): @@ -2312,11 +1078,11 @@ def get_child_system_names( Parameters ---------- - coordinate_system_name: + coordinate_system_name : Name of the coordinate system - neighbors_only: - If 'True', only child coordinate systems that are directly connected to the - specified coordinate system are included in the returned list. If 'False', + neighbors_only : + If `True`, only child coordinate systems that are directly connected to the + specified coordinate system are included in the returned list. If `False`, child systems of arbitrary hierarchical depth are included. Returns @@ -2349,7 +1115,7 @@ def coordinate_system_names(self) -> List: Returns ------- - List: + List : List of coordinate system names. """ @@ -2365,7 +1131,7 @@ def data_names(self) -> List[str]: Names of the attached data sets """ - return self._data.keys() + return list(self._data.keys()) def get_data( self, data_name, target_coordinate_system_name=None @@ -2405,7 +1171,7 @@ def get_data_system_name(self, data_name: str) -> str: Parameters ---------- - data_name : str + data_name : Name of the data Returns @@ -2497,14 +1263,15 @@ def get_cs( reference times. Therefore an exception is raised. If your intention is to add a reference time to the resulting coordinate system, you should call this function without a specified reference time and add it explicitly to the - returned `LocalCoordinateSystem`. + returned `~weldx.transformations.LocalCoordinateSystem`. **Information regarding the implementation:** It is important to mention that all coordinate systems that are involved in the transformation should be interpolated to a common time line before they are - combined using the 'LocalCoordinateSystem's __add__ and __sub__ functions. + combined using the `~weldx.transformations.LocalCoordinateSystem` 's __add__ + and __sub__ functions. If this is not done before, serious interpolation errors for rotations can occur. The reason is, that those operators also perform time interpolations if the timestamps of 2 systems do not match. When chaining multiple @@ -2523,25 +1290,25 @@ def get_cs( Additionally, if the transformed system is rotating itself, the transformation to the parent's reference system might cause the rotation angle between to time steps to exceed 180 degrees. Since the SLERP always takes the shortest - angle between 2 ''keyframes'', further interpolations wrongly change the + angle between 2 ``keyframes``, further interpolations wrongly change the rotation order. Parameters ---------- - coordinate_system_name : str + coordinate_system_name : Name of the coordinate system - reference_system_name : str + reference_system_name : Name of the reference coordinate system time : pandas.TimedeltaIndex, pandas.DatetimeIndex, pint.Quantity or str Specifies the desired time of the returned coordinate system. You can also pass the name of another coordinate system to use its time attribute as reference - time_ref : pandas.Timestamp + time_ref : The desired reference time of the returned coordinate system Returns ------- - LocalCoordinateSystem + ~weldx.transformations.LocalCoordinateSystem Local coordinate system """ @@ -2583,7 +1350,7 @@ def get_cs( else: time_ref = pd.Timestamp(time_ref) - time_interp, time_ref_interp = _build_time_index(time, time_ref) + time_interp, time_ref_interp = build_time_index(time, time_ref) lcs_result = LocalCoordinateSystem() for edge in path_edges: @@ -2633,7 +1400,7 @@ def subsystems(self) -> List["CoordinateSystemManager"]: Returns ------- - List: + List : List containing all the subsystems. """ @@ -2657,7 +1424,7 @@ def subsystems(self) -> List["CoordinateSystemManager"]: return sub_system_list def has_coordinate_system(self, coordinate_system_name: str) -> bool: - """Return 'True' if a coordinate system with specified name already exists. + """Return `True` if a coordinate system with specified name already exists. Parameters ---------- @@ -2667,13 +1434,13 @@ def has_coordinate_system(self, coordinate_system_name: str) -> bool: Returns ------- bool - 'True' or 'False' + `True` or `False` """ return coordinate_system_name in self._graph.nodes def has_data(self, coordinate_system_name: str, data_name: str) -> bool: - """Return 'True' if the desired coordinate system owns the specified data. + """Return `True` if the desired coordinate system owns the specified data. Parameters ---------- @@ -2685,7 +1452,7 @@ def has_data(self, coordinate_system_name: str, data_name: str) -> bool: Returns ------- bool - 'True' or 'False' + `True` or `False` """ return data_name in self._graph.nodes[coordinate_system_name]["data"] @@ -2696,7 +1463,7 @@ def interp_time( pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp], - "LocalCoordinateSystem", + LocalCoordinateSystem, ], time_ref: pd.Timestamp = None, affected_coordinate_systems: Union[str, List[str], None] = None, @@ -2710,19 +1477,20 @@ def interp_time( Parameters ---------- time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem + ~weldx.transformations.LocalCoordinateSystem The target time for the interpolation. In addition to the supported - time formats, the function also accepts a `LocalCoordinateSystem` as + time formats, the function also accepts a + `~weldx.transformations.LocalCoordinateSystem` as ``time`` source object - time_ref : pandas.Timestamp + time_ref : A reference timestamp that can be provided if the ``time`` parameter is a `pandas.TimedeltaIndex` affected_coordinate_systems : str or List[str] A single coordinate system name or a list of coordinate system names that should be interpolated in time. Only transformations towards the systems root node are affected. - in_place : bool - If 'True' the interpolation is performed in place, otherwise a + in_place : + If `True` the interpolation is performed in place, otherwise a new instance is returned. (Default value = False) Returns @@ -2746,9 +1514,13 @@ def interp_time( for edge in affected_edges: if self._graph.edges[edge]["defined"]: - self._graph.edges[edge]["lcs"] = self._graph.edges[edge][ - "lcs" - ].interp_time(time, time_ref) + lcs = self._graph.edges[edge]["lcs"] + # this prevents failures when calling lcs.interp_time with reference + # times or DatetimeIndex. + if lcs.reference_time is None and self._reference_time is not None: + lcs.reset_reference_time(self._reference_time) + self._graph.edges[edge]["lcs"] = lcs.interp_time(time, time_ref) + for edge in affected_edges: if not self._graph.edges[edge]["defined"]: self._graph.edges[edge]["lcs"] = self._graph.edges[ @@ -2781,24 +1553,31 @@ def is_neighbor_of( def merge(self, other: "CoordinateSystemManager"): """Merge another coordinate system managers into the current instance. - Both 'CoordinateSystemManager' need to have exactly one common coordinate + Both `CoordinateSystemManager` need to have exactly one common coordinate system. They are merged at this node. Internally, information is kept to undo the merge process. Parameters ---------- other: - CoordinateSystemManager instance that should be merged into the current + `CoordinateSystemManager` instance that should be merged into the current instance. """ - if ( - other._number_of_time_dependent_lcs > 0 - and self.reference_time != other.reference_time + if other._number_of_time_dependent_lcs > 0 and ( + (not self.uses_absolute_times and other.uses_absolute_times) + or ( + (self.uses_absolute_times and not self.has_reference_time) + and not other.uses_absolute_times + ) + or ( + (self.has_reference_time and other.uses_absolute_times) + and (self.reference_time != other.reference_time) + ) ): raise Exception( - "You can only merge subsystems with time dependent coordinate systems" - "if the reference times of both 'CoordinateSystemManager' instances" + "You can only merge subsystems with time dependent coordinate systems " + "if the reference times of both `CoordinateSystemManager` instances " "are identical." ) @@ -2893,6 +1672,8 @@ def _get_tree_positions_for_plot(self): def plot_graph(self, ax=None): """Plot the graph of the coordinate system manager.""" if ax is None: + from matplotlib import pylab as plt + _, ax = plt.subplots() color_map = [] pos = self._get_tree_positions_for_plot() @@ -2909,7 +1690,7 @@ def plot_graph(self, ax=None): def plot( self, backend: str = "mpl", - axes: plt.Axes.axes = None, + axes: matplotlib.axes.Axes = None, reference_system: str = None, coordinate_systems: List[str] = None, data_sets: List[str] = None, @@ -2920,9 +1701,10 @@ def plot( pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp], - "LocalCoordinateSystem", + LocalCoordinateSystem, ] = None, time_ref: pd.Timestamp = None, + axes_equal: bool = False, show_data_labels: bool = True, show_labels: bool = True, show_origins: bool = True, @@ -2934,72 +1716,79 @@ def plot( Parameters ---------- - backend : str + backend : Select the rendering backend of the plot. The options are: - - 'k3d' to get an interactive plot using [k3d](https://k3d-jupyter.org/) - - 'mpl' for static plots using [matplotlib](https://matplotlib.org/) + - ``k3d`` to get an interactive plot using `k3d `_ + - ``mpl`` for static plots using `matplotlib `_ Note that k3d only works inside jupyter notebooks axes : matplotlib.axes.Axes (matplotlib only) The target axes object that should be drawn to. If `None` is provided, a new one will be created. - reference_system : str + reference_system : The name of the reference system for the plotted coordinate systems - coordinate_systems : List[str] + coordinate_systems : Names of the coordinate systems that should be drawn. If `None` is provided, all systems are plotted. - data_sets : List[str] + data_sets : Names of the data sets that should be drawn. If `None` is provided, all data is plotted. - colors: Dict[str, int] + colors : A mapping between a coordinate system name or a data set name and a color. The colors must be provided as 24 bit integer values that are divided into three 8 bit sections for the rgb values. For example `0xFF0000` for pure red. Each coordinate system or data set that does not have a mapping in this dictionary will get a default color assigned to it. - title : str + title : The title of the plot - limits : List[Tuple[float,float]] + limits : The coordinate limits of the plot. time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem + ~weldx.transformations.LocalCoordinateSystem The time steps that should be plotted - time_ref : pandas.Timestamp + time_ref : A reference timestamp that can be provided if the ``time`` parameter is a `pandas.TimedeltaIndex` - show_data_labels : bool + axes_equal : + (matplotlib only) If `True`, all axes are adjusted to cover an equally large + range of value. That doesn't mean, that the limits are identical + show_data_labels : (k3d only) If `True`, plotted data sets get labels with their names attached to them - show_labels : bool + show_labels : (k3d only) If `True`, plotted coordinate systems get labels with their names attached to them - show_origins : bool + show_origins : If `True`, the origins of the coordinate system are visualized in the color assigned to the coordinate system. - show_traces : bool + show_traces : If `True`, the trace of time dependent coordinate systems is plotted in the coordinate systems color. - show_vectors : bool + show_vectors : (matplotlib only) If `True`, the coordinate cross of time dependent coordinate systems is plotted. - show_wireframe : bool + show_wireframe : (k3d only) If `True`, data sets that contain mesh data are rendered in wireframe mode. If `False`, the data """ + if backend not in ("mpl", "k3d"): + raise ValueError( + f"backend has to be one of ('mpl', 'k3d'), but was {backend}" + ) + vis = None if backend == "k3d": - CoordinateSystemManagerVisualizerK3D( + from weldx.visualization import CoordinateSystemManagerVisualizerK3D + + vis = CoordinateSystemManagerVisualizerK3D( csm=self, reference_system=reference_system, coordinate_systems=coordinate_systems, data_sets=data_sets, colors=colors, - title=title, limits=limits, - time=time, - time_ref=time_ref, show_data_labels=show_data_labels, show_labels=show_labels, show_origins=show_origins, @@ -3007,8 +1796,10 @@ def plot( show_vectors=show_vectors, show_wireframe=show_wireframe, ) - elif backend == "mpl": - axes = plot_coordinate_system_manager_matplotlib( + if backend == "mpl": + from weldx.visualization import plot_coordinate_system_manager_matplotlib + + vis = plot_coordinate_system_manager_matplotlib( csm=self, axes=axes, reference_system=reference_system, @@ -3018,12 +1809,13 @@ def plot( time_ref=time_ref, title=title, limits=limits, + set_axes_equal=axes_equal, show_origins=show_origins, show_trace=show_traces, show_vectors=show_vectors, + show_wireframe=show_wireframe, ) - else: - raise ValueError(f"Unknown rendering backend: '{backend}'") + return vis def remove_subsystems(self): """Remove all subsystems from the coordinate system manager.""" @@ -3043,7 +1835,8 @@ def time_union( """Get the time union of all or selected local coordinate systems. If neither the `CoordinateSystemManager` nor its attached - `LocalCoordinateSystem` instances possess a reference time, the function + `~weldx.transformations.LocalCoordinateSystem` instances possess a reference + time, the function returns a `pandas.TimedeltaIndex`. Otherwise, a `pandas.DatetimeIndex` is returned. The following table gives an overview of all possible reference time combinations and the corresponding return type: @@ -3083,14 +1876,24 @@ def time_union( if not lcs_list: return None - time_list = [ut.to_pandas_time_index(lcs) for lcs in lcs_list] - if self.has_reference_time: + time_list = [util.to_pandas_time_index(lcs) for lcs in lcs_list] + reference_time = self.reference_time + if self.uses_absolute_times and not reference_time: + reference_time = min( + [ + lcs.reference_time + for lcs in self.lcs_time_dependent + if lcs.reference_time + ] + ) + + if reference_time: time_list = [ - t + self.reference_time if isinstance(t, pd.TimedeltaIndex) else t + t + reference_time if isinstance(t, pd.TimedeltaIndex) else t for t in time_list ] - return ut.get_time_union(time_list) + return util.get_time_union(time_list) def transform_data( self, @@ -3133,7 +1936,7 @@ def transform_data( data = xr.DataArray(data, dims=["n", "c"], coords={"c": ["x", "y", "z"]}) lcs = self.get_cs(source_coordinate_system_name, target_coordinate_system_name) - mul = ut.xr_matmul( + mul = util.xr_matmul( lcs.orientation, data, dims_a=["c", "v"], dims_b=["c"], dims_out=["c"] ) return mul + lcs.coordinates @@ -3150,7 +1953,7 @@ def unmerge(self) -> List["CoordinateSystemManager"]: Returns ------- List[CoordinateSystemManager]: - A list containing previously merged 'CoordinateSystemManager' instances. + A list containing previously merged `CoordinateSystemManager` instances. """ subsystems = self.subsystems diff --git a/weldx/transformations/local_cs.py b/weldx/transformations/local_cs.py new file mode 100644 index 000000000..cc17b0ff6 --- /dev/null +++ b/weldx/transformations/local_cs.py @@ -0,0 +1,897 @@ +"""Contains methods and classes for coordinate transformations.""" + +from __future__ import annotations + +from copy import deepcopy +from typing import TYPE_CHECKING, List, Union + +import numpy as np +import pandas as pd +import pint +import xarray as xr +from scipy.spatial.transform import Rotation as Rot + +import weldx.util as ut +from weldx.transformations.util import build_time_index, normalize + +if TYPE_CHECKING: + import matplotlib.axes + +__all__ = ("LocalCoordinateSystem",) + + +class LocalCoordinateSystem: + """Defines a local cartesian coordinate system in 3d. + + Notes + ----- + Learn how to use this class by reading the + :doc:`Tutorial <../tutorials/transformations_01_coordinate_systems>`. + + """ + + def __init__( + self, + orientation: Union[xr.DataArray, np.ndarray, List[List], Rot] = None, + coordinates: Union[xr.DataArray, np.ndarray, List] = None, + time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, pint.Quantity] = None, + time_ref: pd.Timestamp = None, + construction_checks: bool = True, + ): + """Construct a cartesian coordinate system. + + Parameters + ---------- + orientation : + Matrix of 3 orthogonal column vectors which represent + the coordinate systems orientation. Keep in mind, that the columns of the + corresponding orientation matrix is equal to the normalized orientation + vectors. So each orthogonal transformation matrix can also be + provided as orientation. + Passing a scipy.spatial.transform.Rotation object is also supported. + coordinates : + Coordinates of the origin + time : + Time data for time dependent coordinate systems + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + construction_checks : + If 'True', the validity of the data will be verified + + Returns + ------- + LocalCoordinateSystem + Cartesian coordinate system + + """ + if orientation is None: + orientation = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) + if coordinates is None: + coordinates = np.array([0, 0, 0]) + + time, time_ref = build_time_index(time, time_ref) + orientation = self._build_orientation(orientation, time) + coordinates = self._build_coordinates(coordinates, time) + + if construction_checks: + ut.xr_check_coords( + coordinates, + dict( + c={"values": ["x", "y", "z"]}, + time={"dtype": "timedelta64", "optional": True}, + ), + ) + + ut.xr_check_coords( + orientation, + dict( + c={"values": ["x", "y", "z"]}, + v={"values": [0, 1, 2]}, + time={"dtype": "timedelta64", "optional": True}, + ), + ) + + orientation = xr.apply_ufunc( + normalize, + orientation, + input_core_dims=[["c"]], + output_core_dims=[["c"]], + ) + + # vectorize test if orthogonal + if not ut.xr_is_orthogonal_matrix(orientation, dims=["c", "v"]): + raise ValueError("Orientation vectors must be orthogonal") + + # unify time axis + if ( + ("time" in orientation.coords) + and ("time" in coordinates.coords) + and (not np.all(orientation.time.data == coordinates.time.data)) + ): + time_union = ut.get_time_union([orientation, coordinates]) + orientation = ut.xr_interp_orientation_in_time(orientation, time_union) + coordinates = ut.xr_interp_coordinates_in_time(coordinates, time_union) + + coordinates.name = "coordinates" + orientation.name = "orientation" + + self._dataset = xr.merge([coordinates, orientation], join="exact") + if "time" in self._dataset and time_ref is not None: + self._dataset.weldx.time_ref = time_ref + + def __repr__(self): + """Give __repr_ output in xarray format.""" + return self._dataset.__repr__().replace( + " "LocalCoordinateSystem": + """Add 2 coordinate systems. + + Generates a new coordinate system by treating the left-hand side + coordinate system as being defined in the right hand-side coordinate + system. + The transformations from the base coordinate system to the new + coordinate system are equivalent to the combination of the + transformations from both added coordinate systems: + + R_n = R_r * R_l + T_n = R_r * T_l + T_r + + R_r and T_r are rotation matrix and translation vector of the + right-hand side coordinate system, R_l and T_l of the left-hand side + coordinate system and R_n and T_n of the resulting coordinate system. + + If the left-hand side system has a time component, the data of the right-hand + side system will be interpolated to the same times, before the previously shown + operations are performed per point in time. In case, that the left-hand side + system has no time component, but the right-hand side does, the resulting system + has the same time components as the right-hand side system. + + Parameters + ---------- + rhs_cs : + Right-hand side coordinate system + + Returns + ------- + LocalCoordinateSystem + Resulting coordinate system. + + """ + lhs_cs = self + if ( + lhs_cs.reference_time != rhs_cs.reference_time + and lhs_cs.has_reference_time + and rhs_cs.has_reference_time + ): + if lhs_cs.reference_time < rhs_cs.reference_time: + time_ref = lhs_cs.reference_time + rhs_cs = deepcopy(rhs_cs) + rhs_cs.reset_reference_time(time_ref) + else: + time_ref = rhs_cs.reference_time + lhs_cs = deepcopy(lhs_cs) + lhs_cs.reset_reference_time(time_ref) + elif not lhs_cs.has_reference_time: + time_ref = rhs_cs.reference_time + else: + time_ref = lhs_cs.reference_time + + rhs_cs = rhs_cs.interp_time(lhs_cs.time, time_ref) + + orientation = ut.xr_matmul( + rhs_cs.orientation, lhs_cs.orientation, dims_a=["c", "v"] + ) + coordinates = ( + ut.xr_matmul(rhs_cs.orientation, lhs_cs.coordinates, ["c", "v"], ["c"]) + + rhs_cs.coordinates + ) + return LocalCoordinateSystem(orientation, coordinates, time_ref=time_ref) + + def __sub__(self, rhs_cs: "LocalCoordinateSystem") -> "LocalCoordinateSystem": + """Subtract 2 coordinate systems. + + Generates a new coordinate system from two local coordinate systems + with the same reference coordinate system. The resulting system is + equivalent to the left-hand side system but with the right-hand side + as reference coordinate system. + This is achieved by the following transformations: + + R_n = R_r^(-1) * R_l + T_n = R_r^(-1) * (T_l - T_r) + + R_r and T_r are rotation matrix and translation vector of the + right-hand side coordinate system, R_l and T_l of the left-hand side + coordinate system and R_n and T_n of the resulting coordinate system. + + If the left-hand side system has a time component, the data of the right-hand + side system will be interpolated to the same times, before the previously shown + operations are performed per point in time. In case, that the left-hand side + system has no time component, but the right-hand side does, the resulting system + has the same time components as the right-hand side system. + + Parameters + ---------- + rhs_cs : + Right-hand side coordinate system + + Returns + ------- + LocalCoordinateSystem + Resulting coordinate system. + + """ + rhs_cs_inv = rhs_cs.invert() + return self + rhs_cs_inv + + def __eq__(self: "LocalCoordinateSystem", other: "LocalCoordinateSystem") -> bool: + """Check equality of LocalCoordinateSystems.""" + return ( + self.orientation.identical(other.orientation) + and self.coordinates.identical(other.coordinates) + and self.reference_time == other.reference_time + ) + + @staticmethod + def _build_orientation( + orientation: Union[xr.DataArray, np.ndarray, List[List], Rot], + time: pd.DatetimeIndex = None, + ): + """Create xarray orientation from different formats and time-inputs. + + Parameters + ---------- + orientation : + Orientation object or data. + time : + Valid time index formatted with `_build_time_index`. + + Returns + ------- + xarray.DataArray + + """ + if not isinstance(orientation, xr.DataArray): + time_orientation = None + if isinstance(orientation, Rot): + orientation = orientation.as_matrix() + elif not isinstance(orientation, np.ndarray): + orientation = np.array(orientation) + + if orientation.ndim == 3: + time_orientation = time + orientation = ut.xr_3d_matrix(orientation, time_orientation) + + # make sure we have correct "time" format + orientation = orientation.weldx.time_ref_restore() + + return orientation + + @staticmethod + def _build_coordinates(coordinates, time: pd.DatetimeIndex = None): + """Create xarray coordinates from different formats and time-inputs. + + Parameters + ---------- + coordinates: + Coordinates data. + time: + Valid time index formatted with `_build_time_index`. + + Returns + ------- + xarray.DataArray + + """ + if not isinstance(coordinates, xr.DataArray): + time_coordinates = None + if not isinstance(coordinates, (np.ndarray, pint.Quantity)): + coordinates = np.array(coordinates) + if coordinates.ndim == 2: + time_coordinates = time + coordinates = ut.xr_3d_vector(coordinates, time_coordinates) + + # make sure we have correct "time" format + coordinates = coordinates.weldx.time_ref_restore() + + return coordinates + + @classmethod + def from_euler( + cls, sequence, angles, degrees=False, coordinates=None, time=None, time_ref=None + ) -> "LocalCoordinateSystem": + """Construct a local coordinate system from an euler sequence. + + This function uses scipy.spatial.transform.Rotation.from_euler method to define + the coordinate systems orientation. Take a look at it's documentation, if some + information is missing here. The related parameter docs are a copy of the scipy + documentation. + + Parameters + ---------- + sequence : + Specifies sequence of axes for rotations. Up to 3 characters + belonging to the set {‘X’, ‘Y’, ‘Z’} for intrinsic rotations, + or {‘x’, ‘y’, ‘z’} for extrinsic rotations. + Extrinsic and intrinsic rotations cannot be mixed in one function call. + angles : + Euler angles specified in radians (degrees is False) or degrees + (degrees is True). For a single character seq, angles can be: + - a single value + - array_like with shape (N,), where each angle[i] corresponds to a single + rotation + - array_like with shape (N, 1), where each angle[i, 0] corresponds to a + single rotation + For 2- and 3-character wide seq, angles can be: + - array_like with shape (W,) where W is the width of seq, which corresponds + to a single rotation with W axes + - array_like with shape (N, W) where each angle[i] corresponds to a sequence + of Euler angles describing a single rotation + degrees : + If True, then the given angles are assumed to be in degrees. + Default is False. + coordinates : + Coordinates of the origin (Default value = None) + time : + Time data for time dependent coordinate systems (Default value = None) + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + + Returns + ------- + LocalCoordinateSystem + Local coordinate system + + """ + orientation = Rot.from_euler(sequence, angles, degrees) + return cls(orientation, coordinates=coordinates, time=time, time_ref=time_ref) + + @classmethod + def from_orientation( + cls, orientation, coordinates=None, time=None, time_ref=None + ) -> "LocalCoordinateSystem": + """Construct a local coordinate system from orientation matrix. + + Parameters + ---------- + orientation : + Orthogonal transformation matrix + coordinates : + Coordinates of the origin (Default value = None) + time : + Time data for time dependent coordinate systems (Default value = None) + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + + Returns + ------- + LocalCoordinateSystem + Local coordinate system + + """ + return cls(orientation, coordinates=coordinates, time=time, time_ref=time_ref) + + @classmethod + def from_xyz( + cls, vec_x, vec_y, vec_z, coordinates=None, time=None, time_ref=None + ) -> "LocalCoordinateSystem": + """Construct a local coordinate system from 3 vectors defining the orientation. + + Parameters + ---------- + vec_x : + Vector defining the x-axis + vec_y : + Vector defining the y-axis + vec_z : + Vector defining the z-axis + coordinates : + Coordinates of the origin (Default value = None) + time : + Time data for time dependent coordinate systems (Default value = None) + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + + Returns + ------- + LocalCoordinateSystem + Local coordinate system + + """ + vec_x = ut.to_float_array(vec_x) + vec_y = ut.to_float_array(vec_y) + vec_z = ut.to_float_array(vec_z) + + orientation = np.concatenate((vec_x, vec_y, vec_z), axis=vec_x.ndim - 1) + orientation = np.reshape(orientation, (*vec_x.shape, 3)) + orientation = orientation.swapaxes(orientation.ndim - 1, orientation.ndim - 2) + return cls(orientation, coordinates=coordinates, time=time, time_ref=time_ref) + + @classmethod + def from_xy_and_orientation( + cls, + vec_x, + vec_y, + positive_orientation=True, + coordinates=None, + time=None, + time_ref=None, + ) -> "LocalCoordinateSystem": + """Construct a coordinate system from 2 vectors and an orientation. + + Parameters + ---------- + vec_x : + Vector defining the x-axis + vec_y : + Vector defining the y-axis + positive_orientation : + Set to True if the orientation should + be positive and to False if not (Default value = True) + coordinates : + Coordinates of the origin (Default value = None) + time : + Time data for time dependent coordinate systems (Default value = None) + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + + Returns + ------- + LocalCoordinateSystem + Local coordinate system + + """ + vec_z = cls._calculate_orthogonal_axis(vec_x, vec_y) * cls._sign_orientation( + positive_orientation + ) + + return cls.from_xyz(vec_x, vec_y, vec_z, coordinates, time, time_ref=time_ref) + + @classmethod + def from_yz_and_orientation( + cls, + vec_y, + vec_z, + positive_orientation=True, + coordinates=None, + time=None, + time_ref=None, + ) -> "LocalCoordinateSystem": + """Construct a coordinate system from 2 vectors and an orientation. + + Parameters + ---------- + vec_y : + Vector defining the y-axis + vec_z : + Vector defining the z-axis + positive_orientation : + Set to True if the orientation should + be positive and to False if not (Default value = True) + coordinates : + Coordinates of the origin (Default value = None) + time : + Time data for time dependent coordinate systems (Default value = None) + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + + Returns + ------- + LocalCoordinateSystem + Local coordinate system + + """ + vec_x = cls._calculate_orthogonal_axis(vec_y, vec_z) * cls._sign_orientation( + positive_orientation + ) + + return cls.from_xyz(vec_x, vec_y, vec_z, coordinates, time, time_ref=time_ref) + + @classmethod + def from_xz_and_orientation( + cls, + vec_x, + vec_z, + positive_orientation=True, + coordinates=None, + time=None, + time_ref=None, + ) -> "LocalCoordinateSystem": + """Construct a coordinate system from 2 vectors and an orientation. + + Parameters + ---------- + vec_x : + Vector defining the x-axis + vec_z : + Vector defining the z-axis + positive_orientation : + Set to True if the orientation should + be positive and to False if not (Default value = True) + coordinates : + Coordinates of the origin (Default value = None) + time : + Time data for time dependent coordinate systems (Default value = None) + time_ref : + Reference Timestamp to use if time is Timedelta or pint.Quantity. + + Returns + ------- + LocalCoordinateSystem + Local coordinate system + + """ + vec_y = cls._calculate_orthogonal_axis(vec_z, vec_x) * cls._sign_orientation( + positive_orientation + ) + + return cls.from_xyz(vec_x, vec_y, vec_z, coordinates, time, time_ref=time_ref) + + @staticmethod + def _sign_orientation(positive_orientation): + """Get -1 or 1 depending on the coordinate systems orientation. + + Parameters + ---------- + positive_orientation : + Set to True if the orientation should + be positive and to False if not + + Returns + ------- + int + 1 if the coordinate system has positive orientation, + -1 otherwise + + """ + if positive_orientation: + return 1 + return -1 + + @staticmethod + def _calculate_orthogonal_axis(a_0, a_1): + """Calculate an axis which is orthogonal to two other axes. + + The calculated axis has a positive orientation towards the other 2 + axes. + + Parameters + ---------- + a_0 : + First axis + a_1 : + Second axis + + Returns + ------- + numpy.ndarray + Orthogonal axis + + """ + return np.cross(a_0, a_1) + + @property + def orientation(self) -> xr.DataArray: + """Get the coordinate systems orientation matrix. + + Returns + ------- + xarray.DataArray + Orientation matrix + + """ + return self.dataset.orientation + + @property + def coordinates(self) -> xr.DataArray: + """Get the coordinate systems coordinates. + + Returns + ------- + xarray.DataArray + Coordinates of the coordinate system + + """ + return self.dataset.coordinates + + @property + def is_time_dependent(self) -> bool: + """Return `True` if the coordinate system is time dependent. + + Returns + ------- + bool : + `True` if the coordinate system is time dependent, `False` otherwise. + + """ + return self.time is not None + + @property + def has_reference_time(self) -> bool: + """Return `True` if the coordinate system has a reference time. + + Returns + ------- + bool : + `True` if the coordinate system has a reference time, `False` otherwise. + + """ + return self.reference_time is not None + + @property + def reference_time(self) -> Union[pd.Timestamp, None]: + """Get the coordinate systems reference time. + + Returns + ------- + pandas.Timestamp: + The coordinate systems reference time + + """ + return self._dataset.weldx.time_ref + + @property + def datetimeindex(self) -> Union[pd.DatetimeIndex, None]: + """Get the time as 'pandas.DatetimeIndex'. + + If the coordinate system has no reference time, 'None' is returned. + + Returns + ------- + Union[pandas.DatetimeIndex, None]: + The coordinate systems time as 'pandas.DatetimeIndex' + + """ + if not self.has_reference_time: + return None + return self.time + self.reference_time + + @property + def time(self) -> Union[pd.TimedeltaIndex, None]: + """Get the time union of the local coordinate system (None if system is static). + + Returns + ------- + pandas.TimedeltaIndex + DateTimeIndex-like time union + + """ + if "time" in self._dataset.coords: + return self._dataset.time + return None + + @property + def time_quantity(self) -> pint.Quantity: + """Get the time as 'pint.Quantity'. + + Returns + ------- + pint.Quantity: + The coordinate systems time as 'pint.Quantity' + + """ + return ut.pandas_time_delta_to_quantity(self.time) + + @property + def dataset(self) -> xr.Dataset: + """Get the underlying xarray.Dataset with ordered dimensions. + + Returns + ------- + xarray.Dataset + xarray Dataset with coordinates and orientation as DataVariables. + + """ + return self._dataset.transpose(..., "c", "v") + + @property + def is_unity_translation(self) -> bool: + """Return true if the LCS has a zero translation/coordinates value.""" + if self.coordinates.shape[-1] == 3 and np.allclose( + self.coordinates, np.zeros(3) + ): + return True + return False + + @property + def is_unity_rotation(self) -> bool: + """Return true if the LCS represents a unity rotation/orientations value.""" + if self.orientation.shape[-2:] == (3, 3) and np.allclose( + self.orientation, np.eye(3) + ): + return True + return False + + def as_euler( + self, seq: str = "xyz", degrees: bool = False + ) -> np.ndarray: # pragma: no cover + """Return Euler angle representation of the coordinate system orientation. + + Parameters + ---------- + seq : + Euler rotation sequence as described in + https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial + .transform.Rotation.as_euler.html + degrees : + Returned angles are in degrees if True, else they are in radians. + Default is False. + + Returns + ------- + numpy.ndarray + Array of euler angles. + + """ + return self.as_rotation().as_euler(seq=seq, degrees=degrees) + + def as_rotation(self) -> Rot: # pragma: no cover + """Get a scipy.Rotation object from the coordinate system orientation. + + Returns + ------- + scipy.spatial.transform.Rotation + Scipy rotation object representing the orientation. + + """ + return Rot.from_matrix(self.orientation.values) + + def interp_time( + self, + time: Union[ + pd.DatetimeIndex, + pd.TimedeltaIndex, + List[pd.Timestamp], + "LocalCoordinateSystem", + None, + ], + time_ref: Union[pd.Timestamp, None] = None, + ) -> "LocalCoordinateSystem": + """Interpolates the data in time. + + Parameters + ---------- + time : + Series of times. + If passing "None" no interpolation will be performed. + time_ref: + The reference timestamp + + Returns + ------- + LocalCoordinateSystem + Coordinate system with interpolated data + + """ + if (not self.is_time_dependent) or (time is None): + return self + + # use LCS reference time if none provided + if isinstance(time, LocalCoordinateSystem) and time_ref is None: + time_ref = time.reference_time + time = ut.to_pandas_time_index(time) + + if self.has_reference_time != ( + time_ref is not None or isinstance(time, pd.DatetimeIndex) + ): + raise TypeError( + "Only 1 reference time provided for time dependent coordinate " + "system. Either the reference time of the coordinate system or the " + "one passed to the function is 'None'. Only cases where the " + "reference times are both 'None' or both contain a timestamp are " + "allowed. Also check that the reference time has the correct type." + ) + + if self.has_reference_time and (not isinstance(time, pd.DatetimeIndex)): + time = time + time_ref + + orientation = ut.xr_interp_orientation_in_time(self.orientation, time) + coordinates = ut.xr_interp_coordinates_in_time(self.coordinates, time) + + return LocalCoordinateSystem(orientation, coordinates, time_ref=time_ref) + + def invert(self) -> "LocalCoordinateSystem": + """Get a local coordinate system defining the parent in the child system. + + Inverse is defined as orientation_new=orientation.T, + coordinates_new=orientation.T*(-coordinates) + + Returns + ------- + LocalCoordinateSystem + Inverted coordinate system. + + """ + orientation = ut.xr_transpose_matrix_data(self.orientation, dim1="c", dim2="v") + coordinates = ut.xr_matmul( + self.orientation, + -self.coordinates, + dims_a=["c", "v"], + dims_b=["c"], + trans_a=True, + ) + return LocalCoordinateSystem( + orientation, coordinates, self.time, self.reference_time + ) + + def plot( + self, + axes: matplotlib.axes.Axes = None, + color: str = None, + label: str = None, + time: Union[ + pd.DatetimeIndex, + pd.TimedeltaIndex, + List[pd.Timestamp], + "LocalCoordinateSystem", + ] = None, + time_ref: pd.Timestamp = None, + time_index: int = None, + show_origin: bool = True, + show_trace: bool = True, + show_vectors: bool = True, + ): # pragma: no cover + """Plot the coordinate system. + + Parameters + ---------- + axes : matplotlib.axes.Axes + The target matplotlib axes object that should be drawn to. If `None` is + provided, a new one will be created. + color : str + The color of the coordinate system. The string must be a valid matplotlib + color format. See: + https://matplotlib.org/3.1.0/api/colors_api.html#module-matplotlib.colors + label : str + The name of the coordinate system + time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ + LocalCoordinateSystem + The time steps that should be plotted. Missing time steps in the data will + be interpolated. + time_ref : pandas.Timestamp + A reference timestamp that can be provided if the ``time`` parameter is a + `pandas.TimedeltaIndex` + time_index: int + If the coordinate system is time dependent, this parameter can be used to + to select a specific key frame by its index. + show_origin: bool + If `True`, a small dot with the assigned color will mark the coordinate + systems' origin. + show_trace : bool + If `True`, the trace of time dependent coordinate systems is plotted. + show_vectors : bool + If `True`, the coordinate cross of time dependent coordinate systems is + plotted. + + """ + from weldx.visualization import plot_local_coordinate_system_matplotlib + + plot_local_coordinate_system_matplotlib( + self, + axes=axes, + color=color, + label=label, + time=time, + time_ref=time_ref, + time_index=time_index, + show_origin=show_origin, + show_trace=show_trace, + show_vectors=show_vectors, + ) + + def reset_reference_time(self, time_ref_new: pd.Timestamp): + """Reset the reference time of the coordinate system. + + The time values of the coordinate system are adjusted to the new reference time. + If no reference time has been set before, the time values will remain + unmodified. This assumes that the current time delta values are already + referring to the new reference time. + + Parameters + ---------- + time_ref_new: pandas.Timestamp + The new reference time + + """ + self._dataset.weldx.time_ref = time_ref_new diff --git a/weldx/transformations/rotation.py b/weldx/transformations/rotation.py new file mode 100644 index 000000000..b7088c607 --- /dev/null +++ b/weldx/transformations/rotation.py @@ -0,0 +1,111 @@ +"""Contains tools to handle rotations.""" + +import numpy as np +from scipy.spatial.transform import Rotation as _Rotation + +from weldx.constants import WELDX_UNIT_REGISTRY as UREG + +_DEFAULT_LEN_UNIT = UREG.millimeters +_DEFAULT_ANG_UNIT = UREG.rad + + +@UREG.wraps(None, _DEFAULT_ANG_UNIT, strict=False) +def rotation_matrix_x(angle): + """Create a rotation matrix that rotates around the x-axis. + + Parameters + ---------- + angle : + Rotation angle + + Returns + ------- + numpy.ndarray + Rotation matrix + + """ + return _Rotation.from_euler("x", angle).as_matrix() + + +@UREG.wraps(None, _DEFAULT_ANG_UNIT, strict=False) +def rotation_matrix_y(angle): + """Create a rotation matrix that rotates around the y-axis. + + Parameters + ---------- + angle : + Rotation angle + + Returns + ------- + numpy.ndarray + Rotation matrix + + """ + return _Rotation.from_euler("y", angle).as_matrix() + + +@UREG.wraps(None, _DEFAULT_ANG_UNIT, strict=False) +def rotation_matrix_z(angle) -> np.ndarray: + """Create a rotation matrix that rotates around the z-axis. + + Parameters + ---------- + angle : + Rotation angle + + Returns + ------- + numpy.ndarray + Rotation matrix + + """ + return _Rotation.from_euler("z", angle).as_matrix() + + +class WXRotation(_Rotation): + """Wrapper for creating meta-tagged Scipy.Rotation objects.""" + + @classmethod + def from_quat(cls, quat: np.ndarray) -> "WXRotation": + """Initialize from quaternions. + + scipy.spatial.transform.Rotation docs for details. + """ + rot = super().from_quat(quat) + setattr(rot, "wx_meta", {"constructor": "from_quat"}) + return rot + + @classmethod + def from_matrix(cls, matrix: np.ndarray) -> "WXRotation": + """Initialize from matrix. + + scipy.spatial.transform.Rotation docs for details. + """ + rot = super().from_matrix(matrix) + setattr(rot, "wx_meta", {"constructor": "from_matrix"}) + return rot + + @classmethod + def from_rotvec(cls, rotvec: np.ndarray) -> "WXRotation": + """Initialize from rotation vector. + + scipy.spatial.transform.Rotation docs for details. + """ + rot = super().from_rotvec(rotvec) + setattr(rot, "wx_meta", {"constructor": "from_rotvec"}) + return rot + + @classmethod + def from_euler(cls, seq: str, angles, degrees: bool = False) -> "WXRotation": + """Initialize from euler angles. + + scipy.spatial.transform.Rotation docs for details. + """ + rot = super().from_euler(seq=seq, angles=angles, degrees=degrees) + setattr( + rot, + "wx_meta", + {"constructor": "from_euler", "seq": seq, "degrees": degrees}, + ) + return rot diff --git a/weldx/transformations/util.py b/weldx/transformations/util.py new file mode 100644 index 000000000..5c7cc0556 --- /dev/null +++ b/weldx/transformations/util.py @@ -0,0 +1,288 @@ +"""Contains functions for coordinate transformations.""" + +import math +from typing import Union + +import numpy as np +import pandas as pd +import pint + +from weldx import util + +__all__ = [ + "scale_matrix", + "normalize", + "orientation_point_plane_containing_origin", + "orientation_point_plane", + "is_orthogonal", + "is_orthogonal_matrix", + "point_left_of_line", + "reflection_sign", + "vector_points_to_left_of_vector", +] + + +def build_time_index( + time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, pint.Quantity] = None, + time_ref: pd.Timestamp = None, +) -> pd.TimedeltaIndex: + """Build time index used for xarray objects. + + Parameters + ---------- + time: + Datetime- or Timedelta-like time index. + time_ref: + Reference timestamp for Timedelta inputs. + + Returns + ------- + pandas.TimedeltaIndex + + """ + if time is None: + # time_ref = None + return time, time_ref + + time = util.to_pandas_time_index(time) + + if isinstance(time, pd.DatetimeIndex): + if time_ref is None: + time_ref = time[0] + time = time - time_ref + + return time, time_ref + + +def scale_matrix(scale_x, scale_y, scale_z) -> np.ndarray: + """Return a scaling matrix. + + Parameters + ---------- + scale_x : + Scaling factor in x direction + scale_y : + Scaling factor in y direction + scale_z : + Scaling factor in z direction + + Returns + ------- + numpy.ndarray + Scaling matrix + + """ + return np.diag([scale_x, scale_y, scale_z]).astype(float) + + +def normalize(a): + """Normalize (l2 norm) an ndarray along the last dimension. + + Parameters + ---------- + a : + data in ndarray + + Returns + ------- + numpy.ndarray + Normalized ndarray + + """ + norm = np.linalg.norm(a, axis=(-1), keepdims=True) + if not np.all(norm): + raise ValueError("Length 0 encountered during normalization.") + return a / norm + + +def orientation_point_plane_containing_origin(point, p_a, p_b): + """Determine a points orientation relative to a plane containing the origin. + + The side is defined by the winding order of the triangle 'origin - A - + B'. When looking at it from the left-hand side, the ordering is clockwise + and counter-clockwise when looking from the right-hand side. + + The function returns 1 if the point lies left of the plane, -1 if it is + on the right and 0 if it lies on the plane. + + Note, that this function is not appropriate to check if a point lies on + a plane since it has no tolerance to compensate for numerical errors. + + Additional note: The points A and B can also been considered as two + vectors spanning the plane. + + Parameters + ---------- + point : + Point + p_a : + Second point of the triangle 'origin - A - B'. + p_b : + Third point of the triangle 'origin - A - B'. + + Returns + ------- + int + 1, -1 or 0 (see description) + + """ + if ( + math.isclose(np.linalg.norm(p_a), 0) + or math.isclose(np.linalg.norm(p_b), 0) + or math.isclose(np.linalg.norm(p_b - p_a), 0) + ): + raise ValueError("One or more points describing the plane are identical.") + + return np.sign(np.linalg.det([p_a, p_b, point])) + + +def orientation_point_plane(point, p_a, p_b, p_c): + """Determine a points orientation relative to an arbitrary plane. + + The side is defined by the winding order of the triangle 'A - B - C'. + When looking at it from the left-hand side, the ordering is clockwise + and counter-clockwise when looking from the right-hand side. + + The function returns 1 if the point lies left of the plane, -1 if it is + on the right and 0 if it lies on the plane. + + Note, that this function is not appropriate to check if a point lies on + a plane since it has no tolerance to compensate for numerical errors. + + Parameters + ---------- + point : + Point + p_a : + First point of the triangle 'A - B - C'. + p_b : + Second point of the triangle 'A - B - C'. + p_c : + Third point of the triangle 'A - B - C'. + + Returns + ------- + int + 1, -1 or 0 (see description) + + """ + vec_a_b = p_b - p_a + vec_a_c = p_c - p_a + vec_a_point = point - p_a + return orientation_point_plane_containing_origin(vec_a_point, vec_a_b, vec_a_c) + + +def is_orthogonal(vec_u, vec_v, tolerance=1e-9): + """Check if vectors are orthogonal. + + Parameters + ---------- + vec_u : + First vector + vec_v : + Second vector + tolerance : + Numerical tolerance (Default value = 1e-9) + + Returns + ------- + bool + True or False + + """ + if math.isclose(np.dot(vec_u, vec_u), 0) or math.isclose(np.dot(vec_v, vec_v), 0): + raise ValueError("One or both vectors have zero length.") + + return math.isclose(np.dot(vec_u, vec_v), 0, abs_tol=tolerance) + + +def is_orthogonal_matrix(a: np.ndarray, atol=1e-9) -> bool: + """Check if ndarray is orthogonal matrix in the last two dimensions. + + Parameters + ---------- + a : + Matrix to check + atol : + atol to pass onto np.allclose (Default value = 1e-9) + + Returns + ------- + bool + True if last 2 dimensions of a are orthogonal + + """ + return np.allclose(np.matmul(a, a.swapaxes(-1, -2)), np.eye(a.shape[-1]), atol=atol) + + +def point_left_of_line(point, line_start, line_end): + """Determine if a point lies left of a line. + + Returns 1 if the point is left of the line and -1 if it is to the right. + If the point is located on the line, this function returns 0. + + Parameters + ---------- + point : + Point + line_start : + Starting point of the line + line_end : + End point of the line + + Returns + ------- + int + 1,-1 or 0 (see description) + + """ + vec_line_start_end = line_end - line_start + vec_line_start_point = point - line_start + return vector_points_to_left_of_vector(vec_line_start_point, vec_line_start_end) + + +def reflection_sign(matrix): + """Get a sign indicating if the transformation is a reflection. + + Returns -1 if the transformation contains a reflection and 1 if not. + + Parameters + ---------- + matrix : + Transformation matrix + + Returns + ------- + int + 1 or -1 (see description) + + """ + sign = int(np.sign(np.linalg.det(matrix))) + + if sign == 0: + raise ValueError("Invalid transformation") + + return sign + + +def vector_points_to_left_of_vector(vector, vector_reference): + """Determine if a vector points to the left of another vector. + + Returns 1 if the vector points to the left of the reference vector and + -1 if it points to the right. In case both vectors point into the same + or the opposite directions, this function returns 0. + + Parameters + ---------- + vector : + Vector + vector_reference : + Reference vector + + Returns + ------- + int + 1,-1 or 0 (see description) + + """ + return int(np.sign(np.linalg.det([vector_reference, vector]))) diff --git a/weldx/utility.py b/weldx/util.py similarity index 97% rename from weldx/utility.py rename to weldx/util.py index 997125816..400486239 100644 --- a/weldx/utility.py +++ b/weldx/util.py @@ -1,8 +1,10 @@ """Contains package internal utility functions.""" import math +import warnings from collections.abc import Iterable from functools import reduce +from inspect import getmembers, isfunction from typing import Any, Dict, List, Union import numpy as np @@ -72,6 +74,38 @@ def inner_decorator( return inner_decorator +def inherit_docstrings(cls): + """Inherits (public) docstrings from parent classes. + + Traverses the MRO until it finds a docstring to use, or leave it blank, + in case no parent has a docstring available. + + Parameters + ---------- + cls: type + The class to decorate. + + Returns + ------- + cls: type + The class with updated doc strings. + + """ + for name, func in getmembers( + cls, predicate=lambda x: isfunction(x) or isinstance(x, property) + ): + if func.__doc__ or name.startswith("_"): + continue + for parent in cls.__mro__[1:]: + if hasattr(parent, name): + func.__doc__ = getattr(parent, name).__doc__ + if not func.__doc__: + warnings.warn( + f"could not derive docstring for {cls}.{name}", stacklevel=1 + ) + return cls + + def sine( f: pint.Quantity, amp: pint.Quantity, diff --git a/weldx/visualization.py b/weldx/visualization.py deleted file mode 100644 index be1aabf37..000000000 --- a/weldx/visualization.py +++ /dev/null @@ -1,1459 +0,0 @@ -"""Contains some functions to help with visualization.""" - -from typing import Any, Dict, Generator, List, Tuple, Union - -import k3d -import k3d.platonic as platonic -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd -from IPython.display import display -from ipywidgets import Checkbox, Dropdown, HBox, IntSlider, Layout, Play, VBox, jslink - -import weldx.geometry as geo - -RGB_BLACK = 0x000000 -RGB_BLUE = 0x0000FF -RGB_CYAN = 0x00FFFF -RGB_GREEN = 0x00AA00 -RGB_MAGENTA = 0xFF00FF -RGB_RED = 0xFF0000 -RGB_YELLOW = 0xAAAA00 - - -def _color_rgb_to_int(rgb_color_tuple: Tuple[int, int, int]) -> int: - """Convert an RGB color tuple to an 24 bit integer. - - Parameters - ---------- - rgb_color_tuple : Tuple[int, int, int] - The color as RGB tuple. Values must be in the range 0-255. - - Returns - ------- - int : - Color as 24 bit integer - - """ - return int("0x{:02x}{:02x}{:02x}".format(*rgb_color_tuple), 0) - - -def _color_int_to_rgb(integer: int) -> Tuple[int, int, int]: - """Convert an 24 bit integer into a RGB color tuple with the value range (0-255). - - Parameters - ---------- - integer : int - The value that should be converted - - Returns - ------- - Tuple[int, int, int]: - The resulting RGB tuple. - - """ - return ((integer >> 16) & 255, (integer >> 8) & 255, integer & 255) - - -def _color_rgb_to_rgb_normalized( - rgb: Tuple[int, int, int] -) -> Tuple[float, float, float]: - """Normalize an RGB color tuple with the range (0-255) to the range (0.0-1.0). - - Parameters - ---------- - rgb : Tuple[int, int, int] - Color tuple with values in the range (0-255) - - Returns - ------- - Tuple[float, float, float] : - Color tuple with values in the range (0.0-1.0) - - """ - return tuple([val / 255 for val in rgb]) - - -def _color_rgb_normalized_to_rgb( - rgb: Tuple[float, float, float] -) -> Tuple[int, int, int]: - """Normalize an RGB color tuple with the range (0.0-1.0) to the range (0-255). - - Parameters - ---------- - rgb : Tuple[float, float, float] - Color tuple with values in the range (0.0-1.0) - - Returns - ------- - Tuple[int, int, int] : - Color tuple with values in the range (0-255) - - """ - return tuple([int(np.round(val * 255)) for val in rgb]) - - -def _color_int_to_rgb_normalized(integer): - """Convert an 24 bit integer into a RGB color tuple with the value range (0.0-1.0). - - Parameters - ---------- - integer : int - The value that should be converted - - Returns - ------- - Tuple[float, float, float]: - The resulting RGB tuple. - - """ - rgb = _color_int_to_rgb(integer) - return _color_rgb_to_rgb_normalized(rgb) - - -def _color_rgb_normalized_to_int(rgb: Tuple[float, float, float]) -> int: - """Convert a normalized RGB color tuple to an 24 bit integer. - - Parameters - ---------- - rgb : Tuple[float, float, float] - The color as RGB tuple. Values must be in the range 0.0-1.0. - - Returns - ------- - int : - Color as 24 bit integer - - """ - return _color_rgb_to_int(_color_rgb_normalized_to_rgb(rgb)) - - -def _shuffled_tab20_colors() -> List[int]: - """Get a shuffled list of matplotlib 'tab20' colors. - - Returns - ------- - List[int] : - List of colors - - """ - num_colors = 20 - colormap = plt.cm.get_cmap("tab20", num_colors) - colors = [colormap(i)[:3] for i in range(num_colors)] - - # randomize colors - np.random.seed(42) - np.random.shuffle(colors) - - return [_color_rgb_normalized_to_int(color) for color in colors] - - -_color_list = [ - RGB_RED, - RGB_GREEN, - RGB_BLUE, - RGB_YELLOW, - RGB_CYAN, - RGB_MAGENTA, - *_shuffled_tab20_colors(), -] - - -def _color_generator_function() -> int: - """Yield a 24 bit RGB color integer. - - The returned value is taken from a predefined list. - - Yields - ------ - int: - 24 bit RGB color integer - - """ - while True: - for color in _color_list: - yield color - - -def _get_color(key: str, color_dict: Dict[str, int], color_generator: Generator) -> int: - """Get a 24 bit RGB color from a dictionary or generator function. - - If the provided key is found in the dictionary, the corresponding color is returned. - Otherwise, the generator is used to provide a color. - - Parameters - ---------- - key : str - The key that should be searched for in the dictionary - color_dict : Dict[str, int] - A dictionary containing name to color mappings - color_generator : Generator - A generator that returns a color integer - - Returns - ------- - int : - RGB color as 24 bit integer - - """ - if color_dict is not None and key in color_dict: - return _color_rgb_to_int(color_dict[key]) - return next(color_generator) - - -def new_3d_figure_and_axes( - num_subplots: int = 1, height: int = 500, width: int = 500, pixel_per_inch: int = 50 -): - """Get a matplotlib figure and axes for 3d plots. - - Parameters - ---------- - num_subplots : int - Number of subplots (horizontal) - height : int - Height in pixels - width : int - Width in pixels - pixel_per_inch : - Defines how many pixels an inch covers. This is only relevant for the fallback - method. - - Returns - ------- - fig : matplotlib.figure.Figure - The matplotlib figure object - ax : matplotlib..axes.Axes - The matplotlib axes object - - """ - fig, ax = plt.subplots( - ncols=num_subplots, subplot_kw={"projection": "3d", "proj_type": "ortho"} - ) - try: - fig.canvas.layout.height = f"{height}px" - fig.canvas.layout.width = f"{width}px" - except Exception: # skipcq: PYL-W0703 - fig.set_size_inches(w=width / pixel_per_inch, h=height / pixel_per_inch) - return fig, ax - - -def draw_coordinate_system_matplotlib( - coordinate_system, - axes: plt.Axes.axes, - color: Any = None, - label: str = None, - time_idx: int = None, - show_origin: bool = True, - show_vectors: bool = True, -): - """Draw a coordinate system in a matplotlib 3d plot. - - Parameters - ---------- - coordinate_system : weldx.transformations.LocalCoordinateSystem - Coordinate system - axes : matplotlib.axes.Axes - Target matplotlib axes object - color : Any - Valid matplotlib color selection. The origin of the coordinate system - will be marked with this color. - label : str - Name that appears in the legend. Only viable if a color - was specified. - time_idx : int - Selects time dependent data by index if the coordinate system has - a time dependency. - show_origin : bool - If `True`, the origin of the coordinate system will be highlighted in the - color passed as another parameter - show_vectors : bool - If `True`, the the coordinate axes of the coordinate system are visualized - - """ - if not (show_vectors or show_origin): - return - if "time" in coordinate_system.dataset.coords: - if time_idx is None: - time_idx = 0 - if isinstance(time_idx, int): - dsx = coordinate_system.dataset.isel(time=time_idx) - else: - dsx = coordinate_system.dataset.sel(time=time_idx).isel(time=0) - else: - dsx = coordinate_system.dataset - - p_0 = dsx.coordinates - - if show_vectors: - orientation = dsx.orientation - p_x = p_0 + orientation[:, 0] - p_y = p_0 + orientation[:, 1] - p_z = p_0 + orientation[:, 2] - - axes.plot([p_0[0], p_x[0]], [p_0[1], p_x[1]], [p_0[2], p_x[2]], "r") - axes.plot([p_0[0], p_y[0]], [p_0[1], p_y[1]], [p_0[2], p_y[2]], "g") - axes.plot([p_0[0], p_z[0]], [p_0[1], p_z[1]], [p_0[2], p_z[2]], "b") - if color is not None: - if show_origin: - axes.plot([p_0[0]], [p_0[1]], [p_0[2]], "o", color=color, label=label) - elif label is not None: - raise Exception("Labels can only be assigned if a color was specified") - - -def set_axes_equal(axes): - """Adjust axis in a 3d plot to be equally scaled. - - Source code taken from the stackoverflow answer of 'karlo' in the - following question: - https://stackoverflow.com/questions/13685386/matplotlib-equal-unit - -length-with-equal-aspect-ratio-z-axis-is-not-equal-to - - Parameters - ---------- - axes : - Matplotlib axes object (output from plt.gca()) - - """ - x_limits = axes.get_xlim3d() - y_limits = axes.get_ylim3d() - z_limits = axes.get_zlim3d() - - x_range = abs(x_limits[1] - x_limits[0]) - x_middle = np.mean(x_limits) - y_range = abs(y_limits[1] - y_limits[0]) - y_middle = np.mean(y_limits) - z_range = abs(z_limits[1] - z_limits[0]) - z_middle = np.mean(z_limits) - - # The plot bounding box is a sphere in the sense of the infinity - # norm, hence I call half the max range the plot radius. - plot_radius = 0.5 * max([x_range, y_range, z_range]) - - axes.set_xlim3d([x_middle - plot_radius, x_middle + plot_radius]) - axes.set_ylim3d([y_middle - plot_radius, y_middle + plot_radius]) - axes.set_zlim3d([z_middle - plot_radius, z_middle + plot_radius]) - - -def plot_local_coordinate_system_matplotlib( - lcs, - axes: plt.Axes.axes = None, - color: Any = None, - label: str = None, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]] = None, - time_ref: pd.Timestamp = None, - time_index: int = None, - show_origin: bool = True, - show_trace: bool = True, - show_vectors: bool = True, -) -> plt.Axes.axes: - """Visualize a `weldx.transformations.LocalCoordinateSystem` using matplotlib. - - Parameters - ---------- - lcs : weldx.transformations.LocalCoordinateSystem - The coordinate system that should be visualized - axes : matplotlib.axes.Axes - The target matplotlib axes. If `None` is provided, a new one will be created - color : Any - An arbitrary color. The data type must be compatible with matplotlib. - label : str - Name of the coordinate system - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem - The time steps that should be plotted - time_ref : pandas.Timestamp - A reference timestamp that can be provided if the ``time`` parameter is a - `pandas.TimedeltaIndex` - time_index : int - Index of a specific time step that should be plotted - show_origin : bool - If `True`, the origin of the coordinate system will be highlighted in the - color passed as another parameter - show_trace : - If `True`, the trace of a time dependent coordinate system will be visualized in - the color passed as another parameter - show_vectors : bool - If `True`, the the coordinate axes of the coordinate system are visualized - - Returns - ------- - matplotlib.axes.Axes : - The axes object that was used as canvas for the plot - - """ - if axes is None: - _, axes = plt.subplots(subplot_kw={"projection": "3d", "proj_type": "ortho"}) - - if lcs.is_time_dependent and time is not None: - lcs = lcs.interp_time(time, time_ref) - - if lcs.is_time_dependent and time_index is None: - for i, _ in enumerate(lcs.time): - draw_coordinate_system_matplotlib( - lcs, - axes, - color=color, - label=label, - time_idx=i, - show_origin=show_origin, - show_vectors=show_vectors, - ) - label = None - else: - draw_coordinate_system_matplotlib( - lcs, - axes, - color=color, - label=label, - time_idx=time_index, - show_origin=show_origin, - show_vectors=show_vectors, - ) - - if show_trace and lcs.coordinates.values.ndim > 1: - coords = lcs.coordinates.values - if color is None: - color = "k" - axes.plot(coords[:, 0], coords[:, 1], coords[:, 2], ":", color=color) - - return axes - - -def _set_limits_matplotlib( - axes: plt.Axes.axes, limits: Union[List[Tuple[float, float]], Tuple[float, float]] -): - """Set the limits of an axes object. - - Parameters - ---------- - axes : matplotlib.axes.Axes - The axes object - limits : Tuple[float, float] or List[Tuple[float, float]] - Each tuple marks lower and upper boundary of the x, y and z axis. If only a - single tuple is passed, the boundaries are used for all axis. If `None` - is provided, the axis are adjusted to be of equal length. - - """ - if limits is None: - set_axes_equal(axes) - else: - if isinstance(limits, Tuple): - limits = [limits] - if len(limits) == 1: - limits = [limits[0] for _ in range(3)] - axes.set_xlim(limits[0]) - axes.set_ylim(limits[1]) - axes.set_zlim(limits[2]) - - -def plot_coordinate_system_manager_matplotlib( - csm, - axes: plt.Axes.axes = None, - reference_system: str = None, - coordinate_systems: List[str] = None, - data_sets: List[str] = None, - colors: Dict[str, int] = None, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]] = None, - time_ref: pd.Timestamp = None, - title: str = None, - limits: Union[List[Tuple[float, float]], Tuple[float, float]] = None, - show_origins: bool = True, - show_trace: bool = True, - show_vectors: bool = True, -) -> plt.Axes.axes: - """Plot the coordinate systems of a `weldx.transformations.CoordinateSystemManager`. - - Parameters - ---------- - csm : weldx.transformations.CoordinateSystemManager - The `weldx.transformations.CoordinateSystemManager` that should be plotted - axes : matplotlib.axes.Axes - The target axes object that should be drawn to. If `None` is provided, a new - one will be created. - reference_system : str - The name of the reference system for the plotted coordinate systems - coordinate_systems : List[str] - Names of the coordinate systems that should be drawn. If `None` is provided, - all systems are plotted. - data_sets : List[str] - Names of the data sets that should be drawn. If `None` is provided, all data - is plotted. - colors: Dict[str, int] - A mapping between a coordinate system name or a data set name and a color. - The colors must be provided as 24 bit integer values that are divided into - three 8 bit sections for the rgb values. For example `0xFF0000` for pure - red. - Each coordinate system or data set that does not have a mapping in this - dictionary will get a default color assigned to it. - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - weldx.transformations.LocalCoordinateSystem - The time steps that should be plotted - time_ref : pandas.Timestamp - A reference timestamp that can be provided if the ``time`` parameter is a - `pandas.TimedeltaIndex` - title : str - The title of the plot - limits : Tuple[float, float] or List[Tuple[float, float]] - Each tuple marks lower and upper boundary of the x, y and z axis. If only a - single tuple is passed, the boundaries are used for all axis. If `None` - is provided, the axis are adjusted to be of equal length. - show_origins : bool - If `True`, the origins of the coordinate system are visualized in the color - assigned to the coordinate system. - show_trace : bool - If `True`, the trace of time dependent coordinate systems is plotted. - show_vectors : bool - If `True`, the coordinate cross of time dependent coordinate systems is plotted. - - Returns - ------- - matplotlib.axes.Axes : - The axes object that was used as canvas for the plot - - """ - if time is not None: - return plot_coordinate_system_manager_matplotlib( - csm.interp_time(time=time, time_ref=time_ref), - axes=axes, - reference_system=reference_system, - coordinate_systems=coordinate_systems, - title=title, - show_origins=show_origins, - show_trace=show_trace, - show_vectors=show_vectors, - ) - if axes is None: - _, axes = new_3d_figure_and_axes() - axes.set_xlabel("x") - axes.set_ylabel("y") - axes.set_zlabel("z") - - if reference_system is None: - reference_system = csm.root_system_name - if coordinate_systems is None: - coordinate_systems = csm.coordinate_system_names - if data_sets is None: - data_sets = csm.data_names - if title is not None: - axes.set_title(title) - - # plot coordinate systems - color_gen = _color_generator_function() - for lcs_name in coordinate_systems: - color = _color_int_to_rgb_normalized(_get_color(lcs_name, colors, color_gen)) - lcs = csm.get_cs(lcs_name, reference_system) - lcs.plot( - axes=axes, - color=color, - label=lcs_name, - show_origin=show_origins, - show_trace=show_trace, - show_vectors=show_vectors, - ) - # plot data - for data_name in data_sets: - color = _color_int_to_rgb_normalized(_get_color(data_name, colors, color_gen)) - data = csm.get_data(data_name, reference_system) - triangles = None - if isinstance(data, geo.SpatialData): - triangles = data.triangles - data = data.coordinates - - data = data.data - while data.ndim > 2: - data = data[0] - - axes.plot(data[:, 0], data[:, 1], data[:, 2], "x", color=color, label=data_name) - if triangles is not None: - for triangle in triangles: - triangle_data = data[[*triangle, triangle[0]], :] - axes.plot( - triangle_data[:, 0], - triangle_data[:, 1], - triangle_data[:, 2], - color=color, - ) - - _set_limits_matplotlib(axes, limits) - axes.legend() - - return axes - - -def plot_coordinate_systems( - cs_data: Tuple[str, Dict], - axes: plt.Axes.axes = None, - title: str = None, - limits: Union[List[Tuple[float, float]], Tuple[float, float]] = None, - time_index: int = None, - legend_pos: str = "lower left", -) -> plt.Axes.axes: - """Plot multiple coordinate systems. - - Parameters - ---------- - cs_data : Tuple[str, Dict] - A tuple containing the coordinate system that should be plotted and a dictionary - with the key word arguments that should be passed to its plot function. - axes : matplotlib.axes.Axes - The target axes object that should be drawn to. If `None` is provided, a new - one will be created. - title : str - The title of the plot - limits : Tuple[float, float] or List[Tuple[float, float]] - Each tuple marks lower and upper boundary of the x, y and z axis. If only a - single tuple is passed, the boundaries are used for all axis. If `None` - is provided, the axis are adjusted to be of equal length. - time_index : int - Index of a specific time step that should be plotted if the corresponding - coordinate system is time dependent - legend_pos : str - A string that specifies the position of the legend. See the matplotlib - documentation for further details - - Returns - ------- - matplotlib.axes.Axes : - The axes object that was used as canvas for the plot - - """ - if axes is None: - _, axes = new_3d_figure_and_axes() - - for lcs, kwargs in cs_data: - if "time_index" not in kwargs: - kwargs["time_index"] = time_index - lcs.plot(axes, **kwargs) - - _set_limits_matplotlib(axes, limits) - - if title is not None: - axes.set_title(title) - axes.legend(loc=legend_pos) - - return axes - - -# k3d ---------------------------------------------------------------------------------- - - -class CoordinateSystemVisualizerK3D: - """Visualizes a `weldx.transformations.LocalCoordinateSystem` using k3d.""" - - def __init__( - self, - lcs, - plot: k3d.Plot = None, - name: str = None, - color: int = RGB_BLACK, - show_origin=True, - show_trace=True, - show_vectors=True, - ): - """Create a `CoordinateSystemVisualizerK3D`. - - Parameters - ---------- - lcs : weldx.transformations.LocalCoordinateSystem - Coordinate system that should be visualized - plot : k3d.Plot - A k3d plotting widget. - name : str - Name of the coordinate system - color : int - The RGB color of the coordinate system (affects trace and label) as a 24 bit - integer value. - show_origin : bool - If `True`, the origin of the coordinate system will be highlighted in the - color passed as another parameter - show_trace : - If `True`, the trace of a time dependent coordinate system will be - visualized in the color passed as another parameter - show_vectors : bool - If `True`, the the coordinate axes of the coordinate system are visualized - - """ - coordinates, orientation = self._get_coordinates_and_orientation(lcs) - self._lcs = lcs - self._color = color - - self._vectors = k3d.vectors( - origins=[coordinates for _ in range(3)], - vectors=orientation.transpose(), - colors=[[RGB_RED, RGB_RED], [RGB_GREEN, RGB_GREEN], [RGB_BLUE, RGB_BLUE]], - labels=[], - label_size=1.5, - ) - self._vectors.visible = show_vectors - - self._label = None - if name is not None: - self._label = k3d.text( - text=name, - position=coordinates + 0.05, - color=self._color, - size=1, - label_box=False, - ) - - self._trace = k3d.line( - np.array(lcs.coordinates.values, dtype="float32"), - shader="simple", - width=0.05, - color=color, - ) - self._trace.visible = show_trace - - self.origin = platonic.Octahedron(size=0.1).mesh - self.origin.color = color - self.origin.model_matrix = self._create_model_matrix(coordinates, orientation) - self.origin.visible = show_origin - - if plot is not None: - plot += self._vectors - plot += self._trace - plot += self.origin - if self._label is not None: - plot += self._label - - @staticmethod - def _create_model_matrix( - coordinates: np.ndarray, orientation: np.ndarray - ) -> np.ndarray: - """Create the model matrix from an orientation and coordinates. - - Parameters - ---------- - coordinates : numpy.ndarray - The coordinates of the origin - orientation : numpy.ndarray - The orientation of the coordinate system - - Returns - ------- - numpy.ndarray : - The model matrix - - """ - model_matrix = np.eye(4, dtype="float32") - model_matrix[:3, :3] = orientation - model_matrix[:3, 3] = coordinates - return model_matrix - - @staticmethod - def _get_coordinates_and_orientation(lcs, index: int = 0): - """Get the coordinates and orientation of a coordinate system. - - Parameters - ---------- - lcs : weldx.LocalCoordinateSystem - The coordinate system - index : int - If the coordinate system is time dependent, the passed value is the index - of the values that should be returned - - Returns - ------- - coordinates : numpy.ndarray - The coordinates - orientation : numpy.ndarray - The orientation - - """ - coordinates = lcs.coordinates.isel( - time=index, missing_dims="ignore" - ).values.astype("float32") - - orientation = lcs.orientation.isel( - time=index, missing_dims="ignore" - ).values.astype("float32") - - return coordinates, orientation - - def _update_positions(self, coordinates: np.ndarray, orientation: np.ndarray): - """Update the positions of the coordinate cross and label. - - Parameters - ---------- - coordinates : numpy.ndarray - The new coordinates - orientation : numpy.ndarray - The new orientation - - """ - self._vectors.origins = [coordinates for _ in range(3)] - self._vectors.vectors = orientation.transpose() - self.origin.model_matrix = self._create_model_matrix(coordinates, orientation) - if self._label is not None: - self._label.position = coordinates + 0.05 - - def show_label(self, show_label: bool): - """Set the visibility of the label. - - Parameters - ---------- - show_label : bool - If `True`, the label will be shown - - """ - self._label.visible = show_label - - def show_origin(self, show_origin: bool): - """Set the visibility of the coordinate systems' origin. - - Parameters - ---------- - show_origin : bool - If `True`, the coordinate systems origin is shown. - - """ - self.origin.visible = show_origin - - def show_trace(self, show_trace: bool): - """Set the visibility of coordinate systems' trace. - - Parameters - ---------- - show_trace : bool - If `True`, the coordinate systems' trace is shown. - - """ - self._trace.visible = show_trace - - def show_vectors(self, show_vectors: bool): - """Set the visibility of the coordinate axis vectors. - - Parameters - ---------- - show_vectors : bool - If `True`, the coordinate axis vectors are shown. - - """ - self._vectors.visible = show_vectors - - def update_lcs(self, lcs, index: int = 0): - """Pass a new coordinate system to the visualizer. - - Parameters - ---------- - lcs : weldx.transformations.LocalCoordinateSystem - The new coordinate system - index : int - The time index of the new coordinate system that should be visualized. - - """ - self._lcs = lcs - self._trace.vertices = np.array(lcs.coordinates.values, dtype="float32") - self.update_time_index(index) - - def update_time( - self, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]], - time_ref: pd.Timestamp = None, - ): - """Update the plotted time step. - - Parameters - ---------- - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem - The time steps that should be plotted - time_ref : pandas.Timestamp - A reference timestamp that can be provided if the ``time`` parameter is a - `pandas.TimedeltaIndex` - - """ - lcs = self._lcs.interp_time(time, time_ref) - coordinates, orientation = self._get_coordinates_and_orientation(lcs) - - self._update_positions(coordinates, orientation) - - def update_time_index(self, index: int): - """Update the plotted time step. - - Parameters - ---------- - index : int - The array index of the time step - - """ - coordinates, orientation = self._get_coordinates_and_orientation( - self._lcs, index - ) - self._update_positions(coordinates, orientation) - - -class SpatialDataVisualizer: - """Visualizes spatial data.""" - - visualization_methods = ["auto", "point", "mesh", "both"] - - def __init__( - self, - data, - name: str, - cs_vis: CoordinateSystemVisualizerK3D, - plot: k3d.Plot = None, - color: int = RGB_BLACK, - visualization_method: str = "auto", - show_wireframe: bool = False, - ): - """Create a 'SpatialDataVisualizer' instance. - - Parameters - ---------- - data : numpy.ndarray or weldx.geometry.SpatialData - The data that should be visualized - name : str - Name of the data - cs_vis : CoordinateSystemVisualizerK3D - An instance of the 'CoordinateSystemVisualizerK3D'. This serves as reference - coordinate system for the data and is needed to calculate the correct - position of the data - plot : k3d.Plot - A k3d plotting widget. - color : int - The RGB color of the coordinate system (affects trace and label) as a 24 bit - integer value. - visualization_method : str - The initial data visualization method. Options are 'point', 'mesh', 'both' - and 'auto'. If 'auto' is selected, a mesh will be drawn if triangle data is - available and points if not. - show_wireframe : bool - If 'True', meshes will be drawn as wireframes - - """ - triangles = None - if isinstance(data, geo.SpatialData): - triangles = data.triangles - data = data.coordinates.data - - self._cs_vis = cs_vis - - self._label_pos = np.mean(data, axis=0) - self._label = None - if name is not None: - self._label = k3d.text( - text=name, - position=self._label_pos, - reference_point="cc", - color=color, - size=0.5, - label_box=True, - ) - self._points = k3d.points(data, point_size=0.05, color=color) - self._mesh = None - if triangles is not None: - self._mesh = k3d.mesh( - data, triangles, side="double", color=color, wireframe=show_wireframe - ) - - self.update_model_matrix() - self.set_visualization_method(visualization_method) - - if plot is not None: - plot += self._points - if self._mesh is not None: - plot += self._mesh - if self._label is not None: - plot += self._label - - def set_visualization_method(self, method: str): - """Set the visualization method. - - Parameters - ---------- - method : str - The data visualization method. Options are 'point', 'mesh', 'both' and - 'auto'. If 'auto' is selected, a mesh will be drawn if triangle data is - available and points if not. - - """ - if method not in SpatialDataVisualizer.visualization_methods: - raise ValueError(f"Unknown visualization method: '{method}'") - - if method == "auto": - if self._mesh is not None: - method = "mesh" - else: - method = "point" - - self._points.visible = method in ["point", "both"] - if self._mesh is not None: - self._mesh.visible = method in ["mesh", "both"] - - def show_label(self, show_label: bool): - """Set the visibility of the label. - - Parameters - ---------- - show_label : bool - If `True`, the label will be shown - - """ - self._label.visible = show_label - - def show_wireframe(self, show_wireframe: bool): - """Set wireframe rendering. - - Parameters - ---------- - show_wireframe : bool - If `True`, the mesh will be rendered as wireframe - - """ - if self._mesh is not None: - self._mesh.wireframe = show_wireframe - - def update_model_matrix(self): - """Update the model matrices of the k3d objects.""" - model_mat = self._cs_vis.origin.model_matrix - self._points.model_matrix = model_mat - if self._mesh is not None: - self._mesh.model_matrix = model_mat - if self._label is not None: - self._label.position = ( - np.matmul(model_mat[0:3, 0:3], self._label_pos) + model_mat[0:3, 3] - ) - - -class CoordinateSystemManagerVisualizerK3D: - """Visualizes a `weldx.transformations.CoordinateSystemManager` using k3d.""" - - def __init__( - self, - csm, - coordinate_systems: List[str] = None, - data_sets: List[str] = None, - colors: Dict[str, int] = None, - reference_system: str = None, - title: str = None, - limits: List[Tuple[float, float]] = None, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]] = None, - time_ref: pd.Timestamp = None, - show_data_labels: bool = True, - show_labels: bool = True, - show_origins: bool = True, - show_traces: bool = True, - show_vectors: bool = True, - show_wireframe: bool = True, - ): - """Create a `CoordinateSystemManagerVisualizerK3D`. - - Parameters - ---------- - csm : weldx.transformations.CoordinateSystemManager - The `weldx.transformations.CoordinateSystemManager` instance that should be - visualized - coordinate_systems : List[str] - The names of the coordinate systems that should be visualized. If ´None´ is - provided, all systems are plotted - data_sets : List[str] - The names of data sets that should be visualized. If ´None´ is provided, all - data is plotted - colors : Dict[str, int] - A mapping between a coordinate system name or a data set name and a color. - The colors must be provided as 24 bit integer values that are divided into - three 8 bit sections for the rgb values. For example `0xFF0000` for pure - red. - Each coordinate system or data set that does not have a mapping in this - dictionary will get a default color assigned to it. - reference_system : str - Name of the initial reference system. If `None` is provided, the root system - of the `weldx.transformations.CoordinateSystemManager` instance will be used - title : str - The title of the plot - limits : List[Tuple[float, float]] - The limits of the plotted volume - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - weldx.transformations.LocalCoordinateSystem - The time steps that should be plotted initially - time_ref : pandas.Timestamp - A reference timestamp that can be provided if the ``time`` parameter is a - `pandas.TimedeltaIndex` - show_data_labels : bool - If `True`, the data labels will be shown initially - show_labels : bool - If `True`, the coordinate system labels will be shown initially - show_origins : bool - If `True`, the coordinate systems' origins will be shown initially - show_traces : bool - If `True`, the coordinate systems' traces will be shown initially - show_vectors : bool - If `True`, the coordinate systems' axis vectors will be shown initially - show_wireframe : bool - If `True`, spatial data containing mesh data will be drawn as wireframe - - """ - if time is None: - time = csm.time_union() - if time is not None: - csm = csm.interp_time(time=time, time_ref=time_ref) - self._csm = csm.interp_time(time=time, time_ref=time_ref) - self._current_time_index = 0 - - if coordinate_systems is None: - coordinate_systems = csm.coordinate_system_names - if data_sets is None: - data_sets = self._csm.data_names - if reference_system is None: - reference_system = self._csm.root_system_name - - grid_auto_fit = True - grid = (-1, -1, -1, 1, 1, 1) - if limits is not None: - grid_auto_fit = False - if len(limits) == 1: - grid = [limits[0][int(i / 3)] for i in range(6)] - else: - grid = [limits[i % 3][int(i / 3)] for i in range(6)] - - # create plot - self._color_generator = _color_generator_function() - plot = k3d.plot( - grid_auto_fit=grid_auto_fit, - grid=grid, - ) - self._lcs_vis = { - lcs_name: CoordinateSystemVisualizerK3D( - self._csm.get_cs(lcs_name, reference_system), - plot, - lcs_name, - color=_get_color(lcs_name, colors, self._color_generator), - show_origin=show_origins, - show_trace=show_traces, - show_vectors=show_vectors, - ) - for lcs_name in coordinate_systems - } - self._data_vis = { - data_name: SpatialDataVisualizer( - self._csm.get_data(data_name=data_name), - data_name, - self._lcs_vis[self._csm.get_data_system_name(data_name=data_name)], - plot, - color=_get_color(data_name, colors, self._color_generator), - show_wireframe=show_wireframe, - ) - for data_name in data_sets - } - - # create controls - self._controls = self._create_controls( - time, - reference_system, - show_data_labels, - show_labels, - show_origins, - show_traces, - show_vectors, - show_wireframe, - ) - - # add title - self._title = None - if title is not None: - self._title = k3d.text2d( - f"{title}", - position=(0.5, 0), - color=RGB_BLACK, - is_html=True, - size=1.5, - reference_point="ct", - ) - plot += self._title - - # add time info - self._time = time - self._time_ref = time_ref - self._time_info = None - if time is not None: - self._time_info = k3d.text2d( - f"time: {time[0]}", - position=(0, 1), - color=RGB_BLACK, - is_html=True, - size=1.0, - reference_point="lb", - ) - plot += self._time_info - - # display everything - plot.display() - display(self._controls) - - # workaround since using it inside the init method of the coordinate system - # visualizer somehow causes the labels to be created twice with one version - # being always visible - self.show_labels(show_labels) - - def _create_controls( - self, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]], - reference_system: str, - show_data_labels: bool, - show_labels: bool, - show_origins: bool, - show_traces: bool, - show_vectors: bool, - show_wireframe: bool, - ): - """Create the control panel. - - Parameters - ---------- - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem - The time steps that should be plotted initially - reference_system : str - Name of the initial reference system. If `None` is provided, the root system - of the `CoordinateSystemManager` instance will be used - show_data_labels : bool - If `True`, the data labels will be shown initially - show_labels : bool - If `True`, the coordinate system labels will be shown initially - show_origins : bool - If `True`, the coordinate systems' origins will be shown initially - show_traces : bool - If `True`, the coordinate systems' traces will be shown initially - show_vectors : bool - If `True`, the coordinate systems' axis vectors will be shown initially - show_wireframe : bool - If `True`, spatial data containing mesh data will be drawn as wireframe - - """ - num_times = 1 - disable_time_widgets = True - lo = Layout(width="200px") - - # create widgets - if time is not None: - num_times = len(time) - disable_time_widgets = False - - play = Play( - min=0, - max=num_times - 1, - value=self._current_time_index, - step=1, - ) - time_slider = IntSlider( - min=0, - max=num_times - 1, - value=self._current_time_index, - description="Time:", - ) - reference_dropdown = Dropdown( - options=self._csm.coordinate_system_names, - value=reference_system, - description="Reference:", - disabled=False, - ) - data_dropdown = Dropdown( - options=SpatialDataVisualizer.visualization_methods, - value="auto", - description="data repr.:", - disabled=False, - layout=lo, - ) - - lo = Layout(width="200px") - vectors_cb = Checkbox(value=show_vectors, description="show vectors", layout=lo) - origin_cb = Checkbox(value=show_origins, description="show origins", layout=lo) - traces_cb = Checkbox(value=show_traces, description="show traces", layout=lo) - labels_cb = Checkbox(value=show_labels, description="show labels", layout=lo) - wf_cb = Checkbox(value=show_wireframe, description="show wireframe", layout=lo) - data_labels_cb = Checkbox( - value=show_data_labels, description="show data labels", layout=lo - ) - - jslink((play, "value"), (time_slider, "value")) - play.disabled = disable_time_widgets - time_slider.disabled = disable_time_widgets - - # callback functions - def _reference_callback(change): - self.update_reference_system(change["new"]) - - def _time_callback(change): - self.update_time_index(change["new"]) - - def _vectors_callback(change): - self.show_vectors(change["new"]) - - def _origins_callback(change): - self.show_origins(change["new"]) - - def _traces_callback(change): - self.show_traces(change["new"]) - - def _labels_callback(change): - self.show_labels(change["new"]) - - def _data_callback(change): - self.set_data_visualization_method(change["new"]) - - def _data_labels_callback(change): - self.show_data_labels(change["new"]) - - def _wireframe_callback(change): - self.show_wireframes(change["new"]) - - # register callbacks - time_slider.observe(_time_callback, names="value") - reference_dropdown.observe(_reference_callback, names="value") - vectors_cb.observe(_vectors_callback, names="value") - origin_cb.observe(_origins_callback, names="value") - traces_cb.observe(_traces_callback, names="value") - labels_cb.observe(_labels_callback, names="value") - data_dropdown.observe(_data_callback, names="value") - data_labels_cb.observe(_data_labels_callback, names="value") - wf_cb.observe(_wireframe_callback, names="value") - - # create control panel - row_1 = HBox([time_slider, play, reference_dropdown]) - row_2 = HBox([vectors_cb, origin_cb, traces_cb, labels_cb]) - if len(self._data_vis) > 0: - row_3 = HBox([data_dropdown, wf_cb, data_labels_cb]) - return VBox([row_1, row_2, row_3]) - return VBox([row_1, row_2]) - - def set_data_visualization_method(self, representation: str): - """Set the data visualization method. - - Parameters - ---------- - representation : str - The data visualization method. Options are 'point', 'mesh', 'both' and - 'auto'. If 'auto' is selected, a mesh will be drawn if triangle data is - available and points if not. - - """ - for _, data_vis in self._data_vis.items(): - data_vis.set_visualization_method(representation) - - def show_data_labels(self, show_data_labels: bool): - """Set the visibility of data labels. - - Parameters - ---------- - show_data_labels: bool - If `True`, labels are shown. - - """ - for _, data_vis in self._data_vis.items(): - data_vis.show_label(show_data_labels) - - def show_labels(self, show_labels: bool): - """Set the visibility of the coordinate systems' labels. - - Parameters - ---------- - show_labels : bool - If `True`, the coordinate systems' labels are shown. - - """ - for _, lcs_vis in self._lcs_vis.items(): - lcs_vis.show_label(show_labels) - - def show_origins(self, show_origins: bool): - """Set the visibility of the coordinate systems' origins. - - Parameters - ---------- - show_origins : bool - If `True`, the coordinate systems origins are shown. - - """ - for _, lcs_vis in self._lcs_vis.items(): - lcs_vis.show_origin(show_origins) - - def show_traces(self, show_traces: bool): - """Set the visibility of coordinate systems' traces. - - Parameters - ---------- - show_traces : bool - If `True`, the coordinate systems' traces are shown. - - """ - for _, lcs_vis in self._lcs_vis.items(): - lcs_vis.show_trace(show_traces) - - def show_vectors(self, show_vectors: bool): - """Set the visibility of the coordinate axis vectors. - - Parameters - ---------- - show_vectors : bool - If `True`, the coordinate axis vectors are shown. - - """ - for _, lcs_vis in self._lcs_vis.items(): - lcs_vis.show_vectors(show_vectors) - - def show_wireframes(self, show_wireframes: bool): - """Set if meshes should be drawn in wireframe mode. - - Parameters - ---------- - show_wireframes : bool - If `True`, meshes are rendered as wireframes - - """ - for _, data_vis in self._data_vis.items(): - data_vis.show_wireframe(show_wireframes) - - def update_reference_system(self, reference_system): - """Update the reference system of the plot. - - Parameters - ---------- - reference_system : str - Name of the new reference system - - """ - for lcs_name, lcs_vis in self._lcs_vis.items(): - lcs_vis.update_lcs( - self._csm.get_cs(lcs_name, reference_system), self._current_time_index - ) - for _, data_vis in self._data_vis.items(): - data_vis.update_model_matrix() - - def update_time( - self, - time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]], - time_ref: pd.Timestamp = None, - ): - """Update the plotted time. - - Parameters - ---------- - time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ - LocalCoordinateSystem - The time steps that should be plotted - time_ref : pandas.Timestamp - A reference timestamp that can be provided if the ``time`` parameter is a - `pandas.TimedeltaIndex` - - """ - for _, lcs_vis in self._lcs_vis.items(): - lcs_vis.update_time(time, time_ref) - for _, data_vis in self._data_vis.items(): - data_vis.update_model_matrix() - - def update_time_index(self, index: int): - """Update the plotted time by index. - - Parameters - ---------- - index : int - The new index - - """ - self._current_time_index = index - for _, lcs_vis in self._lcs_vis.items(): - lcs_vis.update_time_index(index) - for _, data_vis in self._data_vis.items(): - data_vis.update_model_matrix() - self._time_info.text = f"time: {self._time[index]}" diff --git a/weldx/visualization/__init__.py b/weldx/visualization/__init__.py new file mode 100644 index 000000000..271ef03d0 --- /dev/null +++ b/weldx/visualization/__init__.py @@ -0,0 +1,10 @@ +from .k3d_impl import CoordinateSystemManagerVisualizerK3D, SpatialDataVisualizer +from .matplotlib_impl import ( + axes_equal, + draw_coordinate_system_matplotlib, + new_3d_figure_and_axes, + plot_coordinate_system_manager_matplotlib, + plot_coordinate_systems, + plot_local_coordinate_system_matplotlib, + plot_spatial_data_matplotlib, +) diff --git a/weldx/visualization/colors.py b/weldx/visualization/colors.py new file mode 100644 index 000000000..2f22f2379 --- /dev/null +++ b/weldx/visualization/colors.py @@ -0,0 +1,225 @@ +"""Color related tools.""" + +from typing import Dict, Generator, List, Tuple, Union + +import matplotlib.pyplot as plt +import numpy as np + +RGB_BLACK = 0x000000 +RGB_BLUE = 0x0000FF +RGB_CYAN = 0x00FFFF +RGB_GREEN = 0x00AA00 +RGB_MAGENTA = 0xFF00FF +RGB_RED = 0xFF0000 +RGB_YELLOW = 0xAAAA00 + + +def _color_rgb_to_int(rgb_color_tuple: Tuple[int, int, int]) -> int: + """Convert an RGB color tuple to an 24 bit integer. + + Parameters + ---------- + rgb_color_tuple : Tuple[int, int, int] + The color as RGB tuple. Values must be in the range 0-255. + + Returns + ------- + int : + Color as 24 bit integer + + """ + return int("0x{:02x}{:02x}{:02x}".format(*rgb_color_tuple), 0) + + +def _color_int_to_rgb(integer: int) -> Tuple[int, int, int]: + """Convert an 24 bit integer into a RGB color tuple with the value range (0-255). + + Parameters + ---------- + integer : int + The value that should be converted + + Returns + ------- + Tuple[int, int, int]: + The resulting RGB tuple. + + """ + return (integer >> 16) & 255, (integer >> 8) & 255, integer & 255 + + +def _color_rgb_to_rgb_normalized( + rgb: Tuple[int, int, int] +) -> Tuple[float, float, float]: + """Normalize an RGB color tuple with the range (0-255) to the range (0.0-1.0). + + Parameters + ---------- + rgb : Tuple[int, int, int] + Color tuple with values in the range (0-255) + + Returns + ------- + Tuple[float, float, float] : + Color tuple with values in the range (0.0-1.0) + + """ + return tuple([val / 255 for val in rgb]) + + +def _color_rgb_normalized_to_rgb( + rgb: Tuple[float, float, float] +) -> Tuple[int, int, int]: + """Normalize an RGB color tuple with the range (0.0-1.0) to the range (0-255). + + Parameters + ---------- + rgb : Tuple[float, float, float] + Color tuple with values in the range (0.0-1.0) + + Returns + ------- + Tuple[int, int, int] : + Color tuple with values in the range (0-255) + + """ + return tuple([int(np.round(val * 255)) for val in rgb]) + + +def color_int_to_rgb_normalized(integer): + """Convert an 24 bit integer into a RGB color tuple with the value range (0.0-1.0). + + Parameters + ---------- + integer : int + The value that should be converted + + Returns + ------- + Tuple[float, float, float]: + The resulting RGB tuple. + + """ + rgb = _color_int_to_rgb(integer) + return _color_rgb_to_rgb_normalized(rgb) + + +def _color_rgb_normalized_to_int(rgb: Tuple[float, float, float]) -> int: + """Convert a normalized RGB color tuple to an 24 bit integer. + + Parameters + ---------- + rgb : Tuple[float, float, float] + The color as RGB tuple. Values must be in the range 0.0-1.0. + + Returns + ------- + int : + Color as 24 bit integer + + """ + return _color_rgb_to_int(_color_rgb_normalized_to_rgb(rgb)) + + +def _shuffled_tab20_colors() -> List[int]: + """Get a shuffled list of matplotlib 'tab20' colors. + + Returns + ------- + List[int] : + List of colors + + """ + num_colors = 20 + colormap = plt.cm.get_cmap("tab20", num_colors) + colors = [colormap(i)[:3] for i in range(num_colors)] + + # randomize colors + state = np.random.RandomState(42) + state.shuffle(colors) + + return [_color_rgb_normalized_to_int(color) for color in colors] + + +def color_to_rgb_normalized( + color: Union[int, Tuple[int, int, int], Tuple[float, float, float]] +) -> Tuple[float, float, float]: + """Convert an arbitrary RGB color representation into a normalized RGB triplet. + + Parameters + ---------- + color : Union[int, Tuple[int, int, int], Tuple[float, float, float]] + A 24 bit integer, a triplet of integers with a value range of 0-255 + or a triplet of floats with a value range of 0.0-1.0 + that represent an RGB color. + + Returns + ------- + Tuple[float, float, float] : + RGB color triplet with the value range 0.0 to 1.0 + + """ + if isinstance(color, Tuple) and len(color) == 3: + if all(isinstance(number, int) for number in color): + return _color_rgb_to_rgb_normalized(color) + if all(isinstance(number, (int, float)) for number in color): + return color + if isinstance(color, int): + return color_int_to_rgb_normalized(color) + raise TypeError("Unsupported color format.") + + +_color_list = [ + RGB_RED, + RGB_GREEN, + RGB_BLUE, + RGB_YELLOW, + RGB_CYAN, + RGB_MAGENTA, + *_shuffled_tab20_colors(), +] + + +def color_generator_function() -> int: + """Yield a 24 bit RGB color integer. + + The returned value is taken from a predefined list. + + Yields + ------ + int: + 24 bit RGB color integer + + """ + while True: + for color in _color_list: + yield color + + +def get_color(key: str, color_dict: Dict[str, int], color_generator: Generator) -> int: + """Get a 24 bit RGB color from a dictionary or generator function. + + If the provided key is found in the dictionary, the corresponding color is returned. + Otherwise, the generator is used to provide a color. + + Parameters + ---------- + key : + The key that should be searched for in the dictionary + color_dict : + A dictionary containing name to color mappings + color_generator : + A generator that returns a color integer + + Returns + ------- + int : + RGB color as 24 bit integer + + """ + if color_dict is not None and key in color_dict: + return _color_rgb_to_int(color_dict[key]) + try: + return next(color_generator) + except StopIteration: + raise RuntimeError(f"given generator {color_generator} exhausted.") diff --git a/weldx/visualization/k3d_impl.py b/weldx/visualization/k3d_impl.py new file mode 100644 index 000000000..121cb4881 --- /dev/null +++ b/weldx/visualization/k3d_impl.py @@ -0,0 +1,822 @@ +"""Contains some functions to help with visualization.""" + +from typing import Dict, List, Tuple, Union + +import k3d +import k3d.platonic as platonic +import numpy as np +import pandas as pd +from IPython.display import display +from ipywidgets import Checkbox, Dropdown, HBox, IntSlider, Layout, Play, VBox, jslink + +from weldx import geometry as geo + +from .colors import ( + RGB_BLACK, + RGB_BLUE, + RGB_GREEN, + RGB_RED, + color_generator_function, + get_color, +) + +__all__ = ["CoordinateSystemManagerVisualizerK3D", "SpatialDataVisualizer"] + + +def _get_coordinates_and_orientation(lcs, index: int = 0): + """Get the coordinates and orientation of a coordinate system. + + Parameters + ---------- + lcs : weldx.LocalCoordinateSystem + The coordinate system + index : int + If the coordinate system is time dependent, the passed value is the index + of the values that should be returned + + Returns + ------- + coordinates : numpy.ndarray + The coordinates + orientation : numpy.ndarray + The orientation + + """ + coordinates = lcs.coordinates.isel(time=index, missing_dims="ignore").values.astype( + "float32" + ) + + orientation = lcs.orientation.isel(time=index, missing_dims="ignore").values.astype( + "float32" + ) + + return coordinates, orientation + + +def _create_model_matrix( + coordinates: np.ndarray, orientation: np.ndarray +) -> np.ndarray: + """Create the model matrix from an orientation and coordinates. + + Parameters + ---------- + coordinates : numpy.ndarray + The coordinates of the origin + orientation : numpy.ndarray + The orientation of the coordinate system + + Returns + ------- + numpy.ndarray : + The model matrix + + """ + model_matrix = np.eye(4, dtype="float32") + model_matrix[:3, :3] = orientation + model_matrix[:3, 3] = coordinates + return model_matrix + + +class CoordinateSystemVisualizerK3D: + """Visualizes a `weldx.transformations.LocalCoordinateSystem` using k3d.""" + + def __init__( + self, + lcs, + plot: k3d.Plot = None, + name: str = None, + color: int = RGB_BLACK, + show_origin=True, + show_trace=True, + show_vectors=True, + ): + """Create a `CoordinateSystemVisualizerK3D`. + + Parameters + ---------- + lcs : weldx.transformations.LocalCoordinateSystem + Coordinate system that should be visualized + plot : k3d.Plot + A k3d plotting widget. + name : str + Name of the coordinate system + color : int + The RGB color of the coordinate system (affects trace and label) as a 24 bit + integer value. + show_origin : bool + If `True`, the origin of the coordinate system will be highlighted in the + color passed as another parameter + show_trace : + If `True`, the trace of a time dependent coordinate system will be + visualized in the color passed as another parameter + show_vectors : bool + If `True`, the the coordinate axes of the coordinate system are visualized + + """ + coordinates, orientation = _get_coordinates_and_orientation(lcs) + self._lcs = lcs + self._color = color + + self._vectors = k3d.vectors( + origins=[coordinates for _ in range(3)], + vectors=orientation.transpose(), + colors=[[RGB_RED, RGB_RED], [RGB_GREEN, RGB_GREEN], [RGB_BLUE, RGB_BLUE]], + labels=[], + label_size=1.5, + ) + self._vectors.visible = show_vectors + + self._label = None + if name is not None: + self._label = k3d.text( + text=name, + position=coordinates + 0.05, + color=self._color, + size=1, + label_box=False, + ) + + self._trace = k3d.line( + np.array(lcs.coordinates.values, dtype="float32"), + shader="simple", + width=0.05, + color=color, + ) + self._trace.visible = show_trace + + self.origin = platonic.Octahedron(size=0.1).mesh + self.origin.color = color + self.origin.model_matrix = _create_model_matrix(coordinates, orientation) + self.origin.visible = show_origin + + if plot is not None: + plot += self._vectors + plot += self._trace + plot += self.origin + if self._label is not None: + plot += self._label + + def _update_positions(self, coordinates: np.ndarray, orientation: np.ndarray): + """Update the positions of the coordinate cross and label. + + Parameters + ---------- + coordinates : numpy.ndarray + The new coordinates + orientation : numpy.ndarray + The new orientation + + """ + self._vectors.origins = [coordinates for _ in range(3)] + self._vectors.vectors = orientation.transpose() + self.origin.model_matrix = _create_model_matrix(coordinates, orientation) + if self._label is not None: + self._label.position = coordinates + 0.05 + + def show_label(self, show_label: bool): + """Set the visibility of the label. + + Parameters + ---------- + show_label : bool + If `True`, the label will be shown + + """ + self._label.visible = show_label + + def show_origin(self, show_origin: bool): + """Set the visibility of the coordinate systems' origin. + + Parameters + ---------- + show_origin : bool + If `True`, the coordinate systems origin is shown. + + """ + self.origin.visible = show_origin + + def show_trace(self, show_trace: bool): + """Set the visibility of coordinate systems' trace. + + Parameters + ---------- + show_trace : bool + If `True`, the coordinate systems' trace is shown. + + """ + self._trace.visible = show_trace + + def show_vectors(self, show_vectors: bool): + """Set the visibility of the coordinate axis vectors. + + Parameters + ---------- + show_vectors : bool + If `True`, the coordinate axis vectors are shown. + + """ + self._vectors.visible = show_vectors + + def update_lcs(self, lcs, index: int = 0): + """Pass a new coordinate system to the visualizer. + + Parameters + ---------- + lcs : weldx.transformations.LocalCoordinateSystem + The new coordinate system + index : int + The time index of the new coordinate system that should be visualized. + + """ + self._lcs = lcs + self._trace.vertices = np.array(lcs.coordinates.values, dtype="float32") + self.update_time_index(index) + + def update_time_index(self, index: int): + """Update the plotted time step. + + Parameters + ---------- + index : int + The array index of the time step + + """ + coordinates, orientation = _get_coordinates_and_orientation(self._lcs, index) + self._update_positions(coordinates, orientation) + + +class SpatialDataVisualizer: + """Visualizes spatial data.""" + + visualization_methods = ["auto", "point", "mesh", "both"] + + def __init__( + self, + data, + name: str, + reference_system: str, + plot: k3d.Plot = None, + color: int = RGB_BLACK, + visualization_method: str = "auto", + show_wireframe: bool = False, + ): + """Create a 'SpatialDataVisualizer' instance. + + Parameters + ---------- + data : numpy.ndarray or weldx.geometry.SpatialData + The data that should be visualized + name : str + Name of the data + reference_system : str + Name of the data's reference system + plot : k3d.Plot + A k3d plotting widget. + color : int + The RGB color of the coordinate system (affects trace and label) as a 24 bit + integer value. + visualization_method : str + The initial data visualization method. Options are 'point', 'mesh', 'both' + and 'auto'. If 'auto' is selected, a mesh will be drawn if triangle data is + available and points if not. + show_wireframe : bool + If 'True', meshes will be drawn as wireframes + + """ + triangles = None + if isinstance(data, geo.SpatialData): + triangles = data.triangles + data = data.coordinates.data + + self._reference_system = reference_system + + self._label_pos = np.mean(data, axis=0) + self._label = None + if name is not None: + self._label = k3d.text( + text=name, + position=self._label_pos, + reference_point="cc", + color=color, + size=0.5, + label_box=True, + ) + + self._points = k3d.points(data, point_size=0.05, color=color) + self._mesh = None + if triangles is not None: + self._mesh = k3d.mesh( + data, triangles, side="double", color=color, wireframe=show_wireframe + ) + + self.set_visualization_method(visualization_method) + + if plot is not None: + plot += self._points + if self._mesh is not None: + plot += self._mesh + if self._label is not None: + plot += self._label + + @property + def reference_system(self) -> str: + """Get the name of the reference coordinate system. + + Returns + ------- + str : + Name of the reference coordinate system + + """ + return self._reference_system + + def set_visualization_method(self, method: str): + """Set the visualization method. + + Parameters + ---------- + method : str + The data visualization method. Options are 'point', 'mesh', 'both' and + 'auto'. If 'auto' is selected, a mesh will be drawn if triangle data is + available and points if not. + + """ + if method not in SpatialDataVisualizer.visualization_methods: + raise ValueError(f"Unknown visualization method: '{method}'") + + if method == "auto": + if self._mesh is not None: + method = "mesh" + else: + method = "point" + + self._points.visible = method in ["point", "both"] + if self._mesh is not None: + self._mesh.visible = method in ["mesh", "both"] + + def show_label(self, show_label: bool): + """Set the visibility of the label. + + Parameters + ---------- + show_label : bool + If `True`, the label will be shown + + """ + self._label.visible = show_label + + def show_wireframe(self, show_wireframe: bool): + """Set wireframe rendering. + + Parameters + ---------- + show_wireframe : bool + If `True`, the mesh will be rendered as wireframe + + """ + if self._mesh is not None: + self._mesh.wireframe = show_wireframe + + def update_model_matrix(self, model_mat): + """Update the model matrices of the k3d objects.""" + self._points.model_matrix = model_mat + if self._mesh is not None: + self._mesh.model_matrix = model_mat + if self._label is not None: + self._label.position = ( + np.matmul(model_mat[0:3, 0:3], self._label_pos) + model_mat[0:3, 3] + ) + + +class CoordinateSystemManagerVisualizerK3D: + """Visualizes a `weldx.transformations.CoordinateSystemManager` using k3d.""" + + def __init__( + self, + csm, + coordinate_systems: List[str] = None, + data_sets: List[str] = None, + colors: Dict[str, int] = None, + reference_system: str = None, + title: str = None, + limits: List[Tuple[float, float]] = None, + time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]] = None, + time_ref: pd.Timestamp = None, + show_data_labels: bool = True, + show_labels: bool = True, + show_origins: bool = True, + show_traces: bool = True, + show_vectors: bool = True, + show_wireframe: bool = True, + ): + """Create a `CoordinateSystemManagerVisualizerK3D`. + + Parameters + ---------- + csm : weldx.transformations.CoordinateSystemManager + The `weldx.transformations.CoordinateSystemManager` instance that should be + visualized + coordinate_systems : List[str] + The names of the coordinate systems that should be visualized. If ´None´ is + provided, all systems are plotted + data_sets : List[str] + The names of data sets that should be visualized. If ´None´ is provided, all + data is plotted + colors : Dict[str, int] + A mapping between a coordinate system name or a data set name and a color. + The colors must be provided as 24 bit integer values that are divided into + three 8 bit sections for the rgb values. For example `0xFF0000` for pure + red. + Each coordinate system or data set that does not have a mapping in this + dictionary will get a default color assigned to it. + reference_system : str + Name of the initial reference system. If `None` is provided, the root system + of the `weldx.transformations.CoordinateSystemManager` instance will be used + title : str + The title of the plot + limits : List[Tuple[float, float]] + The limits of the plotted volume + time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ + weldx.transformations.LocalCoordinateSystem + The time steps that should be plotted initially + time_ref : pandas.Timestamp + A reference timestamp that can be provided if the ``time`` parameter is a + `pandas.TimedeltaIndex` + show_data_labels : bool + If `True`, the data labels will be shown initially + show_labels : bool + If `True`, the coordinate system labels will be shown initially + show_origins : bool + If `True`, the coordinate systems' origins will be shown initially + show_traces : bool + If `True`, the coordinate systems' traces will be shown initially + show_vectors : bool + If `True`, the coordinate systems' axis vectors will be shown initially + show_wireframe : bool + If `True`, spatial data containing mesh data will be drawn as wireframe + + """ + if time is None: + time = csm.time_union() + if time is not None: + csm = csm.interp_time(time=time, time_ref=time_ref) + + self._csm = csm.interp_time(time=time, time_ref=time_ref) + self._current_time_index = 0 + + if coordinate_systems is None: + coordinate_systems = csm.coordinate_system_names + if data_sets is None: + data_sets = self._csm.data_names + if reference_system is None: + reference_system = self._csm.root_system_name + self._current_reference_system = reference_system + + grid_auto_fit = True + grid = (-1, -1, -1, 1, 1, 1) + if limits is not None: + grid_auto_fit = False + if len(limits) == 1: + grid = [limits[0][int(i / 3)] for i in range(6)] + else: + grid = [limits[i % 3][int(i / 3)] for i in range(6)] + + # create plot + self._color_generator = color_generator_function() + plot = k3d.plot( + grid_auto_fit=grid_auto_fit, + grid=grid, + ) + self._lcs_vis = { + lcs_name: CoordinateSystemVisualizerK3D( + self._csm.get_cs(lcs_name, reference_system), + plot, + lcs_name, + color=get_color(lcs_name, colors, self._color_generator), + show_origin=show_origins, + show_trace=show_traces, + show_vectors=show_vectors, + ) + for lcs_name in coordinate_systems + } + self._data_vis = { + data_name: SpatialDataVisualizer( + self._csm.get_data(data_name=data_name), + data_name, + self._csm.get_data_system_name(data_name=data_name), + plot, + color=get_color(data_name, colors, self._color_generator), + show_wireframe=show_wireframe, + ) + for data_name in data_sets + } + self._update_spatial_data() + + # create controls + self._controls = self._create_controls( + time, + show_data_labels, + show_labels, + show_origins, + show_traces, + show_vectors, + show_wireframe, + ) + + # add title + self._title = None + if title is not None: + self._title = k3d.text2d( + f"{title}", + position=(0.5, 0), + color=RGB_BLACK, + is_html=True, + size=1.5, + reference_point="ct", + ) + plot += self._title + + # add time info + self._time = time + self._time_ref = time_ref + self._time_info = None + if time is not None: + self._time_info = k3d.text2d( + f"time: {time[0]}", + position=(0, 1), + color=RGB_BLACK, + is_html=True, + size=1.0, + reference_point="lb", + ) + plot += self._time_info + + # display everything + plot.display() + display(self._controls) + + # workaround since using it inside the init method of the coordinate system + # visualizer somehow causes the labels to be created twice with one version + # being always visible + self.show_data_labels(show_data_labels) + self.show_labels(show_labels) + + def _create_controls( + self, + time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]], + show_data_labels: bool, + show_labels: bool, + show_origins: bool, + show_traces: bool, + show_vectors: bool, + show_wireframe: bool, + ): + """Create the control panel. + + Parameters + ---------- + time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ + LocalCoordinateSystem + The time steps that should be plotted initially + show_data_labels : bool + If `True`, the data labels will be shown initially + show_labels : bool + If `True`, the coordinate system labels will be shown initially + show_origins : bool + If `True`, the coordinate systems' origins will be shown initially + show_traces : bool + If `True`, the coordinate systems' traces will be shown initially + show_vectors : bool + If `True`, the coordinate systems' axis vectors will be shown initially + show_wireframe : bool + If `True`, spatial data containing mesh data will be drawn as wireframe + + """ + num_times = 1 + disable_time_widgets = True + lo = Layout(width="200px") + + # create widgets + if time is not None: + num_times = len(time) + disable_time_widgets = False + + play = Play( + min=0, + max=num_times - 1, + value=self._current_time_index, + step=1, + ) + time_slider = IntSlider( + min=0, + max=num_times - 1, + value=self._current_time_index, + description="Time:", + ) + reference_dropdown = Dropdown( + options=self._csm.coordinate_system_names, + value=self._current_reference_system, + description="Reference:", + disabled=False, + ) + data_dropdown = Dropdown( + options=SpatialDataVisualizer.visualization_methods, + value="auto", + description="data repr.:", + disabled=False, + layout=lo, + ) + + lo = Layout(width="200px") + vectors_cb = Checkbox(value=show_vectors, description="show vectors", layout=lo) + origin_cb = Checkbox(value=show_origins, description="show origins", layout=lo) + traces_cb = Checkbox(value=show_traces, description="show traces", layout=lo) + labels_cb = Checkbox(value=show_labels, description="show labels", layout=lo) + wf_cb = Checkbox(value=show_wireframe, description="show wireframe", layout=lo) + data_labels_cb = Checkbox( + value=show_data_labels, description="show data labels", layout=lo + ) + + jslink((play, "value"), (time_slider, "value")) + play.disabled = disable_time_widgets + time_slider.disabled = disable_time_widgets + + # callback functions + def _reference_callback(change): + self.update_reference_system(change["new"]) + + def _time_callback(change): + self.update_time_index(change["new"]) + + def _vectors_callback(change): + self.show_vectors(change["new"]) + + def _origins_callback(change): + self.show_origins(change["new"]) + + def _traces_callback(change): + self.show_traces(change["new"]) + + def _labels_callback(change): + self.show_labels(change["new"]) + + def _data_callback(change): + self.set_data_visualization_method(change["new"]) + + def _data_labels_callback(change): + self.show_data_labels(change["new"]) + + def _wireframe_callback(change): + self.show_wireframes(change["new"]) + + # register callbacks + time_slider.observe(_time_callback, names="value") + reference_dropdown.observe(_reference_callback, names="value") + vectors_cb.observe(_vectors_callback, names="value") + origin_cb.observe(_origins_callback, names="value") + traces_cb.observe(_traces_callback, names="value") + labels_cb.observe(_labels_callback, names="value") + data_dropdown.observe(_data_callback, names="value") + data_labels_cb.observe(_data_labels_callback, names="value") + wf_cb.observe(_wireframe_callback, names="value") + + # create control panel + row_1 = HBox([time_slider, play, reference_dropdown]) + row_2 = HBox([vectors_cb, origin_cb, traces_cb, labels_cb]) + if len(self._data_vis) > 0: + row_3 = HBox([data_dropdown, wf_cb, data_labels_cb]) + return VBox([row_1, row_2, row_3]) + return VBox([row_1, row_2]) + + def _get_model_matrix(self, lcs_name): + lcs_vis = self._lcs_vis.get(lcs_name) + if lcs_vis is not None: + return lcs_vis.origin.model_matrix + + lcs = self._csm.get_cs(lcs_name, self._current_reference_system) + coordinates, orientation = _get_coordinates_and_orientation( + lcs, self._current_time_index + ) + return _create_model_matrix(coordinates, orientation) + + def _update_spatial_data(self): + for _, data_vis in self._data_vis.items(): + model_matrix = self._get_model_matrix(data_vis.reference_system) + data_vis.update_model_matrix(model_matrix) + + def set_data_visualization_method(self, representation: str): + """Set the data visualization method. + + Parameters + ---------- + representation : str + The data visualization method. Options are 'point', 'mesh', 'both' and + 'auto'. If 'auto' is selected, a mesh will be drawn if triangle data is + available and points if not. + + """ + for _, data_vis in self._data_vis.items(): + data_vis.set_visualization_method(representation) + + def show_data_labels(self, show_data_labels: bool): + """Set the visibility of data labels. + + Parameters + ---------- + show_data_labels: bool + If `True`, labels are shown. + + """ + for _, data_vis in self._data_vis.items(): + data_vis.show_label(show_data_labels) + + def show_labels(self, show_labels: bool): + """Set the visibility of the coordinate systems' labels. + + Parameters + ---------- + show_labels : bool + If `True`, the coordinate systems' labels are shown. + + """ + for _, lcs_vis in self._lcs_vis.items(): + lcs_vis.show_label(show_labels) + + def show_origins(self, show_origins: bool): + """Set the visibility of the coordinate systems' origins. + + Parameters + ---------- + show_origins : bool + If `True`, the coordinate systems origins are shown. + + """ + for _, lcs_vis in self._lcs_vis.items(): + lcs_vis.show_origin(show_origins) + + def show_traces(self, show_traces: bool): + """Set the visibility of coordinate systems' traces. + + Parameters + ---------- + show_traces : bool + If `True`, the coordinate systems' traces are shown. + + """ + for _, lcs_vis in self._lcs_vis.items(): + lcs_vis.show_trace(show_traces) + + def show_vectors(self, show_vectors: bool): + """Set the visibility of the coordinate axis vectors. + + Parameters + ---------- + show_vectors : bool + If `True`, the coordinate axis vectors are shown. + + """ + for _, lcs_vis in self._lcs_vis.items(): + lcs_vis.show_vectors(show_vectors) + + def show_wireframes(self, show_wireframes: bool): + """Set if meshes should be drawn in wireframe mode. + + Parameters + ---------- + show_wireframes : bool + If `True`, meshes are rendered as wireframes + + """ + for _, data_vis in self._data_vis.items(): + data_vis.show_wireframe(show_wireframes) + + def update_reference_system(self, reference_system): + """Update the reference system of the plot. + + Parameters + ---------- + reference_system : str + Name of the new reference system + + """ + self._current_reference_system = reference_system + for lcs_name, lcs_vis in self._lcs_vis.items(): + lcs_vis.update_lcs( + self._csm.get_cs(lcs_name, reference_system), self._current_time_index + ) + self._update_spatial_data() + + def update_time_index(self, index: int): + """Update the plotted time by index. + + Parameters + ---------- + index : int + The new index + + """ + self._current_time_index = index + for _, lcs_vis in self._lcs_vis.items(): + lcs_vis.update_time_index(index) + self._update_spatial_data() + self._time_info.text = f"time: {self._time[index]}" diff --git a/weldx/visualization/matplotlib_impl.py b/weldx/visualization/matplotlib_impl.py new file mode 100644 index 000000000..5d9a20666 --- /dev/null +++ b/weldx/visualization/matplotlib_impl.py @@ -0,0 +1,522 @@ +"""Contains some functions written in matplotlib to help with visualization.""" + +from typing import Any, Dict, List, Tuple, Union + +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +import weldx.geometry as geo +from weldx.visualization.colors import ( + color_generator_function, + color_int_to_rgb_normalized, + color_to_rgb_normalized, + get_color, +) + + +def new_3d_figure_and_axes( + num_subplots: int = 1, height: int = 500, width: int = 500, pixel_per_inch: int = 50 +): + """Get a matplotlib figure and axes for 3d plots. + + Parameters + ---------- + num_subplots : int + Number of subplots (horizontal) + height : int + Height in pixels + width : int + Width in pixels + pixel_per_inch : + Defines how many pixels an inch covers. This is only relevant for the fallback + method. + + Returns + ------- + fig : matplotlib.figure.Figure + The matplotlib figure object + ax : matplotlib..axes.Axes + The matplotlib axes object + + """ + fig, ax = plt.subplots( + ncols=num_subplots, subplot_kw={"projection": "3d", "proj_type": "ortho"} + ) + try: + fig.canvas.layout.height = f"{height}px" + fig.canvas.layout.width = f"{width}px" + except Exception: # skipcq: PYL-W0703 + fig.set_size_inches(w=width / pixel_per_inch, h=height / pixel_per_inch) + return fig, ax + + +def axes_equal(axes): + """Adjust axis in a 3d plot to be equally scaled. + + Source code taken from the stackoverflow answer of 'karlo' in the + following question: + https://stackoverflow.com/questions/13685386/matplotlib-equal-unit + -length-with-equal-aspect-ratio-z-axis-is-not-equal-to + + Parameters + ---------- + axes : + Matplotlib axes object (output from plt.gca()) + + """ + x_limits = axes.get_xlim3d() + y_limits = axes.get_ylim3d() + z_limits = axes.get_zlim3d() + + x_range = abs(x_limits[1] - x_limits[0]) + x_middle = np.mean(x_limits) + y_range = abs(y_limits[1] - y_limits[0]) + y_middle = np.mean(y_limits) + z_range = abs(z_limits[1] - z_limits[0]) + z_middle = np.mean(z_limits) + + # The plot bounding box is a sphere in the sense of the infinity + # norm, hence I call half the max range the plot radius. + plot_radius = 0.5 * max([x_range, y_range, z_range]) + + axes.set_xlim3d([x_middle - plot_radius, x_middle + plot_radius]) + axes.set_ylim3d([y_middle - plot_radius, y_middle + plot_radius]) + axes.set_zlim3d([z_middle - plot_radius, z_middle + plot_radius]) + + +def draw_coordinate_system_matplotlib( + coordinate_system, + axes: plt.Axes.axes, + color: Any = None, + label: str = None, + time_idx: int = None, + show_origin: bool = True, + show_vectors: bool = True, +): + """Draw a coordinate system in a matplotlib 3d plot. + + Parameters + ---------- + coordinate_system : weldx.transformations.LocalCoordinateSystem + Coordinate system + axes : matplotlib.axes.Axes + Target matplotlib axes object + color : Any + Valid matplotlib color selection. The origin of the coordinate system + will be marked with this color. + label : str + Name that appears in the legend. Only viable if a color + was specified. + time_idx : int + Selects time dependent data by index if the coordinate system has + a time dependency. + show_origin : bool + If `True`, the origin of the coordinate system will be highlighted in the + color passed as another parameter + show_vectors : bool + If `True`, the the coordinate axes of the coordinate system are visualized + + """ + if not (show_vectors or show_origin): + return + if "time" in coordinate_system.dataset.coords: + if time_idx is None: + time_idx = 0 + if isinstance(time_idx, int): + dsx = coordinate_system.dataset.isel(time=time_idx) + else: + dsx = coordinate_system.dataset.sel(time=time_idx).isel(time=0) + else: + dsx = coordinate_system.dataset + + p_0 = dsx.coordinates + + if show_vectors: + orientation = dsx.orientation + p_x = p_0 + orientation[:, 0] + p_y = p_0 + orientation[:, 1] + p_z = p_0 + orientation[:, 2] + + axes.plot([p_0[0], p_x[0]], [p_0[1], p_x[1]], [p_0[2], p_x[2]], "r") + axes.plot([p_0[0], p_y[0]], [p_0[1], p_y[1]], [p_0[2], p_y[2]], "g") + axes.plot([p_0[0], p_z[0]], [p_0[1], p_z[1]], [p_0[2], p_z[2]], "b") + if color is not None: + if show_origin: + axes.plot([p_0[0]], [p_0[1]], [p_0[2]], "o", color=color, label=label) + elif label is not None: + raise Exception("Labels can only be assigned if a color was specified") + + +def plot_local_coordinate_system_matplotlib( + lcs, + axes: plt.Axes.axes = None, + color: Any = None, + label: str = None, + time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]] = None, + time_ref: pd.Timestamp = None, + time_index: int = None, + show_origin: bool = True, + show_trace: bool = True, + show_vectors: bool = True, +) -> plt.Axes.axes: + """Visualize a `weldx.transformations.LocalCoordinateSystem` using matplotlib. + + Parameters + ---------- + lcs : weldx.transformations.LocalCoordinateSystem + The coordinate system that should be visualized + axes : matplotlib.axes.Axes + The target matplotlib axes. If `None` is provided, a new one will be created + color : Any + An arbitrary color. The data type must be compatible with matplotlib. + label : str + Name of the coordinate system + time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ + ~weldx.transformations.LocalCoordinateSystem + The time steps that should be plotted + time_ref : pandas.Timestamp + A reference timestamp that can be provided if the ``time`` parameter is a + `pandas.TimedeltaIndex` + time_index : int + Index of a specific time step that should be plotted + show_origin : bool + If `True`, the origin of the coordinate system will be highlighted in the + color passed as another parameter + show_trace : + If `True`, the trace of a time dependent coordinate system will be visualized in + the color passed as another parameter + show_vectors : bool + If `True`, the the coordinate axes of the coordinate system are visualized + + Returns + ------- + matplotlib.axes.Axes : + The axes object that was used as canvas for the plot + + """ + if axes is None: + _, axes = plt.subplots(subplot_kw={"projection": "3d", "proj_type": "ortho"}) + + if lcs.is_time_dependent and time is not None: + lcs = lcs.interp_time(time, time_ref) + + if lcs.is_time_dependent and time_index is None: + for i, _ in enumerate(lcs.time): + draw_coordinate_system_matplotlib( + lcs, + axes, + color=color, + label=label, + time_idx=i, + show_origin=show_origin, + show_vectors=show_vectors, + ) + label = None + else: + draw_coordinate_system_matplotlib( + lcs, + axes, + color=color, + label=label, + time_idx=time_index, + show_origin=show_origin, + show_vectors=show_vectors, + ) + + if show_trace and lcs.coordinates.values.ndim > 1: + coords = lcs.coordinates.values + if color is None: + color = "k" + axes.plot(coords[:, 0], coords[:, 1], coords[:, 2], ":", color=color) + + return axes + + +def _set_limits_matplotlib( + axes: plt.Axes.axes, + limits: Union[List[Tuple[float, float]], Tuple[float, float]], + set_axes_equal: bool = False, +): + """Set the limits of an axes object. + + Parameters + ---------- + axes : matplotlib.axes.Axes + The axes object + limits : Tuple[float, float] or List[Tuple[float, float]] + Each tuple marks lower and upper boundary of the x, y and z axis. If only a + single tuple is passed, the boundaries are used for all axis. If `None` + is provided, the axis are adjusted to be of equal length. + set_axes_equal : bool + (matplotlib only) If `True`, all axes are adjusted to cover an equally large + range of value. That doesn't mean, that the limits are identical + + """ + if limits is not None: + if isinstance(limits, Tuple): + limits = [limits] + if len(limits) == 1: + limits = [limits[0] for _ in range(3)] + axes.set_xlim(limits[0]) + axes.set_ylim(limits[1]) + axes.set_zlim(limits[2]) + elif set_axes_equal: + axes_equal(axes) + + +def plot_coordinate_systems( + cs_data: Tuple[str, Dict], + axes: plt.Axes.axes = None, + title: str = None, + limits: Union[List[Tuple[float, float]], Tuple[float, float]] = None, + time_index: int = None, + legend_pos: str = "lower left", +) -> plt.Axes.axes: + """Plot multiple coordinate systems. + + Parameters + ---------- + cs_data : Tuple[str, Dict] + A tuple containing the coordinate system that should be plotted and a dictionary + with the key word arguments that should be passed to its plot function. + axes : matplotlib.axes.Axes + The target axes object that should be drawn to. If `None` is provided, a new + one will be created. + title : str + The title of the plot + limits : Tuple[float, float] or List[Tuple[float, float]] + Each tuple marks lower and upper boundary of the x, y and z axis. If only a + single tuple is passed, the boundaries are used for all axis. If `None` + is provided, the axis are adjusted to be of equal length. + time_index : int + Index of a specific time step that should be plotted if the corresponding + coordinate system is time dependent + legend_pos : str + A string that specifies the position of the legend. See the matplotlib + documentation for further details + + Returns + ------- + matplotlib.axes.Axes : + The axes object that was used as canvas for the plot + + """ + if axes is None: + _, axes = new_3d_figure_and_axes() + + for lcs, kwargs in cs_data: + if "time_index" not in kwargs: + kwargs["time_index"] = time_index + lcs.plot(axes, **kwargs) + + _set_limits_matplotlib(axes, limits) + + if title is not None: + axes.set_title(title) + axes.legend(loc=legend_pos) + + return axes + + +def plot_coordinate_system_manager_matplotlib( + csm, + axes: plt.Axes.axes = None, + reference_system: str = None, + coordinate_systems: List[str] = None, + data_sets: List[str] = None, + colors: Dict[str, int] = None, + time: Union[pd.DatetimeIndex, pd.TimedeltaIndex, List[pd.Timestamp]] = None, + time_ref: pd.Timestamp = None, + title: str = None, + limits: Union[List[Tuple[float, float]], Tuple[float, float]] = None, + set_axes_equal: bool = False, + show_origins: bool = True, + show_trace: bool = True, + show_vectors: bool = True, + show_wireframe: bool = True, +) -> plt.Axes.axes: + """Plot the coordinate systems of a `weldx.transformations.CoordinateSystemManager`. + + Parameters + ---------- + csm : weldx.transformations.CoordinateSystemManager + The `weldx.transformations.CoordinateSystemManager` that should be plotted + axes : matplotlib.axes.Axes + The target axes object that should be drawn to. If `None` is provided, a new + one will be created. + reference_system : str + The name of the reference system for the plotted coordinate systems + coordinate_systems : List[str] + Names of the coordinate systems that should be drawn. If `None` is provided, + all systems are plotted. + data_sets : List[str] + Names of the data sets that should be drawn. If `None` is provided, all data + is plotted. + colors: Dict[str, int] + A mapping between a coordinate system name or a data set name and a color. + The colors must be provided as 24 bit integer values that are divided into + three 8 bit sections for the rgb values. For example `0xFF0000` for pure + red. + Each coordinate system or data set that does not have a mapping in this + dictionary will get a default color assigned to it. + time : pandas.DatetimeIndex, pandas.TimedeltaIndex, List[pandas.Timestamp], or \ + weldx.transformations.LocalCoordinateSystem + The time steps that should be plotted + time_ref : pandas.Timestamp + A reference timestamp that can be provided if the ``time`` parameter is a + `pandas.TimedeltaIndex` + title : str + The title of the plot + limits : Tuple[float, float] or List[Tuple[float, float]] + Each tuple marks lower and upper boundary of the x, y and z axis. If only a + single tuple is passed, the boundaries are used for all axis. If `None` + is provided, the axis are adjusted to be of equal length. + set_axes_equal : bool + (matplotlib only) If `True`, all axes are adjusted to cover an equally large + range of value. That doesn't mean, that the limits are identical + show_origins : bool + If `True`, the origins of the coordinate system are visualized in the color + assigned to the coordinate system. + show_trace : bool + If `True`, the trace of time dependent coordinate systems is plotted. + show_vectors : bool + If `True`, the coordinate cross of time dependent coordinate systems is plotted. + show_wireframe : bool + If `True`, the mesh is visualized as wireframe. Otherwise, it is not shown. + + Returns + ------- + matplotlib.axes.Axes : + The axes object that was used as canvas for the plot. + + """ + if time is not None: + return plot_coordinate_system_manager_matplotlib( + csm.interp_time(time=time, time_ref=time_ref), + axes=axes, + reference_system=reference_system, + coordinate_systems=coordinate_systems, + title=title, + show_origins=show_origins, + show_trace=show_trace, + show_vectors=show_vectors, + ) + if axes is None: + _, axes = new_3d_figure_and_axes() + axes.set_xlabel("x") + axes.set_ylabel("y") + axes.set_zlabel("z") + + if reference_system is None: + reference_system = csm.root_system_name + if coordinate_systems is None: + coordinate_systems = csm.coordinate_system_names + if data_sets is None: + data_sets = csm.data_names + if title is not None: + axes.set_title(title) + + # plot coordinate systems + color_gen = color_generator_function() + for lcs_name in coordinate_systems: + color = color_int_to_rgb_normalized(get_color(lcs_name, colors, color_gen)) + lcs = csm.get_cs(lcs_name, reference_system) + lcs.plot( + axes=axes, + color=color, + label=lcs_name, + show_origin=show_origins, + show_trace=show_trace, + show_vectors=show_vectors, + ) + # plot data + for data_name in data_sets: + color = color_int_to_rgb_normalized(get_color(data_name, colors, color_gen)) + data = csm.get_data(data_name, reference_system) + plot_spatial_data_matplotlib( + data=data, + axes=axes, + color=color, + label=data_name, + show_wireframe=show_wireframe, + ) + + _set_limits_matplotlib(axes, limits, set_axes_equal) + axes.legend() + + return axes + + +def plot_spatial_data_matplotlib( + data, + axes: plt.Axes = None, + color: Union[int, Tuple[int, int, int], Tuple[float, float, float]] = None, + label: str = None, + show_wireframe: bool = True, +) -> plt.Axes: + """Visualize a `weldx.geometry.SpatialData` instance. + + Parameters + ---------- + data : weldx.geometry.SpatialData + The data that should be visualized + axes : matplotlib.axes.Axes + The target `matplotlib.axes.Axes` object of the plot. If 'None' is passed, a + new figure will be created + color : Union[int, Tuple[int, int, int], Tuple[float, float, float]] + A 24 bit integer, a triplet of integers with a value range of 0-255 + or a triplet of floats with a value range of 0.0-1.0 that represent an RGB + color + label : str + Label of the plotted geometry + show_wireframe : bool + If `True`, the mesh is plotted as wireframe. Otherwise only the raster + points are visualized. Currently, the wireframe can't be visualized if a + `weldx.geometry.VariableProfile` is used. + + Returns + ------- + matplotlib.axes.Axes : + The `matplotlib.axes.Axes` instance that was used for the plot. + + """ + if axes is None: + _, axes = new_3d_figure_and_axes() + + if not isinstance(data, geo.SpatialData): + data = geo.SpatialData(data) + + if color is None: + color = (0.0, 0.0, 0.0) + else: + color = color_to_rgb_normalized(color) + + coordinates = data.coordinates.data + triangles = data.triangles + + # if data is time dependent or has other extra dimensions, just take the first value + while coordinates.ndim > 2: + coordinates = coordinates[0] + + axes.scatter( + coordinates[:, 0], + coordinates[:, 1], + coordinates[:, 2], + marker=".", + color=color, + label=label, + zorder=2, + ) + if triangles is not None and show_wireframe: + for triangle in triangles: + triangle_data = coordinates[[*triangle, triangle[0]], :] + axes.plot( + triangle_data[:, 0], + triangle_data[:, 1], + triangle_data[:, 2], + color=color, + zorder=1, + ) + + return axes diff --git a/weldx/welding/__init__.py b/weldx/welding/__init__.py index 91f23caf4..b44675e6d 100644 --- a/weldx/welding/__init__.py +++ b/weldx/welding/__init__.py @@ -1,3 +1,3 @@ """Temporary module for welding related classes.""" -from . import groove, processes +from . import groove, processes, util diff --git a/weldx/welding/groove/iso_9692_1.py b/weldx/welding/groove/iso_9692_1.py index 9ccb7e800..94e8fc073 100644 --- a/weldx/welding/groove/iso_9692_1.py +++ b/weldx/welding/groove/iso_9692_1.py @@ -1,16 +1,19 @@ """ISO 9692-1 welding groove type definitions.""" - +import abc +from abc import abstractmethod from dataclasses import dataclass, field -from typing import List +from typing import List, Tuple, Union import numpy as np import pint +from sympy import Point2D, Polygon import weldx.geometry as geo from weldx.constants import WELDX_QUANTITY as Q_ -from weldx.utility import ureg_check_class +from weldx.util import inherit_docstrings, ureg_check_class __all__ = [ + "IsoBaseGroove", "IGroove", "VGroove", "VVGroove", @@ -40,9 +43,44 @@ def _set_default_heights(groove): groove.h1 = groove.h2 -class IsoBaseGroove: +def _get_bounds(points): + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return min(xs), min(ys), max(xs), max(ys) + + +def _compute_cross_sect_shape_points( + points: List[List[Union[Point2D, Tuple]]] +) -> pint.Quantity: # noqa + # Assumes that we have two separate shapes for each workpiece + # 1. compute the total area of all workpieces + # 2. compute bounding box of all pieces (this includes the rift) + # 3. compute area A = A_outer - A_workpieces + + area_workpiece = 0 + bounds = [] + + for shape_points in points: + p = Polygon(*shape_points, evaluate=False) + area_workpiece += abs(p.area) + x1, y1, x2, y2 = p.bounds + bounds.append((x1, y1)) + bounds.append((x2, y2)) + + # outer bbox + x1, y1, x2, y2 = _get_bounds(bounds) + + bounding_box = Polygon((x1, y1), (x2, y1), (x2, y2), (x1, y2), evaluate=False) + + return Q_(float(bounding_box.area - area_workpiece), "mm²") + + +class IsoBaseGroove(metaclass=abc.ABCMeta): """Generic base class for all groove types.""" + _AREA_RASTER_WIDTH = 0.1 + """steers the area approximation of the groove in ~cross_sect_area.""" + def parameters(self): """Return groove parameters as dictionary of quantities.""" return {k: v for k, v in self.__dict__.items() if isinstance(v, pint.Quantity)} @@ -65,33 +103,45 @@ def plot( grid=True, line_style=".-", ax=None, + show_area: bool = True, ): - """Plot a 2D-Profile. + """Plot a 2D groove profile. Parameters ---------- title : - (Default value = None) + custom plot title axis_label : label string to pass onto matplotlib (Default value = None) raster_width : - (Default value = 0.1) + rasterization distance show_params : - (Default value = True) + list groove parameters in plot title axis : - (Default value = "equal") + axis scaling style grid : - (Default value = True) + matplotlib grid setting line_style : - (Default value = ".") + matplotlib linestyle ax : - (Default value = None) + Axis to plot to + show_area + Calculate and show the groove cross section area in the plot title. """ profile = self.to_profile() if title is None: title = _groove_type_to_name[self.__class__] + if show_area: + try: + ca = self.cross_sect_area + title = title + f" ({np.around(ca,1):~.3P})" + except NotImplementedError: + pass + except Exception as ex: + raise ex + if show_params: title = title + "\n" + ", ".join(self.param_strings()) @@ -99,19 +149,64 @@ def plot( title, raster_width, None, axis, axis_label, grid, line_style, ax=ax ) + @abstractmethod def to_profile(self, width_default: pint.Quantity = None) -> geo.Profile: """Implement profile generation. Parameters ---------- - width_default : + width_default : pint.Quantity optional width to extend each side of the profile (Default value = None) + Returns + ------- + profile: weldx.geometry.Profile + The Profile object contains the shapes forming the groove. + + """ + + # TODO: there is some effort going on to define dimensionality as type annotation. + # https://github.com/hgrecco/pint/pull/1259 with this we can can annotate something + # like -> Q(['length**2']) + @property + @abc.abstractmethod + def cross_sect_area(self) -> pint.Quantity: + """Area of the cross-section of the two work pieces. + + Returns + ------- + area : pint.Quantity + The computed area in mm². + """ - raise NotImplementedError("to_profile() must be defined in subclass.") + + def _compute_cross_sect_area_from_profile(self): + points = [] + profile = self.to_profile() + + for shape in profile.shapes: + shape_points = [] + points.append(shape_points) + for seg in shape.segments: + if isinstance(seg, geo.LineSegment): + shape_points.append(seg.point_start) + shape_points.append(seg.point_end) + else: + raise RuntimeError("only for line segments!") + return _compute_cross_sect_shape_points(points) + + def _compute_cross_sect_area_interpolated(self): + # this method computes an approximation of the area by creating a big polygon + # out of the rasterization points + profile = self.to_profile() + rasterization = profile.rasterize(self._AREA_RASTER_WIDTH, stack=False) + points = [[(x, y) for x, y in shape.T] for shape in rasterization] + + return _compute_cross_sect_shape_points(points) @ureg_check_class("[length]", "[length]", None) +@inherit_docstrings @dataclass class IGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -163,8 +258,13 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_from_profile() + @ureg_check_class("[length]", "[]", "[length]", "[length]", None) +@inherit_docstrings @dataclass class VGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -252,8 +352,13 @@ def to_profile(self, width_default=Q_(2, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_from_profile() + @ureg_check_class("[length]", "[]", "[]", "[length]", "[length]", "[length]", None) +@inherit_docstrings @dataclass class VVGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -349,8 +454,13 @@ def to_profile(self, width_default=Q_(5, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_from_profile() + @ureg_check_class("[length]", "[]", "[]", "[length]", "[length]", "[length]", None) +@inherit_docstrings @dataclass class UVGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -444,8 +554,13 @@ def to_profile(self, width_default: pint.Quantity = Q_(2, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_interpolated() + @ureg_check_class("[length]", "[]", "[length]", "[length]", "[length]", None) +@inherit_docstrings @dataclass class UGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -562,11 +677,15 @@ def to_profile(self, width_default: pint.Quantity = Q_(3, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_interpolated() + @ureg_check_class("[length]", "[]", "[length]", "[length]", None) +@inherit_docstrings @dataclass class HVGroove(IsoBaseGroove): - # noinspection PyUnresolvedReferences """A HV-Groove. For a detailed description of the execution look in get_groove. @@ -645,8 +764,13 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([shape_h, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_from_profile() + @ureg_check_class("[length]", "[]", "[length]", "[length]", "[length]", None) +@inherit_docstrings @dataclass class HUGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -738,8 +862,13 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([shape_h, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_interpolated() + @ureg_check_class("[length]", "[]", "[]", "[length]", None, None, "[length]", None) +@inherit_docstrings @dataclass class DVGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -840,6 +969,10 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_from_profile() + @ureg_check_class( "[length]", @@ -853,6 +986,7 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: "[length]", None, ) +@inherit_docstrings @dataclass class DUGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -967,8 +1101,13 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([shape, shape_r], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_interpolated() + @ureg_check_class("[length]", "[]", "[]", "[length]", None, None, "[length]", None) +@inherit_docstrings @dataclass class DHVGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -1057,6 +1196,10 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([left_shape, right_shape], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_from_profile() + @ureg_check_class( "[length]", @@ -1070,6 +1213,7 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: "[length]", None, ) +@inherit_docstrings @dataclass class DHUGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -1167,6 +1311,10 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: return geo.Profile([left_shape, right_shape], units=_DEFAULT_LEN_UNIT) + @property + def cross_sect_area(self): # noqa + return self._compute_cross_sect_area_interpolated() + @ureg_check_class( "[length]", @@ -1176,6 +1324,7 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: None, None, ) +@inherit_docstrings @dataclass class FFGroove(IsoBaseGroove): # noinspection PyUnresolvedReferences @@ -1385,6 +1534,10 @@ def to_profile(self, width_default: pint.Quantity = Q_(5, "mm")) -> geo.Profile: ' "3.1.3", "4.1.1", "4.1.2", "4.1.3"' ) + @property + def cross_sect_area(self): # noqa + raise NotImplementedError("Cannot determine FFGroove cross sectional area") + def _helperfunction(segment, array) -> geo.Shape: """Calculate a shape from input. diff --git a/weldx/welding/util.py b/weldx/welding/util.py new file mode 100644 index 000000000..f84a68855 --- /dev/null +++ b/weldx/welding/util.py @@ -0,0 +1,39 @@ +"""Collection of welding utilities.""" +import numpy as np +import pint + +from weldx.constants import WELDX_UNIT_REGISTRY +from weldx.welding.groove.iso_9692_1 import IsoBaseGroove + +__all__ = ["compute_welding_speed"] + + +@WELDX_UNIT_REGISTRY.check(None, "[length]/[time]", "[length]") +def compute_welding_speed( + groove: IsoBaseGroove, + wire_feed: pint.Quantity, + wire_diameter: pint.Quantity, +) -> pint.Quantity: + """Compute how fast the torch has to be moved to fill the given groove. + + Parameters + ---------- + groove + groove definition to compute welding speed for. + wire_feed: pint.Quantity + feed of the wire, given in dimensionality "length/time". + wire_diameter: pint.Quantity + diameter of welding wire, given in dimensionality "length". + + Returns + ------- + speed: pint.Quantity + The computed welding speed, given in dimensionality "length/time". + + """ + groove_area = groove.cross_sect_area + wire_area = np.pi / 4 * wire_diameter ** 2 + weld_speed = wire_area * wire_feed / groove_area + + weld_speed.ito_reduced_units() + return weld_speed