diff --git a/.github/workflows/tutorial-tests-colab.yaml b/.github/workflows/tutorial-tests-colab.yaml index 35c4a753..30966aaa 100644 --- a/.github/workflows/tutorial-tests-colab.yaml +++ b/.github/workflows/tutorial-tests-colab.yaml @@ -41,5 +41,5 @@ jobs: - name: Check if Jupyter Notebooks run without errors run: > - python -m pytest --nbmake docs/tutorials/ --nbmake-timeout=600 --color=yes + python -m pytest --nbmake docs/tutorials/ --nbmake-timeout=1200 --color=yes -n=auto diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 65187b07..3067e6a6 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -689,6 +689,7 @@ It owns and coordinates all components: | `project.analysis` | `Analysis` | Calculator, minimiser, fitting | | `project.summary` | `Summary` | Report generation | | `project.plotter` | `Plotter` | Visualisation | +| `project.verbosity` | `str` | Console output level (full/short/silent) | ### 7.1 Data Flow @@ -721,6 +722,47 @@ project_dir/ └── hrpt.cif # One file per experiment ``` +### 7.3 Verbosity + +`Project.verbosity` controls how much console output operations produce. +It is backed by `VerbosityEnum` (in `utils/enums.py`) and accepts three +values: + +| Level | Enum member | Behaviour | +| -------- | ---------------------- | -------------------------------------------------- | +| `full` | `VerbosityEnum.FULL` | Multi-line output with headers, tables, and detail | +| `short` | `VerbosityEnum.SHORT` | One-line status message per action | +| `silent` | `VerbosityEnum.SILENT` | No console output | + +The default is `'full'`. + +```python +project.verbosity = 'short' +``` + +**Resolution order:** methods that produce console output (e.g. +`analysis.fit()`, `experiments.add_from_data_path()`) accept an optional +`verbosity` keyword argument. When the argument is `None` (the default), +the method reads `project.verbosity`. When a string is passed, it +overrides the project-level setting for that single call. + +```python +# Use project-level default for all operations +project.verbosity = 'short' +project.analysis.fit() # → short mode + +# Override for a single call +project.analysis.fit(verbosity='silent') # → silent, project stays short +``` + +**Output styles per level:** + +- **Data loading** — `full`: paragraph header + detail line; `short`: + `✅ Data loaded: Experiment 🔬 'name'. N points.`; `silent`: nothing. +- **Fitting** — `full`: per-iteration progress table with improvement + percentages; `short`: one-row-per-experiment summary table; `silent`: + nothing. + --- ## 8. User-Facing API Patterns diff --git a/docs/docs/tutorials/ed-17.ipynb b/docs/docs/tutorials/ed-17.ipynb index 566c1783..a6500f67 100644 --- a/docs/docs/tutorials/ed-17.ipynb +++ b/docs/docs/tutorials/ed-17.ipynb @@ -7,8 +7,8 @@ "source": [ "# Structure Refinement: Co2SiO4, D20 (T-scan)\n", "\n", - "This example demonstrates a Rietveld refinement of Co2SiO4 crystal\n", - "structure using constant wavelength neutron powder diffraction data\n", + "This example demonstrates a Rietveld refinement of the Co2SiO4 crystal\n", + "structure using constant-wavelength neutron powder diffraction data\n", "from D20 at ILL. A sequential refinement of the same structure against\n", "a temperature scan is performed to show how to manage multiple\n", "experiments in a project." @@ -39,8 +39,7 @@ "source": [ "## Step 1: Define Project\n", "\n", - "The project object is used to manage the structure, experiment, and\n", - "analysis." + "The project object manages structures, experiments, and analysis." ] }, { @@ -50,7 +49,6 @@ "metadata": {}, "outputs": [], "source": [ - "# Create minimal project without name and description\n", "project = ed.Project()" ] }, @@ -58,6 +56,25 @@ "cell_type": "markdown", "id": "5", "metadata": {}, + "source": [ + "Set output verbosity level to \"short\" to show only one-line status\n", + "messages during the analysis process." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6", + "metadata": {}, + "outputs": [], + "source": [ + "project.verbosity = 'short'" + ] + }, + { + "cell_type": "markdown", + "id": "7", + "metadata": {}, "source": [ "## Step 2: Define Crystal Structure\n", "\n", @@ -70,7 +87,7 @@ { "cell_type": "code", "execution_count": null, - "id": "6", + "id": "8", "metadata": {}, "outputs": [], "source": [ @@ -80,7 +97,7 @@ }, { "cell_type": "markdown", - "id": "7", + "id": "9", "metadata": {}, "source": [ "#### Set Space Group" @@ -89,7 +106,7 @@ { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -99,7 +116,7 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "11", "metadata": {}, "source": [ "#### Set Unit Cell" @@ -108,7 +125,7 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -119,7 +136,7 @@ }, { "cell_type": "markdown", - "id": "11", + "id": "13", "metadata": {}, "source": [ "#### Set Atom Sites" @@ -128,7 +145,7 @@ { "cell_type": "code", "execution_count": null, - "id": "12", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -190,13 +207,13 @@ }, { "cell_type": "markdown", - "id": "13", + "id": "15", "metadata": {}, "source": [ - "## Define Experiment\n", + "## Step 3: Define Experiments\n", "\n", "This section shows how to add experiments, configure their parameters,\n", - "and link the structures defined in the previous step.\n", + "and link the structures defined above.\n", "\n", "#### Download Measured Data" ] @@ -204,7 +221,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -213,7 +230,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "17", "metadata": {}, "source": [ "#### Create Experiments and Set Temperature" @@ -222,7 +239,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -242,7 +259,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "19", "metadata": {}, "source": [ "#### Set Instrument" @@ -251,7 +268,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -262,7 +279,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "21", "metadata": {}, "source": [ "#### Set Peak Profile" @@ -271,7 +288,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -284,7 +301,7 @@ }, { "cell_type": "markdown", - "id": "21", + "id": "23", "metadata": {}, "source": [ "#### Set Excluded Regions" @@ -293,7 +310,7 @@ { "cell_type": "code", "execution_count": null, - "id": "22", + "id": "24", "metadata": {}, "outputs": [], "source": [ @@ -304,7 +321,7 @@ }, { "cell_type": "markdown", - "id": "23", + "id": "25", "metadata": {}, "source": [ "#### Set Background" @@ -313,7 +330,7 @@ { "cell_type": "code", "execution_count": null, - "id": "24", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -336,7 +353,7 @@ }, { "cell_type": "markdown", - "id": "25", + "id": "27", "metadata": {}, "source": [ "#### Set Linked Phases" @@ -345,7 +362,7 @@ { "cell_type": "code", "execution_count": null, - "id": "26", + "id": "28", "metadata": {}, "outputs": [], "source": [ @@ -355,18 +372,18 @@ }, { "cell_type": "markdown", - "id": "27", + "id": "29", "metadata": {}, "source": [ - "## Perform Analysis\n", + "## Step 4: Perform Analysis\n", "\n", - "This section shows the analysis process, including how to set up\n", - "calculation and fitting engines." + "This section shows how to set free parameters, define constraints,\n", + "and run the refinement." ] }, { "cell_type": "markdown", - "id": "28", + "id": "30", "metadata": {}, "source": [ "#### Set Free Parameters" @@ -375,7 +392,7 @@ { "cell_type": "code", "execution_count": null, - "id": "29", + "id": "31", "metadata": {}, "outputs": [], "source": [ @@ -406,7 +423,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30", + "id": "32", "metadata": {}, "outputs": [], "source": [ @@ -426,7 +443,7 @@ }, { "cell_type": "markdown", - "id": "31", + "id": "33", "metadata": {}, "source": [ "#### Set Constraints\n", @@ -437,7 +454,7 @@ { "cell_type": "code", "execution_count": null, - "id": "32", + "id": "34", "metadata": {}, "outputs": [], "source": [ @@ -453,7 +470,7 @@ }, { "cell_type": "markdown", - "id": "33", + "id": "35", "metadata": {}, "source": [ "Set constraints." @@ -462,7 +479,7 @@ { "cell_type": "code", "execution_count": null, - "id": "34", + "id": "36", "metadata": {}, "outputs": [], "source": [ @@ -473,7 +490,7 @@ }, { "cell_type": "markdown", - "id": "35", + "id": "37", "metadata": {}, "source": [ "Apply constraints." @@ -482,7 +499,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36", + "id": "38", "metadata": {}, "outputs": [], "source": [ @@ -491,16 +508,16 @@ }, { "cell_type": "markdown", - "id": "37", + "id": "39", "metadata": {}, "source": [ - "#### Set Fit Mode and Weights" + "#### Set Fit Mode" ] }, { "cell_type": "code", "execution_count": null, - "id": "38", + "id": "40", "metadata": {}, "outputs": [], "source": [ @@ -509,7 +526,7 @@ }, { "cell_type": "markdown", - "id": "39", + "id": "41", "metadata": {}, "source": [ "#### Run Fitting" @@ -518,7 +535,7 @@ { "cell_type": "code", "execution_count": null, - "id": "40", + "id": "42", "metadata": {}, "outputs": [], "source": [ @@ -527,7 +544,7 @@ }, { "cell_type": "markdown", - "id": "41", + "id": "43", "metadata": {}, "source": [ "#### Plot Measured vs Calculated" @@ -536,7 +553,7 @@ { "cell_type": "code", "execution_count": null, - "id": "42", + "id": "44", "metadata": {}, "outputs": [], "source": [ @@ -546,26 +563,36 @@ }, { "cell_type": "markdown", - "id": "43", + "id": "45", "metadata": {}, "source": [ - "#### Plot parameters evolution" + "#### Plot Parameter Evolution\n", + "\n", + "Define the quantity to use as the x-axis in the following plots." ] }, { "cell_type": "code", "execution_count": null, - "id": "44", + "id": "46", "metadata": {}, "outputs": [], "source": [ "temperature = project.experiments[0].diffrn.ambient_temperature" ] }, + { + "cell_type": "markdown", + "id": "47", + "metadata": {}, + "source": [ + "Plot unit cell parameters vs. temperature." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "45", + "id": "48", "metadata": {}, "outputs": [], "source": [ @@ -574,10 +601,18 @@ "project.plot_param_series(structure.cell.length_c, versus=temperature)" ] }, + { + "cell_type": "markdown", + "id": "49", + "metadata": {}, + "source": [ + "Plot isotropic displacement parameters vs. temperature." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "46", + "id": "50", "metadata": {}, "outputs": [], "source": [ @@ -587,6 +622,28 @@ "project.plot_param_series(structure.atom_sites['O2'].b_iso, versus=temperature)\n", "project.plot_param_series(structure.atom_sites['O3'].b_iso, versus=temperature)" ] + }, + { + "cell_type": "markdown", + "id": "51", + "metadata": {}, + "source": [ + "Plot selected fractional coordinates vs. temperature." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52", + "metadata": {}, + "outputs": [], + "source": [ + "project.plot_param_series(structure.atom_sites['Co2'].fract_x, versus=temperature)\n", + "project.plot_param_series(structure.atom_sites['Co2'].fract_z, versus=temperature)\n", + "project.plot_param_series(structure.atom_sites['O1'].fract_z, versus=temperature)\n", + "project.plot_param_series(structure.atom_sites['O2'].fract_z, versus=temperature)\n", + "project.plot_param_series(structure.atom_sites['O3'].fract_z, versus=temperature)" + ] } ], "metadata": { diff --git a/docs/docs/tutorials/ed-17.py b/docs/docs/tutorials/ed-17.py index 0f059849..af06031f 100644 --- a/docs/docs/tutorials/ed-17.py +++ b/docs/docs/tutorials/ed-17.py @@ -1,8 +1,8 @@ # %% [markdown] # # Structure Refinement: Co2SiO4, D20 (T-scan) # -# This example demonstrates a Rietveld refinement of Co2SiO4 crystal -# structure using constant wavelength neutron powder diffraction data +# This example demonstrates a Rietveld refinement of the Co2SiO4 crystal +# structure using constant-wavelength neutron powder diffraction data # from D20 at ILL. A sequential refinement of the same structure against # a temperature scan is performed to show how to manage multiple # experiments in a project. @@ -16,13 +16,18 @@ # %% [markdown] # ## Step 1: Define Project # -# The project object is used to manage the structure, experiment, and -# analysis. +# The project object manages structures, experiments, and analysis. # %% -# Create minimal project without name and description project = ed.Project() +# %% [markdown] +# Set output verbosity level to "short" to show only one-line status +# messages during the analysis process. + +# %% +project.verbosity = 'short' + # %% [markdown] # ## Step 2: Define Crystal Structure # @@ -110,10 +115,10 @@ ) # %% [markdown] -# ## Define Experiment +# ## Step 3: Define Experiments # # This section shows how to add experiments, configure their parameters, -# and link the structures defined in the previous step. +# and link the structures defined above. # # #### Download Measured Data @@ -191,10 +196,10 @@ expt.linked_phases.create(id='cosio', scale=1.2) # %% [markdown] -# ## Perform Analysis +# ## Step 4: Perform Analysis # -# This section shows the analysis process, including how to set up -# calculation and fitting engines. +# This section shows how to set free parameters, define constraints, +# and run the refinement. # %% [markdown] # #### Set Free Parameters @@ -267,7 +272,7 @@ project.analysis.apply_constraints() # %% [markdown] -# #### Set Fit Mode and Weights +# #### Set Fit Mode # %% project.analysis.fit_mode.mode = 'single' @@ -286,19 +291,37 @@ project.plot_meas_vs_calc(expt_name=last_expt_name, show_residual=True) # %% [markdown] -# #### Plot parameters evolution +# #### Plot Parameter Evolution +# +# Define the quantity to use as the x-axis in the following plots. # %% temperature = project.experiments[0].diffrn.ambient_temperature +# %% [markdown] +# Plot unit cell parameters vs. temperature. + # %% project.plot_param_series(structure.cell.length_a, versus=temperature) project.plot_param_series(structure.cell.length_b, versus=temperature) project.plot_param_series(structure.cell.length_c, versus=temperature) +# %% [markdown] +# Plot isotropic displacement parameters vs. temperature. + # %% project.plot_param_series(structure.atom_sites['Co1'].b_iso, versus=temperature) project.plot_param_series(structure.atom_sites['Si'].b_iso, versus=temperature) project.plot_param_series(structure.atom_sites['O1'].b_iso, versus=temperature) project.plot_param_series(structure.atom_sites['O2'].b_iso, versus=temperature) project.plot_param_series(structure.atom_sites['O3'].b_iso, versus=temperature) + +# %% [markdown] +# Plot selected fractional coordinates vs. temperature. + +# %% +project.plot_param_series(structure.atom_sites['Co2'].fract_x, versus=temperature) +project.plot_param_series(structure.atom_sites['Co2'].fract_z, versus=temperature) +project.plot_param_series(structure.atom_sites['O1'].fract_z, versus=temperature) +project.plot_param_series(structure.atom_sites['O2'].fract_z, versus=temperature) +project.plot_param_series(structure.atom_sites['O3'].fract_z, versus=temperature) diff --git a/pixi.toml b/pixi.toml index 2cbf09c5..37952beb 100644 --- a/pixi.toml +++ b/pixi.toml @@ -95,7 +95,7 @@ default = { features = ['default', 'py-max'] } unit-tests = 'python -m pytest tests/unit/ --color=yes -v' integration-tests = 'python -m pytest tests/integration/ --color=yes -n auto -v' script-tests = 'python -m pytest tools/test_scripts.py --color=yes -n auto -v' -notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=600 --color=yes -n auto -v' +notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --color=yes -n auto -v' test = { depends-on = ['unit-tests'] } @@ -169,7 +169,7 @@ cov = { depends-on = [ notebook-convert = 'jupytext docs/docs/tutorials/*.py --from py:percent --to ipynb' notebook-strip = 'nbstripout docs/docs/tutorials/*.ipynb' notebook-tweak = 'python tools/tweak_notebooks.py tutorials/' -notebook-exec = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=600 --overwrite --color=yes -n auto -v' +notebook-exec = 'python -m pytest --nbmake docs/docs/tutorials/ --nbmake-timeout=1200 --overwrite --color=yes -n auto -v' notebook-prepare = { depends-on = [ #'notebook-convert', diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index f97c2e13..c87fc545 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -20,6 +20,7 @@ from easydiffraction.core.variable import StringDescriptor from easydiffraction.datablocks.experiment.collection import Experiments from easydiffraction.display.tables import TableRenderer +from easydiffraction.utils.enums import VerbosityEnum from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log from easydiffraction.utils.utils import render_cif @@ -576,7 +577,7 @@ def apply_constraints(self) -> None: self.constraints_handler.set_constraints(self.constraints) self.constraints_handler.apply() - def fit(self) -> None: + def fit(self, verbosity: str | None = None) -> None: """ Execute fitting for all experiments. @@ -593,11 +594,21 @@ def fit(self) -> None: programmatically (e.g., ``analysis.fit_results.reduced_chi_square``). - Example:: - - project.analysis.fit() project.analysis.show_fit_results() # - Display results + Parameters + ---------- + verbosity : str | None, default=None + Console output verbosity: ``'full'`` for detailed per- + experiment progress, ``'short'`` for a + one-row-per-experiment summary table, or ``'silent'`` for no + output. When ``None``, uses ``project.verbosity``. + + Raises + ------ + NotImplementedError + If the fit mode is not ``'single'`` or ``'joint'``. """ + verb = VerbosityEnum(verbosity if verbosity is not None else self.project.verbosity) + structures = self.project.structures if not structures: log.warning('No structures found in the project. Cannot run fit.') @@ -615,24 +626,52 @@ def fit(self) -> None: if not len(self._joint_fit_experiments): for id in experiments.names: self._joint_fit_experiments.create(id=id, weight=0.5) - console.paragraph( - f"Using all experiments 🔬 {experiments.names} for '{mode.value}' fitting" - ) + if verb is not VerbosityEnum.SILENT: + console.paragraph( + f"Using all experiments 🔬 {experiments.names} for '{mode.value}' fitting" + ) self.fitter.fit( structures, experiments, weights=self._joint_fit_experiments, analysis=self, + verbosity=verb, ) # After fitting, get the results self.fit_results = self.fitter.results elif mode is FitModeEnum.SINGLE: + expt_names = experiments.names + num_expts = len(expt_names) + + # Short mode: print header and create display handle once + short_headers = ['experiment', 'χ²', 'iterations', 'status'] + short_alignments = ['left', 'right', 'right', 'center'] + short_rows: list[list[str]] = [] + short_display_handle: object | None = None + if verb is VerbosityEnum.SHORT: + from easydiffraction.analysis.fit_helpers.tracking import _make_display_handle + + first = expt_names[0] + last = expt_names[-1] + minimizer_name = self.fitter.selection + console.paragraph( + f"Using {num_expts} experiments 🔬 from '{first}' to " + f"'{last}' for '{mode.value}' fitting" + ) + console.print(f"🚀 Starting fit process with '{minimizer_name}'...") + console.print('📈 Goodness-of-fit (reduced χ²) per experiment:') + short_display_handle = _make_display_handle() + # TODO: Find a better way without creating dummy # experiments? - for expt_name in experiments.names: - console.paragraph(f"Using experiment 🔬 '{expt_name}' for '{mode.value}' fitting") + for _idx, expt_name in enumerate(expt_names, start=1): + if verb is VerbosityEnum.FULL: + console.paragraph( + f"Using experiment 🔬 '{expt_name}' for '{mode.value}' fitting" + ) + experiment = experiments[expt_name] dummy_experiments = Experiments() # TODO: Find a better name @@ -646,6 +685,7 @@ def fit(self) -> None: structures, dummy_experiments, analysis=self, + verbosity=verb, ) # After fitting, snapshot parameter values before @@ -661,6 +701,30 @@ def fit(self) -> None: self._parameter_snapshots[expt_name] = snapshot self.fit_results = results + # Short mode: append one summary row and update in-place + if verb is VerbosityEnum.SHORT: + chi2_str = ( + f'{results.reduced_chi_square:.2f}' + if results.reduced_chi_square is not None + else '—' + ) + iters = str(self.fitter.minimizer.tracker.best_iteration or 0) + status = '✅' if results.success else '❌' + short_rows.append([expt_name, chi2_str, iters, status]) + render_table( + columns_headers=short_headers, + columns_alignment=short_alignments, + columns_data=short_rows, + display_handle=short_display_handle, + ) + + # Short mode: close the display handle + if short_display_handle is not None and hasattr(short_display_handle, 'close'): + from contextlib import suppress + + with suppress(Exception): + short_display_handle.close() + else: raise NotImplementedError(f'Fit mode {mode.value} not implemented yet.') diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index eb32f3ea..804dc536 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -19,6 +19,7 @@ clear_output = None from easydiffraction.analysis.fit_helpers.metrics import calculate_reduced_chi_square +from easydiffraction.utils.enums import VerbosityEnum from easydiffraction.utils.environment import in_jupyter from easydiffraction.utils.utils import render_table @@ -101,6 +102,7 @@ def __init__(self) -> None: self._best_chi2: Optional[float] = None self._best_iteration: Optional[int] = None self._fitting_time: Optional[float] = None + self._verbosity: VerbosityEnum = VerbosityEnum.FULL self._df_rows: List[List[str]] = [] self._display_handle: Optional[object] = None @@ -223,6 +225,11 @@ def start_tracking(self, minimizer_name: str) -> None: minimizer_name : str Name of the minimizer used for the run. """ + if self._verbosity is VerbosityEnum.SILENT: + return + if self._verbosity is VerbosityEnum.SHORT: + return + console.print(f"🚀 Starting fit process with '{minimizer_name}'...") console.print('📈 Goodness-of-fit (reduced χ²) change:') @@ -247,9 +254,11 @@ def add_tracking_info(self, row: List[str]) -> None: row : List[str] Columns corresponding to DEFAULT_HEADERS. """ + self._df_rows.append(row) + if self._verbosity is not VerbosityEnum.FULL: + return # Append and update via the active handle (Jupyter or # terminal live) - self._df_rows.append(row) render_table( columns_headers=DEFAULT_HEADERS, columns_alignment=DEFAULT_ALIGNMENTS, @@ -267,6 +276,9 @@ def finish_tracking(self) -> None: ] self.add_tracking_info(row) + if self._verbosity is not VerbosityEnum.FULL: + return + # Close terminal live if used if self._display_handle is not None and hasattr(self._display_handle, 'close'): with suppress(Exception): diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index fa6c0368..de6dcdaa 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -14,6 +14,7 @@ from easydiffraction.core.variable import Parameter from easydiffraction.datablocks.experiment.collection import Experiments from easydiffraction.datablocks.structure.collection import Structures +from easydiffraction.utils.enums import VerbosityEnum if TYPE_CHECKING: from easydiffraction.analysis.fit_helpers.reporting import FitResults @@ -34,6 +35,7 @@ def fit( experiments: Experiments, weights: Optional[np.array] = None, analysis: object = None, + verbosity: VerbosityEnum = VerbosityEnum.FULL, ) -> None: """ Run the fitting process. @@ -53,6 +55,8 @@ def fit( analysis : object, default=None Optional Analysis object to update its categories during fitting. + verbosity : VerbosityEnum, default=VerbosityEnum.FULL + Console output verbosity. """ params = structures.free_parameters + experiments.free_parameters @@ -87,7 +91,7 @@ def objective_function(engine_params: Dict[str, Any]) -> np.ndarray: ) # Perform fitting - self.results = self.minimizer.fit(params, objective_function) + self.results = self.minimizer.fit(params, objective_function, verbosity=verbosity) def _process_fit_results( self, diff --git a/src/easydiffraction/analysis/minimizers/base.py b/src/easydiffraction/analysis/minimizers/base.py index f8499dad..63b6bc27 100644 --- a/src/easydiffraction/analysis/minimizers/base.py +++ b/src/easydiffraction/analysis/minimizers/base.py @@ -13,6 +13,7 @@ from easydiffraction.analysis.fit_helpers.reporting import FitResults from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker +from easydiffraction.utils.enums import VerbosityEnum class MinimizerBase(ABC): @@ -42,7 +43,11 @@ def __init__( self._fitting_time: Optional[float] = None self.tracker: FitProgressTracker = FitProgressTracker() - def _start_tracking(self, minimizer_name: str) -> None: + def _start_tracking( + self, + minimizer_name: str, + verbosity: VerbosityEnum = VerbosityEnum.FULL, + ) -> None: """ Initialize progress tracking and timer. @@ -50,8 +55,11 @@ def _start_tracking(self, minimizer_name: str) -> None: ---------- minimizer_name : str Human-readable name shown in progress. + verbosity : VerbosityEnum, default=VerbosityEnum.FULL + Console output verbosity. """ self.tracker.reset() + self.tracker._verbosity = verbosity self.tracker.start_tracking(minimizer_name) self.tracker.start_timer() @@ -136,6 +144,7 @@ def fit( self, parameters: List[object], objective_function: Callable[..., object], + verbosity: VerbosityEnum = VerbosityEnum.FULL, ) -> FitResults: """ Run the full minimization workflow. @@ -147,6 +156,8 @@ def fit( objective_function : Callable[..., object] Callable returning residuals for a given set of engine arguments. + verbosity : VerbosityEnum, default=VerbosityEnum.FULL + Console output verbosity. Returns ------- @@ -157,7 +168,7 @@ def fit( if self.method is not None: minimizer_name += f' ({self.method})' - self._start_tracking(minimizer_name) + self._start_tracking(minimizer_name, verbosity=verbosity) solver_args = self._prepare_solver_args(parameters) raw_result = self._run_solver(objective_function, **solver_args) diff --git a/src/easydiffraction/datablocks/experiment/collection.py b/src/easydiffraction/datablocks/experiment/collection.py index 5cdf3f6b..fb30d013 100644 --- a/src/easydiffraction/datablocks/experiment/collection.py +++ b/src/easydiffraction/datablocks/experiment/collection.py @@ -7,6 +7,7 @@ from easydiffraction.core.datablock import DatablockCollection from easydiffraction.datablocks.experiment.item.base import ExperimentBase from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory +from easydiffraction.utils.enums import VerbosityEnum from easydiffraction.utils.logging import console @@ -105,6 +106,7 @@ def add_from_data_path( beam_mode: str | None = None, radiation_probe: str | None = None, scattering_type: str | None = None, + verbosity: str | None = None, ) -> None: """ Add an experiment from a data file path. @@ -123,7 +125,14 @@ def add_from_data_path( Radiation probe (e.g. ``'neutron'``). scattering_type : str | None, default=None Scattering type (e.g. ``'bragg'``). + verbosity : str | None, default=None + Console output verbosity: ``'full'`` for multi-line output, + ``'short'`` for a one-line status message, or ``'silent'`` + for no output. When ``None``, uses ``project.verbosity``. """ + if verbosity is None and self._parent is not None: + verbosity = self._parent.verbosity + verb = VerbosityEnum(verbosity) if verbosity is not None else VerbosityEnum.FULL experiment = ExperimentFactory.from_data_path( name=name, data_path=data_path, @@ -131,6 +140,7 @@ def add_from_data_path( beam_mode=beam_mode, radiation_probe=radiation_probe, scattering_type=scattering_type, + verbosity=verb, ) self.add(experiment) diff --git a/src/easydiffraction/datablocks/experiment/item/base.py b/src/easydiffraction/datablocks/experiment/item/base.py index bc42728f..5c426a94 100644 --- a/src/easydiffraction/datablocks/experiment/item/base.py +++ b/src/easydiffraction/datablocks/experiment/item/base.py @@ -562,7 +562,7 @@ def _get_valid_linked_phases( return valid_linked_phases @abstractmethod - def _load_ascii_data_to_experiment(self, data_path: str) -> None: + def _load_ascii_data_to_experiment(self, data_path: str) -> int: """ Load powder diffraction data from an ASCII file. @@ -571,6 +571,11 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: data_path : str Path to data file with columns compatible with the beam mode (e.g. 2θ/I/σ for CWL, TOF/I/σ for TOF). + + Returns + ------- + int + Number of loaded data points. """ pass diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py index 01c7aebe..6da9ffe7 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_pd.py @@ -58,7 +58,7 @@ def __init__( def _load_ascii_data_to_experiment( self, data_path: str, - ) -> None: + ) -> int: """ Load (x, y, sy) data from an ASCII file into the data category. @@ -68,6 +68,16 @@ def _load_ascii_data_to_experiment( If ``sy`` has values smaller than ``0.0001``, they are replaced with ``1.0``. + + Parameters + ---------- + data_path : str + Path to the ASCII data file. + + Returns + ------- + int + Number of loaded data points. """ data = load_numeric_block(data_path) @@ -76,7 +86,7 @@ def _load_ascii_data_to_experiment( 'Data file must have at least two columns: x and y.', exc_type=ValueError, ) - return + return 0 if data.shape[1] < 3: log.warning('No uncertainty (sy) column provided. Defaulting to sqrt(y).') @@ -100,14 +110,7 @@ def _load_ascii_data_to_experiment( self.data._set_intensity_meas(y) self.data._set_intensity_meas_su(sy) - temperature = '' - if self.diffrn.ambient_temperature.value is not None: - temperature = f' Temperature: {self.diffrn.ambient_temperature.value:.3f} K.' - - console.paragraph('Data loaded successfully') - console.print( - f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}.{temperature}" - ) + return len(x) # ------------------------------------------------------------------ # Instrument (switchable-category pattern) diff --git a/src/easydiffraction/datablocks/experiment/item/bragg_sc.py b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py index 4cc372d4..f77d3af6 100644 --- a/src/easydiffraction/datablocks/experiment/item/bragg_sc.py +++ b/src/easydiffraction/datablocks/experiment/item/bragg_sc.py @@ -15,7 +15,6 @@ from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory from easydiffraction.io.ascii import load_numeric_block -from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log if TYPE_CHECKING: @@ -44,12 +43,22 @@ def __init__( ) -> None: super().__init__(name=name, type=type) - def _load_ascii_data_to_experiment(self, data_path: str) -> None: + def _load_ascii_data_to_experiment(self, data_path: str) -> int: """ Load measured data from an ASCII file into the data category. The file format is space/column separated with 5 columns: ``h k l Iobs sIobs``. + + Parameters + ---------- + data_path : str + Path to the ASCII data file. + + Returns + ------- + int + Number of loaded data points. """ data = load_numeric_block(data_path) @@ -58,7 +67,7 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: 'Data file must have at least 5 columns: h, k, l, Iobs, sIobs.', exc_type=ValueError, ) - return + return 0 # Extract Miller indices h, k, l indices_h: np.ndarray = data[:, 0].astype(int) @@ -74,8 +83,7 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: self.data._set_intensity_meas(integrated_intensities) self.data._set_intensity_meas_su(integrated_intensities_su) - console.paragraph('Data loaded successfully') - console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(indices_h)}") + return len(indices_h) @ExperimentFactory.register @@ -100,12 +108,22 @@ def __init__( ) -> None: super().__init__(name=name, type=type) - def _load_ascii_data_to_experiment(self, data_path: str) -> None: + def _load_ascii_data_to_experiment(self, data_path: str) -> int: """ Load measured data from an ASCII file into the data category. The file format is space/column separated with 6 columns: ``h k l Iobs sIobs wavelength``. + + Parameters + ---------- + data_path : str + Path to the ASCII data file. + + Returns + ------- + int + Number of loaded data points. """ try: data = load_numeric_block(data_path) @@ -114,14 +132,14 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: f'Failed to read data from {data_path}: {e}', exc_type=IOError, ) - return + return 0 if data.shape[1] < 6: log.error( 'Data file must have at least 6 columns: h, k, l, Iobs, sIobs, wavelength.', exc_type=ValueError, ) - return + return 0 # Extract Miller indices h, k, l indices_h: np.ndarray = data[:, 0].astype(int) @@ -141,5 +159,4 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: self.data._set_intensity_meas_su(integrated_intensities_su) self.data._set_wavelength(wavelength) - console.paragraph('Data loaded successfully') - console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(indices_h)}") + return len(indices_h) diff --git a/src/easydiffraction/datablocks/experiment/item/factory.py b/src/easydiffraction/datablocks/experiment/item/factory.py index c23f60f5..5c0b3094 100644 --- a/src/easydiffraction/datablocks/experiment/item/factory.py +++ b/src/easydiffraction/datablocks/experiment/item/factory.py @@ -23,6 +23,8 @@ from easydiffraction.io.cif.parse import document_from_string from easydiffraction.io.cif.parse import name_from_block from easydiffraction.io.cif.parse import pick_sole_block +from easydiffraction.utils.enums import VerbosityEnum +from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log if TYPE_CHECKING: @@ -229,6 +231,7 @@ def from_data_path( beam_mode: str | None = None, radiation_probe: str | None = None, scattering_type: str | None = None, + verbosity: VerbosityEnum = VerbosityEnum.FULL, ) -> ExperimentBase: """ Create an experiment from a raw data ASCII file. @@ -247,6 +250,8 @@ def from_data_path( Radiation probe (e.g. ``'neutron'``). scattering_type : str | None, default=None Scattering type (e.g. ``'bragg'``). + verbosity : VerbosityEnum, default=VerbosityEnum.FULL + Console output verbosity. Returns ------- @@ -261,5 +266,12 @@ def from_data_path( scattering_type=scattering_type, ) - expt_obj._load_ascii_data_to_experiment(data_path) + num_points = expt_obj._load_ascii_data_to_experiment(data_path) + + if verbosity is VerbosityEnum.FULL: + console.paragraph('Data loaded successfully') + console.print(f"Experiment 🔬 '{name}'. Number of data points: {num_points}.") + elif verbosity is VerbosityEnum.SHORT: + console.print(f"✅ Data loaded: Experiment 🔬 '{name}'. {num_points} points.") + return expt_obj diff --git a/src/easydiffraction/datablocks/experiment/item/total_pd.py b/src/easydiffraction/datablocks/experiment/item/total_pd.py index ade11e6f..2ed856ba 100644 --- a/src/easydiffraction/datablocks/experiment/item/total_pd.py +++ b/src/easydiffraction/datablocks/experiment/item/total_pd.py @@ -14,7 +14,6 @@ from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum from easydiffraction.datablocks.experiment.item.factory import ExperimentFactory -from easydiffraction.utils.logging import console if TYPE_CHECKING: from easydiffraction.datablocks.experiment.categories.experiment_type import ExperimentType @@ -41,11 +40,30 @@ def __init__( ) -> None: super().__init__(name=name, type=type) - def _load_ascii_data_to_experiment(self, data_path: str) -> None: + def _load_ascii_data_to_experiment(self, data_path: str) -> int: """ Load x, y, sy values from an ASCII file into the experiment. The file must be structured as: x y sy + + Parameters + ---------- + data_path : str + Path to the ASCII data file. + + Returns + ------- + int + Number of loaded data points. + + Raises + ------ + ImportError + If the ``diffpy`` package is not installed. + IOError + If the data file cannot be read. + ValueError + If the data file has fewer than two columns. """ try: from diffpy.utils.parsers.loaddata import loadData @@ -71,5 +89,4 @@ def _load_ascii_data_to_experiment(self, data_path: str) -> None: self.data._set_g_r_meas(y) self.data._set_g_r_meas_su(sy) - console.paragraph('Data loaded successfully') - console.print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}") + return len(x) diff --git a/src/easydiffraction/project/project.py b/src/easydiffraction/project/project.py index ace0fc95..61b2cd5d 100644 --- a/src/easydiffraction/project/project.py +++ b/src/easydiffraction/project/project.py @@ -17,6 +17,7 @@ from easydiffraction.io.cif.serialize import project_to_cif from easydiffraction.project.project_info import ProjectInfo from easydiffraction.summary.summary import Summary +from easydiffraction.utils.enums import VerbosityEnum from easydiffraction.utils.logging import console from easydiffraction.utils.logging import log @@ -48,6 +49,7 @@ def __init__( self._summary = Summary(self) self._saved = False self._varname = varname() + self._verbosity: VerbosityEnum = VerbosityEnum.FULL # ------------------------------------------------------------------ # Dunder methods @@ -141,6 +143,31 @@ def as_cif(self) -> str: # Concatenate sections using centralized CIF serializers return project_to_cif(self) + @property + def verbosity(self) -> str: + """ + Project-wide console output verbosity. + + Returns + ------- + str + One of ``'full'``, ``'short'``, or ``'silent'``. + """ + return self._verbosity.value + + @verbosity.setter + def verbosity(self, value: str) -> None: + """ + Set project-wide console output verbosity. + + Parameters + ---------- + value : str + ``'full'`` for multi-line output, ``'short'`` for one-line + status messages, or ``'silent'`` for no output. + """ + self._verbosity = VerbosityEnum(value) + # ------------------------------------------ # Project File I/O # ------------------------------------------ diff --git a/src/easydiffraction/utils/enums.py b/src/easydiffraction/utils/enums.py new file mode 100644 index 00000000..c4aac164 --- /dev/null +++ b/src/easydiffraction/utils/enums.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""General-purpose enumerations shared across the library.""" + +from __future__ import annotations + +from enum import Enum + + +class VerbosityEnum(str, Enum): + """ + Console output verbosity level. + + Controls how much information is printed during operations such as + data loading, fitting, and saving. + + Members + ------- + FULL Multi-line output with headers, tables, and details. SHORT + Single-line status messages per action. SILENT No console output. + """ + + FULL = 'full' + SHORT = 'short' + SILENT = 'silent' + + @classmethod + def default(cls) -> VerbosityEnum: + """Return the default verbosity (FULL).""" + return cls.FULL diff --git a/tests/unit/easydiffraction/analysis/test_fitting.py b/tests/unit/easydiffraction/analysis/test_fitting.py index 2f703f77..ed9ea419 100644 --- a/tests/unit/easydiffraction/analysis/test_fitting.py +++ b/tests/unit/easydiffraction/analysis/test_fitting.py @@ -26,7 +26,7 @@ def names(self): class DummyMin: tracker = type('T', (), {'track': staticmethod(lambda a, b: a)})() - def fit(self, params, obj): + def fit(self, params, obj, verbosity=None): return None f = Fitter() @@ -66,7 +66,7 @@ class MockFitResults: class DummyMin: tracker = type('T', (), {'track': staticmethod(lambda a, b: a)})() - def fit(self, params, obj): + def fit(self, params, obj, verbosity=None): return MockFitResults() def _sync_result_to_parameters(self, params, engine_params): diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py index c2a0ab92..b666374a 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_base.py @@ -19,8 +19,8 @@ def test_pd_experiment_peak_profile_type_switch(capsys): from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum class ConcretePd(PdExperimentBase): - def _load_ascii_data_to_experiment(self, data_path: str) -> None: - pass + def _load_ascii_data_to_experiment(self, data_path: str) -> int: + return 0 et = ExperimentType() et._set_sample_form(SampleFormEnum.POWDER.value) diff --git a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py index a69dd1bd..014f65fe 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py +++ b/tests/unit/easydiffraction/datablocks/experiment/item/test_bragg_sc.py @@ -22,9 +22,9 @@ def _mk_type_sc_bragg(): class _ConcreteCwlSc(CwlScExperiment): - def _load_ascii_data_to_experiment(self, data_path: str) -> None: + def _load_ascii_data_to_experiment(self, data_path: str) -> int: # Not used in this test - pass + return 0 def test_init_and_placeholder_no_crash(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/unit/easydiffraction/datablocks/experiment/test_collection.py b/tests/unit/easydiffraction/datablocks/experiment/test_collection.py index bcb9f822..3d5819f6 100644 --- a/tests/unit/easydiffraction/datablocks/experiment/test_collection.py +++ b/tests/unit/easydiffraction/datablocks/experiment/test_collection.py @@ -23,8 +23,8 @@ class DummyExp(ExperimentBase): def __init__(self, name='e1'): super().__init__(name=name, type=DummyType()) - def _load_ascii_data_to_experiment(self, data_path: str) -> None: - pass + def _load_ascii_data_to_experiment(self, data_path: str) -> int: + return 0 exps = Experiments() exps.add(DummyExp('a')) diff --git a/tests/unit/easydiffraction/project/test_project.py b/tests/unit/easydiffraction/project/test_project.py index c6a64055..aedd24a8 100644 --- a/tests/unit/easydiffraction/project/test_project.py +++ b/tests/unit/easydiffraction/project/test_project.py @@ -20,3 +20,32 @@ def test_project_help(capsys): assert 'experiments' in out assert 'analysis' in out assert 'summary' in out + + +def test_project_verbosity_default(): + from easydiffraction.project.project import Project + + p = Project() + assert p.verbosity == 'full' + + +def test_project_verbosity_setter(): + from easydiffraction.project.project import Project + + p = Project() + p.verbosity = 'short' + assert p.verbosity == 'short' + p.verbosity = 'silent' + assert p.verbosity == 'silent' + p.verbosity = 'full' + assert p.verbosity == 'full' + + +def test_project_verbosity_invalid(): + import pytest + + from easydiffraction.project.project import Project + + p = Project() + with pytest.raises(ValueError): + p.verbosity = 'verbose' diff --git a/tests/unit/easydiffraction/utils/test_enums.py b/tests/unit/easydiffraction/utils/test_enums.py new file mode 100644 index 00000000..9d970540 --- /dev/null +++ b/tests/unit/easydiffraction/utils/test_enums.py @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +import pytest + +from easydiffraction.utils.enums import VerbosityEnum + + +def test_verbosity_enum_members(): + assert VerbosityEnum.FULL == 'full' + assert VerbosityEnum.SHORT == 'short' + assert VerbosityEnum.SILENT == 'silent' + + +def test_verbosity_enum_from_string(): + assert VerbosityEnum('full') is VerbosityEnum.FULL + assert VerbosityEnum('short') is VerbosityEnum.SHORT + assert VerbosityEnum('silent') is VerbosityEnum.SILENT + + +def test_verbosity_enum_invalid_string(): + with pytest.raises(ValueError): + VerbosityEnum('verbose') + + +def test_verbosity_enum_default(): + assert VerbosityEnum.default() is VerbosityEnum.FULL