Source code for nbragg.utils

import NCrystal as NC
from pathlib import Path
import numpy as np
import pickle
import json
import os
import warnings

# Constants
SPEED_OF_LIGHT = 299792458  # m/s
MASS_OF_NEUTRON = 939.56542052 * 1e6 / (SPEED_OF_LIGHT ** 2)  # [eV s²/m²]

def time2energy(time, flight_path_length):
    """
    Convert time-of-flight to energy of the neutron.

    Parameters:
    time (float): Time-of-flight in seconds.
    flight_path_length (float): Flight path length in meters.

    Returns:
    float: Energy of the neutron in electronvolts (eV).
    """
    γ = 1 / np.sqrt(1 - (flight_path_length / time) ** 2 / SPEED_OF_LIGHT ** 2)
    return (γ - 1) * MASS_OF_NEUTRON * SPEED_OF_LIGHT ** 2  # eV

def energy2time(energy, flight_path_length):
    """
    Convert energy to time-of-flight of the neutron.

    Parameters:
    energy (float): Energy of the neutron in electronvolts (eV).
    flight_path_length (float): Flight path length in meters.

    Returns:
    float: Time-of-flight in seconds.
    """
    γ = 1 + energy / (MASS_OF_NEUTRON * SPEED_OF_LIGHT ** 2)
    return flight_path_length / SPEED_OF_LIGHT * np.sqrt(γ ** 2 / (γ ** 2 - 1))

# Initialize materials as a global dictionary
materials = {}
_initialized = False
_initializing_materials = False  # Recursion guard for initialize_materials

def get_cache_path():
    """
    Get the path to the materials cache file.
    Tries multiple locations in order of preference:
    1. User's home directory cache
    2. Package directory (if writable)
    3. Temporary directory as fallback
    """
    try:
        if os.name == 'nt':  # Windows
            cache_dir = Path(os.environ.get('LOCALAPPDATA', Path.home() / 'AppData' / 'Local')) / 'nbragg'
        else:  # Linux/Mac
            cache_dir = Path(os.environ.get('HOME', '/tmp')) / '.cache' / 'nbragg'
        
        cache_dir.mkdir(parents=True, exist_ok=True)
        cache_file = cache_dir / 'materials_cache.pkl'
        
        # Test if writable
        cache_file.touch(exist_ok=True)
        return cache_file
    except (OSError, PermissionError):
        pass
    
    # Try package directory
    try:
        pkg_dir = Path(__file__).parent
        cache_file = pkg_dir / '.materials_cache.pkl'
        cache_file.touch(exist_ok=True)
        return cache_file
    except (OSError, PermissionError, NameError):
        pass
    
    # Fallback to temp directory
    import tempfile
    cache_dir = Path(tempfile.gettempdir()) / 'nbragg_cache'
    cache_dir.mkdir(parents=True, exist_ok=True)
    return cache_dir / 'materials_cache.pkl'

def get_user_materials_path():
    """
    Get the path to user-registered materials storage.
    This is separate from the cache and stores custom materials.
    """
    try:
        if os.name == 'nt':  # Windows
            data_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming')) / 'nbragg'
        else:  # Linux/Mac
            data_dir = Path.home() / '.local' / 'share' / 'nbragg'
        
        data_dir.mkdir(parents=True, exist_ok=True)
        return data_dir / 'user_materials.json'
    except (OSError, PermissionError):
        # Fallback to temp
        import tempfile
        data_dir = Path(tempfile.gettempdir()) / 'nbragg_data'
        data_dir.mkdir(parents=True, exist_ok=True)
        return data_dir / 'user_materials.json'

