Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/backmerge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -31,19 +31,19 @@ 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
with:
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: |
Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 18 additions & 4 deletions .github/workflows/release-pr.yaml
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
Expand All @@ -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: |
Expand All @@ -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 }}
39 changes: 38 additions & 1 deletion src/easydiffraction/analysis/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
14 changes: 10 additions & 4 deletions src/easydiffraction/analysis/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions src/easydiffraction/utils/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The character changed from box drawing character '━' (U+2501, BOX DRAWINGS HEAVY HORIZONTAL) to em dash '—' (U+2014, EM DASH). Box drawing characters are specifically designed for creating visual lines and borders in terminal output and typically render more consistently across terminals. The em dash is a punctuation mark and may not create visually continuous lines. Consider whether this change was intentional, as it may affect the visual appearance of section headers in console output.

Copilot uses AI. Check for mistakes.
formatted = f'[bold green]{line}\n{full_title}\n{line}[/bold green]'
if not in_jupyter():
formatted = f'\n{formatted}'
cls._console.print(formatted)
Expand All @@ -566,7 +566,7 @@ def chapter(cls, title: str) -> None:
and padding.
"""
width = ConsoleManager._detect_width()
symbol = ''
symbol = ''
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The character changed from box drawing character '─' (U+2500, BOX DRAWINGS LIGHT HORIZONTAL) to em dash '—' (U+2014, EM DASH). Box drawing characters are specifically designed for creating visual lines and borders in terminal output and typically render more consistently across terminals. The em dash is a punctuation mark and may not create visually continuous lines. Consider whether this change was intentional, as it may affect the visual appearance of chapter headers in console output.

Copilot uses AI. Check for mistakes.
full_title = f' {title.upper()} '
pad_len = (width - len(full_title)) // 2
padding = symbol * pad_len
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/easydiffraction/analysis/test_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assignment to 'experiments' is unnecessary as it is redefined before this value is used.

Suggested change
experiments = object()

Copilot uses AI. Check for mistakes.
_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'
56 changes: 56 additions & 0 deletions tests/unit/easydiffraction/analysis/test_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions tests/unit/easydiffraction/utils/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions tutorials/ed-1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading