diff --git a/waveform_editor/gui/shape_editor/nice_plotter.py b/waveform_editor/gui/shape_editor/nice_plotter.py
index 1756a0c4..801388a5 100644
--- a/waveform_editor/gui/shape_editor/nice_plotter.py
+++ b/waveform_editor/gui/shape_editor/nice_plotter.py
@@ -107,6 +107,8 @@ def _plot_plasma_shape(self):
if self.plasma_shape.input_mode == self.plasma_shape.GAP_INPUT:
return self._plot_gaps(r, z)
+ elif self.plasma_shape.input_mode == self.plasma_shape.WEIGHTED_POINTS_INPUT:
+ return self._plot_weighted_points(r, z)
else:
return self._plot_outline_shape(r, z)
@@ -146,6 +148,18 @@ def _plot_gaps(self, r, z):
)
return hv.Overlay(plot_elements)
+ def _plot_weighted_points(self, r, z):
+ """Plots weighted points as scatterplot.
+
+ Args:
+ r: Radial coordinates of the points.
+ z: Height coordinates of the points.
+
+ Returns:
+ Holoviews overlay with scatter plot of the points.
+ """
+ return hv.Overlay([hv.Scatter((r, z)).opts(color="blue", size=8, marker="o")])
+
@pn.depends("pf_active", "show_coils", "communicator.pf_active")
def _plot_coil_rectangles(self):
"""Creates rectangular and path overlays for PF coils.
diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py
index c585b96a..44f4731f 100644
--- a/waveform_editor/gui/shape_editor/plasma_properties.py
+++ b/waveform_editor/gui/shape_editor/plasma_properties.py
@@ -113,7 +113,7 @@ def __panel__(self):
self._value_input,
css_classes=["property-card"],
stylesheets=[_CARD_CSS],
- max_width=600,
+ sizing_mode="stretch_width",
margin=(0, 0, 8, 0),
)
@@ -149,13 +149,25 @@ def __init__(self, **params):
self._resetting = False
self._alpha_input = FormattedEditableFloatSlider.from_param(
- self.param.alpha, name="Alpha", margin=0
+ self.param.alpha,
+ name="Alpha",
+ margin=0,
+ sizing_mode="stretch_width",
+ width=None,
)
self._beta_input = FormattedEditableFloatSlider.from_param(
- self.param.beta, name="Beta", margin=0
+ self.param.beta,
+ name="Beta",
+ margin=0,
+ sizing_mode="stretch_width",
+ width=None,
)
self._gamma_input = FormattedEditableFloatSlider.from_param(
- self.param.gamma, name="Gamma", margin=0
+ self.param.gamma,
+ name="Gamma",
+ margin=0,
+ sizing_mode="stretch_width",
+ width=None,
)
self._profiles_pane = pn.pane.HoloViews(
hv.DynamicMap(self._plot_profiles), width=350, height=350
@@ -311,7 +323,7 @@ def __panel__(self):
self._profiles_pane,
css_classes=["property-card"],
stylesheets=[_CARD_CSS],
- max_width=600,
+ sizing_mode="stretch_width",
margin=(0, 0, 8, 0),
)
@@ -436,7 +448,7 @@ def __panel__(self):
),
css_classes=["property-card", "ids-source-card"],
stylesheets=[_CARD_CSS],
- max_width=600,
+ sizing_mode="stretch_width",
margin=(0, 0, 8, 0),
)
return pn.Column(
diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py
index a39e6f8c..c396b7c0 100644
--- a/waveform_editor/gui/shape_editor/plasma_shape.py
+++ b/waveform_editor/gui/shape_editor/plasma_shape.py
@@ -1,4 +1,7 @@
+from pathlib import Path
+
import imas
+import pandas as pd
import panel as pn
import param
from panel.viewable import Viewer
@@ -15,6 +18,8 @@
update_outline_from_gaps,
)
+_CARD_CSS = (Path(__file__).parent.parent / "styles" / "property_card.css").read_text()
+
class PlasmaShapeParams(Viewer):
"""Helper class containing parameters to parameterize the plasma shape."""
@@ -41,22 +46,179 @@ class PlasmaShapeParams(Viewer):
)
def __panel__(self):
- widgets = {}
- for name in self.param:
- if isinstance(self.param[name], param.Integer):
- widgets[name] = FixedWidthEditableIntSlider
- elif isinstance(self.param[name], param.Number):
- widgets[name] = FormattedEditableFloatSlider
- return pn.Param(self.param, widgets=widgets, show_name=False)
+ def _slider(n):
+ p = getattr(self.param, n)
+ kwargs = {"sizing_mode": "stretch_width", "width": None}
+ if isinstance(self.param[n], param.Integer):
+ return FixedWidthEditableIntSlider.from_param(p, **kwargs)
+ return FormattedEditableFloatSlider.from_param(p, **kwargs)
+
+ def _group(title, *param_names):
+ return pn.Column(
+ pn.pane.HTML(
+ f"{title}"
+ "
",
+ margin=(0, 0, 0, 0),
+ ),
+ *[_slider(n) for n in param_names],
+ css_classes=["property-card"],
+ stylesheets=[_CARD_CSS],
+ margin=(0, 0, 8, 0),
+ )
+
+ return pn.Column(
+ _group("Geometry", "a", "center_r", "center_z"),
+ _group("Shape coefficients", "kappa", "delta"),
+ _group("X point", "rx", "zx"),
+ _group("Boundary", "n_desired_bnd_points"),
+ margin=(10, 20, 0, 20),
+ max_width=800,
+ )
+
+
+class WeightedPointsTable(param.Parameterized):
+ """Widget for managing weighted points table for defining plasma shape."""
+
+ COL_R = "R [m]"
+ COL_Z = "Z [m]"
+ COL_WEIGHT = "weight"
+ COL_DELETE = "🗑️"
+
+ points = param.DataFrame(default=pd.DataFrame(columns=[COL_R, COL_Z, COL_WEIGHT]))
+
+ def __init__(self):
+ super().__init__()
+ initial_df = pd.DataFrame(
+ columns=(self.COL_DELETE, self.COL_R, self.COL_Z, self.COL_WEIGHT)
+ )
+ self._tabulator = pn.widgets.Tabulator(
+ value=initial_df,
+ editors={
+ self.COL_DELETE: None,
+ self.COL_R: {"type": "number"},
+ self.COL_Z: {"type": "number"},
+ self.COL_WEIGHT: {"type": "number", "step": 1},
+ },
+ layout="fit_data_fill",
+ sizing_mode="stretch_width",
+ show_index=False,
+ on_click=self._on_delete_click,
+ on_edit=self._on_edit,
+ )
+ self.param.watch(self._update_tabulator, "points", onlychanged=True)
+ self._update_tabulator()
+
+ def _update_tabulator(self, event=None):
+ """Update the Tabulator to reflect the current DataFrame."""
+ df = self.points
+ data = []
+ for _, row in df.iterrows():
+ data.append(
+ (
+ self.COL_DELETE,
+ row[self.COL_R],
+ row[self.COL_Z],
+ row[self.COL_WEIGHT],
+ )
+ )
+
+ # Add empty row if last data row has R and Z values filled
+ if len(df) == 0 or (
+ df.iloc[-1][self.COL_R] != "" and df.iloc[-1][self.COL_Z] != ""
+ ):
+ data.append((self.COL_DELETE, "", "", 1))
+
+ new_df = pd.DataFrame(
+ data, columns=(self.COL_DELETE, self.COL_R, self.COL_Z, self.COL_WEIGHT)
+ )
+ # Convert columns to allow mixed types
+ new_df[self.COL_R] = new_df[self.COL_R].astype(object)
+ new_df[self.COL_Z] = new_df[self.COL_Z].astype(object)
+ self._tabulator.value = new_df
+
+ def _on_delete_click(self, event):
+ if event.column == self.COL_DELETE:
+ n_data_rows = len(self.points)
+ if event.row < n_data_rows:
+ df = self.points.copy()
+ df = df.drop(index=event.row).reset_index(drop=True)
+ self.param.update(points=df)
+
+ def _on_edit(self, event):
+ """Handle edits in the weighted points tabulator."""
+ df = self.points.copy()
+ is_empty_row = event.row >= len(df)
+
+ # Convert columns to object dtype to allow mixed types
+ for col in [self.COL_R, self.COL_Z, self.COL_WEIGHT]:
+ if col in df.columns:
+ df[col] = df[col].astype(object)
+
+ if event.column == self.COL_WEIGHT and (
+ event.value is None or event.value < 1 or event.value > 1000
+ ):
+ pn.state.notifications.error("Weight must be between 1 and 1000")
+ prev = (
+ self.points.iloc[event.row][self.COL_WEIGHT] if not is_empty_row else 1
+ )
+ self._tabulator.value.at[event.row, self.COL_WEIGHT] = prev
+ self._tabulator.param.trigger("value")
+ return
+
+ if is_empty_row:
+ new_row = {self.COL_R: "", self.COL_Z: "", self.COL_WEIGHT: 1}
+ new_row[event.column] = event.value
+ new_df = pd.DataFrame([new_row])
+ df = pd.concat([df, new_df], ignore_index=True)
+ else:
+ df.at[event.row, event.column] = event.value
+
+ self.param.update(points=df)
+
+ def get_outline_coordinates(self):
+ """Generate outline coordinates from weighted points.
+
+ Returns:
+ tuple: (outline_r, outline_z) lists of coordinates, or (None, None) if
+ fewer than 2 valid points
+ """
+ if self.points.empty:
+ return None, None
+
+ # Filter out rows with empty R or Z
+ valid_df = self.points.dropna(subset=[self.COL_R, self.COL_Z])
+ valid_df = valid_df[(valid_df[self.COL_R] != "") & (valid_df[self.COL_Z] != "")]
+
+ if len(valid_df) < 2:
+ return None, None
+
+ # Duplicate points according to their weight
+ outline_r = []
+ outline_z = []
+ for _, row in valid_df.iterrows():
+ weight = row[self.COL_WEIGHT]
+ outline_r.extend([row[self.COL_R]] * int(weight))
+ outline_z.extend([row[self.COL_Z]] * int(weight))
+
+ return outline_r, outline_z
+
+ def __panel__(self):
+ return self._tabulator
class PlasmaShape(Viewer):
PARAMETERIZED_INPUT = "Parameterized"
EQUILIBRIUM_INPUT = "Equilibrium IDS outline"
GAP_INPUT = "Equilibrium IDS Gaps"
+ WEIGHTED_POINTS_INPUT = "Weighted Points"
input_mode = param.ObjectSelector(
default=EQUILIBRIUM_INPUT,
- objects=[EQUILIBRIUM_INPUT, PARAMETERIZED_INPUT, GAP_INPUT],
+ objects=[
+ EQUILIBRIUM_INPUT,
+ PARAMETERIZED_INPUT,
+ GAP_INPUT,
+ WEIGHTED_POINTS_INPUT,
+ ],
label="Shape input mode",
)
input_outline = param.ClassSelector(
@@ -65,6 +227,9 @@ class PlasmaShape(Viewer):
input_gaps = param.ClassSelector(
class_=EquilibriumInput, default=EquilibriumInput()
)
+ weighted_points_table = param.ClassSelector(
+ class_=WeightedPointsTable, default=WeightedPointsTable()
+ )
shape_params = param.ClassSelector(
class_=PlasmaShapeParams, default=PlasmaShapeParams()
)
@@ -74,11 +239,34 @@ class PlasmaShape(Viewer):
def __init__(self):
super().__init__()
- self.indicator = WarningIndicator(visible=self.param.has_shape.rx.not_())
+ self.outline_indicator = WarningIndicator(
+ tooltip="No valid equilibrium IDS with outline loaded",
+ visible=self.param.has_shape.rx.not_(),
+ )
+ self.gap_indicator = WarningIndicator(
+ tooltip="No valid equilibrium IDS with gaps loaded",
+ visible=self.param.has_shape.rx.not_(),
+ )
+ self.weighted_points_indicator = WarningIndicator(
+ tooltip="At least 2 points are required to define a plasma shape",
+ visible=self.param.has_shape.rx.not_(),
+ )
self.gap_ui = pn.Column(visible=self.param.input_mode.rx() == self.GAP_INPUT)
- self.radio_box = pn.widgets.RadioBoxGroup.from_param(
- self.param.input_mode, inline=True, margin=(15, 20, 0, 20)
+ self.radio_box = pn.widgets.RadioButtonGroup(
+ options={
+ "Equilibrium\nIDS Outline": self.EQUILIBRIUM_INPUT,
+ "Parameterized": self.PARAMETERIZED_INPUT,
+ "Equilibrium\nIDS Gaps": self.GAP_INPUT,
+ "Weighted\nPoints": self.WEIGHTED_POINTS_INPUT,
+ },
+ value=self.input_mode,
+ button_type="primary",
+ sizing_mode="stretch_width",
+ max_width=800,
+ margin=(15, 20, 0, 20),
+ stylesheets=[_CARD_CSS],
)
+ self.radio_box.link(self, value="input_mode", bidirectional=True)
self.panel = pn.Column(self.radio_box, self._panel_shape_options, self.gap_ui)
self.outline_r = None
self.outline_z = None
@@ -88,6 +276,7 @@ def __init__(self):
"shape_params.param",
"input_outline.param",
"input_gaps.param",
+ "weighted_points_table.param",
"input_mode",
watch=True,
)
@@ -102,6 +291,8 @@ def _set_plasma_shape(self):
self._load_shape_from_params()
elif self.input_mode == self.GAP_INPUT:
self._load_shape_from_gaps()
+ elif self.input_mode == self.WEIGHTED_POINTS_INPUT:
+ self._load_shape_from_weighted_points()
if self.outline_r and self.outline_z:
self.has_shape = True
@@ -196,6 +387,12 @@ def _create_gap_ui(self):
self.gap_ui.extend(new_gap_ui)
+ def _load_shape_from_weighted_points(self):
+ """Load plasma boundary outline from weighted points."""
+ self.outline_r, self.outline_z = (
+ self.weighted_points_table.get_outline_coordinates()
+ )
+
def _load_shape_from_params(self):
"""Compute plasma boundary outline from parameterized shape inputs."""
self.outline_r, self.outline_z = compute_outline_from_params(
@@ -214,9 +411,15 @@ def _panel_shape_options(self):
if self.input_mode == self.PARAMETERIZED_INPUT:
return self.shape_params
elif self.input_mode == self.EQUILIBRIUM_INPUT:
- return pn.Row(pn.Param(self.input_outline, show_name=False), self.indicator)
+ return pn.Row(
+ pn.Param(self.input_outline, show_name=False), self.outline_indicator
+ )
elif self.input_mode == self.GAP_INPUT:
- return pn.Row(pn.Param(self.input_gaps, show_name=False), self.indicator)
+ return pn.Row(
+ pn.Param(self.input_gaps, show_name=False), self.gap_indicator
+ )
+ elif self.input_mode == self.WEIGHTED_POINTS_INPUT:
+ return pn.Row(self.weighted_points_table, self.weighted_points_indicator)
def __panel__(self):
return self.panel
diff --git a/waveform_editor/gui/shape_editor/shape_editor.py b/waveform_editor/gui/shape_editor/shape_editor.py
index 7d215820..5ec3ba52 100644
--- a/waveform_editor/gui/shape_editor/shape_editor.py
+++ b/waveform_editor/gui/shape_editor/shape_editor.py
@@ -106,6 +106,8 @@ def __init__(self, main_gui):
pn.Spacer(sizing_mode="stretch_width"),
button_stop,
button_start,
+ max_width=800,
+ sizing_mode="stretch_width",
)
self.metrics = Metrics()
@@ -146,7 +148,6 @@ def __init__(self, main_gui):
left_col,
pn.Column(
menu,
- pn.layout.Divider(),
inputs,
sizing_mode="stretch_both",
),
@@ -171,6 +172,7 @@ def _create_card(
panel_object,
title=title,
sizing_mode="stretch_width",
+ max_width=800,
collapsed=collapsed,
visible=visible,
)
diff --git a/waveform_editor/gui/styles/property_card.css b/waveform_editor/gui/styles/property_card.css
index 702b296f..7309eeaf 100644
--- a/waveform_editor/gui/styles/property_card.css
+++ b/waveform_editor/gui/styles/property_card.css
@@ -14,6 +14,16 @@
color: white !important;
}
+:host .bk-btn-group {
+ align-items: stretch;
+}
+
+:host .bk-btn {
+ white-space: pre-line;
+ line-height: 1.3;
+ height: auto;
+}
+
.ids-badge {
display: inline-block;
padding: 2px 8px;
diff --git a/waveform_editor/gui/util.py b/waveform_editor/gui/util.py
index 14382c97..7c3193a7 100644
--- a/waveform_editor/gui/util.py
+++ b/waveform_editor/gui/util.py
@@ -24,6 +24,9 @@ class EquilibriumInput(param.Parameterized):
class WarningIndicator(pn.widgets.StaticText):
- def __init__(self, **params):
+ def __init__(self, tooltip="", **params):
params.setdefault("margin", (40, 0, 0, 0))
- super().__init__(value="⚠️", **params)
+ icon = (
+ f'⚠️' if tooltip else "⚠️"
+ )
+ super().__init__(value=icon, **params)