def get_ncmat_storage_dir():
    """
    Get the directory for storing virtual NCMAT file contents.
    This allows materials to persist even if original files are deleted.
    """
    try:
        if os.name == 'nt':  # Windows
            data_dir = Path(os.environ.get('APPDATA', Path.home() / 'AppData' / 'Roaming')) / 'nbragg' / 'ncmat_files'
        else:  # Linux/Mac
            data_dir = Path.home() / '.local' / 'share' / 'nbragg' / 'ncmat_files'
        
        data_dir.mkdir(parents=True, exist_ok=True)
        return data_dir
    except (OSError, PermissionError):
        # Fallback to temp
        import tempfile
        data_dir = Path(tempfile.gettempdir()) / 'nbragg_data' / 'ncmat_files'
        data_dir.mkdir(parents=True, exist_ok=True)
        return data_dir

def save_cache(materials_dict):
    """Save materials dictionary to cache file."""
    try:
        cache_path = get_cache_path()
        with open(cache_path, 'wb') as f:
            pickle.dump(materials_dict, f, protocol=pickle.HIGHEST_PROTOCOL)
        return True
    except Exception as e:
        warnings.warn(f"Could not save materials cache: {e}")
        return False

def load_cache():
    """Load materials dictionary from cache file."""
    try:
        cache_path = get_cache_path()
        if cache_path.exists():
            with open(cache_path, 'rb') as f:
                return pickle.load(f)
    except Exception as e:
        warnings.warn(f"Could not load materials cache: {e}")
    return None

def save_user_materials(user_materials_dict):
    """Save user-registered materials to persistent storage."""
    try:
        user_path = get_user_materials_path()
        # Convert to JSON-serializable format
        json_dict = {}
        for key, value in user_materials_dict.items():
            json_dict[key] = {k: v for k, v in value.items() if v is not None and k != '_ncmat_content'}
        
        with open(user_path, 'w') as f:
            json.dump(json_dict, f, indent=2)
        return True
    except Exception as e:
        warnings.warn(f"Could not save user materials: {e}")
        return False

def load_user_materials():
    """Load user-registered materials from persistent storage."""
    try:
        user_path = get_user_materials_path()
        if user_path.exists():
            with open(user_path, 'r') as f:
                json_dict = json.load(f)
            
            # Convert back to proper format with None values
            materials_dict = {}
            for key, value in json_dict.items():
                materials_dict[key] = {
                    'mat': value.get('mat'),
                    'temp': value.get('temp', 300.0),
                    'mos': value.get('mos'),
                    'dir1': value.get('dir1'),
                    'dir2': value.get('dir2'),
                    'dirtol': value.get('dirtol'),
                    'theta': value.get('theta'),
                    'phi': value.get('phi'),
                    'a': value.get('a'),
                    'b': value.get('b'),
                    'c': value.get('c'),
                    'ext_method': value.get('ext_method'),
                    'ext_l': value.get('ext_l'),
                    'ext_g': value.get('ext_g'),
                    'ext_L': value.get('ext_L'),
                    'ext_dist': value.get('ext_dist'),
                    'sans': value.get('sans'),
                    'weight': value.get('weight', 1.0),
                }
                # Add metadata fields
                for k, v in value.items():
                    if k.startswith('_'):
                        materials_dict[key][k] = v
                
                # Load virtual NCMAT content if it exists
                if '_ncmat_stored' in value and value['_ncmat_stored']:
                    ncmat_file = materials_dict[key]['mat']
                    stored_path = get_ncmat_storage_dir() / ncmat_file
                    if stored_path.exists():
                        with open(stored_path, 'r') as f:
                            ncmat_content = f.read()
                            # Register with NCrystal
                            NC.registerInMemoryFileData(ncmat_file, ncmat_content)
                            materials_dict[key]['_ncmat_content'] = ncmat_content
            
            return materials_dict
    except Exception as e:
        warnings.warn(f"Could not load user materials: {e}")
    return {}

def save_ncmat_content(filename, content):
    """
    Save NCMAT file content to persistent storage.
    
    Parameters:
    -----------
    filename : str
        Name of the NCMAT file
    content : str
        Content of the NCMAT file
    
    Returns:
    --------
    bool : True if successful, False otherwise
    """
    try:
        storage_dir = get_ncmat_storage_dir()
        filepath = storage_dir / filename
        with open(filepath, 'w') as f:
            f.write(content)
        return True
    except Exception as e:
        warnings.warn(f"Could not save NCMAT content for {filename}: {e}")
        return False

