diff --git a/docs/dev/implementation-plans/progress-activity-indicator.md b/docs/dev/implementation-plans/progress-activity-indicator.md deleted file mode 100644 index e776e0127..000000000 --- a/docs/dev/implementation-plans/progress-activity-indicator.md +++ /dev/null @@ -1,407 +0,0 @@ -# Progress Activity Indicator Implementation Plan - -**Status:** Proposed -**Date:** 2026-05-14 - -## Goal - -Add a small activity indicator for fitting and other long-running -calculations. The indicator should read as the same feature in terminal -and Jupyter, while using environment-appropriate rendering underneath. - -This is an activity indicator, not a numeric progress bar. Most -deterministic minimizers do not expose a reliable total work estimate, -and the existing fit progress table updates only when meaningful fit -state changes. A spinner-style indicator communicates that work is -continuing without implying a percentage that may be unavailable. - -## User-Facing Behavior - -### Visibility - -The indicator is controlled by existing verbosity: - -| Verbosity | Behavior | -| --------- | ---------------------------------------------------------------------------------------------------- | -| `silent` | Show nothing. No table, no activity indicator, no status line. | -| `short` | Show the activity indicator, but not the detailed fit progress table. Keep existing short summaries. | -| `full` | Show the detailed progress table and the activity indicator below it. | - -This changes the current fit tracker behavior where `short` returns -before creating any live progress output. - -### Labels - -Use the following labels: - -| Work type | Label | -| --------------------------------- | ------------ | -| Deterministic single fit | `fitting` | -| Sequential fit | `fitting` | -| Bayesian DREAM burn phase | `burn-in` | -| Bayesian DREAM sampling phase | `sampling` | -| Posterior predictive plots/checks | `processing` | -| Posterior pair plots | `processing` | -| Other long calculations | `processing` | - -The label must be updateable while work is running. DREAM should switch -from `burn-in` to `sampling` when sampler progress reports the phase -change. - -### Visual Style - -Use compact Unicode spinner frames: - -```text -⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ -``` - -Examples: - -```text -⠋ fitting -⠴ burn-in -⠇ sampling -⠙ processing -``` - -The indicator should be a single line. In `full` mode it appears below -the progress table. In `short` mode it appears as the only live progress -element. - -## Current Code Paths - -### Fit Progress - -The single-fit and sampler progress lifecycle is owned by -`src/easydiffraction/analysis/fit_helpers/tracking.py`. - -Important methods: - -- `FitProgressTracker.start_tracking(...)` -- `FitProgressTracker.add_tracking_info(...)` -- `FitProgressTracker.track_sampler_progress(...)` -- `FitProgressTracker.finish_tracking(...)` -- `_make_display_handle()` -- `_TerminalLiveHandle` - -The existing table update path uses `render_table(...)`, which delegates -to `TableRenderer` and then to either: - -- `PandasTableBackend` in Jupyter -- `RichTableBackend` in terminal - -### Sequential Fit - -Sequential fitting currently has separate progress output in -`src/easydiffraction/analysis/sequential.py`. - -Important functions: - -- `fit_sequential(...)` -- `_run_fit_loop(...)` -- `_report_chunk_progress(...)` - -Sequential fit should reuse the same activity indicator abstraction -rather than adding a separate spinner implementation. - -### Posterior And Other Long Calculations - -Posterior display work is routed through: - -- `src/easydiffraction/project/display.py` -- `src/easydiffraction/display/plotting.py` - -Important entry points include: - -- `PosteriorDisplay.pairs(...)` -- `PosteriorDisplay.predictive(...)` -- `Plotter.plot_posterior_pairs(...)` -- `Plotter.plot_posterior_predictive(...)` - -These should use the generic `processing` label if an activity indicator -is added to those paths. - -## Architecture - -### Add A Shared Activity Indicator - -Add a small shared display helper, preferably: - -```text -src/easydiffraction/display/progress.py -``` - -Recommended public/internal shape: - -```python -class ActivityIndicator: - def __init__(self, label: str = "processing", *, verbosity: VerbosityEnum) -> None: ... - def start(self) -> None: ... - def update(self, *, label: str | None = None, content: object | None = None) -> None: ... - def stop(self, *, final_label: str | None = None) -> None: ... -``` - -The exact class name can change during implementation, but it should -provide these capabilities: - -- no output in `silent` -- live output in `short` and `full` -- label updates while running -- optional table/content rendering above the indicator in `full` -- terminal and Jupyter implementations behind one API -- safe cleanup on exceptions - -### Terminal Rendering - -Use Rich for terminal rendering. - -Preferred implementation: - -- keep using `rich.live.Live` -- render a `rich.console.Group` -- group content should be: - - the progress table renderable, when present - - the activity indicator line - -The indicator line can be either: - -- Rich's built-in `Spinner`, if it works cleanly inside the existing - `Live` setup, or -- a local unicode-frame renderable driven by the same frame list. - -Do not create a second independent `Live` instance for the same output -area. The table and spinner should be refreshed together through one -live handle. - -### Jupyter Rendering - -Use an IPython `DisplayHandle` and HTML. - -The Jupyter spinner should be browser-driven CSS animation, not a -Python-loop animation. This matters because Python may not regain -control during expensive calculations, but CSS keeps animating once the -HTML has been displayed. - -Recommended HTML structure: - -```html -
- - fitting -
-``` - -The table HTML and spinner HTML can be updated together in the same -display handle, or the spinner can have its own display handle below the -table. Prefer a single display handle if it keeps table-and-spinner -replacement simpler and avoids duplicated output cells. - -### Table Rendering Refactor - -The existing table backends mostly print/update directly. For a clean -combined table-plus-spinner render, add a way to build table renderables -without immediately displaying them. - -Possible approach: - -1. Keep `render_table(...)` working for existing callers. -2. Add a backend method that returns a renderable representation: - - Rich: return `rich.table.Table` - - Pandas: return HTML from `Styler.to_html()` -3. Let the activity indicator compose that renderable with the spinner. - -This avoids hard-coding table internals in the tracker and keeps normal -table rendering backwards compatible. - -## Fit Tracker Integration - -### State - -Add fields to `FitProgressTracker`: - -- `_activity_indicator` -- `_activity_label` - -The label is derived from tracking mode: - -- fit mode -> `fitting` -- sampler mode -> initial label from sampler phase if known, otherwise - `sampling` - -### `start_tracking(...)` - -Update behavior: - -1. Set tracking mode. -2. Return immediately only for `silent`. -3. Print the existing start/header messages only where appropriate: - - keep current full messages - - keep short mode concise -4. Create the activity indicator for `short` and `full`. -5. In `full`, render the initial empty progress table plus the - indicator. -6. In `short`, render only the indicator. - -### `add_tracking_info(...)` - -Update behavior: - -1. Always store row state for `full` mode. -2. In `full`, refresh the table plus the indicator. -3. In `short`, do not render the table; keep the indicator running. - -### `track_sampler_progress(...)` - -Update the activity label from `SamplerProgressUpdate.phase`: - -- phase `burn-in` -> label `burn-in` -- phase `sampling` -> label `sampling` -- any other phase -> normalized phase string if user-facing, otherwise - `processing` - -The existing sampler table's `phase` column remains unchanged. - -### `finish_tracking(...)` - -Update behavior: - -1. Finalize the last table row as today. -2. Stop the activity indicator for `short` and `full`. -3. In `full`, print the current completion summary. -4. In `short`, keep or add only a concise completion line if the current - short behavior expects one. -5. In `silent`, print nothing. - -Use `try/finally` in minimizer execution paths so the indicator is -stopped when a solver raises. - -## Sequential Fit Integration - -Sequential fit should use the same `ActivityIndicator`. - -Recommended behavior: - -- `silent`: no output. -- `short`: show one activity indicator labelled `fitting`; keep concise - chunk summaries when chunks finish. -- `full`: show one activity indicator labelled `fitting`; keep detailed - chunk summaries. - -Implementation points: - -1. Create the indicator in `fit_sequential(...)` after preflight checks - and before `_run_fit_loop(...)`. -2. Pass it into `_run_fit_loop(...)`, or wrap `_run_fit_loop(...)` in a - context manager. -3. Update the indicator content after each chunk if the implementation - supports content text, for example: - - ```text - ⠼ fitting chunk 3/20 - ``` - -4. Stop the indicator in a `finally` block before printing final - completion output. - -Do not duplicate spinner frame logic in `sequential.py`. - -## Posterior And Generic Processing Integration - -The first implementation can focus on fitting and sequential fitting. -After that, add a small context helper for generic long calculations: - -```python -with activity_indicator("processing", verbosity=VerbosityEnum(project.verbosity)): - ... -``` - -Use this for: - -- posterior predictive summary generation -- posterior pair plot construction when sample thinning, density grids, - or figure construction take noticeable time -- any future calculation where total progress is unknown - -The generic helper should default to `processing`. - -## Testing Plan - -### Unit Tests - -Add tests for the shared progress helper: - -- `silent` does not create display handles or print output. -- `short` starts the indicator. -- `full` can compose content plus indicator. -- label updates replace the visible label. -- `stop()` suppresses cleanup errors. - -Extend tracker tests: - -- `FitProgressTracker.start_tracking(...)` starts the indicator in - `short`. -- `silent` still shows nothing. -- full fit mode uses label `fitting`. -- sampler updates switch labels from `burn-in` to `sampling`. -- finalization stops the indicator on success. -- finalization stops the indicator when solver preparation or solver - execution raises. - -Extend sequential tests: - -- `fit_sequential(..., verbosity="short")` starts and stops the shared - indicator. -- `fit_sequential(..., verbosity="silent")` does not start it. -- chunk progress does not create a separate spinner. - -### Rendering Tests - -Terminal: - -- fake or monkeypatch `Live` and assert one live handle receives grouped - table-plus-indicator content. - -Jupyter: - -- fake `DisplayHandle` and assert generated HTML contains the activity - container and the selected label. -- assert CSS animation is included once, not duplicated on every table - update if avoidable. - -### Regression Tests - -Keep existing tests passing: - -- `tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py` -- `tests/integration/fitting/test_bayesian_tracker_and_base.py` -- sequential tests under `tests/integration/fitting/test_sequential.py` - -## Implementation Sequence - -1. Add `display/progress.py` with a minimal `ActivityIndicator`. -2. Add tests for verbosity behavior and label updates. -3. Refactor table rendering just enough to allow Rich renderable and - Jupyter HTML composition. -4. Wire `FitProgressTracker` to the activity indicator. -5. Update tracker tests for `short`, `full`, `silent`, and sampler phase - labels. -6. Wire sequential fitting to the shared activity indicator. -7. Update sequential tests. -8. Add generic `processing` context helper. -9. Add the helper to posterior predictive and posterior pairs if - profiling or user feedback shows those operations need visible - activity feedback. -10. Run focused unit tests, then the relevant integration tests. - -## Open Design Checks - -- Whether `short` mode should print the existing start line before the - spinner or show only the spinner until completion. -- Whether terminal output should use Rich's built-in `Spinner` or the - explicit EasyDiffraction frame list. Prefer the explicit list if - consistency with Jupyter matters more than Rich defaults. -- Whether the table and spinner should share one Jupyter display handle. - Prefer one handle unless it complicates the existing pandas backend. -- Whether generic display/plot operations need a public verbosity - argument, or should only read `project.verbosity`. diff --git a/docs/dev/package-structure-full.md b/docs/dev/package-structure-full.md index e71f099de..dccf421a4 100644 --- a/docs/dev/package-structure-full.md +++ b/docs/dev/package-structure-full.md @@ -59,7 +59,6 @@ │ │ │ └── 🏷️ class FitResults │ │ └── 📄 tracking.py │ │ ├── 🏷️ class SamplerProgressUpdate -│ │ ├── 🏷️ class _TerminalLiveHandle │ │ └── 🏷️ class FitProgressTracker │ ├── 📁 minimizers │ │ ├── 📄 __init__.py @@ -385,6 +384,10 @@ │ │ ├── 🏷️ class _PosteriorPairsLegendState │ │ ├── 🏷️ class Plotter │ │ └── 🏷️ class PlotterFactory +│ ├── 📄 progress.py +│ │ ├── 🏷️ class _TerminalLiveHandle +│ │ ├── 🏷️ class ActivityIndicator +│ │ └── 🏷️ class _ActivityIndicatorContext │ ├── 📄 tables.py │ │ ├── 🏷️ class TableEngineEnum │ │ ├── 🏷️ class TableRenderer diff --git a/docs/dev/package-structure-short.md b/docs/dev/package-structure-short.md index 573142dae..b4e46ec73 100644 --- a/docs/dev/package-structure-short.md +++ b/docs/dev/package-structure-short.md @@ -186,6 +186,7 @@ │ ├── 📄 __init__.py │ ├── 📄 base.py │ ├── 📄 plotting.py +│ ├── 📄 progress.py │ ├── 📄 tables.py │ └── 📄 utils.py ├── 📁 io diff --git a/docs/docs/tutorials/ed-21.ipynb b/docs/docs/tutorials/ed-21.ipynb index 33f77196f..f0d5123d9 100644 --- a/docs/docs/tutorials/ed-21.ipynb +++ b/docs/docs/tutorials/ed-21.ipynb @@ -734,11 +734,7 @@ "metadata": {}, "outputs": [], "source": [ - "project.display.posterior.predictive(\n", - " expt_name='hrpt',\n", - " x_min=92,\n", - " x_max=93,\n", - ")" + "project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93)" ] } ], diff --git a/docs/docs/tutorials/ed-21.py b/docs/docs/tutorials/ed-21.py index e890f55bc..7c6f9bdb5 100644 --- a/docs/docs/tutorials/ed-21.py +++ b/docs/docs/tutorials/ed-21.py @@ -347,8 +347,4 @@ # after the Bayesian run. # %% -project.display.posterior.predictive( - expt_name='hrpt', - x_min=92, - x_max=93, -) +project.display.posterior.predictive(expt_name='hrpt', x_min=92, x_max=93) diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 484c3e03d..a2a4f7dee 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -14,12 +14,12 @@ from easydiffraction.analysis.categories.fit import FitFactory from easydiffraction.analysis.categories.fit import FitModeEnum from easydiffraction.analysis.categories.joint_fit_experiments import JointFitExperiments -from easydiffraction.analysis.fit_helpers.tracking import _make_display_handle from easydiffraction.analysis.fitting import Fitter from easydiffraction.core.singleton import ConstraintsHandler from easydiffraction.core.variable import NumericDescriptor from easydiffraction.core.variable import Parameter from easydiffraction.core.variable import StringDescriptor +from easydiffraction.display.progress import make_display_handle from easydiffraction.display.tables import TableRenderer from easydiffraction.io.cif.serialize import analysis_to_cif from easydiffraction.utils.enums import VerbosityEnum @@ -614,37 +614,43 @@ def _fit_single( short_display_handle = self._fit_single_print_header(verb, expt_names, mode) short_rows: list[list[str]] = [] + self.fitter.minimizer.tracker._set_shared_display_handle(short_display_handle) - for expt_name in expt_names: - if verb is VerbosityEnum.FULL: - console.print(f"📋 Using experiment 🔬 '{expt_name}' for '{mode.value}' fitting") + try: + for expt_name in expt_names: + if verb is VerbosityEnum.FULL: + console.print( + f"📋 Using experiment 🔬 '{expt_name}' for '{mode.value}' fitting" + ) - experiment = experiments[expt_name] - self.fitter.fit( - structures, - [experiment], - analysis=self, - verbosity=verb, - use_physical_limits=use_physical_limits, - random_seed=random_seed, - ) + experiment = experiments[expt_name] + self.fitter.fit( + structures, + [experiment], + analysis=self, + verbosity=verb, + use_physical_limits=use_physical_limits, + random_seed=random_seed, + ) - # After fitting, snapshot parameter values before - # they get overwritten by the next experiment's fit - results = self.fitter.results - self._snapshot_params(expt_name, results) - self.fit_results = results + # After fitting, snapshot parameter values before + # they get overwritten by the next experiment's fit + results = self.fitter.results + self._snapshot_params(expt_name, results) + self.fit_results = results - # Short mode: append one summary row and update in-place - if verb is VerbosityEnum.SHORT: - self._fit_single_update_short_table( - short_rows, expt_name, results, short_display_handle - ) + # Short mode: append one summary row and update in-place + if verb is VerbosityEnum.SHORT: + self._fit_single_update_short_table( + short_rows, expt_name, results, short_display_handle + ) + finally: + self.fitter.minimizer.tracker._set_shared_display_handle(None) - # Short mode: close the display handle - if short_display_handle is not None and hasattr(short_display_handle, 'close'): - with suppress(Exception): - short_display_handle.close() + # Short mode: close the display handle + if short_display_handle is not None and hasattr(short_display_handle, 'close'): + with suppress(Exception): + short_display_handle.close() @staticmethod def _fit_single_print_header( @@ -680,7 +686,7 @@ def _fit_single_print_header( ) console.print("🚀 Starting fit process with 'lmfit'...") console.print('📈 Goodness-of-fit (reduced χ²) per experiment:') - return _make_display_handle() + return make_display_handle() def _snapshot_params(self, expt_name: str, results: object) -> None: """ diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index b5cfb11a2..0915ae929 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -1,35 +1,29 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + +import sys import time -from contextlib import suppress from dataclasses import dataclass - -import numpy as np - -from easydiffraction.utils.logging import console - -try: - from IPython.display import HTML - from IPython.display import DisplayHandle - from IPython.display import display -except ImportError: - display = None - clear_output = None +from typing import TYPE_CHECKING from easydiffraction.analysis.fit_helpers.metrics import calculate_reduced_chi_square +from easydiffraction.display.progress import ACTIVITY_LABEL_BURN_IN +from easydiffraction.display.progress import ACTIVITY_LABEL_FITTING +from easydiffraction.display.progress import ACTIVITY_LABEL_SAMPLING +from easydiffraction.display.progress import ActivityIndicator +from easydiffraction.display.progress import _TerminalLiveHandle as _SharedTerminalLiveHandle +from easydiffraction.display.progress import make_display_handle from easydiffraction.utils.enums import VerbosityEnum -from easydiffraction.utils.environment import in_jupyter -from easydiffraction.utils.utils import render_table - -try: - from rich.live import Live -except ImportError: # pragma: no cover - rich always available in app env - Live = None # type: ignore[assignment] +from easydiffraction.utils.logging import console +from easydiffraction.utils.utils import build_table_renderable -from easydiffraction.utils.logging import ConsoleManager +if TYPE_CHECKING: + import numpy as np SIGNIFICANT_CHANGE_THRESHOLD = 0.01 # 1% threshold +FIT_PROGRESS_UPDATE_SECONDS = 5.0 SAMPLER_PROGRESS_UPDATE_SECONDS = 5.0 TRACKING_MODE_FIT = 'fit' TRACKING_MODE_SAMPLER = 'sampling' @@ -38,6 +32,13 @@ SAMPLER_HEADERS = ['iteration', 'progress', 'time (s)', 'log posterior', 'phase'] SAMPLER_ALIGNMENTS = ['center', 'center', 'center', 'center', 'center'] +_TerminalLiveHandle = _SharedTerminalLiveHandle + + +def _make_display_handle() -> object | None: + """Return a backward-compatible generic live display handle.""" + return make_display_handle() + @dataclass(frozen=True, slots=True) class SamplerProgressUpdate: @@ -55,56 +56,6 @@ class SamplerProgressUpdate: force_report: bool = False -class _TerminalLiveHandle: - """ - Adapter that exposes update()/close() for terminal live updates. - - Wraps a rich.live.Live instance but keeps the tracker decoupled from - the underlying UI mechanism. - """ - - def __init__(self, live: object) -> None: - self._live = live - - def update(self, renderable: object) -> None: - """ - Refresh the live display with a new renderable. - - Parameters - ---------- - renderable : object - A Rich-compatible renderable to display. - """ - self._live.update(renderable, refresh=True) - - def close(self) -> None: - """Stop the live display, suppressing any errors.""" - with suppress(Exception): - self._live.stop() - - -def _make_display_handle() -> object | None: - """ - Create and initialize a display/update handle for the environment. - - - In Jupyter, returns an IPython DisplayHandle and creates a - placeholder. - In terminal, returns a _TerminalLiveHandle backed by - rich Live. - If neither applies, returns None. - """ - if in_jupyter() and display is not None and HTML is not None: - h = DisplayHandle() - # Create an empty placeholder area to update in place - h.display(HTML('')) - return h - if Live is not None: - # Reuse the shared Console to coordinate with logging output - # and keep consistent width - live = Live(console=ConsoleManager.get(), auto_refresh=True) - live.start() - return _TerminalLiveHandle(live) - return None - - class FitProgressTracker: """ Track and report reduced chi-square during optimization. @@ -135,11 +86,13 @@ def __init__(self) -> None: self._last_sampler_elapsed_time: float | None = None self._df_rows: list[list[str]] = [] - self._display_handle: object | None = None - self._live: object | None = None + self._activity_indicator: ActivityIndicator | None = None + self._activity_label: str = ACTIVITY_LABEL_FITTING + self._shared_display_handle: object | None = None def reset(self) -> None: """Reset internal state before a new optimization run.""" + self._stop_activity_indicator() self._iteration = 0 self._previous_chi2 = None self._last_chi2 = None @@ -157,6 +110,8 @@ def reset(self) -> None: self._last_sampler_progress_percent = None self._last_sampler_log_posterior = None self._last_sampler_elapsed_time = None + self._df_rows = [] + self._activity_label = ACTIVITY_LABEL_FITTING def track( self, @@ -193,47 +148,51 @@ def track( return residuals row: list[str] = [] + elapsed_time = self._current_elapsed_time() - # First iteration, initialize tracking if self._previous_chi2 is None: self._previous_chi2 = reduced_chi2 self._best_chi2 = reduced_chi2 self._best_iteration = self._iteration + self._last_progress_time = elapsed_time row = [ str(self._iteration), - self._format_elapsed_time(), + self._format_elapsed_time(elapsed_time), f'{reduced_chi2:.2f}', '', ] - - # Subsequent iterations, check for significant changes else: change = (self._previous_chi2 - reduced_chi2) / self._previous_chi2 - # Improvement check if change > SIGNIFICANT_CHANGE_THRESHOLD: change_in_percent = change * 100 row = [ str(self._iteration), - self._format_elapsed_time(), + self._format_elapsed_time(elapsed_time), f'{reduced_chi2:.2f}', f'{change_in_percent:.1f}% ↓', ] self._previous_chi2 = reduced_chi2 + self._last_progress_time = elapsed_time + elif self._should_render_fit_row(elapsed_time): + row = [ + str(self._iteration), + self._format_elapsed_time(elapsed_time), + f'{reduced_chi2:.2f}', + '', + ] + self._last_progress_time = elapsed_time - # Output if there is something new to display if row: self.add_tracking_info(row) - # Update best chi-square if better if self._best_chi2 is None or reduced_chi2 < self._best_chi2: self._best_chi2 = reduced_chi2 self._best_iteration = self._iteration - # Store last chi-square and iteration self._last_chi2 = reduced_chi2 self._last_iteration = self._iteration @@ -259,6 +218,7 @@ def track_sampler_progress(self, update: SamplerProgressUpdate) -> None: self._last_sampler_progress_percent = clamped_progress self._last_sampler_log_posterior = update.log_posterior self._last_sampler_elapsed_time = update.elapsed_time + self._set_activity_label(self._activity_label_for_sampler_phase(update.phase)) row = self._initial_sampler_progress_row( update=update, @@ -326,29 +286,20 @@ def start_tracking(self, minimizer_name: str, *, mode: str = TRACKING_MODE_FIT) self._tracking_mode = ( TRACKING_MODE_SAMPLER if mode == TRACKING_MODE_SAMPLER else TRACKING_MODE_FIT ) + self._df_rows = [] + self._activity_label = self._default_activity_label() if self._verbosity is VerbosityEnum.SILENT: return - if self._verbosity is VerbosityEnum.SHORT: - return - console.print(f"🚀 Starting fit process with '{minimizer_name}'...") - if self._tracking_mode == TRACKING_MODE_SAMPLER: - console.print('📈 Bayesian sampling progress:') - else: - console.print('📈 Goodness-of-fit progress:') - - # Reset rows and create an environment-appropriate handle - self._df_rows = [] - self._display_handle = _make_display_handle() + if self._verbosity is VerbosityEnum.FULL: + console.print(f"🚀 Starting fit process with '{minimizer_name}'...") + if self._tracking_mode == TRACKING_MODE_SAMPLER: + console.print('📈 Bayesian sampling progress:') + else: + console.print('📈 Goodness-of-fit progress:') - # Initial empty table; subsequent updates will reuse the handle - render_table( - columns_headers=self._headers(), - columns_alignment=self._alignments(), - columns_data=self._df_rows, - display_handle=self._display_handle, - ) + self._start_activity_indicator() def add_tracking_info(self, row: list[str]) -> None: """ @@ -364,16 +315,8 @@ def add_tracking_info(self, row: list[str]) -> None: if iteration_cell.isdigit(): self._last_reported_iteration = int(iteration_cell) self._df_rows.append(row) - if self._verbosity is not VerbosityEnum.FULL: - return - # Append and update via the active handle (Jupyter or - # terminal live) - render_table( - columns_headers=self._headers(), - columns_alignment=self._alignments(), - columns_data=self._df_rows, - display_handle=self._display_handle, - ) + if self._verbosity is VerbosityEnum.FULL: + self._refresh_activity_indicator() def finish_tracking(self) -> None: """Finalize progress display and print best result summary.""" @@ -382,11 +325,19 @@ def finish_tracking(self) -> None: else: self._finalize_fit_tracking_row() - if self._verbosity is not VerbosityEnum.FULL: + if self._verbosity is VerbosityEnum.SILENT: return - self._close_display_handle() - self._print_completion_summary() + self._stop_activity_indicator() + if self._verbosity is VerbosityEnum.FULL and not self._cleanup_during_exception(): + self._print_completion_summary() + + @staticmethod + def _cleanup_during_exception() -> bool: + """ + Return whether cleanup runs during exception handling. + """ + return sys.exc_info()[0] is not None def _initial_sampler_progress_row( self, @@ -511,7 +462,7 @@ def _final_sampler_tracking_row(self) -> list[str] | None: f'{final_progress:.1f}%', self._format_elapsed_time(elapsed_time), log_posterior, - self._last_sampler_phase or 'sampling', + self._last_sampler_phase or TRACKING_MODE_SAMPLER, ] def _finalize_fit_tracking_row(self) -> None: @@ -566,16 +517,14 @@ def _sampler_iteration_label(self, iteration: int) -> str: clamped_iteration = min(iteration, self._sampler_total_iterations) return f'{clamped_iteration}/{self._sampler_total_iterations}' - def _close_display_handle(self) -> None: - if self._display_handle is not None and hasattr(self._display_handle, 'close'): - with suppress(Exception): - self._display_handle.close() - def _print_completion_summary(self) -> None: if self._tracking_mode == TRACKING_MODE_SAMPLER: console.print('✅ Bayesian sampling complete.') return + if self._best_chi2 is None or self._best_iteration is None: + return + console.print( f'🏆 Best goodness-of-fit (reduced χ²) is {self._best_chi2:.2f} ' f'at iteration {self._best_iteration}' @@ -611,6 +560,11 @@ def _format_elapsed_time(self, elapsed_time: float | None = None) -> str: return '' return f'{resolved_time:.2f}' + def _should_render_fit_row(self, elapsed_time: float | None) -> bool: + if elapsed_time is None or self._last_progress_time is None: + return False + return elapsed_time - self._last_progress_time >= FIT_PROGRESS_UPDATE_SECONDS + @staticmethod def _rows_match_on_columns( current_row: list[str], @@ -636,12 +590,67 @@ def _replace_last_tracking_row(self, row: list[str]) -> None: return self._df_rows[-1] = row - if self._verbosity is not VerbosityEnum.FULL: + if self._verbosity is VerbosityEnum.FULL: + self._refresh_activity_indicator() + + def _default_activity_label(self) -> str: + if self._tracking_mode == TRACKING_MODE_SAMPLER: + return ACTIVITY_LABEL_SAMPLING + return ACTIVITY_LABEL_FITTING + + @staticmethod + def _activity_label_for_sampler_phase(phase: str) -> str: + normalized_phase = phase.strip().lower() + if normalized_phase == 'burn-in': + return ACTIVITY_LABEL_BURN_IN + if normalized_phase == 'sampling': + return ACTIVITY_LABEL_SAMPLING + if normalized_phase: + return normalized_phase + return ACTIVITY_LABEL_SAMPLING + + def _set_shared_display_handle(self, display_handle: object | None) -> None: + self._shared_display_handle = display_handle + + def _start_activity_indicator(self) -> None: + self._activity_indicator = ActivityIndicator( + self._activity_label, + verbosity=self._verbosity, + display_handle=self._shared_display_handle, + ) + self._activity_indicator.start() + self._refresh_activity_indicator() + + def _stop_activity_indicator(self) -> None: + if self._activity_indicator is None: return - render_table( + self._activity_indicator.stop() + self._activity_indicator = None + + def _set_activity_label(self, label: str) -> None: + if label == self._activity_label: + return + + self._activity_label = label + self._refresh_activity_indicator() + + def _refresh_activity_indicator(self) -> None: + if self._activity_indicator is None: + return + + if self._verbosity is VerbosityEnum.FULL: + self._activity_indicator.update( + label=self._activity_label, + content=self._table_renderable(), + ) + return + + self._activity_indicator.update(label=self._activity_label) + + def _table_renderable(self) -> object: + return build_table_renderable( columns_headers=self._headers(), columns_alignment=self._alignments(), columns_data=self._df_rows, - display_handle=self._display_handle, ) diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py index 25feb184e..a52d38b1a 100644 --- a/src/easydiffraction/analysis/sequential.py +++ b/src/easydiffraction/analysis/sequential.py @@ -17,6 +17,8 @@ from typing import TYPE_CHECKING from typing import Any +from easydiffraction.display.progress import ACTIVITY_LABEL_FITTING +from easydiffraction.display.progress import ActivityIndicator from easydiffraction.io.ascii import extract_data_paths_from_dir from easydiffraction.utils.enums import VerbosityEnum from easydiffraction.utils.logging import console @@ -529,9 +531,11 @@ def _report_chunk_progress( if verbosity is VerbosityEnum.SHORT: status = '✅' if successful else '❌' - print(f'{status} Chunk {chunk_idx}/{total_chunks}: {num_files} files, avg χ² = {chi2_str}') + console.print( + f'{status} Chunk {chunk_idx}/{total_chunks}: {num_files} files, avg χ² = {chi2_str}' + ) elif verbosity is VerbosityEnum.FULL: - print( + console.print( f'Chunk {chunk_idx}/{total_chunks}: ' f'{num_files} files, {len(successful)} succeeded, ' f'avg reduced χ² = {chi2_str}' @@ -540,7 +544,7 @@ def _report_chunk_progress( status = '✅' if r.get('fit_success') else '❌' rchi2 = r.get('reduced_chi_squared') rchi2_str = f'{rchi2:.2f}' if rchi2 is not None else '—' - print(f' {status} {Path(r["file_path"]).name}: χ² = {rchi2_str}') + console.print(f' {status} {Path(r["file_path"]).name}: χ² = {rchi2_str}') def _apply_diffrn_metadata( @@ -653,7 +657,7 @@ def _setup_csv_and_recovery( num_skipped = len(already_fitted) log.info(f'Resuming: {num_skipped} files already fitted, skipping.') if verb is not VerbosityEnum.SILENT: - print(f'📂 Resuming from CSV: {num_skipped} files already fitted.') + console.print(f'📂 Resuming from CSV: {num_skipped} files already fitted.') if recovered_params is not None: template = replace(template, initial_params=recovered_params) else: @@ -755,6 +759,7 @@ def _run_fit_loop( csv_info: tuple[Path, list[str]], extract_diffrn: Callable | None, verb: VerbosityEnum, + indicator: ActivityIndicator | None, ) -> None: """ Execute the chunk-based fitting loop. @@ -773,6 +778,8 @@ def _run_fit_loop( User callback for diffrn metadata. verb : VerbosityEnum Output verbosity. + indicator : ActivityIndicator | None + Shared sequential-fit activity indicator. """ csv_path, header = csv_info total_chunks = len(chunks) @@ -789,6 +796,8 @@ def _run_fit_loop( _append_to_csv(csv_path, header, results) _report_chunk_progress(chunk_idx, total_chunks, results, verb) + if indicator is not None: + indicator.update() # Propagate last successful params last_ok = _find_last_successful(results) @@ -841,16 +850,15 @@ def fit_sequential( if mp.parent_process() is not None: return - project = analysis.project - verb = VerbosityEnum(project.verbosity) + verb = VerbosityEnum(analysis.project.verbosity) - _check_seq_preconditions(project) + _check_seq_preconditions(analysis.project) data_paths = extract_data_paths_from_dir(data_dir, file_pattern=file_pattern) - template = _build_template(project) + template = _build_template(analysis.project) csv_path, header, already_fitted, template = _setup_csv_and_recovery( - project, + analysis.project, template, verb, ) @@ -860,7 +868,7 @@ def fit_sequential( remaining.reverse() if not remaining: if verb is not VerbosityEnum.SILENT: - print('✅ All files already fitted. Nothing to do.') + console.print('✅ All files already fitted. Nothing to do.') return max_workers, chunk_size = _resolve_workers(max_workers, chunk_size) @@ -874,15 +882,30 @@ def fit_sequential( ) console.print('📈 Goodness-of-fit (reduced χ²):') + indicator = None + if verb is not VerbosityEnum.SILENT: + indicator = ActivityIndicator(ACTIVITY_LABEL_FITTING, verbosity=verb) + indicator.start() + pool_cm, main_mod, main_file_bak, main_spec_bak = _create_pool_context(max_workers) try: - _run_fit_loop(pool_cm, chunks, template, (csv_path, header), extract_diffrn, verb) + _run_fit_loop( + pool_cm, + chunks, + template, + (csv_path, header), + extract_diffrn, + verb, + indicator, + ) finally: + if indicator is not None: + indicator.stop() _restore_main_state(main_mod, main_file_bak, main_spec_bak) if verb is not VerbosityEnum.SILENT: - print( + console.print( f'✅ Sequential fitting complete: ' f'{len(already_fitted) + len(remaining)} files processed.' ) - print(f'📄 Results saved to: {csv_path}') + console.print(f'📄 Results saved to: {csv_path}') diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index d41c1f9eb..fbbda288f 100644 --- a/src/easydiffraction/display/plotters/plotly.py +++ b/src/easydiffraction/display/plotters/plotly.py @@ -1755,7 +1755,7 @@ def _get_predictive_band_traces( line={'color': PREDICTIVE_BAND_EDGE_COLOR, 'width': 1}, fill='tonexty', fillcolor=PREDICTIVE_BAND_COLOR, - name='95% interval', + name='95% credible interval', hoverinfo='skip', legendgroup='predictive_band', legendrank=35, diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index a258579e8..2ca6dff0f 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -97,12 +97,11 @@ class PosteriorPairPlotStyleEnum(StrEnum): POSTERIOR_HISTOGRAM_FILL_COLOR = 'rgba(120, 120, 120, 0.38)' POSTERIOR_HISTOGRAM_LINE_COLOR = 'rgba(120, 120, 120, 0.24)' POSTERIOR_INTERVAL_95_FILL_COLOR = 'rgba(214, 39, 40, 0.14)' -POSTERIOR_INTERVAL_68_FILL_COLOR = 'rgba(214, 39, 40, 0.26)' POSTERIOR_MEDIAN_LINE_COLOR = 'rgb(80, 80, 80)' POSTERIOR_POINT_ESTIMATE_LINE_COLOR = 'rgb(214, 39, 40)' POSTERIOR_POINT_ESTIMATE_TRACE_NAME = 'Max posterior' POSTERIOR_POINT_ESTIMATE_LINE_DASH = 'dot' -POSTERIOR_PREDICTIVE_INTERVAL_TRACE_NAME = '95% interval' +POSTERIOR_PREDICTIVE_INTERVAL_TRACE_NAME = '95% credible interval' POSTERIOR_DRAW_LINE_COLOR = 'rgba(140, 140, 140, 0.18)' POSTERIOR_SCATTER_MARKER_COLOR = 'rgba(140, 140, 140, 0.20)' POSTERIOR_CONTOUR_FILL_COLORSCALE = [ @@ -1175,7 +1174,7 @@ def _plot_single_crystal_posterior_predictive( if style != 'band': log.warning( 'Single-crystal posterior predictive plots currently support ' - 'style="band" only; rendering the 95% interval.' + 'style="band" only; rendering the 95% credible interval.' ) summary = self._get_or_build_posterior_predictive_summary( @@ -2597,15 +2596,6 @@ def _add_posterior_distribution_interval_traces( color=POSTERIOR_INTERVAL_95_FILL_COLOR, ) ) - fig.add_trace( - self._posterior_interval_band_trace( - x0=summary.interval_68[0], - x1=summary.interval_68[1], - y_axis_range=y_axis_range, - trace_name='68% credible interval', - color=POSTERIOR_INTERVAL_68_FILL_COLOR, - ) - ) @staticmethod def _add_posterior_distribution_histogram( @@ -3614,7 +3604,7 @@ def _plot_single_crystal_posterior_predictive_summary( trace.customdata = np.column_stack((lower_95, upper_95, y_meas_su)) trace.hovertemplate = ( 'Predicted I²: %{x:,.2f}
' - '95% interval: [%{customdata[0]:,.2f}, %{customdata[1]:,.2f}]
' + '95% credible interval: [%{customdata[0]:,.2f}, %{customdata[1]:,.2f}]
' 'Measured I²: %{y:,.2f}
' 'su(I²meas): %{customdata[2]:,.2f}' ) diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py new file mode 100644 index 000000000..1cde4d1c2 --- /dev/null +++ b/src/easydiffraction/display/progress.py @@ -0,0 +1,439 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Environment-aware activity indicator for long-running tasks.""" + +from __future__ import annotations + +import html +from contextlib import AbstractContextManager +from contextlib import suppress +from time import monotonic +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from types import TracebackType + +try: + from IPython.display import HTML + from IPython.display import DisplayHandle +except ImportError: # pragma: no cover - optional dependency + HTML = None + DisplayHandle = None + +from rich.console import Group +from rich.live import Live +from rich.protocol import is_renderable +from rich.text import Text + +from easydiffraction.utils.enums import VerbosityEnum +from easydiffraction.utils.environment import in_jupyter +from easydiffraction.utils.logging import ConsoleManager + +ACTIVITY_LABEL_BURN_IN = 'Burn-in...' +ACTIVITY_LABEL_FITTING = 'Fitting...' +ACTIVITY_LABEL_PROCESSING = 'Processing...' +ACTIVITY_LABEL_SAMPLING = 'Sampling...' +ACTIVITY_ACCENT_COLOR = '#d97706' +ACTIVITY_TERMINAL_STYLE = ACTIVITY_ACCENT_COLOR + +SPINNER_FRAMES: tuple[str, ...] = ( + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏', +) +_SPINNER_FRAME_SECONDS = 0.1 +_JUPYTER_SPINNER_SECONDS = 1.0 + + +class _TerminalLiveHandle: + """ + Adapter exposing update()/close() for terminal live updates. + + Wraps a ``rich.live.Live`` instance so callers can treat terminal + and notebook handles through a single update-oriented interface. + """ + + def __init__(self, *, console: object) -> None: + self._renderable: object = Text('') + self._live = Live( + console=console, + auto_refresh=True, + refresh_per_second=1 / _SPINNER_FRAME_SECONDS, + get_renderable=self._get_renderable, + ) + self._live.start() + + def _get_renderable(self) -> object: + renderable = self._renderable + if callable(renderable): + return renderable() + return renderable + + def update(self, renderable: object) -> None: + """ + Refresh the live display with a new renderable. + + Parameters + ---------- + renderable : object + A Rich-compatible renderable to display. + """ + self._renderable = renderable + self._live.refresh() + + def close(self) -> None: + """Stop the live display, suppressing any errors.""" + with suppress(Exception): + self._live.stop() + + +def make_display_handle() -> object | None: + """ + Create a generic in-place display handle for the active environment. + + Returns + ------- + object | None + An IPython ``DisplayHandle`` in notebooks, a terminal live + handle in the console, or ``None`` if neither is available. + """ + if in_jupyter() and DisplayHandle is not None and HTML is not None: + handle = DisplayHandle() + with suppress(Exception): + handle.display(HTML('')) + return handle + + return _TerminalLiveHandle(console=ConsoleManager.get()) + + +class ActivityIndicator: + """ + Render a live activity indicator for long-running work. + + Parameters + ---------- + label : str, default=ACTIVITY_LABEL_PROCESSING + User-facing activity label. + verbosity : VerbosityEnum + Output verbosity controlling whether live display is shown. + display_handle : object | None, default=None + Optional existing live display handle to reuse. + """ + + def __init__( + self, + label: str = ACTIVITY_LABEL_PROCESSING, + *, + verbosity: VerbosityEnum, + display_handle: object | None = None, + ) -> None: + self._label = label + self._verbosity = verbosity + self._content: object | None = None + self._provided_display_handle = display_handle + self._display_handle: object | None = None + self._live: object | None = None + self._running = False + self._keep_stopped_label = False + self._started_at = monotonic() + + def start(self) -> None: + """ + Start the live activity indicator. + + Returns + ------- + None + Starts live rendering unless verbosity is silent. + """ + if self._verbosity is VerbosityEnum.SILENT or self._running: + return + + self._running = True + self._keep_stopped_label = False + self._started_at = monotonic() + + if self._provided_display_handle is not None: + self._display_handle = self._provided_display_handle + self._refresh() + return + + if in_jupyter() and DisplayHandle is not None and HTML is not None: + handle = DisplayHandle() + self._display_handle = handle + with suppress(Exception): + handle.display(HTML(self._render_html())) + return + + live = Live( + console=ConsoleManager.get(), + auto_refresh=True, + refresh_per_second=1 / _SPINNER_FRAME_SECONDS, + get_renderable=self._terminal_renderable, + ) + live.start() + self._live = live + + def update( + self, + *, + label: str | None = None, + content: object | None = None, + ) -> None: + """ + Refresh the current label and optional rendered content. + + Parameters + ---------- + label : str | None, default=None + Replacement activity label. When ``None``, keep the current + label. + content : object | None, default=None + Optional content rendered above the indicator. When + ``None``, keep the current content. + """ + if label is not None: + self._label = label + if content is not None: + self._content = content + self._refresh() + + def stop(self, *, final_label: str | None = None) -> None: + """ + Stop live rendering and keep optional final content visible. + + Parameters + ---------- + final_label : str | None, default=None + Optional final label to leave in place after stopping. When + omitted, only the current content remains visible. + """ + self._running = False + self._keep_stopped_label = final_label is not None + if final_label is not None: + self._label = final_label + + self._refresh() + + if self._live is not None: + with suppress(Exception): + self._live.stop() + self._live = None + self._display_handle = None + + def _terminal_renderable(self) -> object: + """Return the terminal renderable for the current state.""" + renderables: list[object] = [] + content = self._terminal_content() + if content is not None: + renderables.append(content) + + indicator_line = self._terminal_indicator_line() + if indicator_line is not None: + renderables.append(indicator_line) + + if not renderables: + return Text('') + + if len(renderables) == 1: + return renderables[0] + + return Group(*renderables) + + def _refresh(self) -> None: + if self._verbosity is VerbosityEnum.SILENT: + return + + if self._display_handle is not None: + self._refresh_display_handle() + return + + if self._live is not None: + with suppress(Exception): + self._live.refresh() + + def _refresh_display_handle(self) -> None: + if self._display_handle is None: + return + + if ( + HTML is not None + and DisplayHandle is not None + and isinstance(self._display_handle, DisplayHandle) + ): + with suppress(Exception): + self._display_handle.update(HTML(self._render_html())) + return + + renderable: object = self._terminal_renderable() + if isinstance(self._display_handle, _TerminalLiveHandle): + renderable = self._terminal_renderable + with suppress(Exception): + self._display_handle.update(renderable) + + def _terminal_content(self) -> object | None: + if self._content is None: + return None + if is_renderable(self._content): + return self._content + return Text(str(self._content)) + + def _terminal_indicator_line(self) -> Text | None: + if self._running: + frame = self._current_frame() + return Text(f'{frame} {self._label}', style=ACTIVITY_TERMINAL_STYLE) + if self._keep_stopped_label: + return Text(self._label, style=ACTIVITY_TERMINAL_STYLE) + return None + + def _current_frame(self) -> str: + elapsed = monotonic() - self._started_at + frame_index = int(elapsed / _SPINNER_FRAME_SECONDS) % len(SPINNER_FRAMES) + return SPINNER_FRAMES[frame_index] + + def _render_html(self) -> str: + content_html = self._html_content() + indicator_html = self._html_indicator() + + sections = [section for section in (content_html, indicator_html) if section] + if not sections: + return '' + + body = ''.join(sections) + return f'{self._html_style()}
{body}
' + + def _html_content(self) -> str: + if self._content is None: + return '' + if isinstance(self._content, str): + return self._content + + data = getattr(self._content, 'data', None) + if isinstance(data, str): + return data + + text = html.escape(str(self._content)) + return f'
{text}
' + + def _html_indicator(self) -> str: + safe_label = html.escape(self._label) + + if self._running: + return ( + '
' + '' + f'{safe_label}' + '
' + ) + + if self._keep_stopped_label: + return ( + '
' + f'{safe_label}' + '
' + ) + + return '' + + @staticmethod + def _html_style() -> str: + keyframes = [] + total_frames = len(SPINNER_FRAMES) + for index, frame in enumerate(SPINNER_FRAMES): + percent = int(index * 100 / total_frames) + keyframes.append(f'{percent}% {{ content: "{frame}"; }}') + keyframes.append(f'100% {{ content: "{SPINNER_FRAMES[0]}"; }}') + keyframe_css = ' '.join(keyframes) + + return ( + '' + ) + + +class _ActivityIndicatorContext(AbstractContextManager[ActivityIndicator]): + """Context manager wrapper for ``ActivityIndicator``.""" + + def __init__(self, *, label: str, verbosity: VerbosityEnum) -> None: + self._indicator = ActivityIndicator(label, verbosity=verbosity) + + def __enter__(self) -> ActivityIndicator: + self._indicator.start() + return self._indicator + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type + del exc_value + del traceback + self._indicator.stop() + + +def activity_indicator( + label: str = ACTIVITY_LABEL_PROCESSING, + *, + verbosity: VerbosityEnum, +) -> AbstractContextManager[ActivityIndicator]: + """ + Manage an activity indicator around a block of work. + + Parameters + ---------- + label : str, default=ACTIVITY_LABEL_PROCESSING + User-facing activity label. + verbosity : VerbosityEnum + Output verbosity controlling whether live display is shown. + + Returns + ------- + AbstractContextManager[ActivityIndicator] + Context manager that starts the indicator on entry and stops it + on exit. + """ + return _ActivityIndicatorContext(label=label, verbosity=verbosity) diff --git a/src/easydiffraction/display/tablers/base.py b/src/easydiffraction/display/tablers/base.py index 75922fce3..0bc2bddfb 100644 --- a/src/easydiffraction/display/tablers/base.py +++ b/src/easydiffraction/display/tablers/base.py @@ -98,6 +98,29 @@ def _rich_border_color(self) -> str: def _pandas_border_color(self) -> str: return self._rich_to_hex(self._rich_border_color) + @abstractmethod + def build_renderable( + self, + alignments: object, + df: object, + ) -> object: + """ + Build a backend-native table representation. + + Parameters + ---------- + alignments : object + Iterable of column justifications (e.g., ``'left'`` or + ``'center'``) corresponding to the data columns. + df : object + Index-aware DataFrame with data to render. + + Returns + ------- + object + Backend-native renderable, such as a Rich table or HTML. + """ + @abstractmethod def render( self, diff --git a/src/easydiffraction/display/tablers/pandas.py b/src/easydiffraction/display/tablers/pandas.py index affa3ff8f..823c2cb09 100644 --- a/src/easydiffraction/display/tablers/pandas.py +++ b/src/easydiffraction/display/tablers/pandas.py @@ -241,6 +241,38 @@ def render( object Backend-defined return value (commonly ``None``). """ - color = self._pandas_border_color - styler = self._apply_styling(df, alignments, color) + styler = self._build_styler(alignments, df) self._update_display(styler, display_handle) + + def build_renderable( + self, + alignments: object, + df: object, + ) -> object: + """ + Build notebook HTML for the provided table. + + Parameters + ---------- + alignments : object + Iterable of column justifications (e.g. 'left'). + df : object + Index-aware DataFrame whose index is shown as the first + column. + + Returns + ------- + object + HTML string representation of the styled table. + """ + styler = self._build_styler(alignments, df) + return styler.to_html() + + def _build_styler( + self, + alignments: object, + df: object, + ) -> object: + """Return a configured pandas Styler for the provided table.""" + color = self._pandas_border_color + return self._apply_styling(df, alignments, color) diff --git a/src/easydiffraction/display/tablers/rich.py b/src/easydiffraction/display/tablers/rich.py index 8daafb6ba..e23334be3 100644 --- a/src/easydiffraction/display/tablers/rich.py +++ b/src/easydiffraction/display/tablers/rich.py @@ -66,24 +66,27 @@ def _to_html(table: Table) -> str: "
 Table:
+    def build_renderable(
+        self,
+        alignments: object,
+        df: object,
+    ) -> object:
         """
         Construct a Rich Table with formatted data and alignment.
 
         Parameters
         ----------
-        df : object
-            DataFrame-like object providing rows to render.
         alignments : object
             Iterable of text alignment values for columns.
-        color : str
-            Rich color name used for borders/index style.
+        df : object
+            DataFrame-like object providing rows to render.
 
         Returns
         -------
-        Table
+        object
             A :class:`~rich.table.Table` configured for display.
         """
+        color = self._rich_border_color
         table = Table(
             title=None,
             box=RICH_TABLE_BOX,
@@ -172,6 +175,5 @@ def render(
         object
             Backend-defined return value (commonly ``None``).
         """
-        color = self._rich_border_color
-        table = self._build_table(df, alignments, color)
+        table = self.build_renderable(alignments, df)
         self._update_display(table, display_handle)
diff --git a/src/easydiffraction/display/tables.py b/src/easydiffraction/display/tables.py
index cda6f0d19..574d245a0 100644
--- a/src/easydiffraction/display/tables.py
+++ b/src/easydiffraction/display/tables.py
@@ -75,6 +75,47 @@ def show_config(self) -> None:
         console.paragraph('Current tabler configuration')
         TableRenderer.get().render(df)
 
+    @staticmethod
+    def _prepare_dataframe(df: object) -> tuple[object, object]:
+        """
+        Normalize input table data for backend consumption.
+
+        Parameters
+        ----------
+        df : object
+            DataFrame with a two-level column index where the second
+            level provides per-column alignment.
+
+        Returns
+        -------
+        tuple[object, object]
+            Normalized ``(alignments, dataframe)`` pair.
+        """
+        prepared_df = df.copy()
+        prepared_df.index += 1
+
+        alignments = prepared_df.columns.get_level_values(1)
+        prepared_df.columns = prepared_df.columns.get_level_values(0)
+        return alignments, prepared_df
+
+    def build_renderable(self, df: object) -> object:
+        """
+        Build a backend-native renderable without displaying it.
+
+        Parameters
+        ----------
+        df : object
+            DataFrame with a two-level column index where the second
+            level provides per-column alignment.
+
+        Returns
+        -------
+        object
+            Backend-native renderable, such as a Rich table or HTML.
+        """
+        alignments, prepared_df = self._prepare_dataframe(df)
+        return self._backend.build_renderable(alignments, prepared_df)
+
     def render(self, df: object, display_handle: object | None = None) -> object:
         """
         Render a DataFrame as a table using the active backend.
@@ -94,19 +135,8 @@ def render(self, df: object, display_handle: object | None = None) -> object:
         object
             Backend-specific return value (usually ``None``).
         """
-        # Work on a copy to avoid mutating the original DataFrame
-        df = df.copy()
-
-        # Force starting index from 1
-        df.index += 1
-
-        # Extract column alignments
-        alignments = df.columns.get_level_values(1)
-
-        # Remove alignments from df (Keep only the first index level)
-        df.columns = df.columns.get_level_values(0)
-
-        return self._backend.render(alignments, df, display_handle)
+        alignments, prepared_df = self._prepare_dataframe(df)
+        return self._backend.render(alignments, prepared_df, display_handle)
 
 
 class TableRendererFactory(RendererFactoryBase):
diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py
index 2071463d5..6bab7df3d 100644
--- a/src/easydiffraction/project/display.py
+++ b/src/easydiffraction/project/display.py
@@ -13,6 +13,9 @@
 from easydiffraction.display.plotting import PlotterEngineEnum
 from easydiffraction.display.plotting import PosteriorPairPlotStyleEnum
 from easydiffraction.display.plotting import _MeasVsCalcPlotOptions
+from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING
+from easydiffraction.display.progress import activity_indicator
+from easydiffraction.utils.enums import VerbosityEnum
 from easydiffraction.utils.utils import render_object_help
 from easydiffraction.utils.utils import render_table
 
@@ -128,12 +131,16 @@ def pairs(
         max_parameters: int = 6,
     ) -> None:
         """Plot posterior pair relationships for sampled parameters."""
-        self._project.rendering.plotter.plot_posterior_pairs(
-            parameters=parameters,
-            style=style,
-            threshold=threshold,
-            max_parameters=max_parameters,
-        )
+        with activity_indicator(
+            ACTIVITY_LABEL_PROCESSING,
+            verbosity=VerbosityEnum(self._project.verbosity),
+        ):
+            self._project.rendering.plotter.plot_posterior_pairs(
+                parameters=parameters,
+                style=style,
+                threshold=threshold,
+                max_parameters=max_parameters,
+            )
 
     def distribution(self, param: object) -> None:
         """Plot one sampled parameter's posterior distribution."""
@@ -150,14 +157,18 @@ def predictive(
         x: object | None = None,
     ) -> None:
         """Plot posterior predictive summaries for one experiment."""
-        self._project.rendering.plotter.plot_posterior_predictive(
-            expt_name=expt_name,
-            style=style,
-            x_min=x_min,
-            x_max=x_max,
-            show_residual=show_residual,
-            x=x,
-        )
+        with activity_indicator(
+            ACTIVITY_LABEL_PROCESSING,
+            verbosity=VerbosityEnum(self._project.verbosity),
+        ):
+            self._project.rendering.plotter.plot_posterior_predictive(
+                expt_name=expt_name,
+                style=style,
+                x_min=x_min,
+                x_max=x_max,
+                show_residual=show_residual,
+                x=x,
+            )
 
     def help(self) -> None:
         """Print available posterior-display methods."""
@@ -213,19 +224,23 @@ def pattern(
                 msg = self._status_by_name(statuses, 'auto').reason
                 raise ValueError(msg)
             if 'uncertainty' in auto_include:
-                self._project.rendering.plotter._plot_posterior_predictive_request(
-                    expt_name=expt_name,
-                    style='band',
-                    plot_options=_MeasVsCalcPlotOptions(
-                        x_min=x_min,
-                        x_max=x_max,
-                        show_residual=True if 'residual' in auto_include else None,
-                        show_background='background' in auto_include,
-                        show_bragg='bragg' in auto_include,
-                        show_excluded='excluded' in auto_include,
-                        x=x,
-                    ),
-                )
+                with activity_indicator(
+                    ACTIVITY_LABEL_PROCESSING,
+                    verbosity=VerbosityEnum(self._project.verbosity),
+                ):
+                    self._project.rendering.plotter._plot_posterior_predictive_request(
+                        expt_name=expt_name,
+                        style='band',
+                        plot_options=_MeasVsCalcPlotOptions(
+                            x_min=x_min,
+                            x_max=x_max,
+                            show_residual=True if 'residual' in auto_include else None,
+                            show_background='background' in auto_include,
+                            show_bragg='bragg' in auto_include,
+                            show_excluded='excluded' in auto_include,
+                            x=x,
+                        ),
+                    )
                 return
             self._show_point_estimate_pattern(
                 expt_name=expt_name,
@@ -243,19 +258,23 @@ def pattern(
             raise ValueError(msg)
 
         if 'uncertainty' in normalized_include:
-            self._project.rendering.plotter._plot_posterior_predictive_request(
-                expt_name=expt_name,
-                style='band',
-                plot_options=_MeasVsCalcPlotOptions(
-                    x_min=x_min,
-                    x_max=x_max,
-                    show_residual=True if 'residual' in normalized_include else None,
-                    show_background='background' in normalized_include,
-                    show_bragg='bragg' in normalized_include,
-                    show_excluded='excluded' in normalized_include,
-                    x=x,
-                ),
-            )
+            with activity_indicator(
+                ACTIVITY_LABEL_PROCESSING,
+                verbosity=VerbosityEnum(self._project.verbosity),
+            ):
+                self._project.rendering.plotter._plot_posterior_predictive_request(
+                    expt_name=expt_name,
+                    style='band',
+                    plot_options=_MeasVsCalcPlotOptions(
+                        x_min=x_min,
+                        x_max=x_max,
+                        show_residual=True if 'residual' in normalized_include else None,
+                        show_background='background' in normalized_include,
+                        show_bragg='bragg' in normalized_include,
+                        show_excluded='excluded' in normalized_include,
+                        x=x,
+                    ),
+                )
             return
 
         self._show_point_estimate_pattern(
diff --git a/src/easydiffraction/utils/utils.py b/src/easydiffraction/utils/utils.py
index a81f1cb0b..2fe66e739 100644
--- a/src/easydiffraction/utils/utils.py
+++ b/src/easydiffraction/utils/utils.py
@@ -563,6 +563,38 @@ def render_table(
     tabler.render(df, display_handle=display_handle)
 
 
+def build_table_renderable(
+    columns_data: object,
+    columns_alignment: object,
+    columns_headers: object = None,
+) -> object:
+    """
+    Build a table renderable for the active display backend.
+
+    Parameters
+    ----------
+    columns_data : object
+        A list of rows, where each row is a list of cell values.
+    columns_alignment : object
+        A list of alignment strings (e.g. ``'left'``, ``'right'``,
+        ``'center'``) matching the number of columns.
+    columns_headers : object, default=None
+        Optional list of column header strings.
+
+    Returns
+    -------
+    object
+        Backend-native renderable, such as a Rich table or HTML.
+    """
+    headers = [
+        (col, align) for col, align in zip(columns_headers, columns_alignment, strict=False)
+    ]
+    df = pd.DataFrame(columns_data, columns=pd.MultiIndex.from_tuples(headers))
+
+    tabler = TableRenderer.get()
+    return tabler.build_renderable(df)
+
+
 def _help_first_sentence(docstring: str | None) -> str:
     """Return the first paragraph of a docstring on one line."""
     if not docstring:
diff --git a/tests/integration/fitting/test_bayesian_tracker_and_base.py b/tests/integration/fitting/test_bayesian_tracker_and_base.py
index f63cb111f..e6988a0f3 100644
--- a/tests/integration/fitting/test_bayesian_tracker_and_base.py
+++ b/tests/integration/fitting/test_bayesian_tracker_and_base.py
@@ -27,7 +27,25 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
     import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
 
-    monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+    events: list[tuple[str, object]] = []
+
+    class FakeIndicator:
+        def __init__(self, label, *, verbosity, display_handle=None):
+            del display_handle
+            events.append(('init', label, verbosity))
+
+        def start(self):
+            events.append(('start', None))
+
+        def update(self, *, label=None, content=None):
+            events.append(('update', label))
+            del content
+
+        def stop(self):
+            events.append(('stop', None))
+
+    monkeypatch.setattr(tracking_mod, 'ActivityIndicator', FakeIndicator)
+    monkeypatch.setattr(tracking_mod, 'build_table_renderable', lambda **kwargs: 'table')
 
     tracker = FitProgressTracker()
     tracker.start_tracking('dummy')
@@ -46,6 +64,7 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
     out = capsys.readouterr().out
     assert 'Best goodness-of-fit' in out
     assert tracker.best_iteration is not None
+    assert ('init', tracking_mod.ACTIVITY_LABEL_FITTING, tracker._verbosity) in events
 
 
 def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys):
@@ -53,7 +72,25 @@ def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys):
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
     from easydiffraction.analysis.fit_helpers.tracking import SamplerProgressUpdate
 
-    monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+    events: list[tuple[str, object]] = []
+
+    class FakeIndicator:
+        def __init__(self, label, *, verbosity, display_handle=None):
+            del display_handle
+            events.append(('init', label, verbosity))
+
+        def start(self):
+            events.append(('start', None))
+
+        def update(self, *, label=None, content=None):
+            events.append(('update', label))
+            del content
+
+        def stop(self):
+            events.append(('stop', None))
+
+    monkeypatch.setattr(tracking_mod, 'ActivityIndicator', FakeIndicator)
+    monkeypatch.setattr(tracking_mod, 'build_table_renderable', lambda **kwargs: 'table')
 
     tracker = FitProgressTracker()
     tracker.start_tracking('dream', mode='sampling')
@@ -90,6 +127,8 @@ def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys):
     assert 'Bayesian sampling complete.' in out
     assert tracker.best_chi2 == pytest.approx(1.0)
     assert tracker.best_iteration == 10
+    assert ('update', tracking_mod.ACTIVITY_LABEL_BURN_IN) in events
+    assert ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING) in events
 
 
 def test_tracker_helper_error_paths_and_short_mode(monkeypatch):
@@ -97,7 +136,24 @@ def test_tracker_helper_error_paths_and_short_mode(monkeypatch):
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
     from easydiffraction.utils.enums import VerbosityEnum
 
-    monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+    events: list[tuple[str, object]] = []
+
+    class FakeIndicator:
+        def __init__(self, label, *, verbosity, display_handle=None):
+            del display_handle
+            events.append(('init', label, verbosity))
+
+        def start(self):
+            events.append(('start', None))
+
+        def update(self, *, label=None, content=None):
+            events.append(('update', label))
+            del content
+
+        def stop(self):
+            events.append(('stop', None))
+
+    monkeypatch.setattr(tracking_mod, 'ActivityIndicator', FakeIndicator)
 
     tracker = FitProgressTracker()
     tracker._verbosity = VerbosityEnum.SHORT
@@ -111,14 +167,17 @@ def test_tracker_helper_error_paths_and_short_mode(monkeypatch):
         FitProgressTracker()._sampler_iteration_label(1)
 
     assert FitProgressTracker._rows_match_on_columns(['1', 'a'], ['1', 'b'], (0,)) is True
+    assert events == [
+        ('init', tracking_mod.ACTIVITY_LABEL_SAMPLING, VerbosityEnum.SHORT),
+        ('start', None),
+        ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING),
+        ('stop', None),
+    ]
 
 
-def test_tracker_final_sampler_row_replaces_last_row(monkeypatch):
-    import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+def test_tracker_final_sampler_row_replaces_last_row():
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
 
-    monkeypatch.setattr(tracking_mod, 'render_table', lambda **kwargs: None)
-
     tracker = FitProgressTracker()
     tracker._tracking_mode = 'sampling'
     tracker._sampler_total_iterations = 10
@@ -137,44 +196,32 @@ def test_tracker_final_sampler_row_replaces_last_row(monkeypatch):
 def test_make_display_handle_uses_terminal_live_when_available(monkeypatch):
     import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
 
-    class FakeLive:
-        def __init__(self, *, console, auto_refresh):
-            self.console = console
-            self.auto_refresh = auto_refresh
-            self.started = False
-            self.stopped = False
-
-        def start(self):
-            self.started = True
-
-        def stop(self):
-            self.stopped = True
-
-    monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
-    monkeypatch.setattr(tracking_mod, 'Live', FakeLive)
-    monkeypatch.setattr(tracking_mod.ConsoleManager, 'get', lambda: 'console')
-
-    handle = tracking_mod._make_display_handle()
+    sentinel = object()
 
-    assert isinstance(handle, tracking_mod._TerminalLiveHandle)
-    assert handle._live.console == 'console'
-    assert handle._live.auto_refresh is True
-    assert handle._live.started is True
+    monkeypatch.setattr(tracking_mod, 'make_display_handle', lambda: sentinel)
 
-    handle.close()
-
-    assert handle._live.stopped is True
+    assert tracking_mod._make_display_handle() is sentinel
 
 
 def test_tracker_misc_helper_paths(monkeypatch):
     import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
+    from easydiffraction.utils.enums import VerbosityEnum
 
     render_calls: list[dict[str, object]] = []
-    monkeypatch.setattr(tracking_mod, 'render_table', lambda **kwargs: render_calls.append(kwargs))
+    update_calls: list[dict[str, object]] = []
     monkeypatch.setattr(
         tracking_mod, 'calculate_reduced_chi_square', lambda residuals, n_params: 3.0
     )
+    monkeypatch.setattr(
+        tracking_mod,
+        'build_table_renderable',
+        lambda **kwargs: render_calls.append(kwargs) or 'renderable',
+    )
+
+    class FakeIndicator:
+        def update(self, *, label=None, content=None):
+            update_calls.append({'label': label, 'content': content})
 
     tracker = FitProgressTracker()
     tracker._tracking_mode = tracking_mod.TRACKING_MODE_SAMPLER
@@ -197,13 +244,21 @@ def test_tracker_misc_helper_paths(monkeypatch):
     assert tracker._current_elapsed_time() is None
     assert tracker._format_elapsed_time() == ''
 
+    tracker._verbosity = VerbosityEnum.FULL
+    tracker._activity_indicator = FakeIndicator()
     tracker._replace_last_tracking_row(['1'])
 
     assert tracker._df_rows == [['1']]
     assert len(render_calls) == 1
+    assert update_calls == [
+        {
+            'label': tracking_mod.ACTIVITY_LABEL_FITTING,
+            'content': 'renderable',
+        }
+    ]
 
 
-def test_tracker_final_rows_cover_fallbacks_and_close_suppression():
+def test_tracker_final_rows_cover_fallbacks_and_activity_labels():
     import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
 
@@ -218,15 +273,19 @@ def test_tracker_final_rows_cover_fallbacks_and_close_suppression():
     tracker._tracking_mode = tracking_mod.TRACKING_MODE_FIT
     tracker._fitting_time = 1.5
     assert tracker._final_fit_tracking_row() == ['8', '1.50', '', '']
-
-    class BadHandle:
-        @staticmethod
-        def close() -> None:
-            message = 'boom'
-            raise RuntimeError(message)
-
-    tracker._display_handle = BadHandle()
-    tracker._close_display_handle()
+    tracker._tracking_mode = tracking_mod.TRACKING_MODE_SAMPLER
+    assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_SAMPLING
+    tracker._tracking_mode = tracking_mod.TRACKING_MODE_FIT
+    assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_FITTING
+    assert (
+        tracker._activity_label_for_sampler_phase('burn-in') == tracking_mod.ACTIVITY_LABEL_BURN_IN
+    )
+    assert (
+        tracker._activity_label_for_sampler_phase('sampling')
+        == tracking_mod.ACTIVITY_LABEL_SAMPLING
+    )
+    assert tracker._activity_label_for_sampler_phase('annealing') == 'annealing'
+    assert tracker._activity_label_for_sampler_phase('') == tracking_mod.ACTIVITY_LABEL_SAMPLING
 
 
 def test_minimizer_base_fit_flow_and_finalize():
diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py
index 561eb54c3..877a46b1a 100644
--- a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py
+++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py
@@ -13,11 +13,11 @@ def test_module_import():
 
 
 def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
-    import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+    import easydiffraction.display.progress as progress_mod
     from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
 
-    # Force terminal branch (not notebook): tracking imports in_jupyter directly
-    monkeypatch.setattr(tracking_mod, 'in_jupyter', lambda: False)
+    # Force terminal branch (not notebook) in the shared progress layer.
+    monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False)
 
     tracker = FitProgressTracker()
     tracker.start_tracking('dummy')
@@ -43,3 +43,37 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys):
     out2 = capsys.readouterr().out
     assert 'Best goodness-of-fit' in out2
     assert tracker.best_iteration is not None
+
+
+def test_tracker_fit_adds_timed_rows_and_resets_counter(monkeypatch):
+    import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+    from easydiffraction.analysis.fit_helpers.tracking import FitProgressTracker
+
+    chi2_values = iter([5.0, 4.0, 3.97, 3.97, 3.97, 3.97])
+    perf_counter_values = iter([0.0, 0.0, 2.0, 6.9, 7.1, 11.9, 12.2])
+
+    monkeypatch.setattr(
+        tracking_mod,
+        'calculate_reduced_chi_square',
+        lambda residuals, n_parameters: next(chi2_values),
+    )
+    monkeypatch.setattr(
+        tracking_mod.time,
+        'perf_counter',
+        lambda: next(perf_counter_values),
+    )
+
+    tracker = FitProgressTracker()
+    tracker.start_timer()
+
+    for _ in range(6):
+        tracker.track(np.array([1.0]), parameters=[1.0])
+
+    assert tracker._df_rows == [
+        ['1', '0.00', '5.00', ''],
+        ['2', '2.00', '4.00', '20.0% ↓'],
+        ['4', '7.10', '3.97', ''],
+        ['6', '12.20', '3.97', ''],
+    ]
+    assert tracker._last_progress_time == 12.2
+    assert tracker._previous_chi2 == 4.0
diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_base.py b/tests/unit/easydiffraction/analysis/minimizers/test_base.py
index 0ad0ca127..3b18f5354 100644
--- a/tests/unit/easydiffraction/analysis/minimizers/test_base.py
+++ b/tests/unit/easydiffraction/analysis/minimizers/test_base.py
@@ -158,3 +158,46 @@ def _check_success(self, raw_result):
 
     assert minimizer.started is True
     assert minimizer.stopped is True
+
+
+def test_minimizer_base_fit_preserves_solver_prep_error_during_cleanup(monkeypatch):
+    import easydiffraction.analysis.fit_helpers.tracking as tracking_mod
+    from easydiffraction.analysis.minimizers.base import MinimizerBase
+
+    monkeypatch.setattr(
+        tracking_mod.FitProgressTracker,
+        '_start_activity_indicator',
+        lambda self: None,
+    )
+    monkeypatch.setattr(
+        tracking_mod.FitProgressTracker,
+        '_stop_activity_indicator',
+        lambda self: None,
+    )
+    monkeypatch.setattr(tracking_mod.console, 'print', lambda *args, **kwargs: None)
+
+    class M(MinimizerBase):
+        def __init__(self):
+            super().__init__(name='dummy', method='m', max_iterations=5)
+
+        def _prepare_solver_args(self, parameters):
+            del parameters
+            msg = 'prep failed'
+            raise ValueError(msg)
+
+        def _run_solver(self, objective_function, **kwargs):
+            del objective_function, kwargs
+            msg = 'should not run solver'
+            raise AssertionError(msg)
+
+        def _sync_result_to_parameters(self, parameters, raw_result):
+            del parameters, raw_result
+
+        def _check_success(self, raw_result):
+            del raw_result
+            return True
+
+    minimizer = M()
+
+    with pytest.raises(ValueError, match='prep failed'):
+        minimizer.fit(parameters=[_DummyParam(1.0)], objective_function=lambda _: np.array([0.0]))
diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py
index c5b2ef248..637facc02 100644
--- a/tests/unit/easydiffraction/analysis/test_analysis.py
+++ b/tests/unit/easydiffraction/analysis/test_analysis.py
@@ -1,6 +1,8 @@
 # SPDX-FileCopyrightText: 2025 EasyScience contributors 
 # SPDX-License-Identifier: BSD-3-Clause
 
+from types import SimpleNamespace
+
 
 def test_module_import():
     import easydiffraction.analysis.analysis as MUT
@@ -132,3 +134,96 @@ def values(self):
     a.display.fit_results()
 
     assert process_called['called'], '_process_fit_results should be called'
+
+
+def test_fit_single_short_reuses_tracker_display_handle(monkeypatch):
+    from easydiffraction.analysis.analysis import Analysis
+    from easydiffraction.utils.enums import VerbosityEnum
+
+    class Handle:
+        def __init__(self) -> None:
+            self.closed = False
+
+        def close(self) -> None:
+            self.closed = True
+
+    class Experiments:
+        def __init__(self) -> None:
+            self.names = ['e1']
+
+        def __getitem__(self, name: str) -> object:
+            del name
+            return object()
+
+    class Tracker:
+        def __init__(self) -> None:
+            self.best_iteration = 7
+            self.display_handles: list[object | None] = []
+
+        def _set_shared_display_handle(self, display_handle: object | None) -> None:
+            self.display_handles.append(display_handle)
+
+    project = SimpleNamespace(
+        experiments=Experiments(),
+        structures=object(),
+        _varname='proj',
+    )
+    analysis = Analysis(project=project)
+    tracker = Tracker()
+    analysis.fitter.minimizer = SimpleNamespace(tracker=tracker)
+    analysis.fitter.results = None
+
+    handle = Handle()
+    short_display_handles: list[object | None] = []
+
+    def fake_make_display_handle() -> Handle:
+        return handle
+
+    def fake_fit(
+        structures: object,
+        experiments: list[object],
+        *,
+        analysis: object,
+        verbosity: object,
+        use_physical_limits: bool,
+        random_seed: int | None,
+    ) -> None:
+        del structures, experiments, analysis, verbosity, use_physical_limits, random_seed
+        analysis_obj = fake_fit.analysis_obj
+        analysis_obj.fitter.results = SimpleNamespace(
+            reduced_chi_square=1.23,
+            success=True,
+            parameters=[],
+        )
+
+    fake_fit.analysis_obj = analysis
+
+    def fake_update_short_table(
+        self,
+        short_rows: list[list[str]],
+        expt_name: str,
+        results: object,
+        display_handle: object | None,
+    ) -> None:
+        del self, expt_name, results
+        short_rows.append(['e1', '1.23', '7', '✅'])
+        short_display_handles.append(display_handle)
+
+    monkeypatch.setattr(
+        'easydiffraction.analysis.analysis.make_display_handle', fake_make_display_handle
+    )
+    monkeypatch.setattr(analysis, '_snapshot_params', lambda expt_name, results: None)
+    monkeypatch.setattr(analysis.fitter, 'fit', fake_fit)
+    monkeypatch.setattr(Analysis, '_fit_single_update_short_table', fake_update_short_table)
+
+    analysis._fit_single(
+        VerbosityEnum.SHORT,
+        project.structures,
+        project.experiments,
+        use_physical_limits=False,
+        random_seed=None,
+    )
+
+    assert tracker.display_handles == [handle, None]
+    assert short_display_handles == [handle]
+    assert handle.closed is True
diff --git a/tests/unit/easydiffraction/analysis/test_sequential.py b/tests/unit/easydiffraction/analysis/test_sequential.py
index 3179a0b13..5e01b586d 100644
--- a/tests/unit/easydiffraction/analysis/test_sequential.py
+++ b/tests/unit/easydiffraction/analysis/test_sequential.py
@@ -4,7 +4,9 @@
 
 from __future__ import annotations
 
+import contextlib
 import csv
+from types import SimpleNamespace
 
 import pytest
 
@@ -14,6 +16,8 @@
 from easydiffraction.analysis.sequential import _build_csv_header
 from easydiffraction.analysis.sequential import _read_csv_for_recovery
 from easydiffraction.analysis.sequential import _write_csv_header
+from easydiffraction.display.progress import ACTIVITY_LABEL_FITTING
+from easydiffraction.utils.enums import VerbosityEnum
 
 
 # ------------------------------------------------------------------
@@ -300,3 +304,130 @@ def test_fields_accessible(self):
         assert template.diffrn_field_names == ['temp']
         assert template.minimizer_tag == 'lmfit'
         assert template.calculator_tag == 'cryspy'
+
+
+def test_fit_sequential_short_starts_and_stops_shared_indicator(monkeypatch, tmp_path):
+    import easydiffraction.analysis.sequential as sequential_mod
+
+    events: list[tuple[object, ...]] = []
+    template = _minimal_template()
+
+    class FakeIndicator:
+        def __init__(self, label, *, verbosity):
+            events.append(('init', label, verbosity))
+
+        def start(self):
+            events.append(('start',))
+
+        def update(self):
+            events.append(('update',))
+
+        def stop(self):
+            events.append(('stop',))
+
+    def fake_run_fit_loop(
+        pool_cm, chunks, template_arg, csv_info, extract_diffrn, verb, indicator
+    ):
+        del pool_cm, csv_info, extract_diffrn
+        assert chunks == [['scan_001.xye']]
+        assert template_arg == template
+        assert verb is VerbosityEnum.SHORT
+        assert indicator is not None
+        indicator.update()
+
+    monkeypatch.setattr(sequential_mod, 'ActivityIndicator', FakeIndicator)
+    monkeypatch.setattr(sequential_mod.mp, 'parent_process', lambda: None)
+    monkeypatch.setattr(sequential_mod, '_check_seq_preconditions', lambda project: None)
+    monkeypatch.setattr(
+        sequential_mod,
+        'extract_data_paths_from_dir',
+        lambda data_dir, file_pattern='*': ['scan_001.xye'],
+    )
+    monkeypatch.setattr(sequential_mod, '_build_template', lambda project: template)
+    monkeypatch.setattr(
+        sequential_mod,
+        '_setup_csv_and_recovery',
+        lambda project, template_arg, verb: (
+            tmp_path / 'results.csv',
+            ['file_path'],
+            set(),
+            template_arg,
+        ),
+    )
+    monkeypatch.setattr(sequential_mod, '_resolve_workers', lambda max_workers, chunk_size: (1, 1))
+    monkeypatch.setattr(
+        sequential_mod,
+        '_create_pool_context',
+        lambda max_workers: (contextlib.nullcontext(None), None, None, None),
+    )
+    monkeypatch.setattr(sequential_mod, '_run_fit_loop', fake_run_fit_loop)
+    monkeypatch.setattr(sequential_mod, '_restore_main_state', lambda *args: None)
+
+    analysis = SimpleNamespace(
+        project=SimpleNamespace(verbosity='short'),
+        fitter=SimpleNamespace(selection='lmfit'),
+    )
+
+    sequential_mod.fit_sequential(analysis, data_dir=str(tmp_path))
+
+    assert events == [
+        ('init', ACTIVITY_LABEL_FITTING, VerbosityEnum.SHORT),
+        ('start',),
+        ('update',),
+        ('stop',),
+    ]
+
+
+def test_fit_sequential_silent_does_not_start_indicator(monkeypatch, tmp_path):
+    import easydiffraction.analysis.sequential as sequential_mod
+
+    template = _minimal_template()
+
+    class FailingIndicator:
+        def __init__(self, *args, **kwargs):
+            message = 'silent mode should not create an activity indicator'
+            raise AssertionError(message)
+
+    def fake_run_fit_loop(
+        pool_cm, chunks, template_arg, csv_info, extract_diffrn, verb, indicator
+    ):
+        del pool_cm, csv_info, extract_diffrn
+        assert chunks == [['scan_001.xye']]
+        assert template_arg == template
+        assert verb is VerbosityEnum.SILENT
+        assert indicator is None
+
+    monkeypatch.setattr(sequential_mod, 'ActivityIndicator', FailingIndicator)
+    monkeypatch.setattr(sequential_mod.mp, 'parent_process', lambda: None)
+    monkeypatch.setattr(sequential_mod, '_check_seq_preconditions', lambda project: None)
+    monkeypatch.setattr(
+        sequential_mod,
+        'extract_data_paths_from_dir',
+        lambda data_dir, file_pattern='*': ['scan_001.xye'],
+    )
+    monkeypatch.setattr(sequential_mod, '_build_template', lambda project: template)
+    monkeypatch.setattr(
+        sequential_mod,
+        '_setup_csv_and_recovery',
+        lambda project, template_arg, verb: (
+            tmp_path / 'results.csv',
+            ['file_path'],
+            set(),
+            template_arg,
+        ),
+    )
+    monkeypatch.setattr(sequential_mod, '_resolve_workers', lambda max_workers, chunk_size: (1, 1))
+    monkeypatch.setattr(
+        sequential_mod,
+        '_create_pool_context',
+        lambda max_workers: (contextlib.nullcontext(None), None, None, None),
+    )
+    monkeypatch.setattr(sequential_mod, '_run_fit_loop', fake_run_fit_loop)
+    monkeypatch.setattr(sequential_mod, '_restore_main_state', lambda *args: None)
+
+    analysis = SimpleNamespace(
+        project=SimpleNamespace(verbosity='silent'),
+        fitter=SimpleNamespace(selection='lmfit'),
+    )
+
+    sequential_mod.fit_sequential(analysis, data_dir=str(tmp_path))
diff --git a/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py
index 5c3c8dd50..5b5836229 100644
--- a/tests/unit/easydiffraction/display/plotters/test_plotly.py
+++ b/tests/unit/easydiffraction/display/plotters/test_plotly.py
@@ -828,7 +828,9 @@ def fake_show_figure(self, fig):
     )
 
     fig = captured['fig']
-    predictive_band_trace = next(trace for trace in fig.data if trace.name == '95% interval')
+    predictive_band_trace = next(
+        trace for trace in fig.data if trace.name == '95% credible interval'
+    )
     max_posterior_trace = next(trace for trace in fig.data if trace.name == 'Max posterior')
     residual_trace = next(trace for trace in fig.data if trace.name == 'Residual (Imeas - Icalc)')
 
diff --git a/tests/unit/easydiffraction/display/tablers/test_pandas.py b/tests/unit/easydiffraction/display/tablers/test_pandas.py
index 2a2828a9c..e4a77c5b6 100644
--- a/tests/unit/easydiffraction/display/tablers/test_pandas.py
+++ b/tests/unit/easydiffraction/display/tablers/test_pandas.py
@@ -33,3 +33,15 @@ def test_apply_styling_returns_styler(self):
         df = pd.DataFrame({'A': [1.0], 'B': [2.0]})
         styler = backend._apply_styling(df, ['left', 'right'], '#aabbcc')
         assert hasattr(styler, 'to_html')
+
+    def test_build_renderable_returns_html(self):
+        from easydiffraction.display.tablers.pandas import PandasTableBackend
+
+        pytest.importorskip('jinja2')
+        backend = PandasTableBackend()
+        df = pd.DataFrame({'A': [1.0], 'B': [2.0]})
+
+        html = backend.build_renderable(['left', 'right'], df)
+
+        assert isinstance(html, str)
+        assert '= {
         'Posterior histogram',
         'Marginal density',
-        '68% credible interval',
         '95% credible interval',
         'Median',
         'Max posterior',
     }
     marginal_trace = next(trace for trace in figure.data if trace.name == 'Marginal density')
     histogram_trace = next(trace for trace in figure.data if trace.name == 'Posterior histogram')
-    interval_68_trace = next(
-        trace for trace in figure.data if trace.name == '68% credible interval'
-    )
     interval_trace = next(trace for trace in figure.data if trace.name == '95% credible interval')
     max_posterior_trace = next(trace for trace in figure.data if trace.name == 'Max posterior')
     assert marginal_trace.line.color == POSTERIOR_PAIR_MARGINAL_DENSITY_LINE_COLOR
@@ -640,7 +635,7 @@ def test_build_param_distribution_plot_returns_plotly_figure():
     assert marginal_trace.fillcolor == POSTERIOR_PAIR_MARGINAL_DENSITY_FILL_COLOR
     assert marginal_trace.hovertemplate == 'length_a: %{x:.4f}
density: %{y:.4f}' assert histogram_trace.xbins.size is not None - assert interval_68_trace.fillcolor == POSTERIOR_INTERVAL_68_FILL_COLOR + assert '68% credible interval' not in {trace.name for trace in figure.data} assert interval_trace.fillcolor == POSTERIOR_INTERVAL_95_FILL_COLOR assert max_posterior_trace.line.dash == POSTERIOR_POINT_ESTIMATE_LINE_DASH assert figure.layout.xaxis.range is not None @@ -685,7 +680,7 @@ def test_plot_posterior_predictive_summary_uses_consistent_labels_and_styles(mon measured_trace = next(trace for trace in fig.data if trace.name == 'Measured') max_posterior_trace = next(trace for trace in fig.data if trace.name == 'Max posterior') - assert upper_band_trace.name == '95% interval' + assert upper_band_trace.name == '95% credible interval' assert upper_band_trace.fillcolor == POSTERIOR_INTERVAL_95_FILL_COLOR assert upper_band_trace.legendrank == 30 assert measured_trace.legendrank == 10 diff --git a/tests/unit/easydiffraction/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py new file mode 100644 index 000000000..f690159ed --- /dev/null +++ b/tests/unit/easydiffraction/display/test_progress.py @@ -0,0 +1,210 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Tests for display/progress.py.""" + +from __future__ import annotations + +from easydiffraction.utils.enums import VerbosityEnum + + +def test_make_display_handle_uses_terminal_live_when_available(monkeypatch): + import easydiffraction.display.progress as progress_mod + + class FakeLive: + def __init__( + self, + renderable=None, + *, + console, + auto_refresh, + refresh_per_second, + get_renderable=None, + ): + self.renderable = renderable + self.console = console + self.auto_refresh = auto_refresh + self.refresh_per_second = refresh_per_second + self.get_renderable = get_renderable + self.started = False + self.stopped = False + self.refresh_calls = 0 + + def start(self): + self.started = True + + def stop(self): + self.stopped = True + + def refresh(self): + self.refresh_calls += 1 + + monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False) + monkeypatch.setattr(progress_mod, 'Live', FakeLive) + monkeypatch.setattr(progress_mod.ConsoleManager, 'get', lambda: 'console') + + handle = progress_mod.make_display_handle() + + assert isinstance(handle, progress_mod._TerminalLiveHandle) + assert handle._live.console == 'console' + assert handle._live.auto_refresh is True + assert handle._live.get_renderable is not None + assert handle._live.started is True + + handle.update('content') + + assert handle._live.refresh_calls == 1 + assert handle._live.get_renderable() == 'content' + + handle.close() + + assert handle._live.stopped is True + + +def test_activity_indicator_silent_does_not_create_handles(): + from easydiffraction.display.progress import ActivityIndicator + + indicator = ActivityIndicator(verbosity=VerbosityEnum.SILENT) + indicator.start() + + assert indicator._live is None + assert indicator._display_handle is None + + +def test_activity_indicator_html_style_prevents_stretching(): + import easydiffraction.display.progress as progress_mod + + indicator = progress_mod.ActivityIndicator(verbosity=VerbosityEnum.FULL) + style = indicator._html_style() + + assert 'align-items: flex-start;' in style + assert progress_mod.ACTIVITY_ACCENT_COLOR in style + assert 'font-weight: 400;' in style + assert 'var(--jp-ui-font-family' in style + + +def test_activity_indicator_terminal_line_uses_accent_style(): + import easydiffraction.display.progress as progress_mod + + indicator = progress_mod.ActivityIndicator(label='Fitting...', verbosity=VerbosityEnum.FULL) + indicator._running = True + + line = indicator._terminal_indicator_line() + + assert line is not None + assert line.style == progress_mod.ACTIVITY_TERMINAL_STYLE + assert 'bold' not in str(line.style) + + +def test_activity_indicator_terminal_live_uses_dynamic_renderable(monkeypatch): + import easydiffraction.display.progress as progress_mod + + class FakeLive: + def __init__( + self, + renderable=None, + *, + console, + auto_refresh, + refresh_per_second, + get_renderable=None, + ): + self.renderable = renderable + self.console = console + self.auto_refresh = auto_refresh + self.refresh_per_second = refresh_per_second + self.get_renderable = get_renderable + self.refresh_calls = 0 + self.started = False + + def start(self): + self.started = True + + def stop(self): + self.started = False + + def refresh(self): + self.refresh_calls += 1 + + monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False) + monkeypatch.setattr(progress_mod, 'Live', FakeLive) + monkeypatch.setattr(progress_mod.ConsoleManager, 'get', lambda: 'console') + + indicator = progress_mod.ActivityIndicator(label='Fitting...', verbosity=VerbosityEnum.FULL) + indicator.start() + indicator._current_frame = lambda: 'X' + + assert indicator._live is not None + assert indicator._live.get_renderable is not None + assert indicator._live.renderable is None + assert indicator._live.get_renderable().plain == 'X Fitting...' + + indicator.update(label='Sampling...') + + assert indicator._live.refresh_calls == 1 + assert indicator._live.get_renderable().plain == 'X Sampling...' + + +def test_activity_indicator_render_html_uses_current_label(): + from easydiffraction.display.progress import ActivityIndicator + + indicator = ActivityIndicator(label='Fitting...', verbosity=VerbosityEnum.FULL) + indicator._running = True + + html = indicator._render_html() + + assert 'Fitting...' in html + + +def test_activity_indicator_updates_shared_terminal_handle_without_ipython(monkeypatch): + import easydiffraction.display.progress as progress_mod + + class FakeLive: + def __init__( + self, + renderable=None, + *, + console, + auto_refresh, + refresh_per_second, + get_renderable=None, + ): + self.renderable = renderable + self.console = console + self.auto_refresh = auto_refresh + self.refresh_per_second = refresh_per_second + self.get_renderable = get_renderable + self.refresh_calls = 0 + self.started = False + + def start(self): + self.started = True + + def stop(self): + self.started = False + + def refresh(self): + self.refresh_calls += 1 + + monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False) + monkeypatch.setattr(progress_mod, 'HTML', None) + monkeypatch.setattr(progress_mod, 'DisplayHandle', None) + monkeypatch.setattr(progress_mod, 'Live', FakeLive) + monkeypatch.setattr(progress_mod.ConsoleManager, 'get', lambda: 'console') + + handle = progress_mod.make_display_handle() + indicator = progress_mod.ActivityIndicator( + label='Fitting...', + verbosity=VerbosityEnum.FULL, + display_handle=handle, + ) + indicator._current_frame = lambda: 'X' + + indicator.start() + + assert handle._live.refresh_calls == 1 + assert handle._live.get_renderable().plain == 'X Fitting...' + + indicator.update(label='Sampling...') + + assert handle._live.refresh_calls == 2 + assert handle._live.get_renderable().plain == 'X Sampling...' diff --git a/tests/unit/easydiffraction/display/test_tables.py b/tests/unit/easydiffraction/display/test_tables.py index 2fb16fe13..9920e7e12 100644 --- a/tests/unit/easydiffraction/display/test_tables.py +++ b/tests/unit/easydiffraction/display/test_tables.py @@ -2,6 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause """Tests for display/tables.py (TableEngineEnum, TableRenderer, TableRendererFactory).""" +from types import SimpleNamespace + import pandas as pd @@ -59,3 +61,30 @@ def test_render(self, monkeypatch, capsys): # Reset singleton to not leak state monkeypatch.setattr(TableRenderer, '_instance', None) + + def test_build_renderable_normalizes_dataframe(self, monkeypatch): + from easydiffraction.display.tables import TableRenderer + + monkeypatch.setattr(TableRenderer, '_instance', None) + + headers = [('Col', 'left')] + df = pd.DataFrame([['val']], columns=pd.MultiIndex.from_tuples(headers)) + calls: dict[str, object] = {} + + def fake_build_renderable(alignments, prepared_df): + calls['alignments'] = list(alignments) + calls['columns'] = list(prepared_df.columns) + calls['index'] = list(prepared_df.index) + return 'renderable' + + renderer = TableRenderer.get() + renderer._backend = SimpleNamespace(build_renderable=fake_build_renderable) + + assert renderer.build_renderable(df) == 'renderable' + assert calls == { + 'alignments': ['left'], + 'columns': ['Col'], + 'index': [1], + } + + monkeypatch.setattr(TableRenderer, '_instance', None) diff --git a/tests/unit/easydiffraction/project/test_display.py b/tests/unit/easydiffraction/project/test_display.py index 8e8f7695d..b67d7c022 100644 --- a/tests/unit/easydiffraction/project/test_display.py +++ b/tests/unit/easydiffraction/project/test_display.py @@ -4,15 +4,18 @@ from __future__ import annotations +from contextlib import contextmanager from types import SimpleNamespace import pytest from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum +from easydiffraction.display.progress import ACTIVITY_LABEL_PROCESSING from easydiffraction.display.plotting import _MeasVsCalcPlotOptions from easydiffraction.project.display import PatternOptionStatus from easydiffraction.project.display import ProjectDisplay +from easydiffraction.utils.enums import VerbosityEnum def _make_project_stub() -> tuple[SimpleNamespace, list[tuple[str, tuple, dict]]]: @@ -47,6 +50,7 @@ def _recorder(*args, **kwargs): project = SimpleNamespace( analysis=SimpleNamespace(display=analysis_display), rendering=SimpleNamespace(plotter=plotter), + verbosity='full', ) return project, calls @@ -207,9 +211,19 @@ def test_fit_display_delegates_to_analysis_and_rendering(): ) -def test_posterior_display_delegates_to_rendering_plotter(): +def test_posterior_display_delegates_to_rendering_plotter(monkeypatch): + import easydiffraction.project.display as display_mod + project, calls = _make_project_stub() display = ProjectDisplay(project) + indicator_calls: list[tuple[str, VerbosityEnum]] = [] + + @contextmanager + def fake_activity_indicator(label, *, verbosity): + indicator_calls.append((label, verbosity)) + yield object() + + monkeypatch.setattr(display_mod, 'activity_indicator', fake_activity_indicator) display.posterior.pairs(parameters=['a'], threshold=0.5, max_parameters=3) display.posterior.distribution('a') @@ -239,6 +253,10 @@ def test_posterior_display_delegates_to_rendering_plotter(): 'x': 'd_spacing', }, ) + assert indicator_calls == [ + (ACTIVITY_LABEL_PROCESSING, VerbosityEnum.FULL), + (ACTIVITY_LABEL_PROCESSING, VerbosityEnum.FULL), + ] def test_pattern_auto_routes_measured_and_excluded_to_plot_meas(): @@ -266,7 +284,9 @@ def test_pattern_auto_routes_measured_and_excluded_to_plot_meas(): ] -def test_pattern_uncertainty_routes_to_posterior_predictive(): +def test_pattern_uncertainty_routes_to_posterior_predictive(monkeypatch): + import easydiffraction.project.display as display_mod + project, calls = _make_project_stub() display = ProjectDisplay(project) display._pattern_option_statuses = lambda expt_name: _make_statuses( @@ -276,6 +296,14 @@ def test_pattern_uncertainty_routes_to_posterior_predictive(): excluded=True, uncertainty=True, ) + indicator_calls: list[tuple[str, VerbosityEnum]] = [] + + @contextmanager + def fake_activity_indicator(label, *, verbosity): + indicator_calls.append((label, verbosity)) + yield object() + + monkeypatch.setattr(display_mod, 'activity_indicator', fake_activity_indicator) display.pattern( 'hrpt', @@ -303,6 +331,7 @@ def test_pattern_uncertainty_routes_to_posterior_predictive(): }, ) ] + assert indicator_calls == [(ACTIVITY_LABEL_PROCESSING, VerbosityEnum.FULL)] def test_pattern_measured_and_calculated_suppresses_background_and_bragg():