Interactive example with Qt interface

This example provides an interactive experience to most of the features proposed by this module.

Warning

As of writing this, the FPT and MPT path methods will not show the expected coverage (which image usually does). This is mainly an issue caused by the use of only 100 steps for the minimization, which may not be necessary to properly converge.

Also, you will observe that the coverage map changes a lot between two position, this is because the random key used to initialize the minimization process changes on each update.

Requirements

This example requires additional Python modules, namely qtpy and Qt5 bindings (we recommend using PySide6). You can install the necessary modules with pip install qtpy pyside6.

Usage

Running this example is as simple as python examples/qt-interactive.py.

However, you can specify a variety of parameters directly when calling the CLI. See python examples/qt-interactive.py --help.

Getting help

Using the graphical interface should be straightforward.

On top of that, a lot of widgets display contextual information if you hover over them.

Imports

First, we need to import the necessary modules. Note that most imports are only required to create the graphical interface, not actually doing the ray tracing.

from argparse import ArgumentParser, FileType
from functools import partial
from typing import TYPE_CHECKING, Literal, get_args

import jax
import jax.numpy as jnp
from jaxtyping import Array, Float
from matplotlib.backend_bases import MouseEvent, PickEvent
from matplotlib.backends.backend_qtagg import (
    FigureCanvas,  # type: ignore[reportAttributeAccessIssue]
    NavigationToolbar2QT,  # type: ignore[reportPrivateImportUsage]
)
from matplotlib.figure import Figure
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
    QApplication,
    QComboBox,
    QGridLayout,
    QGroupBox,
    QLabel,
    QSlider,
    QSpinBox,
    QVBoxLayout,
    QWidget,
)

from differt2d.abc import Loc
from differt2d.defaults import DEFAULT_ALPHA, DEFAULT_PATCH
from differt2d.geometry import FermatPath, ImagePath, MinPath, Point
from differt2d.logic import hard_sigmoid, sigmoid
from differt2d.scene import Scene, SceneName
from differt2d.utils import P0, received_power

if TYPE_CHECKING:
    from matplotlib.artist import Artist

METHOD_TO_PATH_CLASS = {"image": ImagePath, "FPT": FermatPath, "MPT": MinPath}

GUI classes

The following defines all GUI-related classes and methods, needed to display the application.

class CustomSlider(QSlider):
    def __init__(
        self,
        start: float = 0,
        stop: float = 99,
        num: int = 100,
        base: float = 10.0,
        scale: Literal["linear", "log", "geom"] = "linear",
        orientation=Qt.Orientation.Horizontal,
    ) -> None:
        super().__init__()
        self.setMinimum(0)
        self.setMaximum(num - 1)
        self.setOrientation(orientation)

        if scale == "linear":
            self.values = jnp.linspace(start, stop, num=num)
        elif scale == "log":
            self.values = jnp.logspace(start, stop, num=num, base=base)
        elif scale == "geom":
            self.values = jnp.geomspace(start, stop, num=num)
        else:
            raise ValueError("Invalid scale: " + scale)

    def value(self) -> float:  # type: ignore[reportIncompatibleMethodOverride]
        index = super().value()
        return float(self.values[index])

    def maximum(self):  # type: ignore[reportIncompatibleMethodOverride]
        return float(self.values[-1])

    def minimum(self):  # type: ignore[reportIncompatibleMethodOverride]
        return float(self.values[0])

    def setValue(self, value: float) -> None:  # type: ignore[reportIncompatibleMethodOverride]
        index = jnp.abs(self.values - value).argmin()
        super().setValue(int(index))


LinearSlider = partial(CustomSlider, scale="linear")
LogSlider = partial(CustomSlider, scale="log")
GeomSlider = partial(CustomSlider, scale="geom")