def get_material_url(material_name):
    """
    Generate a URL to the NCrystal material documentation if available.
    
    Parameters:
    -----------
    material_name : str
        Name of the material file (with or without .ncmat extension)
    
    Returns:
    --------
    str or None : URL to the material documentation, or None if not available
    """
    if not material_name.endswith('.ncmat'):
        material_name += '.ncmat'
    
    base_url = "https://raw.githubusercontent.com/wiki/mctools/ncrystal/datalib/"
    return f"{base_url}{material_name}.inspect.pdf"

def make_materials_dict():
    """
    Populate the materials dictionary based on available ncmat files.
    Each entry uses the filename (without .ncmat) as key and contains
    a material specification dictionary compatible with CrossSection.
    """
    mat_dict = {}
    ncmat_files = NC.browseFiles()
    
    for mat in ncmat_files:
        if mat.name.endswith(".ncmat"):
            fullname = mat.name
            name_without_ext = fullname.replace(".ncmat", "")
            
            # Parse filename components
            name = ""
            formula = ""
            space_group = ""
            splitname = name_without_ext.split("_")
            
            if len(splitname) == 3:
                formula, space_group, name = splitname
            elif len(splitname) == 1:
                formula = splitname[0]
                name = formula
            elif len(splitname) == 2:
                formula, space_group = splitname
                name = formula
            elif len(splitname) > 3:
                formula, space_group, *names = splitname
                name = "_".join(names)
            else:
                continue
            
            # Generate URL for material documentation
            material_url = get_material_url(fullname)
            
            # Create material specification compatible with CrossSection
            mat_dict[name_without_ext] = {
                'mat': fullname,
                'temp': 300.0,
                'mos': None,
                'dir1': None,
                'dir2': None,
                'dirtol': None,
                'theta': None,
                'phi': None,
                'a': None,
                'b': None,
                'c': None,
                'ext_method': None,
                'ext_l': None,
                'ext_g': None,
                'ext_L': None,
                'ext_dist': None,
                'sans': None,
                'weight': 1.0,
                # Metadata for convenience
                '_name': name,
                '_formula': formula,
                '_space_group': space_group,
                '_url': material_url
            }
    
    return mat_dict


def initialize_materials():
    """Initialize the global materials dictionary, avoiding recursion issues."""
    global _initializing_materials, _initialized, materials

    # Recursion guard
    if _initializing_materials:
        raise RuntimeError(
            "Recursion detected in initialize_materials. "
            "Try calling initialize_materials() explicitly before accessing materials, "
            "or check for issues in get_cache_path() or IPython output handling."
        )

    _initializing_materials = True
    try:
        cached_materials = load_cache()

        # Always convert to a plain dict to avoid LazyMaterialsDict recursion
        if cached_materials is not None:
            base_data = dict(cached_materials)
        else:
            base_data = make_materials_dict()
            save_cache(base_data)

        # Bypass LazyMaterialsDict.update(), which calls _ensure_initialized()
        super(LazyMaterialsDict, materials).clear()
        super(LazyMaterialsDict, materials).update(base_data)

        # Mark initialization as complete before any access
        _initialized = True
        _initializing_materials = False

    finally:
        _initializing_materials = False


def rebuild_cache(save_to_cache=True):
    """
    Force rebuild of the materials dictionary from NCrystal files.
    
    Parameters:
    save_to_cache (bool): Whether to save the rebuilt cache (default True)
    
    Returns:
    dict: The rebuilt materials dictionary
    """
    global materials
    
    # Rebuild from NCrystal files
    new_materials = make_materials_dict()
    
    # Load user materials
    user_materials = load_user_materials()
    if user_materials:
        new_materials.update(user_materials)
    
    # Update global dictionary
    materials.clear()
    materials.update(new_materials)
    
    # Save to cache if requested
    if save_to_cache:
        save_cache(make_materials_dict())  # Cache only NCrystal materials
    
    return materials

