Commit c155e4dc authored by Duggan, John's avatar Duggan, John
Browse files

Add component for selecting datafiles based on the analysis cluster filesystem

parent 22e1930c
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -40,6 +40,10 @@ View Components
    :members:
    :special-members: __init__

.. autoclass:: nova.trame.view.components.DataSelector
    :members:
    :special-members: __init__

.. _api_interactive2dplot:

.. autoclass:: nova.trame.view.components.visualization.Interactive2DPlot
+1 −2
Original line number Diff line number Diff line
@@ -6,7 +6,7 @@ Changelog = "https://code.ornl.gov/ndip/public-packages/nova-trame/blob/main/CHA

[tool.poetry]
name = "nova-trame"
version = "0.18.2"
version = "0.19.0"
description = "A Python Package for injecting curated themes and custom components into Trame applications"
authors = ["Duggan, John <dugganjw@ornl.gov>"]
readme = "README.md"
@@ -14,7 +14,6 @@ license = "MIT"
keywords = ["NDIP", "Python", "Trame", "Vuetify"]
packages = [{include = "nova", from = "src"}]


[tool.poetry.dependencies]
altair = "*"
libsass = "*"
+210 −0
Original line number Diff line number Diff line
"""Model implementation for DataSelector."""

import os
from pathlib import Path
from typing import Any, List, Optional
from warnings import warn

from pydantic import BaseModel, Field, field_validator, model_validator
from typing_extensions import Self

INSTRUMENTS = {
    "HFIR": {
        "CG-1A": "CG1A",
        "CG-1B": "CG1B",
        "CG-1D": "CG1D",
        "CG-2": "CG2",
        "CG-3": "CG3",
        "CG-4B": "CG4B",
        "CG-4C": "CG4C",
        "CG-4D": "CG4D",
        "HB-1": "HB1",
        "HB-1A": "HB1A",
        "HB-2A": "HB2A",
        "HB-2B": "HB2B",
        "HB-2C": "HB2C",
        "HB-3": "HB3",
        "HB-3A": "HB3A",
        "NOW-G": "NOWG",
        "NOW-V": "NOWV",
    },
    "SNS": {
        "BL-18": "ARCS",
        "BL-0": "BL0",
        "BL-2": "BSS",
        "BL-5": "CNCS",
        "BL-9": "CORELLI",
        "BL-6": "EQSANS",
        "BL-14B": "HYS",
        "BL-11B": "MANDI",
        "BL-1B": "NOM",
        "NOW-G": "NOWG",
        "BL-15": "NSE",
        "BL-11A": "PG3",
        "BL-4B": "REF_L",
        "BL-4A": "REF_M",
        "BL-17": "SEQ",
        "BL-3": "SNAP",
        "BL-12": "TOPAZ",
        "BL-1A": "USANS",
        "BL-10": "VENUS",
        "BL-16B": "VIS",
        "BL-7": "VULCAN",
    },
}


def get_facilities() -> List[str]:
    return list(INSTRUMENTS.keys())


def get_instruments(facility: str) -> List[str]:
    return list(INSTRUMENTS.get(facility, {}).keys())


class DataSelectorState(BaseModel, validate_assignment=True):
    """Selection state for identifying datafiles."""

    facility: str = Field(default="", title="Facility")
    instrument: str = Field(default="", title="Instrument")
    experiment: str = Field(default="", title="Experiment")
    directory: str = Field(default="")
    prefix: str = Field(default="")

    @field_validator("experiment", mode="after")
    @classmethod
    def validate_experiment(cls, experiment: str) -> str:
        if experiment and not experiment.startswith("IPTS-"):
            raise ValueError("experiment must begin with IPTS-")
        return experiment

    @model_validator(mode="after")
    def validate_state(self) -> Self:
        valid_facilities = get_facilities()
        if self.facility and self.facility not in valid_facilities:
            warn(f"Facility '{self.facility}' could not be found. Valid options: {valid_facilities}", stacklevel=1)

        valid_instruments = get_instruments(self.facility)
        if self.instrument and self.instrument not in valid_instruments:
            warn(
                (
                    f"Instrument '{self.instrument}' could not be found in '{self.facility}'. "
                    f"Valid options: {valid_instruments}"
                ),
                stacklevel=1,
            )
        # Validating the experiment is expensive and will fail in our CI due to the filesystem not being mounted there.

        return self


