diff --git a/news/print-results-table.rst b/news/print-results-table.rst new file mode 100644 index 00000000..20fd8753 --- /dev/null +++ b/news/print-results-table.rst @@ -0,0 +1,25 @@ +**Added:** + +* ``rich`` as a dependency, used to print the results tables. +* ``diffpy.srfit.util.format_value_with_uncertainty`` to format a value with its uncertainty in parenthesis notation. +* ``FitResults.print_results_table()`` to print the fit results as tables, with the variables grouped by tag, values in parenthesis notation such as ``3.1997(21)``, and colors that point out large uncertainties and correlations. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* diff --git a/requirements/conda.txt b/requirements/conda.txt index 2e2402dd..71fc72b6 100644 --- a/requirements/conda.txt +++ b/requirements/conda.txt @@ -4,3 +4,4 @@ scipy bg-mpl-stylesheets diffpy.utils diffpy.structure +rich diff --git a/requirements/pip.txt b/requirements/pip.txt index 6a05210c..ae4c5215 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -4,3 +4,4 @@ scipy bg-mpl-stylesheets diffpy.utils diffpy.structure +rich diff --git a/src/diffpy/srfit/fitbase/fitresults.py b/src/diffpy/srfit/fitbase/fitresults.py index af89cb73..6ddfe5c3 100644 --- a/src/diffpy/srfit/fitbase/fitresults.py +++ b/src/diffpy/srfit/fitbase/fitresults.py @@ -28,8 +28,11 @@ from collections import OrderedDict import numpy +from rich.console import Console +from rich.table import Table +from rich.text import Text -from diffpy.srfit.util import _DASHEDLINE +from diffpy.srfit.util import _DASHEDLINE, format_value_with_uncertainty from diffpy.srfit.util import sort_key_for_numeric_string as numstr from diffpy.srfit.util.inpututils import convert_input_to_string from diffpy.utils._deprecator import build_deprecation_message, deprecated @@ -397,6 +400,35 @@ def _calculate_constraint_uncertainties(self): self.conunc.append(sig2c**0.5) return + def _check_uncertainties(self): + """Add a message for each problem with the profile uncertainties + and return whether the uncertainties are reliable.""" + certain = True + for con in self.conresults.values(): + if (con.dy == 1).all(): + certain = False + break + if not certain: + err_msg = ( + "Some quantities invalid due to missing profile uncertainty" + ) + if err_msg not in self.messages: + self.messages.append(err_msg) + for con_name, con in self.conresults.items(): + unweighted_count = numpy.count_nonzero(numpy.isinf(con.dy)) + if unweighted_count == 0: + continue + unweighted_msg = ( + f"{unweighted_count} of the {len(con.dy)} points of " + f"contribution '{con_name}' have no weight, because their " + "uncertainties are zero, negative, infinite or not a number. " + "To include them in the fit, please set valid uncertainties " + "with Profile.set_observed_profile." + ) + if unweighted_msg not in self.messages: + self.messages.append(unweighted_msg) + return certain + def get_results_string(self, header="", footer="", update=False): """Format the results and return them in a string. @@ -427,37 +459,12 @@ def get_results_string(self, header="", footer="", update=False): p = self.precision pe = "%-" + "%i.%ie" % (p + 6, p) pet = "%" + ".%ie" % (p,) - # Check to see if the uncertainty values are reliable. - certain = True - for con in self.conresults.values(): - if (con.dy == 1).all(): - certain = False - break + certain = self._check_uncertainties() # User-defined header if header: lines.append(header) - if not certain: - err_msg = ( - "Some quantities invalid due to missing profile uncertainty" - ) - if err_msg not in self.messages: - self.messages.append(err_msg) - for con_name, con in self.conresults.items(): - unweighted_count = numpy.count_nonzero(numpy.isinf(con.dy)) - if unweighted_count == 0: - continue - unweighted_msg = ( - f"{unweighted_count} of the {len(con.dy)} points of " - f"contribution '{con_name}' have no weight, because their " - "uncertainties are zero, negative, infinite or not a number. " - "To include them in the fit, please set valid uncertainties " - "with Profile.set_observed_profile." - ) - if unweighted_msg not in self.messages: - self.messages.append(unweighted_msg) - lines.extend(self.messages) # Overall results @@ -630,6 +637,176 @@ def print_results(self, header="", footer="", update=False): print(self.get_results_string(header, footer, update).rstrip()) return + def print_results_table(self, header="", footer="", update=False): + """Print the results as tables. + + The tables hold the same results as ``print_results``. The overall + metrics come first, then the metrics of each contribution when + there is more than one. The refined variables are grouped by the + tag they were added with and keep the order they were added in. + Each one shows its value with its uncertainty in parenthesis + notation and its relative uncertainty. A Tag column is shown only + when at least one variable is tagged; untagged variables are then + listed under "untagged", after the tagged ones. The fixed + variables follow when ``showfixed`` is True, then the correlations + greater than 25%, strongest first. + + In a terminal that shows colors, the messages about the results, + such as missing uncertainties, are yellow, and relative + uncertainties greater than 10% and correlations greater than 80% + are red. + + Parameters + ---------- + header : str + The header to print before the tables (default ""). + footer : str + The footer to print after the tables (default ""). + update : bool + The flag indicating whether to call ``update()`` (default + False). + """ + if update: + self.update() + self._check_uncertainties() + console = Console(highlight=False) + if header: + console.print(Text(header), soft_wrap=True) + for message in self.messages: + console.print(Text(message, style="yellow"), soft_wrap=True) + console.print(self._get_overall_table()) + if len(self.conresults) > 1: + console.print(self._get_contributions_table()) + if self.varnames: + console.print(self._get_variables_table()) + if self.showfixed and self.fixednames: + console.print(self._get_fixed_variables_table()) + corrmin = 0.25 + correlations = self._get_correlations_greater_than(corrmin) + corint = int(corrmin * 100) + if correlations: + console.print(self._get_correlations_table(correlations, corint)) + else: + console.print(Text("No correlations greater than %i%%" % corint)) + if footer: + console.print(Text(footer), soft_wrap=True) + return + + def _get_overall_table(self): + overall_table = Table(title="Overall", show_header=False) + overall_table.add_column() + overall_table.add_column(justify="right") + metrics = [ + ("Residual", self.residual), + ("Contributions", self.residual - self.penalty), + ("Restraints", self.penalty), + ("Chi2", self.chi2), + ("Reduced Chi2", self.rchi2), + ("Rw", self.rw), + ] + for metric_name, metric_value in metrics: + overall_table.add_row(metric_name, f"{metric_value:.6g}") + return overall_table + + def _get_contributions_table(self): + contributions_table = Table(title="Contributions") + contributions_table.add_column("Contribution") + for column_name in ["Weight", "Residual", "Chi2", "Rw"]: + contributions_table.add_column(column_name, justify="right") + for name in sorted(self.conresults, key=numstr): + con = self.conresults[name] + contributions_table.add_row( + Text(name), + f"{con.weight:.6g}", + f"{con.residual:.6g}", + f"{con.chi2:.6g}", + f"{con.rw:.6g}", + ) + return contributions_table + + def _get_variable_tags(self): + """Return the first tag given to each variable, or "" for a + variable without one.""" + recipe = self.recipe + variable_tags = [] + for name in self.varnames: + automatic_tags = {name, "all", recipe._fixedtag} + given_tags = [] + for tag in recipe._tagmanager.tags(recipe.get(name)): + if tag not in automatic_tags: + given_tags.append(tag) + variable_tags.append(given_tags[0] if given_tags else "") + return variable_tags + + def _get_variables_table(self): + variable_tags = self._get_variable_tags() + show_tags = any(variable_tags) + indices_by_tag = {} + for index, tag in enumerate(variable_tags): + indices_by_tag.setdefault(tag or "untagged", []).append(index) + if "untagged" in indices_by_tag: + indices_by_tag["untagged"] = indices_by_tag.pop("untagged") + variables_table = Table(title="Variables") + if show_tags: + variables_table.add_column("Tag") + variables_table.add_column("Variable") + variables_table.add_column("Value(unc)", justify="right") + variables_table.add_column("Rel. unc.", justify="right") + for tag, indices in indices_by_tag.items(): + for position, index in enumerate(indices): + value = self.varvals[index] + uncertainty = self.varunc[index] + row = [ + Text(self.varnames[index]), + Text(format_value_with_uncertainty(value, uncertainty)), + _get_relative_uncertainty_text(value, uncertainty), + ] + if show_tags: + row.insert(0, Text(tag if position == 0 else "")) + is_last_with_tag = position == len(indices) - 1 + variables_table.add_row( + *row, end_section=show_tags and is_last_with_tag + ) + return variables_table + + def _get_fixed_variables_table(self): + fixed_variables_table = Table(title="Fixed Variables") + fixed_variables_table.add_column("Variable") + fixed_variables_table.add_column("Value", justify="right") + for name, value in zip(self.fixednames, self.fixedvals): + fixed_variables_table.add_row(Text(name), f"{value:.6g}") + return fixed_variables_table + + def _get_correlations_greater_than(self, corrmin): + """Return (correlation, i, j) for each pair of variables i < j + whose correlation is greater than ``corrmin`` in magnitude, + strongest first.""" + correlations = [] + n = len(self.varnames) + for i in range(n): + for j in range(i + 1, n): + val = self.cov[i, j] / (self.cov[i, i] * self.cov[j, j]) ** 0.5 + if abs(val) > corrmin: + correlations.append((val, i, j)) + correlations.sort(key=lambda vij: abs(vij[0]), reverse=True) + return correlations + + def _get_correlations_table(self, correlations, corint): + correlations_table = Table( + title="Variable Correlations greater than %i%%" % corint + ) + correlations_table.add_column("Variable 1") + correlations_table.add_column("Variable 2") + correlations_table.add_column("Correlation", justify="right") + for val, i, j in correlations: + style = "red" if abs(val) > 0.8 else "" + correlations_table.add_row( + Text(self.varnames[i]), + Text(self.varnames[j]), + Text("%.4f" % val, style=style), + ) + return correlations_table + @deprecated(printResults_dep_msg) def printResults(self, header="", footer="", update=False): """This function has been deprecated and will be removed in version @@ -714,6 +891,20 @@ def get_results_dictionary(self): # End class FitResults +def _get_relative_uncertainty_text(value, uncertainty): + """Return the relative uncertainty as a percentage, in red when it + is greater than 10%, or a dash when it can't be computed.""" + if value == 0 or not numpy.isfinite(uncertainty): + relative_uncertainty_text = Text("—") + return relative_uncertainty_text + relative_uncertainty = abs(uncertainty / value) + style = "red" if relative_uncertainty > 0.1 else "" + relative_uncertainty_text = Text( + "%.2f%%" % (relative_uncertainty * 100), style=style + ) + return relative_uncertainty_text + + class ContributionResults(object): """Class for processing, storing FitContribution results. diff --git a/src/diffpy/srfit/util/__init__.py b/src/diffpy/srfit/util/__init__.py index ec13776a..045caeaa 100644 --- a/src/diffpy/srfit/util/__init__.py +++ b/src/diffpy/srfit/util/__init__.py @@ -14,6 +14,7 @@ ############################################################################## """Utilities and constants used throughout SrFit.""" +import math import re from diffpy.utils._deprecator import build_deprecation_message, deprecated @@ -65,4 +66,37 @@ def sortKeyForNumericString(s): return sort_key_for_numeric_string(s) +def format_value_with_uncertainty(value, uncertainty): + """Return a value and its uncertainty in parenthesis notation. + + The uncertainty is rounded to two significant digits, and the value is + rounded to the same decimal place, so ``3.19965644 +/- 0.0021228`` gives + ``"3.1997(21)"``. An uncertainty that is zero, negative, infinite or not + a number gives the value alone, with six significant digits. + + Parameters + ---------- + value : float + The value to format. + uncertainty : float + The uncertainty in ``value``. + + Returns + ------- + str + The value with the digits of its uncertainty in parentheses, e.g. + ``"3.1997(21)"``. + """ + if not math.isfinite(uncertainty) or uncertainty <= 0: + formatted_value = f"{value:.6g}" + return formatted_value + decimals = 1 - math.floor(math.log10(uncertainty)) + if round(uncertainty * 10**decimals) >= 100: + decimals -= 1 + decimals = max(0, decimals) + uncertainty_digits = round(uncertainty * 10**decimals) + formatted_value = f"{value:.{decimals}f}({uncertainty_digits})" + return formatted_value + + # End of file diff --git a/tests/conftest.py b/tests/conftest.py index d36e3bb2..e5918931 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ from functools import lru_cache import pytest -from numpy import arange, array, linspace, pi, sin +from numpy import arange, array, exp, full_like, linspace, pi, sin import diffpy.srfit.equation.literals as literals from diffpy.srfit import ( @@ -227,6 +227,116 @@ def build_recipe_two_contributions(): return recipe +@pytest.fixture +def two_sample_decay_recipe(): + """Return a recipe fitting the decays of two samples that share one + rate constant. + + Each contribution, ``sample_A`` and ``sample_B`` (weight 0.5), fits + ``A*exp(-k*t) + b`` to a noiseless decay on 41 points in [0, 20] + with uncertainties of 0.05. The data have ``k = 0.35``, ``A = 3`` and + ``b = 0.2`` for sample A, and ``A = 1.5`` and ``b = 0.5`` for sample + B. The variables start away from these values and are added in this + order: ``A_B = 1.4`` (untagged), ``k = 0.3`` (tagged "shared"), + ``A_A = 2.8`` and ``b_A = 0.25`` (tagged "sample A"), and + ``b_B = 0.5``, which is fixed. Sample B's rate is constrained to + ``k``. The default PrintFitHook is silenced. + """ + t = linspace(0, 20, 41) + profile_a = Profile() + profile_a.set_observed_profile( + t, 3 * exp(-0.35 * t) + 0.2, full_like(t, 0.05) + ) + sample_a = FitContribution("sample_A") + sample_a.set_profile(profile_a, xname="t") + sample_a.set_equation("A*exp(-k*t) + b") + profile_b = Profile() + profile_b.set_observed_profile( + t, 1.5 * exp(-0.35 * t) + 0.5, full_like(t, 0.05) + ) + sample_b = FitContribution("sample_B") + sample_b.set_profile(profile_b, xname="t") + sample_b.set_equation("A*exp(-k*t) + b") + recipe = FitRecipe() + recipe.fithooks[0].verbose = 0 + recipe.add_contribution(sample_a) + recipe.add_contribution(sample_b, weight=0.5) + recipe.add_variable(sample_b.A, 1.4, name="A_B") + recipe.add_variable(sample_a.k, 0.3, name="k", tag="shared") + recipe.add_constraint(sample_b.k, "k") + recipe.add_variable(sample_a.A, 2.8, name="A_A", tag="sample A") + recipe.add_variable(sample_a.b, 0.25, name="b_A", tag="sample A") + recipe.add_variable(sample_b.b, 0.5, name="b_B", fixed=True) + return recipe + + +@pytest.fixture +def short_decay_recipe(): + """Return a recipe fitting a decay measured only until it is about + to level off. + + The contribution ``decay`` fits ``A*exp(-k*t) + b`` to the noiseless + decay with ``A = 3``, ``k = 0.35`` and ``b = 0.2`` on 41 points in + [0, 10]. The uncertainties are 0.05, except for the last three + points, whose uncertainties are zero. The untagged variables start at + ``A = 2.8``, ``k = 0.3`` and ``b = 0.25``. The default PrintFitHook is + silenced. + """ + t = linspace(0, 10, 41) + dy = full_like(t, 0.05) + dy[-3:] = 0 + profile = Profile() + profile.set_observed_profile(t, 3 * exp(-0.35 * t) + 0.2, dy) + contribution = FitContribution("decay") + contribution.set_profile(profile, xname="t") + contribution.set_equation("A*exp(-k*t) + b") + recipe = FitRecipe() + recipe.fithooks[0].verbose = 0 + recipe.add_contribution(contribution) + recipe.add_variable(contribution.A, 2.8) + recipe.add_variable(contribution.k, 0.3) + recipe.add_variable(contribution.b, 0.25) + return recipe + + +@pytest.fixture +def plain_terminal(monkeypatch): + """Make rich print the same plain tables on every machine. + + rich reads the console width and the color settings from the + environment, so the printed tables could differ from the expected + ones depending on where the tests run. ``COLUMNS`` is set to 100 + because rich would otherwise squeeze or wrap the tables in a narrow + terminal. ``FORCE_COLOR`` and ``TTY_COMPATIBLE`` are removed because + some CI setups and shell profiles set them, which would make rich + add color codes to the tables. + """ + monkeypatch.setenv("COLUMNS", "100") + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.delenv("TTY_COMPATIBLE", raising=False) + + +@pytest.fixture +def color_terminal(monkeypatch): + """Make rich print in the same 16 colors on every machine. + + rich reads the console width and the color settings from the + environment, and pytest's captured output isn't a terminal, so rich + would print no colors at all. ``COLUMNS`` is set to 100 so that the + tables aren't squeezed in a narrow terminal. ``FORCE_COLOR`` makes + rich print colors. ``TERM`` is set to "xterm" and ``COLORTERM`` is + removed so that rich uses the basic 16 colors, in which red is + ``\\x1b[31m``, rather than the codes of a 256-color or true-color + terminal. ``NO_COLOR`` is removed because it would turn the colors + off. + """ + monkeypatch.setenv("COLUMNS", "100") + monkeypatch.setenv("FORCE_COLOR", "1") + monkeypatch.setenv("TERM", "xterm") + monkeypatch.delenv("COLORTERM", raising=False) + monkeypatch.delenv("NO_COLOR", raising=False) + + @pytest.fixture(scope="session") def as_list(): def _as_list(values): diff --git a/tests/test_fitresults.py b/tests/test_fitresults.py index 99d1dad4..634901b7 100644 --- a/tests/test_fitresults.py +++ b/tests/test_fitresults.py @@ -14,6 +14,8 @@ ############################################################################## """Tests for the fitresults module.""" +import re + import pytest from numpy import array, dot, inf, isfinite, linspace, pi, sin, sqrt from scipy.optimize import leastsq @@ -37,6 +39,77 @@ ------------------------------------------------------------------------------ """ EXPECTED_REFINED_VARIABLES = ["amplitude", "wave_number", "phase_shift"] +# The tables printed for the two_sample_decay_recipe fixture in conftest.py, +# with a header and a footer +EXPECTED_TWO_SAMPLE_TABLES = """\ +My Custom header + Overall +┌───────────────┬───────────┐ +│ Residual │ 172.418 │ +│ Contributions │ 172.418 │ +│ Restraints │ 0 │ +│ Chi2 │ 176.886 │ +│ Reduced Chi2 │ 2.26777 │ +│ Rw │ 0.0911824 │ +└───────────────┴───────────┘ + Contributions +┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┓ +┃ Contribution ┃ Weight ┃ Residual ┃ Chi2 ┃ Rw ┃ +┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━┩ +│ sample_A │ 1 │ 167.95 │ 167.95 │ 0.10299 │ +│ sample_B │ 0.5 │ 17.8726 │ 17.8726 │ 0.0405264 │ +└──────────────┴────────┴──────────┴─────────┴───────────┘ + Variables +┏━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓ +┃ Tag ┃ Variable ┃ Value(unc) ┃ Rel. unc. ┃ +┡━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩ +│ shared │ k │ 0.3000(70) │ 2.34% │ +├──────────┼──────────┼────────────┼───────────┤ +│ sample A │ A_A │ 2.800(34) │ 1.20% │ +│ │ b_A │ 0.250(13) │ 5.03% │ +├──────────┼──────────┼────────────┼───────────┤ +│ untagged │ A_B │ 1.400(53) │ 3.77% │ +└──────────┴──────────┴────────────┴───────────┘ + Fixed Variables +┏━━━━━━━━━━┳━━━━━━━┓ +┃ Variable ┃ Value ┃ +┡━━━━━━━━━━╇━━━━━━━┩ +│ b_B │ 0.5 │ +└──────────┴───────┘ + Variable Correlations greater than 25% +┏━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ +┃ Variable 1 ┃ Variable 2 ┃ Correlation ┃ +┡━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ +│ k │ b_A │ 0.6548 │ +│ k │ A_A │ 0.3832 │ +│ A_B │ k │ 0.2663 │ +└────────────┴────────────┴─────────────┘ +My Custom footer""" +# The tables printed for a sine recipe whose only variable is the +# untagged amplitude +EXPECTED_UNTAGGED_SINE_TABLES = """\ +Some quantities invalid due to missing profile uncertainty + Overall +┌───────────────┬──────┐ +│ Residual │ 0.18 │ +│ Contributions │ 0.18 │ +│ Restraints │ 0 │ +│ Chi2 │ 0.18 │ +│ Reduced Chi2 │ 0.02 │ +│ Rw │ 0.2 │ +└───────────────┴──────┘ + Variables +┏━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━┓ +┃ Variable ┃ Value(unc) ┃ Rel. unc. ┃ +┡━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━┩ +│ A │ 1.20(47) │ 39.28% │ +└──────────┴────────────┴───────────┘ +No correlations greater than 25%""" +# The ANSI codes rich prints around red and yellow text in a 16-color +# terminal, and after styled text +ANSI_RED = "\x1b[31m" +ANSI_YELLOW = "\x1b[33m" +ANSI_RESET = "\x1b[0m" def _optimize_recipe(recipe): @@ -57,6 +130,21 @@ def _get_results_output(results, method_name, capsys, tmp_path): return results_file.read_text() +def _read_printed_tables(capsys): + """Return the printed text without the spaces that pad its lines.""" + printed_lines = capsys.readouterr().out.splitlines() + stripped_lines = [line.rstrip() for line in printed_lines] + return "\n".join(stripped_lines) + + +def _find_colored_text(printed_text, color_code): + """Return each piece of the printed text shown in the color with the + ANSI code ``color_code``, without the spaces that pad it.""" + pattern = re.escape(color_code) + "(.*?)" + re.escape(ANSI_RESET) + colored_pieces = re.findall(pattern, printed_text) + return [piece.strip() for piece in colored_pieces] + + # Test the results text of a refined recipe, whether returned, printed or # saved. @pytest.mark.parametrize( @@ -96,6 +184,101 @@ def test_results_output( assert actual_variables_present == expected_variables_present +def test_print_results_table_with_tagged_two_sample_fit( + two_sample_decay_recipe, capsys, plain_terminal +): + # Test the tables printed for a fit to two data sets. + # C1: A user fits the decays of two samples that share a rate, adds + # sample B's amplitude first without a tag, tags the rate and sample A's + # variables, fixes sample B's baseline, and prints the tables with a + # header and a footer. + # Expected: The header comes first, then the overall metrics, the + # metrics of each sample, the tagged variables grouped by tag in the + # order they were added, sample B's amplitude under "untagged" after + # them, the fixed baseline, the correlations greater than 25% strongest + # first, and the footer. + # Compute the results at the starting values + results = FitResults(two_sample_decay_recipe) + capsys.readouterr() + # Print the tables + results.print_results_table( + header="My Custom header", footer="My Custom footer" + ) + actual_printed_tables = _read_printed_tables(capsys) + expected_printed_tables = EXPECTED_TWO_SAMPLE_TABLES + assert actual_printed_tables == expected_printed_tables + + +def test_print_results_table_without_tags( + build_sine_recipe, capsys, plain_terminal +): + # Test the tables printed when no variable is tagged. + # C1: A user fits sin(x) with only the amplitude free, starting at 1.2, + # without tagging it or giving the data uncertainties, and prints the + # tables. + # Expected: The message about the missing uncertainties comes first, + # the variables table has no Tag column, and a line says there are + # no correlations greater than 25%. + # Free the amplitude and compute the results at its starting value + recipe = build_sine_recipe() + recipe.add_variable(recipe.cont.A, 1.2) + results = FitResults(recipe) + capsys.readouterr() + # Print the tables + results.print_results_table() + actual_printed_tables = _read_printed_tables(capsys) + expected_printed_tables = EXPECTED_UNTAGGED_SINE_TABLES + assert actual_printed_tables == expected_printed_tables + + +def test_print_results_table_without_show_fixed( + two_sample_decay_recipe, capsys +): + # Test the tables printed when fixed variables are hidden. + # C1: A user fits the decays of two samples with sample B's baseline + # fixed, turns off showfixed, and prints the tables. + # Expected: The fixed baseline b_B is not listed. + # Compute the results with the fixed variables hidden + results = FitResults(two_sample_decay_recipe, showfixed=False) + capsys.readouterr() + # Print the tables + results.print_results_table() + actual_printed_tables = _read_printed_tables(capsys) + actual_fixed_baseline_listed = "b_B" in actual_printed_tables + expected_fixed_baseline_listed = False + assert actual_fixed_baseline_listed == expected_fixed_baseline_listed + + +def test_print_results_table_in_color_terminal( + short_decay_recipe, capsys, color_terminal +): + # Test the colors that point out the results to look at. + # C1: A user measures a decay only until it is about to level off, so + # the rate and the baseline can't be told apart, sets the uncertainties + # of the last three points to zero by mistake, and prints the tables in + # a terminal that shows colors. + # Expected: The message about the points with no weight is yellow. + # Only the relative uncertainty greater than 10%, of b, and the + # correlation greater than 80%, of k with b, are red. + # Compute the results at the starting values + results = FitResults(short_decay_recipe) + capsys.readouterr() + # Print the tables and find the yellow and the red text + results.print_results_table() + printed_text = capsys.readouterr().out + actual_yellow_text = _find_colored_text(printed_text, ANSI_YELLOW) + actual_red_text = _find_colored_text(printed_text, ANSI_RED) + expected_yellow_text = [ + "3 of the 41 points of contribution 'decay' have no weight, " + "because their uncertainties are zero, negative, infinite or not " + "a number. To include them in the fit, please set valid " + "uncertainties with Profile.set_observed_profile." + ] + expected_red_text = ["13.19%", "0.9157"] + assert actual_yellow_text == expected_yellow_text + assert actual_red_text == expected_red_text + + def test_get_results_dictionary(build_recipes_one_contribution): # C1: The results dictionary is taken after the recipe is refined to # the noiseless sine it is fit to. diff --git a/tests/test_util.py b/tests/test_util.py index c1bc6224..e0f07607 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -2,7 +2,10 @@ import pytest -from diffpy.srfit.util import sort_key_for_numeric_string +from diffpy.srfit.util import ( + format_value_with_uncertainty, + sort_key_for_numeric_string, +) # The key splits a string into text and integer parts so that numbers sort @@ -34,3 +37,53 @@ def test_sort_key_for_numeric_string_orders_numbers_by_value(): ) expected_order = ["a1", "a2", "a10", "b1"] assert actual_order == expected_order + + +# Test formatting a value with its uncertainty in parenthesis notation. The +# uncertainty is rounded to two significant digits and the value to the +# same decimal place, as in "3.1997(21)" for 3.1997 +/- 0.0021. +@pytest.mark.parametrize( + "input_value, input_uncertainty, expected_formatted_value", + [ + # C1: A user formats a fitted peak position of 3.19965644 with an + # uncertainty of 0.0021228. + # Expected: The uncertainty becomes 21 and the value is rounded to + # four decimal places. + (3.19965644, 0.0021228, "3.1997(21)"), + # C2: A user formats a value whose uncertainty is greater than one. + # Expected: The digits in parentheses are in the same decimal places + # as the last digits of the value. + # 1. An uncertainty of 1.234 is rounded to one decimal place. + # 2. An uncertainty of 12.3 is rounded to a whole number. + (12.345, 1.234, "12.3(12)"), + (1234.6, 12.3, "1235(12)"), + # C3: A user formats a negative slope of -0.298964 with an + # uncertainty of 0.00203. + # Expected: The value keeps its sign, and the uncertainty becomes + # 20 rather than 2. + (-0.298964, 0.00203, "-0.2990(20)"), + # C4: A user formats a value whose uncertainty of 0.0996 rounds up + # to the next power of ten. + # Expected: The uncertainty becomes 10, and the value is rounded to + # two decimal places rather than three. + (1.2345, 0.0996, "1.23(10)"), + # C5: A user formats a value whose uncertainty can't be used. + # Expected: The value is given alone with six significant digits. + # 1. The uncertainty is zero. + # 2. The uncertainty is negative. + # 3. The uncertainty is not a number, as when the covariance + # matrix can't be computed. + # 4. The uncertainty is infinite. + (5.012345678, 0.0, "5.01235"), + (5.012345678, -0.1, "5.01235"), + (5.012345678, float("nan"), "5.01235"), + (5.012345678, float("inf"), "5.01235"), + ], +) +def test_format_value_with_uncertainty( + input_value, input_uncertainty, expected_formatted_value +): + actual_formatted_value = format_value_with_uncertainty( + input_value, input_uncertainty + ) + assert actual_formatted_value == expected_formatted_value