class PlotWidget(QWidget):
    def __init__(
        self,
        scene: Scene,
        resolution: int,
        seed: int,
        parent=None,
    ) -> None:
        super().__init__(parent)

        self.scene = scene
        self.min_order = 0
        self.max_order = 1
        self.patch = DEFAULT_PATCH
        self.approx = False
        self.alpha = DEFAULT_ALPHA
        self.function = hard_sigmoid
        self.r_coef = 0.5
        self.path_cls = METHOD_TO_PATH_CLASS["image"]
        self.key = jax.random.PRNGKey(seed)

        assert len(scene.transmitters) == 1, (
            "This simulation only supports one transmitter"
        )
        assert len(scene.receivers) == 1, "This simulation only supports one receiver"

        # -- Create widgets --

        # Fix ToolTip with dark theme issue

        self.setStyleSheet(
            """
            QToolTip {
                background-color: #ea5626;
            }""",
        )

        # Approx. parameters
        approx_box = QGroupBox("Enable approx.")
        approx_box.setCheckable(True)
        approx_box.setChecked(False)
        approx_box.setToolTip(
            "Click to enable/disable approximation. "
            "When enabled, you can specify further parameters below.",
        )

        def set_approx(approx) -> None:
            self.approx = approx
            self.on_scene_change()

        approx_box.toggled.connect(set_approx)

        grid = QGridLayout()
        approx_box.setLayout(grid)
        self.alpha_slider = LogSlider(0, 3)
        self.alpha_slider.setValue(self.alpha)
        self.alpha_slider.setToolTip(
            "The alpha value, as used by the activation function",
        )
        self.alpha_label = QLabel("1.000e+00")

        grid.addWidget(QLabel("alpha:"), 1, 1)
        grid.addWidget(self.alpha_label, 1, 2)
        grid.addWidget(self.alpha_slider, 1, 3)

        def set_alpha(_) -> None:
            alpha = self.alpha_slider.value()
            self.alpha = alpha
            self.alpha_label.setText(f"{alpha:.3e}")
            self.on_scene_change()

        self.alpha_slider.valueChanged.connect(set_alpha)

        self.function_combo_box = QComboBox()
        self.function_combo_box.addItems(["sigmoid", "hard_sigmoid"])
        self.function_combo_box.setToolTip(" The activation function")
        self.function_combo_box.setCurrentText("hard_sigmoid")

        grid.addWidget(QLabel("activation function:"), 2, 1)
        grid.addWidget(self.function_combo_box, 2, 3)

        def set_function(function) -> None:
            self.function = {
                "sigmoid": sigmoid,
                "hard_sigmoid": hard_sigmoid,
            }[function]
            self.on_scene_change()

        self.function_combo_box.currentTextChanged.connect(set_function)

        # General settings
        settings_box = QGroupBox("General Setting")

        grid = QGridLayout()
        settings_box.setLayout(grid)
        self.patch_slider = LinearSlider(-1.0, 1.0)
        self.patch_slider.setValue(0.0)
        self.patch_slider.setToolTip(
            "The patch value used to virtually increase/decrease objects' "
            "size when checking for intersection",
        )
        self.patch_label = QLabel("+0.00")

        grid.addWidget(QLabel("patch:"), 1, 1)
        grid.addWidget(self.patch_label, 1, 2)
        grid.addWidget(self.patch_slider, 1, 3)

        def set_patch(_) -> None:
            patch = self.patch_slider.value()
            self.patch = patch
            self.patch_label.setText(f"{patch:+.2f}")
            self.on_scene_change()

        self.patch_slider.valueChanged.connect(set_patch)

        self.min_order_spin_box = QSpinBox()
        self.min_order_spin_box.setMinimum(0)
        self.min_order_spin_box.setToolTip(
            "The minimum interaction order, 0 is line of sight.",
        )
        self.min_order_spin_box.setValue(self.min_order)

        grid.addWidget(QLabel("min. order:"), 2, 1)
        grid.addWidget(self.min_order_spin_box, 2, 3)

        def set_min_order(min_order: int) -> None:
            self.min_order = min_order
            self.on_scene_change()

        self.min_order_spin_box.valueChanged.connect(set_min_order)

        self.max_order_spin_box = QSpinBox()
        self.max_order_spin_box.setMinimum(0)
        self.max_order_spin_box.setToolTip(
            "The maximum interaction order, 1 is one reflection max.",
        )
        self.max_order_spin_box.setValue(self.max_order)

        grid.addWidget(QLabel("max. order:"), 3, 1)
        grid.addWidget(self.max_order_spin_box, 3, 3)

        def set_max_order(max_order: int) -> None:
            self.max_order = max_order
            self.on_scene_change()

        self.max_order_spin_box.valueChanged.connect(set_max_order)

        self.r_coef_slider = LinearSlider(0.0, 1.0)
        self.r_coef_slider.setValue(self.r_coef)
        self.r_coef_slider.setToolTip(
            "The reflection coefficient, expressed as a real value between 0 and 1.",
        )
        self.r_coef_label = QLabel("0.50")

        grid.addWidget(QLabel("refl. coef.:"), 4, 1)
        grid.addWidget(self.r_coef_label, 4, 2)
        grid.addWidget(self.r_coef_slider, 4, 3)

        def set_r_coef(_) -> None:
            r_coef = self.r_coef_slider.value()
            self.r_coef = r_coef
            self.r_coef_label.setText(f"{r_coef:.2f}")
            self.on_scene_change()

        self.r_coef_slider.valueChanged.connect(set_r_coef)

        self.path_cls_combo_box = QComboBox()
        self.path_cls_combo_box.addItems(METHOD_TO_PATH_CLASS.keys())  # type: ignore
        self.path_cls_combo_box.setToolTip(
            "The path method that is used to determine each ray path. "
            "Note that each next method is much slower than the previous, "
            "but can simulate more complex interaction types, "
            "like diffraction, not implemented at the moment.",
        )
        self.path_cls_combo_box.setCurrentText("image")

        grid.addWidget(QLabel("method:"), 5, 1)
        grid.addWidget(self.path_cls_combo_box, 5, 3)

        def set_path_cls(method: str) -> None:
            self.path_cls = METHOD_TO_PATH_CLASS[method]
            self.on_scene_change()

        self.path_cls_combo_box.currentTextChanged.connect(set_path_cls)

        # Matplotlib figures
        self.fig = Figure(figsize=(10, 10), tight_layout=True)
        self.view = FigureCanvas(self.fig)
        self.view.setMinimumHeight(200)
        self.ax = self.fig.add_subplot()

        # Toolbar above the figure
        self.toolbar = NavigationToolbar2QT(self.view, self)

        # Figures and toolbar

        main_layout = QVBoxLayout()

        main_layout.addWidget(self.toolbar)
        main_layout.addWidget(self.view)
        main_layout.addWidget(approx_box)
        main_layout.addWidget(settings_box)

        self.setLayout(main_layout)

        self.X, self.Y = self.scene.grid(m=resolution, n=resolution)

        cm = self.coverage_map = self.ax.pcolormesh(
            self.X,
            self.Y,
            jnp.zeros_like(self.X),
            zorder=-2,
            vmin=-50,
            vmax=5,
        )
        cbar = self.fig.colorbar(cm, ax=self.ax)
        cbar.ax.set_ylabel("Power (dB)")

        scene.plot(
            ax=self.ax,
            annotate=False,
            transmitters_kwargs={"picker": True},
            receivers_kwargs={"picker": True},
        )

        self.view.mpl_connect("pick_event", self.on_pick_event)
        self.view.mpl_connect("motion_notify_event", self.on_motion_notify_event)

        self.picked = None

        self.path_artists: list[Artist] = []

        def f(rx_coords: Float[Array, "2"]) -> Float[Array, " "]:
            scene = self.scene.with_transmitters(rx=Point(xy=rx_coords))

            self.key, key = jax.random.split(self.key, 2)

            return scene.accumulate_over_paths(
                fun=partial(received_power, r_coef=self.r_coef),
                reduce_all=True,
                min_order=self.min_order,
                max_order=self.max_order,
                path_cls=self.path_cls,
                key=key,
            )

        self.f_and_df = jax.value_and_grad(f)

        xlim, ylim = self.scene.bounding_box().T
        self.ax.set_xlim(*xlim)
        self.ax.set_ylim(*ylim)
        self.ax.autoscale(False, axis="x")
        self.ax.autoscale(False, axis="y")

        self.on_scene_change()

    def on_pick_event(self, event: PickEvent) -> None:
        if self.picked:
            self.picked = None
        else:
            artist = event.artist
            coords = jnp.array(artist.get_xydata()).reshape(-1)  # type: ignore
            tx, dist_tx = self.scene.get_closest_transmitter(coords)
            rx, dist_rx = self.scene.get_closest_receiver(coords)

            if dist_tx < dist_rx:
                self.picked = (event.artist, tx)
                self.picked_tx = True
            else:
                self.picked = (event.artist, rx)
                self.picked_tx = False

    def on_motion_notify_event(self, event: MouseEvent) -> None:
        if self.picked and event.xdata is not None and event.ydata is not None:
            artist, point_name = self.picked
            if self.picked_tx:
                self.scene = self.scene.update_transmitters(
                    **{point_name: Point(xy=jnp.array([event.xdata, event.ydata]))},
                )
            else:
                self.scene = self.scene.update_receivers(
                    **{point_name: Point(xy=jnp.array([event.xdata, event.ydata]))},
                )
            artist.set_xdata([event.xdata])  # type: ignore
            artist.set_ydata([event.ydata])  # type: ignore
            self.on_scene_change()

    def on_scene_change(self) -> None:
        for artist in self.path_artists:
            artist.remove()

        self.path_artists = []

        self.key, key_acc, key_all = jax.random.split(self.key, 3)

        P: Float[Array, "n n"] = self.scene.accumulate_on_receivers_grid_over_paths(
            self.X,
            self.Y,
            fun=partial(received_power, r_coef=self.r_coef),
            reduce_all=True,
            min_order=self.min_order,
            max_order=self.max_order,
            patch=self.patch,
            approx=self.approx,
            alpha=self.alpha,
            function=self.function,
            path_cls=self.path_cls,
            key=key_acc,
        )  # type: ignore

        PdB = 10.0 * jnp.log10(P / P0)

        self.coverage_map.set_array(PdB)

        for _, _, valid, path, _ in self.scene.all_paths(
            min_order=self.min_order,
            max_order=self.max_order,
            patch=self.patch,
            approx=self.approx,
            alpha=self.alpha,
            function=self.function,
            path_cls=self.path_cls,
            key=key_all,
        ):
            self.path_artists.extend(path.plot(self.ax, zorder=-1, alpha=float(valid)))

        if False:
            _, point = self.picked

            x, y = rx_coords = point.xy

            _p, dp = self.f_and_df(rx_coords)

            ndp = jnp.linalg.norm(dp)

            if ndp > 0:
                dp = dp / ndp

            self.path_artists.extend(self.ax.quiver([x], [y], [dp[0]], [dp[1]]))

        self.view.draw()

