From 051fc416f4a85f19f5383130c60122c9ae904a2b Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 14 May 2026 22:13:17 +0200 Subject: [PATCH 01/16] Add shared activity indicator helper --- src/easydiffraction/display/progress.py | 321 ++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 src/easydiffraction/display/progress.py diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py new file mode 100644 index 00000000..83adf20d --- /dev/null +++ b/src/easydiffraction/display/progress.py @@ -0,0 +1,321 @@ +# 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 contextmanager +from contextlib import suppress +from time import monotonic +from typing import Iterator + +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 Console +from rich.console import ConsoleOptions +from rich.console import Group +from rich.console import RenderResult +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 + +SPINNER_FRAMES: tuple[str, ...] = ( + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏', +) +_SPINNER_FRAME_SECONDS = 0.1 +_JUPYTER_SPINNER_SECONDS = 1.0 + + +class ActivityIndicator: + """ + Render a live activity indicator for long-running work. + + Parameters + ---------- + label : str, default='processing' + User-facing activity label. + verbosity : VerbosityEnum + Output verbosity controlling whether live display is shown. + """ + + def __init__( + self, + label: str = 'processing', + *, + verbosity: VerbosityEnum, + ) -> None: + self._label = label + self._verbosity = verbosity + self._content: object | None = None + 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 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( + self, + console=ConsoleManager.get(), + auto_refresh=True, + refresh_per_second=1 / _SPINNER_FRAME_SECONDS, + ) + 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 __rich_console__( + self, + console: Console, + options: ConsoleOptions, + ) -> RenderResult: + """Yield a Rich renderable for the current activity state.""" + del console + del options + + 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: + yield Text('') + return + + if len(renderables) == 1: + yield renderables[0] + return + + yield Group(*renderables) + + def _refresh(self) -> None: + if self._verbosity is VerbosityEnum.SILENT: + return + + if self._display_handle is not None and HTML is not None: + with suppress(Exception): + self._display_handle.update(HTML(self._render_html())) + + if self._live is not None: + with suppress(Exception): + self._live.refresh() + + 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}') + if self._keep_stopped_label: + return Text(self._label) + 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 '' + + def _html_style(self) -> 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 ( + '' + ) + + +@contextmanager +def activity_indicator( + label: str = 'processing', + *, + verbosity: VerbosityEnum, +) -> Iterator[ActivityIndicator]: + """ + Manage an activity indicator around a block of work. + + Parameters + ---------- + label : str, default='processing' + User-facing activity label. + verbosity : VerbosityEnum + Output verbosity controlling whether live display is shown. + + Yields + ------ + ActivityIndicator + Started indicator that is stopped on block exit. + """ + indicator = ActivityIndicator(label, verbosity=verbosity) + indicator.start() + try: + yield indicator + finally: + indicator.stop() \ No newline at end of file From 4eec50b85c49cc2f25c9177f0ca9d2dee65100bf Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 14 May 2026 22:21:24 +0200 Subject: [PATCH 02/16] Wire fit tracker activity indicator --- .../analysis/fit_helpers/tracking.py | 205 ++++++++---------- src/easydiffraction/display/tablers/base.py | 23 ++ src/easydiffraction/display/tablers/pandas.py | 36 ++- src/easydiffraction/display/tablers/rich.py | 15 +- src/easydiffraction/display/tables.py | 56 +++-- src/easydiffraction/utils/utils.py | 32 +++ 6 files changed, 225 insertions(+), 142 deletions(-) diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index b5cfb11a..bf5de68b 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -1,33 +1,18 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + 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 easydiffraction.analysis.fit_helpers.metrics import calculate_reduced_chi_square +from easydiffraction.display.progress import ActivityIndicator 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 ConsoleManager +from easydiffraction.utils.logging import console +from easydiffraction.utils.utils import build_table_renderable SIGNIFICANT_CHANGE_THRESHOLD = 0.01 # 1% threshold SAMPLER_PROGRESS_UPDATE_SECONDS = 5.0 @@ -37,6 +22,10 @@ DEFAULT_ALIGNMENTS = ['center', 'center', 'center', 'center'] SAMPLER_HEADERS = ['iteration', 'progress', 'time (s)', 'log posterior', 'phase'] SAMPLER_ALIGNMENTS = ['center', 'center', 'center', 'center', 'center'] +ACTIVITY_LABEL_BURN_IN = 'burn-in' +ACTIVITY_LABEL_FITTING = 'fitting' +ACTIVITY_LABEL_PROCESSING = 'processing' +ACTIVITY_LABEL_SAMPLING = 'sampling' @dataclass(frozen=True, slots=True) @@ -55,56 +44,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 +74,12 @@ 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 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 +97,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, @@ -194,7 +136,6 @@ def track( row: list[str] = [] - # First iteration, initialize tracking if self._previous_chi2 is None: self._previous_chi2 = reduced_chi2 self._best_chi2 = reduced_chi2 @@ -206,12 +147,9 @@ def track( 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 @@ -224,16 +162,13 @@ def track( self._previous_chi2 = reduced_chi2 - # 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 +194,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 +262,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 +291,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 +301,12 @@ 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: + self._print_completion_summary() def _initial_sampler_progress_row( self, @@ -511,7 +431,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 ACTIVITY_LABEL_SAMPLING, ] def _finalize_fit_tracking_row(self) -> None: @@ -566,11 +486,6 @@ 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.') @@ -636,12 +551,62 @@ 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 + + def _activity_label_for_sampler_phase(self, phase: str) -> str: + normalized_phase = phase.strip().lower() + if normalized_phase == ACTIVITY_LABEL_BURN_IN: + return ACTIVITY_LABEL_BURN_IN + if normalized_phase == ACTIVITY_LABEL_SAMPLING: + return ACTIVITY_LABEL_SAMPLING + if normalized_phase: + return normalized_phase + return ACTIVITY_LABEL_PROCESSING + + def _start_activity_indicator(self) -> None: + self._activity_indicator = ActivityIndicator( + self._activity_label, + verbosity=self._verbosity, + ) + self._activity_indicator.start() + self._refresh_activity_indicator() + + def _stop_activity_indicator(self) -> None: + if self._activity_indicator is None: + return + + 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 - render_table( + 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, - ) + ) \ No newline at end of file diff --git a/src/easydiffraction/display/tablers/base.py b/src/easydiffraction/display/tablers/base.py index 75922fce..0bc2bddf 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 affa3ff8..823c2cb0 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 8daafb6b..d3ff1c5e 100644 --- a/src/easydiffraction/display/tablers/rich.py +++ b/src/easydiffraction/display/tablers/rich.py @@ -66,7 +66,11 @@ 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.
 