def register_material(filename=None, cif_source=None, save_persistent=True, store_content=True, **materials_dict):
    """
    Register one or more materials in the nbragg materials database.
    
    This function can handle multiple input formats:
    
    1. From a file:
       register_material("path/to/material.ncmat")
       register_material("path/to/material.ncmat", material_name="CustomName")
    
    2. From CIF file or COD ID:
       register_material(cif_source="path/to/material.cif", material_name="MyCIF")
       register_material(cif_source="codid::7123352", material_name="Bismuth")
    
    3. From a dictionary (single material):
       register_material(my_material={'mat': 'Fe.ncmat', 'temp': 500.0})
    
    4. From a dictionary (multiple materials):
       register_material(
           iron={'mat': 'Fe.ncmat', 'temp': 300.0},
           copper={'mat': 'Cu.ncmat', 'temp': 400.0}
       )
    
    5. From CrossSection.materials:
       xs = CrossSection(...)
       register_material(**xs.materials)
       # or with custom names:
       register_material(custom=xs.materials['phase1'])
    
    Parameters:
    -----------
    filename : str or Path, optional
        Path to an .ncmat file to register. If provided, other sources are ignored.
    cif_source : str, optional
        Either a path to a CIF file or a COD ID in format "codid::XXXXXX"
    save_persistent : bool, optional
        Whether to save to persistent user materials storage (default True)
    store_content : bool, optional
        Whether to store the NCMAT file content for offline access (default True)
        This ensures materials persist even if original files are deleted
    **materials_dict : dict
        Keyword arguments where each key is a material name and value is either:
        - A material specification dict with 'mat' key
        - A nested dict of material specifications (from CrossSection.materials)
    
    Returns:
    --------
    dict : The materials that were registered
    """
    updated_materials = {}
    
    # Handle CIF input
    if cif_source is not None:
        try:
            # Create NCMATComposer from CIF
            composer = NC.NCMATComposer(cif_source)
            
            # Determine material name
            material_name = materials_dict.pop('material_name', None)
            if material_name is None:
                if cif_source.startswith('codid::'):
                    material_name = f"COD_{cif_source.split('::')[1]}"
                else:
                    material_name = Path(cif_source).stem
            
            # Generate NCMAT filename
            ncmat_filename = f"{material_name}.ncmat"
            
            # Get NCMAT content
            ncmat_content = composer.create_ncmat()
            
            # Register with NCrystal
            NC.registerInMemoryFileData(ncmat_filename, ncmat_content)
            
            # Store content if requested
            if store_content:
                save_ncmat_content(ncmat_filename, ncmat_content)
            
            # Determine URL (if from COD)
            material_url = None
            if cif_source.startswith('codid::'):
                cod_id = cif_source.split('::')[1]
                material_url = f"http://www.crystallography.net/cod/{cod_id}.html"
            elif Path(cif_source).suffix == '.cif':
                material_url = f"file://{Path(cif_source).absolute()}"
            
            # Create material specification
            new_material = {
                'mat': ncmat_filename,
                'temp': 300.0,
                'mos': None,
                'dir1': None,
                'dir2': None,
                'dirtol': None,
                'theta': None,
                'phi': None,
                'a': None,
                'b': None,
                'c': None,
                'ext_method': None,
                'ext_l': None,
                'ext_g': None,
                'ext_L': None,
                'ext_dist': None,
                'sans': None,
                'weight': 1.0,
                '_name': material_name,
                '_formula': '',
                '_space_group': '',
                '_custom': True,
                '_cif_source': cif_source,
                '_url': material_url,
                '_ncmat_stored': store_content
            }
            
            if store_content:
                new_material['_ncmat_content'] = ncmat_content
            
            updated_materials[material_name] = new_material
            
        except Exception as e:
            raise RuntimeError(f"Failed to process CIF source '{cif_source}': {e}")
    
    # Handle file input
    elif filename is not None:
        filename = Path(filename)
        
        # Read file content
        with open(filename, "r") as fid:
            file_content = fid.read()
        
        # Register with NCrystal
        NC.registerInMemoryFileData(filename.name, file_content)
        
        # Store content if requested
        if store_content:
            save_ncmat_content(filename.name, file_content)
        
        # Determine material name
        material_name = materials_dict.pop('material_name', None)
        if material_name is None:
            material_name = filename.stem  # filename without extension
        
        # Parse filename components for metadata
        name = material_name
        formula = ""
        space_group = ""
        splitname = material_name.split("_")
        
        if len(splitname) == 3:
            formula, space_group, name = splitname
        elif len(splitname) == 1:
            formula = splitname[0]
            name = formula
        elif len(splitname) == 2:
            formula, space_group = splitname
            name = formula
        elif len(splitname) > 3:
            formula, space_group, *names = splitname
            name = "_".join(names)
        
        # Create material specification
        new_material = {
            'mat': filename.name,
            'temp': 300.0,
            'mos': None,
            'dir1': None,
            'dir2': None,
            'dirtol': None,
            'theta': None,
            'phi': None,
            'a': None,
            'b': None,
            'c': None,
            'ext_method': None,
            'ext_l': None,
            'ext_g': None,
            'ext_L': None,
            'ext_dist': None,
            'sans': None,
            'weight': 1.0,
            '_name': name,
            '_formula': formula,
            '_space_group': space_group,
            '_custom': True,
            '_url': f"file://{filename.absolute()}",
            '_ncmat_stored': store_content
        }
        
        if store_content:
            new_material['_ncmat_content'] = file_content
        
        updated_materials[material_name] = new_material
        
    
    # Handle dictionary input
    elif materials_dict:
        for material_name, material_info in materials_dict.items():
            # Check if material_info is a nested dictionary (like from CrossSection.materials)
            if isinstance(material_info, dict):
                # Check if this is a nested structure with multiple materials
                if all(isinstance(v, dict) and 'mat' in v for v in material_info.values()):
                    # This is a nested structure like {'phase1': {...}, 'phase2': {...}}
                    for sub_name, sub_info in material_info.items():
                        full_name = f"{material_name}_{sub_name}"
                        processed_mat = _process_material_dict(sub_info)
                        
                        # Try to store content if it's a custom material
                        if store_content and sub_info.get('mat'):
                            _try_store_material_content(processed_mat)
                        
                        updated_materials[full_name] = processed_mat
                else:
                    # This is a single material specification
                    if 'mat' not in material_info:
                        raise ValueError(f"Material '{material_name}' missing required 'mat' key")
                    
                    processed_mat = _process_material_dict(material_info)
                    
                    # Try to store content if it's a custom material
                    if store_content and material_info.get('mat'):
                        _try_store_material_content(processed_mat)
                    
                    updated_materials[material_name] = processed_mat
            else:
                raise ValueError(f"Material specification for '{material_name}' must be a dictionary")
    else:
        raise ValueError("Either 'filename', 'cif_source', or material dictionaries must be provided")
    
    # Update global materials dictionary
    materials.update(updated_materials)
    
    # Save to persistent storage if requested
    if save_persistent:
        user_materials = load_user_materials()
        user_materials.update(updated_materials)
        save_user_materials(user_materials)
    
    return {k: MaterialSpec(v) if not isinstance(v, MaterialSpec) else v 
        for k, v in updated_materials.items()}

