From 6c284164012ab59639d8b7434520a602ba7b5c74 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Tue, 5 May 2026 17:05:55 +0200 Subject: [PATCH 1/8] add radiobuttons for manual vs eq ids --- .../gui/shape_editor/plasma_properties.py | 329 ++++++++++++------ waveform_editor/gui/styles/property_card.css | 10 + 2 files changed, 241 insertions(+), 98 deletions(-) create mode 100644 waveform_editor/gui/styles/property_card.css diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 29334e8a..2fb2d5ea 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -1,56 +1,176 @@ +from pathlib import Path + import imas import panel as pn import param from panel.viewable import Viewer -from waveform_editor.gui.util import ( - EquilibriumInput, - FormattedEditableFloatSlider, - WarningIndicator, -) from waveform_editor.shape_editor.plasma_properties_calc import ( compute_profiles_from_params, ) +MANUAL = "Manual" +EQ_IDS = "Eq IDS" +PARAMETRIC = "Parametric" -class PlasmaPropertiesParams(Viewer): - """Helper class containing parameters defining the plasma properties.""" +_CARD_CSS = (Path(__file__).parent.parent / "styles" / "property_card.css").read_text() - ip = param.Number( - default=-1.5e7, softbounds=[-1.7e7, 0], label="Plasma current [A]" - ) - r0 = param.Number( - default=6.2, softbounds=[5, 7], label="Reference major radius [m]" - ) - b0 = param.Number( - default=-5.3, softbounds=[-10, 10], label="Toroidal field at R0 [T]" - ) - alpha = param.Number(default=0.5, softbounds=[0.5, 2], step=0.01, label="Alpha") - beta = param.Number(default=0.5, softbounds=[0.5, 2], step=0.01, label="Beta") - gamma = param.Number(default=1.0, softbounds=[0.5, 2], step=0.01, label="Gamma") + +class PropertyInput(Viewer): + """A single scalar plasma property with its own Manual / Eq IDS mode toggle.""" + + mode = param.ObjectSelector(default=MANUAL, objects=[MANUAL, EQ_IDS]) + value = param.Number(default=0.0) + ids_uri = param.String(default="") + ids_time = param.Number(default=0.0) + changed = param.Event() + + def __init__(self, label, default_value, step=0.01, **params): + super().__init__(**params) + self.value = default_value + self._label = label + + self._mode_toggle = pn.widgets.RadioButtonGroup( + options=[MANUAL, EQ_IDS], + value=self.mode, + button_style="outline", + button_type="primary", + stylesheets=[_CARD_CSS], + ) + self._mode_toggle.param.watch(self._on_mode_change, "value") + + self._value_input = pn.widgets.FloatInput( + value=self.value, + step=step, + sizing_mode="stretch_width", + margin=(4, 0, 0, 0), + ) + self._value_input.param.watch(self._on_value_change, "value") + + self._uri_input = pn.widgets.TextInput.from_param( + self.param.ids_uri, + name="", + placeholder="IDS URI", + sizing_mode="stretch_width", + ) + self._time_input = pn.widgets.FloatInput.from_param( + self.param.ids_time, name="Time [s]", width=100 + ) + self.param.watch(lambda *_: + self.param.trigger("changed"), ["ids_uri", "ids_time"]) + + def _on_mode_change(self, event): + self.mode = event.new + self.param.trigger("changed") + + def _on_value_change(self, event): + self.value = event.new + self.param.trigger("changed") def __panel__(self): - widgets = {} - for name in self.param: - if isinstance(self.param[name], param.Number): - widgets[name] = FormattedEditableFloatSlider - return pn.Param(self.param, widgets=widgets, show_name=False) + is_manual = pn.bind(lambda m: m == MANUAL, self.param.mode) + is_ids = pn.bind(lambda m: m == EQ_IDS, self.param.mode) + header = pn.Row( + pn.pane.HTML(self._label, sizing_mode="stretch_width", margin=(5, 0)), + self._mode_toggle, + sizing_mode="stretch_width", + margin=0, + ) + return pn.Column( + header, + pn.Column(self._value_input, visible=is_manual, margin=(4, 0, 0, 0)), + pn.Column(self._uri_input, self._time_input, + visible=is_ids, margin=(4, 0, 0, 0)), + css_classes=["property-card"], + stylesheets=[_CARD_CSS], + max_width=600, + margin=(0, 0, 8, 0), + ) -class PlasmaProperties(Viewer): - MANUAL_INPUT = "Manual" - EQUILIBRIUM_INPUT = "Equilibrium IDS" - input_mode = param.ObjectSelector( - default=EQUILIBRIUM_INPUT, - objects=[EQUILIBRIUM_INPUT, MANUAL_INPUT], - label="Plasma properties input mode", - ) +class PlasmaProfiles(Viewer): + """Widget for selecting the plasma profile source: parametric or from IDS. - input = param.ClassSelector(class_=EquilibriumInput, default=EquilibriumInput()) - properties_params = param.ClassSelector( - class_=PlasmaPropertiesParams, default=PlasmaPropertiesParams() - ) + In Parametric mode, dpressure_dpsi and f_df_dpsi are computed analytically + from alpha/beta/gamma shape parameters. In Eq IDS mode, they are read + directly from an equilibrium IDS file. + """ + + mode = param.ObjectSelector(default=PARAMETRIC, objects=[PARAMETRIC, EQ_IDS]) + alpha = param.Number(default=0.5, step=0.01) + beta = param.Number(default=0.5, step=0.01) + gamma = param.Number(default=1.0, step=0.01) + ids_uri = param.String(default="") + ids_time = param.Number(default=0.0) + changed = param.Event() + + def __init__(self, **params): + super().__init__(**params) + + self._mode_toggle = pn.widgets.RadioButtonGroup( + options=[PARAMETRIC, EQ_IDS], + value=self.mode, + button_style="outline", + button_type="primary", + stylesheets=[_CARD_CSS], + ) + self._mode_toggle.param.watch(self._on_mode_change, "value") + + self._alpha_input = pn.widgets.FloatInput.from_param( + self.param.alpha, name="Alpha", sizing_mode="stretch_width" + ) + self._beta_input = pn.widgets.FloatInput.from_param( + self.param.beta, name="Beta", sizing_mode="stretch_width" + ) + self._gamma_input = pn.widgets.FloatInput.from_param( + self.param.gamma, name="Gamma", sizing_mode="stretch_width" + ) + self.param.watch(lambda *_: + self.param.trigger("changed"), ["alpha", "beta", "gamma"]) + + self._uri_input = pn.widgets.TextInput.from_param( + self.param.ids_uri, + name="", + placeholder="IDS URI", + sizing_mode="stretch_width", + ) + self._time_input = pn.widgets.FloatInput.from_param( + self.param.ids_time, name="Time [s]", width=100 + ) + self.param.watch(lambda *_: + self.param.trigger("changed"), ["ids_uri", "ids_time"]) + def _on_mode_change(self, event): + self.mode = event.new + self.param.trigger("changed") + + def __panel__(self): + is_parametric = pn.bind(lambda m: m == PARAMETRIC, self.param.mode) + is_ids = pn.bind(lambda m: m == EQ_IDS, self.param.mode) + header = pn.Row( + pn.pane.HTML("Profile source", sizing_mode="stretch_width", margin=(5, 0)), + self._mode_toggle, + sizing_mode="stretch_width", + margin=0, + ) + return pn.Column( + header, + pn.Column( + self._alpha_input, self._beta_input, self._gamma_input, + visible=is_parametric, margin=(4, 0, 0, 0), + ), + pn.Column( + self._uri_input, self._time_input, + visible=is_ids, margin=(4, 0, 0, 0), + ), + css_classes=["property-card"], + stylesheets=[_CARD_CSS], + max_width=600, + margin=(0, 0, 8, 0), + ) + + +class PlasmaProperties(Viewer): profile_updated = param.Event( doc="Triggered whenever the dpressure_dpsi and f_df_dpsi are updated." ) @@ -58,11 +178,11 @@ class PlasmaProperties(Viewer): def __init__(self): super().__init__() - self.indicator = WarningIndicator(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) - ) - self.panel = pn.Column(self.radio_box, self._panel_property_options) + self._ip = PropertyInput("Plasma current [A]", default_value=-1.5e7) + self._r0 = PropertyInput("Reference major radius [m]", default_value=6.2) + self._b0 = PropertyInput("Toroidal magnetic field [T]", default_value=-5.3) + self._profiles = PlasmaProfiles() + self.dpressure_dpsi = None self.f_df_dpsi = None self.psi_norm = None @@ -70,71 +190,84 @@ def __init__(self): self.r0 = None self.b0 = None - @param.depends( - "properties_params.param", "input.param", "input_mode", watch=True, on_init=True - ) + for widget in [self._ip, self._r0, self._b0, self._profiles]: + widget.param.watch(lambda *_: self._load_plasma_properties(), "changed") + + self._load_plasma_properties() + + def _load_scalar(self, prop: PropertyInput, extractor): + """Return a scalar value from manual input or extracted from an IDS.""" + if prop.mode == MANUAL: + return prop.value + if not prop.ids_uri: + return None + try: + with imas.DBEntry(prop.ids_uri, "r") as entry: + eq = entry.get_slice( + "equilibrium", prop.ids_time, imas.ids_defs.CLOSEST_INTERP + ) + return extractor(eq) + except Exception as e: + pn.state.notifications.error(f"Could not load from {prop.ids_uri}: {e}") + return None + def _load_plasma_properties(self): - """Update plasma properties based on input mode.""" + self.ip = self._load_scalar( + self._ip, lambda eq: eq.time_slice[0].global_quantities.ip + ) + self.r0 = self._load_scalar( + self._r0, lambda eq: eq.vacuum_toroidal_field.r0 + ) + self.b0 = self._load_scalar( + self._b0, lambda eq: eq.vacuum_toroidal_field.b0[0] + ) - if self.input_mode == self.EQUILIBRIUM_INPUT: - self._load_properties_from_ids() - elif self.input_mode == self.MANUAL_INPUT: - self._load_properties_from_params() + if self._profiles.mode == EQ_IDS: + self._load_profiles_from_ids(self._profiles.ids_uri, + self._profiles.ids_time) + elif self.r0 is not None: + try: + self.psi_norm, self.dpressure_dpsi, self.f_df_dpsi = ( + compute_profiles_from_params( + r0=self.r0, + alpha=self._profiles.alpha, + beta=self._profiles.beta, + gamma=self._profiles.gamma, + ) + ) + except Exception as e: + pn.state.notifications.error(f"Could not compute profiles: {e}") + self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None + else: + self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None + self.has_properties = all( + v is not None + for v in [self.ip, self.r0, self.b0, self.dpressure_dpsi] + ) self.param.trigger("profile_updated") - def _load_properties_from_params(self): - """Load the plasma properties from the properties parameters. Calculate - dpressure_dpsi and f_df_dpsi from the parametric alpha, beta, and gamma - parameters.""" - self.ip = self.properties_params.ip - self.r0 = self.properties_params.r0 - self.b0 = self.properties_params.b0 - ( - self.psi_norm, - self.dpressure_dpsi, - self.f_df_dpsi, - ) = compute_profiles_from_params( - r0=self.properties_params.r0, - alpha=self.properties_params.alpha, - beta=self.properties_params.beta, - gamma=self.properties_params.gamma, - ) - self.has_properties = True - - def _load_properties_from_ids(self): - """Load plasma properties from IDS equilibrium input.""" - if not self.input.uri: - self.has_properties = False + def _load_profiles_from_ids(self, uri: str, time: float): + if not uri: + self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None return try: - with imas.DBEntry(self.input.uri, "r") as entry: - equilibrium = entry.get_slice( - "equilibrium", self.input.time, imas.ids_defs.CLOSEST_INTERP - ) - self.ip = equilibrium.time_slice[0].global_quantities.ip - self.r0 = equilibrium.vacuum_toroidal_field.r0 - self.b0 = equilibrium.vacuum_toroidal_field.b0[0] - - self.dpressure_dpsi = equilibrium.time_slice[0].profiles_1d.dpressure_dpsi - self.f_df_dpsi = equilibrium.time_slice[0].profiles_1d.f_df_dpsi - psi = equilibrium.time_slice[0].profiles_1d.psi + with imas.DBEntry(uri, "r") as entry: + eq = entry.get_slice("equilibrium", time, imas.ids_defs.CLOSEST_INTERP) + self.dpressure_dpsi = eq.time_slice[0].profiles_1d.dpressure_dpsi + self.f_df_dpsi = eq.time_slice[0].profiles_1d.f_df_dpsi + psi = eq.time_slice[0].profiles_1d.psi self.psi_norm = (psi - psi[0]) / (psi[-1] - psi[0]) - - self.has_properties = True except Exception as e: - pn.state.notifications.error( - f"Could not load plasma property outline from {self.input.uri}:" - f" {str(e)}" - ) - self.has_properties = False - - @param.depends("input_mode") - def _panel_property_options(self): - if self.input_mode == self.MANUAL_INPUT: - return self.properties_params - elif self.input_mode == self.EQUILIBRIUM_INPUT: - return pn.Row(pn.Param(self.input, show_name=False), self.indicator) + pn.state.notifications.error(f"Could not load profiles from {uri}: {e}") + self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None def __panel__(self): - return self.panel + return pn.Column( + self._ip, + self._r0, + self._b0, + self._profiles, + sizing_mode="stretch_width", + margin=(20, 20), + ) diff --git a/waveform_editor/gui/styles/property_card.css b/waveform_editor/gui/styles/property_card.css new file mode 100644 index 00000000..c371eaae --- /dev/null +++ b/waveform_editor/gui/styles/property_card.css @@ -0,0 +1,10 @@ +:host(.property-card) { + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 8px 12px 12px 12px; + display: block; +} + +.bk-btn.bk-btn-primary.bk-active { + color: white !important; +} From 029dc4c28237d6f1d93586eb3b7a3caa508927c4 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Tue, 5 May 2026 17:07:07 +0200 Subject: [PATCH 2/8] lint --- .../gui/shape_editor/plasma_properties.py | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 2fb2d5ea..19e8ddb0 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -56,8 +56,9 @@ def __init__(self, label, default_value, step=0.01, **params): self._time_input = pn.widgets.FloatInput.from_param( self.param.ids_time, name="Time [s]", width=100 ) - self.param.watch(lambda *_: - self.param.trigger("changed"), ["ids_uri", "ids_time"]) + self.param.watch( + lambda *_: self.param.trigger("changed"), ["ids_uri", "ids_time"] + ) def _on_mode_change(self, event): self.mode = event.new @@ -79,8 +80,9 @@ def __panel__(self): return pn.Column( header, pn.Column(self._value_input, visible=is_manual, margin=(4, 0, 0, 0)), - pn.Column(self._uri_input, self._time_input, - visible=is_ids, margin=(4, 0, 0, 0)), + pn.Column( + self._uri_input, self._time_input, visible=is_ids, margin=(4, 0, 0, 0) + ), css_classes=["property-card"], stylesheets=[_CARD_CSS], max_width=600, @@ -125,8 +127,9 @@ def __init__(self, **params): self._gamma_input = pn.widgets.FloatInput.from_param( self.param.gamma, name="Gamma", sizing_mode="stretch_width" ) - self.param.watch(lambda *_: - self.param.trigger("changed"), ["alpha", "beta", "gamma"]) + self.param.watch( + lambda *_: self.param.trigger("changed"), ["alpha", "beta", "gamma"] + ) self._uri_input = pn.widgets.TextInput.from_param( self.param.ids_uri, @@ -137,8 +140,9 @@ def __init__(self, **params): self._time_input = pn.widgets.FloatInput.from_param( self.param.ids_time, name="Time [s]", width=100 ) - self.param.watch(lambda *_: - self.param.trigger("changed"), ["ids_uri", "ids_time"]) + self.param.watch( + lambda *_: self.param.trigger("changed"), ["ids_uri", "ids_time"] + ) def _on_mode_change(self, event): self.mode = event.new @@ -156,12 +160,17 @@ def __panel__(self): return pn.Column( header, pn.Column( - self._alpha_input, self._beta_input, self._gamma_input, - visible=is_parametric, margin=(4, 0, 0, 0), + self._alpha_input, + self._beta_input, + self._gamma_input, + visible=is_parametric, + margin=(4, 0, 0, 0), ), pn.Column( - self._uri_input, self._time_input, - visible=is_ids, margin=(4, 0, 0, 0), + self._uri_input, + self._time_input, + visible=is_ids, + margin=(4, 0, 0, 0), ), css_classes=["property-card"], stylesheets=[_CARD_CSS], @@ -215,16 +224,13 @@ def _load_plasma_properties(self): self.ip = self._load_scalar( self._ip, lambda eq: eq.time_slice[0].global_quantities.ip ) - self.r0 = self._load_scalar( - self._r0, lambda eq: eq.vacuum_toroidal_field.r0 - ) - self.b0 = self._load_scalar( - self._b0, lambda eq: eq.vacuum_toroidal_field.b0[0] - ) + self.r0 = self._load_scalar(self._r0, lambda eq: eq.vacuum_toroidal_field.r0) + self.b0 = self._load_scalar(self._b0, lambda eq: eq.vacuum_toroidal_field.b0[0]) if self._profiles.mode == EQ_IDS: - self._load_profiles_from_ids(self._profiles.ids_uri, - self._profiles.ids_time) + self._load_profiles_from_ids( + self._profiles.ids_uri, self._profiles.ids_time + ) elif self.r0 is not None: try: self.psi_norm, self.dpressure_dpsi, self.f_df_dpsi = ( @@ -242,8 +248,7 @@ def _load_plasma_properties(self): self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None self.has_properties = all( - v is not None - for v in [self.ip, self.r0, self.b0, self.dpressure_dpsi] + v is not None for v in [self.ip, self.r0, self.b0, self.dpressure_dpsi] ) self.param.trigger("profile_updated") From 7be8e76e67396e03ad427da252007b5eaaf6862a Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Tue, 19 May 2026 13:37:22 +0200 Subject: [PATCH 3/8] comments --- pyproject.toml | 1 + .../gui/shape_editor/plasma_properties.py | 82 ++++++++----------- .../gui/shape_editor/shape_editor.py | 3 +- 3 files changed, 38 insertions(+), 48 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ca057ecb..c4f7d6bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ select = [ ] [tool.pytest.ini_options] +norecursedirs = ["nice"] filterwarnings = [ # filter deprecation warning mentioned in https://github.com/plotly/Kaleido/issues/194 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning" diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 19e8ddb0..54df79ac 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -5,6 +5,7 @@ import param from panel.viewable import Viewer +from waveform_editor.gui.util import FormattedEditableFloatSlider from waveform_editor.shape_editor.plasma_properties_calc import ( compute_profiles_from_params, ) @@ -30,42 +31,29 @@ def __init__(self, label, default_value, step=0.01, **params): self.value = default_value self._label = label - self._mode_toggle = pn.widgets.RadioButtonGroup( - options=[MANUAL, EQ_IDS], - value=self.mode, + self._mode_toggle = pn.widgets.RadioButtonGroup.from_param( + self.param.mode, button_style="outline", button_type="primary", stylesheets=[_CARD_CSS], ) - self._mode_toggle.param.watch(self._on_mode_change, "value") - - self._value_input = pn.widgets.FloatInput( - value=self.value, + self._value_input = pn.widgets.FloatInput.from_param( + self.param.value, step=step, sizing_mode="stretch_width", margin=(4, 0, 0, 0), ) - self._value_input.param.watch(self._on_value_change, "value") - self._uri_input = pn.widgets.TextInput.from_param( self.param.ids_uri, - name="", - placeholder="IDS URI", + name="IDS URI", sizing_mode="stretch_width", ) self._time_input = pn.widgets.FloatInput.from_param( self.param.ids_time, name="Time [s]", width=100 ) - self.param.watch( - lambda *_: self.param.trigger("changed"), ["ids_uri", "ids_time"] - ) - def _on_mode_change(self, event): - self.mode = event.new - self.param.trigger("changed") - - def _on_value_change(self, event): - self.value = event.new + @param.depends("mode", "value", "ids_uri", "ids_time", watch=True) + def _on_change(self): self.param.trigger("changed") def __panel__(self): @@ -80,7 +68,7 @@ def __panel__(self): return pn.Column( header, pn.Column(self._value_input, visible=is_manual, margin=(4, 0, 0, 0)), - pn.Column( + pn.Row( self._uri_input, self._time_input, visible=is_ids, margin=(4, 0, 0, 0) ), css_classes=["property-card"], @@ -99,53 +87,43 @@ class PlasmaProfiles(Viewer): """ mode = param.ObjectSelector(default=PARAMETRIC, objects=[PARAMETRIC, EQ_IDS]) - alpha = param.Number(default=0.5, step=0.01) - beta = param.Number(default=0.5, step=0.01) - gamma = param.Number(default=1.0, step=0.01) + alpha = param.Number(default=0.5, softbounds=[0.5, 2], step=0.01) + beta = param.Number(default=0.5, softbounds=[0.5, 2], step=0.01) + gamma = param.Number(default=1.0, softbounds=[0.5, 2], step=0.01) ids_uri = param.String(default="") ids_time = param.Number(default=0.0) changed = param.Event() + profiles_plot = param.Parameter(default=None) def __init__(self, **params): super().__init__(**params) - self._mode_toggle = pn.widgets.RadioButtonGroup( - options=[PARAMETRIC, EQ_IDS], - value=self.mode, + self._mode_toggle = pn.widgets.RadioButtonGroup.from_param( + self.param.mode, button_style="outline", button_type="primary", stylesheets=[_CARD_CSS], ) - self._mode_toggle.param.watch(self._on_mode_change, "value") - - self._alpha_input = pn.widgets.FloatInput.from_param( - self.param.alpha, name="Alpha", sizing_mode="stretch_width" - ) - self._beta_input = pn.widgets.FloatInput.from_param( - self.param.beta, name="Beta", sizing_mode="stretch_width" + self._alpha_input = FormattedEditableFloatSlider.from_param( + self.param.alpha, name="Alpha", margin=0 ) - self._gamma_input = pn.widgets.FloatInput.from_param( - self.param.gamma, name="Gamma", sizing_mode="stretch_width" + self._beta_input = FormattedEditableFloatSlider.from_param( + self.param.beta, name="Beta", margin=0 ) - self.param.watch( - lambda *_: self.param.trigger("changed"), ["alpha", "beta", "gamma"] + self._gamma_input = FormattedEditableFloatSlider.from_param( + self.param.gamma, name="Gamma", margin=0 ) - self._uri_input = pn.widgets.TextInput.from_param( self.param.ids_uri, - name="", - placeholder="IDS URI", + name="IDS URI", sizing_mode="stretch_width", ) self._time_input = pn.widgets.FloatInput.from_param( self.param.ids_time, name="Time [s]", width=100 ) - self.param.watch( - lambda *_: self.param.trigger("changed"), ["ids_uri", "ids_time"] - ) - def _on_mode_change(self, event): - self.mode = event.new + @param.depends("mode", "alpha", "beta", "gamma", "ids_uri", "ids_time", watch=True) + def _on_change(self): self.param.trigger("changed") def __panel__(self): @@ -164,6 +142,7 @@ def __panel__(self): self._beta_input, self._gamma_input, visible=is_parametric, + align="end", margin=(4, 0, 0, 0), ), pn.Column( @@ -172,6 +151,7 @@ def __panel__(self): visible=is_ids, margin=(4, 0, 0, 0), ), + pn.bind(lambda p: p, self.param.profiles_plot), css_classes=["property-card"], stylesheets=[_CARD_CSS], max_width=600, @@ -185,9 +165,17 @@ class PlasmaProperties(Viewer): ) has_properties = param.Boolean(doc="Whether the plasma properties are loaded.") + @property + def profiles_plot(self): + return self._profiles.profiles_plot + + @profiles_plot.setter + def profiles_plot(self, value): + self._profiles.profiles_plot = value + def __init__(self): super().__init__() - self._ip = PropertyInput("Plasma current [A]", default_value=-1.5e7) + self._ip = PropertyInput("Plasma current [A]", default_value=-1.5e7, step=1e6) self._r0 = PropertyInput("Reference major radius [m]", default_value=6.2) self._b0 = PropertyInput("Toroidal magnetic field [T]", default_value=-5.3) self._profiles = PlasmaProfiles() diff --git a/waveform_editor/gui/shape_editor/shape_editor.py b/waveform_editor/gui/shape_editor/shape_editor.py index 0b728a58..370d680a 100644 --- a/waveform_editor/gui/shape_editor/shape_editor.py +++ b/waveform_editor/gui/shape_editor/shape_editor.py @@ -52,6 +52,7 @@ def __init__(self, main_gui): plasma_shape=self.plasma_shape, plasma_properties=self.plasma_properties, ) + self.plasma_properties.profiles_plot = self.nice_plotter.profiles_pane self.nice_settings = settings.nice self.xml_params_inv = ET.fromstring( @@ -116,7 +117,7 @@ def __init__(self, main_gui): visible=self.nice_settings.param.is_inverse_mode.rx(), ), self._create_card( - pn.Column(self.plasma_properties, self.nice_plotter.profiles_pane), + self.plasma_properties, "Plasma Properties", is_valid=self.plasma_properties.param.has_properties, ), From 59eac574003347bd79cacce762bf091b57f20332 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan <74863064+ioan-alexandra@users.noreply.github.com> Date: Tue, 19 May 2026 14:08:54 +0200 Subject: [PATCH 4/8] restore Remove 'nice' from norecursedirs in pytest options. --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c4f7d6bf..ca057ecb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,6 @@ select = [ ] [tool.pytest.ini_options] -norecursedirs = ["nice"] filterwarnings = [ # filter deprecation warning mentioned in https://github.com/plotly/Kaleido/issues/194 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning" From 99e313e1194b2336e4ae648d1f27861eb7366388 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Tue, 19 May 2026 17:02:14 +0200 Subject: [PATCH 5/8] add value indicator for eqs --- .../gui/shape_editor/plasma_properties.py | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 54df79ac..c75995db 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -24,6 +24,7 @@ class PropertyInput(Viewer): value = param.Number(default=0.0) ids_uri = param.String(default="") ids_time = param.Number(default=0.0) + loaded_value = param.Number(default=None, allow_None=True, precedence=-1) changed = param.Event() def __init__(self, label, default_value, step=0.01, **params): @@ -56,6 +57,16 @@ def __init__(self, label, default_value, step=0.01, **params): def _on_change(self): self.param.trigger("changed") + @param.depends("loaded_value") + def _loaded_display(self): + if self.loaded_value is None: + return "" + html = ( + f'' + f"Loaded: {self.loaded_value:.4g}" + ) + return pn.pane.HTML(html, margin=(2, 0, 0, 0)) + def __panel__(self): is_manual = pn.bind(lambda m: m == MANUAL, self.param.mode) is_ids = pn.bind(lambda m: m == EQ_IDS, self.param.mode) @@ -68,8 +79,11 @@ def __panel__(self): return pn.Column( header, pn.Column(self._value_input, visible=is_manual, margin=(4, 0, 0, 0)), - pn.Row( - self._uri_input, self._time_input, visible=is_ids, margin=(4, 0, 0, 0) + pn.Column( + pn.Row(self._uri_input, self._time_input, margin=(4, 0, 0, 0)), + self._loaded_display, + visible=is_ids, + margin=0, ), css_classes=["property-card"], stylesheets=[_CARD_CSS], @@ -203,9 +217,12 @@ def _load_scalar(self, prop: PropertyInput, extractor): eq = entry.get_slice( "equilibrium", prop.ids_time, imas.ids_defs.CLOSEST_INTERP ) - return extractor(eq) + value = extractor(eq) + prop.loaded_value = float(value) + return value except Exception as e: pn.state.notifications.error(f"Could not load from {prop.ids_uri}: {e}") + prop.loaded_value = None return None def _load_plasma_properties(self): From bec08bfeb8b4c76d887f30dbb461500312ba52d1 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Fri, 29 May 2026 11:12:00 +0200 Subject: [PATCH 6/8] cleanup --- .../gui/shape_editor/nice_plotter.py | 41 ----------- .../gui/shape_editor/plasma_properties.py | 68 ++++++++++++++++--- .../gui/shape_editor/shape_editor.py | 1 - 3 files changed, 58 insertions(+), 52 deletions(-) diff --git a/waveform_editor/gui/shape_editor/nice_plotter.py b/waveform_editor/gui/shape_editor/nice_plotter.py index 5a8189f3..1756a0c4 100644 --- a/waveform_editor/gui/shape_editor/nice_plotter.py +++ b/waveform_editor/gui/shape_editor/nice_plotter.py @@ -6,7 +6,6 @@ import numpy as np import panel as pn import param -import scipy from imas.ids_toplevel import IDSToplevel from panel.viewable import Viewer @@ -45,9 +44,6 @@ class NicePlotter(Viewer): WIDTH = 800 HEIGHT = 1000 - PROFILE_WIDTH = 350 - PROFILE_HEIGHT = 350 - def __init__(self, **params): super().__init__(**params) self.DEFAULT_OPTS = hv.opts.Overlay( @@ -85,10 +81,6 @@ def __init__(self, **params): loading=self.communicator.param.processing, ) - profiles_plot = hv.DynamicMap(self._plot_profiles) - self.profiles_pane = pn.pane.HoloViews( - profiles_plot, width=self.PROFILE_WIDTH, height=self.PROFILE_HEIGHT - ) self.panel_layout = pn.Param( self.param, show_name=False, @@ -99,39 +91,6 @@ def __init__(self, **params): }, ) - @pn.depends("plasma_properties.profile_updated") - def _plot_profiles(self): - # Define kdims/vdims otherwise Holoviews will link axes with flux map - kdims = "Normalized Poloidal Flux" - vdims = "Profile Value [A.U.]" - if not self.plasma_properties.has_properties: - overlay = hv.Overlay([hv.Curve([], kdims=kdims, vdims=vdims)]) - else: - psi_norm = self.plasma_properties.psi_norm - - # Scale profiles - r0 = self.plasma_properties.r0 - dpressure_dpsi = self.plasma_properties.dpressure_dpsi * r0 - f_df_dpsi = self.plasma_properties.f_df_dpsi / (scipy.constants.mu_0 * r0) - - dpressure_dpsi_curve = hv.Curve( - (psi_norm, dpressure_dpsi), - kdims=kdims, - vdims=vdims, - label="dpressure_dpsi * r₀", - ) - f_df_dpsi_curve = hv.Curve( - (psi_norm, f_df_dpsi), - kdims=kdims, - vdims=vdims, - label="f_df_dpsi / (μ₀ * r₀)", - ) - overlay = dpressure_dpsi_curve * f_df_dpsi_curve - - return overlay.opts( - hv.opts.Overlay(title="Plasma Profiles"), hv.opts.Curve(framewise=True) - ) - @pn.depends( "plasma_shape.shape_updated", "show_desired_shape", "nice_settings.mode" ) diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index c75995db..072df069 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -1,8 +1,10 @@ from pathlib import Path +import holoviews as hv import imas import panel as pn import param +import scipy.constants from panel.viewable import Viewer from waveform_editor.gui.util import FormattedEditableFloatSlider @@ -59,6 +61,7 @@ def _on_change(self): @param.depends("loaded_value") def _loaded_display(self): + """Render a small label showing the last value loaded from IDS.""" if self.loaded_value is None: return "" html = ( @@ -107,7 +110,13 @@ class PlasmaProfiles(Viewer): ids_uri = param.String(default="") ids_time = param.Number(default=0.0) changed = param.Event() - profiles_plot = param.Parameter(default=None) + + # Computed profile data pushed by PlasmaProperties after each load + psi_norm = param.Parameter(default=None, precedence=-1) + dpressure_dpsi = param.Parameter(default=None, precedence=-1) + f_df_dpsi = param.Parameter(default=None, precedence=-1) + r0 = param.Parameter(default=None, precedence=-1) + has_properties = param.Boolean(default=False, precedence=-1) def __init__(self, **params): super().__init__(**params) @@ -135,11 +144,40 @@ def __init__(self, **params): self._time_input = pn.widgets.FloatInput.from_param( self.param.ids_time, name="Time [s]", width=100 ) + self._profiles_pane = pn.pane.HoloViews( + hv.DynamicMap(self._plot_profiles), width=350, height=350 + ) @param.depends("mode", "alpha", "beta", "gamma", "ids_uri", "ids_time", watch=True) def _on_change(self): self.param.trigger("changed") + @pn.depends("psi_norm", "has_properties") + def _plot_profiles(self): + """Plot dpressure_dpsi and f_df_dpsi vs normalised poloidal flux.""" + # Define kdims/vdims explicitly + # to prevent HoloViews linking axes with the flux map + kdims = "Normalized Poloidal Flux" + vdims = "Profile Value [A.U.]" + if not self.has_properties: + return hv.Overlay([hv.Curve([], kdims=kdims, vdims=vdims)]) + r0 = self.r0 + dpressure_dpsi_curve = hv.Curve( + (self.psi_norm, self.dpressure_dpsi * r0), + kdims=kdims, + vdims=vdims, + label="dpressure_dpsi * r₀", + ) + f_df_dpsi_curve = hv.Curve( + (self.psi_norm, self.f_df_dpsi / (scipy.constants.mu_0 * r0)), + kdims=kdims, + vdims=vdims, + label="f_df_dpsi / (μ₀ * r₀)", + ) + return (dpressure_dpsi_curve * f_df_dpsi_curve).opts( + hv.opts.Overlay(title="Plasma Profiles"), hv.opts.Curve(framewise=True) + ) + def __panel__(self): is_parametric = pn.bind(lambda m: m == PARAMETRIC, self.param.mode) is_ids = pn.bind(lambda m: m == EQ_IDS, self.param.mode) @@ -165,7 +203,7 @@ def __panel__(self): visible=is_ids, margin=(4, 0, 0, 0), ), - pn.bind(lambda p: p, self.param.profiles_plot), + self._profiles_pane, css_classes=["property-card"], stylesheets=[_CARD_CSS], max_width=600, @@ -174,19 +212,18 @@ def __panel__(self): class PlasmaProperties(Viewer): + """Assembles per-property inputs; exposes resolved plasma scalars and profiles. + + Each scalar (ip, r0, b0) can independently source its value from manual input or + an equilibrium IDS. Profile functions (dpressure_dpsi, f_df_dpsi) can be computed + parametrically or read from an IDS via the PlasmaProfiles widget. + """ + profile_updated = param.Event( doc="Triggered whenever the dpressure_dpsi and f_df_dpsi are updated." ) has_properties = param.Boolean(doc="Whether the plasma properties are loaded.") - @property - def profiles_plot(self): - return self._profiles.profiles_plot - - @profiles_plot.setter - def profiles_plot(self, value): - self._profiles.profiles_plot = value - def __init__(self): super().__init__() self._ip = PropertyInput("Plasma current [A]", default_value=-1.5e7, step=1e6) @@ -226,6 +263,11 @@ def _load_scalar(self, prop: PropertyInput, extractor): return None def _load_plasma_properties(self): + """Reload all plasma properties from the current mode and inputs. + + Resolves each scalar independently, then either reads profile functions from an + IDS or computes them parametrically. Triggers `profile_updated` when done. + """ self.ip = self._load_scalar( self._ip, lambda eq: eq.time_slice[0].global_quantities.ip ) @@ -255,9 +297,15 @@ def _load_plasma_properties(self): self.has_properties = all( v is not None for v in [self.ip, self.r0, self.b0, self.dpressure_dpsi] ) + self._profiles.psi_norm = self.psi_norm + self._profiles.dpressure_dpsi = self.dpressure_dpsi + self._profiles.f_df_dpsi = self.f_df_dpsi + self._profiles.r0 = self.r0 + self._profiles.has_properties = self.has_properties self.param.trigger("profile_updated") def _load_profiles_from_ids(self, uri: str, time: float): + """Load dpressure_dpsi, f_df_dpsi, and psi_norm from an equilibrium IDS.""" if not uri: self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None return diff --git a/waveform_editor/gui/shape_editor/shape_editor.py b/waveform_editor/gui/shape_editor/shape_editor.py index 370d680a..992aad56 100644 --- a/waveform_editor/gui/shape_editor/shape_editor.py +++ b/waveform_editor/gui/shape_editor/shape_editor.py @@ -52,7 +52,6 @@ def __init__(self, main_gui): plasma_shape=self.plasma_shape, plasma_properties=self.plasma_properties, ) - self.plasma_properties.profiles_plot = self.nice_plotter.profiles_pane self.nice_settings = settings.nice self.xml_params_inv = ET.fromstring( From b3fddcdc207350c9e5452e323f7f024a5721e232 Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Mon, 1 Jun 2026 13:54:58 +0200 Subject: [PATCH 7/8] comments --- .../gui/shape_editor/plasma_properties.py | 196 +++++++++++------- 1 file changed, 120 insertions(+), 76 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 072df069..6e35a93f 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -13,7 +13,7 @@ ) MANUAL = "Manual" -EQ_IDS = "Eq IDS" +EQ_IDS = "Equilibrium IDS" PARAMETRIC = "Parametric" _CARD_CSS = (Path(__file__).parent.parent / "styles" / "property_card.css").read_text() @@ -22,15 +22,30 @@ class PropertyInput(Viewer): """A single scalar plasma property with its own Manual / Eq IDS mode toggle.""" - mode = param.ObjectSelector(default=MANUAL, objects=[MANUAL, EQ_IDS]) + mode = param.Selector(default=MANUAL, objects=[MANUAL, EQ_IDS]) value = param.Number(default=0.0) ids_uri = param.String(default="") ids_time = param.Number(default=0.0) loaded_value = param.Number(default=None, allow_None=True, precedence=-1) + resolved_value = param.Parameter(default=None, precedence=-1) changed = param.Event() - def __init__(self, label, default_value, step=0.01, **params): + def __init__(self, label, default_value, ids_path, step=0.01, **params): + """ + Parameters + ---------- + label: + Display label shown in the card header. + default_value: + Initial value used in Manual mode. + ids_path: + Dot-path into the equilibrium IDS used in Eq IDS mode, e.g. + ``"vacuum_toroidal_field.r0"``. + step: + Step size for the numeric input widget. + """ super().__init__(**params) + self._ids_path = ids_path self.value = default_value self._label = label @@ -54,9 +69,32 @@ def __init__(self, label, default_value, step=0.01, **params): self._time_input = pn.widgets.FloatInput.from_param( self.param.ids_time, name="Time [s]", width=100 ) + self._reload() + + def _reload(self): + if self.mode == MANUAL: + self.resolved_value = self.value + return + if not self.ids_uri: + self.resolved_value = None + return + try: + with imas.DBEntry(self.ids_uri, "r") as entry: + eq = entry.get_slice( + "equilibrium", + self.ids_time, + imas.ids_defs.CLOSEST_INTERP, + lazy=True, + ) + self.loaded_value = self.resolved_value = float(eq[self._ids_path]) + except Exception as e: + pn.state.notifications.error(f"Could not load from {self.ids_uri}: {e}") + self.loaded_value = None + self.resolved_value = None @param.depends("mode", "value", "ids_uri", "ids_time", watch=True) def _on_change(self): + self._reload() self.param.trigger("changed") @param.depends("loaded_value") @@ -73,6 +111,7 @@ def _loaded_display(self): def __panel__(self): is_manual = pn.bind(lambda m: m == MANUAL, self.param.mode) is_ids = pn.bind(lambda m: m == EQ_IDS, self.param.mode) + self._value_input.visible = is_manual header = pn.Row( pn.pane.HTML(self._label, sizing_mode="stretch_width", margin=(5, 0)), self._mode_toggle, @@ -81,7 +120,7 @@ def __panel__(self): ) return pn.Column( header, - pn.Column(self._value_input, visible=is_manual, margin=(4, 0, 0, 0)), + self._value_input, pn.Column( pn.Row(self._uri_input, self._time_input, margin=(4, 0, 0, 0)), self._loaded_display, @@ -103,7 +142,7 @@ class PlasmaProfiles(Viewer): directly from an equilibrium IDS file. """ - mode = param.ObjectSelector(default=PARAMETRIC, objects=[PARAMETRIC, EQ_IDS]) + mode = param.Selector(default=PARAMETRIC, objects=[PARAMETRIC, EQ_IDS]) alpha = param.Number(default=0.5, softbounds=[0.5, 2], step=0.01) beta = param.Number(default=0.5, softbounds=[0.5, 2], step=0.01) gamma = param.Number(default=1.0, softbounds=[0.5, 2], step=0.01) @@ -111,7 +150,7 @@ class PlasmaProfiles(Viewer): ids_time = param.Number(default=0.0) changed = param.Event() - # Computed profile data pushed by PlasmaProperties after each load + # Resolved profile data, updated by _reload() psi_norm = param.Parameter(default=None, precedence=-1) dpressure_dpsi = param.Parameter(default=None, precedence=-1) f_df_dpsi = param.Parameter(default=None, precedence=-1) @@ -148,6 +187,54 @@ def __init__(self, **params): hv.DynamicMap(self._plot_profiles), width=350, height=350 ) + def _reload(self, r0): + """Resolve profile functions from parametric params or an equilibrium IDS. + + Called by PlasmaProperties after it resolves r0. Results are stored in + `psi_norm`, `dpressure_dpsi`, and `f_df_dpsi`. + """ + self.r0 = r0 + if self.mode == EQ_IDS: + self._load_from_ids() + elif r0 is not None: + try: + self.psi_norm, self.dpressure_dpsi, self.f_df_dpsi = ( + compute_profiles_from_params( + r0=r0, + alpha=self.alpha, + beta=self.beta, + gamma=self.gamma, + ) + ) + except Exception as e: + pn.state.notifications.error(f"Could not compute profiles: {e}") + self.psi_norm = self.dpressure_dpsi = self.f_df_dpsi = None + else: + self.psi_norm = self.dpressure_dpsi = self.f_df_dpsi = None + + def _load_from_ids(self): + """Load dpressure_dpsi, f_df_dpsi, and psi_norm from an equilibrium IDS.""" + if not self.ids_uri: + self.psi_norm = self.dpressure_dpsi = self.f_df_dpsi = None + return + try: + with imas.DBEntry(self.ids_uri, "r") as entry: + eq = entry.get_slice( + "equilibrium", + self.ids_time, + imas.ids_defs.CLOSEST_INTERP, + lazy=True, + ) + self.dpressure_dpsi = eq.time_slice[0].profiles_1d.dpressure_dpsi + self.f_df_dpsi = eq.time_slice[0].profiles_1d.f_df_dpsi + psi = eq.time_slice[0].profiles_1d.psi + self.psi_norm = (psi - psi[0]) / (psi[-1] - psi[0]) + except Exception as e: + pn.state.notifications.error( + f"Could not load profiles from {self.ids_uri}: {e}" + ) + self.psi_norm = self.dpressure_dpsi = self.f_df_dpsi = None + @param.depends("mode", "alpha", "beta", "gamma", "ids_uri", "ids_time", watch=True) def _on_change(self): self.param.trigger("changed") @@ -226,9 +313,22 @@ class PlasmaProperties(Viewer): def __init__(self): super().__init__() - self._ip = PropertyInput("Plasma current [A]", default_value=-1.5e7, step=1e6) - self._r0 = PropertyInput("Reference major radius [m]", default_value=6.2) - self._b0 = PropertyInput("Toroidal magnetic field [T]", default_value=-5.3) + self._ip = PropertyInput( + "Plasma current [A]", + default_value=-1.5e7, + ids_path="time_slice[0].global_quantities.ip", + step=1e6, + ) + self._r0 = PropertyInput( + "Reference major radius [m]", + default_value=6.2, + ids_path="vacuum_toroidal_field.r0", + ) + self._b0 = PropertyInput( + "Toroidal magnetic field [T]", + default_value=-5.3, + ids_path="vacuum_toroidal_field.b0[0]", + ) self._profiles = PlasmaProfiles() self.dpressure_dpsi = None @@ -239,87 +339,31 @@ def __init__(self): self.b0 = None for widget in [self._ip, self._r0, self._b0, self._profiles]: - widget.param.watch(lambda *_: self._load_plasma_properties(), "changed") + widget.param.watch(self._load_plasma_properties, "changed") self._load_plasma_properties() - def _load_scalar(self, prop: PropertyInput, extractor): - """Return a scalar value from manual input or extracted from an IDS.""" - if prop.mode == MANUAL: - return prop.value - if not prop.ids_uri: - return None - try: - with imas.DBEntry(prop.ids_uri, "r") as entry: - eq = entry.get_slice( - "equilibrium", prop.ids_time, imas.ids_defs.CLOSEST_INTERP - ) - value = extractor(eq) - prop.loaded_value = float(value) - return value - except Exception as e: - pn.state.notifications.error(f"Could not load from {prop.ids_uri}: {e}") - prop.loaded_value = None - return None - - def _load_plasma_properties(self): + def _load_plasma_properties(self, _event=None): """Reload all plasma properties from the current mode and inputs. - Resolves each scalar independently, then either reads profile functions from an - IDS or computes them parametrically. Triggers `profile_updated` when done. + Reads already-resolved scalar values from each PropertyInput, delegates + profile loading/computation to PlasmaProfiles, then triggers `profile_updated`. """ - self.ip = self._load_scalar( - self._ip, lambda eq: eq.time_slice[0].global_quantities.ip - ) - self.r0 = self._load_scalar(self._r0, lambda eq: eq.vacuum_toroidal_field.r0) - self.b0 = self._load_scalar(self._b0, lambda eq: eq.vacuum_toroidal_field.b0[0]) + self.ip = self._ip.resolved_value + self.r0 = self._r0.resolved_value + self.b0 = self._b0.resolved_value - if self._profiles.mode == EQ_IDS: - self._load_profiles_from_ids( - self._profiles.ids_uri, self._profiles.ids_time - ) - elif self.r0 is not None: - try: - self.psi_norm, self.dpressure_dpsi, self.f_df_dpsi = ( - compute_profiles_from_params( - r0=self.r0, - alpha=self._profiles.alpha, - beta=self._profiles.beta, - gamma=self._profiles.gamma, - ) - ) - except Exception as e: - pn.state.notifications.error(f"Could not compute profiles: {e}") - self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None - else: - self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None + self._profiles._reload(self.r0) + self.psi_norm = self._profiles.psi_norm + self.dpressure_dpsi = self._profiles.dpressure_dpsi + self.f_df_dpsi = self._profiles.f_df_dpsi self.has_properties = all( v is not None for v in [self.ip, self.r0, self.b0, self.dpressure_dpsi] ) - self._profiles.psi_norm = self.psi_norm - self._profiles.dpressure_dpsi = self.dpressure_dpsi - self._profiles.f_df_dpsi = self.f_df_dpsi - self._profiles.r0 = self.r0 self._profiles.has_properties = self.has_properties self.param.trigger("profile_updated") - def _load_profiles_from_ids(self, uri: str, time: float): - """Load dpressure_dpsi, f_df_dpsi, and psi_norm from an equilibrium IDS.""" - if not uri: - self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None - return - try: - with imas.DBEntry(uri, "r") as entry: - eq = entry.get_slice("equilibrium", time, imas.ids_defs.CLOSEST_INTERP) - self.dpressure_dpsi = eq.time_slice[0].profiles_1d.dpressure_dpsi - self.f_df_dpsi = eq.time_slice[0].profiles_1d.f_df_dpsi - psi = eq.time_slice[0].profiles_1d.psi - self.psi_norm = (psi - psi[0]) / (psi[-1] - psi[0]) - except Exception as e: - pn.state.notifications.error(f"Could not load profiles from {uri}: {e}") - self.dpressure_dpsi = self.f_df_dpsi = self.psi_norm = None - def __panel__(self): return pn.Column( self._ip, From ab972aededb058cf13d5cf544cf721c4b994c3af Mon Sep 17 00:00:00 2001 From: Alexandra Ioan Date: Mon, 1 Jun 2026 14:23:33 +0200 Subject: [PATCH 8/8] make PlasmaProperties local --- .../gui/shape_editor/plasma_properties.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/waveform_editor/gui/shape_editor/plasma_properties.py b/waveform_editor/gui/shape_editor/plasma_properties.py index 6e35a93f..bc610710 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -187,20 +187,15 @@ def __init__(self, **params): hv.DynamicMap(self._plot_profiles), width=350, height=350 ) - def _reload(self, r0): - """Resolve profile functions from parametric params or an equilibrium IDS. - - Called by PlasmaProperties after it resolves r0. Results are stored in - `psi_norm`, `dpressure_dpsi`, and `f_df_dpsi`. - """ - self.r0 = r0 + def _reload(self): + """Resolve profile functions from the current params; store results in-place.""" if self.mode == EQ_IDS: self._load_from_ids() - elif r0 is not None: + elif self.r0 is not None: try: self.psi_norm, self.dpressure_dpsi, self.f_df_dpsi = ( compute_profiles_from_params( - r0=r0, + r0=self.r0, alpha=self.alpha, beta=self.beta, gamma=self.gamma, @@ -237,8 +232,14 @@ def _load_from_ids(self): @param.depends("mode", "alpha", "beta", "gamma", "ids_uri", "ids_time", watch=True) def _on_change(self): + self._reload() self.param.trigger("changed") + @param.depends("r0", watch=True) + def _on_r0_change(self): + """Reload silently when r0 is updated by the parent; does not notify parent.""" + self._reload() + @pn.depends("psi_norm", "has_properties") def _plot_profiles(self): """Plot dpressure_dpsi and f_df_dpsi vs normalised poloidal flux.""" @@ -353,7 +354,7 @@ def _load_plasma_properties(self, _event=None): self.r0 = self._r0.resolved_value self.b0 = self._b0.resolved_value - self._profiles._reload(self.r0) + self._profiles.r0 = self.r0 self.psi_norm = self._profiles.psi_norm self.dpressure_dpsi = self._profiles.dpressure_dpsi self.f_df_dpsi = self._profiles.f_df_dpsi