diff --git a/petsctools/__init__.py b/petsctools/__init__.py index edb4c39..d6a7490 100644 --- a/petsctools/__init__.py +++ b/petsctools/__init__.py @@ -37,6 +37,7 @@ is_set_from_options, inserted_options, set_default_parameter, + DefaultOptionSet, ) from .pc import PCBase # noqa: F401 else: @@ -61,6 +62,7 @@ def __getattr__(name): "is_set_from_options", "inserted_options", "set_default_parameter", + "DefaultOptionSet", "PCBase", } if name in petsc4py_attrs: diff --git a/petsctools/options.py b/petsctools/options.py index 89e2cce..413c2bd 100644 --- a/petsctools/options.py +++ b/petsctools/options.py @@ -5,6 +5,7 @@ import functools import itertools import warnings +from functools import cached_property from typing import Any, Iterable import petsc4py @@ -102,16 +103,18 @@ def _warn_unused_options(all_options: Iterable, used_options: Iterable, """ Raise warnings for PETSc options which were not used. - This is meant only as a weakref.finalize callback for the OptionsManager. + This is meant only as a weakref.finalize callback for the + :class:`OptionsManager`. Parameters ---------- all_options : - The full set of options passed to the OptionsManager. + The full set of options passed to the :class:`OptionsManager`. used_options : - The options which were used during the OptionsManager's lifetime. + The options which were used during the :class:`OptionsManager`'s + lifetime. options_prefix : - The options_prefix of the OptionsManager. + The options_prefix of the :class:`OptionsManager`. Raises ------ @@ -127,13 +130,160 @@ def _warn_unused_options(all_options: Iterable, used_options: Iterable, ) +def _validate_prefix(prefix): + """Valid prefixes are strings ending with an underscore. + """ + prefix = str(prefix) + if not prefix.endswith("_"): + prefix += "_" + return prefix + + +class DefaultOptionSet: + """ + Defines a set of common default options shared by multiple PETSc objects. + + Some solvers, e.g. PCFieldsplit, create multiple subsolvers whose prefixes + differ only by the final characters, e.g. ``'fieldsplit_0'``, + ``'fieldsplit_1'``. It is often useful to be able to set default options + for these subsolvers using the un-specialised prefix e.g. + ``'fieldsplit_ksp_type'``. However, just grabbing all options with the + ``'fieldsplit'`` prefix will erroneously find options like ``'0_ksp_type'`` + and ``'1_ksp_type'`` that were meant for a specific subsolver. + + ``DefaultOptionSet`` defines a base prefix (e.g. ``'fieldsplit'``) and a + set of custom prefix endings (e.g. ``[0, 1]``). If passed to an + :class:`OptionsManager` then any default options present in the global + options database will be used if those options are not present either: + in the ``parameters`` passed to the :class:`OptionsManager`; or in the + global ``PETSc.Options`` database with the ``options_prefix`` passed to + the :class:`OptionsManager`. + + For example, to set up a fieldsplit solver you might have the following + options, where both fields are to use ILU as the preconditioner but each + field uses a different KSP type. + + .. code-block:: python3 + + -fieldsplit_pc_type ilu + -fieldsplit_0_ksp_type preonly + -fieldsplit_1_ksp_type richardson + + To create an :class:`OptionsManager` for each field you would call: + + .. code-block:: python3 + + default_options_set = DefaultOptionSet( + base_prefix='fieldsplit', + custom_prefix_endings=(0, 1)) + + fieldsplit_0_options = OptionsManager( + parameters={}, + options_prefix="fieldsplit_0", + default_options_set=default_options_set) + + fieldsplit_1_options = OptionsManager( + parameters={}, + options_prefix="fieldsplit_1", + default_options_set=default_options_set) + + Parameters + ---------- + base_prefix : + The prefix for the default options, which is the beginning of + each full custom prefix. + custom_prefix_endings : + The ends of each individual custom prefix. Often a range of integers. + + Notes + ----- + The base prefix and each custom prefix ending will be converted to a + string and have an underscore appended if they do not already have one. + + See Also + -------- + OptionsManager + get_default_options + attach_options + set_from_options + """ + + def __init__(self, base_prefix: str, custom_prefix_endings: Iterable): + if not custom_prefix_endings: + raise ValueError("custom_prefix_endings cannot be empty") + + base_prefix = _validate_prefix(base_prefix) + + self._base_prefix = base_prefix + self._custom_prefix_endings = tuple( + _validate_prefix(end) for end in custom_prefix_endings) + + @property + def base_prefix(self): + """The prefix for the default options.""" + return self._base_prefix + + @property + def custom_prefix_endings(self): + """The ends of each individual custom prefix.""" + return self._custom_prefix_endings + + @cached_property + def custom_prefixes(self): + """The full custom prefixes.""" + return tuple(self.base_prefix + ending + for ending in self.custom_prefix_endings) + + +def get_default_options(default_options_set: DefaultOptionSet, + options: petsc4py.PETSc.Options | None = None) -> dict: + """ + Extract default options for subsolvers with similar prefixes. + + Parameters + ---------- + default_options_set + The :class:`DefaultOptionSet` which defines the shared options. + options + The ``PETSc.Options`` database to use. If not provided then the global + database will be used. + + Returns + ------- + The dictionary of default options with the base prefix stripped. + + See Also + -------- + DefaultOptionSet + """ + if options is None: + from petsc4py import PETSc + options = PETSc.Options() + + base_prefix = default_options_set.base_prefix + custom_prefixes = default_options_set.custom_prefixes + custom_prefix_endings = default_options_set.custom_prefix_endings + + default_options = { + k.removeprefix(base_prefix): v + for k, v in options.getAll().items() + if (k.startswith(base_prefix) + and not any(k.startswith(prefix) for prefix in custom_prefixes)) + } + # Sanity check, this should never happen. + assert not any(k.startswith(str(end)) + for k in default_options.keys() + for end in custom_prefix_endings) + return default_options + + class OptionsManager: """Class that helps with managing setting PETSc options. The recommended way to use the ``OptionsManager`` is by using the - ``attach_options``, ``set_from_options``, and ``inserted_options`` - free functions. These functions ensure that each ``OptionsManager`` - is associated to a single PETSc object. + :func:`attach_options`, :func:`set_from_options`, and + :func:`inserted_options` free functions. These functions ensure that + each ``OptionsManager`` is associated to a single PETSc object. For detail on the previous approach of using ``OptionsManager`` as a mixin class (where the user takes responsibility for ensuring @@ -142,15 +292,15 @@ class OptionsManager: To use the ``OptionsManager``: 1. Pass a PETSc object a parameters dictionary, and optionally - an options prefix, to ``attach_options``. This will create an - ``OptionsManager`` and set the prefix of the PETSc object, + an options prefix, to :func:`attach_options`. This will create + an ``OptionsManager`` and set the prefix of the PETSc object, but will not yet set it up. - 2. Once the object is ready, pass it to ``set_from_options``, + 2. Once the object is ready, pass it to :func:`set_from_options`, which will insert the solver options into ``PETSc.Options`` and call ``obj.setFromOptions``. - 3. The ``inserted_options`` context manager must be used when - calling methods on the PETSc object within which solver - options will be read, for example ``solve``. + 3. The :func:`inserted_options` context manager must be used when + calling methods on the PETSc object within which solver options + will be read, for example ``solve``. This will insert the provided ``parameters`` into PETSc's global options dictionary within the context manager, and remove them afterwards. This ensures that the global options @@ -174,8 +324,8 @@ class OptionsManager: with inserted_options(ksp): ksp.solve(b, x) - To access the OptionsManager for a PETSc object directly, use - the ``get_options`` function: + To access the ``OptionsManager`` for a PETSc object directly + use the :func:`get_options` function: .. code-block:: python3 @@ -195,7 +345,7 @@ class OptionsManager: So that the runtime monitors which look in the options database actually see options, you need to ensure that the options database is populated at the time of a ``SNESSolve`` or ``KSPSolve`` call. - Do that using the `OptionsManager.inserted_options` context manager. + Do that using the :meth:`OptionsManager.inserted_options` context manager. If using as a mixin class, call the ``OptionsManager`` methods directly: @@ -233,6 +383,9 @@ class OptionsManager: form "{default_prefix}_{n}", where n is a unique integer. Note that because the unique integer is not stable any options passed via the command line with a matching prefix will be ignored. + default_options_set + The prefix set for any default shared with other solvers. + See :class:`DefaultOptionSet` for more information. See Also -------- @@ -242,38 +395,71 @@ class OptionsManager: set_from_options is_set_from_options inserted_options + DefaultOptionSet """ count = itertools.count() def __init__(self, parameters: dict, options_prefix: str | None = None, - default_prefix: str | None = None): + default_prefix: str | None = None, + default_options_set: DefaultOptionSet | None = None): super().__init__() if parameters is None: parameters = {} else: # Convert nested dicts parameters = flatten_parameters(parameters) + + # If no prefix is provided generate a default prefix + # and ignore any command line options if options_prefix is None: default_prefix = default_prefix or "petsctools_" - if not default_prefix.endswith("_"): - default_prefix += "_" + default_prefix = _validate_prefix(default_prefix) self.options_prefix = f"{default_prefix}{next(self.count)}_" self.parameters = parameters self.to_delete = set(parameters) + else: - if options_prefix and not options_prefix.endswith("_"): - options_prefix += "_" + options_prefix = _validate_prefix(options_prefix) self.options_prefix = options_prefix - # Remove those options from the dict that were passed on - # the commandline. + + # Are we part of a solver set sharing defaults? + if default_options_set: + if options_prefix not in default_options_set.custom_prefixes: + raise ValueError( + f"The options_prefix {options_prefix} must be one" + f" of the custom_prefixes of the DefaultOptionSet" + f" {default_options_set.custom_prefixes}") + default_options = get_default_options( + default_options_set, self.options_object) + else: + default_options = {} + + # Note: we need to know which parameters to_delete + # so we need to exclude the relevant command line + # options when combining the parameters from the + # defaults and the source code. + + # Start building parameters from the defaults so + # that they will overwritten by any other source. self.parameters = { k: v - for k, v in parameters.items() + for k, v in default_options.items() if options_prefix + k not in get_commandline_options() } + + # Update using the parameters passed in the code but + # exclude those options from the dict that were passed + # on the commandline because those have global scope and are + # not under the control of the options manager. + self.parameters.update({ + k: v + for k, v in parameters.items() + if options_prefix + k not in get_commandline_options() + }) self.to_delete = set(self.parameters) + # Now update parameters from options, so that they're # available to solver setup (for, e.g., matrix-free). # Can't ask for the prefixed guy in the options object, @@ -281,6 +467,7 @@ def __init__(self, parameters: dict, for k, v in self.options_object.getAll().items(): if k.startswith(self.options_prefix): self.parameters[k[len(self.options_prefix):]] = v + self._setfromoptions = False # Keep track of options used between invocations of inserted_options(). self._used_options = set() @@ -373,24 +560,29 @@ def attach_options( obj: petsc4py.PETSc.Object, parameters: dict | None = None, options_prefix: str | None = None, - default_prefix: str | None = None + default_prefix: str | None = None, + default_options_set: DefaultOptionSet | None = None ) -> None: - """Set up an OptionsManager and attach it to a PETSc Object. + """Set up an :class:`OptionsManager` and attach it to a PETSc Object. Parameters ---------- obj - The object to attach an OptionsManager to. + The object to attach an :class:`OptionsManager` to. parameters The dictionary of parameters to use. options_prefix The options prefix to use for this object. default_prefix Base string for autogenerated default prefixes. + default_options_set + The prefix set for any default shared with other solvers. See Also -------- OptionsManager + set_from_options + DefaultOptionSet """ if has_options(obj): raise PetscToolsException( @@ -402,25 +594,29 @@ def attach_options( parameters=parameters, options_prefix=options_prefix, default_prefix=default_prefix, + default_options_set=default_options_set ) obj.setAttr("options", options) def has_options(obj: petsc4py.PETSc.Object) -> bool: - """Return whether this PETSc object has an OptionsManager attached. + """Return whether this PETSc object has an :class:`OptionsManager` + attached. Parameters ---------- obj - The object which may have an OptionsManager. + The object which may have an :class:`OptionsManager`. Returns ------- - Whether the object has an OptionsManager. + Whether the object has an :class:`OptionsManager`. See Also -------- OptionsManager + attach_options + set_from_options """ return "options" in obj.getDict() and isinstance( obj.getAttr("options"), OptionsManager @@ -428,25 +624,27 @@ def has_options(obj: petsc4py.PETSc.Object) -> bool: def get_options(obj: petsc4py.PETSc.Object) -> OptionsManager: - """Return the OptionsManager attached to this PETSc object. + """Return the :class:`OptionsManager` attached to this PETSc object. Parameters ---------- obj - The object to get the OptionsManager from. + The object to get the :class:`OptionsManager` from. Returns ------- - The OptionsManager attached to the object. + The :class:`OptionsManager` attached to the object. Raises ------ PetscToolsException - If the object does not have an OptionsManager. + If the object does not have an :class:`OptionsManager`. See Also -------- OptionsManager + attach_options + set_from_options """ if not has_options(obj): raise PetscToolsException( @@ -458,12 +656,13 @@ def get_options(obj: petsc4py.PETSc.Object) -> OptionsManager: def set_default_parameter( obj: petsc4py.PETSc.Object, key: str, val: Any ) -> None: - """Set a default parameter value in the OptionsManager of a PETSc object. + """Set a default parameter value in the :class:`OptionsManager` of a + PETSc object. Parameters ---------- obj - The object to get the OptionsManager from. + The object to get the :class:`OptionsManager` from. key The options parameter name val @@ -472,12 +671,14 @@ def set_default_parameter( Raises ------ PetscToolsException - If the object does not have an OptionsManager. + If the object does not have an :class:`OptionsManager`. See Also -------- OptionsManager OptionsManager.set_default_parameter + attach_options + set_from_options """ get_options(obj).set_default_parameter(key, val) @@ -487,8 +688,9 @@ def set_from_options( parameters: dict | None = None, options_prefix: str | None = None, default_prefix: str | None = None, + default_options_set: DefaultOptionSet | None = None ) -> None: - """Set up a PETSc object from the options in its OptionsManager. + """Set up a PETSc object from the options in its :class:`OptionsManager`. Calls ``obj.setOptionsPrefix`` and ``obj.setFromOptions`` whilst inside the ``inserted_options`` context manager, which ensures @@ -510,15 +712,17 @@ def set_from_options( The options prefix to use for this object. default_prefix Base string for autogenerated default prefixes. + default_options_set + The prefix set for any default shared with other solvers. Raises ------ PetscToolsException If the neither ``parameters`` nor ``options_prefix`` are - provided but ``obj`` does not have an OptionsManager attached. + provided but ``obj`` does not have an :class:`OptionsManager` attached. PetscToolsException If the either ``parameters`` or ``options_prefix`` are provided - but ``obj`` already has an OptionsManager attached. + but ``obj`` already has an :class:`OptionsManager` attached. PetscToolsWarning If set_from_options has already been called for this object. @@ -526,6 +730,8 @@ def set_from_options( -------- OptionsManager OptionsManager.set_from_options + attach_options + DefaultOptionSet """ if has_options(obj): if parameters is not None or options_prefix is not None: @@ -545,6 +751,7 @@ def set_from_options( obj, parameters=parameters, options_prefix=options_prefix, default_prefix=default_prefix, + default_options_set=default_options_set ) if is_set_from_options(obj): @@ -558,7 +765,8 @@ def set_from_options( def is_set_from_options(obj: petsc4py.PETSc.Object) -> bool: """ - Return whether this PETSc object has been set by the OptionsManager. + Return whether this PETSc object has been set by the + :class:`OptionsManager`. Parameters ---------- @@ -572,11 +780,13 @@ def is_set_from_options(obj: petsc4py.PETSc.Object) -> bool: Raises ------ PetscToolsException - If the object does not have an OptionsManager. + If the object does not have an :class:`OptionsManager`. See Also -------- OptionsManager + attach_options + set_from_options """ return get_options(obj)._setfromoptions @@ -584,12 +794,24 @@ def is_set_from_options(obj: petsc4py.PETSc.Object) -> bool: @contextlib.contextmanager def inserted_options(obj): """Context manager inside which the PETSc options database - contains the parameters from this object's OptionsManager. + contains the parameters from this object's :class:`OptionsManager`. + + Parameters + ---------- + obj : + The object which may have been set from options. + + Raises + ------ + PetscToolsException + If the object does not have an :class:`OptionsManager`. See Also -------- OptionsManager OptionsManager.inserted_options + attach_options + set_from_options """ with get_options(obj).inserted_options(): yield diff --git a/tests/test_options.py b/tests/test_options.py index b33bde8..67527fc 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -96,3 +96,60 @@ def test_options_prefix(): options = petsctools.OptionsManager({}, options_prefix="myobj", default_prefix="firedrake") assert options.options_prefix.startswith("myobj_") + + +@pytest.mark.skipnopetsc4py +def test_default_options(): + from petsc4py import PETSc + + parameters = { + 'options_left': 0, + 'unrelated_option': 0, + 'base_opt1': 1, + 'base_opt2': 2, + 'base_opt3': 3, + 'base_0_opt3': 4, + 'base_1_opt3': 5, + 'base_2_opt4': 6, + } + options = PETSc.Options() + for k, v in parameters.items(): + options[k] = v + + # default_options = {"opt1": 1, "opt2": 2, "opt3": 3} + default_option_set = petsctools.DefaultOptionSet( + base_prefix="base", custom_prefix_endings=("0", "1", "2")) + + # test default is overriden by command line + options0 = petsctools.OptionsManager( + parameters={}, + options_prefix="base_0", + default_options_set=default_option_set) + + # test defaults is overriden by command line and source-code + options1 = petsctools.OptionsManager( + parameters={"opt2": "7"}, + options_prefix="base_1", + default_options_set=default_option_set) + + # test both defaults and non-defaults are picked up + options2 = petsctools.OptionsManager( + parameters={}, + options_prefix="base_2", + default_options_set=default_option_set) + + assert len(options0.parameters) == 3 + assert options0.parameters["opt1"] == "1" + assert options0.parameters["opt2"] == "2" + assert options0.parameters["opt3"] == "4" + + assert len(options1.parameters) == 3 + assert options1.parameters["opt1"] == "1" + assert options1.parameters["opt2"] == "7" + assert options1.parameters["opt3"] == "5" + + assert len(options2.parameters) == 4 + assert options2.parameters["opt1"] == "1" + assert options2.parameters["opt2"] == "2" + assert options2.parameters["opt3"] == "3" + assert options2.parameters["opt4"] == "6"