Source code for ogstools.materiallib.core.material_manager

# Copyright (c) 2012-2025, OpenGeoSys Community (http://www.opengeosys.org)
#            Distributed under a Modified BSD License.
#            See accompanying file LICENSE.txt or
#            http://www.opengeosys.org/project/license
#

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any

import yaml  # type: ignore[import]

import ogstools.definitions as defs
from ogstools.materiallib.schema.process_schema import PROCESS_SCHEMAS

from .material import Material

logger = logging.getLogger(__name__)


[docs] class MaterialManager: """ Manager for material definitions loaded from a repository of YAML files. A MaterialManager can be constructed in two ways: 1. **From a repository directory** (default): If no materials are provided, all YAML files in the given `data_dir` are parsed into Material objects and stored in `materials_db`. 2. **From pre-filtered materials**: If a dictionary of `Material` objects is passed via `materials`, the manager is created directly from these. This is typically used internally when creating filtered views. Once constructed, a MaterialManager can: - provide access to the stored materials - filter materials according to a process schema, subdomain mapping, and/or fluid assignments - represent the filtered material set as a new MaterialManager. """
[docs] def __init__( self, data_dir: Path | None = None, materials: dict[str, Material] | None = None, subdomain_ids: dict[str, int] | None = None, process: str | None = None, ): """ Initialize a MaterialManager instance. Parameters ---------- data_dir : Path | None Directory containing the repository of material YAML files. Defaults to `defs.MATERIALS_DIR`. Only used if no `materials` are passed. materials : dict[str, Material] | None Pre-loaded material dictionary. If None, materials are loaded from the repository directory. If provided, these materials are used as-is without accessing the repository (commonly from filtering). subdomain_ids : dict[str, int] | None Mapping of subdomain names to material IDs. process : str | None Process type used for filtering, if applicable. Notes ----- - If `materials` is None, the instance represents the **full material repository** loaded from the given directory. - If `materials` is provided, the instance represents a **filtered view** and does not perform any additional repository access. """ self.data_dir = data_dir or Path(defs.MATERIALS_DIR) self.materials_db: dict[str, Material] = materials or {} self.subdomain_ids: dict[str, int] = subdomain_ids or {} self.fluids: dict[str, Material] = {} self.process = process if materials is None: # only load from repository if not provided logger.info("Loading materials from repository: %s", self.data_dir) self._load_materials() logger.debug("Materials loaded: %s", list(self.materials_db.keys())) else: logger.debug("Using provided materials: %s", list(materials.keys()))
# ------------------------------------------------------------ # Loading # ------------------------------------------------------------ def _load_materials(self) -> None: yaml_files = list(self.data_dir.glob("*.yml")) + list( self.data_dir.glob("*.yaml") ) if not yaml_files: msg = f"No YAML files found in {self.data_dir}" raise FileNotFoundError(msg) for file_path in yaml_files: with file_path.open(encoding="utf-8") as file: raw_data = yaml.safe_load(file) if not isinstance(raw_data, dict): logger.debug("Skipping invalid YAML file: %s", file_path) continue if "name" not in raw_data: logger.debug( "Skipping YAML file without 'name': %s", file_path ) continue name = raw_data["name"] material = Material(name=name, raw_data=raw_data) self.materials_db[material.name] = material # ------------------------------------------------------------ # Accessors # ------------------------------------------------------------
[docs] def get_material(self, name: str) -> Material | None: """ Retrieve a material from the repository by name. """ return self.materials_db.get(name)
def _list_materials(self) -> list[str]: return list(self.materials_db.keys()) # ------------------------------------------------------------ # Filtering # ------------------------------------------------------------
[docs] def filter( self, process: str, subdomains: list[dict[str, Any]], fluids: dict[str, str] | None = None, ) -> MaterialManager: """ Create a filtered view of the materials for a given process. Filtering is based on: - a process schema, - subdomain assignments (mapping subdomain names to one or more material IDs), - optional fluid phase materials. Parameters ---------- process : str The process name to filter by. subdomains : list[dict[str, Any]] A list of dictionaries, each containing the material name, a subdomain name, and one or more material IDs. fluids : dict[str, str] | None A dictionary mapping phase types to material names. Returns ------- MaterialManager A new MaterialManager instance containing only the filtered materials. This new instance does not access the repository; it reuses the already loaded Material objects. """ schema = PROCESS_SCHEMAS.get(process) if schema is None: msg = f"No process schema found for '{process}'" raise ValueError(msg) filtered: dict[str, Material] = {} subdomain_ids: dict[str, int] = {} # Solids (subdomains) for entry in subdomains: name = entry["material"] mat = self.get_material(name) if mat is None: msg = f"Material '{name}' not found in repository." raise ValueError(msg) filtered_mat = mat.filter_process(schema) subdomain_name = entry["subdomain"] mat_ids = entry["material_ids"] if isinstance(mat_ids, int): mat_ids = [mat_ids] for mat_id in mat_ids: # if only one ID, keep the plain subdomain name # if multiple IDs, disambiguate by appending the ID key = ( subdomain_name if len(mat_ids) == 1 else f"{subdomain_name}_{mat_id}" ) filtered[key] = filtered_mat subdomain_ids[key] = mat_id # Fluids fluid_materials: dict[str, Material] = {} for phase_type, mat_name in (fluids or {}).items(): raw = self.get_material(mat_name) if raw is None: msg = f"Fluid material '{mat_name}' not found in repository." raise ValueError(msg) fluid_materials[phase_type] = raw.filter_process(schema) return MaterialManager( data_dir=self.data_dir, materials=filtered, subdomain_ids=subdomain_ids, process=process, )._with_fluids(fluid_materials)
def _with_fluids(self, fluids: dict[str, Material]) -> MaterialManager: """ Internal helper to attach fluid materials to a filtered manager. """ self.fluids = fluids return self def _list_ids(self) -> list[int]: """ Return a list of material IDs for the subdomains. """ return list(self.subdomain_ids.values()) def _list_subdomains(self) -> list[str]: """ Return a list of subdomain names managed by this instance. """ return list(self.subdomain_ids.keys()) # ------------------------------------------------------------ # Representation # ------------------------------------------------------------ def __repr__(self) -> str: lines = [f"<MaterialManager with {len(self.materials_db)} materials>"] # Solids / medium materials if self.subdomain_ids: lines.append( f" ├─ {len(self.subdomain_ids)} solid material entries mapped to material_ids:" ) for name in sorted( self.subdomain_ids, key=lambda n: self.subdomain_ids.get(n, 999) ): mat = self.materials_db.get(name) mid = self.subdomain_ids.get(name, "?") lines.append( f" │ [{mid}] {name}: '{mat.name if mat else '?'}'" ) else: lines.append(" ├─ No solid or medium materials defined") # Fluids: prefer explicit self.fluids if present if self.fluids: lines.append(f" ├─ {len(self.fluids)} fluid materials:") for phase_type, mat in self.fluids.items(): lines.append(f" │ {phase_type}: {mat.name}") else: # fallback: derive fluids from materials_db fluid_keys = [ k for k in self.materials_db if k not in self.subdomain_ids ] if fluid_keys: lines.append(f" ├─ {len(fluid_keys)} fluid materials:") for phase_type in fluid_keys: mat = self.materials_db[phase_type] lines.append(f" │ {phase_type}: {mat.name}") else: lines.append(" └─ No fluid materials assigned") return "\n".join(lines)