From daf8566f48bbd53106fe5344f5e80fe602cb8880 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Tue, 30 Dec 2025 21:07:04 +0100 Subject: [PATCH 1/2] Improve CI workflows and fix documentation deployment (#117) * Serializes docs deploy; fetches gh-pages branch * Clarifies docs CI and versioned deploy with mike * Includes pytest.ini in PyPI test workflow * Removes PyPI extras install from test workflow * Updates CI to use new tutorials download command * Replaces PR backmerge with direct merge on tags * Update .github/workflows/backmerge.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/docs.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/backmerge.yaml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/backmerge-pr.yaml | 81 --------------------------- .github/workflows/backmerge.yaml | 66 ++++++++++++++++++++++ .github/workflows/docs.yaml | 85 +++++++++++++++++------------ .github/workflows/pypi-test.yaml | 6 +- 4 files changed, 117 insertions(+), 121 deletions(-) delete mode 100644 .github/workflows/backmerge-pr.yaml create mode 100644 .github/workflows/backmerge.yaml diff --git a/.github/workflows/backmerge-pr.yaml b/.github/workflows/backmerge-pr.yaml deleted file mode 100644 index 868a7689..00000000 --- a/.github/workflows/backmerge-pr.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# This workflow creates a backmerge PR into `develop` whenever a new version tag is pushed (v*). -# -# Key points: -# - Creates a PR from `master` to `develop` when a new tag is pushed. -# - The PR is auto-merged using a MERGE COMMIT (not squash) via a GitHub App token. -# The GitHub App must be added to the develop ruleset bypass list. -# -# Required repo config: -# https://github.com/organizations/easyscience/settings/secrets/actions -# https://github.com/organizations/easyscience/settings/variables/actions -# - Actions secret: ES_BACKMERGE_PRIVATE_KEY (GitHub App private key PEM) -# - Actions variable: ES_BACKMERGE_APP_ID (GitHub App ID) - -name: Backmerge PR (master -> develop) - -on: - push: - tags: ['v*'] - -permissions: - contents: write - pull-requests: write - -jobs: - create-backmerge-pr: - runs-on: ubuntu-latest - - steps: - - name: Create GitHub App installation token - id: app-token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ vars.BACKMERGE_APP_ID }} - private-key: ${{ secrets.BACKMERGE_PRIVATE_KEY }} - - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - token: ${{ steps.app-token.outputs.token }} - - - name: Create PR from master to develop (or reuse if exists) - id: pr - run: | - set -euo pipefail - - TAG='${{ github.ref_name }}' - - TITLE="Backmerge: ${TAG} from master into develop" - - BODY="⚠️ This PR is created automatically for backmerge of the release tag \`${TAG}\` from \`master\` into \`develop\`. - - It is labeled \`[maintainer] auto-pull-request\` and is excluded from release notes and version bump logic." - - # Check if a PR from master to develop already exists - EXISTING_PR=$(gh pr list --repo "${{ github.repository }}" --base develop --head master --state open --json number --jq '.[0].number // empty') - - if [ -n "$EXISTING_PR" ]; then - echo "PR #${EXISTING_PR} already exists for master -> develop" - echo "pr_number=${EXISTING_PR}" >> "$GITHUB_OUTPUT" - else - PR_URL=$(gh pr create \ - --repo "${{ github.repository }}" \ - --base develop \ - --head master \ - --title "$TITLE" \ - --label "[maintainer] auto-pull-request" \ - --body "$BODY") - PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') - echo "Created PR #${PR_NUMBER}" - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - fi - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} - - - name: Enable auto-merge using MERGE COMMIT - run: | - set -euo pipefail - gh pr merge --repo "${{ github.repository }}" --merge --auto "${{ steps.pr.outputs.pr_number }}" - env: - GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/backmerge.yaml b/.github/workflows/backmerge.yaml new file mode 100644 index 00000000..9d3ebe75 --- /dev/null +++ b/.github/workflows/backmerge.yaml @@ -0,0 +1,66 @@ +# This workflow automatically merges `master` into `develop` whenever a new version tag is pushed (v*). +# +# Key points: +# - Directly merges master into develop without creating a PR. +# - Skips CI on the merge commit using [skip ci] in the commit message. +# - The code being merged has already been tested as part of the release process. +# - This ensures develop stays up-to-date with release changes (version bumps, etc.). +# +# Required repo config: +# https://github.com/organizations/easyscience/settings/secrets/actions +# https://github.com/organizations/easyscience/settings/variables/actions +# - Actions secret: BACKMERGE_PRIVATE_KEY (GitHub App private key PEM) +# - Actions variable: BACKMERGE_APP_ID (GitHub App ID) +# The GitHub App must be added to the develop branch ruleset bypass list. + +name: Backmerge (master -> develop) + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + backmerge: + runs-on: ubuntu-latest + + steps: + - name: Create GitHub App installation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.BACKMERGE_APP_ID }} + private-key: ${{ secrets.BACKMERGE_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Configure git + run: | + git config user.name "es-backmerge[bot]" + git config user.email "${{ vars.BACKMERGE_APP_ID }}+es-backmerge[bot]@users.noreply.github.com" + + - name: Merge master into develop + run: | + set -euo pipefail + + TAG='${{ github.ref_name }}' + + # Ensure local develop branch exists and is up-to-date with origin + git fetch origin develop:develop + # Switch to develop branch + git checkout develop + + # Merge master into develop (no fast-forward to preserve history) + # Use [skip ci] to avoid triggering CI - the code was already tested on master + git merge origin/master --no-ff -m "Backmerge: ${TAG} from master into develop [skip ci]" + + # Push the merge commit to develop + git push origin develop + + echo "✅ Successfully merged master (${TAG}) into develop" diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 46542289..6f503927 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,18 +1,15 @@ # This workflow builds and deploys documentation for the project. # -# Steps overview: -# Job 1: build-docs -# - Builds documentation (including running Jupyter notebooks to generate output cells). -# - Uploads the built site as a Pages artifact. -# Job 2: deploy-dev -# - Deploys the built site to GitHub Pages for development (pushes to develop/master). -# Job 3: deploy-prod -# - Deploys the built site to gh-pages branch for production (release tags starting with v*). -# - This triggers deployment to a custom domain via webhook. +# Overview: +# - Converts tutorial Python scripts to Jupyter notebooks and executes them. +# - Builds the documentation site using MkDocs with the Material theme. +# - Uploads the built site as an artifact for local inspection. +# - Deploys versioned documentation to the gh-pages branch using Mike: +# - For release tags (v*): deploys to a versioned folder (e.g., /0.9.1/) and updates /latest/. +# - For branches: deploys to /dev/. # -# The action summary page will contain links to the built artifact for downloading -# and inspecting, as well as links to both the development and production versions of -# the deployed documentation site. +# The action summary page will contain a link to the built artifact for downloading +# and inspecting, as well as a link to the deployed documentation site. name: Docs build and deployment @@ -30,12 +27,12 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# Allow only one concurrent workflow, skipping runs queued between the run -# in-progress and latest queued. And cancel in-progress runs. +# Allow only one concurrent deployment to gh-pages at a time. +# All docs workflows share the same concurrency group to prevent race conditions +# when multiple branches/tags trigger simultaneous deployments. concurrency: - group: - ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + group: docs-gh-pages-deploy + cancel-in-progress: false # Set the environment variables to be used in all jobs defined in this workflow env: @@ -53,16 +50,17 @@ env: NOTEBOOKS_DIR: tutorials jobs: - # Job 1: Build the static files for the documentation site + # Single job that builds and deploys documentation. + # Uses macOS runner for consistent Plotly chart rendering. build-deploy-docs: strategy: matrix: - os: [macos-14] # Use macOS to switch to dark mode for Plotly charts + os: [macos-14] runs-on: ${{ matrix.os }} permissions: - contents: write # required for pushing to gh-pages branch + contents: write # Required for pushing to the gh-pages branch steps: # Setting DOCS_VERSION to be used in mkdocs.yml, and then in the @@ -87,10 +85,10 @@ jobs: echo "DOCS_VERSION=${DOCS_VERSION}" >> "$GITHUB_ENV" echo "DEPLOYMENT_URL=https://easyscience.github.io/${{ github.event.repository.name }}/${DOCS_VERSION}" >> "$GITHUB_ENV" + # Check out the repository source code. + # Note: The gh-pages branch is fetched separately later for mike deployment. - name: Check-out repository uses: actions/checkout@v5 - with: - fetch-depth: 0 # full history with tags; needed for mike to push/deploy docs # Activate dark mode to create documentation with Plotly charts in dark mode # Need a better solution to automatically switch the chart colour theme based on the mkdocs material switcher @@ -103,6 +101,8 @@ jobs: # dark-mode on # dark-mode status + # Set up the pixi package manager and install dependencies from pixi.toml. + # Uses frozen lockfile to ensure reproducible builds. - name: Set up pixi uses: prefix-dev/setup-pixi@v0.9.0 with: @@ -113,37 +113,43 @@ jobs: cache: false post-cleanup: false + # Install additional development dependencies (e.g., pre-commit hooks, dev tools). - name: Install and setup development dependencies shell: bash run: pixi run dev + # Clone shared documentation assets and branding resources from external repositories. + # These contain common MkDocs configuration, templates, stylesheets, and images. - name: Clone easyscience/assets-docs and easyscience/assets-branding run: | cd .. git clone https://github.com/easyscience/assets-docs.git git clone https://github.com/easyscience/assets-branding.git + # Copy assets from the cloned repositories into the docs/ directory. + # This includes stylesheets, images, templates, and other shared resources. - name: Add files from cloned repositories run: pixi run docs-assets - # Convert python scripts in the notebooks directory to Jupyter notebooks - # Strip output from the notebooks - # Prepare the notebooks for documentation (add colab cell, etc.) - # Run the notebooks to generate the output cells using multiple cores - # The notebooks are then used to build the documentation site + # Convert Python scripts in the tutorials/ directory to Jupyter notebooks. + # This step also strips any existing output from the notebooks and prepares + # them for documentation. - name: Convert tutorial scripts to notebooks run: pixi run notebook-prepare - # The following step is needed to avoid the following message during the build: - # "Matplotlib is building the font cache; this may take a moment" + # Pre-import the main package to trigger Matplotlib font cache building. + # This avoids "Matplotlib is building the font cache" messages during notebook execution. - name: Pre-build site step run: pixi run python -c "import easydiffraction" - # Run the notebooks to generate the output cells using multiple cores + # Execute all Jupyter notebooks to generate output cells (plots, tables, etc.). + # Uses multiple cores for parallel execution to speed up the process. - name: Run notebooks # if: false # Temporarily disabled to speed up the docs build run: pixi run notebook-exec + # Move the executed notebooks to docs/tutorials/ directory + # so they can be included in the documentation site. - name: Move notebooks to docs/tutorials run: pixi run docs-notebooks @@ -170,17 +176,24 @@ jobs: if-no-files-found: 'error' compression-level: 0 - # Publish versioned docs with Mike (dev on branches, prod on tags) - # Configure git for pushing to gh-pages branch + # Configure git user for mike to commit and push to gh-pages branch. - name: Configure git for pushing run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Publish versioned docs with Mike. The tagged release docs go to - # the versioned prefix, e.g. /v1.2.3/, and also to /latest/. - # All other branches go to /dev/. - # "${RELEASE_VERSION#v}" for v0.8.3.post1 -> 0.8.3.post1 + # Fetch the gh-pages branch to ensure mike has the latest remote state. + # This is required because the checkout step only fetches the source branch, + # not the gh-pages branch that mike needs to update. + - name: Fetch gh-pages branch + run: | + git fetch origin gh-pages:gh-pages 2>/dev/null || true + + # Deploy versioned documentation using mike (MkDocs plugin for versioning). + # - For release tags (v*): deploys to versioned folder (e.g., /0.9.1/) and aliases to /latest/. + # - For branches: deploys to /dev/. + # The "${RELEASE_VERSION#v}" syntax strips the 'v' prefix (v0.9.1 -> 0.9.1). + # Also sets 'latest' as the default version for the version selector. - name: Rebuild and deploy docs with mike run: | if [[ "${IS_RELEASE_TAG}" == "true" ]]; then diff --git a/.github/workflows/pypi-test.yaml b/.github/workflows/pypi-test.yaml index 38f439e5..47e46068 100644 --- a/.github/workflows/pypi-test.yaml +++ b/.github/workflows/pypi-test.yaml @@ -50,14 +50,12 @@ jobs: unzip ${DEFAULT_BRANCH}.zip -d . mkdir -p tests cp -r diffraction-lib-${DEFAULT_BRANCH}/tests/* tests/ + cp diffraction-lib-${DEFAULT_BRANCH}/pytest.ini . rm -rf ${DEFAULT_BRANCH}.zip diffraction-lib-${DEFAULT_BRANCH} - name: Create the environment and install dependencies run: pixi install - - name: Install package from PyPI with all extras - run: pixi add --pypi "easydiffraction[all]" - - name: Run unit tests to verify the installation run: pixi run unit-tests @@ -71,7 +69,7 @@ jobs: run: | pixi run easydiffraction --version pixi run easydiffraction list-tutorials - pixi run easydiffraction fetch-tutorials + pixi run easydiffraction download-all-tutorials - name: Test tutorials as notebooks run: pixi run notebook-tests From 828a54a43ae8f30c83102fe36fae49bcde34e7a5 Mon Sep 17 00:00:00 2001 From: Andrew Sazonov Date: Tue, 30 Dec 2025 22:45:38 +0100 Subject: [PATCH 2/2] Decouple fitting from results and add `show_fit_results` API (#118) * Decouples fit from results; adds show_fit_results * Updates tutorials for split fit/results API * Standardizes console separators to em dashes * Migrates workflows to Easyscience App auth * Grants app token access to dashboard repo --- .github/workflows/backmerge.yaml | 14 ++--- .github/workflows/dashboard.yaml | 15 ++++- .github/workflows/docs.yaml | 14 ++++- .github/workflows/release-pr.yaml | 22 ++++++-- src/easydiffraction/analysis/analysis.py | 39 ++++++++++++- src/easydiffraction/analysis/fitting.py | 14 +++-- src/easydiffraction/utils/logging.py | 6 +- .../easydiffraction/analysis/test_analysis.py | 53 ++++++++++++++++++ .../easydiffraction/analysis/test_fitting.py | 56 +++++++++++++++++++ .../unit/easydiffraction/utils/test_utils.py | 1 + tutorials/ed-1.py | 4 ++ tutorials/ed-10.py | 1 + tutorials/ed-11.py | 1 + tutorials/ed-12.py | 1 + tutorials/ed-13.py | 5 ++ tutorials/ed-2.py | 1 + tutorials/ed-3.py | 5 ++ tutorials/ed-4.py | 1 + tutorials/ed-5.py | 1 + tutorials/ed-6.py | 12 ++++ tutorials/ed-7.py | 4 ++ tutorials/ed-8.py | 1 + tutorials/ed-9.py | 1 + 23 files changed, 250 insertions(+), 22 deletions(-) diff --git a/.github/workflows/backmerge.yaml b/.github/workflows/backmerge.yaml index 9d3ebe75..f69b5379 100644 --- a/.github/workflows/backmerge.yaml +++ b/.github/workflows/backmerge.yaml @@ -9,8 +9,8 @@ # Required repo config: # https://github.com/organizations/easyscience/settings/secrets/actions # https://github.com/organizations/easyscience/settings/variables/actions -# - Actions secret: BACKMERGE_PRIVATE_KEY (GitHub App private key PEM) -# - Actions variable: BACKMERGE_APP_ID (GitHub App ID) +# - Actions secret: EASYSCIENCE_APP_KEY (GitHub App private key PEM) +# - Actions variable: EASYSCIENCE_APP_ID (GitHub App ID) # The GitHub App must be added to the develop branch ruleset bypass list. name: Backmerge (master -> develop) @@ -31,8 +31,8 @@ jobs: id: app-token uses: actions/create-github-app-token@v2 with: - app-id: ${{ vars.BACKMERGE_APP_ID }} - private-key: ${{ secrets.BACKMERGE_PRIVATE_KEY }} + app-id: ${{ vars.EASYSCIENCE_APP_ID }} + private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} - name: Checkout repository uses: actions/checkout@v5 @@ -40,10 +40,10 @@ jobs: fetch-depth: 0 token: ${{ steps.app-token.outputs.token }} - - name: Configure git + - name: Configure git for pushing run: | - git config user.name "es-backmerge[bot]" - git config user.email "${{ vars.BACKMERGE_APP_ID }}+es-backmerge[bot]@users.noreply.github.com" + git config user.name "easyscience[bot]" + git config user.email "${{ vars.EASYSCIENCE_APP_ID }}+easyscience[bot]@users.noreply.github.com" - name: Merge master into develop run: | diff --git a/.github/workflows/dashboard.yaml b/.github/workflows/dashboard.yaml index 1076f349..93445c3f 100644 --- a/.github/workflows/dashboard.yaml +++ b/.github/workflows/dashboard.yaml @@ -24,6 +24,19 @@ jobs: runs-on: ubuntu-latest steps: + # Create GitHub App token for pushing to external dashboard repo. + # The 'repositories' parameter is required to grant access to repos + # other than the one where the workflow is running. + - name: Create GitHub App installation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.EASYSCIENCE_APP_ID }} + private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} + repositories: | + ${{ github.event.repository.name }} + dashboard + - name: Checkout repository uses: actions/checkout@v5 with: @@ -80,7 +93,7 @@ jobs: with: external_repository: ${{ env.REPO_OWNER }}/dashboard publish_branch: ${{ env.DEFAULT_BRANCH }} - personal_token: ${{ secrets.GH_API_PERSONAL_ACCESS_TOKEN }} + personal_token: ${{ steps.app-token.outputs.token }} publish_dir: ./_dashboard_publish keep_files: true diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 6f503927..0cf747c3 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -85,10 +85,20 @@ jobs: echo "DOCS_VERSION=${DOCS_VERSION}" >> "$GITHUB_ENV" echo "DEPLOYMENT_URL=https://easyscience.github.io/${{ github.event.repository.name }}/${DOCS_VERSION}" >> "$GITHUB_ENV" + # Create GitHub App token for pushing to gh-pages as easyscience[bot]. + - name: Create GitHub App installation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.EASYSCIENCE_APP_ID }} + private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} + # Check out the repository source code. # Note: The gh-pages branch is fetched separately later for mike deployment. - name: Check-out repository uses: actions/checkout@v5 + with: + token: ${{ steps.app-token.outputs.token }} # Activate dark mode to create documentation with Plotly charts in dark mode # Need a better solution to automatically switch the chart colour theme based on the mkdocs material switcher @@ -179,8 +189,8 @@ jobs: # Configure git user for mike to commit and push to gh-pages branch. - name: Configure git for pushing run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "easyscience[bot]" + git config user.email "${{ vars.EASYSCIENCE_APP_ID }}+easyscience[bot]@users.noreply.github.com" # Fetch the gh-pages branch to ensure mike has the latest remote state. # This is required because the checkout step only fetches the source branch, diff --git a/.github/workflows/release-pr.yaml b/.github/workflows/release-pr.yaml index 8f028fd6..eda67884 100644 --- a/.github/workflows/release-pr.yaml +++ b/.github/workflows/release-pr.yaml @@ -1,10 +1,16 @@ -# This workflow creates an automated release PR from `develop` into `main`. +# This workflow creates an automated release PR from `develop` into `master`. # # Usage: # - Triggered manually via workflow_dispatch. # - Creates a PR titled "Release: merge develop into master". # - Adds the label "[maintainer] auto-pull-request" so it is excluded from changelogs. # - The PR body makes clear that this is automation only (no review needed). +# +# Required repo config: +# https://github.com/organizations/easyscience/settings/secrets/actions +# https://github.com/organizations/easyscience/settings/variables/actions +# - Actions secret: EASYSCIENCE_APP_KEY (GitHub App private key PEM) +# - Actions variable: EASYSCIENCE_APP_ID (GitHub App ID) name: Release PR (develop -> master) @@ -13,7 +19,7 @@ on: workflow_dispatch: permissions: - contents: write + contents: read pull-requests: write # Set the environment variables to be used in all jobs defined in this workflow @@ -24,10 +30,18 @@ jobs: create-pull-request: runs-on: ubuntu-latest steps: + - name: Create GitHub App installation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.EASYSCIENCE_APP_ID }} + private-key: ${{ secrets.EASYSCIENCE_APP_KEY }} + - name: Checkout develop branch uses: actions/checkout@v5 with: ref: develop + token: ${{ steps.app-token.outputs.token }} - name: Create PR from develop to ${{ env.DEFAULT_BRANCH }} run: | @@ -36,8 +50,8 @@ jobs: --head develop \ --title "Release: merge develop into ${{ env.DEFAULT_BRANCH }}" \ --label "[maintainer] auto-pull-request" \ - --body "⚠️ This PR is created automatically to trigger the release pipeline. It merges the accumulated changes from \`develop\` into \`${{ env.DEFAULT_BRANCH }}\`. + --body "This PR is created automatically to trigger the release pipeline. It merges the accumulated changes from \`develop\` into \`${{ env.DEFAULT_BRANCH }}\`. It is labeled \`[maintainer] auto-pull-request\` and is excluded from release notes and version bump logic." env: - GITHUB_TOKEN: ${{ secrets.GH_API_PERSONAL_ACCESS_TOKEN }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/src/easydiffraction/analysis/analysis.py b/src/easydiffraction/analysis/analysis.py index 404fa835..9e8bd1cd 100644 --- a/src/easydiffraction/analysis/analysis.py +++ b/src/easydiffraction/analysis/analysis.py @@ -501,10 +501,23 @@ def fit(self): """Execute fitting using the selected mode, calculator and minimizer. + This method performs the optimization but does not display + results automatically. Call :meth:`show_fit_results` after + fitting to see a summary of the fit quality and parameter + values. + In 'single' mode, fits each experiment independently. In 'joint' mode, performs a simultaneous fit across experiments with weights. - Sets :attr:`fit_results` on success. + + Sets :attr:`fit_results` on success, which can be accessed + programmatically + (e.g., ``analysis.fit_results.reduced_chi_square``). + + Example:: + + project.analysis.fit() + project.analysis.show_fit_results() # Display results """ sample_models = self.project.sample_models if not sample_models: @@ -554,6 +567,30 @@ def fit(self): # After fitting, get the results self.fit_results = self.fitter.results + def show_fit_results(self) -> None: + """Display a summary of the fit results. + + Renders the fit quality metrics (reduced χ², R-factors) and a + table of fitted parameters with their starting values, final + values, and uncertainties. + + This method should be called after :meth:`fit` completes. If no + fit has been performed yet, a warning is logged. + + Example:: + + project.analysis.fit() + project.analysis.show_fit_results() + """ + if not hasattr(self, 'fit_results') or self.fit_results is None: + log.warning('No fit results available. Run fit() first.') + return + + sample_models = self.project.sample_models + experiments = self.project.experiments + + self.fitter._process_fit_results(sample_models, experiments) + def _update_categories(self, called_by_minimizer=False) -> None: """Update all categories owned by Analysis. diff --git a/src/easydiffraction/analysis/fitting.py b/src/easydiffraction/analysis/fitting.py index 84e60dbf..bf3b851d 100644 --- a/src/easydiffraction/analysis/fitting.py +++ b/src/easydiffraction/analysis/fitting.py @@ -37,6 +37,10 @@ def fit( ) -> None: """Run the fitting process. + This method performs the optimization but does not display + results. Use :meth:`show_fit_results` on the Analysis object + to display the fit results after fitting is complete. + Args: sample_models: Collection of sample models. experiments: Collection of experiments. @@ -66,15 +70,17 @@ def objective_function(engine_params: Dict[str, Any]) -> np.ndarray: # Perform fitting self.results = self.minimizer.fit(params, objective_function) - # Post-fit processing - self._process_fit_results(sample_models, experiments) - def _process_fit_results( self, sample_models: SampleModels, experiments: Experiments, ) -> None: - """Collect reliability inputs and display results after fitting. + """Collect reliability inputs and display fit results. + + This method is typically called by + :meth:`Analysis.show_fit_results` rather than directly. It + calculates R-factors and other metrics, then renders them to + the console. Args: sample_models: Collection of sample models. diff --git a/src/easydiffraction/utils/logging.py b/src/easydiffraction/utils/logging.py index b56f4a53..890ff134 100644 --- a/src/easydiffraction/utils/logging.py +++ b/src/easydiffraction/utils/logging.py @@ -554,8 +554,8 @@ def paragraph(cls, title: str) -> None: def section(cls, title: str) -> None: """Formats a section header with bold green text.""" full_title = f'{title.upper()}' - line = '━' * len(full_title) - formatted = f'[bold green]\n{line}\n{full_title}\n{line}[/bold green]' + line = '—' * len(full_title) + formatted = f'[bold green]{line}\n{full_title}\n{line}[/bold green]' if not in_jupyter(): formatted = f'\n{formatted}' cls._console.print(formatted) @@ -566,7 +566,7 @@ def chapter(cls, title: str) -> None: and padding. """ width = ConsoleManager._detect_width() - symbol = '─' + symbol = '—' full_title = f' {title.upper()} ' pad_len = (width - len(full_title)) // 2 padding = symbol * pad_len diff --git a/tests/unit/easydiffraction/analysis/test_analysis.py b/tests/unit/easydiffraction/analysis/test_analysis.py index 53a2b796..b99502e1 100644 --- a/tests/unit/easydiffraction/analysis/test_analysis.py +++ b/tests/unit/easydiffraction/analysis/test_analysis.py @@ -82,3 +82,56 @@ def test_fit_modes_show_and_switch_to_joint(monkeypatch, capsys): out2 = capsys.readouterr().out assert 'Current fit mode changed to' in out2 assert a.fit_mode == 'joint' + + +def test_show_fit_results_warns_when_no_results(capsys): + """Test that show_fit_results logs a warning when fit() has not been run.""" + from easydiffraction.analysis.analysis import Analysis + + a = Analysis(project=_make_project_with_names([])) + + # Ensure fit_results is not set + assert not hasattr(a, 'fit_results') or a.fit_results is None + + a.show_fit_results() + out = capsys.readouterr().out + assert 'No fit results available' in out + + +def test_show_fit_results_calls_process_fit_results(monkeypatch): + """Test that show_fit_results delegates to fitter._process_fit_results.""" + from easydiffraction.analysis.analysis import Analysis + + # Track if _process_fit_results was called + process_called = {'called': False, 'args': None} + + def mock_process_fit_results(sample_models, experiments): + process_called['called'] = True + process_called['args'] = (sample_models, experiments) + + # Create a mock project with sample_models and experiments + class MockProject: + sample_models = object() + experiments = object() + _varname = 'proj' + + class experiments_cls: + names = [] + + experiments = experiments_cls() + + project = MockProject() + project.sample_models = object() + project.experiments.names = [] + + a = Analysis(project=project) + + # Set up fit_results so show_fit_results doesn't return early + a.fit_results = object() + + # Mock the fitter's _process_fit_results method + monkeypatch.setattr(a.fitter, '_process_fit_results', mock_process_fit_results) + + a.show_fit_results() + + assert process_called['called'], '_process_fit_results should be called' diff --git a/tests/unit/easydiffraction/analysis/test_fitting.py b/tests/unit/easydiffraction/analysis/test_fitting.py index 13a173e4..15899c2c 100644 --- a/tests/unit/easydiffraction/analysis/test_fitting.py +++ b/tests/unit/easydiffraction/analysis/test_fitting.py @@ -34,3 +34,59 @@ def fit(self, params, obj): f.fit(sample_models=DummyCollection(), experiments=DummyCollection()) out = capsys.readouterr().out assert 'No parameters selected for fitting' in out + + +def test_fitter_fit_does_not_call_process_fit_results(monkeypatch): + """Test that Fitter.fit() does not automatically call _process_fit_results. + + The display of results is now the responsibility of Analysis.show_fit_results(). + """ + from easydiffraction.analysis.fitting import Fitter + + process_called = {'called': False} + + class DummyParam: + value = 1.0 + _fit_start_value = None + + class DummyCollection: + free_parameters = [DummyParam()] + + def __init__(self): + self._names = ['e1'] + + @property + def names(self): + return self._names + + class MockFitResults: + pass + + class DummyMin: + tracker = type('T', (), {'track': staticmethod(lambda a, b: a)})() + + def fit(self, params, obj): + return MockFitResults() + + def _sync_result_to_parameters(self, params, engine_params): + pass + + f = Fitter() + f.minimizer = DummyMin() + + # Track if _process_fit_results is called + original_process = f._process_fit_results + + def mock_process(*args, **kwargs): + process_called['called'] = True + return original_process(*args, **kwargs) + + monkeypatch.setattr(f, '_process_fit_results', mock_process) + + f.fit(sample_models=DummyCollection(), experiments=DummyCollection()) + + assert not process_called['called'], ( + 'Fitter.fit() should not call _process_fit_results automatically. ' + 'Use Analysis.show_fit_results() instead.' + ) + assert f.results is not None, 'Fitter.fit() should still set results' diff --git a/tests/unit/easydiffraction/utils/test_utils.py b/tests/unit/easydiffraction/utils/test_utils.py index b5fb3e10..278b7918 100644 --- a/tests/unit/easydiffraction/utils/test_utils.py +++ b/tests/unit/easydiffraction/utils/test_utils.py @@ -189,6 +189,7 @@ def test_get_version_for_url_released(monkeypatch): assert MUT._get_version_for_url() == '0.8.0.post1' +@pytest.mark.filterwarnings('ignore:Failed to fetch tutorials index:UserWarning') def test_fetch_tutorials_index_returns_empty_on_error(monkeypatch): import easydiffraction.utils.utils as MUT diff --git a/tutorials/ed-1.py b/tutorials/ed-1.py index fb52a375..38bacfda 100644 --- a/tutorials/ed-1.py +++ b/tutorials/ed-1.py @@ -60,6 +60,10 @@ # in the input CIF files, are refined by default. project.analysis.fit() +# %% +# Show fit results summary +project.analysis.show_fit_results() + # %% project.experiments.show_names() diff --git a/tutorials/ed-10.py b/tutorials/ed-10.py index e9c3e796..f3253429 100644 --- a/tutorials/ed-10.py +++ b/tutorials/ed-10.py @@ -83,6 +83,7 @@ # %% project.analysis.current_calculator = 'pdffit' project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # ## Plot Measured vs Calculated diff --git a/tutorials/ed-11.py b/tutorials/ed-11.py index 92c378bb..4a5b3176 100644 --- a/tutorials/ed-11.py +++ b/tutorials/ed-11.py @@ -96,6 +96,7 @@ # %% project.analysis.current_calculator = 'pdffit' project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # ## Plot Measured vs Calculated diff --git a/tutorials/ed-12.py b/tutorials/ed-12.py index 45e4739d..c40cc5ad 100644 --- a/tutorials/ed-12.py +++ b/tutorials/ed-12.py @@ -117,6 +117,7 @@ # %% project.analysis.current_calculator = 'pdffit' project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # ## Plot Measured vs Calculated diff --git a/tutorials/ed-13.py b/tutorials/ed-13.py index fe8efa23..e2bf1110 100644 --- a/tutorials/ed-13.py +++ b/tutorials/ed-13.py @@ -614,6 +614,7 @@ # %% project_1.analysis.fit() +project_1.analysis.show_fit_results() # %% [markdown] # #### Check Fit Results @@ -1109,6 +1110,7 @@ project_2.plot_meas_vs_calc(expt_name='sim_lbco') project_2.analysis.fit() +project_2.analysis.show_fit_results() # %% [markdown] # #### Exercise 5.3: Find the Misfit in the Fit @@ -1177,6 +1179,7 @@ project_2.sample_models['lbco'].cell.length_a.free = True project_2.analysis.fit() +project_2.analysis.show_fit_results() project_2.plot_meas_vs_calc(expt_name='sim_lbco') @@ -1255,6 +1258,7 @@ project_2.experiments['sim_lbco'].peak.asym_alpha_1.free = True project_2.analysis.fit() +project_2.analysis.show_fit_results() project_2.plot_meas_vs_calc(expt_name='sim_lbco', d_spacing=True, x_min=1.35, x_max=1.40) @@ -1422,6 +1426,7 @@ # Now we can perform the fit with both phases included. project_2.analysis.fit() +project_2.analysis.show_fit_results() # Let's plot the measured diffraction pattern and the calculated # diffraction pattern both for the full range and for a zoomed-in region diff --git a/tutorials/ed-2.py b/tutorials/ed-2.py index 92c8bd37..4390b65b 100644 --- a/tutorials/ed-2.py +++ b/tutorials/ed-2.py @@ -160,6 +160,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% project.plot_meas_vs_calc(expt_name='hrpt', show_residual=True) diff --git a/tutorials/ed-3.py b/tutorials/ed-3.py index 39df7344..c0aefa1b 100644 --- a/tutorials/ed-3.py +++ b/tutorials/ed-3.py @@ -460,6 +460,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -498,6 +499,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -536,6 +538,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -604,6 +607,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -675,6 +679,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated diff --git a/tutorials/ed-4.py b/tutorials/ed-4.py index a946c659..ce857a2a 100644 --- a/tutorials/ed-4.py +++ b/tutorials/ed-4.py @@ -319,6 +319,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated diff --git a/tutorials/ed-5.py b/tutorials/ed-5.py index 625ddd0a..0e46baa8 100644 --- a/tutorials/ed-5.py +++ b/tutorials/ed-5.py @@ -288,6 +288,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated diff --git a/tutorials/ed-6.py b/tutorials/ed-6.py index 77664ba5..106a088c 100644 --- a/tutorials/ed-6.py +++ b/tutorials/ed-6.py @@ -225,6 +225,9 @@ # %% project.analysis.fit() +# %% +project.analysis.show_fit_results() + # %% [markdown] # #### Plot Measured vs Calculated @@ -260,6 +263,9 @@ # %% project.analysis.fit() +# %% +project.analysis.show_fit_results() + # %% [markdown] # #### Plot Measured vs Calculated @@ -293,6 +299,9 @@ # %% project.analysis.fit() +# %% +project.analysis.show_fit_results() + # %% [markdown] # #### Plot Measured vs Calculated @@ -326,6 +335,9 @@ # %% project.analysis.fit() +# %% +project.analysis.show_fit_results() + # %% [markdown] # #### Plot Measured vs Calculated diff --git a/tutorials/ed-7.py b/tutorials/ed-7.py index 26aee1ae..1e12be88 100644 --- a/tutorials/ed-7.py +++ b/tutorials/ed-7.py @@ -178,6 +178,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -208,6 +209,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -246,6 +248,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated @@ -275,6 +278,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated diff --git a/tutorials/ed-8.py b/tutorials/ed-8.py index 4f795376..2aa85227 100644 --- a/tutorials/ed-8.py +++ b/tutorials/ed-8.py @@ -360,6 +360,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated diff --git a/tutorials/ed-9.py b/tutorials/ed-9.py index 1055a069..a5fe1647 100644 --- a/tutorials/ed-9.py +++ b/tutorials/ed-9.py @@ -309,6 +309,7 @@ # %% project.analysis.fit() +project.analysis.show_fit_results() # %% [markdown] # #### Plot Measured vs Calculated