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
18 changes: 18 additions & 0 deletions src/easyreflectometry/fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,24 @@
]
return result

def record_fit_results(self, results: list[FitResults] | None) -> None:

Check warning on line 358 in src/easyreflectometry/fitting.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/fitting.py#L358

Added line #L358 was not covered by tests
"""Record fit results produced outside this fitter instance.

The application runs threaded fits directly on the lower-level
``easy_science_multi_fitter`` (for progress reporting and cancellation)
rather than through :meth:`fit`. As a result, the high-level
``_fit_results`` used by :attr:`chi2`, :attr:`reduced_chi`, and the
HTML summary's goodness-of-fit are never populated. Call this after such
a fit so those consumers reflect the latest results. Pass ``None`` to
clear.

:param results: The list of ``FitResults`` from the completed fit, or
``None`` to reset.
"""
self._fit_results = results
if not results:
self._classical_fit_metrics = None

Check warning on line 374 in src/easyreflectometry/fitting.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/fitting.py#L372-L374

Added lines #L372 - L374 were not covered by tests

def mcmc_sample(
self,
data: sc.DataGroup,
Expand Down
2 changes: 1 addition & 1 deletion src/easyreflectometry/summary/html_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
</tr>
<tr>
<td>No. of constraints</td>
<td>num_constriants</td>
<td>num_constraints</td>
</tr>
"""

Expand Down
28 changes: 15 additions & 13 deletions src/easyreflectometry/summary/summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,26 +309,28 @@
html_refinement = html_refinement.replace('num_total_params', f'{num_params}')
html_refinement = html_refinement.replace('num_free_params', f'{num_free_params}')
html_refinement = html_refinement.replace('num_fixed_params', f'{num_fixed_params}')
html_refinement = html_refinement.replace('num_constriants', f'{num_constraints}')
html_refinement = html_refinement.replace('num_constraints', f'{num_constraints}')

Check warning on line 312 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L312

Added line #L312 was not covered by tests
return html_refinement

def _compute_goodness_of_fit(self) -> str:
"""Return reduced chi² as a formatted string, or 'N/A' if no fit has been run."""
last_fit_results = getattr(self._project, '_last_fit_results', None)
if not last_fit_results:
"""Return reduced chi² as a formatted string, or 'N/A' if no fit has been run.

The value is read from the project's fitter, which computes the reduced
chi-square directly from the raw chi-square and the global degrees of
freedom across every fitted dataset. Deriving it this way keeps the
summary independent of the per-minimizer ``reduced_chi`` / ``reduced_chi2``
attribute naming used by the underlying ``FitResults`` objects.
"""
fitter = self._project.fitter
if fitter is None:

Check warning on line 325 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L324-L325

Added lines #L324 - L325 were not covered by tests
return 'N/A'
try:
if len(last_fit_results) == 1:
gof = float(last_fit_results[0].reduced_chi2)
else:
total_chi2 = sum(float(r.chi2) for r in last_fit_results)
total_points = sum(len(r.x) for r in last_fit_results)
n_pars = last_fit_results[0].n_pars
dof = total_points - n_pars
gof = total_chi2 / dof if dof > 0 else 0.0
return f'{gof:.4g}'
gof = fitter.reduced_chi

Check warning on line 328 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L328

Added line #L328 was not covered by tests
except (AttributeError, TypeError, ValueError, ZeroDivisionError):
return 'N/A'
if gof is None:
return 'N/A'
return f'{gof:.4g}'

Check warning on line 333 in src/easyreflectometry/summary/summary.py

View check run for this annotation

Codecov / codecov/patch

src/easyreflectometry/summary/summary.py#L331-L333

Added lines #L331 - L333 were not covered by tests

def _figures_section(self, interactive: bool = True) -> str:
"""Figures section.
Expand Down
46 changes: 46 additions & 0 deletions tests/summary/test_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
from unittest.mock import MagicMock

import numpy as np
import pytest
from easyscience import global_object

Expand Down Expand Up @@ -167,6 +168,51 @@ def test_refinement_section(self, project: Project) -> None:
assert 'No. of free parameters:' in html
assert '0' in html
assert 'No. of constraints' in html
# The (previously misspelt) constraints token must be fully substituted.
assert 'num_constriants' not in html
assert 'num_constraints' not in html

@staticmethod
def _populate_fit_results(project: Project, chi2: float, n_points: int, n_pars: int) -> None:
"""Emulate a completed fit by storing results on the project's fitter."""
fit_result = MagicMock()
fit_result.chi2 = chi2
fit_result.x = np.arange(n_points)
fit_result.n_pars = n_pars
project.fitter._fit_results = [fit_result]

def test_compute_goodness_of_fit_na_before_fit(self, project: Project) -> None:
# When
summary = Summary(project)

# Then Expect: no fit has been run, so goodness-of-fit is unavailable.
assert summary._compute_goodness_of_fit() == 'N/A'

def test_compute_goodness_of_fit_after_fit(self, project: Project) -> None:
# When: reduced chi² = 20 / (14 - 4) = 2.0
summary = Summary(project)
self._populate_fit_results(project, chi2=20.0, n_points=14, n_pars=4)

# Then
gof = summary._compute_goodness_of_fit()

# Expect
assert gof != 'N/A'
assert float(gof) == pytest.approx(2.0)

def test_refinement_section_shows_goodness_of_fit(self, project: Project) -> None:
# When
summary = Summary(project)
self._populate_fit_results(project, chi2=20.0, n_points=14, n_pars=4)
gof = summary._compute_goodness_of_fit()

# Then
html = summary._refinement_section()

# Expect: the template token is replaced with the actual value, not 'N/A'.
assert gof != 'N/A'
assert gof in html
assert 'goodness_of_fit' not in html

def test_save_sld_plot(self, project: Project, tmp_path) -> None:
# When
Expand Down
40 changes: 40 additions & 0 deletions tests/test_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,46 @@ def test_reduced_chi_uses_global_dof_across_fit_results():
assert fitter.reduced_chi == pytest.approx(expected)


def test_record_fit_results_populates_reduced_chi():
"""Results computed outside the fitter can be recorded so reduced_chi works.

Mirrors the app path where the threaded fit runs on the low-level
easy_science_multi_fitter and the high-level results must be recorded
explicitly (otherwise the HTML summary goodness-of-fit shows 'N/A').
"""
model = Model()
model.interface = CalculatorFactory()
fitter = MultiFitter(model)

assert fitter.reduced_chi is None

fit_result = MagicMock()
fit_result.chi2 = 20.0
fit_result.x = np.arange(14)
fit_result.n_pars = 4

fitter.record_fit_results([fit_result])

assert fitter.reduced_chi == pytest.approx(20.0 / (14 - 4))


def test_record_fit_results_none_clears_state():
model = Model()
model.interface = CalculatorFactory()
fitter = MultiFitter(model)

fit_result = MagicMock()
fit_result.chi2 = 20.0
fit_result.x = np.arange(14)
fit_result.n_pars = 4
fitter.record_fit_results([fit_result])

fitter.record_fit_results(None)

assert fitter.reduced_chi is None
assert fitter.chi2 is None


def test_fit_single_data_set_1d_all_zero_variance_raises():
"""Legacy mask mode raises when all points have zero variance."""
model = Model()
Expand Down
Loading