diff --git a/docs/source/design/taviclasses.rst b/docs/source/design/taviclasses.rst index f9761c216..5a26ef277 100644 --- a/docs/source/design/taviclasses.rst +++ b/docs/source/design/taviclasses.rst @@ -16,53 +16,30 @@ every time there are new valid values received from the user (front end). .. mermaid:: classDiagram - TaviModel "1" -->"1" CrosshairParameters - CrosshairParameters "1" -->"1" SingleCrystalParameters - - class TaviModel{ - +float incident_energy_ei - +float detector_tank_angle_s2 - +float polarization_direction_angle_p - +enum 'PlotType' plot_type - +CrosshairParameters crosshair_parameters - +set_single_crystal_parameters(params: dict[str, float]) - +get_single_crystal_parameters() - +set_crosshair(current_experiment_type: str, DeltaE: float = None, modQ: float = None) - +get_crosshair() - +get_ang_Q_beam() - +set_experiment_parameters(Ei: float, S2: float, alpha_p: float, plot_type: str) - +get_experiment_parameters() - +check_plot_update(deltaE) - +calculate_graph_data() + TaviProject "1" -->"1" TaviData + TaviData "1" --> "1" Scan + + class TaviProject{ + -TaviData tavi_data + +load_raw_scan_from_folder() } - class CrosshairParameters{ - +float crosshair_delta_e - +float crosshair_mod_q - +experiment_type: str - +SingleCrystalParameters single_crystal_parameters - +set_crosshair(current_experiment_type: str, DeltaE: float = None, modQ: float = None) - +get_crosshair() - +get_experiment_type() + class TaviData{ + -RawDataPtr rawdataptr:dict[str, Scan] + -CombineDataPtr combinedataptr:dict[str, np.array] + -process_selected_data:list[str] + -show_selected_data:list[str] + -FitPtr fitptr + -PlotPtr plotptr } - class SingleCrystalParameters{ - +float lattice_a - +float lattice_b - +float lattice_c - +float lattice_alpha - +float lattice_beta - +float lattice_gamma - +float q_rlu_h - +float q_rlu_k - +float q_rlu_l - +set_single_crystal_parameters(params: dict[str, float]) - +get_single_crystal_parameters() - +calculate_crosshair_mod_q() + class Scan{ + -Data raw_data + -MetaData meta_data + -ErrorMessage error_message } - Tavi View +++++++++++++++ @@ -70,99 +47,21 @@ Tavi View .. mermaid:: classDiagram - TaviView "1" -->"1" ExperimentWidget - TaviView "1" -->"1" CrosshairWidget - TaviView "1" -->"1" SingleCrystalWidget - TaviView "1" -->"1" SelectorWidget - TaviView "1" -->"1" PlotWidget + TaviView "1" -->"1" MainWindow class TaviView{ - +ExperimentWidget:experiment_widget - +SingleCrystalWidget:sc_widget - +CrosshairWidget:crosshair_widget - +SelectorWidget:selection_widget - +PlotWidget:plot_widget - - } + -MainWindow:main_window + -MainMenuBar:menu_bar + +install_menu_bar() + +closeEvent() + +force_close() + +exit_message_box() - class PlotWidget{ - +matplotlib.Figure: figure - +matplotlib.FigureCanvas: static_canvas - +matplotlib.NavigationToolbar: toolbar - +matplotlib.colorbar.Colorbar: heatmap - +float:eline_data - +float:qline_data - +matplotlib.lines.Line2D:eline - +matplotlib.lines.Line2D:qline - +update_plot_crosshair(crosshair_data: dict) - +update_crosshair(eline: float, qline: float) - +update_plot(q_min: list[float], q_max: list[float], energy_transfer: list[float], q2d: list[list[float]], e2d: list[list[float]], scharpf_angle: list[list[float]],plot_label: str) - +set_axes_meta_and_draw_plot() } - class SelectorWidget{ - +str:powder_label - +QRadioButton: powder_rb - +str:sc_label - +QRadioButton: sc_rb - +selector_init(selected_label: str) - +sc_toggle() - +get_selected_mode_label() - } - - class ExperimentWidget{ - +QLabel:incident_energy_ei_label - +QLineEdit:incident_energy_ei_edit - +QLabel:detector_tank_angle_s2_label - +QLineEdit:detector_tank_angle_s2_edit - +QLabel:polarization_direction_angle_p_label - +QLineEdit:polarization_direction_angle_p_edit - +QLabel:plot_type_label - +QComboBox:plot_type_combobox - +initializeCombo(options: list[str]) - +validate_inputs(*_, **__) - +validate_all_inputs() - +set_values(values: dict[str, Union[float, str]]) - } - - class CrosshairWidget{ - +QLabel:crosshair_delta_e_label - +QLineEdit:crosshair_delta_e_edit - +QLabel:crosshair_mod_q_label - +QLineEdit:crosshair_mod_q_edit - +QLabel:angle_q_z_label - +QLineEdit:angle_q_z_edit - +set_mod_q_enabled(state: bool) - +set_values(values: dict[str, float]) - +validate_inputs(*_, **__) - +validation_status_all_inputs() - +validate_all_inputs() - +set_QZ_values(angle: float) - } - - class SingleCrystalWidget{ - +QLabel:lattice_a_label - +QLineEdit:lattice_a_edit - +QLabel:latticeb_label - +QLineEdit:lattice_b_edit - +QLabel:lattice_c_label - +QLineEdit:lattice_c_edit - +QLabel:lattice_alpha_label - +QLineEdit:lattice_alpha_edit - +QLabel:lattice_beta_label - +QLineEdit:lattice_beta_edit - +QLabel:lattice_gamma_label - +QLineEdit:lattice_gamma_edit - +QLabel:q_rlu_h_label - +QLineEdit:q_rlu_h_edit - +QLabel:q_rlu_k_label - +QLineEdit:q_rlu_k_edit - +QLabel:q_rlu_l_label - +QLineEdit:q_rlu_l_edit - +set_values(values: dict[str, float]) - +validate_inputs(*_, **__) - +validate_angles() - +validate_all_inputs() + class MainWindow{ + -LoadView: load_view + -Placeholder for other view widgets:Other } @@ -173,24 +72,40 @@ Tavi Presenter .. mermaid:: classDiagram - TaviPresenter "1" -->"1" TaviModel - TaviPresenter "1" -->"1" TaviView + MainPresenter "1" -->"1" TaviProject + MainPresenter "1" -->"1" TaviView + TaviView "1" -->"2" MainWindow - class TaviPresenter{ - -TaviModel:model + class MainPresenter{ + -TaviProject:model -TaviView:view - +handle_field_values_update() - +handle_switch_to_powder() - +handle_switch_to_sc() - +handle_QZ_angle() + +menu_bar() + +install_menu_bar() + +load_raw_scan_presenter(load_raw_scan_view, model_dict["TaviProjectProxy"]) + +exit() } - class TaviModel{ - #from above + class TaviProject{ + # from above + -TaviData + +load_raw_scan_from_folder() } class TaviView{ - #from above + # from above + -MainWindow:main_window + -MainMenuBar:menu_bar + +install_menu_bar() + +closeEvent() + +force_close() + +exit_message_box() + + } + + class MainWindow{ + # from above + -LoadView: load_view + -Placeholder for other view widgets:Other } The Hppt Model and View are unaware of one another. The Presenter is the connecting link that has a direct access and interacts with both. @@ -198,7 +113,7 @@ The Presenter describes the main workflows that require communication and coordi Any value processing and/or filtering to match the requirements and logic of the View and Model side should happen on the Presenter. -#. Application Start - TaviView Initialization. All default values are retrieved from the settings file. +#. Application Start - TaviView Initialization. All default settings (if there is any) are retrieved from the settings file. .. mermaid:: @@ -208,213 +123,15 @@ Any value processing and/or filtering to match the requirements and logic of the participant Model Note over View,Model: TaviView Initialization - Presenter->>Model: A. Get Experiment parameters - Presenter->>View: Set Experiment parameters (experiment_widget.set_values) - Note left of View: Display Experiment parameters values - Note left of View: experiment_parameters_update is triggered - - Presenter->>Model: B. Get SingleCrystal parameters - Note left of Presenter: Get the available plot types from the experiment_settings file - Presenter->>View: Set SingleCrystal parameters (singlecrystal_widget.set_parameters) - Note left of View: Display SingleCrystal parameters values - Note left of View: handle_field_values_update is triggered - - Presenter->>Model: C. Get default experiment mode (Single Crystal) - Presenter->>View: Set experiment mode (selection_widget.selector_init) - Note left of View: Workflow continues for selecting experiment type = Single Crystal - Note left of View: handle_field_values_update is triggered - -#. This describes the sequence of events happening among M-V-P when CrosshairWidget parameters are updated in order to see a new plot : handle_field_values_update() - - * Valid Status with Replot: - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: Plot draw due to any CrosshairWidget parameter update - Note left of View: User updates a parameter at CrosshairWidget - Note left of View: Check the validation status of all CrosshairWidget parameters (CrosshairWidget.validate_all_inputs) - View->>Presenter: Emit the valid signal and pass the crosshair parameters - Presenter->>View: Get the experiment type - Presenter->>Model: Send crosshair_delta_e to decide on replot - Note right of Model: Calculate replot based on new delta_e value, and previous Emin, delta_e values - Model->>Presenter: Returns the replot to True - Presenter->>Model: Set crosshair data (set_crosshair_data) - Note right of Model: Store the crosshair data - Presenter->>Model: Get momentum transfer angle - Model->>Presenter: Return momentum transfer angle - Presenter->>Model: Calculate plot data (calculate_graph_data) - Note right of Model: Calculate plot dictionary data - Model->>Presenter: Return graph data dictionary - Presenter->>View: Return graph data (plot_widget.update_plot) - Note left of View: Draw the (colormap) heatmap - Presenter->>View: Return graph data (plot_widget.update_crosshair) - Note left of View: Draw the crosshair - Presenter->>View: Display momentum transfer angle - Note left of View: Update momentum transfer angle - + Note left of View: User clicks "File"->"Load Experiment Folder" + View->>View: Pop up a QDialog that allows user to view files and traverse system directories + View->>Presenter: Return a single string of folder directory path + Presenter->>Model: Pass experiment folder directory to load_raw_scan_from_folder(folder) + Note right of Model: load_raw_scan_from_folder(folder) perform loading - * Valid Status without Replot: - - .. mermaid:: + Model->>View: TO BE FILLED WITH BACKEND STORY - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: Plot draw due to any CrosshairWidget parameter update - Note left of View: User updates a parameter at CrosshairWidget - Note left of View: Check the validation status of all CrosshairWidget parameters (CrosshairWidget.validate_all_inputs) - View->>Presenter: Emit the valid signal and pass the crosshair parameters - Presenter->>Model: Send crosshair_delta_e to decide on replot - Note right of Model: Calculate replot based on new delta_e value, and previous Emin, delta_e values - Model->>Presenter: Returns the replot to False - Presenter->>Model: Set crosshair data (set_crosshair_data) - Note right of Model: Store the crosshair data - Presenter->>Model: Get momentum transfer angle - Model->>Presenter: Return momentum transfer angle - Presenter->>View: Return graph data (plot_widget.update_crosshair) - Note left of View: Draw the crosshair - Presenter->>View: Display momentum transfer angle - Note left of View: Update momentum transfer angle - - * Invalid Status: - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: CrosshairWidget parameter update - Note left of View: User updates a parameter at CrosshairWidget - Note Left of View: Check the validation status of all CrosshairWidget parameters (CrosshairWidget.validate_all_inputs) - Note Left of View: Red borders appear (validate_inputs) no signal is emitted - - -#. This describes the sequence of events happening among M-V-P when ExperimentWidget parameters are updated in order to see a new plot : handle_field_values_update() - - * Valid Status: - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: Plot draw due to any ExperimentWidget parameter update - Note left of View: User updates a parameter at ExperimentWidget - Note left of View: Check the validation status of all ExperimentWidget parameters (ExperimentWidget.validate_all_inputs) - View->>Presenter: Emit the valid signal and pass the experiment parameters - Presenter->>Model: Set the parameters (set_experiment_data) - Presenter->>Model: Calculate plot data (calculate_graph_data) - Note right of Model: Calculate plot dictionary data - Model->>Presenter: Return graph data dictionary - Presenter->>View: Return graph data (plot_widget.update_plot) - Presenter->>Model: Get momentum transfer angle - Model->>Presenter: Return momentum transfer angle - Note left of View: Draw the (colormap) heatmap - Presenter->>View: Display momentum transfer angle - Note left of View: Update momentum transfer angle - - * Invalid Status: - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: ExperimentWidget parameter update - Note left of View: User updates a parameter at ExperimentWidget - Note left of View: Check the validation status of all ExperimentWidget parameters (ExperimentWidget.validate_all_inputs) - Note Left of View: Red borders appear (validate_inputs) no signal is emitted - - -#. This describes the sequence of events happening among M-V-P when Single Crystal parameters are updated in order to see a new plot : handle_field_values_update() - - * Valid Status: - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: Plot draw due to any SingleCrystalWidget parameter update - Note left of View: User updates a parameter at SingleCrystalWidget - Note left of View: Check the validation status of all SingleCrystalWidget parameters (SingleCrystalWidget.validate_all_inputs) - View->>Presenter: Emit the valid signal and pass the single crystal parameters - Presenter->>Model: Set the parameters (set_single_crystal_data) - Presenter->>Model: Get the new crosshair data (get_crosshair_data) - Presenter->>Model: Get momentum transfer angle - Model->>Presenter: Return momentum transfer angle - Presenter->>View: Display the crosshair data (crosshair_widget.set_values) - Note left of Presenter: Check the validation status of all crosshair_widget parameters (CrosshairWidget.validation_status_all_inputs) is valid - Presenter->>View: Return graph data (plot_widget.update_crosshair) - Note left of View: Draw the crosshair - Presenter->>View: Display momentum transfer angle - Note left of View: Update momentum transfer angle - - * Invalid Status: - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: Plot draw due to any SingleCrystalWidget parameter update - Note left of View: User updates a parameter at SingleCrystalWidget - Note left of View: Check the validation status of all SingleCrystalWidget parameters (SingleCrystalWidget.validate_all_inputs) - View->>Presenter: Emit the valid signal and pass the single crystal parameters - Presenter->>Model: Set the parameters (set_single_crystal_data) - Presenter->>Model: Get the new crosshair data (get_crosshair_data) - Presenter->>View: Display the crosshair data (crosshair_widget.set_values) - Note left of Presenter: Check the validation status of all crosshair_widget parameters (CrosshairWidget.validation_status_all_inputs) is invalid - Note left of Presenter: Nothing - -#. This describes the sequence of events happening among M-V-P when user selects the "Powder" mode : handle_switch_to_powder() - - .. mermaid:: - - sequenceDiagram - participant View - participant Presenter - participant Model - - Note over View,Model: Updates due to switching to Powder Mode - Note left of View: User selects the Powder radio button - View->>Presenter: Trigger the update - Presenter->>View: Update fields' visibility for powder case(field_visibility_in_Powder) - Note left of View: Hide the SingleCrystalWidget - Note left of View: Make crosshair_mod_q_edit field editable - Presenter->>Model: Set crosshair parameters with the experiment_type="powder" (set_crosshair_data) - Note right of Model: Store the crosshair data - Presenter->>Model: Get crosshair parameters for the experiment_type="powder"(get_crosshair_data) - Note right of Model: Return the mod_q and the delta_e values - Model->>Presenter: Return the crosshair data - Presenter->>View: Return the crosshair data - Note left of View: Display the data in the crosshair_widget - Presenter->>View: Return graph data (plot_widget.update_crosshair) - Note left of View: Draw the crosshair - Presenter->>Model: Get experiment parameters (get_experiment_data) - Presenter->>View: Set experiment parameters (experiment_widget.set_values) - Note left of View: Display experiment parameters values - Note left of View: handle_field_values_update is triggered - - -#. This describes the sequence of events happening among M-V-P when user selects the "Single Crystal" mode : handle_switch_to_sc() +#. PLACEHOLDER MERMAID DIAGRAM * Valid Status: @@ -484,10 +201,7 @@ Any value processing and/or filtering to match the requirements and logic of the Experiment Settings ++++++++++++++++++++++ - -The parameters' default values for the application are stored in a file, experiment_settings.py, next to the model file. They are imported -in the Tavi Model file and used during the Experiment object's initialization and data calculations. The options for experiment and plot types are used in Tavi Model and View files. -More specifically the parameters with their values are: +Placveholder for tavi settings, leaving HYSPECPPT's template here for reference. Update accordingly. * Experiment type options .. code-block:: bash diff --git a/docs/source/gui.rst b/docs/source/gui.rst index a3b3f45d9..527c72a0b 100644 --- a/docs/source/gui.rst +++ b/docs/source/gui.rst @@ -3,35 +3,17 @@ Graphical user interface ######################## -As of version 1.0.0 the graphical user interface for the planning tool looks like +As of version 1.0.0 the graphical user interface for TAVI looks like -.. image:: images/ppt_sc.png +.. image:: images/tavi_placeholder.png :width: 600 -The user is supposed to select the incident energy, detector tank angle, and the angle between polarization and the -beam direction. This will generate a map of the angle between the polarization and momentum transfer :math:`\alpha_s`, or some -relevant derived quantity. Currently we have implemented :math:`\cos^2\alpha_s` and :math:`(1+\cos^2\alpha_s)/2`. - -The user can position a crosshair at a certain momentum and energy transfer position, in order to test several possible polarization directions. -In the single crystal mode, the magnitude of momentum transfer :math:`|\vec Q|` is provided via lattice parameters and reciprocal -lattice coordinates. In the powder mode, the user enters this quantity directly. - -.. image:: images/ppt_pow.png - :width: 600 - -One can use the plotting toolbar to -* zoom in/out/pan -* change the color range, by zooming on the colorbar -* change the colormap. Click on edit axes, customize the default plot, click on the Images tab. +One can use TAVI for: +* data visualization +* combining different scans +* performing fitting of the peaks. Validation ---------- -The graphical user interface will provide a visual feedback (red border) if some of the quantities required for calculations are missing -or outside acceptable limits. The tooltips provide this information. In addition, in the single crystal mode, the sum of the lattice angles must be greater than :math:`360^\circ`, and the sum of any two angles must be greater than the remaining one. - -Negative energy transfer ------------------------- - -By default, the energy transfer range for the plot is given by the incident energy :math:`E_i`, from :math:`-E_i` to :math:`E_i`. -If the energy transfer of the crosshair is selected to be less than :math:`-E_i`, the new minimum will be :math:`-1.2\Delta E`. +The graphical user interface will provide a visual feedback as a pop-up window for an errors ursers might encounter. diff --git a/docs/source/images/ppt_pow.png b/docs/source/images/ppt_pow.png deleted file mode 100644 index b5ecd52a1..000000000 Binary files a/docs/source/images/ppt_pow.png and /dev/null differ diff --git a/docs/source/images/ppt_sc.png b/docs/source/images/ppt_sc.png deleted file mode 100644 index c96b84570..000000000 Binary files a/docs/source/images/ppt_sc.png and /dev/null differ diff --git a/docs/source/images/tavi_placeholder.png b/docs/source/images/tavi_placeholder.png new file mode 100644 index 000000000..db21ee69d Binary files /dev/null and b/docs/source/images/tavi_placeholder.png differ diff --git a/pixi.lock b/pixi.lock index 07e4042e7..d983b05b8 100644 --- a/pixi.lock +++ b/pixi.lock @@ -375,7 +375,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d2/b8/f0b9b880c03a3db8eaff63d76ca751ac7d8e45483fb7a0bb9f8e5c6ce433/toml_cli-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: . + - pypi: ./ osx-64: - conda: https://conda.anaconda.org/conda-forge/osx-64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda @@ -707,7 +707,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d2/b8/f0b9b880c03a3db8eaff63d76ca751ac7d8e45483fb7a0bb9f8e5c6ce433/toml_cli-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: . + - pypi: ./ osx-arm64: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-7_kmp_llvm.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda @@ -1039,7 +1039,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/d2/b8/f0b9b880c03a3db8eaff63d76ca751ac7d8e45483fb7a0bb9f8e5c6ce433/toml_cli-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: . + - pypi: ./ win-64: - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-2_gnu.conda - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda @@ -1364,7 +1364,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/d2/b8/f0b9b880c03a3db8eaff63d76ca751ac7d8e45483fb7a0bb9f8e5c6ce433/toml_cli-0.8.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl - - pypi: . + - pypi: ./ packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 @@ -10270,10 +10270,10 @@ packages: - pkg:pypi/stack-data?source=hash-mapping size: 26988 timestamp: 1733569565672 -- pypi: . +- pypi: ./ name: tavi - version: 0.4.0.dev13 - sha256: 4593dc3acfa9f3c5ce81c3f87ca72e01e91670a9995d057dee63747100165874 + version: 0.4.0.dev9 + sha256: 95b5dc9450b97db5ebfda5e42912d7a66ae278c6e453af1cd101a699f46d1e81 requires_python: '>=3.10' editable: true - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2022.3.0-h3155e25_2.conda diff --git a/pyproject.toml b/pyproject.toml index bfd31efe5..1d37ea792 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,6 +79,7 @@ ignore = [ "ARG002", "ARG004", "F821", + "D401", "N801", "N802", "N806", diff --git a/src/tavi/__main__.py b/src/tavi/__main__.py index 5c6b6dbe2..f417db1d9 100644 --- a/src/tavi/__main__.py +++ b/src/tavi/__main__.py @@ -4,7 +4,8 @@ from qtpy.QtWidgets import QApplication -from tavi.backend.model.interface.TaviProjectInterface import TaviProjectInterface +from tavi.backend.model.interface.tavi_project_interface import TaviProjectProxy +from tavi.backend.model.tavi_project_model import TaviProjectModel from tavi.configuration import Configuration from tavi.frontend.presenter.main_presenter import MainPresenter @@ -25,7 +26,8 @@ def execute() -> None: print(" ".join(msg)) sys.exit(-1) - dict_of_model = {"TaviProjectInterface": TaviProjectInterface()} + tavi_project_model = TaviProjectModel() + dict_of_model = {"TaviProjectProxy": TaviProjectProxy(tavi_project_model)} presenter = MainPresenter(dict_of_model) presenter._view.show() diff --git a/src/tavi/backend/model/interface/TaviProjectInterface.py b/src/tavi/backend/model/interface/TaviProjectInterface.py deleted file mode 100644 index 83f383697..000000000 --- a/src/tavi/backend/model/interface/TaviProjectInterface.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Tavi project interface.""" - - -class TaviProjectInterface: - """Tavi project interface.""" - - def print(self) -> None: - """Print.""" - print("TODO: Implement model, model.interface, multihreading, model_proxy") diff --git a/src/tavi/backend/model/interface/tavi_project_interface.py b/src/tavi/backend/model/interface/tavi_project_interface.py new file mode 100644 index 000000000..d1391c953 --- /dev/null +++ b/src/tavi/backend/model/interface/tavi_project_interface.py @@ -0,0 +1,17 @@ +"""Tavi project interface.""" + +import abc + +from tavi.meta.multithreading.proxy import Proxy + + +class TaviProjectInterface(metaclass=abc.ABCMeta): + """Tavi project interface.""" + + @abc.abstractmethod + def load_raw_scan_from_folder(self) -> None: + """Abstract method to get tavi data.""" + pass + + +TaviProjectProxy = Proxy(TaviProjectInterface) diff --git a/src/tavi/backend/model/tavi_project_model.py b/src/tavi/backend/model/tavi_project_model.py new file mode 100644 index 000000000..40e2dc224 --- /dev/null +++ b/src/tavi/backend/model/tavi_project_model.py @@ -0,0 +1,29 @@ +"""Tavi Project.""" + +from tavi.backend.model.interface.tavi_project_interface import TaviProjectInterface +from tavi.event_broker.event_broker import EventBroker +from tavi.event_broker.event_type import Event +from tavi.library.data.model_response import ModelResponse, ResponseCode +from tavi.meta.decorators.singleton import Singleton + + +@Singleton +class TaviProjectModel(TaviProjectInterface): + """Tavi project class.""" + + def __init__(self) -> None: + """Init tavi data.""" + self._event_broker = EventBroker() + + def send(self, event: Event) -> None: + """Send pre-register event to event broker.""" + self._event_broker.publish(event) + + def load_raw_scan_from_folder(self, folder: str) -> None: + """Load a folder containing raw scans.""" + print("folder director received by model:", folder) + # TO DO + # Implement load raw scan from folder logic + # raw_scan_loading_event = RawScanLoadingEvent(raw_scan_uuid = ...) + # self.send(raw_scan_loading_event) + return ModelResponse(code=ResponseCode.OK, message="TODO: implement loading backend") diff --git a/src/tavi/event_broker/event_broker.py b/src/tavi/event_broker/event_broker.py new file mode 100644 index 000000000..954dd0436 --- /dev/null +++ b/src/tavi/event_broker/event_broker.py @@ -0,0 +1,32 @@ +""" +Event broker class. + +Stub implementation, will be updated. + +""" + +from collections import defaultdict +from typing import Any, Literal + +from tavi.meta.decorators.singleton import Singleton + + +@Singleton +class EventBroker: + """Event broker class.""" + + def __init__(self) -> None: + """Initialize event broker.""" + if not hasattr(self, "registry"): + self.registry = defaultdict(list) + + def register(self, event_type: Any, callable: Literal["event_type"]) -> None: + """Register event with the broker.""" + self.registry[event_type].append(callable) + + def publish(self, event: Any) -> None: + """Publish event to the broker.""" + event_type = type(event) + if callable_list := self.registry.get(event_type): + for callable in callable_list: + callable(event) diff --git a/src/tavi/event_broker/event_type.py b/src/tavi/event_broker/event_type.py new file mode 100644 index 000000000..61785f362 --- /dev/null +++ b/src/tavi/event_broker/event_type.py @@ -0,0 +1,16 @@ +"""Define event type here.""" + +from attr import dataclass + + +class Event: + """Docstring for Event.""" + + pass + + +@dataclass +class RawScanLoadingEvent(Event): + """loading raw data event.""" + + raw_scan_uuid: list[str] diff --git a/src/tavi/frontend/presenter/file_menu_presenter.py b/src/tavi/frontend/presenter/file_menu_presenter.py index a4f0c5e89..d2b6febef 100644 --- a/src/tavi/frontend/presenter/file_menu_presenter.py +++ b/src/tavi/frontend/presenter/file_menu_presenter.py @@ -7,7 +7,7 @@ from tavi.frontend.view.file_menu_view import FileMenu if TYPE_CHECKING: - from tavi.backend.model.interface.TaviProjectInterface import TaviProjectInterface + from tavi.backend.model.interface.tavi_project_interface import TaviProjectInterface class FileMenuPresenter: @@ -36,7 +36,7 @@ def __init__(self, exit_routine: Any, model: TaviProjectInterface) -> None: self._view.setup_callback_load_folder(self.handle_load_folder) self._view.setup_callback_exit(self.exit) - def handle_load_folder(self, folder_dir: list[str]) -> None: + def handle_load_folder(self, folder: list[str]) -> None: """ Handle folder-loading requests from the view. @@ -47,13 +47,13 @@ def handle_load_folder(self, folder_dir: list[str]) -> None: Parameters ---------- - folder_dir : list[str] + folder : list[str] A list containing one or more filesystem paths. Only the first entry is used, as Qt's `QFileDialog` returns a list even when selecting a single folder. """ - self._model.print() + self._model.load_raw_scan_from_folder(folder) def exit(self) -> None: """Exit in menu.""" diff --git a/src/tavi/frontend/presenter/load_raw_scan_presenter.py b/src/tavi/frontend/presenter/load_raw_scan_presenter.py new file mode 100644 index 000000000..9c44c5164 --- /dev/null +++ b/src/tavi/frontend/presenter/load_raw_scan_presenter.py @@ -0,0 +1,44 @@ +"""Load raw scan presenter.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from tavi.event_broker.event_broker import EventBroker +from tavi.event_broker.event_type import RawScanLoadingEvent + +if TYPE_CHECKING: + from tavi.backend.model.interface.tavi_project_interface import TaviProjectInterface + from tavi.frontend.view.load_raw_scan_view import LoadView + + +class LoadRawScanPresenter: + """ + Presenter responsible for data loading. + + Mediating dataloading-related updates between the + model (`TaviProjectInterface`) and the load_raw_scan_view (`LoadView`). + + Attributes + ---------- + _view : LoadView + The load view associated with this presenter. + _model : TaviProjectInterface + The model providing metadata updates. + event_broker : EventBroker + The event system used to subscribe to different loading data update events. + + """ + + def __init__(self, view: LoadView, model: TaviProjectInterface) -> None: + """Initialize the metadata presenter and register for `meta_data` events.""" + super().__init__() + self._view = view + self._model = model + self.event_broker = EventBroker() + self.event_broker.register(RawScanLoadingEvent, self.update_treeview_data) + + def update_treeview_data(self, event: RawScanLoadingEvent) -> None: + """Update the treeview GUI after loading complete.""" + # TODO: implement rules to display tavi data after backend story + print("TODO: Implement rules to display loaded data after backend story.") diff --git a/src/tavi/frontend/presenter/main_presenter.py b/src/tavi/frontend/presenter/main_presenter.py index c28d6bcdf..165efbb16 100644 --- a/src/tavi/frontend/presenter/main_presenter.py +++ b/src/tavi/frontend/presenter/main_presenter.py @@ -1,6 +1,7 @@ """Main presenter for tavi.""" from tavi.frontend.presenter.file_menu_presenter import FileMenuPresenter +from tavi.frontend.presenter.load_raw_scan_presenter import LoadRawScanPresenter from tavi.frontend.view.main_view import TaviView from tavi.frontend.view.menubar_view import MainMenuBar @@ -12,10 +13,13 @@ def __init__(self, model_dict: dict) -> None: """Init main views.""" self._view = TaviView() self._view.exit_requested.connect(self.exit) - self.file_menu_presenter = FileMenuPresenter(self.exit, model=model_dict["TaviProjectInterface"]) + self.file_menu_presenter = FileMenuPresenter(self.exit, model=model_dict["TaviProjectProxy"]) menu_bar = MainMenuBar(self._view, file_menu_view=self.file_menu_presenter._view) self._view.install_menu_bar(menu_bar) + self.load_raw_scan_view = self._view.main_window.load_view + self.load_raw_scan_presenter = LoadRawScanPresenter(self.load_raw_scan_view, model_dict["TaviProjectProxy"]) + def exit(self) -> bool: """ Presenter handles dirty-save confirmation. diff --git a/src/tavi/frontend/view/file_menu_view.py b/src/tavi/frontend/view/file_menu_view.py index 120147e45..386d90fd1 100644 --- a/src/tavi/frontend/view/file_menu_view.py +++ b/src/tavi/frontend/view/file_menu_view.py @@ -41,8 +41,8 @@ def __init__(self, parent: Any = None) -> None: self.setTitle("File") self.new_project_action = QAction("New Project", self) self.load_project_action = QAction("Load Project", self) - self.load_file_action = QAction("Load File(s)", self) - self.load_folder_action = QAction("Load Folder", self) + self.load_file_action = QAction("Load Data File(s)", self) + self.load_folder_action = QAction("Load Experiment Folder", self) self.save_action = QAction("Save Project", self) self.exit_action = QAction("Exit", self) diff --git a/src/tavi/frontend/view/load_raw_scan_view.py b/src/tavi/frontend/view/load_raw_scan_view.py new file mode 100644 index 000000000..7cadb2cef --- /dev/null +++ b/src/tavi/frontend/view/load_raw_scan_view.py @@ -0,0 +1,169 @@ +"""Docstring for tavi.frontend.views.load_view.""" + +from typing import List, Optional + +from qtpy.QtCore import QObject, Qt, Signal +from qtpy.QtGui import QColor, QFont, QStandardItem, QStandardItemModel +from qtpy.QtWidgets import ( + QTreeView, + QVBoxLayout, + QWidget, +) + + +class LoadView(QWidget): + """Main widget.""" + + update_tree_signal = Signal(list) + + def __init__(self, parent: Optional["QObject"] = None) -> None: + """ + Construct the main treeview widget. + + Args: + parent (QObject): Optional parent + + """ + super().__init__(parent) + self.click_on_a_scan_callback = None + + layout = QVBoxLayout() + self.setLayout(layout) + + self.tree_widget = TreeViewWidget(self) + layout.addWidget(self.tree_widget) + + self.tree_widget.clicked_file_signal.connect(self.pass_selected_file) + + # handle thread safe operations from secondary thread + self.update_tree_signal.connect( + self.tree_widget.add_tree_data, + type=Qt.QueuedConnection, # run safely on GUI thread + ) + + def setup_callback_click_on_a_scan(self, callback: None) -> None: + """Setup call back functions to handle when clicking on a scann.""" + self.click_on_a_scan_callback = callback + + def pass_selected_file(self, filename: str) -> None: + """Invoke the call back with positional input arg.""" + self.click_on_a_scan_callback(filename) + + def update_add_tree_data(self, event_list: list[str]) -> None: + """Invoke update_tree_signal to process data coming in from a different thread.""" + self._bridge.update_tree_signal.emit(event_list) + + +class TreeViewWidget(QWidget): + """ + A widget that displays a hierarchical tree view. + + This widget is typically used to show experiment folders and their associated + scan files. Items are populated with `add_tree_data()`, and the widget emits + `clicked_file_signal` whenever a user selects a child item (i.e., a file). + + Signals + ------- + clicked_file_signal : Signal(str) + Emitted when a file (child item) is clicked. The signal carries the file + name or identifier associated with the selected tree item. + + Parameters + ---------- + parent : Optional[QObject], default=None + Parent widget for proper Qt ownership. + + """ + + clicked_file_signal = Signal(str) + highlighted_scan_changed = Signal(str) + + def __init__(self, parent: Optional["QObject"] = None) -> None: + """ + Initialize the tree view widget. + + This method: + - Creates a vertical layout. + - Initializes a `QTreeView` with a hidden header. + - Creates a `QStandardItemModel` with an invisible root node. + - Connects the view's clicked index signal to `select_file()`. + """ + super().__init__(parent) + + layoutTreeView = QVBoxLayout() + self.setLayout(layoutTreeView) + self.treeView = QTreeView(self) + self.treeView.setHeaderHidden(True) + self.treeModel = QStandardItemModel() + self.rootNode = self.treeModel.invisibleRootItem() + + layoutTreeView.addWidget(self.treeView) + + self.treeView.clicked.connect(self.select_file) + + def add_tree_data(self, list_of_files: List[str]) -> None: + """ + Populate the tree view with a folder node and its associated files. + + The real structure should be discussed and scoped out based on how we want to create uuid for raw scans. + """ + if "exp" in list_of_files[0]: + filename = list_of_files[0].split("_") + self.experiment_folder = StandardItem(filename[1], 16, set_bold=True) + else: + self.experiment_folder = StandardItem("Folder", 16, set_bold=True) + self.rootNode.appendRow(self.experiment_folder) + + for file in list_of_files: + self.experiment_folder.appendRow(StandardItem(file)) + self.treeView.setModel(self.treeModel) + + def select_file(self, val: str) -> None: + """ + Handle selection of a tree item and emit a signal if the item represents a file. + + Only child items (files) emit `clicked_file_signal`; the folder node itself + does not produce a signal. + """ + if val.parent().isValid(): + self.clicked_file_signal.emit(val.data()) + + +class StandardItem(QStandardItem): + """ + Convenience item class for populating Qt tree and list models with styled text. + + This subclass of `QStandardItem` applies font size, bolding, color, and marks + the item as non-editable by default. It is used throughout the tree view for + folder and file entries. + + Parameters + ---------- + txt : str, default="" + Text to display in the item. + font_size : int, default=12 + Point size for the item's font. + set_bold : bool, default=False + Whether the item text should be bold. + color : QColor, default=QColor(0, 0, 0) + Text color to apply to the item. + + """ + + def __init__( + self, txt: str = "", font_size: int = 12, set_bold: bool = False, color: QColor = QColor(0, 0, 0) + ) -> None: + """ + Initialize a styled non-editable `QStandardItem`. + + This method constructs a font object, applies styling, and assigns the + formatted text to the underlying item. + """ + super().__init__() + fnt = QFont("Open Sans", font_size) + fnt.setBold(set_bold) + + self.setEditable(False) + self.setForeground(color) + self.setFont(fnt) + self.setText(txt) diff --git a/src/tavi/frontend/view/main_view.py b/src/tavi/frontend/view/main_view.py index 5f61b5d3a..f2f55d6d2 100644 --- a/src/tavi/frontend/view/main_view.py +++ b/src/tavi/frontend/view/main_view.py @@ -9,6 +9,7 @@ from tavi import __version__ from tavi.backend.model.help_model import help_function +from tavi.frontend.view.load_raw_scan_view import LoadView logger = logging.getLogger("TAVI") @@ -25,12 +26,14 @@ def __init__(self, parent: Any = None) -> None: # initialize view #!!!!!!!!!!!!!!!!!!!! - # self.load_view = load_view - # self.load_view.setParent(self) + self.load_view = LoadView() + self.load_view.setParent(self) #!!!!!!!!!!!!!!!!!!!! ### Set the layout layout = QVBoxLayout() + ui_layout = QVBoxLayout() + ui_layout.addWidget(self.load_view) # Help button help_button = QPushButton("Help") @@ -39,6 +42,7 @@ def __init__(self, parent: Any = None) -> None: # Set bottom interface layout hor_layout = QHBoxLayout() hor_layout.addWidget(help_button) + layout.addLayout(ui_layout) layout.addLayout(hor_layout) self.setLayout(layout) diff --git a/tests/unit/view_test/test_load_raw_scan_view.py b/tests/unit/view_test/test_load_raw_scan_view.py new file mode 100644 index 000000000..e73eef758 --- /dev/null +++ b/tests/unit/view_test/test_load_raw_scan_view.py @@ -0,0 +1,100 @@ +# tests/test_load_view.py + +import pytest +from qtpy.QtGui import QColor + +from tavi.frontend.view.load_raw_scan_view import LoadView, StandardItem, TreeViewWidget + + +def test_standard_item_styling(): + item = StandardItem("hello", font_size=18, set_bold=True, color=QColor(1, 2, 3)) + + assert item.text() == "hello" + assert item.isEditable() is False + + f = item.font() + assert f.pointSize() == 18 + assert f.bold() is True + + # Foreground is a brush; compare its color + assert item.foreground().color() == QColor(1, 2, 3) + + +def test_treeview_add_tree_data_exp_folder_name(qtbot): + w = TreeViewWidget() + qtbot.addWidget(w) + + files = ["exp_12345_scan001", "scan002", "scan003"] + w.add_tree_data(files) + + # root has one row: the folder + assert w.rootNode.rowCount() == 1 + folder_item = w.rootNode.child(0) + assert folder_item.text() == "12345" + + folder_font = folder_item.font() + assert folder_font.pointSize() == 16 + assert folder_font.bold() is True + + # folder has children = all files + assert folder_item.rowCount() == len(files) + assert folder_item.child(0).text() == files[0] + assert folder_item.child(1).text() == files[1] + + +def test_treeview_add_tree_data_default_folder(qtbot): + w = TreeViewWidget() + qtbot.addWidget(w) + + files = ["scan001", "scan002"] + w.add_tree_data(files) + + assert w.rootNode.rowCount() == 1 + folder_item = w.rootNode.child(0) + assert folder_item.text() == "Folder" + assert folder_item.rowCount() == len(files) + + +def test_select_file_emits_only_for_child_item(qtbot): + w = TreeViewWidget() + qtbot.addWidget(w) + + files = ["exp_9_scan001", "scan002"] + w.add_tree_data(files) + + folder_item = w.rootNode.child(0) + child_item = folder_item.child(0) + + folder_index = w.treeModel.indexFromItem(folder_item) + child_index = w.treeModel.indexFromItem(child_item) + + # Clicking folder: should NOT emit because folder_index.parent().isValid() == False + emitted = {"val": None} + + def on_clicked(v): + emitted["val"] = v + + w.clicked_file_signal.connect(on_clicked) + w.select_file(folder_index) + assert emitted["val"] is None + + # Clicking child: should emit + with qtbot.waitSignal(w.clicked_file_signal, timeout=1000) as blocker: + w.select_file(child_index) + assert blocker.args == [files[0]] # emitted filename + + +def test_load_view_pass_selected_file_calls_callback(qtbot): + view = LoadView() + qtbot.addWidget(view) + + called = {"filename": None} + + def cb(filename: str): + called["filename"] = filename + + view.setup_callback_click_on_a_scan(cb) + + # Simulate the TreeViewWidget signal flow: + view.tree_widget.clicked_file_signal.emit("my_scan_file") + assert called["filename"] == "my_scan_file" diff --git a/tests/unit/view_test/test_menu_view.py b/tests/unit/view_test/test_menu_view.py index dd4e4b709..44758d424 100644 --- a/tests/unit/view_test/test_menu_view.py +++ b/tests/unit/view_test/test_menu_view.py @@ -18,7 +18,7 @@ def test_file_menu_action_labels(qtbot): assert labels["new_project"] == "New Project" assert labels["load_project"] == "Load Project" - assert labels["load_file"] == "Load File(s)" - assert labels["load_folder"] == "Load Folder" + assert labels["load_file"] == "Load Data File(s)" + assert labels["load_folder"] == "Load Experiment Folder" assert labels["save"] == "Save Project" assert labels["exit"] == "Exit"