CLI options

This part is not very interesting, and uses the builtin argparse module to create a set of command-line options and parse them.

if __name__ == "__main__":
    parser = ArgumentParser(
        prog="interactive-example",
        description="DiffeRT2d's interactive example.",
        epilog="This example shows most of the features available in this "
        "Python module. Feel free to modify the various parameters using "
        "the sliders and other widgets.",
    )
    parser.add_argument(
        "--scene",
        metavar="NAME",
        default="basic_scene",
        choices=get_args(SceneName),
        help="select scene by name (default: basic_scene, "
        f"allowed: {', '.join(get_args(SceneName))})",
        dest="scene_name",
    )
    parser.add_argument(
        "--resolution",
        metavar="INT",
        type=int,
        default=150,
        choices=range(1, 999999),
        help="set the grid resolution (default: 150, allowed: 1 to 999999 excl.)",
    )
    parser.add_argument(
        "--seed",
        metavar="INT",
        type=int,
        default=1234,
        choices=range(1, 999999),
        help="set the grid resolution (default: 1234, allowed: 1 to 999999 excl.)",
    )
    parser.add_argument(
        "--file",
        metavar="PATH",
        type=FileType("r"),
        default=None,
        help="if present, read the scene from the specified file path",
    )
    parser.add_argument(
        "--tx-loc",
        metavar="Loc",
        default="NW",
        choices=get_args(Loc),
        help="when file is set, set the transmitter location (default: NW, "
        f"allowed: {', '.join(get_args(Loc))})",
    )
    parser.add_argument(
        "--rx-loc",
        metavar="Loc",
        default="SE",
        choices=get_args(Loc),
        help="when file is set, set the receiver location (default: SE, "
        f"allowed: {', '.join(get_args(Loc))})",
    )
    args = parser.parse_args()

    if args.file:
        scene = Scene.from_geojson(
            args.file.read(),
            tx_loc=args.tx_loc,
            rx_loc=args.rx_loc,
        )
    else:
        scene = Scene.from_scene_name(args.scene_name)

    app = QApplication.instance()

    if app is None:
        app = QApplication([])

    plot_widget = PlotWidget(
        scene=scene,
        resolution=args.resolution,
        seed=args.seed,
    )
    plot_widget.show()
    app.exec_()
qt interactive

Total running time of the script: (0 minutes 3.363 seconds)

Gallery generated by Sphinx-Gallery