@@ -76,14 +80,12 @@ def _build_table(self, df: object, alignments: object, color: str) -> Table:
             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.
-
         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 +174,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 cda6f0d1..574d245a 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/utils/utils.py b/src/easydiffraction/utils/utils.py
index a81f1cb0..2fe66e73 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:

From b896bf893c113033d1f4ae738671b2bf79d348e2 Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 22:22:45 +0200
Subject: [PATCH 03/16] Reuse activity indicator for sequential fitting

---
 src/easydiffraction/analysis/sequential.py | 39 +++++++++++++++++-----
 1 file changed, 31 insertions(+), 8 deletions(-)

diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py
index 25feb184..c57bfdfb 100644
--- a/src/easydiffraction/analysis/sequential.py
+++ b/src/easydiffraction/analysis/sequential.py
@@ -17,6 +17,7 @@
 from typing import TYPE_CHECKING
 from typing import Any
 
+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 +530,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 +543,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 +656,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 +758,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 +777,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 +795,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)
@@ -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('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}')

From 03e7e37eb105f7cdefcd03f6ce332507b41937c2 Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 22:27:00 +0200
Subject: [PATCH 04/16] Restore shared display handle compatibility

---
 src/easydiffraction/analysis/analysis.py      |  4 +-
 .../analysis/fit_helpers/tracking.py          |  9 ++++
 src/easydiffraction/display/progress.py       | 49 +++++++++++++++++++
 3 files changed, 60 insertions(+), 2 deletions(-)

diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py
index 484c3e03..9c5478ec 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
@@ -680,7 +680,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 bf5de68b..b9035c66 100644
--- a/src/easydiffraction/analysis/fit_helpers/tracking.py
+++ b/src/easydiffraction/analysis/fit_helpers/tracking.py
@@ -10,6 +10,8 @@
 
 from easydiffraction.analysis.fit_helpers.metrics import calculate_reduced_chi_square
 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.logging import console
 from easydiffraction.utils.utils import build_table_renderable
@@ -27,6 +29,13 @@
 ACTIVITY_LABEL_PROCESSING = 'processing'
 ACTIVITY_LABEL_SAMPLING = 'sampling'
 
+_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:
diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py
index 83adf20d..733188a7 100644
--- a/src/easydiffraction/display/progress.py
+++ b/src/easydiffraction/display/progress.py
@@ -45,6 +45,55 @@
 _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, 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 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
