From c48e842a463d5fa5d2fb3426a2f666ae0fab7134 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Tue, 19 May 2026 15:35:06 +0200 Subject: [PATCH 01/15] add weighted points table --- .../gui/shape_editor/nice_plotter.py | 2 + .../gui/shape_editor/plasma_shape.py | 109 +++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/waveform_editor/gui/shape_editor/nice_plotter.py b/waveform_editor/gui/shape_editor/nice_plotter.py index 5a8189f3..9ed2eb70 100644 --- a/waveform_editor/gui/shape_editor/nice_plotter.py +++ b/waveform_editor/gui/shape_editor/nice_plotter.py @@ -148,6 +148,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_outline_shape(r, z) else: return self._plot_outline_shape(r, z) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index a39e6f8c..5474ed09 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -1,4 +1,5 @@ import imas +import pandas as pd import panel as pn import param from panel.viewable import Viewer @@ -50,13 +51,28 @@ def __panel__(self): return pn.Param(self.param, widgets=widgets, show_name=False) +class WeightedPointsInput(param.Parameterized): + """Parameterized class containing weighted points for plasma shape definition.""" + + points = param.DataFrame( + default=pd.DataFrame(columns=["R[m]", "Z[m]", "weight"]), + doc="DataFrame containing R, Z coordinates and weights", + ) + + 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 +81,9 @@ class PlasmaShape(Viewer): input_gaps = param.ClassSelector( class_=EquilibriumInput, default=EquilibriumInput() ) + input_weighted_points = param.ClassSelector( + class_=WeightedPointsInput, default=WeightedPointsInput() + ) shape_params = param.ClassSelector( class_=PlasmaShapeParams, default=PlasmaShapeParams() ) @@ -76,10 +95,15 @@ def __init__(self): super().__init__() self.indicator = WarningIndicator(visible=self.param.has_shape.rx.not_()) self.gap_ui = pn.Column(visible=self.param.input_mode.rx() == self.GAP_INPUT) + self.weighted_points_tabulator = self._create_weighted_points_tabulator() self.radio_box = pn.widgets.RadioBoxGroup.from_param( self.param.input_mode, inline=True, margin=(15, 20, 0, 20) ) - self.panel = pn.Column(self.radio_box, self._panel_shape_options, self.gap_ui) + self.panel = pn.Column( + self.radio_box, + self._panel_shape_options, + self.gap_ui, + ) self.outline_r = None self.outline_z = None self.gaps = [] @@ -88,6 +112,7 @@ def __init__(self): "shape_params.param", "input_outline.param", "input_gaps.param", + "input_weighted_points.param", "input_mode", watch=True, ) @@ -102,8 +127,14 @@ 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: + if ( + self.outline_r is not None + and self.outline_z is not None + and len(self.outline_r) > 0 + ): self.has_shape = True else: self.has_shape = False @@ -196,6 +227,76 @@ def _create_gap_ui(self): self.gap_ui.extend(new_gap_ui) + def _create_weighted_points_tabulator(self): + """Create the Tabulator widget for weighted points.""" + self.weighted_points_tabulator = pn.widgets.Tabulator( + value=pd.DataFrame(columns=("delete", "R[m]", "Z[m]", "weight")), + editors={ + "delete": None, + "R[m]": {"type": "number"}, + "Z[m]": {"type": "number"}, + "weight": {"type": "number"}, + }, + titles={"delete": "🗑️", "R[m]": "R[m]", "Z[m]": "Z[m]", "weight": "weight"}, + layout="fit_data_stretch", + sizing_mode="stretch_width", + show_index=False, + ) + self.weighted_points_tabulator.on_click(self._on_weighted_points_delete_click) + self.weighted_points_tabulator.on_edit(self._on_weighted_points_edit) + self.input_weighted_points.param.watch( + self._update_weighted_points_df, "points", onlychanged=True + ) + self._update_weighted_points_df() + return self.weighted_points_tabulator + + def _update_weighted_points_df(self, event=None): + """Update the Tabulator to reflect the current DataFrame.""" + df = self.input_weighted_points.points + data = [] + for _, row in df.iterrows(): + data.append(("🗑️", row["R[m]"], row["Z[m]"], row["weight"])) + data.append(("🗑️", 0.0, 0.0, 1.0)) + new_df = pd.DataFrame(data, columns=("delete", "R[m]", "Z[m]", "weight")) + self.weighted_points_tabulator.value = new_df + + def _on_weighted_points_delete_click(self, event): + """Handle delete button clicks in the weighted points tabulator.""" + if event.column == "delete": + n_data_rows = len(self.input_weighted_points.points) + if event.row < n_data_rows: + df = self.input_weighted_points.points.copy() + df = df.drop(index=event.row).reset_index(drop=True) + self.input_weighted_points.param.update(points=df) + + def _on_weighted_points_edit(self, event): + """Handle edits in the weighted points tabulator.""" + df = self.input_weighted_points.points.copy() + n_data_rows = len(df) + is_empty_row = event.row >= n_data_rows + + col_map = {"R[m]": "R[m]", "Z[m]": "Z[m]", "weight": "weight"} + if event.column not in col_map: + return + + if is_empty_row: + new_row = {"R[m]": 0.0, "Z[m]": 0.0, "weight": 1.0} + 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.input_weighted_points.param.update(points=df) + + def _load_shape_from_weighted_points(self): + """Load plasma boundary outline from weighted points.""" + df = self.input_weighted_points.points + if df.empty: + return + self.outline_r = df["R[m]"].values.astype(float) + self.outline_z = df["Z[m]"].values.astype(float) + def _load_shape_from_params(self): """Compute plasma boundary outline from parameterized shape inputs.""" self.outline_r, self.outline_z = compute_outline_from_params( @@ -217,6 +318,8 @@ def _panel_shape_options(self): return pn.Row(pn.Param(self.input_outline, show_name=False), self.indicator) elif self.input_mode == self.GAP_INPUT: return pn.Row(pn.Param(self.input_gaps, show_name=False), self.indicator) + elif self.input_mode == self.WEIGHTED_POINTS_INPUT: + return self.weighted_points_tabulator def __panel__(self): return self.panel From a47d261f8c03192b7791b16e8c1dc759c5fccac6 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Tue, 19 May 2026 15:45:16 +0200 Subject: [PATCH 02/15] add points to plot --- waveform_editor/gui/shape_editor/nice_plotter.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/waveform_editor/gui/shape_editor/nice_plotter.py b/waveform_editor/gui/shape_editor/nice_plotter.py index 9ed2eb70..2599e238 100644 --- a/waveform_editor/gui/shape_editor/nice_plotter.py +++ b/waveform_editor/gui/shape_editor/nice_plotter.py @@ -149,7 +149,7 @@ 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_outline_shape(r, z) + return self._plot_weighted_points(r, z) else: return self._plot_outline_shape(r, z) @@ -189,6 +189,18 @@ def _plot_gaps(self, r, z): ) return hv.Overlay(plot_elements) + def _plot_weighted_points(self, r, z): + """Plots weighted points as scatter dots. + + 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. From 29442276659d42c7ab8884154cee1951f56db256 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Tue, 19 May 2026 15:53:44 +0200 Subject: [PATCH 03/15] fix table --- .../gui/shape_editor/nice_plotter.py | 2 +- .../gui/shape_editor/plasma_shape.py | 215 +++++++++++------- 2 files changed, 128 insertions(+), 89 deletions(-) diff --git a/waveform_editor/gui/shape_editor/nice_plotter.py b/waveform_editor/gui/shape_editor/nice_plotter.py index 2599e238..c8fd5ec9 100644 --- a/waveform_editor/gui/shape_editor/nice_plotter.py +++ b/waveform_editor/gui/shape_editor/nice_plotter.py @@ -190,7 +190,7 @@ def _plot_gaps(self, r, z): return hv.Overlay(plot_elements) def _plot_weighted_points(self, r, z): - """Plots weighted points as scatter dots. + """Plots weighted points as scatterplot. Args: r: Radial coordinates of the points. diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index 5474ed09..c0370e74 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -51,13 +51,125 @@ def __panel__(self): return pn.Param(self.param, widgets=widgets, show_name=False) -class WeightedPointsInput(param.Parameterized): - """Parameterized class containing weighted points for plasma shape definition.""" +class WeightedPointsTable(param.Parameterized): + """Widget for managing weighted points table for defining plasma shape.""" - points = param.DataFrame( - default=pd.DataFrame(columns=["R[m]", "Z[m]", "weight"]), - doc="DataFrame containing R, Z coordinates and weights", - ) + 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"}, + }, + layout="fit_data_fill", + sizing_mode="stretch_width", + show_index=False, + ) + self._tabulator.on_click(self._on_delete_click) + self._tabulator.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 ( + pd.notna(df.iloc[-1][self.COL_R]) + and pd.notna(df.iloc[-1][self.COL_Z]) + and 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 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 empty + """ + 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 valid_df.empty: + return None, None + + # Duplicate points according to their weight + outline_r = [] + outline_z = [] + for _, row in valid_df.iterrows(): + weight = int(row[self.COL_WEIGHT]) if pd.notna(row[self.COL_WEIGHT]) else 1 + outline_r.extend([float(row[self.COL_R])] * weight) + outline_z.extend([float(row[self.COL_Z])] * weight) + + return outline_r, outline_z + + def __panel__(self): + return self._tabulator class PlasmaShape(Viewer): @@ -81,8 +193,8 @@ class PlasmaShape(Viewer): input_gaps = param.ClassSelector( class_=EquilibriumInput, default=EquilibriumInput() ) - input_weighted_points = param.ClassSelector( - class_=WeightedPointsInput, default=WeightedPointsInput() + weighted_points_table = param.ClassSelector( + class_=WeightedPointsTable, default=WeightedPointsTable() ) shape_params = param.ClassSelector( class_=PlasmaShapeParams, default=PlasmaShapeParams() @@ -95,15 +207,10 @@ def __init__(self): super().__init__() self.indicator = WarningIndicator(visible=self.param.has_shape.rx.not_()) self.gap_ui = pn.Column(visible=self.param.input_mode.rx() == self.GAP_INPUT) - self.weighted_points_tabulator = self._create_weighted_points_tabulator() self.radio_box = pn.widgets.RadioBoxGroup.from_param( self.param.input_mode, inline=True, margin=(15, 20, 0, 20) ) - self.panel = pn.Column( - self.radio_box, - self._panel_shape_options, - self.gap_ui, - ) + self.panel = pn.Column(self.radio_box, self._panel_shape_options, self.gap_ui) self.outline_r = None self.outline_z = None self.gaps = [] @@ -112,7 +219,7 @@ def __init__(self): "shape_params.param", "input_outline.param", "input_gaps.param", - "input_weighted_points.param", + "weighted_points_table.param", "input_mode", watch=True, ) @@ -130,11 +237,7 @@ def _set_plasma_shape(self): elif self.input_mode == self.WEIGHTED_POINTS_INPUT: self._load_shape_from_weighted_points() - if ( - self.outline_r is not None - and self.outline_z is not None - and len(self.outline_r) > 0 - ): + if self.outline_r and self.outline_z: self.has_shape = True else: self.has_shape = False @@ -227,75 +330,11 @@ def _create_gap_ui(self): self.gap_ui.extend(new_gap_ui) - def _create_weighted_points_tabulator(self): - """Create the Tabulator widget for weighted points.""" - self.weighted_points_tabulator = pn.widgets.Tabulator( - value=pd.DataFrame(columns=("delete", "R[m]", "Z[m]", "weight")), - editors={ - "delete": None, - "R[m]": {"type": "number"}, - "Z[m]": {"type": "number"}, - "weight": {"type": "number"}, - }, - titles={"delete": "🗑️", "R[m]": "R[m]", "Z[m]": "Z[m]", "weight": "weight"}, - layout="fit_data_stretch", - sizing_mode="stretch_width", - show_index=False, - ) - self.weighted_points_tabulator.on_click(self._on_weighted_points_delete_click) - self.weighted_points_tabulator.on_edit(self._on_weighted_points_edit) - self.input_weighted_points.param.watch( - self._update_weighted_points_df, "points", onlychanged=True - ) - self._update_weighted_points_df() - return self.weighted_points_tabulator - - def _update_weighted_points_df(self, event=None): - """Update the Tabulator to reflect the current DataFrame.""" - df = self.input_weighted_points.points - data = [] - for _, row in df.iterrows(): - data.append(("🗑️", row["R[m]"], row["Z[m]"], row["weight"])) - data.append(("🗑️", 0.0, 0.0, 1.0)) - new_df = pd.DataFrame(data, columns=("delete", "R[m]", "Z[m]", "weight")) - self.weighted_points_tabulator.value = new_df - - def _on_weighted_points_delete_click(self, event): - """Handle delete button clicks in the weighted points tabulator.""" - if event.column == "delete": - n_data_rows = len(self.input_weighted_points.points) - if event.row < n_data_rows: - df = self.input_weighted_points.points.copy() - df = df.drop(index=event.row).reset_index(drop=True) - self.input_weighted_points.param.update(points=df) - - def _on_weighted_points_edit(self, event): - """Handle edits in the weighted points tabulator.""" - df = self.input_weighted_points.points.copy() - n_data_rows = len(df) - is_empty_row = event.row >= n_data_rows - - col_map = {"R[m]": "R[m]", "Z[m]": "Z[m]", "weight": "weight"} - if event.column not in col_map: - return - - if is_empty_row: - new_row = {"R[m]": 0.0, "Z[m]": 0.0, "weight": 1.0} - 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.input_weighted_points.param.update(points=df) - def _load_shape_from_weighted_points(self): """Load plasma boundary outline from weighted points.""" - df = self.input_weighted_points.points - if df.empty: - return - self.outline_r = df["R[m]"].values.astype(float) - self.outline_z = df["Z[m]"].values.astype(float) + 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.""" @@ -319,7 +358,7 @@ def _panel_shape_options(self): elif self.input_mode == self.GAP_INPUT: return pn.Row(pn.Param(self.input_gaps, show_name=False), self.indicator) elif self.input_mode == self.WEIGHTED_POINTS_INPUT: - return self.weighted_points_tabulator + return self.weighted_points_table def __panel__(self): return self.panel From 271f5bbe2cdf65d4eabe85007bc5c3f979956d58 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Wed, 20 May 2026 16:18:35 +0200 Subject: [PATCH 04/15] cleanup --- .../gui/shape_editor/plasma_shape.py | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index c0370e74..efbeb70c 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -68,18 +68,12 @@ def __init__(self): ) 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"}, - }, layout="fit_data_fill", sizing_mode="stretch_width", show_index=False, + on_click=self._on_delete_click, + on_edit=self._on_edit, ) - self._tabulator.on_click(self._on_delete_click) - self._tabulator.on_edit(self._on_edit) self.param.watch(self._update_tabulator, "points", onlychanged=True) self._update_tabulator() @@ -99,10 +93,7 @@ def _update_tabulator(self, event=None): # Add empty row if last data row has R and Z values filled if len(df) == 0 or ( - pd.notna(df.iloc[-1][self.COL_R]) - and pd.notna(df.iloc[-1][self.COL_Z]) - and df.iloc[-1][self.COL_R] != "" - and df.iloc[-1][self.COL_Z] != "" + df.iloc[-1][self.COL_R] != "" and df.iloc[-1][self.COL_Z] != "" ): data.append((self.COL_DELETE, "", "", 1)) @@ -132,6 +123,13 @@ def _on_edit(self, event): if col in df.columns: df[col] = df[col].astype(object) + if event.column == self.COL_WEIGHT: + if event.value < 1: + pn.state.notifications.error("Weight must be >= 1") + self._tabulator.value.at[event.row, self.COL_WEIGHT] = 1 + 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 @@ -162,9 +160,9 @@ def get_outline_coordinates(self): outline_r = [] outline_z = [] for _, row in valid_df.iterrows(): - weight = int(row[self.COL_WEIGHT]) if pd.notna(row[self.COL_WEIGHT]) else 1 - outline_r.extend([float(row[self.COL_R])] * weight) - outline_z.extend([float(row[self.COL_Z])] * weight) + weight = row[self.COL_WEIGHT] + outline_r.extend([row[self.COL_R]] * weight) + outline_z.extend([row[self.COL_Z]] * weight) return outline_r, outline_z From 66de6e8638fce8fdb9374529139f7d0d3ee0005a Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Wed, 20 May 2026 16:25:07 +0200 Subject: [PATCH 05/15] set editors --- waveform_editor/gui/shape_editor/plasma_shape.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index efbeb70c..748073a1 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -68,6 +68,12 @@ def __init__(self): ) 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"}, + }, layout="fit_data_fill", sizing_mode="stretch_width", show_index=False, From c8ec9602b81b5cd7aaaec9774a50d93c25d3ef95 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Wed, 20 May 2026 16:29:00 +0200 Subject: [PATCH 06/15] ruff --- waveform_editor/gui/shape_editor/plasma_shape.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index 748073a1..8e3ab969 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -129,12 +129,11 @@ def _on_edit(self, event): if col in df.columns: df[col] = df[col].astype(object) - if event.column == self.COL_WEIGHT: - if event.value < 1: - pn.state.notifications.error("Weight must be >= 1") - self._tabulator.value.at[event.row, self.COL_WEIGHT] = 1 - self._tabulator.param.trigger("value") - return + if event.column == self.COL_WEIGHT and event.value < 1: + pn.state.notifications.error("Weight must be >= 1") + self._tabulator.value.at[event.row, self.COL_WEIGHT] = 1 + self._tabulator.param.trigger("value") + return if is_empty_row: new_row = {self.COL_R: "", self.COL_Z: "", self.COL_WEIGHT: 1} From 6ad89383c12536d7538503a1cd9a259daab9c10b Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Wed, 20 May 2026 16:34:51 +0200 Subject: [PATCH 07/15] cap weight value --- waveform_editor/gui/shape_editor/plasma_shape.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index 8e3ab969..ef52d589 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -129,8 +129,8 @@ def _on_edit(self, event): if col in df.columns: df[col] = df[col].astype(object) - if event.column == self.COL_WEIGHT and event.value < 1: - pn.state.notifications.error("Weight must be >= 1") + if event.column == self.COL_WEIGHT and (event.value < 1 or event.value > 1000): + pn.state.notifications.error("Weight must be between 1 and 1000") self._tabulator.value.at[event.row, self.COL_WEIGHT] = 1 self._tabulator.param.trigger("value") return From ea95365a8d7128f3c987ef79e2687f9aaa281fbf Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Mon, 1 Jun 2026 11:13:47 +0200 Subject: [PATCH 08/15] cleanup --- .../gui/shape_editor/plasma_shape.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index ef52d589..48ebb34c 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -72,7 +72,7 @@ def __init__(self): self.COL_DELETE: None, self.COL_R: {"type": "number"}, self.COL_Z: {"type": "number"}, - self.COL_WEIGHT: {"type": "number"}, + self.COL_WEIGHT: {"type": "number", "step": 1}, }, layout="fit_data_fill", sizing_mode="stretch_width", @@ -129,9 +129,16 @@ def _on_edit(self, event): if col in df.columns: df[col] = df[col].astype(object) - if event.column == self.COL_WEIGHT and (event.value < 1 or event.value > 1000): + 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") - self._tabulator.value.at[event.row, self.COL_WEIGHT] = 1 + 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 @@ -166,8 +173,8 @@ def get_outline_coordinates(self): outline_z = [] for _, row in valid_df.iterrows(): weight = row[self.COL_WEIGHT] - outline_r.extend([row[self.COL_R]] * weight) - outline_z.extend([row[self.COL_Z]] * weight) + outline_r.extend([row[self.COL_R]] * int(weight)) + outline_z.extend([row[self.COL_Z]] * int(weight)) return outline_r, outline_z @@ -211,7 +218,7 @@ def __init__(self): self.indicator = WarningIndicator(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.param.input_mode, inline=False, margin=(15, 20, 0, 20) ) self.panel = pn.Column(self.radio_box, self._panel_shape_options, self.gap_ui) self.outline_r = None From 370ade9e40ac1f2084aea88e2484787500201e6e Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Mon, 1 Jun 2026 11:16:22 +0200 Subject: [PATCH 09/15] lint --- waveform_editor/gui/shape_editor/plasma_shape.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index 48ebb34c..500061d2 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -134,9 +134,7 @@ def _on_edit(self, event): ): 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.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") From a3e0a35e1d568a1438de04298ee38596f23d994f Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Tue, 23 Jun 2026 14:55:01 +0200 Subject: [PATCH 10/15] ensure at least 2 points in weighted points table --- waveform_editor/gui/shape_editor/plasma_shape.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index 500061d2..f40ed139 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -154,7 +154,7 @@ def get_outline_coordinates(self): """Generate outline coordinates from weighted points. Returns: - tuple: (outline_r, outline_z) lists of coordinates, or (None, None) if empty + tuple: (outline_r, outline_z) lists of coordinates, or (None, None) if fewer than 2 valid points """ if self.points.empty: return None, None @@ -163,7 +163,7 @@ def get_outline_coordinates(self): 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 valid_df.empty: + if len(valid_df) < 2: return None, None # Duplicate points according to their weight From 02e2dad030bcbbcc86be7ba67adc780bb596f569 Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Tue, 23 Jun 2026 14:57:30 +0200 Subject: [PATCH 11/15] linting --- waveform_editor/gui/shape_editor/plasma_shape.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index f40ed139..c588a6fd 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -154,7 +154,8 @@ 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 + tuple: (outline_r, outline_z) lists of coordinates, or (None, None) if + fewer than 2 valid points """ if self.points.empty: return None, None From a39fbc81e5102eb419ce9194ba1cad7f390b6e8e Mon Sep 17 00:00:00 2001 From: Sebbe Blokhuizen Date: Tue, 23 Jun 2026 15:32:06 +0200 Subject: [PATCH 12/15] add tooltip warning --- .../gui/shape_editor/plasma_properties.py | 5 +++- .../gui/shape_editor/plasma_shape.py | 23 +++++++++++++++---- waveform_editor/gui/util.py | 7 ++++-- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 29334e8a..0607b9e2 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -58,7 +58,10 @@ class PlasmaProperties(Viewer): def __init__(self): super().__init__() - self.indicator = WarningIndicator(visible=self.param.has_properties.rx.not_()) + self.indicator = WarningIndicator( + tooltip="No valid equilibrium IDS loaded", + visible=self.param.has_properties.rx.not_(), + ) self.radio_box = pn.widgets.RadioBoxGroup.from_param( self.param.input_mode, inline=True, margin=(15, 20, 0, 20) ) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index c588a6fd..2faa6943 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -214,7 +214,18 @@ 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=False, margin=(15, 20, 0, 20) @@ -363,11 +374,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 self.weighted_points_table + return pn.Row(self.weighted_points_table, self.weighted_points_indicator) def __panel__(self): return self.panel 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) From 1480d14ba6b19cdfa71f324da3388acb3e35fd10 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Tue, 23 Jun 2026 17:16:45 +0200 Subject: [PATCH 13/15] make tab resposiveness and change design of checkboxes --- .../gui/shape_editor/plasma_properties.py | 24 ++++++--- .../gui/shape_editor/plasma_shape.py | 53 +++++++++++++++---- waveform_editor/gui/styles/property_card.css | 10 ++++ 3 files changed, 72 insertions(+), 15 deletions(-) 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 2faa6943..7dab36a5 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -1,3 +1,5 @@ +from pathlib import Path + import imas import pandas as pd import panel as pn @@ -16,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.""" @@ -42,13 +46,33 @@ 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), + ) class WeightedPointsTable(param.Parameterized): @@ -227,9 +251,20 @@ def __init__(self): 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=False, margin=(15, 20, 0, 20) + self.radio_box = pn.widgets.RadioButtonGroup( + options={ + "Eq IDS\nOutline": self.EQUILIBRIUM_INPUT, + "Parameterized": self.PARAMETERIZED_INPUT, + "Eq IDS\nGaps": self.GAP_INPUT, + "Weighted\nPoints": self.WEIGHTED_POINTS_INPUT, + }, + value=self.input_mode, + button_type="primary", + sizing_mode="stretch_width", + 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 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; From a51894856a59324e63ea650b847ef261e70f71a1 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Wed, 24 Jun 2026 11:48:51 +0200 Subject: [PATCH 14/15] add max widths for the components --- waveform_editor/gui/shape_editor/plasma_shape.py | 6 ++++-- waveform_editor/gui/shape_editor/shape_editor.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_shape.py b/waveform_editor/gui/shape_editor/plasma_shape.py index 7dab36a5..c396b7c0 100644 --- a/waveform_editor/gui/shape_editor/plasma_shape.py +++ b/waveform_editor/gui/shape_editor/plasma_shape.py @@ -72,6 +72,7 @@ def _group(title, *param_names): _group("X point", "rx", "zx"), _group("Boundary", "n_desired_bnd_points"), margin=(10, 20, 0, 20), + max_width=800, ) @@ -253,14 +254,15 @@ def __init__(self): self.gap_ui = pn.Column(visible=self.param.input_mode.rx() == self.GAP_INPUT) self.radio_box = pn.widgets.RadioButtonGroup( options={ - "Eq IDS\nOutline": self.EQUILIBRIUM_INPUT, + "Equilibrium\nIDS Outline": self.EQUILIBRIUM_INPUT, "Parameterized": self.PARAMETERIZED_INPUT, - "Eq IDS\nGaps": self.GAP_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], ) diff --git a/waveform_editor/gui/shape_editor/shape_editor.py b/waveform_editor/gui/shape_editor/shape_editor.py index 7d215820..b82513f2 100644 --- a/waveform_editor/gui/shape_editor/shape_editor.py +++ b/waveform_editor/gui/shape_editor/shape_editor.py @@ -146,7 +146,6 @@ def __init__(self, main_gui): left_col, pn.Column( menu, - pn.layout.Divider(), inputs, sizing_mode="stretch_both", ), @@ -171,6 +170,7 @@ def _create_card( panel_object, title=title, sizing_mode="stretch_width", + max_width=800, collapsed=collapsed, visible=visible, ) From 93efd975308f7cd1afd626612010d2ebf592b809 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Wed, 24 Jun 2026 11:53:06 +0200 Subject: [PATCH 15/15] move buttons to the left --- waveform_editor/gui/shape_editor/shape_editor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/waveform_editor/gui/shape_editor/shape_editor.py b/waveform_editor/gui/shape_editor/shape_editor.py index b82513f2..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()