Source code for CADETProcess.parameter_space.mappers

"""Path-based and callable parameter mappers.

Provides the primitive that writes a parameter value into an evaluation object.
``DotPathMapper`` traverses a dot-separated attribute/key path; ``IndexedMapper``
extends this with a read-patch-write cycle for array elements; ``CallableMapper``
delegates to a user-supplied function for anything else.  All mappers broadcast
over a list of evaluation objects.

The path segment parser is centralized here so that every mapper that accepts a
string path uses identical traversal and parsing semantics.

Path syntax
-----------
``"column.length"``
    Dot-separated attribute / dict-key traversal.
``"film_diffusion[2]"``
    Attribute traversal followed by an integer array index.
``"film_diffusion[1:3]"``
    Attribute traversal followed by a slice.
``"units[0].column.length"``
    Intermediate index followed by further attribute traversal.
"""

from __future__ import annotations

import re
import warnings
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, Sequence
from typing import Any

import numpy as np
from numpy.exceptions import VisibleDeprecationWarning

__all__ = [
    "parse_path",
    "ParameterMapperBase",
    "DotPathMapper",
    "IndexedMapper",
    "CallableMapper",
    "make_preprocessing_mapper",
]

# Matches the bracket expression at the very end of a dot-segment: "name[expr]"
_BRACKET_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\[([^\]]*)\]$")


def _parse_index_expr(expr: str) -> int | slice:
    """Parse an index expression string into an ``int`` or ``slice``.

    Parameters
    ----------
    expr : str
        Content inside ``[...]``, e.g. ``"2"``, ``"1:3"``, ``":"``

    Returns
    -------
    int or slice
    """
    expr = expr.strip()
    if ":" in expr:
        if expr.count(":") > 2:
            raise ValueError(f"Malformed slice expression {expr!r}: too many colons.")
        parts = expr.split(":", 2)
        try:
            start = int(parts[0].strip()) if parts[0].strip() else None
            stop = int(parts[1].strip()) if len(parts) > 1 and parts[1].strip() else None
            step = int(parts[2].strip()) if len(parts) > 2 and parts[2].strip() else None
        except ValueError as exc:
            raise ValueError(f"Malformed slice expression {expr!r}.") from exc
        return slice(start, stop, step)
    try:
        return int(expr)
    except ValueError as exc:
        raise ValueError(f"Malformed index expression {expr!r}.") from exc


