diff --git a/changelog.md b/changelog.md index 5c534543c..ae82dfea2 100644 --- a/changelog.md +++ b/changelog.md @@ -27,6 +27,7 @@ - 🩹 Fix Crash in optimization_group_calculator_linked when using guidance spectra (#950) - 🩹 ParameterGroup.get degrades full_label of nested Parameters with nesting over 2 (#1043) +- 🩹 Show validation problem if parameters are missing values (default: NaN) (#1076) ### 📚 Documentation diff --git a/glotaran/model/model.py b/glotaran/model/model.py index a43b37b1c..f735cc70f 100644 --- a/glotaran/model/model.py +++ b/glotaran/model/model.py @@ -291,8 +291,8 @@ def get_dataset_groups(self) -> dict[str, DatasetGroup]: if group not in groups: try: groups[group] = DatasetGroup(model=self.dataset_group_models[group]) - except KeyError: - raise ValueError(f"Unknown dataset group '{group}'") + except KeyError as e: + raise ValueError(f"Unknown dataset group '{group}'") from e groups[group].dataset_models[dataset_model.label] = dataset_model return groups @@ -367,7 +367,7 @@ def is_groupable(self, parameters: ParameterGroup, data: dict[str, xr.DataArray] } return len(global_dimensions) == 1 and len(model_dimensions) == 1 - def problem_list(self, parameters: ParameterGroup = None) -> list[str]: + def problem_list(self, parameters: ParameterGroup | None = None) -> list[str]: """ Returns a list with all problems in the model and missing parameters if specified. @@ -388,9 +388,18 @@ def problem_list(self, parameters: ParameterGroup = None) -> list[str]: for item in items.values(): problems += item.validate(self, parameters=parameters) + if parameters is not None and len(parameters.missing_parameter_value_labels) != 0: + label_prefix = "\n - " + problems.append( + f"Parameter definition is missing values for the labels:" + f"{label_prefix}{label_prefix.join(parameters.missing_parameter_value_labels)}" + ) + return problems - def validate(self, parameters: ParameterGroup = None, raise_exception: bool = False) -> str: + def validate( + self, parameters: ParameterGroup = None, raise_exception: bool = False + ) -> MarkdownStr: """ Returns a string listing all problems in the model and missing parameters if specified. @@ -403,14 +412,14 @@ def validate(self, parameters: ParameterGroup = None, raise_exception: bool = Fa result = "" if problems := self.problem_list(parameters): - result = f"Your model has {len(problems)} problems:\n" + result = f"Your model has {len(problems)} problem{'s' if len(problems) > 1 else ''}:\n" for p in problems: result += f"\n * {p}" if raise_exception: raise ModelError(result) else: result = "Your model is valid." - return result + return MarkdownStr(result) def valid(self, parameters: ParameterGroup = None) -> bool: """Returns `True` if the number problems in the model is 0, else `False` diff --git a/glotaran/model/test/test_model.py b/glotaran/model/test/test_model.py index b1970cc2b..116eb6d5e 100644 --- a/glotaran/model/test/test_model.py +++ b/glotaran/model/test/test_model.py @@ -10,6 +10,7 @@ import xarray as xr from IPython.core.formatters import format_display_data +from glotaran.io import load_parameters from glotaran.model import DatasetModel from glotaran.model import Megacomplex from glotaran.model import megacomplex @@ -337,6 +338,58 @@ def test_model_validity(test_model: Model, model_error: Model, parameter: Parame assert len(model_error.problem_list(parameter)) == 10 +def test_model_validate_missing_parameters(): + """Show list of missing parameters as a problem.""" + + model_dict = { + "default_megacomplex": "decay-sequential", + "megacomplex": { + "megacomplex_sequential_decay": { + "type": "decay-sequential", + "compartments": ["species_1", "species_2", "species_3", "species_4"], + "rates": [ + "b.missing_value_1", + "b.missing_value_2", + "b.2", + "kinetic.j.missing_value_3", + ], + "dimension": "time", + } + }, + "dataset": { + "dataset_1": { + "group": "default", + "megacomplex": ["megacomplex_sequential_decay"], + } + }, + } + model = Model.from_dict(model_dict) + parameters = load_parameters( + dedent( + """\ + b: + - ["missing_value_1",] + - ["missing_value_2"] + - ["2", 0.75] + kinetic: + j: + - ["missing_value_3"] + """ + ), + format_name="yml_str", + ) + expected = dedent( + """\ + Your model has 1 problem: + + * Parameter definition is missing values for the labels: + - b.missing_value_1 + - b.missing_value_2 + - kinetic.j.missing_value_3""" + ) + assert str(model.validate(parameters)) == expected + + def test_items(test_model: Model): assert "m1" in test_model.megacomplex diff --git a/glotaran/parameter/parameter.py b/glotaran/parameter/parameter.py index d18930bc1..71cf2efbc 100644 --- a/glotaran/parameter/parameter.py +++ b/glotaran/parameter/parameter.py @@ -173,7 +173,7 @@ def from_list_or_value( else: values = sanitize_parameter_list(value) param.label = _retrieve_item_from_list_by_type(values, str, label) - param.value = float(_retrieve_item_from_list_by_type(values, (int, float), 0)) + param.value = float(_retrieve_item_from_list_by_type(values, (int, float), np.nan)) options = _retrieve_item_from_list_by_type(values, dict, None) if default_options: diff --git a/glotaran/parameter/parameter_group.py b/glotaran/parameter/parameter_group.py index 9b91fda74..f6ff18a27 100644 --- a/glotaran/parameter/parameter_group.py +++ b/glotaran/parameter/parameter_group.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib from copy import copy from textwrap import indent from typing import TYPE_CHECKING @@ -132,10 +133,8 @@ def from_list( for i, item in enumerate(parameter_list): if isinstance(item, (str, int, float)): - try: + with contextlib.suppress(ValueError): item = float(item) - except Exception: - pass if isinstance(item, (float, int, list)): root.add_parameter( Parameter.from_list_or_value(item, label=str(i + 1), default_options=defaults) @@ -444,14 +443,14 @@ def get(self, label: str) -> Parameter: # type:ignore[override] for element in path: try: group = group[element] - except KeyError: - raise ParameterNotFoundException(path, label) + except KeyError as e: + raise ParameterNotFoundException(path, label) from e try: parameter = group._parameters[label] parameter.full_label = full_label return parameter - except KeyError: - raise ParameterNotFoundException(path, label) + except KeyError as e: + raise ParameterNotFoundException(path, label) from e def copy(self) -> ParameterGroup: """Create a copy of the :class:`ParameterGroup`. @@ -491,6 +490,7 @@ def all( """ root = f"{root}{self.label}{separator}" if root is not None else "" for label, p in self._parameters.items(): + p.full_label = f"{root}{label}" yield (f"{root}{label}", p) for _, l in self.items(): yield from l.all(root=root, separator=separator) @@ -585,6 +585,21 @@ def update_parameter_expression(self): ) parameter.value = value + @property + def missing_parameter_value_labels(self) -> list[str]: + """List of full labels where the value is a NaN. + + This property is used to validate that all parameters have starting values. + + Returns + ------- + str + List full labels with missing value. + """ + parameter_df = self.to_dataframe(as_optimized=False) + parameter_nan_value_mask = parameter_df["value"].isna() + return parameter_df[parameter_nan_value_mask]["label"].to_list() + def markdown(self, float_format: str = ".3e") -> MarkdownStr: """Format the :class:`ParameterGroup` as markdown string. diff --git a/glotaran/parameter/test/test_parameter_group.py b/glotaran/parameter/test/test_parameter_group.py index db99bbf57..2fea4cc34 100644 --- a/glotaran/parameter/test/test_parameter_group.py +++ b/glotaran/parameter/test/test_parameter_group.py @@ -93,6 +93,9 @@ def test_parameter_group_from_dict_nested(): assert params.get("kinetic.j.1").full_label == "kinetic.j.1" + roundtrip_df = ParameterGroup.from_dataframe(params.to_dataframe()).to_dataframe() + assert all(roundtrip_df.label == params.to_dataframe().label) + def test_parameter_group_to_array(): params = """ @@ -339,3 +342,27 @@ def test_parameter_group_to_from_df(): assert got.non_negative == wanted.non_negative assert got.value == wanted.value assert got.vary == wanted.vary + + +def test_missing_parameter_value_labels(): + """Full labels of all parameters with missing values (NaN) get listed.""" + parameter_group = load_parameters( + dedent( + """\ + b: + - ["missing_value_1",] + - ["missing_value_2"] + - ["2", 0.75] + kinetic: + j: + - ["missing_value_3"] + """ + ), + format_name="yml_str", + ) + + assert parameter_group.missing_parameter_value_labels == [ + "b.missing_value_1", + "b.missing_value_2", + "kinetic.j.missing_value_3", + ] diff --git a/glotaran/project/scheme.py b/glotaran/project/scheme.py index 5471e1dc1..5ae1f2398 100644 --- a/glotaran/project/scheme.py +++ b/glotaran/project/scheme.py @@ -119,12 +119,12 @@ def problem_list(self) -> list[str]: """ return self.model.problem_list(self.parameters) - def validate(self) -> str: + def validate(self) -> MarkdownStr: """Return a string listing all problems in the model and missing parameters. Returns ------- - str + MarkdownStr A user-friendly string containing all the problems of a model if any. Defaults to 'Your model is valid.' if no problems are found. """ @@ -150,9 +150,7 @@ def markdown(self): """ model_markdown_str = self.model.markdown(parameters=self.parameters) - markdown_str = "\n\n" - markdown_str += "__Scheme__\n\n" - + markdown_str = "\n\n__Scheme__\n\n" if self.non_negative_least_squares is not None: markdown_str += f"* *non_negative_least_squares*: {self.non_negative_least_squares}\n" markdown_str += (