Note
Go to the end to download the full example code.
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_()

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