def _try_store_material_content(material_dict):
    """
    Try to store the NCMAT content for a material if accessible.
    
    Parameters:
    -----------
    material_dict : dict
        Material dictionary to update with stored content info
    """
    try:
        mat_file = material_dict.get('mat')
        if mat_file:
            # Try to get the content from NCrystal
            text_data = NC.createTextData(mat_file)
            ncmat_content = text_data.rawData
            
            # Save the content
            if save_ncmat_content(mat_file, ncmat_content):
                material_dict['_ncmat_stored'] = True
                material_dict['_ncmat_content'] = ncmat_content
    except Exception:
        # If we can't access the content, that's okay
        pass

def _process_material_dict(material_info):
    """
    Process a material info dictionary into the standard format.
    
    Parameters:
    -----------
    material_info : dict
        Material specification dictionary
    
    Returns:
    --------
    dict : Processed material dictionary with all required keys
    """
    # Set defaults for missing keys
    full_material = {
        'mat': material_info['mat'],
        'temp': material_info.get('temp', 300.0),
        'mos': material_info.get('mos', None),
        'dir1': material_info.get('dir1', None),
        'dir2': material_info.get('dir2', None),
        'dirtol': material_info.get('dirtol', None),
        'theta': material_info.get('theta', None),
        'phi': material_info.get('phi', None),
        'a': material_info.get('a', None),
        'b': material_info.get('b', None),
        'c': material_info.get('c', None),
        'ext_method': material_info.get('ext_method', None),
        'ext_l': material_info.get('ext_l', None),
        'ext_g': material_info.get('ext_g', None),
        'ext_L': material_info.get('ext_L', None),
        'ext_dist': material_info.get('ext_dist', None),
        'sans': material_info.get('sans', None),
        'weight': material_info.get('weight', 1.0),
        '_custom': True  # Mark as user-registered
    }
    
    # Copy over any metadata fields (starting with _)
    for key, value in material_info.items():
        if key.startswith('_') and key != '_custom':
            full_material[key] = value
    
    return full_material

