Source code for cfdmod.core.container

"""Generic container of :class:`DataSource` (or any pickleable value).

A directional / parametric multi-case map, keyed by a frozen Pydantic
case-parameters object:

- a ``dict[K, V]`` keyed by a frozen case-parameters object
  (``cfdmod.dynamics.BuildingCaseParameters`` is one example, but the
  container does not care);
- ``join_by(callback)`` partitions the container by a derived key
  (e.g. "by direction", "by recurrence period");
- ``filter_by(callback)`` returns a sub-container;
- ``map_values(pipeline, *, pool=None)`` runs a pipeline over every
  value, optionally in parallel via an injected :class:`Pool`.

The building dynamic-response cases (``cfdmod.dynamics.cases``) group and
filter directional results through :meth:`join_by` / :meth:`filter_by`
with no bespoke machinery.
"""

from __future__ import annotations

__all__ = ["Container"]

from typing import Any, Callable, Generic, Hashable, Iterator, TypeVar

from pydantic import BaseModel, ConfigDict, Field

from cfdmod.core.protocols import Pool

K = TypeVar("K", bound=Hashable)
V = TypeVar("V")
T = TypeVar("T", bound=Hashable)


[docs] class Container(BaseModel, Generic[K, V]): """Hashable-keyed map of values, with parallel fanout and partition. Frozen at the model level; the underlying dict is replaced by construction of a new container rather than mutated. """ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) items: dict[K, V] = Field(default_factory=dict) def __len__(self) -> int: return len(self.items) def __iter__(self) -> Iterator[K]: # type: ignore[override] return iter(self.items) def __contains__(self, key: K) -> bool: return key in self.items def __getitem__(self, key: K) -> V: return self.items[key] def keys(self): return self.items.keys() def values(self): return self.items.values() # ----- Functional updates ------------------------------------------------- def with_item(self, key: K, value: V) -> "Container[K, V]": new_items = dict(self.items) new_items[key] = value return self.__class__(items=new_items) def without_key(self, key: K) -> "Container[K, V]": new_items = {k: v for k, v in self.items.items() if k != key} return self.__class__(items=new_items) def merge(self, other: "Container[K, V]") -> "Container[K, V]": new_items = dict(self.items) new_items.update(other.items) return self.__class__(items=new_items) # ----- Partition / filter ------------------------------------------------
[docs] def filter_by(self, predicate: Callable[[K], bool]) -> "Container[K, V]": """Return a sub-container of entries whose key satisfies ``predicate``.""" new_items = {k: v for k, v in self.items.items() if predicate(k)} return self.__class__(items=new_items)
[docs] def join_by(self, callback: Callable[[K], T]) -> dict[T, "Container[K, V]"]: """Partition by a derived key. Mirrors ``HFPIAnalysisResults.join_by``: for every entry, run ``callback(key)`` to derive a *partition* key, then collect entries sharing each partition key into their own container. """ partitions: dict[T, dict[K, V]] = {} for k, v in self.items.items(): partition_key = callback(k) partitions.setdefault(partition_key, {})[k] = v return {pk: self.__class__(items=pv) for pk, pv in partitions.items()}
# ----- Map over values ---------------------------------------------------
[docs] def map_values( self, func: Callable[[V], Any], *, pool: Pool | None = None, ) -> "Container[K, Any]": """Apply ``func`` to every value. If ``pool`` is supplied, fanout runs through ``pool.map`` and the entries' order is preserved by re-zipping with the keys. Without a pool the work runs sequentially in insertion order. """ keys = list(self.items.keys()) values = list(self.items.values()) if pool is None: new_values = [func(v) for v in values] else: new_values = pool.map(func, values) return self.__class__(items=dict(zip(keys, new_values)))