+
+    live = Live(console=ConsoleManager.get(), auto_refresh=True)
+    live.start()
+    return _TerminalLiveHandle(live)
+
+
 class ActivityIndicator:
     """
     Render a live activity indicator for long-running work.

From 89fc953fd464e79fe792eb6d8ee6f28a82199a83 Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 22:29:00 +0200
Subject: [PATCH 05/16] Show processing indicator for posterior displays

---
 src/easydiffraction/project/display.py | 92 +++++++++++++++-----------
 1 file changed, 52 insertions(+), 40 deletions(-)

diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py
index 2071463d..dbddb58c 100644
--- a/src/easydiffraction/project/display.py
+++ b/src/easydiffraction/project/display.py
@@ -10,9 +10,11 @@
 from easydiffraction.datablocks.experiment.item.base import intensity_category_for
 from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
 from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
+from easydiffraction.display.progress import activity_indicator
 from easydiffraction.display.plotting import PlotterEngineEnum
 from easydiffraction.display.plotting import PosteriorPairPlotStyleEnum
 from easydiffraction.display.plotting import _MeasVsCalcPlotOptions
+from easydiffraction.utils.enums import VerbosityEnum
 from easydiffraction.utils.utils import render_object_help
 from easydiffraction.utils.utils import render_table
 
@@ -128,12 +130,13 @@ 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('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 +153,15 @@ 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('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 +217,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(
+                    '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 +251,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(
+                '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(

From 5e7fa91b33a353b14c70f3c9f011ec6f8360f47b Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 22:33:24 +0200
Subject: [PATCH 06/16] Prevent notebook progress table stretching

---
 src/easydiffraction/display/progress.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py
index 733188a7..17cea101 100644
--- a/src/easydiffraction/display/progress.py
+++ b/src/easydiffraction/display/progress.py
@@ -314,6 +314,7 @@ def _html_style(self) -> str:
             '.ed-activity-stack {'
             'display: flex;'
             'flex-direction: column;'
+            'align-items: flex-start;'
             'gap: 0.35rem;'
             'margin-top: 0.5rem;'
             '}'

From 48b196b4ec9f188b4f7ac51ec6978c3f0ace6f65 Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 23:08:56 +0200
Subject: [PATCH 07/16] Add activity label constants and styling

---
 .../analysis/fit_helpers/tracking.py          |  27 ++--
 src/easydiffraction/analysis/sequential.py    |   3 +-
 src/easydiffraction/display/progress.py       |  31 ++--
 src/easydiffraction/display/tablers/rich.py   |   1 +
 src/easydiffraction/project/display.py        |  17 ++-
 .../fitting/test_bayesian_tracker_and_base.py | 142 ++++++++++++------
 .../analysis/test_sequential.py               | 131 ++++++++++++++++
 .../display/tablers/test_pandas.py            |  12 ++
 .../display/tablers/test_rich.py              |   6 +-
 .../easydiffraction/display/test_progress.py  |  86 +++++++++++
 .../easydiffraction/display/test_tables.py    |  29 ++++
 .../easydiffraction/project/test_display.py   |  33 +++-
 12 files changed, 441 insertions(+), 77 deletions(-)
 create mode 100644 tests/unit/easydiffraction/display/test_progress.py

diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py
index b9035c66..fe0d6381 100644
--- a/src/easydiffraction/analysis/fit_helpers/tracking.py
+++ b/src/easydiffraction/analysis/fit_helpers/tracking.py
@@ -5,10 +5,13 @@
 
 import time
 from dataclasses import dataclass
-
-import numpy as np
+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_PROCESSING
+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
@@ -16,6 +19,9 @@
 from easydiffraction.utils.logging import console
 from easydiffraction.utils.utils import build_table_renderable
 
+if TYPE_CHECKING:
+    import numpy as np
+
 SIGNIFICANT_CHANGE_THRESHOLD = 0.01  # 1% threshold
 SAMPLER_PROGRESS_UPDATE_SECONDS = 5.0
 TRACKING_MODE_FIT = 'fit'
@@ -24,10 +30,6 @@
 DEFAULT_ALIGNMENTS = ['center', 'center', 'center', 'center']
 SAMPLER_HEADERS = ['iteration', 'progress', 'time (s)', 'log posterior', 'phase']
 SAMPLER_ALIGNMENTS = ['center', 'center', 'center', 'center', 'center']
-ACTIVITY_LABEL_BURN_IN = 'burn-in'
-ACTIVITY_LABEL_FITTING = 'fitting'
-ACTIVITY_LABEL_PROCESSING = 'processing'
-ACTIVITY_LABEL_SAMPLING = 'sampling'
 
 _TerminalLiveHandle = _SharedTerminalLiveHandle
 
@@ -440,7 +442,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 ACTIVITY_LABEL_SAMPLING,
+            self._last_sampler_phase or TRACKING_MODE_SAMPLER,
         ]
 
     def _finalize_fit_tracking_row(self) -> None:
@@ -565,14 +567,15 @@ def _replace_last_tracking_row(self, row: list[str]) -> None:
 
     def _default_activity_label(self) -> str:
         if self._tracking_mode == TRACKING_MODE_SAMPLER:
-            return ACTIVITY_LABEL_SAMPLING
+            return ACTIVITY_LABEL_PROCESSING
         return ACTIVITY_LABEL_FITTING
 
-    def _activity_label_for_sampler_phase(self, phase: str) -> str:
+    @staticmethod
+    def _activity_label_for_sampler_phase(phase: str) -> str:
         normalized_phase = phase.strip().lower()
-        if normalized_phase == ACTIVITY_LABEL_BURN_IN:
+        if normalized_phase == 'burn-in':
             return ACTIVITY_LABEL_BURN_IN
-        if normalized_phase == ACTIVITY_LABEL_SAMPLING:
+        if normalized_phase == 'sampling':
             return ACTIVITY_LABEL_SAMPLING
         if normalized_phase:
             return normalized_phase
@@ -618,4 +621,4 @@ def _table_renderable(self) -> object:
             columns_headers=self._headers(),
             columns_alignment=self._alignments(),
             columns_data=self._df_rows,
-        )
\ No newline at end of file
+        )
diff --git a/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py
index c57bfdfb..feb53366 100644
--- a/src/easydiffraction/analysis/sequential.py
+++ b/src/easydiffraction/analysis/sequential.py
@@ -17,6 +17,7 @@
 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
@@ -884,7 +885,7 @@ def fit_sequential(
 
     indicator = None
     if verb is not VerbosityEnum.SILENT:
-        indicator = ActivityIndicator('fitting', verbosity=verb)
+        indicator = ActivityIndicator(ACTIVITY_LABEL_FITTING, verbosity=verb)
         indicator.start()
 
     pool_cm, main_mod, main_file_bak, main_spec_bak = _create_pool_context(max_workers)
diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py
index 17cea101..08c3aa3e 100644
--- a/src/easydiffraction/display/progress.py
+++ b/src/easydiffraction/display/progress.py
@@ -5,10 +5,10 @@
 from __future__ import annotations
 
 import html
+from collections.abc import Iterator
 from contextlib import contextmanager
 from contextlib import suppress
 from time import monotonic
-from typing import Iterator
 
 try:
     from IPython.display import HTML
@@ -29,6 +29,13 @@
 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 = f'bold {ACTIVITY_ACCENT_COLOR}'
+
 SPINNER_FRAMES: tuple[str, ...] = (
     '⠋',
     '⠙',
@@ -100,7 +107,7 @@ class ActivityIndicator:
 
     Parameters
     ----------
-    label : str, default='processing'
+    label : str, default=ACTIVITY_LABEL_PROCESSING
         User-facing activity label.
     verbosity : VerbosityEnum
         Output verbosity controlling whether live display is shown.
@@ -108,7 +115,7 @@ class ActivityIndicator:
 
     def __init__(
         self,
-        label: str = 'processing',
+        label: str = ACTIVITY_LABEL_PROCESSING,
         *,
         verbosity: VerbosityEnum,
     ) -> None:
@@ -184,8 +191,8 @@ def stop(self, *, final_label: str | None = None) -> None:
         Parameters
         ----------
         final_label : str | None, default=None
-            Optional final label to leave in place after stopping.
-            When omitted, only the current content remains visible.
+            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
@@ -250,9 +257,9 @@ def _terminal_content(self) -> object | None:
     def _terminal_indicator_line(self) -> Text | None:
         if self._running:
             frame = self._current_frame()
-            return Text(f'{frame} {self._label}')
+            return Text(f'{frame} {self._label}', style=ACTIVITY_TERMINAL_STYLE)
         if self._keep_stopped_label:
-            return Text(self._label)
+            return Text(self._label, style=ACTIVITY_TERMINAL_STYLE)
         return None
 
     def _current_frame(self) -> str:
@@ -322,8 +329,10 @@ def _html_style(self) -> str:
             'display: inline-flex;'
             'align-items: center;'
             'gap: 0.45rem;'
+            f'color: {ACTIVITY_ACCENT_COLOR};'
             'font-family: ui-monospace, SFMono-Regular, Menlo, monospace;'
             'font-size: 0.95rem;'
+            'font-weight: 600;'
             'line-height: 1.1;'
             '}'
             '.ed-activity-pre {'
@@ -344,7 +353,7 @@ def _html_style(self) -> str:
 
 @contextmanager
 def activity_indicator(
-    label: str = 'processing',
+    label: str = ACTIVITY_LABEL_PROCESSING,
     *,
     verbosity: VerbosityEnum,
 ) -> Iterator[ActivityIndicator]:
@@ -353,14 +362,14 @@ def activity_indicator(
 
     Parameters
     ----------
-    label : str, default='processing'
+    label : str, default=ACTIVITY_LABEL_PROCESSING
         User-facing activity label.
     verbosity : VerbosityEnum
         Output verbosity controlling whether live display is shown.
 
     Yields
     ------
-    ActivityIndicator
+    Iterator[ActivityIndicator]
         Started indicator that is stopped on block exit.
     """
     indicator = ActivityIndicator(label, verbosity=verbosity)