def get_material_info(material_key, show_url=False):
    """
    Get information about a material in a formatted way.
    
    Parameters:
    -----------
    material_key : str
        Key of the material in the materials dictionary
    show_url : bool, optional
        Whether to display clickable URL (default False)
    
    Returns:
    --------
    str : Formatted material information
    """
    _ensure_initialized()
    
    if material_key not in materials:
        return f"Material '{material_key}' not found"
    
    mat = materials[material_key]
    
    info_lines = [f"Material: {material_key}"]
    info_lines.append(f"  File: {mat['mat']}")
    
    if mat.get('_name'):
        info_lines.append(f"  Name: {mat['_name']}")
    if mat.get('_formula'):
        info_lines.append(f"  Formula: {mat['_formula']}")
    if mat.get('_space_group'):
        info_lines.append(f"  Space Group: {mat['_space_group']}")
    
    info_lines.append(f"  Temperature: {mat['temp']} K")
    
    if mat.get('_cif_source'):
        info_lines.append(f"  CIF Source: {mat['_cif_source']}")
    
    if mat.get('_custom'):
        info_lines.append("  Status: User-registered")
    
    if mat.get('_ncmat_stored'):
        info_lines.append("  Storage: Content stored locally")
    
    if mat.get('_url'):
        if show_url:
            info_lines.append(f"  URL: {mat['_url']}")
        else:
            info_lines.append(f"  Documentation: Available (use show_url=True)")
    
    return '\n'.join(info_lines)

def list_materials(filter_str=None, custom_only=False):
    """
    List all available materials with optional filtering.
    
    Parameters:
    -----------
    filter_str : str, optional
        Filter materials by name/formula (case-insensitive)
    custom_only : bool, optional
        Only show user-registered materials (default False)
    
    Returns:
    --------
    list : List of material keys matching the criteria
    """
    _ensure_initialized()
    
    result = []
    for key, mat in materials.items():
        # Apply filters
        if custom_only and not mat.get('_custom'):
            continue
        
        if filter_str:
            search_str = filter_str.lower()
            if not any(search_str in str(mat.get(field, '')).lower() 
                      for field in ['_name', '_formula', 'mat', '_space_group']):
                continue
        
        result.append(key)
    
    return sorted(result)

def clear_user_materials():
    """
    Clear all user-registered materials from persistent storage.
    Reloads materials from cache (NCrystal files only).
    """
    global materials
    
    # Remove user materials file
    try:
        user_path = get_user_materials_path()
        if user_path.exists():
            user_path.unlink()
    except Exception as e:
        warnings.warn(f"Could not clear user materials: {e}")
    
    # Reload from cache only
    materials.clear()
    cached_materials = load_cache()
    if cached_materials:
        materials.update(cached_materials)
    else:
        # Rebuild if no cache
        rebuild_cache()

