diff --git a/.gitignore b/.gitignore index 7bd652a2..87d1d64a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ projects/ nohup.out /*.obj dist/ +tmp/ diff --git a/DCO.md b/DCO.md new file mode 100644 index 00000000..d950df0c --- /dev/null +++ b/DCO.md @@ -0,0 +1,34 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. \ No newline at end of file diff --git a/Sionna_Ray_Tracing_Asset.ipynb b/Sionna_Ray_Tracing_Asset.ipynb new file mode 100644 index 00000000..ad554774 --- /dev/null +++ b/Sionna_Ray_Tracing_Asset.ipynb @@ -0,0 +1,2001 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2f6a8731", + "metadata": {}, + "source": [ + "# Introduction to Sionna RT - Assets & BSDFs Configuration Tools" + ] + }, + { + "cell_type": "markdown", + "id": "f702817e", + "metadata": {}, + "source": [ + "In this notebook, you will\n", + "- Learn how to programaticaly append or remove assets object to a scene (without using Blender).\n", + "- See the impact of the assets within the scene w.r.t. ray-traced channels for link-level simulations instead of stochastic channel models.\n", + "- Learn how to define, set and change the rendering properties of a radio material in the scene using BSDF object.\n" + ] + }, + { + "cell_type": "markdown", + "id": "43f0a988", + "metadata": {}, + "source": [ + "## Table of Contents\n", + "* [Information on Assets](#Information-on-Assets)\n", + "* [GPU Configuration and Imports](#GPU-Configuration-and-Imports)\n", + "* [Imports](#Imports)\n", + "* [Loading Scenes](#loading-scenes)\n", + "* [Load and Manage Assets](#load-and-manage-assets)\n", + "* [Adding a Second Asset](#adding-a-second-asset)\n", + "* [Removing an Asset](#removing-an-asset)\n", + "* [Multi Component Objects Asset](#multi-component-objects-asset)\n", + "* [Interaction with Paths](#interaction-with-paths)\n", + "* [Radio Materials and BSDFs](#radio-materials-and-bsdfs)\n", + "* [Annex Asset Features](#annex-asset-features)" + ] + }, + { + "cell_type": "markdown", + "id": "2f32f3d7", + "metadata": {}, + "source": [ + "## Information on Assets\n", + "\n", + "It is usefull to be able to construct a scene dynamically by adding and removing objects. To this end, Sionna incorporates a few functionalities. \n", + "\n", + "One can define a scene, e.g. using Blender, import the scene within Sionna and then add new assets (object or group of objects) to that scene. The assets can be separately defined in Blender. \n", + "\n", + "As an example, one could define a scene with a car park, and append to that scene a varing number of cars to see the impact in terms of RF propagation. Using this mechanism, the user should be able to automatically generate datasets from the scenes and/or define complex scenario.\n" + ] + }, + { + "cell_type": "markdown", + "id": "938106f2", + "metadata": {}, + "source": [ + "## GPU Configuration and Imports " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "98c23abd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "import os\n", + "gpu_num = 0 # Use \"\" to use the CPU\n", + "os.environ[\"CUDA_VISIBLE_DEVICES\"] = f\"{gpu_num}\"\n", + "os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'\n", + "\n", + "# Colab does currently not support the latest version of ipython.\n", + "# Thus, the preview does not work in Colab. However, whenever possible we\n", + "# strongly recommend to use the scene preview mode.\n", + "try: # detect if the notebook runs in Colab\n", + " import google.colab\n", + " colab_compat = True # deactivate preview\n", + "except:\n", + " colab_compat = False\n", + "resolution = [480,320] # increase for higher quality of renderings\n", + "\n", + "# Allows to exit cell execution in Jupyter\n", + "class ExitCell(Exception):\n", + " def _render_traceback_(self):\n", + " pass\n", + "\n", + "# Import Sionna\n", + "try:\n", + " import sionna\n", + "except ImportError as e:\n", + " # Install Sionna if package is not already installed\n", + " import os\n", + " os.system(\"pip install sionna\")\n", + "\n", + "# Configure the notebook to use only a single GPU and allocate only as much memory as needed\n", + "# For more details, see https://www.tensorflow.org/guide/gpu\n", + "import tensorflow as tf\n", + "gpus = tf.config.list_physical_devices('GPU')\n", + "if gpus:\n", + " try:\n", + " \n", + " tf.config.experimental.set_memory_growth(gpus[0], True)\n", + " except RuntimeError as e:\n", + " print(e)\n", + "# Avoid warnings from TensorFlow\n", + "tf.get_logger().setLevel('ERROR')\n", + "\n", + "tf.random.set_seed(1) # Set global random seed for reproducibility\n" + ] + }, + { + "cell_type": "markdown", + "id": "b20b7d49", + "metadata": {}, + "source": [ + "# Imports" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "34147eec", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "\n", + "# Import Sionna RT components\n", + "from sionna.rt import load_scene, Transmitter, Receiver, PlanarArray, AssetObject, BSDF, RadioMaterial\n", + "\n", + "# For link-level simulations\n", + "from sionna.channel import cir_to_time_channel" + ] + }, + { + "cell_type": "markdown", + "id": "58380340", + "metadata": {}, + "source": [ + "## Loading Scenes\n", + "\n", + "The Sionna RT module can either load external scene files (in Mitsuba's XML file format) or it can load one of the [integrated scenes](https://nvlabs.github.io/sionna/api/rt.html#example-scenes).\n", + "\n", + "In this example, we load a scene containing a floor and a basic wall above it. The scene can be seen as a the static reference, in which one can add or remove assets. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d66aa238", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# Load scene\n", + "scene = load_scene(filename=sionna.rt.scene.floor_wall)\n", + "\n", + "\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512);\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "c6ec6563", + "metadata": {}, + "source": [ + "## Load and Manage Assets\n", + "\n", + "In a static scene, one can add assets at various position to evaluate different scenario without having to design a dedicated scene beforehand. It should be noted, that assets are added to the scene by recreating the scene from scratch (reloading the scene). Hence, the action itself to add or remove an asset object to a scene is not differentiable, but the previous and the novel scenes preserve their differentiability properties as any scene in Sionna. This tool must be seen as a way to programmatically construct the scene, e.g. to generate many random scenes to construct a dataset for neural networks training.\n", + "\n", + "The actions that trigger a scene reload will be presented throughout this notebook, as well as the impact of reloading the scene.\n", + "\n", + "Some preloaded assets are located in sionna/rt/assets. Let's see how to load and use them.\n", + "\n", + "NB: As a workaround to maintain differentiability when placing an object, one could use the mobility tools from Sionna to move pre-existing objects of the scene into or out of the zone of interest of the scene. Yet, this would require to have put these objects in the scene in the first place." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "48401ccd", + "metadata": {}, + "outputs": [], + "source": [ + "# Load an asset\n", + "body_asset = AssetObject(name=\"asset_0\", filename=sionna.rt.asset_object.body)\n", + "\n", + "# Alternative call\n", + "# body_asset = AssetObject(name=\"asset_0\", filename=\"./sionna/rt/assets/body/body.xml\")\n", + "\n", + "# Add the asset to the scene. Adding an AssetObject to the scene trigger a scene reload.\n", + "print(f\"Memory address of the Scene object: {hex(id(scene))}\")\n", + "scene.add(body_asset)\n", + "print(f\"Memory address of the Scene object: {hex(id(scene))}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c619b884", + "metadata": {}, + "outputs": [], + "source": [ + "body_asset.position = [1.,0.,0.]\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "a66efde2", + "metadata": {}, + "source": [ + "As it can be seen in the preview, a body has been added to the previous scene. This action triggered a scene reload.\n", + "\n", + "Reloading the scene does not create a new Scene object for previously existing objects e.g. the wall or the floor.\n", + "\n", + "The body can be modified in the same way as any object of the scene.\n", + "\n", + "It is listed in the .asset_objects method from the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "037a11b4", + "metadata": {}, + "outputs": [], + "source": [ + "# Print a dictionnary containing the assets of the scene\n", + "print(scene.asset_objects)" + ] + }, + { + "cell_type": "markdown", + "id": "ed615dbd", + "metadata": {}, + "source": [ + "The dictionnary contains all the assets of the scene.\n", + "\n", + "Our body \"asset_0\" is listed as an AssetObject. They are slightly different from others Sionna's SceneObject, as we will demonstrate in this notebook. \n", + "\n", + "First of all, AssetObject can be composed of several SceneObjects. Use the method .shapes to display them." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0ac9659", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the asset object by name as any object from the scene\n", + "body_asset = scene.get('asset_0')\n", + "print(f\"Check the type of the body_asset variable: {type(body_asset).__name__}\")\n", + "\n", + "# Print the objects (SceneObject) composing the asset (AssetObject)\n", + "print(f\"{body_asset.shapes}\")" + ] + }, + { + "cell_type": "markdown", + "id": "0c024452", + "metadata": {}, + "source": [ + "Our asset (AssetObject) \"asset_0\" is composed of one object (SceneObject) \"asset_0_body\".\n", + "\n", + "The name of the object is based on the asset name \"asset_0\" associated with the component name defined in Blender \"body\".\n", + "\n", + "The method AssetObject.shapes return a dictionnary, containing all the SceneObject that composed the asset.\n", + "\n", + "You should have noted that the dictionnary contains a so called weakproxy so the SceneObject. You can handle this object as any SceneObject since it is basically a proxy to the SceneObject. \n", + "\n", + "The only major difference is that the weakproxy is a weak reference to the SceneObject. The only strong reference to the SceneObject is contained in the Scene itself. Getting the SceneObject from the scene or the assets should only return a weak reference. The reason behind this design choice is explained later, when we will remove an AssetObject from the scene.\n", + "\n", + "To learn more on weak reference, see [here](https://docs.python.org/3.12/library/weakref.html)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a14ee1e", + "metadata": {}, + "outputs": [], + "source": [ + "# Print a dictionnary containing all the objects of the scene\n", + "print(scene.objects) # also return weak references" + ] + }, + { + "cell_type": "markdown", + "id": "3a0454e3", + "metadata": {}, + "source": [ + "The scene contains three objects:\n", + "- The \"floor\" that was in the basic scene.\n", + "- The \"wall\" that was also in the basic scene.\n", + "- The \"body\", named \"asset_0_body\", that we have added.\n", + "\n", + "Hence, the body is seen as a classic SceneObject by Sionna." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0a595116", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the body object from the scene as any SceneObject\n", + "body_object = scene.get('asset_0_body') # also return a weak reference\n", + "\n", + "print(f\"Current body asset position: {body_asset.position.numpy()}\")\n", + "print(f\"Current body asset orientation: {body_asset.orientation.numpy()}\")\n", + "print(f\"Current body asset velocity: {body_asset.velocity.numpy()}\")\n", + "\n", + "print(f\"Current body object position: {body_object.position.numpy()}\")\n", + "print(f\"Current body object orientation: {body_object.orientation.numpy()}\")\n", + "print(f\"Current body object velocity: {body_object.velocity.numpy()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a350a446", + "metadata": {}, + "source": [ + "The asset also exposes the usual attributes \"position\", \"orientation\" and \"velocity\".\n", + "\n", + "As it can be seen, the position of the object is not [0.,0.,0.], contrary to the asset one. This is because Sionna creates a virtual rectangular Axis-Aligned Bounding Box (AABB) around the whole asset and return the position of the barycenter of this box. Hence, the position is somewhere at the center of the body. The asset position, on the contrary, is the origin defined originally in Blender; here at the feet of the body. To witness that behavior, we can simply fix the position of the object to [0.,0.,0.] using the SceneObject property." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f77bbc4e", + "metadata": {}, + "outputs": [], + "source": [ + "# Store the original position\n", + "original_body_object_position = body_object.position.numpy()\n", + "\n", + "# Manually set the SceneObject position\n", + "body_object.position = [0.,0.,0.]\n", + "\n", + "print(f\"Current body object position: {body_object.position.numpy()}\")\n", + "print(f\"Current body asset position: {body_asset.position.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "0f176595", + "metadata": {}, + "source": [ + "The AABB (hit box) barycenter of our asset is now at the coordinates [0.,0.,0.]. The position (and other attributes like orientation and velocity) of the asset is not modified when modifying a component object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7f72725", + "metadata": {}, + "outputs": [], + "source": [ + "# Manually set the orientation of the SceneObject\n", + "body_object.orientation = [np.pi/4.,np.pi/4.,np.pi/4.]\n", + "\n", + "print(f\"Orientation of the object: {body_object.orientation.numpy()}\")\n", + "print(f\"Position of the object: {body_object.position.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "c089ce69", + "metadata": {}, + "source": [ + "After the change of orientation of the SceneObject, the position of the barycenter of the object has changed. That is because Sionna's AABB is not rotated, but recomputed with edges parallel to the coordinate reference axis (x,y,z), hence modifying the barycenter position.\n", + "\n", + "That's why we advice to directly manipulate the asset itself, as you will be able to conveniently and consistently manipulate group of objects together. Also, this is more natural, as the origin of the asset is defined by the user through the asset generation in Blender.\n", + "\n", + "This is also the reason why modifying the position/orientation/velocity of an object from an asset will not modify the asset's configuration." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e068a1d1", + "metadata": {}, + "outputs": [], + "source": [ + "# Show that the position and orientation of the asset has not been modified\n", + "print(f\"Position of the asset: {body_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {body_asset.orientation.numpy()}\")\n", + "\n", + "# Position the body at its original position, with original orientation\n", + "# First set the orientation and then the position to avoid side effect\n", + "body_object.orientation = [0.,0.,0.]\n", + "body_object.position = original_body_object_position\n", + "\n", + "print(f\"Position of the object: {body_object.position.numpy()}\")\n", + "print(f\"Orientation of the object: {body_object.orientation.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "439316de", + "metadata": {}, + "source": [ + "The body is back on its feet! Now let's move it to one side of the wall using the asset properties." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "29d0d998", + "metadata": {}, + "outputs": [], + "source": [ + "# Move the body to one side of the wall\n", + "body_asset.position = [1.,0.,0.]\n", + "\n", + "print(f\"Position of the asset: {body_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {body_asset.orientation.numpy()}\")\n", + "\n", + "print(f\"Position of the object: {body_object.position.numpy()}\")\n", + "print(f\"Orientation of the object: {body_object.orientation.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "a16d0a92", + "metadata": {}, + "source": [ + "The body object position has been correctly moved along with the asset one. Now let's change its orientation so it faces the wall." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7627c667", + "metadata": {}, + "outputs": [], + "source": [ + "# Make the body face the wall\n", + "body_asset.orientation = [-np.pi/2.,0.,0.]\n", + "\n", + "print(f\"Position of the asset: {body_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {body_asset.orientation.numpy()}\")\n", + "\n", + "print(f\"Position of the object: {body_object.position.numpy()}\")\n", + "print(f\"Orientation of the object: {body_object.orientation.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "834379f7", + "metadata": {}, + "source": [ + "The center of the body asset is located at its feet. So we can turn it upside down." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91927d50", + "metadata": {}, + "outputs": [], + "source": [ + "# Turn the body upside down\n", + "body_asset.orientation += [0.,np.pi,0.]\n", + "\n", + "print(f\"Position of the asset: {body_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {body_asset.orientation.numpy()}\")\n", + "\n", + "print(f\"Position of the object: {body_object.position.numpy()}\")\n", + "print(f\"Orientation of the object: {body_object.orientation.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "dfe2795e", + "metadata": {}, + "source": [ + "## Adding a Second Asset" + ] + }, + { + "cell_type": "markdown", + "id": "cc0ffed3", + "metadata": {}, + "source": [ + "Now, let's add another asset body to the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e4ffbe57", + "metadata": {}, + "outputs": [], + "source": [ + "# Rename the reference to the first asset and object\n", + "first_body_asset = body_asset\n", + "first_body_object = body_object\n", + "\n", + "# Print the first body object memory address\n", + "print(f\"Memory address of the first body (SceneObject): {repr(first_body_object)}\")\n", + "\n", + "# Create a second body asset\n", + "second_body_asset = AssetObject(name=\"asset_1\", filename=sionna.rt.asset_object.body)\n", + "\n", + "# Add the second asset to the scene\n", + "scene.add(second_body_asset) # trigger a scene reload\n", + "\n", + "# Show that the first body object is still the same after the scene relaod\n", + "print(f\"Memory address of the first body (SceneObject): {repr(first_body_object)}\") # Same SceneObject, the weak reference is still alive\n", + "# Same behavior by getting the object again from the scene\n", + "print(f\"Memory address of the first body (SceneObject): {repr(scene.get('asset_0_body'))}\") # Same SceneObject, the weak reference is still alive\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "63c0e763", + "metadata": {}, + "source": [ + "Again, adding an asset to a scene will reload the scene.\n", + "\n", + "All the objects already present in the scene, that should still be present after the reload, are set back to their previous state.\n", + "\n", + "Here, the \"asset_0_body\" attributes (position/orientation/velocity) are set back, so that it seems that the body never moved.\n", + "\n", + "The SceneObject (in the python sense) are the same, as it can be seen from the memory address of the SceneObject. Also, the weak references are still alive." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79700f2c", + "metadata": {}, + "outputs": [], + "source": [ + "# Check that the position of the asset_0 asset and asset_0_body object are set correctly\n", + "print(f\"Position of the asset: {first_body_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {first_body_asset.orientation.numpy()}\")\n", + "print(f\"Position of the object: {first_body_object.position.numpy()}\")\n", + "print(f\"Orientation of the object: {first_body_object.orientation.numpy()}\")" + ] + }, + { + "cell_type": "markdown", + "id": "a53d61e1", + "metadata": {}, + "source": [ + "Now let's move our second asset, as we have done for the first one." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb526bc0", + "metadata": {}, + "outputs": [], + "source": [ + "# Show that the second asset was added to the scene\n", + "print(\"Dictionnary of the assets in the scene.\")\n", + "print(scene.asset_objects)\n", + "\n", + "# Show that the second body was added to the scene\n", + "print(\"Dictionnary of the objects in the scene.\")\n", + "print(scene.objects)\n", + "\n", + "# Get a reference to the second body object\n", + "second_body_object = scene.get(\"asset_1_body\") # return a weak reference\n", + "\n", + "# Position the second asset/body, so that it mirrors the first one\n", + "second_body_asset.position = [1.,0.,0.]\n", + "second_body_asset.orientation = [-np.pi/2.,0.,0.]\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "62935deb", + "metadata": {}, + "source": [ + "## Removing an Asset" + ] + }, + { + "cell_type": "markdown", + "id": "7935109e", + "metadata": {}, + "source": [ + "We can remove an asset from a scene. This action triggers a scene reload." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26debf67", + "metadata": {}, + "outputs": [], + "source": [ + "# We remove the first asset\n", + "scene.remove(\"asset_0\") # trigger scene reload\n", + "\n", + "print(f\"Scene assets: {scene.asset_objects}\")\n", + "print(f\"Scene objects: {scene.objects}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "49d2cfbb", + "metadata": {}, + "source": [ + "The first asset has correctly been removed from the scene. All the component SceneObject (here the first body) have also been removed.\n", + "\n", + "The first thing to note, is that the AssetObject itself is not deleted outside of the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1bbc1d2f", + "metadata": {}, + "outputs": [], + "source": [ + "# Show that the first AssetObject is still available\n", + "print(first_body_asset)" + ] + }, + { + "cell_type": "markdown", + "id": "3521f0a7", + "metadata": {}, + "source": [ + "Hence it can be added again to the scene if needed.\n", + "\n", + "The second thing to note, is that the removed SceneObject(s) are no longer referenced. More precisely, the strong reference in the scene has been deleted, removing any access through the weak references." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff2e1791", + "metadata": {}, + "outputs": [], + "source": [ + "# The weak references are dead\n", + "try:\n", + " print(first_body_object)\n", + "except ReferenceError as e:\n", + " print(f\"Trying to access the SceneObject returned the error: {e}\")\n", + "\n", + "# The dictionnary of the asset is empty\n", + "print(f\"The dictionnary of the component objects of the asset is equal to: {first_body_asset.shapes}\")" + ] + }, + { + "cell_type": "markdown", + "id": "acea418c", + "metadata": {}, + "source": [ + "The main objective was to help the user and avoid errors, where one would try to access and modify SceneObjects that are not present in the scene anymore.\n", + "\n", + "The following cells presents the way to access to objects of the scene and some specific behavior." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1fa8c845", + "metadata": {}, + "outputs": [], + "source": [ + "# get method directly return a weakproxy reference to the SceneObject\n", + "second_body_object_get = scene.get('asset_1_body')\n", + "print(f\"Show the SceneObject: {second_body_object_get}\")\n", + "print(f\"Show the weakproxy and the SceneObject: {repr(second_body_object_get)}\")\n", + "print(f\"The type is ProxyType: {type(second_body_object_get)}\")\n", + "print(f\"The basic memory address is the proxy one: {hex(id(second_body_object_get))}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa44d734", + "metadata": {}, + "outputs": [], + "source": [ + "# shapes attribute from asset return a dictionnary, filled with weakproxy to the component SceneObjects\n", + "second_body_asset_shapes = second_body_asset.shapes\n", + "print(f\"The dictionnary of the component objects of the asset is equal to: {second_body_asset_shapes}\")\n", + "\n", + "# Access to the SceneObject as you will with any dictionnary\n", + "second_body_object_shapes = second_body_asset_shapes['asset_1_body']\n", + "print(f\"Show the SceneObject: {second_body_object_shapes}\")\n", + "print(f\"Show the weakproxy and the SceneObject: {repr(second_body_object_shapes)}\")\n", + "print(f\"The type is ProxyType: {type(second_body_object_shapes)}\")\n", + "print(f\"The basic memory address is the proxy one: {hex(id(second_body_object_shapes))}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "97bd134c", + "metadata": {}, + "outputs": [], + "source": [ + "# objects method from the scene return a custom WeakRefDict object\n", + "scene_objects_dictionnary = scene.objects\n", + "print(f\"The dictionnary of the objects of the scene is equal to: {scene_objects_dictionnary}\")\n", + " \n", + "# Access to the SceneObject as you will with any dictionnary\n", + "second_body_object_objects = scene_objects_dictionnary['asset_1_body']\n", + "print(f\"Show the SceneObject: {second_body_object_objects}\")\n", + "print(f\"Show the weakproxy and the SceneObject: {repr(second_body_object_objects)}\")\n", + "print(f\"The type is ProxyType: {type(second_body_object_objects)}\")\n", + "print(f\"The basic memory address is the proxy one: {hex(id(second_body_object_objects))}\")" + ] + }, + { + "cell_type": "markdown", + "id": "5d28e1f2", + "metadata": {}, + "source": [ + "Adding an asset with the same name than another asset will replace the previous asset with the new one.\n", + "\n", + "The SceneObjects of the previous asset are removed and replaced with new ones.\n", + "\n", + "Only the name of the asset is important, not the content of the asset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55211064", + "metadata": {}, + "outputs": [], + "source": [ + "# Create an asset with the same name\n", + "second_body_asset_bis = AssetObject('asset_1', filename=sionna.rt.asset_object.body)\n", + "\n", + "# Add it to the scene remove the previous one, triggers a scene reload and warns the user\n", + "scene.add(second_body_asset_bis)\n", + "\n", + "# All the previous weak references are dead\n", + "try:\n", + " print(second_body_object_get)\n", + "except ReferenceError as e:\n", + " print(f\"Trying to access the SceneObject returned the error: {e}\")\n", + "try:\n", + " print(second_body_object_shapes)\n", + "except ReferenceError as e:\n", + " print(f\"Trying to access the SceneObject returned the error: {e}\")\n", + "try:\n", + " print(second_body_object_objects)\n", + "except ReferenceError as e:\n", + " print(f\"Trying to access the SceneObject returned the error: {e}\")\n", + " \n", + "# The original asset object still exist, but not in the scene\n", + "print(f\"The original asset: {second_body_asset}\")\n", + "# This is the one in the scene\n", + "print(f\"The new asset: {second_body_asset_bis}\")\n", + "print(f\"Scene assets: {scene.asset_objects}\")\n", + "print(f\"Scene objects: {scene.objects}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "97d8d828", + "metadata": {}, + "source": [ + "## Multi Component Objects Asset" + ] + }, + { + "cell_type": "markdown", + "id": "e3ba238f", + "metadata": {}, + "source": [ + "So now that we have played a little with a single shape asset, let's use a multi-shape asset with two persons." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0804f2db", + "metadata": {}, + "outputs": [], + "source": [ + "# Remove the body asset\n", + "scene.remove('asset_1') # scene reload\n", + "\n", + "print(f\"Scene assets: {scene.asset_objects}\")\n", + "print(f\"Scene objects: {scene.objects}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27179342", + "metadata": {}, + "outputs": [], + "source": [ + "# Load the two bodies asset\n", + "two_bodies_asset = AssetObject(name=\"asset_two_bodies\", filename=sionna.rt.asset_object.two_persons)\n", + "\n", + "# Add the asset to the scene\n", + "scene.add(two_bodies_asset) # scene reload\n", + "\n", + "print(f\"Scene assets: {scene.asset_objects}\")\n", + "print(f\"Scene objects: {scene.objects}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "0c3e8a9f", + "metadata": {}, + "source": [ + "Our asset is composed of two objects \"asset_two_bodies_person_1\" and \"asset_two_bodies_person_2\", as it can be seen in the objects list of the scene.\n", + "\n", + "We can use the asset properties to move the bodies around." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed89f983", + "metadata": {}, + "outputs": [], + "source": [ + "# Get individual objects\n", + "body_1_object = scene.get('asset_two_bodies_person_1')\n", + "body_2_object = scene.get('asset_two_bodies_person_2')\n", + "\n", + "# Move the two bodies to one side of the wall\n", + "two_bodies_asset.position = [1.,0.,0.]\n", + "\n", + "print(f\"Position of the asset: {two_bodies_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {two_bodies_asset.orientation.numpy()}\")\n", + "\n", + "print(f\"Position of the person 1 object: {body_1_object.position.numpy()}\")\n", + "print(f\"Orientation of the person 1 object: {body_1_object.orientation.numpy()}\") \n", + "\n", + "print(f\"Position of the person 2 object: {body_2_object.position.numpy()}\")\n", + "print(f\"Orientation of the person 2 object: {body_2_object.orientation.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "0cecc0b2", + "metadata": {}, + "source": [ + "As explained before, using the asset properties is convenient to move around group of SceneObjects.\n", + "\n", + "Here, the two bodies are both handled by a single asset.\n", + "\n", + "Also, note that the orientation property of the SceneObject is not related to the actual orientation of the bodies, but represent the current angle compared to the one at init.\n", + "\n", + "The center of position and rotation of the asset is on the ground, between the two bodies." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2a56ae5", + "metadata": {}, + "outputs": [], + "source": [ + "# Move the two bodies to each side of the wall\n", + "two_bodies_asset.position = [0.,0.,0.]\n", + "two_bodies_asset.orientation = [np.pi/2.,0.,0.]\n", + "\n", + "print(f\"Position of the asset: {two_bodies_asset.position.numpy()}\")\n", + "print(f\"Orientation of the asset: {two_bodies_asset.orientation.numpy()}\")\n", + "\n", + "print(f\"Position of the person 1 object: {body_1_object.position.numpy()}\")\n", + "print(f\"Orientation of the person 1 object: {body_1_object.orientation.numpy()}\") \n", + "\n", + "print(f\"Position of the person 2 object: {body_2_object.position.numpy()}\")\n", + "print(f\"Orientation of the person 2 object: {body_2_object.orientation.numpy()}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "82c60807", + "metadata": {}, + "source": [ + "## Interaction with Paths" + ] + }, + { + "cell_type": "markdown", + "id": "c324d2cb", + "metadata": {}, + "source": [ + "Now we will demonstrate that the componennt objects of assets have the same interaction with ray tracing than any other Sionna's SceneObject.\n", + "\n", + "We will add a TX/RX couple at each side of the wall.\n", + "\n", + "The bodies objects will be individually moved (even if not recommended), so that we increase the space between them and can use them as reflectors." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c52b06b2", + "metadata": {}, + "outputs": [], + "source": [ + "# Place the bodies\n", + "two_bodies_asset.orientation = [0.,0.,0.]\n", + "body_1_object.position += [0.,1.5,0.]\n", + "body_2_object.position += [0.,-1.5,0.]\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6b70f571", + "metadata": {}, + "outputs": [], + "source": [ + "# Configure the transmitter and receiver arrays\n", + "# These are isotropic antennas\n", + "scene.tx_array = PlanarArray(num_rows=1,\n", + " num_cols=1,\n", + " vertical_spacing=0.5,\n", + " horizontal_spacing=0.5,\n", + " pattern=\"iso\",\n", + " polarization=\"V\")\n", + "\n", + "scene.rx_array = scene.tx_array\n", + "\n", + "# Add a transmitter and receiver with equal distance from the center of the scene\n", + "# The position is set precisely to get reflexions from the bodies hands\n", + "scene.add(Transmitter(name=\"tx\", position=[-3.1,0.,2.1]))\n", + "scene.add(Receiver(name=\"rx\", position=[3.1,0.,2.1]))\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "91f6b3b7", + "metadata": {}, + "outputs": [], + "source": [ + "# Fix the scene frequency\n", + "scene.frequency = 2.4e9\n", + "\n", + "# No LOS, one reflection max, exhaustive path tracing\n", + "paths = scene.compute_paths(los=False, reflection=True, max_depth=1, num_samples=1e6, method='exhaustive')\n", + "\n", + "print(f\"Amplitude of the paths: {np.abs(paths.a[0,0,0,0,0,1:,0].numpy())}\")\n", + "print(f\"Delay of the paths: {paths.tau[0,0,0,1:].numpy()}\")\n", + "\n", + "# Open 3D preview (only works in Jupyter notebook)\n", + "if colab_compat:\n", + " scene.render(\"scene-cam-0\", paths=paths);\n", + " raise ExitCell\n", + "scene.preview(paths=paths)" + ] + }, + { + "cell_type": "markdown", + "id": "e8354ca2", + "metadata": {}, + "source": [ + "The bodies provided in the asset are composed of numerous small reflective surfaces. The positions of TX and RX were chosen to obtain reflections on the hands.\n", + "\n", + "NB: To see more paths, one could activate the scattering.\n", + "\n", + "There are exactly 9 paths in the scene:\n", + "* The first path to arrive is reflected on the floor.\n", + "* The 8 other paths are reflected on the hands. You can zoom on the preview to see the 8 paths.\n", + "\n", + "In the paths list, we can identify which path reflected on which body by looking at the objects property of the paths." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "286c3711", + "metadata": {}, + "outputs": [], + "source": [ + "# Print the object on which each path reflected\n", + "print(f\"Object ID on which the corresponding path reflected: {paths.objects[0,0,0,1:]}\")\n", + "\n", + "# Show object ID of body 1 and 2\n", + "print(f\"Floor ID: {scene.get('floor').object_id}\")\n", + "print(f\"First body ID: {body_1_object.object_id}\")\n", + "print(f\"Second body ID: {body_2_object.object_id}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c5c145c3", + "metadata": {}, + "source": [ + "Because of the symetry of the scene, these paths arrive at (almost) the same time with (almost) the same energy." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "46a0ab2b", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the paths amplitude and delay\n", + "a, tau = paths.cir()\n", + "\n", + "# Remove first null LOS path\n", + "a = a[...,1:,:]\n", + "tau = tau[...,1:]\n", + "\n", + "t = tau[0,0,0,:]/1e-9 # Scale to ns\n", + "a_abs = np.abs(a)[0,0,0,0,0,:,0]\n", + "a_max = np.max(a_abs)\n", + "\n", + "# Add dummy entry at start/end for nicer figure\n", + "t = np.concatenate([(0.,), t, (np.max(t)*1.1,)])\n", + "a_abs = np.concatenate([(np.nan,), a_abs, (np.nan,)])\n", + "\n", + "# Plot the CIR\n", + "plt.figure()\n", + "plt.title(\"Channel impulse response realization\")\n", + "plt.stem(t, a_abs)\n", + "plt.xlim([0, np.max(t)])\n", + "plt.ylim([-2e-6, a_max*1.1])\n", + "plt.xlabel(r\"$\\tau$ [ns]\")\n", + "plt.ylabel(r\"$|a|$\");" + ] + }, + { + "cell_type": "markdown", + "id": "46fca0ec", + "metadata": {}, + "source": [ + "On the CIR, we can see that the 8 paths reflected by the hands arrived at the same time, around 2.45 ns. Generating the time channel estimate from the CIR should merge the power of these paths." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b81eaeac", + "metadata": {}, + "outputs": [], + "source": [ + "# Compute the time channel from the CIR\n", + "sampling_freq = 5e9 # 0.2ns resolution\n", + "l_min = -6\n", + "l_max = 20\n", + "h_time = cir_to_time_channel(bandwidth=sampling_freq, a=a, tau=tau, l_min=l_min, l_max=l_max, normalize=True)\n", + "t_time = np.arange(l_min, (l_max+1), 1) * (1/sampling_freq)\n", + "\n", + "h_time_abs = np.abs(h_time[0,0,0,0,0,0,:].numpy())\n", + "\n", + "# And plot the CIR\n", + "plt.figure()\n", + "plt.title(\"Time Channel from CIR\")\n", + "plt.stem(t_time, h_time_abs)\n", + "plt.xlim([0, np.max(t_time)])\n", + "plt.xlabel(r\"$\\tau$ [ns]\")\n", + "plt.ylabel(r\"$|h_{time}|$\");" + ] + }, + { + "cell_type": "markdown", + "id": "3a6e94a4", + "metadata": {}, + "source": [ + "## Radio Materials and BSDFs" + ] + }, + { + "cell_type": "markdown", + "id": "d7f50fc0", + "metadata": {}, + "source": [ + "In Sionna the materials of the SceneObjects are defined by a RadioMaterial.\n", + "\n", + "A RadioMaterial defines how the radio waves will interact with the object, thanks to attributes like permittivity, conductivity or scattering coefficient.\n", + "\n", + "Sionna defines some radio materials at initialization. All the radio materials currently defined and available can be exposed by the radio_materials dictionnary from the scene:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ac2790a", + "metadata": {}, + "outputs": [], + "source": [ + "print(scene.radio_materials)" + ] + }, + { + "cell_type": "markdown", + "id": "30db559b", + "metadata": {}, + "source": [ + "This does not mean that they are all currently used in the scene itself, but they are defined in Sionna.\n", + "\n", + "Each RadioMaterial has a name and can be allocated to a SceneObject. Also, we can check all the objects with a given RadioMaterial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff441921", + "metadata": {}, + "outputs": [], + "source": [ + "# Check the radio material of each object of the scene\n", + "for obj in list(scene.objects.values()):\n", + " print(f\"SceneObject {obj.name} is composed of {obj.radio_material.name}\")\n", + " \n", + "# Get the objects using each material if any\n", + "# The objects are referenced in the RadioMaterial through their object ID\n", + "for rm in list(scene.radio_materials.values()):\n", + " obj_list = []\n", + " for obj_id in rm.using_objects:\n", + " for obj in list(scene.objects.values()):\n", + " if obj.object_id == obj_id:\n", + " obj_list.append(obj.name)\n", + " if len(obj_list) != 0:\n", + " print(f\"Material {rm.name} is used by {obj_list}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e42a3257", + "metadata": {}, + "outputs": [], + "source": [ + "# Modify the RadioMaterial of the first person\n", + "body_1_object.radio_material = \"itu_brick\" # does NOT trigger a reload scene\n", + "\n", + "# Alternative call:\n", + "# itu_brick_rm = scene.get('itu_brick') # return a RadioMaterial\n", + "# body_1_object.radio_material = itu_brick_rm\n", + "\n", + "print(f\"{body_1_object.name} is made of {body_1_object.radio_material.name}\")\n", + "\n", + "# Check that the first person is now in the list of itu_brick RM, and not anymore in itu_concrete\n", + "for rm in list(scene.radio_materials.values()):\n", + " obj_list = []\n", + " for obj_id in rm.using_objects:\n", + " for obj in list(scene.objects.values()):\n", + " if obj.object_id == obj_id:\n", + " obj_list.append(obj.name)\n", + " if len(obj_list) != 0:\n", + " print(f\"Material {rm.name} is used by {obj_list}\")\n", + " \n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "5c73679a", + "metadata": {}, + "source": [ + "As you can see, the visual aspect of the first body is still in marble and has not been updated. Changing the radio material of an object do not change the way it is rendered in the scene.\n", + "\n", + "This is because the scene is NOT reloaded after a RadioMaterial assignement, since it would break differentiability. \n", + "\n", + "To update the rendering, one can manually trigger a scene reload.\n", + "\n", + "It should be noted that, even if the scene is not reloaded, RadioMaterial is correctly set and would affect the paths as expected." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bcc5d652", + "metadata": {}, + "outputs": [], + "source": [ + "# Check the impact on the paths\n", + "paths = scene.compute_paths(los=False, reflection=True, max_depth=1, num_samples=1e6, method='exhaustive')\n", + "\n", + "print(f\"Amplitude of the paths: {np.abs(paths.a[0,0,0,0,0,1:,0].numpy())}\")\n", + "print(f\"Delay of the paths: {paths.tau[0,0,0,1:].numpy()}\")\n", + "\n", + "# Print the object on which each path reflected\n", + "print(f\"Object ID on which the corresponding path reflected: {paths.objects[0,0,0,1:]}\")\n", + "\n", + "# Show object ID of body 1 and 2\n", + "print(f\"Floor ID: {scene.get('floor').object_id}\")\n", + "print(f\"First body ID: {body_1_object.object_id}\")\n", + "print(f\"Second body ID: {body_2_object.object_id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "21e63b6c", + "metadata": {}, + "outputs": [], + "source": [ + "# Clear the scene of TX/RX\n", + "scene.remove('tx')\n", + "scene.remove('rx')" + ] + }, + { + "cell_type": "markdown", + "id": "c5be1008", + "metadata": {}, + "source": [ + "The paths with amplitude 4.7e-4 bounced on the brick person (first body), and the ones with amplitude 5.7e-4 bounced on the marble person (second body)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72f81dab", + "metadata": {}, + "outputs": [], + "source": [ + "# Manually reload the scene to change the renderring\n", + "scene.reload()\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "b9a933d7", + "metadata": {}, + "source": [ + "After the reload of the scene, the positions of the objects are maintained after the reload. The first body is correctly displayed as a brick object.\n", + "\n", + "The RadioMaterial class does not define itself how it should be rendered. This is handled by the BSDF class.\n", + "\n", + "A single BSDF is affected to each RadioMaterial defined in Sionna.\n", + "\n", + "At initialization, the BSDF is defined as random. It is said to be \"placeholder\". Hence one can use any pre-defined RadioMaterial without specifying the BSDF, but we advice to define a proper custom BSDF before that.\n", + "\n", + "The definition of the BSDF can be done manually, or directly through the .xml file of the scene or an asset.\n", + "\n", + "At initialization, a BSDF object is not associated to a RadioMaterial. Once associated, the BSDF object take the name of the RadioMaterial." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "013003e2", + "metadata": {}, + "outputs": [], + "source": [ + "# Get a RadioMaterial defined in the scene\n", + "rm_itu_metal = scene.get(\"itu_metal\")\n", + "\n", + "# Show the BSDF object associated to it\n", + "print(f\"The BSDF object describing the itu_metal rendering: {rm_itu_metal.bsdf}\")\n", + "print(f\"The BSDF object has a name: {rm_itu_metal.bsdf.name}\")\n", + "print(f\"The corresponding (random) RGB triplet: {rm_itu_metal.bsdf.rgb}\")\n", + "\n", + "# Set the second body radio material to itu_metal\n", + "body_2_object.radio_material = \"itu_metal\"\n", + "\n", + "# Manually reload the scene to change the renderring\n", + "scene.reload()\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "772ff2f2", + "metadata": {}, + "source": [ + "We can modify the BSDF manually and set a custom RGB triplet, or equivalently the color using a color name.\n", + "\n", + "The list of available colors can be found [here](https://matplotlib.org/stable/gallery/color/named_colors.html#css-colors)\n", + "\n", + "Modifying the BSDF triggers a scene reload, to modify the rendering." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "81e3a72f", + "metadata": {}, + "outputs": [], + "source": [ + "# Check if the BSDF is placeholder\n", + "print(f\"At initialization, the BSDF is a placeholder: {rm_itu_metal.bsdf.is_placeholder}\")\n", + "\n", + "# Setting the RGB of a BSDF triggers a reload scene to update the rendering\n", + "rm_itu_metal.bsdf.color = 'chartreuse'\n", + "print(f\"The corresponding chartreuse RGB triplet: {rm_itu_metal.bsdf.rgb}\")\n", + "\n", + "# Check if BSDF is placeholder\n", + "print(f\"After setting the color, the BSDF is not a placeholder anymore: {rm_itu_metal.bsdf.is_placeholder}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "944ac81a", + "metadata": {}, + "source": [ + "Once the BSDF of a RadioMaterial has been set, it is not a placeholder anymore. It means that the BSDF is properly set: the user has defined how the material should be rendered.\n", + "\n", + "Placeholder or not, the BSDF can still be modified manually by the user." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed4707e2", + "metadata": {}, + "outputs": [], + "source": [ + "# Create new BSDF object manually\n", + "correct_metal_bsdf = BSDF(name='metal_color', color=[69./255.,58/255.,60/255.])\n", + "correct_wood_bsdf = BSDF(name='wood_color', color='sienna')\n", + "\n", + "# Get the 'itu_wood' RadioMaterial, it has a placeholder BSDF\n", + "rm_itu_wood = scene.get('itu_wood')\n", + "\n", + "# Get reference to current BSDFs\n", + "prev_metal_bsdf = rm_itu_metal.bsdf\n", + "prev_wood_bsdf = rm_itu_wood.bsdf\n", + "\n", + "# Show that they are not yet associated to a RadioMaterial\n", + "print(f\"Newly created BSDF don't have RadioMaterial associated to them: {correct_metal_bsdf.radio_material} and {correct_wood_bsdf.radio_material}\")\n", + "print(f\"Contrary to the ones in the RadioMaterial: {prev_metal_bsdf.radio_material.name} and {prev_wood_bsdf.radio_material.name}\")\n", + "\n", + "# Assign the input BSDF properties to the RadioMaterial BSDFs\n", + "rm_itu_metal.bsdf = correct_metal_bsdf # triggers scene reload\n", + "rm_itu_wood.bsdf = correct_wood_bsdf # triggers scene reload\n", + "\n", + "# Once associated to a RadioMaterial, their attribute is updated\n", + "print(f\"New BSDF are now associated to the RadioMaterial: {correct_metal_bsdf.radio_material.name} and {correct_wood_bsdf.radio_material.name}\")\n", + "print(f\"Contrary to the previous ones: {prev_metal_bsdf.radio_material} and {prev_wood_bsdf.radio_material}\")\n", + "\n", + "# Show that the name of the created BSDF have changed\n", + "print(f\"The name of the itu_metal BSDF is: {correct_metal_bsdf.name}\")\n", + "print(f\"The name of the itu_wood BSDF is: {correct_wood_bsdf.name}\")\n", + "\n", + "# Show that the BSDF ot itu_wood is no longer placeholder\n", + "print(f\"The BSDF of itu_wood is not a placeholder anymore: {correct_wood_bsdf.is_placeholder}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "361d4970", + "metadata": {}, + "source": [ + "When you load a scene or add an asset, each shape, a.k.a SceneObject, is associated with a BSDF described in the .xml file of the scene/asset. Then, the RadioMaterial of the SceneObject is infered, based on the name of the BSDF.\n", + "\n", + "Two basic behaviors can be triggered:\n", + "* The BSDF / RadioMaterial name is not currently known in Sionna. A new RadioMaterial is added to the scene with the same name and associated with the corresponding BSDF. You still have to specify the RadioMaterial properties for interaction with radio waves.\n", + "* The BSDF / RadioMaterial name already exist. The shapes will be associated with the pre-existing RadioMaterial. Regarding the BSDF of the RadioMaterial, if it is still a placeholder, then it will be automatically replaced by the one defined in the .xml file. Otherwise, the standard behavior is to keep the BSDF already specified in the RadioMaterial, and to discard the new BSDF. This is what happened in this notebook, when we loaded the scene itu_brick and itu_concrete BSDFs where overwrited, as well as itu_marble when we loaded our assets.\n", + "\n", + "It is possible to provide a RadioMaterial, by name or object reference, to an asset. This will set the RadioMaterial of all component SceneObject to the given RadioMaterial.\n", + "\n", + "Equivalently, if all the component objects of an asset have the same RadioMaterial, it will automatically set the asset RadioMaterial property." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7b427258", + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"Currently, body 1 is in brick and body 2 is in metal. The asset RadioMaterial is undefined: {two_bodies_asset.radio_material}\")\n", + "\n", + "# Set body 1 to the same RadioMaterial as body 2\n", + "body_1_object.radio_material = body_2_object.radio_material\n", + "\n", + "print(f\"Now that both bodies are in itu_metal, the asset also took that property: {two_bodies_asset.radio_material.name}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "386207c2", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a new two persons asset and force the RadioMaterial of both bodies as itu_wood\n", + "two_bodies_asset_bis = AssetObject(name='asset_2', filename=sionna.rt.asset_object.two_persons, orientation=[-np.pi/2.,0.,0.], radio_material='itu_wood')\n", + "\n", + "# Add it to the scene\n", + "scene.add(two_bodies_asset_bis) # triggers scene reload\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "56b87a84", + "metadata": {}, + "source": [ + "Changing the RadioMaterial of the asset will change the component SceneObjects ones." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6dcf74c2", + "metadata": {}, + "outputs": [], + "source": [ + "# Set the RadioMaterial of the two bodies\n", + "two_bodies_asset_bis.radio_material = 'itu_marble'\n", + "\n", + "# Check that it worked\n", + "print(f\"Current RadioMaterial of the asset is: {two_bodies_asset_bis.radio_material.name}\")\n", + "\n", + "# Manually reload the scene to update the rendering\n", + "scene.reload()\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "4cec5899", + "metadata": {}, + "source": [ + "The itu_marble BSDF was defined in the .xml file of the assets body and two_persons.\n", + "\n", + "It means that the BSDF is not placeholder.\n", + "\n", + "Since the BSDF has been defined through an .xml file, it also means that it does not have a RGB/color property.\n", + "\n", + "Setting the RGB/color manually will overwrite the .xml definition of the BSDF." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cfb9f0cc", + "metadata": {}, + "outputs": [], + "source": [ + "# Change manually the BSDF of itu_marble\n", + "rm_itu_marble = scene.get('itu_marble')\n", + "print(f\"Is the BSDF placeholder: {rm_itu_marble.bsdf.is_placeholder}\")\n", + "print(f\"The BSDF does not have a color or RGB: {rm_itu_marble.bsdf.rgb} | {rm_itu_marble.bsdf.color}\")\n", + "# Manually set the color to overwrite its .xml element\n", + "rm_itu_marble.bsdf.color = 'magenta'\n", + "print(f\"The BSDF now have a color and RGB: {rm_itu_marble.bsdf.rgb} | {rm_itu_marble.bsdf.color}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c3958e0b", + "metadata": {}, + "outputs": [], + "source": [ + "# Clear the scene by removing asset_1\n", + "scene.remove('asset_two_bodies') # trigger scene reload\n", + "\n", + "# Create a new asset body. The .xml file describes a itu_marble BSDF\n", + "body_asset = AssetObject(name='asset_3', filename=sionna.rt.asset_object.body)\n", + "\n", + "# Add the asset to the scene\n", + "scene.add(body_asset) # trigger a scene reload\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "e98a55c8", + "metadata": {}, + "source": [ + "While a more appropriate color was provided in the body asset .xml file for the itu_marble RadioMaterial's BSDF, it has not been selected because the current BSDF is not placeholder.\n", + "\n", + "Yet, this original BSDF can be recovered in the AssetObject." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d312ff5e", + "metadata": {}, + "outputs": [], + "source": [ + "# Get the dictionnary of the BSDFs originally present in the asset .xml file\n", + "print(f\"Dictionnary of original BSDF: {body_asset.original_bsdfs}\")" + ] + }, + { + "cell_type": "markdown", + "id": "f082f9ad", + "metadata": {}, + "source": [ + "With the original BSDF accessible, the user can manually apply them if necessary.\n", + "\n", + "We can still force the BSDF overwrite by setting the property overwrite_scene_bsdfs to True at the AssetObject creation. It should be noted that the flag takes effect only when the asset is added to the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b5c45abb", + "metadata": {}, + "outputs": [], + "source": [ + "# Set the flag to True when creating the AssetObject\n", + "body_asset = AssetObject(name='asset_3', filename=sionna.rt.asset_object.body, overwrite_scene_bsdfs=True)\n", + "\n", + "# Add the asset to the scene\n", + "scene.add(body_asset) # trigger a scene reload\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "a2a78490", + "metadata": {}, + "source": [ + "Since the asset's flag overwrite_scene_bsdfs was set to True before being added to the scene, all the involved BSDFs have been overwritten.\n", + "\n", + "In case you plan to create a new custom RadioMaterial, a random BSDF will be automatically allocated to it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02bfc897", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a custom RadioMaterial\n", + "my_custom_rm = RadioMaterial(name='my_custom_mat')\n", + "\n", + "# Print the random RGB triplet associated\n", + "print(f\"The BSDF name of my custom RadioMaterial is: {my_custom_rm.bsdf.name}\")\n", + "print(f\"At init, the RGB triplet of my custom RadioMaterial was: {my_custom_rm.bsdf.rgb}\")\n", + "\n", + "# Associate my new material with the solo body\n", + "body_asset.radio_material = my_custom_rm\n", + "\n", + "# Modify the rendering so it looks like metal\n", + "my_custom_rm.bsdf.assign(rm_itu_metal.bsdf) # trigger a scene reload\n", + "print(f\"The BSDF name of my custom RadioMaterial is still the same: {my_custom_rm.bsdf.name}\")\n", + "print(f\"And the two BSDFs are different python objects: {hex(id(my_custom_rm.bsdf))} is not equal to {hex(id(rm_itu_metal.bsdf))}\")\n", + "\n", + "# Alternative way to create the RadioMaterial with the desired BSDF\n", + "# my_custom_rm = RadioMaterial(name='my_custom_mat', bsdf=rm_itu_metal.bsdf)\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "f007864d", + "metadata": {}, + "source": [ + "When the custom RadioMaterial is applied to a SceneObject, it is automatically added to the scene.radio_materials dictionnary and to the .xml file describing the scene.\n", + "\n", + "It is not possible to add to the scene a RadioMaterial with the same name as another one. As you may have noticed, the modification of a RadioMaterial property from anywhere (from an AssetObject, a SceneObject, etc) will propagate to all the SceneObject sharing this RadioMaterial. In fact, each RadioMaterial object is unique, so that the scene is coherent.\n", + "\n", + "Yet, there is a mechanism to assign the properties of a RadioMaterial to another." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d816e5a3", + "metadata": {}, + "outputs": [], + "source": [ + "# Create another custom RadioMaterial with the same name\n", + "my_custom_rm_bis = RadioMaterial(name='my_custom_mat', relative_permittivity=100000., bsdf=rm_itu_wood.bsdf)\n", + " \n", + "# Assigning my_custom_rm_bis to an object would raise an error\n", + "# Two possible solutions to solve this\n", + "# First solution, modify the RadioMaterial of the scene\n", + "my_custom_rm.assign(my_custom_rm_bis)\n", + "\n", + "# Check the permitivity \n", + "print(f\"The permitivity of my custom RadioMaterial is now {my_custom_rm.relative_permittivity}\")\n", + "print(f\"The two RadioMaterial are different python objects: {hex(id(my_custom_rm))} is not equal to {hex(id(my_custom_rm_bis))}\")\n", + "\n", + "# Need to reload the scene manually to update the rendering\n", + "scene.reload()\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "3a1fb96c", + "metadata": {}, + "source": [ + "As you can see, the .assign method for radio material does not automatically reload the scene.\n", + "\n", + "Yet, the BSDF is correctly transfered from one RadioMaterial to another." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eed269cd", + "metadata": {}, + "outputs": [], + "source": [ + "my_custom_rm_bis.relative_permittivity = 10.\n", + "my_custom_rm_bis.bsdf.assign(rm_itu_metal.bsdf)\n", + "\n", + "# Second solution, set the asset flag overwrite_scene_radio_materials to True when creating the AssetObject and change its RadioMaterial\n", + "# The overwrite action will be triggered when the asset is added to the scene\n", + "# The asset RadioMaterial is assigned to the RadioMaterial present in the scene and the asset RadioMaterial is replaced by the scene RadioMaterial with updated properties\n", + "body_asset = AssetObject(name='asset_3', filename=sionna.rt.asset_object.body, radio_material=my_custom_rm_bis, overwrite_scene_radio_materials=True)\n", + "\n", + "# Add the asset to the scene\n", + "# Since it has the same name as the other body asset, the one present in the scene will be replaced\n", + "scene.add(body_asset) # trigger a scene reload\n", + "\n", + "# Check the permitivity \n", + "print(f\"The permitivity of my custom RadioMaterial is now {my_custom_rm.relative_permittivity}\")\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "307bafed", + "metadata": {}, + "source": [ + "## Annex Asset Features" + ] + }, + { + "cell_type": "markdown", + "id": "c8e695bd", + "metadata": {}, + "source": [ + "The AssetObject also exposes the look_at method. You can also look at an AssetObject of the scene." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5eb0c7d4", + "metadata": {}, + "outputs": [], + "source": [ + "# Position the body and the two persons assets\n", + "two_bodies_asset_bis.position = [2., -1., 0.]\n", + "body_asset.position = [1., +1., 0.]\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ef7b12d2", + "metadata": {}, + "outputs": [], + "source": [ + "# Align the orientation of the two bodies asset so that the x-axis points toward the body asset\n", + "two_bodies_asset_bis.look_at('asset_3')\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c57fbe3", + "metadata": {}, + "outputs": [], + "source": [ + "# Align the orientation of the body asset so that the x-axis points toward the two bodies asset\n", + "body_asset.look_at('asset_2')\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "0cf47442", + "metadata": {}, + "source": [ + "As you can see, the definition of the look at method depends on a definition of the x-axis in the Mitsuba shape (so the one defined in Blender).\n", + "\n", + "Hence, if the look at feature is important for you, check that the \"front\" of your asset is oriented towards its x-axis." + ] + }, + { + "cell_type": "markdown", + "id": "2c1517f5", + "metadata": {}, + "source": [ + "The asset also has a velocity attribute that will enforce the velocity vector on the component objects ones." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3a6ef843", + "metadata": {}, + "outputs": [], + "source": [ + "# Check the two bodies velocity\n", + "body_1_object = scene.get('asset_2_person_1')\n", + "body_2_object = scene.get('asset_2_person_2')\n", + "\n", + "print(f\"Velocity of the first body: {body_1_object.velocity}\")\n", + "print(f\"Velocity of the second body: {body_2_object.velocity}\")\n", + "\n", + "# Asset velocity\n", + "print(f\"The asset velocity is: {two_bodies_asset_bis.velocity}\")\n", + "\n", + "# Set the asset velocity\n", + "two_bodies_asset_bis.velocity = [1.,2.,3.]\n", + "print(f\"The asset velocity is: {two_bodies_asset_bis.velocity}\")\n", + "print(f\"Velocity of the first body: {body_1_object.velocity}\")\n", + "print(f\"Velocity of the second body: {body_2_object.velocity}\")\n", + "\n", + "# Change the velocity of one component SceneObject\n", + "body_1_object.velocity += 1.\n", + "print(f\"The asset velocity is: {two_bodies_asset_bis.velocity}\")\n", + "print(f\"Velocity of the first body: {body_1_object.velocity}\")\n", + "\n", + "# Set the velocity of the body to automatically set the velocity of the asset\n", + "body_2_object.velocity += 1.\n", + "print(f\"The asset velocity is: {two_bodies_asset_bis.velocity}\")\n", + "print(f\"Velocity of the second body: {body_2_object.velocity}\")" + ] + }, + { + "cell_type": "markdown", + "id": "90cb3868", + "metadata": {}, + "source": [ + "Finally, you can remove (but not add!!!) SceneObject that are not part of an asset from the scene.\n", + "\n", + "To remove a SceneObject from an asset, you have to remove the whole asset.\n", + "\n", + "Once the SceneObject removed, it is currently not possible to add it again to the scene, so watchout!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1052fecc", + "metadata": {}, + "outputs": [], + "source": [ + "# Removing a component object from an asset triggers an error\n", + "try:\n", + " scene.remove('asset_3_body')\n", + "except ValueError as e:\n", + " print(e)\n", + " \n", + "# You can remove a SceneObject from the original scene\n", + "# But you cannot add it again\n", + "scene.remove('wall')\n", + "\n", + "# Preview the scene\n", + "if colab_compat:\n", + " scene.render(camera=\"scene-cam-0\", num_samples=512)\n", + " raise ExitCell\n", + "scene.preview()" + ] + }, + { + "cell_type": "markdown", + "id": "a3563dfa", + "metadata": {}, + "source": [ + "It is not currently possible to manually create a SceneObject, nor create an AssetObject from a SceneObject." + ] + } + ], + "metadata": { + "interpreter": { + "hash": "4cd7ab41f5fca4b9b44701077e38c5ffd31fe66a6cab21e0214b68d958d0e462" + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "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.11.9" + }, + "vscode": { + "interpreter": { + "hash": "e7370f93d1d0cde622a1f8e1c04877d8463912d04d973331ad4851f04de6915a" + } + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": {}, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/source/api/rt.rst b/doc/source/api/rt.rst index 1a64ed6a..5992477e 100644 --- a/doc/source/api/rt.rst +++ b/doc/source/api/rt.rst @@ -19,7 +19,9 @@ The paper `Sionna RT: Differentiable Ray Tracing for Radio Propagation Modeling .. include:: rt_coverage_map.rst.txt .. include:: rt_camera.rst.txt .. include:: rt_scene_object.rst.txt +.. include:: rt_asset_object.rst.txt .. include:: rt_radio_material.rst.txt +.. include:: rt_bsdf.rst.txt .. include:: rt_radio_device.rst.txt .. include:: rt_antenna_array.rst.txt .. include:: rt_antenna.rst.txt diff --git a/doc/source/api/rt_asset_object.rst.txt b/doc/source/api/rt_asset_object.rst.txt new file mode 100644 index 00000000..061a5b5b --- /dev/null +++ b/doc/source/api/rt_asset_object.rst.txt @@ -0,0 +1,36 @@ +Asset Objects +************* + +AssetObject +----------- +.. autoclass:: sionna.rt.AssetObject + :members: + :inherited-members: + :exclude-members: random_position_init_bias, init, scene, _remove_from_scene, _assign_to_scene, _move_meshes_to_scene, _update_materials, _update_geometry, _handle_radio_material, _append_bsdfs, update_shape, get_shape, remove_shape, update_radio_material, update_velocity + +Example Assets +************** +Sionna has several integrated assets that are listed below. +They can be loaded and used as follows: + +.. code-block:: Python + + asset = AssetObject(name="asset", filename=sionna.rt.asset_object.monkey) + +body +---- +.. autodata:: sionna.rt.asset_object.body + :annotation: + +monkey +------ +.. autodata:: sionna.rt.asset_object.monkey + :annotation: + +two_persons +----------- +.. autodata:: sionna.rt.asset_object.two_persons + :annotation: + + + diff --git a/doc/source/api/rt_bsdf.rst.txt b/doc/source/api/rt_bsdf.rst.txt new file mode 100644 index 00000000..3ada47b6 --- /dev/null +++ b/doc/source/api/rt_bsdf.rst.txt @@ -0,0 +1,9 @@ +BSDFs +***** + +BSDF +---- +.. autoclass:: sionna.rt.BSDF + :members: + :inherited-members: + :exclude-members: use_counter, is_used, using_objects, add_object_using, scene, radio_material, has_radio_material, is_placeholder, set_scene, _color_name_to_rgb \ No newline at end of file diff --git a/doc/source/api/rt_scene.rst.txt b/doc/source/api/rt_scene.rst.txt index bc1c848a..25fdd916 100644 --- a/doc/source/api/rt_scene.rst.txt +++ b/doc/source/api/rt_scene.rst.txt @@ -131,7 +131,7 @@ Scene ----- .. autoclass:: sionna.rt.Scene :members: - :exclude-members: _check_scene, _load_cameras, _load_scene_objects, _is_name_used, register_radio_device, unregister_radio_device, register_radio_material, register_scene_object, compute_paths, trace_paths, compute_fields, render, render_to_file, preview, mi_scene, preview_widget, coverage_map + :exclude-members: append_to_xml, remove_from_xml, update_shape_bsdf_xml, _check_scene, _load_cameras, _load_scene_objects, _is_name_used, register_radio_device, unregister_radio_device, register_radio_material, register_scene_object, compute_paths, trace_paths, compute_fields, render, render_to_file, preview, mi_scene, preview_widget, coverage_map compute_paths ------------- diff --git a/doc/source/api/rt_scene_object.rst.txt b/doc/source/api/rt_scene_object.rst.txt index a36c1f82..f5494c80 100644 --- a/doc/source/api/rt_scene_object.rst.txt +++ b/doc/source/api/rt_scene_object.rst.txt @@ -38,4 +38,4 @@ SceneObject .. autoclass:: sionna.rt.SceneObject :members: :inherited-members: - :exclude-members: scene, object_id + :exclude-members: scene, object_id, update_mi_shape, set_asset_object diff --git a/environment.yml b/environment.yml new file mode 100644 index 00000000..b484cf6b --- /dev/null +++ b/environment.yml @@ -0,0 +1,115 @@ +name: sensing +channels: + - conda-forge + - defaults +dependencies: + - appnope=0.1.3=pyhd8ed1ab_0 + - asttokens=2.4.1=pyhd8ed1ab_0 + - bzip2=1.0.8=h620ffc9_4 + - ca-certificates=2023.11.17=hf0a4a13_0 + - comm=0.2.1=pyhd8ed1ab_0 + - debugpy=1.6.7=py311h313beb8_0 + - decorator=5.1.1=pyhd8ed1ab_0 + - exceptiongroup=1.2.0=pyhd8ed1ab_0 + - executing=2.0.1=pyhd8ed1ab_0 + - importlib-metadata=7.0.1=pyha770c72_0 + - importlib_metadata=7.0.1=hd8ed1ab_0 + - ipykernel=6.28.0=pyh3cd1d5f_0 + - ipython=8.20.0=pyh707e725_0 + - jedi=0.19.1=pyhd8ed1ab_0 + - jupyter_client=8.6.0=pyhd8ed1ab_0 + - jupyter_core=5.5.0=py311hca03da5_0 + - libcxx=16.0.6=h4653b0c_0 + - libffi=3.4.4=hca03da5_0 + - libsodium=1.0.18=h27ca646_1 + - matplotlib-inline=0.1.6=pyhd8ed1ab_0 + - ncurses=6.4=h313beb8_0 + - nest-asyncio=1.5.8=pyhd8ed1ab_0 + - openssl=3.2.0=h0d3ecfb_1 + - packaging=23.2=pyhd8ed1ab_0 + - parso=0.8.3=pyhd8ed1ab_0 + - pickleshare=0.7.5=py_1003 + - pip=23.3.1=py311hca03da5_0 + - platformdirs=4.1.0=pyhd8ed1ab_0 + - psutil=5.9.0=py311h80987f9_0 + - ptyprocess=0.7.0=pyhd3deb0d_0 + - pure_eval=0.2.2=pyhd8ed1ab_0 + - pygments=2.17.2=pyhd8ed1ab_0 + - python=3.11.7=hb885b13_0 + - python-dateutil=2.8.2=pyhd8ed1ab_0 + - pyzmq=25.1.0=py311h313beb8_0 + - readline=8.2=h1a28f6b_0 + - setuptools=68.2.2=py311hca03da5_0 + - six=1.16.0=pyh6c4a22f_0 + - sqlite=3.41.2=h80987f9_0 + - stack_data=0.6.2=pyhd8ed1ab_0 + - tk=8.6.12=hb8d0fd4_0 + - tornado=6.3.3=py311h80987f9_0 + - traitlets=5.14.1=pyhd8ed1ab_0 + - typing_extensions=4.9.0=pyha770c72_0 + - tzdata=2023d=h04d1e81_0 + - wcwidth=0.2.13=pyhd8ed1ab_0 + - wheel=0.41.2=py311hca03da5_0 + - xz=5.4.5=h80987f9_0 + - zeromq=4.3.5=h965bd2d_0 + - zipp=3.17.0=pyhd8ed1ab_0 + - zlib=1.2.13=h5a0b063_0 + - pip: + - absl-py==2.0.0 + - astunparse==1.6.3 + - cachetools==5.3.2 + - certifi==2023.11.17 + - charset-normalizer==3.3.2 + - contourpy==1.2.0 + - cycler==0.12.1 + - drjit==0.4.4 + - flatbuffers==23.5.26 + - fonttools==4.47.0 + - gast==0.4.0 + - google-auth==2.26.1 + - google-auth-oauthlib==1.0.0 + - google-pasta==0.2.0 + - grpcio==1.60.0 + - h5py==3.10.0 + - idna==3.6 + - importlib-resources==6.1.1 + - ipydatawidgets==4.3.2 + - ipywidgets==8.0.5 + - jupyterlab-widgets==3.0.5 + - keras==2.13.1 + - kiwisolver==1.4.5 + - libclang==16.0.6 + - markdown==3.5.1 + - markupsafe==2.1.3 + - matplotlib==3.8.2 + - mitsuba==3.5.0 + - numpy==1.24.3 + - oauthlib==3.2.2 + - opt-einsum==3.3.0 + - pexpect==4.9.0 + - pillow==10.2.0 + - prompt-toolkit==3.0.43 + - protobuf==4.25.1 + - pyasn1==0.5.1 + - pyasn1-modules==0.3.0 + - pyparsing==3.1.1 + - pythreejs==2.4.2 + - requests==2.31.0 + - requests-oauthlib==1.3.1 + - rsa==4.9 + - scipy==1.11.4 + - sionna==0.16.1 + - stack-data==0.6.3 + - tensorboard==2.13.0 + - tensorboard-data-server==0.7.2 + - tensorflow-estimator==2.13.0 + - tensorflow-macos==2.13.1 + - termcolor==2.4.0 + - traittypes==0.2.1 + - typing-extensions==4.5.0 + - urllib3==2.1.0 + - werkzeug==3.0.1 + - widgetsnbextension==4.0.9 + - wrapt==1.16.0 + - xmltodict==0.13.0 + diff --git a/sionna/rt/__init__.py b/sionna/rt/__init__.py index fae160a2..40d084b0 100644 --- a/sionna/rt/__init__.py +++ b/sionna/rt/__init__.py @@ -39,10 +39,12 @@ polarization_model_1, polarization_model_2 from .antenna_array import AntennaArray, PlanarArray from .radio_material import RadioMaterial +from .bsdf import BSDF from .ris import AmplitudeProfile, DiscreteProfile, DiscreteAmplitudeProfile,\ DiscretePhaseProfile, CellGrid, PhaseProfile, RIS,\ ProfileInterpolator, LagrangeProfileInterpolator from .scene_object import SceneObject +from .asset_object import AssetObject from .scattering_pattern import ScatteringPattern, LambertianPattern,\ DirectivePattern, BackscatteringPattern from .transmitter import Transmitter diff --git a/sionna/rt/asset_object.py b/sionna/rt/asset_object.py new file mode 100644 index 00000000..25b08614 --- /dev/null +++ b/sionna/rt/asset_object.py @@ -0,0 +1,869 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +""" +Base class for asset objects. +""" + + +import os +import tensorflow as tf + + +import numpy as np +import xml.etree.ElementTree as ET +import warnings +import copy + +from importlib_resources import files + + +from .radio_material import RadioMaterial +from .bsdf import BSDF +from .object import Object + +from ..utils.misc import copy_and_rename_files +from .utils import normalize, theta_phi_from_unit_vec +from sionna.constants import PI + +from . import assets + +class AssetObject(): + # pylint: disable=line-too-long + r"""AssetObject(name, filename, position=(0.,0.,0.), orientation=(0.,0.,0.), radio_material=None, dtype=tf.complex64) + + A class for managing asset objects. An asset is an object that can be added or removed from a scene and which can consist of multiple shapes. + When added to a scene, the asset creates SceneObject instances corresponding to each of its mesh shapes. These scene objects can be moved, rotated, + and manipulated individually or collectively through the AssetObject. + + The AssetObject keeps track of the corresponding scene objects, allowing for higher-level operations such as moving all shapes of an asset together + while maintaining their relative positions and orientations. The asset is associated with an XML file descriptor pointing to one or multiple mesh + (.ply) files. A RadioMaterial can be assigned to the asset, which will be applied to all its shapes. If no material is provided, materials will be + inferred from the BSDF descriptors in the asset XML file. + + Note: When exporting an asset in Blender with the Mitsuba (Sionna) file format, it's important to set Z-axis as "up" and Y-axis as "forward". + This should align the coordinates correctly. + + Example asset can be loaded as follows: + + .. code-block:: Python + + scene = load_scene() + asset = AssetObject(name='asset_name', filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + Parameters + ---------- + name : str + Name of the asset object. + + filename : str + Path to the asset XML file. + + position : [3], float + Position :math:`(x,y,z)` as three-dimensional vector. + Defaults to `None`, in which case the position is not set at init. + + orientation : [3], float | None + Orientation :math:`(\alpha, \beta, \gamma)` specified + through three angles corresponding to a 3D rotation + as defined in :eq:`rotation`. + This parameter is ignored if ``look_at`` is not `None`. + + look_at : [3], float | :class:`sionna.rt.Object` | :class:`sionna.rt.AssetObject` | None + A position or the instance of an :class:`~sionna.rt.Object` to + point toward to. + + radio_material : :class:`~sionna.rt.RadioMaterial`:, optional (default is None) + If set, the radio material is to be associated with all the asset's shapes. If not specified, materials will be inferred from the BSDF descriptors in the XML file. + + overwrite_scene_bsdfs : :boolean:, optional, (default is False) + If True replace all existing bsdf from the scene by the ones specified in the asset files. Otherwise, replace only placeholder scene's bsdfs. + This argument can be only defined at asset initialisation and has an effect only upon adding the asset to as scene. + + overwrite_scene_radio_materials : :boolean:, optional, (default is False) + If True update existing radio_material from the scene by the ones specified in the asset files. Otherwise, replace only placeholder scene's radio_materials. + This argument can be only defined at asset initialisation and has an effect only upon adding the asset to as scene. + + dtype : tf.DType, optional (default is `tf.complex64`) + Datatype for all computations, inputs, and outputs. + """ + + def __init__(self, + name, + filename, + position=(0.,0.,0.), + orientation=(0.,0.,0.), + look_at=None, + radio_material=None, + overwrite_scene_bsdfs = False, + overwrite_scene_radio_materials = False, + dtype=tf.complex64 + ): + + + if dtype not in (tf.complex64, tf.complex128): + raise TypeError("`dtype` must be tf.complex64 or tf.complex128`") + + self._dtype = dtype + self._rdtype = dtype.real_dtype + + # Asset name + self._name = name + + # Initialize shapes associated with asset + self._shapes = {} #Attributed when added to a scene + + # Asset's XML and meshes sources directory + self._filename = filename + self._xml_tree = ET.parse(filename) + + # Init scene propertie + self._scene = None + + # Change asset's mesh directory and asset xml file to a dedicated directory: + self._meshes_folder_path = f"meshes/{self._name}/" + for shape in self._xml_tree.findall(".//shape"): + # Find elements with name="filename" within each asset to modify the shape pathes to the new mesh folder + string_element = shape.find(".//string[@name='filename']") + mesh_path = string_element.get('value') + filename = mesh_path.split('/')[-1] + new_mesh_path = self._meshes_folder_path + filename + string_element.set('value', new_mesh_path) + + # Position & Orientation properties + # Init boolean flag: Used for inital transforms applied when adding the asset to a scene + self._init = True + self._random_position_init_bias = np.random.random(3) # Initial (temporary) position transform to avoid ovelapping asset which mess with mitsuba load_scene method. + + # (Initial) position and orientation of the asset + self._position = tf.cast(position, dtype=self._rdtype) + if np.max(orientation) > 2 * PI: + warnings.warn("Orientation angle exceeds 2Ï€. Angles should be in radians. If already in radians, you can ignore this warning; otherwise, convert to radians.") + + + if look_at is None: + self._orientation = tf.cast(orientation, dtype=self._rdtype) #in radians + else: + self._orientation = tf.cast([0,0,0], dtype=self._rdtype) + self.look_at(look_at) + + # Velocity + self._velocity = tf.cast([0,0,0], dtype=self._rdtype) + + # Material (If multiple shapes within the asset >>> Associate the same material to all shapes) + if radio_material is not None: + if not isinstance(radio_material,str) and not isinstance(radio_material,RadioMaterial): + raise TypeError("`radio_material` must be `str` or `RadioMaterial` (or None)") + + self._radio_material = radio_material + self._overwrite_scene_bsdfs = overwrite_scene_bsdfs # If True, replace scene's bsdfs when adding asset even when they are not placeholder bsdf + self._overwrite_scene_radio_materials = overwrite_scene_radio_materials # If True, update scene's materials when adding asset even when they are not placeholder material + + + # Structure to store original asset bsdfs as specified in the asset XML file, if needed + self._original_bsdfs = {} + root_to_append = self._xml_tree.getroot() + bsdfs_to_append = root_to_append.findall('bsdf') + for bsdf in bsdfs_to_append: + bsdf = copy.deepcopy(bsdf) + bsdf_name = bsdf.get('id') + self._original_bsdfs[f"{bsdf_name}"] = BSDF(name=f"{bsdf_name}", xml_element=bsdf) + + # Bypass update flag - internal flag used to avoid intempestiv update trigger by shape modification called within an asset method + self._bypass_update = False + + + @property + def original_bsdfs(self): + r""" + `dict` : { "name", :class:`~sionna.rt.BSDF`}: Get the original asset's bsdfs + """ + return self._original_bsdfs + + @property + def overwrite_scene_bsdfs(self): + r""" + bool : Get the flag overwrite scene BSDFs + """ + return self._overwrite_scene_bsdfs + + # @overwrite_scene_bsdfs.setter + # def overwrite_scene_bsdfs(self, b): + # self._overwrite_scene_bsdfs = b + + @property + def overwrite_scene_radio_materials(self): + r""" + bool : Get the flag overwrite scene radio materials + """ + return self._overwrite_scene_radio_materials + + # @overwrite_scene_radio_materials.setter + # def overwrite_scene_radio_materials(self, b): + # self._overwrite_scene_radio_materials = b + + @property + def filename(self): + r""" + str : Get the filename of the asset + """ + return self._filename + + @property + def name(self): + r""" + str : Get the name of the asset + """ + return self._name + + @property + def xml_tree(self): + r""" + xml.etree.ElementTree.ElementTree : Get the XML tree of the asset + """ + return self._xml_tree + + @property + def dtype(self): + r""" + tf.complex64 | tf.complex128 : Get/set the datatype used in tensors + """ + return self._dtype + + @dtype.setter + def dtype(self, new_dtype): + if new_dtype not in (tf.complex64, tf.complex128): + raise ValueError("`dtype` must be tf.complex64 or tf.complex128`") + self._dtype = new_dtype + self._rdtype = new_dtype.real_dtype + + self._position = tf.cast(self._position, dtype=self._rdtype) + self._orientation = tf.cast(self._orientation, dtype=self._rdtype) + + @property + def position(self): + # pylint: disable=line-too-long + r""" + [3], tf.float : Get/Set the position of the asset + + This method updates the position of the asset and moves all associated shapes + while keeping their relative positions. If the asset is being initialized and + is part of a scene, it corrects the initial position bias that was added to + avoid overlapping shapes. + + Notes + ----- + - If the asset is being initialized (`self._init` is `True`) and is part of a scene,the initial position bias is corrected. + - The method calculates the difference between the new position and the currentposition (or the initial position bias) and applies this difference to all associated shapes to move them accordingly. + """ + return self._position + + @position.setter + def position(self, new_position): + # Move all shapes associated to assed while keeping their relative positions + position = tf.cast(new_position, dtype=self._rdtype) + if self._init and self._scene is not None: + diff = position - self._random_position_init_bias # Correct the initial position bias initally added to avoid mitsuba to merge edges at the same position + else: + diff = position - self._position + self._position = position + + for shape_id in self.shapes: + + scene_object = self.shapes[shape_id] + if scene_object is not None: + scene_object.position += diff + + @property + def orientation(self): + # pylint: disable=line-too-long + r""" + [3], tf.float : Get/Set the orientation of the asset. + + This property updates the orientation of the asset and rotates all associated shapes + while keeping their relative positions. + + Notes + ----- + - If the asset is being initialized (`self._init` is `True`) and is part of a scene, the new orientation is applied directly. + - The method calculates the difference between the new orientation and the current orientation and applies this difference to all associated shapes to rotate them accordingly. + - The rotation is performed around a center of rotation shifted by the asset's position (i.e. all shapes are rotated around the asset position). + """ + return self._orientation + + @orientation.setter + def orientation(self, new_orientation): + # Rotate all shapes associated to asset while keeping their relative positions (i.e. rotate arround the asset position) + orientation = tf.cast(new_orientation, dtype=self._rdtype) + if self._init and self._scene is not None: + diff = orientation + else: + diff = orientation - self._orientation + + self._orientation = orientation + + for shape_id in self.shapes: + scene_object = self.shapes[shape_id] + if scene_object is not None: + scene_object = self._scene.get(shape_id) + old_center_of_rotation = scene_object.center_of_rotation + new_center_of_rotation = self._position - scene_object.position#self._position#( + scene_object.center_of_rotation = new_center_of_rotation + scene_object.orientation += diff + scene_object.center_of_rotation = old_center_of_rotation + + @property + def radio_material(self): + # pylint: disable=line-too-long + r""" + :class:`~sionna.rt.RadioMaterial` : Get/Set the radio material of the object. + + This property updates the radio material of the asset and ensures that all associated shapes use the same material. If the asset is part of a scene, it checks if the material already exists in the scene and handles any conflicts appropriately. + + Parameters + ---------- + mat : str or :class:`~sionna.rt.RadioMaterial` + The new radio material for the asset. It can be specified as a string + (the name of the material) or as an instance of `RadioMaterial`. + + Notes + ----- + - If the asset is part of a scene and the material is specified as a string, it checks if a material with that name already exists in the scene. If it does not exist, an error is raised. + - If the material is specified as a `RadioMaterial` instance, it is added to the scene if it does not already exist. + - The method temporarily disables scene auto-reload to avoid intempestive reloading at each asset's shape radio material (more precisely BSDF) assignation, or if multiple asset are added simultaneously + - The method sets an internal bypass flag to avoid re-updating the asset material for each shape modification. + + Raises + ------ + TypeError + If the material is not of type `str` or `RadioMaterial`. + + ValueError + If the material specified as a string does not exist in the scene. + + ValueError + If the material specified as `RadioMaterial` has a name already in + use by an item of the scene + """ + return self._radio_material + + @radio_material.setter + def radio_material(self, mat): + if not isinstance(mat, RadioMaterial) and not isinstance(mat, str): + err_msg = "Radio material must be of type 'str' or 'sionna.rt.RadioMaterial" + raise TypeError(err_msg) + + mat_obj = mat + + # If the asset as been added to a scene + if self._scene is not None: + if isinstance(mat, str): + mat_obj = self.scene.get(mat) + if (mat_obj is None) or (not isinstance(mat_obj, RadioMaterial)): + err_msg = f"Unknown radio material '{mat}'" + raise ValueError(err_msg) + + + # Turn scene auto reload off, if a new material was to be added + b_tmp = self._scene.bypass_reload + self._scene.bypass_reload = True + # Add radio material before assigning it to SceneObject ensures that if auto-reload + # is enabled the asset has the correct material upon reload + if mat_obj.name not in self._scene.radio_materials: + self._radio_material = mat_obj + self._scene.add(mat_obj) + + # set asset update bypass to True so that the asset material is not re-updated at each asset shape modification + self._bypass_update = True + for shape_name in self._shapes: + + # Get the current radio material of the scene object corresponding to that shape. + scene_object = self._scene.get(shape_name) + + # Update the material of the scene object + scene_object.radio_material = mat_obj + + self._scene.bypass_reload = b_tmp + + self._bypass_update = False + + # Store the new asset material (which is now the same for all asset's shape) + self._radio_material = mat_obj + + @property + def velocity(self): + """ + [3], tf.float : Get/set the velocity vector to all shapees of the asset[m/s] + """ + return self._velocity + + @velocity.setter + def velocity(self, v): + if not tf.shape(v)==3: + raise ValueError("`velocity` must have shape [3]") + self._velocity = tf.cast(v, self._rdtype) + + # set asset update bypass to True so that the asset material is not re-updated at each asset shape modification + self._bypass_update = True + + for shape_id in self.shapes: + scene_object = self._scene.get(shape_id) + scene_object.velocity = self._velocity + + self._bypass_update = False + + @property + def shapes(self): + """ + `dict`, { "name", :class:`~sionna.rt.SceneObject`} : dictionary + of asset's scene objects + """ + return self._shapes + + @shapes.setter + def shapes(self, d): + if not isinstance(d, dict): + raise ValueError("Expected a dictionary") + self._shapes = d + + def look_at(self, target): + # pylint: disable=line-too-long + r""" + Sets the orientation of the asset so that the x-axis points toward an + ``Object``. + + Input + ----- + target : [3], float | :class:`sionna.rt.Object` | str + A position or the name or instance of an + :class:`sionna.rt.Object` in the scene to point toward to + """ + # Get position to look at + if isinstance(target, str): + if self._scene is None: + err_msg = "No scene have been affected to current AssetObject" + raise TypeError(err_msg) + + obj = self.scene.get(target) + if not isinstance(obj, Object) and not isinstance(obj, AssetObject): + raise ValueError(f"No camera, device, asset or object named '{target}' found.") + else: + target = obj.position + elif isinstance(target, (Object, AssetObject)): + target = target.position + else: + target = tf.cast(target, dtype=self._rdtype) + if not target.shape[0]==3: + raise ValueError("`target` must be a three-element vector)") + + # Compute angles relative to LCS + x = target - self.position + x, _ = normalize(x) + theta, phi = theta_phi_from_unit_vec(x) + alpha = phi # Rotation around z-axis + beta = theta-PI/2 # Rotation around y-axis + gamma = 0.0 # Rotation around x-axis + self.orientation = (alpha, beta, gamma) + + ############################################## + # Internal methods. + # Should not be appear in the user + # documentation + ############################################## + + @property + def random_position_init_bias(self): + r""" + [3], float : Get the random initial position bias + """ + return self._random_position_init_bias + + @property + def init(self): + r""" + bool : Get/set the asset initialization flag used for initial position and rotation definition. + """ + return self._init + + @init.setter + def init(self, init): + self._init = init + + @property + def scene(self): + r""" + :class:`~sionna.rt.Scene` | None : Get the asset's scene + """ + return self._scene + + @scene.setter + def scene(self, scene): + r""" + :class:`~sionna.rt.Scene` | None: Set a scene to current asset. + If scene is None, the asset is properly reset and can be added again to another scene + """ + if self._scene is not None and scene is None: + self._remove_from_scene() + + elif scene is not self._scene: + self._assign_to_scene(scene) + + # Otherwise, no action needed since either: + # - scene is not None but self._scene is already set to the same scene. + # - both scene and self._scene are None. + + def _remove_from_scene(self): + r""" + Remove the asset from its current scene. + """ + # Clear the asset's shapes dictionary + self._shapes.clear() + # Set asset's scene to None + self._scene = None + # Reset asset init boolean flag (in case the asset will be added again to a scene). + self._init = True + + def _assign_to_scene(self, scene): + r""" + Assign the asset to a new scene. + + Input + ----- + scene : :class:`~sionna.rt.Scene` + The scene to assign the asset to. + """ + if self._scene is not None: + msg = f"The asset '{self}' is already assigned to another Scene '{self._scene}'. " \ + "Assets object instance can only be assigned to a single scene. Please first remove " \ + "asset from current scene by using asset.scene.remove(asset.name)" + raise RuntimeError(msg) + + # Affect the scene to the current asset + self._scene = scene + + # Affect scene's dtype to the current asset + self.dtype = scene.dtype + + # Reset asset init boolean flag (in case the asset was previously added to a scene). + self._init = True + + # Move asset's meshes to the scene's meshes directory + self._move_meshes_to_scene() + + # Update materials + self._update_materials() + + # Update geometry + self._update_geometry() + + def _move_meshes_to_scene(self): + r""" + Move asset's meshes to the scene's meshes directory. + """ + asset_path = os.path.dirname(self.filename) + meshes_source_dir = os.path.join(asset_path, 'meshes') + destination_dir = os.path.join(self._scene.tmp_directory_path, self._meshes_folder_path) + copy_and_rename_files(meshes_source_dir, destination_dir, prefix='') + + def _update_materials(self): + r""" + Update materials in the scene. + """ + if self._radio_material is not None: + self._handle_radio_material() + else: + self._append_bsdfs() + + def _update_geometry(self): + r""" + Update the geometry of the scene. + + This method updates the geometry of the scene by appending each shape + defined in the asset's XML to the scene's XML. It also adapts the shape + IDs to ensure they are unique within the scene and applies a temporary + position bias to avoid overlapping shapes when loading the scene. + + Notes + ----- + Mitsuba automatically merges vertices at the same position when loading + the scene. To avoid this, a small random transform is applied to ensure + that two assets never share the same position before calling the + `mi.load()` function. The correct position and orientation are then + applied during :meth:`~sionna.rt.Scene._load_scene_objects()`call, after calling `:meth:mi.load()`. + """ + # Get the root of the asset's XML + root_to_append = self._xml_tree.getroot() + + # Find all shapes elements in the asset + shapes_to_append = root_to_append.findall('shape') + + # Append each shape to the parent element in the original scene while adapting their ids + for shape in shapes_to_append: + # Define the shape name + shape_id = shape.get('id') + shape_name = shape_id[5:] if shape_id.startswith('mesh-') else shape_id + new_shape_id = f"{self._name}_{shape_name}" + self._shapes[new_shape_id] = None + shape.set('id',f"mesh-{new_shape_id}") + shape.set('name',f"mesh-{new_shape_id}") + + # Define shape transforms - Add (temporary) position bias + # Mitsuba automatically merges vertices at the same position when loading the scene. + # To avoid this, apply a small random transform within the XML scene descriptor. + # The correct position and orientation are then applied when loading the scene objects. + transform = ET.SubElement(shape, 'transform', name="to_world") + position = self._random_position_init_bias + translate_value = f"{position[0]} {position[1]} {position[2]}" + ET.SubElement(transform, 'translate', value=translate_value) + + # Set the correct BSDF name in the shape descriptor + ref = shape.find('ref') + if self._radio_material is not None: + # If a radio material is specified, use its name for all shapes of the asset + ref.set('id',f"mat-{self._radio_material.name}") + self._scene.add(self._radio_material) + + # If no radio material is specified, use the BSDF name from the XML descriptor + else: + bsdf_name = ref.get('id') + mat_name = bsdf_name[4:] if bsdf_name.startswith('mat-') else bsdf_name + ref.set('id',f"mat-{mat_name}") + + # Add shape to xml + self._scene.append_to_xml(shape, overwrite=False) + + def _handle_radio_material(self): + r""" + Handle the assignment of the radio material to the asset. + + This method manages the assignment of the radio material of the asset, + ensuring that the material is properly gotten from or added to the scene and that any + conflicts with existing materials are handled appropriately. + + If the radio material is specified as a string, it checks if a material + with that name already exists in the scene. If it does not exist, a + placeholder material is created and added to the scene. If a non-material item + with the same name but exists, an error is raised. + + If the radio material is specified as a `RadioMaterial` instance, it + checks if a material with the same name already exists in the scene. If + the `overwrite_scene_radio_materials` flag is set to `True`, the existing + material is replaced with the new one. Otherwise, the new material is + added to the scene only if it does not conflict with existing materials. + + Raises + ------ + ValueError + If an item with the same name but of a different type already + exists in the scene. + + TypeError + If the radio material is not of type `str` or `RadioMaterial`. + """ + if isinstance(self._radio_material, str): + mat = self._scene.get(self.radio_material) + if mat is None: + # If the radio material does not exist and the name is available, then a placeholder is used. + # In this case, the user is in charge of defining the radio material properties afterward. + mat = RadioMaterial(self._radio_material) + mat.is_placeholder = True + self._scene.add(mat) + elif not isinstance(mat, RadioMaterial): + # If the radio material does not exist, but an item of the scene already uses that name. + raise ValueError(f"Name '{self._radio_material}' is already used by another item of the scene") + self._radio_material = mat + elif isinstance(self._radio_material, RadioMaterial): + mat = self._scene.get(self._radio_material.name) + if isinstance(mat, RadioMaterial) and self._overwrite_scene_radio_materials: + # There exist a different instance of RadioMaterial with the same name but the user explicitely specified that + # he wants to replace existing scene materials by the one from the asset (even if the existing material is not a placeholder). + mat.assign(self._radio_material) + self._radio_material = mat + else: + # The scene.add() method will trigger an error if there exist an item with the same name or non-placeholder radio material which is not the same object. + # If there exist a placeholder radio material with same name, it will be updated with the asset radio material properties + # If the asset's radio material is a radio material from the scene (same object), then the asset radio material will not be (re)added to the scene. + # See scene.add() method for more details. + self._scene.add(self._radio_material) + self._radio_material = self._scene.get(self._radio_material.name) + + else: + raise TypeError("Asset 'radio_material' must be of type 'str', or 'RadioMaterial'") + + def _append_bsdfs(self): + r""" + Append BSDFs to the scene. + + This method appends/get radio material from/to the scene based on the BSDFs (Bidirectional Scattering Distribution Functions) + defined in the asset's XML file. If no radio material is specified for the asset, it ensures that all BSDFs are added to the scene. + Existing BSDFs in the scene are overwritten if they are placeholders or if the `overwrite_scene_bsdfs` flag is set to `True`. + + Notes + ----- + - If a BSDF with the same name already exists in the scene and is a placeholder, it is updated with the BSDF from the asset. + - If a BSDF with the same name already exists in the scene and is not a placeholder, it is not overwritten unless the `overwrite_scene_bsdfs` flag is set to `True`. + - If a radio material with the same name does not exist in the scene, a new placeholder radio material is created and added to the scene. + + Raises + ------ + ValueError + If a scene item with the same name but of a different type already + exists in the scene. + """ + + # Get the root of the asset's XML + root_to_append = self._xml_tree.getroot() + + bsdfs_to_append = root_to_append.findall('bsdf') + for bsdf in bsdfs_to_append: + bsdf_name = bsdf.get('id') + + # Change naming to adhere to Sionna conventions + mat_name = bsdf_name[4:] if bsdf_name.startswith('mat-') else bsdf_name + + bsdf.set('id',f"mat-{mat_name}") + mat = self._scene.get(mat_name) + + if isinstance(mat, RadioMaterial): # >>> A radio material exists in the scene with that name. + if mat.bsdf.is_placeholder or self._overwrite_scene_bsdfs: + # If the material bsdf is a placeholder (or the user explicitely want to overwrite existing bsdfs) then the existing bsdf + # is updated with the bsdf of the asset. + mat.bsdf.xml_element = bsdf + # Else, if the material bsdf is not a placeholder then we keep the original scene bsdf + + else: # >>> mat is None or another class + # If the radio material does not exist and the name is available, then a placeholder is used. + # In this case, the user is in charge of defining the radio material properties afterward. + # The asset's bsdf is affected to the newly material + # If the radio material does not exist, but an item of the scene already uses that name, then scene.add() will raise an error. + material_bsdf = BSDF(name=f"mat-{mat_name}",xml_element=bsdf) + mat = RadioMaterial(mat_name, bsdf=material_bsdf) + mat.is_placeholder = True + self._scene.add(mat) + + def update_shape(self, key, value): + self._shapes[key] = value + + def get_shape(self, key): + return self._shapes.get(key) + + def remove_shape(self, key): + if key in self._shapes: + del self._shapes[key] + + def update_radio_material(self): + r""" + Ensure all asset's shapes share the same radio material. + + This method checks if all shapes associated with the asset share the same + radio material. If they do, the asset's `radio_material` property is set + to that material. If they do not, the `radio_material` property is set to `None`. + + Notes + ----- + - This method is bypassed if the `_bypass_update` flag is set to `True`. + - The method iterates over all shapes of the asset and collects their radio materials. If there is only one unique material, it is assigned to the asset's `radio_material` property. Otherwise, the property is set to `None`. + """ + if not self._bypass_update: + asset_mats = [] + for shape_name in self._shapes: + shape = self._shapes[shape_name] + shape_radio_material = shape.radio_material + + # Store the asset material(s) + if shape_radio_material not in asset_mats: + asset_mats.append(shape_radio_material) + + if len(asset_mats) == 1: + # If there is a single material used by all shapes of an asset, the general asset material property is set to that material + self._radio_material = asset_mats[0] + else: + # Otherwise, it is set to None + self._radio_material = None + + def update_velocity(self): + r""" + Ensure all asset's shapes share the same velocity. + + This method checks if all shapes associated with the asset share the same + velocity. If they do, the asset's `velocity` property is set to that velocity. + If they do not, the `velocity` property is set to `None`. + + Notes + ----- + - This method is bypassed if the `_bypass_update` flag is set to `True`. + - The method iterates over all shapes of the asset and collects their velocities. If all shapes have the same velocity, it is assigned to the asset's `velocity` property. Otherwise, the property is set to `None`. + """ + if not self._bypass_update: + + shape_names = list(self._shapes.keys()) + + first_shape_velocity = self._shapes[shape_names[0]].velocity + + for shape_name in shape_names[1:]: + shape = self._shapes[shape_name] + shape_velocity = shape.velocity + + if not np.array_equal(shape_velocity, first_shape_velocity): + # Not all shapes share the same velocity vector + self._velocity = None + return + + first_shape_velocity = shape_velocity + # All shapes share the same velocity vector + self._velocity = shape_velocity + + + +# Module variables for example asset files +# +test_asset_1 = str(files(assets).joinpath("test/test_asset_1/test_asset_1.xml")) +# pylint: disable=C0301 +""" +Example asset containing two 1x1x1m cubes spaced by 1m along the y-axis, with mat-itu_wood and mat-itu_metal materials. +""" + +test_asset_2 = str(files(assets).joinpath("test/test_asset_2/test_asset_2.xml")) +# pylint: disable=C0301 +""" +Example asset containing two 1x1x1m cubes spaced by 1m along the y-axis, with mat-custom_rm_1 and custom_rm_2 materials. +""" + +test_asset_3 = str(files(assets).joinpath("test/test_asset_3/test_asset_3.xml")) +# pylint: disable=C0301 +""" +Example asset containing a single 1x1x1m cubes with mat-itu_marble material. +""" + +test_asset_4 = str(files(assets).joinpath("test/test_asset_4/test_asset_4.xml")) +# pylint: disable=C0301 +""" +Example asset containing a single 1x1x1m cubes with mat-floor material. +""" + +monkey = str(files(assets).joinpath("monkey/monkey.xml")) +# pylint: disable=C0301 +""" +Example asset containing the famous "Suzanne" monkey head from Blender with mat-itu_marble material. +""" + +body = str(files(assets).joinpath("body/body.xml")) +# pylint: disable=C0301 +""" +Example asset containing a single body with mat-itu_marble material. +""" + +two_persons = str(files(assets).joinpath("two_persons/two_persons.xml")) +# pylint: disable=C0301 +""" +Example asset containing two persons with mat-itu_marble material. +""" + +simple_reflector = str(files(assets).joinpath("simple_reflector/simple_reflector.xml")) +# pylint: disable=C0301 +""" +Example asset containing a single mat-itu_metal reflector. +""" diff --git a/sionna/rt/assets/__init__.py b/sionna/rt/assets/__init__.py new file mode 100644 index 00000000..9be35dfb --- /dev/null +++ b/sionna/rt/assets/__init__.py @@ -0,0 +1,4 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# diff --git a/sionna/rt/assets/body/body.xml b/sionna/rt/assets/body/body.xml new file mode 100644 index 00000000..c4a1f8f6 --- /dev/null +++ b/sionna/rt/assets/body/body.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/body/meshes/body.ply b/sionna/rt/assets/body/meshes/body.ply new file mode 100644 index 00000000..2cc72266 Binary files /dev/null and b/sionna/rt/assets/body/meshes/body.ply differ diff --git a/sionna/rt/assets/monkey/meshes/monkey.ply b/sionna/rt/assets/monkey/meshes/monkey.ply new file mode 100644 index 00000000..af3bfd23 Binary files /dev/null and b/sionna/rt/assets/monkey/meshes/monkey.ply differ diff --git a/sionna/rt/assets/monkey/monkey.xml b/sionna/rt/assets/monkey/monkey.xml new file mode 100644 index 00000000..246699c8 --- /dev/null +++ b/sionna/rt/assets/monkey/monkey.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/simple_reflector/meshes/reflector.ply b/sionna/rt/assets/simple_reflector/meshes/reflector.ply new file mode 100644 index 00000000..7b81a233 Binary files /dev/null and b/sionna/rt/assets/simple_reflector/meshes/reflector.ply differ diff --git a/sionna/rt/assets/simple_reflector/simple_reflector.xml b/sionna/rt/assets/simple_reflector/simple_reflector.xml new file mode 100644 index 00000000..8ce69995 --- /dev/null +++ b/sionna/rt/assets/simple_reflector/simple_reflector.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/test/test_asset_1/meshes/cube_0.ply b/sionna/rt/assets/test/test_asset_1/meshes/cube_0.ply new file mode 100644 index 00000000..a7dbeb77 Binary files /dev/null and b/sionna/rt/assets/test/test_asset_1/meshes/cube_0.ply differ diff --git a/sionna/rt/assets/test/test_asset_1/meshes/cube_1.ply b/sionna/rt/assets/test/test_asset_1/meshes/cube_1.ply new file mode 100644 index 00000000..6f2f5f2a Binary files /dev/null and b/sionna/rt/assets/test/test_asset_1/meshes/cube_1.ply differ diff --git a/sionna/rt/assets/test/test_asset_1/test_asset_1.xml b/sionna/rt/assets/test/test_asset_1/test_asset_1.xml new file mode 100644 index 00000000..255fcc75 --- /dev/null +++ b/sionna/rt/assets/test/test_asset_1/test_asset_1.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/test/test_asset_2/meshes/cube_0.ply b/sionna/rt/assets/test/test_asset_2/meshes/cube_0.ply new file mode 100644 index 00000000..a7dbeb77 Binary files /dev/null and b/sionna/rt/assets/test/test_asset_2/meshes/cube_0.ply differ diff --git a/sionna/rt/assets/test/test_asset_2/meshes/cube_1.ply b/sionna/rt/assets/test/test_asset_2/meshes/cube_1.ply new file mode 100644 index 00000000..6f2f5f2a Binary files /dev/null and b/sionna/rt/assets/test/test_asset_2/meshes/cube_1.ply differ diff --git a/sionna/rt/assets/test/test_asset_2/test_asset_2.xml b/sionna/rt/assets/test/test_asset_2/test_asset_2.xml new file mode 100644 index 00000000..eb0d50d3 --- /dev/null +++ b/sionna/rt/assets/test/test_asset_2/test_asset_2.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/test/test_asset_3/meshes/cube.ply b/sionna/rt/assets/test/test_asset_3/meshes/cube.ply new file mode 100644 index 00000000..926708bb Binary files /dev/null and b/sionna/rt/assets/test/test_asset_3/meshes/cube.ply differ diff --git a/sionna/rt/assets/test/test_asset_3/test_asset_3.xml b/sionna/rt/assets/test/test_asset_3/test_asset_3.xml new file mode 100644 index 00000000..28e62aa2 --- /dev/null +++ b/sionna/rt/assets/test/test_asset_3/test_asset_3.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/test/test_asset_4/meshes/cube.ply b/sionna/rt/assets/test/test_asset_4/meshes/cube.ply new file mode 100644 index 00000000..926708bb Binary files /dev/null and b/sionna/rt/assets/test/test_asset_4/meshes/cube.ply differ diff --git a/sionna/rt/assets/test/test_asset_4/test_asset_4.xml b/sionna/rt/assets/test/test_asset_4/test_asset_4.xml new file mode 100644 index 00000000..0c648a5a --- /dev/null +++ b/sionna/rt/assets/test/test_asset_4/test_asset_4.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/assets/two_persons/meshes/person_1.ply b/sionna/rt/assets/two_persons/meshes/person_1.ply new file mode 100644 index 00000000..9fa410cf Binary files /dev/null and b/sionna/rt/assets/two_persons/meshes/person_1.ply differ diff --git a/sionna/rt/assets/two_persons/meshes/person_2.ply b/sionna/rt/assets/two_persons/meshes/person_2.ply new file mode 100644 index 00000000..dc43de0c Binary files /dev/null and b/sionna/rt/assets/two_persons/meshes/person_2.ply differ diff --git a/sionna/rt/assets/two_persons/two_persons.xml b/sionna/rt/assets/two_persons/two_persons.xml new file mode 100644 index 00000000..20ce7604 --- /dev/null +++ b/sionna/rt/assets/two_persons/two_persons.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sionna/rt/bsdf.py b/sionna/rt/bsdf.py new file mode 100644 index 00000000..af7c228f --- /dev/null +++ b/sionna/rt/bsdf.py @@ -0,0 +1,409 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +""" +Base class for BSDF. +""" + +import numpy as np +import tensorflow as tf +import xml.etree.ElementTree as ET +import copy +import matplotlib.colors as mcolors + + +class BSDF: + # pylint: disable=line-too-long + r"""BSDF(name, xml_element=None, color=None) + + Class implementing a BSDF descriptor + + BSDF stands for bidirectional scattering distribution function. Essentially, it's a mathematical function that determines the probability that a + specific ray of light will be reflected (scattered) at a given angle. Such functions is used within graphical engine for rendering. Here, we do + not properly define a bsdf and bsdf related functions, but rather use this class as a data structure to store bsdf related information, such as the objects + within a scene that use a given bsdf. + + Parameters + ---------- + name : str + Unique name of the bsdf + + xml_element : :class:`ET.Element`, optional + XML Element instance from xml.etree.ElementTree.Element library (default is None). + + color: [3], `float`, or `str`, optional + RGB color triplet or color name `str` of the asset (default is None). + """ + + def __init__(self, + name, + xml_element = None, + color = None, + ): + + if not isinstance(name, str): + raise TypeError("`name` must be a string") + self._name = name + + + if xml_element is not None: + # Store the specified XML descriptor + self._xml_element = xml_element + self._xml_element.set('id',f"{self._name}") + self._xml_element.set('name',f"{self._name}") + + self._rgb = None + self._color = None + + else: + if color is None: + # If neither RGB or XML element are specified a random color is chosen + rgb = np.random.rand(3) + elif isinstance(color,str): + (color,rgb) = self._color_name_to_rgb(color) + elif len(color) == 3 and max(color) <= 1 and min(color) >= 0: + rgb = color + color = None + else: + raise TypeError("`color` must be a list of 3 `float` between 0 and 1, or a valid `str` color name.") + + self._rgb = rgb + self._color = color + + # Create a default RGB bsdf element + self._xml_element = ET.Element('bsdf', attrib={'type': 'diffuse', 'id': self._name, 'name': self._name}) + rgb_value = f"{self._rgb[0]} {self._rgb[1]} {self._rgb[2]}" + ET.SubElement(self._xml_element, 'rgb', attrib={'value': rgb_value, 'name': 'reflectance'}) + + + # By default we assume the bsdf not to be a placeholder (i.e. it is defined by the user). + # When the bsdf is automatically instantiated, e.g. during the creation of a material, then _is_placeholder argument is set to True by the parent object. + self._is_placeholder = False + + # Radio material + self._radio_material = None + + @property + def name(self): + r""" + str (read-only): Get the name of the BSDF. + """ + return self._name + + @property + def xml_element(self): + r""" + Get/set the XML element description of the BSDF. + + Returns + ------- + xml_element : :class:`ET.Element` + The XML element descriptor of the BSDF. + + Parameters + ---------- + xml_element : :class:`ET.Element` + The new XML element descriptor of the BSDF (see ElementTree library). + + Raises + ------ + TypeError + If `xml_element` is not an instance of :class:`ET.Element`. + ValueError + If the root element is not . + """ + return self._xml_element + + @xml_element.setter + def xml_element(self, xml_element): + + if not isinstance(xml_element, ET.Element): + raise TypeError("`element` must be an ET.Element descriptor of a BSDF.") + + # Check if the root element is + if xml_element.tag != 'bsdf': + raise ValueError("The root element must be .") + + # Store the specified XML descriptor and change the name to match that of the bsdf/material + self._xml_element = xml_element + self._xml_element.set('id',f"{self._name}") + self._xml_element.set('name',f"{self._name}") + + self._is_placeholder = False + + # Set color and rgb to `None` since rgb or color are not always present in a complex bsdf xml descriptor + #If rgb is defined (and only rgb) set rgb to the corresponding values: + if len(self._xml_element) == 1 and self._xml_element[0].tag == 'rgb': + rgb = self._xml_element[0] + if 'value' in rgb.attrib: + self._rgb = [float(x) for x in rgb.attrib['value'].split()] + else: + self._rgb = None + else: + self._rgb = None + self._color = None + + if self.scene is not None: + self.scene.append_to_xml(self._xml_element, overwrite=True) + self.scene.reload() + + @property + def rgb(self): + r""" + Get/set the RGB color of the BSDF. + + Returns + ------- + rgb : list of float + A list of 3 floats representing the RGB color of the BSDF, with values between 0 and 1. + + Parameters + ---------- + rgb : list of float + A list of 3 floats representing the RGB color, with values between 0 and 1. + + Raises + ------ + TypeError + If `rgb` is not a list of 3 floats between 0 and 1. + """ + return self._rgb + + @rgb.setter + def rgb(self, rgb): + + if len(rgb) != 3 or max(rgb) > 1 or min(rgb) < 0: + raise TypeError("`rgb` must be a list of 3 floats comprised between 0 and 1") + self._rgb = rgb + self._color = None + + # Create a default bsdf element + self._xml_element = ET.Element('bsdf', attrib={'type': 'diffuse', 'id': self._name, 'name': self._name}) + rgb_value = f"{self._rgb[0]} {self._rgb[1]} {self._rgb[2]}" + ET.SubElement(self._xml_element, 'rgb', attrib={'value': rgb_value, 'name': 'reflectance'}) + + self._is_placeholder = False + + if self.scene is not None: + self.scene.append_to_xml(self._xml_element, overwrite=True) + self.scene.reload() + + @property + def color(self): + r""" + Get/set the color of the BSDF. + + Returns + ------- + str or None + Color name of the BSDF or None. + + Parameters + ---------- + color : list of float or str + RGB float triplet with values comprised between 0 and 1, or a color name. + + Raises + ------ + TypeError + If the input is not a list of 3 floats between 0 and 1, or a valid color name. + """ + return self._color + + @color.setter + def color(self, color): + + if isinstance(color, str): + color,rgb = self._color_name_to_rgb(color) + elif len(color) == 3 and max(color) <= 1 and min(color) >= 0: + rgb = color + color = None + else: + raise TypeError("`color` must be a list of 3 `float` between 0 and 1, or a valid `str` color name.") + + self._rgb = rgb + self._color = color + + # Create a default bsdf element + self._xml_element = ET.Element('bsdf', attrib={'type': 'diffuse', 'id': self._name, 'name': self._name}) + rgb_value = f"{self._rgb[0]} {self._rgb[1]} {self._rgb[2]}" + ET.SubElement(self._xml_element, 'rgb', attrib={'value': rgb_value, 'name': 'reflectance'}) + + self._is_placeholder = False + + if self.scene is not None: + self.scene.append_to_xml(self._xml_element, overwrite=True) + self.scene.reload() + + def assign(self, b): + r""" + Assign new values to the BSDF properties from another + BSDF ``b``. + + Input + ------ + b : :class:`~sionna.rt.BSDF + BSDF from which to assign the new values + """ + if not isinstance(b, BSDF): + raise TypeError("`b` is not a BSDF") + + # When assigning a the bsdf attributes we should not use the same objects: + self._rgb = copy.copy(b.rgb) + self._color = copy.copy(b.color) + self._xml_element = copy.deepcopy(b.xml_element) + + # Since assign method does not replace the object itself, the we should update the name of the assigned bsdf before updating xml file + self._xml_element.set('id',f"{self._name}") + self._xml_element.set('name',f"{self._name}") + + # No that a BSDF has been assigned, the BSDF is not a placeholder anymore + self.is_placeholder = False + + if self.scene is not None: + self.scene.append_to_xml(self._xml_element, overwrite=True) + self.scene.reload() + + ############################################## + # Internal methods. + # Should not be documented. + ############################################## + + @property + def use_counter(self): + r""" + int : Number of scene objects using this BSDF + """ + if self.radio_material is not None: + return self.radio_material.use_counter + else: + return set() + + @property + def is_used(self): + r""" + bool : Indicator if the BSDF is used by at least one object of the scene + """ + if self.radio_material is not None: + return self.radio_material.is_used + else: + return False + + @property + def using_objects(self): + r""" + [num_using_objects], tf.int : Identifiers of the objects using this BSDF + """ + if self.radio_material is not None: + return self.radio_material.using_objects + else: + return tf.cast(tuple(set()), tf.int32) + + @property + def scene(self): + r""" + :class:`~sionna.rt.Scene` : Get/set the scene + """ + if self._radio_material is not None: + return self._radio_material.scene + else: + return None + + @property + def radio_material(self): + r""" + :class:`~sionna.rt.RadioMaterial` : Get/set the BSDF's RadioMaterial + """ + return self._radio_material + + @radio_material.setter + def radio_material(self, radio_material): + if radio_material is not None: + self._radio_material = radio_material + + self._name = f"mat-{self._radio_material.name}" + self._xml_element.set('id',f"{self._name}") + self._xml_element.set('name',f"{self._name}") + else: + self._radio_material = None + + @property + def has_radio_material(self): + r""" + bool : Return True if the radio_material of the BSDF is set, False otherwise. + """ + return bool(self.radio_material is not None) + + @property + def is_placeholder(self): + r""" + bool : Get/set if this bsdf is a placeholder. A bsdf is considered a placeholder when it has been randomly defined upon instantiation of a RadioMaterial(random rgb tuple) + """ + return self._is_placeholder + + @is_placeholder.setter + def is_placeholder(self, v): + self._is_placeholder = v + + def set_scene(self, overwrite=False): + r""" + Set the BSDF XML to the scene. + """ + if self.scene is not None: + existing_bsdf = self.scene.append_to_xml(self._xml_element, overwrite=overwrite) + + if existing_bsdf is not None: + if not overwrite: + # Set the existing bsdf from the XML as the BSDF xml_element (and update rgb/color parameters accordingly) + self._xml_element = existing_bsdf + + # If rgb is defined (and only rgb) set rgb to the corresponding values: + if len(self._xml_element) == 1 and self._xml_element[0].tag == 'rgb': + rgb = self._xml_element[0] + if 'value' in rgb.attrib: + self._rgb = [float(x) for x in rgb.attrib['value'].split()] + else: + self._rgb = None + else: + self._rgb = None + + self._color = None + self._is_placeholder = False + self.scene.reload() + + def _color_name_to_rgb(self,color): + r""" + Convert a color name to an RGB triplet. + + Parameters + ---------- + color : str + The name of the color. + + Returns + ------- + tuple + A tuple containing the color name and the corresponding RGB triplet. + + Raises + ------ + TypeError + If `color` is not a string or if the color name is unknown. + """ + if not isinstance(color,str): + raise TypeError("`color` must be a `str`.") + + # Dictionary mapping color names to RGB values using matplotlib + color_names = {name: mcolors.to_rgb(color) for name, color in mcolors.CSS4_COLORS.items()} + + # If a color name is provided, get the corresponding RGB value + color = color.lower() + if color in color_names: + rgb = color_names[color] + return (color,rgb) + else: + raise TypeError(f"Unknown color name '{color}'.") + + \ No newline at end of file diff --git a/sionna/rt/radio_device.py b/sionna/rt/radio_device.py index 57d35749..b53137df 100644 --- a/sionna/rt/radio_device.py +++ b/sionna/rt/radio_device.py @@ -10,6 +10,7 @@ import tensorflow as tf from .object import Object +from .asset_object import AssetObject from .utils import normalize, theta_phi_from_unit_vec from sionna.constants import PI @@ -129,15 +130,17 @@ def look_at(self, target): :class:`~sionna.rt.Transmitter`, :class:`~sionna.rt.Receiver`, :class:`~sionna.rt.RIS`, or :class:`~sionna.rt.Camera` in the scene to look at. - """ + """ + + # Get position to look at if isinstance(target, str): obj = self.scene.get(target) - if not isinstance(obj, Object): - raise ValueError(f"No camera, device, or object named '{target}' found.") + if not isinstance(obj, Object) and not isinstance(obj, AssetObject): + raise ValueError(f"No camera, device, asset or object named '{target}' found.") else: target = obj.position - elif isinstance(target, Object): + elif isinstance(target, (Object, AssetObject)): target = target.position else: target = tf.cast(target, dtype=self._rdtype) diff --git a/sionna/rt/radio_material.py b/sionna/rt/radio_material.py index e9d713cc..7a248186 100644 --- a/sionna/rt/radio_material.py +++ b/sionna/rt/radio_material.py @@ -8,11 +8,14 @@ """ import tensorflow as tf +import warnings from . import scene from sionna.constants import DIELECTRIC_PERMITTIVITY_VACUUM, PI from .scattering_pattern import ScatteringPattern, LambertianPattern +from .bsdf import BSDF + class RadioMaterial: # pylint: disable=line-too-long r""" @@ -41,6 +44,9 @@ class RadioMaterial: such as a Keras layer implementing a neural network. In the former case, it could be set to a trainable variable: + Additionally, a radio material can be associated with a :class:`~sionna.rt.BSDF` object to define + its rendering properties in more detail. The BSDF can be specified at instantiation or assigned later. + .. code-block:: Python mat = RadioMaterial("my_mat") @@ -91,6 +97,11 @@ class RadioMaterial: If set to `None`, the material properties are constant and equal to ``relative_permittivity`` and ``conductivity``. Defaults to `None`. + + bsdf : :class:`~sionna.rt.BSDF` | `None` + An optional BSDF object to describe the rendering properties of the material. + If not provided, a placeholder BSDF with a random RGB color is created. + Defaults to `None`. dtype : tf.complex64 or tf.complex128 Datatype. @@ -105,6 +116,7 @@ def __init__(self, xpd_coefficient=0.0, scattering_pattern=None, frequency_update_callback=None, + bsdf=None, dtype=tf.complex64): if not isinstance(name, str): @@ -128,6 +140,9 @@ def __init__(self, self.relative_permittivity = relative_permittivity self.conductivity = conductivity + + + # Save the callback for when the frequency is updated # or if the RadioMaterial is added to a scene self._frequency_update_callback = frequency_update_callback @@ -144,6 +159,26 @@ def __init__(self, # Set of objects identifiers that use this material self._objects_using = set() + # Set scene to None + self._scene = None + + # Init material BSDF + if bsdf is not None: + if not isinstance(bsdf, BSDF): + raise TypeError("`bsdf` must be a BSDF") + if bsdf.has_radio_material: + warnings.warn("The input BSDF is already associated with another material. Creating a dummy BSDF and assigning the input BSDF.") + bsdf_name = f"mat-{self._name}" + new_bsdf = BSDF(name=bsdf_name) + new_bsdf.assign(bsdf) + bsdf = new_bsdf + self._bsdf = bsdf + else: + bsdf_name = f"mat-{self._name}" + self._bsdf = BSDF(name=bsdf_name) # Since neither rgb nor xml_element are specified, a placeholder bsdf is created with random rgb color + self._bsdf.is_placeholder = True + + self._bsdf.radio_material = self @property def name(self): @@ -308,6 +343,77 @@ def using_objects(self): tf_objects_using = tf.cast(tuple(self._objects_using), tf.int32) return tf_objects_using + @property + def bsdf(self): + r"""Get/set the BSDF associated with the radio material. + + Returns + ------- + :class:`~sionna.rt.BSDF` + The BSDF associated with the radio material. + + Parameters + ---------- + bsdf : :class:`~sionna.rt.BSDF` + The BSDF to associate with the radio material. + + Raises + ------ + TypeError + If `bsdf` is not an instance of :class:`~sionna.rt.BSDF`. + ValueError + If the BSDF is already used by another :class:`~sionna.rt.RadioMaterial`. + """ + return self._bsdf + + @bsdf.setter + def bsdf(self, bsdf): + if not isinstance(bsdf, BSDF): + raise TypeError("`bsdf` must be a BSDF") + + if bsdf.has_radio_material: + raise ValueError("Can't set an already used BSDF to another material. Prefer the assign method.") + + + + self._bsdf.radio_material = None + self._bsdf = bsdf + self._bsdf.radio_material = self + self._bsdf.set_scene(overwrite=True) + + + def assign(self, rm): + """ + Assign new values to the radio material properties from another + radio material ``rm`` + + Input + ------ + rm : :class:`~sionna.rt.RadioMaterial + Radio material from which to assign the new values + """ + if not isinstance(rm, RadioMaterial): + raise TypeError("`rm` is not a RadioMaterial") + self.relative_permittivity = rm.relative_permittivity + self.conductivity = rm.conductivity + self.scattering_coefficient = rm.scattering_coefficient + self.xpd_coefficient = rm.xpd_coefficient + self.scattering_pattern = rm.scattering_pattern + self.frequency_update_callback = rm.frequency_update_callback + + # The default beahviour when updating/modyfing a radio material is to not automatically reload the scene (which can break the diffenrtiability) + if self._scene is not None: + b_tmp = self._scene.bypass_reload + self._scene.bypass_reload = True + + self.bsdf.assign(rm.bsdf) + + if self._scene is not None: + self._scene.bypass_reload = b_tmp + + # No that a RadioMaterial has been assigned, the RadioMaterial is not a placeholder anymore + self.is_placeholder = False + ############################################## # Internal methods. # Should not be documented. @@ -331,6 +437,7 @@ def add_object_using(self, object_id): """ self._objects_using.add(object_id) + def discard_object_using(self, object_id): """ Remove an object from the set of objects using this material @@ -339,6 +446,11 @@ def discard_object_using(self, object_id): f"Object with id {object_id} is not in the set of {self.name}" self._objects_using.discard(object_id) + + def reset_objects_using(self): + self._objects_using = set() + + @property def is_placeholder(self): """ @@ -350,21 +462,15 @@ def is_placeholder(self): def is_placeholder(self, v): self._is_placeholder = v - def assign(self, rm): + @property + def scene(self): """ - Assign new values to the radio material properties from another - radio material ``rm`` - - Input - ------ - rm : :class:`~sionna.rt.RadioMaterial - Radio material from which to assign the new values + scene : Get/set the scene """ - if not isinstance(rm, RadioMaterial): - raise TypeError("`rm` is not a RadioMaterial") - self.relative_permittivity = rm.relative_permittivity - self.conductivity = rm.conductivity - self.scattering_coefficient = rm.scattering_coefficient - self.xpd_coefficient = rm.xpd_coefficient - self.scattering_pattern = rm.scattering_pattern - self.frequency_update_callback = rm.frequency_update_callback + return self._scene + + @scene.setter + def scene(self, s): + self._scene = s + self._bsdf.set_scene(overwrite=False) + diff --git a/sionna/rt/scene.py b/sionna/rt/scene.py index 0b1f3228..0ecd307a 100644 --- a/sionna/rt/scene.py +++ b/sionna/rt/scene.py @@ -9,13 +9,19 @@ """ import os +import shutil from importlib_resources import files import matplotlib import matplotlib.pyplot as plt import mitsuba as mi import tensorflow as tf +import xml.etree.ElementTree as ET import drjit as dr +import warnings +import weakref + + from .antenna_array import AntennaArray from .camera import Camera @@ -26,6 +32,7 @@ from .receiver import Receiver from .ris import RIS from .scene_object import SceneObject +from .asset_object import AssetObject from .solver_paths import SolverPaths, PathsTmpData from .solver_cm import SolverCoverageMap from .transmitter import Transmitter @@ -50,7 +57,7 @@ class Scene: for which propagation :class:`~sionna.rt.Paths`, channel impulse responses (CIRs) or coverage maps (:class:`~sionna.rt.CoverageMap`) can be computed, as well as cameras (:class:`~sionna.rt.Camera`) for rendering. - The only way to instantiate a scene is by calling :meth:`~sionna.rt.Scene,.load_scene()`. + The only way to instantiate a scene is by calling :meth:`~sionna.rt.Scene.load_scene()`. Note that only a single scene can be loaded at a time. Example scenes can be loaded as follows: @@ -70,6 +77,8 @@ class Scene: # This object is a singleton, as it is assumed that only one scene can be # loaded at a time. _instance = None + + def __new__(cls, *args, **kwargs): # pylint: disable=unused-argument if cls._instance is None: instance = object.__new__(cls) @@ -87,6 +96,8 @@ def __new__(cls, *args, **kwargs): # pylint: disable=unused-argument instance._ris = {} # Cameras instance._cameras = {} + # Asset objects + instance._asset_objects = {} # Transmitter antenna array instance._tx_array = None # Receiver antenna array @@ -116,6 +127,22 @@ def __init__(self, env_filename = None, dtype = tf.complex64): # If a filename is provided, loads the scene from it. # The previous scene is overwritten. if env_filename: + # By-pass reload() method is activated before scene init. + self._bypass_reload = True + + # Check if the 'tmp' directory exists, and create it if it doesn't + self.tmp_directory_path = 'tmp/' + try: + #print("Scene Initialisation") + if os.path.exists(self.tmp_directory_path): + # Remove the directory and all its contents + shutil.rmtree(self.tmp_directory_path) + #print(f"Old scene directory '{self.tmp_directory_path}' has been removed.") + + # Recreate an empty dir + os.makedirs(self.tmp_directory_path) + except Exception as e: + raise RuntimeError(f"An error occurred while initializing the scene: {e}") from e if dtype not in (tf.complex64, tf.complex128): msg = "`dtype` must be tf.complex64 or tf.complex128`" @@ -129,19 +156,48 @@ def __init__(self, env_filename = None, dtype = tf.complex64): # Set the frequency to the default value self.frequency = Scene.DEFAULT_FREQUENCY - # Populate with ITU materials - instantiate_itu_materials(self._dtype) - # Load the scene # Keep track of the Mitsuba scene if env_filename == "__empty__": # Set an empty scene - self._scene = mi.load_dict({"type": "scene", - "integrator": { - "type": "path", - }}) + # """ + # self._scene_dict = {"type": "scene", + # "integrator": { + # "type": "path", + # }} + # self._scene = mi.load_dict(self._scene_dict) + # """ + #self._scene_string = '' + # Create an empty scene xml descriptor + scene = ET.Element('scene', version="2.1.0") + integrator = ET.SubElement(scene, 'integrator', type="path") + tree = ET.ElementTree(scene) + + # Write the XML content to the file + ET.indent(tree, space="\t", level=0) + tree.write(os.path.join(self.tmp_directory_path, 'tmp_scene.xml'), encoding='utf-8', xml_declaration=True) + self._xml_tree = tree else: - self._scene = mi.load_file(env_filename) + # Load, parse and copy the input XML file to a tmp folder + tree = ET.parse(env_filename) + ET.indent(tree, space="\t", level=0) + tree.write(os.path.join(self.tmp_directory_path, 'tmp_scene.xml'), encoding='utf-8', xml_declaration=True) + self._xml_tree = tree + + # copy corresponding meshes folder to the 'tmp/meshes/' folder + path = os.path.dirname(env_filename) + meshes_source_dir = os.path.join(path, 'meshes') + destination_dir = os.path.join(self.tmp_directory_path, 'meshes') + if not os.path.exists(destination_dir): + os.makedirs(destination_dir) + shutil.copytree(meshes_source_dir, destination_dir, dirs_exist_ok=True) + + + # Populate with ITU materials - This will also trigger the update of the XML file with placeholder bsdf + instantiate_itu_materials(self._dtype) + + # Load the scene in mitsuba + self._scene = mi.load_file(os.path.join(self.tmp_directory_path, 'tmp_scene.xml')) self._scene_params = mi.traverse(self._scene) # Instantiate the solver @@ -163,6 +219,13 @@ def __init__(self, env_filename = None, dtype = tf.complex64): # By default, no callable is used for scattering patterns self._scattering_pattern_callable = None + # By-pass reload() method is deactivated after scene init. + self._bypass_reload = False + + # By-pass scene geometry update is set to False by default, hence the scene geometry is automatically update e.g. when moving a scene object + # self._bypass_scene_geometry_update = False + + @property def cameras(self): """ @@ -194,6 +257,7 @@ def frequency(self, f): for mat in self.radio_materials.values(): mat.frequency_update() + @property def wavelength(self): """ @@ -265,12 +329,22 @@ def radio_materials(self): @property def objects(self): + # pylint: disable=line-too-long + """" + `dict` (read-only), { "name", :class:`weakref.ProxyType` to :class:`~sionna.rt.SceneObject`} : Dictionary + of scene objects. The dictionary values are weak references (proxies) to the scene objects, meaning that the objects can't be strong referenced outside + the class by the user and can thus be garbage collected if there are no strong references to them elsewhere i.e. when they are deleted within scene class. + """ + return {k:weakref.proxy(v) for k,v in self._scene_objects.items()} + + @property + def asset_objects(self): # pylint: disable=line-too-long """ - `dict` (read-only), { "name", :class:`~sionna.rt.SceneObject`} : Dictionary - of scene objects + `dict` (read-only), { "name", :class:`~sionna.rt.AssetObject`} : Dictionary + of asset objects """ - return dict(self._scene_objects) + return dict(self._asset_objects) @property def tx_array(self): @@ -322,7 +396,7 @@ def center(self): def get(self, name): # pylint: disable=line-too-long """ - Returns a scene object, transmitter, receiver, camera, or radio material + Returns a scene object, transmitter, receiver, camera, asset object or radio material Input ----- @@ -331,7 +405,7 @@ def get(self, name): Output ------ - item : :class:`~sionna.rt.SceneObject` | :class:`~sionna.rt.RadioMaterial` | :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.RIS` | :class:`~sionna.rt.Camera` | `None` + item : :class:`~sionna.rt.SceneObject` | :class:`~sionna.rt.RadioMaterial` | :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.RIS` | :class:`~sionna.rt.Camera` | :class:`~sionna.rt.AssetObject` | `None` Retrieved item. Returns `None` if no corresponding item was found in the scene. """ if name in self._transmitters: @@ -345,122 +419,249 @@ def get(self, name): if name in self._radio_materials: return self._radio_materials[name] if name in self._scene_objects: - return self._scene_objects[name] + return weakref.proxy(self._scene_objects[name]) if name in self._cameras: return self._cameras[name] + if name in self._asset_objects: + return self._asset_objects[name] return None - def add(self, item): - # pylint: disable=line-too-long + def reload(self): # pylint: disable=line-too-long """ - Adds a transmitter, receiver, RIS, radio material, or camera to the scene. + Reload the scene and the corresponding RT solvers, while keeping all cameras, TX, RX, RIS, etc. objects in place. - If a different item with the same name as ``item`` is already part of the scene, - an error is raised. + This method reloads the scene from the XML file, reinitializes the RT solvers, and reloads the scene objects. It is useful + when changes have been made to the scene's assets or BSDFs. Input + ----- + None + + Output ------ - item : :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.RIS` | :class:`~sionna.rt.RadioMaterial` | :class:`~sionna.rt.Camera` - Item to add to the scene - """ - if ( (not isinstance(item, Camera)) - and (not isinstance(item, RadioDevice)) - and (not isinstance(item, RadioMaterial)) ): - err_msg = "The input must be a Transmitter, Receiver, RIS, Camera, or"\ - " RadioMaterial" - raise ValueError(err_msg) - - name = item.name - s_item = self.get(name) - if s_item is not None: - if s_item is not item: - # In the case of RadioMaterial, the current item with same - # name could just be a placeholder - if (isinstance(s_item, RadioMaterial) - and isinstance(item, RadioMaterial) - and s_item.is_placeholder): - s_item.assign(item) - s_item.is_placeholder = False - else: - msg = f"Name '{name}' is already used by another item of"\ - " the scene" - raise ValueError(msg) - else: - # This item was already added. - return - if isinstance(item, Transmitter): - self._transmitters[name] = item - item.scene = self - elif isinstance(item, Receiver): - self._receivers[name] = item - item.scene = self - elif isinstance(item, RIS): - self._ris[name] = item - # Manually assign object_id to each RIS - if len(self.objects)>0: - max_id = max(obj.object_id for obj in self.objects.values()) - else: - max_id=0 - max_id += len(self._ris) - item.object_id = max_id - # Set scene propety and radio material - item.scene = self - item.radio_material = "itu_metal" - elif isinstance(item, RadioMaterial): - self._radio_materials[name] = item - item.frequency_update() - elif isinstance(item, Camera): - self._cameras[name] = item - item.scene = self - - def remove(self, name): + bool + Returns `True` if the scene was reloaded successfully (which requires `self._bypass_reload` to be `False`), otherwise returns `False`. + + Notes + ----- + - This method resets the preview widget, reloads the XML file with Mitsuba, reinitializes the solvers, and reloads the scene objects. + - The :meth:`~sionna.rt.Scene._load_scene_objects()` method is called to ensure that all scene objects are correctly instantiated/updated based on the current state of the scene. + - If :attr:`bypass_reload` is `True`, the method does nothing and returns `False`. + """ + + if not self._bypass_reload: + # Reset the preview widget + self._preview_widget = None + + # Reloade the xml file with mitsuba + self._scene = mi.load_file(os.path.join(self.tmp_directory_path, 'tmp_scene.xml')) + + # Get the scene parameters + self._scene_params = mi.traverse(self._scene) + + # (Re)-instantiate the solver + self._solver_paths = SolverPaths(self, dtype=self._dtype) + + # (Re)-instantiate the solver for coverage map + self._solver_cm = SolverCoverageMap(self, solver=self._solver_paths, dtype=self._dtype) + + # (Re)load the scene objects + self._load_scene_objects() + + return True + else: + return False + + + + def add(self, item_list): # pylint: disable=line-too-long """ - Removes a transmitter, receiver, RIS, camera, or radio material from the - scene. + Adds a (list of) transmitter, receiver, radio material, camera, or geometrical asset to the scene. - In the case of a radio material, it must not be used by any object of - the scene. + If a different item with the same name as ``item`` is already part of the scene, + an error is raised. Input ----- - name : str - Name of the item to remove - """ - if not isinstance(name, str): - raise ValueError("The input should be a string") - item = self.get(name) + item_list : :class:`~sionna.rt.Transmitter` | :class:`~sionna.rt.Receiver` | :class:`~sionna.rt.RIS` | :class:`~sionna.rt.RadioMaterial` | :class:`~sionna.rt.Camera` | :class:`~sionna.rt.AssetObject` | list + Item or list of items to add to the scene. - if item is None: - pass + Notes + ----- + - This method adds the specified items to the scene and updates their properties accordingly. + - If an item with the same name already exists in the scene, an error is raised unless: + - The existing item is a placeholder `RadioMaterial`, in which case the properties of the new `RadioMaterial` are assigned to the placeholder. + - The existing item is an `AssetObject`, in which case the existing `AssetObject` is removed and replaced with the new one, and a warning is issued. + - If an `AssetObject` is added, the scene is reloaded to reflect the changes. + - The :meth:`~sionna.rt.Scene.reload()` method is called if the scene needs to be reloaded after adding the items. + """ - elif isinstance(item, Transmitter): - del self._transmitters[name] + if not isinstance(item_list, list): + item_list = [item_list] + + need_to_reload_scene = False + for item in item_list: + if ( (not isinstance(item, Camera)) + and (not isinstance(item, RadioDevice)) + and (not isinstance(item, RadioMaterial)) + and (not isinstance(item, AssetObject))): + err_msg = "The input must be a Transmitter, Receiver, RIS, Camera, , AssetObject or"\ + " RadioMaterial" + raise ValueError(err_msg) + + name = item.name + s_item = self.get(name) + if s_item is not None: + if s_item is not item: + # In the case of RadioMaterial, the current item with same + # name could just be a placeholder + if (isinstance(s_item, RadioMaterial) + and isinstance(item, RadioMaterial) + and s_item.is_placeholder): + s_item.assign(item) + s_item.is_placeholder = False + item.frequency_update() + continue # Since the material is updated, no need to add it to self._radio_materials. Continue to next item in item_list. + + elif (isinstance(s_item, AssetObject) and isinstance(item, AssetObject)): + #print(f"Asset {name} already present in scene has been removed from the scene. If you want to keep both, use a different name.",s_item,item) + warnings.warn(f"Asset {name} already present in scene has been removed from the scene. If you want to keep both, use a different name.") + need_to_reload_scene = True + b_tmp = self._bypass_reload + self._bypass_reload = True # all self.reload() call will be by-passed + self.remove(name) + self._bypass_reload = b_tmp + else: + msg = f"Name '{name}' is already used by another item of"\ + " the scene" + raise ValueError(msg) + else: + # This item was already added. Continue to next item in item_list. + continue #return + if isinstance(item, Transmitter): + self._transmitters[name] = item + item.scene = self + elif isinstance(item, Receiver): + self._receivers[name] = item + item.scene = self + elif isinstance(item, RIS): + self._ris[name] = item + # Manually assign object_id to each RIS + if len(self.objects)>0: + max_id = max(obj.object_id for obj in self.objects.values()) + else: + max_id=0 + max_id += len(self._ris) + item.object_id = max_id + # Set scene propety and radio material + item.scene = self + item.radio_material = "itu_metal" + elif isinstance(item, RadioMaterial): + self._radio_materials[name] = item + item.scene = self + item.frequency_update() + elif isinstance(item, Camera): + self._cameras[name] = item + item.scene = self + elif isinstance(item, AssetObject): + need_to_reload_scene = True + b_tmp = self._bypass_reload + self._bypass_reload = True # all self.reload() call will be by-passed + self._asset_objects[name] = item + item.scene = self + self._bypass_reload = b_tmp + + + if need_to_reload_scene: + self.reload() + + + def remove(self, name_list): + # pylint: disable=line-too-long + """ + Removes a (list of) transmitter, receiver, RIS, camera, asset object, scene object, or radio material from the scene. - elif isinstance(item, Receiver): - del self._receivers[name] + In the case of a radio material, it must not be used by any object of the scene. - elif isinstance(item, RIS): - del self._ris[name] + Input + ----- + name_list : str | list of str + Name or list of names of the items to remove. - elif isinstance(item, RIS): - del self._ris[name] + Notes + ----- + - This method removes the specified items from the scene and updates the scene accordingly. + - If an `AssetObject` or `SceneObject` is removed, the scene is reloaded to reflect the changes. + - If a `RadioMaterial` is removed, it must not be used by any object in the scene. + - The :meth:`~sionna.rt.Scene.reload()` method is called if the scene needs to be reloaded after removing the items. + """ + if not isinstance(name_list, list): + name_list = [name_list] + + need_to_reload_scene = False + for name in name_list: + if not isinstance(name, str): + raise ValueError("The input should be a string") + item = self.get(name) + + if item is None: + warnings.warn(f"No item with name `{name}` to remove from scene.") + + elif isinstance(item, Transmitter): + del self._transmitters[name] + + elif isinstance(item, Receiver): + del self._receivers[name] + + elif isinstance(item, RIS): + del self._ris[name] + + elif isinstance(item, RIS): + del self._ris[name] + + elif isinstance(item, Camera): + del self._cameras[name] + + elif isinstance(item, AssetObject): + need_to_reload_scene = True + b_tmp = self._bypass_reload + self._bypass_reload = True # all self.reload() call will be by-passed + for shape_name in item.shapes: + obj = self.get(shape_name) + obj.delete_from_scene() + del self._scene_objects[shape_name] + item.scene = None + del self._asset_objects[name] + self._bypass_reload = b_tmp + + elif isinstance(item, SceneObject): + if item.asset_object is not None : + msg = "Can't remove a SceneObject part of an AssetObject. Try removing the complete AssetObject instead." + raise ValueError(msg) + need_to_reload_scene = True + b_tmp = self._bypass_reload + self._bypass_reload = True # all self.reload() call will be by-passed + item.delete_from_scene() + del self._scene_objects[name] + self._bypass_reload = b_tmp + + elif isinstance(item, RadioMaterial): + if item.is_used: + msg = f"The radio material '{name}' is used by at least one"\ + " object" + raise ValueError(msg) + del self._radio_materials[name] - elif isinstance(item, Camera): - del self._cameras[name] + else: + msg = "Only Transmitters, Receivers, RIS, Cameras, AssetObject, SceneObject or RadioMaterials"\ + " can be removed" + raise TypeError(msg) - elif isinstance(item, RadioMaterial): - if item.is_used: - msg = f"The radio material '{name}' is used by at least one"\ - " object" - raise ValueError(msg) - del self._radio_materials[name] + if need_to_reload_scene: + self.reload() - else: - msg = "Only Transmitters, Receivers, RIS, Cameras, or RadioMaterials"\ - " can be removed" - raise TypeError(msg) def trace_paths(self, max_depth=3, method="fibonacci", num_samples=int(1e6), @@ -755,20 +956,20 @@ def compute_paths(self, max_depth=3, method="fibonacci", :class:`~sionna.rt.Paths` object. The path computation consists of two main steps as shown in the below figure. - + .. figure:: ../figures/compute_paths.svg :align: center For a configured :class:`~sionna.rt.Scene`, the function first traces geometric propagation paths using :meth:`~sionna.rt.Scene.trace_paths`. This step is independent of the - :class:`~sionna.rt.RadioMaterial` of the scene objects as well as the transmitters' and receivers' + :class:`~sionna.rt.RadioMaterial` of the scene objects as well as the transmitters' and receivers' antenna :attr:`~sionna.rt.Antenna.patterns` and :attr:`~sionna.rt.Transmitter.orientation`, but depends on the selected propagation phenomena, such as reflection, scattering, and diffraction. The traced paths are then converted to EM fields by the function :meth:`~sionna.rt.Scene.compute_fields`. The resulting :class:`~sionna.rt.Paths` object can be used to compute channel impulse responses via :meth:`~sionna.rt.Paths.cir`. The advantage of separating path tracing - and field computation is that one can study the impact of different radio materials + and field computation is that one can study the impact of different radio materials by executing :meth:`~sionna.rt.Scene.compute_fields` multiple times without re-tracing the propagation paths. This can for example speed-up the calibration of scene parameters by several orders of magnitude. @@ -1157,8 +1358,8 @@ def coverage_map(self, combining_vec : [num_rx_ant], complex | None Combining vector. - If set to `None`, then no combining is applied, and - the energy received by all antennas is summed. + If set to `None`, then defaults to + :math:`\frac{1}{\sqrt{\text{num_rx_ant}}} [1,\dots,1]^{\mathsf{T}}`. precoding_vec : [num_tx_ant], complex | None Precoding vector. @@ -1254,7 +1455,11 @@ def coverage_map(self, cm_size = tf.cast(cm_size, self._rdtype) # Check and initialize the combining and precoding vector - if combining_vec is not None: + if combining_vec is None: + combining_vec = tf.ones([self.rx_array.num_ant], self._dtype) + combining_vec /= tf.sqrt(tf.cast(self.rx_array.num_ant, + self._dtype)) + else: combining_vec = tf.cast(combining_vec, self._dtype) if precoding_vec is None: num_tx = len(self.transmitters) @@ -1293,10 +1498,9 @@ def preview(self, paths=None, show_paths=True, show_devices=True, show_orientations=False, coverage_map=None, cm_tx=0, cm_db_scale=True, cm_vmin=None, cm_vmax=None, - resolution=(655, 500), fov=45, background='#ffffff', - clip_at=None, clip_plane_orientation=(0, 0, -1)): + resolution=(655, 500), fov=45, background='#ffffff'): # pylint: disable=line-too-long - r"""preview(paths=None, show_paths=True, show_devices=True, coverage_map=None, cm_tx=0, cm_vmin=None, cm_vmax=None, resolution=(655, 500), fov=45, background='#ffffff', clip_at=None, clip_plane_orientation=(0, 0, -1)) + r"""preview(paths=None, show_paths=True, show_devices=True, coverage_map=None, cm_tx=0, cm_vmin=None, cm_vmax=None, resolution=(655, 500), fov=45, background='#ffffff') In an interactive notebook environment, opens an interactive 3D viewer of the scene. @@ -1384,16 +1588,6 @@ def preview(self, paths=None, show_paths=True, show_devices=True, Background color in hex format prefixed by '#'. Defaults to '#ffffff' (white). - clip_at : float - If not `None`, the scene preview will be clipped (cut) by a plane - with normal orientation ``clip_plane_orientation`` and offset ``clip_at``. - That means that everything *behind* the plane becomes invisible. - This allows visualizing the interior of meshes, such as buildings. - Defaults to `None`. - - clip_plane_orientation : tuple[float, float, float] - Normal vector of the clipping plane. - Defaults to (0,0,-1). """ if (self._preview_widget is not None) and (resolution is not None): assert isinstance(resolution, (tuple, list)) and len(resolution) == 2 @@ -1426,9 +1620,6 @@ def preview(self, paths=None, show_paths=True, show_devices=True, coverage_map, tx=cm_tx, db_scale=cm_db_scale, vmin=cm_vmin, vmax=cm_vmax) - # Clipping - fig.set_clipping_plane(offset=clip_at, orientation=clip_plane_orientation) - # Update the camera state if not needs_reset: fig.center_view() @@ -1694,6 +1885,15 @@ def radio_material_callable(self): def radio_material_callable(self, rm_callable): self._radio_material_callable = rm_callable + @property + def bypass_reload(self): + """Boolean parameter that defines if the scene.reload() method should be bypassed when called, to avoid any automatic reload.""" + return self._bypass_reload + + @bypass_reload.setter + def bypass_reload(self, b:bool): + self._bypass_reload = b + @property def scattering_pattern_callable(self): # pylint: disable=line-too-long @@ -1773,12 +1973,182 @@ def preview_widget(self): """ return self._preview_widget + def append_to_xml(self, element:ET.Element, overwrite=False): + # pylint: disable=line-too-long + """ + Append an XML Element of type or to the XML Tree of the scene (at root level). + + Input + ----- + element : :class:`ET.Element` + An XML element of type or to be appended to the scene's XML tree. + + overwrite : bool, optional + If True, overwrite an existing element with the same `id`. If `False`, raise a warning if an element + with the same `id` already exists. Default is `False`. + + Output + ------ + :class:`ET.Element` or `None` + Returns the existing element if it already exists. Otherwise, returns `None`. + + Raises + ------ + ValueError + If the provided `element` is not of type or . + + Notes + ----- + - This method modifies the scene's XML tree by appending the provided element at the root level. + - If an element with the same `id` already exists, a warning is raised and the existing element is returned. + - If `overwrite` is True, the existing element with the same `id` is removed before appending the new element. + - The modified XML tree is written back to the scene XML file. + """ + # Check if element contains a single BSDF or Shape element at root level: + element_type = element.tag + if element_type not in ['shape','bsdf']: + raise ValueError("`element` must be an instance of `ET.Element` of type or ") + element_id = element.get('id') + + root = self._xml_tree.getroot() + elements_in_root = root.findall(f'{element_type}') + ids_in_root = [elt.get('id') for elt in elements_in_root] + + if element_id not in ids_in_root: + root.append(element) + # Write the modified scene XML tree back to the XML file before reloading the file with mitsuba + ET.indent(self._xml_tree, space="\t", level=0) + self._xml_tree.write(os.path.join(self.tmp_directory_path, 'tmp_scene.xml')) + return None + else: + for e in elements_in_root: + if e.get('id') == element_id: + break + if overwrite: + warnings.warn(f"Element of type {element_type} with id: {element_id} is already present in xml file. Overwriting with new element.") + self.remove_from_xml(element_id,element_type) + root.append(element) + ET.indent(self._xml_tree, space="\t", level=0) + self._xml_tree.write(os.path.join(self.tmp_directory_path, 'tmp_scene.xml')) + return e + else: + warnings.warn(f"Element of type {element_type} with id: {element_id} is already present in xml file. Set 'overwrite=True' to overwrite.") + return e + + def remove_from_xml(self, element_id:str, element_type=None): + # pylint: disable=line-too-long + """ + Remove an XML Element of type 'bsdf' or 'shape' from the XML tree of the scene (at root level) based on its id. + + This method modifies the scene's XML tree by removing the specified element at the root level. If the element + with the specified `id` and `element_type` is not found, a warning is raised. + + Input + ----- + element_id : str + The id of the XML element to be removed. + + element_type : str ('bsdf'|'shape'), optional + The type of the XML element to be removed. Valid types are 'bsdf' and 'shape'. + If `None`, all types are searched. Default is `None`. + + Output + ------ + :class:`ET.Element` or `None` + Returns the removed element if it was found and removed. Otherwise, returns `None`. + + Raises + ------ + ValueError + If the provided `element_type` is not one of the valid types ('bsdf' or 'shape'). + + Notes + ----- + - This method modifies the scene's XML tree by removing the specified element at the root level. + - If the element with the specified `id` and `element_type` is not found, a warning is raised. + - The modified XML tree is written back to the scene XML file. + """ + + valid_element_types = ['bsdf','shape'] + root = self._xml_tree.getroot() + if element_type is not None: + if element_type not in valid_element_types: + raise ValueError(f"`element_type` must be string. Valid types are {str(valid_element_types)}.") + elements_in_root = root.findall(f'{element_type}') + else: + elements_in_root = [] + for t in valid_element_types: + elements_in_root += root.findall(f'{t}') + + for e in elements_in_root: + if e.get('id') == element_id: + root.remove(e) + # Write the modified scene XML tree back to the XML file before reloading the file with mitsuba + ET.indent(self._xml_tree, space="\t", level=0) + self._xml_tree.write(os.path.join(self.tmp_directory_path, 'tmp_scene.xml')) + return e + + if element_type is None: + warnings.warn(f"No {str(valid_element_types)} element with name {element_id} in root to remove.") + else: + warnings.warn(f"No {element_type} element with name {element_id} in root to remove.") + return None + + def update_shape_bsdf_xml(self, shape_name, bsdf_name): + # pylint: disable=line-too-long + """ + Update the XML file such that the shape with id 'shape_name' now references the BSDF with id 'bsdf_name'. + + This method modifies the scene's XML tree by updating the specified shape to reference the specified BSDF. + The modified XML tree is written back to the scene XML file. + + Input + ----- + shape_name : str + The name of the shape to be updated. If the name does not start with 'mesh-', it will be prefixed with 'mesh-'. + + bsdf_name : str + The name of the BSDF to be referenced by the shape. If the name does not start with 'mat-', it will be prefixed with 'mat-'. + + Output + ------ + bool + Returns `True` if the update was successful, `False` otherwise. + + Notes + ----- + - This method modifies the scene's XML tree by updating the specified shape to reference the specified BSDF. + - If the shape with the specified `shape_name` is not found, a warning is raised and the method returns `False`. + - The modified XML tree is written back to the scene XML file. + """ + if shape_name[:5] != 'mesh': + shape_name = f"mesh-{shape_name}" + + if bsdf_name[:3] != 'mat': + bsdf_name = f"mat-{bsdf_name}" + + root = self._xml_tree.getroot() + shapes_in_root = root.findall('shape') + for shape in shapes_in_root: + if shape.get('id') == shape_name or f"mesh-{shape.get('id')}" == shape_name: + ref = shape.find('ref') + ref.set('id',f"{bsdf_name}") + ref.set('name','bsdf') + + # Write the modified scene XML tree back to the XML file before reloading the file with mitsuba + ET.indent(self._xml_tree, space="\t", level=0) + self._xml_tree.write(os.path.join(self.tmp_directory_path, 'tmp_scene.xml')) + return True + + warnings.warn(f"No shape element with name {shape_name} in root to update.") + return False + def scene_geometry_updated(self): """ Callback to trigger when the scene geometry is updated """ # Update the scene geometry in the preview - if self._preview_widget: + if self._preview_widget: #and not self._bypass_scene_geometry_update: self._preview_widget.redraw_scene_geometry() def _clear(self): @@ -1792,6 +2162,7 @@ def _clear(self): self._ris.clear() self._ris.clear() self._cameras.clear() + self._asset_objects.clear() self._radio_materials.clear() self._scene_objects.clear() self._tx_array = None @@ -1867,39 +2238,70 @@ def _load_scene_objects(self): """ # Parse all shapes in the scene scene = self._scene - objects_id = dr.reinterpret_array_v(mi.UInt32, - scene.shapes_dr()).tf() + objects_id = dr.reinterpret_array_v(mi.UInt32,scene.shapes_dr()).tf() for obj_id,s in zip(objects_id,scene.shapes()): obj_id = int(obj_id.numpy()) # Only meshes are handled if not isinstance(s, mi.Mesh): raise TypeError('Only triangle meshes are supported') - # Setup the material - mat_name = s.bsdf().id() + # Setup the material based on the shape bsdf's name + bsdf = s.bsdf() + mat_name = bsdf.id() + if mat_name.startswith("mat-"): mat_name = mat_name[4:] mat = self.get(mat_name) + if (mat is not None) and (not isinstance(mat, RadioMaterial)): raise ValueError(f"Name'{name}' already used by another item") elif mat is None: - # If the radio material does not exist, then a placeholder is - # used. + # If the radio material does not exist, then a placeholder is created and used. mat = RadioMaterial(mat_name) mat.is_placeholder = True - self._radio_materials[mat_name] = mat + self.add(mat) - # Instantiate the scene objects + # Instantiate or update the scene objects name = s.id() if name.startswith('mesh-'): name = name[5:] - if self._is_name_used(name): + + if self._is_name_used(name) and not name in self._scene_objects: raise ValueError(f"Name'{name}' already used by another item") - obj = SceneObject(name, object_id=obj_id, mi_shape=s, dtype=self._dtype) - obj.scene = self - obj.radio_material = mat_name - self._scene_objects[name] = obj + if name not in self._scene_objects: + obj = SceneObject(name, object_id=obj_id, mi_shape=s, dtype=self._dtype) + obj.scene = self + obj.radio_material = mat_name + self._scene_objects[name] = obj + else: + obj = self._scene_objects[name] + obj.update_mi_shape(mi_shape=s,object_id=obj_id) + + + # Asset initialisation + for asset_name in self._asset_objects: + asset = self.get(asset_name) + if asset.init: + # Append the scene objects to the shapes of the asset + for obj_name in asset.shapes: + # shape name is the name of a newly created SceneObject + obj = self.get(obj_name) + obj.set_asset_object(asset.name) + asset.update_shape(key=obj_name, value=obj) + + # Check that all asset's shape share the same radio_material + asset.update_radio_material() + + # Apply the initial position and orientation transform of the asset + # When the asset is constructed, the position and orientation parameters are set by the user (or to default) but not applied to the + # corresponding scene shapes since the scene objects are not yet constructed. To apply these initial parameters, now that the scene objects + # have been instantiated, we call the position and orientation setter functions on the initial position and orientation values. + asset.position = asset.position + asset.orientation = asset.orientation + asset.velocity = asset.velocity + + asset.init = False def _is_name_used(self, name): """ @@ -1909,7 +2311,8 @@ def _is_name_used(self, name): used = ((name in self._transmitters) or (name in self._receivers) or (name in self._radio_materials) - or (name in self._scene_objects)) + or (name in self._scene_objects) + or (name in self._asset_objects)) return used diff --git a/sionna/rt/scene_object.py b/sionna/rt/scene_object.py index 75fb0808..baf40684 100644 --- a/sionna/rt/scene_object.py +++ b/sionna/rt/scene_object.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # +# """ Class representing objects in the scene """ @@ -9,6 +10,7 @@ from .object import Object from .radio_material import RadioMaterial +from .asset_object import AssetObject import drjit as dr import mitsuba as mi from .utils import mi_to_tf_tensor, angles_to_mitsuba_rotation, normalize,\ @@ -54,7 +56,10 @@ def __init__(self, self._mi_shape = mi_shape # Set velocity vector - self.velocity = tf.cast([0,0,0], dtype=self._rdtype) + self._velocity = tf.cast([0,0,0], dtype=self._rdtype) + + # Set center of rotation. This parameter is used for nested asset objects rotations. + self._center_of_rotation = tf.cast([0,0,0], dtype=self._rdtype) if self._dtype == tf.complex64: self._mi_point_t = mi.Point3f @@ -67,17 +72,38 @@ def __init__(self, self._mi_scalar_t = mi.Float64 self._mi_transform_t = mi.Transform4d + # Store if the SceneObject belongs to an AssetObject + self._asset_object = None + @property def object_id(self): r""" - int : Get/set the identifier of this object + int : Get the identifier of this object """ return self._object_id @object_id.setter def object_id(self, v): + if self._scene is not None: + self.radio_material.discard_object_using(self._object_id) + self.radio_material.add_object_using(v) + self._object_id = v + @property + def mi_shape(self): + r""" + :class:`mitsuba.Shape` : Get the Mitsuba shape of this object + """ + return self._mi_shape + + @property + def asset_object(self): + r""" + str (read-only): Get the parent :class:`~sionna.rt.AssetObject` name of this object if it exists + """ + return self._asset_object + @property def radio_material(self): r""" @@ -130,6 +156,13 @@ def radio_material(self, mat): # Add the RadioMaterial to the scene if not already done self.scene.add(self._radio_material) + # Update shape's bsdf in the xml file accordingly + self._scene.update_shape_bsdf_xml(shape_name=f"mesh-{self._name}", bsdf_name=self.radio_material.bsdf.name) + + # Update the asset radio material if the scene object belong to an asset: + if self._asset_object is not None: + self._scene.get(self.asset_object).update_radio_material() + @property def velocity(self): """ @@ -143,6 +176,23 @@ def velocity(self, v): raise ValueError("`velocity` must have shape [3]") self._velocity = tf.cast(v, self._rdtype) + # Update the asset velocity if the scene object belong to an asset: + if self._asset_object is not None: + self._scene.get(self.asset_object).update_velocity() + + @property + def center_of_rotation(self): + """ + [3], tf.float : Get/set the center of rotation of the object. By default, the center of rotation is (0,0,0) (i.e. the object turn around its AABB center). + """ + return self._center_of_rotation + + @center_of_rotation.setter + def center_of_rotation(self, c): + if not tf.shape(c)==3: + raise ValueError("`center_of_rotation` must have shape [3]") + self._center_of_rotation = tf.cast(c, self._rdtype) + @property def position(self): """ @@ -269,12 +319,18 @@ def orientation(self, new_orient): inv_cur_rotation = cur_rotation.inverse() # Build the transform. - # The object is first translated to the origin, then rotated, then + # The object is first translated to the origin (shifted by its distance to center of rotation), then rotated, then # translated back to its current position - transform = ( self._mi_transform_t.translate(self.position.numpy()) + + transform = ( self._mi_transform_t.translate(self.position.numpy() + self._center_of_rotation.numpy()) @ new_rotation @ inv_cur_rotation - @ self._mi_transform_t.translate(-self.position.numpy()) ) + @ self._mi_transform_t.translate(-self.position.numpy() - self._center_of_rotation.numpy())) + + # transform = ( self._mi_transform_t.translate(self.position.numpy() + (self._center_of_rotation.numpy() - self.position.numpy())) + # @ new_rotation + # @ inv_cur_rotation + # @ self._mi_transform_t.translate(-self.position.numpy() - (self._center_of_rotation.numpy() - self.position.numpy()))) ## Update Mitsuba vertices @@ -391,6 +447,7 @@ def orientation(self, new_orient): solver_paths.wedges_normals.scatter_nd_update(wedges_ind, wedges_normals) + # Update orientation property self._orientation = new_orient # Trigger scene callback @@ -411,11 +468,11 @@ def look_at(self, target): # Get position to look at if isinstance(target, str): obj = self.scene.get(target) - if not isinstance(obj, Object): + if not isinstance(obj, Object) and not isinstance(obj, AssetObject): raise ValueError(f"No camera, device, or object named '{target}' found.") else: target = obj.position - elif isinstance(target, Object): + elif isinstance(target, Object) or not isinstance(obj, AssetObject): target = target.position else: target = tf.cast(target, dtype=self._rdtype) @@ -430,3 +487,79 @@ def look_at(self, target): beta = theta-PI/2 # Rotation around y-axis gamma = 0.0 # Rotation around x-axis self.orientation = (alpha, beta, gamma) + + ############################################## + # Internal methods. + # Should not be appear in the user + # documentation + ############################################## + + def update_mi_shape(self, mi_shape, object_id): + """ + Update the Mitsuba shape and object ID of the SceneObject. + + This method updates the Mitsuba shape and object ID of the SceneObject while preserving its current position and orientation. It also updates the radio material object counter accordingly. + + Parameters + ---------- + mi_shape : mitsuba.Shape + The new Mitsuba shape to assign to the SceneObject. + object_id : int + The new object ID to assign to the SceneObject. + + Notes + ----- + - The position and orientation of the SceneObject are temporarily stored and then reapplied after updating the Mitsuba shape and object ID. + - The radio material usage is updated to reflect the new object ID. + """ + tmp_position = self.position + tmp_orientation = self.orientation + + # Reset the _orientation parameter to its default value without triggering shape update + self._orientation = tf.cast([0,0,0], dtype=self._rdtype) + + if self._radio_material is not None: + self.radio_material.discard_object_using(self._object_id) + self.radio_material.add_object_using(object_id) + + self._object_id = object_id + + self._mi_shape = mi_shape + + self.position = tmp_position + self.orientation = tmp_orientation + + def delete_from_scene(self): + """ + Delete the SceneObject from the scene. + + This method removes the SceneObject from the scene by deleting its shape from the XML representation and discarding it from the set of objects using its radio material. + + Notes + ----- + - The shape is removed from the scene's XML representation. + - The SceneObject is discarded from the set of objects using its radio material. + """ + # Remove shape from XML + self._scene.remove_from_xml(f"mesh-{self._name}","shape") + + # Discard the scene object from the objects using this material + self._radio_material.discard_object_using(self.object_id) + + def set_asset_object(self, a): + """ + Internal method to set the asset_object attribute. + + Parameters + ---------- + a : str + The new value for the asset_object attribute. + + Raises + ------ + TypeError + If `a` is not a string. + """ + if not isinstance(a, str): + raise TypeError("`asset_object` must be a string") + self._asset_object = a diff --git a/sionna/rt/scenes/box/box.xml b/sionna/rt/scenes/box/box.xml index 02404488..3e349739 100644 --- a/sionna/rt/scenes/box/box.xml +++ b/sionna/rt/scenes/box/box.xml @@ -1,7 +1,7 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> diff --git a/sionna/rt/scenes/double_reflector/double_reflector.xml b/sionna/rt/scenes/double_reflector/double_reflector.xml index 7fd6cfac..622f60e3 100644 --- a/sionna/rt/scenes/double_reflector/double_reflector.xml +++ b/sionna/rt/scenes/double_reflector/double_reflector.xml @@ -1,6 +1,6 @@ diff --git a/sionna/rt/scenes/etoile/etoile.xml b/sionna/rt/scenes/etoile/etoile.xml index 51ca67d5..32633612 100644 --- a/sionna/rt/scenes/etoile/etoile.xml +++ b/sionna/rt/scenes/etoile/etoile.xml @@ -1,7 +1,7 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> diff --git a/sionna/rt/scenes/floor_wall/floor_wall.xml b/sionna/rt/scenes/floor_wall/floor_wall.xml index 463b5bc2..d18c7b39 100644 --- a/sionna/rt/scenes/floor_wall/floor_wall.xml +++ b/sionna/rt/scenes/floor_wall/floor_wall.xml @@ -1,7 +1,7 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> diff --git a/sionna/rt/scenes/munich/munich.xml b/sionna/rt/scenes/munich/munich.xml index 03cc2836..3dd217cb 100644 --- a/sionna/rt/scenes/munich/munich.xml +++ b/sionna/rt/scenes/munich/munich.xml @@ -1,7 +1,7 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> diff --git a/sionna/rt/scenes/simple_reflector/simple_reflector.xml b/sionna/rt/scenes/simple_reflector/simple_reflector.xml index c0b4822e..8ce69995 100644 --- a/sionna/rt/scenes/simple_reflector/simple_reflector.xml +++ b/sionna/rt/scenes/simple_reflector/simple_reflector.xml @@ -1,7 +1,7 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> diff --git a/sionna/rt/scenes/simple_street_canyon/simple_street_canyon.xml b/sionna/rt/scenes/simple_street_canyon/simple_street_canyon.xml index a367b95e..fb8b678f 100644 --- a/sionna/rt/scenes/simple_street_canyon/simple_street_canyon.xml +++ b/sionna/rt/scenes/simple_street_canyon/simple_street_canyon.xml @@ -1,7 +1,8 @@ - + + diff --git a/sionna/rt/scenes/simple_street_canyon_with_cars/simple_street_canyon_with_cars.xml b/sionna/rt/scenes/simple_street_canyon_with_cars/simple_street_canyon_with_cars.xml index 23183d1e..a4696724 100644 --- a/sionna/rt/scenes/simple_street_canyon_with_cars/simple_street_canyon_with_cars.xml +++ b/sionna/rt/scenes/simple_street_canyon_with_cars/simple_street_canyon_with_cars.xml @@ -1,7 +1,8 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. +All rights reserved. SPDX-License-Identifier: Apache-2.0 +--> + diff --git a/sionna/rt/scenes/simple_wedge/simple_wedge.xml b/sionna/rt/scenes/simple_wedge/simple_wedge.xml index 21382e1e..c17e540b 100644 --- a/sionna/rt/scenes/simple_wedge/simple_wedge.xml +++ b/sionna/rt/scenes/simple_wedge/simple_wedge.xml @@ -1,7 +1,8 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> + diff --git a/sionna/rt/scenes/triple_reflector/triple_reflector.xml b/sionna/rt/scenes/triple_reflector/triple_reflector.xml index ed88f825..f03c59d5 100644 --- a/sionna/rt/scenes/triple_reflector/triple_reflector.xml +++ b/sionna/rt/scenes/triple_reflector/triple_reflector.xml @@ -1,7 +1,8 @@ +SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 +--> + diff --git a/sionna/signal/utils.py b/sionna/signal/utils.py index 92dfc8d1..70b3f920 100644 --- a/sionna/signal/utils.py +++ b/sionna/signal/utils.py @@ -8,6 +8,7 @@ import matplotlib.pyplot as plt import tensorflow as tf from sionna.utils.tensors import expand_to_rank +#from sionna.sionna.utils.tensors import expand_to_rank from tensorflow.experimental.numpy import swapaxes diff --git a/sionna/utils/__init__.py b/sionna/utils/__init__.py index 46d2b62a..a4b75e90 100644 --- a/sionna/utils/__init__.py +++ b/sionna/utils/__init__.py @@ -2,11 +2,12 @@ # SPDX-FileCopyrightText: Copyright (c) 2021-2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -"""Utilities sub-package of the Sionna library. - +""" +Utilities sub-package of the Sionna library. """ from .metrics import * from .misc import * from .tensors import * from .plotting import * + diff --git a/sionna/utils/misc.py b/sionna/utils/misc.py index c1ffc779..bf8484d2 100644 --- a/sionna/utils/misc.py +++ b/sionna/utils/misc.py @@ -6,12 +6,16 @@ import numpy as np import tensorflow as tf +import time +import os +import shutil + from tensorflow.keras.layers import Layer from tensorflow.experimental.numpy import log10 as _log10 from tensorflow.experimental.numpy import log2 as _log2 + from sionna.utils.metrics import count_errors, count_block_errors from sionna.mapping import Mapper, Constellation -import time from sionna import signal @@ -935,6 +939,57 @@ def complex_normal(shape, var=1.0, dtype=tf.complex64): return x +def copy_and_rename_files(source_dir, destination_dir, prefix): + # pylint: disable=line-too-long + r""" + Copy and rename files from a source directory to a destination directory. + + This function walks through the source directory, creates corresponding directories + in the destination directory, and copies files from the source to the destination + while renaming them with a specified prefix. + + Parameters + ---------- + source_dir : str + The path to the source directory from which files will be copied. + destination_dir : str + The path to the destination directory where files will be copied to. + prefix : str + The prefix to add to each filename in the destination directory. + + Notes + ----- + - The directory structure of the source directory is replicated in the destination directory. + - If the destination directory or any intermediate directories do not exist, they are created. + + Examples + -------- + .. code-block:: Python + copy_and_rename_files('path/to/source', 'path/to/destination', 'prefix_') + + This will copy all files from 'path/to/source' to 'path/to/destination' and rename them starting with 'prefix_'. + + Raises + ------ + OSError + If an error occurs during directory creation or file copying. + """ + # Walk through the source directory + for root, dirs, files in os.walk(source_dir): + # Create corresponding directories in the destination + relative_path = os.path.relpath(root, source_dir) + dest_path = os.path.join(destination_dir, relative_path) + os.makedirs(dest_path, exist_ok=True) + + # Copy and rename files + for file in files: + source_file = os.path.join(root, file) + # Create the new filename with the prefix + new_filename = prefix + file + dest_file = os.path.join(dest_path, new_filename) + shutil.copy2(source_file, dest_file) + # print(f"Copied {os.path.abspath(source_file)} to {os.path.abspath(dest_file)}") + ########################################################### # Deprecated aliases that will not be included in the next # major release @@ -956,3 +1011,4 @@ def empirical_psd(x, show=True, oversampling=1.0, ylim=(-30,3)): print( "Warning: The alias utils.empirical_psd will not be included in" " Sionna 1.0. Please use signal.empirical_psd instead.") return signal.empirical_psd(x, show, oversampling, ylim) + diff --git a/spec-file.txt b/spec-file.txt new file mode 100644 index 00000000..b5d435bb --- /dev/null +++ b/spec-file.txt @@ -0,0 +1,57 @@ +# This file may be used to create an environment using: +# $ conda create --name --file +# platform: osx-arm64 +@EXPLICIT +https://repo.anaconda.com/pkgs/main/osx-arm64/bzip2-1.0.8-h620ffc9_4.conda +https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2023.11.17-hf0a4a13_0.conda +https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-16.0.6-h4653b0c_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/libffi-3.4.4-hca03da5_0.conda +https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.18-h27ca646_1.tar.bz2 +https://repo.anaconda.com/pkgs/main/osx-arm64/ncurses-6.4-h313beb8_0.conda +https://repo.anaconda.com/pkgs/main/noarch/tzdata-2023d-h04d1e81_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/xz-5.4.5-h80987f9_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/zlib-1.2.13-h5a0b063_0.conda +https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.2.0-h0d3ecfb_1.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/readline-8.2-h1a28f6b_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/tk-8.6.12-hb8d0fd4_0.conda +https://conda.anaconda.org/conda-forge/osx-arm64/zeromq-4.3.5-h965bd2d_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/sqlite-3.41.2-h80987f9_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/python-3.11.7-hb885b13_0.conda +https://conda.anaconda.org/conda-forge/noarch/appnope-0.1.3-pyhd8ed1ab_0.tar.bz2 +https://repo.anaconda.com/pkgs/main/osx-arm64/debugpy-1.6.7-py311h313beb8_0.conda +https://conda.anaconda.org/conda-forge/noarch/decorator-5.1.1-pyhd8ed1ab_0.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/executing-2.0.1-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/nest-asyncio-1.5.8-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/packaging-23.2-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/parso-0.8.3-pyhd8ed1ab_0.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/pickleshare-0.7.5-py_1003.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.1.0-pyhd8ed1ab_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/psutil-5.9.0-py311h80987f9_0.conda +https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd3deb0d_0.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.2-pyhd8ed1ab_0.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/pygments-2.17.2-pyhd8ed1ab_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/pyzmq-25.1.0-py311h313beb8_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/setuptools-68.2.2-py311hca03da5_0.conda +https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2 +https://repo.anaconda.com/pkgs/main/osx-arm64/tornado-6.3.3-py311h80987f9_0.conda +https://conda.anaconda.org/conda-forge/noarch/traitlets-5.14.1-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.9.0-pyha770c72_0.conda +https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.2.13-pyhd8ed1ab_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/wheel-0.41.2-py311hca03da5_0.conda +https://conda.anaconda.org/conda-forge/noarch/zipp-3.17.0-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/asttokens-2.4.1-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/comm-0.2.1-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-7.0.1-pyha770c72_0.conda +https://conda.anaconda.org/conda-forge/noarch/jedi-0.19.1-pyhd8ed1ab_0.conda +https://repo.anaconda.com/pkgs/main/osx-arm64/jupyter_core-5.5.0-py311hca03da5_0.conda +https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.1.6-pyhd8ed1ab_0.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/pexpect-4.8.0-pyh1a96a4e_2.tar.bz2 +https://repo.anaconda.com/pkgs/main/osx-arm64/pip-23.3.1-py311hca03da5_0.conda +https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.42-pyha770c72_0.conda +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.8.2-pyhd8ed1ab_0.tar.bz2 +https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-7.0.1-hd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.2-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/ipython-8.20.0-pyh707e725_0.conda +https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.6.0-pyhd8ed1ab_0.conda +https://conda.anaconda.org/conda-forge/noarch/ipykernel-6.28.0-pyh3cd1d5f_0.conda diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/integration/__init__.py b/test/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/__init__.py b/test/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/channel/__init__.py b/test/unit/channel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/fec/__init__.py b/test/unit/fec/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/mapping/__init__.py b/test/unit/mapping/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/mimo/__init__.py b/test/unit/mimo/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/nr/__init__.py b/test/unit/nr/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/ofdm/__init__.py b/test/unit/ofdm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/rt/__init__.py b/test/unit/rt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/rt/asset_object/__init__.py b/test/unit/rt/asset_object/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/rt/asset_object/test_asset_add.py b/test/unit/rt/asset_object/test_asset_add.py new file mode 100644 index 00000000..1eeb0e64 --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_add.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import tensorflow as tf +import sys + +from sionna.rt import * + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetAdd(unittest.TestCase): + """Tests related to the AssetObject class""" + + # def setUp(self): + # import os + # import shutil + + # self.tmp_directory_path = 'tmp/' + # try: + # if os.path.exists(self.tmp_directory_path): + # # Remove the directory and all its contents + # shutil.rmtree(self.tmp_directory_path) + # # Recreate an empty dir + # os.makedirs(self.tmp_directory_path) + # except Exception as e: + # print(f"An error occurred: {e}") + + # def tearDown(self): + # import os + # import shutil + + # self.tmp_directory_path = 'tmp/' + # try: + # if os.path.exists(self.tmp_directory_path): + # # Remove the directory and all its contents + # shutil.rmtree(self.tmp_directory_path) + # # Recreate an empty dir + # os.makedirs(self.tmp_directory_path) + # except Exception as e: + # print(f"An error occurred: {e}") + + def test_add_asset(self): + """Adding asset to scene""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(isinstance(scene.get("asset_0"),AssetObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_0"),SceneObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_1"),SceneObject)) + + def test_add_asset_keep_scene_object_ref(self): + """Check that adding asset to scene does not impact other SceneObject""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + ref_obj = scene.get("floor") + scene.add(asset) + self.assertTrue(ref_obj == scene.get("floor")) + + def test_add_asset_list(self): + """Adding list of asset to scene""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset_0 = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1) + asset_list = [asset_0, asset_1] + scene.add(asset_list) + + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(isinstance(scene.get("asset_0"),AssetObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_0"),SceneObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_1"),SceneObject)) + + self.assertTrue("asset_1" in scene.asset_objects) + self.assertTrue(isinstance(scene.get("asset_1"),AssetObject)) + self.assertTrue(isinstance(scene.get("asset_1_cube_0"),SceneObject)) + self.assertTrue(isinstance(scene.get("asset_1_cube_1"),SceneObject)) + + def test_add_asset_adjust_to_scene_dtype(self): + """When adding an asset to a scene, the asset should adapt to the scene dtype""" + scene = load_scene(sionna.rt.scene.floor_wall, dtype=tf.complex64) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, dtype=tf.complex128) + scene.add(asset) + self.assertEqual(scene.dtype, asset.dtype) + self.assertEqual(scene.dtype.real_dtype, asset.position.dtype) + + def test_add_asset_overwrite(self): + """When adding an asset to a scene, the asset should overwrite any asset with the same name""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset_0 = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset_0) + + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(scene.get("asset_0") == asset_0) + + asset_0_bis = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset_0_bis) + + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(scene.get("asset_0") != asset_0) + + def test_asset_shape_dictionary(self): + """Instanciation of the asset's shapes dict is correct when adding asset""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + + self.assertFalse("asset_0" in scene.asset_objects) + self.assertTrue(asset.shapes == {}) + + scene.add(asset) + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(isinstance(scene.get("asset_0"),AssetObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_0"),SceneObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_1"),SceneObject)) + print("SceneObject: ",asset.shapes["asset_0_cube_0"]) + self.assertTrue(isinstance(asset.shapes["asset_0_cube_0"],SceneObject)) + self.assertTrue(isinstance(asset.shapes["asset_0_cube_1"],SceneObject)) \ No newline at end of file diff --git a/test/unit/rt/asset_object/test_asset_delete.py b/test/unit/rt/asset_object/test_asset_delete.py new file mode 100644 index 00000000..a45b96e0 --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_delete.py @@ -0,0 +1,122 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import xml.etree.ElementTree as ET +import sys + +from sionna.rt import * +from sionna.constants import SPEED_OF_LIGHT, PI + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetDelete(unittest.TestCase): + """Tests related to the deletion an asset object""" + + def test_remove_asset(self): + """Removing an asset from scene""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(isinstance(scene.get("asset_0"),AssetObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_0"),SceneObject)) + self.assertTrue(isinstance(scene.get("asset_0_cube_1"),SceneObject)) + + scene.remove("asset_0") + self.assertTrue("asset_0" not in scene.asset_objects) + self.assertTrue(scene.get("asset_0") == None) + self.assertTrue(scene.get("asset_0_cube_0") == None) + self.assertTrue(scene.get("asset_0_cube_1") == None) + + + def test_weak_references(self): + """Check that scene objects are weakly referenced outside the scene class""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + # What happens when removing the asset from the scene (N.B. the asset object is not removed but the link to the scene and the corresponding SceneObjects should be) + asset_shapes = asset.shapes + asset_object = asset_shapes["asset_0_cube_0"] + scene_object = scene.get("asset_0_cube_0") + self.assertTrue(scene_object is asset_object) + + scene.remove("asset_0") + + self.assertTrue(scene.get("asset_0") == None) + self.assertTrue(scene.get("asset_0_cube_0") == None) + self.assertTrue(asset_shapes == {}) + + with self.assertRaises(ReferenceError) as context: + print(asset_object) + self.assertEqual(str(context.exception), "weakly-referenced object no longer exists") + + def test_asset_scene_objets_are_scene_scene_object(self): + """Check that AssetObjects reference the same SceneObjects as Scene""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + # What happens when removing the asset from the scene (N.B. the asset object is not removed but the link to the scene and the corresponding SceneObjects should be) + asset_shapes = asset.shapes + asset_object = asset_shapes["asset_0_cube_0"] + scene_object = scene.get("asset_0_cube_0") + self.assertTrue(asset_object is scene_object) + + def test_shape_name_is_consistent_with_scene_object_name(self): + """Check that the name of shape in the xml is consistent with scene object names, i.e. shape name is f'mesh-{scene_object.name}'""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1) + asset_2 = AssetObject(name="mesh-asset_2", filename=sionna.rt.asset_object.test_asset_2) + scene.add([asset_1,asset_2]) + + root = scene._xml_tree.getroot() + shapes_in_root = root.findall('shape') + shapes_in_root = [shape.get('id') for shape in shapes_in_root] + + for obj_name in scene.objects: + self.assertTrue(f"mesh-{obj_name}" in shapes_in_root) + + def test_remove_scene_object(self): + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + ref_obj = scene.get('wall') + ref_obj_mi_shape = ref_obj.mi_shape + + # Remove object >>> Object does not exist anymore + self.assertTrue(isinstance(scene.get('floor'),SceneObject)) + scene.remove("floor") + self.assertEqual(scene.get('floor'),None) + + # Scene is reloaded + self.assertTrue(ref_obj is scene.get('wall')) + self.assertFalse(ref_obj_mi_shape is scene.get('wall').mi_shape) + + # Can't remove an object from an asset + with self.assertRaises(ValueError) as context: + scene.remove("asset_0_cube_0") + self.assertEqual(str(context.exception), "Can't remove a SceneObject part of an AssetObject. Try removing the complete AssetObject instead.") + + diff --git a/test/unit/rt/asset_object/test_asset_material.py b/test/unit/rt/asset_object/test_asset_material.py new file mode 100644 index 00000000..668bb136 --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_material.py @@ -0,0 +1,593 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import tensorflow as tf +import sys + +from sionna.rt import * +from sionna.constants import SPEED_OF_LIGHT, PI + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetMaterial(unittest.TestCase): + """Tests related to the asset's materials definition before adding the asset to a scene""" + + def test_xml_asset_material(self): + """Test showing that specifying no asset material before adding the asset works when the asset is added to scene. + When no asset material is specified, the material from the asset xml file are used. If this materials have the same + name as existing scene material, then the scene material are used. Yet, if the scene material have placeholder bsdfs, + the later are replace by the asset's xml bsdf description.""" + + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) # since no material are specified, the material from the asset xml wiil be used: test asset is made of itu_metal and itu_wood + + # The materials described in the asset xml file are 'itu_wood'and 'itu_metal' which are existing scene material, although with placeholder bsdfs + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + prev_glass_bsdf = prev_glass.bsdf + prev_wood_bsdf = prev_wood.bsdf + prev_metal_bsdf = prev_metal.bsdf + prev_glass_bsdf_xml = prev_glass.bsdf.xml_element + prev_wood_bsdf_xml = prev_wood.bsdf.xml_element + prev_metal_bsdf_xml = prev_metal.bsdf.xml_element + + # Before adding asset none of these materials are used and the bsdf are all placeholders + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(prev_glass_bsdf.is_placeholder) + self.assertTrue(prev_wood_bsdf.is_placeholder) + self.assertTrue(prev_metal_bsdf.is_placeholder) + + # Add the asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + # After adding the asset, the asset radio material is a dictionary where each key is a material name, and the value are the list of scene object using these material within the asset + # When adding asset to scene, the material are specified on a per shape basis (using the asset xml) since no material have been specified. + #self.assertTrue(asset.radio_material == {prev_wood.name: [scene.get("asset_0_cube_0")],prev_metal.name:[scene.get("asset_0_cube_1")]}) + self.assertTrue(asset.radio_material == None) + self.assertTrue(scene.get("asset_0").radio_material == asset.radio_material) + + # After adding the asset, the material described in the asset XML (itu_wood and itu_metal) are now used and their bsdf are not placeholders anymore + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + self.assertFalse(new_glass.is_used) + self.assertTrue(new_wood.is_used) + self.assertTrue(new_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(new_glass_bsdf.is_placeholder) + self.assertFalse(new_wood_bsdf.is_placeholder) + self.assertFalse(new_metal_bsdf.is_placeholder) + self.assertTrue(asset.shapes['asset_0_cube_0'].object_id in scene.get("itu_wood").using_objects) + self.assertTrue(asset.shapes['asset_0_cube_1'].object_id in scene.get("itu_metal").using_objects) + + # After adding asset, the material object instance and bsdf should not change + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + new_glass_bsdf_xml = new_glass_bsdf.xml_element + new_wood_bsdf_xml = new_wood_bsdf.xml_element + new_metal_bsdf_xml = new_metal_bsdf.xml_element + + self.assertTrue(new_glass is prev_glass) + self.assertTrue(new_wood is prev_wood) + self.assertTrue(new_metal is prev_metal) + self.assertTrue(new_glass_bsdf is prev_glass_bsdf) + self.assertTrue(new_wood_bsdf is prev_wood_bsdf) + self.assertTrue(new_metal_bsdf is prev_metal_bsdf) + + # Although the bsdf and material object are the same, the bsdf xml_element shoud have been updated + self.assertTrue(prev_glass_bsdf_xml == new_glass_bsdf_xml) + self.assertFalse(prev_wood_bsdf_xml == new_wood_bsdf_xml) + self.assertFalse(prev_metal_bsdf_xml == new_metal_bsdf_xml) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + def test_xml_asset_material_unknown(self): + """Check that specifying asset material as None works even when the asset_xml file refering to unknown scene materials, + thus leading to the creation of placeholder materials (and bsdf)""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_2) # radio material = None >>> use material name from the XML file + + self.assertTrue(asset.radio_material == None) + self.assertTrue(scene.get("custom_rm_1") == None) + self.assertTrue(scene.get("custom_rm_2") == None) + + # Add the asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + # After adding the asset, the asset radio material are placeholder RadioMaterials + scene_custom_rm_1 = scene.radio_materials["custom_rm_1"] + scene_custom_rm_2 = scene.radio_materials["custom_rm_2"] + self.assertTrue(asset.radio_material == None) + self.assertTrue(asset.shapes['asset_0_cube_1'].radio_material == scene_custom_rm_1) + self.assertTrue(asset.shapes['asset_0_cube_0'].radio_material == scene_custom_rm_2) + self.assertTrue(scene_custom_rm_1.is_placeholder) + self.assertFalse(scene_custom_rm_1.bsdf.is_placeholder) + self.assertTrue(scene_custom_rm_1.is_used) + self.assertTrue(scene_custom_rm_2.is_placeholder) + self.assertFalse(scene_custom_rm_2.bsdf.is_placeholder) + self.assertTrue(scene_custom_rm_2.is_used) + + # After adding the asset, the asset radio material is a dictionary where each key is a material name, and the value are the list of scene object using these material within the asset + # self.assertTrue(asset.radio_material == {scene_custom_rm_2.name: [scene.get("asset_0_cube_0")],scene_custom_rm_1.name:[scene.get("asset_0_cube_1")]}) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + def test_xml_asset_material_unknown_existing_item(self): + """Check that specifying asset material as None does not work if the bsdf name in the asset_xml are the same as existing item (not radio materials) + within the scen, thus preventing the creation of a placeholder material (and bsdf)""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_4) # radio material = None >>> use material name from the XML file "floor" + + self.assertTrue(asset.radio_material == None) + self.assertTrue(scene.get("floor") != None) + + # Add the asset + with self.assertRaises(ValueError) as context: + scene.add(asset) + self.assertEqual(str(context.exception), "Name 'floor' is already used by another item of the scene") + + + def test_str_asset_material(self): + """Test showing that specifying asset material as a `str` before adding the asset work when the asset is added to scene. Here the material name point to an existing scene material""" + + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") # create a metal asset + + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + prev_glass_bsdf = prev_glass.bsdf + prev_wood_bsdf = prev_wood.bsdf + prev_metal_bsdf = prev_metal.bsdf + prev_glass_bsdf_xml = prev_glass.bsdf.xml_element + prev_wood_bsdf_xml = prev_wood.bsdf.xml_element + prev_metal_bsdf_xml = prev_metal.bsdf.xml_element + + # Before adding asset none of these materials are used and the bsdf are all placeholders + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(prev_glass_bsdf.is_placeholder) + self.assertTrue(prev_wood_bsdf.is_placeholder) + self.assertTrue(prev_metal_bsdf.is_placeholder) + + # Before adding the asset, the asset radio material is the "itu_metal" str. + self.assertTrue(asset.radio_material == "itu_metal") + + # Add the asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + # After adding the asset, the asset radio material is the "itu_metal" RadioMaterial from scene + metal_radio_material = scene.radio_materials["itu_metal"] + self.assertTrue(asset.radio_material == metal_radio_material) + self.assertTrue(scene.get("asset_0").radio_material == metal_radio_material) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == metal_radio_material) + self.assertTrue(shape.object_id in scene.get("itu_metal").using_objects) + + # After adding the asset, the material "itu_metal" is now used but the bsdf are still placeholders (since we use the material from the scene not from the asset xml) + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + self.assertFalse(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertTrue(new_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(new_glass_bsdf.is_placeholder) + self.assertTrue(new_wood_bsdf.is_placeholder) + self.assertTrue(new_metal_bsdf.is_placeholder) + + + # After adding asset, the material object instance and bsdf should not change + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + new_glass_bsdf_xml = new_glass_bsdf.xml_element + new_wood_bsdf_xml = new_wood_bsdf.xml_element + new_metal_bsdf_xml = new_metal_bsdf.xml_element + + self.assertTrue(new_glass is prev_glass) + self.assertTrue(new_wood is prev_wood) + self.assertTrue(new_metal is prev_metal) + self.assertTrue(new_glass_bsdf is prev_glass_bsdf) + self.assertTrue(new_wood_bsdf is prev_wood_bsdf) + self.assertTrue(new_metal_bsdf is prev_metal_bsdf) + + # Since the used material for the asset existed in the scene the bsdf xml_element shoudn't have been updated + self.assertTrue(prev_glass_bsdf_xml == new_glass_bsdf_xml) + self.assertTrue(prev_wood_bsdf_xml == new_wood_bsdf_xml) + self.assertTrue(prev_metal_bsdf_xml == new_metal_bsdf_xml) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + def test_str_asset_material_unknown(self): + """Check that specifying asset material as a `str` refering to an unknown scene material, leads to the creation of a placeholder material (and bsdf)""" + + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="custom_rm") + + self.assertTrue(asset.radio_material == "custom_rm") + self.assertTrue(scene.get("custom_rm") == None) + + # Add the asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + # After adding the asset, the asset radio material is the "custom_rm" placeholder RadioMaterial + scene_custom_rm = scene.radio_materials["custom_rm"] + self.assertTrue(asset.radio_material == scene_custom_rm) + self.assertTrue(scene.get("asset_0").radio_material == scene_custom_rm) + self.assertTrue(scene_custom_rm.is_placeholder) + self.assertTrue(scene_custom_rm.bsdf.is_placeholder) + self.assertTrue(scene_custom_rm.is_used) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + + def test_radio_material_asset_material(self): + """Test showing that specifying asset material as a RadioMaterial before adding the asset work when the asset is added to scene""" + scene = load_scene(sionna.rt.scene.floor_wall) + custom_rm = RadioMaterial("custom_rm", bsdf=BSDF("custom_bsdf")) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material=custom_rm) # create a custom_rm asset + + prev_custom_rm = scene.get("custom_rm") + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + prev_glass_bsdf = prev_glass.bsdf + prev_wood_bsdf = prev_wood.bsdf + prev_metal_bsdf = prev_metal.bsdf + prev_glass_bsdf_xml = prev_glass.bsdf.xml_element + prev_wood_bsdf_xml = prev_wood.bsdf.xml_element + prev_metal_bsdf_xml = prev_metal.bsdf.xml_element + + # Before adding asset none of these materials are used and the bsdf are all placeholders + # The custom rm is not in scene radio_materials + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(prev_glass_bsdf.is_placeholder) + self.assertTrue(prev_wood_bsdf.is_placeholder) + self.assertTrue(prev_metal_bsdf.is_placeholder) + self.assertTrue(prev_custom_rm == None) + + # Before adding the asset, the asset radio material is the custom_rm RadioMateral. + self.assertTrue(asset.radio_material == custom_rm) + + # Add the asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + # After adding the asset, the asset radio material is the new custom_rm + scene_custom_rm = scene.get("custom_rm") + self.assertTrue(custom_rm == scene_custom_rm) + self.assertTrue(asset.radio_material == scene_custom_rm) + self.assertTrue(scene.get("asset_0").radio_material == scene_custom_rm) + + # all shapes are using this material + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == scene_custom_rm) + self.assertTrue(shape.object_id in scene_custom_rm.using_objects) + + # After adding the asset, the material are still not used + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + self.assertFalse(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertFalse(new_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(new_glass_bsdf.is_placeholder) + self.assertTrue(new_wood_bsdf.is_placeholder) + self.assertTrue(new_metal_bsdf.is_placeholder) + + # Check that the added rm is not placeholder, nor its bsdf + self.assertTrue(scene_custom_rm.is_used) + self.assertFalse(scene_custom_rm.is_placeholder) + self.assertFalse(scene_custom_rm.bsdf.is_placeholder) + + # After adding asset, the material object instance and bsdf should not change + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + new_glass_bsdf_xml = new_glass_bsdf.xml_element + new_wood_bsdf_xml = new_wood_bsdf.xml_element + new_metal_bsdf_xml = new_metal_bsdf.xml_element + + self.assertTrue(new_glass is prev_glass) + self.assertTrue(new_wood is prev_wood) + self.assertTrue(new_metal is prev_metal) + self.assertTrue(new_glass_bsdf is prev_glass_bsdf) + self.assertTrue(new_wood_bsdf is prev_wood_bsdf) + self.assertTrue(new_metal_bsdf is prev_metal_bsdf) + + # Since the the itu material are not used by the asset, the bsdf xml_element shoudn't have been updated + self.assertTrue(prev_glass_bsdf_xml == new_glass_bsdf_xml) + self.assertTrue(prev_wood_bsdf_xml == new_wood_bsdf_xml) + self.assertTrue(prev_metal_bsdf_xml == new_metal_bsdf_xml) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + def test_radio_material_from_scene_asset_material(self): + """Test showing that specifying asset material as a RadioMaterial from the scene before adding the asset work when the asset is added to scene""" + scene = load_scene(sionna.rt.scene.floor_wall) + itu_wood = scene.get("itu_wood") + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material=itu_wood) # create a itu_wood asset, where itu_wood is a RadioMaterial extracted from scene + + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + prev_glass_bsdf = prev_glass.bsdf + prev_wood_bsdf = prev_wood.bsdf + prev_metal_bsdf = prev_metal.bsdf + prev_glass_bsdf_xml = prev_glass.bsdf.xml_element + prev_wood_bsdf_xml = prev_wood.bsdf.xml_element + prev_metal_bsdf_xml = prev_metal.bsdf.xml_element + + # Before adding asset none of these materials are used and the bsdf are all placeholders + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(prev_glass_bsdf.is_placeholder) + self.assertTrue(prev_wood_bsdf.is_placeholder) + self.assertTrue(prev_metal_bsdf.is_placeholder) + + # Before adding the asset, the asset radio material is the itu_wood RadioMaterial. + self.assertTrue(asset.radio_material == itu_wood) + self.assertTrue(asset.radio_material == scene.get("itu_wood")) + + # Add the asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + # After adding the asset, the asset radio material is the itu_wood RadioMaterial. + self.assertTrue(itu_wood == scene.get("itu_wood")) + self.assertTrue(asset.radio_material == itu_wood) + self.assertTrue(scene.get("asset_0").radio_material == itu_wood) + + # all shapes are using this material + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == itu_wood) + self.assertTrue(shape.object_id in itu_wood.using_objects) + + # After adding the asset, the material are still not used except for the itu_wood + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal = scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + self.assertFalse(new_glass.is_used) + self.assertTrue(new_wood.is_used) + self.assertFalse(new_metal.is_used) + self.assertFalse(prev_glass.is_placeholder) + self.assertFalse(prev_wood.is_placeholder) + self.assertFalse(prev_metal.is_placeholder) + self.assertTrue(new_glass_bsdf.is_placeholder) + self.assertTrue(new_wood_bsdf.is_placeholder) + self.assertTrue(new_metal_bsdf.is_placeholder) + + # After adding asset, the material object instance and bsdf should not change + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + new_glass_bsdf = new_glass.bsdf + new_wood_bsdf = new_wood.bsdf + new_metal_bsdf = new_metal.bsdf + new_glass_bsdf_xml = new_glass_bsdf.xml_element + new_wood_bsdf_xml = new_wood_bsdf.xml_element + new_metal_bsdf_xml = new_metal_bsdf.xml_element + + self.assertTrue(new_glass is prev_glass) + self.assertTrue(new_wood is prev_wood) + self.assertTrue(new_metal is prev_metal) + self.assertTrue(new_glass_bsdf is prev_glass_bsdf) + self.assertTrue(new_wood_bsdf is prev_wood_bsdf) + self.assertTrue(new_metal_bsdf is prev_metal_bsdf) + + # Since the asset uses the scene material as is, the bsdf xml_element shoudn't have been updated + self.assertTrue(prev_glass_bsdf_xml == new_glass_bsdf_xml) + self.assertTrue(prev_wood_bsdf_xml == new_wood_bsdf_xml) + self.assertTrue(prev_metal_bsdf_xml == new_metal_bsdf_xml) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + def test_already_used_name_asset_material(self): + """Test showing that specifying asset material with a name already in use(item or other RadioMaterial) already present in the scene doesn't work when the asset is added to scene""" + + # create a floor material asset, where floor is str already used in the scene by another SceneObject + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="floor") + + with self.assertRaises(ValueError) as context: + scene.add(asset) + self.assertEqual(str(context.exception), "Name 'floor' is already used by another item of the scene") + + # create a itu_wood material asset, where itu_wood is a new RadioMaterial whose name is already used in the scene by another RadioMaterial + scene = load_scene(sionna.rt.scene.floor_wall) + custom_rm = RadioMaterial("itu_wood") + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material=custom_rm) + + with self.assertRaises(ValueError) as context: + scene.add(asset) + self.assertEqual(str(context.exception), "Name 'itu_wood' is already used by another item of the scene") + + # create a floor material asset, where floor is a new RadioMaterial whose name is already used in the scene by another SceneObject + scene = load_scene(sionna.rt.scene.floor_wall) + custom_rm = RadioMaterial("floor") + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material=custom_rm) + + with self.assertRaises(ValueError) as context: + scene.add(asset) + self.assertEqual(str(context.exception), "Name 'floor' is already used by another item of the scene") + + + def test_wrong_type_asset_material(self): + """Test showing that specifying asset material using an invalid type raises an error""" + scene = load_scene(sionna.rt.scene.floor_wall) + + with self.assertRaises(TypeError) as context: + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material=5) # create an asset using an int as a radio_material + self.assertEqual(str(context.exception), "`radio_material` must be `str` or `RadioMaterial` (or None)") + + + def test_asset_material_via_ray(self): + """Check that adding asset material with different material cause difference in the ray propagation""" + + scene = load_scene() # Load empty scene + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + + ## The test cube(s) are two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis + cube_edge_length = 1 + cubes_separation = 1 + asset = AssetObject(name="reflector", filename=sionna.rt.asset_object.test_asset_1, position=[0,-cubes_separation,-cube_edge_length/2]) + scene.add(asset) + + d0 = 100 + scene.add(Transmitter("tx", position=[0,+1,d0])) + scene.add(Receiver("rx", position=[0,-1,d0])) + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + + # Since the material is not vacuum, the path has energy + self.assertTrue(tf.reduce_sum(tf.squeeze(cir[0])) != 0) + + # Change asset radio material to vacuum + scene = load_scene() # Load empty scene + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + + ## The test cube(s) are two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis + cube_edge_length = 1 + cubes_separation = 1 + asset = AssetObject(name="reflector", filename=sionna.rt.asset_object.test_asset_1, position=[0,-cubes_separation,-cube_edge_length/2], radio_material='vacuum') + scene.add(asset) + + d0 = 100 + scene.add(Transmitter("tx", position=[0,+1,d0])) + scene.add(Receiver("rx", position=[0,-1,d0])) + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + + # Since the material is now vacuum, the path has no energy + self.assertTrue(tf.reduce_sum(tf.squeeze(cir[0])) == 0) + + def test_radio_material_without_scene(self): + """Check that material is properly set even if the scene is not set yet""" + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset.radio_material = "itu_glass" + self.assertEqual(asset.radio_material,"itu_glass") + + scene = load_scene() + scene.add(asset) + scene_asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + itu_glass = scene.get("itu_glass") + self.assertEqual(asset.radio_material,itu_glass) + self.assertEqual(scene_asset.radio_material,itu_glass) + self.assertEqual(cube_0_object.radio_material,itu_glass) + self.assertEqual(cube_1_object.radio_material,itu_glass) + + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + rm = RadioMaterial("custom_rm") + asset.radio_material = rm + self.assertEqual(asset.radio_material,rm) + + scene = load_scene() + scene.add(asset) + scene_asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertEqual(asset.radio_material,rm) + self.assertEqual(scene_asset.radio_material,rm) + self.assertEqual(cube_0_object.radio_material,rm) + self.assertEqual(cube_1_object.radio_material,rm) \ No newline at end of file diff --git a/test/unit/rt/asset_object/test_asset_material_update.py b/test/unit/rt/asset_object/test_asset_material_update.py new file mode 100644 index 00000000..f8041ffe --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_material_update.py @@ -0,0 +1,354 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import sys + +from sionna.rt import * +from sionna.constants import SPEED_OF_LIGHT + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetMaterialUpdate(unittest.TestCase): + """Tests related to the update of asset's materials (i.e. after adding the asset to a scene)""" + def test_str_asset_material_update(self): + """Test showing that changing asset material as a `str` after adding the asset works. Here the material name point to an existing scene material""" + + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") # create a metal asset + + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + + # Before adding asset none of these materials are used and the bsdf are all placeholders + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + + # Before adding the asset, the asset radio material is the "itu_metal" str. + self.assertTrue(asset.radio_material == "itu_metal") + + # Add the asset + scene.add(asset) + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + + # After adding the asset, the asset radio material is the "itu_metal" RadioMaterial from scene + metal_radio_material = scene.radio_materials["itu_metal"] + self.assertTrue(asset.radio_material == metal_radio_material) + self.assertTrue(scene.get("asset_0").radio_material == metal_radio_material) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == metal_radio_material) + self.assertTrue(shape.object_id in scene.get("itu_metal").using_objects) + + # After adding the asset, the material "itu_metal" is now used but the bsdf are still placeholders (since we use the material from the scene not from the asset xml) + self.assertFalse(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertTrue(new_metal.is_used) + + # Now we change the asset material using a string pointing to an existing material + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + asset.radio_material = "itu_glass" + + # After changing the asset material, the asset radio material is the "itu_glass" RadioMaterial from scene + glass_radio_material = scene.radio_materials["itu_glass"] + self.assertTrue(asset.radio_material == glass_radio_material) + self.assertTrue(scene.get("asset_0").radio_material == glass_radio_material) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == glass_radio_material) + self.assertTrue(shape.object_id in scene.get("itu_glass").using_objects) + + # After changing the asset material, the material "itu_metal" is not used anymore and "itu _glass" is now used + self.assertTrue(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertFalse(new_metal.is_used) + + # The material change should not trigger an auto reload of the scene. To update the view (i.e. bsdf related), the user must trigger scene.reload() + # which might break differentiability... + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape == scene.get("floor").mi_shape) + + + def test_str_asset_material_unknown_update(self): + """Check that changing asset material (after adding asset to scene) as a `str` refering to an unknown scene material, leads to the creation of a placeholder material (and bsdf)""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") # create a metal asset + + # Add the asset + scene.add(asset) + + # Now we change the asset material using a string pointing to an unknown material >>> Error + with self.assertRaises(ValueError) as context: + asset.radio_material = "custom_rm" + self.assertEqual(str(context.exception), "Unknown radio material 'custom_rm'") + + + + def test_radio_material_asset_material_update(self): + """Test showing that changing asset material using a RadioMaterial object after adding the asset works""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") # create a metal asset + + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + + # Before adding asset none of these materials are used and the bsdf are all placeholders + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + + # Before adding the asset, the asset radio material is the "itu_metal" str. + self.assertTrue(asset.radio_material == "itu_metal") + + # Add the asset + scene.add(asset) + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + + # After adding the asset, the asset radio material is the "itu_metal" RadioMaterial from scene + metal_radio_material = scene.radio_materials["itu_metal"] + self.assertTrue(asset.radio_material == metal_radio_material) + self.assertTrue(scene.get("asset_0").radio_material == metal_radio_material) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == metal_radio_material) + self.assertTrue(shape.object_id in scene.get("itu_metal").using_objects) + + # After adding the asset, the material "itu_metal" is now used but the bsdf are still placeholders (since we use the material from the scene not from the asset xml) + self.assertFalse(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertTrue(new_metal.is_used) + + # Now we change the asset material using a new RadioMaterial object + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + asset.radio_material = RadioMaterial(name="custom_rm") + + # After changing the asset material, the asset radio material is the "itu_glass" RadioMaterial from scene + custom_rm = scene.radio_materials["custom_rm"] + self.assertTrue(asset.radio_material == custom_rm) + self.assertTrue(scene.get("asset_0").radio_material == custom_rm) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.object_id in scene.get("custom_rm").using_objects) + self.assertTrue(shape.radio_material == custom_rm) + + # After changing the asset material, the material "itu_metal" is not used anymore and "itu _glass" is now used + self.assertFalse(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertFalse(new_metal.is_used) + self.assertTrue(custom_rm.is_used) + + # The material change should not trigger an auto reload of the scene (default behaviour). To update the view (i.e. bsdf related), the user must trigger scene.reload() + # which might break differentiability... + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape == scene.get("floor").mi_shape) + + + def test_radio_material_from_scene_asset_material_update(self): + """Test showing that changing asset material using a RadioMaterial object from the scene after adding the asset works""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") # create a metal asset + + prev_glass = scene.get("itu_glass") + prev_wood = scene.get("itu_wood") + prev_metal =scene.get("itu_metal") + + # Before adding asset none of these materials are used and the bsdf are all placeholders + self.assertFalse(prev_glass.is_used) + self.assertFalse(prev_wood.is_used) + self.assertFalse(prev_metal.is_used) + + # Before adding the asset, the asset radio material is the "itu_metal" str. + self.assertTrue(asset.radio_material == "itu_metal") + + # Add the asset + scene.add(asset) + new_glass = scene.get("itu_glass") + new_wood = scene.get("itu_wood") + new_metal =scene.get("itu_metal") + + # After adding the asset, the asset radio material is the "itu_metal" RadioMaterial from scene + metal_radio_material = scene.radio_materials["itu_metal"] + self.assertTrue(asset.radio_material == metal_radio_material) + self.assertTrue(scene.get("asset_0").radio_material == metal_radio_material) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == metal_radio_material) + self.assertTrue(shape.object_id in scene.get("itu_metal").using_objects) + + # After adding the asset, the material "itu_metal" is now used but the bsdf are still placeholders (since we use the material from the scene not from the asset xml) + self.assertFalse(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertTrue(new_metal.is_used) + + # Now we change the asset material using a RadioMaterial object from the scene + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + asset.radio_material = scene.get("itu_glass") + + # After changing the asset material, the asset radio material is the "itu_glass" RadioMaterial from scene + glass_radio_material = scene.radio_materials["itu_glass"] + self.assertTrue(asset.radio_material == glass_radio_material) + self.assertTrue(scene.get("asset_0").radio_material == glass_radio_material) + + # all shapes are metal + asset_shapes = [asset.shapes[shape_name] for shape_name in asset.shapes] + for shape in asset_shapes: + self.assertTrue(shape.radio_material == glass_radio_material) + self.assertTrue(shape.object_id in scene.get("itu_glass").using_objects) + + # After changing the asset material, the material "itu_metal" is not used anymore and "itu _glass" is now used + self.assertTrue(new_glass.is_used) + self.assertFalse(new_wood.is_used) + self.assertFalse(new_metal.is_used) + + # The material change should not trigger an auto reload of the scene. To update the view (i.e. bsdf related), the user must trigger scene.reload() + # which might break differentiability... + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape == scene.get("floor").mi_shape) + + + def test_already_used_name_asset_material_update(self): + """Test showing that chaning asset material (after adding asset to scene) with a name already in use(item or other RadioMaterial) + already present in the scene doesn't work.""" + + # create a floor material asset, where floor is str already used in the scene by another SceneObject + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + with self.assertRaises(ValueError) as context: + asset.radio_material = 'floor' + self.assertEqual(str(context.exception), "Unknown radio material 'floor'") + + # create a itu_wood material asset, where itu_wood is a new RadioMaterial whose name is already used in the scene by another RadioMaterial + scene = load_scene(sionna.rt.scene.floor_wall) + custom_rm = RadioMaterial("itu_wood") + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + with self.assertRaises(ValueError) as context: + asset.radio_material = custom_rm + self.assertEqual(str(context.exception), "Name 'itu_wood' is already used by another item of the scene") + + # create a floor material asset, where floor is a new RadioMaterial whose name is already used in the scene by another SceneObject + scene = load_scene(sionna.rt.scene.floor_wall) + custom_rm = RadioMaterial("floor") + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + with self.assertRaises(ValueError) as context: + asset.radio_material = custom_rm + self.assertEqual(str(context.exception), "Name 'floor' is already used by another item of the scene") + + + def test_wrong_type_asset_material_update(self): + """Test showing that changing asset material (after adding asset to scene) using an invalid type raises an error""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material='itu_wood') + scene.add(asset) + with self.assertRaises(TypeError) as context: + asset.radio_material=5 # replace the asset material using an int as a radio_material + self.assertEqual(str(context.exception), "Radio material must be of type 'str' or 'sionna.rt.RadioMaterial") + + def test_none_asset_material_update(self): + """Test showing that changing asset material to None (after adding asset to scene) does not work""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material='itu_wood') + scene.add(asset) + with self.assertRaises(TypeError) as context: + asset.radio_material=None # replace the asset material to None + self.assertEqual(str(context.exception), "Radio material must be of type 'str' or 'sionna.rt.RadioMaterial") + + def test_asset_material_update_via_ray(self): + """Check that an asset material update cause difference in the ray propagation""" + + scene = load_scene() # Load empty scene + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + + ## The test cube(s) are two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis + cube_edge_length = 1 + cubes_separation = 1 + asset = AssetObject(name="reflector", filename=sionna.rt.asset_object.test_asset_1, position=[0,-cubes_separation,-cube_edge_length/2]) # we shift the asset so that the face of the metal cube is aligned with the xy-plane and center in (0,0) + scene.add(asset) + + d0 = 100 + d1 = np.sqrt(d0*d0 + 1) #Hypothenuse of the TX (or RX) to reflector asset square triangle + scene.add(Transmitter("tx", position=[0,+1,d0])) + scene.add(Receiver("rx", position=[0,-1,d0])) + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + tau = tf.squeeze(cir[1])[1] + d2 = SPEED_OF_LIGHT*tau/2 + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(d1 - d2),epsilon))) + #self.assertEqual(d1,d2) + + d3 = 80 + d4 = np.sqrt(d3*d3 + 1) + asset.position += [0,0,(d0-d3)] + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + tau = tf.squeeze(cir[1])[1] + d5 = SPEED_OF_LIGHT*tau/2 + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(d4 - d5),epsilon))) + + # Since the material is not vacuum, the path has energy + self.assertTrue(tf.reduce_sum(tf.squeeze(cir[0])) != 0) + + # Change asset radio material + ref_obj = scene.get('floor') + asset.radio_material = 'vacuum'#fully_absorbant_radio_material + + # Check that the scene have not been reloaded (no need if just changing material properties, and not visuals) + self.assertTrue(ref_obj == scene.get("floor")) + + # Measure new pathes with vacuum material (the path still exist but it has 0 energy) + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + self.assertTrue(tf.reduce_sum(tf.squeeze(cir[0])) == 0) \ No newline at end of file diff --git a/test/unit/rt/asset_object/test_asset_misc.py b/test/unit/rt/asset_object/test_asset_misc.py new file mode 100644 index 00000000..ae3713c5 --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_misc.py @@ -0,0 +1,696 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import tensorflow as tf +import xml.etree.ElementTree as ET +import sys +import numpy as np + +from sionna.rt import * +from sionna.constants import PI + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetMisc(unittest.TestCase): + """Miscellaneous tests related to asset's materials data structures and methods""" + def test_placeholder_material_replaced_by_asset_material(self): + """Check that material placeholder replaced by asset material if asset material is defined (ie non placeholder). + Check that replacing a placeholder material does not add a new material but rather transfer (i.e. assign) the properties + of the new material to the old, placeholder, one""" + + def remove_all_whitespace(s): + return s.replace(" ", "").replace("\t", "").replace("\n", "").replace("\r", "") + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset_xml = asset.xml_tree.getroot() + asset_bsdf_elts = asset_xml.findall('bsdf') + for elt in asset_bsdf_elts: + if elt.get('id') == 'mat-itu_metal': + itu_metal_asset_xml = elt + break + + # set the 'itu_metal' RadioMaterial from scene as a placeholder + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene.is_placeholder = True + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + self.assertTrue(itu_metal_scene.is_placeholder) + scene.add(asset) + + + # Since no material have been defined in the asset, the asset material should be the scene material, and the scene material + # shouldn't have changed (except the bsdf of the scene material which is updated by the non placeholder bsdf of the asset) + self.assertTrue(scene.get('itu_metal')==itu_metal_scene) + self.assertTrue(itu_metal_scene.relative_permittivity == 1.0) + self.assertTrue(itu_metal_scene.bsdf.xml_element==itu_metal_asset_xml and itu_metal_scene.bsdf.xml_element != itu_metal_scene_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # Specifying asset radio material + scene = load_scene(sionna.rt.scene.floor_wall) + itu_metal_asset = RadioMaterial('itu_metal',relative_permittivity=0.0) + itu_metal_asset_xml = itu_metal_asset.bsdf.xml_element + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode', method='xml') + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,radio_material=itu_metal_asset) + + # set the 'itu_metal' RadioMaterial from scene as a placeholder + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene.is_placeholder = True + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertTrue(itu_metal_scene.is_placeholder) + + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + new_itu_metal_scene = scene.get('itu_metal') + new_itu_metal_scene_xml = new_itu_metal_scene.bsdf.xml_element + new_itu_metal_scene_xml_str = ET.tostring(new_itu_metal_scene_xml, encoding='unicode') + + # Since a material have been defined in the asset, the scene material, whichwas a placeholder, should have been replace by the asset's material + # Yet, the scene material object should be the same although its properties should have changed (the asset material have been assigned to the scene material) + self.assertTrue(new_itu_metal_scene == itu_metal_scene) # the material object is the same it has just beeen updated + self.assertFalse(new_itu_metal_scene.is_placeholder) + self.assertTrue(new_itu_metal_scene.relative_permittivity==0.0) # The material properties have been updated according to asset RadioMaterial + self.assertTrue(remove_all_whitespace(itu_metal_scene_xml_str) != remove_all_whitespace(new_itu_metal_scene_xml_str)) # The scene bsdf have been updated compared to before adding the asset + self.assertTrue(remove_all_whitespace(new_itu_metal_scene_xml_str) == remove_all_whitespace(itu_metal_asset_xml_str)) + self.assertTrue(new_itu_metal_scene_xml != itu_metal_asset_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + + def test_placeholder_bsdf_replaced_by_asset_bsdf(self): + """Check that placeholder bsdf replaced by asset bsdf if asset bsdf is defined (ie non placeholder)""" + def remove_all_whitespace(s): + return s.replace(" ", "").replace("\t", "").replace("\n", "").replace("\r", "") + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset_xml = asset.xml_tree.getroot() + asset_bsdf_elts = asset_xml.findall('bsdf') + for elt in asset_bsdf_elts: + if elt.get('id') == 'mat-itu_metal': + itu_metal_asset_xml = elt + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode') + break + + # set the 'itu_metal' RadioMaterial BSDF from scene not as a placeholder + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene.bsdf.is_placeholder = False + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertFalse(itu_metal_scene.is_placeholder) + self.assertFalse(itu_metal_scene.bsdf.is_placeholder) + self.assertTrue(itu_metal_scene.relative_permittivity==1.0) + scene.add(asset) + + new_itu_metal_scene = scene.get('itu_metal') + new_itu_metal_scene_xml = new_itu_metal_scene.bsdf.xml_element + new_itu_metal_scene_xml_str = ET.tostring(new_itu_metal_scene_xml, encoding='unicode') + + # Since, nor the bsdf neither the material are placeholder, the asset should not remove the material and bsdf properties + self.assertTrue(new_itu_metal_scene == itu_metal_scene) # the material object is the same + self.assertFalse(new_itu_metal_scene.is_placeholder) + self.assertFalse(new_itu_metal_scene.bsdf.is_placeholder) + self.assertTrue(new_itu_metal_scene.relative_permittivity==1.0) # The material properties are unchanged + + self.assertTrue(remove_all_whitespace(itu_metal_scene_xml_str) == remove_all_whitespace(new_itu_metal_scene_xml_str)) # The scene bsdf are unchanged + self.assertTrue(remove_all_whitespace(new_itu_metal_scene_xml_str) != remove_all_whitespace(itu_metal_asset_xml_str)) + self.assertTrue(new_itu_metal_scene_xml != itu_metal_asset_xml) + self.assertTrue(new_itu_metal_scene_xml == itu_metal_scene_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # set the 'itu_metal' RadioMaterial BSDF from scene as a placeholder + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset_xml = asset.xml_tree.getroot() + asset_bsdf_elts = asset_xml.findall('bsdf') + for elt in asset_bsdf_elts: + if elt.get('id') == 'mat-itu_metal': + itu_metal_asset_xml = elt + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode') + break + itu_metal_scene = scene.get('itu_metal') + #itu_metal_scene.bsdf.is_placeholder = True + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertFalse(itu_metal_scene.is_placeholder) + self.assertTrue(itu_metal_scene.bsdf.is_placeholder) # by default the bsdf is placeholder + self.assertTrue(itu_metal_scene.relative_permittivity==1.0) + + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + new_itu_metal_scene = scene.get('itu_metal') + new_itu_metal_scene_xml = new_itu_metal_scene.bsdf.xml_element + new_itu_metal_scene_xml_str = ET.tostring(new_itu_metal_scene_xml, encoding='unicode') + + # Since, the bsdf of the scene material is now a placeholder, the asset should update bsdf properties + self.assertTrue(new_itu_metal_scene == itu_metal_scene) # the material object is the same + self.assertFalse(new_itu_metal_scene.is_placeholder) + # self.assertFalse(new_itu_metal_scene.bsdf.is_placeholder) # The BSDF is not placeholder now that the asset has been added + self.assertTrue(new_itu_metal_scene.relative_permittivity==1.0) # The material properties are unchanged + + self.assertTrue(remove_all_whitespace(itu_metal_scene_xml_str) != remove_all_whitespace(new_itu_metal_scene_xml_str)) # The scene bsdf has changed + self.assertTrue(remove_all_whitespace(new_itu_metal_scene_xml_str) == remove_all_whitespace(itu_metal_asset_xml_str)) + self.assertTrue(new_itu_metal_scene_xml == itu_metal_asset_xml) + self.assertTrue(new_itu_metal_scene_xml != itu_metal_scene_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + def test_non_placeholder_material_replaced_by_asset_material_if_overwrite(self): + """Check that material non-placeholder replaced by asset material when specified""" + def remove_all_whitespace(s): + return s.replace(" ", "").replace("\t", "").replace("\n", "").replace("\r", "") + + # Specifying asset radio material - No overwrite + scene = load_scene(sionna.rt.scene.floor_wall) + itu_metal_asset = RadioMaterial('itu_metal',relative_permittivity=0.0) + itu_metal_asset_xml = itu_metal_asset.bsdf.xml_element + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode', method='xml') + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,radio_material=itu_metal_asset) + self.assertFalse(itu_metal_asset.is_placeholder) + + # The 'itu_metal' RadioMaterial from scene is not placeholder + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertFalse(itu_metal_scene.is_placeholder) + + # Since both the asset and the scene material are non-placeholders adding asset to scene should raise an error. + + with self.assertRaises(ValueError) as context: + scene.add(asset) + self.assertEqual(str(context.exception), "Name 'itu_metal' is already used by another item of the scene") + + # Specifying asset radio material - Set asset overwrite_scene_radio_materials = True + scene = load_scene(sionna.rt.scene.floor_wall) + itu_metal_asset = RadioMaterial('itu_metal',relative_permittivity=0.0) + itu_metal_asset_xml = itu_metal_asset.bsdf.xml_element + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode', method='xml') + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,radio_material=itu_metal_asset, overwrite_scene_radio_materials=True) + self.assertFalse(itu_metal_asset.is_placeholder) + + # The 'itu_metal' RadioMaterial from scene is not placeholder + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertFalse(itu_metal_scene.is_placeholder) + + # Both the asset and the scene material are non-placeholders, but the overwrite arg. from the asset is set to True + # Thus, adding asset to scene should not raise an error. + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + new_itu_metal_scene = scene.get('itu_metal') + new_itu_metal_scene_xml = new_itu_metal_scene.bsdf.xml_element + new_itu_metal_scene_xml_str = ET.tostring(new_itu_metal_scene_xml, encoding='unicode') + + # Since the asset was set to overwrite existing scene material, the scene material should have been replaced by the asset's material + # Yet, the scene material object should be the same although its properties should have changed (the asset material have been assigned to the scene material) + self.assertTrue(new_itu_metal_scene == itu_metal_scene) # the material object is the same it has just beeen updated + self.assertFalse(new_itu_metal_scene.is_placeholder) + self.assertTrue(new_itu_metal_scene.relative_permittivity==0.0) # The material properties have been updated according to asset RadioMaterial + self.assertTrue(remove_all_whitespace(itu_metal_scene_xml_str) != remove_all_whitespace(new_itu_metal_scene_xml_str)) # The scene bsdf have been updated compared to before adding the asset + self.assertTrue(remove_all_whitespace(new_itu_metal_scene_xml_str) == remove_all_whitespace(itu_metal_asset_xml_str)) + self.assertTrue(new_itu_metal_scene_xml != itu_metal_asset_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + + def test_non_placeholder_bsdf_replaced_by_asset_bsdf_if_overwrite(self): + """Check that bsdf non-placeholder replaced by asset bsdf when specified""" + def remove_all_whitespace(s): + return s.replace(" ", "").replace("\t", "").replace("\n", "").replace("\r", "") + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset_xml = asset.xml_tree.getroot() + asset_bsdf_elts = asset_xml.findall('bsdf') + for elt in asset_bsdf_elts: + if elt.get('id') == 'mat-itu_metal': + itu_metal_asset_xml = elt + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode') + break + + # set the 'itu_metal' RadioMaterial BSDF from scene not as a placeholder + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene.bsdf.is_placeholder = False + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertFalse(itu_metal_scene.is_placeholder) + self.assertFalse(itu_metal_scene.bsdf.is_placeholder) + self.assertTrue(itu_metal_scene.relative_permittivity==1.0) + scene.add(asset) + + new_itu_metal_scene = scene.get('itu_metal') + new_itu_metal_scene_xml = new_itu_metal_scene.bsdf.xml_element + new_itu_metal_scene_xml_str = ET.tostring(new_itu_metal_scene_xml, encoding='unicode') + + # Since, nor the bsdf neither the material are placeholder, the asset should not remove the material and bsdf properties + self.assertTrue(new_itu_metal_scene == itu_metal_scene) # the material object is the same + self.assertFalse(new_itu_metal_scene.is_placeholder) + self.assertFalse(new_itu_metal_scene.bsdf.is_placeholder) + self.assertTrue(new_itu_metal_scene.relative_permittivity==1.0) # The material properties are unchanged + + self.assertTrue(remove_all_whitespace(itu_metal_scene_xml_str) == remove_all_whitespace(new_itu_metal_scene_xml_str)) # The scene bsdf are unchanged + self.assertTrue(remove_all_whitespace(new_itu_metal_scene_xml_str) != remove_all_whitespace(itu_metal_asset_xml_str)) + self.assertTrue(new_itu_metal_scene_xml != itu_metal_asset_xml) + self.assertTrue(new_itu_metal_scene_xml == itu_metal_scene_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # Again, set the 'itu_metal' RadioMaterial BSDF from scene not as a placeholder, but set the asset to overwrite existing bsdf (even if placeholders) + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,overwrite_scene_bsdfs=True) + asset_xml = asset.xml_tree.getroot() + asset_bsdf_elts = asset_xml.findall('bsdf') + for elt in asset_bsdf_elts: + if elt.get('id') == 'mat-itu_metal': + itu_metal_asset_xml = elt + itu_metal_asset_xml_str = ET.tostring(itu_metal_asset_xml, encoding='unicode') + break + itu_metal_scene = scene.get('itu_metal') + itu_metal_scene.bsdf.is_placeholder = False + itu_metal_scene_bsdf = itu_metal_scene.bsdf + itu_metal_scene_xml = itu_metal_scene.bsdf.xml_element + itu_metal_scene_xml_str = ET.tostring(itu_metal_scene_xml, encoding='unicode') + self.assertFalse(itu_metal_scene.is_placeholder) + self.assertFalse(itu_metal_scene.bsdf.is_placeholder) + self.assertTrue(itu_metal_scene.relative_permittivity==1.0) + + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + + new_itu_metal_scene = scene.get('itu_metal') + new_itu_metal_scene_xml = new_itu_metal_scene.bsdf.xml_element + new_itu_metal_scene_xml_str = ET.tostring(new_itu_metal_scene_xml, encoding='unicode') + + # Since, the asset is configured to overwrite existing bsdf, the asset should update existing scene bsdf properties + self.assertTrue(new_itu_metal_scene == itu_metal_scene) # the material object is the same + self.assertFalse(new_itu_metal_scene.is_placeholder) + self.assertTrue(new_itu_metal_scene.bsdf == itu_metal_scene_bsdf) # The BSDF object is not changed simply updated + self.assertTrue(new_itu_metal_scene.relative_permittivity==1.0) # The material properties are unchanged + + self.assertTrue(remove_all_whitespace(itu_metal_scene_xml_str) != remove_all_whitespace(new_itu_metal_scene_xml_str)) # The scene bsdf has changed + self.assertTrue(remove_all_whitespace(new_itu_metal_scene_xml_str) == remove_all_whitespace(itu_metal_asset_xml_str)) + self.assertTrue(new_itu_metal_scene_xml == itu_metal_asset_xml) + self.assertTrue(new_itu_metal_scene_xml != itu_metal_scene_xml) + self.assertTrue(scene.get('asset_0_cube_1').radio_material==itu_metal_scene) + + # Check that the scene is automatically reloaded + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + + + + + def test_asset_removal_keep_materials(self): + """Check that asset removal does not delete material even when not used anymore""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1) #"itu_metal", "itu_wood" + asset_2 = AssetObject(name="asset_2", filename=sionna.rt.asset_object.test_asset_2) #"custom_rm_1" and "mat-custom_rm_2" + custom_rm_3 = RadioMaterial('mat-custom_rm_3') + asset_3 = AssetObject(name="asset_3", filename=sionna.rt.asset_object.test_asset_2, radio_material=custom_rm_3) + custom_rm_4 = RadioMaterial('custom_rm_4') + asset_4 = AssetObject(name="asset_4", filename=sionna.rt.asset_object.test_asset_2, radio_material=custom_rm_4) + scene.add([asset_1,asset_2,asset_3,asset_4]) + for mat_name in scene.radio_materials: + self.assertTrue(scene.get(mat_name).bsdf.name == f"mat-{mat_name}") + self.assertTrue("custom_rm_1" in scene.radio_materials) + self.assertTrue("custom_rm_2" in scene.radio_materials) + self.assertTrue("mat-custom_rm_3" in scene.radio_materials) + self.assertTrue("custom_rm_4" in scene.radio_materials) + + scene.remove([asset_1.name,asset_2.name,asset_3.name,asset_4.name]) + for mat_name in scene.radio_materials: + self.assertTrue(scene.get(mat_name).bsdf.name == f"mat-{mat_name}") + self.assertTrue("custom_rm_1" in scene.radio_materials) + self.assertTrue("custom_rm_2" in scene.radio_materials) + self.assertTrue("mat-custom_rm_3" in scene.radio_materials) + self.assertTrue("custom_rm_4" in scene.radio_materials) + + + + + def test_add_remove_asset_update_using_objects_mat_bsdf(self): + """Chek that the using_objects list are updated as expected when adding or removing assets.""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset_0 = AssetObject("asset_0", sionna.rt.asset_object.test_asset_1) + asset_1 = AssetObject("asset_1", sionna.rt.asset_object.test_asset_1) + + itu_wood = scene.get('itu_wood') + itu_metal = scene.get('itu_metal') + self.assertFalse(itu_wood.is_used) + self.assertFalse(itu_metal.is_used) + self.assertTrue(itu_wood.using_objects.numpy().tolist() == []) + self.assertTrue(itu_metal.using_objects.numpy().tolist() == []) + + scene.add(asset_0) + self.assertTrue(itu_wood.is_used) + self.assertTrue(itu_metal.is_used) + self.assertTrue(itu_wood.use_counter == 1) + self.assertTrue(itu_metal.use_counter == 1) + self.assertTrue(itu_wood.using_objects.numpy().tolist() == [asset_0.shapes["asset_0_cube_0"].object_id]) + self.assertTrue(itu_metal.using_objects.numpy().tolist() == [asset_0.shapes["asset_0_cube_1"].object_id]) + + scene.add(asset_1) + self.assertTrue(itu_wood.is_used) + self.assertTrue(itu_metal.is_used) + self.assertTrue(itu_wood.use_counter == 2) + self.assertTrue(itu_metal.use_counter == 2) + self.assertTrue(itu_wood.using_objects.numpy().tolist().sort() == [asset_0.shapes["asset_0_cube_0"].object_id,asset_1.shapes["asset_1_cube_0"].object_id].sort()) + self.assertTrue(itu_metal.using_objects.numpy().tolist().sort() == [asset_0.shapes["asset_0_cube_1"].object_id,asset_1.shapes["asset_1_cube_1"].object_id].sort()) + + scene.remove("asset_1") + self.assertTrue(itu_wood.is_used) + self.assertTrue(itu_metal.is_used) + self.assertTrue(itu_wood.use_counter == 1) + self.assertTrue(itu_metal.use_counter == 1) + self.assertTrue(itu_wood.using_objects.numpy().tolist() == [asset_0.shapes["asset_0_cube_0"].object_id]) + self.assertTrue(itu_metal.using_objects.numpy().tolist() == [asset_0.shapes["asset_0_cube_1"].object_id]) + + scene.remove("asset_0") + self.assertFalse(itu_wood.is_used) + self.assertFalse(itu_metal.is_used) + self.assertTrue(itu_wood.using_objects.numpy().tolist() == []) + self.assertTrue(itu_metal.using_objects.numpy().tolist() == []) + + def test_asset_material_updated_on_scene_object_material_update(self): + """ Check that when changing material properties of a SceneObject belonging to an asset update the asset_material property""" + # Single shape asset >>> replace old material by new one + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject("asset_0", sionna.rt.asset_object.test_asset_3) + self.assertTrue(asset.radio_material == None) + + scene.add(asset) + + itu_marble = scene.get('itu_marble') + self.assertTrue(asset.radio_material == itu_marble) + scene_obj = scene.get("asset_0_cube") + self.assertTrue(scene_obj.radio_material == itu_marble) + + itu_metal = scene.get('itu_metal') + scene_obj.radio_material = itu_metal + self.assertTrue(asset.radio_material == itu_metal) + scene_obj = scene.get("asset_0_cube") + self.assertTrue(scene_obj.radio_material == itu_metal) + + # Multi-shape asset with single material to multiple material + # >>> replace the single material by None, since one all shapes does not have the same material. + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject("asset_0", sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") + self.assertTrue(asset.radio_material == "itu_metal") + + scene.add(asset) + + itu_metal = scene.get('itu_metal') + self.assertTrue(asset.radio_material == itu_metal) + scene_obj_0 = scene.get("asset_0_cube_0") + self.assertTrue(scene_obj_0.radio_material == itu_metal) + scene_obj_1 = scene.get("asset_0_cube_1") + self.assertTrue(scene_obj_1.radio_material == itu_metal) + + itu_wood = scene.get('itu_wood') + scene_obj_0.radio_material = itu_wood + self.assertTrue(asset.radio_material == None) + scene_obj_0 = scene.get("asset_0_cube_0") + self.assertTrue(scene_obj_0.radio_material == itu_wood) + scene_obj_1 = scene.get("asset_0_cube_1") + self.assertTrue(scene_obj_1.radio_material == itu_metal) + + # Multi-shape asset with multiple materials to single material>>> Replace None by the single material + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject("asset_0", sionna.rt.asset_object.test_asset_1) + self.assertTrue(asset.radio_material == None) + + scene.add(asset) + + itu_metal = scene.get('itu_metal') + itu_wood = scene.get('itu_wood') + self.assertTrue(asset.radio_material == None) + scene_obj_0 = scene.get("asset_0_cube_0") + self.assertTrue(scene_obj_0.radio_material == itu_wood) + scene_obj_1 = scene.get("asset_0_cube_1") + self.assertTrue(scene_obj_1.radio_material == itu_metal) + + scene_obj_1.radio_material = itu_wood + self.assertTrue(asset.radio_material == itu_wood) + scene_obj_0 = scene.get("asset_0_cube_0") + self.assertTrue(scene_obj_0.radio_material == itu_wood) + scene_obj_1 = scene.get("asset_0_cube_1") + self.assertTrue(scene_obj_1.radio_material == itu_wood) + + def test_reload_scene_keep_asset_properties(self): + """Check that when the scene is reloaded, the properties of the asset object are kept, + even if they were previously changed by user (e.g. position, radio material.radio_material), + aswell as the asset constituent""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") + + self.assertTrue(np.equal(asset.position, [0,0,0]).all()) + self.assertTrue(np.equal(asset.orientation, [0,0,0]).all()) + self.assertEqual(asset.radio_material, 'itu_metal') + + scene.add(asset) + + epsilon = 1e-5 + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [0,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].position - [0,+1,0]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [0,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [0,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].orientation - [0,0,0]),epsilon))) + + itu_metal = scene.get('itu_metal') + self.assertEqual(asset.radio_material, itu_metal) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_metal) + self.assertEqual(asset.shapes['asset_0_cube_1'].radio_material, itu_metal) + + # Changing whole asset properties + asset.position += [5,2,1] + asset.orientation += [PI / 2, 0, 0] + asset.radio_material = 'itu_wood' + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [5,2,1]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [6,+2,1]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].position - [4,+2,1]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [PI/2,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [PI/2,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].orientation - [PI/2,0,0]),epsilon))) + + itu_wood = scene.get('itu_wood') + self.assertEqual(asset.radio_material, itu_wood) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_wood) + self.assertEqual(asset.shapes['asset_0_cube_1'].radio_material, itu_wood) + + # Reload scene + scene.reload() + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [5,2,1]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [6,+2,1]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].position - [4,+2,1]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [PI/2,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [PI/2,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].orientation - [PI/2,0,0]),epsilon))) + + itu_wood = scene.get('itu_wood') + self.assertEqual(asset.radio_material, itu_wood) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_wood) + self.assertEqual(asset.shapes['asset_0_cube_1'].radio_material, itu_wood) + + def test_reload_scene_keep_asset_scene_object_properties(self): + """Check that when the scene is reloaded, the properties of the asset object are kept, + even if the scene object of the asset were previously changed by user individually (e.g. position, radio material.radio_material)""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") + + self.assertTrue(np.equal(asset.position, [0,0,0]).all()) + self.assertTrue(np.equal(asset.orientation, [0,0,0]).all()) + self.assertEqual(asset.radio_material, 'itu_metal') + + scene.add(asset) + + epsilon = 1e-5 + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [0,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].position - [0,+1,0]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [0,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [0,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].orientation - [0,0,0]),epsilon))) + + itu_metal = scene.get('itu_metal') + self.assertEqual(asset.radio_material, itu_metal) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_metal) + self.assertEqual(asset.shapes['asset_0_cube_1'].radio_material, itu_metal) + + # Changing whole asset properties + asset.position += [5,2,1] + asset.orientation += [PI / 2, 0, 0] + asset.radio_material = 'itu_wood' + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [5,2,1]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [6,+2,1]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].position - [4,+2,1]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [PI/2,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [PI/2,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].orientation - [PI/2,0,0]),epsilon))) + + itu_wood = scene.get('itu_wood') + self.assertEqual(asset.radio_material, itu_wood) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_wood) + self.assertEqual(asset.shapes['asset_0_cube_1'].radio_material, itu_wood) + + # Changing individual asset's scene object properties + asset.shapes['asset_0_cube_0'].position += [-2,+3,-5] + asset.shapes['asset_0_cube_0'].orientation += [0, PI/2, 0] + asset.shapes['asset_0_cube_0'].radio_material = 'itu_glass' + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [5,2,1]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [4,+5,-4]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [PI/2,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [PI/2,PI/2,0]),epsilon))) + + itu_glass = scene.get('itu_glass') + self.assertEqual(asset.radio_material, None) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_glass) + + # Reload scene + scene.reload() + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [5,2,1]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].position - [4,+5,-4]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].position - [4,+2,1]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [PI/2,0,0]), epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_0'].orientation - [PI/2,PI/2,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.shapes['asset_0_cube_1'].orientation - [PI/2,0,0]),epsilon))) + + itu_wood = scene.get('itu_wood') + itu_glass = scene.get('itu_glass') + self.assertEqual(asset.radio_material, None) + self.assertEqual(asset.shapes['asset_0_cube_0'].radio_material, itu_glass) + self.assertEqual(asset.shapes['asset_0_cube_1'].radio_material, itu_wood) + + def test_update_asset_material_object_using(self): + """Check that changing the material of an AssetObject or a SceneObject, update the object_using sets of the material and its bsdf accordingly""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset_0 = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_metal") + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1, radio_material="itu_concrete") + + scene.add([asset_0, asset_1]) + + concrete = scene.get('itu_concrete') + brick = scene.get('itu_brick') + metal = scene.get('itu_metal') + + self.assertTrue(concrete.is_used) + self.assertTrue(brick.is_used) + self.assertTrue(metal.is_used) + + concrete_obj = concrete.using_objects.numpy().tolist() + brick_obj = brick.using_objects.numpy().tolist() + metal_obj = metal.using_objects.numpy().tolist() + + self.assertEqual(len(concrete_obj), 3) + self.assertEqual(len(brick_obj), 1) + self.assertEqual(len(metal_obj), 2) + + self.assertTrue([scene.get("floor").object_id, scene.get("asset_1_cube_0").object_id, scene.get("asset_1_cube_1").object_id].sort() == concrete_obj.sort()) + self.assertTrue([scene.get("wall").object_id] == brick_obj) + self.assertTrue([scene.get("asset_0_cube_0").object_id, scene.get("asset_0_cube_1").object_id].sort() == metal_obj.sort()) + + asset_0.radio_material = "itu_glass" + asset_1.radio_material = "itu_wood" + scene.get("floor").radio_material = "itu_brick" + glass = scene.get('itu_glass') + wood = scene.get('itu_wood') + + self.assertFalse(concrete.is_used) + self.assertTrue(brick.is_used) + self.assertFalse(metal.is_used) + self.assertTrue(glass.is_used) + self.assertTrue(wood.is_used) + + + concrete_obj = concrete.using_objects.numpy().tolist() + brick_obj = brick.using_objects.numpy().tolist() + metal_obj = metal.using_objects.numpy().tolist() + glass_obj = glass.using_objects.numpy().tolist() + wood_obj = wood.using_objects.numpy().tolist() + + self.assertEqual(len(concrete_obj), 0) + self.assertEqual(len(brick_obj), 2) + self.assertEqual(len(metal_obj), 0) + self.assertEqual(len(glass_obj), 2) + self.assertEqual(len(wood_obj), 2) + + self.assertTrue([scene.get("asset_1_cube_0").object_id, scene.get("asset_1_cube_1").object_id].sort() == glass_obj.sort()) + self.assertTrue([scene.get("floor").object_id, scene.get("wall").object_id].sort() == brick_obj.sort()) + self.assertTrue([scene.get("asset_0_cube_0").object_id, scene.get("asset_0_cube_1").object_id].sort() == wood_obj.sort()) + + + def test_original_asset_bsdf_are_stored(self): + """Test showing that the asset's original bsdfs are propely stored in the asset data-structure""" + # single-material asset + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_3) + asset_xml_root = asset._xml_tree.getroot() + bsdfs_in_asset_xml = asset_xml_root.findall('bsdf') + bsdf_ids = [bsdf.get('id') for bsdf in bsdfs_in_asset_xml] + + for id in bsdf_ids: + self.assertTrue(id in asset.original_bsdfs) + for bsdf in bsdfs_in_asset_xml: + if bsdf.get('id') == id: + self.assertTrue(bsdf,asset.original_bsdfs[id].xml_element) + self.assertEqual([b for b in asset.original_bsdfs].sort(),['mat-itu_marble'].sort()) + + + # Multi-material asset + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + asset_xml_root = asset._xml_tree.getroot() + bsdfs_in_asset_xml = asset_xml_root.findall('bsdf') + bsdf_ids = [bsdf.get('id') for bsdf in bsdfs_in_asset_xml] + + for id in bsdf_ids: + self.assertTrue(id in asset.original_bsdfs) + for bsdf in bsdfs_in_asset_xml: + if bsdf.get('id') == id: + self.assertTrue(bsdf,asset.original_bsdfs[id].xml_element) + self.assertEqual([b for b in asset.original_bsdfs].sort(),['mat-itu_metal','mat-itu_wood'].sort()) \ No newline at end of file diff --git a/test/unit/rt/asset_object/test_asset_orientation.py b/test/unit/rt/asset_object/test_asset_orientation.py new file mode 100644 index 00000000..712772f7 --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_orientation.py @@ -0,0 +1,259 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import sys + +from sionna.rt import * +from sionna.constants import PI + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetOrientation(unittest.TestCase): + """Tests related to the change of an asset's orientation""" + + def test_change_orientation_with_dtype(self): + """Changing the orientation works in all dtypes""" + for dtype in [tf.complex64, tf.complex128]: + scene = load_scene(sionna.rt.scene.floor_wall, dtype=dtype) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + target_orientation = tf.cast([-PI/3, 0.1, PI/2], dtype.real_dtype) + asset.orientation = target_orientation + self.assertEqual(asset.orientation.dtype, dtype.real_dtype) + self.assertTrue(np.array_equal(asset.orientation, target_orientation)) + + def test_no_position_change(self): + """Changing orientation should not change the position of the asset""" + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + pos_org = asset.position + asset.orientation = [0.2,0.3,-0.4] + self.assertTrue(tf.reduce_all(asset.position==pos_org)) + + def test_orientation_axis_convention(self): + """Check axis convention when rotating asset""" + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + + epsilon = 1e-5 # The Boudning-boxes computed by sionna to estimate the position of an object are not entirely accurate + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Rotation around x-axis + asset.orientation += [0, 0, PI / 2] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,0,-1]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,0,+1]),epsilon))) + + # Rotation around y-axis + asset.orientation += [0, PI / 2, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[-1,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[+1,0,0]),epsilon))) + + # Rotation around z-axis + asset.orientation += [PI / 2, 0, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + def test_set_orientation_before_adding_asset_to_scene(self): + """Check orientation parameter works before adding asset to scene""" + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + + epsilon = 1e-5 # The Boudning-boxes computed by sionna to estimate the position of an object are not entirely accurate + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Rotation around x-axis + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,orientation=[0, 0, PI / 2]) + scene.add(asset) + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,0,-1]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,0,+1]),epsilon))) + + # Rotation around y-axis + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,orientation=[0, PI / 2, PI / 2]) + scene.add(asset) + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[-1,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[+1,0,0]),epsilon))) + + # Rotation around z-axis + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1,orientation=[PI / 2, PI / 2, PI / 2]) + scene.add(asset) + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + + def test_orientation_add_vs_set(self): + """Check that rotation accumulation lead to the same result as setting the complete rotation at once for asset objects""" + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + + epsilon = 1e-5 # The Boudning-boxes computed by sionna to estimate the position of an object are not entirely accurate + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Add rotation around z-axis + asset.orientation += [PI / 2, 0, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[+1,0,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[-1,0,0]),epsilon))) + + # Add rotation around z-axis + asset.orientation += [3 * PI / 2, 0, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Set rotation around z-axis + asset.orientation = [0, 0, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Set rotation around z-axis + asset.orientation = [2 * PI, 0, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + def test_orientation_impacts_paths(self): + """Test showing that rotating a simple reflector asset can make a paths disappear""" + scene = load_scene() # Empty scene + asset = AssetObject(name="reflector", filename=sionna.rt.scene.simple_reflector) # N.B. a scene file can be added as an asset into a scene + scene.add(asset) + + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.add(Transmitter("tx", position=[0.1,0,100])) + scene.add(Receiver("rx", position=[0.1,0,100])) + + # There should be a single reflected path + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1) + self.assertEqual(tf.squeeze(paths.tau).shape, []) + + # Rotating the reflector by PI/4 should make the path disappear + asset.orientation = [0,0,PI/4] + + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1) + self.assertEqual(tf.squeeze(paths.tau).shape, [0]) + + def test_reverse_orientation_transform(self): + """Test showing that reversing the transform get the object to its initial coordinates""" + scene = load_scene() # Empty scene + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + init_pos_cube_0 = scene.get('asset_0_cube_0').position + init_pos_cube_1 = scene.get('asset_0_cube_1').position + + random_rotation = np.random.random(3) + asset.orientation += +random_rotation + asset.orientation += -random_rotation + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(init_pos_cube_0 - scene.get('asset_0_cube_0').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(init_pos_cube_1 - scene.get('asset_0_cube_1').position),epsilon))) + + + def test_look_at(self): + """Check that the look_at argument works to set an asset orientation""" + scene = load_scene() # Empty scene + asset_0 = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, position=[0,0,0]) + scene.add(asset_0) + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1, position=[2,2,0], look_at=asset_0) + scene.add(asset_1) + + print(asset_0.position,asset_1.orientation) + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([2-np.sqrt(2)/2,2+np.sqrt(2)/2,0]) - scene.get('asset_1_cube_0').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([2+np.sqrt(2)/2,2-np.sqrt(2)/2,0]) - scene.get('asset_1_cube_1').position),epsilon))) + + def test_look_at_update(self): + """Check that the look_at argument works to update an asset orientation""" + scene = load_scene() # Empty scene + asset_0 = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, position=[0,0,0]) + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1, position=[2,2,0]) + scene.add([asset_0,asset_1]) + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([0,-1,0]) - scene.get('asset_0_cube_0').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([0,+1,0]) - scene.get('asset_0_cube_1').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([2,+1,0]) - scene.get('asset_1_cube_0').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([2,+3,0]) - scene.get('asset_1_cube_1').position),epsilon))) + + asset_0.look_at(asset_1) + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([+np.sqrt(2)/2,-np.sqrt(2)/2,0]) - scene.get('asset_0_cube_0').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([-np.sqrt(2)/2,+np.sqrt(2)/2,0]) - scene.get('asset_0_cube_1').position),epsilon))) + + asset_1.look_at([0,0,0]) + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([2-np.sqrt(2)/2,2+np.sqrt(2)/2,0]) - scene.get('asset_1_cube_0').position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(np.array([2+np.sqrt(2)/2,2-np.sqrt(2)/2,0]) - scene.get('asset_1_cube_1').position),epsilon))) + + def test_orientation_without_scene(self): + """Check that orientation is properly set even if the scene is not set yet""" + epsilon = 1e-5 + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [0,0,0]),epsilon))) + + asset.orientation = [0, 0, PI / 2] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [0, 0, PI / 2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [0, 0, 0]),epsilon))) + + scene = load_scene() + scene.add(asset) + scene_asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.orientation - [0, 0, PI / 2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [0, 0, 0]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(scene_asset.orientation - [0, 0, PI / 2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(scene_asset.position - [0, 0, 0]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.orientation - [0, 0, PI / 2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position - [0,0,-1]),epsilon))) + + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.orientation - [0, 0, PI / 2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position - [0,0,+1]),epsilon))) + + diff --git a/test/unit/rt/asset_object/test_asset_position.py b/test/unit/rt/asset_object/test_asset_position.py new file mode 100644 index 00000000..da0450e5 --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_position.py @@ -0,0 +1,196 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import sys + +from sionna.rt import * +from sionna.constants import SPEED_OF_LIGHT + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestAssetPosition(unittest.TestCase): + """Tests related to the change of an asset's position""" + + def test_change_position_with_dtype(self): + """Changing the position works in all dtypes""" + for dtype in [tf.complex64, tf.complex128]: + scene = load_scene(sionna.rt.scene.floor_wall, dtype=dtype) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + target_position = tf.cast([12., 0.5, -3.], dtype.real_dtype) + asset.position = target_position + self.assertEqual(asset.position.dtype, dtype.real_dtype) + self.assertTrue(np.array_equal(asset.position, target_position)) + + def test_change_position_via_ray(self): + """Modifying a position leads to the desired result (in terms of propagation)""" + scene = load_scene() # Load empty scene + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + + ## The test cube(s) are two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis + cube_edge_length = 1 + cubes_separation = 1 + asset = AssetObject(name="reflector", filename=sionna.rt.asset_object.test_asset_1, position=[0,-cubes_separation,-cube_edge_length/2]) # we shift the asset so that the face of the metal cube is aligned with the xy-plane and center in (0,0) + scene.add(asset) + + d0 = 100 + d1 = np.sqrt(d0*d0 + 1) #Hypothenuse of the TX (or RX) to reflector asset square triangle + scene.add(Transmitter("tx", position=[0,+1,d0])) + scene.add(Receiver("rx", position=[0,-1,d0])) + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + tau = tf.squeeze(cir[1])[1] + d2 = SPEED_OF_LIGHT*tau/2 + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(d1 - d2),epsilon))) + #self.assertEqual(d1,d2) + + d3 = 80 + d4 = np.sqrt(d3*d3 + 1) + asset.position += [0,0,(d0-d3)] + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + tau = tf.squeeze(cir[1])[1] + d5 = SPEED_OF_LIGHT*tau/2 + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(d4 - d5),epsilon))) + #self.assertEqual(d4,d5) + + def test_asset_vs_object_position_consistency(self): + """Check if the position of the asset is consistent with that of its shape constituents. Here we consider a composite asset made of + two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis. Hence, the centers of the cubes are (0,-1,0) and (0,+1,0). """ + scene = load_scene(sionna.rt.scene.floor_wall) + + random_position = np.random.random(3) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1, position=random_position) + scene.add(asset) + + asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + cube_0_position = np.array([0,-1,0]) + random_position + cube_1_position = np.array([0,+1,0]) + random_position + + self.assertTrue("asset_0" in scene.asset_objects) + self.assertTrue(isinstance(asset,AssetObject)) + self.assertTrue(isinstance(cube_0_object,SceneObject)) + self.assertTrue(isinstance(cube_1_object,SceneObject)) + self.assertTrue(tf.reduce_all(asset.position==random_position)) + epsilon = 1e-5 # The Bounding-boxes computed by sionna to estimate the position of an object are not entirely accurate + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-cube_0_position),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-cube_1_position),epsilon))) + + + def test_init_bias_when_reloading_scene(self): + """Check if the bias introduced at asset object init to avoid Mitsuba mixing up shapes at the same position, + is not added several time when reloading a scene.""" + + scene = load_scene(sionna.rt.scene.floor_wall) + + asset_0 = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset_0) + + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + + asset_0_position_0 = tf.Variable(asset_0.position) + cube_0_position_0 = tf.Variable(cube_0_object.position) + cube_1_position_0 = tf.Variable(cube_1_object.position) + + # Adding a secondary asset to reload the scene + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset_1) + + asset_0_position_1 = tf.Variable(asset_0.position) + cube_0_position_1 = tf.Variable(cube_0_object.position) + cube_1_position_1 = tf.Variable(cube_1_object.position) + + # Or manually reload the scene + scene.reload() + + asset_0_position_2 = tf.Variable(asset_0.position) + cube_0_position_2 = tf.Variable(cube_0_object.position) + cube_1_position_2 = tf.Variable(cube_1_object.position) + + self.assertTrue(tf.reduce_all((asset_0_position_0==asset_0_position_1) == (asset_0_position_0==asset_0_position_2))) + self.assertTrue(tf.reduce_all((cube_0_position_0==cube_0_position_1) == (cube_0_position_0==cube_0_position_2))) + self.assertTrue(tf.reduce_all((cube_1_position_0==cube_1_position_1) == (cube_1_position_0==cube_1_position_2))) + + + def test_position_add_vs_set(self): + """Check that position accumulation lead to the same result as setting the complete position at once for asset objects""" + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + + epsilon = 1e-5 # The Boudning-boxes computed by sionna to estimate the position of an object are not entirely accurate + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Add position + asset.position += [+2, -3, +5] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[+2, -4, +5]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[+2, -2, +5]),epsilon))) + + # Add position + asset.position += [-1, +1, -2] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[+1, -3, +3]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[+1, -1, +3]),epsilon))) + + # Set position + asset.position = [0, 0, 0] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[0,-1,0]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[0,+1,0]),epsilon))) + + # Set position + asset.position = [+1,-2,+3] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position-[+1,-3,+3]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position-[+1,-1,+3]),epsilon))) + + def test_position_without_scene(self): + """Check that position is properly set even if the scene is not set yet""" + epsilon = 1e-5 + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [0,0,0]),epsilon))) + + asset.position = [4,3,2] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [4,3,2]),epsilon))) + + scene = load_scene() + scene.add(asset) + scene_asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.position - [4,3,2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(scene_asset.position -[4,3,2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.position - [4,2,2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.position - [4,4,2]),epsilon))) diff --git a/test/unit/rt/asset_object/test_asset_velocity.py b/test/unit/rt/asset_object/test_asset_velocity.py new file mode 100644 index 00000000..e7aae2ce --- /dev/null +++ b/test/unit/rt/asset_object/test_asset_velocity.py @@ -0,0 +1,127 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import xml.etree.ElementTree as ET +import sys + +from sionna.rt import * +from sionna.constants import SPEED_OF_LIGHT, PI + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + +class TestAssetVelocity(unittest.TestCase): + """Tests related to the change of an asset's velocity""" + + def test_change_velocity_with_dtype(self): + """Changing the velocity works in all dtypes""" + for dtype in [tf.complex64, tf.complex128]: + scene = load_scene(sionna.rt.scene.floor_wall, dtype=dtype) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_3, radio_material='itu_metal') + scene.add(asset) + target_velocity = tf.cast([12., 0.5, -3.], dtype.real_dtype) + asset.velocity = target_velocity + self.assertEqual(asset.velocity.dtype, dtype.real_dtype) + self.assertTrue(np.array_equal(asset.velocity, target_velocity)) + + def test_change_velocity_via_ray(self): + """Test that moving reflector has the right Doppler shift""" + scene = load_scene() + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_3, radio_material='itu_metal') + scene.add(asset) + asset.velocity = [0, 0, -20] + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = scene.tx_array + scene.add(Transmitter("tx", [-25,0.1,50])) + scene.add(Receiver("rx", [ 25,0.1,50])) + + # Compute the reflected path + paths = scene.compute_paths(max_depth=1, los=False) + + # Compute theoretical Doppler shift for this path + theta_t = tf.squeeze(paths.theta_t) + phi_t = tf.squeeze(paths.phi_t) + k_0 = r_hat(theta_t, phi_t) + theta_r = tf.squeeze(paths.theta_r) + phi_r = tf.squeeze(paths.phi_r) + k_1 = -r_hat(theta_r, phi_r) + doppler_theo = np.sum((k_1-k_0)*asset.velocity)/scene.wavelength + + a = tf.squeeze(paths.doppler).numpy() + b = doppler_theo.numpy() + + self.assertAlmostEqual(a,b,places=3) + + def test_asset_vs_object_velocities_consistency(self): + """Check if the position of the asset is consistent with that of its shape constituents. Here we consider a composite asset made of + two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis. Hence, the centers of the cubes are (0,-1,0) and (0,+1,0). """ + scene = load_scene(sionna.rt.scene.floor_wall) + + + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + scene.add(asset) + + asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + + self.assertTrue(np.array_equal(asset.velocity,np.array([0,0,0]) )) + self.assertTrue(np.array_equal(cube_0_object.velocity,np.array([0,0,0]))) + self.assertTrue(np.array_equal(cube_1_object.velocity,np.array([0,0,0]))) + + # Change asset velocity + random_velocity = np.random.random(3) + asset.velocity = random_velocity + + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.velocity-random_velocity),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.velocity-random_velocity),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.velocity-random_velocity),epsilon))) + + + # Change asset's scene object velocity + random_velocity_2 = np.random.random(3) + cube_0_object.velocity = random_velocity_2 + + self.assertEqual(asset.velocity,None) + epsilon = 1e-5 + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.velocity-random_velocity_2),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.velocity-random_velocity),epsilon))) + + def test_velocity_without_scene(self): + """Check that velocity is properly set even if the scene is not set yet""" + epsilon = 1e-5 + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.velocity - [0,0,0]),epsilon))) + + asset.velocity = [4,3,2] + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.velocity - [4,3,2]),epsilon))) + + scene = load_scene() + scene.add(asset) + scene_asset = scene.get("asset_0") + cube_0_object = scene.get("asset_0_cube_0") + cube_1_object = scene.get("asset_0_cube_1") + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(asset.velocity - [4,3,2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(scene_asset.velocity -[4,3,2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_0_object.velocity - [4,3,2]),epsilon))) + self.assertTrue(tf.reduce_all(tf.math.less_equal(tf.math.abs(cube_1_object.velocity - [4,3,2]),epsilon))) diff --git a/test/unit/rt/test_radio_materials_and_bsdfs.py b/test/unit/rt/test_radio_materials_and_bsdfs.py new file mode 100644 index 00000000..1860c7dc --- /dev/null +++ b/test/unit/rt/test_radio_materials_and_bsdfs.py @@ -0,0 +1,716 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import xml.etree.ElementTree as ET +import sys + +from sionna.rt import * +from sionna.constants import SPEED_OF_LIGHT, PI + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + +class TestBSDFUpdate(unittest.TestCase): + """Tests related to updating the BSDF of a material""" + + def test_set_bsdf_when_placeholder(self): + """Test setting the RGB property of a placeholder BSDF""" + placeholder_bsdf = BSDF(name="placeholder_bsdf") + placeholder_bsdf.is_placeholder = True + self.assertTrue(placeholder_bsdf.is_placeholder) + + placeholder_bsdf_rgb = placeholder_bsdf.rgb + placeholder_bsdf.rgb = (0.5, 0.5, 0.5) + self.assertFalse(placeholder_bsdf.is_placeholder) + self.assertTrue(np.equal(placeholder_bsdf.rgb, (0.5, 0.5, 0.5)).all()) + self.assertFalse(np.equal(placeholder_bsdf.rgb, placeholder_bsdf_rgb).all()) + + def test_set_bsdf_when_not_placeholder(self): + """Test setting the RGB property of a non-placeholder BSDF""" + non_placeholder_bsdf = BSDF(name="non_placeholder_bsdf") + self.assertFalse(non_placeholder_bsdf.is_placeholder) + + non_placeholder_bsdf_rgb = non_placeholder_bsdf.rgb + non_placeholder_bsdf.rgb = (0.5, 0.5, 0.5) + self.assertFalse(non_placeholder_bsdf.is_placeholder) + self.assertTrue(np.equal(non_placeholder_bsdf.rgb, (0.5, 0.5, 0.5)).all()) + self.assertFalse(np.equal(non_placeholder_bsdf.rgb, non_placeholder_bsdf_rgb).all()) + + def test_update_bsdf_in_scene_when_placeholder(self): + """Test updating the BSDF of a material in the scene when the BSDF is a placeholder""" + scene = load_scene(sionna.rt.scene.floor_wall) + placeholder_bsdf = scene.get("itu_glass").bsdf + self.assertTrue(placeholder_bsdf.is_placeholder) + + bsdfs_in_root = scene._xml_tree.getroot().findall('bsdf') + for bsdf in bsdfs_in_root: + if bsdf.get('id') == 'mat-itu_glass': + break + rgb = [float(x) for x in bsdf.find('rgb').get('value').split()] + self.assertFalse(np.equal(rgb, (0.5, 0.5, 0.5)).all()) + + placeholder_bsdf_rgb = placeholder_bsdf.rgb + placeholder_bsdf.rgb = (0.5, 0.5, 0.5) + self.assertFalse(placeholder_bsdf.is_placeholder) + self.assertTrue(np.equal(placeholder_bsdf.rgb, (0.5, 0.5, 0.5)).all()) + self.assertFalse(np.equal(placeholder_bsdf.rgb, placeholder_bsdf_rgb).all()) + + bsdfs_in_root = scene._xml_tree.getroot().findall('bsdf') + for bsdf in bsdfs_in_root: + if bsdf.get('id') == 'mat-itu_glass': + break + rgb = [float(x) for x in bsdf.find('rgb').get('value').split()] + self.assertTrue(np.equal(rgb, (0.5, 0.5, 0.5)).all()) + + + def test_update_bsdf_in_scene_when_not_placeholder(self): + """Test updating the BSDF of a material in the scene when the BSDF is not a placeholder""" + scene = load_scene(sionna.rt.scene.floor_wall) + non_placeholder_bsdf = scene.get("itu_concrete").bsdf + self.assertFalse(non_placeholder_bsdf.is_placeholder) + + bsdfs_in_root = scene._xml_tree.getroot().findall('bsdf') + for bsdf in bsdfs_in_root: + if bsdf.get('id') == 'mat-itu_concrete': + break + rgb = [float(x) for x in bsdf.find('bsdf').find('rgb').get('value').split()] + self.assertFalse(np.equal(rgb, (0.5, 0.5, 0.5)).all()) + + + non_placeholder_bsdf_rgb = non_placeholder_bsdf.rgb + non_placeholder_bsdf.rgb = (0.5, 0.5, 0.5) + self.assertFalse(non_placeholder_bsdf.is_placeholder) + self.assertTrue(np.equal(non_placeholder_bsdf.rgb, (0.5, 0.5, 0.5)).all()) + self.assertFalse(np.equal(non_placeholder_bsdf.rgb, non_placeholder_bsdf_rgb).all()) + + bsdfs_in_root = scene._xml_tree.getroot().findall('bsdf') + for bsdf in bsdfs_in_root: + if bsdf.get('id') == 'mat-itu_concrete': + break + rgb = [float(x) for x in bsdf.find('rgb').get('value').split()] + self.assertTrue(np.equal(rgb, (0.5, 0.5, 0.5)).all()) + + def test_scene_reload_on_placeholder_bsdf_update(self): + """Test that the scene is reloaded when the BSDF is updated""" + scene = load_scene(sionna.rt.scene.floor_wall) + placeholder_bsdf = scene.get("itu_glass").bsdf + self.assertTrue(placeholder_bsdf.is_placeholder) + ref_obj = scene.get("floor").mi_shape + placeholder_bsdf.rgb = (0.5, 0.5, 0.5) + self.assertNotEqual(ref_obj, scene.get("floor").mi_shape) + + def test_scene_reload_on_non_placeholder_bsdf_update(self): + """Test that the scene is reloaded when the BSDF is updated""" + scene = load_scene(sionna.rt.scene.floor_wall) + non_placeholder_bsdf = scene.get("itu_concrete").bsdf + self.assertFalse(non_placeholder_bsdf.is_placeholder) + ref_obj = scene.get("floor").mi_shape + non_placeholder_bsdf.rgb = (0.5, 0.5, 0.5) + self.assertNotEqual(ref_obj, scene.get("floor").mi_shape) + + + +class TestBSDF(unittest.TestCase): + """Tests related to the BSDF class""" + + def test_bsdf_modification_update_shape_bsdf(self): + """Check that the modification/setup of a material and/or bsdf leads to the update of the corresponding shape's bsdf in the XML file.""" + scene = load_scene(sionna.rt.scene.floor_wall) + + # Add asset with mat + asset = AssetObject("asset_0", sionna.rt.asset_object.test_asset_1, radio_material='itu_metal') + scene.add(asset) + + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + for elt in elements_in_root: + if id == elt.get('id') == "mesh-asset_0_cube_0": + break + + bsdf = elt.find('ref').get('id') + self.assertEqual(bsdf, "mat-itu_metal") + + for elt in elements_in_root: + if id == elt.get('id') == "mesh-asset_0_cube_1": + break + + bsdf = elt.find('ref').get('id') + self.assertEqual(bsdf, "mat-itu_metal") + + # Change scene object material + scene.get("asset_0_cube_0").radio_material = 'itu_wood' + for elt in elements_in_root: + if elt.get('id') == "mesh-asset_0_cube_0": + break + + bsdf = elt.find('ref').get('id') + self.assertEqual(bsdf, "mat-itu_wood") + + scene.get("asset_0_cube_1").radio_material = 'itu_glass' + for elt in elements_in_root: + if elt.get('id') == "mesh-asset_0_cube_1": + break + + bsdf = elt.find('ref').get('id') + self.assertEqual(bsdf, "mat-itu_glass") + + + + def test_itu_materials_bsdfs_are_placeholders(self): + """Test showing that Sionna base materials'bsdfs are placeholders, except when they are defined in the scene XML file""" + scene = load_scene() # no material defined in the XML file + for mat in scene.radio_materials: + mat = scene.get(mat) + if mat.name[:3] == 'itu': + if mat.is_used: + self.assertTrue(not mat.bsdf.is_placeholder) + else: + self.assertTrue(mat.bsdf.is_placeholder) + + scene = load_scene(sionna.rt.scene.floor_wall) # material 'itu_concrete' and 'itu_brick' are defined in the XML file + for mat in scene.radio_materials: + mat = scene.get(mat) + if mat.name == 'itu_concrete' or mat.name == 'itu_brick': + self.assertTrue(mat.is_used) + if mat.name[:3] == 'itu': + if mat.is_used: + self.assertTrue(not mat.bsdf.is_placeholder) + else: + self.assertTrue(mat.bsdf.is_placeholder) + + def test_rgb_setter_bsdf(self): + """Check that the bsdf RGB setter works as expected""" + bsdf = BSDF("bsdf") + + # Valid RGB triplet + prev_rgb = bsdf.rgb + new_rgb = (0.5,0.2,0.6) + bsdf.rgb = new_rgb + self.assertTrue(np.equal(bsdf.rgb, new_rgb).all()) + self.assertTrue(not np.equal(bsdf.rgb, prev_rgb).all()) + + # Invalid format or values + prev_rgb = bsdf.rgb + new_rgb = (0.5,0.2,0.6,0.9) + with self.assertRaises(TypeError) as context: + bsdf.rgb = new_rgb + self.assertEqual(str(context.exception), "`rgb` must be a list of 3 floats comprised between 0 and 1") + + new_rgb = (256,45,18) + with self.assertRaises(TypeError) as context: + bsdf.rgb = new_rgb + self.assertEqual(str(context.exception), "`rgb` must be a list of 3 floats comprised between 0 and 1") + + new_rgb = "(0.5,0.2,0.6)" + with self.assertRaises(TypeError) as context: + bsdf.rgb = new_rgb + self.assertEqual(str(context.exception), "`rgb` must be a list of 3 floats comprised between 0 and 1") + + def test_color_setter_bsdf(self): + """Check that the bsdf color setter works as expected""" + bsdf = BSDF("bsdf") + + # Valid RGB triplet + prev_rgb = bsdf.rgb + new_rgb = (0.5,0.2,0.6) + bsdf.color = new_rgb + self.assertEqual(bsdf.color, None) + self.assertTrue(np.equal(bsdf.rgb, new_rgb).all()) + self.assertTrue(not np.equal(bsdf.rgb, prev_rgb).all()) + + # Valid color name + prev_rgb = bsdf.rgb + new_color = 'Red' + bsdf.color = new_color + self.assertEqual(bsdf.color, 'red') + self.assertTrue(np.equal(bsdf.rgb, (1,0,0)).all()) + self.assertTrue(not np.equal(bsdf.rgb, prev_rgb).all()) + + # Invalid format or values + prev_rgb = bsdf.rgb + new_rgb = (0.5,0.2,0.6,0.9) + with self.assertRaises(TypeError) as context: + bsdf.color = new_rgb + self.assertEqual(str(context.exception), "`color` must be a list of 3 `float` between 0 and 1, or a valid `str` color name.") + + new_rgb = (256,45,18) + with self.assertRaises(TypeError) as context: + bsdf.color = new_rgb + self.assertEqual(str(context.exception), "`color` must be a list of 3 `float` between 0 and 1, or a valid `str` color name.") + + new_rgb = "(0.5,0.2,0.6)" + with self.assertRaises(TypeError) as context: + bsdf.color = new_rgb + self.assertEqual(str(context.exception), "Unknown color name '(0.5,0.2,0.6)'.") + + def test_xml_element_setter_bsdf(self): + """Check that the bsdf xml_element setter works as expected""" + bsdf = BSDF("bsdf") + + # Valid BSDF Element tree + xml_str = '' + xml_element = ET.fromstring(xml_str) + bsdf.xml_element = xml_element + self.assertEqual(bsdf.xml_element, xml_element) + + # Invalid Element type + with self.assertRaises(TypeError) as context: + bsdf.xml_element = "not_an_element" + self.assertEqual(str(context.exception), "`element` must be an ET.Element descriptor of a BSDF.") + + # Invalid root element + xml_str = '' + xml_element = ET.fromstring(xml_str) + with self.assertRaises(ValueError) as context: + bsdf.xml_element = xml_element + self.assertEqual(str(context.exception), "The root element must be .") + + def test_set_bsdf(self): + """Check that the bsdf init method works as expected""" + + # Name is set + bsdf = BSDF("bsdf") + self.assertEqual(bsdf.name, "bsdf") + self.assertEqual(bsdf.color, None) + self.assertEqual(len(bsdf.rgb), 3) + + # If xml_element is defined it will be used no matter the color argument + xml_str = '' + xml_element = ET.fromstring(xml_str) + bsdf = BSDF("bsdf",xml_element=xml_element) + + self.assertEqual(bsdf.color, None) + self.assertEqual(bsdf.rgb, None) + self.assertEqual(bsdf.xml_element, xml_element) + + color = 'red' + bsdf = BSDF("bsdf",xml_element=xml_element,color=color) + + self.assertEqual(bsdf.color, None) + self.assertEqual(bsdf.rgb, None) + self.assertEqual(bsdf.xml_element, xml_element) + + color = (1,1,1) + bsdf = BSDF("bsdf",xml_element=xml_element,color=color) + + self.assertEqual(bsdf.color, None) + self.assertEqual(bsdf.rgb, None) + self.assertEqual(bsdf.xml_element, xml_element) + + # Valid RGB triplet + color = (1,1,1) + bsdf = BSDF("bsdf",color=color) + self.assertEqual(bsdf.color, None) + self.assertTrue(np.equal(bsdf.rgb, color).all()) + + # Valid color name + color = 'Red' + bsdf = BSDF("bsdf",color=color) + self.assertEqual(bsdf.color, 'red') + self.assertTrue(np.equal(bsdf.rgb, (1,0,0)).all()) + + # Invalid color or XML element + color = (0.5,0.2,0.6,0.9) + with self.assertRaises(TypeError) as context: + bsdf = BSDF("bsdf",color=color) + self.assertEqual(str(context.exception), "`color` must be a list of 3 `float` between 0 and 1, or a valid `str` color name.") + + color = (256,45,18) + with self.assertRaises(TypeError) as context: + bsdf = BSDF("bsdf",color=color) + self.assertEqual(str(context.exception), "`color` must be a list of 3 `float` between 0 and 1, or a valid `str` color name.") + + color = "(0.5,0.2,0.6)" + with self.assertRaises(TypeError) as context: + bsdf = BSDF("bsdf",color=color) + self.assertEqual(str(context.exception), "Unknown color name '(0.5,0.2,0.6)'.") + +class TestRadioMaterial(unittest.TestCase): + """Tests related to the RadioMaterial class""" + + # def setUp(self): + # self.scene = load_scene(sionna.rt.scene.floor_wall) + + def test_itu_materials_are_not_placeholders(self): + """Test showing that Sionna base materials are not placeholders""" + scene = load_scene(sionna.rt.scene.floor_wall) + for mat in scene.radio_materials: + mat = scene.get(mat) + if mat.name[:3] == 'itu': + self.assertTrue(not mat.is_placeholder) + + def test_bsdf_name_is_consistent_with_mat_name(self): + """Check that the name of bsdf is set according to the radio_material name, i.e. bsdf name is f'mat-{radio_material.name}'""" + scene = load_scene(sionna.rt.scene.floor_wall) + # The asset object uses bsdf with badly formated material "custom_rm_1" and "mat-custom_rm_2" + # >>> Check that when adding asset, name convention are respected both for bsdf and material + asset_1 = AssetObject(name="asset_1", filename=sionna.rt.asset_object.test_asset_1) #"itu_metal", "itu_wood" + asset_2 = AssetObject(name="asset_2", filename=sionna.rt.asset_object.test_asset_2) #"custom_rm_1" and "mat-custom_rm_2" + custom_rm_3 = RadioMaterial('mat-custom_rm_3') + asset_3 = AssetObject(name="asset_3", filename=sionna.rt.asset_object.test_asset_2, radio_material=custom_rm_3) + custom_rm_4 = RadioMaterial('custom_rm_4') + asset_4 = AssetObject(name="asset_4", filename=sionna.rt.asset_object.test_asset_2, radio_material=custom_rm_4) + scene.add([asset_1,asset_2,asset_3,asset_4]) + for mat_name in scene.radio_materials: + self.assertTrue(scene.get(mat_name).bsdf.name == f"mat-{mat_name}") + self.assertTrue("custom_rm_1" in scene.radio_materials) + self.assertTrue("custom_rm_2" in scene.radio_materials) + self.assertTrue("mat-custom_rm_3" in scene.radio_materials) + self.assertTrue("custom_rm_4" in scene.radio_materials) + + root = scene._xml_tree.getroot() + bsdfs_in_root = root.findall('bsdf') + bsdfs_in_root = [bsdf.get('id') for bsdf in bsdfs_in_root] + self.assertTrue("mat-custom_rm_1" in bsdfs_in_root) + self.assertTrue("mat-custom_rm_2" in bsdfs_in_root) + self.assertTrue("mat-mat-custom_rm_3" in bsdfs_in_root) + self.assertTrue("mat-custom_rm_4" in bsdfs_in_root) + + def test_new_radio_material_assignation_to_scene_object(self): + """Check that the assignation of a new radio_material (i.e. not present at scene init) to a scene object is working.""" + scene = load_scene(sionna.rt.scene.floor_wall) + rm = RadioMaterial('new') + scene.add(rm) + scene_obj = scene.get('floor') + scene_obj_mi_shape = scene_obj.mi_shape + scene_obj.radio_material = rm + + concrete = scene.get('itu_concrete') + self.assertTrue(scene_obj.radio_material == rm) + self.assertTrue(scene_obj.object_id not in concrete.using_objects) + self.assertTrue(scene_obj.object_id in rm.using_objects) + + scene.reload() + new_scene_obj = scene.get('floor') + self.assertTrue(new_scene_obj.radio_material == rm) + self.assertFalse(new_scene_obj.object_id in concrete.using_objects) + self.assertTrue(new_scene_obj.object_id in rm.using_objects) + self.assertTrue(new_scene_obj is scene_obj) + self.assertFalse(new_scene_obj.mi_shape is scene_obj_mi_shape) + +class TestObjectUsingMatBSDFSync(unittest.TestCase): + """Tests related to synchronization of objects_using between material and BSDF""" + + def setUp(self): + self.scene = load_scene(sionna.rt.scene.floor_wall) + + def test_initial_state_of_materials_and_bsdfs(self): + """Test initial state of materials and their BSDFs""" + itu_concrete = self.scene.get('itu_concrete') + itu_brick = self.scene.get('itu_brick') + + self.assertTrue(itu_concrete.is_used) + self.assertTrue(itu_concrete.bsdf.is_used) + self.assertTrue(itu_brick.is_used) + self.assertTrue(itu_brick.bsdf.is_used) + self.assertEqual(itu_concrete.using_objects.numpy().tolist(), itu_concrete.bsdf.using_objects.numpy().tolist()) + self.assertEqual(itu_brick.using_objects.numpy().tolist(), itu_brick.bsdf.using_objects.numpy().tolist()) + + def test_assigning_radio_material(self): + """Test assigning one RadioMaterial to another. The assign method should not copy the object_using from one mat to the other since they remains distinct materials.""" + itu_concrete = self.scene.get('itu_concrete') + itu_brick = self.scene.get('itu_brick') + + itu_brick.assign(itu_concrete) + + new_itu_concrete = self.scene.get('itu_concrete') + new_itu_brick = self.scene.get('itu_brick') + + self.assertTrue(new_itu_concrete.is_used) + self.assertTrue(new_itu_brick.is_used) + self.assertEqual(new_itu_concrete.using_objects.numpy().tolist(), new_itu_concrete.bsdf.using_objects.numpy().tolist()) + self.assertEqual(new_itu_brick.using_objects.numpy().tolist(), new_itu_brick.bsdf.using_objects.numpy().tolist()) + + self.assertEqual(new_itu_concrete.using_objects.numpy().tolist(), itu_concrete.using_objects.numpy().tolist()) + self.assertEqual(new_itu_brick.using_objects.numpy().tolist(), itu_brick.using_objects.numpy().tolist()) + self.assertEqual(new_itu_concrete.bsdf.using_objects.numpy().tolist(), itu_concrete.bsdf.using_objects.numpy().tolist()) + self.assertEqual(new_itu_brick.bsdf.using_objects.numpy().tolist(), itu_brick.bsdf.using_objects.numpy().tolist()) + + def test_assigning_bsdf_to_material_bsdf(self): + """Test assigning a non-used BSDF to a material's bsdf. This should not copy the object_using from one BSDF to the other since they remains distinct BSDF when using assign method. + The assign method from BSDF class, contrarily to the RadioMaterial class, triggers the reload of the scene by default.""" + bsdf = BSDF("bsdf") + itu_concrete = self.scene.get('itu_concrete') + itu_concrete_obj_using = itu_concrete.using_objects.numpy().tolist() + + self.assertFalse(bsdf.is_used) + self.assertEqual(bsdf.using_objects.numpy().tolist(),[]) + + itu_concrete.bsdf.assign(bsdf) + + new_itu_concrete = self.scene.get('itu_concrete') + + self.assertTrue(new_itu_concrete is itu_concrete) + self.assertTrue(new_itu_concrete.is_used) + self.assertTrue(new_itu_concrete.bsdf.is_used) + self.assertFalse(bsdf.is_used) + self.assertEqual(bsdf.using_objects.numpy().tolist(),[]) + self.assertEqual(new_itu_concrete.using_objects.numpy().tolist(), new_itu_concrete.bsdf.using_objects.numpy().tolist()) + self.assertNotEqual(new_itu_concrete.using_objects.numpy().tolist(),bsdf.using_objects.numpy().tolist()) + self.assertNotEqual(new_itu_concrete.using_objects.numpy().tolist(),itu_concrete_obj_using) + + def test_assigning_used_bsdf_to_material_bsdf(self): + """Test assigning a used BSDF to a material's bsdf""" + itu_concrete = self.scene.get('itu_concrete') + itu_concrete_bsdf = itu_concrete.bsdf + itu_brick = self.scene.get('itu_brick') + itu_brick_bsdf = itu_brick.bsdf + + itu_concrete = self.scene.get('itu_concrete') + itu_concrete_bsdf = itu_concrete.bsdf + itu_concrete_obj_using = itu_concrete.using_objects.numpy().tolist() + itu_brick = self.scene.get('itu_brick') + itu_brick_bsdf = itu_brick.bsdf + itu_brick_obj_using = itu_brick.using_objects.numpy().tolist() + + itu_brick.bsdf.assign(itu_concrete.bsdf) + + new_itu_concrete = self.scene.get('itu_concrete') + new_itu_brick = self.scene.get('itu_brick') + + self.assertTrue(new_itu_concrete is itu_concrete) + self.assertTrue(new_itu_brick is itu_brick) + self.assertTrue(new_itu_concrete.bsdf is itu_concrete_bsdf) + self.assertTrue(new_itu_brick.bsdf is itu_brick_bsdf) + self.assertFalse(new_itu_brick.bsdf is new_itu_concrete.bsdf) + self.assertTrue(new_itu_concrete.is_used) + self.assertTrue(new_itu_brick.is_used) + self.assertTrue(new_itu_concrete.bsdf.is_used) + self.assertTrue(new_itu_brick.bsdf.is_used) + self.assertEqual(new_itu_concrete.using_objects.numpy().tolist(), new_itu_concrete.bsdf.using_objects.numpy().tolist()) + self.assertEqual(new_itu_brick.using_objects.numpy().tolist(), new_itu_brick.bsdf.using_objects.numpy().tolist()) + self.assertNotEqual(new_itu_brick.using_objects.numpy().tolist(), new_itu_concrete.using_objects.numpy().tolist()) + self.assertNotEqual(new_itu_concrete.using_objects.numpy().tolist(), itu_concrete_obj_using) #Because of the scene reload triggered by BSDF assign() + self.assertNotEqual(new_itu_brick.using_objects.numpy().tolist(), itu_brick_obj_using) + + def test_setting_new_bsdf_to_material_bsdf(self): + """Test setting a new BSDF to a material's BSDF""" + bsdf = BSDF("bsdf") + itu_concrete = self.scene.get('itu_concrete') + itu_concrete_bsdf = itu_concrete.bsdf + + self.assertTrue(not bsdf.is_used) + self.assertTrue(bsdf.using_objects.numpy().tolist() == []) + + itu_concrete.bsdf = bsdf + + new_itu_concrete = self.scene.get('itu_concrete') + + self.assertFalse(itu_concrete_bsdf.is_used) + self.assertTrue(new_itu_concrete.is_used) + self.assertTrue(new_itu_concrete.bsdf.is_used) + self.assertTrue(bsdf.is_used) + self.assertEqual(new_itu_concrete.using_objects.numpy().tolist(), bsdf.using_objects.numpy().tolist()) + self.assertEqual(new_itu_concrete.using_objects.numpy().tolist(), new_itu_concrete.bsdf.using_objects.numpy().tolist()) + + def test_setting_used_bsdf_to_material_bsdf(self): + """Check that assigning a used bsdf (i.e. BSDF used by another material too) to another radiomaterial triggers an error""" + itu_concrete = self.scene.get('itu_concrete') + itu_brick= self.scene.get('itu_brick') + + self.assertTrue(itu_concrete.bsdf.is_used) + + with self.assertRaises(ValueError) as context: + itu_brick.bsdf = itu_concrete.bsdf + self.assertEqual(str(context.exception), "Can't set an already used BSDF to another material. Prefer the assign method.") + + + + +class TestSceneReload(unittest.TestCase): + """Tests related to the reloading of a scene""" + + def test_reload_scene_reset_material_object_using(self): + """Check that the scene reload reset the object_using counters of the bsdfs and material, before instantianting new SceneObjects""" + scene = load_scene(sionna.rt.scene.floor_wall) + mat = scene.get("itu_concrete") + bsdf = mat.bsdf + mat_obj_using = mat.using_objects.numpy().tolist() + bsdf_obj_using = bsdf.using_objects.numpy().tolist() + + self.assertTrue(mat_obj_using == bsdf_obj_using) + self.assertTrue(mat.is_used) + self.assertTrue(bsdf.is_used) + + scene.reload() + + new_mat = scene.get("itu_concrete") + new_bsdf = new_mat.bsdf + new_mat_obj_using = new_mat.using_objects.numpy().tolist() + new_bsdf_obj_using = new_bsdf.using_objects.numpy().tolist() + + self.assertTrue(new_mat_obj_using == new_bsdf_obj_using) + self.assertTrue(new_mat.is_used) + self.assertTrue(new_bsdf.is_used) + + self.assertTrue(new_mat is mat) + self.assertTrue(new_bsdf is bsdf) + self.assertTrue(new_mat_obj_using != mat_obj_using) + self.assertTrue(len(new_mat_obj_using) == len(mat_obj_using)) + + def test_reload_scene_keep_scene_object_properties(self): + """Check that when the scene is reloaded, the properties of the scene object, not related to any assets, are kept, + even if they were previously changed by user (e.g. position)""" + + # Manual reload + scene = load_scene(sionna.rt.scene.floor_wall) + + # change scene SceneObject property: + scene_obj = scene.get('floor') + scene_obj_mi_shape = scene_obj.mi_shape + original_pos = scene_obj.position + new_pos = [7,9,1] + + # Position + self.assertTrue(np.equal(scene_obj.position,original_pos).all) + scene_obj.position = new_pos + self.assertTrue(np.equal(scene_obj.position,new_pos).all) + + # Material + self.assertTrue(scene_obj.radio_material == scene.get('itu_concrete')) + scene_obj.radio_material = 'itu_glass' + self.assertTrue(scene_obj.radio_material == scene.get('itu_glass')) + + # Reload + self.assertTrue(scene._bypass_reload != True) + scene.reload() + new_scene_obj = scene.get('floor') + + # Check position after reload + self.assertTrue(new_scene_obj is scene_obj) + self.assertFalse(new_scene_obj.mi_shape is scene_obj_mi_shape) + self.assertTrue(np.equal(scene_obj.position,new_pos).all) + self.assertTrue(np.equal(new_scene_obj.position,new_pos).all) + + # Check material after reload + self.assertTrue(scene_obj.radio_material == scene.get('itu_glass')) + self.assertTrue(new_scene_obj.radio_material == scene.get('itu_glass')) + + # Auto reload (e.g. when adding an asset) + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) + + # change scene SceneObject property: + scene_obj = scene.get('floor') + scene_obj_mi_shape = scene_obj.mi_shape + original_pos = scene_obj.position + new_pos = [7,9,1] + + # Position + self.assertTrue(np.equal(scene_obj.position,original_pos).all) + scene_obj.position = new_pos + self.assertTrue(np.equal(scene_obj.position,new_pos).all) + + # Material + self.assertTrue(scene_obj.radio_material == scene.get('itu_concrete')) + scene_obj.radio_material = 'itu_glass' + self.assertTrue(scene_obj.radio_material == scene.get('itu_glass')) + + # Reload by adding an asset + self.assertTrue(scene._bypass_reload != True) + scene.add(asset) + new_scene_obj = scene.get('floor') + + # Check position after reload + self.assertTrue(new_scene_obj is scene_obj) + self.assertFalse(new_scene_obj.mi_shape is scene_obj_mi_shape) + self.assertTrue(np.equal(scene_obj.position,new_pos).all) + self.assertTrue(np.equal(new_scene_obj.position,new_pos).all) + + # Check material after reload + self.assertTrue(scene_obj.radio_material == scene.get('itu_glass')) + self.assertTrue(new_scene_obj.radio_material == scene.get('itu_glass')) + + + def test_scene_reload(self): + """Check that the scene is properly reloaded when necessary and only when necessary""" + # Reload when: + # - After adding assets + scene = load_scene(sionna.rt.scene.floor_wall) + asset = AssetObject(name="asset_0", filename=sionna.rt.asset_object.test_asset_1) # create a metal asset + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.add(asset) + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + # - After removing assets + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.remove("asset_0") + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + # - After changing/setting bsdf + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + metal = scene.get("itu_metal") + metal.bsdf.rgb = (0.5,0.2,0.9) + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + # - After manually calling scene.reload() + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + scene.reload() + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape != scene.get("floor").mi_shape) + + # - Not when changing material of an object (user has to manually trigger the reload): + ref_obj = scene.get("floor") + ref_obj_mi_shape = ref_obj.mi_shape + ref_obj.radio_material = "itu_glass" + self.assertTrue(ref_obj == scene.get("floor")) + self.assertTrue(ref_obj_mi_shape == scene.get("floor").mi_shape) + + def test_scene_reload_via_ray(self): + """Check that the scene is properly reloaded and the propagation properties are thus changed""" + scene = load_scene() # Load empty scene + scene.tx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + scene.rx_array = PlanarArray(1,1,0.5,0.5,"iso","V") + + ## The test cube(s) are two 1x1x1m cubes, centered in (0,0,0), spaced by 1 m along the y-axis + cube_edge_length = 1 + cubes_separation = 1 + asset = AssetObject(name="reflector", filename=sionna.rt.asset_object.test_asset_1, position=[0,-cubes_separation,-cube_edge_length/2]) # we shift the asset so that the face of the metal cube is aligned with the xy-plane and center in (0,0) + + d0 = 100 + scene.add(Transmitter("tx", position=[0,+1,d0])) + scene.add(Receiver("rx", position=[0,-1,d0])) + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + self.assertTrue(len(tf.squeeze(cir[0])) == 0) + + scene.add(asset) + + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + self.assertTrue(len(tf.squeeze(cir[0])) == 2 ) + + scene.remove("reflector") + + paths = scene.compute_paths(los=False, diffraction=False, max_depth=1, method="exhaustive") + paths.normalize_delays = False + cir = paths.cir() + self.assertTrue(len(tf.squeeze(cir[0])) == 0) \ No newline at end of file diff --git a/test/unit/rt/test_xml.py b/test/unit/rt/test_xml.py new file mode 100644 index 00000000..b2de8d08 --- /dev/null +++ b/test/unit/rt/test_xml.py @@ -0,0 +1,478 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 ORANGE - Author: Guillaume Larue . All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +try: + import sionna +except ImportError as e: + import sys + sys.path.append("..") + import sionna + +import pytest +import unittest +import numpy as np +import tensorflow as tf +import xml.etree.ElementTree as ET +import sys + +from sionna.rt import * + +gpus = tf.config.list_physical_devices('GPU') +print('Number of GPUs available :', len(gpus)) +if gpus: + gpu_num = 0 + try: + tf.config.set_visible_devices(gpus[gpu_num], 'GPU') + print('Only GPU number', gpu_num, 'used.') + tf.config.experimental.set_memory_growth(gpus[gpu_num], True) + except RuntimeError as e: + print(e) + + +class TestXMLChange(unittest.TestCase): + """Tests related to the change of the XML file""" + + def test_append_new_shape_to_xml(self): + """Check that the scene.append_to_xml() method works when adding a new shape""" + shape = ET.Element('shape', attrib={'type': 'ply', 'id': 'mesh-new'}) + ET.SubElement(shape, 'string', attrib={'name': 'filename', 'value': 'meshes/wall.ply'}) + ET.SubElement(shape, 'boolean', attrib={'name': 'face_normals', 'value': 'true'}) + ET.SubElement(shape, 'ref', attrib={'id': 'mat-itu_glass', 'name': 'bsdf'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-new' in ids_in_root) + + out = scene.append_to_xml(shape, overwrite=False) + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-new' in ids_in_root) + self.assertTrue(out is None) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-new' in ids_in_root) + + out = scene.append_to_xml(shape, overwrite=True) + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-new' in ids_in_root) + self.assertTrue(out is None) + + + def test_append_new_bsdf_to_xml(self): + """Check that the scene.append_to_xml() method works when adding a new bsdf""" + bsdf = ET.Element('bsdf', attrib={'type': 'diffuse', 'id': 'bsdf-new', 'name': 'bsdf-new'}) + ET.SubElement(bsdf, 'rgb', attrib={'value': '1.000000 0.000000 0.300000', 'name': 'reflectance'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('bsdf-new' in ids_in_root) + + out = scene.append_to_xml(bsdf, overwrite=False) + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('bsdf-new' in ids_in_root) + self.assertTrue(out is None) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('bsdf-new' in ids_in_root) + + out = scene.append_to_xml(bsdf, overwrite=True) + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('bsdf-new' in ids_in_root) + self.assertTrue(out is None) + + def test_append_other_to_xml(self): + """Check that the scene.append_to_xml() method does not accept non bsdf or shape elements""" + other_elt = ET.Element('other', attrib={'type': 'diffuse', 'id': 'other_elt', 'name': 'other_elt'}) + ET.SubElement(other_elt, 'boolean', attrib={'name': 'other', 'value': 'false'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('other') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('other_elt' in ids_in_root) + + with self.assertRaises(ValueError) as context: + out = scene.append_to_xml(other_elt, overwrite=False) + self.assertEqual(str(context.exception), "`element` must be an instance of `ET.Element` of type or ") + + elements_in_root = root.findall('other') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('other_elt' in ids_in_root) + + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('other') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('other_elt' in ids_in_root) + + with self.assertRaises(ValueError) as context: + out = scene.append_to_xml(other_elt, overwrite=True) + self.assertEqual(str(context.exception), "`element` must be an instance of `ET.Element` of type or ") + + elements_in_root = root.findall('other') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('other_elt' in ids_in_root) + + + def test_append_existing_shape_to_xml(self): + """Check that the scene.append_to_xml() method does not change the XML when adding an existing shape with 'overwrite' argumnt set to False""" + shape = ET.Element('shape', attrib={'type': 'ply', 'id': 'mesh-floor'}) + ET.SubElement(shape, 'string', attrib={'name': 'filename', 'value': 'meshes/wall.ply'}) + ET.SubElement(shape, 'boolean', attrib={'name': 'face_normals', 'value': 'true'}) + ET.SubElement(shape, 'ref', attrib={'id': 'mat-itu_glass', 'name': 'bsdf'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + + for elt in elements_in_root: + if elt.get('id') == 'mesh-floor': + break + + with self.assertWarns(UserWarning) as context: + out = scene.append_to_xml(shape, overwrite=False) + self.assertTrue(ET.tostring(elt, encoding='unicode') == ET.tostring(out, encoding='unicode')) + self.assertEqual(str(context.warning), "Element of type shape with id: mesh-floor is already present in xml file. Set 'overwrite=True' to overwrite.") + + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + for new_elt in elements_in_root: + if new_elt.get('id') == 'mesh-floor': + break + self.assertTrue('mesh-floor' in ids_in_root) + self.assertTrue(ET.tostring(elt, encoding='unicode') == ET.tostring(new_elt, encoding='unicode')) + + + def test_append_existing_shape_to_xml_overwrite(self): + """Check that the scene.append_to_xml() method does change the XML when adding an existing shape with 'overwrite' argumnt set to True""" + shape = ET.Element('shape', attrib={'type': 'ply', 'id': 'mesh-floor'}) + ET.SubElement(shape, 'string', attrib={'name': 'filename', 'value': 'meshes/wall.ply'}) + ET.SubElement(shape, 'boolean', attrib={'name': 'face_normals', 'value': 'true'}) + ET.SubElement(shape, 'ref', attrib={'id': 'mat-itu_glass', 'name': 'bsdf'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + + for elt in elements_in_root: + if elt.get('id') == 'mesh-floor': + break + + with self.assertWarns(UserWarning) as context: + out = scene.append_to_xml(shape, overwrite=True) + self.assertTrue(ET.tostring(elt, encoding='unicode')== ET.tostring(out, encoding='unicode')) + self.assertEqual(str(context.warning), "Element of type shape with id: mesh-floor is already present in xml file. Overwriting with new element.") + + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + for new_elt in elements_in_root: + if new_elt.get('id') == 'mesh-floor': + break + self.assertTrue('mesh-floor' in ids_in_root) + self.assertTrue(ET.tostring(elt, encoding='unicode') != ET.tostring(new_elt, encoding='unicode')) + + + def test_append_existing_bsdf_to_xml(self): + """Check that the scene.append_to_xml() method does not change the XML when adding an existing bsdf with 'overwrite' argumnt set to False""" + bsdf = ET.Element('bsdf', attrib={'type': 'diffuse', 'id': 'mat-itu_concrete', 'name': 'mat-itu_concrete'}) + ET.SubElement(bsdf, 'rgb', attrib={'value': '1.000000 0.000000 0.300000', 'name': 'reflectance'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mat-itu_concrete' in ids_in_root) + + for elt in elements_in_root: + if elt.get('id') == 'mat-itu_concrete': + break + + with self.assertWarns(UserWarning) as context: + out = scene.append_to_xml(bsdf, overwrite=False) + self.assertTrue(ET.tostring(elt, encoding='unicode') == ET.tostring(out, encoding='unicode')) + self.assertEqual(str(context.warning), "Element of type bsdf with id: mat-itu_concrete is already present in xml file. Set 'overwrite=True' to overwrite.") + + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + for new_elt in elements_in_root: + if new_elt.get('id') == 'mat-itu_concrete': + break + self.assertTrue('mat-itu_concrete' in ids_in_root) + self.assertTrue(ET.tostring(elt, encoding='unicode') == ET.tostring(new_elt, encoding='unicode')) + + + def test_append_existing_bsdf_to_xml_overwrite(self): + """Check that the scene.append_to_xml() method does change the XML when adding an existing bsdf with 'overwrite' argumnt set to True""" + bsdf = ET.Element('bsdf', attrib={'type': 'diffuse', 'id': 'mat-itu_concrete', 'name': 'mat-itu_concrete'}) + ET.SubElement(bsdf, 'rgb', attrib={'value': '1.000000 0.000000 0.300000', 'name': 'reflectance'}) + + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mat-itu_concrete' in ids_in_root) + + for elt in elements_in_root: + if elt.get('id') == 'mat-itu_concrete': + break + + with self.assertWarns(UserWarning) as context: + out = scene.append_to_xml(bsdf, overwrite=True) + self.assertTrue(ET.tostring(elt, encoding='unicode')== ET.tostring(out, encoding='unicode')) + self.assertEqual(str(context.warning), "Element of type bsdf with id: mat-itu_concrete is already present in xml file. Overwriting with new element.") + + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + for new_elt in elements_in_root: + if new_elt.get('id') == 'mat-itu_concrete': + break + self.assertTrue('mat-itu_concrete' in ids_in_root) + self.assertTrue(ET.tostring(elt, encoding='unicode') != ET.tostring(new_elt, encoding='unicode')) + + + + def test_remove_existing_shape_from_xml(self): + """Check that the scene.remove_from_xml() method does change the XML when removing an existing shape without specyfing 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + + scene.remove_from_xml('mesh-floor') + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-floor' in ids_in_root) + + def test_remove_existing_shape_from_xml_correct_type(self): + """Check that the scene.remove_from_xml() method does change the XML when removing an existing shape while specyfing the correct 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + + scene.remove_from_xml('mesh-floor', element_type='shape') + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-floor' in ids_in_root) + + def test_remove_existing_shape_from_xml_wrong_type(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing an existing shape while specyfing the wrong 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('mesh-floor', element_type='bsdf') + self.assertEqual(str(context.warning), "No bsdf element with name mesh-floor in root to remove.") + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + + def test_remove_existing_bsdf_from_xml(self): + """Check that the scene.remove_from_xml() method does change the XML when removing an existing bsdf without specyfing 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mat-itu_concrete' in ids_in_root) + + scene.remove_from_xml('mat-itu_concrete') + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mat-itu_concrete' in ids_in_root) + + def test_remove_existing_bsdf_from_xml_correct_type(self): + """Check that the scene.remove_from_xml() method does change the XML when removing an existing bsdf while specyfing the correct 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mat-itu_concrete' in ids_in_root) + + scene.remove_from_xml('mat-itu_concrete', element_type='bsdf') + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mat-itu_concrete' in ids_in_root) + + def test_remove_existing_bsdf_from_xml_wrong_type(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing an existing bsdf while specyfing the wrong 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mat-itu_concrete' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('mat-itu_concrete', element_type='shape') + self.assertEqual(str(context.warning), "No shape element with name mat-itu_concrete in root to remove.") + elements_in_root = root.findall('bsdf') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mat-itu_concrete' in ids_in_root) + + def test_remove_existing_invalid_from_xml(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing an existing invalid element without specyfing 'element_type' argument""" + # Remove existing other invalid type (without specyfing type) + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('sensor') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('Camera' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('Camera') + self.assertEqual(str(context.warning), "No ['bsdf', 'shape'] element with name Camera in root to remove.") + elements_in_root = root.findall('sensor') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('Camera' in ids_in_root) + + def test_remove_existing_invalid_from_xml_correct_type(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing an existing invalid element while specyfing correct (but invalid)'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('sensor') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('Camera' in ids_in_root) + + with self.assertRaises(ValueError) as context: + scene.remove_from_xml('Camera', element_type='sensor') + self.assertEqual(str(context.exception), "`element_type` must be string. Valid types are ['bsdf', 'shape'].") + elements_in_root = root.findall('sensor') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('Camera' in ids_in_root) + + def test_remove_existing_invalid_from_xml_correct_type(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing an existing invalid element while specyfing incorrect (but valid)'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('sensor') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('Camera' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('Camera', element_type='shape') + self.assertEqual(str(context.warning), "No shape element with name Camera in root to remove.") + elements_in_root = root.findall('sensor') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('Camera' in ids_in_root) + + + def test_remove_non_existing_shape_from_xml(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing a non-existing shape without specyfing 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-wrong' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('mesh-wrong') + self.assertEqual(str(context.warning), "No ['bsdf', 'shape'] element with name mesh-wrong in root to remove.") + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-wrong' in ids_in_root) + + def test_remove_non_existing_shape_from_xml_correct_type(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing a non-existing shape while specyfing the correct 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-wrong' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('mesh-wrong', element_type='shape') + self.assertEqual(str(context.warning), "No shape element with name mesh-wrong in root to remove.") + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-wrong' in ids_in_root) + + def test_remove_non_existing_shape_from_xml_wrong_type(self): + """Check that the scene.remove_from_xml() method does not change the XML when removing a non-existing shape while specyfing the wrong 'element_type' argument""" + scene = load_scene(sionna.rt.scene.floor_wall) + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-wrong' in ids_in_root) + + with self.assertWarns(UserWarning) as context: + scene.remove_from_xml('mesh-wrong', element_type='bsdf') + self.assertEqual(str(context.warning), "No bsdf element with name mesh-wrong in root to remove.") + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertFalse('mesh-wrong' in ids_in_root) + + def test_update_shape_bsdf_xml(self): + """Check that the scene.update_shape_bsdf_xml() method does change the XML when specifying an existing shape name""" + scene = load_scene(sionna.rt.scene.floor_wall) + root = scene._xml_tree.getroot() + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + for elt in elements_in_root: + if elt.get('id') == 'mesh-floor': + break + ref = elt.find('ref') + bsdf = ref.get('id') + self.assertTrue(bsdf == 'mat-itu_concrete') + + scene.update_shape_bsdf_xml(shape_name='mesh-floor',bsdf_name='mat-itu_glass') + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + for elt in elements_in_root: + if elt.get('id') == 'mesh-floor': + break + ref = elt.find('ref') + bsdf = ref.get('id') + self.assertTrue(bsdf == 'mat-itu_glass') + + scene.update_shape_bsdf_xml(shape_name='floor',bsdf_name='mat-itu_wood') + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + for elt in elements_in_root: + if elt.get('id') == 'mesh-floor': + break + ref = elt.find('ref') + bsdf = ref.get('id') + self.assertTrue(bsdf == 'mat-itu_wood') + + scene.update_shape_bsdf_xml(shape_name='floor',bsdf_name='plastic') + elements_in_root = root.findall('shape') + ids_in_root = [elt.get('id') for elt in elements_in_root] + self.assertTrue('mesh-floor' in ids_in_root) + for elt in elements_in_root: + if elt.get('id') == 'mesh-floor': + break + ref = elt.find('ref') + bsdf = ref.get('id') + self.assertTrue(bsdf == 'mat-plastic') + + + with self.assertWarns(UserWarning) as context: + scene.update_shape_bsdf_xml(shape_name='wrong',bsdf_name='plastic') + self.assertEqual(str(context.warning), "No shape element with name mesh-wrong in root to update.") + \ No newline at end of file diff --git a/test/unit/signal/__init__.py b/test/unit/signal/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/unit/utils/__init__.py b/test/unit/utils/__init__.py new file mode 100644 index 00000000..e69de29b