@@ -368,4 +377,4 @@ def activity_indicator(
     try:
         yield indicator
     finally:
-        indicator.stop()
\ No newline at end of file
+        indicator.stop()
diff --git a/src/easydiffraction/display/tablers/rich.py b/src/easydiffraction/display/tablers/rich.py
index d3ff1c5e..b9d7031d 100644
--- a/src/easydiffraction/display/tablers/rich.py
+++ b/src/easydiffraction/display/tablers/rich.py
@@ -80,6 +80,7 @@ def build_renderable(
             DataFrame-like object providing rows to render.
         alignments : object
             Iterable of text alignment values for columns.
+
         Returns
         -------
         object
diff --git a/src/easydiffraction/project/display.py b/src/easydiffraction/project/display.py
index dbddb58c..6bab7df3 100644
--- a/src/easydiffraction/project/display.py
+++ b/src/easydiffraction/project/display.py
@@ -10,10 +10,11 @@
 from easydiffraction.datablocks.experiment.item.base import intensity_category_for
 from easydiffraction.datablocks.experiment.item.enums import SampleFormEnum
 from easydiffraction.datablocks.experiment.item.enums import ScatteringTypeEnum
-from easydiffraction.display.progress import activity_indicator
 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
@@ -130,7 +131,10 @@ def pairs(
         max_parameters: int = 6,
     ) -> None:
         """Plot posterior pair relationships for sampled parameters."""
-        with activity_indicator('processing', verbosity=VerbosityEnum(self._project.verbosity)):
+        with activity_indicator(
+            ACTIVITY_LABEL_PROCESSING,
+            verbosity=VerbosityEnum(self._project.verbosity),
+        ):
             self._project.rendering.plotter.plot_posterior_pairs(
                 parameters=parameters,
                 style=style,
@@ -153,7 +157,10 @@ def predictive(
         x: object | None = None,
     ) -> None:
         """Plot posterior predictive summaries for one experiment."""
-        with activity_indicator('processing', verbosity=VerbosityEnum(self._project.verbosity)):
+        with activity_indicator(
+            ACTIVITY_LABEL_PROCESSING,
+            verbosity=VerbosityEnum(self._project.verbosity),
+        ):
             self._project.rendering.plotter.plot_posterior_predictive(
                 expt_name=expt_name,
                 style=style,
@@ -218,7 +225,7 @@ def pattern(
                 raise ValueError(msg)
             if 'uncertainty' in auto_include:
                 with activity_indicator(
-                    'processing',
+                    ACTIVITY_LABEL_PROCESSING,
                     verbosity=VerbosityEnum(self._project.verbosity),
                 ):
                     self._project.rendering.plotter._plot_posterior_predictive_request(
@@ -252,7 +259,7 @@ def pattern(
 
         if 'uncertainty' in normalized_include:
             with activity_indicator(
-                'processing',
+                ACTIVITY_LABEL_PROCESSING,
                 verbosity=VerbosityEnum(self._project.verbosity),
             ):
                 self._project.rendering.plotter._plot_posterior_predictive_request(
diff --git a/tests/integration/fitting/test_bayesian_tracker_and_base.py b/tests/integration/fitting/test_bayesian_tracker_and_base.py
index f63cb111..85332ca3 100644
--- a/tests/integration/fitting/test_bayesian_tracker_and_base.py
+++ b/tests/integration/fitting/test_bayesian_tracker_and_base.py
@@ -27,7 +27,24 @@ 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):
+            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 +63,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 +71,24 @@ 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):
+            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 +125,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 +134,23 @@ 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):
+            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 +164,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_PROCESSING, VerbosityEnum.SHORT),
+        ('start', None),
+        ('update', tracking_mod.ACTIVITY_LABEL_PROCESSING),
+        ('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 +193,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 +241,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 +270,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_PROCESSING
+    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_PROCESSING
 
 
 def test_minimizer_base_fit_flow_and_finalize():
diff --git a/tests/unit/easydiffraction/analysis/test_sequential.py b/tests/unit/easydiffraction/analysis/test_sequential.py
index 3179a0b1..5e01b586 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/tablers/test_pandas.py b/tests/unit/easydiffraction/display/tablers/test_pandas.py
index 2a2828a9..e4a77c5b 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 '
+# 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, *, 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
+
+        def update(self, renderable, refresh=False):
+            del renderable
+            del refresh
+
+    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.started is True
+
+    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
+
+
+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
+
+
+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
diff --git a/tests/unit/easydiffraction/display/test_tables.py b/tests/unit/easydiffraction/display/test_tables.py
index 2fb16fe1..9920e7e1 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 8e8f7695..b67d7c02 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():

From 333b7d3a374d6a82bfd5300e88cbb5116eb09c78 Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 23:14:26 +0200
Subject: [PATCH 08/16] Refine progress indicator styling

---
 src/easydiffraction/display/progress.py             | 11 ++++++++---
 tests/unit/easydiffraction/display/test_progress.py |  3 +++
 2 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py
index 08c3aa3e..0036a71a 100644
--- a/src/easydiffraction/display/progress.py
+++ b/src/easydiffraction/display/progress.py
@@ -34,7 +34,7 @@
 ACTIVITY_LABEL_PROCESSING = 'Processing...'
 ACTIVITY_LABEL_SAMPLING = 'Sampling...'
 ACTIVITY_ACCENT_COLOR = '#d97706'
-ACTIVITY_TERMINAL_STYLE = f'bold {ACTIVITY_ACCENT_COLOR}'
+ACTIVITY_TERMINAL_STYLE = ACTIVITY_ACCENT_COLOR
 
 SPINNER_FRAMES: tuple[str, ...] = (
     '⠋',
@@ -330,16 +330,21 @@ def _html_style(self) -> str:
             'align-items: center;'
             'gap: 0.45rem;'
             f'color: {ACTIVITY_ACCENT_COLOR};'
-            'font-family: ui-monospace, SFMono-Regular, Menlo, monospace;'
             'font-size: 0.95rem;'
-            'font-weight: 600;'
+            'font-weight: 400;'
             'line-height: 1.1;'
             '}'
+            '.ed-activity-label {'
+            'font-family: var(--jp-ui-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);'
+            '}'
             '.ed-activity-pre {'
             'margin: 0;'
             'font-family: ui-monospace, SFMono-Regular, Menlo, monospace;'
             'white-space: pre-wrap;'
             '}'
+            '.ed-activity-spinner {'
+            'font-family: ui-monospace, SFMono-Regular, Menlo, monospace;'
+            '}'
             '.ed-activity-spinner::before {'
             f'animation: ed-activity-frames {_JUPYTER_SPINNER_SECONDS}s steps(1) infinite;'
             'content: "⠋";'
diff --git a/tests/unit/easydiffraction/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py
index 88bae1a0..fa961ff3 100644
--- a/tests/unit/easydiffraction/display/test_progress.py
+++ b/tests/unit/easydiffraction/display/test_progress.py
@@ -61,6 +61,8 @@ def test_activity_indicator_html_style_prevents_stretching():
 
     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():
@@ -73,6 +75,7 @@ def test_activity_indicator_terminal_line_uses_accent_style():
 
     assert line is not None
     assert line.style == progress_mod.ACTIVITY_TERMINAL_STYLE
+    assert 'bold' not in str(line.style)
 
 
 def test_activity_indicator_render_html_uses_current_label():

From 2f9cb6b83309dff2795abaaa82c6bfdb2c75b618 Mon Sep 17 00:00:00 2001
From: Andrew Sazonov 
Date: Thu, 14 May 2026 23:31:58 +0200
Subject: [PATCH 09/16] Refactor progress indicator context manager

---
 docs/dev/package-structure-full.md            |  5 +-
 docs/dev/package-structure-short.md           |  1 +
 src/easydiffraction/analysis/sequential.py    |  9 +-
 src/easydiffraction/display/progress.py       | 86 +++++++++++--------
 src/easydiffraction/display/tablers/rich.py   |  4 +-
 .../analysis/fit_helpers/test_tracking.py     |  6 +-
 .../easydiffraction/display/test_progress.py  |  2 +-
 7 files changed, 65 insertions(+), 48 deletions(-)

diff --git a/docs/dev/package-structure-full.md b/docs/dev/package-structure-full.md
index e71f099d..dccf421a 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 573142da..b4e46ec7 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/src/easydiffraction/analysis/sequential.py b/src/easydiffraction/analysis/sequential.py
index feb53366..a52d38b1 100644
--- a/src/easydiffraction/analysis/sequential.py
+++ b/src/easydiffraction/analysis/sequential.py
@@ -850,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,
     )
diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py
index 0036a71a..8d9b4b68 100644
--- a/src/easydiffraction/display/progress.py
+++ b/src/easydiffraction/display/progress.py
@@ -5,10 +5,13 @@
 from __future__ import annotations
 
 import html
-from collections.abc import Iterator
-from contextlib import contextmanager
+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
@@ -17,10 +20,7 @@
     HTML = None
     DisplayHandle = None
 
-from rich.console import Console
-from rich.console import ConsoleOptions
 from rich.console import Group
-from rich.console import RenderResult
 from rich.live import Live
 from rich.protocol import is_renderable
 from rich.text import Text
@@ -152,7 +152,7 @@ def start(self) -> None:
             return
 
         live = Live(
-            self,
+            self._terminal_renderable(),
             console=ConsoleManager.get(),
             auto_refresh=True,
             refresh_per_second=1 / _SPINNER_FRAME_SECONDS,
@@ -207,15 +207,8 @@ def stop(self, *, final_label: str | None = None) -> None:
         self._live = None
         self._display_handle = None
 
-    def __rich_console__(
-        self,
-        console: Console,
-        options: ConsoleOptions,
-    ) -> RenderResult:
-        """Yield a Rich renderable for the current activity state."""
-        del console
-        del options
-
+    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:
@@ -226,14 +219,12 @@ def __rich_console__(
             renderables.append(indicator_line)
 
         if not renderables:
-            yield Text('')
-            return
+            return Text('')
 
         if len(renderables) == 1:
-            yield renderables[0]
-            return
+            return renderables[0]
 
-        yield Group(*renderables)
+        return Group(*renderables)
 
     def _refresh(self) -> None:
         if self._verbosity is VerbosityEnum.SILENT:
@@ -245,7 +236,7 @@ def _refresh(self) -> None:
 
         if self._live is not None:
             with suppress(Exception):
-                self._live.refresh()
+                self._live.update(self._terminal_renderable(), refresh=True)
 
     def _terminal_content(self) -> object | None:
         if self._content is None:
@@ -303,11 +294,16 @@ def _html_indicator(self) -> str:
             )
 
         if self._keep_stopped_label:
-            return f'
{safe_label}
' + return ( + '
' + f'{safe_label}' + '
' + ) return '' - def _html_style(self) -> str: + @staticmethod + def _html_style() -> str: keyframes = [] total_frames = len(SPINNER_FRAMES) for index, frame in enumerate(SPINNER_FRAMES): @@ -335,7 +331,8 @@ def _html_style(self) -> str: 'line-height: 1.1;' '}' '.ed-activity-label {' - 'font-family: var(--jp-ui-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);' + 'font-family: var(--jp-ui-font-family, -apple-system, BlinkMacSystemFont, ' + '"Segoe UI", sans-serif);' '}' '.ed-activity-pre {' 'margin: 0;' @@ -356,12 +353,33 @@ def _html_style(self) -> str: ) -@contextmanager +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, -) -> Iterator[ActivityIndicator]: +) -> AbstractContextManager[ActivityIndicator]: """ Manage an activity indicator around a block of work. @@ -372,14 +390,10 @@ def activity_indicator( verbosity : VerbosityEnum Output verbosity controlling whether live display is shown. - Yields - ------ - Iterator[ActivityIndicator] - Started indicator that is stopped on block exit. + Returns + ------- + AbstractContextManager[ActivityIndicator] + Context manager that starts the indicator on entry and stops it + on exit. """ - indicator = ActivityIndicator(label, verbosity=verbosity) - indicator.start() - try: - yield indicator - finally: - indicator.stop() + return _ActivityIndicatorContext(label=label, verbosity=verbosity) diff --git a/src/easydiffraction/display/tablers/rich.py b/src/easydiffraction/display/tablers/rich.py index b9d7031d..e23334be 100644 --- a/src/easydiffraction/display/tablers/rich.py +++ b/src/easydiffraction/display/tablers/rich.py @@ -76,10 +76,10 @@ def build_renderable( Parameters ---------- - df : object - DataFrame-like object providing rows to render. alignments : object Iterable of text alignment values for columns. + df : object + DataFrame-like object providing rows to render. Returns ------- diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py index 561eb54c..6239c5ef 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') diff --git a/tests/unit/easydiffraction/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py index fa961ff3..857bdbe5 100644 --- a/tests/unit/easydiffraction/display/test_progress.py +++ b/tests/unit/easydiffraction/display/test_progress.py @@ -23,7 +23,7 @@ def start(self): def stop(self): self.stopped = True - def update(self, renderable, refresh=False): + def update(self, renderable, *, refresh: bool | None = None): del renderable del refresh From e5b71eb10b128e3e4883e210b8125cadafa92988 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 14 May 2026 23:44:11 +0200 Subject: [PATCH 10/16] Format posterior predictive call --- docs/docs/tutorials/ed-21.ipynb | 6 +----- docs/docs/tutorials/ed-21.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/docs/tutorials/ed-21.ipynb b/docs/docs/tutorials/ed-21.ipynb index 33f77196..f0d5123d 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 e890f55b..7c6f9bdb 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) From 760435628a79044db0f24dfbce376cc9d3f24a88 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 14 May 2026 23:44:42 +0200 Subject: [PATCH 11/16] Rename 95% interval, remove 68% interval --- src/easydiffraction/display/plotters/plotly.py | 2 +- src/easydiffraction/display/plotting.py | 14 ++------------ .../display/plotters/test_plotly.py | 4 +++- .../unit/easydiffraction/display/test_plotting.py | 9 ++------- 4 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/easydiffraction/display/plotters/plotly.py b/src/easydiffraction/display/plotters/plotly.py index d41c1f9e..fbbda288 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 a258579e..4ddffaf5 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 = [ @@ -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/tests/unit/easydiffraction/display/plotters/test_plotly.py b/tests/unit/easydiffraction/display/plotters/test_plotly.py index 5c3c8dd5..5b583622 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/test_plotting.py b/tests/unit/easydiffraction/display/test_plotting.py index 08540bf0..18f0afe8 100644 --- a/tests/unit/easydiffraction/display/test_plotting.py +++ b/tests/unit/easydiffraction/display/test_plotting.py @@ -607,7 +607,6 @@ def test_build_posterior_pairs_plot_rejects_unknown_style(): def test_build_param_distribution_plot_returns_plotly_figure(): - from easydiffraction.display.plotting import POSTERIOR_INTERVAL_68_FILL_COLOR from easydiffraction.display.plotting import POSTERIOR_PAIR_MARGINAL_DENSITY_FILL_COLOR from easydiffraction.display.plotting import POSTERIOR_PAIR_MARGINAL_DENSITY_LINE_COLOR from easydiffraction.display.plotting import POSTERIOR_PAIR_MARGINAL_DENSITY_LINE_WIDTH @@ -623,16 +622,12 @@ def test_build_param_distribution_plot_returns_plotly_figure(): assert {trace.name for trace in figure.data} >= { '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 From 268d6b5e8f83418e5477b78c1a0c2afd543b09ac Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Thu, 14 May 2026 23:54:42 +0200 Subject: [PATCH 12/16] Refactor progress indicator to use dynamic renderable --- src/easydiffraction/display/plotting.py | 2 +- src/easydiffraction/display/progress.py | 4 +- .../easydiffraction/display/test_progress.py | 49 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/easydiffraction/display/plotting.py b/src/easydiffraction/display/plotting.py index 4ddffaf5..2ca6dff0 100644 --- a/src/easydiffraction/display/plotting.py +++ b/src/easydiffraction/display/plotting.py @@ -1174,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( diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py index 8d9b4b68..e30e9e35 100644 --- a/src/easydiffraction/display/progress.py +++ b/src/easydiffraction/display/progress.py @@ -152,10 +152,10 @@ def start(self) -> None: return live = Live( - self._terminal_renderable(), console=ConsoleManager.get(), auto_refresh=True, refresh_per_second=1 / _SPINNER_FRAME_SECONDS, + get_renderable=self._terminal_renderable, ) live.start() self._live = live @@ -236,7 +236,7 @@ def _refresh(self) -> None: if self._live is not None: with suppress(Exception): - self._live.update(self._terminal_renderable(), refresh=True) + self._live.refresh() def _terminal_content(self) -> object | None: if self._content is None: diff --git a/tests/unit/easydiffraction/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py index 857bdbe5..bac4ad55 100644 --- a/tests/unit/easydiffraction/display/test_progress.py +++ b/tests/unit/easydiffraction/display/test_progress.py @@ -78,6 +78,55 @@ def test_activity_indicator_terminal_line_uses_accent_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 From b5d0671aa87cfeadd8872344b6303d4bc43a039a Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 00:03:13 +0200 Subject: [PATCH 13/16] Share display handle across multi-experiment fits --- src/easydiffraction/analysis/analysis.py | 58 ++++++----- .../analysis/fit_helpers/tracking.py | 9 +- src/easydiffraction/display/progress.py | 52 ++++++++-- .../fitting/test_bayesian_tracker_and_base.py | 17 ++-- .../easydiffraction/analysis/test_analysis.py | 95 +++++++++++++++++++ .../easydiffraction/display/test_progress.py | 25 ++++- 6 files changed, 209 insertions(+), 47 deletions(-) diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 9c5478ec..a2a4f7de 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -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( diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index fe0d6381..effb6a65 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -87,6 +87,7 @@ def __init__(self) -> None: self._df_rows: list[list[str]] = [] 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.""" @@ -567,7 +568,7 @@ def _replace_last_tracking_row(self, row: list[str]) -> None: def _default_activity_label(self) -> str: if self._tracking_mode == TRACKING_MODE_SAMPLER: - return ACTIVITY_LABEL_PROCESSING + return ACTIVITY_LABEL_SAMPLING return ACTIVITY_LABEL_FITTING @staticmethod @@ -579,12 +580,16 @@ def _activity_label_for_sampler_phase(phase: str) -> str: return ACTIVITY_LABEL_SAMPLING if normalized_phase: return normalized_phase - return ACTIVITY_LABEL_PROCESSING + 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() diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py index e30e9e35..0dfe1343 100644 --- a/src/easydiffraction/display/progress.py +++ b/src/easydiffraction/display/progress.py @@ -60,8 +60,21 @@ class _TerminalLiveHandle: and notebook handles through a single update-oriented interface. """ - def __init__(self, live: object) -> None: - self._live = live + 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: """ @@ -72,7 +85,8 @@ def update(self, renderable: object) -> None: renderable : object A Rich-compatible renderable to display. """ - self._live.update(renderable, refresh=True) + self._renderable = renderable + self._live.refresh() def close(self) -> None: """Stop the live display, suppressing any errors.""" @@ -96,9 +110,7 @@ def make_display_handle() -> object | None: handle.display(HTML('')) return handle - live = Live(console=ConsoleManager.get(), auto_refresh=True) - live.start() - return _TerminalLiveHandle(live) + return _TerminalLiveHandle(console=ConsoleManager.get()) class ActivityIndicator: @@ -111,6 +123,8 @@ class ActivityIndicator: 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__( @@ -118,10 +132,12 @@ def __init__( 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 @@ -144,6 +160,11 @@ def start(self) -> None: 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 @@ -231,13 +252,28 @@ def _refresh(self) -> None: return if self._display_handle is not None and HTML is not None: - with suppress(Exception): - self._display_handle.update(HTML(self._render_html())) + 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 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 diff --git a/tests/integration/fitting/test_bayesian_tracker_and_base.py b/tests/integration/fitting/test_bayesian_tracker_and_base.py index 85332ca3..e6988a0f 100644 --- a/tests/integration/fitting/test_bayesian_tracker_and_base.py +++ b/tests/integration/fitting/test_bayesian_tracker_and_base.py @@ -30,7 +30,8 @@ def test_tracker_terminal_flow_prints_and_updates_best(monkeypatch, capsys): events: list[tuple[str, object]] = [] class FakeIndicator: - def __init__(self, label, *, verbosity): + def __init__(self, label, *, verbosity, display_handle=None): + del display_handle events.append(('init', label, verbosity)) def start(self): @@ -74,7 +75,8 @@ def test_tracker_sampler_progress_renders_and_completes(monkeypatch, capsys): events: list[tuple[str, object]] = [] class FakeIndicator: - def __init__(self, label, *, verbosity): + def __init__(self, label, *, verbosity, display_handle=None): + del display_handle events.append(('init', label, verbosity)) def start(self): @@ -137,7 +139,8 @@ def test_tracker_helper_error_paths_and_short_mode(monkeypatch): events: list[tuple[str, object]] = [] class FakeIndicator: - def __init__(self, label, *, verbosity): + def __init__(self, label, *, verbosity, display_handle=None): + del display_handle events.append(('init', label, verbosity)) def start(self): @@ -165,9 +168,9 @@ def stop(self): assert FitProgressTracker._rows_match_on_columns(['1', 'a'], ['1', 'b'], (0,)) is True assert events == [ - ('init', tracking_mod.ACTIVITY_LABEL_PROCESSING, VerbosityEnum.SHORT), + ('init', tracking_mod.ACTIVITY_LABEL_SAMPLING, VerbosityEnum.SHORT), ('start', None), - ('update', tracking_mod.ACTIVITY_LABEL_PROCESSING), + ('update', tracking_mod.ACTIVITY_LABEL_SAMPLING), ('stop', None), ] @@ -271,7 +274,7 @@ def test_tracker_final_rows_cover_fallbacks_and_activity_labels(): tracker._fitting_time = 1.5 assert tracker._final_fit_tracking_row() == ['8', '1.50', '', ''] tracker._tracking_mode = tracking_mod.TRACKING_MODE_SAMPLER - assert tracker._default_activity_label() == tracking_mod.ACTIVITY_LABEL_PROCESSING + 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 ( @@ -282,7 +285,7 @@ def test_tracker_final_rows_cover_fallbacks_and_activity_labels(): == 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_PROCESSING + 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/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index c5b2ef24..637facc0 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/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py index bac4ad55..e044f0cf 100644 --- a/tests/unit/easydiffraction/display/test_progress.py +++ b/tests/unit/easydiffraction/display/test_progress.py @@ -11,11 +11,23 @@ def test_make_display_handle_uses_terminal_live_when_available(monkeypatch): import easydiffraction.display.progress as progress_mod class FakeLive: - def __init__(self, *, console, auto_refresh): + 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 @@ -23,9 +35,8 @@ def start(self): def stop(self): self.stopped = True - def update(self, renderable, *, refresh: bool | None = None): - del renderable - del refresh + def refresh(self): + self.refresh_calls += 1 monkeypatch.setattr(progress_mod, 'in_jupyter', lambda: False) monkeypatch.setattr(progress_mod, 'Live', FakeLive) @@ -36,8 +47,14 @@ def update(self, renderable, *, refresh: bool | None = None): 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 From 1b2338a6b0cdd61588f9e7795aac05c7005c410f Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 00:07:04 +0200 Subject: [PATCH 14/16] Add timed progress updates for fit tracking --- .../analysis/fit_helpers/tracking.py | 22 ++++++++++-- .../analysis/fit_helpers/test_tracking.py | 34 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index effb6a65..8ea2c418 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -10,7 +10,6 @@ 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_PROCESSING from easydiffraction.display.progress import ACTIVITY_LABEL_SAMPLING from easydiffraction.display.progress import ActivityIndicator from easydiffraction.display.progress import _TerminalLiveHandle as _SharedTerminalLiveHandle @@ -23,6 +22,7 @@ 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' @@ -147,15 +147,17 @@ def track( return residuals row: list[str] = [] + elapsed_time = self._current_elapsed_time() 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}', '', ] @@ -167,12 +169,21 @@ def track( 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 if row: self.add_tracking_info(row) @@ -538,6 +549,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], diff --git a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py index 6239c5ef..877a46b1 100644 --- a/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py +++ b/tests/unit/easydiffraction/analysis/fit_helpers/test_tracking.py @@ -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 From 563bf0b2ba39ea7a4826028dc7274184b84e9186 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 00:20:46 +0200 Subject: [PATCH 15/16] Prevent progress indicator errors on exception --- .../analysis/fit_helpers/tracking.py | 11 +++- src/easydiffraction/display/progress.py | 8 ++- .../analysis/minimizers/test_base.py | 43 +++++++++++++++ .../easydiffraction/display/test_progress.py | 55 +++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index 8ea2c418..b133ef22 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -3,6 +3,7 @@ from __future__ import annotations +import sys import time from dataclasses import dataclass from typing import TYPE_CHECKING @@ -328,9 +329,14 @@ def finish_tracking(self) -> None: return self._stop_activity_indicator() - if self._verbosity is VerbosityEnum.FULL: + if self._verbosity is VerbosityEnum.FULL and not self._cleanup_during_exception(): self._print_completion_summary() + @staticmethod + def _cleanup_during_exception() -> bool: + """Return whether tracking cleanup runs while handling an error.""" + return sys.exc_info()[0] is not None + def _initial_sampler_progress_row( self, *, @@ -514,6 +520,9 @@ def _print_completion_summary(self) -> None: 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}' diff --git a/src/easydiffraction/display/progress.py b/src/easydiffraction/display/progress.py index 0dfe1343..1cde4d1c 100644 --- a/src/easydiffraction/display/progress.py +++ b/src/easydiffraction/display/progress.py @@ -251,7 +251,7 @@ def _refresh(self) -> None: if self._verbosity is VerbosityEnum.SILENT: return - if self._display_handle is not None and HTML is not None: + if self._display_handle is not None: self._refresh_display_handle() return @@ -263,7 +263,11 @@ def _refresh_display_handle(self) -> None: if self._display_handle is None: return - if DisplayHandle is not None and isinstance(self._display_handle, DisplayHandle): + 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 diff --git a/tests/unit/easydiffraction/analysis/minimizers/test_base.py b/tests/unit/easydiffraction/analysis/minimizers/test_base.py index 0ad0ca12..3b18f535 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/display/test_progress.py b/tests/unit/easydiffraction/display/test_progress.py index e044f0cf..f690159e 100644 --- a/tests/unit/easydiffraction/display/test_progress.py +++ b/tests/unit/easydiffraction/display/test_progress.py @@ -153,3 +153,58 @@ def test_activity_indicator_render_html_uses_current_label(): 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...' From d340581a31a0e28b36637581298d5628bf7afd27 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Fri, 15 May 2026 00:21:09 +0200 Subject: [PATCH 16/16] Remove completed progress indicator plan --- .../progress-activity-indicator.md | 407 ------------------ .../analysis/fit_helpers/tracking.py | 4 +- 2 files changed, 3 insertions(+), 408 deletions(-) delete mode 100644 docs/dev/implementation-plans/progress-activity-indicator.md 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 e776e012..00000000 --- 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/src/easydiffraction/analysis/fit_helpers/tracking.py b/src/easydiffraction/analysis/fit_helpers/tracking.py index b133ef22..0915ae92 100644 --- a/src/easydiffraction/analysis/fit_helpers/tracking.py +++ b/src/easydiffraction/analysis/fit_helpers/tracking.py @@ -334,7 +334,9 @@ def finish_tracking(self) -> None: @staticmethod def _cleanup_during_exception() -> bool: - """Return whether tracking cleanup runs while handling an error.""" + """ + Return whether cleanup runs during exception handling. + """ return sys.exc_info()[0] is not None def _initial_sampler_progress_row(