Source code for geopulse.uq.uncertain

"""Core uncertainty type: ``Uncertain[T]``.

Design philosophy: UQ is baked in from day one, not bolted on later. The
:class:`Uncertain` type wraps any numeric value (scalar, array, or dataclass)
and can represent either a deterministic value or a distribution of values.

When deterministic values flow through the pipeline, there is zero overhead.
When distributions flow through, Monte-Carlo propagation kicks in via
:func:`propagate_uncertainty`.

Examples
--------
>>> from geopulse.uq.uncertain import Uncertain, propagate_uncertainty
>>> b = Uncertain(nominal=1.0, distribution="gaussian", params={"std": 0.1})
>>> out = propagate_uncertainty(lambda x: 2.0 * x, b, n_samples=200)
>>> abs(out.mean - 2.0) < 0.05
True
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any, Generic, Optional, TypeVar

import numpy as np

__all__ = ["Uncertain", "propagate_uncertainty"]

T = TypeVar("T")


[docs] @dataclass class Uncertain(Generic[T]): """A value that may carry uncertainty information. Parameters ---------- nominal : T The central / best-estimate value. samples : list[T] | None, optional Monte-Carlo samples, if uncertainty has been propagated. Length equals the number of MC draws. distribution : str, optional Distribution family. One of ``"deterministic"``, ``"gaussian"``, ``"uniform"``, ``"ensemble"``. Default: ``"deterministic"``. params : dict, optional Distribution parameters. For ``gaussian``: ``{"std": ...}``. For ``uniform``: ``{"low": ..., "high": ...}``. For ``ensemble``: empty (samples ARE the distribution). Notes ----- :class:`Uncertain` is *not* frozen: :meth:`generate_samples` is a query method and does not mutate the instance, but downstream propagation code may attach freshly-drawn ``samples`` after construction. """ nominal: T samples: Optional[list[T]] = None distribution: str = "deterministic" params: dict = field(default_factory=dict) @property def is_deterministic(self) -> bool: """Whether this value carries no uncertainty.""" return self.distribution == "deterministic" and self.samples is None @property def n_samples(self) -> int: """Number of Monte-Carlo samples, or 0 if deterministic.""" return len(self.samples) if self.samples is not None else 0 @property def mean(self) -> Any: """Mean of ``samples``, or ``nominal`` if deterministic.""" if self.samples is None: return self.nominal return np.mean(np.asarray(self.samples), axis=0) @property def std(self) -> Any: """Standard deviation of ``samples``, or zero if deterministic.""" if self.samples is None: if hasattr(self.nominal, "__len__"): return np.zeros_like(np.asarray(self.nominal)) return 0.0 return np.std(np.asarray(self.samples), axis=0)
[docs] def generate_samples( self, n: int, rng: Optional[np.random.Generator] = None, ) -> list: """Draw ``n`` Monte-Carlo samples from the declared distribution. Parameters ---------- n : int Number of samples to generate. rng : numpy.random.Generator, optional Reproducible RNG. If ``None``, a fresh default RNG is created. Returns ------- list ``n`` samples, each with the same shape/type as :attr:`nominal`. Raises ------ ValueError If :attr:`distribution` is not one of the supported families. """ if rng is None: rng = np.random.default_rng() if self.distribution == "deterministic": return [self.nominal] * n if self.distribution == "gaussian": std = self.params.get("std", 0.0) shape = np.shape(self.nominal) # type: ignore[arg-type] # T is unbounded return [ self.nominal + rng.normal(0.0, std, size=shape) # type: ignore[operator] for _ in range(n) ] if self.distribution == "uniform": low = self.params.get("low") high = self.params.get("high") if low is None or high is None: raise ValueError("uniform distribution requires 'low' and 'high'") shape = np.shape(self.nominal) # type: ignore[arg-type] # T is unbounded return [rng.uniform(low, high, size=shape) for _ in range(n)] if self.distribution == "ensemble": if self.samples is None: raise ValueError("ensemble distribution requires pre-populated samples") # Resample with replacement. idx = rng.integers(0, len(self.samples), size=n) return [self.samples[i] for i in idx] raise ValueError(f"Unknown distribution: {self.distribution!r}")
[docs] def propagate_uncertainty( func: Callable[..., Any], *args: Any, n_samples: int = 100, seed: int = 42, **kwargs: Any, ) -> Uncertain: """Propagate uncertainty through a function via Monte Carlo. Any argument that is an :class:`Uncertain` with a non-deterministic distribution is sampled; deterministic arguments pass through unchanged. Parameters ---------- func : callable The function to propagate through. *args Positional arguments — may include :class:`Uncertain` values. n_samples : int, optional Number of Monte-Carlo samples. Default: 100. seed : int, optional Random seed for reproducibility. Default: 42. **kwargs Keyword arguments — may include :class:`Uncertain` values. Returns ------- Uncertain Result with ``distribution="ensemble"`` and ``samples`` populated. Examples -------- >>> u = Uncertain(nominal=1.0, distribution="gaussian", params={"std": 0.1}) >>> out = propagate_uncertainty(lambda x: x * 2, u, n_samples=100) >>> out.n_samples 100 """ rng = np.random.default_rng(seed) def _sample(v: Any) -> list: if isinstance(v, Uncertain) and not v.is_deterministic: return v.generate_samples(n_samples, rng) val = v.nominal if isinstance(v, Uncertain) else v return [val] * n_samples arg_samples = [_sample(a) for a in args] kwarg_samples = {k: _sample(v) for k, v in kwargs.items()} results: list[Any] = [] for i in range(n_samples): sample_args = [s[i] for s in arg_samples] sample_kwargs = {k: v[i] for k, v in kwarg_samples.items()} results.append(func(*sample_args, **sample_kwargs)) nominal_args = [a.nominal if isinstance(a, Uncertain) else a for a in args] nominal_kwargs = {k: (v.nominal if isinstance(v, Uncertain) else v) for k, v in kwargs.items()} nominal = func(*nominal_args, **nominal_kwargs) return Uncertain(nominal=nominal, samples=results, distribution="ensemble")