class DataSelectorModel:
    """Manages file system interactions for the DataSelector widget."""

    def __init__(self, facility: str, instrument: str, prefix: str) -> None:
        self.state = DataSelectorState()
        self.state.facility = facility
        self.state.instrument = instrument
        self.state.prefix = prefix

    def get_facilities(self) -> List[str]:
        return sorted(get_facilities())

    def get_instrument_dir(self) -> str:
        return INSTRUMENTS.get(self.state.facility, {}).get(self.state.instrument, "")

    def get_instruments(self) -> List[str]:
        return sorted(get_instruments(self.state.facility))

    def get_experiments(self) -> List[str]:
        experiments = []

        instrument_path = Path("/") / self.state.facility / self.get_instrument_dir()
        try:
            for dirname in os.listdir(instrument_path):
                if dirname.startswith("IPTS-") and os.access(instrument_path / dirname, mode=os.R_OK):
                    experiments.append(dirname)
        except OSError:
            pass

        return sorted(experiments)

    def sort_directories(self, directories: List[Any]) -> List[Any]:
        # Sort the current level of dictionaries
        sorted_dirs = sorted(directories, key=lambda x: x["title"])

        # Process each sorted item to sort their children
        for item in sorted_dirs:
            if "children" in item and isinstance(item["children"], list):
                item["children"] = self.sort_directories(item["children"])

        return sorted_dirs

    def get_directories(self) -> List[Any]:
        if not self.state.experiment:
            return []

        directories = []

        experiment_path = Path("/") / self.state.facility / self.get_instrument_dir() / self.state.experiment
        try:
            for dirpath, _, _ in os.walk(experiment_path):
                # Get the relative path from the start path
                path_parts = os.path.relpath(dirpath, experiment_path).split(os.sep)

                # Only create a new entry for top-level directories
                if len(path_parts) == 1 and path_parts[0] != ".":  # This indicates a top-level directory
                    current_dir = {"path": dirpath, "title": path_parts[0]}
                    directories.append(current_dir)

                # Add subdirectories to the corresponding parent directory
                elif len(path_parts) > 1:
                    current_level: Any = directories
                    for part in path_parts[:-1]:  # Parent directories
                        for item in current_level:
                            if item["title"] == part:
                                if "children" not in item:
                                    item["children"] = []
                                current_level = item["children"]
                                break

                    # Add the last part (current directory) as a child
                    current_level.append({"path": dirpath, "title": path_parts[-1]})
        except OSError:
            pass

        return self.sort_directories(directories)

    def get_datafiles(self) -> List[str]:
        datafiles = []

        try:
            if self.state.prefix:
                datafile_path = str(
                    Path("/")
                    / self.state.facility
                    / self.get_instrument_dir()
                    / self.state.experiment
                    / self.state.prefix
                )
            else:
                datafile_path = self.state.directory

            for entry in os.scandir(datafile_path):
                if entry.is_file():
                    datafiles.append(entry.path)
        except OSError:
            pass

        return sorted(datafiles)

    def set_directory(self, directory_path: str) -> None:
        self.state.directory = directory_path

    def set_state(self, facility: Optional[str], instrument: Optional[str], experiment: Optional[str]) -> None:
        if facility is not None:
            self.state.facility = facility
        if instrument is not None:
            self.state.instrument = instrument
        if experiment is not None:
            self.state.experiment = experiment
+2 −1
Original line number Diff line number Diff line
from .data_selector import DataSelector
from .file_upload import FileUpload
from .input_field import InputField
from .remote_file_input import RemoteFileInput

__all__ = ["FileUpload", "InputField", "RemoteFileInput"]
__all__ = ["DataSelector", "FileUpload", "InputField", "RemoteFileInput"]
+196 −0
Original line number Diff line number Diff line
"""View Implementation for DataSelector."""

from typing import Any, Optional, cast

from trame.app import get_server
from trame.widgets import client, html
from trame.widgets import vuetify3 as vuetify

from nova.mvvm.trame_binding import TrameBinding
from nova.trame.model.data_selector import DataSelectorModel
from nova.trame.view.layouts import GridLayout, VBoxLayout
from nova.trame.view_model.data_selector import DataSelectorViewModel

from .input_field import InputField

vuetify.enable_lab()