[docs] def parse_path(path: str) -> tuple[str | int | slice, ...]: """Split a path string into typed segments. Dot notation separates attribute / dict-key hops. Bracket notation at the end of a segment specifies an array index or slice. Parameters ---------- path : str Path string, e.g. ``"column.length"``, ``"film_diffusion[2]"``, ``"column.film_diffusion[1:3]"``, or ``"units[0].column.length"``. Returns ------- tuple[str | int | slice, ...] Non-empty tuple of segments. String segments are attribute names or dict keys; ``int`` and ``slice`` segments are array indices. Raises ------ ValueError If *path* is empty, contains an empty attribute segment, or has malformed bracket notation. Examples -------- >>> parse_path("column.length") ('column', 'length') >>> parse_path("film_diffusion[2]") ('film_diffusion', 2) >>> parse_path("column.film_diffusion[1:3]") ('column', 'film_diffusion', slice(1, 3, None)) >>> parse_path("units[0].column.length") ('units', 0, 'column', 'length') >>> parse_path("feed") ('feed',) """ if not path: raise ValueError("Path must not be empty.") segments: list[str | int | slice] = [] for dot_seg in path.split("."): if not dot_seg: raise ValueError(f"Path {path!r} contains an empty segment.") m = _BRACKET_RE.match(dot_seg) if m: segments.append(m.group(1)) segments.append(_parse_index_expr(m.group(2))) else: # No bracket — must be a plain identifier (no stray ']' etc.) if "[" in dot_seg or "]" in dot_seg: raise ValueError( f"Malformed bracket notation in segment {dot_seg!r} of path {path!r}." ) segments.append(dot_seg) return tuple(segments)
def _traverse(root: Any, segments: Sequence[str | int | slice]) -> tuple[Any, str | int | slice]: """Walk *segments* starting from *root*, returning ``(parent, leaf_segment)``. Intermediate string segments are resolved by dict lookup (for ``Mapping`` nodes) or attribute access (for everything else). Intermediate int / slice segments are resolved via ``__getitem__``. The leaf segment is returned unevaluated so the caller decides how to write. Raises ------ KeyError If a dict-hop key is missing. AttributeError If an attribute-hop name is missing. IndexError If an integer-hop index is out of range. """ cur = root for seg in segments[:-1]: if isinstance(seg, (int, slice)): cur = cur[seg] elif isinstance(cur, Mapping): cur = cur[seg] else: cur = getattr(cur, seg) return cur, segments[-1]
[docs] class ParameterMapperBase(ABC): """Base class: broadcasts a write over a list of evaluation objects.""" def __init__(self, evaluation_objects: Sequence[Any]) -> None: self.evaluation_objects = list(evaluation_objects)
[docs] def set_value(self, value: Any) -> None: """Write *value* into every object in ``evaluation_objects``.""" for obj in self.evaluation_objects: self._set_value(obj, value)
@abstractmethod def _set_value(self, obj: Any, value: Any) -> None: """Write *value* into a single evaluation object."""
[docs] def get_value(self) -> Any: """Read the current value from the first evaluation object. Returns ``None`` for mappers that do not support read-back (e.g. ``CallableMapper``). """ if not self.evaluation_objects: return None return self._get_value(self.evaluation_objects[0])
def _get_value(self, obj: Any) -> Any: # noqa: ARG002 """Read from a single evaluation object. Override to support read-back.""" return None
[docs] class DotPathMapper(ParameterMapperBase): """Write a value to the leaf referenced by a dot-separated path. Traverses dicts and objects in any combination; missing hops raise. The leaf segment must be a string (attribute name or dict key). For array element writes use ``IndexedMapper``. Parameters ---------- evaluation_objects : sequence Objects to write into. path : str Dot-separated path to the target attribute or dict key. Must not contain bracket index notation (e.g. ``"film_diffusion[2]"``). Raises ------ ValueError If *path* ends with a bracket index. Use ``IndexedMapper`` instead. """ def __init__(self, evaluation_objects: Sequence[Any], path: str) -> None: super().__init__(evaluation_objects) self.path = path self._segments = parse_path(path) if isinstance(self._segments[-1], (int, slice)): raise ValueError( f"Path {path!r} ends with an array index. " "Use IndexedMapper for indexed array writes." ) def _set_value(self, obj: Any, value: Any) -> None: parent, leaf = _traverse(obj, self._segments) if isinstance(parent, Mapping): parent[leaf] = value else: setattr(parent, leaf, value) def _get_value(self, obj: Any) -> Any: parent, leaf = _traverse(obj, self._segments) if isinstance(parent, Mapping): return parent[leaf] return getattr(parent, leaf)
[docs] class IndexedMapper(ParameterMapperBase): """Read-patch-write mapper for individual array elements or slices. Traverses to the target array via *path*, then patches the entry at *index* with the new scalar value and writes the whole array back. This read-patch-write cycle is necessary because array-valued parameters are stored as plain values (not mutable proxies) on most model objects. Supports two equivalent forms:: IndexedMapper(objs, path="film_diffusion[2]") IndexedMapper(objs, path="film_diffusion", index=2) Multi-dimensional parameters (e.g. 2-D reaction exponent arrays or polynomial concentration profiles) are supported via tuple indices:: IndexedMapper(objs, path="exponents_fwd", index=(0, 1)) IndexedMapper(objs, path="c", index=np.s_[0, :]) Genuinely inhomogeneous (ragged) arrays raise ``NotImplementedError``. Use a ``CallableMapper`` for those. A bare (non-tuple) index into a polynomial parameter (one whose descriptor exposes ``fill_values``, e.g. ``NdPolynomial``) selects a whole coefficient row rather than a single cell. In that case the row is filled via the descriptor's own ``fill_values(shape, value)`` — the polynomial convention of "set the constant coefficient, zero the rest" — instead of broadcasting the scalar across every coefficient. A tuple index (e.g. ``(0, 1)``) that fully specifies a single cell is a plain scalar write either way. Parameters ---------- evaluation_objects : sequence Objects to write into. path : str Dot-separated path to the target array attribute. May embed a scalar index as bracket notation: ``"film_diffusion[2]"``. index : int, slice, or tuple of (int | slice), optional Index into the array. Required when *path* does not embed an index; must be omitted when *path* already contains one. Pass a tuple for multi-dimensional access, e.g. ``(0, 1)`` or ``np.s_[0, :]``. """ def __init__( self, evaluation_objects: Sequence[Any], path: str, index: int | slice | tuple | None = None, ) -> None: super().__init__(evaluation_objects) segments = parse_path(path) if isinstance(segments[-1], (int, slice)): if index is not None: raise ValueError( "Cannot specify both embedded index notation in path and " "an explicit index parameter." ) self._attr_segments = segments[:-1] self._index: int | slice | tuple = segments[-1] else: if index is None: raise ValueError( "IndexedMapper requires an index. Either embed it in the " "path (e.g. 'film_diffusion[2]') or pass index= explicitly." ) self._attr_segments = segments self._index = index if not self._attr_segments: raise ValueError("IndexedMapper requires a non-empty attribute path.") self.path = ".".join(str(s) for s in self._attr_segments) self.index = self._index def _set_value(self, obj: Any, value: Any) -> None: parent, leaf = _traverse(obj, self._attr_segments) if isinstance(parent, Mapping): current = parent[leaf] descriptor = None else: current = getattr(parent, leaf) descriptor = getattr(type(parent), leaf, None) new_value = self._patch(current, value, descriptor) if isinstance(parent, Mapping): parent[leaf] = new_value else: setattr(parent, leaf, new_value) def _get_value(self, obj: Any) -> Any: parent, leaf = _traverse(obj, self._attr_segments) if isinstance(parent, Mapping): arr = parent[leaf] else: arr = getattr(parent, leaf) return np.asarray(arr)[self._index] def _patch(self, current: Any, value: Any, descriptor: Any = None) -> Any: """Return a copy of *current* with ``self._index`` set to *value*.""" was_list = isinstance(current, list) with warnings.catch_warnings(): warnings.simplefilter("error", VisibleDeprecationWarning) try: arr = np.array(current, copy=True) except (ValueError, VisibleDeprecationWarning): raise NotImplementedError( "Inhomogeneous (ragged) arrays are not supported by " "IndexedMapper. Use CallableMapper instead." ) if arr.dtype == object: raise NotImplementedError( "Object-type arrays are not supported by IndexedMapper." ) target = arr[self._index] fill_values = getattr(descriptor, "fill_values", None) if fill_values is not None and isinstance(target, np.ndarray): # Bare index into a polynomial parameter: reuse the descriptor's own # "constant coefficient, zero the rest" convention for the row # instead of broadcasting the scalar across every coefficient. arr[self._index] = fill_values(target.shape, value) else: arr[self._index] = value return arr.tolist() if was_list else arr
[docs] class CallableMapper(ParameterMapperBase): """Write a value by delegating to a user-supplied function. The callable receives ``(evaluation_object, value)`` and is responsible for the actual write. This is the escape hatch for any target that cannot be expressed as a dot-path. Parameters ---------- evaluation_objects : sequence Objects to write into. fn : Callable[[Any, Any], None] Function called as ``fn(obj, value)`` for each evaluation object. """ def __init__( self, evaluation_objects: Sequence[Any], fn: Callable[[Any, Any], None] ) -> None: super().__init__(evaluation_objects) self.fn = fn def _set_value(self, obj: Any, value: Any) -> None: self.fn(obj, value)
def make_preprocessing_mapper( evaluation_objects: Sequence[Any], path: str, pre_processing: Callable[[Any], Any], ) -> CallableMapper: """Return a CallableMapper that applies *pre_processing* before writing via *path*. Useful when the value must be transformed (e.g. unit conversion, clipping) before it is written to the attribute at *path*. Parameters ---------- evaluation_objects : sequence Objects to write into. path : str Dot-separated attribute path on each evaluation object. pre_processing : callable Applied to the raw value before the write: ``obj.attr = pre_processing(v)``. """ *parts, attr = path.split(".") def _write(obj: Any, v: Any) -> None: target = obj for part in parts: target = getattr(target, part) setattr(target, attr, pre_processing(v)) return CallableMapper(evaluation_objects, _write)