def _ensure_initialized():
    """Ensure materials dictionary is initialized (lazy loading)."""
    global _initialized
    if not _initialized:
        initialize_materials()
        _initialized = True




[docs]class MaterialSpec(dict): """ A dictionary subclass for material specifications that supports weight multiplication and provides enhanced representation with hyperlinks. Usage: material = nbragg.materials["Al2O3_sg167_Corundum"] weighted_material = material * 0.5 # Creates new MaterialSpec with weight=0.5 xs = CrossSection(phase1=material * 0.3, phase2=other_material * 0.7) """ def __mul__(self, weight): """ Multiply material by a weight factor. Parameters: ----------- weight : float Weight factor to apply to the material Returns: -------- MaterialSpec : New MaterialSpec with updated weight """ if not isinstance(weight, (int, float)): return NotImplemented # Create a copy of the material new_material = MaterialSpec(self) new_material['weight'] = float(weight) return new_material def __rmul__(self, weight): """ Right multiplication (weight * material). Parameters: ----------- weight : float Weight factor to apply to the material Returns: -------- MaterialSpec : New MaterialSpec with updated weight """ return self.__mul__(weight) def __repr__(self): """ Enhanced representation with hyperlink for URL. """ # Create a copy of the dict for display display_dict = dict(self) # Replace URL with hyperlink object if present if '_url' in display_dict and display_dict['_url']: url = display_dict['_url'] display_dict['_url'] = Hyperlink(url) return repr(display_dict) def _repr_pretty_(self, p, cycle): """ IPython/Jupyter pretty printing support. """ if cycle: p.text('{...}') return with p.group(1, '{', '}'): if self: for idx, (key, value) in enumerate(self.items()): if idx: p.text(',') p.breakable() p.text(repr(key)) p.text(': ') # Special handling for URL if key == '_url' and value: p.text(repr(Hyperlink(value))) else: p.pretty(value)
[docs]class LazyMaterialsDict(dict): """Dictionary that initializes materials on first access and returns MaterialSpec objects.""" def __getitem__(self, key): _ensure_initialized() # Try exact match first try: material_dict = super().__getitem__(key) except KeyError: # If key ends with .ncmat, try without extension if key.endswith('.ncmat'): try: material_dict = super().__getitem__(key[:-6]) except KeyError: raise KeyError(key) # If key doesn't end with .ncmat, try with extension else: try: material_dict = super().__getitem__(f"{key}.ncmat") except KeyError: raise KeyError(key) # Wrap in MaterialSpec if not already wrapped if not isinstance(material_dict, MaterialSpec): material_dict = MaterialSpec(material_dict) # Update the stored value so we don't re-wrap every time # Use the original key that worked for storage for possible_key in [key, key[:-6] if key.endswith('.ncmat') else None, f"{key}.ncmat"]: if possible_key and possible_key in dict.keys(self): super().__setitem__(possible_key, material_dict) break return material_dict def __iter__(self): _ensure_initialized() return super().__iter__() def __len__(self): _ensure_initialized() return super().__len__()
[docs] def keys(self): _ensure_initialized() return super().keys()
[docs] def values(self): """Return MaterialSpec-wrapped values.""" _ensure_initialized() for key in super().keys(): yield self[key] # Use __getitem__ to get wrapped version
[docs] def items(self): """Return items with MaterialSpec-wrapped values.""" _ensure_initialized() for key in super().keys(): yield key, self[key] # Use __getitem__ to get wrapped version
[docs] def get(self, key, default=None): _ensure_initialized() if key not in self: return default return self[key] # Use __getitem__ to get wrapped version
[docs] def update(self, *args, **kwargs): """Override update to handle initialization properly.""" _ensure_initialized() super().update(*args, **kwargs)
# Add to your module exports or integrate into existing code __all__ = ['MaterialSpec', 'Hyperlink', 'LazyMaterialsDict'] # Replace materials dict with lazy version materials = LazyMaterialsDict()