class DataSelector(vuetify.VDataTableVirtual):
    """Allows the user to select datafiles from an IPTS experiment."""

    def __init__(
        self,
        v_model: str,
        facility: str = "",
        instrument: str = "",
        prefix: str = "",
        select_strategy: str = "all",
        **kwargs: Any,
    ) -> None:
        """Constructor for DataSelector.

        Parameters
        ----------
        v_model : str
            The name of the state variable to bind to this widget. The state variable will contain a list of the files
            selected by the user.
        facility : str, optional
            The facility to restrict data selection to. Options: HFIR, SNS
        instrument : str, optional
            The instrument to restrict data selection to. Please use the instrument acronym (e.g. CG-2).
        prefix : str, optional
            A subdirectory within the user's chosen experiment to show files. If not specified, the user will be shown a
            folder browser and will be able to see all files in the experiment that they have access to.
        select_strategy : str, optional
            The selection strategy to pass to the `VDataTable component <https://trame.readthedocs.io/en/latest/trame.widgets.vuetify3.html#trame.widgets.vuetify3.VDataTable>`__.
            If unset, the `all` strategy will be used.
        **kwargs
            All other arguments will be passed to the underlying
            `VDataTable component <https://trame.readthedocs.io/en/latest/trame.widgets.vuetify3.html#trame.widgets.vuetify3.VDataTable>`_.

        Returns
        -------
        None
        """
        if "items" in kwargs:
            raise AttributeError("The items parameter is not allowed on DataSelector widget.")

        if "label" in kwargs:
            self._label = kwargs["label"]
        else:
            self._label = None

        self._v_model = v_model
        self._prefix = prefix
        self._select_strategy = select_strategy

        self._state_name = f"nova__dataselector_{self._next_id}_state"
        self._facilities_name = f"nova__dataselector_{self._next_id}_facilities"
        self._instruments_name = f"nova__dataselector_{self._next_id}_instruments"
        self._experiments_name = f"nova__dataselector_{self._next_id}_experiments"
        self._directories_name = f"nova__dataselector_{self._next_id}_directories"
        self._datafiles_name = f"nova__dataselector_{self._next_id}_datafiles"

        self._flush_state = f"flushState('{self._v_model.split('.')[0]}');"
        self._reset_state = client.JSEval(exec=f"{self._v_model} = []; {self._flush_state}").exec

        self.create_model(facility, instrument)
        self.create_viewmodel()

        self.create_ui(facility, instrument, **kwargs)

    def create_ui(self, facility: str, instrument: str, **kwargs: Any) -> None:
        with VBoxLayout(classes="nova-data-selector", height="100%"):
            with GridLayout(columns=3):
                columns = 3
                if facility == "":
                    columns -= 1
                    InputField(
                        v_model=f"{self._state_name}.facility", items=(self._facilities_name,), type="autocomplete"
                    )
                if instrument == "":
                    columns -= 1
                    InputField(
                        v_model=f"{self._state_name}.instrument", items=(self._instruments_name,), type="autocomplete"
                    )
                InputField(
                    v_model=f"{self._state_name}.experiment",
                    column_span=columns,
                    items=(self._experiments_name,),
                    type="autocomplete",
                )

            with GridLayout(columns=2, classes="flex-1-0 h-0", valign="start"):
                if not self._prefix:
                    with html.Div(classes="d-flex flex-column h-100 overflow-hidden"):
                        vuetify.VListSubheader("Available Directories", classes="flex-0-1 justify-center px-0")
                        vuetify.VTreeview(
                            v_if=(f"{self._directories_name}.length > 0",),
                            activatable=True,
                            active_strategy="single-independent",
                            classes="flex-1-0 h-0 overflow-y-auto",
                            item_value="path",
                            items=(self._directories_name,),
                            update_activated=(self._vm.set_directory, "$event"),
                        )
                        vuetify.VListItem("No directories found", classes="flex-0-1 text-center", v_else=True)

                super().__init__(
                    v_model=self._v_model,
                    classes="h-100 overflow-y-auto",
                    fixed_header=True,
                    headers=("[{ align: 'left', key: 'title', title: 'Available Datafiles' }]",),
                    item_title="title",
                    item_value="path",
                    select_strategy=self._select_strategy,
                    show_select=True,
                    **kwargs,
                )
                if self._label:
                    self.label = self._label
                self.items = (self._datafiles_name,)
                if "update_modelValue" not in kwargs:
                    self.update_modelValue = self._flush_state

            with cast(
                vuetify.VSelect,
                InputField(
                    v_model=self._v_model,
                    classes="flex-0-1 nova-readonly",
                    clearable=True,
                    readonly=True,
                    type="select",
                    click_clear=self.reset,
                ),
            ):
                with vuetify.Template(raw_attrs=['v-slot:selection="{ item, index }"']):
                    vuetify.VChip("{{ item.title.split('/').reverse()[0] }}", v_if="index < 2")
                    html.Span(
                        f"(+{{{{ {self._v_model}.length - 2 }}}} others)", v_if="index === 2", classes="text-caption"
                    )

    def create_model(self, facility: str, instrument: str) -> None:
        self._model = DataSelectorModel(facility, instrument, self._prefix)

    def create_viewmodel(self) -> None:
        server = get_server(None, client_type="vue3")
        binding = TrameBinding(server.state)

        self._vm = DataSelectorViewModel(self._model, binding)
        self._vm.state_bind.connect(self._state_name)
        self._vm.facilities_bind.connect(self._facilities_name)
        self._vm.instruments_bind.connect(self._instruments_name)
        self._vm.experiments_bind.connect(self._experiments_name)
        self._vm.directories_bind.connect(self._directories_name)
        self._vm.datafiles_bind.connect(self._datafiles_name)
        self._vm.reset_bind.connect(self.reset)

        self._vm.update_view()

    def reset(self, _: Any = None) -> None:
        self._reset_state()

    def set_state(
        self, facility: Optional[str] = None, instrument: Optional[str] = None, experiment: Optional[str] = None
    ) -> None:
        """Programmatically set the facility, instrument, and/or experiment to restrict data selection to.

        If a parameter is None, then it will not be updated.

        Parameters
        ----------
        facility : str, optional
            The facility to restrict data selection to. Options: HFIR, SNS
        instrument : str, optional
            The instrument to restrict data selection to. Must be at the selected facility.
        experiment : str, optional
            The experiment to restrict data selection to. Must begin with "IPTS-". It is your responsibility to validate
            that the provided experiment exists within the instrument directory. If it doesn't then no datafiles will be
            shown to the user.

        Returns
        -------
        None
        """
        self._vm.set_state(facility, instrument, experiment)
Loading