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 29334e8a..bc610710 100644 --- a/waveform_editor/gui/shape_editor/plasma_properties.py +++ b/waveform_editor/gui/shape_editor/plasma_properties.py @@ -1,55 +1,311 @@ +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 ( - EquilibriumInput, - FormattedEditableFloatSlider, - WarningIndicator, -) +from waveform_editor.gui.util import FormattedEditableFloatSlider from waveform_editor.shape_editor.plasma_properties_calc import ( compute_profiles_from_params, ) +MANUAL = "Manual" +EQ_IDS = "Equilibrium 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.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, 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 + + self._mode_toggle = pn.widgets.RadioButtonGroup.from_param( + self.param.mode, + button_style="outline", + button_type="primary", + stylesheets=[_CARD_CSS], + ) + self._value_input = pn.widgets.FloatInput.from_param( + self.param.value, + step=step, + sizing_mode="stretch_width", + margin=(4, 0, 0, 0), + ) + self._uri_input = pn.widgets.TextInput.from_param( + self.param.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._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") + def _loaded_display(self): + """Render a small label showing the last value loaded from IDS.""" + 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) + self._value_input.visible = is_manual + 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, + self._value_input, + 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], + max_width=600, + margin=(0, 0, 8, 0), + ) + + +class PlasmaProfiles(Viewer): + """Widget for selecting the plasma profile source: parametric or from IDS. + + 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.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) + ids_uri = param.String(default="") + ids_time = param.Number(default=0.0) + changed = param.Event() + + # 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) + r0 = param.Parameter(default=None, precedence=-1) + has_properties = param.Boolean(default=False, precedence=-1) + + def __init__(self, **params): + super().__init__(**params) + + self._mode_toggle = pn.widgets.RadioButtonGroup.from_param( + self.param.mode, + button_style="outline", + button_type="primary", + stylesheets=[_CARD_CSS], + ) + self._alpha_input = FormattedEditableFloatSlider.from_param( + self.param.alpha, name="Alpha", margin=0 + ) + self._beta_input = FormattedEditableFloatSlider.from_param( + self.param.beta, name="Beta", margin=0 + ) + 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="IDS URI", + sizing_mode="stretch_width", + ) + 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 + ) + + def _reload(self): + """Resolve profile functions from the current params; store results in-place.""" + if self.mode == EQ_IDS: + self._load_from_ids() + 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.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._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.""" + # 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): - 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_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, + align="end", + margin=(4, 0, 0, 0), + ), + pn.Column( + self._uri_input, + self._time_input, + visible=is_ids, + margin=(4, 0, 0, 0), + ), + self._profiles_pane, + 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", - ) + """Assembles per-property inputs; exposes resolved plasma scalars and profiles. - input = param.ClassSelector(class_=EquilibriumInput, default=EquilibriumInput()) - properties_params = param.ClassSelector( - class_=PlasmaPropertiesParams, default=PlasmaPropertiesParams() - ) + 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." @@ -58,11 +314,24 @@ 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._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.panel = pn.Column(self.radio_box, self._panel_property_options) + self._profiles = PlasmaProfiles() + self.dpressure_dpsi = None self.f_df_dpsi = None self.psi_norm = None @@ -70,71 +339,38 @@ def __init__(self): self.r0 = None self.b0 = None - @param.depends( - "properties_params.param", "input.param", "input_mode", watch=True, on_init=True - ) - def _load_plasma_properties(self): - """Update plasma properties based on input mode.""" - - if self.input_mode == self.EQUILIBRIUM_INPUT: - self._load_properties_from_ids() - elif self.input_mode == self.MANUAL_INPUT: - self._load_properties_from_params() + for widget in [self._ip, self._r0, self._b0, self._profiles]: + widget.param.watch(self._load_plasma_properties, "changed") - self.param.trigger("profile_updated") + self._load_plasma_properties() - 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 - 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] + def _load_plasma_properties(self, _event=None): + """Reload all plasma properties from the current mode and inputs. - 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 - self.psi_norm = (psi - psi[0]) / (psi[-1] - psi[0]) + Reads already-resolved scalar values from each PropertyInput, delegates + profile loading/computation to PlasmaProfiles, then triggers `profile_updated`. + """ + self.ip = self._ip.resolved_value + self.r0 = self._r0.resolved_value + self.b0 = self._b0.resolved_value - 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 + 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 - @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) + self.has_properties = all( + v is not None for v in [self.ip, self.r0, self.b0, self.dpressure_dpsi] + ) + self._profiles.has_properties = self.has_properties + self.param.trigger("profile_updated") 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/shape_editor/shape_editor.py b/waveform_editor/gui/shape_editor/shape_editor.py index 0b728a58..992aad56 100644 --- a/waveform_editor/gui/shape_editor/shape_editor.py +++ b/waveform_editor/gui/shape_editor/shape_editor.py @@ -116,7 +116,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, ), 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; +}